Compare commits

...

79 Commits

Author SHA1 Message Date
Franck Nijhof 18e12740d9 2024.11.0 (#129970) 2024-11-06 20:10:51 +01:00
Franck Nijhof 5a24b670a2 Ran ruff 2024-11-06 19:32:23 +01:00
Franck Nijhof 94c5c8f42e Bump version to 2024.11.0 2024-11-06 19:29:07 +01:00
Manu e84d5fba11 Add state invitation to list access sensor in Bring integration (#129960) 2024-11-06 19:28:54 +01:00
Franck Nijhof 782417528c Bump version to 2024.11.0b9 2024-11-06 18:25:29 +01:00
Robert Resch 7757423d18 Bump go2rtc-client to 0.1.0 (#129965) 2024-11-06 18:24:12 +01:00
Joost Lekkerkerker e5a28f4f25 Remove deprecation issues for LCN once entities removed (#129955) 2024-11-06 18:21:32 +01:00
Erik Montnemery c18d50910f Call async_refresh_providers when camera entity feature changes (#129941) 2024-11-06 18:21:28 +01:00
Franck Nijhof 3b840c684b Bump version to 2024.11.0b8 2024-11-06 15:44:10 +01:00
Bram Kragten bc84fdc64a Update frontend to 20241106.0 (#129953) 2024-11-06 15:43:33 +01:00
Robert Resch 401262c23d Bump go2rtc-client to 0.0.1b5 (#129952) 2024-11-06 15:42:22 +01:00
Manu 795384ca2d Improve error messages in Habitica (#129948)
Improve error messages
2024-11-06 15:41:44 +01:00
J. Diego Rodríguez Royo dfc3423c83 Delete binary door deprecation issue on unload at Home Connect (#129947) 2024-11-06 15:41:39 +01:00
Robert Resch 22b5071c26 Bump go2rtc-client to 0.0.1b4 (#129942) 2024-11-06 15:40:30 +01:00
Joost Lekkerkerker 4b9524c5c1 Write squeezebox player state after query (#129939) 2024-11-06 15:39:07 +01:00
Joost Lekkerkerker 9cd46c7f03 Bump spotifyaio to 0.8.5 (#129938) 2024-11-06 15:39:03 +01:00
Robert Resch 232a6868ff Fix native sync WebRTC offer (#129931) 2024-11-06 15:39:00 +01:00
Kunal Aggarwal 361e0d4fc7 Adding "peaceful" status as on value to Tuya Presence Sensor (#129925) 2024-11-06 15:38:57 +01:00
Paulus Schoutsen 26d8d5343a Ensure all template names are strings (#129921) 2024-11-06 15:38:53 +01:00
starkillerOG 995aab8347 Bump reolink_aio to 0.10.4 (#129914) 2024-11-06 15:38:50 +01:00
Robert Resch 399011552b Disable uv cache (#129912) 2024-11-06 15:38:46 +01:00
Markus Jacobsen 0c9f30364c Update Bang & Olufsen source list as availability changes (#129910) 2024-11-06 15:38:43 +01:00
Louis Christ bdc17621ee Map "stop" to MediaPlayerState.IDLE in bluesound integration (#129904)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2024-11-06 15:38:40 +01:00
Joost Lekkerkerker 399c53a57e Bump spotifyaio to 0.8.4 (#129899) 2024-11-06 15:38:36 +01:00
Daniel Hjelseth Høyer f55e13bde4 Bump pyTibber to 0.30.4 (#129844) 2024-11-06 15:38:32 +01:00
Michael Hansen 48d9df89ac Bump intents and add HassRespond test (#129830) 2024-11-06 15:36:46 +01:00
kingal123 adf836d9ac Update pylutron to 0.2.16 (#129653)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2024-11-06 15:33:16 +01:00
Franck Nijhof 211ce43127 Bump version to 2024.11.0b7 2024-11-05 20:33:48 +01:00
G Johansson f5555df990 Bump holidays to 0.60 (#129909) 2024-11-05 20:33:39 +01:00
Paul Bottein 82c2422990 Update frontend to 20241105.0 (#129906) 2024-11-05 20:33:36 +01:00
Erik Montnemery 734ebc1adb Improve improv BLE error handling (#129902) 2024-11-05 20:33:33 +01:00
Paulus Schoutsen eb3371beef Change Ollama default to llama3.2 (#129901) 2024-11-05 20:33:30 +01:00
Manu e1ef1063fe Prevent update entity becoming unavailable on device disconnect in IronOS (#129840)
* Don't render update entity unavailable when Pinecil device disconnects

* fixes
2024-11-05 20:33:27 +01:00
Diogo Gomes c355a53485 Set friendly name of utility meter select entity when configured through YAML (#128267)
* set select friendly name in YAML

* backward compatibility added

* clean

* cleaner backward compatibility approach

* don't introduce default unique_id

* split test according to review
2024-11-05 20:33:23 +01:00
Franck Nijhof c85eb6bf8e Bump version to 2024.11.0b6 2024-11-05 16:51:05 +01:00
Joost Lekkerkerker cc30d34e87 Remove timers from LG ThinQ (#129898) 2024-11-05 16:50:41 +01:00
Erik Montnemery 14875a1101 Map go2rtc log levels to Python log levels (#129894) 2024-11-05 16:50:38 +01:00
Joost Lekkerkerker 030aebb97f Use default package for yt-dlp (#129886) 2024-11-05 16:50:35 +01:00
Erik Montnemery 6e2f36b6d4 Log go2rtc output with warning level on error (#129882) 2024-11-05 16:50:32 +01:00
Robert Resch 25a05eb156 Append a 1 to all go2rtc ports to avoid port conflicts (#129881) 2024-11-05 16:50:29 +01:00
J. Diego Rodríguez Royo b71c4377f6 Removed stale translation and improved set_setting translation at Home Connect (#129878) 2024-11-05 16:50:25 +01:00
Michael Arthur d671341864 Update snapshot for lg thinq (#129856)
update snapshot for lg thinq

Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2024-11-05 16:39:02 +01:00
Mike Degatano 383f712d43 Add repair for add-on boot fail (#129847) 2024-11-05 16:38:59 +01:00
Alex Bush 8a20cd77a0 Bump pyfibaro to 0.8.0 (#129846) 2024-11-05 16:38:56 +01:00
Richard Kroegel 14023644ef Bump bimmer_connected to 0.16.4 (#129838) 2024-11-05 16:38:53 +01:00
dotvav 496fc42b94 Bump pypalazzetti to 0.1.10 (#129832) 2024-11-05 16:38:50 +01:00
Erik Montnemery da0688ce8e Validate go2rtc server version (#129810) 2024-11-05 16:38:47 +01:00
Robert Resch 89d3707cb7 Skip adding providers if the camera has native WebRTC (#129808)
* Skip adding providers if the camera has native WebRTC

* Update homeassistant/components/camera/__init__.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Implement suggestion

* Add tests

* Shorten test name

* Fix test

---------

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2024-11-05 16:38:44 +01:00
Kunal Aggarwal 3f5e395e2f Adding new on values for Tuya Presence Detection Sensor (#129801) 2024-11-05 16:38:41 +01:00
Joost Lekkerkerker 00ea1cab9f Add basic testing framework to LG ThinQ (#127785)
Co-authored-by: jangwon.lee <jangwon.lee@lge.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
Co-authored-by: YunseonPark-LGE <34848373+YunseonPark-LGE@users.noreply.github.com>
Co-authored-by: LG-ThinQ-Integration <LG-ThinQ-Integration@lge.com>
Co-authored-by: Franck Nijhof <git@frenck.dev>
2024-11-05 16:38:37 +01:00
Paulus Schoutsen c7b2ffbc8e Bump version to 2024.11.0b5 2024-11-05 03:00:18 +00:00
J. Nick Koston 3a1502e2bb Disable SRTP for unifiprotect RTSPS stream (#129852) 2024-11-05 02:59:23 +00:00
J. Nick Koston b830f83a34 Bump uiprotect to 6.4.0 (#129851) 2024-11-05 02:59:23 +00:00
J. Nick Koston 2982e733bc Fix unifiprotect supported features being set too late (#129850) 2024-11-05 02:59:22 +00:00
starkillerOG e89ce215c6 Bump reolink-aio to 0.10.3 (#129841) 2024-11-05 02:59:21 +00:00
G Johansson b6345f8d07 Fix translations in hydrawise (#129834) 2024-11-05 02:59:20 +00:00
G Johansson 9d261bab48 Fix translation in ovo energy (#129833) 2024-11-05 02:59:19 +00:00
Michael Hansen b6f875134e Add HassRespond intent (#129755)
* Add HassHello intent

* Rename to HassRespond

* LLM's ignore HassRespond intent
2024-11-05 02:59:18 +00:00
Artur Pragacz 90ceebdf91 Fix source mapping in Onkyo (#129716)
* Fix source mapping

* Fix copy paste
2024-11-05 02:59:18 +00:00
Paulus Schoutsen 03e6a13896 Bump version to 2024.11.0b4 2024-11-04 18:48:58 +00:00
G Johansson 9fb3261f02 Fix translations in landisgyr (#129831) 2024-11-04 18:48:37 +00:00
Bram Kragten 0bc6b8b0d4 Update frontend to 20241104.0 (#129829) 2024-11-04 18:48:36 +00:00
G Johansson 18d2ced045 Fix translations in homeworks (#129824) 2024-11-04 18:48:35 +00:00
Robert Resch 6c75e0bee1 Remove all ice_servers on native sync WebRTC cameras (#129819) 2024-11-04 18:48:35 +00:00
Steven B. 0b981f42bb Bump python-kasa to 0.7.7 (#129817)
Bump tplink dependency python-kasa to 0.7.7
2024-11-04 18:48:34 +00:00
Paulus Schoutsen 82868a8588 Fix ESPHome dashboard check (#129812) 2024-11-04 18:48:33 +00:00
Erik Montnemery 6e93777f54 Fix create flow logic for single config entry integrations (#129807)
* Fix create flow logic for single config entry integrations

* Adjust MQTT test
2024-11-04 18:47:41 +00:00
Erik Montnemery 9349292464 Fix aborting flows for single config entry integrations (#129805) 2024-11-04 18:43:56 +00:00
Robert Resch 7084b3b52c Update go2rtc stream if stream_source is not matching (#129804) 2024-11-04 18:43:55 +00:00
epenet 0f0f5fd0ab Fix incorrect description placeholders in azure event hub (#129803) 2024-11-04 18:43:54 +00:00
Joost Lekkerkerker cb0b942db3 Improve error handling in Spotify (#129799) 2024-11-04 18:43:53 +00:00
Erik Montnemery b1c9f83952 Fix stringification of discovered hassio uuid (#129797) 2024-11-04 18:43:52 +00:00
Joost Lekkerkerker 1ff0efc97b Bump yt-dlp to 2024.11.04 (#129794) 2024-11-04 18:43:51 +00:00
Robert Resch a4da2a9eb5 Use RTCIceCandidate instead of str for candidate (#129793) 2024-11-04 18:43:51 +00:00
Antoine Reversat ba3cfb5f87 Bump ayla-iot-unofficial to 1.4.3 (#129743)
Upgrade to ayla-iot-unofficial v1.4.3
2024-11-04 18:43:50 +00:00
Luca Angemi bf196935f6 Add state class to precipitation_intensity in Aemet (#129670)
Update sensor.py
2024-11-04 18:43:49 +00:00
Joost Lekkerkerker 6e98343706 Update Spotify state after mutation (#129607) 2024-11-04 18:43:48 +00:00
Erik Montnemery de453ab5c1 Add watchdog to monitor and respawn go2rtc server (#129497) 2024-11-04 18:43:47 +00:00
Andre Lengwenus f408de4fc3 Bump lcn-frontend to 0.2.1 (#129457) 2024-11-04 18:43:47 +00:00
129 changed files with 2786 additions and 636 deletions
+2 -1
View File
@@ -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
+2
View File
@@ -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,
)
+19 -17
View File
@@ -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"
}
}
}
+2 -1
View File
@@ -16,7 +16,8 @@
"list_access": {
"default": "mdi:account-lock",
"state": {
"shared": "mdi:account-group"
"shared": "mdi:account-group",
"invitation": "mdi:account-multiple-plus"
}
}
},
+1 -1
View File
@@ -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,
),
)
+2 -1
View File
@@ -66,7 +66,8 @@
"name": "List access",
"state": {
"registered": "Private",
"shared": "Shared"
"shared": "Shared",
"invitation": "Invitation pending"
}
}
}
+57 -25
View File
@@ -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."""
+15 -4
View File
@@ -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"]
}
+4 -2
View File
@@ -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"]
}
+33 -17
View File
@@ -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)
+2
View File
@@ -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
}
+148 -8
View File
@@ -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"
+1
View File
@@ -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"
+2 -2
View File
@@ -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",
+8 -4
View File
@@ -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")
+13 -1
View File
@@ -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:
+4 -1
View File
@@ -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%]"
}
+11 -1
View File
@@ -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."""
+1 -1
View File
@@ -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"]
}
+1 -86
View File
@@ -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
}
+46 -18
View File
@@ -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"
+39 -25
View File
@@ -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"]
}
+4 -8
View File
@@ -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
+14 -15
View File
@@ -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"]
}
+10 -3
View File
@@ -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
+1 -1
View File
@@ -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)
+1
View File
@@ -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)
+1
View File
@@ -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:
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+2 -1
View File
@@ -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
+1 -1
View File
@@ -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>"
+4 -2
View File
@@ -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",
+1
View File
@@ -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>,
+34 -2
View File
@@ -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
+50
View File
@@ -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."""
+110 -2
View File
@@ -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()
+67 -57
View File
@@ -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())
+37 -59
View File
@@ -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,
+4 -1
View File
@@ -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
+100 -10
View File
@@ -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)
+246 -17
View File
@@ -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()
+2 -2
View File
@@ -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,
),
],
+1 -1
View File
@@ -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
+4 -4
View File
@@ -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,
}
+101
View File
@@ -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
+3 -1
View File
@@ -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,
+3 -1
View File
@@ -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