forked from home-assistant/core
Compare commits
231 Commits
2025.3.0b0
...
2025.3.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f244b2b66 | ||
|
|
1b7e53fd01 | ||
|
|
bfabf972a8 | ||
|
|
c0c997eed8 | ||
|
|
14b07087dc | ||
|
|
f54a634563 | ||
|
|
e98d518b0b | ||
|
|
121ee27105 | ||
|
|
5681f4f2ea | ||
|
|
8a63fa3bb7 | ||
|
|
983a2f513d | ||
|
|
aab349e787 | ||
|
|
21ced23c3c | ||
|
|
a453e9d4c2 | ||
|
|
3f493dce06 | ||
|
|
403fe36489 | ||
|
|
66fd7d9e8a | ||
|
|
c9ceade10d | ||
|
|
85b6b3a360 | ||
|
|
a2102f9b98 | ||
|
|
28cad1d085 | ||
|
|
9d8dbfbf3f | ||
|
|
1382a001e3 | ||
|
|
88e3dcccda | ||
|
|
43e24cf833 | ||
|
|
65aef40a3f | ||
|
|
4d1c89f0d1 | ||
|
|
831f2dc30e | ||
|
|
1566ab3b28 | ||
|
|
c852e1398c | ||
|
|
761be9342e | ||
|
|
54ad44a574 | ||
|
|
fed4015bab | ||
|
|
019a0ebf9b | ||
|
|
7607b7d494 | ||
|
|
8b96a9606d | ||
|
|
6349821037 | ||
|
|
db26a42734 | ||
|
|
74fe35f44e | ||
|
|
e648716ddf | ||
|
|
2e20245cdf | ||
|
|
a12915fc14 | ||
|
|
3d5e4b980f | ||
|
|
f2f653efcf | ||
|
|
b5c7bdd98f | ||
|
|
38e6133202 | ||
|
|
8541dc5bde | ||
|
|
5327996bad | ||
|
|
4ddc43a9d9 | ||
|
|
e6dea4179b | ||
|
|
0318b85517 | ||
|
|
29987d443e | ||
|
|
cbfd8707b9 | ||
|
|
5f158f5c87 | ||
|
|
d67ccd2fce | ||
|
|
29c9d3804b | ||
|
|
76d478c84f | ||
|
|
5d9d6f099c | ||
|
|
e4b31640b3 | ||
|
|
c43f6a67d0 | ||
|
|
0bbab63193 | ||
|
|
06188b8fbd | ||
|
|
bbbb5cadd4 | ||
|
|
52fcdda429 | ||
|
|
7d93ceb0f0 | ||
|
|
873e4b77eb | ||
|
|
61f0eabcbb | ||
|
|
134b5319e1 | ||
|
|
ee78e21950 | ||
|
|
323bc54efc | ||
|
|
fd2dee3c11 | ||
|
|
fc53322c07 | ||
|
|
faf9977abb | ||
|
|
7336c8fc07 | ||
|
|
5cfaeda95b | ||
|
|
a78e9039c6 | ||
|
|
227f3cea25 | ||
|
|
cab4890246 | ||
|
|
95fd096bdd | ||
|
|
91cf8cb547 | ||
|
|
3ce4f3f918 | ||
|
|
4e89948b5c | ||
|
|
9f95383201 | ||
|
|
7e452521c8 | ||
|
|
991de6f1d0 | ||
|
|
be32e3fe8f | ||
|
|
d6eb61e9ec | ||
|
|
e74fe69d65 | ||
|
|
208406123e | ||
|
|
8bcd135f3d | ||
|
|
e7ea0e435e | ||
|
|
b15b680cfe | ||
|
|
5e26d98bdf | ||
|
|
9f94ee280a | ||
|
|
efa98539fa | ||
|
|
113cd4bfcc | ||
|
|
ccbaf76e44 | ||
|
|
5d9d93d3a1 | ||
|
|
c2c5274aac | ||
|
|
89756394c9 | ||
|
|
352aa88e79 | ||
|
|
714962bd7a | ||
|
|
fb4c50b5dc | ||
|
|
b4794b2029 | ||
|
|
3a8c8accfe | ||
|
|
844adfc590 | ||
|
|
a279e23fb5 | ||
|
|
af9bbd0585 | ||
|
|
1304194f09 | ||
|
|
e909417a3f | ||
|
|
02706c116d | ||
|
|
3af6b5cb4c | ||
|
|
35c1bb1ec5 | ||
|
|
97cc3984c5 | ||
|
|
98e317dd55 | ||
|
|
ed088aa72f | ||
|
|
51162320cb | ||
|
|
b88eab8ba3 | ||
|
|
6c080ee650 | ||
|
|
8056b0df2b | ||
|
|
3f94b7a61c | ||
|
|
1484e46317 | ||
|
|
2812c8a993 | ||
|
|
5043e2ad10 | ||
|
|
2c2fd76270 | ||
|
|
7001f8daaf | ||
|
|
b41fc932c5 | ||
|
|
0872243297 | ||
|
|
bba889975a | ||
|
|
01e8ca6495 | ||
|
|
7d82375f81 | ||
|
|
47033e587b | ||
|
|
e73b08b269 | ||
|
|
a195a9107b | ||
|
|
185949cc18 | ||
|
|
c129f27c95 | ||
|
|
6a5a66e2f9 | ||
|
|
db63d9fcbf | ||
|
|
5b3d798eca | ||
|
|
a0dde2a7d6 | ||
|
|
1bdc33d52d | ||
|
|
f1d332da5a | ||
|
|
304c13261a | ||
|
|
c58cbfd6f4 | ||
|
|
b890d3e15a | ||
|
|
2c9b8b6835 | ||
|
|
73cc1f51ca | ||
|
|
dca77e8232 | ||
|
|
03cb177e7c | ||
|
|
ad04b53615 | ||
|
|
46bcb307f6 | ||
|
|
b816625028 | ||
|
|
0940fc7806 | ||
|
|
50aefc3653 | ||
|
|
c0dc83cbc0 | ||
|
|
8382663be4 | ||
|
|
7e1309d874 | ||
|
|
1d0cba1a43 | ||
|
|
7d9a6ceb6b | ||
|
|
6abdb28a03 | ||
|
|
3690e03951 | ||
|
|
4fe4d14f16 | ||
|
|
74e8ffa555 | ||
|
|
c257b228f1 | ||
|
|
6ff0f67d03 | ||
|
|
8fdff9ca37 | ||
|
|
9055dff9bd | ||
|
|
e766d681b5 | ||
|
|
511e57d0b3 | ||
|
|
74be49d00d | ||
|
|
684c3aac6b | ||
|
|
a718b6ebff | ||
|
|
f17274d417 | ||
|
|
1530139a61 | ||
|
|
f56d65b2ec | ||
|
|
21277a81d3 | ||
|
|
e1ce5b8c69 | ||
|
|
0323a9c4e6 | ||
|
|
c7d89398a0 | ||
|
|
8cc587d3a7 | ||
|
|
5ad156767a | ||
|
|
f54b3f4de2 | ||
|
|
6f0c62dc9d | ||
|
|
dce8bca103 | ||
|
|
22af8af132 | ||
|
|
8a62b882bf | ||
|
|
708f22fe6f | ||
|
|
a4e71e2055 | ||
|
|
61a3cc37e0 | ||
|
|
a0668e5a5b | ||
|
|
b4b7142b55 | ||
|
|
108b71d33c | ||
|
|
2636a47333 | ||
|
|
17116fcd6c | ||
|
|
17c16144d1 | ||
|
|
178d509d56 | ||
|
|
09c129de40 | ||
|
|
07128ba063 | ||
|
|
a786ff53ff | ||
|
|
d2e19c829d | ||
|
|
94b342f26a | ||
|
|
9e3e6b3f43 | ||
|
|
4300900322 | ||
|
|
342e04974d | ||
|
|
fdb4c0a81f | ||
|
|
6de878ffe4 | ||
|
|
c63aaec09e | ||
|
|
d8bf47c101 | ||
|
|
736ff8828d | ||
|
|
b501999a4c | ||
|
|
3985f1c6c8 | ||
|
|
46ec3987a8 | ||
|
|
df4e5a54e3 | ||
|
|
d8a259044f | ||
|
|
0891669aee | ||
|
|
83c0351338 | ||
|
|
c5e5fe555d | ||
|
|
345ba73777 | ||
|
|
e4200a79a2 | ||
|
|
381fa65ba0 | ||
|
|
16314711b8 | ||
|
|
553abe4a4a | ||
|
|
6a1bbdb3a7 | ||
|
|
59d92c75bd | ||
|
|
7732e6878e | ||
|
|
2cde317d59 | ||
|
|
0c08430507 | ||
|
|
fa6d7d5e3c | ||
|
|
585b950a46 | ||
|
|
3effc2e182 | ||
|
|
0e1602ff71 |
40
.github/workflows/wheels.yml
vendored
40
.github/workflows/wheels.yml
vendored
@@ -218,15 +218,7 @@ jobs:
|
||||
sed -i "/uv/d" requirements.txt
|
||||
sed -i "/uv/d" requirements_diff.txt
|
||||
|
||||
- name: Split requirements all
|
||||
run: |
|
||||
# We split requirements all into multiple files.
|
||||
# This is to prevent the build from running out of memory when
|
||||
# resolving packages on 32-bits systems (like armhf, armv7).
|
||||
|
||||
split -l $(expr $(expr $(cat requirements_all.txt | wc -l) + 1) / 3) requirements_all_wheels_${{ matrix.arch }}.txt requirements_all.txt
|
||||
|
||||
- name: Build wheels (part 1)
|
||||
- name: Build wheels
|
||||
uses: home-assistant/wheels@2024.11.0
|
||||
with:
|
||||
abi: ${{ matrix.abi }}
|
||||
@@ -238,32 +230,4 @@ jobs:
|
||||
skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl
|
||||
constraints: "homeassistant/package_constraints.txt"
|
||||
requirements-diff: "requirements_diff.txt"
|
||||
requirements: "requirements_all.txtaa"
|
||||
|
||||
- name: Build wheels (part 2)
|
||||
uses: home-assistant/wheels@2024.11.0
|
||||
with:
|
||||
abi: ${{ matrix.abi }}
|
||||
tag: musllinux_1_2
|
||||
arch: ${{ matrix.arch }}
|
||||
wheels-key: ${{ secrets.WHEELS_KEY }}
|
||||
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;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-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
|
||||
constraints: "homeassistant/package_constraints.txt"
|
||||
requirements-diff: "requirements_diff.txt"
|
||||
requirements: "requirements_all.txtab"
|
||||
|
||||
- name: Build wheels (part 3)
|
||||
uses: home-assistant/wheels@2024.11.0
|
||||
with:
|
||||
abi: ${{ matrix.abi }}
|
||||
tag: musllinux_1_2
|
||||
arch: ${{ matrix.arch }}
|
||||
wheels-key: ${{ secrets.WHEELS_KEY }}
|
||||
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;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-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
|
||||
constraints: "homeassistant/package_constraints.txt"
|
||||
requirements-diff: "requirements_diff.txt"
|
||||
requirements: "requirements_all.txtac"
|
||||
requirements: "requirements_all.txt"
|
||||
|
||||
@@ -14,7 +14,7 @@ from homeassistant.components.notify import (
|
||||
)
|
||||
from homeassistant.const import STATE_IDLE, STATE_OFF, STATE_ON
|
||||
from homeassistant.core import Event, EventStateChangedData, HassJob, HomeAssistant
|
||||
from homeassistant.exceptions import ServiceNotFound
|
||||
from homeassistant.exceptions import ServiceNotFound, ServiceValidationError
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.event import (
|
||||
async_track_point_in_time,
|
||||
@@ -195,7 +195,8 @@ class AlertEntity(Entity):
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Async Acknowledge alert."""
|
||||
LOGGER.debug("Acknowledged Alert: %s", self._attr_name)
|
||||
if not self._can_ack:
|
||||
raise ServiceValidationError("This alert cannot be acknowledged")
|
||||
self._ack = True
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
1
homeassistant/components/apollo_automation/__init__.py
Normal file
1
homeassistant/components/apollo_automation/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Virtual integration: Apollo Automation."""
|
||||
6
homeassistant/components/apollo_automation/manifest.json
Normal file
6
homeassistant/components/apollo_automation/manifest.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"domain": "apollo_automation",
|
||||
"name": "Apollo Automation",
|
||||
"integration_type": "virtual",
|
||||
"supported_by": "esphome"
|
||||
}
|
||||
@@ -1103,12 +1103,16 @@ class PipelineRun:
|
||||
) & conversation.ConversationEntityFeature.CONTROL:
|
||||
intent_filter = _async_local_fallback_intent_filter
|
||||
|
||||
# Try local intents first, if preferred.
|
||||
elif self.pipeline.prefer_local_intents and (
|
||||
intent_response := await conversation.async_handle_intents(
|
||||
self.hass,
|
||||
user_input,
|
||||
intent_filter=intent_filter,
|
||||
# Try local intents
|
||||
if (
|
||||
intent_response is None
|
||||
and self.pipeline.prefer_local_intents
|
||||
and (
|
||||
intent_response := await conversation.async_handle_intents(
|
||||
self.hass,
|
||||
user_input,
|
||||
intent_filter=intent_filter,
|
||||
)
|
||||
)
|
||||
):
|
||||
# Local intent matched
|
||||
|
||||
@@ -14,6 +14,7 @@ from itertools import chain
|
||||
import json
|
||||
from pathlib import Path, PurePath
|
||||
import shutil
|
||||
import sys
|
||||
import tarfile
|
||||
import time
|
||||
from typing import IO, TYPE_CHECKING, Any, Protocol, TypedDict, cast
|
||||
@@ -117,6 +118,7 @@ class BackupManagerState(StrEnum):
|
||||
|
||||
IDLE = "idle"
|
||||
CREATE_BACKUP = "create_backup"
|
||||
BLOCKED = "blocked"
|
||||
RECEIVE_BACKUP = "receive_backup"
|
||||
RESTORE_BACKUP = "restore_backup"
|
||||
|
||||
@@ -225,6 +227,13 @@ class RestoreBackupEvent(ManagerStateEvent):
|
||||
state: RestoreBackupState
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True, slots=True)
|
||||
class BlockedEvent(ManagerStateEvent):
|
||||
"""Backup manager blocked, Home Assistant is starting."""
|
||||
|
||||
manager_state: BackupManagerState = BackupManagerState.BLOCKED
|
||||
|
||||
|
||||
class BackupPlatformProtocol(Protocol):
|
||||
"""Define the format that backup platforms can have."""
|
||||
|
||||
@@ -308,6 +317,12 @@ class DecryptOnDowloadNotSupported(BackupManagerError):
|
||||
_message = "On-the-fly decryption is not supported for this backup."
|
||||
|
||||
|
||||
class BackupManagerExceptionGroup(BackupManagerError, ExceptionGroup):
|
||||
"""Raised when multiple exceptions occur."""
|
||||
|
||||
error_code = "multiple_errors"
|
||||
|
||||
|
||||
class BackupManager:
|
||||
"""Define the format that backup managers can have."""
|
||||
|
||||
@@ -333,7 +348,7 @@ class BackupManager:
|
||||
self.remove_next_delete_event: Callable[[], None] | None = None
|
||||
|
||||
# Latest backup event and backup event subscribers
|
||||
self.last_event: ManagerStateEvent = IdleEvent()
|
||||
self.last_event: ManagerStateEvent = BlockedEvent()
|
||||
self.last_non_idle_event: ManagerStateEvent | None = None
|
||||
self._backup_event_subscriptions = hass.data[
|
||||
DATA_BACKUP
|
||||
@@ -347,10 +362,19 @@ class BackupManager:
|
||||
self.known_backups.load(stored["backups"])
|
||||
|
||||
await self._reader_writer.async_validate_config(config=self.config)
|
||||
|
||||
await self._reader_writer.async_resume_restore_progress_after_restart(
|
||||
on_progress=self.async_on_backup_event
|
||||
)
|
||||
|
||||
async def set_manager_idle_after_start(hass: HomeAssistant) -> None:
|
||||
"""Set manager to idle after start."""
|
||||
self.async_on_backup_event(IdleEvent())
|
||||
|
||||
if self.state == BackupManagerState.BLOCKED:
|
||||
# If we're not finishing a restore job, set the manager to idle after start
|
||||
start.async_at_started(self.hass, set_manager_idle_after_start)
|
||||
|
||||
await self.load_platforms()
|
||||
|
||||
@property
|
||||
@@ -1286,7 +1310,7 @@ class BackupManager:
|
||||
if (current_state := self.state) != (new_state := event.manager_state):
|
||||
LOGGER.debug("Backup state: %s -> %s", current_state, new_state)
|
||||
self.last_event = event
|
||||
if not isinstance(event, IdleEvent):
|
||||
if not isinstance(event, (BlockedEvent, IdleEvent)):
|
||||
self.last_non_idle_event = event
|
||||
for subscription in self._backup_event_subscriptions:
|
||||
subscription(event)
|
||||
@@ -1605,10 +1629,24 @@ class CoreBackupReaderWriter(BackupReaderWriter):
|
||||
)
|
||||
finally:
|
||||
# Inform integrations the backup is done
|
||||
# If there's an unhandled exception, we keep it so we can rethrow it in case
|
||||
# the post backup actions also fail.
|
||||
unhandled_exc = sys.exception()
|
||||
try:
|
||||
await manager.async_post_backup_actions()
|
||||
except BackupManagerError as err:
|
||||
raise BackupReaderWriterError(str(err)) from err
|
||||
try:
|
||||
await manager.async_post_backup_actions()
|
||||
except BackupManagerError as err:
|
||||
raise BackupReaderWriterError(str(err)) from err
|
||||
except Exception as err:
|
||||
if not unhandled_exc:
|
||||
raise
|
||||
# If there's an unhandled exception, we wrap both that and the exception
|
||||
# from the post backup actions in an ExceptionGroup so the caller is
|
||||
# aware of both exceptions.
|
||||
raise BackupManagerExceptionGroup(
|
||||
f"Multiple errors when creating backup: {unhandled_exc}, {err}",
|
||||
[unhandled_exc, err],
|
||||
) from None
|
||||
|
||||
def _mkdir_and_generate_backup_contents(
|
||||
self,
|
||||
|
||||
@@ -75,6 +75,9 @@ class BluesoundConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self, discovery_info: ZeroconfServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a flow initialized by zeroconf discovery."""
|
||||
# the player can have an ipv6 address, but the api is only available on ipv4
|
||||
if discovery_info.ip_address.version != 4:
|
||||
return self.async_abort(reason="no_ipv4_address")
|
||||
if discovery_info.port is not None:
|
||||
self._port = discovery_info.port
|
||||
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]"
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"no_ipv4_address": "No IPv4 address found."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
|
||||
@@ -21,6 +21,6 @@
|
||||
"bluetooth-auto-recovery==1.4.4",
|
||||
"bluetooth-data-tools==1.23.4",
|
||||
"dbus-fast==2.33.0",
|
||||
"habluetooth==3.24.0"
|
||||
"habluetooth==3.24.1"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -77,9 +77,12 @@ class BringEventEntity(BringBaseEntity, EventEntity):
|
||||
attributes = asdict(activity.content)
|
||||
|
||||
attributes["last_activity_by"] = next(
|
||||
x.name
|
||||
for x in bring_list.users.users
|
||||
if x.publicUuid == activity.content.publicUserUuid
|
||||
(
|
||||
x.name
|
||||
for x in bring_list.users.users
|
||||
if x.publicUuid == activity.content.publicUserUuid
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
self._trigger_event(
|
||||
|
||||
@@ -153,6 +153,27 @@ def _has_min_duration(
|
||||
return validate
|
||||
|
||||
|
||||
def _has_positive_interval(
|
||||
start_key: str, end_key: str, duration_key: str
|
||||
) -> Callable[[dict[str, Any]], dict[str, Any]]:
|
||||
"""Verify that the time span between start and end is greater than zero."""
|
||||
|
||||
def validate(obj: dict[str, Any]) -> dict[str, Any]:
|
||||
if (duration := obj.get(duration_key)) is not None:
|
||||
if duration <= datetime.timedelta(seconds=0):
|
||||
raise vol.Invalid(f"Expected positive duration ({duration})")
|
||||
return obj
|
||||
|
||||
if (start := obj.get(start_key)) and (end := obj.get(end_key)):
|
||||
if start >= end:
|
||||
raise vol.Invalid(
|
||||
f"Expected end time to be after start time ({start}, {end})"
|
||||
)
|
||||
return obj
|
||||
|
||||
return validate
|
||||
|
||||
|
||||
def _has_same_type(*keys: Any) -> Callable[[dict[str, Any]], dict[str, Any]]:
|
||||
"""Verify that all values are of the same type."""
|
||||
|
||||
@@ -281,6 +302,7 @@ SERVICE_GET_EVENTS_SCHEMA: Final = vol.All(
|
||||
),
|
||||
}
|
||||
),
|
||||
_has_positive_interval(EVENT_START_DATETIME, EVENT_END_DATETIME, EVENT_DURATION),
|
||||
)
|
||||
|
||||
|
||||
@@ -870,6 +892,7 @@ async def async_get_events_service(
|
||||
end = start + service_call.data[EVENT_DURATION]
|
||||
else:
|
||||
end = service_call.data[EVENT_END_DATETIME]
|
||||
|
||||
calendar_event_list = await calendar.async_get_events(
|
||||
calendar.hass, dt_util.as_local(start), dt_util.as_local(end)
|
||||
)
|
||||
|
||||
@@ -68,7 +68,6 @@ from .const import ( # noqa: F401
|
||||
FAN_ON,
|
||||
FAN_TOP,
|
||||
HVAC_MODES,
|
||||
INTENT_GET_TEMPERATURE,
|
||||
INTENT_SET_TEMPERATURE,
|
||||
PRESET_ACTIVITY,
|
||||
PRESET_AWAY,
|
||||
|
||||
@@ -126,7 +126,6 @@ DEFAULT_MAX_HUMIDITY = 99
|
||||
|
||||
DOMAIN = "climate"
|
||||
|
||||
INTENT_GET_TEMPERATURE = "HassClimateGetTemperature"
|
||||
INTENT_SET_TEMPERATURE = "HassClimateSetTemperature"
|
||||
|
||||
SERVICE_SET_AUX_HEAT = "set_aux_heat"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Intents for the client integration."""
|
||||
"""Intents for the climate integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -11,7 +11,6 @@ from homeassistant.helpers import config_validation as cv, intent
|
||||
from . import (
|
||||
ATTR_TEMPERATURE,
|
||||
DOMAIN,
|
||||
INTENT_GET_TEMPERATURE,
|
||||
INTENT_SET_TEMPERATURE,
|
||||
SERVICE_SET_TEMPERATURE,
|
||||
ClimateEntityFeature,
|
||||
@@ -20,49 +19,9 @@ from . import (
|
||||
|
||||
async def async_setup_intents(hass: HomeAssistant) -> None:
|
||||
"""Set up the climate intents."""
|
||||
intent.async_register(hass, GetTemperatureIntent())
|
||||
intent.async_register(hass, SetTemperatureIntent())
|
||||
|
||||
|
||||
class GetTemperatureIntent(intent.IntentHandler):
|
||||
"""Handle GetTemperature intents."""
|
||||
|
||||
intent_type = INTENT_GET_TEMPERATURE
|
||||
description = "Gets the current temperature of a climate device or entity"
|
||||
slot_schema = {
|
||||
vol.Optional("area"): intent.non_empty_string,
|
||||
vol.Optional("name"): intent.non_empty_string,
|
||||
}
|
||||
platforms = {DOMAIN}
|
||||
|
||||
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
|
||||
"""Handle the intent."""
|
||||
hass = intent_obj.hass
|
||||
slots = self.async_validate_slots(intent_obj.slots)
|
||||
|
||||
name: str | None = None
|
||||
if "name" in slots:
|
||||
name = slots["name"]["value"]
|
||||
|
||||
area: str | None = None
|
||||
if "area" in slots:
|
||||
area = slots["area"]["value"]
|
||||
|
||||
match_constraints = intent.MatchTargetsConstraints(
|
||||
name=name, area_name=area, domains=[DOMAIN], assistant=intent_obj.assistant
|
||||
)
|
||||
match_result = intent.async_match_targets(hass, match_constraints)
|
||||
if not match_result.is_match:
|
||||
raise intent.MatchFailedError(
|
||||
result=match_result, constraints=match_constraints
|
||||
)
|
||||
|
||||
response = intent_obj.create_response()
|
||||
response.response_type = intent.IntentResponseType.QUERY_ANSWER
|
||||
response.async_set_states(matched_states=match_result.states)
|
||||
return response
|
||||
|
||||
|
||||
class SetTemperatureIntent(intent.IntentHandler):
|
||||
"""Handle SetTemperature intents."""
|
||||
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from aioelectricitymaps import (
|
||||
ElectricityMaps,
|
||||
ElectricityMapsError,
|
||||
ElectricityMapsInvalidTokenError,
|
||||
ElectricityMapsNoDataError,
|
||||
)
|
||||
@@ -36,6 +36,8 @@ TYPE_USE_HOME = "use_home_location"
|
||||
TYPE_SPECIFY_COORDINATES = "specify_coordinates"
|
||||
TYPE_SPECIFY_COUNTRY = "specify_country_code"
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ElectricityMapsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Co2signal."""
|
||||
@@ -158,7 +160,8 @@ class ElectricityMapsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors["base"] = "invalid_auth"
|
||||
except ElectricityMapsNoDataError:
|
||||
errors["base"] = "no_data"
|
||||
except ElectricityMapsError:
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected error occurred while checking API key")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
if self.source == SOURCE_REAUTH:
|
||||
|
||||
@@ -49,7 +49,11 @@ def async_get_chat_log(
|
||||
raise RuntimeError(
|
||||
"Cannot attach chat log delta listener unless initial caller"
|
||||
)
|
||||
if user_input is not None:
|
||||
if user_input is not None and (
|
||||
(content := chat_log.content[-1]).role != "user"
|
||||
# MyPy doesn't understand that content is a UserContent here
|
||||
or content.content != user_input.text # type: ignore[union-attr]
|
||||
):
|
||||
chat_log.async_add_user_content(UserContent(content=user_input.text))
|
||||
|
||||
yield chat_log
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==2.2.3", "home-assistant-intents==2025.2.5"]
|
||||
"requirements": ["hassil==2.2.3", "home-assistant-intents==2025.3.5"]
|
||||
}
|
||||
|
||||
@@ -24,7 +24,14 @@ from homeassistant.const import (
|
||||
STATE_UNKNOWN,
|
||||
UnitOfTime,
|
||||
)
|
||||
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
|
||||
from homeassistant.core import (
|
||||
Event,
|
||||
EventStateChangedData,
|
||||
EventStateReportedData,
|
||||
HomeAssistant,
|
||||
State,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
from homeassistant.helpers.device import async_device_info_to_link_from_entity
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
@@ -32,7 +39,10 @@ from homeassistant.helpers.entity_platform import (
|
||||
AddConfigEntryEntitiesCallback,
|
||||
AddEntitiesCallback,
|
||||
)
|
||||
from homeassistant.helpers.event import async_track_state_change_event
|
||||
from homeassistant.helpers.event import (
|
||||
async_track_state_change_event,
|
||||
async_track_state_report_event,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from .const import (
|
||||
@@ -200,13 +210,33 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
||||
_LOGGER.warning("Could not restore last state: %s", err)
|
||||
|
||||
@callback
|
||||
def calc_derivative(event: Event[EventStateChangedData]) -> None:
|
||||
def on_state_reported(event: Event[EventStateReportedData]) -> None:
|
||||
"""Handle constant sensor state."""
|
||||
if self._attr_native_value == Decimal(0):
|
||||
# If the derivative is zero, and the source sensor hasn't
|
||||
# changed state, then we know it will still be zero.
|
||||
return
|
||||
new_state = event.data["new_state"]
|
||||
if new_state is not None:
|
||||
calc_derivative(
|
||||
new_state, new_state.state, event.data["old_last_reported"]
|
||||
)
|
||||
|
||||
@callback
|
||||
def on_state_changed(event: Event[EventStateChangedData]) -> None:
|
||||
"""Handle changed sensor state."""
|
||||
new_state = event.data["new_state"]
|
||||
old_state = event.data["old_state"]
|
||||
if new_state is not None and old_state is not None:
|
||||
calc_derivative(new_state, old_state.state, old_state.last_reported)
|
||||
|
||||
def calc_derivative(
|
||||
new_state: State, old_value: str, old_last_reported: datetime
|
||||
) -> None:
|
||||
"""Handle the sensor state changes."""
|
||||
if (
|
||||
(old_state := event.data["old_state"]) is None
|
||||
or old_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE)
|
||||
or (new_state := event.data["new_state"]) is None
|
||||
or new_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE)
|
||||
if old_value in (STATE_UNKNOWN, STATE_UNAVAILABLE) or new_state.state in (
|
||||
STATE_UNKNOWN,
|
||||
STATE_UNAVAILABLE,
|
||||
):
|
||||
return
|
||||
|
||||
@@ -220,15 +250,15 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
||||
self._state_list = [
|
||||
(time_start, time_end, state)
|
||||
for time_start, time_end, state in self._state_list
|
||||
if (new_state.last_updated - time_end).total_seconds()
|
||||
if (new_state.last_reported - time_end).total_seconds()
|
||||
< self._time_window
|
||||
]
|
||||
|
||||
try:
|
||||
elapsed_time = (
|
||||
new_state.last_updated - old_state.last_updated
|
||||
new_state.last_reported - old_last_reported
|
||||
).total_seconds()
|
||||
delta_value = Decimal(new_state.state) - Decimal(old_state.state)
|
||||
delta_value = Decimal(new_state.state) - Decimal(old_value)
|
||||
new_derivative = (
|
||||
delta_value
|
||||
/ Decimal(elapsed_time)
|
||||
@@ -240,7 +270,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
||||
_LOGGER.warning("While calculating derivative: %s", err)
|
||||
except DecimalException as err:
|
||||
_LOGGER.warning(
|
||||
"Invalid state (%s > %s): %s", old_state.state, new_state.state, err
|
||||
"Invalid state (%s > %s): %s", old_value, new_state.state, err
|
||||
)
|
||||
except AssertionError as err:
|
||||
_LOGGER.error("Could not calculate derivative: %s", err)
|
||||
@@ -257,7 +287,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
||||
|
||||
# add latest derivative to the window list
|
||||
self._state_list.append(
|
||||
(old_state.last_updated, new_state.last_updated, new_derivative)
|
||||
(old_last_reported, new_state.last_reported, new_derivative)
|
||||
)
|
||||
|
||||
def calculate_weight(
|
||||
@@ -277,13 +307,19 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
||||
else:
|
||||
derivative = Decimal("0.00")
|
||||
for start, end, value in self._state_list:
|
||||
weight = calculate_weight(start, end, new_state.last_updated)
|
||||
weight = calculate_weight(start, end, new_state.last_reported)
|
||||
derivative = derivative + (value * Decimal(weight))
|
||||
self._attr_native_value = round(derivative, self._round_digits)
|
||||
self.async_write_ha_state()
|
||||
|
||||
self.async_on_remove(
|
||||
async_track_state_change_event(
|
||||
self.hass, self._sensor_source_id, calc_derivative
|
||||
self.hass, self._sensor_source_id, on_state_changed
|
||||
)
|
||||
)
|
||||
|
||||
self.async_on_remove(
|
||||
async_track_state_report_event(
|
||||
self.hass, self._sensor_source_id, on_state_reported
|
||||
)
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ from devolo_plc_api.device_api import (
|
||||
WifiGuestAccessGet,
|
||||
)
|
||||
from devolo_plc_api.plcnet_api import DataRate, LogicalNetwork
|
||||
from yarl import URL
|
||||
|
||||
from homeassistant.const import ATTR_CONNECTIONS
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
@@ -43,7 +44,7 @@ class DevoloEntity(Entity):
|
||||
self.entry = entry
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
configuration_url=f"http://{self.device.ip}",
|
||||
configuration_url=URL.build(scheme="http", host=self.device.ip),
|
||||
identifiers={(DOMAIN, str(self.device.serial_number))},
|
||||
manufacturer="devolo",
|
||||
model=self.device.product,
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/ecovacs",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
|
||||
"requirements": ["py-sucks==0.9.10", "deebot-client==12.2.0"]
|
||||
"requirements": ["py-sucks==0.9.10", "deebot-client==12.3.1"]
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"dependencies": ["webhook"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/ecowitt",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["aioecowitt==2024.2.1"]
|
||||
"requirements": ["aioecowitt==2025.3.1"]
|
||||
}
|
||||
|
||||
@@ -101,9 +101,11 @@ def hostname_from_url(url: str) -> str:
|
||||
|
||||
def _host_validator(config: dict[str, str]) -> dict[str, str]:
|
||||
"""Validate that a host is properly configured."""
|
||||
if config[CONF_HOST].startswith("elks://"):
|
||||
if config[CONF_HOST].startswith(("elks://", "elksv1_2://")):
|
||||
if CONF_USERNAME not in config or CONF_PASSWORD not in config:
|
||||
raise vol.Invalid("Specify username and password for elks://")
|
||||
raise vol.Invalid(
|
||||
"Specify username and password for elks:// or elksv1_2://"
|
||||
)
|
||||
elif not config[CONF_HOST].startswith("elk://") and not config[
|
||||
CONF_HOST
|
||||
].startswith("serial://"):
|
||||
|
||||
@@ -105,6 +105,7 @@ class ElkArea(ElkAttachedEntity, AlarmControlPanelEntity, RestoreEntity):
|
||||
AlarmControlPanelEntityFeature.ARM_HOME
|
||||
| AlarmControlPanelEntityFeature.ARM_AWAY
|
||||
| AlarmControlPanelEntityFeature.ARM_NIGHT
|
||||
| AlarmControlPanelEntityFeature.ARM_VACATION
|
||||
)
|
||||
_element: Area
|
||||
|
||||
@@ -204,7 +205,7 @@ class ElkArea(ElkAttachedEntity, AlarmControlPanelEntity, RestoreEntity):
|
||||
ArmedStatus.ARMED_STAY_INSTANT: AlarmControlPanelState.ARMED_HOME,
|
||||
ArmedStatus.ARMED_TO_NIGHT: AlarmControlPanelState.ARMED_NIGHT,
|
||||
ArmedStatus.ARMED_TO_NIGHT_INSTANT: AlarmControlPanelState.ARMED_NIGHT,
|
||||
ArmedStatus.ARMED_TO_VACATION: AlarmControlPanelState.ARMED_AWAY,
|
||||
ArmedStatus.ARMED_TO_VACATION: AlarmControlPanelState.ARMED_VACATION,
|
||||
}
|
||||
|
||||
if self._element.alarm_state is None:
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["sense_energy"],
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["sense-energy==0.13.5"]
|
||||
"requirements": ["sense-energy==0.13.7"]
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/environment_canada",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["env_canada"],
|
||||
"requirements": ["env-canada==0.7.2"]
|
||||
"requirements": ["env-canada==0.8.0"]
|
||||
}
|
||||
|
||||
@@ -22,5 +22,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["eq3btsmart"],
|
||||
"requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.7.1"]
|
||||
"requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.8.0"]
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import partial
|
||||
from math import isfinite
|
||||
from typing import Any, cast
|
||||
|
||||
from aioesphomeapi import (
|
||||
@@ -238,9 +239,13 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti
|
||||
@esphome_state_property
|
||||
def current_humidity(self) -> int | None:
|
||||
"""Return the current humidity."""
|
||||
if not self._static_info.supports_current_humidity:
|
||||
if (
|
||||
not self._static_info.supports_current_humidity
|
||||
or (val := self._state.current_humidity) is None
|
||||
or not isfinite(val)
|
||||
):
|
||||
return None
|
||||
return round(self._state.current_humidity)
|
||||
return round(val)
|
||||
|
||||
@property
|
||||
@esphome_float_state_property
|
||||
|
||||
@@ -13,11 +13,13 @@ DEFAULT_ALLOW_SERVICE_CALLS = True
|
||||
DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS = False
|
||||
|
||||
|
||||
STABLE_BLE_VERSION_STR = "2025.2.1"
|
||||
STABLE_BLE_VERSION_STR = "2025.2.2"
|
||||
STABLE_BLE_VERSION = AwesomeVersion(STABLE_BLE_VERSION_STR)
|
||||
PROJECT_URLS = {
|
||||
"esphome.bluetooth-proxy": "https://esphome.github.io/bluetooth-proxies/",
|
||||
}
|
||||
DEFAULT_URL = f"https://esphome.io/changelog/{STABLE_BLE_VERSION_STR}.html"
|
||||
# ESPHome always uses .0 for the changelog URL
|
||||
STABLE_BLE_URL_VERSION = f"{STABLE_BLE_VERSION.major}.{STABLE_BLE_VERSION.minor}.0"
|
||||
DEFAULT_URL = f"https://esphome.io/changelog/{STABLE_BLE_URL_VERSION}.html"
|
||||
|
||||
DATA_FFMPEG_PROXY = f"{DOMAIN}.ffmpeg_proxy"
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"requirements": [
|
||||
"aioesphomeapi==29.2.0",
|
||||
"esphome-dashboard-api==1.2.3",
|
||||
"bleak-esphome==2.7.1"
|
||||
"bleak-esphome==2.8.0"
|
||||
],
|
||||
"zeroconf": ["_esphomelib._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ from typing import Any
|
||||
import evohomeasync as ec1
|
||||
import evohomeasync2 as ec2
|
||||
from evohomeasync2.const import (
|
||||
SZ_DHW,
|
||||
SZ_GATEWAY_ID,
|
||||
SZ_GATEWAY_INFO,
|
||||
SZ_GATEWAYS,
|
||||
@@ -19,8 +20,9 @@ from evohomeasync2.const import (
|
||||
SZ_TEMPERATURE_CONTROL_SYSTEMS,
|
||||
SZ_TIME_ZONE,
|
||||
SZ_USE_DAYLIGHT_SAVE_SWITCHING,
|
||||
SZ_ZONES,
|
||||
)
|
||||
from evohomeasync2.schemas.typedefs import EvoLocStatusResponseT
|
||||
from evohomeasync2.schemas.typedefs import EvoLocStatusResponseT, EvoTcsConfigResponseT
|
||||
|
||||
from homeassistant.const import CONF_SCAN_INTERVAL
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -113,17 +115,19 @@ class EvoDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
SZ_USE_DAYLIGHT_SAVE_SWITCHING
|
||||
],
|
||||
}
|
||||
tcs_info: EvoTcsConfigResponseT = self.tcs.config # type: ignore[assignment]
|
||||
tcs_info[SZ_ZONES] = [zone.config for zone in self.tcs.zones]
|
||||
if self.tcs.hotwater:
|
||||
tcs_info[SZ_DHW] = self.tcs.hotwater.config
|
||||
gwy_info = {
|
||||
SZ_GATEWAY_ID: self.loc.gateways[0].id,
|
||||
SZ_TEMPERATURE_CONTROL_SYSTEMS: [
|
||||
self.loc.gateways[0].systems[0].config
|
||||
],
|
||||
SZ_TEMPERATURE_CONTROL_SYSTEMS: [tcs_info],
|
||||
}
|
||||
config = {
|
||||
SZ_LOCATION_INFO: loc_info,
|
||||
SZ_GATEWAYS: [{SZ_GATEWAY_INFO: gwy_info}],
|
||||
}
|
||||
self.logger.debug("Config = %s", config)
|
||||
self.logger.debug("Config = %s", [config])
|
||||
|
||||
async def call_client_api(
|
||||
self,
|
||||
@@ -203,10 +207,18 @@ class EvoDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
|
||||
async def _update_v2_schedules(self) -> None:
|
||||
for zone in self.tcs.zones:
|
||||
await zone.get_schedule()
|
||||
try:
|
||||
await zone.get_schedule()
|
||||
except ec2.InvalidScheduleError as err:
|
||||
self.logger.warning(
|
||||
"Zone '%s' has an invalid/missing schedule: %r", zone.name, err
|
||||
)
|
||||
|
||||
if dhw := self.tcs.hotwater:
|
||||
await dhw.get_schedule()
|
||||
try:
|
||||
await dhw.get_schedule()
|
||||
except ec2.InvalidScheduleError as err:
|
||||
self.logger.warning("DHW has an invalid/missing schedule: %r", err)
|
||||
|
||||
async def _async_update_data(self) -> EvoLocStatusResponseT: # type: ignore[override]
|
||||
"""Fetch the latest state of an entire TCC Location.
|
||||
|
||||
@@ -6,6 +6,7 @@ import logging
|
||||
from typing import Any
|
||||
|
||||
import evohomeasync2 as evo
|
||||
from evohomeasync2.schemas.typedefs import DayOfWeekDhwT
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
@@ -102,7 +103,7 @@ class EvoChild(EvoEntity):
|
||||
|
||||
self._evo_tcs = evo_device.tcs
|
||||
|
||||
self._schedule: dict[str, Any] | None = None
|
||||
self._schedule: list[DayOfWeekDhwT] | None = None
|
||||
self._setpoints: dict[str, Any] = {}
|
||||
|
||||
@property
|
||||
@@ -123,6 +124,9 @@ class EvoChild(EvoEntity):
|
||||
Only Zones & DHW controllers (but not the TCS) can have schedules.
|
||||
"""
|
||||
|
||||
if not self._schedule:
|
||||
return self._setpoints
|
||||
|
||||
this_sp_dtm, this_sp_val = self._evo_device.this_switchpoint
|
||||
next_sp_dtm, next_sp_val = self._evo_device.next_switchpoint
|
||||
|
||||
@@ -152,10 +156,10 @@ class EvoChild(EvoEntity):
|
||||
self._evo_device,
|
||||
err,
|
||||
)
|
||||
self._schedule = {}
|
||||
self._schedule = []
|
||||
return
|
||||
else:
|
||||
self._schedule = schedule or {} # mypy hint
|
||||
self._schedule = schedule # type: ignore[assignment]
|
||||
|
||||
_LOGGER.debug("Schedule['%s'] = %s", self.name, schedule)
|
||||
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["evohome", "evohomeasync", "evohomeasync2"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["evohome-async==1.0.2"]
|
||||
"requirements": ["evohome-async==1.0.4"]
|
||||
}
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20250226.0"]
|
||||
"requirements": ["home-assistant-frontend==20250306.0"]
|
||||
}
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/fujitsu_fglair",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["ayla-iot-unofficial==1.4.5"]
|
||||
"requirements": ["ayla-iot-unofficial==1.4.7"]
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
|
||||
from google import genai # type: ignore[attr-defined]
|
||||
@@ -65,9 +66,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
prompt_parts = [call.data[CONF_PROMPT]]
|
||||
|
||||
config_entry: GoogleGenerativeAIConfigEntry = hass.config_entries.async_entries(
|
||||
DOMAIN
|
||||
)[0]
|
||||
config_entry: GoogleGenerativeAIConfigEntry = (
|
||||
hass.config_entries.async_loaded_entries(DOMAIN)[0]
|
||||
)
|
||||
|
||||
client = config_entry.runtime_data
|
||||
|
||||
@@ -83,7 +84,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
)
|
||||
if not Path(filename).exists():
|
||||
raise HomeAssistantError(f"`{filename}` does not exist")
|
||||
prompt_parts.append(client.files.upload(file=filename))
|
||||
mimetype = mimetypes.guess_type(filename)[0]
|
||||
with open(filename, "rb") as file:
|
||||
uploaded_file = client.files.upload(
|
||||
file=file, config={"mime_type": mimetype}
|
||||
)
|
||||
prompt_parts.append(uploaded_file)
|
||||
|
||||
await hass.async_add_executor_job(append_files_to_prompt)
|
||||
|
||||
|
||||
@@ -64,28 +64,18 @@ async def async_setup_entry(
|
||||
|
||||
|
||||
SUPPORTED_SCHEMA_KEYS = {
|
||||
"min_items",
|
||||
"example",
|
||||
"property_ordering",
|
||||
"pattern",
|
||||
"minimum",
|
||||
"default",
|
||||
"any_of",
|
||||
"max_length",
|
||||
"title",
|
||||
"min_properties",
|
||||
"min_length",
|
||||
"max_items",
|
||||
"maximum",
|
||||
"nullable",
|
||||
"max_properties",
|
||||
# Gemini API does not support all of the OpenAPI schema
|
||||
# SoT: https://ai.google.dev/api/caching#Schema
|
||||
"type",
|
||||
"description",
|
||||
"enum",
|
||||
"format",
|
||||
"items",
|
||||
"description",
|
||||
"nullable",
|
||||
"enum",
|
||||
"max_items",
|
||||
"min_items",
|
||||
"properties",
|
||||
"required",
|
||||
"items",
|
||||
}
|
||||
|
||||
|
||||
@@ -109,11 +99,20 @@ def _format_schema(schema: dict[str, Any]) -> Schema:
|
||||
key = _camel_to_snake(key)
|
||||
if key not in SUPPORTED_SCHEMA_KEYS:
|
||||
continue
|
||||
if key == "any_of":
|
||||
val = [_format_schema(subschema) for subschema in val]
|
||||
if key == "type":
|
||||
val = val.upper()
|
||||
if key == "items":
|
||||
elif key == "format":
|
||||
# Gemini API does not support all formats, see: https://ai.google.dev/api/caching#Schema
|
||||
# formats that are not supported are ignored
|
||||
if schema.get("type") == "string" and val not in ("enum", "date-time"):
|
||||
continue
|
||||
if schema.get("type") == "number" and val not in ("float", "double"):
|
||||
continue
|
||||
if schema.get("type") == "integer" and val not in ("int32", "int64"):
|
||||
continue
|
||||
if schema.get("type") not in ("string", "number", "integer"):
|
||||
continue
|
||||
elif key == "items":
|
||||
val = _format_schema(val)
|
||||
elif key == "properties":
|
||||
val = {k: _format_schema(v) for k, v in val.items()}
|
||||
@@ -277,6 +276,13 @@ class GoogleGenerativeAIConversationEntity(
|
||||
):
|
||||
return await self._async_handle_message(user_input, chat_log)
|
||||
|
||||
def _fix_tool_name(self, tool_name: str) -> str:
|
||||
"""Fix tool name if needed."""
|
||||
# The Gemini 2.0+ tokenizer seemingly has a issue with the HassListAddItem tool
|
||||
# name. This makes sure when it incorrectly changes the name, that we change it
|
||||
# back for HA to call.
|
||||
return tool_name if tool_name != "HasListAddItem" else "HassListAddItem"
|
||||
|
||||
async def _async_handle_message(
|
||||
self,
|
||||
user_input: conversation.ConversationInput,
|
||||
@@ -436,7 +442,10 @@ class GoogleGenerativeAIConversationEntity(
|
||||
tool_name = tool_call.name
|
||||
tool_args = _escape_decode(tool_call.args)
|
||||
tool_calls.append(
|
||||
llm.ToolInput(tool_name=tool_name, tool_args=tool_args)
|
||||
llm.ToolInput(
|
||||
tool_name=self._fix_tool_name(tool_name),
|
||||
tool_args=tool_args,
|
||||
)
|
||||
)
|
||||
|
||||
chat_request = _create_google_tool_response_content(
|
||||
|
||||
@@ -135,5 +135,5 @@
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/govee_ble",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["govee-ble==0.43.0"]
|
||||
"requirements": ["govee-ble==0.43.1"]
|
||||
}
|
||||
|
||||
@@ -20,3 +20,4 @@ MAX_ERRORS = 2
|
||||
TARGET_TEMPERATURE_STEP = 1
|
||||
|
||||
UPDATE_INTERVAL = 60
|
||||
MAX_EXPECTED_RESPONSE_TIME_INTERVAL = UPDATE_INTERVAL * 2
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
@@ -24,6 +25,7 @@ from .const import (
|
||||
DISPATCH_DEVICE_DISCOVERED,
|
||||
DOMAIN,
|
||||
MAX_ERRORS,
|
||||
MAX_EXPECTED_RESPONSE_TIME_INTERVAL,
|
||||
UPDATE_INTERVAL,
|
||||
)
|
||||
|
||||
@@ -48,7 +50,6 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
always_update=False,
|
||||
)
|
||||
self.device = device
|
||||
self.device.add_handler(Response.DATA, self.device_state_updated)
|
||||
self.device.add_handler(Response.RESULT, self.device_state_updated)
|
||||
|
||||
self._error_count: int = 0
|
||||
@@ -88,7 +89,9 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
# raise update failed if time for more than MAX_ERRORS has passed since last update
|
||||
now = utcnow()
|
||||
elapsed_success = now - self._last_response_time
|
||||
if self.update_interval and elapsed_success >= self.update_interval:
|
||||
if self.update_interval and elapsed_success >= timedelta(
|
||||
seconds=MAX_EXPECTED_RESPONSE_TIME_INTERVAL
|
||||
):
|
||||
if not self._last_error_time or (
|
||||
(now - self.update_interval) >= self._last_error_time
|
||||
):
|
||||
@@ -96,16 +99,19 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
self._error_count += 1
|
||||
|
||||
_LOGGER.warning(
|
||||
"Device %s is unresponsive for %s seconds",
|
||||
"Device %s took an unusually long time to respond, %s seconds",
|
||||
self.name,
|
||||
elapsed_success,
|
||||
)
|
||||
else:
|
||||
self._error_count = 0
|
||||
if self.last_update_success and self._error_count >= MAX_ERRORS:
|
||||
raise UpdateFailed(
|
||||
f"Device {self.name} is unresponsive for too long and now unavailable"
|
||||
)
|
||||
|
||||
return self.device.raw_properties
|
||||
self._last_response_time = utcnow()
|
||||
return copy.deepcopy(self.device.raw_properties)
|
||||
|
||||
async def push_state_update(self):
|
||||
"""Send state updates to the physical device."""
|
||||
|
||||
@@ -26,6 +26,7 @@ TOTAL_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
api_key="todayEnergy",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="total_output_power",
|
||||
@@ -33,6 +34,7 @@ TOTAL_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
api_key="invTodayPpv",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="total_energy_output",
|
||||
|
||||
@@ -14,7 +14,12 @@ from pyheos import (
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_IGNORE,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
OptionsFlow,
|
||||
)
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import selector
|
||||
@@ -141,8 +146,10 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
hostname = urlparse(discovery_info.ssdp_location).hostname
|
||||
assert hostname is not None
|
||||
|
||||
# Abort early when discovered host is part of the current system
|
||||
if entry and hostname in _get_current_hosts(entry):
|
||||
# Abort early when discovery is ignored or host is part of the current system
|
||||
if entry and (
|
||||
entry.source == SOURCE_IGNORE or hostname in _get_current_hosts(entry)
|
||||
):
|
||||
return self.async_abort(reason="single_instance_allowed")
|
||||
|
||||
# Connect to discovered host and get system information
|
||||
@@ -198,7 +205,7 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Obtain host and validate connection."""
|
||||
await self.async_set_unique_id(DOMAIN)
|
||||
await self.async_set_unique_id(DOMAIN, raise_on_progress=False)
|
||||
self._abort_if_unique_id_configured(error="single_instance_allowed")
|
||||
# Try connecting to host if provided
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
@@ -159,13 +159,12 @@ class HeosCoordinator(DataUpdateCoordinator[None]):
|
||||
|
||||
async def _async_on_reconnected(self) -> None:
|
||||
"""Handle when reconnected so resources are updated and entities marked available."""
|
||||
await self._async_update_players()
|
||||
await self._async_update_sources()
|
||||
_LOGGER.warning("Successfully reconnected to HEOS host %s", self.host)
|
||||
self.async_update_listeners()
|
||||
|
||||
async def _async_on_controller_event(
|
||||
self, event: str, data: PlayerUpdateResult | None
|
||||
self, event: str, data: PlayerUpdateResult | None = None
|
||||
) -> None:
|
||||
"""Handle a controller event, such as players or groups changed."""
|
||||
if event == const.EVENT_PLAYERS_CHANGED:
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyheos"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["pyheos==1.0.2"],
|
||||
"requirements": ["pyheos==1.0.3"],
|
||||
"ssdp": [
|
||||
{
|
||||
"st": "urn:schemas-denon-com:device:ACT-Denon:1"
|
||||
|
||||
@@ -11,7 +11,6 @@ from hko import HKO, HKOError
|
||||
from homeassistant.components.weather import (
|
||||
ATTR_CONDITION_CLOUDY,
|
||||
ATTR_CONDITION_FOG,
|
||||
ATTR_CONDITION_HAIL,
|
||||
ATTR_CONDITION_LIGHTNING_RAINY,
|
||||
ATTR_CONDITION_PARTLYCLOUDY,
|
||||
ATTR_CONDITION_POURING,
|
||||
@@ -145,7 +144,7 @@ class HKOUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"""Return the condition corresponding to the weather info."""
|
||||
info = info.lower()
|
||||
if WEATHER_INFO_RAIN in info:
|
||||
return ATTR_CONDITION_HAIL
|
||||
return ATTR_CONDITION_RAINY
|
||||
if WEATHER_INFO_SNOW in info and WEATHER_INFO_RAIN in info:
|
||||
return ATTR_CONDITION_SNOWY_RAINY
|
||||
if WEATHER_INFO_SNOW in info:
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/holiday",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["holidays==0.67", "babel==2.15.0"]
|
||||
"requirements": ["holidays==0.68", "babel==2.15.0"]
|
||||
}
|
||||
|
||||
@@ -16,11 +16,17 @@ from aiohomeconnect.model import (
|
||||
SettingKey,
|
||||
)
|
||||
from aiohomeconnect.model.error import HomeConnectError
|
||||
import aiohttp
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import ATTR_DEVICE_ID, Platform
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
ConfigEntryNotReady,
|
||||
HomeAssistantError,
|
||||
ServiceValidationError,
|
||||
)
|
||||
from homeassistant.helpers import (
|
||||
config_entry_oauth2_flow,
|
||||
config_validation as cv,
|
||||
@@ -611,18 +617,31 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeConnectConfigEntry)
|
||||
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
|
||||
|
||||
config_entry_auth = AsyncConfigEntryAuth(hass, session)
|
||||
try:
|
||||
await config_entry_auth.async_get_access_token()
|
||||
except aiohttp.ClientResponseError as err:
|
||||
if 400 <= err.status < 500:
|
||||
raise ConfigEntryAuthFailed from err
|
||||
raise ConfigEntryNotReady from err
|
||||
except aiohttp.ClientError as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
home_connect_client = HomeConnectClient(config_entry_auth)
|
||||
|
||||
coordinator = HomeConnectCoordinator(hass, entry, home_connect_client)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
await coordinator.async_setup()
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
entry.runtime_data.start_event_listener()
|
||||
|
||||
entry.async_create_background_task(
|
||||
hass,
|
||||
coordinator.async_refresh(),
|
||||
f"home_connect-initial-full-refresh-{entry.entry_id}",
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -137,41 +137,6 @@ def setup_home_connect_entry(
|
||||
defaultdict(list)
|
||||
)
|
||||
|
||||
entities: list[HomeConnectEntity] = []
|
||||
for appliance in entry.runtime_data.data.values():
|
||||
entities_to_add = get_entities_for_appliance(entry, appliance)
|
||||
if get_option_entities_for_appliance:
|
||||
entities_to_add.extend(get_option_entities_for_appliance(entry, appliance))
|
||||
for event_key in (
|
||||
EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
|
||||
EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
|
||||
):
|
||||
changed_options_listener_remove_callback = (
|
||||
entry.runtime_data.async_add_listener(
|
||||
partial(
|
||||
_create_option_entities,
|
||||
entry,
|
||||
appliance,
|
||||
known_entity_unique_ids,
|
||||
get_option_entities_for_appliance,
|
||||
async_add_entities,
|
||||
),
|
||||
(appliance.info.ha_id, event_key),
|
||||
)
|
||||
)
|
||||
entry.async_on_unload(changed_options_listener_remove_callback)
|
||||
changed_options_listener_remove_callbacks[appliance.info.ha_id].append(
|
||||
changed_options_listener_remove_callback
|
||||
)
|
||||
known_entity_unique_ids.update(
|
||||
{
|
||||
cast(str, entity.unique_id): appliance.info.ha_id
|
||||
for entity in entities_to_add
|
||||
}
|
||||
)
|
||||
entities.extend(entities_to_add)
|
||||
async_add_entities(entities)
|
||||
|
||||
entry.async_on_unload(
|
||||
entry.runtime_data.async_add_special_listener(
|
||||
partial(
|
||||
|
||||
@@ -10,6 +10,7 @@ from .utils import bsh_key_to_translation_key
|
||||
|
||||
DOMAIN = "home_connect"
|
||||
|
||||
API_DEFAULT_RETRY_AFTER = 60
|
||||
|
||||
APPLIANCES_WITH_PROGRAMS = (
|
||||
"CleaningRobot",
|
||||
@@ -284,7 +285,9 @@ SPIN_SPEED_OPTIONS = {
|
||||
"LaundryCare.Washer.EnumType.SpinSpeed.Off",
|
||||
"LaundryCare.Washer.EnumType.SpinSpeed.RPM400",
|
||||
"LaundryCare.Washer.EnumType.SpinSpeed.RPM600",
|
||||
"LaundryCare.Washer.EnumType.SpinSpeed.RPM700",
|
||||
"LaundryCare.Washer.EnumType.SpinSpeed.RPM800",
|
||||
"LaundryCare.Washer.EnumType.SpinSpeed.RPM900",
|
||||
"LaundryCare.Washer.EnumType.SpinSpeed.RPM1000",
|
||||
"LaundryCare.Washer.EnumType.SpinSpeed.RPM1200",
|
||||
"LaundryCare.Washer.EnumType.SpinSpeed.RPM1400",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from asyncio import sleep as asyncio_sleep
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
@@ -29,6 +29,7 @@ from aiohomeconnect.model.error import (
|
||||
HomeConnectApiError,
|
||||
HomeConnectError,
|
||||
HomeConnectRequestError,
|
||||
TooManyRequestsError,
|
||||
UnauthorizedError,
|
||||
)
|
||||
from aiohomeconnect.model.program import EnumerateProgram, ProgramDefinitionOption
|
||||
@@ -36,19 +37,17 @@ from propcache.api import cached_property
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import APPLIANCES_WITH_PROGRAMS, DOMAIN
|
||||
from .const import API_DEFAULT_RETRY_AFTER, APPLIANCES_WITH_PROGRAMS, DOMAIN
|
||||
from .utils import get_dict_from_home_connect_error
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type HomeConnectConfigEntry = ConfigEntry[HomeConnectCoordinator]
|
||||
|
||||
EVENT_STREAM_RECONNECT_DELAY = 30
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class HomeConnectApplianceData:
|
||||
@@ -100,6 +99,7 @@ class HomeConnectCoordinator(
|
||||
CALLBACK_TYPE, tuple[CALLBACK_TYPE, tuple[EventKey, ...]]
|
||||
] = {}
|
||||
self.device_registry = dr.async_get(self.hass)
|
||||
self.data = {}
|
||||
|
||||
@cached_property
|
||||
def context_listeners(self) -> dict[tuple[str, EventKey], list[CALLBACK_TYPE]]:
|
||||
@@ -155,12 +155,22 @@ class HomeConnectCoordinator(
|
||||
f"home_connect-events_listener_task-{self.config_entry.entry_id}",
|
||||
)
|
||||
|
||||
async def _event_listener(self) -> None:
|
||||
async def _event_listener(self) -> None: # noqa: C901
|
||||
"""Match event with listener for event type."""
|
||||
retry_time = 10
|
||||
while True:
|
||||
try:
|
||||
async for event_message in self.client.stream_all_events():
|
||||
retry_time = 10
|
||||
event_message_ha_id = event_message.ha_id
|
||||
if (
|
||||
event_message_ha_id in self.data
|
||||
and not self.data[event_message_ha_id].info.connected
|
||||
):
|
||||
self.data[event_message_ha_id].info.connected = True
|
||||
self._call_all_event_listeners_for_appliance(
|
||||
event_message_ha_id
|
||||
)
|
||||
match event_message.type:
|
||||
case EventType.STATUS:
|
||||
statuses = self.data[event_message_ha_id].status
|
||||
@@ -256,20 +266,25 @@ class HomeConnectCoordinator(
|
||||
except (EventStreamInterruptedError, HomeConnectRequestError) as error:
|
||||
_LOGGER.debug(
|
||||
"Non-breaking error (%s) while listening for events,"
|
||||
" continuing in 30 seconds",
|
||||
" continuing in %s seconds",
|
||||
type(error).__name__,
|
||||
retry_time,
|
||||
)
|
||||
await asyncio.sleep(EVENT_STREAM_RECONNECT_DELAY)
|
||||
await asyncio_sleep(retry_time)
|
||||
retry_time = min(retry_time * 2, 3600)
|
||||
except HomeConnectApiError as error:
|
||||
_LOGGER.error("Error while listening for events: %s", error)
|
||||
self.hass.config_entries.async_schedule_reload(
|
||||
self.config_entry.entry_id
|
||||
)
|
||||
break
|
||||
# if there was a non-breaking error, we continue listening
|
||||
# but we need to refresh the data to get the possible changes
|
||||
# that happened while the event stream was interrupted
|
||||
await self.async_refresh()
|
||||
|
||||
# Trigger to delete the possible depaired device entities
|
||||
# from known_entities variable at common.py
|
||||
for listener, context in self._special_listeners.values():
|
||||
assert isinstance(context, tuple)
|
||||
if EventKey.BSH_COMMON_APPLIANCE_DEPAIRED in context:
|
||||
listener()
|
||||
|
||||
@callback
|
||||
def _call_event_listener(self, event_message: EventMessage) -> None:
|
||||
@@ -288,6 +303,42 @@ class HomeConnectCoordinator(
|
||||
|
||||
async def _async_update_data(self) -> dict[str, HomeConnectApplianceData]:
|
||||
"""Fetch data from Home Connect."""
|
||||
await self._async_setup()
|
||||
|
||||
for appliance_data in self.data.values():
|
||||
appliance = appliance_data.info
|
||||
ha_id = appliance.ha_id
|
||||
while True:
|
||||
try:
|
||||
self.data[ha_id] = await self._get_appliance_data(
|
||||
appliance, self.data.get(ha_id)
|
||||
)
|
||||
except TooManyRequestsError as err:
|
||||
_LOGGER.debug(
|
||||
"Rate limit exceeded on initial fetch: %s",
|
||||
err,
|
||||
)
|
||||
await asyncio_sleep(err.retry_after or API_DEFAULT_RETRY_AFTER)
|
||||
else:
|
||||
break
|
||||
|
||||
for listener, context in self._special_listeners.values():
|
||||
assert isinstance(context, tuple)
|
||||
if EventKey.BSH_COMMON_APPLIANCE_PAIRED in context:
|
||||
listener()
|
||||
|
||||
return self.data
|
||||
|
||||
async def async_setup(self) -> None:
|
||||
"""Set up the devices."""
|
||||
try:
|
||||
await self._async_setup()
|
||||
except UpdateFailed as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
"""Set up the devices."""
|
||||
old_appliances = set(self.data.keys())
|
||||
try:
|
||||
appliances = await self.client.get_home_appliances()
|
||||
except UnauthorizedError as error:
|
||||
@@ -297,18 +348,46 @@ class HomeConnectCoordinator(
|
||||
translation_placeholders=get_dict_from_home_connect_error(error),
|
||||
) from error
|
||||
except HomeConnectError as error:
|
||||
for appliance_data in self.data.values():
|
||||
appliance_data.info.connected = False
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="fetch_api_error",
|
||||
translation_placeholders=get_dict_from_home_connect_error(error),
|
||||
) from error
|
||||
|
||||
return {
|
||||
appliance.ha_id: await self._get_appliance_data(
|
||||
appliance, self.data.get(appliance.ha_id) if self.data else None
|
||||
for appliance in appliances.homeappliances:
|
||||
self.device_registry.async_get_or_create(
|
||||
config_entry_id=self.config_entry.entry_id,
|
||||
identifiers={(DOMAIN, appliance.ha_id)},
|
||||
manufacturer=appliance.brand,
|
||||
name=appliance.name,
|
||||
model=appliance.vib,
|
||||
)
|
||||
for appliance in appliances.homeappliances
|
||||
}
|
||||
if appliance.ha_id not in self.data:
|
||||
self.data[appliance.ha_id] = HomeConnectApplianceData(
|
||||
commands=set(),
|
||||
events={},
|
||||
info=appliance,
|
||||
options={},
|
||||
programs=[],
|
||||
settings={},
|
||||
status={},
|
||||
)
|
||||
else:
|
||||
self.data[appliance.ha_id].info.connected = appliance.connected
|
||||
old_appliances.remove(appliance.ha_id)
|
||||
|
||||
for ha_id in old_appliances:
|
||||
self.data.pop(ha_id, None)
|
||||
device = self.device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, ha_id)}
|
||||
)
|
||||
if device:
|
||||
self.device_registry.async_update_device(
|
||||
device_id=device.id,
|
||||
remove_config_entry_id=self.config_entry.entry_id,
|
||||
)
|
||||
|
||||
async def _get_appliance_data(
|
||||
self,
|
||||
@@ -330,6 +409,8 @@ class HomeConnectCoordinator(
|
||||
await self.client.get_settings(appliance.ha_id)
|
||||
).settings
|
||||
}
|
||||
except TooManyRequestsError:
|
||||
raise
|
||||
except HomeConnectError as error:
|
||||
_LOGGER.debug(
|
||||
"Error fetching settings for %s: %s",
|
||||
@@ -344,6 +425,8 @@ class HomeConnectCoordinator(
|
||||
status.key: status
|
||||
for status in (await self.client.get_status(appliance.ha_id)).status
|
||||
}
|
||||
except TooManyRequestsError:
|
||||
raise
|
||||
except HomeConnectError as error:
|
||||
_LOGGER.debug(
|
||||
"Error fetching status for %s: %s",
|
||||
@@ -360,6 +443,8 @@ class HomeConnectCoordinator(
|
||||
if appliance.type in APPLIANCES_WITH_PROGRAMS:
|
||||
try:
|
||||
all_programs = await self.client.get_all_programs(appliance.ha_id)
|
||||
except TooManyRequestsError:
|
||||
raise
|
||||
except HomeConnectError as error:
|
||||
_LOGGER.debug(
|
||||
"Error fetching programs for %s: %s",
|
||||
@@ -418,6 +503,8 @@ class HomeConnectCoordinator(
|
||||
await self.client.get_available_commands(appliance.ha_id)
|
||||
).commands
|
||||
}
|
||||
except TooManyRequestsError:
|
||||
raise
|
||||
except HomeConnectError:
|
||||
commands = set()
|
||||
|
||||
@@ -440,13 +527,29 @@ class HomeConnectCoordinator(
|
||||
self, ha_id: str, program_key: ProgramKey
|
||||
) -> dict[OptionKey, ProgramDefinitionOption]:
|
||||
"""Get options with constraints for appliance."""
|
||||
return {
|
||||
option.key: option
|
||||
for option in (
|
||||
await self.client.get_available_program(ha_id, program_key=program_key)
|
||||
).options
|
||||
or []
|
||||
}
|
||||
if program_key is ProgramKey.UNKNOWN:
|
||||
return {}
|
||||
try:
|
||||
return {
|
||||
option.key: option
|
||||
for option in (
|
||||
await self.client.get_available_program(
|
||||
ha_id, program_key=program_key
|
||||
)
|
||||
).options
|
||||
or []
|
||||
}
|
||||
except TooManyRequestsError:
|
||||
raise
|
||||
except HomeConnectError as error:
|
||||
_LOGGER.debug(
|
||||
"Error fetching options for %s: %s",
|
||||
ha_id,
|
||||
error
|
||||
if isinstance(error, HomeConnectApiError)
|
||||
else type(error).__name__,
|
||||
)
|
||||
return {}
|
||||
|
||||
async def update_options(
|
||||
self, ha_id: str, event_key: EventKey, program_key: ProgramKey
|
||||
@@ -456,8 +559,7 @@ class HomeConnectCoordinator(
|
||||
events = self.data[ha_id].events
|
||||
options_to_notify = options.copy()
|
||||
options.clear()
|
||||
if program_key is not ProgramKey.UNKNOWN:
|
||||
options.update(await self.get_options_definitions(ha_id, program_key))
|
||||
options.update(await self.get_options_definitions(ha_id, program_key))
|
||||
|
||||
for option in options.values():
|
||||
option_value = option.constraints.default if option.constraints else None
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
"""Home Connect entity base class."""
|
||||
|
||||
from abc import abstractmethod
|
||||
from collections.abc import Callable, Coroutine
|
||||
import contextlib
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from typing import cast
|
||||
from typing import Any, Concatenate, cast
|
||||
|
||||
from aiohomeconnect.model import EventKey, OptionKey
|
||||
from aiohomeconnect.model.error import ActiveProgramNotSetError, HomeConnectError
|
||||
from aiohomeconnect.model.error import (
|
||||
ActiveProgramNotSetError,
|
||||
HomeConnectError,
|
||||
TooManyRequestsError,
|
||||
)
|
||||
|
||||
from homeassistant.const import STATE_UNAVAILABLE
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import API_DEFAULT_RETRY_AFTER, DOMAIN
|
||||
from .coordinator import HomeConnectApplianceData, HomeConnectCoordinator
|
||||
from .utils import get_dict_from_home_connect_error
|
||||
|
||||
@@ -51,8 +59,10 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]):
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
self.update_native_value()
|
||||
available = self._attr_available = self.appliance.info.connected
|
||||
self.async_write_ha_state()
|
||||
_LOGGER.debug("Updated %s, new state: %s", self.entity_id, self.state)
|
||||
state = STATE_UNAVAILABLE if not available else self.state
|
||||
_LOGGER.debug("Updated %s, new state: %s", self.entity_id, state)
|
||||
|
||||
@property
|
||||
def bsh_key(self) -> str:
|
||||
@@ -61,10 +71,13 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]):
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return (
|
||||
self.appliance.info.connected and self._attr_available and super().available
|
||||
)
|
||||
"""Return True if entity is available.
|
||||
|
||||
Do not use self.last_update_success for available state
|
||||
as event updates should take precedence over the coordinator
|
||||
refresh.
|
||||
"""
|
||||
return self._attr_available
|
||||
|
||||
|
||||
class HomeConnectOptionEntity(HomeConnectEntity):
|
||||
@@ -121,3 +134,34 @@ class HomeConnectOptionEntity(HomeConnectEntity):
|
||||
def bsh_key(self) -> OptionKey:
|
||||
"""Return the BSH key."""
|
||||
return cast(OptionKey, self.entity_description.key)
|
||||
|
||||
|
||||
def constraint_fetcher[_EntityT: HomeConnectEntity, **_P](
|
||||
func: Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, Any]],
|
||||
) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]:
|
||||
"""Decorate the function to catch Home Connect too many requests error and retry later.
|
||||
|
||||
If it needs to be called later, it will call async_write_ha_state function
|
||||
"""
|
||||
|
||||
async def handler_to_return(
|
||||
self: _EntityT, *args: _P.args, **kwargs: _P.kwargs
|
||||
) -> None:
|
||||
async def handler(_datetime: datetime | None = None) -> None:
|
||||
try:
|
||||
await func(self, *args, **kwargs)
|
||||
except TooManyRequestsError as err:
|
||||
if (retry_after := err.retry_after) is None:
|
||||
retry_after = API_DEFAULT_RETRY_AFTER
|
||||
async_call_later(self.hass, retry_after, handler)
|
||||
except HomeConnectError as err:
|
||||
_LOGGER.error(
|
||||
"Error fetching constraints for %s: %s", self.entity_id, err
|
||||
)
|
||||
else:
|
||||
if _datetime is not None:
|
||||
self.async_write_ha_state()
|
||||
|
||||
await handler()
|
||||
|
||||
return handler_to_return
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/home_connect",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aiohomeconnect"],
|
||||
"requirements": ["aiohomeconnect==0.15.0"],
|
||||
"requirements": ["aiohomeconnect==0.16.3"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ from .const import (
|
||||
UNIT_MAP,
|
||||
)
|
||||
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
|
||||
from .entity import HomeConnectEntity, HomeConnectOptionEntity
|
||||
from .entity import HomeConnectEntity, HomeConnectOptionEntity, constraint_fetcher
|
||||
from .utils import get_dict_from_home_connect_error
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -189,19 +189,25 @@ class HomeConnectNumberEntity(HomeConnectEntity, NumberEntity):
|
||||
},
|
||||
) from err
|
||||
|
||||
@constraint_fetcher
|
||||
async def async_fetch_constraints(self) -> None:
|
||||
"""Fetch the max and min values and step for the number entity."""
|
||||
try:
|
||||
setting_key = cast(SettingKey, self.bsh_key)
|
||||
data = self.appliance.settings.get(setting_key)
|
||||
if not data or not data.unit or not data.constraints:
|
||||
data = await self.coordinator.client.get_setting(
|
||||
self.appliance.info.ha_id, setting_key=SettingKey(self.bsh_key)
|
||||
self.appliance.info.ha_id, setting_key=setting_key
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
_LOGGER.error("An error occurred: %s", err)
|
||||
else:
|
||||
if data.unit:
|
||||
self._attr_native_unit_of_measurement = data.unit
|
||||
self.set_constraints(data)
|
||||
|
||||
def set_constraints(self, setting: GetSetting) -> None:
|
||||
"""Set constraints for the number entity."""
|
||||
if setting.unit:
|
||||
self._attr_native_unit_of_measurement = UNIT_MAP.get(
|
||||
setting.unit, setting.unit
|
||||
)
|
||||
if not (constraints := setting.constraints):
|
||||
return
|
||||
if constraints.max:
|
||||
@@ -222,10 +228,10 @@ class HomeConnectNumberEntity(HomeConnectEntity, NumberEntity):
|
||||
"""When entity is added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
data = self.appliance.settings[cast(SettingKey, self.bsh_key)]
|
||||
self._attr_native_unit_of_measurement = data.unit
|
||||
self.set_constraints(data)
|
||||
if (
|
||||
not hasattr(self, "_attr_native_min_value")
|
||||
not hasattr(self, "_attr_native_unit_of_measurement")
|
||||
or not hasattr(self, "_attr_native_min_value")
|
||||
or not hasattr(self, "_attr_native_max_value")
|
||||
or not hasattr(self, "_attr_native_step")
|
||||
):
|
||||
@@ -253,7 +259,6 @@ class HomeConnectOptionNumberEntity(HomeConnectOptionEntity, NumberEntity):
|
||||
or candidate_unit != self._attr_native_unit_of_measurement
|
||||
):
|
||||
self._attr_native_unit_of_measurement = candidate_unit
|
||||
self.__dict__.pop("unit_of_measurement", None)
|
||||
option_constraints = option_definition.constraints
|
||||
if option_constraints:
|
||||
if (
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Provides a select platform for Home Connect."""
|
||||
|
||||
from collections.abc import Callable, Coroutine
|
||||
import contextlib
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
|
||||
from aiohomeconnect.client import Client as HomeConnectClient
|
||||
@@ -47,9 +47,11 @@ from .coordinator import (
|
||||
HomeConnectConfigEntry,
|
||||
HomeConnectCoordinator,
|
||||
)
|
||||
from .entity import HomeConnectEntity, HomeConnectOptionEntity
|
||||
from .entity import HomeConnectEntity, HomeConnectOptionEntity, constraint_fetcher
|
||||
from .utils import bsh_key_to_translation_key, get_dict_from_home_connect_error
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
FUNCTIONAL_LIGHT_COLOR_TEMPERATURE_ENUM = {
|
||||
@@ -413,6 +415,7 @@ class HomeConnectSelectEntity(HomeConnectEntity, SelectEntity):
|
||||
"""Select setting class for Home Connect."""
|
||||
|
||||
entity_description: HomeConnectSelectEntityDescription
|
||||
_original_option_keys: set[str | None]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -421,6 +424,7 @@ class HomeConnectSelectEntity(HomeConnectEntity, SelectEntity):
|
||||
desc: HomeConnectSelectEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
self._original_option_keys = set(desc.values_translation_key)
|
||||
super().__init__(
|
||||
coordinator,
|
||||
appliance,
|
||||
@@ -458,23 +462,29 @@ class HomeConnectSelectEntity(HomeConnectEntity, SelectEntity):
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""When entity is added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
await self.async_fetch_options()
|
||||
|
||||
@constraint_fetcher
|
||||
async def async_fetch_options(self) -> None:
|
||||
"""Fetch options from the API."""
|
||||
setting = self.appliance.settings.get(cast(SettingKey, self.bsh_key))
|
||||
if (
|
||||
not setting
|
||||
or not setting.constraints
|
||||
or not setting.constraints.allowed_values
|
||||
):
|
||||
with contextlib.suppress(HomeConnectError):
|
||||
setting = await self.coordinator.client.get_setting(
|
||||
self.appliance.info.ha_id,
|
||||
setting_key=cast(SettingKey, self.bsh_key),
|
||||
)
|
||||
setting = await self.coordinator.client.get_setting(
|
||||
self.appliance.info.ha_id,
|
||||
setting_key=cast(SettingKey, self.bsh_key),
|
||||
)
|
||||
|
||||
if setting and setting.constraints and setting.constraints.allowed_values:
|
||||
self._original_option_keys = set(setting.constraints.allowed_values)
|
||||
self._attr_options = [
|
||||
self.entity_description.values_translation_key[option]
|
||||
for option in setting.constraints.allowed_values
|
||||
if option in self.entity_description.values_translation_key
|
||||
for option in self._original_option_keys
|
||||
if option is not None
|
||||
and option in self.entity_description.values_translation_key
|
||||
]
|
||||
|
||||
|
||||
@@ -491,7 +501,7 @@ class HomeConnectSelectOptionEntity(HomeConnectOptionEntity, SelectEntity):
|
||||
desc: HomeConnectSelectEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
self._original_option_keys = set(desc.values_translation_key.keys())
|
||||
self._original_option_keys = set(desc.values_translation_key)
|
||||
super().__init__(
|
||||
coordinator,
|
||||
appliance,
|
||||
@@ -524,5 +534,5 @@ class HomeConnectSelectOptionEntity(HomeConnectOptionEntity, SelectEntity):
|
||||
self.entity_description.values_translation_key[option]
|
||||
for option in self._original_option_keys
|
||||
if option is not None
|
||||
and option in self.entity_description.values_translation_key
|
||||
]
|
||||
self.__dict__.pop("options", None)
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
"""Provides a sensor for Home Connect."""
|
||||
|
||||
import contextlib
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import cast
|
||||
|
||||
from aiohomeconnect.model import EventKey, StatusKey
|
||||
from aiohomeconnect.model.error import HomeConnectError
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
@@ -28,7 +27,9 @@ from .const import (
|
||||
UNIT_MAP,
|
||||
)
|
||||
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
|
||||
from .entity import HomeConnectEntity
|
||||
from .entity import HomeConnectEntity, constraint_fetcher
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -335,16 +336,14 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity):
|
||||
else:
|
||||
await self.fetch_unit()
|
||||
|
||||
@constraint_fetcher
|
||||
async def fetch_unit(self) -> None:
|
||||
"""Fetch the unit of measurement."""
|
||||
with contextlib.suppress(HomeConnectError):
|
||||
data = await self.coordinator.client.get_status_value(
|
||||
self.appliance.info.ha_id, status_key=cast(StatusKey, self.bsh_key)
|
||||
)
|
||||
if data.unit:
|
||||
self._attr_native_unit_of_measurement = UNIT_MAP.get(
|
||||
data.unit, data.unit
|
||||
)
|
||||
data = await self.coordinator.client.get_status_value(
|
||||
self.appliance.info.ha_id, status_key=cast(StatusKey, self.bsh_key)
|
||||
)
|
||||
if data.unit:
|
||||
self._attr_native_unit_of_measurement = UNIT_MAP.get(data.unit, data.unit)
|
||||
|
||||
|
||||
class HomeConnectProgramSensor(HomeConnectSensor):
|
||||
@@ -386,6 +385,13 @@ class HomeConnectProgramSensor(HomeConnectSensor):
|
||||
|
||||
def update_native_value(self) -> None:
|
||||
"""Update the program sensor's status."""
|
||||
self.program_running = (
|
||||
status := self.appliance.status.get(StatusKey.BSH_COMMON_OPERATION_STATE)
|
||||
) is not None and status.value in [
|
||||
BSH_OPERATION_STATE_RUN,
|
||||
BSH_OPERATION_STATE_PAUSE,
|
||||
BSH_OPERATION_STATE_FINISHED,
|
||||
]
|
||||
event = self.appliance.events.get(cast(EventKey, self.bsh_key))
|
||||
if event:
|
||||
self._update_native_value(event.value)
|
||||
|
||||
@@ -468,11 +468,11 @@ set_program_and_options:
|
||||
translation_key: venting_level
|
||||
options:
|
||||
- cooking_hood_enum_type_stage_fan_off
|
||||
- cooking_hood_enum_type_stage_fan_stage01
|
||||
- cooking_hood_enum_type_stage_fan_stage02
|
||||
- cooking_hood_enum_type_stage_fan_stage03
|
||||
- cooking_hood_enum_type_stage_fan_stage04
|
||||
- cooking_hood_enum_type_stage_fan_stage05
|
||||
- cooking_hood_enum_type_stage_fan_stage_01
|
||||
- cooking_hood_enum_type_stage_fan_stage_02
|
||||
- cooking_hood_enum_type_stage_fan_stage_03
|
||||
- cooking_hood_enum_type_stage_fan_stage_04
|
||||
- cooking_hood_enum_type_stage_fan_stage_05
|
||||
cooking_hood_option_intensive_level:
|
||||
example: cooking_hood_enum_type_intensive_stage_intensive_stage1
|
||||
required: false
|
||||
@@ -528,7 +528,7 @@ set_program_and_options:
|
||||
collapsed: true
|
||||
fields:
|
||||
laundry_care_washer_option_temperature:
|
||||
example: laundry_care_washer_enum_type_temperature_g_c40
|
||||
example: laundry_care_washer_enum_type_temperature_g_c_40
|
||||
required: false
|
||||
selector:
|
||||
select:
|
||||
@@ -536,14 +536,14 @@ set_program_and_options:
|
||||
translation_key: washer_temperature
|
||||
options:
|
||||
- laundry_care_washer_enum_type_temperature_cold
|
||||
- laundry_care_washer_enum_type_temperature_g_c20
|
||||
- laundry_care_washer_enum_type_temperature_g_c30
|
||||
- laundry_care_washer_enum_type_temperature_g_c40
|
||||
- laundry_care_washer_enum_type_temperature_g_c50
|
||||
- laundry_care_washer_enum_type_temperature_g_c60
|
||||
- laundry_care_washer_enum_type_temperature_g_c70
|
||||
- laundry_care_washer_enum_type_temperature_g_c80
|
||||
- laundry_care_washer_enum_type_temperature_g_c90
|
||||
- laundry_care_washer_enum_type_temperature_g_c_20
|
||||
- laundry_care_washer_enum_type_temperature_g_c_30
|
||||
- laundry_care_washer_enum_type_temperature_g_c_40
|
||||
- laundry_care_washer_enum_type_temperature_g_c_50
|
||||
- laundry_care_washer_enum_type_temperature_g_c_60
|
||||
- laundry_care_washer_enum_type_temperature_g_c_70
|
||||
- laundry_care_washer_enum_type_temperature_g_c_80
|
||||
- laundry_care_washer_enum_type_temperature_g_c_90
|
||||
- laundry_care_washer_enum_type_temperature_ul_cold
|
||||
- laundry_care_washer_enum_type_temperature_ul_warm
|
||||
- laundry_care_washer_enum_type_temperature_ul_hot
|
||||
@@ -557,13 +557,15 @@ set_program_and_options:
|
||||
translation_key: spin_speed
|
||||
options:
|
||||
- laundry_care_washer_enum_type_spin_speed_off
|
||||
- laundry_care_washer_enum_type_spin_speed_r_p_m400
|
||||
- laundry_care_washer_enum_type_spin_speed_r_p_m600
|
||||
- laundry_care_washer_enum_type_spin_speed_r_p_m800
|
||||
- laundry_care_washer_enum_type_spin_speed_r_p_m1000
|
||||
- laundry_care_washer_enum_type_spin_speed_r_p_m1200
|
||||
- laundry_care_washer_enum_type_spin_speed_r_p_m1400
|
||||
- laundry_care_washer_enum_type_spin_speed_r_p_m1600
|
||||
- laundry_care_washer_enum_type_spin_speed_r_p_m_400
|
||||
- laundry_care_washer_enum_type_spin_speed_r_p_m_600
|
||||
- laundry_care_washer_enum_type_spin_speed_r_p_m_700
|
||||
- laundry_care_washer_enum_type_spin_speed_r_p_m_800
|
||||
- laundry_care_washer_enum_type_spin_speed_r_p_m_900
|
||||
- laundry_care_washer_enum_type_spin_speed_r_p_m_1000
|
||||
- laundry_care_washer_enum_type_spin_speed_r_p_m_1200
|
||||
- laundry_care_washer_enum_type_spin_speed_r_p_m_1400
|
||||
- laundry_care_washer_enum_type_spin_speed_r_p_m_1600
|
||||
- laundry_care_washer_enum_type_spin_speed_ul_off
|
||||
- laundry_care_washer_enum_type_spin_speed_ul_low
|
||||
- laundry_care_washer_enum_type_spin_speed_ul_medium
|
||||
|
||||
@@ -417,11 +417,11 @@
|
||||
"venting_level": {
|
||||
"options": {
|
||||
"cooking_hood_enum_type_stage_fan_off": "Fan off",
|
||||
"cooking_hood_enum_type_stage_fan_stage01": "Fan stage 1",
|
||||
"cooking_hood_enum_type_stage_fan_stage02": "Fan stage 2",
|
||||
"cooking_hood_enum_type_stage_fan_stage03": "Fan stage 3",
|
||||
"cooking_hood_enum_type_stage_fan_stage04": "Fan stage 4",
|
||||
"cooking_hood_enum_type_stage_fan_stage05": "Fan stage 5"
|
||||
"cooking_hood_enum_type_stage_fan_stage_01": "Fan stage 1",
|
||||
"cooking_hood_enum_type_stage_fan_stage_02": "Fan stage 2",
|
||||
"cooking_hood_enum_type_stage_fan_stage_03": "Fan stage 3",
|
||||
"cooking_hood_enum_type_stage_fan_stage_04": "Fan stage 4",
|
||||
"cooking_hood_enum_type_stage_fan_stage_05": "Fan stage 5"
|
||||
}
|
||||
},
|
||||
"intensive_level": {
|
||||
@@ -441,14 +441,14 @@
|
||||
"washer_temperature": {
|
||||
"options": {
|
||||
"laundry_care_washer_enum_type_temperature_cold": "Cold",
|
||||
"laundry_care_washer_enum_type_temperature_g_c20": "20ºC clothes",
|
||||
"laundry_care_washer_enum_type_temperature_g_c30": "30ºC clothes",
|
||||
"laundry_care_washer_enum_type_temperature_g_c40": "40ºC clothes",
|
||||
"laundry_care_washer_enum_type_temperature_g_c50": "50ºC clothes",
|
||||
"laundry_care_washer_enum_type_temperature_g_c60": "60ºC clothes",
|
||||
"laundry_care_washer_enum_type_temperature_g_c70": "70ºC clothes",
|
||||
"laundry_care_washer_enum_type_temperature_g_c80": "80ºC clothes",
|
||||
"laundry_care_washer_enum_type_temperature_g_c90": "90ºC clothes",
|
||||
"laundry_care_washer_enum_type_temperature_g_c_20": "20ºC clothes",
|
||||
"laundry_care_washer_enum_type_temperature_g_c_30": "30ºC clothes",
|
||||
"laundry_care_washer_enum_type_temperature_g_c_40": "40ºC clothes",
|
||||
"laundry_care_washer_enum_type_temperature_g_c_50": "50ºC clothes",
|
||||
"laundry_care_washer_enum_type_temperature_g_c_60": "60ºC clothes",
|
||||
"laundry_care_washer_enum_type_temperature_g_c_70": "70ºC clothes",
|
||||
"laundry_care_washer_enum_type_temperature_g_c_80": "80ºC clothes",
|
||||
"laundry_care_washer_enum_type_temperature_g_c_90": "90ºC clothes",
|
||||
"laundry_care_washer_enum_type_temperature_ul_cold": "Cold",
|
||||
"laundry_care_washer_enum_type_temperature_ul_warm": "Warm",
|
||||
"laundry_care_washer_enum_type_temperature_ul_hot": "Hot",
|
||||
@@ -458,13 +458,15 @@
|
||||
"spin_speed": {
|
||||
"options": {
|
||||
"laundry_care_washer_enum_type_spin_speed_off": "Off",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m400": "400 rpm",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m600": "600 rpm",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m800": "800 rpm",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m1000": "1000 rpm",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m1200": "1200 rpm",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m1400": "1400 rpm",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m1600": "1600 rpm",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m_400": "400 rpm",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m_600": "600 rpm",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m_700": "700 rpm",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m_800": "800 rpm",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m_900": "900 rpm",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m_1000": "1000 rpm",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m_1200": "1200 rpm",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m_1400": "1400 rpm",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m_1600": "1600 rpm",
|
||||
"laundry_care_washer_enum_type_spin_speed_ul_off": "Off",
|
||||
"laundry_care_washer_enum_type_spin_speed_ul_low": "Low",
|
||||
"laundry_care_washer_enum_type_spin_speed_ul_medium": "Medium",
|
||||
@@ -1382,11 +1384,11 @@
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_hood_option_venting_level::name%]",
|
||||
"state": {
|
||||
"cooking_hood_enum_type_stage_fan_off": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_off%]",
|
||||
"cooking_hood_enum_type_stage_fan_stage01": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage01%]",
|
||||
"cooking_hood_enum_type_stage_fan_stage02": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage02%]",
|
||||
"cooking_hood_enum_type_stage_fan_stage03": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage03%]",
|
||||
"cooking_hood_enum_type_stage_fan_stage04": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage04%]",
|
||||
"cooking_hood_enum_type_stage_fan_stage05": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage05%]"
|
||||
"cooking_hood_enum_type_stage_fan_stage_01": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage_01%]",
|
||||
"cooking_hood_enum_type_stage_fan_stage_02": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage_02%]",
|
||||
"cooking_hood_enum_type_stage_fan_stage_03": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage_03%]",
|
||||
"cooking_hood_enum_type_stage_fan_stage_04": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage_04%]",
|
||||
"cooking_hood_enum_type_stage_fan_stage_05": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage_05%]"
|
||||
}
|
||||
},
|
||||
"intensive_level": {
|
||||
@@ -1409,14 +1411,14 @@
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_temperature::name%]",
|
||||
"state": {
|
||||
"laundry_care_washer_enum_type_temperature_cold": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_cold%]",
|
||||
"laundry_care_washer_enum_type_temperature_g_c20": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c20%]",
|
||||
"laundry_care_washer_enum_type_temperature_g_c30": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c30%]",
|
||||
"laundry_care_washer_enum_type_temperature_g_c40": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c40%]",
|
||||
"laundry_care_washer_enum_type_temperature_g_c50": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c50%]",
|
||||
"laundry_care_washer_enum_type_temperature_g_c60": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c60%]",
|
||||
"laundry_care_washer_enum_type_temperature_g_c70": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c70%]",
|
||||
"laundry_care_washer_enum_type_temperature_g_c80": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c80%]",
|
||||
"laundry_care_washer_enum_type_temperature_g_c90": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c90%]",
|
||||
"laundry_care_washer_enum_type_temperature_g_c_20": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_20%]",
|
||||
"laundry_care_washer_enum_type_temperature_g_c_30": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_30%]",
|
||||
"laundry_care_washer_enum_type_temperature_g_c_40": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_40%]",
|
||||
"laundry_care_washer_enum_type_temperature_g_c_50": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_50%]",
|
||||
"laundry_care_washer_enum_type_temperature_g_c_60": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_60%]",
|
||||
"laundry_care_washer_enum_type_temperature_g_c_70": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_70%]",
|
||||
"laundry_care_washer_enum_type_temperature_g_c_80": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_80%]",
|
||||
"laundry_care_washer_enum_type_temperature_g_c_90": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_90%]",
|
||||
"laundry_care_washer_enum_type_temperature_ul_cold": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_ul_cold%]",
|
||||
"laundry_care_washer_enum_type_temperature_ul_warm": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_ul_warm%]",
|
||||
"laundry_care_washer_enum_type_temperature_ul_hot": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_ul_hot%]",
|
||||
@@ -1427,13 +1429,15 @@
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_spin_speed::name%]",
|
||||
"state": {
|
||||
"laundry_care_washer_enum_type_spin_speed_off": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_off%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m400%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m600%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m800": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m800%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m1000": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1000%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m1200": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1200%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m1400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1400%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m1600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1600%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m_400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_400%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m_600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_600%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m_700": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_700%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m_800": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_800%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m_900": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_900%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m_1000": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_1000%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m_1200": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_1200%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m_1400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_1400%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m_1600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_1600%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_ul_off": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_off%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_ul_low": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_low%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_ul_medium": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_medium%]",
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"brightness": {
|
||||
"default": "mdi:brightness-5"
|
||||
},
|
||||
"brightness_instance": {
|
||||
"default": "mdi:brightness-5"
|
||||
},
|
||||
"link_quality": {
|
||||
"default": "mdi:signal"
|
||||
},
|
||||
@@ -9,7 +15,7 @@
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"watchdog_on_off": {
|
||||
"watchdog": {
|
||||
"default": "mdi:dog"
|
||||
},
|
||||
"manual_operation": {
|
||||
|
||||
@@ -40,10 +40,22 @@ def get_window_value(attribute: HomeeAttribute) -> str | None:
|
||||
return vals.get(attribute.current_value)
|
||||
|
||||
|
||||
def get_brightness_device_class(
|
||||
attribute: HomeeAttribute, device_class: SensorDeviceClass | None
|
||||
) -> SensorDeviceClass | None:
|
||||
"""Return the device class for a brightness sensor."""
|
||||
if attribute.unit == "%":
|
||||
return None
|
||||
return device_class
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class HomeeSensorEntityDescription(SensorEntityDescription):
|
||||
"""A class that describes Homee sensor entities."""
|
||||
|
||||
device_class_fn: Callable[
|
||||
[HomeeAttribute, SensorDeviceClass | None], SensorDeviceClass | None
|
||||
] = lambda attribute, device_class: device_class
|
||||
value_fn: Callable[[HomeeAttribute], str | float | None] = (
|
||||
lambda value: value.current_value
|
||||
)
|
||||
@@ -67,6 +79,7 @@ SENSOR_DESCRIPTIONS: dict[AttributeType, HomeeSensorEntityDescription] = {
|
||||
AttributeType.BRIGHTNESS: HomeeSensorEntityDescription(
|
||||
key="brightness",
|
||||
device_class=SensorDeviceClass.ILLUMINANCE,
|
||||
device_class_fn=get_brightness_device_class,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=(
|
||||
lambda attribute: attribute.current_value * 1000
|
||||
@@ -303,6 +316,9 @@ class HomeeSensor(HomeeEntity, SensorEntity):
|
||||
if attribute.instance > 0:
|
||||
self._attr_translation_key = f"{self._attr_translation_key}_instance"
|
||||
self._attr_translation_placeholders = {"instance": str(attribute.instance)}
|
||||
self._attr_device_class = description.device_class_fn(
|
||||
attribute, description.device_class
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | str | None:
|
||||
|
||||
@@ -111,6 +111,9 @@
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"brightness": {
|
||||
"name": "Illuminance"
|
||||
},
|
||||
"brightness_instance": {
|
||||
"name": "Illuminance {instance}"
|
||||
},
|
||||
|
||||
@@ -154,7 +154,6 @@ class HKDevice:
|
||||
self._pending_subscribes: set[tuple[int, int]] = set()
|
||||
self._subscribe_timer: CALLBACK_TYPE | None = None
|
||||
self._load_platforms_lock = asyncio.Lock()
|
||||
self._full_update_requested: bool = False
|
||||
|
||||
@property
|
||||
def entity_map(self) -> Accessories:
|
||||
@@ -841,48 +840,11 @@ class HKDevice:
|
||||
|
||||
async def async_request_update(self, now: datetime | None = None) -> None:
|
||||
"""Request an debounced update from the accessory."""
|
||||
self._full_update_requested = True
|
||||
await self._debounced_update.async_call()
|
||||
|
||||
async def async_update(self, now: datetime | None = None) -> None:
|
||||
"""Poll state of all entities attached to this bridge/accessory."""
|
||||
to_poll = self.pollable_characteristics
|
||||
accessories = self.entity_map.accessories
|
||||
|
||||
if (
|
||||
not self._full_update_requested
|
||||
and len(accessories) == 1
|
||||
and self.available
|
||||
and not (to_poll - self.watchable_characteristics)
|
||||
and self.pairing.is_available
|
||||
and await self.pairing.controller.async_reachable(
|
||||
self.unique_id, timeout=5.0
|
||||
)
|
||||
):
|
||||
# If its a single accessory and all chars are watchable,
|
||||
# only poll the firmware version to keep the connection alive
|
||||
# https://github.com/home-assistant/core/issues/123412
|
||||
#
|
||||
# Firmware revision is used here since iOS does this to keep camera
|
||||
# connections alive, and the goal is to not regress
|
||||
# https://github.com/home-assistant/core/issues/116143
|
||||
# by polling characteristics that are not normally polled frequently
|
||||
# and may not be tested by the device vendor.
|
||||
#
|
||||
_LOGGER.debug(
|
||||
"Accessory is reachable, limiting poll to firmware version: %s",
|
||||
self.unique_id,
|
||||
)
|
||||
first_accessory = accessories[0]
|
||||
accessory_info = first_accessory.services.first(
|
||||
service_type=ServicesTypes.ACCESSORY_INFORMATION
|
||||
)
|
||||
assert accessory_info is not None
|
||||
firmware_iid = accessory_info[CharacteristicsTypes.FIRMWARE_REVISION].iid
|
||||
to_poll = {(first_accessory.aid, firmware_iid)}
|
||||
|
||||
self._full_update_requested = False
|
||||
|
||||
if not to_poll:
|
||||
self.async_update_available_state()
|
||||
_LOGGER.debug(
|
||||
|
||||
@@ -14,6 +14,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/homekit_controller",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aiohomekit", "commentjson"],
|
||||
"requirements": ["aiohomekit==3.2.7"],
|
||||
"requirements": ["aiohomekit==3.2.8"],
|
||||
"zeroconf": ["_hap._tcp.local.", "_hap._udp.local."]
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Pick Homematic IP access point",
|
||||
"description": "If you are about to register a **Homematic IP HCU1**, please press the button on top of the device before you continue.\n\nThe registration process must be completed within 5 minutes.",
|
||||
"data": {
|
||||
"hapid": "Access point ID (SGTIN)",
|
||||
"pin": "[%key:common::config_flow::data::pin%]",
|
||||
|
||||
@@ -94,7 +94,12 @@ async def async_setup_devices(bridge: HueBridge):
|
||||
add_device(hue_resource)
|
||||
|
||||
# create/update all current devices found in controllers
|
||||
known_devices = [add_device(hue_device) for hue_device in dev_controller]
|
||||
# sort the devices to ensure bridges are added first
|
||||
hue_devices = list(dev_controller)
|
||||
hue_devices.sort(
|
||||
key=lambda dev: dev.metadata.archetype != DeviceArchetypes.BRIDGE_V2
|
||||
)
|
||||
known_devices = [add_device(hue_device) for hue_device in hue_devices]
|
||||
known_devices += [add_device(hue_room) for hue_room in api.groups.room]
|
||||
known_devices += [add_device(hue_zone) for hue_zone in api.groups.zone]
|
||||
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/hydrawise",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pydrawise"],
|
||||
"requirements": ["pydrawise==2025.2.0"]
|
||||
"requirements": ["pydrawise==2025.3.0"]
|
||||
}
|
||||
|
||||
@@ -280,7 +280,7 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]):
|
||||
if self.custom_event_template is not None:
|
||||
try:
|
||||
data["custom"] = self.custom_event_template.async_render(
|
||||
data, parse_result=True
|
||||
data | {"text": message.text}, parse_result=True
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"IMAP custom template (%s) for msguid %s (%s) rendered to: %s, initial: %s",
|
||||
|
||||
@@ -28,5 +28,5 @@
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/inkbird",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["inkbird-ble==0.7.0"]
|
||||
"requirements": ["inkbird-ble==0.7.1"]
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ from aiohttp import web
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import http
|
||||
from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN
|
||||
from homeassistant.components.cover import (
|
||||
ATTR_POSITION,
|
||||
DOMAIN as COVER_DOMAIN,
|
||||
@@ -140,6 +141,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
intent.async_register(hass, GetCurrentDateIntentHandler())
|
||||
intent.async_register(hass, GetCurrentTimeIntentHandler())
|
||||
intent.async_register(hass, RespondIntentHandler())
|
||||
intent.async_register(hass, GetTemperatureIntent())
|
||||
|
||||
return True
|
||||
|
||||
@@ -444,6 +446,48 @@ class RespondIntentHandler(intent.IntentHandler):
|
||||
return response
|
||||
|
||||
|
||||
class GetTemperatureIntent(intent.IntentHandler):
|
||||
"""Handle GetTemperature intents."""
|
||||
|
||||
intent_type = intent.INTENT_GET_TEMPERATURE
|
||||
description = "Gets the current temperature of a climate device or entity"
|
||||
slot_schema = {
|
||||
vol.Optional("area"): intent.non_empty_string,
|
||||
vol.Optional("name"): intent.non_empty_string,
|
||||
}
|
||||
platforms = {CLIMATE_DOMAIN}
|
||||
|
||||
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
|
||||
"""Handle the intent."""
|
||||
hass = intent_obj.hass
|
||||
slots = self.async_validate_slots(intent_obj.slots)
|
||||
|
||||
name: str | None = None
|
||||
if "name" in slots:
|
||||
name = slots["name"]["value"]
|
||||
|
||||
area: str | None = None
|
||||
if "area" in slots:
|
||||
area = slots["area"]["value"]
|
||||
|
||||
match_constraints = intent.MatchTargetsConstraints(
|
||||
name=name,
|
||||
area_name=area,
|
||||
domains=[CLIMATE_DOMAIN],
|
||||
assistant=intent_obj.assistant,
|
||||
)
|
||||
match_result = intent.async_match_targets(hass, match_constraints)
|
||||
if not match_result.is_match:
|
||||
raise intent.MatchFailedError(
|
||||
result=match_result, constraints=match_constraints
|
||||
)
|
||||
|
||||
response = intent_obj.create_response()
|
||||
response.response_type = intent.IntentResponseType.QUERY_ANSWER
|
||||
response.async_set_states(matched_states=match_result.states)
|
||||
return response
|
||||
|
||||
|
||||
async def _async_process_intent(
|
||||
hass: HomeAssistant, domain: str, platform: IntentPlatformProtocol
|
||||
) -> None:
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"loggers": ["xknx", "xknxproject"],
|
||||
"requirements": [
|
||||
"xknx==3.6.0",
|
||||
"xknxproject==3.8.1",
|
||||
"xknxproject==3.8.2",
|
||||
"knx-frontend==2025.1.30.194235"
|
||||
],
|
||||
"single_config_entry": true
|
||||
|
||||
@@ -61,6 +61,42 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) -
|
||||
client=client,
|
||||
)
|
||||
|
||||
# initialize the firmware update coordinator early to check the firmware version
|
||||
firmware_device = LaMarzoccoMachine(
|
||||
model=entry.data[CONF_MODEL],
|
||||
serial_number=entry.unique_id,
|
||||
name=entry.data[CONF_NAME],
|
||||
cloud_client=cloud_client,
|
||||
)
|
||||
|
||||
firmware_coordinator = LaMarzoccoFirmwareUpdateCoordinator(
|
||||
hass, entry, firmware_device
|
||||
)
|
||||
await firmware_coordinator.async_config_entry_first_refresh()
|
||||
gateway_version = version.parse(
|
||||
firmware_device.firmware[FirmwareType.GATEWAY].current_version
|
||||
)
|
||||
|
||||
if gateway_version >= version.parse("v5.0.9"):
|
||||
# remove host from config entry, it is not supported anymore
|
||||
data = {k: v for k, v in entry.data.items() if k != CONF_HOST}
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
data=data,
|
||||
)
|
||||
|
||||
elif gateway_version < version.parse("v3.4-rc5"):
|
||||
# incompatible gateway firmware, create an issue
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"unsupported_gateway_firmware",
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.ERROR,
|
||||
translation_key="unsupported_gateway_firmware",
|
||||
translation_placeholders={"gateway_version": str(gateway_version)},
|
||||
)
|
||||
|
||||
# initialize local API
|
||||
local_client: LaMarzoccoLocalClient | None = None
|
||||
if (host := entry.data.get(CONF_HOST)) is not None:
|
||||
@@ -117,30 +153,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) -
|
||||
|
||||
coordinators = LaMarzoccoRuntimeData(
|
||||
LaMarzoccoConfigUpdateCoordinator(hass, entry, device, local_client),
|
||||
LaMarzoccoFirmwareUpdateCoordinator(hass, entry, device),
|
||||
firmware_coordinator,
|
||||
LaMarzoccoStatisticsUpdateCoordinator(hass, entry, device),
|
||||
)
|
||||
|
||||
# API does not like concurrent requests, so no asyncio.gather here
|
||||
await coordinators.config_coordinator.async_config_entry_first_refresh()
|
||||
await coordinators.firmware_coordinator.async_config_entry_first_refresh()
|
||||
await coordinators.statistics_coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = coordinators
|
||||
|
||||
gateway_version = device.firmware[FirmwareType.GATEWAY].current_version
|
||||
if version.parse(gateway_version) < version.parse("v3.4-rc5"):
|
||||
# incompatible gateway firmware, create an issue
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"unsupported_gateway_firmware",
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.ERROR,
|
||||
translation_key="unsupported_gateway_firmware",
|
||||
translation_placeholders={"gateway_version": gateway_version},
|
||||
)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
async def update_listener(
|
||||
|
||||
@@ -37,5 +37,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pylamarzocco"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["pylamarzocco==1.4.7"]
|
||||
"requirements": ["pylamarzocco==1.4.9"]
|
||||
}
|
||||
|
||||
@@ -144,9 +144,12 @@ KEY_ENTITIES: tuple[LaMarzoccoKeyNumberEntityDescription, ...] = (
|
||||
set_value_fn=lambda machine, value, key: machine.set_prebrew_time(
|
||||
prebrew_off_time=value, key=key
|
||||
),
|
||||
native_value_fn=lambda config, key: config.prebrew_configuration[key].off_time,
|
||||
native_value_fn=lambda config, key: config.prebrew_configuration[key][
|
||||
0
|
||||
].off_time,
|
||||
available_fn=lambda device: len(device.config.prebrew_configuration) > 0
|
||||
and device.config.prebrew_mode == PrebrewMode.PREBREW,
|
||||
and device.config.prebrew_mode
|
||||
in (PrebrewMode.PREBREW, PrebrewMode.PREBREW_ENABLED),
|
||||
supported_fn=lambda coordinator: coordinator.device.model
|
||||
!= MachineModel.GS3_MP,
|
||||
),
|
||||
@@ -162,9 +165,12 @@ KEY_ENTITIES: tuple[LaMarzoccoKeyNumberEntityDescription, ...] = (
|
||||
set_value_fn=lambda machine, value, key: machine.set_prebrew_time(
|
||||
prebrew_on_time=value, key=key
|
||||
),
|
||||
native_value_fn=lambda config, key: config.prebrew_configuration[key].off_time,
|
||||
native_value_fn=lambda config, key: config.prebrew_configuration[key][
|
||||
0
|
||||
].off_time,
|
||||
available_fn=lambda device: len(device.config.prebrew_configuration) > 0
|
||||
and device.config.prebrew_mode == PrebrewMode.PREBREW,
|
||||
and device.config.prebrew_mode
|
||||
in (PrebrewMode.PREBREW, PrebrewMode.PREBREW_ENABLED),
|
||||
supported_fn=lambda coordinator: coordinator.device.model
|
||||
!= MachineModel.GS3_MP,
|
||||
),
|
||||
@@ -180,8 +186,8 @@ KEY_ENTITIES: tuple[LaMarzoccoKeyNumberEntityDescription, ...] = (
|
||||
set_value_fn=lambda machine, value, key: machine.set_preinfusion_time(
|
||||
preinfusion_time=value, key=key
|
||||
),
|
||||
native_value_fn=lambda config, key: config.prebrew_configuration[
|
||||
key
|
||||
native_value_fn=lambda config, key: config.prebrew_configuration[key][
|
||||
1
|
||||
].preinfusion_time,
|
||||
available_fn=lambda device: len(device.config.prebrew_configuration) > 0
|
||||
and device.config.prebrew_mode == PrebrewMode.PREINFUSION,
|
||||
|
||||
@@ -38,6 +38,7 @@ STEAM_LEVEL_LM_TO_HA = {value: key for key, value in STEAM_LEVEL_HA_TO_LM.items(
|
||||
PREBREW_MODE_HA_TO_LM = {
|
||||
"disabled": PrebrewMode.DISABLED,
|
||||
"prebrew": PrebrewMode.PREBREW,
|
||||
"prebrew_enabled": PrebrewMode.PREBREW_ENABLED,
|
||||
"preinfusion": PrebrewMode.PREINFUSION,
|
||||
}
|
||||
|
||||
|
||||
@@ -148,6 +148,7 @@
|
||||
"state": {
|
||||
"disabled": "Disabled",
|
||||
"prebrew": "Prebrew",
|
||||
"prebrew_enabled": "Prebrew",
|
||||
"preinfusion": "Preinfusion"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -110,7 +110,9 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity):
|
||||
self._attr_hvac_modes = [HVACMode.OFF]
|
||||
self._attr_hvac_mode = HVACMode.OFF
|
||||
self._attr_preset_modes = []
|
||||
self._attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
self._attr_temperature_unit = (
|
||||
self._get_unit_of_measurement(self.data.unit) or UnitOfTemperature.CELSIUS
|
||||
)
|
||||
self._requested_hvac_mode: str | None = None
|
||||
|
||||
# Set up HVAC modes.
|
||||
@@ -182,6 +184,11 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity):
|
||||
self._attr_target_temperature_high = self.data.target_temp_high
|
||||
self._attr_target_temperature_low = self.data.target_temp_low
|
||||
|
||||
# Update unit.
|
||||
self._attr_temperature_unit = (
|
||||
self._get_unit_of_measurement(self.data.unit) or UnitOfTemperature.CELSIUS
|
||||
)
|
||||
|
||||
_LOGGER.debug(
|
||||
"[%s:%s] update status: c:%s, t:%s, l:%s, h:%s, hvac:%s, unit:%s, step:%s",
|
||||
self.coordinator.device_name,
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
from datetime import timedelta
|
||||
from typing import Final
|
||||
|
||||
from homeassistant.const import UnitOfTemperature
|
||||
|
||||
# Config flow
|
||||
DOMAIN = "lg_thinq"
|
||||
COMPANY = "LGE"
|
||||
@@ -18,3 +20,10 @@ MQTT_SUBSCRIPTION_INTERVAL: Final = timedelta(days=1)
|
||||
# MQTT: Message types
|
||||
DEVICE_PUSH_MESSAGE: Final = "DEVICE_PUSH"
|
||||
DEVICE_STATUS_MESSAGE: Final = "DEVICE_STATUS"
|
||||
|
||||
# Unit conversion map
|
||||
DEVICE_UNIT_TO_HA: dict[str, str] = {
|
||||
"F": UnitOfTemperature.FAHRENHEIT,
|
||||
"C": UnitOfTemperature.CELSIUS,
|
||||
}
|
||||
REVERSE_DEVICE_UNIT_TO_HA = {v: k for k, v in DEVICE_UNIT_TO_HA.items()}
|
||||
|
||||
@@ -2,19 +2,21 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from thinqconnect import ThinQAPIException
|
||||
from thinqconnect.integration import HABridge
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.const import EVENT_CORE_CONFIG_UPDATE
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import ThinqConfigEntry
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import DOMAIN, REVERSE_DEVICE_UNIT_TO_HA
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -54,6 +56,40 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
f"{self.device_id}_{self.sub_id}" if self.sub_id else self.device_id
|
||||
)
|
||||
|
||||
# Set your preferred temperature unit. This will allow us to retrieve
|
||||
# temperature values from the API in a converted value corresponding to
|
||||
# preferred unit.
|
||||
self._update_preferred_temperature_unit()
|
||||
|
||||
# Add a callback to handle core config update.
|
||||
self.unit_system: str | None = None
|
||||
self.hass.bus.async_listen(
|
||||
event_type=EVENT_CORE_CONFIG_UPDATE,
|
||||
listener=self._handle_update_config,
|
||||
event_filter=self.async_config_update_filter,
|
||||
)
|
||||
|
||||
async def _handle_update_config(self, _: Event) -> None:
|
||||
"""Handle update core config."""
|
||||
self._update_preferred_temperature_unit()
|
||||
|
||||
await self.async_refresh()
|
||||
|
||||
@callback
|
||||
def async_config_update_filter(self, event_data: Mapping[str, Any]) -> bool:
|
||||
"""Filter out unwanted events."""
|
||||
if (unit_system := event_data.get("unit_system")) != self.unit_system:
|
||||
self.unit_system = unit_system
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _update_preferred_temperature_unit(self) -> None:
|
||||
"""Update preferred temperature unit."""
|
||||
self.api.set_preferred_temperature_unit(
|
||||
REVERSE_DEVICE_UNIT_TO_HA.get(self.hass.config.units.temperature_unit)
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
"""Request to the server to update the status from full response data."""
|
||||
try:
|
||||
|
||||
@@ -10,25 +10,19 @@ from thinqconnect import ThinQAPIException
|
||||
from thinqconnect.devices.const import Location
|
||||
from thinqconnect.integration import PropertyState
|
||||
|
||||
from homeassistant.const import UnitOfTemperature
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import COMPANY, DOMAIN
|
||||
from .const import COMPANY, DEVICE_UNIT_TO_HA, DOMAIN
|
||||
from .coordinator import DeviceDataUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
EMPTY_STATE = PropertyState()
|
||||
|
||||
UNIT_CONVERSION_MAP: dict[str, str] = {
|
||||
"F": UnitOfTemperature.FAHRENHEIT,
|
||||
"C": UnitOfTemperature.CELSIUS,
|
||||
}
|
||||
|
||||
|
||||
class ThinQEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]):
|
||||
"""The base implementation of all lg thinq entities."""
|
||||
@@ -75,7 +69,7 @@ class ThinQEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]):
|
||||
if unit is None:
|
||||
return None
|
||||
|
||||
return UNIT_CONVERSION_MAP.get(unit)
|
||||
return DEVICE_UNIT_TO_HA.get(unit)
|
||||
|
||||
def _update_status(self) -> None:
|
||||
"""Update status itself.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"domain": "matter",
|
||||
"name": "Matter (BETA)",
|
||||
"name": "Matter",
|
||||
"after_dependencies": ["hassio"],
|
||||
"codeowners": ["@home-assistant/matter"],
|
||||
"config_flow": true,
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"iot_class": "calculated",
|
||||
"loggers": ["yt_dlp"],
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["yt-dlp[default]==2025.01.26"],
|
||||
"requirements": ["yt-dlp[default]==2025.02.19"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import socket
|
||||
import ssl
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from uuid import uuid4
|
||||
|
||||
import certifi
|
||||
|
||||
@@ -292,7 +293,7 @@ class MqttClientSetup:
|
||||
"""
|
||||
# We don't import on the top because some integrations
|
||||
# should be able to optionally rely on MQTT.
|
||||
import paho.mqtt.client as mqtt # pylint: disable=import-outside-toplevel
|
||||
from paho.mqtt import client as mqtt # pylint: disable=import-outside-toplevel
|
||||
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from .async_client import AsyncMQTTClient
|
||||
@@ -309,9 +310,10 @@ class MqttClientSetup:
|
||||
clean_session = True
|
||||
|
||||
if (client_id := config.get(CONF_CLIENT_ID)) is None:
|
||||
# PAHO MQTT relies on the MQTT server to generate random client IDs.
|
||||
# However, that feature is not mandatory so we generate our own.
|
||||
client_id = None
|
||||
# PAHO MQTT relies on the MQTT server to generate random client ID
|
||||
# for protocol version 3.1, however, that feature is not mandatory
|
||||
# so we generate our own.
|
||||
client_id = mqtt._base62(uuid4().int, padding=22) # noqa: SLF001
|
||||
transport: str = config.get(CONF_TRANSPORT, DEFAULT_TRANSPORT)
|
||||
self._client = AsyncMQTTClient(
|
||||
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
|
||||
|
||||
@@ -31,7 +31,6 @@ from homeassistant.components.light import (
|
||||
LightEntity,
|
||||
LightEntityFeature,
|
||||
brightness_supported,
|
||||
color_supported,
|
||||
valid_supported_color_modes,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
@@ -217,6 +216,10 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity):
|
||||
self._attr_color_mode = next(iter(self.supported_color_modes))
|
||||
else:
|
||||
self._attr_color_mode = ColorMode.UNKNOWN
|
||||
elif config.get(CONF_BRIGHTNESS):
|
||||
# Brightness is supported and no supported_color_modes are set,
|
||||
# so set brightness as the supported color mode.
|
||||
self._attr_supported_color_modes = {ColorMode.BRIGHTNESS}
|
||||
|
||||
def _update_color(self, values: dict[str, Any]) -> None:
|
||||
color_mode: str = values["color_mode"]
|
||||
@@ -289,7 +292,7 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity):
|
||||
elif values["state"] is None:
|
||||
self._attr_is_on = None
|
||||
|
||||
if color_supported(self.supported_color_modes) and "color_mode" in values:
|
||||
if "color_mode" in values:
|
||||
self._update_color(values)
|
||||
|
||||
if brightness_supported(self.supported_color_modes):
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import TYPE_CHECKING
|
||||
from music_assistant_client import MusicAssistantClient
|
||||
from music_assistant_client.exceptions import CannotConnect, InvalidServerVersion
|
||||
from music_assistant_models.enums import EventType
|
||||
from music_assistant_models.errors import MusicAssistantError
|
||||
from music_assistant_models.errors import ActionUnavailable, MusicAssistantError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
||||
from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP, Platform
|
||||
@@ -23,7 +23,7 @@ from homeassistant.helpers.issue_registry import (
|
||||
async_delete_issue,
|
||||
)
|
||||
|
||||
from .actions import register_actions
|
||||
from .actions import get_music_assistant_client, register_actions
|
||||
from .const import DOMAIN, LOGGER
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -137,6 +137,18 @@ async def async_setup_entry(
|
||||
mass.subscribe(handle_player_removed, EventType.PLAYER_REMOVED)
|
||||
)
|
||||
|
||||
# check if any playerconfigs have been removed while we were disconnected
|
||||
all_player_configs = await mass.config.get_player_configs()
|
||||
player_ids = {player.player_id for player in all_player_configs}
|
||||
dev_reg = dr.async_get(hass)
|
||||
dev_entries = dr.async_entries_for_config_entry(dev_reg, entry.entry_id)
|
||||
for device in dev_entries:
|
||||
for identifier in device.identifiers:
|
||||
if identifier[0] == DOMAIN and identifier[1] not in player_ids:
|
||||
dev_reg.async_update_device(
|
||||
device.id, remove_config_entry_id=entry.entry_id
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -174,3 +186,31 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
await mass_entry_data.mass.disconnect()
|
||||
|
||||
return unload_ok
|
||||
|
||||
|
||||
async def async_remove_config_entry_device(
|
||||
hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry
|
||||
) -> bool:
|
||||
"""Remove a config entry from a device."""
|
||||
player_id = next(
|
||||
(
|
||||
identifier[1]
|
||||
for identifier in device_entry.identifiers
|
||||
if identifier[0] == DOMAIN
|
||||
),
|
||||
None,
|
||||
)
|
||||
if player_id is None:
|
||||
# this should not be possible at all, but guard it anyways
|
||||
return False
|
||||
mass = get_music_assistant_client(hass, config_entry.entry_id)
|
||||
if mass.players.get(player_id) is None:
|
||||
# player is already removed on the server, this is an orphaned device
|
||||
return True
|
||||
# try to remove the player from the server
|
||||
try:
|
||||
await mass.config.remove_player_config(player_id)
|
||||
except ActionUnavailable:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
@@ -23,6 +23,7 @@ from .const import (
|
||||
ATTR_ALBUM_TYPE,
|
||||
ATTR_ALBUMS,
|
||||
ATTR_ARTISTS,
|
||||
ATTR_AUDIOBOOKS,
|
||||
ATTR_CONFIG_ENTRY_ID,
|
||||
ATTR_FAVORITE,
|
||||
ATTR_ITEMS,
|
||||
@@ -32,6 +33,7 @@ from .const import (
|
||||
ATTR_OFFSET,
|
||||
ATTR_ORDER_BY,
|
||||
ATTR_PLAYLISTS,
|
||||
ATTR_PODCASTS,
|
||||
ATTR_RADIO,
|
||||
ATTR_SEARCH,
|
||||
ATTR_SEARCH_ALBUM,
|
||||
@@ -48,7 +50,15 @@ from .schemas import (
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from music_assistant_client import MusicAssistantClient
|
||||
from music_assistant_models.media_items import Album, Artist, Playlist, Radio, Track
|
||||
from music_assistant_models.media_items import (
|
||||
Album,
|
||||
Artist,
|
||||
Audiobook,
|
||||
Playlist,
|
||||
Podcast,
|
||||
Radio,
|
||||
Track,
|
||||
)
|
||||
|
||||
from . import MusicAssistantConfigEntry
|
||||
|
||||
@@ -155,6 +165,14 @@ async def handle_search(call: ServiceCall) -> ServiceResponse:
|
||||
media_item_dict_from_mass_item(mass, item)
|
||||
for item in search_results.radio
|
||||
],
|
||||
ATTR_AUDIOBOOKS: [
|
||||
media_item_dict_from_mass_item(mass, item)
|
||||
for item in search_results.audiobooks
|
||||
],
|
||||
ATTR_PODCASTS: [
|
||||
media_item_dict_from_mass_item(mass, item)
|
||||
for item in search_results.podcasts
|
||||
],
|
||||
}
|
||||
)
|
||||
return response
|
||||
@@ -175,7 +193,13 @@ async def handle_get_library(call: ServiceCall) -> ServiceResponse:
|
||||
"order_by": order_by,
|
||||
}
|
||||
library_result: (
|
||||
list[Album] | list[Artist] | list[Track] | list[Radio] | list[Playlist]
|
||||
list[Album]
|
||||
| list[Artist]
|
||||
| list[Track]
|
||||
| list[Radio]
|
||||
| list[Playlist]
|
||||
| list[Audiobook]
|
||||
| list[Podcast]
|
||||
)
|
||||
if media_type == MediaType.ALBUM:
|
||||
library_result = await mass.music.get_library_albums(
|
||||
@@ -199,6 +223,14 @@ async def handle_get_library(call: ServiceCall) -> ServiceResponse:
|
||||
library_result = await mass.music.get_library_playlists(
|
||||
**base_params,
|
||||
)
|
||||
elif media_type == MediaType.AUDIOBOOK:
|
||||
library_result = await mass.music.get_library_audiobooks(
|
||||
**base_params,
|
||||
)
|
||||
elif media_type == MediaType.PODCAST:
|
||||
library_result = await mass.music.get_library_podcasts(
|
||||
**base_params,
|
||||
)
|
||||
else:
|
||||
raise ServiceValidationError(f"Unsupported media type {media_type}")
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ ATTR_ARTISTS = "artists"
|
||||
ATTR_ALBUMS = "albums"
|
||||
ATTR_TRACKS = "tracks"
|
||||
ATTR_PLAYLISTS = "playlists"
|
||||
ATTR_AUDIOBOOKS = "audiobooks"
|
||||
ATTR_PODCASTS = "podcasts"
|
||||
ATTR_RADIO = "radio"
|
||||
ATTR_ITEMS = "items"
|
||||
ATTR_RADIO_MODE = "radio_mode"
|
||||
|
||||
@@ -9,6 +9,7 @@ import functools
|
||||
import os
|
||||
from typing import TYPE_CHECKING, Any, Concatenate
|
||||
|
||||
from music_assistant_models.constants import PLAYER_CONTROL_NONE
|
||||
from music_assistant_models.enums import (
|
||||
EventType,
|
||||
MediaType,
|
||||
@@ -80,19 +81,14 @@ if TYPE_CHECKING:
|
||||
from music_assistant_client import MusicAssistantClient
|
||||
from music_assistant_models.player import Player
|
||||
|
||||
SUPPORTED_FEATURES = (
|
||||
MediaPlayerEntityFeature.PAUSE
|
||||
| MediaPlayerEntityFeature.VOLUME_SET
|
||||
| MediaPlayerEntityFeature.STOP
|
||||
SUPPORTED_FEATURES_BASE = (
|
||||
MediaPlayerEntityFeature.STOP
|
||||
| MediaPlayerEntityFeature.PREVIOUS_TRACK
|
||||
| MediaPlayerEntityFeature.NEXT_TRACK
|
||||
| MediaPlayerEntityFeature.SHUFFLE_SET
|
||||
| MediaPlayerEntityFeature.REPEAT_SET
|
||||
| MediaPlayerEntityFeature.TURN_ON
|
||||
| MediaPlayerEntityFeature.TURN_OFF
|
||||
| MediaPlayerEntityFeature.PLAY
|
||||
| MediaPlayerEntityFeature.PLAY_MEDIA
|
||||
| MediaPlayerEntityFeature.VOLUME_STEP
|
||||
| MediaPlayerEntityFeature.CLEAR_PLAYLIST
|
||||
| MediaPlayerEntityFeature.BROWSE_MEDIA
|
||||
| MediaPlayerEntityFeature.MEDIA_ENQUEUE
|
||||
@@ -212,11 +208,7 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
|
||||
"""Initialize MediaPlayer entity."""
|
||||
super().__init__(mass, player_id)
|
||||
self._attr_icon = self.player.icon.replace("mdi-", "mdi:")
|
||||
self._attr_supported_features = SUPPORTED_FEATURES
|
||||
if PlayerFeature.SET_MEMBERS in self.player.supported_features:
|
||||
self._attr_supported_features |= MediaPlayerEntityFeature.GROUPING
|
||||
if PlayerFeature.VOLUME_MUTE in self.player.supported_features:
|
||||
self._attr_supported_features |= MediaPlayerEntityFeature.VOLUME_MUTE
|
||||
self._set_supported_features()
|
||||
self._attr_device_class = MediaPlayerDeviceClass.SPEAKER
|
||||
self._prev_time: float = 0
|
||||
|
||||
@@ -241,6 +233,19 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
|
||||
)
|
||||
)
|
||||
|
||||
# we subscribe to the player config changed event to update
|
||||
# the supported features of the player
|
||||
async def player_config_changed(event: MassEvent) -> None:
|
||||
self._set_supported_features()
|
||||
await self.async_on_update()
|
||||
self.async_write_ha_state()
|
||||
|
||||
self.async_on_remove(
|
||||
self.mass.subscribe(
|
||||
player_config_changed, EventType.PLAYER_CONFIG_UPDATED, self.player_id
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def active_queue(self) -> PlayerQueue | None:
|
||||
"""Return the active queue for this player (if any)."""
|
||||
@@ -271,22 +276,26 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
|
||||
self._attr_state = MediaPlayerState(player.state.value)
|
||||
else:
|
||||
self._attr_state = MediaPlayerState(STATE_OFF)
|
||||
group_members_entity_ids: list[str] = []
|
||||
|
||||
group_members: list[str] = []
|
||||
if player.group_childs:
|
||||
# translate MA group_childs to HA group_members as entity id's
|
||||
entity_registry = er.async_get(self.hass)
|
||||
group_members_entity_ids = [
|
||||
entity_id
|
||||
for child_id in player.group_childs
|
||||
if (
|
||||
entity_id := entity_registry.async_get_entity_id(
|
||||
self.platform.domain, DOMAIN, child_id
|
||||
)
|
||||
group_members = player.group_childs
|
||||
elif player.synced_to and (parent := self.mass.players.get(player.synced_to)):
|
||||
group_members = parent.group_childs
|
||||
|
||||
# translate MA group_childs to HA group_members as entity id's
|
||||
entity_registry = er.async_get(self.hass)
|
||||
group_members_entity_ids: list[str] = [
|
||||
entity_id
|
||||
for child_id in group_members
|
||||
if (
|
||||
entity_id := entity_registry.async_get_entity_id(
|
||||
self.platform.domain, DOMAIN, child_id
|
||||
)
|
||||
]
|
||||
# NOTE: we sort the group_members for now,
|
||||
# until the MA API returns them sorted (group_childs is now a set)
|
||||
self._attr_group_members = sorted(group_members_entity_ids)
|
||||
)
|
||||
]
|
||||
|
||||
self._attr_group_members = group_members_entity_ids
|
||||
self._attr_volume_level = (
|
||||
player.volume_level / 100 if player.volume_level is not None else None
|
||||
)
|
||||
@@ -682,3 +691,20 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
|
||||
if isinstance(queue_option, MediaPlayerEnqueue):
|
||||
queue_option = QUEUE_OPTION_MAP.get(queue_option)
|
||||
return queue_option
|
||||
|
||||
def _set_supported_features(self) -> None:
|
||||
"""Set supported features based on player capabilities."""
|
||||
supported_features = SUPPORTED_FEATURES_BASE
|
||||
if PlayerFeature.SET_MEMBERS in self.player.supported_features:
|
||||
supported_features |= MediaPlayerEntityFeature.GROUPING
|
||||
if PlayerFeature.PAUSE in self.player.supported_features:
|
||||
supported_features |= MediaPlayerEntityFeature.PAUSE
|
||||
if self.player.mute_control != PLAYER_CONTROL_NONE:
|
||||
supported_features |= MediaPlayerEntityFeature.VOLUME_MUTE
|
||||
if self.player.volume_control != PLAYER_CONTROL_NONE:
|
||||
supported_features |= MediaPlayerEntityFeature.VOLUME_STEP
|
||||
supported_features |= MediaPlayerEntityFeature.VOLUME_SET
|
||||
if self.player.power_control != PLAYER_CONTROL_NONE:
|
||||
supported_features |= MediaPlayerEntityFeature.TURN_ON
|
||||
supported_features |= MediaPlayerEntityFeature.TURN_OFF
|
||||
self._attr_supported_features = supported_features
|
||||
|
||||
@@ -15,6 +15,7 @@ from .const import (
|
||||
ATTR_ALBUM,
|
||||
ATTR_ALBUMS,
|
||||
ATTR_ARTISTS,
|
||||
ATTR_AUDIOBOOKS,
|
||||
ATTR_BIT_DEPTH,
|
||||
ATTR_CONTENT_TYPE,
|
||||
ATTR_CURRENT_INDEX,
|
||||
@@ -31,6 +32,7 @@ from .const import (
|
||||
ATTR_OFFSET,
|
||||
ATTR_ORDER_BY,
|
||||
ATTR_PLAYLISTS,
|
||||
ATTR_PODCASTS,
|
||||
ATTR_PROVIDER,
|
||||
ATTR_QUEUE_ID,
|
||||
ATTR_QUEUE_ITEM_ID,
|
||||
@@ -101,6 +103,12 @@ SEARCH_RESULT_SCHEMA = vol.Schema(
|
||||
vol.Required(ATTR_RADIO): vol.All(
|
||||
cv.ensure_list, [vol.Schema(MEDIA_ITEM_SCHEMA)]
|
||||
),
|
||||
vol.Required(ATTR_AUDIOBOOKS): vol.All(
|
||||
cv.ensure_list, [vol.Schema(MEDIA_ITEM_SCHEMA)]
|
||||
),
|
||||
vol.Required(ATTR_PODCASTS): vol.All(
|
||||
cv.ensure_list, [vol.Schema(MEDIA_ITEM_SCHEMA)]
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -21,7 +21,10 @@ play_media:
|
||||
options:
|
||||
- artist
|
||||
- album
|
||||
- audiobook
|
||||
- folder
|
||||
- playlist
|
||||
- podcast
|
||||
- track
|
||||
- radio
|
||||
artist:
|
||||
@@ -118,7 +121,9 @@ search:
|
||||
options:
|
||||
- artist
|
||||
- album
|
||||
- audiobook
|
||||
- playlist
|
||||
- podcast
|
||||
- track
|
||||
- radio
|
||||
artist:
|
||||
@@ -160,7 +165,9 @@ get_library:
|
||||
options:
|
||||
- artist
|
||||
- album
|
||||
- audiobook
|
||||
- playlist
|
||||
- podcast
|
||||
- track
|
||||
- radio
|
||||
favorite:
|
||||
|
||||
@@ -195,8 +195,11 @@
|
||||
"options": {
|
||||
"artist": "Artist",
|
||||
"album": "Album",
|
||||
"audiobook": "Audiobook",
|
||||
"folder": "Folder",
|
||||
"track": "Track",
|
||||
"playlist": "Playlist",
|
||||
"podcast": "Podcast",
|
||||
"radio": "Radio"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -20,7 +20,7 @@ from homeassistant.exceptions import PlatformNotReady
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
from homeassistant.util import Throttle
|
||||
from homeassistant.util import Throttle, dt as dt_util
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -119,6 +119,8 @@ class NSDepartureSensor(SensorEntity):
|
||||
self._time = time
|
||||
self._state = None
|
||||
self._trips = None
|
||||
self._first_trip = None
|
||||
self._next_trip = None
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
@@ -133,44 +135,44 @@ class NSDepartureSensor(SensorEntity):
|
||||
@property
|
||||
def extra_state_attributes(self):
|
||||
"""Return the state attributes."""
|
||||
if not self._trips:
|
||||
if not self._trips or self._first_trip is None:
|
||||
return None
|
||||
|
||||
if self._trips[0].trip_parts:
|
||||
route = [self._trips[0].departure]
|
||||
route.extend(k.destination for k in self._trips[0].trip_parts)
|
||||
if self._first_trip.trip_parts:
|
||||
route = [self._first_trip.departure]
|
||||
route.extend(k.destination for k in self._first_trip.trip_parts)
|
||||
|
||||
# Static attributes
|
||||
attributes = {
|
||||
"going": self._trips[0].going,
|
||||
"going": self._first_trip.going,
|
||||
"departure_time_planned": None,
|
||||
"departure_time_actual": None,
|
||||
"departure_delay": False,
|
||||
"departure_platform_planned": self._trips[0].departure_platform_planned,
|
||||
"departure_platform_actual": self._trips[0].departure_platform_actual,
|
||||
"departure_platform_planned": self._first_trip.departure_platform_planned,
|
||||
"departure_platform_actual": self._first_trip.departure_platform_actual,
|
||||
"arrival_time_planned": None,
|
||||
"arrival_time_actual": None,
|
||||
"arrival_delay": False,
|
||||
"arrival_platform_planned": self._trips[0].arrival_platform_planned,
|
||||
"arrival_platform_actual": self._trips[0].arrival_platform_actual,
|
||||
"arrival_platform_planned": self._first_trip.arrival_platform_planned,
|
||||
"arrival_platform_actual": self._first_trip.arrival_platform_actual,
|
||||
"next": None,
|
||||
"status": self._trips[0].status.lower(),
|
||||
"transfers": self._trips[0].nr_transfers,
|
||||
"status": self._first_trip.status.lower(),
|
||||
"transfers": self._first_trip.nr_transfers,
|
||||
"route": route,
|
||||
"remarks": None,
|
||||
}
|
||||
|
||||
# Planned departure attributes
|
||||
if self._trips[0].departure_time_planned is not None:
|
||||
attributes["departure_time_planned"] = self._trips[
|
||||
0
|
||||
].departure_time_planned.strftime("%H:%M")
|
||||
if self._first_trip.departure_time_planned is not None:
|
||||
attributes["departure_time_planned"] = (
|
||||
self._first_trip.departure_time_planned.strftime("%H:%M")
|
||||
)
|
||||
|
||||
# Actual departure attributes
|
||||
if self._trips[0].departure_time_actual is not None:
|
||||
attributes["departure_time_actual"] = self._trips[
|
||||
0
|
||||
].departure_time_actual.strftime("%H:%M")
|
||||
if self._first_trip.departure_time_actual is not None:
|
||||
attributes["departure_time_actual"] = (
|
||||
self._first_trip.departure_time_actual.strftime("%H:%M")
|
||||
)
|
||||
|
||||
# Delay departure attributes
|
||||
if (
|
||||
@@ -182,16 +184,16 @@ class NSDepartureSensor(SensorEntity):
|
||||
attributes["departure_delay"] = True
|
||||
|
||||
# Planned arrival attributes
|
||||
if self._trips[0].arrival_time_planned is not None:
|
||||
attributes["arrival_time_planned"] = self._trips[
|
||||
0
|
||||
].arrival_time_planned.strftime("%H:%M")
|
||||
if self._first_trip.arrival_time_planned is not None:
|
||||
attributes["arrival_time_planned"] = (
|
||||
self._first_trip.arrival_time_planned.strftime("%H:%M")
|
||||
)
|
||||
|
||||
# Actual arrival attributes
|
||||
if self._trips[0].arrival_time_actual is not None:
|
||||
attributes["arrival_time_actual"] = self._trips[
|
||||
0
|
||||
].arrival_time_actual.strftime("%H:%M")
|
||||
if self._first_trip.arrival_time_actual is not None:
|
||||
attributes["arrival_time_actual"] = (
|
||||
self._first_trip.arrival_time_actual.strftime("%H:%M")
|
||||
)
|
||||
|
||||
# Delay arrival attributes
|
||||
if (
|
||||
@@ -202,15 +204,14 @@ class NSDepartureSensor(SensorEntity):
|
||||
attributes["arrival_delay"] = True
|
||||
|
||||
# Next attributes
|
||||
if len(self._trips) > 1:
|
||||
if self._trips[1].departure_time_actual is not None:
|
||||
attributes["next"] = self._trips[1].departure_time_actual.strftime(
|
||||
"%H:%M"
|
||||
)
|
||||
elif self._trips[1].departure_time_planned is not None:
|
||||
attributes["next"] = self._trips[1].departure_time_planned.strftime(
|
||||
"%H:%M"
|
||||
)
|
||||
if self._next_trip.departure_time_actual is not None:
|
||||
attributes["next"] = self._next_trip.departure_time_actual.strftime("%H:%M")
|
||||
elif self._next_trip.departure_time_planned is not None:
|
||||
attributes["next"] = self._next_trip.departure_time_planned.strftime(
|
||||
"%H:%M"
|
||||
)
|
||||
else:
|
||||
attributes["next"] = None
|
||||
|
||||
return attributes
|
||||
|
||||
@@ -225,6 +226,7 @@ class NSDepartureSensor(SensorEntity):
|
||||
):
|
||||
self._state = None
|
||||
self._trips = None
|
||||
self._first_trip = None
|
||||
return
|
||||
|
||||
# Set the search parameter to search from a specific trip time
|
||||
@@ -236,19 +238,51 @@ class NSDepartureSensor(SensorEntity):
|
||||
.strftime("%d-%m-%Y %H:%M")
|
||||
)
|
||||
else:
|
||||
trip_time = datetime.now().strftime("%d-%m-%Y %H:%M")
|
||||
trip_time = dt_util.now().strftime("%d-%m-%Y %H:%M")
|
||||
|
||||
try:
|
||||
self._trips = self._nsapi.get_trips(
|
||||
trip_time, self._departure, self._via, self._heading, True, 0, 2
|
||||
)
|
||||
if self._trips:
|
||||
if self._trips[0].departure_time_actual is None:
|
||||
planned_time = self._trips[0].departure_time_planned
|
||||
self._state = planned_time.strftime("%H:%M")
|
||||
all_times = []
|
||||
|
||||
# If a train is delayed we can observe this through departure_time_actual.
|
||||
for trip in self._trips:
|
||||
if trip.departure_time_actual is None:
|
||||
all_times.append(trip.departure_time_planned)
|
||||
else:
|
||||
all_times.append(trip.departure_time_actual)
|
||||
|
||||
# Remove all trains that already left.
|
||||
filtered_times = [
|
||||
(i, time)
|
||||
for i, time in enumerate(all_times)
|
||||
if time > dt_util.now()
|
||||
]
|
||||
|
||||
if len(filtered_times) > 0:
|
||||
sorted_times = sorted(filtered_times, key=lambda x: x[1])
|
||||
self._first_trip = self._trips[sorted_times[0][0]]
|
||||
self._state = sorted_times[0][1].strftime("%H:%M")
|
||||
|
||||
# Filter again to remove trains that leave at the exact same time.
|
||||
filtered_times = [
|
||||
(i, time)
|
||||
for i, time in enumerate(all_times)
|
||||
if time > sorted_times[0][1]
|
||||
]
|
||||
|
||||
if len(filtered_times) > 0:
|
||||
sorted_times = sorted(filtered_times, key=lambda x: x[1])
|
||||
self._next_trip = self._trips[sorted_times[0][0]]
|
||||
else:
|
||||
self._next_trip = None
|
||||
|
||||
else:
|
||||
actual_time = self._trips[0].departure_time_actual
|
||||
self._state = actual_time.strftime("%H:%M")
|
||||
self._first_trip = None
|
||||
self._state = None
|
||||
|
||||
except (
|
||||
requests.exceptions.ConnectionError,
|
||||
requests.exceptions.HTTPError,
|
||||
|
||||
@@ -19,5 +19,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/nest",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["google_nest_sdm"],
|
||||
"requirements": ["google-nest-sdm==7.1.3"]
|
||||
"requirements": ["google-nest-sdm==7.1.4"]
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
|
||||
"missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]",
|
||||
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/nexia",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["nexia"],
|
||||
"requirements": ["nexia==2.0.9"]
|
||||
"requirements": ["nexia==2.2.2"]
|
||||
}
|
||||
|
||||
@@ -58,6 +58,9 @@
|
||||
"switch": {
|
||||
"hold": {
|
||||
"name": "Hold"
|
||||
},
|
||||
"emergency_heat": {
|
||||
"name": "Emergency heat"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -486,6 +486,7 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = {
|
||||
NumberDeviceClass.PM25: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
|
||||
NumberDeviceClass.POWER_FACTOR: {PERCENTAGE, None},
|
||||
NumberDeviceClass.POWER: {
|
||||
UnitOfPower.MILLIWATT,
|
||||
UnitOfPower.WATT,
|
||||
UnitOfPower.KILO_WATT,
|
||||
UnitOfPower.MEGA_WATT,
|
||||
|
||||
@@ -7,5 +7,6 @@ set_value:
|
||||
fields:
|
||||
value:
|
||||
example: 42
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
|
||||
@@ -41,14 +41,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> bool:
|
||||
"""Set up OneDrive from a config entry."""
|
||||
implementation = await async_get_config_entry_implementation(hass, entry)
|
||||
session = OAuth2Session(hass, entry, implementation)
|
||||
|
||||
async def get_access_token() -> str:
|
||||
await session.async_ensure_token_valid()
|
||||
return cast(str, session.token[CONF_ACCESS_TOKEN])
|
||||
|
||||
client = OneDriveClient(get_access_token, async_get_clientsession(hass))
|
||||
client, get_access_token = await _get_onedrive_client(hass, entry)
|
||||
|
||||
# get approot, will be created automatically if it does not exist
|
||||
approot = await _handle_item_operation(client.get_approot, "approot")
|
||||
@@ -104,11 +97,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) ->
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
async def update_listener(hass: HomeAssistant, entry: OneDriveConfigEntry) -> None:
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(update_listener))
|
||||
|
||||
def async_notify_backup_listeners() -> None:
|
||||
for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []):
|
||||
listener()
|
||||
@@ -164,20 +152,47 @@ async def async_migrate_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -
|
||||
_LOGGER.debug(
|
||||
"Migrating OneDrive config entry from version %s.%s", version, minor_version
|
||||
)
|
||||
|
||||
client, _ = await _get_onedrive_client(hass, entry)
|
||||
instance_id = await async_get_instance_id(hass)
|
||||
try:
|
||||
approot = await client.get_approot()
|
||||
folder = await client.get_drive_item(
|
||||
f"{approot.id}:/backups_{instance_id[:8]}:"
|
||||
)
|
||||
except OneDriveException:
|
||||
_LOGGER.exception("Migration to version 1.2 failed")
|
||||
return False
|
||||
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
data={
|
||||
**entry.data,
|
||||
CONF_FOLDER_ID: "id", # will be updated during setup_entry
|
||||
CONF_FOLDER_ID: folder.id,
|
||||
CONF_FOLDER_NAME: f"backups_{instance_id[:8]}",
|
||||
},
|
||||
minor_version=2,
|
||||
)
|
||||
_LOGGER.debug("Migration to version 1.2 successful")
|
||||
return True
|
||||
|
||||
|
||||
async def _get_onedrive_client(
|
||||
hass: HomeAssistant, entry: OneDriveConfigEntry
|
||||
) -> tuple[OneDriveClient, Callable[[], Awaitable[str]]]:
|
||||
"""Get OneDrive client."""
|
||||
implementation = await async_get_config_entry_implementation(hass, entry)
|
||||
session = OAuth2Session(hass, entry, implementation)
|
||||
|
||||
async def get_access_token() -> str:
|
||||
await session.async_ensure_token_valid()
|
||||
return cast(str, session.token[CONF_ACCESS_TOKEN])
|
||||
|
||||
return (
|
||||
OneDriveClient(get_access_token, async_get_clientsession(hass)),
|
||||
get_access_token,
|
||||
)
|
||||
|
||||
|
||||
async def _handle_item_operation(
|
||||
func: Callable[[], Awaitable[Item]], folder: str
|
||||
) -> Item:
|
||||
|
||||
33
homeassistant/components/onedrive/diagnostics.py
Normal file
33
homeassistant/components/onedrive/diagnostics.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""Diagnostics support for OneDrive."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import OneDriveConfigEntry
|
||||
|
||||
TO_REDACT = {"display_name", "email", CONF_ACCESS_TOKEN, CONF_TOKEN}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant,
|
||||
entry: OneDriveConfigEntry,
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
|
||||
data = {
|
||||
"drive": asdict(coordinator.data),
|
||||
"config": {
|
||||
**entry.data,
|
||||
**entry.options,
|
||||
},
|
||||
}
|
||||
|
||||
return async_redact_data(data, TO_REDACT)
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["onedrive_personal_sdk"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["onedrive-personal-sdk==0.0.11"]
|
||||
"requirements": ["onedrive-personal-sdk==0.0.13"]
|
||||
}
|
||||
|
||||
@@ -41,10 +41,7 @@ rules:
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics:
|
||||
status: exempt
|
||||
comment: |
|
||||
There is no data to diagnose.
|
||||
diagnostics: done
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: |
|
||||
|
||||
@@ -103,7 +103,7 @@ class OneDriveDriveStateSensor(
|
||||
self._attr_unique_id = f"{coordinator.data.id}_{description.key}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
name=coordinator.data.name,
|
||||
name=coordinator.data.name or coordinator.config_entry.title,
|
||||
identifiers={(DOMAIN, coordinator.data.id)},
|
||||
manufacturer="Microsoft",
|
||||
model=f"OneDrive {coordinator.data.drive_type.value.capitalize()}",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user