mirror of
https://github.com/home-assistant/core.git
synced 2026-03-18 00:42:07 +01:00
Compare commits
203 Commits
PIRUnoccup
...
adjust_con
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9649486c5 | ||
|
|
c8ce4eb32d | ||
|
|
22aca8b7af | ||
|
|
770864082f | ||
|
|
14545660e2 | ||
|
|
836353015b | ||
|
|
c57ffd4d78 | ||
|
|
cbebfdf149 | ||
|
|
d8ed9ca66f | ||
|
|
5caf8a5b83 | ||
|
|
c05210683e | ||
|
|
aa8dd4bb66 | ||
|
|
ee7d6157d9 | ||
|
|
adec1d128c | ||
|
|
0a2fc97696 | ||
|
|
447d616097 | ||
|
|
d3102e718d | ||
|
|
69ee49735a | ||
|
|
35a99dd4a4 | ||
|
|
51c3397be8 | ||
|
|
57f0fd2ed2 | ||
|
|
fa7a216afe | ||
|
|
20f4426e1d | ||
|
|
ba30563772 | ||
|
|
b807c104a3 | ||
|
|
9e6abb719a | ||
|
|
ed2083a60d | ||
|
|
94db0d5eab | ||
|
|
06eed998b9 | ||
|
|
fb5c2f2566 | ||
|
|
4f7d065230 | ||
|
|
d034df9b93 | ||
|
|
6c9fc7c7a1 | ||
|
|
ba58ef23d8 | ||
|
|
0a0fa96ac1 | ||
|
|
9cc7ef75b0 | ||
|
|
2e0d6d2bbf | ||
|
|
bafef2065f | ||
|
|
fdfe87de4c | ||
|
|
933d123db3 | ||
|
|
1f9946a1b8 | ||
|
|
403e30b56e | ||
|
|
e4524d9b68 | ||
|
|
738100c897 | ||
|
|
67356de21b | ||
|
|
80c5bd1843 | ||
|
|
492883de57 | ||
|
|
45f1247237 | ||
|
|
0e76d927cf | ||
|
|
4769a769e0 | ||
|
|
f2d62049ec | ||
|
|
751b2638ce | ||
|
|
120d3ee85a | ||
|
|
2d273a86ba | ||
|
|
9bbd9d8bcd | ||
|
|
5ff2cac077 | ||
|
|
74b0d058ec | ||
|
|
29f96e3f9c | ||
|
|
39b44445ec | ||
|
|
589622c05a | ||
|
|
6abe576ec9 | ||
|
|
75978d8837 | ||
|
|
a2da13a0b3 | ||
|
|
ce081d7e71 | ||
|
|
037e123e11 | ||
|
|
592b7e5594 | ||
|
|
a963eed3a7 | ||
|
|
2042f2e2bd | ||
|
|
3580fab26e | ||
|
|
1817522107 | ||
|
|
98a9ce3a64 | ||
|
|
163bfb0fdd | ||
|
|
66f04c702c | ||
|
|
41c497c49e | ||
|
|
c25a664365 | ||
|
|
3dec70abce | ||
|
|
3c2f696a23 | ||
|
|
54745dc1f2 | ||
|
|
e4345c72d9 | ||
|
|
7acb253ae2 | ||
|
|
812c63eeb7 | ||
|
|
7f13731035 | ||
|
|
879178e8a2 | ||
|
|
4d8cedb061 | ||
|
|
e9f0d8a550 | ||
|
|
c5a04deb28 | ||
|
|
f2a205e8d7 | ||
|
|
254aa30ad8 | ||
|
|
de4025634a | ||
|
|
db4af890f4 | ||
|
|
501c8fecec | ||
|
|
03edee1335 | ||
|
|
00b0da7d26 | ||
|
|
bf23fc5887 | ||
|
|
6f746c4375 | ||
|
|
e7c3a62569 | ||
|
|
b1578a0c8c | ||
|
|
56b4d2c015 | ||
|
|
d5ee99c450 | ||
|
|
7d2a305996 | ||
|
|
6945418805 | ||
|
|
ccecbcb389 | ||
|
|
8bb51c0662 | ||
|
|
f66edf6b86 | ||
|
|
70e469366b | ||
|
|
4a9ba865be | ||
|
|
0167182e2e | ||
|
|
11411a880d | ||
|
|
ce47abe1d3 | ||
|
|
b58513c19a | ||
|
|
4e1dab6d8b | ||
|
|
5ae8e1c319 | ||
|
|
17bf6ca591 | ||
|
|
256d30c38d | ||
|
|
5d182394c2 | ||
|
|
011e6863d8 | ||
|
|
b902b590b1 | ||
|
|
960666e15b | ||
|
|
1fb59c9f11 | ||
|
|
332bf95e16 | ||
|
|
e35fc8267e | ||
|
|
f8b4ffc0d7 | ||
|
|
003ee5a699 | ||
|
|
c91d805174 | ||
|
|
c478d19ae3 | ||
|
|
09169b0f06 | ||
|
|
aa1dbee315 | ||
|
|
daf89e5673 | ||
|
|
85dc81c147 | ||
|
|
5acf24cb53 | ||
|
|
79829a311c | ||
|
|
ce2c62ae28 | ||
|
|
1cda3f47d6 | ||
|
|
e254716615 | ||
|
|
1d410f4cbd | ||
|
|
6616793e2b | ||
|
|
6766961327 | ||
|
|
dd6fc11d28 | ||
|
|
cb5b8b212c | ||
|
|
66b96d096e | ||
|
|
e86160de36 | ||
|
|
7617007edd | ||
|
|
3e065b31b3 | ||
|
|
5f909a6f3a | ||
|
|
6117a20ec6 | ||
|
|
93bc05bb3f | ||
|
|
e7397ccaa7 | ||
|
|
91a43873a2 | ||
|
|
469e06fb8c | ||
|
|
bac370e775 | ||
|
|
1a9da26286 | ||
|
|
f795707c53 | ||
|
|
9ad1356e4b | ||
|
|
0f70d5fd39 | ||
|
|
f4c6724953 | ||
|
|
82432d9ee7 | ||
|
|
8db07f3ceb | ||
|
|
2fe9d1ef86 | ||
|
|
cbb1f3726c | ||
|
|
beb122bb1a | ||
|
|
8d6099b055 | ||
|
|
7ebe11c0e6 | ||
|
|
12b14b46c0 | ||
|
|
cc45201f2d | ||
|
|
a433a163a3 | ||
|
|
7fd86145d1 | ||
|
|
f244af590e | ||
|
|
9a7dd98d89 | ||
|
|
6c4beba465 | ||
|
|
3a46beec76 | ||
|
|
d7c2dfc4d4 | ||
|
|
4efbafb003 | ||
|
|
1b10db28f1 | ||
|
|
1e988fbb04 | ||
|
|
9ab577aad4 | ||
|
|
ed53469eb6 | ||
|
|
56aa96a00c | ||
|
|
99c6cdbe44 | ||
|
|
1fd30b73e7 | ||
|
|
14aace0c00 | ||
|
|
6eed18623b | ||
|
|
66ca7d5782 | ||
|
|
a7436cbdc3 | ||
|
|
5e57b0272d | ||
|
|
e16b6ab026 | ||
|
|
e21fb14b9a | ||
|
|
8e099a874b | ||
|
|
a5302a6219 | ||
|
|
f761ac5b49 | ||
|
|
6988e73ddc | ||
|
|
a88374557b | ||
|
|
f2456b2c3a | ||
|
|
c1a525b7aa | ||
|
|
9d2febd24e | ||
|
|
54f96bcc33 | ||
|
|
5582d83f7b | ||
|
|
2832456bcd | ||
|
|
070c5821e4 | ||
|
|
07caa8ed2d | ||
|
|
b02f447e4d | ||
|
|
4fbb22e861 | ||
|
|
45199a341f | ||
|
|
de5f42d7a0 |
8
.github/workflows/builder.yml
vendored
8
.github/workflows/builder.yml
vendored
@@ -72,7 +72,7 @@ jobs:
|
||||
- name: Download Translations
|
||||
run: python3 -m script.translations download
|
||||
env:
|
||||
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
|
||||
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }} # zizmor: ignore[secrets-outside-env]
|
||||
|
||||
- name: Archive translations
|
||||
shell: bash
|
||||
@@ -203,7 +203,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0
|
||||
with:
|
||||
cosign-release: "v2.5.3"
|
||||
|
||||
@@ -400,7 +400,7 @@ jobs:
|
||||
registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"]
|
||||
steps:
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0
|
||||
with:
|
||||
cosign-release: "v2.5.3"
|
||||
|
||||
@@ -614,7 +614,7 @@ jobs:
|
||||
|
||||
- name: Generate artifact attestation
|
||||
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
|
||||
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
|
||||
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
|
||||
with:
|
||||
subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
|
||||
8
.github/workflows/ci.yaml
vendored
8
.github/workflows/ci.yaml
vendored
@@ -852,10 +852,6 @@ jobs:
|
||||
needs:
|
||||
- info
|
||||
- base
|
||||
- gen-requirements-all
|
||||
- hassfest
|
||||
- prek
|
||||
- mypy
|
||||
steps:
|
||||
- name: Restore apt cache
|
||||
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
@@ -1400,7 +1396,7 @@ jobs:
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
flags: full-suite
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env]
|
||||
|
||||
pytest-partial:
|
||||
name: Run tests Python ${{ matrix.python-version }} (${{ matrix.group }})
|
||||
@@ -1570,7 +1566,7 @@ jobs:
|
||||
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env]
|
||||
|
||||
upload-test-results:
|
||||
name: Upload test results to Codecov
|
||||
|
||||
4
.github/workflows/stale.yml
vendored
4
.github/workflows/stale.yml
vendored
@@ -58,8 +58,8 @@ jobs:
|
||||
# v1.7.0
|
||||
uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a
|
||||
with:
|
||||
app_id: ${{ secrets.ISSUE_TRIAGE_APP_ID }}
|
||||
private_key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }}
|
||||
app_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
|
||||
# Used for:
|
||||
|
||||
2
.github/workflows/translations.yml
vendored
2
.github/workflows/translations.yml
vendored
@@ -33,6 +33,6 @@ jobs:
|
||||
|
||||
- name: Upload Translations
|
||||
env:
|
||||
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
|
||||
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }} # zizmor: ignore[secrets-outside-env]
|
||||
run: |
|
||||
python3 -m script.translations upload
|
||||
|
||||
4
.github/workflows/wheels.yml
vendored
4
.github/workflows/wheels.yml
vendored
@@ -142,7 +142,7 @@ jobs:
|
||||
abi: ${{ matrix.abi }}
|
||||
tag: musllinux_1_2
|
||||
arch: ${{ matrix.arch }}
|
||||
wheels-key: ${{ secrets.WHEELS_KEY }}
|
||||
wheels-key: ${{ secrets.WHEELS_KEY }} # zizmor: ignore[secrets-outside-env]
|
||||
env-file: true
|
||||
apk: "libffi-dev;openssl-dev;yaml-dev;nasm;zlib-ng-dev"
|
||||
skip-binary: aiohttp;multidict;propcache;yarl;SQLAlchemy
|
||||
@@ -200,7 +200,7 @@ jobs:
|
||||
abi: ${{ matrix.abi }}
|
||||
tag: musllinux_1_2
|
||||
arch: ${{ matrix.arch }}
|
||||
wheels-key: ${{ secrets.WHEELS_KEY }}
|
||||
wheels-key: ${{ secrets.WHEELS_KEY }} # zizmor: ignore[secrets-outside-env]
|
||||
env-file: true
|
||||
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-ng-dev"
|
||||
skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl
|
||||
|
||||
@@ -18,7 +18,7 @@ repos:
|
||||
exclude_types: [csv, json, html]
|
||||
exclude: ^tests/fixtures/|homeassistant/generated/|tests/components/.*/snapshots/
|
||||
- repo: https://github.com/zizmorcore/zizmor-pre-commit
|
||||
rev: v1.22.0
|
||||
rev: v1.23.1
|
||||
hooks:
|
||||
- id: zizmor
|
||||
args:
|
||||
|
||||
@@ -570,6 +570,7 @@ homeassistant.components.trafikverket_train.*
|
||||
homeassistant.components.trafikverket_weatherstation.*
|
||||
homeassistant.components.transmission.*
|
||||
homeassistant.components.trend.*
|
||||
homeassistant.components.trmnl.*
|
||||
homeassistant.components.tts.*
|
||||
homeassistant.components.twentemilieu.*
|
||||
homeassistant.components.unifi.*
|
||||
|
||||
4
CODEOWNERS
generated
4
CODEOWNERS
generated
@@ -974,6 +974,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/logbook/ @home-assistant/core
|
||||
/homeassistant/components/logger/ @home-assistant/core
|
||||
/tests/components/logger/ @home-assistant/core
|
||||
/homeassistant/components/lojack/ @devinslick
|
||||
/tests/components/lojack/ @devinslick
|
||||
/homeassistant/components/london_underground/ @jpbede
|
||||
/tests/components/london_underground/ @jpbede
|
||||
/homeassistant/components/lookin/ @ANMalko @bdraco
|
||||
@@ -1770,6 +1772,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/trend/ @jpbede
|
||||
/homeassistant/components/triggercmd/ @rvmey
|
||||
/tests/components/triggercmd/ @rvmey
|
||||
/homeassistant/components/trmnl/ @joostlek
|
||||
/tests/components/trmnl/ @joostlek
|
||||
/homeassistant/components/tts/ @home-assistant/core
|
||||
/tests/components/tts/ @home-assistant/core
|
||||
/homeassistant/components/tuya/ @Tuya @zlinoliver
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
{
|
||||
"domain": "ubiquiti",
|
||||
"name": "Ubiquiti",
|
||||
"integrations": ["airos", "unifi", "unifi_direct", "unifiled", "unifiprotect"]
|
||||
"integrations": [
|
||||
"airos",
|
||||
"unifi",
|
||||
"unifi_access",
|
||||
"unifi_direct",
|
||||
"unifiled",
|
||||
"unifiprotect"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -46,19 +46,10 @@ async def async_setup_entry(
|
||||
api.AsyncConfigEntryAuth(aiohttp_client.async_get_clientsession(hass), session)
|
||||
)
|
||||
|
||||
try:
|
||||
doors = await client.get_doors()
|
||||
except aiohttp.ClientResponseError as err:
|
||||
if 400 <= err.status < 500:
|
||||
raise ConfigEntryAuthFailed(err) from err
|
||||
raise ConfigEntryNotReady from err
|
||||
except aiohttp.ClientError as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
coordinator = AladdinConnectCoordinator(hass, entry, client)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = {
|
||||
door.unique_id: AladdinConnectCoordinator(hass, entry, client, door)
|
||||
for door in doors
|
||||
}
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
@@ -100,7 +91,7 @@ def remove_stale_devices(
|
||||
device_entries = dr.async_entries_for_config_entry(
|
||||
device_registry, config_entry.entry_id
|
||||
)
|
||||
all_device_ids = set(config_entry.runtime_data)
|
||||
all_device_ids = set(config_entry.runtime_data.data)
|
||||
|
||||
for device_entry in device_entries:
|
||||
device_id: str | None = None
|
||||
|
||||
@@ -11,22 +11,24 @@ from genie_partner_sdk.model import GarageDoor
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
type AladdinConnectConfigEntry = ConfigEntry[dict[str, AladdinConnectCoordinator]]
|
||||
type AladdinConnectConfigEntry = ConfigEntry[AladdinConnectCoordinator]
|
||||
SCAN_INTERVAL = timedelta(seconds=15)
|
||||
|
||||
|
||||
class AladdinConnectCoordinator(DataUpdateCoordinator[GarageDoor]):
|
||||
class AladdinConnectCoordinator(DataUpdateCoordinator[dict[str, GarageDoor]]):
|
||||
"""Coordinator for Aladdin Connect integration."""
|
||||
|
||||
config_entry: AladdinConnectConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry: AladdinConnectConfigEntry,
|
||||
client: AladdinConnectClient,
|
||||
garage_door: GarageDoor,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
@@ -37,18 +39,16 @@ class AladdinConnectCoordinator(DataUpdateCoordinator[GarageDoor]):
|
||||
update_interval=SCAN_INTERVAL,
|
||||
)
|
||||
self.client = client
|
||||
self.data = garage_door
|
||||
|
||||
async def _async_update_data(self) -> GarageDoor:
|
||||
async def _async_update_data(self) -> dict[str, GarageDoor]:
|
||||
"""Fetch data from the Aladdin Connect API."""
|
||||
try:
|
||||
await self.client.update_door(self.data.device_id, self.data.door_number)
|
||||
doors = await self.client.get_doors()
|
||||
except aiohttp.ClientResponseError as err:
|
||||
if 400 <= err.status < 500:
|
||||
raise ConfigEntryAuthFailed(err) from err
|
||||
raise UpdateFailed(f"Error communicating with API: {err}") from err
|
||||
except aiohttp.ClientError as err:
|
||||
raise UpdateFailed(f"Error communicating with API: {err}") from err
|
||||
self.data.status = self.client.get_door_status(
|
||||
self.data.device_id, self.data.door_number
|
||||
)
|
||||
self.data.battery_level = self.client.get_battery_status(
|
||||
self.data.device_id, self.data.door_number
|
||||
)
|
||||
return self.data
|
||||
|
||||
return {door.unique_id: door for door in doors}
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Any
|
||||
import aiohttp
|
||||
|
||||
from homeassistant.components.cover import CoverDeviceClass, CoverEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
@@ -24,11 +24,22 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the cover platform."""
|
||||
coordinators = entry.runtime_data
|
||||
coordinator = entry.runtime_data
|
||||
known_devices: set[str] = set()
|
||||
|
||||
async_add_entities(
|
||||
AladdinCoverEntity(coordinator) for coordinator in coordinators.values()
|
||||
)
|
||||
@callback
|
||||
def _async_add_new_devices() -> None:
|
||||
"""Detect and add entities for new doors."""
|
||||
current_devices = set(coordinator.data)
|
||||
new_devices = current_devices - known_devices
|
||||
if new_devices:
|
||||
known_devices.update(new_devices)
|
||||
async_add_entities(
|
||||
AladdinCoverEntity(coordinator, door_id) for door_id in new_devices
|
||||
)
|
||||
|
||||
_async_add_new_devices()
|
||||
entry.async_on_unload(coordinator.async_add_listener(_async_add_new_devices))
|
||||
|
||||
|
||||
class AladdinCoverEntity(AladdinConnectEntity, CoverEntity):
|
||||
@@ -38,10 +49,10 @@ class AladdinCoverEntity(AladdinConnectEntity, CoverEntity):
|
||||
_attr_supported_features = SUPPORTED_FEATURES
|
||||
_attr_name = None
|
||||
|
||||
def __init__(self, coordinator: AladdinConnectCoordinator) -> None:
|
||||
def __init__(self, coordinator: AladdinConnectCoordinator, door_id: str) -> None:
|
||||
"""Initialize the Aladdin Connect cover."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = coordinator.data.unique_id
|
||||
super().__init__(coordinator, door_id)
|
||||
self._attr_unique_id = door_id
|
||||
|
||||
async def async_open_cover(self, **kwargs: Any) -> None:
|
||||
"""Issue open command to cover."""
|
||||
@@ -66,16 +77,16 @@ class AladdinCoverEntity(AladdinConnectEntity, CoverEntity):
|
||||
@property
|
||||
def is_closed(self) -> bool | None:
|
||||
"""Update is closed attribute."""
|
||||
if (status := self.coordinator.data.status) is None:
|
||||
if (status := self.door.status) is None:
|
||||
return None
|
||||
return status == "closed"
|
||||
|
||||
@property
|
||||
def is_closing(self) -> bool | None:
|
||||
"""Update is closing attribute."""
|
||||
return self.coordinator.data.status == "closing"
|
||||
return self.door.status == "closing"
|
||||
|
||||
@property
|
||||
def is_opening(self) -> bool | None:
|
||||
"""Update is opening attribute."""
|
||||
return self.coordinator.data.status == "opening"
|
||||
return self.door.status == "opening"
|
||||
|
||||
@@ -20,13 +20,13 @@ async def async_get_config_entry_diagnostics(
|
||||
"config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT),
|
||||
"doors": {
|
||||
uid: {
|
||||
"device_id": coordinator.data.device_id,
|
||||
"door_number": coordinator.data.door_number,
|
||||
"name": coordinator.data.name,
|
||||
"status": coordinator.data.status,
|
||||
"link_status": coordinator.data.link_status,
|
||||
"battery_level": coordinator.data.battery_level,
|
||||
"device_id": door.device_id,
|
||||
"door_number": door.door_number,
|
||||
"name": door.name,
|
||||
"status": door.status,
|
||||
"link_status": door.link_status,
|
||||
"battery_level": door.battery_level,
|
||||
}
|
||||
for uid, coordinator in config_entry.runtime_data.items()
|
||||
for uid, door in config_entry.runtime_data.data.items()
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Base class for Aladdin Connect entities."""
|
||||
|
||||
from genie_partner_sdk.client import AladdinConnectClient
|
||||
from genie_partner_sdk.model import GarageDoor
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
@@ -14,17 +15,28 @@ class AladdinConnectEntity(CoordinatorEntity[AladdinConnectCoordinator]):
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, coordinator: AladdinConnectCoordinator) -> None:
|
||||
def __init__(self, coordinator: AladdinConnectCoordinator, door_id: str) -> None:
|
||||
"""Initialize Aladdin Connect entity."""
|
||||
super().__init__(coordinator)
|
||||
device = coordinator.data
|
||||
self._door_id = door_id
|
||||
door = self.door
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, device.unique_id)},
|
||||
identifiers={(DOMAIN, door.unique_id)},
|
||||
manufacturer="Aladdin Connect",
|
||||
name=device.name,
|
||||
name=door.name,
|
||||
)
|
||||
self._device_id = device.device_id
|
||||
self._number = device.door_number
|
||||
self._device_id = door.device_id
|
||||
self._number = door.door_number
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return super().available and self._door_id in self.coordinator.data
|
||||
|
||||
@property
|
||||
def door(self) -> GarageDoor:
|
||||
"""Return the garage door data."""
|
||||
return self.coordinator.data[self._door_id]
|
||||
|
||||
@property
|
||||
def client(self) -> AladdinConnectClient:
|
||||
|
||||
@@ -57,7 +57,7 @@ rules:
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices: todo
|
||||
dynamic-devices: done
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
|
||||
@@ -14,7 +14,7 @@ from homeassistant.components.sensor import (
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import PERCENTAGE, EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import AladdinConnectConfigEntry, AladdinConnectCoordinator
|
||||
@@ -49,13 +49,24 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Aladdin Connect sensor devices."""
|
||||
coordinators = entry.runtime_data
|
||||
coordinator = entry.runtime_data
|
||||
known_devices: set[str] = set()
|
||||
|
||||
async_add_entities(
|
||||
AladdinConnectSensor(coordinator, description)
|
||||
for coordinator in coordinators.values()
|
||||
for description in SENSOR_TYPES
|
||||
)
|
||||
@callback
|
||||
def _async_add_new_devices() -> None:
|
||||
"""Detect and add entities for new doors."""
|
||||
current_devices = set(coordinator.data)
|
||||
new_devices = current_devices - known_devices
|
||||
if new_devices:
|
||||
known_devices.update(new_devices)
|
||||
async_add_entities(
|
||||
AladdinConnectSensor(coordinator, door_id, description)
|
||||
for door_id in new_devices
|
||||
for description in SENSOR_TYPES
|
||||
)
|
||||
|
||||
_async_add_new_devices()
|
||||
entry.async_on_unload(coordinator.async_add_listener(_async_add_new_devices))
|
||||
|
||||
|
||||
class AladdinConnectSensor(AladdinConnectEntity, SensorEntity):
|
||||
@@ -66,14 +77,15 @@ class AladdinConnectSensor(AladdinConnectEntity, SensorEntity):
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: AladdinConnectCoordinator,
|
||||
door_id: str,
|
||||
entity_description: AladdinConnectSensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the Aladdin Connect sensor."""
|
||||
super().__init__(coordinator)
|
||||
super().__init__(coordinator, door_id)
|
||||
self.entity_description = entity_description
|
||||
self._attr_unique_id = f"{coordinator.data.unique_id}-{entity_description.key}"
|
||||
self._attr_unique_id = f"{door_id}-{entity_description.key}"
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the state of the sensor."""
|
||||
return self.entity_description.value_fn(self.coordinator.data)
|
||||
return self.entity_description.value_fn(self.door)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Defines a base Alexa Devices entity."""
|
||||
|
||||
from aioamazondevices.const.devices import SPEAKER_GROUP_DEVICE_TYPE
|
||||
from aioamazondevices.structures import AmazonDevice
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
@@ -25,20 +24,15 @@ class AmazonEntity(CoordinatorEntity[AmazonDevicesCoordinator]):
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator)
|
||||
self._serial_num = serial_num
|
||||
model = self.device.model
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, serial_num)},
|
||||
name=self.device.account_name,
|
||||
model=model,
|
||||
model=self.device.model,
|
||||
model_id=self.device.device_type,
|
||||
manufacturer=self.device.manufacturer or "Amazon",
|
||||
hw_version=self.device.hardware_version,
|
||||
sw_version=(
|
||||
self.device.software_version
|
||||
if model != SPEAKER_GROUP_DEVICE_TYPE
|
||||
else None
|
||||
),
|
||||
serial_number=serial_num if model != SPEAKER_GROUP_DEVICE_TYPE else None,
|
||||
sw_version=self.device.software_version,
|
||||
serial_number=serial_num,
|
||||
)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{serial_num}-{description.key}"
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==13.0.0"]
|
||||
"requirements": ["aioamazondevices==13.0.1"]
|
||||
}
|
||||
|
||||
@@ -338,6 +338,7 @@ class Analytics:
|
||||
|
||||
hass = self._hass
|
||||
supervisor_info = None
|
||||
addons_info: dict[str, Any] | None = None
|
||||
operating_system_info: dict[str, Any] = {}
|
||||
|
||||
if self._data.uuid is None:
|
||||
@@ -347,6 +348,7 @@ class Analytics:
|
||||
if self.supervisor:
|
||||
supervisor_info = hassio.get_supervisor_info(hass)
|
||||
operating_system_info = hassio.get_os_info(hass) or {}
|
||||
addons_info = hassio.get_addons_info(hass) or {}
|
||||
|
||||
system_info = await async_get_system_info(hass)
|
||||
integrations = []
|
||||
@@ -419,13 +421,10 @@ class Analytics:
|
||||
|
||||
integrations.append(integration.domain)
|
||||
|
||||
if supervisor_info is not None:
|
||||
if addons_info is not None:
|
||||
supervisor_client = hassio.get_supervisor_client(hass)
|
||||
installed_addons = await asyncio.gather(
|
||||
*(
|
||||
supervisor_client.addons.addon_info(addon[ATTR_SLUG])
|
||||
for addon in supervisor_info[ATTR_ADDONS]
|
||||
)
|
||||
*(supervisor_client.addons.addon_info(slug) for slug in addons_info)
|
||||
)
|
||||
addons.extend(
|
||||
{
|
||||
|
||||
@@ -17,7 +17,7 @@ from .coordinator import ArcamFmjConfigEntry, ArcamFmjCoordinator, ArcamFmjRunti
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
PLATFORMS = [Platform.MEDIA_PLAYER]
|
||||
PLATFORMS = [Platform.BINARY_SENSOR, Platform.MEDIA_PLAYER, Platform.SENSOR]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ArcamFmjConfigEntry) -> bool:
|
||||
|
||||
68
homeassistant/components/arcam_fmj/binary_sensor.py
Normal file
68
homeassistant/components/arcam_fmj/binary_sensor.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""Arcam binary sensors for incoming stream info."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from arcam.fmj.state import State
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorEntity,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import ArcamFmjConfigEntry
|
||||
from .entity import ArcamFmjEntity
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class ArcamFmjBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||
"""Describes an Arcam FMJ binary sensor entity."""
|
||||
|
||||
value_fn: Callable[[State], bool | None]
|
||||
|
||||
|
||||
BINARY_SENSORS: tuple[ArcamFmjBinarySensorEntityDescription, ...] = (
|
||||
ArcamFmjBinarySensorEntityDescription(
|
||||
key="incoming_video_interlaced",
|
||||
translation_key="incoming_video_interlaced",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda state: (
|
||||
vp.interlaced
|
||||
if (vp := state.get_incoming_video_parameters()) is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ArcamFmjConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Arcam FMJ binary sensors from a config entry."""
|
||||
coordinators = config_entry.runtime_data.coordinators
|
||||
|
||||
entities: list[ArcamFmjBinarySensorEntity] = []
|
||||
for coordinator in coordinators.values():
|
||||
entities.extend(
|
||||
ArcamFmjBinarySensorEntity(coordinator, description)
|
||||
for description in BINARY_SENSORS
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class ArcamFmjBinarySensorEntity(ArcamFmjEntity, BinarySensorEntity):
|
||||
"""Representation of an Arcam FMJ binary sensor."""
|
||||
|
||||
entity_description: ArcamFmjBinarySensorEntityDescription
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return the binary sensor value."""
|
||||
return self.entity_description.value_fn(self.coordinator.state)
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .coordinator import ArcamFmjCoordinator
|
||||
@@ -12,9 +13,16 @@ class ArcamFmjEntity(CoordinatorEntity[ArcamFmjCoordinator]):
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, coordinator: ArcamFmjCoordinator) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ArcamFmjCoordinator,
|
||||
description: EntityDescription | None = None,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_device_info = coordinator.device_info
|
||||
self._attr_entity_registry_enabled_default = coordinator.state.zn == 1
|
||||
self._attr_unique_id = coordinator.zone_unique_id
|
||||
if description is not None:
|
||||
self._attr_unique_id = f"{self._attr_unique_id}-{description.key}"
|
||||
self.entity_description = description
|
||||
|
||||
35
homeassistant/components/arcam_fmj/icons.json
Normal file
35
homeassistant/components/arcam_fmj/icons.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"incoming_video_interlaced": {
|
||||
"default": "mdi:reorder-horizontal"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"incoming_audio_config": {
|
||||
"default": "mdi:surround-sound"
|
||||
},
|
||||
"incoming_audio_format": {
|
||||
"default": "mdi:dolby"
|
||||
},
|
||||
"incoming_audio_sample_rate": {
|
||||
"default": "mdi:waveform"
|
||||
},
|
||||
"incoming_video_aspect_ratio": {
|
||||
"default": "mdi:aspect-ratio"
|
||||
},
|
||||
"incoming_video_colorspace": {
|
||||
"default": "mdi:palette"
|
||||
},
|
||||
"incoming_video_horizontal_resolution": {
|
||||
"default": "mdi:arrow-expand-horizontal"
|
||||
},
|
||||
"incoming_video_refresh_rate": {
|
||||
"default": "mdi:animation"
|
||||
},
|
||||
"incoming_video_vertical_resolution": {
|
||||
"default": "mdi:arrow-expand-vertical"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
162
homeassistant/components/arcam_fmj/sensor.py
Normal file
162
homeassistant/components/arcam_fmj/sensor.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""Arcam sensors for incoming stream info."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from arcam.fmj import IncomingVideoAspectRatio, IncomingVideoColorspace
|
||||
from arcam.fmj.state import IncomingAudioConfig, IncomingAudioFormat, State
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import EntityCategory, UnitOfFrequency
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import ArcamFmjConfigEntry
|
||||
from .entity import ArcamFmjEntity
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class ArcamFmjSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes an Arcam FMJ sensor entity."""
|
||||
|
||||
value_fn: Callable[[State], int | float | str | None]
|
||||
|
||||
|
||||
SENSORS: tuple[ArcamFmjSensorEntityDescription, ...] = (
|
||||
ArcamFmjSensorEntityDescription(
|
||||
key="incoming_video_horizontal_resolution",
|
||||
translation_key="incoming_video_horizontal_resolution",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement="px",
|
||||
suggested_display_precision=0,
|
||||
value_fn=lambda state: (
|
||||
vp.horizontal_resolution
|
||||
if (vp := state.get_incoming_video_parameters()) is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
ArcamFmjSensorEntityDescription(
|
||||
key="incoming_video_vertical_resolution",
|
||||
translation_key="incoming_video_vertical_resolution",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement="px",
|
||||
suggested_display_precision=0,
|
||||
value_fn=lambda state: (
|
||||
vp.vertical_resolution
|
||||
if (vp := state.get_incoming_video_parameters()) is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
ArcamFmjSensorEntityDescription(
|
||||
key="incoming_video_refresh_rate",
|
||||
translation_key="incoming_video_refresh_rate",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
device_class=SensorDeviceClass.FREQUENCY,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfFrequency.HERTZ,
|
||||
suggested_display_precision=0,
|
||||
value_fn=lambda state: (
|
||||
vp.refresh_rate
|
||||
if (vp := state.get_incoming_video_parameters()) is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
ArcamFmjSensorEntityDescription(
|
||||
key="incoming_video_aspect_ratio",
|
||||
translation_key="incoming_video_aspect_ratio",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=[member.name.lower() for member in IncomingVideoAspectRatio],
|
||||
value_fn=lambda state: (
|
||||
vp.aspect_ratio.name.lower()
|
||||
if (vp := state.get_incoming_video_parameters()) is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
ArcamFmjSensorEntityDescription(
|
||||
key="incoming_video_colorspace",
|
||||
translation_key="incoming_video_colorspace",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=[member.name.lower() for member in IncomingVideoColorspace],
|
||||
value_fn=lambda state: (
|
||||
vp.colorspace.name.lower()
|
||||
if (vp := state.get_incoming_video_parameters()) is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
ArcamFmjSensorEntityDescription(
|
||||
key="incoming_audio_format",
|
||||
translation_key="incoming_audio_format",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=[member.name.lower() for member in IncomingAudioFormat],
|
||||
value_fn=lambda state: (
|
||||
result.name.lower()
|
||||
if (result := state.get_incoming_audio_format()[0]) is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
ArcamFmjSensorEntityDescription(
|
||||
key="incoming_audio_config",
|
||||
translation_key="incoming_audio_config",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=[member.name.lower() for member in IncomingAudioConfig],
|
||||
value_fn=lambda state: (
|
||||
result.name.lower()
|
||||
if (result := state.get_incoming_audio_format()[1]) is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
ArcamFmjSensorEntityDescription(
|
||||
key="incoming_audio_sample_rate",
|
||||
translation_key="incoming_audio_sample_rate",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
device_class=SensorDeviceClass.FREQUENCY,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfFrequency.HERTZ,
|
||||
suggested_display_precision=0,
|
||||
value_fn=lambda state: (
|
||||
None
|
||||
if (sample_rate := state.get_incoming_audio_sample_rate()) == 0
|
||||
else sample_rate
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ArcamFmjConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Arcam FMJ sensors from a config entry."""
|
||||
coordinators = config_entry.runtime_data.coordinators
|
||||
|
||||
entities: list[ArcamFmjSensorEntity] = []
|
||||
for coordinator in coordinators.values():
|
||||
entities.extend(
|
||||
ArcamFmjSensorEntity(coordinator, description) for description in SENSORS
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class ArcamFmjSensorEntity(ArcamFmjEntity, SensorEntity):
|
||||
"""Representation of an Arcam FMJ sensor."""
|
||||
|
||||
entity_description: ArcamFmjSensorEntityDescription
|
||||
|
||||
@property
|
||||
def native_value(self) -> int | float | str | None:
|
||||
"""Return the sensor value."""
|
||||
return self.entity_description.value_fn(self.coordinator.state)
|
||||
@@ -23,5 +23,121 @@
|
||||
"trigger_type": {
|
||||
"turn_on": "{entity_name} was requested to turn on"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"incoming_video_interlaced": {
|
||||
"name": "Incoming video interlaced"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"incoming_audio_config": {
|
||||
"name": "Incoming audio configuration",
|
||||
"state": {
|
||||
"auro_10_1": "Auro 10.1",
|
||||
"auro_11_1": "Auro 11.1",
|
||||
"auro_13_1": "Auro 13.1",
|
||||
"auro_2_2_2": "Auro 2.2.2",
|
||||
"auro_5_0": "Auro 5.0",
|
||||
"auro_5_1": "Auro 5.1",
|
||||
"auro_8_0": "Auro 8.0",
|
||||
"auro_9_1": "Auro 9.1",
|
||||
"auro_quad": "Auro quad",
|
||||
"dual_mono": "Dual mono",
|
||||
"dual_mono_lfe": "Dual mono + LFE",
|
||||
"mono": "Mono",
|
||||
"mono_lfe": "Mono + LFE",
|
||||
"stereo_center": "Stereo center",
|
||||
"stereo_center_lfe": "Stereo center + LFE",
|
||||
"stereo_center_surr_lr": "Stereo center surround L/R",
|
||||
"stereo_center_surr_lr_back_lr": "Stereo center surround L/R back L/R",
|
||||
"stereo_center_surr_lr_back_lr_lfe": "Stereo center surround L/R back L/R + LFE",
|
||||
"stereo_center_surr_lr_back_matrix": "Stereo center surround L/R back matrix",
|
||||
"stereo_center_surr_lr_back_matrix_lfe": "Stereo center surround L/R back matrix + LFE",
|
||||
"stereo_center_surr_lr_back_mono": "Stereo center surround L/R back mono",
|
||||
"stereo_center_surr_lr_back_mono_lfe": "Stereo center surround L/R back mono + LFE",
|
||||
"stereo_center_surr_lr_lfe": "Stereo center surround L/R + LFE",
|
||||
"stereo_center_surr_mono": "Stereo center surround mono",
|
||||
"stereo_center_surr_mono_lfe": "Stereo center surround mono + LFE",
|
||||
"stereo_downmix": "Stereo downmix",
|
||||
"stereo_downmix_lfe": "Stereo downmix + LFE",
|
||||
"stereo_lfe": "Stereo + LFE",
|
||||
"stereo_only": "Stereo only",
|
||||
"stereo_only_lo_ro": "Stereo only Lo/Ro",
|
||||
"stereo_only_lo_ro_lfe": "Stereo only Lo/Ro + LFE",
|
||||
"stereo_surr_lr": "Stereo surround L/R",
|
||||
"stereo_surr_lr_back_lr": "Stereo surround L/R back L/R",
|
||||
"stereo_surr_lr_back_lr_lfe": "Stereo surround L/R back L/R + LFE",
|
||||
"stereo_surr_lr_back_matrix": "Stereo surround L/R back matrix",
|
||||
"stereo_surr_lr_back_matrix_lfe": "Stereo surround L/R back matrix + LFE",
|
||||
"stereo_surr_lr_back_mono": "Stereo surround L/R back mono",
|
||||
"stereo_surr_lr_back_mono_lfe": "Stereo surround L/R back mono + LFE",
|
||||
"stereo_surr_lr_lfe": "Stereo surround L/R + LFE",
|
||||
"stereo_surr_mono": "Stereo surround mono",
|
||||
"stereo_surr_mono_lfe": "Stereo surround mono + LFE",
|
||||
"undetected": "Undetected",
|
||||
"unknown": "Unknown"
|
||||
}
|
||||
},
|
||||
"incoming_audio_format": {
|
||||
"name": "Incoming audio format",
|
||||
"state": {
|
||||
"analogue_direct": "Analogue direct",
|
||||
"auro_3d": "Auro-3D",
|
||||
"dolby_atmos": "Dolby Atmos",
|
||||
"dolby_digital": "Dolby Digital",
|
||||
"dolby_digital_ex": "Dolby Digital EX",
|
||||
"dolby_digital_plus": "Dolby Digital Plus",
|
||||
"dolby_digital_surround": "Dolby Digital Surround",
|
||||
"dolby_digital_true_hd": "Dolby TrueHD",
|
||||
"dts": "DTS",
|
||||
"dts_96_24": "DTS 96/24",
|
||||
"dts_core": "DTS Core",
|
||||
"dts_es_discrete": "DTS-ES Discrete",
|
||||
"dts_es_discrete_96_24": "DTS-ES Discrete 96/24",
|
||||
"dts_es_matrix": "DTS-ES Matrix",
|
||||
"dts_es_matrix_96_24": "DTS-ES Matrix 96/24",
|
||||
"dts_hd_high_res_audio": "DTS-HD High Resolution Audio",
|
||||
"dts_hd_master_audio": "DTS-HD Master Audio",
|
||||
"dts_low_bit_rate": "DTS Low Bit Rate",
|
||||
"dts_x": "DTS:X",
|
||||
"imax_enhanced": "IMAX Enhanced",
|
||||
"pcm": "PCM",
|
||||
"pcm_zero": "PCM zero",
|
||||
"undetected": "Undetected",
|
||||
"unsupported": "Unsupported"
|
||||
}
|
||||
},
|
||||
"incoming_audio_sample_rate": {
|
||||
"name": "Incoming audio sample rate"
|
||||
},
|
||||
"incoming_video_aspect_ratio": {
|
||||
"name": "Incoming video aspect ratio",
|
||||
"state": {
|
||||
"aspect_16_9": "16:9",
|
||||
"aspect_4_3": "4:3",
|
||||
"undefined": "Undefined"
|
||||
}
|
||||
},
|
||||
"incoming_video_colorspace": {
|
||||
"name": "Incoming video colorspace",
|
||||
"state": {
|
||||
"dolby_vision": "Dolby Vision",
|
||||
"hdr10": "HDR10",
|
||||
"hdr10_plus": "HDR10+",
|
||||
"hlg": "HLG",
|
||||
"normal": "Normal"
|
||||
}
|
||||
},
|
||||
"incoming_video_horizontal_resolution": {
|
||||
"name": "Incoming video horizontal resolution"
|
||||
},
|
||||
"incoming_video_refresh_rate": {
|
||||
"name": "Incoming video refresh rate"
|
||||
},
|
||||
"incoming_video_vertical_resolution": {
|
||||
"name": "Incoming video vertical resolution"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,19 +78,13 @@ class AssistPipelineSelect(SelectEntity, restore_state.RestoreEntity):
|
||||
index: int = 0,
|
||||
) -> None:
|
||||
"""Initialize a pipeline selector."""
|
||||
if index < 1:
|
||||
# Keep compatibility
|
||||
key_suffix = ""
|
||||
placeholder = ""
|
||||
else:
|
||||
key_suffix = f"_{index + 1}"
|
||||
placeholder = f" {index + 1}"
|
||||
|
||||
self.entity_description = replace(
|
||||
self.entity_description,
|
||||
key=f"pipeline{key_suffix}",
|
||||
translation_placeholders={"index": placeholder},
|
||||
)
|
||||
if index >= 1:
|
||||
self.entity_description = replace(
|
||||
self.entity_description,
|
||||
key=f"pipeline_{index + 1}",
|
||||
translation_key="pipeline_n",
|
||||
translation_placeholders={"index": str(index + 1)},
|
||||
)
|
||||
|
||||
self._domain = domain
|
||||
self._unique_id_prefix = unique_id_prefix
|
||||
|
||||
@@ -7,11 +7,17 @@
|
||||
},
|
||||
"select": {
|
||||
"pipeline": {
|
||||
"name": "Assistant{index}",
|
||||
"name": "Assistant",
|
||||
"state": {
|
||||
"preferred": "Preferred"
|
||||
}
|
||||
},
|
||||
"pipeline_n": {
|
||||
"name": "Assistant {index}",
|
||||
"state": {
|
||||
"preferred": "[%key:component::assist_pipeline::entity::select::pipeline::state::preferred%]"
|
||||
}
|
||||
},
|
||||
"vad_sensitivity": {
|
||||
"name": "Finished speaking detection",
|
||||
"state": {
|
||||
|
||||
@@ -121,6 +121,7 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = {
|
||||
"alarm_control_panel",
|
||||
"assist_satellite",
|
||||
"climate",
|
||||
"cover",
|
||||
"device_tracker",
|
||||
"fan",
|
||||
"humidifier",
|
||||
|
||||
@@ -21,6 +21,6 @@
|
||||
"bluetooth-auto-recovery==1.5.3",
|
||||
"bluetooth-data-tools==1.28.4",
|
||||
"dbus-fast==3.1.2",
|
||||
"habluetooth==5.9.1"
|
||||
"habluetooth==5.10.2"
|
||||
]
|
||||
}
|
||||
|
||||
22
homeassistant/components/chess_com/diagnostics.py
Normal file
22
homeassistant/components/chess_com/diagnostics.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""Diagnostics support for Chess.com."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import ChessConfigEntry
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: ChessConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
return {
|
||||
"player": asdict(coordinator.data.player),
|
||||
"stats": asdict(coordinator.data.stats),
|
||||
}
|
||||
@@ -41,7 +41,7 @@ rules:
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
diagnostics: done
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: Can't detect a game
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
"""Provides conditions for climates."""
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.condition import (
|
||||
Condition,
|
||||
make_entity_state_attribute_condition,
|
||||
make_entity_state_condition,
|
||||
)
|
||||
from homeassistant.helpers.automation import DomainSpec
|
||||
from homeassistant.helpers.condition import Condition, make_entity_state_condition
|
||||
|
||||
from .const import ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode
|
||||
|
||||
@@ -22,14 +19,14 @@ CONDITIONS: dict[str, type[Condition]] = {
|
||||
HVACMode.HEAT_COOL,
|
||||
},
|
||||
),
|
||||
"is_cooling": make_entity_state_attribute_condition(
|
||||
DOMAIN, ATTR_HVAC_ACTION, HVACAction.COOLING
|
||||
"is_cooling": make_entity_state_condition(
|
||||
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.COOLING
|
||||
),
|
||||
"is_drying": make_entity_state_attribute_condition(
|
||||
DOMAIN, ATTR_HVAC_ACTION, HVACAction.DRYING
|
||||
"is_drying": make_entity_state_condition(
|
||||
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.DRYING
|
||||
),
|
||||
"is_heating": make_entity_state_attribute_condition(
|
||||
DOMAIN, ATTR_HVAC_ACTION, HVACAction.HEATING
|
||||
"is_heating": make_entity_state_condition(
|
||||
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.HEATING
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ from homeassistant.helpers.trigger import (
|
||||
TriggerConfig,
|
||||
make_entity_numerical_state_changed_trigger,
|
||||
make_entity_numerical_state_crossed_threshold_trigger,
|
||||
make_entity_target_state_attribute_trigger,
|
||||
make_entity_target_state_trigger,
|
||||
make_entity_transition_trigger,
|
||||
)
|
||||
@@ -47,11 +46,11 @@ class HVACModeChangedTrigger(EntityTargetStateTriggerBase):
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"hvac_mode_changed": HVACModeChangedTrigger,
|
||||
"started_cooling": make_entity_target_state_attribute_trigger(
|
||||
DOMAIN, ATTR_HVAC_ACTION, HVACAction.COOLING
|
||||
"started_cooling": make_entity_target_state_trigger(
|
||||
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.COOLING
|
||||
),
|
||||
"started_drying": make_entity_target_state_attribute_trigger(
|
||||
DOMAIN, ATTR_HVAC_ACTION, HVACAction.DRYING
|
||||
"started_drying": make_entity_target_state_trigger(
|
||||
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.DRYING
|
||||
),
|
||||
"target_humidity_changed": make_entity_numerical_state_changed_trigger(
|
||||
{DOMAIN: NumericalDomainSpec(value_source=ATTR_HUMIDITY)}
|
||||
@@ -80,8 +79,8 @@ TRIGGERS: dict[str, type[Trigger]] = {
|
||||
HVACMode.HEAT_COOL,
|
||||
},
|
||||
),
|
||||
"started_heating": make_entity_target_state_attribute_trigger(
|
||||
DOMAIN, ATTR_HVAC_ACTION, HVACAction.HEATING
|
||||
"started_heating": make_entity_target_state_trigger(
|
||||
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.HEATING
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aiocomelit"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiocomelit==2.0.0"]
|
||||
"requirements": ["aiocomelit==2.0.1"]
|
||||
}
|
||||
|
||||
@@ -153,8 +153,8 @@ def websocket_get_entities(
|
||||
{
|
||||
vol.Required("type"): "config/entity_registry/update",
|
||||
vol.Required("entity_id"): cv.entity_id,
|
||||
vol.Optional("aliases"): [vol.Any(str, None)],
|
||||
# If passed in, we update value. Passing None will remove old value.
|
||||
vol.Optional("aliases"): list,
|
||||
vol.Optional("area_id"): vol.Any(str, None),
|
||||
# Categories is a mapping of key/value (scope/category_id) pairs.
|
||||
# If passed in, we update/adjust only the provided scope(s).
|
||||
@@ -225,10 +225,15 @@ def websocket_update_entity(
|
||||
changes[key] = msg[key]
|
||||
|
||||
if "aliases" in msg:
|
||||
# Create a set for the aliases without:
|
||||
# - Empty strings
|
||||
# Sanitize aliases by removing:
|
||||
# - Trailing and leading whitespace characters in the individual aliases
|
||||
changes["aliases"] = {s_strip for s in msg["aliases"] if (s_strip := s.strip())}
|
||||
# - Empty strings
|
||||
changes["aliases"] = aliases = []
|
||||
for alias in msg["aliases"]:
|
||||
if alias is None:
|
||||
aliases.append(er.COMPUTED_NAME)
|
||||
elif alias := alias.strip():
|
||||
aliases.append(alias)
|
||||
|
||||
if "labels" in msg:
|
||||
# Convert labels to a set
|
||||
|
||||
@@ -992,18 +992,11 @@ class DefaultAgent(ConversationEntity):
|
||||
continue
|
||||
context[attr] = state.attributes[attr]
|
||||
|
||||
if (
|
||||
entity := entity_registry.async_get(state.entity_id)
|
||||
) and entity.aliases:
|
||||
for alias in entity.aliases:
|
||||
alias = alias.strip()
|
||||
if not alias:
|
||||
continue
|
||||
|
||||
yield (alias, alias, context)
|
||||
|
||||
# Default name
|
||||
yield (state.name, state.name, context)
|
||||
entity_entry = entity_registry.async_get(state.entity_id)
|
||||
for name in intent.async_get_entity_aliases(
|
||||
self.hass, entity_entry, state=state
|
||||
):
|
||||
yield (name, name, context)
|
||||
|
||||
def _recognize_strict(
|
||||
self,
|
||||
|
||||
103
homeassistant/components/cover/condition.py
Normal file
103
homeassistant/components/cover/condition.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""Provides conditions for covers."""
|
||||
|
||||
from homeassistant.const import STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant, State, split_entity_id
|
||||
from homeassistant.helpers.condition import Condition, EntityConditionBase
|
||||
|
||||
from .const import ATTR_IS_CLOSED, DOMAIN, CoverDeviceClass
|
||||
from .models import CoverDomainSpec
|
||||
|
||||
|
||||
class CoverConditionBase(EntityConditionBase[CoverDomainSpec]):
|
||||
"""Base condition for cover state checks."""
|
||||
|
||||
def is_valid_state(self, entity_state: State) -> bool:
|
||||
"""Check if the state matches the expected cover state."""
|
||||
domain_spec = self._domain_specs[split_entity_id(entity_state.entity_id)[0]]
|
||||
if domain_spec.value_source is not None:
|
||||
return (
|
||||
entity_state.attributes.get(domain_spec.value_source)
|
||||
== domain_spec.target_value
|
||||
)
|
||||
return entity_state.state == domain_spec.target_value
|
||||
|
||||
|
||||
def make_cover_is_open_condition(
|
||||
*, device_classes: dict[str, str]
|
||||
) -> type[CoverConditionBase]:
|
||||
"""Create a condition for cover is open."""
|
||||
|
||||
class CoverIsOpenCondition(CoverConditionBase):
|
||||
"""Condition for cover is open."""
|
||||
|
||||
_domain_specs = {
|
||||
domain: CoverDomainSpec(
|
||||
device_class=dc,
|
||||
value_source=ATTR_IS_CLOSED if domain == DOMAIN else None,
|
||||
target_value=False if domain == DOMAIN else STATE_ON,
|
||||
)
|
||||
for domain, dc in device_classes.items()
|
||||
}
|
||||
|
||||
return CoverIsOpenCondition
|
||||
|
||||
|
||||
def make_cover_is_closed_condition(
|
||||
*, device_classes: dict[str, str]
|
||||
) -> type[CoverConditionBase]:
|
||||
"""Create a condition for cover is closed."""
|
||||
|
||||
class CoverIsClosedCondition(CoverConditionBase):
|
||||
"""Condition for cover is closed."""
|
||||
|
||||
_domain_specs = {
|
||||
domain: CoverDomainSpec(
|
||||
device_class=dc,
|
||||
value_source=ATTR_IS_CLOSED if domain == DOMAIN else None,
|
||||
target_value=True if domain == DOMAIN else STATE_OFF,
|
||||
)
|
||||
for domain, dc in device_classes.items()
|
||||
}
|
||||
|
||||
return CoverIsClosedCondition
|
||||
|
||||
|
||||
DEVICE_CLASSES_AWNING: dict[str, str] = {DOMAIN: CoverDeviceClass.AWNING}
|
||||
DEVICE_CLASSES_BLIND: dict[str, str] = {DOMAIN: CoverDeviceClass.BLIND}
|
||||
DEVICE_CLASSES_CURTAIN: dict[str, str] = {DOMAIN: CoverDeviceClass.CURTAIN}
|
||||
DEVICE_CLASSES_SHADE: dict[str, str] = {DOMAIN: CoverDeviceClass.SHADE}
|
||||
DEVICE_CLASSES_SHUTTER: dict[str, str] = {DOMAIN: CoverDeviceClass.SHUTTER}
|
||||
|
||||
CONDITIONS: dict[str, type[Condition]] = {
|
||||
"awning_is_closed": make_cover_is_closed_condition(
|
||||
device_classes=DEVICE_CLASSES_AWNING
|
||||
),
|
||||
"awning_is_open": make_cover_is_open_condition(
|
||||
device_classes=DEVICE_CLASSES_AWNING
|
||||
),
|
||||
"blind_is_closed": make_cover_is_closed_condition(
|
||||
device_classes=DEVICE_CLASSES_BLIND
|
||||
),
|
||||
"blind_is_open": make_cover_is_open_condition(device_classes=DEVICE_CLASSES_BLIND),
|
||||
"curtain_is_closed": make_cover_is_closed_condition(
|
||||
device_classes=DEVICE_CLASSES_CURTAIN
|
||||
),
|
||||
"curtain_is_open": make_cover_is_open_condition(
|
||||
device_classes=DEVICE_CLASSES_CURTAIN
|
||||
),
|
||||
"shade_is_closed": make_cover_is_closed_condition(
|
||||
device_classes=DEVICE_CLASSES_SHADE
|
||||
),
|
||||
"shade_is_open": make_cover_is_open_condition(device_classes=DEVICE_CLASSES_SHADE),
|
||||
"shutter_is_closed": make_cover_is_closed_condition(
|
||||
device_classes=DEVICE_CLASSES_SHUTTER
|
||||
),
|
||||
"shutter_is_open": make_cover_is_open_condition(
|
||||
device_classes=DEVICE_CLASSES_SHUTTER
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
|
||||
"""Return the conditions for covers."""
|
||||
return CONDITIONS
|
||||
80
homeassistant/components/cover/conditions.yaml
Normal file
80
homeassistant/components/cover/conditions.yaml
Normal file
@@ -0,0 +1,80 @@
|
||||
.condition_common_fields: &condition_common_fields
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
translation_key: condition_behavior
|
||||
options:
|
||||
- all
|
||||
- any
|
||||
|
||||
awning_is_closed:
|
||||
fields: *condition_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: cover
|
||||
device_class: awning
|
||||
|
||||
awning_is_open:
|
||||
fields: *condition_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: cover
|
||||
device_class: awning
|
||||
|
||||
blind_is_closed:
|
||||
fields: *condition_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: cover
|
||||
device_class: blind
|
||||
|
||||
blind_is_open:
|
||||
fields: *condition_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: cover
|
||||
device_class: blind
|
||||
|
||||
curtain_is_closed:
|
||||
fields: *condition_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: cover
|
||||
device_class: curtain
|
||||
|
||||
curtain_is_open:
|
||||
fields: *condition_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: cover
|
||||
device_class: curtain
|
||||
|
||||
shade_is_closed:
|
||||
fields: *condition_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: cover
|
||||
device_class: shade
|
||||
|
||||
shade_is_open:
|
||||
fields: *condition_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: cover
|
||||
device_class: shade
|
||||
|
||||
shutter_is_closed:
|
||||
fields: *condition_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: cover
|
||||
device_class: shutter
|
||||
|
||||
shutter_is_open:
|
||||
fields: *condition_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: cover
|
||||
device_class: shutter
|
||||
@@ -1,4 +1,36 @@
|
||||
{
|
||||
"conditions": {
|
||||
"awning_is_closed": {
|
||||
"condition": "mdi:storefront-outline"
|
||||
},
|
||||
"awning_is_open": {
|
||||
"condition": "mdi:storefront-outline"
|
||||
},
|
||||
"blind_is_closed": {
|
||||
"condition": "mdi:blinds-horizontal-closed"
|
||||
},
|
||||
"blind_is_open": {
|
||||
"condition": "mdi:blinds-horizontal"
|
||||
},
|
||||
"curtain_is_closed": {
|
||||
"condition": "mdi:curtains-closed"
|
||||
},
|
||||
"curtain_is_open": {
|
||||
"condition": "mdi:curtains"
|
||||
},
|
||||
"shade_is_closed": {
|
||||
"condition": "mdi:roller-shade-closed"
|
||||
},
|
||||
"shade_is_open": {
|
||||
"condition": "mdi:roller-shade"
|
||||
},
|
||||
"shutter_is_closed": {
|
||||
"condition": "mdi:window-shutter"
|
||||
},
|
||||
"shutter_is_open": {
|
||||
"condition": "mdi:window-shutter-open"
|
||||
}
|
||||
},
|
||||
"entity_component": {
|
||||
"_": {
|
||||
"default": "mdi:window-open",
|
||||
|
||||
12
homeassistant/components/cover/models.py
Normal file
12
homeassistant/components/cover/models.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""Data models for the cover integration."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from homeassistant.helpers.automation import DomainSpec
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CoverDomainSpec(DomainSpec):
|
||||
"""DomainSpec with a target value for comparison."""
|
||||
|
||||
target_value: str | bool | None = None
|
||||
@@ -1,8 +1,112 @@
|
||||
{
|
||||
"common": {
|
||||
"condition_behavior_description": "How the state should match on the targeted covers.",
|
||||
"condition_behavior_name": "Behavior",
|
||||
"trigger_behavior_description": "The behavior of the targeted covers to trigger on.",
|
||||
"trigger_behavior_name": "Behavior"
|
||||
},
|
||||
"conditions": {
|
||||
"awning_is_closed": {
|
||||
"description": "Tests if one or more awnings are closed.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::cover::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Awning is closed"
|
||||
},
|
||||
"awning_is_open": {
|
||||
"description": "Tests if one or more awnings are open.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::cover::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Awning is open"
|
||||
},
|
||||
"blind_is_closed": {
|
||||
"description": "Tests if one or more blinds are closed.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::cover::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Blind is closed"
|
||||
},
|
||||
"blind_is_open": {
|
||||
"description": "Tests if one or more blinds are open.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::cover::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Blind is open"
|
||||
},
|
||||
"curtain_is_closed": {
|
||||
"description": "Tests if one or more curtains are closed.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::cover::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Curtain is closed"
|
||||
},
|
||||
"curtain_is_open": {
|
||||
"description": "Tests if one or more curtains are open.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::cover::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Curtain is open"
|
||||
},
|
||||
"shade_is_closed": {
|
||||
"description": "Tests if one or more shades are closed.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::cover::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Shade is closed"
|
||||
},
|
||||
"shade_is_open": {
|
||||
"description": "Tests if one or more shades are open.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::cover::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Shade is open"
|
||||
},
|
||||
"shutter_is_closed": {
|
||||
"description": "Tests if one or more shutters are closed.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::cover::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Shutter is closed"
|
||||
},
|
||||
"shutter_is_open": {
|
||||
"description": "Tests if one or more shutters are open.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::cover::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Shutter is open"
|
||||
}
|
||||
},
|
||||
"device_automation": {
|
||||
"action_type": {
|
||||
"close": "Close {entity_name}",
|
||||
@@ -87,6 +191,12 @@
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"condition_behavior": {
|
||||
"options": {
|
||||
"all": "All",
|
||||
"any": "Any"
|
||||
}
|
||||
},
|
||||
"trigger_behavior": {
|
||||
"options": {
|
||||
"any": "Any",
|
||||
|
||||
@@ -1,20 +1,11 @@
|
||||
"""Provides triggers for covers."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN
|
||||
from homeassistant.core import HomeAssistant, State, split_entity_id
|
||||
from homeassistant.helpers.automation import DomainSpec
|
||||
from homeassistant.helpers.trigger import EntityTriggerBase, Trigger
|
||||
|
||||
from .const import ATTR_IS_CLOSED, DOMAIN, CoverDeviceClass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CoverDomainSpec(DomainSpec):
|
||||
"""DomainSpec with a target value for comparison."""
|
||||
|
||||
target_value: str | bool | None = None
|
||||
from .models import CoverDomainSpec
|
||||
|
||||
|
||||
class CoverTriggerBase(EntityTriggerBase[CoverDomainSpec]):
|
||||
|
||||
@@ -9,9 +9,12 @@ from typing import Any
|
||||
from homeassistant.components.valve import ValveEntity, ValveEntityFeature, ValveState
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.event import async_track_utc_time_change
|
||||
|
||||
from . import DOMAIN
|
||||
|
||||
OPEN_CLOSE_DELAY = 2 # Used to give a realistic open/close experience in frontend
|
||||
|
||||
|
||||
@@ -23,10 +26,10 @@ async def async_setup_entry(
|
||||
"""Set up the Demo config entry."""
|
||||
async_add_entities(
|
||||
[
|
||||
DemoValve("Front Garden", ValveState.OPEN),
|
||||
DemoValve("Orchard", ValveState.CLOSED),
|
||||
DemoValve("Back Garden", ValveState.CLOSED, position=70),
|
||||
DemoValve("Trees", ValveState.CLOSED, position=30),
|
||||
DemoValve("valve_1", "Front Garden", ValveState.OPEN),
|
||||
DemoValve("valve_2", "Orchard", ValveState.CLOSED),
|
||||
DemoValve("valve_3", "Back Garden", ValveState.CLOSED, position=70),
|
||||
DemoValve("valve_4", "Trees", ValveState.CLOSED, position=30),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -34,17 +37,24 @@ async def async_setup_entry(
|
||||
class DemoValve(ValveEntity):
|
||||
"""Representation of a Demo valve."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
unique_id: str,
|
||||
name: str,
|
||||
state: str,
|
||||
moveable: bool = True,
|
||||
position: int | None = None,
|
||||
) -> None:
|
||||
"""Initialize the valve."""
|
||||
self._attr_name = name
|
||||
self._attr_unique_id = unique_id
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, unique_id)},
|
||||
name=name,
|
||||
)
|
||||
if moveable:
|
||||
self._attr_supported_features = (
|
||||
ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE
|
||||
|
||||
@@ -38,9 +38,9 @@ from homeassistant.util.hass_dict import HassKey
|
||||
from homeassistant.util.json import format_unserializable_data
|
||||
|
||||
from .const import DOMAIN, REDACTED, DiagnosticsSubType, DiagnosticsType
|
||||
from .util import async_redact_data
|
||||
from .util import async_redact_data, entity_entry_as_dict
|
||||
|
||||
__all__ = ["REDACTED", "async_redact_data"]
|
||||
__all__ = ["REDACTED", "async_redact_data", "entity_entry_as_dict"]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -5,7 +5,10 @@ from __future__ import annotations
|
||||
from collections.abc import Iterable, Mapping
|
||||
from typing import Any, cast, overload
|
||||
|
||||
import attr
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.entity_registry import RegistryEntry
|
||||
|
||||
from .const import REDACTED
|
||||
|
||||
@@ -42,3 +45,16 @@ def async_redact_data[_T](data: _T, to_redact: Iterable[Any]) -> _T:
|
||||
redacted[key] = [async_redact_data(item, to_redact) for item in value]
|
||||
|
||||
return cast(_T, redacted)
|
||||
|
||||
|
||||
def _entity_entry_filter(a: attr.Attribute, _: Any) -> bool:
|
||||
return a.name not in ("_cache", "compat_aliases", "compat_name")
|
||||
|
||||
|
||||
@callback
|
||||
def entity_entry_as_dict(entry: RegistryEntry) -> dict[str, Any]:
|
||||
"""Convert an entity registry entry to a dict for diagnostics.
|
||||
|
||||
This excludes internal fields that should not be exposed in diagnostics.
|
||||
"""
|
||||
return attr.asdict(entry, filter=_entity_entry_filter)
|
||||
|
||||
@@ -11,7 +11,7 @@ from attr import asdict
|
||||
from pyenphase.envoy import Envoy
|
||||
from pyenphase.exceptions import EnvoyError
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.components.diagnostics import async_redact_data, entity_entry_as_dict
|
||||
from homeassistant.const import (
|
||||
CONF_NAME,
|
||||
CONF_PASSWORD,
|
||||
@@ -111,8 +111,7 @@ async def async_get_config_entry_diagnostics(
|
||||
if state := hass.states.get(entity.entity_id):
|
||||
state_dict = dict(state.as_dict())
|
||||
state_dict.pop("context", None)
|
||||
entity_dict = asdict(entity)
|
||||
entity_dict.pop("_cache", None)
|
||||
entity_dict = entity_entry_as_dict(entity)
|
||||
entities.append({"entity": entity_dict, "state": state_dict})
|
||||
device_dict = asdict(device)
|
||||
device_dict.pop("_cache", None)
|
||||
|
||||
@@ -160,6 +160,23 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity):
|
||||
_native_supported_color_modes: tuple[ESPHomeColorMode, ...]
|
||||
_supports_color_mode = False
|
||||
|
||||
def _color_temp_to_cold_warm(self, color_temp_mired: float) -> tuple[float, float]:
|
||||
"""Convert a color temperature in mireds to cold/warm white fractions.
|
||||
|
||||
Returns (cold_white, warm_white) normalized so the brighter channel
|
||||
is 1.0.
|
||||
"""
|
||||
static_info = self._static_info
|
||||
min_mireds = static_info.min_mireds
|
||||
max_mireds = static_info.max_mireds
|
||||
if max_mireds <= min_mireds:
|
||||
return 1.0, 1.0
|
||||
color_temp_clamped = min(max(color_temp_mired, min_mireds), max_mireds)
|
||||
ww_frac = (color_temp_clamped - min_mireds) / (max_mireds - min_mireds)
|
||||
cw_frac = 1 - ww_frac
|
||||
max_frac = max(cw_frac, ww_frac)
|
||||
return cw_frac / max_frac, ww_frac / max_frac
|
||||
|
||||
@property
|
||||
@esphome_state_property
|
||||
def is_on(self) -> bool:
|
||||
@@ -241,12 +258,19 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity):
|
||||
|
||||
if (color_temp_k := kwargs.get(ATTR_COLOR_TEMP_KELVIN)) is not None:
|
||||
# Do not use kelvin_to_mired here to prevent precision loss
|
||||
data["color_temperature"] = 1_000_000.0 / color_temp_k
|
||||
color_temp_mired = 1_000_000.0 / color_temp_k
|
||||
if color_temp_modes := _filter_color_modes(
|
||||
color_modes, LightColorCapability.COLOR_TEMPERATURE
|
||||
):
|
||||
data["color_temperature"] = color_temp_mired
|
||||
color_modes = color_temp_modes
|
||||
else:
|
||||
# Convert color temperature to explicit cold/warm white
|
||||
# values to avoid ESPHome applying brightness to both
|
||||
# master brightness and white channels (b² effect).
|
||||
data["cold_white"], data["warm_white"] = self._color_temp_to_cold_warm(
|
||||
color_temp_mired
|
||||
)
|
||||
color_modes = _filter_color_modes(
|
||||
color_modes, LightColorCapability.COLD_WARM_WHITE
|
||||
)
|
||||
@@ -345,19 +369,13 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity):
|
||||
self._native_supported_color_modes, LightColorCapability.COLD_WARM_WHITE
|
||||
):
|
||||
# Try to reverse white + color temp to cwww
|
||||
static_info = self._static_info
|
||||
min_ct = static_info.min_mireds
|
||||
max_ct = static_info.max_mireds
|
||||
color_temp = min(max(state.color_temperature, min_ct), max_ct)
|
||||
white = state.white
|
||||
|
||||
ww_frac = (color_temp - min_ct) / (max_ct - min_ct)
|
||||
cw_frac = 1 - ww_frac
|
||||
cw, ww = self._color_temp_to_cold_warm(state.color_temperature)
|
||||
|
||||
return (
|
||||
*rgb,
|
||||
round(white * cw_frac / max(cw_frac, ww_frac) * 255),
|
||||
round(white * ww_frac / max(cw_frac, ww_frac) * 255),
|
||||
round(white * cw * 255),
|
||||
round(white * ww * 255),
|
||||
)
|
||||
return (
|
||||
*rgb,
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==44.3.1",
|
||||
"aioesphomeapi==44.5.2",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.7.1"
|
||||
],
|
||||
|
||||
@@ -123,19 +123,13 @@ class EsphomeAssistSatelliteWakeWordSelect(
|
||||
|
||||
def __init__(self, entry_data: RuntimeEntryData, index: int = 0) -> None:
|
||||
"""Initialize a wake word selector."""
|
||||
if index < 1:
|
||||
# Keep compatibility
|
||||
key_suffix = ""
|
||||
placeholder = ""
|
||||
else:
|
||||
key_suffix = f"_{index + 1}"
|
||||
placeholder = f" {index + 1}"
|
||||
|
||||
self.entity_description = replace(
|
||||
self.entity_description,
|
||||
key=f"wake_word{key_suffix}",
|
||||
translation_placeholders={"index": placeholder},
|
||||
)
|
||||
if index >= 1:
|
||||
self.entity_description = replace(
|
||||
self.entity_description,
|
||||
key=f"wake_word_{index + 1}",
|
||||
translation_key="wake_word_n",
|
||||
translation_placeholders={"index": str(index + 1)},
|
||||
)
|
||||
|
||||
EsphomeAssistEntity.__init__(self, entry_data)
|
||||
|
||||
|
||||
@@ -107,6 +107,12 @@
|
||||
"preferred": "[%key:component::assist_pipeline::entity::select::pipeline::state::preferred%]"
|
||||
}
|
||||
},
|
||||
"pipeline_n": {
|
||||
"name": "[%key:component::assist_pipeline::entity::select::pipeline_n::name%]",
|
||||
"state": {
|
||||
"preferred": "[%key:component::assist_pipeline::entity::select::pipeline::state::preferred%]"
|
||||
}
|
||||
},
|
||||
"vad_sensitivity": {
|
||||
"name": "[%key:component::assist_pipeline::entity::select::vad_sensitivity::name%]",
|
||||
"state": {
|
||||
@@ -116,11 +122,18 @@
|
||||
}
|
||||
},
|
||||
"wake_word": {
|
||||
"name": "Wake word{index}",
|
||||
"name": "Wake word",
|
||||
"state": {
|
||||
"no_wake_word": "No wake word",
|
||||
"okay_nabu": "Okay Nabu"
|
||||
}
|
||||
},
|
||||
"wake_word_n": {
|
||||
"name": "Wake word {index}",
|
||||
"state": {
|
||||
"no_wake_word": "[%key:component::esphome::entity::select::wake_word::state::no_wake_word%]",
|
||||
"okay_nabu": "[%key:component::esphome::entity::select::wake_word::state::okay_nabu%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -103,6 +103,8 @@ class FreeboxFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
) -> ConfigFlowResult:
|
||||
"""Initialize flow from zeroconf."""
|
||||
zeroconf_properties = discovery_info.properties
|
||||
host = zeroconf_properties["api_domain"]
|
||||
port = zeroconf_properties["https_port"]
|
||||
host = zeroconf_properties.get("api_domain")
|
||||
if not host:
|
||||
return self.async_abort(reason="missing_api_domain")
|
||||
port = zeroconf_properties.get("https_port") or discovery_info.port
|
||||
return await self.async_step_user({CONF_HOST: host, CONF_PORT: port})
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"missing_api_domain": "The discovered Freebox service did not provide the required API domain. Try again later or configure the Freebox manually."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import ClientError
|
||||
@@ -56,3 +57,42 @@ class FreshrFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, _user_input: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauthentication."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauthentication confirmation."""
|
||||
errors: dict[str, str] = {}
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
|
||||
if user_input is not None:
|
||||
client = FreshrClient(session=async_get_clientsession(self.hass))
|
||||
try:
|
||||
await client.login(
|
||||
reauth_entry.data[CONF_USERNAME], user_input[CONF_PASSWORD]
|
||||
)
|
||||
except LoginError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except ClientError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception: # noqa: BLE001
|
||||
LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return self.async_update_reload_and_abort(
|
||||
reauth_entry,
|
||||
data_updates={CONF_PASSWORD: user_input[CONF_PASSWORD]},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=vol.Schema({vol.Required(CONF_PASSWORD): str}),
|
||||
description_placeholders={CONF_USERNAME: reauth_entry.data[CONF_USERNAME]},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/freshr",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["pyfreshr==1.2.0"]
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ rules:
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: todo
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"wrong_account": "Cannot change the account username."
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
@@ -12,6 +10,15 @@
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "[%key:component::freshr::config::step::user::data_description::password%]"
|
||||
},
|
||||
"description": "Re-enter the password for your Fresh-r account `{username}`."
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["fritzconnection"],
|
||||
"quality_scale": "bronze",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["fritzconnection[qr]==1.15.1", "xmltodict==1.0.2"],
|
||||
"ssdp": [
|
||||
{
|
||||
|
||||
@@ -29,9 +29,7 @@ rules:
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
test-coverage:
|
||||
status: todo
|
||||
comment: we are close to the goal of 95%
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
|
||||
@@ -4,9 +4,9 @@ set_guest_wifi_password:
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
integration: fritz
|
||||
entity:
|
||||
device_class: connectivity
|
||||
integration: fritz
|
||||
domain: update
|
||||
password:
|
||||
required: false
|
||||
selector:
|
||||
@@ -23,9 +23,9 @@ dial:
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
integration: fritz
|
||||
entity:
|
||||
device_class: connectivity
|
||||
integration: fritz
|
||||
domain: update
|
||||
number:
|
||||
required: true
|
||||
selector:
|
||||
|
||||
@@ -133,26 +133,20 @@ async def _async_wifi_entities_list(
|
||||
]
|
||||
)
|
||||
_LOGGER.debug("WiFi networks count: %s", wifi_count)
|
||||
networks: dict = {}
|
||||
networks: dict[int, dict[str, Any]] = {}
|
||||
for i in range(1, wifi_count + 1):
|
||||
network_info = await avm_wrapper.async_get_wlan_configuration(i)
|
||||
# Devices with 4 WLAN services, use the 2nd for internal communications
|
||||
if not (wifi_count == 4 and i == 2):
|
||||
networks[i] = {
|
||||
"ssid": network_info["NewSSID"],
|
||||
"bssid": network_info["NewBSSID"],
|
||||
"standard": network_info["NewStandard"],
|
||||
"enabled": network_info["NewEnable"],
|
||||
"status": network_info["NewStatus"],
|
||||
}
|
||||
networks[i] = network_info
|
||||
for i, network in networks.copy().items():
|
||||
networks[i]["switch_name"] = network["ssid"]
|
||||
networks[i]["switch_name"] = network["NewSSID"]
|
||||
if (
|
||||
len(
|
||||
[
|
||||
j
|
||||
for j, n in networks.items()
|
||||
if slugify(n["ssid"]) == slugify(network["ssid"])
|
||||
if slugify(n["NewSSID"]) == slugify(network["NewSSID"])
|
||||
]
|
||||
)
|
||||
> 1
|
||||
@@ -434,13 +428,11 @@ class FritzBoxPortSwitch(FritzBoxBaseSwitch):
|
||||
for key, attr in attributes_dict.items():
|
||||
self._attributes[attr] = self.port_mapping[key]
|
||||
|
||||
async def _async_switch_on_off_executor(self, turn_on: bool) -> bool:
|
||||
async def _async_switch_on_off_executor(self, turn_on: bool) -> None:
|
||||
self.port_mapping["NewEnabled"] = "1" if turn_on else "0"
|
||||
|
||||
resp = await self._avm_wrapper.async_add_port_mapping(
|
||||
await self._avm_wrapper.async_add_port_mapping(
|
||||
self.connection_type, self.port_mapping
|
||||
)
|
||||
return bool(resp is not None)
|
||||
|
||||
|
||||
class FritzBoxDeflectionSwitch(FritzBoxBaseCoordinatorSwitch):
|
||||
@@ -525,12 +517,11 @@ class FritzBoxProfileSwitch(FritzDeviceBase, SwitchEntity):
|
||||
"""Turn off switch."""
|
||||
await self._async_handle_turn_on_off(turn_on=False)
|
||||
|
||||
async def _async_handle_turn_on_off(self, turn_on: bool) -> bool:
|
||||
async def _async_handle_turn_on_off(self, turn_on: bool) -> None:
|
||||
"""Handle switch state change request."""
|
||||
await self._avm_wrapper.async_set_allow_wan_access(self.ip_address, turn_on)
|
||||
self._avm_wrapper.devices[self._mac].wan_access = turn_on
|
||||
self.async_write_ha_state()
|
||||
return True
|
||||
|
||||
|
||||
class FritzBoxWifiSwitch(FritzBoxBaseSwitch):
|
||||
@@ -541,10 +532,11 @@ class FritzBoxWifiSwitch(FritzBoxBaseSwitch):
|
||||
avm_wrapper: AvmWrapper,
|
||||
device_friendly_name: str,
|
||||
network_num: int,
|
||||
network_data: dict,
|
||||
network_data: dict[str, Any],
|
||||
) -> None:
|
||||
"""Init Fritz Wifi switch."""
|
||||
self._avm_wrapper = avm_wrapper
|
||||
self._wifi_info = network_data
|
||||
|
||||
self._attributes = {}
|
||||
self._attr_entity_category = EntityCategory.CONFIG
|
||||
@@ -560,7 +552,7 @@ class FritzBoxWifiSwitch(FritzBoxBaseSwitch):
|
||||
type=SWITCH_TYPE_WIFINETWORK,
|
||||
callback_update=self._async_fetch_update,
|
||||
callback_switch=self._async_switch_on_off_executor,
|
||||
init_state=network_data["enabled"],
|
||||
init_state=network_data["NewEnable"],
|
||||
)
|
||||
super().__init__(self._avm_wrapper, device_friendly_name, switch_info)
|
||||
|
||||
@@ -587,7 +579,9 @@ class FritzBoxWifiSwitch(FritzBoxBaseSwitch):
|
||||
self._attributes["mac_address_control"] = wifi_info[
|
||||
"NewMACAddressControlEnabled"
|
||||
]
|
||||
self._wifi_info = wifi_info
|
||||
|
||||
async def _async_switch_on_off_executor(self, turn_on: bool) -> None:
|
||||
"""Handle wifi switch."""
|
||||
self._wifi_info["NewEnable"] = turn_on
|
||||
await self._avm_wrapper.async_set_wlan_configuration(self._network_num, turn_on)
|
||||
|
||||
@@ -7,7 +7,7 @@ import logging
|
||||
|
||||
from bleak.backends.device import BLEDevice
|
||||
from gardena_bluetooth.client import CachedConnection, Client
|
||||
from gardena_bluetooth.const import DeviceConfiguration, DeviceInformation
|
||||
from gardena_bluetooth.const import AquaContour, DeviceConfiguration, DeviceInformation
|
||||
from gardena_bluetooth.exceptions import (
|
||||
CharacteristicNoAccess,
|
||||
CharacteristicNotFound,
|
||||
@@ -35,6 +35,7 @@ PLATFORMS: list[Platform] = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.BUTTON,
|
||||
Platform.NUMBER,
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
Platform.VALVE,
|
||||
@@ -90,8 +91,10 @@ async def async_setup_entry(
|
||||
|
||||
name = entry.title
|
||||
name = await client.read_char(DeviceConfiguration.custom_device_name, name)
|
||||
name = await client.read_char(AquaContour.custom_device_name, name)
|
||||
|
||||
await _update_timestamp(client, DeviceConfiguration.unix_timestamp)
|
||||
await _update_timestamp(client, AquaContour.unix_timestamp)
|
||||
|
||||
except (TimeoutError, CommunicationFailure, DeviceUnavailable) as exception:
|
||||
await client.disconnect()
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from gardena_bluetooth.const import Sensor, Valve
|
||||
from gardena_bluetooth.const import AquaContour, Sensor, Valve
|
||||
from gardena_bluetooth.parse import CharacteristicBool
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
@@ -47,6 +47,13 @@ DESCRIPTIONS = (
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
char=Sensor.connected_state,
|
||||
),
|
||||
GardenaBluetoothBinarySensorEntityDescription(
|
||||
key=AquaContour.frost_warning.unique_id,
|
||||
translation_key="frost_warning",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
char=AquaContour.frost_warning,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ def _is_supported(discovery_info: BluetoothServiceInfo):
|
||||
ProductType.WATER_COMPUTER,
|
||||
ProductType.AUTOMATS,
|
||||
ProductType.PRESSURE_TANKS,
|
||||
ProductType.AQUA_CONTOURS,
|
||||
):
|
||||
_LOGGER.debug("Unsupported device: %s", manufacturer_data)
|
||||
return False
|
||||
@@ -70,6 +71,7 @@ class GardenaBluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
async def async_read_data(self):
|
||||
"""Try to connect to device and extract information."""
|
||||
assert self.address
|
||||
client = Client(get_connection(self.hass, self.address))
|
||||
try:
|
||||
model = await client.read_char(DeviceInformation.model_number)
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from gardena_bluetooth.const import DeviceConfiguration, Sensor, Valve
|
||||
from gardena_bluetooth.const import DeviceConfiguration, Sensor, Spray, Valve
|
||||
from gardena_bluetooth.parse import (
|
||||
Characteristic,
|
||||
CharacteristicInt,
|
||||
@@ -18,7 +18,7 @@ from homeassistant.components.number import (
|
||||
NumberEntityDescription,
|
||||
NumberMode,
|
||||
)
|
||||
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime
|
||||
from homeassistant.const import DEGREE, PERCENTAGE, EntityCategory, UnitOfTime
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
@@ -34,6 +34,7 @@ class GardenaBluetoothNumberEntityDescription(NumberEntityDescription):
|
||||
default_factory=lambda: CharacteristicInt("")
|
||||
)
|
||||
connected_state: Characteristic | None = None
|
||||
scale: float = 1.0
|
||||
|
||||
@property
|
||||
def context(self) -> set[str]:
|
||||
@@ -104,6 +105,27 @@ DESCRIPTIONS = (
|
||||
char=Sensor.threshold,
|
||||
connected_state=Sensor.connected_state,
|
||||
),
|
||||
GardenaBluetoothNumberEntityDescription(
|
||||
key="spray_sector",
|
||||
translation_key="spray_sector",
|
||||
native_unit_of_measurement=DEGREE,
|
||||
mode=NumberMode.BOX,
|
||||
native_min_value=0.0,
|
||||
native_max_value=359.0,
|
||||
native_step=1.0,
|
||||
char=Spray.sector,
|
||||
),
|
||||
GardenaBluetoothNumberEntityDescription(
|
||||
key="spray_distance",
|
||||
translation_key="spray_distance",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
mode=NumberMode.SLIDER,
|
||||
native_min_value=0.0,
|
||||
native_max_value=100.0,
|
||||
native_step=0.1,
|
||||
char=Spray.distance,
|
||||
scale=10.0,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -134,7 +156,7 @@ class GardenaBluetoothNumber(GardenaBluetoothDescriptorEntity, NumberEntity):
|
||||
if data is None:
|
||||
self._attr_native_value = None
|
||||
else:
|
||||
self._attr_native_value = float(data)
|
||||
self._attr_native_value = float(data) / self.entity_description.scale
|
||||
|
||||
if char := self.entity_description.connected_state:
|
||||
self._attr_available = bool(self.coordinator.get_cached(char))
|
||||
@@ -145,7 +167,9 @@ class GardenaBluetoothNumber(GardenaBluetoothDescriptorEntity, NumberEntity):
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""Set new value."""
|
||||
await self.coordinator.write(self.entity_description.char, int(value))
|
||||
await self.coordinator.write(
|
||||
self.entity_description.char, int(value * self.entity_description.scale)
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
|
||||
113
homeassistant/components/gardena_bluetooth/select.py
Normal file
113
homeassistant/components/gardena_bluetooth/select.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""Support for select entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import IntEnum
|
||||
|
||||
from gardena_bluetooth.const import (
|
||||
AquaContour,
|
||||
AquaContourPosition,
|
||||
AquaContourWatering,
|
||||
)
|
||||
from gardena_bluetooth.parse import CharacteristicInt
|
||||
|
||||
from homeassistant.components.select import SelectEntity, SelectEntityDescription
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import GardenaBluetoothConfigEntry
|
||||
from .entity import GardenaBluetoothDescriptorEntity
|
||||
|
||||
|
||||
def _enum_to_int(enum: type[IntEnum]) -> dict[str, int]:
|
||||
return {member.name.lower(): member.value for member in enum}
|
||||
|
||||
|
||||
def _reverse_dict(value: dict[str, int]) -> dict[int, str]:
|
||||
return {value: key for key, value in value.items()}
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class GardenaBluetoothSelectEntityDescription(SelectEntityDescription):
|
||||
"""Description of entity."""
|
||||
|
||||
key: str = field(init=False)
|
||||
char: CharacteristicInt
|
||||
option_to_number: dict[str, int]
|
||||
number_to_option: dict[int, str] = field(init=False)
|
||||
|
||||
def __post_init__(self):
|
||||
"""Initialize calculated fields."""
|
||||
object.__setattr__(self, "key", self.char.unique_id)
|
||||
object.__setattr__(self, "options", list(self.option_to_number.keys()))
|
||||
object.__setattr__(
|
||||
self, "number_to_option", _reverse_dict(self.option_to_number)
|
||||
)
|
||||
|
||||
@property
|
||||
def context(self) -> set[str]:
|
||||
"""Context needed for update coordinator."""
|
||||
return {self.char.uuid}
|
||||
|
||||
|
||||
DESCRIPTIONS = (
|
||||
GardenaBluetoothSelectEntityDescription(
|
||||
translation_key="watering_active",
|
||||
char=AquaContourWatering.watering_active,
|
||||
option_to_number=_enum_to_int(AquaContourWatering.watering_active.enum),
|
||||
),
|
||||
GardenaBluetoothSelectEntityDescription(
|
||||
translation_key="operation_mode",
|
||||
char=AquaContour.operation_mode,
|
||||
option_to_number=_enum_to_int(AquaContour.operation_mode.enum),
|
||||
),
|
||||
GardenaBluetoothSelectEntityDescription(
|
||||
translation_key="active_position",
|
||||
char=AquaContourPosition.active_position,
|
||||
option_to_number={
|
||||
"position_1": 1,
|
||||
"position_2": 2,
|
||||
"position_3": 3,
|
||||
"position_4": 4,
|
||||
"position_5": 5,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: GardenaBluetoothConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up select based on a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
entities = [
|
||||
GardenaBluetoothSelectEntity(coordinator, description, description.context)
|
||||
for description in DESCRIPTIONS
|
||||
if description.char.unique_id in coordinator.characteristics
|
||||
]
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class GardenaBluetoothSelectEntity(GardenaBluetoothDescriptorEntity, SelectEntity):
|
||||
"""Representation of a select entity."""
|
||||
|
||||
entity_description: GardenaBluetoothSelectEntityDescription
|
||||
|
||||
@property
|
||||
def current_option(self) -> str | None:
|
||||
"""Return the selected entity option to represent the entity state."""
|
||||
char = self.entity_description.char
|
||||
value = self.coordinator.get_cached(char)
|
||||
if value is None:
|
||||
return None
|
||||
return self.entity_description.number_to_option.get(value)
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Change the selected option."""
|
||||
char = self.entity_description.char
|
||||
value = self.entity_description.option_to_number[option]
|
||||
await self.coordinator.write(char, value)
|
||||
self.async_write_ha_state()
|
||||
@@ -2,10 +2,19 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from gardena_bluetooth.const import Battery, Sensor, Valve
|
||||
from gardena_bluetooth.const import (
|
||||
AquaContourBattery,
|
||||
Battery,
|
||||
EventHistory,
|
||||
FlowStatistics,
|
||||
Sensor,
|
||||
Spray,
|
||||
Valve,
|
||||
)
|
||||
from gardena_bluetooth.parse import Characteristic
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
@@ -13,8 +22,15 @@ from homeassistant.components.sensor import (
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
StateType,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
DEGREE,
|
||||
PERCENTAGE,
|
||||
EntityCategory,
|
||||
UnitOfVolume,
|
||||
UnitOfVolumeFlowRate,
|
||||
)
|
||||
from homeassistant.const import PERCENTAGE, EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util import dt as dt_util
|
||||
@@ -22,13 +38,28 @@ from homeassistant.util import dt as dt_util
|
||||
from .coordinator import GardenaBluetoothConfigEntry, GardenaBluetoothCoordinator
|
||||
from .entity import GardenaBluetoothDescriptorEntity, GardenaBluetoothEntity
|
||||
|
||||
type SensorRawType = StateType | datetime
|
||||
|
||||
|
||||
def _get_timestamp(value: datetime | None):
|
||||
if value is None:
|
||||
return None
|
||||
return value.replace(tzinfo=dt_util.get_default_time_zone())
|
||||
|
||||
|
||||
def _get_distance_ratio(value: int | None):
|
||||
if value is None:
|
||||
return None
|
||||
return value / 1000
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GardenaBluetoothSensorEntityDescription(SensorEntityDescription):
|
||||
class GardenaBluetoothSensorEntityDescription[T](SensorEntityDescription):
|
||||
"""Description of entity."""
|
||||
|
||||
char: Characteristic = field(default_factory=lambda: Characteristic(""))
|
||||
char: Characteristic[T] = field(default_factory=lambda: Characteristic(""))
|
||||
connected_state: Characteristic | None = None
|
||||
get: Callable[[T | None], SensorRawType] = lambda x: x # type: ignore[assignment, return-value]
|
||||
|
||||
@property
|
||||
def context(self) -> set[str]:
|
||||
@@ -56,6 +87,14 @@ DESCRIPTIONS = (
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
char=Battery.battery_level,
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=AquaContourBattery.battery_level.unique_id,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
char=AquaContourBattery.battery_level,
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=Sensor.battery_level.unique_id,
|
||||
translation_key="sensor_battery_level",
|
||||
@@ -88,6 +127,78 @@ DESCRIPTIONS = (
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
char=Sensor.measurement_timestamp,
|
||||
connected_state=Sensor.connected_state,
|
||||
get=_get_timestamp,
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=FlowStatistics.overall.unique_id,
|
||||
translation_key="flow_statistics_overall",
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
device_class=SensorDeviceClass.VOLUME,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
native_unit_of_measurement=UnitOfVolume.LITERS,
|
||||
char=FlowStatistics.overall,
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=FlowStatistics.current.unique_id,
|
||||
translation_key="flow_statistics_current",
|
||||
device_class=SensorDeviceClass.VOLUME_FLOW_RATE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
native_unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_MINUTE,
|
||||
char=FlowStatistics.current,
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=FlowStatistics.resettable.unique_id,
|
||||
translation_key="flow_statistics_resettable",
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
device_class=SensorDeviceClass.VOLUME,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
native_unit_of_measurement=UnitOfVolume.LITERS,
|
||||
char=FlowStatistics.resettable,
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=FlowStatistics.last_reset.unique_id,
|
||||
translation_key="flow_statistics_reset_timestamp",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
char=FlowStatistics.last_reset,
|
||||
get=_get_timestamp,
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=Spray.current_distance.unique_id,
|
||||
translation_key="spray_current_distance",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
char=Spray.current_distance,
|
||||
get=_get_distance_ratio,
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key=Spray.current_sector.unique_id,
|
||||
translation_key="spray_current_sector",
|
||||
state_class=SensorStateClass.MEASUREMENT_ANGLE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
native_unit_of_measurement=DEGREE,
|
||||
char=Spray.current_sector,
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key="aqua_contour_error",
|
||||
translation_key="aqua_contour_error",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
char=EventHistory.error,
|
||||
get=lambda x: (
|
||||
x.error_code.name.lower()
|
||||
if x and isinstance(x.error_code, EventHistory.error.enum)
|
||||
else None
|
||||
),
|
||||
options=[member.name.lower() for member in EventHistory.error.enum],
|
||||
),
|
||||
GardenaBluetoothSensorEntityDescription(
|
||||
key="aqua_contour_error_timestamp",
|
||||
translation_key="error_timestamp",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
char=EventHistory.error,
|
||||
get=lambda x: _get_timestamp(x.time_stamp) if x else None,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -116,8 +227,7 @@ class GardenaBluetoothSensor(GardenaBluetoothDescriptorEntity, SensorEntity):
|
||||
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
value = self.coordinator.get_cached(self.entity_description.char)
|
||||
if isinstance(value, datetime):
|
||||
value = value.replace(tzinfo=dt_util.get_default_time_zone())
|
||||
value = self.entity_description.get(value)
|
||||
self._attr_native_value = value
|
||||
|
||||
if char := self.entity_description.connected_state:
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"frost_warning": {
|
||||
"name": "Frost"
|
||||
},
|
||||
"sensor_connected_state": {
|
||||
"name": "Sensor connection"
|
||||
},
|
||||
@@ -52,12 +55,79 @@
|
||||
},
|
||||
"sensor_threshold": {
|
||||
"name": "Sensor threshold"
|
||||
},
|
||||
"spray_distance": {
|
||||
"name": "Distance"
|
||||
},
|
||||
"spray_sector": {
|
||||
"name": "Sector"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"active_position": {
|
||||
"name": "Active position",
|
||||
"state": {
|
||||
"position_1": "Position 1",
|
||||
"position_2": "Position 2",
|
||||
"position_3": "Position 3",
|
||||
"position_4": "Position 4",
|
||||
"position_5": "Position 5"
|
||||
}
|
||||
},
|
||||
"operation_mode": {
|
||||
"name": "Operation mode",
|
||||
"state": {
|
||||
"active": "Active",
|
||||
"deep_sleep": "Deep sleep",
|
||||
"manual_mode": "Manual",
|
||||
"pre_winter": "Winter preparation"
|
||||
}
|
||||
},
|
||||
"watering_active": {
|
||||
"name": "Watering",
|
||||
"state": {
|
||||
"contour_1": "Contour 1",
|
||||
"contour_2": "Contour 2",
|
||||
"contour_3": "Contour 3",
|
||||
"contour_4": "Contour 4",
|
||||
"contour_5": "Contour 5",
|
||||
"preview": "Preview",
|
||||
"rest": "Idle",
|
||||
"setup_mode": "Setup"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"activation_reason": {
|
||||
"name": "Activation reason"
|
||||
},
|
||||
"aqua_contour_error": {
|
||||
"name": "Error",
|
||||
"state": {
|
||||
"charger_error": "Charger error",
|
||||
"flash_error": "Flash error",
|
||||
"no_error": "No error detected",
|
||||
"no_water": "Not enough water",
|
||||
"rotation_sensor_error": "Rotation sensor error",
|
||||
"sprinkler_motor_error": "Sprinkler motor error",
|
||||
"valve_motor_error": "Valve motor error"
|
||||
}
|
||||
},
|
||||
"error_timestamp": {
|
||||
"name": "Error timestamp"
|
||||
},
|
||||
"flow_statistics_current": {
|
||||
"name": "Current flow"
|
||||
},
|
||||
"flow_statistics_overall": {
|
||||
"name": "Overall flow"
|
||||
},
|
||||
"flow_statistics_reset_timestamp": {
|
||||
"name": "Flow reset timestamp"
|
||||
},
|
||||
"flow_statistics_resettable": {
|
||||
"name": "Flow since reset"
|
||||
},
|
||||
"remaining_open_timestamp": {
|
||||
"name": "Valve closing"
|
||||
},
|
||||
@@ -69,6 +139,12 @@
|
||||
},
|
||||
"sensor_type": {
|
||||
"name": "Sensor type"
|
||||
},
|
||||
"spray_current_distance": {
|
||||
"name": "Current distance"
|
||||
},
|
||||
"spray_current_sector": {
|
||||
"name": "Current sector"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["googleapiclient"],
|
||||
"requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==13.2.0"]
|
||||
"requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==13.2.2"]
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ from homeassistant.helpers import (
|
||||
area_registry as ar,
|
||||
device_registry as dr,
|
||||
entity_registry as er,
|
||||
intent,
|
||||
start,
|
||||
)
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
@@ -597,7 +598,6 @@ class GoogleEntity:
|
||||
state = self.state
|
||||
traits = self.traits()
|
||||
entity_config = self.config.entity_config.get(state.entity_id, {})
|
||||
name = (entity_config.get(CONF_NAME) or state.name).strip()
|
||||
|
||||
# Find entity/device/area registry entries
|
||||
entity_entry, device_entry, area_entry = _get_registry_entries(
|
||||
@@ -607,7 +607,6 @@ class GoogleEntity:
|
||||
# Build the device info
|
||||
device = {
|
||||
"id": state.entity_id,
|
||||
"name": {"name": name},
|
||||
"attributes": {},
|
||||
"traits": [trait.name for trait in traits],
|
||||
"willReportState": self.config.should_report_state,
|
||||
@@ -615,13 +614,18 @@ class GoogleEntity:
|
||||
state.domain, state.attributes.get(ATTR_DEVICE_CLASS)
|
||||
),
|
||||
}
|
||||
# Add aliases
|
||||
if (config_aliases := entity_config.get(CONF_ALIASES, [])) or (
|
||||
entity_entry and entity_entry.aliases
|
||||
):
|
||||
device["name"]["nicknames"] = [name, *config_aliases]
|
||||
if entity_entry:
|
||||
device["name"]["nicknames"].extend(entity_entry.aliases)
|
||||
# Add name and aliases.
|
||||
# The entity's alias list is ordered: the first slot naturally serves
|
||||
# as the primary name (set to the auto-generated full entity name by
|
||||
# default), while the rest serve as alternative names (nicknames).
|
||||
aliases = intent.async_get_entity_aliases(
|
||||
self.hass, entity_entry, state=state, allow_empty=False
|
||||
)
|
||||
name, *aliases = aliases
|
||||
name = entity_config.get(CONF_NAME) or name
|
||||
device["name"] = {"name": name}
|
||||
if (config_aliases := entity_config.get(CONF_ALIASES, [])) or aliases:
|
||||
device["name"]["nicknames"] = [name, *config_aliases, *aliases]
|
||||
|
||||
# Add local SDK info if enabled
|
||||
if self.config.is_local_sdk_active and self.should_expose_local():
|
||||
|
||||
@@ -239,6 +239,9 @@ def _login_classic_api(
|
||||
return login_response
|
||||
|
||||
|
||||
V1_DEVICE_TYPES: dict[int, str] = {5: "sph", 7: "min"}
|
||||
|
||||
|
||||
def get_device_list_v1(
|
||||
api, config: Mapping[str, str]
|
||||
) -> tuple[list[dict[str, str]], str]:
|
||||
@@ -260,18 +263,17 @@ def get_device_list_v1(
|
||||
f"API error during device list: {e.error_msg or str(e)} (Code: {e.error_code})"
|
||||
) from e
|
||||
devices = devices_dict.get("devices", [])
|
||||
# Only MIN device (type = 7) support implemented in current V1 API
|
||||
supported_devices = [
|
||||
{
|
||||
"deviceSn": device.get("device_sn", ""),
|
||||
"deviceType": "min",
|
||||
"deviceType": V1_DEVICE_TYPES[device.get("type")],
|
||||
}
|
||||
for device in devices
|
||||
if device.get("type") == 7
|
||||
if device.get("type") in V1_DEVICE_TYPES
|
||||
]
|
||||
|
||||
for device in devices:
|
||||
if device.get("type") != 7:
|
||||
if device.get("type") not in V1_DEVICE_TYPES:
|
||||
_LOGGER.warning(
|
||||
"Device %s with type %s not supported in Open API V1, skipping",
|
||||
device.get("device_sn", ""),
|
||||
@@ -348,7 +350,7 @@ async def async_setup_entry(
|
||||
hass, config_entry, device["deviceSn"], device["deviceType"], plant_id
|
||||
)
|
||||
for device in devices
|
||||
if device["deviceType"] in ["inverter", "tlx", "storage", "mix", "min"]
|
||||
if device["deviceType"] in ["inverter", "tlx", "storage", "mix", "min", "sph"]
|
||||
}
|
||||
|
||||
# Perform the first refresh for the total coordinator
|
||||
|
||||
@@ -167,6 +167,36 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
**storage_info_detail["storageDetailBean"],
|
||||
**storage_energy_overview,
|
||||
}
|
||||
elif self.device_type == "sph":
|
||||
try:
|
||||
sph_detail = self.api.sph_detail(self.device_id)
|
||||
sph_energy = self.api.sph_energy(self.device_id)
|
||||
except growattServer.GrowattV1ApiError as err:
|
||||
if err.error_code == V1_API_ERROR_NO_PRIVILEGE:
|
||||
raise ConfigEntryAuthFailed(
|
||||
f"Authentication failed for Growatt API: {err.error_msg or str(err)}"
|
||||
) from err
|
||||
raise UpdateFailed(f"Error fetching SPH device data: {err}") from err
|
||||
|
||||
combined = {**sph_detail, **sph_energy}
|
||||
|
||||
# Parse last update timestamp from sph_energy "time" field
|
||||
time_str = sph_energy.get("time")
|
||||
if time_str:
|
||||
try:
|
||||
parsed = datetime.datetime.strptime(time_str, "%Y-%m-%d %H:%M:%S")
|
||||
combined["lastdataupdate"] = parsed.replace(
|
||||
tzinfo=dt_util.get_default_time_zone()
|
||||
)
|
||||
except ValueError, TypeError:
|
||||
_LOGGER.debug(
|
||||
"Could not parse SPH time field for %s: %r",
|
||||
self.device_id,
|
||||
time_str,
|
||||
)
|
||||
|
||||
self.data = combined
|
||||
_LOGGER.debug("sph_info for device %s: %r", self.device_id, self.data)
|
||||
elif self.device_type == "mix":
|
||||
mix_info = self.api.mix_info(self.device_id)
|
||||
mix_totals = self.api.mix_totals(self.device_id, self.plant_id)
|
||||
@@ -448,3 +478,123 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
return "00:00"
|
||||
else:
|
||||
return f"{hour:02d}:{minute:02d}"
|
||||
|
||||
async def update_ac_charge_times(
|
||||
self,
|
||||
charge_power: int,
|
||||
charge_stop_soc: int,
|
||||
mains_enabled: bool,
|
||||
periods: list[dict],
|
||||
) -> None:
|
||||
"""Update AC charge time periods for SPH device.
|
||||
|
||||
Args:
|
||||
charge_power: Charge power limit (0-100 %)
|
||||
charge_stop_soc: Stop charging at this SOC level (0-100 %)
|
||||
mains_enabled: Whether AC (mains) charging is enabled
|
||||
periods: List of up to 3 dicts with keys start_time, end_time, enabled
|
||||
"""
|
||||
if self.api_version != "v1":
|
||||
raise ServiceValidationError(
|
||||
"Updating AC charge times requires token authentication"
|
||||
)
|
||||
|
||||
try:
|
||||
await self.hass.async_add_executor_job(
|
||||
self.api.sph_write_ac_charge_times,
|
||||
self.device_id,
|
||||
charge_power,
|
||||
charge_stop_soc,
|
||||
mains_enabled,
|
||||
periods,
|
||||
)
|
||||
except growattServer.GrowattV1ApiError as err:
|
||||
raise HomeAssistantError(
|
||||
f"API error updating AC charge times: {err}"
|
||||
) from err
|
||||
|
||||
if self.data:
|
||||
self.data["chargePowerCommand"] = charge_power
|
||||
self.data["wchargeSOCLowLimit"] = charge_stop_soc
|
||||
self.data["acChargeEnable"] = 1 if mains_enabled else 0
|
||||
for i, period in enumerate(periods, 1):
|
||||
self.data[f"forcedChargeTimeStart{i}"] = period["start_time"].strftime(
|
||||
"%H:%M"
|
||||
)
|
||||
self.data[f"forcedChargeTimeStop{i}"] = period["end_time"].strftime(
|
||||
"%H:%M"
|
||||
)
|
||||
self.data[f"forcedChargeStopSwitch{i}"] = (
|
||||
1 if period.get("enabled", False) else 0
|
||||
)
|
||||
self.async_set_updated_data(self.data)
|
||||
|
||||
async def update_ac_discharge_times(
|
||||
self,
|
||||
discharge_power: int,
|
||||
discharge_stop_soc: int,
|
||||
periods: list[dict],
|
||||
) -> None:
|
||||
"""Update AC discharge time periods for SPH device.
|
||||
|
||||
Args:
|
||||
discharge_power: Discharge power limit (0-100 %)
|
||||
discharge_stop_soc: Stop discharging at this SOC level (0-100 %)
|
||||
periods: List of up to 3 dicts with keys start_time, end_time, enabled
|
||||
"""
|
||||
if self.api_version != "v1":
|
||||
raise ServiceValidationError(
|
||||
"Updating AC discharge times requires token authentication"
|
||||
)
|
||||
|
||||
try:
|
||||
await self.hass.async_add_executor_job(
|
||||
self.api.sph_write_ac_discharge_times,
|
||||
self.device_id,
|
||||
discharge_power,
|
||||
discharge_stop_soc,
|
||||
periods,
|
||||
)
|
||||
except growattServer.GrowattV1ApiError as err:
|
||||
raise HomeAssistantError(
|
||||
f"API error updating AC discharge times: {err}"
|
||||
) from err
|
||||
|
||||
if self.data:
|
||||
self.data["disChargePowerCommand"] = discharge_power
|
||||
self.data["wdisChargeSOCLowLimit"] = discharge_stop_soc
|
||||
for i, period in enumerate(periods, 1):
|
||||
self.data[f"forcedDischargeTimeStart{i}"] = period[
|
||||
"start_time"
|
||||
].strftime("%H:%M")
|
||||
self.data[f"forcedDischargeTimeStop{i}"] = period["end_time"].strftime(
|
||||
"%H:%M"
|
||||
)
|
||||
self.data[f"forcedDischargeStopSwitch{i}"] = (
|
||||
1 if period.get("enabled", False) else 0
|
||||
)
|
||||
self.async_set_updated_data(self.data)
|
||||
|
||||
async def read_ac_charge_times(self) -> dict:
|
||||
"""Read AC charge time settings from SPH device cache."""
|
||||
if self.api_version != "v1":
|
||||
raise ServiceValidationError(
|
||||
"Reading AC charge times requires token authentication"
|
||||
)
|
||||
|
||||
if not self.data:
|
||||
await self.async_refresh()
|
||||
|
||||
return self.api.sph_read_ac_charge_times(settings_data=self.data)
|
||||
|
||||
async def read_ac_discharge_times(self) -> dict:
|
||||
"""Read AC discharge time settings from SPH device cache."""
|
||||
if self.api_version != "v1":
|
||||
raise ServiceValidationError(
|
||||
"Reading AC discharge times requires token authentication"
|
||||
)
|
||||
|
||||
if not self.data:
|
||||
await self.async_refresh()
|
||||
|
||||
return self.api.sph_read_ac_discharge_times(settings_data=self.data)
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
{
|
||||
"services": {
|
||||
"read_ac_charge_times": {
|
||||
"service": "mdi:battery-clock-outline"
|
||||
},
|
||||
"read_ac_discharge_times": {
|
||||
"service": "mdi:battery-clock-outline"
|
||||
},
|
||||
"read_time_segments": {
|
||||
"service": "mdi:clock-outline"
|
||||
},
|
||||
"update_time_segment": {
|
||||
"service": "mdi:clock-edit"
|
||||
},
|
||||
"write_ac_charge_times": {
|
||||
"service": "mdi:battery-clock"
|
||||
},
|
||||
"write_ac_discharge_times": {
|
||||
"service": "mdi:battery-clock"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,5 +7,6 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["growattServer"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["growattServer==1.9.0"]
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ from ..coordinator import GrowattConfigEntry, GrowattCoordinator
|
||||
from .inverter import INVERTER_SENSOR_TYPES
|
||||
from .mix import MIX_SENSOR_TYPES
|
||||
from .sensor_entity_description import GrowattSensorEntityDescription
|
||||
from .sph import SPH_SENSOR_TYPES
|
||||
from .storage import STORAGE_SENSOR_TYPES
|
||||
from .tlx import TLX_SENSOR_TYPES
|
||||
from .total import TOTAL_SENSOR_TYPES
|
||||
@@ -57,6 +58,8 @@ async def async_setup_entry(
|
||||
sensor_descriptions = list(STORAGE_SENSOR_TYPES)
|
||||
elif device_coordinator.device_type == "mix":
|
||||
sensor_descriptions = list(MIX_SENSOR_TYPES)
|
||||
elif device_coordinator.device_type == "sph":
|
||||
sensor_descriptions = list(SPH_SENSOR_TYPES)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"Device type %s was found but is not supported right now",
|
||||
|
||||
291
homeassistant/components/growatt_server/sensor/sph.py
Normal file
291
homeassistant/components/growatt_server/sensor/sph.py
Normal file
@@ -0,0 +1,291 @@
|
||||
"""Growatt Sensor definitions for the SPH type."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
|
||||
from homeassistant.const import (
|
||||
PERCENTAGE,
|
||||
UnitOfElectricPotential,
|
||||
UnitOfEnergy,
|
||||
UnitOfFrequency,
|
||||
UnitOfPower,
|
||||
UnitOfTemperature,
|
||||
)
|
||||
|
||||
from .sensor_entity_description import GrowattSensorEntityDescription
|
||||
|
||||
SPH_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
# Values from 'sph_detail' API call
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_statement_of_charge",
|
||||
translation_key="mix_statement_of_charge",
|
||||
api_key="bmsSOC",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_battery_voltage",
|
||||
translation_key="mix_battery_voltage",
|
||||
api_key="vbat",
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_pv1_voltage",
|
||||
translation_key="mix_pv1_voltage",
|
||||
api_key="vpv1",
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_pv2_voltage",
|
||||
translation_key="mix_pv2_voltage",
|
||||
api_key="vpv2",
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_grid_voltage",
|
||||
translation_key="mix_grid_voltage",
|
||||
api_key="vac1",
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_battery_charge",
|
||||
translation_key="mix_battery_charge",
|
||||
api_key="pcharge1",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_battery_discharge_w",
|
||||
translation_key="mix_battery_discharge_w",
|
||||
api_key="pdischarge1",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_export_to_grid",
|
||||
translation_key="mix_export_to_grid",
|
||||
api_key="pacToGridTotal",
|
||||
native_unit_of_measurement=UnitOfPower.KILO_WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_import_from_grid",
|
||||
translation_key="mix_import_from_grid",
|
||||
api_key="pacToUserR",
|
||||
native_unit_of_measurement=UnitOfPower.KILO_WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="sph_grid_frequency",
|
||||
translation_key="sph_grid_frequency",
|
||||
api_key="fac",
|
||||
native_unit_of_measurement=UnitOfFrequency.HERTZ,
|
||||
device_class=SensorDeviceClass.FREQUENCY,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="sph_temperature_1",
|
||||
translation_key="sph_temperature_1",
|
||||
api_key="temp1",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="sph_temperature_2",
|
||||
translation_key="sph_temperature_2",
|
||||
api_key="temp2",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="sph_temperature_3",
|
||||
translation_key="sph_temperature_3",
|
||||
api_key="temp3",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="sph_temperature_4",
|
||||
translation_key="sph_temperature_4",
|
||||
api_key="temp4",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="sph_temperature_5",
|
||||
translation_key="sph_temperature_5",
|
||||
api_key="temp5",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
# Values from 'sph_energy' API call
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_wattage_pv_1",
|
||||
translation_key="mix_wattage_pv_1",
|
||||
api_key="ppv1",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_wattage_pv_2",
|
||||
translation_key="mix_wattage_pv_2",
|
||||
api_key="ppv2",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_wattage_pv_all",
|
||||
translation_key="mix_wattage_pv_all",
|
||||
api_key="ppv",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_battery_charge_today",
|
||||
translation_key="mix_battery_charge_today",
|
||||
api_key="echarge1Today",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_battery_charge_lifetime",
|
||||
translation_key="mix_battery_charge_lifetime",
|
||||
api_key="echarge1Total",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
never_resets=True,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_battery_discharge_today",
|
||||
translation_key="mix_battery_discharge_today",
|
||||
api_key="edischarge1Today",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_battery_discharge_lifetime",
|
||||
translation_key="mix_battery_discharge_lifetime",
|
||||
api_key="edischarge1Total",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
never_resets=True,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_solar_generation_today",
|
||||
translation_key="mix_solar_generation_today",
|
||||
api_key="epvtoday",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_solar_generation_lifetime",
|
||||
translation_key="mix_solar_generation_lifetime",
|
||||
api_key="epvTotal",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
never_resets=True,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_system_production_today",
|
||||
translation_key="mix_system_production_today",
|
||||
api_key="esystemtoday",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_self_consumption_today",
|
||||
translation_key="mix_self_consumption_today",
|
||||
api_key="eselfToday",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_import_from_grid_today",
|
||||
translation_key="mix_import_from_grid_today",
|
||||
api_key="etoUserToday",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_export_to_grid_today",
|
||||
translation_key="mix_export_to_grid_today",
|
||||
api_key="etoGridToday",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_export_to_grid_lifetime",
|
||||
translation_key="mix_export_to_grid_lifetime",
|
||||
api_key="etogridTotal",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
never_resets=True,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_load_consumption_today",
|
||||
translation_key="mix_load_consumption_today",
|
||||
api_key="elocalLoadToday",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_load_consumption_lifetime",
|
||||
translation_key="mix_load_consumption_lifetime",
|
||||
api_key="elocalLoadTotal",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
never_resets=True,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_load_consumption_battery_today",
|
||||
translation_key="mix_load_consumption_battery_today",
|
||||
api_key="echarge1",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_load_consumption_solar_today",
|
||||
translation_key="mix_load_consumption_solar_today",
|
||||
api_key="eChargeToday",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
# Synthetic timestamp from 'time' field in sph_energy response
|
||||
GrowattSensorEntityDescription(
|
||||
key="mix_last_update",
|
||||
translation_key="mix_last_update",
|
||||
api_key="lastdataupdate",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
),
|
||||
)
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import datetime, time
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
@@ -21,67 +21,77 @@ if TYPE_CHECKING:
|
||||
from .coordinator import GrowattCoordinator
|
||||
|
||||
|
||||
def _get_coordinators(
|
||||
hass: HomeAssistant, device_type: str
|
||||
) -> dict[str, GrowattCoordinator]:
|
||||
"""Get all coordinators of a given device type with V1 API."""
|
||||
coordinators: dict[str, GrowattCoordinator] = {}
|
||||
|
||||
for entry in hass.config_entries.async_entries(DOMAIN):
|
||||
if entry.state != ConfigEntryState.LOADED:
|
||||
continue
|
||||
|
||||
for coord in entry.runtime_data.devices.values():
|
||||
if coord.device_type == device_type and coord.api_version == "v1":
|
||||
coordinators[coord.device_id] = coord
|
||||
|
||||
return coordinators
|
||||
|
||||
|
||||
def _get_coordinator(
|
||||
hass: HomeAssistant, device_id: str, device_type: str
|
||||
) -> GrowattCoordinator:
|
||||
"""Get coordinator by device registry ID and device type."""
|
||||
coordinators = _get_coordinators(hass, device_type)
|
||||
|
||||
if not coordinators:
|
||||
raise ServiceValidationError(
|
||||
f"No {device_type.upper()} devices with token authentication are configured. "
|
||||
f"Services require {device_type.upper()} devices with V1 API access."
|
||||
)
|
||||
|
||||
device_registry = dr.async_get(hass)
|
||||
device_entry = device_registry.async_get(device_id)
|
||||
|
||||
if not device_entry:
|
||||
raise ServiceValidationError(f"Device '{device_id}' not found")
|
||||
|
||||
serial_number = None
|
||||
for identifier in device_entry.identifiers:
|
||||
if identifier[0] == DOMAIN:
|
||||
serial_number = identifier[1]
|
||||
break
|
||||
|
||||
if not serial_number:
|
||||
raise ServiceValidationError(f"Device '{device_id}' is not a Growatt device")
|
||||
|
||||
if serial_number not in coordinators:
|
||||
raise ServiceValidationError(
|
||||
f"{device_type.upper()} device '{serial_number}' not found or not configured for services"
|
||||
)
|
||||
|
||||
return coordinators[serial_number]
|
||||
|
||||
|
||||
def _parse_time_str(time_str: str, field_name: str) -> time:
|
||||
"""Parse a time string (HH:MM or HH:MM:SS) to a datetime.time object."""
|
||||
parts = time_str.split(":")
|
||||
if len(parts) not in (2, 3):
|
||||
raise ServiceValidationError(
|
||||
f"{field_name} must be in HH:MM or HH:MM:SS format"
|
||||
)
|
||||
try:
|
||||
return datetime.strptime(f"{parts[0]}:{parts[1]}", "%H:%M").time()
|
||||
except (ValueError, IndexError) as err:
|
||||
raise ServiceValidationError(
|
||||
f"{field_name} must be in HH:MM or HH:MM:SS format"
|
||||
) from err
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Register services for Growatt Server integration."""
|
||||
|
||||
def get_min_coordinators() -> dict[str, GrowattCoordinator]:
|
||||
"""Get all MIN coordinators with V1 API from loaded config entries."""
|
||||
min_coordinators: dict[str, GrowattCoordinator] = {}
|
||||
|
||||
for entry in hass.config_entries.async_entries(DOMAIN):
|
||||
if entry.state != ConfigEntryState.LOADED:
|
||||
continue
|
||||
|
||||
# Add MIN coordinators from this entry
|
||||
for coord in entry.runtime_data.devices.values():
|
||||
if coord.device_type == "min" and coord.api_version == "v1":
|
||||
min_coordinators[coord.device_id] = coord
|
||||
|
||||
return min_coordinators
|
||||
|
||||
def get_coordinator(device_id: str) -> GrowattCoordinator:
|
||||
"""Get coordinator by device_id.
|
||||
|
||||
Args:
|
||||
device_id: Device registry ID (not serial number)
|
||||
"""
|
||||
# Get current coordinators (they may have changed since service registration)
|
||||
min_coordinators = get_min_coordinators()
|
||||
|
||||
if not min_coordinators:
|
||||
raise ServiceValidationError(
|
||||
"No MIN devices with token authentication are configured. "
|
||||
"Services require MIN devices with V1 API access."
|
||||
)
|
||||
|
||||
# Device registry ID provided - map to serial number
|
||||
device_registry = dr.async_get(hass)
|
||||
device_entry = device_registry.async_get(device_id)
|
||||
|
||||
if not device_entry:
|
||||
raise ServiceValidationError(f"Device '{device_id}' not found")
|
||||
|
||||
# Extract serial number from device identifiers
|
||||
serial_number = None
|
||||
for identifier in device_entry.identifiers:
|
||||
if identifier[0] == DOMAIN:
|
||||
serial_number = identifier[1]
|
||||
break
|
||||
|
||||
if not serial_number:
|
||||
raise ServiceValidationError(
|
||||
f"Device '{device_id}' is not a Growatt device"
|
||||
)
|
||||
|
||||
# Find coordinator by serial number
|
||||
if serial_number not in min_coordinators:
|
||||
raise ServiceValidationError(
|
||||
f"MIN device '{serial_number}' not found or not configured for services"
|
||||
)
|
||||
|
||||
return min_coordinators[serial_number]
|
||||
|
||||
async def handle_update_time_segment(call: ServiceCall) -> None:
|
||||
"""Handle update_time_segment service call."""
|
||||
segment_id: int = int(call.data["segment_id"])
|
||||
@@ -91,13 +101,11 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
enabled: bool = call.data["enabled"]
|
||||
device_id: str = call.data["device_id"]
|
||||
|
||||
# Validate segment_id range
|
||||
if not 1 <= segment_id <= 9:
|
||||
raise ServiceValidationError(
|
||||
f"segment_id must be between 1 and 9, got {segment_id}"
|
||||
)
|
||||
|
||||
# Validate and convert batt_mode string to integer
|
||||
valid_modes = {
|
||||
"load_first": BATT_MODE_LOAD_FIRST,
|
||||
"battery_first": BATT_MODE_BATTERY_FIRST,
|
||||
@@ -109,50 +117,121 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
)
|
||||
batt_mode: int = valid_modes[batt_mode_str]
|
||||
|
||||
# Convert time strings to datetime.time objects
|
||||
# UI time selector sends HH:MM:SS, but we only need HH:MM (strip seconds)
|
||||
try:
|
||||
# Take only HH:MM part (ignore seconds if present)
|
||||
start_parts = start_time_str.split(":")
|
||||
start_time_hhmm = f"{start_parts[0]}:{start_parts[1]}"
|
||||
start_time = datetime.strptime(start_time_hhmm, "%H:%M").time()
|
||||
except (ValueError, IndexError) as err:
|
||||
raise ServiceValidationError(
|
||||
"start_time must be in HH:MM or HH:MM:SS format"
|
||||
) from err
|
||||
|
||||
try:
|
||||
# Take only HH:MM part (ignore seconds if present)
|
||||
end_parts = end_time_str.split(":")
|
||||
end_time_hhmm = f"{end_parts[0]}:{end_parts[1]}"
|
||||
end_time = datetime.strptime(end_time_hhmm, "%H:%M").time()
|
||||
except (ValueError, IndexError) as err:
|
||||
raise ServiceValidationError(
|
||||
"end_time must be in HH:MM or HH:MM:SS format"
|
||||
) from err
|
||||
|
||||
# Get the appropriate MIN coordinator
|
||||
coordinator: GrowattCoordinator = get_coordinator(device_id)
|
||||
start_time = _parse_time_str(start_time_str, "start_time")
|
||||
end_time = _parse_time_str(end_time_str, "end_time")
|
||||
|
||||
coordinator: GrowattCoordinator = _get_coordinator(hass, device_id, "min")
|
||||
await coordinator.update_time_segment(
|
||||
segment_id,
|
||||
batt_mode,
|
||||
start_time,
|
||||
end_time,
|
||||
enabled,
|
||||
segment_id, batt_mode, start_time, end_time, enabled
|
||||
)
|
||||
|
||||
async def handle_read_time_segments(call: ServiceCall) -> dict[str, Any]:
|
||||
"""Handle read_time_segments service call."""
|
||||
device_id: str = call.data["device_id"]
|
||||
|
||||
# Get the appropriate MIN coordinator
|
||||
coordinator: GrowattCoordinator = get_coordinator(device_id)
|
||||
|
||||
coordinator: GrowattCoordinator = _get_coordinator(
|
||||
hass, call.data["device_id"], "min"
|
||||
)
|
||||
time_segments: list[dict[str, Any]] = await coordinator.read_time_segments()
|
||||
|
||||
return {"time_segments": time_segments}
|
||||
|
||||
async def handle_write_ac_charge_times(call: ServiceCall) -> None:
|
||||
"""Handle write_ac_charge_times service call for SPH devices."""
|
||||
coordinator: GrowattCoordinator = _get_coordinator(
|
||||
hass, call.data["device_id"], "sph"
|
||||
)
|
||||
# Read current settings first — the SPH API requires all 3 periods in
|
||||
# every write call. Any period not supplied by the caller is filled in
|
||||
# from the cache so existing settings are not overwritten with zeros.
|
||||
current = await coordinator.read_ac_charge_times()
|
||||
|
||||
charge_power: int = int(call.data.get("charge_power", current["charge_power"]))
|
||||
charge_stop_soc: int = int(
|
||||
call.data.get("charge_stop_soc", current["charge_stop_soc"])
|
||||
)
|
||||
mains_enabled: bool = call.data.get("mains_enabled", current["mains_enabled"])
|
||||
|
||||
if not 0 <= charge_power <= 100:
|
||||
raise ServiceValidationError(
|
||||
f"charge_power must be between 0 and 100, got {charge_power}"
|
||||
)
|
||||
if not 0 <= charge_stop_soc <= 100:
|
||||
raise ServiceValidationError(
|
||||
f"charge_stop_soc must be between 0 and 100, got {charge_stop_soc}"
|
||||
)
|
||||
|
||||
periods = []
|
||||
for i in range(1, 4):
|
||||
cached = current["periods"][i - 1]
|
||||
start = _parse_time_str(
|
||||
call.data.get(f"period_{i}_start", cached["start_time"]),
|
||||
f"period_{i}_start",
|
||||
)
|
||||
end = _parse_time_str(
|
||||
call.data.get(f"period_{i}_end", cached["end_time"]),
|
||||
f"period_{i}_end",
|
||||
)
|
||||
enabled: bool = call.data.get(f"period_{i}_enabled", cached["enabled"])
|
||||
periods.append({"start_time": start, "end_time": end, "enabled": enabled})
|
||||
|
||||
await coordinator.update_ac_charge_times(
|
||||
charge_power, charge_stop_soc, mains_enabled, periods
|
||||
)
|
||||
|
||||
async def handle_write_ac_discharge_times(call: ServiceCall) -> None:
|
||||
"""Handle write_ac_discharge_times service call for SPH devices."""
|
||||
coordinator: GrowattCoordinator = _get_coordinator(
|
||||
hass, call.data["device_id"], "sph"
|
||||
)
|
||||
# Read current settings first — same read-merge-write pattern as charge.
|
||||
current = await coordinator.read_ac_discharge_times()
|
||||
|
||||
discharge_power: int = int(
|
||||
call.data.get("discharge_power", current["discharge_power"])
|
||||
)
|
||||
discharge_stop_soc: int = int(
|
||||
call.data.get("discharge_stop_soc", current["discharge_stop_soc"])
|
||||
)
|
||||
|
||||
if not 0 <= discharge_power <= 100:
|
||||
raise ServiceValidationError(
|
||||
f"discharge_power must be between 0 and 100, got {discharge_power}"
|
||||
)
|
||||
if not 0 <= discharge_stop_soc <= 100:
|
||||
raise ServiceValidationError(
|
||||
f"discharge_stop_soc must be between 0 and 100, got {discharge_stop_soc}"
|
||||
)
|
||||
|
||||
periods = []
|
||||
for i in range(1, 4):
|
||||
cached = current["periods"][i - 1]
|
||||
start = _parse_time_str(
|
||||
call.data.get(f"period_{i}_start", cached["start_time"]),
|
||||
f"period_{i}_start",
|
||||
)
|
||||
end = _parse_time_str(
|
||||
call.data.get(f"period_{i}_end", cached["end_time"]),
|
||||
f"period_{i}_end",
|
||||
)
|
||||
enabled: bool = call.data.get(f"period_{i}_enabled", cached["enabled"])
|
||||
periods.append({"start_time": start, "end_time": end, "enabled": enabled})
|
||||
|
||||
await coordinator.update_ac_discharge_times(
|
||||
discharge_power, discharge_stop_soc, periods
|
||||
)
|
||||
|
||||
async def handle_read_ac_charge_times(call: ServiceCall) -> dict[str, Any]:
|
||||
"""Handle read_ac_charge_times service call for SPH devices."""
|
||||
coordinator: GrowattCoordinator = _get_coordinator(
|
||||
hass, call.data["device_id"], "sph"
|
||||
)
|
||||
return await coordinator.read_ac_charge_times()
|
||||
|
||||
async def handle_read_ac_discharge_times(call: ServiceCall) -> dict[str, Any]:
|
||||
"""Handle read_ac_discharge_times service call for SPH devices."""
|
||||
coordinator: GrowattCoordinator = _get_coordinator(
|
||||
hass, call.data["device_id"], "sph"
|
||||
)
|
||||
return await coordinator.read_ac_discharge_times()
|
||||
|
||||
# Register services without schema - services.yaml will provide UI definition
|
||||
# Schema validation happens in the handler functions
|
||||
hass.services.async_register(
|
||||
@@ -168,3 +247,31 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
handle_read_time_segments,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"write_ac_charge_times",
|
||||
handle_write_ac_charge_times,
|
||||
supports_response=SupportsResponse.NONE,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"write_ac_discharge_times",
|
||||
handle_write_ac_discharge_times,
|
||||
supports_response=SupportsResponse.NONE,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"read_ac_charge_times",
|
||||
handle_read_ac_charge_times,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"read_ac_discharge_times",
|
||||
handle_read_ac_discharge_times,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
@@ -48,3 +48,162 @@ read_time_segments:
|
||||
selector:
|
||||
device:
|
||||
integration: growatt_server
|
||||
|
||||
write_ac_charge_times:
|
||||
fields:
|
||||
device_id:
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
integration: growatt_server
|
||||
charge_power:
|
||||
required: false
|
||||
example: 100
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 100
|
||||
mode: slider
|
||||
charge_stop_soc:
|
||||
required: false
|
||||
example: 100
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 100
|
||||
mode: slider
|
||||
mains_enabled:
|
||||
required: false
|
||||
example: true
|
||||
selector:
|
||||
boolean:
|
||||
period_1_start:
|
||||
required: false
|
||||
example: "00:00"
|
||||
selector:
|
||||
time:
|
||||
period_1_end:
|
||||
required: false
|
||||
example: "00:00"
|
||||
selector:
|
||||
time:
|
||||
period_1_enabled:
|
||||
required: false
|
||||
example: false
|
||||
selector:
|
||||
boolean:
|
||||
period_2_start:
|
||||
required: false
|
||||
example: "00:00"
|
||||
selector:
|
||||
time:
|
||||
period_2_end:
|
||||
required: false
|
||||
example: "00:00"
|
||||
selector:
|
||||
time:
|
||||
period_2_enabled:
|
||||
required: false
|
||||
example: false
|
||||
selector:
|
||||
boolean:
|
||||
period_3_start:
|
||||
required: false
|
||||
example: "00:00"
|
||||
selector:
|
||||
time:
|
||||
period_3_end:
|
||||
required: false
|
||||
example: "00:00"
|
||||
selector:
|
||||
time:
|
||||
period_3_enabled:
|
||||
required: false
|
||||
example: false
|
||||
selector:
|
||||
boolean:
|
||||
|
||||
write_ac_discharge_times:
|
||||
fields:
|
||||
device_id:
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
integration: growatt_server
|
||||
discharge_power:
|
||||
required: false
|
||||
example: 100
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 100
|
||||
mode: slider
|
||||
discharge_stop_soc:
|
||||
required: false
|
||||
example: 20
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 100
|
||||
mode: slider
|
||||
period_1_start:
|
||||
required: false
|
||||
example: "00:00"
|
||||
selector:
|
||||
time:
|
||||
period_1_end:
|
||||
required: false
|
||||
example: "00:00"
|
||||
selector:
|
||||
time:
|
||||
period_1_enabled:
|
||||
required: false
|
||||
example: false
|
||||
selector:
|
||||
boolean:
|
||||
period_2_start:
|
||||
required: false
|
||||
example: "00:00"
|
||||
selector:
|
||||
time:
|
||||
period_2_end:
|
||||
required: false
|
||||
example: "00:00"
|
||||
selector:
|
||||
time:
|
||||
period_2_enabled:
|
||||
required: false
|
||||
example: false
|
||||
selector:
|
||||
boolean:
|
||||
period_3_start:
|
||||
required: false
|
||||
example: "00:00"
|
||||
selector:
|
||||
time:
|
||||
period_3_end:
|
||||
required: false
|
||||
example: "00:00"
|
||||
selector:
|
||||
time:
|
||||
period_3_enabled:
|
||||
required: false
|
||||
example: false
|
||||
selector:
|
||||
boolean:
|
||||
|
||||
read_ac_charge_times:
|
||||
fields:
|
||||
device_id:
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
integration: growatt_server
|
||||
|
||||
read_ac_discharge_times:
|
||||
fields:
|
||||
device_id:
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
integration: growatt_server
|
||||
|
||||
@@ -58,14 +58,14 @@
|
||||
"region": "[%key:component::growatt_server::config::step::password_auth::data_description::region%]",
|
||||
"token": "The API token for your Growatt account. You can generate one via the Growatt web portal or ShinePhone app."
|
||||
},
|
||||
"description": "Token authentication is only supported for MIN/TLX devices. For other device types, please use username/password authentication.",
|
||||
"description": "Token authentication is only supported for MIN/SPH devices. For other device types, please use username/password authentication.",
|
||||
"title": "Enter your API token"
|
||||
},
|
||||
"user": {
|
||||
"description": "Note: Token authentication is currently only supported for MIN/TLX devices. For other device types, please use username/password authentication.",
|
||||
"description": "Note: Token authentication is currently only supported for MIN/SPH devices. For other device types, please use username/password authentication.",
|
||||
"menu_options": {
|
||||
"password_auth": "Username/password",
|
||||
"token_auth": "API token (MIN/TLX only)"
|
||||
"token_auth": "API token (MIN/SPH only)"
|
||||
},
|
||||
"title": "Choose authentication method"
|
||||
}
|
||||
@@ -243,6 +243,24 @@
|
||||
"mix_wattage_pv_all": {
|
||||
"name": "All PV wattage"
|
||||
},
|
||||
"sph_grid_frequency": {
|
||||
"name": "AC frequency"
|
||||
},
|
||||
"sph_temperature_1": {
|
||||
"name": "Temperature 1"
|
||||
},
|
||||
"sph_temperature_2": {
|
||||
"name": "Temperature 2"
|
||||
},
|
||||
"sph_temperature_3": {
|
||||
"name": "Temperature 3"
|
||||
},
|
||||
"sph_temperature_4": {
|
||||
"name": "Temperature 4"
|
||||
},
|
||||
"sph_temperature_5": {
|
||||
"name": "Temperature 5"
|
||||
},
|
||||
"storage_ac_input_frequency_out": {
|
||||
"name": "AC input frequency"
|
||||
},
|
||||
@@ -576,6 +594,26 @@
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"read_ac_charge_times": {
|
||||
"description": "Read AC charge time periods from an SPH device.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"description": "The Growatt SPH device to read from.",
|
||||
"name": "Device"
|
||||
}
|
||||
},
|
||||
"name": "Read AC charge times"
|
||||
},
|
||||
"read_ac_discharge_times": {
|
||||
"description": "Read AC discharge time periods from an SPH device.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"description": "[%key:component::growatt_server::services::read_ac_charge_times::fields::device_id::description%]",
|
||||
"name": "[%key:component::growatt_server::services::read_ac_charge_times::fields::device_id::name%]"
|
||||
}
|
||||
},
|
||||
"name": "Read AC discharge times"
|
||||
},
|
||||
"read_time_segments": {
|
||||
"description": "Read all time segments from a supported inverter.",
|
||||
"fields": {
|
||||
@@ -615,6 +653,118 @@
|
||||
}
|
||||
},
|
||||
"name": "Update time segment"
|
||||
},
|
||||
"write_ac_charge_times": {
|
||||
"description": "Write AC charge time periods to an SPH device.",
|
||||
"fields": {
|
||||
"charge_power": {
|
||||
"description": "Charge power limit (%).",
|
||||
"name": "Charge power"
|
||||
},
|
||||
"charge_stop_soc": {
|
||||
"description": "Stop charging at this state of charge (%).",
|
||||
"name": "Charge stop SOC"
|
||||
},
|
||||
"device_id": {
|
||||
"description": "[%key:component::growatt_server::services::read_ac_charge_times::fields::device_id::description%]",
|
||||
"name": "[%key:component::growatt_server::services::read_ac_charge_times::fields::device_id::name%]"
|
||||
},
|
||||
"mains_enabled": {
|
||||
"description": "Enable AC (mains) charging.",
|
||||
"name": "Mains charging enabled"
|
||||
},
|
||||
"period_1_enabled": {
|
||||
"description": "Enable time period 1.",
|
||||
"name": "Period 1 enabled"
|
||||
},
|
||||
"period_1_end": {
|
||||
"description": "End time for period 1 (HH:MM or HH:MM:SS).",
|
||||
"name": "Period 1 end"
|
||||
},
|
||||
"period_1_start": {
|
||||
"description": "Start time for period 1 (HH:MM or HH:MM:SS).",
|
||||
"name": "Period 1 start"
|
||||
},
|
||||
"period_2_enabled": {
|
||||
"description": "Enable time period 2.",
|
||||
"name": "Period 2 enabled"
|
||||
},
|
||||
"period_2_end": {
|
||||
"description": "End time for period 2 (HH:MM or HH:MM:SS).",
|
||||
"name": "Period 2 end"
|
||||
},
|
||||
"period_2_start": {
|
||||
"description": "Start time for period 2 (HH:MM or HH:MM:SS).",
|
||||
"name": "Period 2 start"
|
||||
},
|
||||
"period_3_enabled": {
|
||||
"description": "Enable time period 3.",
|
||||
"name": "Period 3 enabled"
|
||||
},
|
||||
"period_3_end": {
|
||||
"description": "End time for period 3 (HH:MM or HH:MM:SS).",
|
||||
"name": "Period 3 end"
|
||||
},
|
||||
"period_3_start": {
|
||||
"description": "Start time for period 3 (HH:MM or HH:MM:SS).",
|
||||
"name": "Period 3 start"
|
||||
}
|
||||
},
|
||||
"name": "Write AC charge times"
|
||||
},
|
||||
"write_ac_discharge_times": {
|
||||
"description": "Write AC discharge time periods to an SPH device.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"description": "[%key:component::growatt_server::services::read_ac_charge_times::fields::device_id::description%]",
|
||||
"name": "[%key:component::growatt_server::services::read_ac_charge_times::fields::device_id::name%]"
|
||||
},
|
||||
"discharge_power": {
|
||||
"description": "Discharge power limit (%).",
|
||||
"name": "Discharge power"
|
||||
},
|
||||
"discharge_stop_soc": {
|
||||
"description": "Stop discharging at this state of charge (%).",
|
||||
"name": "Discharge stop SOC"
|
||||
},
|
||||
"period_1_enabled": {
|
||||
"description": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_1_enabled::description%]",
|
||||
"name": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_1_enabled::name%]"
|
||||
},
|
||||
"period_1_end": {
|
||||
"description": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_1_end::description%]",
|
||||
"name": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_1_end::name%]"
|
||||
},
|
||||
"period_1_start": {
|
||||
"description": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_1_start::description%]",
|
||||
"name": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_1_start::name%]"
|
||||
},
|
||||
"period_2_enabled": {
|
||||
"description": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_2_enabled::description%]",
|
||||
"name": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_2_enabled::name%]"
|
||||
},
|
||||
"period_2_end": {
|
||||
"description": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_2_end::description%]",
|
||||
"name": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_2_end::name%]"
|
||||
},
|
||||
"period_2_start": {
|
||||
"description": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_2_start::description%]",
|
||||
"name": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_2_start::name%]"
|
||||
},
|
||||
"period_3_enabled": {
|
||||
"description": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_3_enabled::description%]",
|
||||
"name": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_3_enabled::name%]"
|
||||
},
|
||||
"period_3_end": {
|
||||
"description": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_3_end::description%]",
|
||||
"name": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_3_end::name%]"
|
||||
},
|
||||
"period_3_start": {
|
||||
"description": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_3_start::description%]",
|
||||
"name": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_3_start::name%]"
|
||||
}
|
||||
},
|
||||
"name": "Write AC discharge times"
|
||||
}
|
||||
},
|
||||
"title": "Growatt Server"
|
||||
|
||||
@@ -89,18 +89,18 @@
|
||||
"step": {
|
||||
"advanced": {
|
||||
"data": {
|
||||
"api_key": "API Token",
|
||||
"api_key": "API token",
|
||||
"api_user": "User ID",
|
||||
"url": "[%key:common::config_flow::data::url%]",
|
||||
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
||||
},
|
||||
"data_description": {
|
||||
"api_key": "API Token of the Habitica account",
|
||||
"api_key": "API token of the Habitica account",
|
||||
"api_user": "User ID of your Habitica account",
|
||||
"url": "URL of the Habitica installation to connect to. Defaults to `{default_url}`",
|
||||
"verify_ssl": "Enable SSL certificate verification for secure connections. Disable only if connecting to a Habitica instance using a self-signed certificate"
|
||||
},
|
||||
"description": "You can retrieve your `User ID` and `API Token` from [**Settings -> Site Data**]({site_data}) on Habitica or the instance you want to connect to",
|
||||
"description": "You can retrieve your 'User ID' and 'API token' from [**Settings -> Site Data**]({site_data}) on Habitica or the instance you want to connect to",
|
||||
"title": "[%key:component::habitica::config::step::user::menu_options::advanced%]"
|
||||
},
|
||||
"login": {
|
||||
@@ -126,7 +126,7 @@
|
||||
"api_key": "[%key:component::habitica::config::step::advanced::data_description::api_key%]"
|
||||
},
|
||||
"description": "Enter your new API token below. You can find it in Habitica under 'Settings -> Site Data'",
|
||||
"name": "Re-authorize via API Token"
|
||||
"name": "Re-authorize via API token"
|
||||
},
|
||||
"reauth_login": {
|
||||
"data": {
|
||||
|
||||
@@ -9,10 +9,21 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
from typing import Any, NamedTuple
|
||||
from typing import Any, NamedTuple, cast
|
||||
|
||||
from aiohasupervisor import SupervisorError
|
||||
from aiohasupervisor.models import GreenOptions, YellowOptions # noqa: F401
|
||||
from aiohasupervisor.models import (
|
||||
GreenOptions,
|
||||
HomeAssistantInfo,
|
||||
HostInfo,
|
||||
InstalledAddon,
|
||||
NetworkInfo,
|
||||
OSInfo,
|
||||
RootInfo,
|
||||
StoreInfo,
|
||||
SupervisorInfo,
|
||||
YellowOptions,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.auth.const import GROUP_ID_ADMIN
|
||||
@@ -65,7 +76,7 @@ from . import ( # noqa: F401
|
||||
system_health,
|
||||
update,
|
||||
)
|
||||
from .addon_manager import AddonError, AddonInfo, AddonManager, AddonState # noqa: F401
|
||||
from .addon_manager import AddonError, AddonInfo, AddonManager, AddonState
|
||||
from .addon_panel import async_setup_addon_panel
|
||||
from .auth import async_setup_auth_view
|
||||
from .config import HassioConfig
|
||||
@@ -82,7 +93,9 @@ from .const import (
|
||||
ATTR_INPUT,
|
||||
ATTR_LOCATION,
|
||||
ATTR_PASSWORD,
|
||||
ATTR_REPOSITORIES,
|
||||
ATTR_SLUG,
|
||||
DATA_ADDONS_LIST,
|
||||
DATA_COMPONENT,
|
||||
DATA_CONFIG_STORE,
|
||||
DATA_CORE_INFO,
|
||||
@@ -100,18 +113,21 @@ from .const import (
|
||||
from .coordinator import (
|
||||
HassioDataUpdateCoordinator,
|
||||
get_addons_info,
|
||||
get_addons_stats, # noqa: F401
|
||||
get_core_info, # noqa: F401
|
||||
get_core_stats, # noqa: F401
|
||||
get_host_info, # noqa: F401
|
||||
get_addons_list,
|
||||
get_addons_stats,
|
||||
get_core_info,
|
||||
get_core_stats,
|
||||
get_host_info,
|
||||
get_info,
|
||||
get_issues_info, # noqa: F401
|
||||
get_issues_info,
|
||||
get_network_info,
|
||||
get_os_info,
|
||||
get_supervisor_info, # noqa: F401
|
||||
get_supervisor_stats, # noqa: F401
|
||||
get_store,
|
||||
get_supervisor_info,
|
||||
get_supervisor_stats,
|
||||
)
|
||||
from .discovery import async_setup_discovery_view
|
||||
from .handler import ( # noqa: F401
|
||||
from .handler import (
|
||||
HassIO,
|
||||
HassioAPIError,
|
||||
async_update_diagnostics,
|
||||
@@ -122,6 +138,35 @@ from .ingress import async_setup_ingress_view
|
||||
from .issues import SupervisorIssues
|
||||
from .websocket_api import async_load_websocket_api
|
||||
|
||||
# Expose the future safe name now so integrations can use it
|
||||
# All references to addons will eventually be refactored and deprecated
|
||||
get_apps_list = get_addons_list
|
||||
__all__ = [
|
||||
"AddonError",
|
||||
"AddonInfo",
|
||||
"AddonManager",
|
||||
"AddonState",
|
||||
"GreenOptions",
|
||||
"SupervisorError",
|
||||
"YellowOptions",
|
||||
"async_update_diagnostics",
|
||||
"get_addons_info",
|
||||
"get_addons_list",
|
||||
"get_addons_stats",
|
||||
"get_apps_list",
|
||||
"get_core_info",
|
||||
"get_core_stats",
|
||||
"get_host_info",
|
||||
"get_info",
|
||||
"get_issues_info",
|
||||
"get_network_info",
|
||||
"get_os_info",
|
||||
"get_store",
|
||||
"get_supervisor_client",
|
||||
"get_supervisor_info",
|
||||
"get_supervisor_stats",
|
||||
]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -504,27 +549,55 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
|
||||
|
||||
try:
|
||||
(
|
||||
hass.data[DATA_INFO],
|
||||
hass.data[DATA_HOST_INFO],
|
||||
root_info,
|
||||
host_info,
|
||||
store_info,
|
||||
hass.data[DATA_CORE_INFO],
|
||||
hass.data[DATA_SUPERVISOR_INFO],
|
||||
hass.data[DATA_OS_INFO],
|
||||
hass.data[DATA_NETWORK_INFO],
|
||||
) = await asyncio.gather(
|
||||
create_eager_task(hassio.get_info()),
|
||||
create_eager_task(hassio.get_host_info()),
|
||||
create_eager_task(supervisor_client.store.info()),
|
||||
create_eager_task(hassio.get_core_info()),
|
||||
create_eager_task(hassio.get_supervisor_info()),
|
||||
create_eager_task(hassio.get_os_info()),
|
||||
create_eager_task(hassio.get_network_info()),
|
||||
homeassistant_info,
|
||||
supervisor_info,
|
||||
os_info,
|
||||
network_info,
|
||||
addons_list,
|
||||
) = cast(
|
||||
tuple[
|
||||
RootInfo,
|
||||
HostInfo,
|
||||
StoreInfo,
|
||||
HomeAssistantInfo,
|
||||
SupervisorInfo,
|
||||
OSInfo,
|
||||
NetworkInfo,
|
||||
list[InstalledAddon],
|
||||
],
|
||||
await asyncio.gather(
|
||||
create_eager_task(supervisor_client.info()),
|
||||
create_eager_task(supervisor_client.host.info()),
|
||||
create_eager_task(supervisor_client.store.info()),
|
||||
create_eager_task(supervisor_client.homeassistant.info()),
|
||||
create_eager_task(supervisor_client.supervisor.info()),
|
||||
create_eager_task(supervisor_client.os.info()),
|
||||
create_eager_task(supervisor_client.network.info()),
|
||||
create_eager_task(supervisor_client.addons.list()),
|
||||
),
|
||||
)
|
||||
|
||||
except HassioAPIError as err:
|
||||
except SupervisorError as err:
|
||||
_LOGGER.warning("Can't read Supervisor data: %s", err)
|
||||
else:
|
||||
hass.data[DATA_INFO] = root_info.to_dict()
|
||||
hass.data[DATA_HOST_INFO] = host_info.to_dict()
|
||||
hass.data[DATA_STORE] = store_info.to_dict()
|
||||
hass.data[DATA_CORE_INFO] = homeassistant_info.to_dict()
|
||||
hass.data[DATA_SUPERVISOR_INFO] = supervisor_info.to_dict()
|
||||
hass.data[DATA_OS_INFO] = os_info.to_dict()
|
||||
hass.data[DATA_NETWORK_INFO] = network_info.to_dict()
|
||||
hass.data[DATA_ADDONS_LIST] = [addon.to_dict() for addon in addons_list]
|
||||
|
||||
# Deprecated 2026.4.0: Folding repositories and addons.list results into supervisor_info for compatibility
|
||||
# Can drop this after removal period
|
||||
hass.data[DATA_SUPERVISOR_INFO]["repositories"] = hass.data[DATA_STORE][
|
||||
ATTR_REPOSITORIES
|
||||
]
|
||||
hass.data[DATA_SUPERVISOR_INFO]["addons"] = hass.data[DATA_ADDONS_LIST]
|
||||
|
||||
async_call_later(
|
||||
hass,
|
||||
|
||||
@@ -204,8 +204,17 @@ class SupervisorBackupAgent(BackupAgent):
|
||||
location={self.location},
|
||||
filename=PurePath(suggested_backup_filename(backup)),
|
||||
)
|
||||
|
||||
async def stream_with_progress() -> AsyncIterator[bytes]:
|
||||
"""Wrap stream to track upload progress."""
|
||||
bytes_uploaded = 0
|
||||
async for chunk in stream:
|
||||
bytes_uploaded += len(chunk)
|
||||
on_progress(bytes_uploaded=bytes_uploaded)
|
||||
yield chunk
|
||||
|
||||
await self._client.backups.upload_backup(
|
||||
stream,
|
||||
stream_with_progress(),
|
||||
upload_options,
|
||||
)
|
||||
|
||||
|
||||
@@ -93,6 +93,7 @@ DATA_SUPERVISOR_INFO = "hassio_supervisor_info"
|
||||
DATA_SUPERVISOR_STATS = "hassio_supervisor_stats"
|
||||
DATA_ADDONS_INFO = "hassio_addons_info"
|
||||
DATA_ADDONS_STATS = "hassio_addons_stats"
|
||||
DATA_ADDONS_LIST = "hassio_addons_list"
|
||||
HASSIO_UPDATE_INTERVAL = timedelta(minutes=5)
|
||||
|
||||
ATTR_AUTO_UPDATE = "auto_update"
|
||||
@@ -106,6 +107,7 @@ ATTR_STATE = "state"
|
||||
ATTR_STARTED = "started"
|
||||
ATTR_URL = "url"
|
||||
ATTR_REPOSITORY = "repository"
|
||||
ATTR_REPOSITORIES = "repositories"
|
||||
|
||||
DATA_KEY_ADDONS = "addons"
|
||||
DATA_KEY_OS = "os"
|
||||
|
||||
@@ -4,13 +4,20 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
from collections.abc import Awaitable
|
||||
from copy import deepcopy
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from aiohasupervisor import SupervisorError, SupervisorNotFoundError
|
||||
from aiohasupervisor.models import StoreInfo
|
||||
from aiohasupervisor.models.mounts import CIFSMountResponse, NFSMountResponse
|
||||
from aiohasupervisor.models import (
|
||||
AddonState,
|
||||
CIFSMountResponse,
|
||||
InstalledAddon,
|
||||
NFSMountResponse,
|
||||
StoreInfo,
|
||||
)
|
||||
from aiohasupervisor.models.base import ResponseData
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_MANUFACTURER, ATTR_NAME
|
||||
@@ -23,16 +30,16 @@ from homeassistant.loader import bind_hass
|
||||
|
||||
from .const import (
|
||||
ATTR_AUTO_UPDATE,
|
||||
ATTR_REPOSITORIES,
|
||||
ATTR_REPOSITORY,
|
||||
ATTR_SLUG,
|
||||
ATTR_STARTED,
|
||||
ATTR_STATE,
|
||||
ATTR_URL,
|
||||
ATTR_VERSION,
|
||||
CONTAINER_INFO,
|
||||
CONTAINER_STATS,
|
||||
CORE_CONTAINER,
|
||||
DATA_ADDONS_INFO,
|
||||
DATA_ADDONS_LIST,
|
||||
DATA_ADDONS_STATS,
|
||||
DATA_COMPONENT,
|
||||
DATA_CORE_INFO,
|
||||
@@ -57,7 +64,7 @@ from .const import (
|
||||
SUPERVISOR_CONTAINER,
|
||||
SupervisorEntityModel,
|
||||
)
|
||||
from .handler import HassioAPIError, get_supervisor_client
|
||||
from .handler import get_supervisor_client
|
||||
from .jobs import SupervisorJobs
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -118,7 +125,7 @@ def get_network_info(hass: HomeAssistant) -> dict[str, Any] | None:
|
||||
|
||||
@callback
|
||||
@bind_hass
|
||||
def get_addons_info(hass: HomeAssistant) -> dict[str, dict[str, Any]] | None:
|
||||
def get_addons_info(hass: HomeAssistant) -> dict[str, dict[str, Any] | None] | None:
|
||||
"""Return Addons info.
|
||||
|
||||
Async friendly.
|
||||
@@ -126,9 +133,18 @@ def get_addons_info(hass: HomeAssistant) -> dict[str, dict[str, Any]] | None:
|
||||
return hass.data.get(DATA_ADDONS_INFO)
|
||||
|
||||
|
||||
@callback
|
||||
def get_addons_list(hass: HomeAssistant) -> list[dict[str, Any]] | None:
|
||||
"""Return list of installed addons and subset of details for each.
|
||||
|
||||
Async friendly.
|
||||
"""
|
||||
return hass.data.get(DATA_ADDONS_LIST)
|
||||
|
||||
|
||||
@callback
|
||||
@bind_hass
|
||||
def get_addons_stats(hass: HomeAssistant) -> dict[str, Any]:
|
||||
def get_addons_stats(hass: HomeAssistant) -> dict[str, dict[str, Any] | None]:
|
||||
"""Return Addons stats.
|
||||
|
||||
Async friendly.
|
||||
@@ -341,7 +357,7 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
|
||||
try:
|
||||
await self.force_data_refresh(is_first_update)
|
||||
except HassioAPIError as err:
|
||||
except SupervisorError as err:
|
||||
raise UpdateFailed(f"Error on Supervisor API: {err}") from err
|
||||
|
||||
new_data: dict[str, Any] = {}
|
||||
@@ -350,6 +366,7 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
addons_stats = get_addons_stats(self.hass)
|
||||
store_data = get_store(self.hass)
|
||||
mounts_info = await self.supervisor_client.mounts.info()
|
||||
addons_list = get_addons_list(self.hass) or []
|
||||
|
||||
if store_data:
|
||||
repositories = {
|
||||
@@ -360,17 +377,17 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
repositories = {}
|
||||
|
||||
new_data[DATA_KEY_ADDONS] = {
|
||||
addon[ATTR_SLUG]: {
|
||||
(slug := addon[ATTR_SLUG]): {
|
||||
**addon,
|
||||
**((addons_stats or {}).get(addon[ATTR_SLUG]) or {}),
|
||||
ATTR_AUTO_UPDATE: (addons_info.get(addon[ATTR_SLUG]) or {}).get(
|
||||
**(addons_stats.get(slug) or {}),
|
||||
ATTR_AUTO_UPDATE: (addons_info.get(slug) or {}).get(
|
||||
ATTR_AUTO_UPDATE, False
|
||||
),
|
||||
ATTR_REPOSITORY: repositories.get(
|
||||
addon.get(ATTR_REPOSITORY), addon.get(ATTR_REPOSITORY, "")
|
||||
repo_slug := addon.get(ATTR_REPOSITORY, ""), repo_slug
|
||||
),
|
||||
}
|
||||
for addon in supervisor_info.get("addons", [])
|
||||
for addon in addons_list
|
||||
}
|
||||
if self.is_hass_os:
|
||||
new_data[DATA_KEY_OS] = get_os_info(self.hass)
|
||||
@@ -462,32 +479,48 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
container_updates = self._container_updates
|
||||
|
||||
data = self.hass.data
|
||||
hassio = self.hassio
|
||||
updates = {
|
||||
DATA_INFO: hassio.get_info(),
|
||||
DATA_CORE_INFO: hassio.get_core_info(),
|
||||
DATA_SUPERVISOR_INFO: hassio.get_supervisor_info(),
|
||||
DATA_OS_INFO: hassio.get_os_info(),
|
||||
client = self.supervisor_client
|
||||
|
||||
updates: dict[str, Awaitable[ResponseData]] = {
|
||||
DATA_INFO: client.info(),
|
||||
DATA_CORE_INFO: client.homeassistant.info(),
|
||||
DATA_SUPERVISOR_INFO: client.supervisor.info(),
|
||||
DATA_OS_INFO: client.os.info(),
|
||||
DATA_STORE: client.store.info(),
|
||||
}
|
||||
if CONTAINER_STATS in container_updates[CORE_CONTAINER]:
|
||||
updates[DATA_CORE_STATS] = hassio.get_core_stats()
|
||||
updates[DATA_CORE_STATS] = client.homeassistant.stats()
|
||||
if CONTAINER_STATS in container_updates[SUPERVISOR_CONTAINER]:
|
||||
updates[DATA_SUPERVISOR_STATS] = hassio.get_supervisor_stats()
|
||||
updates[DATA_SUPERVISOR_STATS] = client.supervisor.stats()
|
||||
|
||||
results = await asyncio.gather(*updates.values())
|
||||
for key, result in zip(updates, results, strict=False):
|
||||
data[key] = result
|
||||
# Pull off addons.list results for further processing before caching
|
||||
addons_list, *results = await asyncio.gather(
|
||||
client.addons.list(), *updates.values()
|
||||
)
|
||||
for key, result in zip(updates, cast(list[ResponseData], results), strict=True):
|
||||
data[key] = result.to_dict()
|
||||
|
||||
installed_addons = cast(list[InstalledAddon], addons_list)
|
||||
data[DATA_ADDONS_LIST] = [addon.to_dict() for addon in installed_addons]
|
||||
|
||||
# Deprecated 2026.4.0: Folding repositories and addons.list results into supervisor_info for compatibility
|
||||
# Can drop this after removal period
|
||||
data[DATA_SUPERVISOR_INFO].update(
|
||||
{
|
||||
"repositories": data[DATA_STORE][ATTR_REPOSITORIES],
|
||||
"addons": [addon.to_dict() for addon in installed_addons],
|
||||
}
|
||||
)
|
||||
|
||||
all_addons = {addon.slug for addon in installed_addons}
|
||||
started_addons = {
|
||||
addon.slug
|
||||
for addon in installed_addons
|
||||
if addon.state in {AddonState.STARTED, AddonState.STARTUP}
|
||||
}
|
||||
|
||||
_addon_data = data[DATA_SUPERVISOR_INFO].get("addons", [])
|
||||
all_addons: list[str] = []
|
||||
started_addons: list[str] = []
|
||||
for addon in _addon_data:
|
||||
slug = addon[ATTR_SLUG]
|
||||
all_addons.append(slug)
|
||||
if addon[ATTR_STATE] == ATTR_STARTED:
|
||||
started_addons.append(slug)
|
||||
#
|
||||
# Update add-on info if its the first update or
|
||||
# Update addon info if its the first update or
|
||||
# there is at least one entity that needs the data.
|
||||
#
|
||||
# When entities are added they call async_enable_container_updates
|
||||
@@ -514,6 +547,12 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
),
|
||||
):
|
||||
container_data: dict[str, Any] = data.setdefault(data_key, {})
|
||||
|
||||
# Clean up cache
|
||||
for slug in container_data.keys() - wanted_addons:
|
||||
del container_data[slug]
|
||||
|
||||
# Update cache from API
|
||||
container_data.update(
|
||||
dict(
|
||||
await asyncio.gather(
|
||||
@@ -540,7 +579,7 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
return (slug, stats.to_dict())
|
||||
|
||||
async def _update_addon_info(self, slug: str) -> tuple[str, dict[str, Any] | None]:
|
||||
"""Return the info for an add-on."""
|
||||
"""Return the info for an addon."""
|
||||
try:
|
||||
info = await self.supervisor_client.addons.addon_info(slug)
|
||||
except SupervisorError as err:
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Any
|
||||
|
||||
from attr import asdict
|
||||
|
||||
from homeassistant.components.diagnostics import entity_entry_as_dict
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
@@ -44,7 +45,9 @@ async def async_get_config_entry_diagnostics(
|
||||
state_dict = dict(state.as_dict())
|
||||
state_dict.pop("context", None)
|
||||
|
||||
entities.append({"entry": asdict(entity_entry), "state": state_dict})
|
||||
entities.append(
|
||||
{"entry": entity_entry_as_dict(entity_entry), "state": state_dict}
|
||||
)
|
||||
|
||||
devices.append({"device": asdict(device), "entities": entities})
|
||||
|
||||
|
||||
@@ -87,70 +87,6 @@ class HassIO:
|
||||
"""Return base url for Supervisor."""
|
||||
return self._base_url
|
||||
|
||||
@api_data
|
||||
def get_info(self) -> Coroutine:
|
||||
"""Return generic Supervisor information.
|
||||
|
||||
This method returns a coroutine.
|
||||
"""
|
||||
return self.send_command("/info", method="get")
|
||||
|
||||
@api_data
|
||||
def get_host_info(self) -> Coroutine:
|
||||
"""Return data for Host.
|
||||
|
||||
This method returns a coroutine.
|
||||
"""
|
||||
return self.send_command("/host/info", method="get")
|
||||
|
||||
@api_data
|
||||
def get_os_info(self) -> Coroutine:
|
||||
"""Return data for the OS.
|
||||
|
||||
This method returns a coroutine.
|
||||
"""
|
||||
return self.send_command("/os/info", method="get")
|
||||
|
||||
@api_data
|
||||
def get_core_info(self) -> Coroutine:
|
||||
"""Return data for Home Asssistant Core.
|
||||
|
||||
This method returns a coroutine.
|
||||
"""
|
||||
return self.send_command("/core/info", method="get")
|
||||
|
||||
@api_data
|
||||
def get_supervisor_info(self) -> Coroutine:
|
||||
"""Return data for the Supervisor.
|
||||
|
||||
This method returns a coroutine.
|
||||
"""
|
||||
return self.send_command("/supervisor/info", method="get")
|
||||
|
||||
@api_data
|
||||
def get_network_info(self) -> Coroutine:
|
||||
"""Return data for the Host Network.
|
||||
|
||||
This method returns a coroutine.
|
||||
"""
|
||||
return self.send_command("/network/info", method="get")
|
||||
|
||||
@api_data
|
||||
def get_core_stats(self) -> Coroutine:
|
||||
"""Return stats for the core.
|
||||
|
||||
This method returns a coroutine.
|
||||
"""
|
||||
return self.send_command("/core/stats", method="get")
|
||||
|
||||
@api_data
|
||||
def get_supervisor_stats(self) -> Coroutine:
|
||||
"""Return stats for the supervisor.
|
||||
|
||||
This method returns a coroutine.
|
||||
"""
|
||||
return self.send_command("/supervisor/stats", method="get")
|
||||
|
||||
@api_data
|
||||
def get_ingress_panels(self) -> Coroutine:
|
||||
"""Return data for Add-on ingress panels.
|
||||
|
||||
@@ -45,6 +45,7 @@ RESPONSE_HEADERS_FILTER = {
|
||||
}
|
||||
|
||||
MIN_COMPRESSED_SIZE = 128
|
||||
MAX_WEBSOCKET_MESSAGE_SIZE = 16 * 1024 * 1024 # 16 MiB
|
||||
MAX_SIMPLE_RESPONSE_SIZE = 4194000
|
||||
|
||||
DISABLED_TIMEOUT = ClientTimeout(total=None)
|
||||
@@ -126,7 +127,10 @@ class HassIOIngress(HomeAssistantView):
|
||||
req_protocols = ()
|
||||
|
||||
ws_server = web.WebSocketResponse(
|
||||
protocols=req_protocols, autoclose=False, autoping=False
|
||||
protocols=req_protocols,
|
||||
autoclose=False,
|
||||
autoping=False,
|
||||
max_msg_size=MAX_WEBSOCKET_MESSAGE_SIZE,
|
||||
)
|
||||
await ws_server.prepare(request)
|
||||
|
||||
@@ -149,6 +153,7 @@ class HassIOIngress(HomeAssistantView):
|
||||
protocols=req_protocols,
|
||||
autoclose=False,
|
||||
autoping=False,
|
||||
max_msg_size=MAX_WEBSOCKET_MESSAGE_SIZE,
|
||||
) as ws_client:
|
||||
# Proxy requests
|
||||
await asyncio.wait(
|
||||
|
||||
@@ -17,6 +17,7 @@ from aiohasupervisor.models import (
|
||||
UnsupportedReason,
|
||||
)
|
||||
|
||||
from homeassistant.const import ATTR_NAME
|
||||
from homeassistant.core import HassJob, HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
@@ -30,6 +31,7 @@ from .const import (
|
||||
ADDONS_COORDINATOR,
|
||||
ATTR_DATA,
|
||||
ATTR_HEALTHY,
|
||||
ATTR_SLUG,
|
||||
ATTR_STARTUP,
|
||||
ATTR_SUPPORTED,
|
||||
ATTR_UNHEALTHY_REASONS,
|
||||
@@ -59,7 +61,7 @@ from .const import (
|
||||
STARTUP_COMPLETE,
|
||||
UPDATE_KEY_SUPERVISOR,
|
||||
)
|
||||
from .coordinator import HassioDataUpdateCoordinator, get_addons_info, get_host_info
|
||||
from .coordinator import HassioDataUpdateCoordinator, get_addons_list, get_host_info
|
||||
from .handler import HassIO, get_supervisor_client
|
||||
|
||||
ISSUE_KEY_UNHEALTHY = "unhealthy"
|
||||
@@ -265,23 +267,18 @@ class SupervisorIssues:
|
||||
placeholders[PLACEHOLDER_KEY_ADDON_URL] = (
|
||||
f"/hassio/addon/{issue.reference}"
|
||||
)
|
||||
addons = get_addons_info(self._hass)
|
||||
if addons and issue.reference in addons:
|
||||
placeholders[PLACEHOLDER_KEY_ADDON] = addons[issue.reference][
|
||||
"name"
|
||||
]
|
||||
else:
|
||||
placeholders[PLACEHOLDER_KEY_ADDON] = issue.reference
|
||||
addons_list = get_addons_list(self._hass) or []
|
||||
placeholders[PLACEHOLDER_KEY_ADDON] = issue.reference
|
||||
for addon in addons_list:
|
||||
if addon[ATTR_SLUG] == issue.reference:
|
||||
placeholders[PLACEHOLDER_KEY_ADDON] = addon[ATTR_NAME]
|
||||
break
|
||||
|
||||
elif issue.key == ISSUE_KEY_SYSTEM_FREE_SPACE:
|
||||
host_info = get_host_info(self._hass)
|
||||
if (
|
||||
host_info
|
||||
and "data" in host_info
|
||||
and "disk_free" in host_info["data"]
|
||||
):
|
||||
if host_info and "disk_free" in host_info:
|
||||
placeholders[PLACEHOLDER_KEY_FREE_SPACE] = str(
|
||||
host_info["data"]["disk_free"]
|
||||
host_info["disk_free"]
|
||||
)
|
||||
else:
|
||||
placeholders[PLACEHOLDER_KEY_FREE_SPACE] = "<2"
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/hassio",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["aiohasupervisor==0.3.3"],
|
||||
"requirements": ["aiohasupervisor==0.4.1"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -11,11 +11,13 @@ from aiohasupervisor.models import ContextType
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.repairs import RepairsFlow
|
||||
from homeassistant.const import ATTR_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
|
||||
from . import get_addons_info, get_issues_info
|
||||
from . import get_addons_list, get_issues_info
|
||||
from .const import (
|
||||
ATTR_SLUG,
|
||||
EXTRA_PLACEHOLDERS,
|
||||
ISSUE_KEY_ADDON_BOOT_FAIL,
|
||||
ISSUE_KEY_ADDON_DEPRECATED,
|
||||
@@ -154,7 +156,7 @@ class DockerConfigIssueRepairFlow(SupervisorIssueRepairFlow):
|
||||
placeholders = {PLACEHOLDER_KEY_COMPONENTS: ""}
|
||||
supervisor_issues = get_issues_info(self.hass)
|
||||
if supervisor_issues and self.issue:
|
||||
addons = get_addons_info(self.hass) or {}
|
||||
addons_list = get_addons_list(self.hass) or []
|
||||
components: list[str] = []
|
||||
for issue in supervisor_issues.issues:
|
||||
if issue.key == self.issue.key or issue.type != self.issue.type:
|
||||
@@ -166,9 +168,9 @@ class DockerConfigIssueRepairFlow(SupervisorIssueRepairFlow):
|
||||
components.append(
|
||||
next(
|
||||
(
|
||||
info["name"]
|
||||
for slug, info in addons.items()
|
||||
if slug == issue.reference
|
||||
addon[ATTR_NAME]
|
||||
for addon in addons_list
|
||||
if addon[ATTR_SLUG] == issue.reference
|
||||
),
|
||||
issue.reference or "",
|
||||
)
|
||||
@@ -187,13 +189,12 @@ class AddonIssueRepairFlow(SupervisorIssueRepairFlow):
|
||||
"""Get description placeholders for steps."""
|
||||
placeholders: dict[str, str] = super().description_placeholders or {}
|
||||
if self.issue and self.issue.reference:
|
||||
addons = get_addons_info(self.hass)
|
||||
if addons and self.issue.reference in addons:
|
||||
placeholders[PLACEHOLDER_KEY_ADDON] = addons[self.issue.reference][
|
||||
"name"
|
||||
]
|
||||
else:
|
||||
placeholders[PLACEHOLDER_KEY_ADDON] = self.issue.reference
|
||||
addons_list = get_addons_list(self.hass) or []
|
||||
placeholders[PLACEHOLDER_KEY_ADDON] = self.issue.reference
|
||||
for addon in addons_list:
|
||||
if addon[ATTR_SLUG] == self.issue.reference:
|
||||
placeholders[PLACEHOLDER_KEY_ADDON] = addon[ATTR_NAME]
|
||||
break
|
||||
|
||||
return placeholders or None
|
||||
|
||||
|
||||
@@ -225,10 +225,6 @@
|
||||
"description": "System is unsupported because Home Assistant cannot determine when an Internet connection is available. For troubleshooting information, select Learn more.",
|
||||
"title": "Unsupported system - Connectivity check disabled"
|
||||
},
|
||||
"unsupported_content_trust": {
|
||||
"description": "System is unsupported because Home Assistant cannot verify content being run is trusted and not modified by attackers. For troubleshooting information, select Learn more.",
|
||||
"title": "Unsupported system - Content-trust check disabled"
|
||||
},
|
||||
"unsupported_dbus": {
|
||||
"description": "System is unsupported because D-Bus is working incorrectly. Many things fail without this as Supervisor cannot communicate with the host. For troubleshooting information, select Learn more.",
|
||||
"title": "Unsupported system - D-Bus issues"
|
||||
@@ -281,10 +277,6 @@
|
||||
"description": "System is unsupported because additional software outside the Home Assistant ecosystem has been detected. For troubleshooting information, select Learn more.",
|
||||
"title": "Unsupported system - Unsupported software"
|
||||
},
|
||||
"unsupported_source_mods": {
|
||||
"description": "System is unsupported because Supervisor source code has been modified. For troubleshooting information, select Learn more.",
|
||||
"title": "Unsupported system - Supervisor source modifications"
|
||||
},
|
||||
"unsupported_supervisor_version": {
|
||||
"description": "System is unsupported because an out-of-date version of Supervisor is in use and auto-update has been disabled. For troubleshooting information, select Learn more.",
|
||||
"title": "Unsupported system - Supervisor version"
|
||||
|
||||
@@ -9,6 +9,7 @@ from homeassistant.components import system_health
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from .coordinator import (
|
||||
get_addons_list,
|
||||
get_host_info,
|
||||
get_info,
|
||||
get_network_info,
|
||||
@@ -35,6 +36,7 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
|
||||
host_info = get_host_info(hass) or {}
|
||||
supervisor_info = get_supervisor_info(hass)
|
||||
network_info = get_network_info(hass) or {}
|
||||
addons_list = get_addons_list(hass) or []
|
||||
|
||||
healthy: bool | dict[str, str]
|
||||
if supervisor_info is not None and supervisor_info.get("healthy"):
|
||||
@@ -84,6 +86,8 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
|
||||
os_info = get_os_info(hass) or {}
|
||||
information["board"] = os_info.get("board")
|
||||
|
||||
# Not using aiohasupervisor for ping call below intentionally. Given system health
|
||||
# context, it seems preferable to do this check with minimal dependencies
|
||||
information["supervisor_api"] = system_health.async_check_can_reach_url(
|
||||
hass,
|
||||
SUPERVISOR_PING.format(ip_address=ip_address),
|
||||
@@ -95,8 +99,7 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
|
||||
)
|
||||
|
||||
information["installed_addons"] = ", ".join(
|
||||
f"{addon['name']} ({addon['version']})"
|
||||
for addon in (supervisor_info or {}).get("addons", [])
|
||||
f"{addon['name']} ({addon['version']})" for addon in addons_list
|
||||
)
|
||||
|
||||
return information
|
||||
|
||||
@@ -39,7 +39,7 @@ from .const import (
|
||||
WS_TYPE_EVENT,
|
||||
WS_TYPE_SUBSCRIBE,
|
||||
)
|
||||
from .coordinator import get_supervisor_info
|
||||
from .coordinator import get_addons_list
|
||||
from .update_helper import update_addon, update_core
|
||||
|
||||
SCHEMA_WEBSOCKET_EVENT = vol.Schema(
|
||||
@@ -168,8 +168,8 @@ async def websocket_update_addon(
|
||||
"""Websocket handler to update an addon."""
|
||||
addon_name: str | None = None
|
||||
addon_version: str | None = None
|
||||
addons: list = (get_supervisor_info(hass) or {}).get("addons", [])
|
||||
for addon in addons:
|
||||
addons_list: list[dict[str, Any]] = get_addons_list(hass) or []
|
||||
for addon in addons_list:
|
||||
if addon[ATTR_SLUG] == msg["addon"]:
|
||||
addon_name = addon[ATTR_NAME]
|
||||
addon_version = addon[ATTR_VERSION]
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from apyhiveapi import Auth
|
||||
@@ -26,6 +27,8 @@ from homeassistant.core import callback
|
||||
from . import HiveConfigEntry
|
||||
from .const import CONF_CODE, CONF_DEVICE_NAME, CONFIG_ENTRY_VERSION, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HiveFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a Hive config flow."""
|
||||
@@ -36,7 +39,7 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
self.data: dict[str, Any] = {}
|
||||
self.tokens: dict[str, str] = {}
|
||||
self.tokens: dict[str, Any] = {}
|
||||
self.device_registration: bool = False
|
||||
self.device_name = "Home Assistant"
|
||||
|
||||
@@ -67,11 +70,22 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
except HiveApiError:
|
||||
errors["base"] = "no_internet_available"
|
||||
|
||||
if (
|
||||
auth_result := self.tokens.get("AuthenticationResult", {})
|
||||
) and auth_result.get("NewDeviceMetadata"):
|
||||
_LOGGER.debug("Login successful, New device detected")
|
||||
self.device_registration = True
|
||||
return await self.async_step_configuration()
|
||||
|
||||
if self.tokens.get("ChallengeName") == "SMS_MFA":
|
||||
_LOGGER.debug("Login successful, SMS 2FA required")
|
||||
# Complete SMS 2FA.
|
||||
return await self.async_step_2fa()
|
||||
|
||||
if not errors:
|
||||
_LOGGER.debug(
|
||||
"Login successful, no new device detected, no 2FA required"
|
||||
)
|
||||
# Complete the entry.
|
||||
try:
|
||||
return await self.async_setup_hive_entry()
|
||||
@@ -103,6 +117,7 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
errors["base"] = "no_internet_available"
|
||||
|
||||
if not errors:
|
||||
_LOGGER.debug("2FA successful")
|
||||
if self.source == SOURCE_REAUTH:
|
||||
return await self.async_setup_hive_entry()
|
||||
self.device_registration = True
|
||||
@@ -119,10 +134,11 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
if user_input:
|
||||
if self.device_registration:
|
||||
_LOGGER.debug("Attempting to register device")
|
||||
self.device_name = user_input["device_name"]
|
||||
await self.hive_auth.device_registration(user_input["device_name"])
|
||||
self.data["device_data"] = await self.hive_auth.get_device_data()
|
||||
|
||||
_LOGGER.debug("Device registration successful")
|
||||
try:
|
||||
return await self.async_setup_hive_entry()
|
||||
except UnknownHiveError:
|
||||
@@ -142,6 +158,7 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
raise UnknownHiveError
|
||||
|
||||
# Setup the config entry
|
||||
_LOGGER.debug("Setting up Hive entry")
|
||||
self.data["tokens"] = self.tokens
|
||||
if self.source == SOURCE_REAUTH:
|
||||
return self.async_update_reload_and_abort(
|
||||
@@ -160,6 +177,7 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
CONF_USERNAME: entry_data[CONF_USERNAME],
|
||||
CONF_PASSWORD: entry_data[CONF_PASSWORD],
|
||||
}
|
||||
_LOGGER.debug("Reauthenticating user")
|
||||
return await self.async_step_user(data)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -38,6 +38,7 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.BUTTON,
|
||||
Platform.CLIMATE,
|
||||
Platform.FAN,
|
||||
Platform.LIGHT,
|
||||
Platform.NUMBER,
|
||||
|
||||
325
homeassistant/components/home_connect/climate.py
Normal file
325
homeassistant/components/home_connect/climate.py
Normal file
@@ -0,0 +1,325 @@
|
||||
"""Provides climate entities for Home Connect."""
|
||||
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
|
||||
from aiohomeconnect.model import EventKey, OptionKey, ProgramKey, SettingKey
|
||||
from aiohomeconnect.model.error import HomeConnectError
|
||||
from aiohomeconnect.model.program import Execution
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
FAN_AUTO,
|
||||
ClimateEntity,
|
||||
ClimateEntityDescription,
|
||||
ClimateEntityFeature,
|
||||
HVACMode,
|
||||
)
|
||||
from homeassistant.const import UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .common import setup_home_connect_entry
|
||||
from .const import BSH_POWER_ON, BSH_POWER_STANDBY, DOMAIN
|
||||
from .coordinator import HomeConnectApplianceCoordinator, HomeConnectConfigEntry
|
||||
from .entity import HomeConnectEntity
|
||||
from .utils import get_dict_from_home_connect_error
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
HVAC_MODES_PROGRAMS_MAP = {
|
||||
HVACMode.AUTO: ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_AUTO,
|
||||
HVACMode.COOL: ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_COOL,
|
||||
HVACMode.DRY: ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_DRY,
|
||||
HVACMode.FAN_ONLY: ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN,
|
||||
HVACMode.HEAT: ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_HEAT,
|
||||
}
|
||||
|
||||
PROGRAMS_HVAC_MODES_MAP = {v: k for k, v in HVAC_MODES_PROGRAMS_MAP.items()}
|
||||
|
||||
PRESET_MODES_PROGRAMS_MAP = {
|
||||
"active_clean": ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_ACTIVE_CLEAN,
|
||||
}
|
||||
PROGRAMS_PRESET_MODES_MAP = {v: k for k, v in PRESET_MODES_PROGRAMS_MAP.items()}
|
||||
|
||||
FAN_MODES_OPTIONS = {
|
||||
FAN_AUTO: "HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Automatic",
|
||||
"manual": "HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Manual",
|
||||
}
|
||||
|
||||
FAN_MODES_OPTIONS_INVERTED = {v: k for k, v in FAN_MODES_OPTIONS.items()}
|
||||
|
||||
|
||||
AIR_CONDITIONER_ENTITY_DESCRIPTION = ClimateEntityDescription(
|
||||
key="air_conditioner",
|
||||
translation_key="air_conditioner",
|
||||
name=None,
|
||||
)
|
||||
|
||||
|
||||
def _get_entities_for_appliance(
|
||||
appliance_coordinator: HomeConnectApplianceCoordinator,
|
||||
) -> list[HomeConnectEntity]:
|
||||
"""Get a list of entities."""
|
||||
return (
|
||||
[HomeConnectAirConditioningEntity(appliance_coordinator)]
|
||||
if (programs := appliance_coordinator.data.programs)
|
||||
and any(
|
||||
program.key in PROGRAMS_HVAC_MODES_MAP
|
||||
and (
|
||||
program.constraints is None
|
||||
or program.constraints.execution
|
||||
in (Execution.SELECT_AND_START, Execution.START_ONLY)
|
||||
)
|
||||
for program in programs
|
||||
)
|
||||
else []
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: HomeConnectConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Home Connect climate entities."""
|
||||
setup_home_connect_entry(
|
||||
hass,
|
||||
entry,
|
||||
_get_entities_for_appliance,
|
||||
async_add_entities,
|
||||
)
|
||||
|
||||
|
||||
class HomeConnectAirConditioningEntity(HomeConnectEntity, ClimateEntity):
|
||||
"""Representation of a Home Connect climate entity."""
|
||||
|
||||
# Note: The base class requires this to be set even though this
|
||||
# class doesn't support any temperature related functionality.
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: HomeConnectApplianceCoordinator,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(
|
||||
coordinator,
|
||||
AIR_CONDITIONER_ENTITY_DESCRIPTION,
|
||||
context_override=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
|
||||
)
|
||||
|
||||
@property
|
||||
def hvac_modes(self) -> list[HVACMode]:
|
||||
"""Return the list of available hvac operation modes."""
|
||||
hvac_modes = [
|
||||
hvac_mode
|
||||
for program in self.appliance.programs
|
||||
if (hvac_mode := PROGRAMS_HVAC_MODES_MAP.get(program.key))
|
||||
and (
|
||||
program.constraints is None
|
||||
or program.constraints.execution
|
||||
in (Execution.SELECT_AND_START, Execution.START_ONLY)
|
||||
)
|
||||
]
|
||||
if SettingKey.BSH_COMMON_POWER_STATE in self.appliance.settings:
|
||||
hvac_modes.append(HVACMode.OFF)
|
||||
return hvac_modes
|
||||
|
||||
@property
|
||||
def preset_modes(self) -> list[str] | None:
|
||||
"""Return a list of available preset modes."""
|
||||
return (
|
||||
[
|
||||
PROGRAMS_PRESET_MODES_MAP[
|
||||
ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_ACTIVE_CLEAN
|
||||
]
|
||||
]
|
||||
if any(
|
||||
program.key
|
||||
is ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_ACTIVE_CLEAN
|
||||
for program in self.appliance.programs
|
||||
)
|
||||
else None
|
||||
)
|
||||
|
||||
@property
|
||||
def supported_features(self) -> ClimateEntityFeature:
|
||||
"""Return the list of supported features."""
|
||||
features = ClimateEntityFeature(0)
|
||||
if SettingKey.BSH_COMMON_POWER_STATE in self.appliance.settings:
|
||||
features |= ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF
|
||||
if self.preset_modes:
|
||||
features |= ClimateEntityFeature.PRESET_MODE
|
||||
if self.appliance.options.get(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE
|
||||
):
|
||||
features |= ClimateEntityFeature.FAN_MODE
|
||||
return features
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update_fan_mode(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
self.async_write_ha_state()
|
||||
_LOGGER.debug(
|
||||
"Updated %s (fan mode), new state: %s", self.entity_id, self.fan_mode
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""When entity is added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
self.async_on_remove(
|
||||
self.coordinator.async_add_listener(
|
||||
self.async_write_ha_state,
|
||||
EventKey.BSH_COMMON_APPLIANCE_CONNECTED,
|
||||
)
|
||||
)
|
||||
self.async_on_remove(
|
||||
self.coordinator.async_add_listener(
|
||||
self._handle_coordinator_update_fan_mode,
|
||||
EventKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
|
||||
)
|
||||
)
|
||||
self.async_on_remove(
|
||||
self.coordinator.async_add_listener(
|
||||
self._handle_coordinator_update,
|
||||
EventKey(SettingKey.BSH_COMMON_POWER_STATE),
|
||||
)
|
||||
)
|
||||
|
||||
def update_native_value(self) -> None:
|
||||
"""Set the HVAC Mode and preset mode values."""
|
||||
event = self.appliance.events.get(EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM)
|
||||
program_key = cast(ProgramKey, event.value) if event else None
|
||||
power_state = self.appliance.settings.get(SettingKey.BSH_COMMON_POWER_STATE)
|
||||
self._attr_hvac_mode = (
|
||||
HVACMode.OFF
|
||||
if power_state is not None and power_state.value != BSH_POWER_ON
|
||||
else PROGRAMS_HVAC_MODES_MAP.get(program_key)
|
||||
if program_key
|
||||
and program_key
|
||||
!= ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_ACTIVE_CLEAN
|
||||
else None
|
||||
)
|
||||
self._attr_preset_mode = (
|
||||
PROGRAMS_PRESET_MODES_MAP.get(program_key)
|
||||
if program_key
|
||||
== ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_ACTIVE_CLEAN
|
||||
else None
|
||||
)
|
||||
|
||||
@property
|
||||
def fan_mode(self) -> str | None:
|
||||
"""Return the fan setting."""
|
||||
option_value = None
|
||||
if event := self.appliance.events.get(
|
||||
EventKey(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE
|
||||
)
|
||||
):
|
||||
option_value = event.value
|
||||
return (
|
||||
FAN_MODES_OPTIONS_INVERTED.get(cast(str, option_value))
|
||||
if option_value is not None
|
||||
else None
|
||||
)
|
||||
|
||||
@property
|
||||
def fan_modes(self) -> list[str] | None:
|
||||
"""Return the list of available fan modes."""
|
||||
if (
|
||||
(
|
||||
option_definition := self.appliance.options.get(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE
|
||||
)
|
||||
)
|
||||
and (option_constraints := option_definition.constraints)
|
||||
and option_constraints.allowed_values
|
||||
):
|
||||
return [
|
||||
fan_mode
|
||||
for fan_mode, api_value in FAN_MODES_OPTIONS.items()
|
||||
if api_value in option_constraints.allowed_values
|
||||
]
|
||||
if option_definition:
|
||||
# Then the constraints or the allowed values are not present
|
||||
# So we stick to the default values
|
||||
return list(FAN_MODES_OPTIONS.keys())
|
||||
return None
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Switch the device on."""
|
||||
try:
|
||||
await self.coordinator.client.set_setting(
|
||||
self.appliance.info.ha_id,
|
||||
setting_key=SettingKey.BSH_COMMON_POWER_STATE,
|
||||
value=BSH_POWER_ON,
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="power_on",
|
||||
translation_placeholders={
|
||||
**get_dict_from_home_connect_error(err),
|
||||
"appliance_name": self.appliance.info.name,
|
||||
"value": BSH_POWER_ON,
|
||||
},
|
||||
) from err
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Switch the device off."""
|
||||
try:
|
||||
await self.coordinator.client.set_setting(
|
||||
self.appliance.info.ha_id,
|
||||
setting_key=SettingKey.BSH_COMMON_POWER_STATE,
|
||||
value=BSH_POWER_STANDBY,
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="power_off",
|
||||
translation_placeholders={
|
||||
**get_dict_from_home_connect_error(err),
|
||||
"appliance_name": self.appliance.info.name,
|
||||
"value": BSH_POWER_STANDBY,
|
||||
},
|
||||
) from err
|
||||
|
||||
async def _set_program(self, program_key: ProgramKey) -> None:
|
||||
try:
|
||||
await self.coordinator.client.start_program(
|
||||
self.appliance.info.ha_id, program_key=program_key
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="start_program",
|
||||
translation_placeholders={
|
||||
**get_dict_from_home_connect_error(err),
|
||||
"program": program_key.value,
|
||||
},
|
||||
) from err
|
||||
_LOGGER.debug("Updated %s, new state: %s", self.entity_id, self.state)
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set new target hvac mode."""
|
||||
if hvac_mode is HVACMode.OFF:
|
||||
await self.async_turn_off()
|
||||
else:
|
||||
await self._set_program(HVAC_MODES_PROGRAMS_MAP[hvac_mode])
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set new preset mode."""
|
||||
await self._set_program(PRESET_MODES_PROGRAMS_MAP[preset_mode])
|
||||
|
||||
async def async_set_fan_mode(self, fan_mode: str) -> None:
|
||||
"""Set new target fan mode."""
|
||||
await super().async_set_option_with_key(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
|
||||
FAN_MODES_OPTIONS[fan_mode],
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Updated %s's speed mode option, new state: %s", self.entity_id, self.state
|
||||
)
|
||||
@@ -63,6 +63,7 @@ BSH_DOOR_STATE_OPEN = "BSH.Common.EnumType.DoorState.Open"
|
||||
|
||||
SERVICE_SET_PROGRAM_AND_OPTIONS = "set_program_and_options"
|
||||
SERVICE_SETTING = "change_setting"
|
||||
SERVICE_START_SELECTED_PROGRAM = "start_selected_program"
|
||||
|
||||
ATTR_AFFECTS_TO = "affects_to"
|
||||
ATTR_KEY = "key"
|
||||
|
||||
@@ -79,6 +79,29 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectApplianceCoordinator]):
|
||||
"""
|
||||
return self.appliance.info.connected and self._attr_available
|
||||
|
||||
async def async_set_option_with_key(
|
||||
self, option_key: OptionKey, value: Any
|
||||
) -> None:
|
||||
"""Set an option for the entity."""
|
||||
try:
|
||||
# We try to set the active program option first,
|
||||
# if it fails we try to set the selected program option
|
||||
with contextlib.suppress(ActiveProgramNotSetError):
|
||||
await self.coordinator.client.set_active_program_option(
|
||||
self.appliance.info.ha_id, option_key=option_key, value=value
|
||||
)
|
||||
return
|
||||
|
||||
await self.coordinator.client.set_selected_program_option(
|
||||
self.appliance.info.ha_id, option_key=option_key, value=value
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_option",
|
||||
translation_placeholders=get_dict_from_home_connect_error(err),
|
||||
) from err
|
||||
|
||||
|
||||
class HomeConnectOptionEntity(HomeConnectEntity):
|
||||
"""Class for entities that represents program options."""
|
||||
@@ -95,40 +118,9 @@ class HomeConnectOptionEntity(HomeConnectEntity):
|
||||
return event.value
|
||||
return None
|
||||
|
||||
async def async_set_option(self, value: str | float | bool) -> None:
|
||||
async def async_set_option(self, value: Any) -> None:
|
||||
"""Set an option for the entity."""
|
||||
try:
|
||||
# We try to set the active program option first,
|
||||
# if it fails we try to set the selected program option
|
||||
with contextlib.suppress(ActiveProgramNotSetError):
|
||||
await self.coordinator.client.set_active_program_option(
|
||||
self.appliance.info.ha_id,
|
||||
option_key=self.bsh_key,
|
||||
value=value,
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Updated %s for the active program, new state: %s",
|
||||
self.entity_id,
|
||||
self.state,
|
||||
)
|
||||
return
|
||||
|
||||
await self.coordinator.client.set_selected_program_option(
|
||||
self.appliance.info.ha_id,
|
||||
option_key=self.bsh_key,
|
||||
value=value,
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Updated %s for the selected program, new state: %s",
|
||||
self.entity_id,
|
||||
self.state,
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_option",
|
||||
translation_placeholders=get_dict_from_home_connect_error(err),
|
||||
) from err
|
||||
await super().async_set_option_with_key(self.bsh_key, value)
|
||||
|
||||
@property
|
||||
def bsh_key(self) -> OptionKey:
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
"""Provides fan entities for Home Connect."""
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
from typing import cast
|
||||
|
||||
from aiohomeconnect.model import EventKey, OptionKey
|
||||
from aiohomeconnect.model.error import ActiveProgramNotSetError, HomeConnectError
|
||||
|
||||
from homeassistant.components.fan import (
|
||||
FanEntity,
|
||||
@@ -13,14 +11,11 @@ from homeassistant.components.fan import (
|
||||
FanEntityFeature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .common import setup_home_connect_entry
|
||||
from .const import DOMAIN
|
||||
from .coordinator import HomeConnectApplianceCoordinator, HomeConnectConfigEntry
|
||||
from .entity import HomeConnectEntity
|
||||
from .utils import get_dict_from_home_connect_error
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -176,7 +171,7 @@ class HomeConnectAirConditioningFanEntity(HomeConnectEntity, FanEntity):
|
||||
|
||||
async def async_set_percentage(self, percentage: int) -> None:
|
||||
"""Set the speed of the fan, as a percentage."""
|
||||
await self._async_set_option(
|
||||
await super().async_set_option_with_key(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE,
|
||||
percentage,
|
||||
)
|
||||
@@ -188,41 +183,14 @@ class HomeConnectAirConditioningFanEntity(HomeConnectEntity, FanEntity):
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set new target fan mode."""
|
||||
await self._async_set_option(
|
||||
await super().async_set_option_with_key(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
|
||||
FAN_SPEED_MODE_OPTIONS[preset_mode],
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Updated %s's speed mode option, new state: %s",
|
||||
self.entity_id,
|
||||
self.state,
|
||||
"Updated %s's speed mode option, new state: %s", self.entity_id, self.state
|
||||
)
|
||||
|
||||
async def _async_set_option(self, key: OptionKey, value: str | int) -> None:
|
||||
"""Set an option for the entity."""
|
||||
try:
|
||||
# We try to set the active program option first,
|
||||
# if it fails we try to set the selected program option
|
||||
with contextlib.suppress(ActiveProgramNotSetError):
|
||||
await self.coordinator.client.set_active_program_option(
|
||||
self.appliance.info.ha_id,
|
||||
option_key=key,
|
||||
value=value,
|
||||
)
|
||||
return
|
||||
|
||||
await self.coordinator.client.set_selected_program_option(
|
||||
self.appliance.info.ha_id,
|
||||
option_key=key,
|
||||
value=value,
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_option",
|
||||
translation_placeholders=get_dict_from_home_connect_error(err),
|
||||
) from err
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
|
||||
@@ -245,25 +245,10 @@
|
||||
"change_setting": {
|
||||
"service": "mdi:cog"
|
||||
},
|
||||
"pause_program": {
|
||||
"service": "mdi:pause"
|
||||
},
|
||||
"resume_program": {
|
||||
"service": "mdi:play-pause"
|
||||
},
|
||||
"select_program": {
|
||||
"service": "mdi:form-select"
|
||||
},
|
||||
"set_option_active": {
|
||||
"service": "mdi:gesture-tap"
|
||||
},
|
||||
"set_option_selected": {
|
||||
"service": "mdi:gesture-tap"
|
||||
},
|
||||
"set_program_and_options": {
|
||||
"service": "mdi:form-select"
|
||||
},
|
||||
"start_program": {
|
||||
"start_selected_program": {
|
||||
"service": "mdi:play"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,6 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aiohomeconnect"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiohomeconnect==0.30.0"],
|
||||
"requirements": ["aiohomeconnect==0.32.0"],
|
||||
"zeroconf": ["_homeconnect._tcp.local."]
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user