forked from home-assistant/core
Compare commits
205 Commits
2024.11.0b3
...
2024.11.3
| Author | SHA1 | Date | |
|---|---|---|---|
| 0644d782cd | |||
| 4ef50ffd88 | |||
| bfcd4194f3 | |||
| 2f05240e4c | |||
| 44ad8081a3 | |||
| 780eaa8379 | |||
| 75dcdfb087 | |||
| c88ff2ca44 | |||
| 402c668f05 | |||
| 93b4570c04 | |||
| 50a610914b | |||
| 8db18181d0 | |||
| 335124acc6 | |||
| 24ccb9b894 | |||
| a75ce850b8 | |||
| 4753510ace | |||
| fc607ea7e5 | |||
| 477141c22a | |||
| aaa36adbcc | |||
| 9447180c04 | |||
| 6853234f9d | |||
| 6944ba0333 | |||
| 04bc041174 | |||
| a024acf096 | |||
| 5b1aca53ac | |||
| a588ced2e3 | |||
| 876112ff54 | |||
| a48f88033d | |||
| 5deba1766e | |||
| 4863243f5a | |||
| 847afabed1 | |||
| ac270e19be | |||
| ca40b96a89 | |||
| 045e285bfe | |||
| 8d6f2e78f5 | |||
| 9e4d26137e | |||
| f74bfdc974 | |||
| 1cabcdf257 | |||
| c6931d656e | |||
| 942830505a | |||
| 880f28e28a | |||
| f406ffa75a | |||
| 0d695c843f | |||
| 5f09eb97e1 | |||
| 6d561ca373 | |||
| 663ebe199d | |||
| 8b9c4db2b3 | |||
| e478b9b599 | |||
| 5acdf58976 | |||
| 6d861e7f47 | |||
| 281a8eda31 | |||
| 1bc005d0d4 | |||
| 95d60987ab | |||
| 53e38454b2 | |||
| 876b86cd3d | |||
| cb104935ea | |||
| 4c24e26926 | |||
| 4b13d8bc47 | |||
| 433e3718f8 | |||
| 1e3c2c0631 | |||
| 3a2f996c13 | |||
| e4cb3c67d9 | |||
| 8a22433168 | |||
| 0976476d16 | |||
| 28f46a0f88 | |||
| 8b173656e7 | |||
| 08f6f2759b | |||
| f4798d27c7 | |||
| 103a84b4bd | |||
| 4d3502e061 | |||
| 79329e16cf | |||
| 929164251a | |||
| 300724443a | |||
| 70ef3a355c | |||
| 83162c1461 | |||
| a12c76dbdd | |||
| 9292b6da3d | |||
| 8d05183de2 | |||
| a86ff41bbc | |||
| ce92f3de44 | |||
| 465d8b2ee2 | |||
| 218eedfd93 | |||
| afec354b84 | |||
| 282f92e5f3 | |||
| f6cd74e2d7 | |||
| f821ddeab8 | |||
| d408b7ac62 | |||
| 83baa1a788 | |||
| 07a8cf14cd | |||
| 9f447af468 | |||
| c399d8f571 | |||
| 4ea9574229 | |||
| 592b8ed0a0 | |||
| 6b91c0810a | |||
| 9579e4a9c1 | |||
| 7f4f90f06d | |||
| 701a901fe4 | |||
| f914642e31 | |||
| 32dc9fc238 | |||
| b27e0f9fe7 | |||
| f040060b3c | |||
| cc45793896 | |||
| ab0556227c | |||
| c16fb9c93d | |||
| da8fc7a2fc | |||
| 864b4d86f2 | |||
| 1bb0ced7c0 | |||
| 2fe4fc908b | |||
| aa2c3b046f | |||
| 22822cb8aa | |||
| b71383c997 | |||
| b0b163df48 | |||
| 35539dbf60 | |||
| 09d03e8edf | |||
| 46e37f3bdd | |||
| 0206c149cf | |||
| 29620ef977 | |||
| 9012b113ad | |||
| 5f5f6cc3d5 | |||
| 7ff501f3ec | |||
| b0f110b9ab | |||
| 2692bc23a5 | |||
| 1beac5f0f8 | |||
| ec7ba1b7fd | |||
| 5bd1b0dd9c | |||
| a2ad4c9cfd | |||
| 18e12740d9 | |||
| 5a24b670a2 | |||
| 94c5c8f42e | |||
| e84d5fba11 | |||
| 782417528c | |||
| 7757423d18 | |||
| e5a28f4f25 | |||
| c18d50910f | |||
| 3b840c684b | |||
| bc84fdc64a | |||
| 401262c23d | |||
| 795384ca2d | |||
| dfc3423c83 | |||
| 22b5071c26 | |||
| 4b9524c5c1 | |||
| 9cd46c7f03 | |||
| 232a6868ff | |||
| 361e0d4fc7 | |||
| 26d8d5343a | |||
| 995aab8347 | |||
| 399011552b | |||
| 0c9f30364c | |||
| bdc17621ee | |||
| 399c53a57e | |||
| f55e13bde4 | |||
| 48d9df89ac | |||
| adf836d9ac | |||
| 211ce43127 | |||
| f5555df990 | |||
| 82c2422990 | |||
| 734ebc1adb | |||
| eb3371beef | |||
| e1ef1063fe | |||
| c355a53485 | |||
| c85eb6bf8e | |||
| cc30d34e87 | |||
| 14875a1101 | |||
| 030aebb97f | |||
| 6e2f36b6d4 | |||
| 25a05eb156 | |||
| b71c4377f6 | |||
| d671341864 | |||
| 383f712d43 | |||
| 8a20cd77a0 | |||
| 14023644ef | |||
| 496fc42b94 | |||
| da0688ce8e | |||
| 89d3707cb7 | |||
| 3f5e395e2f | |||
| 00ea1cab9f | |||
| c7b2ffbc8e | |||
| 3a1502e2bb | |||
| b830f83a34 | |||
| 2982e733bc | |||
| e89ce215c6 | |||
| b6345f8d07 | |||
| 9d261bab48 | |||
| b6f875134e | |||
| 90ceebdf91 | |||
| 03e6a13896 | |||
| 9fb3261f02 | |||
| 0bc6b8b0d4 | |||
| 18d2ced045 | |||
| 6c75e0bee1 | |||
| 0b981f42bb | |||
| 82868a8588 | |||
| 6e93777f54 | |||
| 9349292464 | |||
| 7084b3b52c | |||
| 0f0f5fd0ab | |||
| cb0b942db3 | |||
| b1c9f83952 | |||
| 1ff0efc97b | |||
| a4da2a9eb5 | |||
| ba3cfb5f87 | |||
| bf196935f6 | |||
| 6e98343706 | |||
| de453ab5c1 | |||
| f408de4fc3 |
@@ -90,7 +90,7 @@ repos:
|
||||
pass_filenames: false
|
||||
language: script
|
||||
types: [text]
|
||||
files: ^(script/hassfest/metadata\.py|homeassistant/const\.py$|pyproject\.toml)$
|
||||
files: ^(script/hassfest/metadata\.py|homeassistant/const\.py$|pyproject\.toml|homeassistant/components/go2rtc/const\.py)$
|
||||
- id: hassfest-mypy-config
|
||||
name: hassfest-mypy-config
|
||||
entry: script/run-in-env.sh python3 -m script.hassfest -p mypy_config
|
||||
|
||||
+3
-2
@@ -7,7 +7,8 @@ FROM ${BUILD_FROM}
|
||||
# Synchronize with homeassistant/core.py:async_stop
|
||||
ENV \
|
||||
S6_SERVICES_GRACETIME=240000 \
|
||||
UV_SYSTEM_PYTHON=true
|
||||
UV_SYSTEM_PYTHON=true \
|
||||
UV_NO_CACHE=true
|
||||
|
||||
ARG QEMU_CPU
|
||||
|
||||
@@ -54,7 +55,7 @@ RUN \
|
||||
"armv7") go2rtc_suffix='arm' ;; \
|
||||
*) go2rtc_suffix=${BUILD_ARCH} ;; \
|
||||
esac \
|
||||
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.6/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
|
||||
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.7/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
|
||||
&& chmod +x /bin/go2rtc \
|
||||
# Verify go2rtc can be executed
|
||||
&& go2rtc --version
|
||||
|
||||
@@ -249,6 +249,7 @@ WEATHER_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = (
|
||||
name="Rain",
|
||||
native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR,
|
||||
device_class=SensorDeviceClass.PRECIPITATION_INTENSITY,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
AemetSensorEntityDescription(
|
||||
key=ATTR_API_RAIN_PROB,
|
||||
@@ -263,6 +264,7 @@ WEATHER_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = (
|
||||
name="Snow",
|
||||
native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR,
|
||||
device_class=SensorDeviceClass.PRECIPITATION_INTENSITY,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
AemetSensorEntityDescription(
|
||||
key=ATTR_API_SNOW_PROB,
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/agent_dvr",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["agent"],
|
||||
"requirements": ["agent-py==0.0.23"]
|
||||
"requirements": ["agent-py==0.0.24"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aioairq"],
|
||||
"requirements": ["aioairq==0.3.2"]
|
||||
"requirements": ["aioairq==0.4.3"]
|
||||
}
|
||||
|
||||
@@ -11,5 +11,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/airzone",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aioairzone"],
|
||||
"requirements": ["aioairzone==0.9.5"]
|
||||
"requirements": ["aioairzone==0.9.7"]
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import asyncio
|
||||
from datetime import timedelta
|
||||
from functools import partial
|
||||
import logging
|
||||
from typing import Any, Final, final
|
||||
from typing import TYPE_CHECKING, Any, Final, final
|
||||
|
||||
from propcache import cached_property
|
||||
import voluptuous as vol
|
||||
@@ -221,9 +221,15 @@ class AlarmControlPanelEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_A
|
||||
@property
|
||||
def state(self) -> str | None:
|
||||
"""Return the current state."""
|
||||
if (alarm_state := self.alarm_state) is None:
|
||||
return None
|
||||
return alarm_state
|
||||
if (alarm_state := self.alarm_state) is not None:
|
||||
return alarm_state
|
||||
if self._attr_state is not None:
|
||||
# Backwards compatibility for integrations that set state directly
|
||||
# Should be removed in 2025.11
|
||||
if TYPE_CHECKING:
|
||||
assert isinstance(self._attr_state, str)
|
||||
return self._attr_state
|
||||
return None
|
||||
|
||||
@cached_property
|
||||
def alarm_state(self) -> AlarmControlPanelState | None:
|
||||
|
||||
@@ -38,6 +38,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ApSystemsConfigEntry) ->
|
||||
ip_address=entry.data[CONF_IP_ADDRESS],
|
||||
port=entry.data.get(CONF_PORT, DEFAULT_PORT),
|
||||
timeout=8,
|
||||
enable_debounce=True,
|
||||
)
|
||||
coordinator = ApSystemsDataCoordinator(hass, api)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/apsystems",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["apsystems-ez1==2.2.1"]
|
||||
"requirements": ["apsystems-ez1==2.4.0"]
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
from typing import Any
|
||||
|
||||
from aiohttp.client_exceptions import ClientConnectionError
|
||||
from APsystemsEZ1 import InverterReturnedError
|
||||
|
||||
from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -40,7 +41,7 @@ class ApSystemsInverterSwitch(ApSystemsEntity, SwitchEntity):
|
||||
"""Update switch status and availability."""
|
||||
try:
|
||||
status = await self._api.get_device_power_status()
|
||||
except (TimeoutError, ClientConnectionError):
|
||||
except (TimeoutError, ClientConnectionError, InverterReturnedError):
|
||||
self._attr_available = False
|
||||
else:
|
||||
self._attr_available = True
|
||||
|
||||
@@ -124,7 +124,9 @@ class AEHConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
step_id=STEP_CONN_STRING,
|
||||
data_schema=CONN_STRING_SCHEMA,
|
||||
errors=errors,
|
||||
description_placeholders=self._data[CONF_EVENT_HUB_INSTANCE_NAME],
|
||||
description_placeholders={
|
||||
"event_hub_instance_name": self._data[CONF_EVENT_HUB_INSTANCE_NAME]
|
||||
},
|
||||
last_step=True,
|
||||
)
|
||||
|
||||
@@ -144,7 +146,9 @@ class AEHConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
step_id=STEP_SAS,
|
||||
data_schema=SAS_SCHEMA,
|
||||
errors=errors,
|
||||
description_placeholders=self._data[CONF_EVENT_HUB_INSTANCE_NAME],
|
||||
description_placeholders={
|
||||
"event_hub_instance_name": self._data[CONF_EVENT_HUB_INSTANCE_NAME]
|
||||
},
|
||||
last_step=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -21,41 +21,57 @@ class BangOlufsenSource:
|
||||
name="Audio Streamer",
|
||||
id="uriStreamer",
|
||||
is_seekable=False,
|
||||
is_enabled=True,
|
||||
is_playable=True,
|
||||
)
|
||||
BLUETOOTH: Final[Source] = Source(
|
||||
name="Bluetooth",
|
||||
id="bluetooth",
|
||||
is_seekable=False,
|
||||
is_enabled=True,
|
||||
is_playable=True,
|
||||
)
|
||||
CHROMECAST: Final[Source] = Source(
|
||||
name="Chromecast built-in",
|
||||
id="chromeCast",
|
||||
is_seekable=False,
|
||||
is_enabled=True,
|
||||
is_playable=True,
|
||||
)
|
||||
LINE_IN: Final[Source] = Source(
|
||||
name="Line-In",
|
||||
id="lineIn",
|
||||
is_seekable=False,
|
||||
is_enabled=True,
|
||||
is_playable=True,
|
||||
)
|
||||
SPDIF: Final[Source] = Source(
|
||||
name="Optical",
|
||||
id="spdif",
|
||||
is_seekable=False,
|
||||
is_enabled=True,
|
||||
is_playable=True,
|
||||
)
|
||||
NET_RADIO: Final[Source] = Source(
|
||||
name="B&O Radio",
|
||||
id="netRadio",
|
||||
is_seekable=False,
|
||||
is_enabled=True,
|
||||
is_playable=True,
|
||||
)
|
||||
DEEZER: Final[Source] = Source(
|
||||
name="Deezer",
|
||||
id="deezer",
|
||||
is_seekable=True,
|
||||
is_enabled=True,
|
||||
is_playable=True,
|
||||
)
|
||||
TIDAL: Final[Source] = Source(
|
||||
name="Tidal",
|
||||
id="tidal",
|
||||
is_seekable=True,
|
||||
is_enabled=True,
|
||||
is_playable=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -170,20 +186,6 @@ VALID_MEDIA_TYPES: Final[tuple] = (
|
||||
MediaType.CHANNEL,
|
||||
)
|
||||
|
||||
# Sources on the device that should not be selectable by the user
|
||||
HIDDEN_SOURCE_IDS: Final[tuple] = (
|
||||
"airPlay",
|
||||
"bluetooth",
|
||||
"chromeCast",
|
||||
"generator",
|
||||
"local",
|
||||
"dlna",
|
||||
"qplay",
|
||||
"wpl",
|
||||
"pl",
|
||||
"beolink",
|
||||
"usbIn",
|
||||
)
|
||||
|
||||
# Fallback sources to use in case of API failure.
|
||||
FALLBACK_SOURCES: Final[SourceArray] = SourceArray(
|
||||
@@ -191,7 +193,7 @@ FALLBACK_SOURCES: Final[SourceArray] = SourceArray(
|
||||
Source(
|
||||
id="uriStreamer",
|
||||
is_enabled=True,
|
||||
is_playable=False,
|
||||
is_playable=True,
|
||||
name="Audio Streamer",
|
||||
type=SourceTypeEnum(value="uriStreamer"),
|
||||
is_seekable=False,
|
||||
@@ -199,7 +201,7 @@ FALLBACK_SOURCES: Final[SourceArray] = SourceArray(
|
||||
Source(
|
||||
id="bluetooth",
|
||||
is_enabled=True,
|
||||
is_playable=False,
|
||||
is_playable=True,
|
||||
name="Bluetooth",
|
||||
type=SourceTypeEnum(value="bluetooth"),
|
||||
is_seekable=False,
|
||||
@@ -207,7 +209,7 @@ FALLBACK_SOURCES: Final[SourceArray] = SourceArray(
|
||||
Source(
|
||||
id="spotify",
|
||||
is_enabled=True,
|
||||
is_playable=False,
|
||||
is_playable=True,
|
||||
name="Spotify Connect",
|
||||
type=SourceTypeEnum(value="spotify"),
|
||||
is_seekable=True,
|
||||
|
||||
@@ -70,7 +70,6 @@ from .const import (
|
||||
CONNECTION_STATUS,
|
||||
DOMAIN,
|
||||
FALLBACK_SOURCES,
|
||||
HIDDEN_SOURCE_IDS,
|
||||
VALID_MEDIA_TYPES,
|
||||
BangOlufsenMediaType,
|
||||
BangOlufsenSource,
|
||||
@@ -169,6 +168,7 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
|
||||
WebsocketNotification.PLAYBACK_ERROR: self._async_update_playback_error,
|
||||
WebsocketNotification.PLAYBACK_METADATA: self._async_update_playback_metadata_and_beolink,
|
||||
WebsocketNotification.PLAYBACK_PROGRESS: self._async_update_playback_progress,
|
||||
WebsocketNotification.PLAYBACK_SOURCE: self._async_update_sources,
|
||||
WebsocketNotification.PLAYBACK_STATE: self._async_update_playback_state,
|
||||
WebsocketNotification.REMOTE_MENU_CHANGED: self._async_update_sources,
|
||||
WebsocketNotification.SOURCE_CHANGE: self._async_update_source_change,
|
||||
@@ -243,7 +243,7 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
|
||||
if queue_settings.shuffle is not None:
|
||||
self._attr_shuffle = queue_settings.shuffle
|
||||
|
||||
async def _async_update_sources(self) -> None:
|
||||
async def _async_update_sources(self, _: Source | None = None) -> None:
|
||||
"""Get sources for the specific product."""
|
||||
|
||||
# Audio sources
|
||||
@@ -270,10 +270,7 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
|
||||
self._audio_sources = {
|
||||
source.id: source.name
|
||||
for source in cast(list[Source], sources.items)
|
||||
if source.is_enabled
|
||||
and source.id
|
||||
and source.name
|
||||
and source.id not in HIDDEN_SOURCE_IDS
|
||||
if source.is_enabled and source.id and source.name and source.is_playable
|
||||
}
|
||||
|
||||
# Some sources are not Beolink expandable, meaning that they can't be joined by
|
||||
|
||||
@@ -63,6 +63,9 @@ class BangOlufsenWebsocket(BangOlufsenBase):
|
||||
self._client.get_playback_progress_notifications(
|
||||
self.on_playback_progress_notification
|
||||
)
|
||||
self._client.get_playback_source_notifications(
|
||||
self.on_playback_source_notification
|
||||
)
|
||||
self._client.get_playback_state_notifications(
|
||||
self.on_playback_state_notification
|
||||
)
|
||||
@@ -157,6 +160,14 @@ class BangOlufsenWebsocket(BangOlufsenBase):
|
||||
notification,
|
||||
)
|
||||
|
||||
def on_playback_source_notification(self, notification: Source) -> None:
|
||||
"""Send playback_source dispatch."""
|
||||
async_dispatcher_send(
|
||||
self.hass,
|
||||
f"{self._unique_id}_{WebsocketNotification.PLAYBACK_SOURCE}",
|
||||
notification,
|
||||
)
|
||||
|
||||
def on_source_change_notification(self, notification: Source) -> None:
|
||||
"""Send source_change dispatch."""
|
||||
async_dispatcher_send(
|
||||
|
||||
@@ -364,12 +364,13 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
if self.is_grouped and not self.is_master:
|
||||
return MediaPlayerState.IDLE
|
||||
|
||||
status = self._status.state
|
||||
if status in ("pause", "stop"):
|
||||
return MediaPlayerState.PAUSED
|
||||
if status in ("stream", "play"):
|
||||
return MediaPlayerState.PLAYING
|
||||
return MediaPlayerState.IDLE
|
||||
match self._status.state:
|
||||
case "pause":
|
||||
return MediaPlayerState.PAUSED
|
||||
case "stream" | "play":
|
||||
return MediaPlayerState.PLAYING
|
||||
case _:
|
||||
return MediaPlayerState.IDLE
|
||||
|
||||
@property
|
||||
def media_title(self) -> str | None:
|
||||
@@ -769,7 +770,7 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
|
||||
async def async_set_volume_level(self, volume: float) -> None:
|
||||
"""Send volume_up command to media player."""
|
||||
volume = int(volume * 100)
|
||||
volume = int(round(volume * 100))
|
||||
volume = min(100, volume)
|
||||
volume = max(0, volume)
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"requirements": [
|
||||
"bleak==0.22.3",
|
||||
"bleak-retry-connector==3.6.0",
|
||||
"bluetooth-adapters==0.20.0",
|
||||
"bluetooth-adapters==0.20.2",
|
||||
"bluetooth-auto-recovery==1.4.2",
|
||||
"bluetooth-data-tools==1.20.0",
|
||||
"dbus-fast==2.24.3",
|
||||
|
||||
@@ -7,7 +7,11 @@ from typing import Any
|
||||
|
||||
from bimmer_connected.api.authentication import MyBMWAuthentication
|
||||
from bimmer_connected.api.regions import get_region_from_name
|
||||
from bimmer_connected.models import MyBMWAPIError, MyBMWAuthError
|
||||
from bimmer_connected.models import (
|
||||
MyBMWAPIError,
|
||||
MyBMWAuthError,
|
||||
MyBMWCaptchaMissingError,
|
||||
)
|
||||
from httpx import RequestError
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -54,6 +58,8 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
|
||||
|
||||
try:
|
||||
await auth.login()
|
||||
except MyBMWCaptchaMissingError as ex:
|
||||
raise MissingCaptcha from ex
|
||||
except MyBMWAuthError as ex:
|
||||
raise InvalidAuth from ex
|
||||
except (MyBMWAPIError, RequestError) as ex:
|
||||
@@ -98,6 +104,8 @@ class BMWConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
CONF_REFRESH_TOKEN: info.get(CONF_REFRESH_TOKEN),
|
||||
CONF_GCID: info.get(CONF_GCID),
|
||||
}
|
||||
except MissingCaptcha:
|
||||
errors["base"] = "missing_captcha"
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidAuth:
|
||||
@@ -192,3 +200,7 @@ class CannotConnect(HomeAssistantError):
|
||||
|
||||
class InvalidAuth(HomeAssistantError):
|
||||
"""Error to indicate there is invalid auth."""
|
||||
|
||||
|
||||
class MissingCaptcha(HomeAssistantError):
|
||||
"""Error to indicate the captcha token is missing."""
|
||||
|
||||
@@ -7,7 +7,12 @@ import logging
|
||||
|
||||
from bimmer_connected.account import MyBMWAccount
|
||||
from bimmer_connected.api.regions import get_region_from_name
|
||||
from bimmer_connected.models import GPSPosition, MyBMWAPIError, MyBMWAuthError
|
||||
from bimmer_connected.models import (
|
||||
GPSPosition,
|
||||
MyBMWAPIError,
|
||||
MyBMWAuthError,
|
||||
MyBMWCaptchaMissingError,
|
||||
)
|
||||
from httpx import RequestError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -61,6 +66,12 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
|
||||
try:
|
||||
await self.account.get_vehicles()
|
||||
except MyBMWCaptchaMissingError as err:
|
||||
# If a captcha is required (user/password login flow), always trigger the reauth flow
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="missing_captcha",
|
||||
) from err
|
||||
except MyBMWAuthError as err:
|
||||
# Allow one retry interval before raising AuthFailed to avoid flaky API issues
|
||||
if self.last_update_success:
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["bimmer_connected"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["bimmer-connected[china]==0.16.3"]
|
||||
"requirements": ["bimmer-connected[china]==0.16.4"]
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"missing_captcha": "Captcha validation missing"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||
@@ -200,6 +201,9 @@
|
||||
"exceptions": {
|
||||
"invalid_poi": {
|
||||
"message": "Invalid data for point of interest: {poi_exception}"
|
||||
},
|
||||
"missing_captcha": {
|
||||
"message": "Login requires captcha validation"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
"list_access": {
|
||||
"default": "mdi:account-lock",
|
||||
"state": {
|
||||
"shared": "mdi:account-group"
|
||||
"shared": "mdi:account-group",
|
||||
"invitation": "mdi:account-multiple-plus"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -79,7 +79,7 @@ SENSOR_DESCRIPTIONS: tuple[BringSensorEntityDescription, ...] = (
|
||||
translation_key=BringSensor.LIST_ACCESS,
|
||||
value_fn=lambda lst, _: lst["status"].lower(),
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
options=["registered", "shared"],
|
||||
options=["registered", "shared", "invitation"],
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -66,7 +66,8 @@
|
||||
"name": "List access",
|
||||
"state": {
|
||||
"registered": "Private",
|
||||
"shared": "Shared"
|
||||
"shared": "Shared",
|
||||
"invitation": "Invitation pending"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aiostreammagic"],
|
||||
"requirements": ["aiostreammagic==2.8.4"],
|
||||
"requirements": ["aiostreammagic==2.8.5"],
|
||||
"zeroconf": ["_stream-magic._tcp.local.", "_smoip._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -51,8 +51,13 @@ CONTROL_ENTITIES: tuple[CambridgeAudioSelectEntityDescription, ...] = (
|
||||
CambridgeAudioSelectEntityDescription(
|
||||
key="display_brightness",
|
||||
translation_key="display_brightness",
|
||||
options=[x.value for x in DisplayBrightness],
|
||||
options=[
|
||||
DisplayBrightness.BRIGHT.value,
|
||||
DisplayBrightness.DIM.value,
|
||||
DisplayBrightness.OFF.value,
|
||||
],
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
load_fn=lambda client: client.display.brightness != DisplayBrightness.NONE,
|
||||
value_fn=lambda client: client.display.brightness,
|
||||
set_value_fn=lambda client, value: client.set_display_brightness(
|
||||
DisplayBrightness(value)
|
||||
|
||||
@@ -20,7 +20,7 @@ from aiohttp import hdrs, web
|
||||
import attr
|
||||
from propcache import cached_property, under_cached_property
|
||||
import voluptuous as vol
|
||||
from webrtc_models import RTCIceServer
|
||||
from webrtc_models import RTCIceCandidate, RTCIceServer
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
|
||||
@@ -472,6 +472,8 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
_attr_state: None = None # State is determined by is_on
|
||||
_attr_supported_features: CameraEntityFeature = CameraEntityFeature(0)
|
||||
|
||||
__supports_stream: CameraEntityFeature | None = None
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize a camera."""
|
||||
self._cache: dict[str, Any] = {}
|
||||
@@ -484,9 +486,13 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
self._create_stream_lock: asyncio.Lock | None = None
|
||||
self._webrtc_provider: CameraWebRTCProvider | None = None
|
||||
self._legacy_webrtc_provider: CameraWebRTCLegacyProvider | None = None
|
||||
self._webrtc_sync_offer = (
|
||||
self._supports_native_sync_webrtc = (
|
||||
type(self).async_handle_web_rtc_offer != Camera.async_handle_web_rtc_offer
|
||||
)
|
||||
self._supports_native_async_webrtc = (
|
||||
type(self).async_handle_async_webrtc_offer
|
||||
!= Camera.async_handle_async_webrtc_offer
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def entity_picture(self) -> str:
|
||||
@@ -623,7 +629,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
|
||||
Integrations can override with a native WebRTC implementation.
|
||||
"""
|
||||
if self._webrtc_sync_offer:
|
||||
if self._supports_native_sync_webrtc:
|
||||
try:
|
||||
answer = await self.async_handle_web_rtc_offer(offer_sdp)
|
||||
except ValueError as ex:
|
||||
@@ -779,6 +785,9 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
async def async_internal_added_to_hass(self) -> None:
|
||||
"""Run when entity about to be added to hass."""
|
||||
await super().async_internal_added_to_hass()
|
||||
self.__supports_stream = (
|
||||
self.supported_features_compat & CameraEntityFeature.STREAM
|
||||
)
|
||||
await self.async_refresh_providers(write_state=False)
|
||||
|
||||
async def async_refresh_providers(self, *, write_state: bool = True) -> None:
|
||||
@@ -788,18 +797,25 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
providers or inputs to the state attributes change.
|
||||
"""
|
||||
old_provider = self._webrtc_provider
|
||||
new_provider = await self._async_get_supported_webrtc_provider(
|
||||
async_get_supported_provider
|
||||
)
|
||||
|
||||
old_legacy_provider = self._legacy_webrtc_provider
|
||||
new_provider = None
|
||||
new_legacy_provider = None
|
||||
if new_provider is None:
|
||||
# Only add the legacy provider if the new provider is not available
|
||||
new_legacy_provider = await self._async_get_supported_webrtc_provider(
|
||||
async_get_supported_legacy_provider
|
||||
|
||||
# Skip all providers if the camera has a native WebRTC implementation
|
||||
if not (
|
||||
self._supports_native_sync_webrtc or self._supports_native_async_webrtc
|
||||
):
|
||||
# Camera doesn't have a native WebRTC implementation
|
||||
new_provider = await self._async_get_supported_webrtc_provider(
|
||||
async_get_supported_provider
|
||||
)
|
||||
|
||||
if new_provider is None:
|
||||
# Only add the legacy provider if the new provider is not available
|
||||
new_legacy_provider = await self._async_get_supported_webrtc_provider(
|
||||
async_get_supported_legacy_provider
|
||||
)
|
||||
|
||||
if old_provider != new_provider or old_legacy_provider != new_legacy_provider:
|
||||
self._webrtc_provider = new_provider
|
||||
self._legacy_webrtc_provider = new_legacy_provider
|
||||
@@ -827,20 +843,26 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
"""Return the WebRTC client configuration and extend it with the registered ice servers."""
|
||||
config = self._async_get_webrtc_client_configuration()
|
||||
|
||||
ice_servers = [
|
||||
server
|
||||
for servers in self.hass.data.get(DATA_ICE_SERVERS, [])
|
||||
for server in servers()
|
||||
]
|
||||
config.configuration.ice_servers.extend(ice_servers)
|
||||
if not self._supports_native_sync_webrtc:
|
||||
# Until 2024.11, the frontend was not resolving any ice servers
|
||||
# The async approach was added 2024.11 and new integrations need to use it
|
||||
ice_servers = [
|
||||
server
|
||||
for servers in self.hass.data.get(DATA_ICE_SERVERS, [])
|
||||
for server in servers()
|
||||
]
|
||||
config.configuration.ice_servers.extend(ice_servers)
|
||||
|
||||
config.get_candidates_upfront = (
|
||||
self._webrtc_sync_offer or self._legacy_webrtc_provider is not None
|
||||
self._supports_native_sync_webrtc
|
||||
or self._legacy_webrtc_provider is not None
|
||||
)
|
||||
|
||||
return config
|
||||
|
||||
async def async_on_webrtc_candidate(self, session_id: str, candidate: str) -> None:
|
||||
async def async_on_webrtc_candidate(
|
||||
self, session_id: str, candidate: RTCIceCandidate
|
||||
) -> None:
|
||||
"""Handle a WebRTC candidate."""
|
||||
if self._webrtc_provider:
|
||||
await self._webrtc_provider.async_on_webrtc_candidate(session_id, candidate)
|
||||
@@ -864,12 +886,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
"""Return the camera capabilities."""
|
||||
frontend_stream_types = set()
|
||||
if CameraEntityFeature.STREAM in self.supported_features_compat:
|
||||
if (
|
||||
type(self).async_handle_web_rtc_offer
|
||||
!= Camera.async_handle_web_rtc_offer
|
||||
or type(self).async_handle_async_webrtc_offer
|
||||
!= Camera.async_handle_async_webrtc_offer
|
||||
):
|
||||
if self._supports_native_sync_webrtc or self._supports_native_async_webrtc:
|
||||
# The camera has a native WebRTC implementation
|
||||
frontend_stream_types.add(StreamType.WEB_RTC)
|
||||
else:
|
||||
@@ -880,6 +897,21 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
|
||||
return CameraCapabilities(frontend_stream_types)
|
||||
|
||||
@callback
|
||||
def async_write_ha_state(self) -> None:
|
||||
"""Write the state to the state machine.
|
||||
|
||||
Schedules async_refresh_providers if support of streams have changed.
|
||||
"""
|
||||
super().async_write_ha_state()
|
||||
if self.__supports_stream != (
|
||||
supports_stream := self.supported_features_compat
|
||||
& CameraEntityFeature.STREAM
|
||||
):
|
||||
self.__supports_stream = supports_stream
|
||||
self._invalidate_camera_capabilities_cache()
|
||||
self.hass.async_create_task(self.async_refresh_providers())
|
||||
|
||||
|
||||
class CameraView(HomeAssistantView):
|
||||
"""Base CameraView."""
|
||||
|
||||
@@ -11,7 +11,7 @@ import logging
|
||||
from typing import TYPE_CHECKING, Any, Protocol
|
||||
|
||||
import voluptuous as vol
|
||||
from webrtc_models import RTCConfiguration, RTCIceServer
|
||||
from webrtc_models import RTCConfiguration, RTCIceCandidate, RTCIceServer
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
@@ -78,7 +78,14 @@ class WebRTCAnswer(WebRTCMessage):
|
||||
class WebRTCCandidate(WebRTCMessage):
|
||||
"""WebRTC candidate."""
|
||||
|
||||
candidate: str
|
||||
candidate: RTCIceCandidate
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""Return a dict representation of the message."""
|
||||
return {
|
||||
"type": self._get_type(),
|
||||
"candidate": self.candidate.candidate,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -138,7 +145,9 @@ class CameraWebRTCProvider(ABC):
|
||||
"""Handle the WebRTC offer and return the answer via the provided callback."""
|
||||
|
||||
@abstractmethod
|
||||
async def async_on_webrtc_candidate(self, session_id: str, candidate: str) -> None:
|
||||
async def async_on_webrtc_candidate(
|
||||
self, session_id: str, candidate: RTCIceCandidate
|
||||
) -> None:
|
||||
"""Handle the WebRTC candidate."""
|
||||
|
||||
@callback
|
||||
@@ -319,7 +328,9 @@ async def ws_candidate(
|
||||
)
|
||||
return
|
||||
|
||||
await camera.async_on_webrtc_candidate(msg["session_id"], msg["candidate"])
|
||||
await camera.async_on_webrtc_candidate(
|
||||
msg["session_id"], RTCIceCandidate(msg["candidate"])
|
||||
)
|
||||
connection.send_message(websocket_api.result_message(msg["id"]))
|
||||
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
},
|
||||
"view_path": {
|
||||
"name": "View path",
|
||||
"description": "The path of the dashboard view to show."
|
||||
"description": "The URL path of the dashboard view to show."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,7 +168,7 @@ class ElectricityMapsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
|
||||
return self.async_create_entry(
|
||||
title=get_extra_name(data) or "CO2 Signal",
|
||||
title=get_extra_name(data) or "Electricity Maps",
|
||||
data=data,
|
||||
)
|
||||
|
||||
|
||||
@@ -294,7 +294,7 @@ class DefaultAgent(ConversationEntity):
|
||||
self.hass, language, DOMAIN, [DOMAIN]
|
||||
)
|
||||
response_text = translations.get(
|
||||
f"component.{DOMAIN}.agent.done", "Done"
|
||||
f"component.{DOMAIN}.conversation.agent.done", "Done"
|
||||
)
|
||||
|
||||
response.async_set_speech(response_text)
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==1.7.4", "home-assistant-intents==2024.10.30"]
|
||||
"requirements": ["hassil==1.7.4", "home-assistant-intents==2024.11.6"]
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
}
|
||||
},
|
||||
"alarm_arm_home_instant": {
|
||||
"name": "Alarm are home instant",
|
||||
"name": "Alarm arm home instant",
|
||||
"description": "Arms the ElkM1 in home instant mode.",
|
||||
"fields": {
|
||||
"code": {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/elmax",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["elmax_api"],
|
||||
"requirements": ["elmax-api==0.0.5"],
|
||||
"requirements": ["elmax-api==0.0.6.1"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_elmax-ssl._tcp.local."
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["sense_energy"],
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["sense-energy==0.13.2"]
|
||||
"requirements": ["sense-energy==0.13.4"]
|
||||
}
|
||||
|
||||
@@ -179,6 +179,9 @@ class FFmpegConvertResponse(web.StreamResponse):
|
||||
# Remove metadata and cover art
|
||||
command_args.extend(["-map_metadata", "-1", "-vn"])
|
||||
|
||||
# disable progress stats on stderr
|
||||
command_args.append("-nostats")
|
||||
|
||||
# Output to stdout
|
||||
command_args.append("pipe:")
|
||||
|
||||
|
||||
@@ -570,8 +570,10 @@ def _async_setup_device_registry(
|
||||
configuration_url = None
|
||||
if device_info.webserver_port > 0:
|
||||
configuration_url = f"http://{entry.data['host']}:{device_info.webserver_port}"
|
||||
elif (dashboard := async_get_dashboard(hass)) and dashboard.data.get(
|
||||
device_info.name
|
||||
elif (
|
||||
(dashboard := async_get_dashboard(hass))
|
||||
and dashboard.data
|
||||
and dashboard.data.get(device_info.name)
|
||||
):
|
||||
configuration_url = f"homeassistant://hassio/ingress/{dashboard.addon_slug}"
|
||||
|
||||
|
||||
@@ -118,7 +118,7 @@
|
||||
},
|
||||
"service_calls_not_allowed": {
|
||||
"title": "{name} is not permitted to perform Home Assistant actions",
|
||||
"description": "The ESPHome device attempted to perform a Home Assistant action, but this functionality is not enabled.\n\nIf you trust this device and want to allow it to perfom Home Assistant action, you can enable this functionality in the options flow."
|
||||
"description": "The ESPHome device attempted to perform a Home Assistant action, but this functionality is not enabled.\n\nIf you trust this device and want to allow it to perform Home Assistant action, you can enable this functionality in the options flow."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import logging
|
||||
from typing import Any
|
||||
import urllib.error
|
||||
@@ -106,7 +107,7 @@ class FeedReaderConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return self.abort_on_import_error(user_input[CONF_URL], "url_error")
|
||||
return self.show_user_form(user_input, {"base": "url_error"})
|
||||
|
||||
feed_title = feed["feed"]["title"]
|
||||
feed_title = html.unescape(feed["feed"]["title"])
|
||||
|
||||
return self.async_create_entry(
|
||||
title=feed_title,
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from calendar import timegm
|
||||
from datetime import datetime
|
||||
import html
|
||||
from logging import getLogger
|
||||
from time import gmtime, struct_time
|
||||
from typing import TYPE_CHECKING
|
||||
@@ -102,7 +103,8 @@ class FeedReaderCoordinator(
|
||||
"""Set up the feed manager."""
|
||||
feed = await self._async_fetch_feed()
|
||||
self.logger.debug("Feed data fetched from %s : %s", self.url, feed["feed"])
|
||||
self.feed_author = feed["feed"].get("author")
|
||||
if feed_author := feed["feed"].get("author"):
|
||||
self.feed_author = html.unescape(feed_author)
|
||||
self.feed_version = feedparser.api.SUPPORTED_VERSIONS.get(feed["version"])
|
||||
self._feed = feed
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import logging
|
||||
|
||||
from feedparser import FeedParserDict
|
||||
@@ -76,15 +77,22 @@ class FeedReaderEvent(CoordinatorEntity[FeedReaderCoordinator], EventEntity):
|
||||
# so we always take the first entry in list, since we only care about the latest entry
|
||||
feed_data: FeedParserDict = data[0]
|
||||
|
||||
if description := feed_data.get("description"):
|
||||
description = html.unescape(description)
|
||||
|
||||
if title := feed_data.get("title"):
|
||||
title = html.unescape(title)
|
||||
|
||||
if content := feed_data.get("content"):
|
||||
if isinstance(content, list) and isinstance(content[0], dict):
|
||||
content = content[0].get("value")
|
||||
content = html.unescape(content)
|
||||
|
||||
self._trigger_event(
|
||||
EVENT_FEEDREADER,
|
||||
{
|
||||
ATTR_DESCRIPTION: feed_data.get("description"),
|
||||
ATTR_TITLE: feed_data.get("title"),
|
||||
ATTR_DESCRIPTION: description,
|
||||
ATTR_TITLE: title,
|
||||
ATTR_LINK: feed_data.get("link"),
|
||||
ATTR_CONTENT: content,
|
||||
},
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
"codeowners": [],
|
||||
"documentation": "https://www.home-assistant.io/integrations/ffmpeg",
|
||||
"integration_type": "system",
|
||||
"requirements": ["ha-ffmpeg==3.2.1"]
|
||||
"requirements": ["ha-ffmpeg==3.2.2"]
|
||||
}
|
||||
|
||||
@@ -69,37 +69,29 @@ class FibaroCover(FibaroEntity, CoverEntity):
|
||||
# so if it is missing we have a device which supports open / close only
|
||||
return not self.fibaro_device.value.has_value
|
||||
|
||||
@property
|
||||
def current_cover_position(self) -> int | None:
|
||||
"""Return current position of cover. 0 is closed, 100 is open."""
|
||||
return self.bound(self.level)
|
||||
def update(self) -> None:
|
||||
"""Update the state."""
|
||||
super().update()
|
||||
|
||||
@property
|
||||
def current_cover_tilt_position(self) -> int | None:
|
||||
"""Return the current tilt position for venetian blinds."""
|
||||
return self.bound(self.level2)
|
||||
self._attr_current_cover_position = self.bound(self.level)
|
||||
self._attr_current_cover_tilt_position = self.bound(self.level2)
|
||||
|
||||
@property
|
||||
def is_opening(self) -> bool | None:
|
||||
"""Return if the cover is opening or not.
|
||||
device_state = self.fibaro_device.state
|
||||
|
||||
Be aware that this property is only available for some modern devices.
|
||||
For example the Fibaro Roller Shutter 4 reports this correctly.
|
||||
"""
|
||||
if self.fibaro_device.state.has_value:
|
||||
return self.fibaro_device.state.str_value().lower() == "opening"
|
||||
return None
|
||||
# Be aware that opening and closing is only available for some modern
|
||||
# devices.
|
||||
# For example the Fibaro Roller Shutter 4 reports this correctly.
|
||||
if device_state.has_value:
|
||||
self._attr_is_opening = device_state.str_value().lower() == "opening"
|
||||
self._attr_is_closing = device_state.str_value().lower() == "closing"
|
||||
|
||||
@property
|
||||
def is_closing(self) -> bool | None:
|
||||
"""Return if the cover is closing or not.
|
||||
|
||||
Be aware that this property is only available for some modern devices.
|
||||
For example the Fibaro Roller Shutter 4 reports this correctly.
|
||||
"""
|
||||
if self.fibaro_device.state.has_value:
|
||||
return self.fibaro_device.state.str_value().lower() == "closing"
|
||||
return None
|
||||
closed: bool | None = None
|
||||
if self._is_open_close_only():
|
||||
if device_state.has_value and device_state.str_value().lower() != "unknown":
|
||||
closed = device_state.str_value().lower() == "closed"
|
||||
elif self.current_cover_position is not None:
|
||||
closed = self.current_cover_position == 0
|
||||
self._attr_is_closed = closed
|
||||
|
||||
def set_cover_position(self, **kwargs: Any) -> None:
|
||||
"""Move the cover to a specific position."""
|
||||
@@ -109,19 +101,6 @@ class FibaroCover(FibaroEntity, CoverEntity):
|
||||
"""Move the cover to a specific position."""
|
||||
self.set_level2(cast(int, kwargs.get(ATTR_TILT_POSITION)))
|
||||
|
||||
@property
|
||||
def is_closed(self) -> bool | None:
|
||||
"""Return if the cover is closed."""
|
||||
if self._is_open_close_only():
|
||||
state = self.fibaro_device.state
|
||||
if not state.has_value or state.str_value().lower() == "unknown":
|
||||
return None
|
||||
return state.str_value().lower() == "closed"
|
||||
|
||||
if self.current_cover_position is None:
|
||||
return None
|
||||
return self.current_cover_position == 0
|
||||
|
||||
def open_cover(self, **kwargs: Any) -> None:
|
||||
"""Open the cover."""
|
||||
self.action("open")
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyfibaro"],
|
||||
"requirements": ["pyfibaro==0.7.8"]
|
||||
"requirements": ["pyfibaro==0.8.0"]
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
},
|
||||
"data_description": {
|
||||
"file_path": "The local file path to retrieve the sensor value from",
|
||||
"value_template": "A template to render the the sensors value based on the file content",
|
||||
"value_template": "A template to render the sensors value based on the file content",
|
||||
"unit_of_measurement": "Unit of measurement for the sensor"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20241031.0"]
|
||||
"requirements": ["home-assistant-frontend==20241106.2"]
|
||||
}
|
||||
|
||||
@@ -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.2"]
|
||||
"requirements": ["ayla-iot-unofficial==1.4.3"]
|
||||
}
|
||||
|
||||
@@ -282,7 +282,7 @@ async def async_test_stream(
|
||||
return {CONF_STREAM_SOURCE: "timeout"}
|
||||
await stream.stop()
|
||||
except StreamWorkerError as err:
|
||||
return {CONF_STREAM_SOURCE: str(err)}
|
||||
return {CONF_STREAM_SOURCE: "unknown_with_details", "error_details": str(err)}
|
||||
except PermissionError:
|
||||
return {CONF_STREAM_SOURCE: "stream_not_permitted"}
|
||||
except OSError as err:
|
||||
@@ -339,6 +339,7 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the start of the config flow."""
|
||||
errors = {}
|
||||
description_placeholders = {}
|
||||
hass = self.hass
|
||||
if user_input:
|
||||
# Secondary validation because serialised vol can't seem to handle this complexity:
|
||||
@@ -372,6 +373,8 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
# temporary preview for user to check the image
|
||||
self.preview_cam = user_input
|
||||
return await self.async_step_user_confirm_still()
|
||||
if "error_details" in errors:
|
||||
description_placeholders["error"] = errors.pop("error_details")
|
||||
elif self.user_input:
|
||||
user_input = self.user_input
|
||||
else:
|
||||
@@ -379,6 +382,7 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=build_schema(user_input),
|
||||
description_placeholders=description_placeholders,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"config": {
|
||||
"error": {
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]",
|
||||
"unknown_with_details": "An unknown error occurred: {error}",
|
||||
"already_exists": "A camera with these URL settings already exists.",
|
||||
"unable_still_load": "Unable to load valid image from still image URL (e.g. invalid host, URL or authentication failure). Review log for more info.",
|
||||
"unable_still_load_auth": "Unable to load valid image from still image URL: The camera may require a user name and password, or they are not correct.",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Add generic thermostat helper",
|
||||
"title": "Add generic thermostat",
|
||||
"description": "Create a climate entity that controls the temperature via a switch and sensor.",
|
||||
"data": {
|
||||
"ac_mode": "Cooling mode",
|
||||
@@ -17,8 +17,8 @@
|
||||
"data_description": {
|
||||
"ac_mode": "Set the actuator specified to be treated as a cooling device instead of a heating device.",
|
||||
"heater": "Switch entity used to cool or heat depending on A/C mode.",
|
||||
"target_sensor": "Temperature sensor that reflect the current temperature.",
|
||||
"min_cycle_duration": "Set a minimum amount of time that the switch specified must be in its current state prior to being switched either off or on. This option will be ignored if the keep alive option is set.",
|
||||
"target_sensor": "Temperature sensor that reflects the current temperature.",
|
||||
"min_cycle_duration": "Set a minimum amount of time that the switch specified must be in its current state prior to being switched either off or on.",
|
||||
"cold_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched on. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will start when the sensor equals or goes below 24.5.",
|
||||
"hot_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched off. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will stop when the sensor equals or goes above 25.5."
|
||||
}
|
||||
|
||||
@@ -4,8 +4,9 @@ import logging
|
||||
import shutil
|
||||
|
||||
from aiohttp.client_exceptions import ClientConnectionError, ServerConnectionError
|
||||
from awesomeversion import AwesomeVersion
|
||||
from go2rtc_client import Go2RtcRestClient
|
||||
from go2rtc_client.exceptions import Go2RtcClientError
|
||||
from go2rtc_client.exceptions import Go2RtcClientError, Go2RtcVersionError
|
||||
from go2rtc_client.ws import (
|
||||
Go2RtcWsClient,
|
||||
ReceiveMessages,
|
||||
@@ -15,6 +16,7 @@ from go2rtc_client.ws import (
|
||||
WsError,
|
||||
)
|
||||
import voluptuous as vol
|
||||
from webrtc_models import RTCIceCandidate
|
||||
|
||||
from homeassistant.components.camera import (
|
||||
Camera,
|
||||
@@ -31,13 +33,23 @@ from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
|
||||
from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv, discovery_flow
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
discovery_flow,
|
||||
issue_registry as ir,
|
||||
)
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
from homeassistant.util.package import is_docker_env
|
||||
|
||||
from .const import CONF_DEBUG_UI, DEBUG_UI_URL_MESSAGE, DOMAIN
|
||||
from .const import (
|
||||
CONF_DEBUG_UI,
|
||||
DEBUG_UI_URL_MESSAGE,
|
||||
DOMAIN,
|
||||
HA_MANAGED_URL,
|
||||
RECOMMENDED_VERSION,
|
||||
)
|
||||
from .server import Server
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -113,14 +125,18 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
server = Server(
|
||||
hass, binary, enable_ui=config.get(DOMAIN, {}).get(CONF_DEBUG_UI, False)
|
||||
)
|
||||
await server.start()
|
||||
try:
|
||||
await server.start()
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.warning("Could not start go2rtc server", exc_info=True)
|
||||
return False
|
||||
|
||||
async def on_stop(event: Event) -> None:
|
||||
await server.stop()
|
||||
|
||||
hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, on_stop)
|
||||
|
||||
url = "http://localhost:1984/"
|
||||
url = HA_MANAGED_URL
|
||||
|
||||
hass.data[_DATA_GO2RTC] = url
|
||||
discovery_flow.async_create_flow(
|
||||
@@ -142,7 +158,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
# Validate the server URL
|
||||
try:
|
||||
client = Go2RtcRestClient(async_get_clientsession(hass), url)
|
||||
await client.streams.list()
|
||||
version = await client.validate_server_version()
|
||||
if version < AwesomeVersion(RECOMMENDED_VERSION):
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"recommended_version",
|
||||
is_fixable=False,
|
||||
is_persistent=False,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="recommended_version",
|
||||
translation_placeholders={
|
||||
"recommended_version": RECOMMENDED_VERSION,
|
||||
"current_version": str(version),
|
||||
},
|
||||
)
|
||||
except Go2RtcClientError as err:
|
||||
if isinstance(err.__cause__, _RETRYABLE_ERRORS):
|
||||
raise ConfigEntryNotReady(
|
||||
@@ -150,6 +180,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
) from err
|
||||
_LOGGER.warning("Could not connect to go2rtc instance on %s (%s)", url, err)
|
||||
return False
|
||||
except Go2RtcVersionError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
f"The go2rtc server version is not supported, {err}"
|
||||
) from err
|
||||
except Exception as err: # noqa: BLE001
|
||||
_LOGGER.warning("Could not connect to go2rtc instance on %s (%s)", url, err)
|
||||
return False
|
||||
@@ -202,16 +236,27 @@ class WebRTCProvider(CameraWebRTCProvider):
|
||||
self._session, self._url, source=camera.entity_id
|
||||
)
|
||||
|
||||
if not (stream_source := await camera.stream_source()):
|
||||
send_message(
|
||||
WebRTCError("go2rtc_webrtc_offer_failed", "Camera has no stream source")
|
||||
)
|
||||
return
|
||||
|
||||
streams = await self._rest_client.streams.list()
|
||||
if camera.entity_id not in streams:
|
||||
if not (stream_source := await camera.stream_source()):
|
||||
send_message(
|
||||
WebRTCError(
|
||||
"go2rtc_webrtc_offer_failed", "Camera has no stream source"
|
||||
)
|
||||
)
|
||||
return
|
||||
await self._rest_client.streams.add(camera.entity_id, stream_source)
|
||||
|
||||
if (stream := streams.get(camera.entity_id)) is None or not any(
|
||||
stream_source == producer.url for producer in stream.producers
|
||||
):
|
||||
await self._rest_client.streams.add(
|
||||
camera.entity_id,
|
||||
[
|
||||
stream_source,
|
||||
# We are setting any ffmpeg rtsp related logs to debug
|
||||
# Connection problems to the camera will be logged by the first stream
|
||||
# Therefore setting it to debug will not hide any important logs
|
||||
f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug",
|
||||
],
|
||||
)
|
||||
|
||||
@callback
|
||||
def on_messages(message: ReceiveMessages) -> None:
|
||||
@@ -219,7 +264,7 @@ class WebRTCProvider(CameraWebRTCProvider):
|
||||
value: WebRTCMessage
|
||||
match message:
|
||||
case WebRTCCandidate():
|
||||
value = HAWebRTCCandidate(message.candidate)
|
||||
value = HAWebRTCCandidate(RTCIceCandidate(message.candidate))
|
||||
case WebRTCAnswer():
|
||||
value = HAWebRTCAnswer(message.sdp)
|
||||
case WsError():
|
||||
@@ -231,11 +276,13 @@ class WebRTCProvider(CameraWebRTCProvider):
|
||||
config = camera.async_get_webrtc_client_configuration()
|
||||
await ws_client.send(WebRTCOffer(offer_sdp, config.configuration.ice_servers))
|
||||
|
||||
async def async_on_webrtc_candidate(self, session_id: str, candidate: str) -> None:
|
||||
async def async_on_webrtc_candidate(
|
||||
self, session_id: str, candidate: RTCIceCandidate
|
||||
) -> None:
|
||||
"""Handle the WebRTC candidate."""
|
||||
|
||||
if ws_client := self._sessions.get(session_id):
|
||||
await ws_client.send(WebRTCCandidate(candidate))
|
||||
await ws_client.send(WebRTCCandidate(candidate.candidate))
|
||||
else:
|
||||
_LOGGER.debug("Unknown session %s. Ignoring candidate", session_id)
|
||||
|
||||
|
||||
@@ -4,3 +4,6 @@ DOMAIN = "go2rtc"
|
||||
|
||||
CONF_DEBUG_UI = "debug_ui"
|
||||
DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time."
|
||||
HA_MANAGED_API_PORT = 11984
|
||||
HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/"
|
||||
RECOMMENDED_VERSION = "1.9.7"
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/go2rtc",
|
||||
"integration_type": "system",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["go2rtc-client==0.0.1b3"],
|
||||
"requirements": ["go2rtc-client==0.1.1"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -1,40 +1,76 @@
|
||||
"""Go2rtc server."""
|
||||
|
||||
import asyncio
|
||||
from collections import deque
|
||||
from contextlib import suppress
|
||||
import logging
|
||||
from tempfile import NamedTemporaryFile
|
||||
|
||||
from go2rtc_client import Go2RtcRestClient
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import HA_MANAGED_API_PORT, HA_MANAGED_URL
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_TERMINATE_TIMEOUT = 5
|
||||
_SETUP_TIMEOUT = 30
|
||||
_SUCCESSFUL_BOOT_MESSAGE = "INF [api] listen addr="
|
||||
_LOCALHOST_IP = "127.0.0.1"
|
||||
_LOG_BUFFER_SIZE = 512
|
||||
_RESPAWN_COOLDOWN = 1
|
||||
|
||||
# Default configuration for HA
|
||||
# - Api is listening only on localhost
|
||||
# - Disable rtsp listener
|
||||
# - Enable rtsp for localhost only as ffmpeg needs it
|
||||
# - Clear default ice servers
|
||||
_GO2RTC_CONFIG_FORMAT = r"""
|
||||
_GO2RTC_CONFIG_FORMAT = r"""# This file is managed by Home Assistant
|
||||
# Do not edit it manually
|
||||
|
||||
api:
|
||||
listen: "{api_ip}:1984"
|
||||
listen: "{api_ip}:{api_port}"
|
||||
|
||||
rtsp:
|
||||
# ffmpeg needs rtsp for opus audio transcoding
|
||||
listen: "127.0.0.1:8554"
|
||||
listen: "127.0.0.1:18554"
|
||||
|
||||
webrtc:
|
||||
listen: ":18555/tcp"
|
||||
ice_servers: []
|
||||
"""
|
||||
|
||||
_LOG_LEVEL_MAP = {
|
||||
"TRC": logging.DEBUG,
|
||||
"DBG": logging.DEBUG,
|
||||
"INF": logging.DEBUG,
|
||||
"WRN": logging.WARNING,
|
||||
"ERR": logging.WARNING,
|
||||
"FTL": logging.ERROR,
|
||||
"PNC": logging.ERROR,
|
||||
}
|
||||
|
||||
|
||||
class Go2RTCServerStartError(HomeAssistantError):
|
||||
"""Raised when server does not start."""
|
||||
|
||||
_message = "Go2rtc server didn't start correctly"
|
||||
|
||||
|
||||
class Go2RTCWatchdogError(HomeAssistantError):
|
||||
"""Raised on watchdog error."""
|
||||
|
||||
|
||||
def _create_temp_file(api_ip: str) -> str:
|
||||
"""Create temporary config file."""
|
||||
# Set delete=False to prevent the file from being deleted when the file is closed
|
||||
# Linux is clearing tmp folder on reboot, so no need to delete it manually
|
||||
with NamedTemporaryFile(prefix="go2rtc_", suffix=".yaml", delete=False) as file:
|
||||
file.write(_GO2RTC_CONFIG_FORMAT.format(api_ip=api_ip).encode())
|
||||
file.write(
|
||||
_GO2RTC_CONFIG_FORMAT.format(
|
||||
api_ip=api_ip, api_port=HA_MANAGED_API_PORT
|
||||
).encode()
|
||||
)
|
||||
return file.name
|
||||
|
||||
|
||||
@@ -47,14 +83,24 @@ class Server:
|
||||
"""Initialize the server."""
|
||||
self._hass = hass
|
||||
self._binary = binary
|
||||
self._log_buffer: deque[str] = deque(maxlen=_LOG_BUFFER_SIZE)
|
||||
self._process: asyncio.subprocess.Process | None = None
|
||||
self._startup_complete = asyncio.Event()
|
||||
self._api_ip = _LOCALHOST_IP
|
||||
if enable_ui:
|
||||
# Listen on all interfaces for allowing access from all ips
|
||||
self._api_ip = ""
|
||||
self._watchdog_task: asyncio.Task | None = None
|
||||
self._watchdog_tasks: list[asyncio.Task] = []
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the server."""
|
||||
await self._start()
|
||||
self._watchdog_task = asyncio.create_task(
|
||||
self._watchdog(), name="Go2rtc respawn"
|
||||
)
|
||||
|
||||
async def _start(self) -> None:
|
||||
"""Start the server."""
|
||||
_LOGGER.debug("Starting go2rtc server")
|
||||
config_file = await self._hass.async_add_executor_job(
|
||||
@@ -82,8 +128,13 @@ class Server:
|
||||
except TimeoutError as err:
|
||||
msg = "Go2rtc server didn't start correctly"
|
||||
_LOGGER.exception(msg)
|
||||
await self.stop()
|
||||
raise HomeAssistantError("Go2rtc server didn't start correctly") from err
|
||||
self._log_server_output(logging.WARNING)
|
||||
await self._stop()
|
||||
raise Go2RTCServerStartError from err
|
||||
|
||||
# Check the server version
|
||||
client = Go2RtcRestClient(async_get_clientsession(self._hass), HA_MANAGED_URL)
|
||||
await client.validate_server_version()
|
||||
|
||||
async def _log_output(self, process: asyncio.subprocess.Process) -> None:
|
||||
"""Log the output of the process."""
|
||||
@@ -91,21 +142,111 @@ class Server:
|
||||
|
||||
async for line in process.stdout:
|
||||
msg = line[:-1].decode().strip()
|
||||
_LOGGER.debug(msg)
|
||||
self._log_buffer.append(msg)
|
||||
loglevel = logging.WARNING
|
||||
if len(split_msg := msg.split(" ", 2)) == 3:
|
||||
loglevel = _LOG_LEVEL_MAP.get(split_msg[1], loglevel)
|
||||
_LOGGER.log(loglevel, msg)
|
||||
if not self._startup_complete.is_set() and _SUCCESSFUL_BOOT_MESSAGE in msg:
|
||||
self._startup_complete.set()
|
||||
|
||||
def _log_server_output(self, loglevel: int) -> None:
|
||||
"""Log captured process output, then clear the log buffer."""
|
||||
for line in list(self._log_buffer): # Copy the deque to avoid mutation error
|
||||
_LOGGER.log(loglevel, line)
|
||||
self._log_buffer.clear()
|
||||
|
||||
async def _watchdog(self) -> None:
|
||||
"""Keep respawning go2rtc servers.
|
||||
|
||||
A new go2rtc server is spawned if the process terminates or the API
|
||||
stops responding.
|
||||
"""
|
||||
while True:
|
||||
try:
|
||||
monitor_process_task = asyncio.create_task(self._monitor_process())
|
||||
self._watchdog_tasks.append(monitor_process_task)
|
||||
monitor_process_task.add_done_callback(self._watchdog_tasks.remove)
|
||||
monitor_api_task = asyncio.create_task(self._monitor_api())
|
||||
self._watchdog_tasks.append(monitor_api_task)
|
||||
monitor_api_task.add_done_callback(self._watchdog_tasks.remove)
|
||||
try:
|
||||
await asyncio.gather(monitor_process_task, monitor_api_task)
|
||||
except Go2RTCWatchdogError:
|
||||
_LOGGER.debug("Caught Go2RTCWatchdogError")
|
||||
for task in self._watchdog_tasks:
|
||||
if task.done():
|
||||
if not task.cancelled():
|
||||
task.exception()
|
||||
continue
|
||||
task.cancel()
|
||||
await asyncio.sleep(_RESPAWN_COOLDOWN)
|
||||
try:
|
||||
await self._stop()
|
||||
_LOGGER.warning("Go2rtc unexpectedly stopped, server log:")
|
||||
self._log_server_output(logging.WARNING)
|
||||
_LOGGER.debug("Spawning new go2rtc server")
|
||||
with suppress(Go2RTCServerStartError):
|
||||
await self._start()
|
||||
except Exception:
|
||||
_LOGGER.exception(
|
||||
"Unexpected error when restarting go2rtc server"
|
||||
)
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected error in go2rtc server watchdog")
|
||||
|
||||
async def _monitor_process(self) -> None:
|
||||
"""Raise if the go2rtc process terminates."""
|
||||
_LOGGER.debug("Monitoring go2rtc server process")
|
||||
if self._process:
|
||||
await self._process.wait()
|
||||
_LOGGER.debug("go2rtc server terminated")
|
||||
raise Go2RTCWatchdogError("Process ended")
|
||||
|
||||
async def _monitor_api(self) -> None:
|
||||
"""Raise if the go2rtc process terminates."""
|
||||
client = Go2RtcRestClient(async_get_clientsession(self._hass), HA_MANAGED_URL)
|
||||
|
||||
_LOGGER.debug("Monitoring go2rtc API")
|
||||
try:
|
||||
while True:
|
||||
await client.validate_server_version()
|
||||
await asyncio.sleep(10)
|
||||
except Exception as err:
|
||||
_LOGGER.debug("go2rtc API did not reply", exc_info=True)
|
||||
raise Go2RTCWatchdogError("API error") from err
|
||||
|
||||
async def _stop_watchdog(self) -> None:
|
||||
"""Handle watchdog stop request."""
|
||||
tasks: list[asyncio.Task] = []
|
||||
if watchdog_task := self._watchdog_task:
|
||||
self._watchdog_task = None
|
||||
tasks.append(watchdog_task)
|
||||
watchdog_task.cancel()
|
||||
for task in self._watchdog_tasks:
|
||||
tasks.append(task)
|
||||
task.cancel()
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the server and abort the watchdog task."""
|
||||
_LOGGER.debug("Server stop requested")
|
||||
await self._stop_watchdog()
|
||||
await self._stop()
|
||||
|
||||
async def _stop(self) -> None:
|
||||
"""Stop the server."""
|
||||
if self._process:
|
||||
_LOGGER.debug("Stopping go2rtc server")
|
||||
process = self._process
|
||||
self._process = None
|
||||
process.terminate()
|
||||
with suppress(ProcessLookupError):
|
||||
process.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=_TERMINATE_TIMEOUT)
|
||||
except TimeoutError:
|
||||
_LOGGER.warning("Go2rtc server didn't terminate gracefully. Killing it")
|
||||
process.kill()
|
||||
with suppress(ProcessLookupError):
|
||||
process.kill()
|
||||
else:
|
||||
_LOGGER.debug("Go2rtc server has been stopped")
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"issues": {
|
||||
"recommended_version": {
|
||||
"title": "Outdated go2rtc server detected",
|
||||
"description": "We detected that you are using an outdated go2rtc server version. For the best experience, we recommend updating the go2rtc server to version `{recommended_version}`.\nCurrently you are using version `{current_version}`."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,8 +87,8 @@
|
||||
}
|
||||
},
|
||||
"create_event": {
|
||||
"name": "Creates event",
|
||||
"description": "Add a new calendar event.",
|
||||
"name": "Create event",
|
||||
"description": "Adds a new calendar event.",
|
||||
"fields": {
|
||||
"summary": {
|
||||
"name": "Summary",
|
||||
|
||||
@@ -59,9 +59,9 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]):
|
||||
tasks_response.extend(await self.api.tasks.user.get(type="completedTodos"))
|
||||
except ClientResponseError as error:
|
||||
if error.status == HTTPStatus.TOO_MANY_REQUESTS:
|
||||
_LOGGER.debug("Currently rate limited, skipping update")
|
||||
_LOGGER.debug("Rate limit exceeded, will try again later")
|
||||
return self.data
|
||||
raise UpdateFailed(f"Error communicating with API: {error}") from error
|
||||
raise UpdateFailed(f"Unable to connect to Habitica: {error}") from error
|
||||
|
||||
return HabiticaData(user=user_response, tasks=tasks_response)
|
||||
|
||||
|
||||
@@ -204,10 +204,10 @@
|
||||
"message": "Unable to create new to-do `{name}` for Habitica, please try again"
|
||||
},
|
||||
"setup_rate_limit_exception": {
|
||||
"message": "Currently rate limited, try again later"
|
||||
"message": "Rate limit exceeded, try again later"
|
||||
},
|
||||
"service_call_unallowed": {
|
||||
"message": "Unable to carry out this action, because the required conditions are not met"
|
||||
"message": "Unable to complete action, the required conditions are not met"
|
||||
},
|
||||
"service_call_exception": {
|
||||
"message": "Unable to connect to Habitica, try again later"
|
||||
|
||||
@@ -103,6 +103,7 @@ PLACEHOLDER_KEY_ADDON_URL = "addon_url"
|
||||
PLACEHOLDER_KEY_REFERENCE = "reference"
|
||||
PLACEHOLDER_KEY_COMPONENTS = "components"
|
||||
|
||||
ISSUE_KEY_ADDON_BOOT_FAIL = "issue_addon_boot_fail"
|
||||
ISSUE_KEY_SYSTEM_DOCKER_CONFIG = "issue_system_docker_config"
|
||||
ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING = "issue_addon_detached_addon_missing"
|
||||
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED = "issue_addon_detached_addon_removed"
|
||||
|
||||
@@ -131,11 +131,11 @@ class HassIODiscovery(HomeAssistantView):
|
||||
config=data.config,
|
||||
name=addon_info.name,
|
||||
slug=data.addon,
|
||||
uuid=str(data.uuid),
|
||||
uuid=data.uuid.hex,
|
||||
),
|
||||
discovery_key=discovery_flow.DiscoveryKey(
|
||||
domain=DOMAIN,
|
||||
key=str(data.uuid),
|
||||
key=data.uuid.hex,
|
||||
version=1,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -36,6 +36,7 @@ from .const import (
|
||||
EVENT_SUPERVISOR_EVENT,
|
||||
EVENT_SUPERVISOR_UPDATE,
|
||||
EVENT_SUPPORTED_CHANGED,
|
||||
ISSUE_KEY_ADDON_BOOT_FAIL,
|
||||
ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING,
|
||||
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
|
||||
ISSUE_KEY_SYSTEM_DOCKER_CONFIG,
|
||||
@@ -94,6 +95,7 @@ UNHEALTHY_REASONS = {
|
||||
|
||||
# Keys (type + context) of issues that when found should be made into a repair
|
||||
ISSUE_KEYS_FOR_REPAIRS = {
|
||||
ISSUE_KEY_ADDON_BOOT_FAIL,
|
||||
"issue_mount_mount_failed",
|
||||
"issue_system_multiple_data_disks",
|
||||
"issue_system_reboot_required",
|
||||
|
||||
@@ -14,6 +14,7 @@ from homeassistant.data_entry_flow import FlowResult
|
||||
|
||||
from . import get_addons_info, get_issues_info
|
||||
from .const import (
|
||||
ISSUE_KEY_ADDON_BOOT_FAIL,
|
||||
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
|
||||
ISSUE_KEY_SYSTEM_DOCKER_CONFIG,
|
||||
PLACEHOLDER_KEY_ADDON,
|
||||
@@ -181,8 +182,8 @@ class DockerConfigIssueRepairFlow(SupervisorIssueRepairFlow):
|
||||
return placeholders
|
||||
|
||||
|
||||
class DetachedAddonIssueRepairFlow(SupervisorIssueRepairFlow):
|
||||
"""Handler for detached addon issue fixing flows."""
|
||||
class AddonIssueRepairFlow(SupervisorIssueRepairFlow):
|
||||
"""Handler for addon issue fixing flows."""
|
||||
|
||||
@property
|
||||
def description_placeholders(self) -> dict[str, str] | None:
|
||||
@@ -210,7 +211,10 @@ async def async_create_fix_flow(
|
||||
issue = supervisor_issues and supervisor_issues.get_issue(issue_id)
|
||||
if issue and issue.key == ISSUE_KEY_SYSTEM_DOCKER_CONFIG:
|
||||
return DockerConfigIssueRepairFlow(issue_id)
|
||||
if issue and issue.key == ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED:
|
||||
return DetachedAddonIssueRepairFlow(issue_id)
|
||||
if issue and issue.key in {
|
||||
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
|
||||
ISSUE_KEY_ADDON_BOOT_FAIL,
|
||||
}:
|
||||
return AddonIssueRepairFlow(issue_id)
|
||||
|
||||
return SupervisorIssueRepairFlow(issue_id)
|
||||
|
||||
@@ -17,6 +17,23 @@
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"issue_addon_boot_fail": {
|
||||
"title": "Add-on failed to start at boot",
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"fix_menu": {
|
||||
"description": "Add-on {addon} is set to start at boot but failed to start. Usually this occurs when the configuration is incorrect or the same port is used in multiple add-ons. Check the configuration as well as logs for {addon} and Supervisor.\n\nUse Start to try again or Disable to turn off the start at boot option.",
|
||||
"menu_options": {
|
||||
"addon_execute_start": "Start",
|
||||
"addon_disable_boot": "Disable"
|
||||
}
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"apply_suggestion_fail": "Could not apply the fix. Check the Supervisor logs for more details."
|
||||
}
|
||||
}
|
||||
},
|
||||
"issue_addon_detached_addon_missing": {
|
||||
"title": "Missing repository for an installed add-on",
|
||||
"description": "Repository for add-on {addon} is missing. This means it will not get updates, and backups may not be restored correctly as the supervisor may not be able to build/download the resources required.\n\nPlease check the [add-on's documentation]({addon_url}) for installation instructions and add the repository to the store."
|
||||
@@ -257,7 +274,7 @@
|
||||
"fields": {
|
||||
"addon": {
|
||||
"name": "Add-on",
|
||||
"description": "The add-on slug."
|
||||
"description": "The add-on to start."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -267,17 +284,17 @@
|
||||
"fields": {
|
||||
"addon": {
|
||||
"name": "[%key:component::hassio::services::addon_start::fields::addon::name%]",
|
||||
"description": "[%key:component::hassio::services::addon_start::fields::addon::description%]"
|
||||
"description": "The add-on to restart."
|
||||
}
|
||||
}
|
||||
},
|
||||
"addon_stdin": {
|
||||
"name": "Write data to add-on stdin.",
|
||||
"description": "Writes data to add-on stdin.",
|
||||
"description": "Writes data to the add-on's standard input.",
|
||||
"fields": {
|
||||
"addon": {
|
||||
"name": "[%key:component::hassio::services::addon_start::fields::addon::name%]",
|
||||
"description": "[%key:component::hassio::services::addon_start::fields::addon::description%]"
|
||||
"description": "The add-on to write to."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -287,7 +304,7 @@
|
||||
"fields": {
|
||||
"addon": {
|
||||
"name": "[%key:component::hassio::services::addon_start::fields::addon::name%]",
|
||||
"description": "[%key:component::hassio::services::addon_start::fields::addon::description%]"
|
||||
"description": "The add-on to stop."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -297,7 +314,7 @@
|
||||
"fields": {
|
||||
"addon": {
|
||||
"name": "[%key:component::hassio::services::addon_start::fields::addon::name%]",
|
||||
"description": "[%key:component::hassio::services::addon_start::fields::addon::description%]"
|
||||
"description": "The add-on to update."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/holiday",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["holidays==0.59", "babel==2.15.0"]
|
||||
"requirements": ["holidays==0.61", "babel==2.15.0"]
|
||||
}
|
||||
|
||||
@@ -13,7 +13,11 @@ from homeassistant.components.script import scripts_with_entity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||
from homeassistant.helpers.issue_registry import (
|
||||
IssueSeverity,
|
||||
async_create_issue,
|
||||
async_delete_issue,
|
||||
)
|
||||
|
||||
from .api import HomeConnectDevice
|
||||
from .const import (
|
||||
@@ -206,3 +210,9 @@ class HomeConnectDoorBinarySensor(HomeConnectBinarySensor):
|
||||
"items": "\n".join([f"- {item}" for item in items]),
|
||||
},
|
||||
)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Call when entity will be removed from hass."""
|
||||
async_delete_issue(
|
||||
self.hass, DOMAIN, f"deprecated_binary_common_door_sensor_{self.entity_id}"
|
||||
)
|
||||
|
||||
@@ -37,11 +37,8 @@
|
||||
"set_light_color": {
|
||||
"message": "Error while trying to set color of {entity_id}: {description}"
|
||||
},
|
||||
"set_light_effect": {
|
||||
"message": "Error while trying to set effect of {entity_id}: {description}"
|
||||
},
|
||||
"set_setting": {
|
||||
"message": "Error while trying to set \"{value}\" to \"{key}\" setting for {entity_id}: {description}"
|
||||
"message": "Error while trying to assign the value \"{value}\" to the setting \"{key}\" for {entity_id}: {description}"
|
||||
},
|
||||
"turn_on": {
|
||||
"message": "Error while trying to turn on {entity_id} ({key}): {description}"
|
||||
|
||||
@@ -18,6 +18,8 @@ from homeassistant.const import (
|
||||
SERVICE_ALARM_ARM_HOME,
|
||||
SERVICE_ALARM_ARM_NIGHT,
|
||||
SERVICE_ALARM_DISARM,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
)
|
||||
from homeassistant.core import State, callback
|
||||
|
||||
@@ -152,12 +154,12 @@ class SecuritySystem(HomeAccessory):
|
||||
@callback
|
||||
def async_update_state(self, new_state: State) -> None:
|
||||
"""Update security state after state changed."""
|
||||
hass_state = None
|
||||
if new_state and new_state.state == "None":
|
||||
# Bail out early for no state
|
||||
hass_state: str | AlarmControlPanelState = new_state.state
|
||||
if hass_state in {"None", STATE_UNKNOWN, STATE_UNAVAILABLE}:
|
||||
# Bail out early for no state, unknown or unavailable
|
||||
return
|
||||
if new_state and new_state.state is not None:
|
||||
hass_state = AlarmControlPanelState(new_state.state)
|
||||
if hass_state is not None:
|
||||
hass_state = AlarmControlPanelState(hass_state)
|
||||
if (
|
||||
hass_state
|
||||
and (current_state := HASS_TO_HOMEKIT_CURRENT.get(hass_state)) is not None
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["homematicip"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["homematicip==1.1.2"]
|
||||
"requirements": ["homematicip==1.1.3"]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
},
|
||||
"error": {
|
||||
"connection_error": "Could not connect to the controller.",
|
||||
"credentials_needed": "The controller needs credentials.",
|
||||
|
||||
@@ -130,10 +130,15 @@ class HueSceneEntity(HueSceneEntityBase):
|
||||
@property
|
||||
def is_dynamic(self) -> bool:
|
||||
"""Return if this scene has a dynamic color palette."""
|
||||
if self.resource.palette.color and len(self.resource.palette.color) > 1:
|
||||
if (
|
||||
self.resource.palette
|
||||
and self.resource.palette.color
|
||||
and len(self.resource.palette.color) > 1
|
||||
):
|
||||
return True
|
||||
if (
|
||||
self.resource.palette.color_temperature
|
||||
self.resource.palette
|
||||
and self.resource.palette.color_temperature
|
||||
and len(self.resource.palette.color_temperature) > 1
|
||||
):
|
||||
return True
|
||||
|
||||
@@ -95,7 +95,7 @@ class PowerViewNumber(ShadeEntity, RestoreNumber):
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{self._attr_unique_id}_{description.key}"
|
||||
|
||||
def set_native_value(self, value: float) -> None:
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""Update the current value."""
|
||||
self._attr_native_value = value
|
||||
self.entity_description.store_value_fn(self.coordinator, self._shade.id, value)
|
||||
|
||||
@@ -8,6 +8,7 @@ from aioautomower.exceptions import (
|
||||
ApiException,
|
||||
AuthException,
|
||||
HusqvarnaWSServerHandshakeError,
|
||||
TimeoutException,
|
||||
)
|
||||
from aioautomower.model import MowerAttributes
|
||||
from aioautomower.session import AutomowerSession
|
||||
@@ -22,6 +23,7 @@ from .const import DOMAIN
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
MAX_WS_RECONNECT_TIME = 600
|
||||
SCAN_INTERVAL = timedelta(minutes=8)
|
||||
DEFAULT_RECONNECT_TIME = 2 # Define a default reconnect time
|
||||
|
||||
|
||||
class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttributes]]):
|
||||
@@ -40,8 +42,8 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib
|
||||
update_interval=SCAN_INTERVAL,
|
||||
)
|
||||
self.api = api
|
||||
|
||||
self.ws_connected: bool = False
|
||||
self.reconnect_time = DEFAULT_RECONNECT_TIME
|
||||
|
||||
async def _async_update_data(self) -> dict[str, MowerAttributes]:
|
||||
"""Subscribe for websocket and poll data from the API."""
|
||||
@@ -66,24 +68,28 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
automower_client: AutomowerSession,
|
||||
reconnect_time: int = 2,
|
||||
) -> None:
|
||||
"""Listen with the client."""
|
||||
try:
|
||||
await automower_client.auth.websocket_connect()
|
||||
reconnect_time = 2
|
||||
# Reset reconnect time after successful connection
|
||||
self.reconnect_time = DEFAULT_RECONNECT_TIME
|
||||
await automower_client.start_listening()
|
||||
except HusqvarnaWSServerHandshakeError as err:
|
||||
_LOGGER.debug(
|
||||
"Failed to connect to websocket. Trying to reconnect: %s", err
|
||||
"Failed to connect to websocket. Trying to reconnect: %s",
|
||||
err,
|
||||
)
|
||||
except TimeoutException as err:
|
||||
_LOGGER.debug(
|
||||
"Failed to listen to websocket. Trying to reconnect: %s",
|
||||
err,
|
||||
)
|
||||
|
||||
if not hass.is_stopping:
|
||||
await asyncio.sleep(reconnect_time)
|
||||
reconnect_time = min(reconnect_time * 2, MAX_WS_RECONNECT_TIME)
|
||||
await self.client_listen(
|
||||
hass=hass,
|
||||
entry=entry,
|
||||
automower_client=automower_client,
|
||||
reconnect_time=reconnect_time,
|
||||
await asyncio.sleep(self.reconnect_time)
|
||||
self.reconnect_time = min(self.reconnect_time * 2, MAX_WS_RECONNECT_TIME)
|
||||
entry.async_create_background_task(
|
||||
hass,
|
||||
self.client_listen(hass, entry, automower_client),
|
||||
"reconnect_task",
|
||||
)
|
||||
|
||||
@@ -32,6 +32,10 @@
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Options",
|
||||
|
||||
@@ -7,8 +7,12 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
|
||||
from .const import DOMAIN, SCAN_INTERVAL
|
||||
from .coordinator import HydrawiseDataUpdateCoordinator
|
||||
from .const import DOMAIN
|
||||
from .coordinator import (
|
||||
HydrawiseMainDataUpdateCoordinator,
|
||||
HydrawiseUpdateCoordinators,
|
||||
HydrawiseWaterUseDataUpdateCoordinator,
|
||||
)
|
||||
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.BINARY_SENSOR,
|
||||
@@ -29,9 +33,18 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
|
||||
auth.Auth(config_entry.data[CONF_USERNAME], config_entry.data[CONF_PASSWORD])
|
||||
)
|
||||
|
||||
coordinator = HydrawiseDataUpdateCoordinator(hass, hydrawise, SCAN_INTERVAL)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = coordinator
|
||||
main_coordinator = HydrawiseMainDataUpdateCoordinator(hass, hydrawise)
|
||||
await main_coordinator.async_config_entry_first_refresh()
|
||||
water_use_coordinator = HydrawiseWaterUseDataUpdateCoordinator(
|
||||
hass, hydrawise, main_coordinator
|
||||
)
|
||||
await water_use_coordinator.async_config_entry_first_refresh()
|
||||
hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = (
|
||||
HydrawiseUpdateCoordinators(
|
||||
main=main_coordinator,
|
||||
water_use=water_use_coordinator,
|
||||
)
|
||||
)
|
||||
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import VolDictType
|
||||
|
||||
from .const import DOMAIN, SERVICE_RESUME, SERVICE_START_WATERING, SERVICE_SUSPEND
|
||||
from .coordinator import HydrawiseDataUpdateCoordinator
|
||||
from .coordinator import HydrawiseUpdateCoordinators
|
||||
from .entity import HydrawiseEntity
|
||||
|
||||
|
||||
@@ -81,18 +81,16 @@ async def async_setup_entry(
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Hydrawise binary_sensor platform."""
|
||||
coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN][
|
||||
config_entry.entry_id
|
||||
]
|
||||
coordinators: HydrawiseUpdateCoordinators = hass.data[DOMAIN][config_entry.entry_id]
|
||||
entities: list[HydrawiseBinarySensor] = []
|
||||
for controller in coordinator.data.controllers.values():
|
||||
for controller in coordinators.main.data.controllers.values():
|
||||
entities.extend(
|
||||
HydrawiseBinarySensor(coordinator, description, controller)
|
||||
HydrawiseBinarySensor(coordinators.main, description, controller)
|
||||
for description in CONTROLLER_BINARY_SENSORS
|
||||
)
|
||||
entities.extend(
|
||||
HydrawiseBinarySensor(
|
||||
coordinator,
|
||||
coordinators.main,
|
||||
description,
|
||||
controller,
|
||||
sensor_id=sensor.id,
|
||||
@@ -103,7 +101,7 @@ async def async_setup_entry(
|
||||
)
|
||||
entities.extend(
|
||||
HydrawiseZoneBinarySensor(
|
||||
coordinator, description, controller, zone_id=zone.id
|
||||
coordinators.main, description, controller, zone_id=zone.id
|
||||
)
|
||||
for zone in controller.zones
|
||||
for description in ZONE_BINARY_SENSORS
|
||||
|
||||
@@ -10,7 +10,8 @@ DEFAULT_WATERING_TIME = timedelta(minutes=15)
|
||||
|
||||
MANUFACTURER = "Hydrawise"
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=60)
|
||||
MAIN_SCAN_INTERVAL = timedelta(seconds=60)
|
||||
WATER_USE_SCAN_INTERVAL = timedelta(minutes=60)
|
||||
|
||||
SIGNAL_UPDATE_HYDRAWISE = "hydrawise_update"
|
||||
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from pydrawise import Hydrawise
|
||||
from pydrawise.schema import Controller, ControllerWaterUseSummary, Sensor, User, Zone
|
||||
@@ -12,7 +11,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
from homeassistant.util.dt import now
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
from .const import DOMAIN, LOGGER, MAIN_SCAN_INTERVAL, WATER_USE_SCAN_INTERVAL
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -20,22 +19,39 @@ class HydrawiseData:
|
||||
"""Container for data fetched from the Hydrawise API."""
|
||||
|
||||
user: User
|
||||
controllers: dict[int, Controller]
|
||||
zones: dict[int, Zone]
|
||||
sensors: dict[int, Sensor]
|
||||
daily_water_summary: dict[int, ControllerWaterUseSummary]
|
||||
controllers: dict[int, Controller] = field(default_factory=dict)
|
||||
zones: dict[int, Zone] = field(default_factory=dict)
|
||||
sensors: dict[int, Sensor] = field(default_factory=dict)
|
||||
daily_water_summary: dict[int, ControllerWaterUseSummary] = field(
|
||||
default_factory=dict
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class HydrawiseUpdateCoordinators:
|
||||
"""Container for all Hydrawise DataUpdateCoordinator instances."""
|
||||
|
||||
main: HydrawiseMainDataUpdateCoordinator
|
||||
water_use: HydrawiseWaterUseDataUpdateCoordinator
|
||||
|
||||
|
||||
class HydrawiseDataUpdateCoordinator(DataUpdateCoordinator[HydrawiseData]):
|
||||
"""The Hydrawise Data Update Coordinator."""
|
||||
"""Base class for Hydrawise Data Update Coordinators."""
|
||||
|
||||
api: Hydrawise
|
||||
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, api: Hydrawise, scan_interval: timedelta
|
||||
) -> None:
|
||||
|
||||
class HydrawiseMainDataUpdateCoordinator(HydrawiseDataUpdateCoordinator):
|
||||
"""The main Hydrawise Data Update Coordinator.
|
||||
|
||||
This fetches the primary state data for Hydrawise controllers and zones
|
||||
at a relatively frequent interval so that the primary functions of the
|
||||
integration are updated in a timely manner.
|
||||
"""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, api: Hydrawise) -> None:
|
||||
"""Initialize HydrawiseDataUpdateCoordinator."""
|
||||
super().__init__(hass, LOGGER, name=DOMAIN, update_interval=scan_interval)
|
||||
super().__init__(hass, LOGGER, name=DOMAIN, update_interval=MAIN_SCAN_INTERVAL)
|
||||
self.api = api
|
||||
|
||||
async def _async_update_data(self) -> HydrawiseData:
|
||||
@@ -43,28 +59,56 @@ class HydrawiseDataUpdateCoordinator(DataUpdateCoordinator[HydrawiseData]):
|
||||
# Don't fetch zones. We'll fetch them for each controller later.
|
||||
# This is to prevent 502 errors in some cases.
|
||||
# See: https://github.com/home-assistant/core/issues/120128
|
||||
user = await self.api.get_user(fetch_zones=False)
|
||||
controllers = {}
|
||||
zones = {}
|
||||
sensors = {}
|
||||
daily_water_summary: dict[int, ControllerWaterUseSummary] = {}
|
||||
for controller in user.controllers:
|
||||
controllers[controller.id] = controller
|
||||
data = HydrawiseData(user=await self.api.get_user(fetch_zones=False))
|
||||
for controller in data.user.controllers:
|
||||
data.controllers[controller.id] = controller
|
||||
controller.zones = await self.api.get_zones(controller)
|
||||
for zone in controller.zones:
|
||||
zones[zone.id] = zone
|
||||
data.zones[zone.id] = zone
|
||||
for sensor in controller.sensors:
|
||||
sensors[sensor.id] = sensor
|
||||
data.sensors[sensor.id] = sensor
|
||||
return data
|
||||
|
||||
|
||||
class HydrawiseWaterUseDataUpdateCoordinator(HydrawiseDataUpdateCoordinator):
|
||||
"""Data Update Coordinator for Hydrawise Water Use.
|
||||
|
||||
This fetches data that is more expensive for the Hydrawise API to compute
|
||||
at a less frequent interval as to not overload the Hydrawise servers.
|
||||
"""
|
||||
|
||||
_main_coordinator: HydrawiseMainDataUpdateCoordinator
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
api: Hydrawise,
|
||||
main_coordinator: HydrawiseMainDataUpdateCoordinator,
|
||||
) -> None:
|
||||
"""Initialize HydrawiseWaterUseDataUpdateCoordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
LOGGER,
|
||||
name=f"{DOMAIN} water use",
|
||||
update_interval=WATER_USE_SCAN_INTERVAL,
|
||||
)
|
||||
self.api = api
|
||||
self._main_coordinator = main_coordinator
|
||||
|
||||
async def _async_update_data(self) -> HydrawiseData:
|
||||
"""Fetch the latest data from Hydrawise."""
|
||||
daily_water_summary: dict[int, ControllerWaterUseSummary] = {}
|
||||
for controller in self._main_coordinator.data.controllers.values():
|
||||
daily_water_summary[controller.id] = await self.api.get_water_use_summary(
|
||||
controller,
|
||||
now().replace(hour=0, minute=0, second=0, microsecond=0),
|
||||
now(),
|
||||
)
|
||||
|
||||
main_data = self._main_coordinator.data
|
||||
return HydrawiseData(
|
||||
user=user,
|
||||
controllers=controllers,
|
||||
zones=zones,
|
||||
sensors=sensors,
|
||||
user=main_data.user,
|
||||
controllers=main_data.controllers,
|
||||
zones=main_data.zones,
|
||||
sensors=main_data.sensors,
|
||||
daily_water_summary=daily_water_summary,
|
||||
)
|
||||
|
||||
@@ -19,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import HydrawiseDataUpdateCoordinator
|
||||
from .coordinator import HydrawiseUpdateCoordinators
|
||||
from .entity import HydrawiseEntity
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ def _get_controller_daily_total_water_use(sensor: HydrawiseSensor) -> float | No
|
||||
return daily_water_summary.total_use
|
||||
|
||||
|
||||
CONTROLLER_SENSORS: tuple[HydrawiseSensorEntityDescription, ...] = (
|
||||
WATER_USE_CONTROLLER_SENSORS: tuple[HydrawiseSensorEntityDescription, ...] = (
|
||||
HydrawiseSensorEntityDescription(
|
||||
key="daily_active_water_time",
|
||||
translation_key="daily_active_water_time",
|
||||
@@ -103,6 +103,16 @@ CONTROLLER_SENSORS: tuple[HydrawiseSensorEntityDescription, ...] = (
|
||||
)
|
||||
|
||||
|
||||
WATER_USE_ZONE_SENSORS: tuple[HydrawiseSensorEntityDescription, ...] = (
|
||||
HydrawiseSensorEntityDescription(
|
||||
key="daily_active_water_time",
|
||||
translation_key="daily_active_water_time",
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
native_unit_of_measurement=UnitOfTime.SECONDS,
|
||||
value_fn=_get_zone_daily_active_water_time,
|
||||
),
|
||||
)
|
||||
|
||||
FLOW_CONTROLLER_SENSORS: tuple[HydrawiseSensorEntityDescription, ...] = (
|
||||
HydrawiseSensorEntityDescription(
|
||||
key="daily_total_water_use",
|
||||
@@ -150,13 +160,6 @@ ZONE_SENSORS: tuple[HydrawiseSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
value_fn=_get_zone_watering_time,
|
||||
),
|
||||
HydrawiseSensorEntityDescription(
|
||||
key="daily_active_water_time",
|
||||
translation_key="daily_active_water_time",
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
native_unit_of_measurement=UnitOfTime.SECONDS,
|
||||
value_fn=_get_zone_daily_active_water_time,
|
||||
),
|
||||
)
|
||||
|
||||
FLOW_MEASUREMENT_KEYS = [x.key for x in FLOW_CONTROLLER_SENSORS]
|
||||
@@ -168,29 +171,37 @@ async def async_setup_entry(
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Hydrawise sensor platform."""
|
||||
coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN][
|
||||
config_entry.entry_id
|
||||
]
|
||||
coordinators: HydrawiseUpdateCoordinators = hass.data[DOMAIN][config_entry.entry_id]
|
||||
entities: list[HydrawiseSensor] = []
|
||||
for controller in coordinator.data.controllers.values():
|
||||
for controller in coordinators.main.data.controllers.values():
|
||||
entities.extend(
|
||||
HydrawiseSensor(coordinator, description, controller)
|
||||
for description in CONTROLLER_SENSORS
|
||||
HydrawiseSensor(coordinators.water_use, description, controller)
|
||||
for description in WATER_USE_CONTROLLER_SENSORS
|
||||
)
|
||||
entities.extend(
|
||||
HydrawiseSensor(coordinator, description, controller, zone_id=zone.id)
|
||||
HydrawiseSensor(
|
||||
coordinators.water_use, description, controller, zone_id=zone.id
|
||||
)
|
||||
for zone in controller.zones
|
||||
for description in WATER_USE_ZONE_SENSORS
|
||||
)
|
||||
entities.extend(
|
||||
HydrawiseSensor(coordinators.main, description, controller, zone_id=zone.id)
|
||||
for zone in controller.zones
|
||||
for description in ZONE_SENSORS
|
||||
)
|
||||
if coordinator.data.daily_water_summary[controller.id].total_use is not None:
|
||||
if (
|
||||
coordinators.water_use.data.daily_water_summary[controller.id].total_use
|
||||
is not None
|
||||
):
|
||||
# we have a flow sensor for this controller
|
||||
entities.extend(
|
||||
HydrawiseSensor(coordinator, description, controller)
|
||||
HydrawiseSensor(coordinators.water_use, description, controller)
|
||||
for description in FLOW_CONTROLLER_SENSORS
|
||||
)
|
||||
entities.extend(
|
||||
HydrawiseSensor(
|
||||
coordinator,
|
||||
coordinators.water_use,
|
||||
description,
|
||||
controller,
|
||||
zone_id=zone.id,
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"timeout_connect": "[%key:common::config_flow::error::timeout_connect%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
|
||||
|
||||
@@ -20,7 +20,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import DEFAULT_WATERING_TIME, DOMAIN
|
||||
from .coordinator import HydrawiseDataUpdateCoordinator
|
||||
from .coordinator import HydrawiseUpdateCoordinators
|
||||
from .entity import HydrawiseEntity
|
||||
|
||||
|
||||
@@ -66,12 +66,10 @@ async def async_setup_entry(
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Hydrawise switch platform."""
|
||||
coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN][
|
||||
config_entry.entry_id
|
||||
]
|
||||
coordinators: HydrawiseUpdateCoordinators = hass.data[DOMAIN][config_entry.entry_id]
|
||||
async_add_entities(
|
||||
HydrawiseSwitch(coordinator, description, controller, zone_id=zone.id)
|
||||
for controller in coordinator.data.controllers.values()
|
||||
HydrawiseSwitch(coordinators.main, description, controller, zone_id=zone.id)
|
||||
for controller in coordinators.main.data.controllers.values()
|
||||
for zone in controller.zones
|
||||
for description in SWITCH_TYPES
|
||||
)
|
||||
|
||||
@@ -17,7 +17,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import HydrawiseDataUpdateCoordinator
|
||||
from .coordinator import HydrawiseUpdateCoordinators
|
||||
from .entity import HydrawiseEntity
|
||||
|
||||
VALVE_TYPES: tuple[ValveEntityDescription, ...] = (
|
||||
@@ -34,12 +34,10 @@ async def async_setup_entry(
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Hydrawise valve platform."""
|
||||
coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN][
|
||||
config_entry.entry_id
|
||||
]
|
||||
coordinators: HydrawiseUpdateCoordinators = hass.data[DOMAIN][config_entry.entry_id]
|
||||
async_add_entities(
|
||||
HydrawiseValve(coordinator, description, controller, zone_id=zone.id)
|
||||
for controller in coordinator.data.controllers.values()
|
||||
HydrawiseValve(coordinators.main, description, controller, zone_id=zone.id)
|
||||
for controller in coordinators.main.data.controllers.values()
|
||||
for zone in controller.zones
|
||||
for description in VALVE_TYPES
|
||||
)
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
"services": {
|
||||
"fetch": {
|
||||
"name": "Fetch message",
|
||||
"description": "Fetch the email message from the server.",
|
||||
"description": "Fetch an email message from the server.",
|
||||
"fields": {
|
||||
"entry": {
|
||||
"name": "Entry",
|
||||
|
||||
@@ -120,12 +120,22 @@ class ImprovBLEConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
assert self._discovery_info is not None
|
||||
|
||||
service_data = self._discovery_info.service_data
|
||||
improv_service_data = ImprovServiceData.from_bytes(
|
||||
service_data[SERVICE_DATA_UUID]
|
||||
)
|
||||
try:
|
||||
improv_service_data = ImprovServiceData.from_bytes(
|
||||
service_data[SERVICE_DATA_UUID]
|
||||
)
|
||||
except improv_ble_errors.InvalidCommand as err:
|
||||
_LOGGER.warning(
|
||||
"Aborting improv flow, device %s sent invalid improv data: '%s'",
|
||||
self._discovery_info.address,
|
||||
service_data[SERVICE_DATA_UUID].hex(),
|
||||
)
|
||||
raise AbortFlow("invalid_improv_data") from err
|
||||
|
||||
if improv_service_data.state in (State.PROVISIONING, State.PROVISIONED):
|
||||
_LOGGER.debug(
|
||||
"Aborting improv flow, device is already provisioned: %s",
|
||||
"Aborting improv flow, device %s is already provisioned: %s",
|
||||
self._discovery_info.address,
|
||||
improv_service_data.state,
|
||||
)
|
||||
raise AbortFlow("already_provisioned")
|
||||
|
||||
@@ -112,7 +112,7 @@
|
||||
"services": {
|
||||
"add_all_link": {
|
||||
"name": "Add all link",
|
||||
"description": "Tells the Insteom Modem (IM) start All-Linking mode. Once the IM is in All-Linking mode, press the link button on the device to complete All-Linking.",
|
||||
"description": "Tells the Insteon Modem (IM) start All-Linking mode. Once the IM is in All-Linking mode, press the link button on the device to complete All-Linking.",
|
||||
"fields": {
|
||||
"group": {
|
||||
"name": "Group",
|
||||
|
||||
@@ -137,6 +137,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
intent.async_register(hass, TimerStatusIntentHandler())
|
||||
intent.async_register(hass, GetCurrentDateIntentHandler())
|
||||
intent.async_register(hass, GetCurrentTimeIntentHandler())
|
||||
intent.async_register(hass, HelloIntentHandler())
|
||||
|
||||
return True
|
||||
|
||||
@@ -364,7 +365,7 @@ class NevermindIntentHandler(intent.IntentHandler):
|
||||
description = "Cancels the current request and does nothing"
|
||||
|
||||
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
|
||||
"""Doe not do anything, and produces an empty response."""
|
||||
"""Do nothing and produces an empty response."""
|
||||
return intent_obj.create_response()
|
||||
|
||||
|
||||
@@ -420,6 +421,17 @@ class GetCurrentTimeIntentHandler(intent.IntentHandler):
|
||||
return response
|
||||
|
||||
|
||||
class HelloIntentHandler(intent.IntentHandler):
|
||||
"""Responds with no action."""
|
||||
|
||||
intent_type = intent.INTENT_RESPOND
|
||||
description = "Returns the provided response with no action."
|
||||
|
||||
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
|
||||
"""Return the provided response, but take no action."""
|
||||
return intent_obj.create_response()
|
||||
|
||||
|
||||
async def _async_process_intent(
|
||||
hass: HomeAssistant, domain: str, platform: IntentPlatformProtocol
|
||||
) -> None:
|
||||
|
||||
@@ -92,4 +92,7 @@ class IronOSUpdate(IronOSBaseEntity, UpdateEntity):
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return super().available and self.firmware_update.last_update_success
|
||||
return (
|
||||
self.installed_version is not None
|
||||
and self.firmware_update.last_update_success
|
||||
)
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
}
|
||||
|
||||
@@ -15,7 +15,11 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_DOMAIN, CONF_ENTITIES, CONF_SOURCE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||
from homeassistant.helpers.issue_registry import (
|
||||
IssueSeverity,
|
||||
async_create_issue,
|
||||
async_delete_issue,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import (
|
||||
@@ -115,6 +119,9 @@ class LcnRegulatorLockSensor(LcnEntity, BinarySensorEntity):
|
||||
await self.device_connection.cancel_status_request_handler(
|
||||
self.setpoint_variable
|
||||
)
|
||||
async_delete_issue(
|
||||
self.hass, DOMAIN, f"deprecated_binary_sensor_{self.entity_id}"
|
||||
)
|
||||
|
||||
def input_received(self, input_obj: InputType) -> None:
|
||||
"""Set sensor value when LCN input object (command) is received."""
|
||||
@@ -201,6 +208,9 @@ class LcnLockKeysSensor(LcnEntity, BinarySensorEntity):
|
||||
await super().async_will_remove_from_hass()
|
||||
if not self.device_connection.is_group:
|
||||
await self.device_connection.cancel_status_request_handler(self.source)
|
||||
async_delete_issue(
|
||||
self.hass, DOMAIN, f"deprecated_binary_sensor_{self.entity_id}"
|
||||
)
|
||||
|
||||
def input_received(self, input_obj: InputType) -> None:
|
||||
"""Set sensor value when LCN input object (command) is received."""
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/lcn",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pypck"],
|
||||
"requirements": ["pypck==0.7.24", "lcn-frontend==0.2.0"]
|
||||
"requirements": ["pypck==0.7.24", "lcn-frontend==0.2.1"]
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_host": "[%key:common::config_flow::error::invalid_host%]"
|
||||
}
|
||||
},
|
||||
"device_automation": {
|
||||
|
||||
@@ -72,8 +72,11 @@ class ThinQFanEntity(ThinQEntity, FanEntity):
|
||||
super().__init__(coordinator, entity_description, property_id)
|
||||
|
||||
self._ordered_named_fan_speeds = []
|
||||
self._attr_supported_features |= FanEntityFeature.SET_SPEED
|
||||
|
||||
self._attr_supported_features = (
|
||||
FanEntityFeature.SET_SPEED
|
||||
| FanEntityFeature.TURN_ON
|
||||
| FanEntityFeature.TURN_OFF
|
||||
)
|
||||
if (fan_modes := self.data.fan_modes) is not None:
|
||||
self._attr_speed_count = len(fan_modes)
|
||||
if self.speed_count == 4:
|
||||
@@ -98,7 +101,7 @@ class ThinQFanEntity(ThinQEntity, FanEntity):
|
||||
self._attr_percentage = 0
|
||||
|
||||
_LOGGER.debug(
|
||||
"[%s:%s] update status: %s -> %s (percntage=%s)",
|
||||
"[%s:%s] update status: %s -> %s (percentage=%s)",
|
||||
self.coordinator.device_name,
|
||||
self.property_id,
|
||||
self.data.is_on,
|
||||
@@ -120,7 +123,7 @@ class ThinQFanEntity(ThinQEntity, FanEntity):
|
||||
return
|
||||
|
||||
_LOGGER.debug(
|
||||
"[%s:%s] async_set_percentage. percntage=%s, value=%s",
|
||||
"[%s:%s] async_set_percentage. percentage=%s, value=%s",
|
||||
self.coordinator.device_name,
|
||||
self.property_id,
|
||||
percentage,
|
||||
|
||||
@@ -255,73 +255,9 @@ WATER_INFO_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = {
|
||||
translation_key=ThinQProperty.WATER_TYPE,
|
||||
),
|
||||
}
|
||||
TIMER_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = {
|
||||
TimerProperty.RELATIVE_TO_START: SensorEntityDescription(
|
||||
key=TimerProperty.RELATIVE_TO_START,
|
||||
translation_key=TimerProperty.RELATIVE_TO_START,
|
||||
),
|
||||
TimerProperty.RELATIVE_TO_START_WM: SensorEntityDescription(
|
||||
key=TimerProperty.RELATIVE_TO_START,
|
||||
translation_key=TimerProperty.RELATIVE_TO_START_WM,
|
||||
),
|
||||
TimerProperty.RELATIVE_TO_STOP: SensorEntityDescription(
|
||||
key=TimerProperty.RELATIVE_TO_STOP,
|
||||
translation_key=TimerProperty.RELATIVE_TO_STOP,
|
||||
),
|
||||
TimerProperty.RELATIVE_TO_STOP_WM: SensorEntityDescription(
|
||||
key=TimerProperty.RELATIVE_TO_STOP,
|
||||
translation_key=TimerProperty.RELATIVE_TO_STOP_WM,
|
||||
),
|
||||
TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP: SensorEntityDescription(
|
||||
key=TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP,
|
||||
translation_key=TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP,
|
||||
),
|
||||
TimerProperty.ABSOLUTE_TO_START: SensorEntityDescription(
|
||||
key=TimerProperty.ABSOLUTE_TO_START,
|
||||
translation_key=TimerProperty.ABSOLUTE_TO_START,
|
||||
),
|
||||
TimerProperty.ABSOLUTE_TO_STOP: SensorEntityDescription(
|
||||
key=TimerProperty.ABSOLUTE_TO_STOP,
|
||||
translation_key=TimerProperty.ABSOLUTE_TO_STOP,
|
||||
),
|
||||
TimerProperty.REMAIN: SensorEntityDescription(
|
||||
key=TimerProperty.REMAIN,
|
||||
translation_key=TimerProperty.REMAIN,
|
||||
),
|
||||
TimerProperty.TARGET: SensorEntityDescription(
|
||||
key=TimerProperty.TARGET,
|
||||
translation_key=TimerProperty.TARGET,
|
||||
),
|
||||
TimerProperty.RUNNING: SensorEntityDescription(
|
||||
key=TimerProperty.RUNNING,
|
||||
translation_key=TimerProperty.RUNNING,
|
||||
),
|
||||
TimerProperty.TOTAL: SensorEntityDescription(
|
||||
key=TimerProperty.TOTAL,
|
||||
translation_key=TimerProperty.TOTAL,
|
||||
),
|
||||
TimerProperty.LIGHT_START: SensorEntityDescription(
|
||||
key=TimerProperty.LIGHT_START,
|
||||
translation_key=TimerProperty.LIGHT_START,
|
||||
),
|
||||
ThinQProperty.ELAPSED_DAY_STATE: SensorEntityDescription(
|
||||
key=ThinQProperty.ELAPSED_DAY_STATE,
|
||||
native_unit_of_measurement=UnitOfTime.DAYS,
|
||||
translation_key=ThinQProperty.ELAPSED_DAY_STATE,
|
||||
),
|
||||
ThinQProperty.ELAPSED_DAY_TOTAL: SensorEntityDescription(
|
||||
key=ThinQProperty.ELAPSED_DAY_TOTAL,
|
||||
native_unit_of_measurement=UnitOfTime.DAYS,
|
||||
translation_key=ThinQProperty.ELAPSED_DAY_TOTAL,
|
||||
),
|
||||
}
|
||||
|
||||
WASHER_SENSORS: tuple[SensorEntityDescription, ...] = (
|
||||
RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE],
|
||||
TIMER_SENSOR_DESC[TimerProperty.RELATIVE_TO_START_WM],
|
||||
TIMER_SENSOR_DESC[TimerProperty.RELATIVE_TO_STOP_WM],
|
||||
TIMER_SENSOR_DESC[TimerProperty.REMAIN],
|
||||
TIMER_SENSOR_DESC[TimerProperty.TOTAL],
|
||||
)
|
||||
DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = {
|
||||
DeviceType.AIR_CONDITIONER: (
|
||||
@@ -332,9 +268,6 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] =
|
||||
AIR_QUALITY_SENSOR_DESC[ThinQProperty.ODOR_LEVEL],
|
||||
AIR_QUALITY_SENSOR_DESC[ThinQProperty.TOTAL_POLLUTION_LEVEL],
|
||||
FILTER_INFO_SENSOR_DESC[ThinQProperty.FILTER_LIFETIME],
|
||||
TIMER_SENSOR_DESC[TimerProperty.RELATIVE_TO_START],
|
||||
TIMER_SENSOR_DESC[TimerProperty.RELATIVE_TO_STOP],
|
||||
TIMER_SENSOR_DESC[TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP],
|
||||
),
|
||||
DeviceType.AIR_PURIFIER_FAN: (
|
||||
AIR_QUALITY_SENSOR_DESC[ThinQProperty.PM1],
|
||||
@@ -345,7 +278,6 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] =
|
||||
AIR_QUALITY_SENSOR_DESC[ThinQProperty.MONITORING_ENABLED],
|
||||
AIR_QUALITY_SENSOR_DESC[ThinQProperty.ODOR_LEVEL],
|
||||
AIR_QUALITY_SENSOR_DESC[ThinQProperty.TOTAL_POLLUTION_LEVEL],
|
||||
TIMER_SENSOR_DESC[TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP],
|
||||
),
|
||||
DeviceType.AIR_PURIFIER: (
|
||||
AIR_QUALITY_SENSOR_DESC[ThinQProperty.PM1],
|
||||
@@ -361,7 +293,6 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] =
|
||||
DeviceType.COOKTOP: (
|
||||
RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE],
|
||||
POWER_SENSOR_DESC[ThinQProperty.POWER_LEVEL],
|
||||
TIMER_SENSOR_DESC[TimerProperty.REMAIN],
|
||||
),
|
||||
DeviceType.DEHUMIDIFIER: (
|
||||
JOB_MODE_SENSOR_DESC[ThinQProperty.CURRENT_JOB_MODE],
|
||||
@@ -372,9 +303,6 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] =
|
||||
PREFERENCE_SENSOR_DESC[ThinQProperty.RINSE_LEVEL],
|
||||
PREFERENCE_SENSOR_DESC[ThinQProperty.SOFTENING_LEVEL],
|
||||
RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE],
|
||||
TIMER_SENSOR_DESC[TimerProperty.RELATIVE_TO_START_WM],
|
||||
TIMER_SENSOR_DESC[TimerProperty.REMAIN],
|
||||
TIMER_SENSOR_DESC[TimerProperty.TOTAL],
|
||||
),
|
||||
DeviceType.DRYER: WASHER_SENSORS,
|
||||
DeviceType.HOME_BREW: (
|
||||
@@ -385,10 +313,7 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] =
|
||||
RECIPE_SENSOR_DESC[ThinQProperty.FLAVOR_INFO],
|
||||
RECIPE_SENSOR_DESC[ThinQProperty.BEER_REMAIN],
|
||||
RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE],
|
||||
TIMER_SENSOR_DESC[ThinQProperty.ELAPSED_DAY_STATE],
|
||||
TIMER_SENSOR_DESC[ThinQProperty.ELAPSED_DAY_TOTAL],
|
||||
),
|
||||
DeviceType.HOOD: (TIMER_SENSOR_DESC[TimerProperty.REMAIN],),
|
||||
DeviceType.HUMIDIFIER: (
|
||||
AIR_QUALITY_SENSOR_DESC[ThinQProperty.PM1],
|
||||
AIR_QUALITY_SENSOR_DESC[ThinQProperty.PM2],
|
||||
@@ -397,9 +322,6 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] =
|
||||
AIR_QUALITY_SENSOR_DESC[ThinQProperty.TEMPERATURE],
|
||||
AIR_QUALITY_SENSOR_DESC[ThinQProperty.MONITORING_ENABLED],
|
||||
AIR_QUALITY_SENSOR_DESC[ThinQProperty.TOTAL_POLLUTION_LEVEL],
|
||||
TIMER_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_START],
|
||||
TIMER_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_STOP],
|
||||
TIMER_SENSOR_DESC[TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP],
|
||||
),
|
||||
DeviceType.KIMCHI_REFRIGERATOR: (
|
||||
REFRIGERATION_SENSOR_DESC[ThinQProperty.FRESH_AIR_FILTER],
|
||||
@@ -408,15 +330,10 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] =
|
||||
translation_key=ThinQProperty.TARGET_TEMPERATURE,
|
||||
),
|
||||
),
|
||||
DeviceType.MICROWAVE_OVEN: (
|
||||
RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE],
|
||||
TIMER_SENSOR_DESC[TimerProperty.REMAIN],
|
||||
),
|
||||
DeviceType.MICROWAVE_OVEN: (RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE],),
|
||||
DeviceType.OVEN: (
|
||||
RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE],
|
||||
TEMPERATURE_SENSOR_DESC[ThinQProperty.TARGET_TEMPERATURE],
|
||||
TIMER_SENSOR_DESC[TimerProperty.REMAIN],
|
||||
TIMER_SENSOR_DESC[TimerProperty.TARGET],
|
||||
),
|
||||
DeviceType.PLANT_CULTIVATOR: (
|
||||
LIGHT_SENSOR_DESC[ThinQProperty.BRIGHTNESS],
|
||||
@@ -427,7 +344,6 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] =
|
||||
TEMPERATURE_SENSOR_DESC[ThinQProperty.DAY_TARGET_TEMPERATURE],
|
||||
TEMPERATURE_SENSOR_DESC[ThinQProperty.NIGHT_TARGET_TEMPERATURE],
|
||||
TEMPERATURE_SENSOR_DESC[ThinQProperty.TEMPERATURE_STATE],
|
||||
TIMER_SENSOR_DESC[TimerProperty.LIGHT_START],
|
||||
),
|
||||
DeviceType.REFRIGERATOR: (
|
||||
REFRIGERATION_SENSOR_DESC[ThinQProperty.FRESH_AIR_FILTER],
|
||||
@@ -436,7 +352,6 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] =
|
||||
DeviceType.ROBOT_CLEANER: (
|
||||
RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE],
|
||||
JOB_MODE_SENSOR_DESC[ThinQProperty.CURRENT_JOB_MODE],
|
||||
TIMER_SENSOR_DESC[TimerProperty.RUNNING],
|
||||
),
|
||||
DeviceType.STICK_CLEANER: (
|
||||
BATTERY_SENSOR_DESC[ThinQProperty.BATTERY_PERCENT],
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["linkplay"],
|
||||
"requirements": ["python-linkplay==0.0.17"],
|
||||
"requirements": ["python-linkplay==0.0.20"],
|
||||
"zeroconf": ["_linkplay._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Any, Concatenate
|
||||
from linkplay.bridge import LinkPlayBridge
|
||||
from linkplay.consts import EqualizerMode, LoopMode, PlayingMode, PlayingStatus
|
||||
from linkplay.controller import LinkPlayController, LinkPlayMultiroom
|
||||
from linkplay.exceptions import LinkPlayException, LinkPlayRequestException
|
||||
from linkplay.exceptions import LinkPlayRequestException
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import media_source
|
||||
@@ -69,6 +69,8 @@ SOURCE_MAP: dict[PlayingMode, str] = {
|
||||
PlayingMode.FM: "FM Radio",
|
||||
PlayingMode.RCA: "RCA",
|
||||
PlayingMode.UDISK: "USB",
|
||||
PlayingMode.SPOTIFY: "Spotify",
|
||||
PlayingMode.TIDAL: "Tidal",
|
||||
PlayingMode.FOLLOWER: "Follower",
|
||||
}
|
||||
|
||||
@@ -201,9 +203,8 @@ class LinkPlayMediaPlayerEntity(MediaPlayerEntity):
|
||||
try:
|
||||
await self._bridge.player.update_status()
|
||||
self._update_properties()
|
||||
except LinkPlayException:
|
||||
except LinkPlayRequestException:
|
||||
self._attr_available = False
|
||||
raise
|
||||
|
||||
@exception_wrap
|
||||
async def async_select_source(self, source: str) -> None:
|
||||
@@ -292,7 +293,15 @@ class LinkPlayMediaPlayerEntity(MediaPlayerEntity):
|
||||
@exception_wrap
|
||||
async def async_play_preset(self, preset_number: int) -> None:
|
||||
"""Play preset number."""
|
||||
await self._bridge.player.play_preset(preset_number)
|
||||
try:
|
||||
await self._bridge.player.play_preset(preset_number)
|
||||
except ValueError as err:
|
||||
raise HomeAssistantError(err) from err
|
||||
|
||||
@exception_wrap
|
||||
async def async_media_seek(self, position: float) -> None:
|
||||
"""Seek to a position."""
|
||||
await self._bridge.player.seek(round(position))
|
||||
|
||||
@exception_wrap
|
||||
async def async_join_players(self, group_members: list[str]) -> None:
|
||||
@@ -379,9 +388,9 @@ class LinkPlayMediaPlayerEntity(MediaPlayerEntity):
|
||||
)
|
||||
|
||||
self._attr_source = SOURCE_MAP.get(self._bridge.player.play_mode, "other")
|
||||
self._attr_media_position = self._bridge.player.current_position / 1000
|
||||
self._attr_media_position = self._bridge.player.current_position_in_seconds
|
||||
self._attr_media_position_updated_at = utcnow()
|
||||
self._attr_media_duration = self._bridge.player.total_length / 1000
|
||||
self._attr_media_duration = self._bridge.player.total_length_in_seconds
|
||||
self._attr_media_artist = self._bridge.player.artist
|
||||
self._attr_media_title = self._bridge.player.title
|
||||
self._attr_media_album_name = self._bridge.player.album
|
||||
|
||||
@@ -11,5 +11,4 @@ play_preset:
|
||||
selector:
|
||||
number:
|
||||
min: 1
|
||||
max: 10
|
||||
mode: box
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/lutron",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pylutron"],
|
||||
"requirements": ["pylutron==0.2.15"],
|
||||
"requirements": ["pylutron==0.2.16"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -28,12 +28,12 @@
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"set_up_new_device": "A new device was detected. Please set it up as a new entity instead of reconfiguring."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"no_mac": "A MAC address was not found. It required to identify the device. Please ensure your device is connectable.",
|
||||
"set_up_new_device": "A new device was detected. Please set it up as a new entity instead of reconfiguring."
|
||||
"no_mac": "A MAC address was not found. It required to identify the device. Please ensure your device is connectable."
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"iot_class": "calculated",
|
||||
"loggers": ["yt_dlp"],
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["yt-dlp==2024.10.22"],
|
||||
"requirements": ["yt-dlp[default]==2024.11.04"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ from homeassistant.const import (
|
||||
CONF_PROTOCOL,
|
||||
CONF_USERNAME,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.data_entry_flow import AbortFlow
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.hassio import is_hassio
|
||||
@@ -737,6 +737,16 @@ class MQTTOptionsFlowHandler(OptionsFlow):
|
||||
)
|
||||
|
||||
|
||||
async def _get_uploaded_file(hass: HomeAssistant, id: str) -> str:
|
||||
"""Get file content from uploaded file."""
|
||||
|
||||
def _proces_uploaded_file() -> str:
|
||||
with process_uploaded_file(hass, id) as file_path:
|
||||
return file_path.read_text(encoding=DEFAULT_ENCODING)
|
||||
|
||||
return await hass.async_add_executor_job(_proces_uploaded_file)
|
||||
|
||||
|
||||
async def async_get_broker_settings(
|
||||
flow: ConfigFlow | OptionsFlow,
|
||||
fields: OrderedDict[Any, Any],
|
||||
@@ -795,8 +805,7 @@ async def async_get_broker_settings(
|
||||
return False
|
||||
certificate_id: str | None = user_input.get(CONF_CERTIFICATE)
|
||||
if certificate_id:
|
||||
with process_uploaded_file(hass, certificate_id) as certificate_file:
|
||||
certificate = certificate_file.read_text(encoding=DEFAULT_ENCODING)
|
||||
certificate = await _get_uploaded_file(hass, certificate_id)
|
||||
|
||||
# Return to form for file upload CA cert or client cert and key
|
||||
if (
|
||||
@@ -812,15 +821,9 @@ async def async_get_broker_settings(
|
||||
return False
|
||||
|
||||
if client_certificate_id:
|
||||
with process_uploaded_file(
|
||||
hass, client_certificate_id
|
||||
) as client_certificate_file:
|
||||
client_certificate = client_certificate_file.read_text(
|
||||
encoding=DEFAULT_ENCODING
|
||||
)
|
||||
client_certificate = await _get_uploaded_file(hass, client_certificate_id)
|
||||
if client_key_id:
|
||||
with process_uploaded_file(hass, client_key_id) as key_file:
|
||||
client_key = key_file.read_text(encoding=DEFAULT_ENCODING)
|
||||
client_key = await _get_uploaded_file(hass, client_key_id)
|
||||
|
||||
certificate_data: dict[str, Any] = {}
|
||||
if certificate:
|
||||
|
||||
@@ -12,11 +12,12 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import MyUplinkConfigEntry, MyUplinkDataCoordinator
|
||||
from .const import F_SERIES
|
||||
from .entity import MyUplinkEntity, MyUplinkSystemEntity
|
||||
from .helpers import find_matching_platform
|
||||
from .helpers import find_matching_platform, transform_model_series
|
||||
|
||||
CATEGORY_BASED_DESCRIPTIONS: dict[str, dict[str, BinarySensorEntityDescription]] = {
|
||||
"F730": {
|
||||
F_SERIES: {
|
||||
"43161": BinarySensorEntityDescription(
|
||||
key="elect_add",
|
||||
translation_key="elect_add",
|
||||
@@ -50,6 +51,7 @@ def get_description(device_point: DevicePoint) -> BinarySensorEntityDescription
|
||||
2. Default to None
|
||||
"""
|
||||
prefix, _, _ = device_point.category.partition(" ")
|
||||
prefix = transform_model_series(prefix)
|
||||
return CATEGORY_BASED_DESCRIPTIONS.get(prefix, {}).get(device_point.parameter_id)
|
||||
|
||||
|
||||
|
||||
@@ -6,3 +6,5 @@ API_ENDPOINT = "https://api.myuplink.com"
|
||||
OAUTH2_AUTHORIZE = "https://api.myuplink.com/oauth/authorize"
|
||||
OAUTH2_TOKEN = "https://api.myuplink.com/oauth/token"
|
||||
OAUTH2_SCOPES = ["WRITESYSTEM", "READSYSTEM", "offline_access"]
|
||||
|
||||
F_SERIES = "f-series"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user