forked from home-assistant/core
Compare commits
79 Commits
2024.11.0b3
...
2024.11.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
+2
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]))
|
||||
|
||||
|
||||
|
||||
@@ -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.4"]
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyfibaro"],
|
||||
"requirements": ["pyfibaro==0.7.8"]
|
||||
"requirements": ["pyfibaro==0.8.0"]
|
||||
}
|
||||
|
||||
@@ -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.0"]
|
||||
}
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/fujitsu_fglair",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["ayla-iot-unofficial==1.4.2"]
|
||||
"requirements": ["ayla-iot-unofficial==1.4.3"]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import shutil
|
||||
|
||||
from aiohttp.client_exceptions import ClientConnectionError, ServerConnectionError
|
||||
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 +15,7 @@ from go2rtc_client.ws import (
|
||||
WsError,
|
||||
)
|
||||
import voluptuous as vol
|
||||
from webrtc_models import RTCIceCandidate
|
||||
|
||||
from homeassistant.components.camera import (
|
||||
Camera,
|
||||
@@ -37,7 +38,7 @@ 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
|
||||
from .server import Server
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -113,14 +114,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 +147,7 @@ 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()
|
||||
await client.validate_server_version()
|
||||
except Go2RtcClientError as err:
|
||||
if isinstance(err.__cause__, _RETRYABLE_ERRORS):
|
||||
raise ConfigEntryNotReady(
|
||||
@@ -150,6 +155,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 +211,21 @@ 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, f"ffmpeg:{camera.entity_id}#audio=opus"],
|
||||
)
|
||||
|
||||
@callback
|
||||
def on_messages(message: ReceiveMessages) -> None:
|
||||
@@ -219,7 +233,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 +245,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,5 @@ 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}/"
|
||||
|
||||
@@ -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.0"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -1,40 +1,75 @@
|
||||
"""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
|
||||
# - Clear default ice servers
|
||||
_GO2RTC_CONFIG_FORMAT = r"""
|
||||
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 +82,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 +127,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 +141,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")
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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.60", "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}"
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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%]",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -24,8 +24,12 @@ MAX_HISTORY_SECONDS = 60 * 60 # 1 hour
|
||||
MODEL_NAMES = [ # https://ollama.com/library
|
||||
"alfred",
|
||||
"all-minilm",
|
||||
"aya-expanse",
|
||||
"aya",
|
||||
"bakllava",
|
||||
"bespoke-minicheck",
|
||||
"bge-large",
|
||||
"bge-m3",
|
||||
"codebooga",
|
||||
"codegeex4",
|
||||
"codegemma",
|
||||
@@ -33,18 +37,19 @@ MODEL_NAMES = [ # https://ollama.com/library
|
||||
"codeqwen",
|
||||
"codestral",
|
||||
"codeup",
|
||||
"command-r",
|
||||
"command-r-plus",
|
||||
"command-r",
|
||||
"dbrx",
|
||||
"deepseek-coder",
|
||||
"deepseek-coder-v2",
|
||||
"deepseek-coder",
|
||||
"deepseek-llm",
|
||||
"deepseek-v2.5",
|
||||
"deepseek-v2",
|
||||
"dolphincoder",
|
||||
"dolphin-llama3",
|
||||
"dolphin-mistral",
|
||||
"dolphin-mixtral",
|
||||
"dolphin-phi",
|
||||
"dolphincoder",
|
||||
"duckdb-nsql",
|
||||
"everythinglm",
|
||||
"falcon",
|
||||
@@ -55,74 +60,97 @@ MODEL_NAMES = [ # https://ollama.com/library
|
||||
"glm4",
|
||||
"goliath",
|
||||
"granite-code",
|
||||
"granite3-dense",
|
||||
"granite3-guardian" "granite3-moe",
|
||||
"hermes3",
|
||||
"internlm2",
|
||||
"llama2",
|
||||
"llama-guard3",
|
||||
"llama-pro",
|
||||
"llama2-chinese",
|
||||
"llama2-uncensored",
|
||||
"llama3",
|
||||
"llama2",
|
||||
"llama3-chatqa",
|
||||
"llama3-gradient",
|
||||
"llama3-groq-tool-use",
|
||||
"llama-pro",
|
||||
"llava",
|
||||
"llama3.1",
|
||||
"llama3.2",
|
||||
"llama3",
|
||||
"llava-llama3",
|
||||
"llava-phi3",
|
||||
"llava",
|
||||
"magicoder",
|
||||
"mathstral",
|
||||
"meditron",
|
||||
"medllama2",
|
||||
"megadolphin",
|
||||
"mistral",
|
||||
"mistrallite",
|
||||
"minicpm-v",
|
||||
"mistral-large",
|
||||
"mistral-nemo",
|
||||
"mistral-openorca",
|
||||
"mistral-small",
|
||||
"mistral",
|
||||
"mistrallite",
|
||||
"mixtral",
|
||||
"moondream",
|
||||
"mxbai-embed-large",
|
||||
"nemotron-mini",
|
||||
"nemotron",
|
||||
"neural-chat",
|
||||
"nexusraven",
|
||||
"nomic-embed-text",
|
||||
"notus",
|
||||
"notux",
|
||||
"nous-hermes",
|
||||
"nous-hermes2",
|
||||
"nous-hermes2-mixtral",
|
||||
"nous-hermes2",
|
||||
"nuextract",
|
||||
"open-orca-platypus2",
|
||||
"openchat",
|
||||
"openhermes",
|
||||
"open-orca-platypus2",
|
||||
"orca2",
|
||||
"orca-mini",
|
||||
"orca2",
|
||||
"paraphrase-multilingual",
|
||||
"phi",
|
||||
"phi3.5",
|
||||
"phi3",
|
||||
"phind-codellama",
|
||||
"qwen",
|
||||
"qwen2-math",
|
||||
"qwen2.5-coder",
|
||||
"qwen2.5",
|
||||
"qwen2",
|
||||
"reader-lm",
|
||||
"reflection",
|
||||
"samantha-mistral",
|
||||
"shieldgemma",
|
||||
"smollm",
|
||||
"smollm2",
|
||||
"snowflake-arctic-embed",
|
||||
"solar-pro",
|
||||
"solar",
|
||||
"sqlcoder",
|
||||
"stable-beluga",
|
||||
"stable-code",
|
||||
"stablelm2",
|
||||
"stablelm-zephyr",
|
||||
"stablelm2",
|
||||
"starcoder",
|
||||
"starcoder2",
|
||||
"starling-lm",
|
||||
"tinydolphin",
|
||||
"tinyllama",
|
||||
"vicuna",
|
||||
"wizard-math",
|
||||
"wizard-vicuna-uncensored",
|
||||
"wizard-vicuna",
|
||||
"wizardcoder",
|
||||
"wizardlm-uncensored",
|
||||
"wizardlm",
|
||||
"wizardlm2",
|
||||
"wizardlm-uncensored",
|
||||
"wizard-math",
|
||||
"wizard-vicuna",
|
||||
"wizard-vicuna-uncensored",
|
||||
"xwinlm",
|
||||
"yarn-llama2",
|
||||
"yarn-mistral",
|
||||
"yi-coder",
|
||||
"yi",
|
||||
"zephyr",
|
||||
]
|
||||
DEFAULT_MODEL = "llama3.1:latest"
|
||||
DEFAULT_MODEL = "llama3.2:latest"
|
||||
|
||||
@@ -128,13 +128,27 @@ ISSUE_URL_PLACEHOLDER = "/config/integrations/dashboard/add?domain=onkyo"
|
||||
|
||||
type InputLibValue = str | tuple[str, ...]
|
||||
|
||||
_cmds: dict[str, InputLibValue] = {
|
||||
k: v["name"]
|
||||
for k, v in {
|
||||
**PYEISCP_COMMANDS["main"]["SLI"]["values"],
|
||||
**PYEISCP_COMMANDS["zone2"]["SLZ"]["values"],
|
||||
}.items()
|
||||
}
|
||||
|
||||
def _input_lib_cmds(zone: str) -> dict[InputSource, InputLibValue]:
|
||||
match zone:
|
||||
case "main":
|
||||
cmds = PYEISCP_COMMANDS["main"]["SLI"]
|
||||
case "zone2":
|
||||
cmds = PYEISCP_COMMANDS["zone2"]["SLZ"]
|
||||
case "zone3":
|
||||
cmds = PYEISCP_COMMANDS["zone3"]["SL3"]
|
||||
case "zone4":
|
||||
cmds = PYEISCP_COMMANDS["zone4"]["SL4"]
|
||||
|
||||
result: dict[InputSource, InputLibValue] = {}
|
||||
for k, v in cmds["values"].items():
|
||||
try:
|
||||
source = InputSource(k)
|
||||
except ValueError:
|
||||
continue
|
||||
result[source] = v["name"]
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def async_setup_platform(
|
||||
@@ -147,16 +161,13 @@ async def async_setup_platform(
|
||||
host = config.get(CONF_HOST)
|
||||
|
||||
source_mapping: dict[str, InputSource] = {}
|
||||
for value, source_lib in _cmds.items():
|
||||
try:
|
||||
source = InputSource(value)
|
||||
except ValueError:
|
||||
continue
|
||||
if isinstance(source_lib, str):
|
||||
source_mapping.setdefault(source_lib, source)
|
||||
else:
|
||||
for source_lib_single in source_lib:
|
||||
source_mapping.setdefault(source_lib_single, source)
|
||||
for zone in ZONES:
|
||||
for source, source_lib in _input_lib_cmds(zone).items():
|
||||
if isinstance(source_lib, str):
|
||||
source_mapping.setdefault(source_lib, source)
|
||||
else:
|
||||
for source_lib_single in source_lib:
|
||||
source_mapping.setdefault(source_lib_single, source)
|
||||
|
||||
sources: dict[InputSource, str] = {}
|
||||
for source_lib_single, source_name in config[CONF_SOURCES].items():
|
||||
@@ -340,9 +351,12 @@ class OnkyoMediaPlayer(MediaPlayerEntity):
|
||||
self._volume_resolution = volume_resolution
|
||||
self._max_volume = max_volume
|
||||
|
||||
self._source_mapping = sources
|
||||
self._reverse_mapping = {value: key for key, value in sources.items()}
|
||||
self._lib_mapping = {_cmds[source.value]: source for source in InputSource}
|
||||
self._name_mapping = sources
|
||||
self._reverse_name_mapping = {value: key for key, value in sources.items()}
|
||||
self._lib_mapping = _input_lib_cmds(zone)
|
||||
self._reverse_lib_mapping = {
|
||||
value: key for key, value in self._lib_mapping.items()
|
||||
}
|
||||
|
||||
self._attr_source_list = list(sources.values())
|
||||
self._attr_extra_state_attributes = {}
|
||||
@@ -414,7 +428,7 @@ class OnkyoMediaPlayer(MediaPlayerEntity):
|
||||
async def async_select_source(self, source: str) -> None:
|
||||
"""Select input source."""
|
||||
if self.source_list and source in self.source_list:
|
||||
source_lib = _cmds[self._reverse_mapping[source].value]
|
||||
source_lib = self._lib_mapping[self._reverse_name_mapping[source]]
|
||||
if isinstance(source_lib, str):
|
||||
source_lib_single = source_lib
|
||||
else:
|
||||
@@ -432,7 +446,7 @@ class OnkyoMediaPlayer(MediaPlayerEntity):
|
||||
) -> None:
|
||||
"""Play radio station by preset number."""
|
||||
if self.source is not None:
|
||||
source = self._reverse_mapping[self.source]
|
||||
source = self._reverse_name_mapping[self.source]
|
||||
if media_type.lower() == "radio" and source in DEFAULT_PLAYABLE_SOURCES:
|
||||
self._update_receiver("preset", media_id)
|
||||
|
||||
@@ -505,9 +519,9 @@ class OnkyoMediaPlayer(MediaPlayerEntity):
|
||||
|
||||
@callback
|
||||
def _parse_source(self, source_lib: InputLibValue) -> None:
|
||||
source = self._lib_mapping[source_lib]
|
||||
if source in self._source_mapping:
|
||||
self._attr_source = self._source_mapping[source]
|
||||
source = self._reverse_lib_mapping[source_lib]
|
||||
if source in self._name_mapping:
|
||||
self._attr_source = self._name_mapping[source]
|
||||
return
|
||||
|
||||
source_meaning = source.value_meaning
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
{
|
||||
"config": {
|
||||
"flow_title": "{username}",
|
||||
"abort": {
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
},
|
||||
"error": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"connection_error": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"authorization_error": "[%key:common::config_flow::error::invalid_auth%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/palazzetti",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["pypalazzetti==0.1.6"]
|
||||
"requirements": ["pypalazzetti==0.1.10"]
|
||||
}
|
||||
|
||||
@@ -18,5 +18,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/reolink",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["reolink_aio"],
|
||||
"requirements": ["reolink-aio==0.10.2"]
|
||||
"requirements": ["reolink-aio==0.10.4"]
|
||||
}
|
||||
|
||||
@@ -75,7 +75,10 @@ class SpotifyCoordinator(DataUpdateCoordinator[SpotifyCoordinatorData]):
|
||||
raise UpdateFailed("Error communicating with Spotify API") from err
|
||||
|
||||
async def _async_update_data(self) -> SpotifyCoordinatorData:
|
||||
current = await self.client.get_playback()
|
||||
try:
|
||||
current = await self.client.get_playback()
|
||||
except SpotifyConnectionError as err:
|
||||
raise UpdateFailed("Error communicating with Spotify API") from err
|
||||
if not current:
|
||||
return SpotifyCoordinatorData(
|
||||
current_playback=None,
|
||||
@@ -90,8 +93,17 @@ class SpotifyCoordinator(DataUpdateCoordinator[SpotifyCoordinatorData]):
|
||||
audio_features: AudioFeatures | None = None
|
||||
if (item := current.item) is not None and item.type == ItemType.TRACK:
|
||||
if item.uri != self._currently_loaded_track:
|
||||
self._currently_loaded_track = item.uri
|
||||
audio_features = await self.client.get_audio_features(item.uri)
|
||||
try:
|
||||
audio_features = await self.client.get_audio_features(item.uri)
|
||||
except SpotifyConnectionError:
|
||||
_LOGGER.debug(
|
||||
"Unable to load audio features for track '%s'. "
|
||||
"Continuing without audio features",
|
||||
item.uri,
|
||||
)
|
||||
audio_features = None
|
||||
else:
|
||||
self._currently_loaded_track = item.uri
|
||||
else:
|
||||
audio_features = self.data.audio_features
|
||||
dj_playlist = False
|
||||
|
||||
@@ -9,6 +9,6 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["spotipy"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["spotifyaio==0.8.3"],
|
||||
"requirements": ["spotifyaio==0.8.5"],
|
||||
"zeroconf": ["_spotify-connect._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable, Coroutine
|
||||
import datetime as dt
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, Concatenate
|
||||
|
||||
from spotifyaio import (
|
||||
Device,
|
||||
@@ -63,6 +64,7 @@ REPEAT_MODE_MAPPING_TO_HA = {
|
||||
REPEAT_MODE_MAPPING_TO_SPOTIFY = {
|
||||
value: key for key, value in REPEAT_MODE_MAPPING_TO_HA.items()
|
||||
}
|
||||
AFTER_REQUEST_SLEEP = 1
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -93,6 +95,19 @@ def ensure_item[_R](
|
||||
return wrapper
|
||||
|
||||
|
||||
def async_refresh_after[_T: SpotifyEntity, **_P](
|
||||
func: Callable[Concatenate[_T, _P], Awaitable[None]],
|
||||
) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]:
|
||||
"""Define a wrapper to yield and refresh after."""
|
||||
|
||||
async def _async_wrap(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None:
|
||||
await func(self, *args, **kwargs)
|
||||
await asyncio.sleep(AFTER_REQUEST_SLEEP)
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
return _async_wrap
|
||||
|
||||
|
||||
class SpotifyMediaPlayer(SpotifyEntity, MediaPlayerEntity):
|
||||
"""Representation of a Spotify controller."""
|
||||
|
||||
@@ -267,30 +282,37 @@ class SpotifyMediaPlayer(SpotifyEntity, MediaPlayerEntity):
|
||||
return None
|
||||
return REPEAT_MODE_MAPPING_TO_HA.get(self.currently_playing.repeat_mode)
|
||||
|
||||
@async_refresh_after
|
||||
async def async_set_volume_level(self, volume: float) -> None:
|
||||
"""Set the volume level."""
|
||||
await self.coordinator.client.set_volume(int(volume * 100))
|
||||
|
||||
@async_refresh_after
|
||||
async def async_media_play(self) -> None:
|
||||
"""Start or resume playback."""
|
||||
await self.coordinator.client.start_playback()
|
||||
|
||||
@async_refresh_after
|
||||
async def async_media_pause(self) -> None:
|
||||
"""Pause playback."""
|
||||
await self.coordinator.client.pause_playback()
|
||||
|
||||
@async_refresh_after
|
||||
async def async_media_previous_track(self) -> None:
|
||||
"""Skip to previous track."""
|
||||
await self.coordinator.client.previous_track()
|
||||
|
||||
@async_refresh_after
|
||||
async def async_media_next_track(self) -> None:
|
||||
"""Skip to next track."""
|
||||
await self.coordinator.client.next_track()
|
||||
|
||||
@async_refresh_after
|
||||
async def async_media_seek(self, position: float) -> None:
|
||||
"""Send seek command."""
|
||||
await self.coordinator.client.seek_track(int(position * 1000))
|
||||
|
||||
@async_refresh_after
|
||||
async def async_play_media(
|
||||
self, media_type: MediaType | str, media_id: str, **kwargs: Any
|
||||
) -> None:
|
||||
@@ -334,6 +356,7 @@ class SpotifyMediaPlayer(SpotifyEntity, MediaPlayerEntity):
|
||||
|
||||
await self.coordinator.client.start_playback(**kwargs)
|
||||
|
||||
@async_refresh_after
|
||||
async def async_select_source(self, source: str) -> None:
|
||||
"""Select playback device."""
|
||||
for device in self.devices.data:
|
||||
@@ -341,10 +364,12 @@ class SpotifyMediaPlayer(SpotifyEntity, MediaPlayerEntity):
|
||||
await self.coordinator.client.transfer_playback(device.device_id)
|
||||
return
|
||||
|
||||
@async_refresh_after
|
||||
async def async_set_shuffle(self, shuffle: bool) -> None:
|
||||
"""Enable/Disable shuffle mode."""
|
||||
await self.coordinator.client.set_shuffle(state=shuffle)
|
||||
|
||||
@async_refresh_after
|
||||
async def async_set_repeat(self, repeat: RepeatMode) -> None:
|
||||
"""Set repeat mode."""
|
||||
if repeat not in REPEAT_MODE_MAPPING_TO_SPOTIFY:
|
||||
|
||||
@@ -535,6 +535,7 @@ class SqueezeBoxMediaPlayerEntity(
|
||||
all_params.extend(parameters)
|
||||
self._query_result = await self._player.async_query(*all_params)
|
||||
_LOGGER.debug("call_query got result %s", self._query_result)
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_join_players(self, group_members: list[str]) -> None:
|
||||
"""Add other Squeezebox players to this player's sync group.
|
||||
|
||||
@@ -535,13 +535,15 @@ class TemplateEntity(Entity): # pylint: disable=hass-enforce-class-module
|
||||
)
|
||||
if self._entity_picture_template is not None:
|
||||
self.add_template_attribute(
|
||||
"_attr_entity_picture", self._entity_picture_template
|
||||
"_attr_entity_picture", self._entity_picture_template, cv.string
|
||||
)
|
||||
if (
|
||||
self._friendly_name_template is not None
|
||||
and not self._friendly_name_template.is_static
|
||||
):
|
||||
self.add_template_attribute("_attr_name", self._friendly_name_template)
|
||||
self.add_template_attribute(
|
||||
"_attr_name", self._friendly_name_template, cv.string
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_start_preview(
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["tibber"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["pyTibber==0.30.3"]
|
||||
"requirements": ["pyTibber==0.30.4"]
|
||||
}
|
||||
|
||||
@@ -47,17 +47,13 @@ async def __get_prices(call: ServiceCall, *, hass: HomeAssistant) -> ServiceResp
|
||||
for tibber_home in tibber_connection.get_homes(only_active=True):
|
||||
home_nickname = tibber_home.name
|
||||
|
||||
price_info = tibber_home.info["viewer"]["home"]["currentSubscription"][
|
||||
"priceInfo"
|
||||
]
|
||||
price_data = [
|
||||
{
|
||||
"start_time": price["startsAt"],
|
||||
"price": price["total"],
|
||||
"level": price["level"],
|
||||
"start_time": starts_at,
|
||||
"price": price,
|
||||
"level": tibber_home.price_level.get(starts_at),
|
||||
}
|
||||
for key in ("today", "tomorrow")
|
||||
for price in price_info[key]
|
||||
for starts_at, price in tibber_home.price_total.items()
|
||||
]
|
||||
|
||||
selected_data = [
|
||||
|
||||
@@ -301,5 +301,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["kasa"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["python-kasa[speedups]==0.7.6"]
|
||||
"requirements": ["python-kasa[speedups]==0.7.7"]
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = {
|
||||
TuyaBinarySensorEntityDescription(
|
||||
key=DPCode.PRESENCE_STATE,
|
||||
device_class=BinarySensorDeviceClass.OCCUPANCY,
|
||||
on_value="presence",
|
||||
on_value={"presence", "small_move", "large_move", "peaceful"},
|
||||
),
|
||||
),
|
||||
# Formaldehyde Detector
|
||||
|
||||
@@ -156,7 +156,8 @@ async def async_setup_entry(
|
||||
async_add_entities(_async_camera_entities(hass, entry, data))
|
||||
|
||||
|
||||
_EMPTY_CAMERA_FEATURES = CameraEntityFeature(0)
|
||||
_DISABLE_FEATURE = CameraEntityFeature(0)
|
||||
_ENABLE_FEATURE = CameraEntityFeature.STREAM
|
||||
|
||||
|
||||
class ProtectCamera(ProtectDeviceEntity, Camera):
|
||||
@@ -195,24 +196,22 @@ class ProtectCamera(ProtectDeviceEntity, Camera):
|
||||
self._attr_name = f"{camera_name} (insecure)"
|
||||
# only the default (first) channel is enabled by default
|
||||
self._attr_entity_registry_enabled_default = is_default and secure
|
||||
# Set the stream source before finishing the init
|
||||
# because async_added_to_hass is too late and camera
|
||||
# integration uses async_internal_added_to_hass to access
|
||||
# the stream source which is called before async_added_to_hass
|
||||
self._async_set_stream_source()
|
||||
|
||||
@callback
|
||||
def _async_set_stream_source(self) -> None:
|
||||
disable_stream = self._disable_stream
|
||||
channel = self.channel
|
||||
|
||||
if not channel.is_rtsp_enabled:
|
||||
disable_stream = False
|
||||
|
||||
rtsp_url = channel.rtsps_url if self._secure else channel.rtsp_url
|
||||
|
||||
# _async_set_stream_source called by __init__
|
||||
# pylint: disable-next=attribute-defined-outside-init
|
||||
self._stream_source = None if disable_stream else rtsp_url
|
||||
if self._stream_source:
|
||||
self._attr_supported_features = CameraEntityFeature.STREAM
|
||||
else:
|
||||
self._attr_supported_features = _EMPTY_CAMERA_FEATURES
|
||||
enable_stream = not self._disable_stream and channel.is_rtsp_enabled
|
||||
# SRTP disabled because go2rtc does not support it
|
||||
# https://github.com/AlexxIT/go2rtc/#source-rtsp
|
||||
rtsp_url = channel.rtsps_no_srtp_url if self._secure else channel.rtsp_url
|
||||
source = rtsp_url if enable_stream else None
|
||||
self._attr_supported_features = _ENABLE_FEATURE if source else _DISABLE_FEATURE
|
||||
self._stream_source = source
|
||||
|
||||
@callback
|
||||
def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None:
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["uiprotect", "unifi_discovery"],
|
||||
"requirements": ["uiprotect==6.3.2", "unifi-discovery==1.2.0"],
|
||||
"requirements": ["uiprotect==6.4.0", "unifi-discovery==1.2.0"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "Ubiquiti Networks",
|
||||
|
||||
@@ -6,7 +6,7 @@ import logging
|
||||
|
||||
from homeassistant.components.select import SelectEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_UNIQUE_ID
|
||||
from homeassistant.const import CONF_NAME, CONF_UNIQUE_ID
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device import async_device_info_to_link_from_entity
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
@@ -36,9 +36,9 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
tariff_select = TariffSelect(
|
||||
name,
|
||||
tariffs,
|
||||
unique_id,
|
||||
name=name,
|
||||
tariffs=tariffs,
|
||||
unique_id=unique_id,
|
||||
device_info=device_info,
|
||||
)
|
||||
async_add_entities([tariff_select])
|
||||
@@ -62,13 +62,15 @@ async def async_setup_platform(
|
||||
conf_meter_unique_id: str | None = hass.data[DATA_UTILITY][meter].get(
|
||||
CONF_UNIQUE_ID
|
||||
)
|
||||
conf_meter_name = hass.data[DATA_UTILITY][meter].get(CONF_NAME, meter)
|
||||
|
||||
async_add_entities(
|
||||
[
|
||||
TariffSelect(
|
||||
meter,
|
||||
discovery_info[CONF_TARIFFS],
|
||||
conf_meter_unique_id,
|
||||
name=conf_meter_name,
|
||||
tariffs=discovery_info[CONF_TARIFFS],
|
||||
yaml_slug=meter,
|
||||
unique_id=conf_meter_unique_id,
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -82,12 +84,16 @@ class TariffSelect(SelectEntity, RestoreEntity):
|
||||
def __init__(
|
||||
self,
|
||||
name,
|
||||
tariffs,
|
||||
unique_id,
|
||||
tariffs: list[str],
|
||||
*,
|
||||
yaml_slug: str | None = None,
|
||||
unique_id: str | None = None,
|
||||
device_info: DeviceInfo | None = None,
|
||||
) -> None:
|
||||
"""Initialize a tariff selector."""
|
||||
self._attr_name = name
|
||||
if yaml_slug: # Backwards compatibility with YAML configuration entries
|
||||
self.entity_id = f"select.{yaml_slug}"
|
||||
self._attr_unique_id = unique_id
|
||||
self._attr_device_info = device_info
|
||||
self._current_tariff: str | None = None
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["holidays"],
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["holidays==0.59"]
|
||||
"requirements": ["holidays==0.60"]
|
||||
}
|
||||
|
||||
@@ -1264,10 +1264,16 @@ class ConfigEntriesFlowManager(
|
||||
|
||||
# Avoid starting a config flow on an integration that only supports
|
||||
# a single config entry, but which already has an entry
|
||||
source = context["source"]
|
||||
if (
|
||||
context.get("source")
|
||||
not in {SOURCE_IGNORE, SOURCE_REAUTH, SOURCE_RECONFIGURE}
|
||||
and self.config_entries.async_has_entries(handler, include_ignore=False)
|
||||
source not in {SOURCE_IGNORE, SOURCE_REAUTH, SOURCE_RECONFIGURE}
|
||||
and (
|
||||
self.config_entries.async_has_entries(handler, include_ignore=False)
|
||||
or (
|
||||
self.config_entries.async_has_entries(handler, include_ignore=True)
|
||||
and source != SOURCE_USER
|
||||
)
|
||||
)
|
||||
and await _support_single_config_entry_only(self.hass, handler)
|
||||
):
|
||||
return ConfigFlowResult(
|
||||
@@ -1446,6 +1452,7 @@ class ConfigEntriesFlowManager(
|
||||
or progress_unique_id == DEFAULT_DISCOVERY_UNIQUE_ID
|
||||
):
|
||||
self.async_abort(progress_flow_id)
|
||||
continue
|
||||
|
||||
# Abort any flows in progress for the same handler
|
||||
# when integration allows only one config entry
|
||||
|
||||
@@ -25,7 +25,7 @@ if TYPE_CHECKING:
|
||||
APPLICATION_NAME: Final = "HomeAssistant"
|
||||
MAJOR_VERSION: Final = 2024
|
||||
MINOR_VERSION: Final = 11
|
||||
PATCH_VERSION: Final = "0b3"
|
||||
PATCH_VERSION: Final = "0"
|
||||
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
|
||||
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
|
||||
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0)
|
||||
|
||||
@@ -56,6 +56,7 @@ INTENT_UNPAUSE_TIMER = "HassUnpauseTimer"
|
||||
INTENT_TIMER_STATUS = "HassTimerStatus"
|
||||
INTENT_GET_CURRENT_DATE = "HassGetCurrentDate"
|
||||
INTENT_GET_CURRENT_TIME = "HassGetCurrentTime"
|
||||
INTENT_RESPOND = "HassRespond"
|
||||
|
||||
SLOT_SCHEMA = vol.Schema({}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
|
||||
@@ -279,6 +279,7 @@ class AssistAPI(API):
|
||||
intent.INTENT_TOGGLE,
|
||||
intent.INTENT_GET_CURRENT_DATE,
|
||||
intent.INTENT_GET_CURRENT_TIME,
|
||||
intent.INTENT_RESPOND,
|
||||
}
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
|
||||
@@ -26,15 +26,15 @@ ciso8601==2.3.1
|
||||
cryptography==43.0.1
|
||||
dbus-fast==2.24.3
|
||||
fnv-hash-fast==1.0.2
|
||||
go2rtc-client==0.0.1b3
|
||||
go2rtc-client==0.1.0
|
||||
ha-av==10.1.1
|
||||
ha-ffmpeg==3.2.1
|
||||
habluetooth==3.6.0
|
||||
hass-nabucasa==0.83.0
|
||||
hassil==1.7.4
|
||||
home-assistant-bluetooth==1.13.0
|
||||
home-assistant-frontend==20241031.0
|
||||
home-assistant-intents==2024.10.30
|
||||
home-assistant-frontend==20241106.0
|
||||
home-assistant-intents==2024.11.4
|
||||
httpx==0.27.2
|
||||
ifaddr==0.2.0
|
||||
Jinja2==3.1.4
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "homeassistant"
|
||||
version = "2024.11.0b3"
|
||||
version = "2024.11.0"
|
||||
license = {text = "Apache-2.0"}
|
||||
description = "Open-source home automation platform running on Python 3."
|
||||
readme = "README.rst"
|
||||
|
||||
+16
-16
@@ -536,7 +536,7 @@ automower-ble==0.2.0
|
||||
axis==63
|
||||
|
||||
# homeassistant.components.fujitsu_fglair
|
||||
ayla-iot-unofficial==1.4.2
|
||||
ayla-iot-unofficial==1.4.3
|
||||
|
||||
# homeassistant.components.azure_event_hub
|
||||
azure-eventhub==5.11.1
|
||||
@@ -572,7 +572,7 @@ beautifulsoup4==4.12.3
|
||||
# beewi-smartclim==0.0.10
|
||||
|
||||
# homeassistant.components.bmw_connected_drive
|
||||
bimmer-connected[china]==0.16.3
|
||||
bimmer-connected[china]==0.16.4
|
||||
|
||||
# homeassistant.components.bizkaibus
|
||||
bizkaibus==0.1.1
|
||||
@@ -986,7 +986,7 @@ gitterpy==0.1.7
|
||||
glances-api==0.8.0
|
||||
|
||||
# homeassistant.components.go2rtc
|
||||
go2rtc-client==0.0.1b3
|
||||
go2rtc-client==0.1.0
|
||||
|
||||
# homeassistant.components.goalzero
|
||||
goalzero==0.2.2
|
||||
@@ -1121,13 +1121,13 @@ hole==0.8.0
|
||||
|
||||
# homeassistant.components.holiday
|
||||
# homeassistant.components.workday
|
||||
holidays==0.59
|
||||
holidays==0.60
|
||||
|
||||
# homeassistant.components.frontend
|
||||
home-assistant-frontend==20241031.0
|
||||
home-assistant-frontend==20241106.0
|
||||
|
||||
# homeassistant.components.conversation
|
||||
home-assistant-intents==2024.10.30
|
||||
home-assistant-intents==2024.11.4
|
||||
|
||||
# homeassistant.components.home_connect
|
||||
homeconnect==0.8.0
|
||||
@@ -1265,7 +1265,7 @@ lakeside==0.13
|
||||
laundrify-aio==1.2.2
|
||||
|
||||
# homeassistant.components.lcn
|
||||
lcn-frontend==0.2.0
|
||||
lcn-frontend==0.2.1
|
||||
|
||||
# homeassistant.components.ld2410_ble
|
||||
ld2410-ble==0.1.1
|
||||
@@ -1735,7 +1735,7 @@ pyRFXtrx==0.31.1
|
||||
pySDCP==1
|
||||
|
||||
# homeassistant.components.tibber
|
||||
pyTibber==0.30.3
|
||||
pyTibber==0.30.4
|
||||
|
||||
# homeassistant.components.dlink
|
||||
pyW215==0.7.0
|
||||
@@ -1904,7 +1904,7 @@ pyevilgenius==2.0.0
|
||||
pyezviz==0.2.1.2
|
||||
|
||||
# homeassistant.components.fibaro
|
||||
pyfibaro==0.7.8
|
||||
pyfibaro==0.8.0
|
||||
|
||||
# homeassistant.components.fido
|
||||
pyfido==2.1.2
|
||||
@@ -2042,7 +2042,7 @@ pylitterbot==2023.5.0
|
||||
pylutron-caseta==0.21.1
|
||||
|
||||
# homeassistant.components.lutron
|
||||
pylutron==0.2.15
|
||||
pylutron==0.2.16
|
||||
|
||||
# homeassistant.components.mailgun
|
||||
pymailgunner==1.4
|
||||
@@ -2143,7 +2143,7 @@ pyoverkiz==1.14.1
|
||||
pyownet==0.10.0.post1
|
||||
|
||||
# homeassistant.components.palazzetti
|
||||
pypalazzetti==0.1.6
|
||||
pypalazzetti==0.1.10
|
||||
|
||||
# homeassistant.components.elv
|
||||
pypca==0.0.7
|
||||
@@ -2353,7 +2353,7 @@ python-join-api==0.0.9
|
||||
python-juicenet==1.1.0
|
||||
|
||||
# homeassistant.components.tplink
|
||||
python-kasa[speedups]==0.7.6
|
||||
python-kasa[speedups]==0.7.7
|
||||
|
||||
# homeassistant.components.linkplay
|
||||
python-linkplay==0.0.17
|
||||
@@ -2547,7 +2547,7 @@ renault-api==0.2.7
|
||||
renson-endura-delta==1.7.1
|
||||
|
||||
# homeassistant.components.reolink
|
||||
reolink-aio==0.10.2
|
||||
reolink-aio==0.10.4
|
||||
|
||||
# homeassistant.components.idteck_prox
|
||||
rfk101py==0.0.1
|
||||
@@ -2707,7 +2707,7 @@ speak2mary==1.4.0
|
||||
speedtest-cli==2.1.3
|
||||
|
||||
# homeassistant.components.spotify
|
||||
spotifyaio==0.8.3
|
||||
spotifyaio==0.8.5
|
||||
|
||||
# homeassistant.components.sql
|
||||
sqlparse==0.5.0
|
||||
@@ -2885,7 +2885,7 @@ typedmonarchmoney==0.3.1
|
||||
uasiren==0.0.1
|
||||
|
||||
# homeassistant.components.unifiprotect
|
||||
uiprotect==6.3.2
|
||||
uiprotect==6.4.0
|
||||
|
||||
# homeassistant.components.landisgyr_heat_meter
|
||||
ultraheat-api==0.5.7
|
||||
@@ -3051,7 +3051,7 @@ youless-api==2.1.2
|
||||
youtubeaio==1.1.5
|
||||
|
||||
# homeassistant.components.media_extractor
|
||||
yt-dlp==2024.10.22
|
||||
yt-dlp[default]==2024.11.04
|
||||
|
||||
# homeassistant.components.zamg
|
||||
zamg==0.3.6
|
||||
|
||||
+16
-16
@@ -485,7 +485,7 @@ automower-ble==0.2.0
|
||||
axis==63
|
||||
|
||||
# homeassistant.components.fujitsu_fglair
|
||||
ayla-iot-unofficial==1.4.2
|
||||
ayla-iot-unofficial==1.4.3
|
||||
|
||||
# homeassistant.components.azure_event_hub
|
||||
azure-eventhub==5.11.1
|
||||
@@ -506,7 +506,7 @@ base36==0.1.1
|
||||
beautifulsoup4==4.12.3
|
||||
|
||||
# homeassistant.components.bmw_connected_drive
|
||||
bimmer-connected[china]==0.16.3
|
||||
bimmer-connected[china]==0.16.4
|
||||
|
||||
# homeassistant.components.eq3btsmart
|
||||
# homeassistant.components.esphome
|
||||
@@ -836,7 +836,7 @@ gios==5.0.0
|
||||
glances-api==0.8.0
|
||||
|
||||
# homeassistant.components.go2rtc
|
||||
go2rtc-client==0.0.1b3
|
||||
go2rtc-client==0.1.0
|
||||
|
||||
# homeassistant.components.goalzero
|
||||
goalzero==0.2.2
|
||||
@@ -947,13 +947,13 @@ hole==0.8.0
|
||||
|
||||
# homeassistant.components.holiday
|
||||
# homeassistant.components.workday
|
||||
holidays==0.59
|
||||
holidays==0.60
|
||||
|
||||
# homeassistant.components.frontend
|
||||
home-assistant-frontend==20241031.0
|
||||
home-assistant-frontend==20241106.0
|
||||
|
||||
# homeassistant.components.conversation
|
||||
home-assistant-intents==2024.10.30
|
||||
home-assistant-intents==2024.11.4
|
||||
|
||||
# homeassistant.components.home_connect
|
||||
homeconnect==0.8.0
|
||||
@@ -1061,7 +1061,7 @@ lacrosse-view==1.0.3
|
||||
laundrify-aio==1.2.2
|
||||
|
||||
# homeassistant.components.lcn
|
||||
lcn-frontend==0.2.0
|
||||
lcn-frontend==0.2.1
|
||||
|
||||
# homeassistant.components.ld2410_ble
|
||||
ld2410-ble==0.1.1
|
||||
@@ -1412,7 +1412,7 @@ pyElectra==1.2.4
|
||||
pyRFXtrx==0.31.1
|
||||
|
||||
# homeassistant.components.tibber
|
||||
pyTibber==0.30.3
|
||||
pyTibber==0.30.4
|
||||
|
||||
# homeassistant.components.dlink
|
||||
pyW215==0.7.0
|
||||
@@ -1533,7 +1533,7 @@ pyevilgenius==2.0.0
|
||||
pyezviz==0.2.1.2
|
||||
|
||||
# homeassistant.components.fibaro
|
||||
pyfibaro==0.7.8
|
||||
pyfibaro==0.8.0
|
||||
|
||||
# homeassistant.components.fido
|
||||
pyfido==2.1.2
|
||||
@@ -1647,7 +1647,7 @@ pylitterbot==2023.5.0
|
||||
pylutron-caseta==0.21.1
|
||||
|
||||
# homeassistant.components.lutron
|
||||
pylutron==0.2.15
|
||||
pylutron==0.2.16
|
||||
|
||||
# homeassistant.components.mailgun
|
||||
pymailgunner==1.4
|
||||
@@ -1730,7 +1730,7 @@ pyoverkiz==1.14.1
|
||||
pyownet==0.10.0.post1
|
||||
|
||||
# homeassistant.components.palazzetti
|
||||
pypalazzetti==0.1.6
|
||||
pypalazzetti==0.1.10
|
||||
|
||||
# homeassistant.components.lcn
|
||||
pypck==0.7.24
|
||||
@@ -1880,7 +1880,7 @@ python-izone==1.2.9
|
||||
python-juicenet==1.1.0
|
||||
|
||||
# homeassistant.components.tplink
|
||||
python-kasa[speedups]==0.7.6
|
||||
python-kasa[speedups]==0.7.7
|
||||
|
||||
# homeassistant.components.linkplay
|
||||
python-linkplay==0.0.17
|
||||
@@ -2038,7 +2038,7 @@ renault-api==0.2.7
|
||||
renson-endura-delta==1.7.1
|
||||
|
||||
# homeassistant.components.reolink
|
||||
reolink-aio==0.10.2
|
||||
reolink-aio==0.10.4
|
||||
|
||||
# homeassistant.components.rflink
|
||||
rflink==0.0.66
|
||||
@@ -2159,7 +2159,7 @@ speak2mary==1.4.0
|
||||
speedtest-cli==2.1.3
|
||||
|
||||
# homeassistant.components.spotify
|
||||
spotifyaio==0.8.3
|
||||
spotifyaio==0.8.5
|
||||
|
||||
# homeassistant.components.sql
|
||||
sqlparse==0.5.0
|
||||
@@ -2298,7 +2298,7 @@ typedmonarchmoney==0.3.1
|
||||
uasiren==0.0.1
|
||||
|
||||
# homeassistant.components.unifiprotect
|
||||
uiprotect==6.3.2
|
||||
uiprotect==6.4.0
|
||||
|
||||
# homeassistant.components.landisgyr_heat_meter
|
||||
ultraheat-api==0.5.7
|
||||
@@ -2437,7 +2437,7 @@ youless-api==2.1.2
|
||||
youtubeaio==1.1.5
|
||||
|
||||
# homeassistant.components.media_extractor
|
||||
yt-dlp==2024.10.22
|
||||
yt-dlp[default]==2024.11.04
|
||||
|
||||
# homeassistant.components.zamg
|
||||
zamg==0.3.6
|
||||
|
||||
@@ -20,7 +20,8 @@ FROM ${{BUILD_FROM}}
|
||||
# Synchronize with homeassistant/core.py:async_stop
|
||||
ENV \
|
||||
S6_SERVICES_GRACETIME={timeout} \
|
||||
UV_SYSTEM_PYTHON=true
|
||||
UV_SYSTEM_PYTHON=true \
|
||||
UV_NO_CACHE=true
|
||||
|
||||
ARG QEMU_CPU
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.4.28,source=/uv,target=/bin/uv \
|
||||
-c /usr/src/homeassistant/homeassistant/package_constraints.txt \
|
||||
-r /usr/src/homeassistant/requirements.txt \
|
||||
stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.7.1 \
|
||||
PyTurboJPEG==1.7.5 ha-ffmpeg==3.2.1 hassil==1.7.4 home-assistant-intents==2024.10.30 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2
|
||||
PyTurboJPEG==1.7.5 ha-ffmpeg==3.2.1 hassil==1.7.4 home-assistant-intents==2024.11.4 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2
|
||||
|
||||
LABEL "name"="hassfest"
|
||||
LABEL "maintainer"="Home Assistant <hello@home-assistant.io>"
|
||||
|
||||
@@ -124,7 +124,7 @@ def mock_mozart_client() -> Generator[AsyncMock]:
|
||||
client.get_available_sources = AsyncMock()
|
||||
client.get_available_sources.return_value = SourceArray(
|
||||
items=[
|
||||
# Is in the HIDDEN_SOURCE_IDS constant, so should not be user selectable
|
||||
# Is not playable, so should not be user selectable
|
||||
Source(
|
||||
name="AirPlay",
|
||||
id="airPlay",
|
||||
@@ -137,14 +137,16 @@ def mock_mozart_client() -> Generator[AsyncMock]:
|
||||
id="tidal",
|
||||
is_enabled=True,
|
||||
is_multiroom_available=True,
|
||||
is_playable=True,
|
||||
),
|
||||
Source(
|
||||
name="Line-In",
|
||||
id="lineIn",
|
||||
is_enabled=True,
|
||||
is_multiroom_available=False,
|
||||
is_playable=True,
|
||||
),
|
||||
# Is disabled, so should not be user selectable
|
||||
# Is disabled and not playable, so should not be user selectable
|
||||
Source(
|
||||
name="Powerlink",
|
||||
id="pl",
|
||||
|
||||
@@ -130,6 +130,7 @@ TEST_VIDEO_SOURCES = ["HDMI A"]
|
||||
TEST_SOURCES = TEST_AUDIO_SOURCES + TEST_VIDEO_SOURCES
|
||||
TEST_FALLBACK_SOURCES = [
|
||||
"Audio Streamer",
|
||||
"Bluetooth",
|
||||
"Spotify Connect",
|
||||
"Line-In",
|
||||
"Optical",
|
||||
|
||||
@@ -10,6 +10,7 @@ from mozart_api.models import (
|
||||
PlayQueueSettings,
|
||||
RenderingState,
|
||||
Source,
|
||||
SourceArray,
|
||||
WebsocketNotificationTag,
|
||||
)
|
||||
import pytest
|
||||
@@ -195,6 +196,37 @@ async def test_async_update_sources_remote(
|
||||
assert mock_mozart_client.get_remote_menu.call_count == 2
|
||||
|
||||
|
||||
async def test_async_update_sources_availability(
|
||||
hass: HomeAssistant,
|
||||
mock_mozart_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test that the playback_source WebSocket event updates available playback sources."""
|
||||
# Remove video sources to simplify test
|
||||
mock_mozart_client.get_remote_menu.return_value = {}
|
||||
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
|
||||
playback_source_callback = (
|
||||
mock_mozart_client.get_playback_source_notifications.call_args[0][0]
|
||||
)
|
||||
|
||||
assert mock_mozart_client.get_available_sources.call_count == 1
|
||||
|
||||
# Add a source that is available and playable
|
||||
mock_mozart_client.get_available_sources.return_value = SourceArray(
|
||||
items=[BangOlufsenSource.TIDAL]
|
||||
)
|
||||
|
||||
# Send playback_source. The source is not actually used, so its attributes don't matter
|
||||
playback_source_callback(Source())
|
||||
|
||||
assert mock_mozart_client.get_available_sources.call_count == 2
|
||||
assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID))
|
||||
assert states.attributes[ATTR_INPUT_SOURCE_LIST] == [BangOlufsenSource.TIDAL.name]
|
||||
|
||||
|
||||
async def test_async_update_playback_metadata(
|
||||
hass: HomeAssistant,
|
||||
mock_mozart_client: AsyncMock,
|
||||
|
||||
@@ -130,6 +130,26 @@ async def test_attributes_set(
|
||||
assert state == snapshot(exclude=props("media_position_updated_at"))
|
||||
|
||||
|
||||
async def test_stop_maps_to_idle(
|
||||
hass: HomeAssistant,
|
||||
setup_config_entry: None,
|
||||
player_mocks: PlayerMocks,
|
||||
) -> None:
|
||||
"""Test the media player stop maps to idle."""
|
||||
player_mocks.player_data.status_long_polling_mock.set(
|
||||
dataclasses.replace(
|
||||
player_mocks.player_data.status_long_polling_mock.get(), state="stop"
|
||||
)
|
||||
)
|
||||
|
||||
# give the long polling loop a chance to update the state; this could be any async call
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert (
|
||||
hass.states.get("media_player.player_name1111").state == MediaPlayerState.IDLE
|
||||
)
|
||||
|
||||
|
||||
async def test_status_updated(
|
||||
hass: HomeAssistant,
|
||||
setup_config_entry: None,
|
||||
|
||||
@@ -4,8 +4,13 @@ from copy import deepcopy
|
||||
from unittest.mock import patch
|
||||
|
||||
from bimmer_connected.api.authentication import MyBMWAuthentication
|
||||
from bimmer_connected.models import MyBMWAPIError, MyBMWAuthError
|
||||
from bimmer_connected.models import (
|
||||
MyBMWAPIError,
|
||||
MyBMWAuthError,
|
||||
MyBMWCaptchaMissingError,
|
||||
)
|
||||
from httpx import RequestError
|
||||
import pytest
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.bmw_connected_drive.config_flow import DOMAIN
|
||||
@@ -311,3 +316,31 @@ async def test_reconfigure_unique_id_abort(hass: HomeAssistant) -> None:
|
||||
assert result2["type"] is FlowResultType.ABORT
|
||||
assert result2["reason"] == "account_mismatch"
|
||||
assert config_entry.data == FIXTURE_COMPLETE_ENTRY
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("bmw_fixture")
|
||||
async def test_captcha_flow_not_set(hass: HomeAssistant) -> None:
|
||||
"""Test the external flow with captcha failing once and succeeding the second time."""
|
||||
|
||||
TEST_REGION = "north_america"
|
||||
|
||||
# Start flow and open form
|
||||
# Start flow and open form
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
# Add login data
|
||||
with patch(
|
||||
"bimmer_connected.api.authentication.MyBMWAuthentication._login_row_na",
|
||||
side_effect=MyBMWCaptchaMissingError(
|
||||
"Missing hCaptcha token for North America login"
|
||||
),
|
||||
):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={**FIXTURE_USER_INPUT, CONF_REGION: TEST_REGION},
|
||||
)
|
||||
assert result["errors"]["base"] == "missing_captcha"
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
"""Test BMW coordinator."""
|
||||
|
||||
from copy import deepcopy
|
||||
from datetime import timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
from bimmer_connected.models import MyBMWAPIError, MyBMWAuthError
|
||||
from bimmer_connected.models import (
|
||||
MyBMWAPIError,
|
||||
MyBMWAuthError,
|
||||
MyBMWCaptchaMissingError,
|
||||
)
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.bmw_connected_drive import DOMAIN as BMW_DOMAIN
|
||||
from homeassistant.const import CONF_REGION
|
||||
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
@@ -122,3 +128,38 @@ async def test_init_reauth(
|
||||
f"config_entry_reauth_{BMW_DOMAIN}_{config_entry.entry_id}",
|
||||
)
|
||||
assert reauth_issue.active is True
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("bmw_fixture")
|
||||
async def test_captcha_reauth(
|
||||
hass: HomeAssistant,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test the reauth form."""
|
||||
TEST_REGION = "north_america"
|
||||
|
||||
config_entry_fixure = deepcopy(FIXTURE_CONFIG_ENTRY)
|
||||
config_entry_fixure["data"][CONF_REGION] = TEST_REGION
|
||||
config_entry = MockConfigEntry(**config_entry_fixure)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
coordinator = config_entry.runtime_data.coordinator
|
||||
|
||||
assert coordinator.last_update_success is True
|
||||
|
||||
freezer.tick(timedelta(minutes=10, seconds=1))
|
||||
with patch(
|
||||
"bimmer_connected.account.MyBMWAccount.get_vehicles",
|
||||
side_effect=MyBMWCaptchaMissingError(
|
||||
"Missing hCaptcha token for North America login"
|
||||
),
|
||||
):
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert coordinator.last_update_success is False
|
||||
assert isinstance(coordinator.last_exception, ConfigEntryAuthFailed) is True
|
||||
assert coordinator.last_exception.translation_key == "missing_captcha"
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"uuid": "77a151f8-77c4-47a3-8295-c750a0e69d4f",
|
||||
"status": "INVITATION",
|
||||
"purchase": [
|
||||
{
|
||||
"uuid": "b5d0790b-5f32-4d5c-91da-e29066f167de",
|
||||
"itemId": "Paprika",
|
||||
"specification": "Rot",
|
||||
"attributes": [
|
||||
{
|
||||
"type": "PURCHASE_CONDITIONS",
|
||||
"content": {
|
||||
"urgent": true,
|
||||
"convenient": true,
|
||||
"discounted": true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"uuid": "72d370ab-d8ca-4e41-b956-91df94795b4e",
|
||||
"itemId": "Pouletbrüstli",
|
||||
"specification": "Bio",
|
||||
"attributes": [
|
||||
{
|
||||
"type": "PURCHASE_CONDITIONS",
|
||||
"content": {
|
||||
"urgent": true,
|
||||
"convenient": true,
|
||||
"discounted": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"recently": [
|
||||
{
|
||||
"uuid": "fc8db30a-647e-4e6c-9d71-3b85d6a2d954",
|
||||
"itemId": "Ananas",
|
||||
"specification": "",
|
||||
"attributes": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"uuid": "77a151f8-77c4-47a3-8295-c750a0e69d4f",
|
||||
"status": "SHARED",
|
||||
"purchase": [
|
||||
{
|
||||
"uuid": "b5d0790b-5f32-4d5c-91da-e29066f167de",
|
||||
"itemId": "Paprika",
|
||||
"specification": "Rot",
|
||||
"attributes": [
|
||||
{
|
||||
"type": "PURCHASE_CONDITIONS",
|
||||
"content": {
|
||||
"urgent": true,
|
||||
"convenient": true,
|
||||
"discounted": true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"uuid": "72d370ab-d8ca-4e41-b956-91df94795b4e",
|
||||
"itemId": "Pouletbrüstli",
|
||||
"specification": "Bio",
|
||||
"attributes": [
|
||||
{
|
||||
"type": "PURCHASE_CONDITIONS",
|
||||
"content": {
|
||||
"urgent": true,
|
||||
"convenient": true,
|
||||
"discounted": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"recently": [
|
||||
{
|
||||
"uuid": "fc8db30a-647e-4e6c-9d71-3b85d6a2d954",
|
||||
"itemId": "Ananas",
|
||||
"specification": "",
|
||||
"attributes": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -55,6 +55,7 @@
|
||||
'options': list([
|
||||
'registered',
|
||||
'shared',
|
||||
'invitation',
|
||||
]),
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
@@ -92,6 +93,7 @@
|
||||
'options': list([
|
||||
'registered',
|
||||
'shared',
|
||||
'invitation',
|
||||
]),
|
||||
}),
|
||||
'context': <ANY>,
|
||||
@@ -344,6 +346,7 @@
|
||||
'options': list([
|
||||
'registered',
|
||||
'shared',
|
||||
'invitation',
|
||||
]),
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
@@ -381,6 +384,7 @@
|
||||
'options': list([
|
||||
'registered',
|
||||
'shared',
|
||||
'invitation',
|
||||
]),
|
||||
}),
|
||||
'context': <ANY>,
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
"""Test for sensor platform of the Bring! integration."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.bring.const import DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
from tests.common import MockConfigEntry, load_json_object_fixture, snapshot_platform
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -42,3 +43,34 @@ async def test_setup(
|
||||
await snapshot_platform(
|
||||
hass, entity_registry, snapshot, bring_config_entry.entry_id
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("fixture", "entity_state"),
|
||||
[
|
||||
("items_invitation", "invitation"),
|
||||
("items_shared", "shared"),
|
||||
("items", "registered"),
|
||||
],
|
||||
)
|
||||
async def test_list_access_states(
|
||||
hass: HomeAssistant,
|
||||
bring_config_entry: MockConfigEntry,
|
||||
mock_bring_client: AsyncMock,
|
||||
fixture: str,
|
||||
entity_state: str,
|
||||
) -> None:
|
||||
"""Snapshot test states of list access sensor."""
|
||||
|
||||
mock_bring_client.get_list.return_value = load_json_object_fixture(
|
||||
f"{fixture}.json", DOMAIN
|
||||
)
|
||||
|
||||
bring_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(bring_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert bring_config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
assert (state := hass.states.get("sensor.einkauf_list_access"))
|
||||
assert state.state == entity_state
|
||||
|
||||
@@ -6,6 +6,16 @@ components. Instead call the service directly.
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
from webrtc_models import RTCIceCandidate
|
||||
|
||||
from homeassistant.components.camera import (
|
||||
Camera,
|
||||
CameraWebRTCProvider,
|
||||
WebRTCAnswer,
|
||||
WebRTCSendMessage,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
|
||||
EMPTY_8_6_JPEG = b"empty_8_6"
|
||||
WEBRTC_ANSWER = "a=sendonly"
|
||||
STREAM_SOURCE = "rtsp://127.0.0.1/stream"
|
||||
@@ -23,3 +33,43 @@ def mock_turbo_jpeg(
|
||||
mocked_turbo_jpeg.scale_with_quality.return_value = EMPTY_8_6_JPEG
|
||||
mocked_turbo_jpeg.encode.return_value = EMPTY_8_6_JPEG
|
||||
return mocked_turbo_jpeg
|
||||
|
||||
|
||||
class SomeTestProvider(CameraWebRTCProvider):
|
||||
"""Test provider."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the provider."""
|
||||
self._is_supported = True
|
||||
|
||||
@property
|
||||
def domain(self) -> str:
|
||||
"""Return the integration domain of the provider."""
|
||||
return "some_test"
|
||||
|
||||
@callback
|
||||
def async_is_supported(self, stream_source: str) -> bool:
|
||||
"""Determine if the provider supports the stream source."""
|
||||
return self._is_supported
|
||||
|
||||
async def async_handle_async_webrtc_offer(
|
||||
self,
|
||||
camera: Camera,
|
||||
offer_sdp: str,
|
||||
session_id: str,
|
||||
send_message: WebRTCSendMessage,
|
||||
) -> None:
|
||||
"""Handle the WebRTC offer and return the answer via the provided callback.
|
||||
|
||||
Return value determines if the offer was handled successfully.
|
||||
"""
|
||||
send_message(WebRTCAnswer(answer="answer"))
|
||||
|
||||
async def async_on_webrtc_candidate(
|
||||
self, session_id: str, candidate: RTCIceCandidate
|
||||
) -> None:
|
||||
"""Handle the WebRTC candidate."""
|
||||
|
||||
@callback
|
||||
def async_close_session(self, session_id: str) -> None:
|
||||
"""Close the session."""
|
||||
|
||||
@@ -1,19 +1,30 @@
|
||||
"""Test helpers for camera."""
|
||||
|
||||
from collections.abc import AsyncGenerator, Generator
|
||||
from unittest.mock import AsyncMock, PropertyMock, patch
|
||||
from unittest.mock import AsyncMock, Mock, PropertyMock, patch
|
||||
|
||||
import pytest
|
||||
from webrtc_models import RTCIceCandidate
|
||||
|
||||
from homeassistant.components import camera
|
||||
from homeassistant.components.camera.const import StreamType
|
||||
from homeassistant.components.camera.webrtc import WebRTCAnswer, WebRTCSendMessage
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigFlow
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from .common import STREAM_SOURCE, WEBRTC_ANSWER
|
||||
from .common import STREAM_SOURCE, WEBRTC_ANSWER, SomeTestProvider
|
||||
|
||||
from tests.common import (
|
||||
MockConfigEntry,
|
||||
MockModule,
|
||||
mock_config_flow,
|
||||
mock_integration,
|
||||
mock_platform,
|
||||
setup_test_component_platform,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -142,3 +153,100 @@ def mock_stream_source_fixture() -> Generator[AsyncMock]:
|
||||
return_value=STREAM_SOURCE,
|
||||
) as mock_stream_source:
|
||||
yield mock_stream_source
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def mock_test_webrtc_cameras(hass: HomeAssistant) -> None:
|
||||
"""Initialize test WebRTC cameras with native RTC support."""
|
||||
|
||||
# Cannot use the fixture mock_camera_web_rtc as it's mocking Camera.async_handle_web_rtc_offer
|
||||
# and native support is checked by verify the function "async_handle_web_rtc_offer" was
|
||||
# overwritten(implemented) or not
|
||||
class BaseCamera(camera.Camera):
|
||||
"""Base Camera."""
|
||||
|
||||
_attr_supported_features: camera.CameraEntityFeature = (
|
||||
camera.CameraEntityFeature.STREAM
|
||||
)
|
||||
_attr_frontend_stream_type: camera.StreamType = camera.StreamType.WEB_RTC
|
||||
|
||||
async def stream_source(self) -> str | None:
|
||||
return STREAM_SOURCE
|
||||
|
||||
class SyncCamera(BaseCamera):
|
||||
"""Mock Camera with native sync WebRTC support."""
|
||||
|
||||
_attr_name = "Sync"
|
||||
|
||||
async def async_handle_web_rtc_offer(self, offer_sdp: str) -> str | None:
|
||||
return WEBRTC_ANSWER
|
||||
|
||||
class AsyncCamera(BaseCamera):
|
||||
"""Mock Camera with native async WebRTC support."""
|
||||
|
||||
_attr_name = "Async"
|
||||
|
||||
async def async_handle_async_webrtc_offer(
|
||||
self, offer_sdp: str, session_id: str, send_message: WebRTCSendMessage
|
||||
) -> None:
|
||||
send_message(WebRTCAnswer(WEBRTC_ANSWER))
|
||||
|
||||
async def async_on_webrtc_candidate(
|
||||
self, session_id: str, candidate: RTCIceCandidate
|
||||
) -> None:
|
||||
"""Handle a WebRTC candidate."""
|
||||
# Do nothing
|
||||
|
||||
domain = "test"
|
||||
|
||||
entry = MockConfigEntry(domain=domain)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
async def async_setup_entry_init(
|
||||
hass: HomeAssistant, config_entry: ConfigEntry
|
||||
) -> bool:
|
||||
"""Set up test config entry."""
|
||||
await hass.config_entries.async_forward_entry_setups(
|
||||
config_entry, [camera.DOMAIN]
|
||||
)
|
||||
return True
|
||||
|
||||
async def async_unload_entry_init(
|
||||
hass: HomeAssistant, config_entry: ConfigEntry
|
||||
) -> bool:
|
||||
"""Unload test config entry."""
|
||||
await hass.config_entries.async_forward_entry_unload(
|
||||
config_entry, camera.DOMAIN
|
||||
)
|
||||
return True
|
||||
|
||||
mock_integration(
|
||||
hass,
|
||||
MockModule(
|
||||
domain,
|
||||
async_setup_entry=async_setup_entry_init,
|
||||
async_unload_entry=async_unload_entry_init,
|
||||
),
|
||||
)
|
||||
setup_test_component_platform(
|
||||
hass, camera.DOMAIN, [SyncCamera(), AsyncCamera()], from_config_entry=True
|
||||
)
|
||||
mock_platform(hass, f"{domain}.config_flow", Mock())
|
||||
|
||||
with mock_config_flow(domain, ConfigFlow):
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def register_test_provider(
|
||||
hass: HomeAssistant,
|
||||
) -> AsyncGenerator[SomeTestProvider]:
|
||||
"""Add WebRTC test provider."""
|
||||
await async_setup_component(hass, "camera", {})
|
||||
|
||||
provider = SomeTestProvider()
|
||||
unsub = camera.async_register_webrtc_provider(hass, provider)
|
||||
await hass.async_block_till_done()
|
||||
yield provider
|
||||
unsub()
|
||||
|
||||
@@ -7,6 +7,7 @@ from unittest.mock import ANY, AsyncMock, Mock, PropertyMock, mock_open, patch
|
||||
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
from webrtc_models import RTCIceCandidate
|
||||
|
||||
from homeassistant.components import camera
|
||||
from homeassistant.components.camera import (
|
||||
@@ -24,7 +25,6 @@ from homeassistant.components.camera.const import (
|
||||
)
|
||||
from homeassistant.components.camera.helper import get_camera_from_entity_id
|
||||
from homeassistant.components.websocket_api import TYPE_RESULT
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigFlow
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
EVENT_HOMEASSISTANT_STARTED,
|
||||
@@ -37,18 +37,12 @@ from homeassistant.helpers import entity_registry as er, issue_registry as ir
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .common import EMPTY_8_6_JPEG, STREAM_SOURCE, WEBRTC_ANSWER, mock_turbo_jpeg
|
||||
from .common import EMPTY_8_6_JPEG, STREAM_SOURCE, mock_turbo_jpeg
|
||||
|
||||
from tests.common import (
|
||||
MockConfigEntry,
|
||||
MockModule,
|
||||
async_fire_time_changed,
|
||||
help_test_all,
|
||||
import_and_test_deprecated_constant_enum,
|
||||
mock_config_flow,
|
||||
mock_integration,
|
||||
mock_platform,
|
||||
setup_test_component_platform,
|
||||
)
|
||||
from tests.typing import ClientSessionGenerator, WebSocketGenerator
|
||||
|
||||
@@ -960,7 +954,7 @@ async def _test_capabilities(
|
||||
send_message(WebRTCAnswer("answer"))
|
||||
|
||||
async def async_on_webrtc_candidate(
|
||||
self, session_id: str, candidate: str
|
||||
self, session_id: str, candidate: RTCIceCandidate
|
||||
) -> None:
|
||||
"""Handle the WebRTC candidate."""
|
||||
|
||||
@@ -985,62 +979,78 @@ async def test_camera_capabilities_hls(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_test_webrtc_cameras")
|
||||
async def test_camera_capabilities_webrtc(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
) -> None:
|
||||
"""Test WebRTC camera capabilities."""
|
||||
|
||||
# Cannot use the fixture mock_camera_web_rtc as it's mocking Camera.async_handle_web_rtc_offer
|
||||
# Camera capabilities are determined by by checking if the function was overwritten(implemented) or not
|
||||
class MockCamera(camera.Camera):
|
||||
"""Mock Camera Entity."""
|
||||
|
||||
_attr_name = "Test"
|
||||
_attr_supported_features: camera.CameraEntityFeature = (
|
||||
camera.CameraEntityFeature.STREAM
|
||||
)
|
||||
|
||||
async def stream_source(self) -> str | None:
|
||||
return STREAM_SOURCE
|
||||
|
||||
async def async_handle_web_rtc_offer(self, offer_sdp: str) -> str | None:
|
||||
return WEBRTC_ANSWER
|
||||
|
||||
domain = "test"
|
||||
|
||||
entry = MockConfigEntry(domain=domain)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
async def async_setup_entry_init(
|
||||
hass: HomeAssistant, config_entry: ConfigEntry
|
||||
) -> bool:
|
||||
"""Set up test config entry."""
|
||||
await hass.config_entries.async_forward_entry_setups(config_entry, [DOMAIN])
|
||||
return True
|
||||
|
||||
async def async_unload_entry_init(
|
||||
hass: HomeAssistant, config_entry: ConfigEntry
|
||||
) -> bool:
|
||||
"""Unload test config entry."""
|
||||
await hass.config_entries.async_forward_entry_unload(config_entry, DOMAIN)
|
||||
return True
|
||||
|
||||
mock_integration(
|
||||
hass,
|
||||
MockModule(
|
||||
domain,
|
||||
async_setup_entry=async_setup_entry_init,
|
||||
async_unload_entry=async_unload_entry_init,
|
||||
),
|
||||
await _test_capabilities(
|
||||
hass, hass_ws_client, "camera.sync", {StreamType.WEB_RTC}, {StreamType.WEB_RTC}
|
||||
)
|
||||
setup_test_component_platform(hass, DOMAIN, [MockCamera()], from_config_entry=True)
|
||||
mock_platform(hass, f"{domain}.config_flow", Mock())
|
||||
|
||||
with mock_config_flow(domain, ConfigFlow):
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("entity_id", "expect_native_async_webrtc"),
|
||||
[("camera.sync", False), ("camera.async", True)],
|
||||
)
|
||||
@pytest.mark.usefixtures("mock_test_webrtc_cameras", "register_test_provider")
|
||||
async def test_webrtc_provider_not_added_for_native_webrtc(
|
||||
hass: HomeAssistant, entity_id: str, expect_native_async_webrtc: bool
|
||||
) -> None:
|
||||
"""Test that a WebRTC provider is not added to a camera when the camera has native WebRTC support."""
|
||||
camera_obj = get_camera_from_entity_id(hass, entity_id)
|
||||
assert camera_obj
|
||||
assert camera_obj._webrtc_provider is None
|
||||
assert camera_obj._supports_native_sync_webrtc is not expect_native_async_webrtc
|
||||
assert camera_obj._supports_native_async_webrtc is expect_native_async_webrtc
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_camera", "mock_stream_source")
|
||||
async def test_camera_capabilities_changing_non_native_support(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
) -> None:
|
||||
"""Test WebRTC camera capabilities."""
|
||||
cam = get_camera_from_entity_id(hass, "camera.demo_camera")
|
||||
assert (
|
||||
cam.supported_features
|
||||
== camera.CameraEntityFeature.ON_OFF | camera.CameraEntityFeature.STREAM
|
||||
)
|
||||
|
||||
await _test_capabilities(
|
||||
hass, hass_ws_client, "camera.test", {StreamType.WEB_RTC}, {StreamType.WEB_RTC}
|
||||
hass,
|
||||
hass_ws_client,
|
||||
cam.entity_id,
|
||||
{StreamType.HLS},
|
||||
{StreamType.HLS, StreamType.WEB_RTC},
|
||||
)
|
||||
|
||||
cam._attr_supported_features = camera.CameraEntityFeature(0)
|
||||
cam.async_write_ha_state()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await _test_capabilities(hass, hass_ws_client, cam.entity_id, set(), set())
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_test_webrtc_cameras")
|
||||
@pytest.mark.parametrize(("entity_id"), ["camera.sync", "camera.async"])
|
||||
async def test_camera_capabilities_changing_native_support(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
entity_id: str,
|
||||
) -> None:
|
||||
"""Test WebRTC camera capabilities."""
|
||||
cam = get_camera_from_entity_id(hass, entity_id)
|
||||
assert cam.supported_features == camera.CameraEntityFeature.STREAM
|
||||
|
||||
await _test_capabilities(
|
||||
hass, hass_ws_client, cam.entity_id, {StreamType.WEB_RTC}, {StreamType.WEB_RTC}
|
||||
)
|
||||
|
||||
cam._attr_supported_features = camera.CameraEntityFeature(0)
|
||||
cam.async_write_ha_state()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await _test_capabilities(hass, hass_ws_client, cam.entity_id, set(), set())
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Any
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from webrtc_models import RTCIceCandidate, RTCIceServer
|
||||
|
||||
from homeassistant.components.camera import (
|
||||
DATA_ICE_SERVERS,
|
||||
@@ -13,7 +14,6 @@ from homeassistant.components.camera import (
|
||||
Camera,
|
||||
CameraEntityFeature,
|
||||
CameraWebRTCProvider,
|
||||
RTCIceServer,
|
||||
StreamType,
|
||||
WebRTCAnswer,
|
||||
WebRTCCandidate,
|
||||
@@ -34,7 +34,7 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from .common import STREAM_SOURCE, WEBRTC_ANSWER
|
||||
from .common import STREAM_SOURCE, WEBRTC_ANSWER, SomeTestProvider
|
||||
|
||||
from tests.common import (
|
||||
MockConfigEntry,
|
||||
@@ -51,44 +51,6 @@ HLS_STREAM_SOURCE = "http://127.0.0.1/example.m3u"
|
||||
TEST_INTEGRATION_DOMAIN = "test"
|
||||
|
||||
|
||||
class SomeTestProvider(CameraWebRTCProvider):
|
||||
"""Test provider."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the provider."""
|
||||
self._is_supported = True
|
||||
|
||||
@property
|
||||
def domain(self) -> str:
|
||||
"""Return the integration domain of the provider."""
|
||||
return "some_test"
|
||||
|
||||
@callback
|
||||
def async_is_supported(self, stream_source: str) -> bool:
|
||||
"""Determine if the provider supports the stream source."""
|
||||
return self._is_supported
|
||||
|
||||
async def async_handle_async_webrtc_offer(
|
||||
self,
|
||||
camera: Camera,
|
||||
offer_sdp: str,
|
||||
session_id: str,
|
||||
send_message: WebRTCSendMessage,
|
||||
) -> None:
|
||||
"""Handle the WebRTC offer and return the answer via the provided callback.
|
||||
|
||||
Return value determines if the offer was handled successfully.
|
||||
"""
|
||||
send_message(WebRTCAnswer(answer="answer"))
|
||||
|
||||
async def async_on_webrtc_candidate(self, session_id: str, candidate: str) -> None:
|
||||
"""Handle the WebRTC candidate."""
|
||||
|
||||
@callback
|
||||
def async_close_session(self, session_id: str) -> None:
|
||||
"""Close the session."""
|
||||
|
||||
|
||||
class Go2RTCProvider(SomeTestProvider):
|
||||
"""go2rtc provider."""
|
||||
|
||||
@@ -177,20 +139,6 @@ async def init_test_integration(
|
||||
return test_camera
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def register_test_provider(
|
||||
hass: HomeAssistant,
|
||||
) -> AsyncGenerator[SomeTestProvider]:
|
||||
"""Add WebRTC test provider."""
|
||||
await async_setup_component(hass, "camera", {})
|
||||
|
||||
provider = SomeTestProvider()
|
||||
unsub = async_register_webrtc_provider(hass, provider)
|
||||
await hass.async_block_till_done()
|
||||
yield provider
|
||||
unsub()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_camera", "mock_stream", "mock_stream_source")
|
||||
async def test_async_register_webrtc_provider(
|
||||
hass: HomeAssistant,
|
||||
@@ -391,6 +339,29 @@ async def test_ws_get_client_config(
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_test_webrtc_cameras")
|
||||
async def test_ws_get_client_config_sync_offer(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""Test get WebRTC client config, when camera is supporting sync offer."""
|
||||
await async_setup_component(hass, "camera", {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
await client.send_json_auto_id(
|
||||
{"type": "camera/webrtc/get_client_config", "entity_id": "camera.sync"}
|
||||
)
|
||||
msg = await client.receive_json()
|
||||
|
||||
# Assert WebSocket response
|
||||
assert msg["type"] == TYPE_RESULT
|
||||
assert msg["success"]
|
||||
assert msg["result"] == {
|
||||
"configuration": {},
|
||||
"getCandidatesUpfront": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_camera_webrtc")
|
||||
async def test_ws_get_client_config_custom_config(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
@@ -503,7 +474,10 @@ async def test_websocket_webrtc_offer(
|
||||
@pytest.mark.parametrize(
|
||||
("message", "expected_frontend_message"),
|
||||
[
|
||||
(WebRTCCandidate("candidate"), {"type": "candidate", "candidate": "candidate"}),
|
||||
(
|
||||
WebRTCCandidate(RTCIceCandidate("candidate")),
|
||||
{"type": "candidate", "candidate": "candidate"},
|
||||
),
|
||||
(
|
||||
WebRTCError("webrtc_offer_failed", "error"),
|
||||
{"type": "error", "code": "webrtc_offer_failed", "message": "error"},
|
||||
@@ -989,7 +963,9 @@ async def test_ws_webrtc_candidate(
|
||||
response = await client.receive_json()
|
||||
assert response["type"] == TYPE_RESULT
|
||||
assert response["success"]
|
||||
mock_on_webrtc_candidate.assert_called_once_with(session_id, candidate)
|
||||
mock_on_webrtc_candidate.assert_called_once_with(
|
||||
session_id, RTCIceCandidate(candidate)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_camera_webrtc")
|
||||
@@ -1039,7 +1015,9 @@ async def test_ws_webrtc_candidate_webrtc_provider(
|
||||
response = await client.receive_json()
|
||||
assert response["type"] == TYPE_RESULT
|
||||
assert response["success"]
|
||||
mock_on_webrtc_candidate.assert_called_once_with(session_id, candidate)
|
||||
mock_on_webrtc_candidate.assert_called_once_with(
|
||||
session_id, RTCIceCandidate(candidate)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_camera_webrtc")
|
||||
@@ -1140,7 +1118,7 @@ async def test_webrtc_provider_optional_interface(hass: HomeAssistant) -> None:
|
||||
send_message(WebRTCAnswer(answer="answer"))
|
||||
|
||||
async def async_on_webrtc_candidate(
|
||||
self, session_id: str, candidate: str
|
||||
self, session_id: str, candidate: RTCIceCandidate
|
||||
) -> None:
|
||||
"""Handle the WebRTC candidate."""
|
||||
|
||||
@@ -1150,7 +1128,7 @@ async def test_webrtc_provider_optional_interface(hass: HomeAssistant) -> None:
|
||||
await provider.async_handle_async_webrtc_offer(
|
||||
Mock(), "offer_sdp", "session_id", Mock()
|
||||
)
|
||||
await provider.async_on_webrtc_candidate("session_id", "candidate")
|
||||
await provider.async_on_webrtc_candidate("session_id", RTCIceCandidate("candidate"))
|
||||
provider.async_close_session("session_id")
|
||||
|
||||
|
||||
|
||||
@@ -431,7 +431,7 @@ async def test_shopping_list_add_item(hass: HomeAssistant) -> None:
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_components")
|
||||
async def test_nevermind_item(hass: HomeAssistant) -> None:
|
||||
async def test_nevermind_intent(hass: HomeAssistant) -> None:
|
||||
"""Test HassNevermind intent through the default agent."""
|
||||
result = await conversation.async_converse(hass, "nevermind", None, Context())
|
||||
assert result.response.intent is not None
|
||||
@@ -441,6 +441,17 @@ async def test_nevermind_item(hass: HomeAssistant) -> None:
|
||||
assert not result.response.speech
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_components")
|
||||
async def test_respond_intent(hass: HomeAssistant) -> None:
|
||||
"""Test HassRespond intent through the default agent."""
|
||||
result = await conversation.async_converse(hass, "hello", None, Context())
|
||||
assert result.response.intent is not None
|
||||
assert result.response.intent.intent_type == intent.INTENT_RESPOND
|
||||
|
||||
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
|
||||
assert result.response.speech["plain"]["speech"] == "Hello from Home Assistant."
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_components")
|
||||
async def test_device_area_context(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -18,9 +18,12 @@ def rest_client() -> Generator[AsyncMock]:
|
||||
patch(
|
||||
"homeassistant.components.go2rtc.Go2RtcRestClient",
|
||||
) as mock_client,
|
||||
patch("homeassistant.components.go2rtc.server.Go2RtcRestClient", mock_client),
|
||||
):
|
||||
client = mock_client.return_value
|
||||
client.streams = Mock(spec_set=_StreamClient)
|
||||
client.streams = streams = Mock(spec_set=_StreamClient)
|
||||
streams.list.return_value = {}
|
||||
client.validate_server_version = AsyncMock()
|
||||
client.webrtc = Mock(spec_set=_WebRTCClient)
|
||||
yield client
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
from aiohttp.client_exceptions import ClientConnectionError, ServerConnectionError
|
||||
from go2rtc_client import Stream
|
||||
from go2rtc_client.exceptions import Go2RtcClientError
|
||||
from go2rtc_client.exceptions import Go2RtcClientError, Go2RtcVersionError
|
||||
from go2rtc_client.models import Producer
|
||||
from go2rtc_client.ws import (
|
||||
ReceiveMessages,
|
||||
@@ -17,6 +17,7 @@ from go2rtc_client.ws import (
|
||||
WsError,
|
||||
)
|
||||
import pytest
|
||||
from webrtc_models import RTCIceCandidate
|
||||
|
||||
from homeassistant.components.camera import (
|
||||
DOMAIN as CAMERA_DOMAIN,
|
||||
@@ -236,7 +237,23 @@ async def _test_setup_and_signaling(
|
||||
|
||||
await test()
|
||||
|
||||
rest_client.streams.add.assert_called_once_with(entity_id, "rtsp://stream")
|
||||
rest_client.streams.add.assert_called_once_with(
|
||||
entity_id, ["rtsp://stream", f"ffmpeg:{camera.entity_id}#audio=opus"]
|
||||
)
|
||||
|
||||
# Stream exists but the source is different
|
||||
rest_client.streams.add.reset_mock()
|
||||
rest_client.streams.list.return_value = {
|
||||
entity_id: Stream([Producer("rtsp://different")])
|
||||
}
|
||||
|
||||
receive_message_callback.reset_mock()
|
||||
ws_client.reset_mock()
|
||||
await test()
|
||||
|
||||
rest_client.streams.add.assert_called_once_with(
|
||||
entity_id, ["rtsp://stream", f"ffmpeg:{camera.entity_id}#audio=opus"]
|
||||
)
|
||||
|
||||
# If the stream is already added, the stream should not be added again.
|
||||
rest_client.streams.add.reset_mock()
|
||||
@@ -379,7 +396,7 @@ async def message_callbacks(
|
||||
[
|
||||
(
|
||||
WebRTCCandidate("candidate"),
|
||||
HAWebRTCCandidate("candidate"),
|
||||
HAWebRTCCandidate(RTCIceCandidate("candidate")),
|
||||
),
|
||||
(
|
||||
WebRTCAnswer(ANSWER_SDP),
|
||||
@@ -415,7 +432,7 @@ async def test_on_candidate(
|
||||
session_id = "session_id"
|
||||
|
||||
# Session doesn't exist
|
||||
await camera.async_on_webrtc_candidate(session_id, "candidate")
|
||||
await camera.async_on_webrtc_candidate(session_id, RTCIceCandidate("candidate"))
|
||||
assert (
|
||||
"homeassistant.components.go2rtc",
|
||||
logging.DEBUG,
|
||||
@@ -435,7 +452,7 @@ async def test_on_candidate(
|
||||
)
|
||||
ws_client.reset_mock()
|
||||
|
||||
await camera.async_on_webrtc_candidate(session_id, "candidate")
|
||||
await camera.async_on_webrtc_candidate(session_id, RTCIceCandidate("candidate"))
|
||||
ws_client.send.assert_called_once_with(WebRTCCandidate("candidate"))
|
||||
assert caplog.record_tuples == []
|
||||
|
||||
@@ -481,6 +498,8 @@ ERR_CONNECT = "Could not connect to go2rtc instance"
|
||||
ERR_CONNECT_RETRY = (
|
||||
"Could not connect to go2rtc instance on http://localhost:1984/; Retrying"
|
||||
)
|
||||
ERR_START_SERVER = "Could not start go2rtc server"
|
||||
ERR_UNSUPPORTED_VERSION = "The go2rtc server version is not supported"
|
||||
_INVALID_CONFIG = "Invalid config for 'go2rtc': "
|
||||
ERR_INVALID_URL = _INVALID_CONFIG + "invalid url"
|
||||
ERR_EXCLUSIVE = _INVALID_CONFIG + DEBUG_UI_URL_MESSAGE
|
||||
@@ -513,8 +532,10 @@ async def test_non_user_setup_with_error(
|
||||
("config", "go2rtc_binary", "is_docker_env", "expected_log_message"),
|
||||
[
|
||||
({DEFAULT_CONFIG_DOMAIN: {}}, None, True, ERR_BINARY_NOT_FOUND),
|
||||
({DEFAULT_CONFIG_DOMAIN: {}}, "/usr/bin/go2rtc", True, ERR_START_SERVER),
|
||||
({DOMAIN: {}}, None, False, ERR_URL_REQUIRED),
|
||||
({DOMAIN: {}}, None, True, ERR_BINARY_NOT_FOUND),
|
||||
({DOMAIN: {}}, "/usr/bin/go2rtc", True, ERR_START_SERVER),
|
||||
({DOMAIN: {CONF_URL: "invalid"}}, None, True, ERR_INVALID_URL),
|
||||
(
|
||||
{DOMAIN: {CONF_URL: "http://localhost:1984", CONF_DEBUG_UI: True}},
|
||||
@@ -546,8 +567,6 @@ async def test_setup_with_setup_error(
|
||||
@pytest.mark.parametrize(
|
||||
("config", "go2rtc_binary", "is_docker_env", "expected_log_message"),
|
||||
[
|
||||
({DEFAULT_CONFIG_DOMAIN: {}}, "/usr/bin/go2rtc", True, ERR_CONNECT),
|
||||
({DOMAIN: {}}, "/usr/bin/go2rtc", True, ERR_CONNECT),
|
||||
({DOMAIN: {CONF_URL: "http://localhost:1984/"}}, None, True, ERR_CONNECT),
|
||||
],
|
||||
)
|
||||
@@ -571,7 +590,7 @@ async def test_setup_with_setup_entry_error(
|
||||
assert expected_log_message in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("config", [{DOMAIN: {}}, {DEFAULT_CONFIG_DOMAIN: {}}])
|
||||
@pytest.mark.parametrize("config", [{DOMAIN: {CONF_URL: "http://localhost:1984/"}}])
|
||||
@pytest.mark.parametrize(
|
||||
("cause", "expected_config_entry_state", "expected_log_message"),
|
||||
[
|
||||
@@ -585,7 +604,7 @@ async def test_setup_with_setup_entry_error(
|
||||
@pytest.mark.usefixtures(
|
||||
"mock_get_binary", "mock_go2rtc_entry", "mock_is_docker_env", "server"
|
||||
)
|
||||
async def test_setup_with_retryable_setup_entry_error(
|
||||
async def test_setup_with_retryable_setup_entry_error_custom_server(
|
||||
hass: HomeAssistant,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
rest_client: AsyncMock,
|
||||
@@ -597,7 +616,78 @@ async def test_setup_with_retryable_setup_entry_error(
|
||||
"""Test setup integration entry fails."""
|
||||
go2rtc_error = Go2RtcClientError()
|
||||
go2rtc_error.__cause__ = cause
|
||||
rest_client.streams.list.side_effect = go2rtc_error
|
||||
rest_client.validate_server_version.side_effect = go2rtc_error
|
||||
assert await async_setup_component(hass, DOMAIN, config)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
config_entries = hass.config_entries.async_entries(DOMAIN)
|
||||
assert len(config_entries) == 1
|
||||
assert config_entries[0].state == expected_config_entry_state
|
||||
assert expected_log_message in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("config", [{DOMAIN: {}}, {DEFAULT_CONFIG_DOMAIN: {}}])
|
||||
@pytest.mark.parametrize(
|
||||
("cause", "expected_config_entry_state", "expected_log_message"),
|
||||
[
|
||||
(ClientConnectionError(), ConfigEntryState.NOT_LOADED, ERR_START_SERVER),
|
||||
(ServerConnectionError(), ConfigEntryState.NOT_LOADED, ERR_START_SERVER),
|
||||
(None, ConfigEntryState.NOT_LOADED, ERR_START_SERVER),
|
||||
(Exception(), ConfigEntryState.NOT_LOADED, ERR_START_SERVER),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("has_go2rtc_entry", [True, False])
|
||||
@pytest.mark.usefixtures(
|
||||
"mock_get_binary", "mock_go2rtc_entry", "mock_is_docker_env", "server"
|
||||
)
|
||||
async def test_setup_with_retryable_setup_entry_error_default_server(
|
||||
hass: HomeAssistant,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
rest_client: AsyncMock,
|
||||
has_go2rtc_entry: bool,
|
||||
config: ConfigType,
|
||||
cause: Exception,
|
||||
expected_config_entry_state: ConfigEntryState,
|
||||
expected_log_message: str,
|
||||
) -> None:
|
||||
"""Test setup integration entry fails."""
|
||||
go2rtc_error = Go2RtcClientError()
|
||||
go2rtc_error.__cause__ = cause
|
||||
rest_client.validate_server_version.side_effect = go2rtc_error
|
||||
assert not await async_setup_component(hass, DOMAIN, config)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
config_entries = hass.config_entries.async_entries(DOMAIN)
|
||||
assert len(config_entries) == has_go2rtc_entry
|
||||
for config_entry in config_entries:
|
||||
assert config_entry.state == expected_config_entry_state
|
||||
assert expected_log_message in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("config", [{DOMAIN: {}}, {DEFAULT_CONFIG_DOMAIN: {}}])
|
||||
@pytest.mark.parametrize(
|
||||
("go2rtc_error", "expected_config_entry_state", "expected_log_message"),
|
||||
[
|
||||
(
|
||||
Go2RtcVersionError("1.9.4", "1.9.5", "2.0.0"),
|
||||
ConfigEntryState.SETUP_RETRY,
|
||||
ERR_UNSUPPORTED_VERSION,
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("has_go2rtc_entry", [True, False])
|
||||
@pytest.mark.usefixtures(
|
||||
"mock_get_binary", "mock_go2rtc_entry", "mock_is_docker_env", "server"
|
||||
)
|
||||
async def test_setup_with_version_error(
|
||||
hass: HomeAssistant,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
rest_client: AsyncMock,
|
||||
config: ConfigType,
|
||||
go2rtc_error: Exception,
|
||||
expected_config_entry_state: ConfigEntryState,
|
||||
expected_log_message: str,
|
||||
) -> None:
|
||||
"""Test setup integration entry fails."""
|
||||
rest_client.validate_server_version.side_effect = [None, go2rtc_error]
|
||||
assert await async_setup_component(hass, DOMAIN, config)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
config_entries = hass.config_entries.async_entries(DOMAIN)
|
||||
|
||||
@@ -38,6 +38,42 @@ def mock_tempfile() -> Generator[Mock]:
|
||||
yield file
|
||||
|
||||
|
||||
def _assert_server_output_logged(
|
||||
server_stdout: list[str],
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
loglevel: int,
|
||||
expect_logged: bool,
|
||||
) -> None:
|
||||
"""Check server stdout was logged."""
|
||||
for entry in server_stdout:
|
||||
assert (
|
||||
(
|
||||
"homeassistant.components.go2rtc.server",
|
||||
loglevel,
|
||||
entry,
|
||||
)
|
||||
in caplog.record_tuples
|
||||
) is expect_logged
|
||||
|
||||
|
||||
def assert_server_output_logged(
|
||||
server_stdout: list[str],
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
loglevel: int,
|
||||
) -> None:
|
||||
"""Check server stdout was logged."""
|
||||
_assert_server_output_logged(server_stdout, caplog, loglevel, True)
|
||||
|
||||
|
||||
def assert_server_output_not_logged(
|
||||
server_stdout: list[str],
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
loglevel: int,
|
||||
) -> None:
|
||||
"""Check server stdout was logged."""
|
||||
_assert_server_output_logged(server_stdout, caplog, loglevel, False)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("enable_ui", "api_ip"),
|
||||
[
|
||||
@@ -47,6 +83,7 @@ def mock_tempfile() -> Generator[Mock]:
|
||||
)
|
||||
async def test_server_run_success(
|
||||
mock_create_subprocess: AsyncMock,
|
||||
rest_client: AsyncMock,
|
||||
server_stdout: list[str],
|
||||
server: Server,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
@@ -70,32 +107,31 @@ async def test_server_run_success(
|
||||
mock_tempfile.write.assert_called_once_with(
|
||||
f"""
|
||||
api:
|
||||
listen: "{api_ip}:1984"
|
||||
listen: "{api_ip}:11984"
|
||||
|
||||
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: []
|
||||
""".encode()
|
||||
)
|
||||
|
||||
# Check that server read the log lines
|
||||
for entry in server_stdout:
|
||||
assert (
|
||||
"homeassistant.components.go2rtc.server",
|
||||
logging.DEBUG,
|
||||
entry,
|
||||
) in caplog.record_tuples
|
||||
# Verify go2rtc binary stdout was logged with debug level
|
||||
assert_server_output_logged(server_stdout, caplog, logging.DEBUG)
|
||||
|
||||
await server.stop()
|
||||
mock_create_subprocess.return_value.terminate.assert_called_once()
|
||||
|
||||
# Verify go2rtc binary stdout was not logged with warning level
|
||||
assert_server_output_not_logged(server_stdout, caplog, logging.WARNING)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_tempfile")
|
||||
async def test_server_timeout_on_stop(
|
||||
mock_create_subprocess: MagicMock, server: Server
|
||||
mock_create_subprocess: MagicMock, rest_client: AsyncMock, server: Server
|
||||
) -> None:
|
||||
"""Test server run where the process takes too long to terminate."""
|
||||
# Start server thread
|
||||
@@ -138,13 +174,9 @@ async def test_server_failed_to_start(
|
||||
):
|
||||
await server.start()
|
||||
|
||||
# Verify go2rtc binary stdout was logged
|
||||
for entry in server_stdout:
|
||||
assert (
|
||||
"homeassistant.components.go2rtc.server",
|
||||
logging.DEBUG,
|
||||
entry,
|
||||
) in caplog.record_tuples
|
||||
# Verify go2rtc binary stdout was logged with debug and warning level
|
||||
assert_server_output_logged(server_stdout, caplog, logging.DEBUG)
|
||||
assert_server_output_logged(server_stdout, caplog, logging.WARNING)
|
||||
|
||||
assert (
|
||||
"homeassistant.components.go2rtc.server",
|
||||
@@ -161,3 +193,200 @@ async def test_server_failed_to_start(
|
||||
stderr=subprocess.STDOUT,
|
||||
close_fds=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("server_stdout", "expected_loglevel"),
|
||||
[
|
||||
(
|
||||
[
|
||||
"09:00:03.466 TRC [api] register path path=/",
|
||||
"09:00:03.466 DBG build vcs.time=2024-10-28T19:47:55Z version=go1.23.2",
|
||||
"09:00:03.466 INF go2rtc platform=linux/amd64 revision=780f378 version=1.9.5",
|
||||
"09:00:03.467 INF [api] listen addr=127.0.0.1:1984",
|
||||
"09:00:03.466 WRN warning message",
|
||||
'09:00:03.466 ERR [api] listen error="listen tcp 127.0.0.1:11984: bind: address already in use"',
|
||||
"09:00:03.466 FTL fatal message",
|
||||
"09:00:03.466 PNC panic message",
|
||||
"exit with signal: interrupt", # Example of stderr write
|
||||
],
|
||||
[
|
||||
logging.DEBUG,
|
||||
logging.DEBUG,
|
||||
logging.DEBUG,
|
||||
logging.DEBUG,
|
||||
logging.WARNING,
|
||||
logging.WARNING,
|
||||
logging.ERROR,
|
||||
logging.ERROR,
|
||||
logging.WARNING,
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
@patch("homeassistant.components.go2rtc.server._RESPAWN_COOLDOWN", 0)
|
||||
async def test_log_level_mapping(
|
||||
hass: HomeAssistant,
|
||||
mock_create_subprocess: MagicMock,
|
||||
server_stdout: list[str],
|
||||
rest_client: AsyncMock,
|
||||
server: Server,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
expected_loglevel: list[int],
|
||||
) -> None:
|
||||
"""Log level mapping."""
|
||||
evt = asyncio.Event()
|
||||
|
||||
async def wait_event() -> None:
|
||||
await evt.wait()
|
||||
|
||||
mock_create_subprocess.return_value.wait.side_effect = wait_event
|
||||
|
||||
await server.start()
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Verify go2rtc binary stdout was logged with default level
|
||||
for i, entry in enumerate(server_stdout):
|
||||
assert (
|
||||
"homeassistant.components.go2rtc.server",
|
||||
expected_loglevel[i],
|
||||
entry,
|
||||
) in caplog.record_tuples
|
||||
|
||||
evt.set()
|
||||
await asyncio.sleep(0.1)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert_server_output_logged(server_stdout, caplog, logging.WARNING)
|
||||
|
||||
await server.stop()
|
||||
|
||||
|
||||
@patch("homeassistant.components.go2rtc.server._RESPAWN_COOLDOWN", 0)
|
||||
async def test_server_restart_process_exit(
|
||||
hass: HomeAssistant,
|
||||
mock_create_subprocess: AsyncMock,
|
||||
server_stdout: list[str],
|
||||
rest_client: AsyncMock,
|
||||
server: Server,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test that the server is restarted when it exits."""
|
||||
evt = asyncio.Event()
|
||||
|
||||
async def wait_event() -> None:
|
||||
await evt.wait()
|
||||
|
||||
mock_create_subprocess.return_value.wait.side_effect = wait_event
|
||||
|
||||
await server.start()
|
||||
mock_create_subprocess.assert_awaited_once()
|
||||
mock_create_subprocess.reset_mock()
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
await hass.async_block_till_done()
|
||||
mock_create_subprocess.assert_not_awaited()
|
||||
|
||||
# Verify go2rtc binary stdout was not yet logged with warning level
|
||||
assert_server_output_not_logged(server_stdout, caplog, logging.WARNING)
|
||||
|
||||
evt.set()
|
||||
await asyncio.sleep(0.1)
|
||||
mock_create_subprocess.assert_awaited_once()
|
||||
|
||||
# Verify go2rtc binary stdout was logged with warning level
|
||||
assert_server_output_logged(server_stdout, caplog, logging.WARNING)
|
||||
|
||||
await server.stop()
|
||||
|
||||
|
||||
@patch("homeassistant.components.go2rtc.server._RESPAWN_COOLDOWN", 0)
|
||||
async def test_server_restart_process_error(
|
||||
hass: HomeAssistant,
|
||||
mock_create_subprocess: AsyncMock,
|
||||
server_stdout: list[str],
|
||||
rest_client: AsyncMock,
|
||||
server: Server,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test that the server is restarted on error."""
|
||||
mock_create_subprocess.return_value.wait.side_effect = [Exception, None, None, None]
|
||||
|
||||
await server.start()
|
||||
mock_create_subprocess.assert_awaited_once()
|
||||
mock_create_subprocess.reset_mock()
|
||||
|
||||
# Verify go2rtc binary stdout was not yet logged with warning level
|
||||
assert_server_output_not_logged(server_stdout, caplog, logging.WARNING)
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
await hass.async_block_till_done()
|
||||
mock_create_subprocess.assert_awaited_once()
|
||||
|
||||
# Verify go2rtc binary stdout was logged with warning level
|
||||
assert_server_output_logged(server_stdout, caplog, logging.WARNING)
|
||||
|
||||
await server.stop()
|
||||
|
||||
|
||||
@patch("homeassistant.components.go2rtc.server._RESPAWN_COOLDOWN", 0)
|
||||
async def test_server_restart_api_error(
|
||||
hass: HomeAssistant,
|
||||
mock_create_subprocess: AsyncMock,
|
||||
server_stdout: list[str],
|
||||
rest_client: AsyncMock,
|
||||
server: Server,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test that the server is restarted on error."""
|
||||
rest_client.streams.list.side_effect = Exception
|
||||
|
||||
await server.start()
|
||||
mock_create_subprocess.assert_awaited_once()
|
||||
mock_create_subprocess.reset_mock()
|
||||
|
||||
# Verify go2rtc binary stdout was not yet logged with warning level
|
||||
assert_server_output_not_logged(server_stdout, caplog, logging.WARNING)
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
await hass.async_block_till_done()
|
||||
mock_create_subprocess.assert_awaited_once()
|
||||
|
||||
# Verify go2rtc binary stdout was logged with warning level
|
||||
assert_server_output_logged(server_stdout, caplog, logging.WARNING)
|
||||
|
||||
await server.stop()
|
||||
|
||||
|
||||
@patch("homeassistant.components.go2rtc.server._RESPAWN_COOLDOWN", 0)
|
||||
async def test_server_restart_error(
|
||||
hass: HomeAssistant,
|
||||
mock_create_subprocess: AsyncMock,
|
||||
server_stdout: list[str],
|
||||
rest_client: AsyncMock,
|
||||
server: Server,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test error handling when exception is raised during restart."""
|
||||
rest_client.streams.list.side_effect = Exception
|
||||
mock_create_subprocess.return_value.terminate.side_effect = [Exception, None]
|
||||
|
||||
await server.start()
|
||||
mock_create_subprocess.assert_awaited_once()
|
||||
mock_create_subprocess.reset_mock()
|
||||
|
||||
# Verify go2rtc binary stdout was not yet logged with warning level
|
||||
assert_server_output_not_logged(server_stdout, caplog, logging.WARNING)
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
await hass.async_block_till_done()
|
||||
mock_create_subprocess.assert_awaited_once()
|
||||
|
||||
# Verify go2rtc binary stdout was logged with warning level
|
||||
assert_server_output_logged(server_stdout, caplog, logging.WARNING)
|
||||
|
||||
assert "Unexpected error when restarting go2rtc server" in caplog.text
|
||||
|
||||
await server.stop()
|
||||
|
||||
@@ -207,7 +207,7 @@ async def test_button_press(
|
||||
[
|
||||
(
|
||||
HTTPStatus.TOO_MANY_REQUESTS,
|
||||
"Currently rate limited",
|
||||
"Rate limit exceeded, try again later",
|
||||
ServiceValidationError,
|
||||
),
|
||||
(
|
||||
@@ -217,7 +217,7 @@ async def test_button_press(
|
||||
),
|
||||
(
|
||||
HTTPStatus.UNAUTHORIZED,
|
||||
"Unable to carry out this action",
|
||||
"Unable to complete action, the required conditions are not met",
|
||||
ServiceValidationError,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -165,4 +165,4 @@ async def test_coordinator_rate_limited(
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert "Currently rate limited, skipping update" in caplog.text
|
||||
assert "Rate limit exceeded, will try again later" in caplog.text
|
||||
|
||||
@@ -91,7 +91,7 @@ async def test_hassio_discovery_startup(
|
||||
},
|
||||
name="Mosquitto Test",
|
||||
slug="mosquitto",
|
||||
uuid=str(uuid),
|
||||
uuid=uuid.hex,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -153,7 +153,7 @@ async def test_hassio_discovery_startup_done(
|
||||
},
|
||||
name="Mosquitto Test",
|
||||
slug="mosquitto",
|
||||
uuid=str(uuid),
|
||||
uuid=uuid.hex,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -203,7 +203,7 @@ async def test_hassio_discovery_webhook(
|
||||
},
|
||||
name="Mosquitto Test",
|
||||
slug="mosquitto",
|
||||
uuid=str(uuid),
|
||||
uuid=uuid.hex,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -286,7 +286,7 @@ async def test_hassio_rediscover(
|
||||
)
|
||||
|
||||
expected_context = {
|
||||
"discovery_key": DiscoveryKey(domain="hassio", key=str(uuid), version=1),
|
||||
"discovery_key": DiscoveryKey(domain="hassio", key=uuid.hex, version=1),
|
||||
"source": config_entries.SOURCE_HASSIO,
|
||||
}
|
||||
|
||||
|
||||
@@ -868,3 +868,104 @@ async def test_supervisor_issue_detached_addon_removed(
|
||||
str(aioclient_mock.mock_calls[-1][1])
|
||||
== "http://127.0.0.1/resolution/suggestion/1235"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"all_setup_requests", [{"include_addons": True}], indirect=True
|
||||
)
|
||||
@pytest.mark.usefixtures("all_setup_requests")
|
||||
async def test_supervisor_issue_addon_boot_fail(
|
||||
hass: HomeAssistant,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
hass_client: ClientSessionGenerator,
|
||||
issue_registry: ir.IssueRegistry,
|
||||
) -> None:
|
||||
"""Test fix flow for supervisor issue."""
|
||||
mock_resolution_info(
|
||||
aioclient_mock,
|
||||
issues=[
|
||||
{
|
||||
"uuid": "1234",
|
||||
"type": "boot_fail",
|
||||
"context": "addon",
|
||||
"reference": "test",
|
||||
"suggestions": [
|
||||
{
|
||||
"uuid": "1235",
|
||||
"type": "execute_start",
|
||||
"context": "addon",
|
||||
"reference": "test",
|
||||
},
|
||||
{
|
||||
"uuid": "1236",
|
||||
"type": "disable_boot",
|
||||
"context": "addon",
|
||||
"reference": "test",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
assert await async_setup_component(hass, "hassio", {})
|
||||
|
||||
repair_issue = issue_registry.async_get_issue(domain="hassio", issue_id="1234")
|
||||
assert repair_issue
|
||||
|
||||
client = await hass_client()
|
||||
|
||||
resp = await client.post(
|
||||
"/api/repairs/issues/fix",
|
||||
json={"handler": "hassio", "issue_id": repair_issue.issue_id},
|
||||
)
|
||||
|
||||
assert resp.status == HTTPStatus.OK
|
||||
data = await resp.json()
|
||||
|
||||
flow_id = data["flow_id"]
|
||||
assert data == {
|
||||
"type": "menu",
|
||||
"flow_id": flow_id,
|
||||
"handler": "hassio",
|
||||
"step_id": "fix_menu",
|
||||
"data_schema": [
|
||||
{
|
||||
"type": "select",
|
||||
"options": [
|
||||
["addon_execute_start", "addon_execute_start"],
|
||||
["addon_disable_boot", "addon_disable_boot"],
|
||||
],
|
||||
"name": "next_step_id",
|
||||
}
|
||||
],
|
||||
"menu_options": ["addon_execute_start", "addon_disable_boot"],
|
||||
"description_placeholders": {
|
||||
"reference": "test",
|
||||
"addon": "test",
|
||||
},
|
||||
}
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/repairs/issues/fix/{flow_id}",
|
||||
json={"next_step_id": "addon_execute_start"},
|
||||
)
|
||||
|
||||
assert resp.status == HTTPStatus.OK
|
||||
data = await resp.json()
|
||||
|
||||
flow_id = data["flow_id"]
|
||||
assert data == {
|
||||
"type": "create_entry",
|
||||
"flow_id": flow_id,
|
||||
"handler": "hassio",
|
||||
"description": None,
|
||||
"description_placeholders": None,
|
||||
}
|
||||
|
||||
assert not issue_registry.async_get_issue(domain="hassio", issue_id="1234")
|
||||
|
||||
assert aioclient_mock.mock_calls[-1][0] == "post"
|
||||
assert (
|
||||
str(aioclient_mock.mock_calls[-1][1])
|
||||
== "http://127.0.0.1/resolution/suggestion/1235"
|
||||
)
|
||||
|
||||
@@ -152,6 +152,7 @@ async def test_create_issue(
|
||||
"""Test we create an issue when an automation or script is using a deprecated entity."""
|
||||
entity_id = "binary_sensor.washer_door"
|
||||
get_appliances.return_value = [appliance]
|
||||
issue_id = f"deprecated_binary_common_door_sensor_{entity_id}"
|
||||
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
@@ -196,6 +197,11 @@ async def test_create_issue(
|
||||
assert scripts_with_entity(hass, entity_id)[0] == "script.test"
|
||||
|
||||
assert len(issue_registry.issues) == 1
|
||||
assert issue_registry.async_get_issue(
|
||||
DOMAIN, f"deprecated_binary_common_door_sensor_{entity_id}"
|
||||
)
|
||||
assert issue_registry.async_get_issue(DOMAIN, issue_id)
|
||||
|
||||
await hass.config_entries.async_unload(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Assert the issue is no longer present
|
||||
assert not issue_registry.async_get_issue(DOMAIN, issue_id)
|
||||
assert len(issue_registry.issues) == 0
|
||||
|
||||
@@ -161,7 +161,9 @@ async def test_number_entity_error(
|
||||
with pytest.raises(HomeConnectError):
|
||||
getattr(problematic_appliance, mock_attr)()
|
||||
|
||||
with pytest.raises(ServiceValidationError, match=r"Error.*set.*setting.*"):
|
||||
with pytest.raises(
|
||||
ServiceValidationError, match=r"Error.*assign.*value.*to.*setting.*"
|
||||
):
|
||||
await hass.services.async_call(
|
||||
NUMBER_DOMAIN,
|
||||
SERVICE_SET_VALUE,
|
||||
|
||||
@@ -135,7 +135,9 @@ async def test_time_entity_error(
|
||||
with pytest.raises(HomeConnectError):
|
||||
getattr(problematic_appliance, mock_attr)()
|
||||
|
||||
with pytest.raises(ServiceValidationError, match=r"Error.*set.*setting.*"):
|
||||
with pytest.raises(
|
||||
ServiceValidationError, match=r"Error.*assign.*value.*to.*setting.*"
|
||||
):
|
||||
await hass.services.async_call(
|
||||
TIME_DOMAIN,
|
||||
SERVICE_SET_VALUE,
|
||||
|
||||
@@ -235,10 +235,6 @@ async def test_user_flow_cannot_connect(
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
|
||||
@pytest.mark.parametrize( # Remove when translations fixed
|
||||
"ignore_translations",
|
||||
["component.homeworks.config.abort.reconfigure_successful"],
|
||||
)
|
||||
async def test_reconfigure_flow(
|
||||
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_homeworks: MagicMock
|
||||
) -> None:
|
||||
@@ -326,10 +322,6 @@ async def test_reconfigure_flow_flow_duplicate(
|
||||
assert result["errors"] == {"base": "duplicated_host_port"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize( # Remove when translations fixed
|
||||
"ignore_translations",
|
||||
["component.homeworks.config.abort.reconfigure_successful"],
|
||||
)
|
||||
async def test_reconfigure_flow_flow_no_change(
|
||||
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_homeworks: MagicMock
|
||||
) -> None:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user