This commit is contained in:
Franck Nijhof 2024-11-15 20:16:10 +01:00 committed by GitHub
commit 847afabed1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
118 changed files with 1026 additions and 737 deletions

View File

@ -90,7 +90,7 @@ repos:
pass_filenames: false
language: script
types: [text]
files: ^(script/hassfest/metadata\.py|homeassistant/const\.py$|pyproject\.toml)$
files: ^(script/hassfest/metadata\.py|homeassistant/const\.py$|pyproject\.toml|homeassistant/components/go2rtc/const\.py)$
- id: hassfest-mypy-config
name: hassfest-mypy-config
entry: script/run-in-env.sh python3 -m script.hassfest -p mypy_config

View File

@ -55,7 +55,7 @@ RUN \
"armv7") go2rtc_suffix='arm' ;; \
*) go2rtc_suffix=${BUILD_ARCH} ;; \
esac \
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.6/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.7/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
&& chmod +x /bin/go2rtc \
# Verify go2rtc can be executed
&& go2rtc --version

View File

@ -6,7 +6,7 @@ import asyncio
from datetime import timedelta
from functools import partial
import logging
from typing import Any, Final, final
from typing import TYPE_CHECKING, Any, Final, final
from propcache import cached_property
import voluptuous as vol
@ -221,9 +221,15 @@ class AlarmControlPanelEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_A
@property
def state(self) -> str | None:
"""Return the current state."""
if (alarm_state := self.alarm_state) is None:
return None
return alarm_state
if (alarm_state := self.alarm_state) is not None:
return alarm_state
if self._attr_state is not None:
# Backwards compatibility for integrations that set state directly
# Should be removed in 2025.11
if TYPE_CHECKING:
assert isinstance(self._attr_state, str)
return self._attr_state
return None
@cached_property
def alarm_state(self) -> AlarmControlPanelState | None:

View File

@ -7,6 +7,6 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["aiostreammagic"],
"requirements": ["aiostreammagic==2.8.4"],
"requirements": ["aiostreammagic==2.8.5"],
"zeroconf": ["_stream-magic._tcp.local.", "_smoip._tcp.local."]
}

View File

@ -51,8 +51,13 @@ CONTROL_ENTITIES: tuple[CambridgeAudioSelectEntityDescription, ...] = (
CambridgeAudioSelectEntityDescription(
key="display_brightness",
translation_key="display_brightness",
options=[x.value for x in DisplayBrightness],
options=[
DisplayBrightness.BRIGHT.value,
DisplayBrightness.DIM.value,
DisplayBrightness.OFF.value,
],
entity_category=EntityCategory.CONFIG,
load_fn=lambda client: client.display.brightness != DisplayBrightness.NONE,
value_fn=lambda client: client.display.brightness,
set_value_fn=lambda client, value: client.set_display_brightness(
DisplayBrightness(value)

View File

@ -168,7 +168,7 @@ class ElectricityMapsConfigFlow(ConfigFlow, domain=DOMAIN):
)
return self.async_create_entry(
title=get_extra_name(data) or "CO2 Signal",
title=get_extra_name(data) or "Electricity Maps",
data=data,
)

View File

@ -294,7 +294,7 @@ class DefaultAgent(ConversationEntity):
self.hass, language, DOMAIN, [DOMAIN]
)
response_text = translations.get(
f"component.{DOMAIN}.agent.done", "Done"
f"component.{DOMAIN}.conversation.agent.done", "Done"
)
response.async_set_speech(response_text)

View File

@ -6,5 +6,5 @@
"iot_class": "local_push",
"loggers": ["sense_energy"],
"quality_scale": "internal",
"requirements": ["sense-energy==0.13.3"]
"requirements": ["sense-energy==0.13.4"]
}

View File

@ -18,7 +18,7 @@
},
"data_description": {
"file_path": "The local file path to retrieve the sensor value from",
"value_template": "A template to render the the sensors value based on the file content",
"value_template": "A template to render the sensors value based on the file content",
"unit_of_measurement": "Unit of measurement for the sensor"
}
},

View File

@ -282,7 +282,7 @@ async def async_test_stream(
return {CONF_STREAM_SOURCE: "timeout"}
await stream.stop()
except StreamWorkerError as err:
return {CONF_STREAM_SOURCE: str(err)}
return {CONF_STREAM_SOURCE: "unknown_with_details", "error_details": str(err)}
except PermissionError:
return {CONF_STREAM_SOURCE: "stream_not_permitted"}
except OSError as err:
@ -339,6 +339,7 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle the start of the config flow."""
errors = {}
description_placeholders = {}
hass = self.hass
if user_input:
# Secondary validation because serialised vol can't seem to handle this complexity:
@ -372,6 +373,8 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
# temporary preview for user to check the image
self.preview_cam = user_input
return await self.async_step_user_confirm_still()
if "error_details" in errors:
description_placeholders["error"] = errors.pop("error_details")
elif self.user_input:
user_input = self.user_input
else:
@ -379,6 +382,7 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="user",
data_schema=build_schema(user_input),
description_placeholders=description_placeholders,
errors=errors,
)

View File

@ -3,6 +3,7 @@
"config": {
"error": {
"unknown": "[%key:common::config_flow::error::unknown%]",
"unknown_with_details": "An unknown error occurred: {error}",
"already_exists": "A camera with these URL settings already exists.",
"unable_still_load": "Unable to load valid image from still image URL (e.g. invalid host, URL or authentication failure). Review log for more info.",
"unable_still_load_auth": "Unable to load valid image from still image URL: The camera may require a user name and password, or they are not correct.",

View File

@ -3,7 +3,7 @@
"config": {
"step": {
"user": {
"title": "Add generic thermostat helper",
"title": "Add generic thermostat",
"description": "Create a climate entity that controls the temperature via a switch and sensor.",
"data": {
"ac_mode": "Cooling mode",
@ -17,8 +17,8 @@
"data_description": {
"ac_mode": "Set the actuator specified to be treated as a cooling device instead of a heating device.",
"heater": "Switch entity used to cool or heat depending on A/C mode.",
"target_sensor": "Temperature sensor that reflect the current temperature.",
"min_cycle_duration": "Set a minimum amount of time that the switch specified must be in its current state prior to being switched either off or on. This option will be ignored if the keep alive option is set.",
"target_sensor": "Temperature sensor that reflects the current temperature.",
"min_cycle_duration": "Set a minimum amount of time that the switch specified must be in its current state prior to being switched either off or on.",
"cold_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched on. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will start when the sensor equals or goes below 24.5.",
"hot_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched off. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will stop when the sensor equals or goes above 25.5."
}

View File

@ -1,12 +1,10 @@
"""The go2rtc component."""
from __future__ import annotations
from dataclasses import dataclass
import logging
import shutil
from aiohttp.client_exceptions import ClientConnectionError, ServerConnectionError
from awesomeversion import AwesomeVersion
from go2rtc_client import Go2RtcRestClient
from go2rtc_client.exceptions import Go2RtcClientError, Go2RtcVersionError
from go2rtc_client.ws import (
@ -35,7 +33,11 @@ from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv, discovery_flow
from homeassistant.helpers import (
config_validation as cv,
discovery_flow,
issue_registry as ir,
)
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey
@ -45,8 +47,8 @@ from .const import (
CONF_DEBUG_UI,
DEBUG_UI_URL_MESSAGE,
DOMAIN,
HA_MANAGED_RTSP_PORT,
HA_MANAGED_URL,
RECOMMENDED_VERSION,
)
from .server import Server
@ -94,22 +96,13 @@ CONFIG_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA,
)
_DATA_GO2RTC: HassKey[Go2RtcData] = HassKey(DOMAIN)
_DATA_GO2RTC: HassKey[str] = HassKey(DOMAIN)
_RETRYABLE_ERRORS = (ClientConnectionError, ServerConnectionError)
@dataclass(frozen=True)
class Go2RtcData:
"""Data for go2rtc."""
url: str
managed: bool
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up WebRTC."""
url: str | None = None
managed = False
if DOMAIN not in config and DEFAULT_CONFIG_DOMAIN not in config:
await _remove_go2rtc_entries(hass)
return True
@ -144,9 +137,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, on_stop)
url = HA_MANAGED_URL
managed = True
hass.data[_DATA_GO2RTC] = Go2RtcData(url, managed)
hass.data[_DATA_GO2RTC] = url
discovery_flow.async_create_flow(
hass, DOMAIN, context={"source": SOURCE_SYSTEM}, data={}
)
@ -161,32 +153,42 @@ async def _remove_go2rtc_entries(hass: HomeAssistant) -> None:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up go2rtc from a config entry."""
data = hass.data[_DATA_GO2RTC]
url = hass.data[_DATA_GO2RTC]
# Validate the server URL
try:
client = Go2RtcRestClient(async_get_clientsession(hass), data.url)
await client.validate_server_version()
client = Go2RtcRestClient(async_get_clientsession(hass), url)
version = await client.validate_server_version()
if version < AwesomeVersion(RECOMMENDED_VERSION):
ir.async_create_issue(
hass,
DOMAIN,
"recommended_version",
is_fixable=False,
is_persistent=False,
severity=ir.IssueSeverity.WARNING,
translation_key="recommended_version",
translation_placeholders={
"recommended_version": RECOMMENDED_VERSION,
"current_version": str(version),
},
)
except Go2RtcClientError as err:
if isinstance(err.__cause__, _RETRYABLE_ERRORS):
raise ConfigEntryNotReady(
f"Could not connect to go2rtc instance on {data.url}"
f"Could not connect to go2rtc instance on {url}"
) from err
_LOGGER.warning(
"Could not connect to go2rtc instance on %s (%s)", data.url, 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)", data.url, err
)
_LOGGER.warning("Could not connect to go2rtc instance on %s (%s)", url, err)
return False
provider = WebRTCProvider(hass, data)
provider = WebRTCProvider(hass, url)
async_register_webrtc_provider(hass, provider)
return True
@ -204,12 +206,12 @@ async def _get_binary(hass: HomeAssistant) -> str | None:
class WebRTCProvider(CameraWebRTCProvider):
"""WebRTC provider."""
def __init__(self, hass: HomeAssistant, data: Go2RtcData) -> None:
def __init__(self, hass: HomeAssistant, url: str) -> None:
"""Initialize the WebRTC provider."""
self._hass = hass
self._data = data
self._url = url
self._session = async_get_clientsession(hass)
self._rest_client = Go2RtcRestClient(self._session, data.url)
self._rest_client = Go2RtcRestClient(self._session, url)
self._sessions: dict[str, Go2RtcWsClient] = {}
@property
@ -231,7 +233,7 @@ class WebRTCProvider(CameraWebRTCProvider):
) -> None:
"""Handle the WebRTC offer and return the answer via the provided callback."""
self._sessions[session_id] = ws_client = Go2RtcWsClient(
self._session, self._data.url, source=camera.entity_id
self._session, self._url, source=camera.entity_id
)
if not (stream_source := await camera.stream_source()):
@ -242,34 +244,18 @@ class WebRTCProvider(CameraWebRTCProvider):
streams = await self._rest_client.streams.list()
if self._data.managed:
# HA manages the go2rtc instance
stream_org_name = camera.entity_id + "_orginal"
stream_redirect_sources = [
f"rtsp://127.0.0.1:{HA_MANAGED_RTSP_PORT}/{stream_org_name}",
f"ffmpeg:{stream_org_name}#audio=opus",
]
if (
(stream_org := streams.get(stream_org_name)) is None
or not any(
stream_source == producer.url for producer in stream_org.producers
)
or (stream_redirect := streams.get(camera.entity_id)) is None
or stream_redirect_sources != [p.url for p in stream_redirect.producers]
):
await self._rest_client.streams.add(stream_org_name, stream_source)
await self._rest_client.streams.add(
camera.entity_id, stream_redirect_sources
)
# go2rtc instance is managed outside HA
elif (stream_org := streams.get(camera.entity_id)) is None or not any(
stream_source == producer.url for producer in stream_org.producers
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"],
[
stream_source,
# We are setting any ffmpeg rtsp related logs to debug
# Connection problems to the camera will be logged by the first stream
# Therefore setting it to debug will not hide any important logs
f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug",
],
)
@callback

View File

@ -6,4 +6,4 @@ 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}/"
HA_MANAGED_RTSP_PORT = 18554
RECOMMENDED_VERSION = "1.9.7"

View File

@ -7,6 +7,6 @@
"documentation": "https://www.home-assistant.io/integrations/go2rtc",
"integration_type": "system",
"iot_class": "local_polling",
"requirements": ["go2rtc-client==0.1.0"],
"requirements": ["go2rtc-client==0.1.1"],
"single_config_entry": true
}

View File

@ -12,7 +12,7 @@ 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_RTSP_PORT, HA_MANAGED_URL
from .const import HA_MANAGED_API_PORT, HA_MANAGED_URL
_LOGGER = logging.getLogger(__name__)
_TERMINATE_TIMEOUT = 5
@ -33,7 +33,7 @@ api:
listen: "{api_ip}:{api_port}"
rtsp:
listen: "127.0.0.1:{rtsp_port}"
listen: "127.0.0.1:18554"
webrtc:
listen: ":18555/tcp"
@ -68,9 +68,7 @@ def _create_temp_file(api_ip: str) -> str:
with NamedTemporaryFile(prefix="go2rtc_", suffix=".yaml", delete=False) as file:
file.write(
_GO2RTC_CONFIG_FORMAT.format(
api_ip=api_ip,
api_port=HA_MANAGED_API_PORT,
rtsp_port=HA_MANAGED_RTSP_PORT,
api_ip=api_ip, api_port=HA_MANAGED_API_PORT
).encode()
)
return file.name

View File

@ -0,0 +1,8 @@
{
"issues": {
"recommended_version": {
"title": "Outdated go2rtc server detected",
"description": "We detected that you are using an outdated go2rtc server version. For the best experience, we recommend updating the go2rtc server to version `{recommended_version}`.\nCurrently you are using version `{current_version}`."
}
}
}

View File

@ -87,8 +87,8 @@
}
},
"create_event": {
"name": "Creates event",
"description": "Add a new calendar event.",
"name": "Create event",
"description": "Adds a new calendar event.",
"fields": {
"summary": {
"name": "Summary",

View File

@ -18,6 +18,8 @@ from homeassistant.const import (
SERVICE_ALARM_ARM_HOME,
SERVICE_ALARM_ARM_NIGHT,
SERVICE_ALARM_DISARM,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.core import State, callback
@ -152,12 +154,12 @@ class SecuritySystem(HomeAccessory):
@callback
def async_update_state(self, new_state: State) -> None:
"""Update security state after state changed."""
hass_state = None
if new_state and new_state.state == "None":
# Bail out early for no state
hass_state: str | AlarmControlPanelState = new_state.state
if hass_state in {"None", STATE_UNKNOWN, STATE_UNAVAILABLE}:
# Bail out early for no state, unknown or unavailable
return
if new_state and new_state.state is not None:
hass_state = AlarmControlPanelState(new_state.state)
if hass_state is not None:
hass_state = AlarmControlPanelState(hass_state)
if (
hass_state
and (current_state := HASS_TO_HOMEKIT_CURRENT.get(hass_state)) is not None

View File

@ -130,10 +130,15 @@ class HueSceneEntity(HueSceneEntityBase):
@property
def is_dynamic(self) -> bool:
"""Return if this scene has a dynamic color palette."""
if self.resource.palette.color and len(self.resource.palette.color) > 1:
if (
self.resource.palette
and self.resource.palette.color
and len(self.resource.palette.color) > 1
):
return True
if (
self.resource.palette.color_temperature
self.resource.palette
and self.resource.palette.color_temperature
and len(self.resource.palette.color_temperature) > 1
):
return True

View File

@ -95,7 +95,7 @@ class PowerViewNumber(ShadeEntity, RestoreNumber):
self.entity_description = description
self._attr_unique_id = f"{self._attr_unique_id}_{description.key}"
def set_native_value(self, value: float) -> None:
async def async_set_native_value(self, value: float) -> None:
"""Update the current value."""
self._attr_native_value = value
self.entity_description.store_value_fn(self.coordinator, self._shade.id, value)

View File

@ -8,6 +8,7 @@ from aioautomower.exceptions import (
ApiException,
AuthException,
HusqvarnaWSServerHandshakeError,
TimeoutException,
)
from aioautomower.model import MowerAttributes
from aioautomower.session import AutomowerSession
@ -22,6 +23,7 @@ from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
MAX_WS_RECONNECT_TIME = 600
SCAN_INTERVAL = timedelta(minutes=8)
DEFAULT_RECONNECT_TIME = 2 # Define a default reconnect time
class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttributes]]):
@ -40,8 +42,8 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib
update_interval=SCAN_INTERVAL,
)
self.api = api
self.ws_connected: bool = False
self.reconnect_time = DEFAULT_RECONNECT_TIME
async def _async_update_data(self) -> dict[str, MowerAttributes]:
"""Subscribe for websocket and poll data from the API."""
@ -66,24 +68,28 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib
hass: HomeAssistant,
entry: ConfigEntry,
automower_client: AutomowerSession,
reconnect_time: int = 2,
) -> None:
"""Listen with the client."""
try:
await automower_client.auth.websocket_connect()
reconnect_time = 2
# Reset reconnect time after successful connection
self.reconnect_time = DEFAULT_RECONNECT_TIME
await automower_client.start_listening()
except HusqvarnaWSServerHandshakeError as err:
_LOGGER.debug(
"Failed to connect to websocket. Trying to reconnect: %s", err
"Failed to connect to websocket. Trying to reconnect: %s",
err,
)
except TimeoutException as err:
_LOGGER.debug(
"Failed to listen to websocket. Trying to reconnect: %s",
err,
)
if not hass.is_stopping:
await asyncio.sleep(reconnect_time)
reconnect_time = min(reconnect_time * 2, MAX_WS_RECONNECT_TIME)
await self.client_listen(
hass=hass,
entry=entry,
automower_client=automower_client,
reconnect_time=reconnect_time,
await asyncio.sleep(self.reconnect_time)
self.reconnect_time = min(self.reconnect_time * 2, MAX_WS_RECONNECT_TIME)
entry.async_create_background_task(
hass,
self.client_listen(hass, entry, automower_client),
"reconnect_task",
)

View File

@ -32,6 +32,10 @@
}
},
"options": {
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
},
"step": {
"init": {
"title": "Options",

View File

@ -7,8 +7,12 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from .const import DOMAIN, SCAN_INTERVAL
from .coordinator import HydrawiseDataUpdateCoordinator
from .const import DOMAIN
from .coordinator import (
HydrawiseMainDataUpdateCoordinator,
HydrawiseUpdateCoordinators,
HydrawiseWaterUseDataUpdateCoordinator,
)
PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
@ -29,9 +33,18 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
auth.Auth(config_entry.data[CONF_USERNAME], config_entry.data[CONF_PASSWORD])
)
coordinator = HydrawiseDataUpdateCoordinator(hass, hydrawise, SCAN_INTERVAL)
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = coordinator
main_coordinator = HydrawiseMainDataUpdateCoordinator(hass, hydrawise)
await main_coordinator.async_config_entry_first_refresh()
water_use_coordinator = HydrawiseWaterUseDataUpdateCoordinator(
hass, hydrawise, main_coordinator
)
await water_use_coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = (
HydrawiseUpdateCoordinators(
main=main_coordinator,
water_use=water_use_coordinator,
)
)
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
return True

View File

@ -21,7 +21,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import VolDictType
from .const import DOMAIN, SERVICE_RESUME, SERVICE_START_WATERING, SERVICE_SUSPEND
from .coordinator import HydrawiseDataUpdateCoordinator
from .coordinator import HydrawiseUpdateCoordinators
from .entity import HydrawiseEntity
@ -81,18 +81,16 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Hydrawise binary_sensor platform."""
coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN][
config_entry.entry_id
]
coordinators: HydrawiseUpdateCoordinators = hass.data[DOMAIN][config_entry.entry_id]
entities: list[HydrawiseBinarySensor] = []
for controller in coordinator.data.controllers.values():
for controller in coordinators.main.data.controllers.values():
entities.extend(
HydrawiseBinarySensor(coordinator, description, controller)
HydrawiseBinarySensor(coordinators.main, description, controller)
for description in CONTROLLER_BINARY_SENSORS
)
entities.extend(
HydrawiseBinarySensor(
coordinator,
coordinators.main,
description,
controller,
sensor_id=sensor.id,
@ -103,7 +101,7 @@ async def async_setup_entry(
)
entities.extend(
HydrawiseZoneBinarySensor(
coordinator, description, controller, zone_id=zone.id
coordinators.main, description, controller, zone_id=zone.id
)
for zone in controller.zones
for description in ZONE_BINARY_SENSORS

View File

@ -10,7 +10,8 @@ DEFAULT_WATERING_TIME = timedelta(minutes=15)
MANUFACTURER = "Hydrawise"
SCAN_INTERVAL = timedelta(seconds=60)
MAIN_SCAN_INTERVAL = timedelta(seconds=60)
WATER_USE_SCAN_INTERVAL = timedelta(minutes=60)
SIGNAL_UPDATE_HYDRAWISE = "hydrawise_update"

View File

@ -2,8 +2,7 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
from dataclasses import dataclass, field
from pydrawise import Hydrawise
from pydrawise.schema import Controller, ControllerWaterUseSummary, Sensor, User, Zone
@ -12,7 +11,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.util.dt import now
from .const import DOMAIN, LOGGER
from .const import DOMAIN, LOGGER, MAIN_SCAN_INTERVAL, WATER_USE_SCAN_INTERVAL
@dataclass
@ -20,22 +19,39 @@ class HydrawiseData:
"""Container for data fetched from the Hydrawise API."""
user: User
controllers: dict[int, Controller]
zones: dict[int, Zone]
sensors: dict[int, Sensor]
daily_water_summary: dict[int, ControllerWaterUseSummary]
controllers: dict[int, Controller] = field(default_factory=dict)
zones: dict[int, Zone] = field(default_factory=dict)
sensors: dict[int, Sensor] = field(default_factory=dict)
daily_water_summary: dict[int, ControllerWaterUseSummary] = field(
default_factory=dict
)
@dataclass
class HydrawiseUpdateCoordinators:
"""Container for all Hydrawise DataUpdateCoordinator instances."""
main: HydrawiseMainDataUpdateCoordinator
water_use: HydrawiseWaterUseDataUpdateCoordinator
class HydrawiseDataUpdateCoordinator(DataUpdateCoordinator[HydrawiseData]):
"""The Hydrawise Data Update Coordinator."""
"""Base class for Hydrawise Data Update Coordinators."""
api: Hydrawise
def __init__(
self, hass: HomeAssistant, api: Hydrawise, scan_interval: timedelta
) -> None:
class HydrawiseMainDataUpdateCoordinator(HydrawiseDataUpdateCoordinator):
"""The main Hydrawise Data Update Coordinator.
This fetches the primary state data for Hydrawise controllers and zones
at a relatively frequent interval so that the primary functions of the
integration are updated in a timely manner.
"""
def __init__(self, hass: HomeAssistant, api: Hydrawise) -> None:
"""Initialize HydrawiseDataUpdateCoordinator."""
super().__init__(hass, LOGGER, name=DOMAIN, update_interval=scan_interval)
super().__init__(hass, LOGGER, name=DOMAIN, update_interval=MAIN_SCAN_INTERVAL)
self.api = api
async def _async_update_data(self) -> HydrawiseData:
@ -43,28 +59,56 @@ class HydrawiseDataUpdateCoordinator(DataUpdateCoordinator[HydrawiseData]):
# Don't fetch zones. We'll fetch them for each controller later.
# This is to prevent 502 errors in some cases.
# See: https://github.com/home-assistant/core/issues/120128
user = await self.api.get_user(fetch_zones=False)
controllers = {}
zones = {}
sensors = {}
daily_water_summary: dict[int, ControllerWaterUseSummary] = {}
for controller in user.controllers:
controllers[controller.id] = controller
data = HydrawiseData(user=await self.api.get_user(fetch_zones=False))
for controller in data.user.controllers:
data.controllers[controller.id] = controller
controller.zones = await self.api.get_zones(controller)
for zone in controller.zones:
zones[zone.id] = zone
data.zones[zone.id] = zone
for sensor in controller.sensors:
sensors[sensor.id] = sensor
data.sensors[sensor.id] = sensor
return data
class HydrawiseWaterUseDataUpdateCoordinator(HydrawiseDataUpdateCoordinator):
"""Data Update Coordinator for Hydrawise Water Use.
This fetches data that is more expensive for the Hydrawise API to compute
at a less frequent interval as to not overload the Hydrawise servers.
"""
_main_coordinator: HydrawiseMainDataUpdateCoordinator
def __init__(
self,
hass: HomeAssistant,
api: Hydrawise,
main_coordinator: HydrawiseMainDataUpdateCoordinator,
) -> None:
"""Initialize HydrawiseWaterUseDataUpdateCoordinator."""
super().__init__(
hass,
LOGGER,
name=f"{DOMAIN} water use",
update_interval=WATER_USE_SCAN_INTERVAL,
)
self.api = api
self._main_coordinator = main_coordinator
async def _async_update_data(self) -> HydrawiseData:
"""Fetch the latest data from Hydrawise."""
daily_water_summary: dict[int, ControllerWaterUseSummary] = {}
for controller in self._main_coordinator.data.controllers.values():
daily_water_summary[controller.id] = await self.api.get_water_use_summary(
controller,
now().replace(hour=0, minute=0, second=0, microsecond=0),
now(),
)
main_data = self._main_coordinator.data
return HydrawiseData(
user=user,
controllers=controllers,
zones=zones,
sensors=sensors,
user=main_data.user,
controllers=main_data.controllers,
zones=main_data.zones,
sensors=main_data.sensors,
daily_water_summary=daily_water_summary,
)

View File

@ -19,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt as dt_util
from .const import DOMAIN
from .coordinator import HydrawiseDataUpdateCoordinator
from .coordinator import HydrawiseUpdateCoordinators
from .entity import HydrawiseEntity
@ -92,7 +92,7 @@ def _get_controller_daily_total_water_use(sensor: HydrawiseSensor) -> float | No
return daily_water_summary.total_use
CONTROLLER_SENSORS: tuple[HydrawiseSensorEntityDescription, ...] = (
WATER_USE_CONTROLLER_SENSORS: tuple[HydrawiseSensorEntityDescription, ...] = (
HydrawiseSensorEntityDescription(
key="daily_active_water_time",
translation_key="daily_active_water_time",
@ -103,6 +103,16 @@ CONTROLLER_SENSORS: tuple[HydrawiseSensorEntityDescription, ...] = (
)
WATER_USE_ZONE_SENSORS: tuple[HydrawiseSensorEntityDescription, ...] = (
HydrawiseSensorEntityDescription(
key="daily_active_water_time",
translation_key="daily_active_water_time",
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.SECONDS,
value_fn=_get_zone_daily_active_water_time,
),
)
FLOW_CONTROLLER_SENSORS: tuple[HydrawiseSensorEntityDescription, ...] = (
HydrawiseSensorEntityDescription(
key="daily_total_water_use",
@ -150,13 +160,6 @@ ZONE_SENSORS: tuple[HydrawiseSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTime.MINUTES,
value_fn=_get_zone_watering_time,
),
HydrawiseSensorEntityDescription(
key="daily_active_water_time",
translation_key="daily_active_water_time",
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.SECONDS,
value_fn=_get_zone_daily_active_water_time,
),
)
FLOW_MEASUREMENT_KEYS = [x.key for x in FLOW_CONTROLLER_SENSORS]
@ -168,29 +171,37 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Hydrawise sensor platform."""
coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN][
config_entry.entry_id
]
coordinators: HydrawiseUpdateCoordinators = hass.data[DOMAIN][config_entry.entry_id]
entities: list[HydrawiseSensor] = []
for controller in coordinator.data.controllers.values():
for controller in coordinators.main.data.controllers.values():
entities.extend(
HydrawiseSensor(coordinator, description, controller)
for description in CONTROLLER_SENSORS
HydrawiseSensor(coordinators.water_use, description, controller)
for description in WATER_USE_CONTROLLER_SENSORS
)
entities.extend(
HydrawiseSensor(coordinator, description, controller, zone_id=zone.id)
HydrawiseSensor(
coordinators.water_use, description, controller, zone_id=zone.id
)
for zone in controller.zones
for description in WATER_USE_ZONE_SENSORS
)
entities.extend(
HydrawiseSensor(coordinators.main, description, controller, zone_id=zone.id)
for zone in controller.zones
for description in ZONE_SENSORS
)
if coordinator.data.daily_water_summary[controller.id].total_use is not None:
if (
coordinators.water_use.data.daily_water_summary[controller.id].total_use
is not None
):
# we have a flow sensor for this controller
entities.extend(
HydrawiseSensor(coordinator, description, controller)
HydrawiseSensor(coordinators.water_use, description, controller)
for description in FLOW_CONTROLLER_SENSORS
)
entities.extend(
HydrawiseSensor(
coordinator,
coordinators.water_use,
description,
controller,
zone_id=zone.id,

View File

@ -20,7 +20,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt as dt_util
from .const import DEFAULT_WATERING_TIME, DOMAIN
from .coordinator import HydrawiseDataUpdateCoordinator
from .coordinator import HydrawiseUpdateCoordinators
from .entity import HydrawiseEntity
@ -66,12 +66,10 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Hydrawise switch platform."""
coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN][
config_entry.entry_id
]
coordinators: HydrawiseUpdateCoordinators = hass.data[DOMAIN][config_entry.entry_id]
async_add_entities(
HydrawiseSwitch(coordinator, description, controller, zone_id=zone.id)
for controller in coordinator.data.controllers.values()
HydrawiseSwitch(coordinators.main, description, controller, zone_id=zone.id)
for controller in coordinators.main.data.controllers.values()
for zone in controller.zones
for description in SWITCH_TYPES
)

View File

@ -17,7 +17,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .coordinator import HydrawiseDataUpdateCoordinator
from .coordinator import HydrawiseUpdateCoordinators
from .entity import HydrawiseEntity
VALVE_TYPES: tuple[ValveEntityDescription, ...] = (
@ -34,12 +34,10 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Hydrawise valve platform."""
coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN][
config_entry.entry_id
]
coordinators: HydrawiseUpdateCoordinators = hass.data[DOMAIN][config_entry.entry_id]
async_add_entities(
HydrawiseValve(coordinator, description, controller, zone_id=zone.id)
for controller in coordinator.data.controllers.values()
HydrawiseValve(coordinators.main, description, controller, zone_id=zone.id)
for controller in coordinators.main.data.controllers.values()
for zone in controller.zones
for description in VALVE_TYPES
)

View File

@ -25,7 +25,8 @@
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_host": "[%key:common::config_flow::error::invalid_host%]"
}
},
"device_automation": {

View File

@ -72,8 +72,11 @@ class ThinQFanEntity(ThinQEntity, FanEntity):
super().__init__(coordinator, entity_description, property_id)
self._ordered_named_fan_speeds = []
self._attr_supported_features |= FanEntityFeature.SET_SPEED
self._attr_supported_features = (
FanEntityFeature.SET_SPEED
| FanEntityFeature.TURN_ON
| FanEntityFeature.TURN_OFF
)
if (fan_modes := self.data.fan_modes) is not None:
self._attr_speed_count = len(fan_modes)
if self.speed_count == 4:
@ -98,7 +101,7 @@ class ThinQFanEntity(ThinQEntity, FanEntity):
self._attr_percentage = 0
_LOGGER.debug(
"[%s:%s] update status: %s -> %s (percntage=%s)",
"[%s:%s] update status: %s -> %s (percentage=%s)",
self.coordinator.device_name,
self.property_id,
self.data.is_on,
@ -120,7 +123,7 @@ class ThinQFanEntity(ThinQEntity, FanEntity):
return
_LOGGER.debug(
"[%s:%s] async_set_percentage. percntage=%s, value=%s",
"[%s:%s] async_set_percentage. percentage=%s, value=%s",
self.coordinator.device_name,
self.property_id,
percentage,

View File

@ -7,6 +7,6 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["linkplay"],
"requirements": ["python-linkplay==0.0.17"],
"requirements": ["python-linkplay==0.0.20"],
"zeroconf": ["_linkplay._tcp.local."]
}

View File

@ -9,7 +9,7 @@ from typing import Any, Concatenate
from linkplay.bridge import LinkPlayBridge
from linkplay.consts import EqualizerMode, LoopMode, PlayingMode, PlayingStatus
from linkplay.controller import LinkPlayController, LinkPlayMultiroom
from linkplay.exceptions import LinkPlayException, LinkPlayRequestException
from linkplay.exceptions import LinkPlayRequestException
import voluptuous as vol
from homeassistant.components import media_source
@ -69,6 +69,8 @@ SOURCE_MAP: dict[PlayingMode, str] = {
PlayingMode.FM: "FM Radio",
PlayingMode.RCA: "RCA",
PlayingMode.UDISK: "USB",
PlayingMode.SPOTIFY: "Spotify",
PlayingMode.TIDAL: "Tidal",
PlayingMode.FOLLOWER: "Follower",
}
@ -201,9 +203,8 @@ class LinkPlayMediaPlayerEntity(MediaPlayerEntity):
try:
await self._bridge.player.update_status()
self._update_properties()
except LinkPlayException:
except LinkPlayRequestException:
self._attr_available = False
raise
@exception_wrap
async def async_select_source(self, source: str) -> None:
@ -292,7 +293,15 @@ class LinkPlayMediaPlayerEntity(MediaPlayerEntity):
@exception_wrap
async def async_play_preset(self, preset_number: int) -> None:
"""Play preset number."""
await self._bridge.player.play_preset(preset_number)
try:
await self._bridge.player.play_preset(preset_number)
except ValueError as err:
raise HomeAssistantError(err) from err
@exception_wrap
async def async_media_seek(self, position: float) -> None:
"""Seek to a position."""
await self._bridge.player.seek(round(position))
@exception_wrap
async def async_join_players(self, group_members: list[str]) -> None:
@ -379,9 +388,9 @@ class LinkPlayMediaPlayerEntity(MediaPlayerEntity):
)
self._attr_source = SOURCE_MAP.get(self._bridge.player.play_mode, "other")
self._attr_media_position = self._bridge.player.current_position / 1000
self._attr_media_position = self._bridge.player.current_position_in_seconds
self._attr_media_position_updated_at = utcnow()
self._attr_media_duration = self._bridge.player.total_length / 1000
self._attr_media_duration = self._bridge.player.total_length_in_seconds
self._attr_media_artist = self._bridge.player.artist
self._attr_media_title = self._bridge.player.title
self._attr_media_album_name = self._bridge.player.album

View File

@ -11,5 +11,4 @@ play_preset:
selector:
number:
min: 1
max: 10
mode: box

View File

@ -28,12 +28,12 @@
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"set_up_new_device": "A new device was detected. Please set it up as a new entity instead of reconfiguring."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"no_mac": "A MAC address was not found. It required to identify the device. Please ensure your device is connectable.",
"set_up_new_device": "A new device was detected. Please set it up as a new entity instead of reconfiguring."
"no_mac": "A MAC address was not found. It required to identify the device. Please ensure your device is connectable."
}
},
"entity": {

View File

@ -12,11 +12,12 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import MyUplinkConfigEntry, MyUplinkDataCoordinator
from .const import F_SERIES
from .entity import MyUplinkEntity, MyUplinkSystemEntity
from .helpers import find_matching_platform
from .helpers import find_matching_platform, transform_model_series
CATEGORY_BASED_DESCRIPTIONS: dict[str, dict[str, BinarySensorEntityDescription]] = {
"F730": {
F_SERIES: {
"43161": BinarySensorEntityDescription(
key="elect_add",
translation_key="elect_add",
@ -50,6 +51,7 @@ def get_description(device_point: DevicePoint) -> BinarySensorEntityDescription
2. Default to None
"""
prefix, _, _ = device_point.category.partition(" ")
prefix = transform_model_series(prefix)
return CATEGORY_BASED_DESCRIPTIONS.get(prefix, {}).get(device_point.parameter_id)

View File

@ -6,3 +6,5 @@ API_ENDPOINT = "https://api.myuplink.com"
OAUTH2_AUTHORIZE = "https://api.myuplink.com/oauth/authorize"
OAUTH2_TOKEN = "https://api.myuplink.com/oauth/token"
OAUTH2_SCOPES = ["WRITESYSTEM", "READSYSTEM", "offline_access"]
F_SERIES = "f-series"

View File

@ -6,6 +6,8 @@ from homeassistant.components.number import NumberEntityDescription
from homeassistant.components.sensor import SensorEntityDescription
from homeassistant.const import Platform
from .const import F_SERIES
def find_matching_platform(
device_point: DevicePoint,
@ -86,8 +88,9 @@ PARAMETER_ID_TO_EXCLUDE_F730 = (
"47941",
"47975",
"48009",
"48042",
"48072",
"48442",
"49909",
"50113",
)
@ -110,7 +113,7 @@ def skip_entity(model: str, device_point: DevicePoint) -> bool:
):
return False
return True
if "F730" in model:
if model.lower().startswith("f"):
# Entity names containing weekdays are used for advanced scheduling in the
# heat pump and should not be exposed in the integration
if any(d in device_point.parameter_name.lower() for d in WEEKDAYS):
@ -118,3 +121,10 @@ def skip_entity(model: str, device_point: DevicePoint) -> bool:
if device_point.parameter_id in PARAMETER_ID_TO_EXCLUDE_F730:
return True
return False
def transform_model_series(prefix: str) -> str:
"""Remap all F-series models."""
if prefix.lower().startswith("f"):
return F_SERIES
return prefix

View File

@ -10,8 +10,9 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import MyUplinkConfigEntry, MyUplinkDataCoordinator
from .const import F_SERIES
from .entity import MyUplinkEntity
from .helpers import find_matching_platform, skip_entity
from .helpers import find_matching_platform, skip_entity, transform_model_series
DEVICE_POINT_UNIT_DESCRIPTIONS: dict[str, NumberEntityDescription] = {
"DM": NumberEntityDescription(
@ -22,7 +23,7 @@ DEVICE_POINT_UNIT_DESCRIPTIONS: dict[str, NumberEntityDescription] = {
}
CATEGORY_BASED_DESCRIPTIONS: dict[str, dict[str, NumberEntityDescription]] = {
"F730": {
F_SERIES: {
"40940": NumberEntityDescription(
key="degree_minutes",
translation_key="degree_minutes",
@ -48,6 +49,7 @@ def get_description(device_point: DevicePoint) -> NumberEntityDescription | None
3. Default to None
"""
prefix, _, _ = device_point.category.partition(" ")
prefix = transform_model_series(prefix)
description = CATEGORY_BASED_DESCRIPTIONS.get(prefix, {}).get(
device_point.parameter_id
)

View File

@ -25,8 +25,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import MyUplinkConfigEntry, MyUplinkDataCoordinator
from .const import F_SERIES
from .entity import MyUplinkEntity
from .helpers import find_matching_platform, skip_entity
from .helpers import find_matching_platform, skip_entity, transform_model_series
DEVICE_POINT_UNIT_DESCRIPTIONS: dict[str, SensorEntityDescription] = {
"°C": SensorEntityDescription(
@ -139,7 +140,7 @@ DEVICE_POINT_UNIT_DESCRIPTIONS: dict[str, SensorEntityDescription] = {
MARKER_FOR_UNKNOWN_VALUE = -32768
CATEGORY_BASED_DESCRIPTIONS: dict[str, dict[str, SensorEntityDescription]] = {
"F730": {
F_SERIES: {
"43108": SensorEntityDescription(
key="fan_mode",
translation_key="fan_mode",
@ -200,6 +201,7 @@ def get_description(device_point: DevicePoint) -> SensorEntityDescription | None
"""
description = None
prefix, _, _ = device_point.category.partition(" ")
prefix = transform_model_series(prefix)
description = CATEGORY_BASED_DESCRIPTIONS.get(prefix, {}).get(
device_point.parameter_id
)

View File

@ -12,11 +12,12 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import MyUplinkConfigEntry, MyUplinkDataCoordinator
from .const import F_SERIES
from .entity import MyUplinkEntity
from .helpers import find_matching_platform, skip_entity
from .helpers import find_matching_platform, skip_entity, transform_model_series
CATEGORY_BASED_DESCRIPTIONS: dict[str, dict[str, SwitchEntityDescription]] = {
"F730": {
F_SERIES: {
"50004": SwitchEntityDescription(
key="temporary_lux",
translation_key="temporary_lux",
@ -47,6 +48,7 @@ def get_description(device_point: DevicePoint) -> SwitchEntityDescription | None
2. Default to None
"""
prefix, _, _ = device_point.category.partition(" ")
prefix = transform_model_series(prefix)
return CATEGORY_BASED_DESCRIPTIONS.get(prefix, {}).get(device_point.parameter_id)

View File

@ -2,9 +2,9 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from abc import ABC
import asyncio
from collections.abc import Callable
from collections.abc import Awaitable, Callable
import datetime
import functools
import logging
@ -19,6 +19,7 @@ from google_nest_sdm.camera_traits import (
from google_nest_sdm.device import Device
from google_nest_sdm.device_manager import DeviceManager
from google_nest_sdm.exceptions import ApiException
from webrtc_models import RTCIceCandidate
from homeassistant.components.camera import (
Camera,
@ -46,6 +47,11 @@ PLACEHOLDER = Path(__file__).parent / "placeholder.png"
# Used to schedule an alarm to refresh the stream before expiration
STREAM_EXPIRATION_BUFFER = datetime.timedelta(seconds=30)
# Refresh streams with a bounded interval and backoff on failure
MIN_REFRESH_BACKOFF_INTERVAL = datetime.timedelta(minutes=1)
MAX_REFRESH_BACKOFF_INTERVAL = datetime.timedelta(minutes=10)
BACKOFF_MULTIPLIER = 1.5
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
@ -67,6 +73,68 @@ async def async_setup_entry(
async_add_entities(entities)
class StreamRefresh:
"""Class that will refresh an expiring stream.
This class will schedule an alarm for the next expiration time of a stream.
When the alarm fires, it runs the provided `refresh_cb` to extend the
lifetime of the stream and return a new expiration time.
A simple backoff will be applied when the refresh callback fails.
"""
def __init__(
self,
hass: HomeAssistant,
expires_at: datetime.datetime,
refresh_cb: Callable[[], Awaitable[datetime.datetime | None]],
) -> None:
"""Initialize StreamRefresh."""
self._hass = hass
self._unsub: Callable[[], None] | None = None
self._min_refresh_interval = MIN_REFRESH_BACKOFF_INTERVAL
self._refresh_cb = refresh_cb
self._schedule_stream_refresh(expires_at - STREAM_EXPIRATION_BUFFER)
def unsub(self) -> None:
"""Invalidates the stream."""
if self._unsub:
self._unsub()
async def _handle_refresh(self, _: datetime.datetime) -> None:
"""Alarm that fires to check if the stream should be refreshed."""
self._unsub = None
try:
expires_at = await self._refresh_cb()
except ApiException as err:
_LOGGER.debug("Failed to refresh stream: %s", err)
# Increase backoff until the max backoff interval is reached
self._min_refresh_interval = min(
self._min_refresh_interval * BACKOFF_MULTIPLIER,
MAX_REFRESH_BACKOFF_INTERVAL,
)
refresh_time = utcnow() + self._min_refresh_interval
else:
if expires_at is None:
return
self._min_refresh_interval = MIN_REFRESH_BACKOFF_INTERVAL # Reset backoff
# Defend against invalid stream expiration time in the past
refresh_time = max(
expires_at - STREAM_EXPIRATION_BUFFER,
utcnow() + self._min_refresh_interval,
)
self._schedule_stream_refresh(refresh_time)
def _schedule_stream_refresh(self, refresh_time: datetime.datetime) -> None:
"""Schedules an alarm to refresh any streams before expiration."""
_LOGGER.debug("Scheduling stream refresh for %s", refresh_time)
self._unsub = async_track_point_in_utc_time(
self._hass,
self._handle_refresh,
refresh_time,
)
class NestCameraBaseEntity(Camera, ABC):
"""Devices that support cameras."""
@ -86,41 +154,6 @@ class NestCameraBaseEntity(Camera, ABC):
self.stream_options[CONF_EXTRA_PART_WAIT_TIME] = 3
# The API "name" field is a unique device identifier.
self._attr_unique_id = f"{self._device.name}-camera"
self._stream_refresh_unsub: Callable[[], None] | None = None
@abstractmethod
def _stream_expires_at(self) -> datetime.datetime | None:
"""Next time when a stream expires."""
@abstractmethod
async def _async_refresh_stream(self) -> None:
"""Refresh any stream to extend expiration time."""
def _schedule_stream_refresh(self) -> None:
"""Schedules an alarm to refresh any streams before expiration."""
if self._stream_refresh_unsub is not None:
self._stream_refresh_unsub()
expiration_time = self._stream_expires_at()
if not expiration_time:
return
refresh_time = expiration_time - STREAM_EXPIRATION_BUFFER
_LOGGER.debug("Scheduled next stream refresh for %s", refresh_time)
self._stream_refresh_unsub = async_track_point_in_utc_time(
self.hass,
self._handle_stream_refresh,
refresh_time,
)
async def _handle_stream_refresh(self, _: datetime.datetime) -> None:
"""Alarm that fires to check if the stream should be refreshed."""
_LOGGER.debug("Examining streams to refresh")
self._stream_refresh_unsub = None
try:
await self._async_refresh_stream()
finally:
self._schedule_stream_refresh()
async def async_added_to_hass(self) -> None:
"""Run when entity is added to register update signal handler."""
@ -128,12 +161,6 @@ class NestCameraBaseEntity(Camera, ABC):
self._device.add_update_listener(self.async_write_ha_state)
)
async def async_will_remove_from_hass(self) -> None:
"""Invalidates the RTSP token when unloaded."""
await super().async_will_remove_from_hass()
if self._stream_refresh_unsub:
self._stream_refresh_unsub()
class NestRTSPEntity(NestCameraBaseEntity):
"""Nest cameras that use RTSP."""
@ -146,6 +173,7 @@ class NestRTSPEntity(NestCameraBaseEntity):
super().__init__(device)
self._create_stream_url_lock = asyncio.Lock()
self._rtsp_live_stream_trait = device.traits[CameraLiveStreamTrait.NAME]
self._refresh_unsub: Callable[[], None] | None = None
@property
def use_stream_for_stills(self) -> bool:
@ -173,20 +201,21 @@ class NestRTSPEntity(NestCameraBaseEntity):
)
except ApiException as err:
raise HomeAssistantError(f"Nest API error: {err}") from err
self._schedule_stream_refresh()
refresh = StreamRefresh(
self.hass,
self._rtsp_stream.expires_at,
self._async_refresh_stream,
)
self._refresh_unsub = refresh.unsub
assert self._rtsp_stream
if self._rtsp_stream.expires_at < utcnow():
_LOGGER.warning("Stream already expired")
return self._rtsp_stream.rtsp_stream_url
def _stream_expires_at(self) -> datetime.datetime | None:
"""Next time when a stream expires."""
return self._rtsp_stream.expires_at if self._rtsp_stream else None
async def _async_refresh_stream(self) -> None:
async def _async_refresh_stream(self) -> datetime.datetime | None:
"""Refresh stream to extend expiration time."""
if not self._rtsp_stream:
return
return None
_LOGGER.debug("Extending RTSP stream")
try:
self._rtsp_stream = await self._rtsp_stream.extend_rtsp_stream()
@ -197,14 +226,17 @@ class NestRTSPEntity(NestCameraBaseEntity):
if self.stream:
await self.stream.stop()
self.stream = None
return
return None
# Update the stream worker with the latest valid url
if self.stream:
self.stream.update_source(self._rtsp_stream.rtsp_stream_url)
return self._rtsp_stream.expires_at
async def async_will_remove_from_hass(self) -> None:
"""Invalidates the RTSP token when unloaded."""
await super().async_will_remove_from_hass()
if self._refresh_unsub is not None:
self._refresh_unsub()
if self._rtsp_stream:
try:
await self._rtsp_stream.stop_stream()
@ -220,37 +252,23 @@ class NestWebRTCEntity(NestCameraBaseEntity):
"""Initialize the camera."""
super().__init__(device)
self._webrtc_sessions: dict[str, WebRtcStream] = {}
self._refresh_unsub: dict[str, Callable[[], None]] = {}
@property
def frontend_stream_type(self) -> StreamType | None:
"""Return the type of stream supported by this camera."""
return StreamType.WEB_RTC
def _stream_expires_at(self) -> datetime.datetime | None:
"""Next time when a stream expires."""
if not self._webrtc_sessions:
return None
return min(stream.expires_at for stream in self._webrtc_sessions.values())
async def _async_refresh_stream(self) -> None:
async def _async_refresh_stream(self, session_id: str) -> datetime.datetime | None:
"""Refresh stream to extend expiration time."""
now = utcnow()
for session_id, webrtc_stream in list(self._webrtc_sessions.items()):
if session_id not in self._webrtc_sessions:
continue
if now < (webrtc_stream.expires_at - STREAM_EXPIRATION_BUFFER):
_LOGGER.debug(
"Stream does not yet expire: %s", webrtc_stream.expires_at
)
continue
_LOGGER.debug("Extending WebRTC stream %s", webrtc_stream.media_session_id)
try:
webrtc_stream = await webrtc_stream.extend_stream()
except ApiException as err:
_LOGGER.debug("Failed to extend stream: %s", err)
else:
if session_id in self._webrtc_sessions:
self._webrtc_sessions[session_id] = webrtc_stream
if not (webrtc_stream := self._webrtc_sessions.get(session_id)):
return None
_LOGGER.debug("Extending WebRTC stream %s", webrtc_stream.media_session_id)
webrtc_stream = await webrtc_stream.extend_stream()
if session_id in self._webrtc_sessions:
self._webrtc_sessions[session_id] = webrtc_stream
return webrtc_stream.expires_at
return None
async def async_camera_image(
self, width: int | None = None, height: int | None = None
@ -278,7 +296,18 @@ class NestWebRTCEntity(NestCameraBaseEntity):
)
self._webrtc_sessions[session_id] = stream
send_message(WebRTCAnswer(stream.answer_sdp))
self._schedule_stream_refresh()
refresh = StreamRefresh(
self.hass,
stream.expires_at,
functools.partial(self._async_refresh_stream, session_id),
)
self._refresh_unsub[session_id] = refresh.unsub
async def async_on_webrtc_candidate(
self, session_id: str, candidate: RTCIceCandidate
) -> None:
"""Ignore WebRTC candidates for Nest cloud based cameras."""
return
@callback
def close_webrtc_session(self, session_id: str) -> None:
@ -287,6 +316,8 @@ class NestWebRTCEntity(NestCameraBaseEntity):
_LOGGER.debug(
"Closing WebRTC session %s, %s", session_id, stream.media_session_id
)
unsub = self._refresh_unsub.pop(session_id)
unsub()
async def stop_stream() -> None:
try:

View File

@ -20,5 +20,5 @@
"iot_class": "cloud_push",
"loggers": ["google_nest_sdm"],
"quality_scale": "platinum",
"requirements": ["google-nest-sdm==6.1.4"]
"requirements": ["google-nest-sdm==6.1.5"]
}

View File

@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["nice_go"],
"requirements": ["nice-go==0.3.9"]
"requirements": ["nice-go==0.3.10"]
}

View File

@ -11,9 +11,11 @@
"title": "Downloading model"
}
},
"abort": {
"download_failed": "Model downloading failed"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"download_failed": "Model downloading failed",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"progress": {

View File

@ -137,7 +137,7 @@ class OnewireOptionsFlowHandler(OptionsFlowWithConfigEntry):
}
if not self.configurable_devices:
return self.async_abort(reason="No configurable devices found.")
return self.async_abort(reason="no_configurable_devices")
return await self.async_step_device_selection(user_input=None)

View File

@ -94,6 +94,9 @@
}
},
"options": {
"abort": {
"no_configurable_devices": "No configurable devices found"
},
"error": {
"device_not_selected": "Select devices to configure"
},

View File

@ -18,11 +18,11 @@
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]",
"pairing_failure": "Unable to pair: {error_id}",
"invalid_pin": "Invalid PIN"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"pairing_failure": "Unable to pair: {error_id}",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}
},

View File

@ -64,10 +64,10 @@ WEBHOOK_SCHEMA = vol.Schema(
vol.Required(ATTR_DEVICE_NAME): cv.string,
vol.Required(ATTR_DEVICE_ID): cv.positive_int,
vol.Required(ATTR_TEMP_UNIT): vol.In(
UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT
[UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT]
),
vol.Required(ATTR_VOLUME_UNIT): vol.In(
UnitOfVolume.LITERS, UnitOfVolume.GALLONS
[UnitOfVolume.LITERS, UnitOfVolume.GALLONS]
),
vol.Required(ATTR_BPM): cv.positive_int,
vol.Required(ATTR_TEMP): vol.Coerce(float),

View File

@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/plaato",
"iot_class": "cloud_push",
"loggers": ["pyplaato"],
"requirements": ["pyplaato==0.0.18"]
"requirements": ["pyplaato==0.0.19"]
}

View File

@ -251,8 +251,8 @@ class PowerwallConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle reauth confirmation."""
errors: dict[str, str] | None = {}
description_placeholders: dict[str, str] = {}
reauth_entry = self._get_reauth_entry()
if user_input is not None:
reauth_entry = self._get_reauth_entry()
errors, _, description_placeholders = await self._async_try_connect(
{CONF_IP_ADDRESS: reauth_entry.data[CONF_IP_ADDRESS], **user_input}
)
@ -261,6 +261,10 @@ class PowerwallConfigFlow(ConfigFlow, domain=DOMAIN):
reauth_entry, data_updates=user_input
)
self.context["title_placeholders"] = {
"name": reauth_entry.title,
"ip_address": reauth_entry.data[CONF_IP_ADDRESS],
}
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema({vol.Optional(CONF_PASSWORD): str}),

View File

@ -18,5 +18,5 @@
"documentation": "https://www.home-assistant.io/integrations/reolink",
"iot_class": "local_push",
"loggers": ["reolink_aio"],
"requirements": ["reolink-aio==0.10.4"]
"requirements": ["reolink-aio==0.11.1"]
}

View File

@ -96,7 +96,7 @@ class RingEvent(RingBaseEntity[RingListenCoordinator, RingDeviceT], EventEntity)
@callback
def _handle_coordinator_update(self) -> None:
if alert := self._get_coordinator_alert():
if (alert := self._get_coordinator_alert()) and not alert.is_update:
self._async_handle_event(alert.kind)
super()._handle_coordinator_update()

View File

@ -30,5 +30,5 @@
"iot_class": "cloud_polling",
"loggers": ["ring_doorbell"],
"quality_scale": "silver",
"requirements": ["ring-doorbell==0.9.8"]
"requirements": ["ring-doorbell==0.9.12"]
}

View File

@ -47,7 +47,6 @@ class RoborockCoordinators:
async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> bool:
"""Set up roborock from a config entry."""
_LOGGER.debug("Integration async setup entry: %s", entry.as_dict())
entry.async_on_unload(entry.add_update_listener(update_listener))
user_data = UserData.from_dict(entry.data[CONF_USER_DATA])

View File

@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["aioruckus"],
"requirements": ["aioruckus==0.41"]
"requirements": ["aioruckus==0.42"]
}

View File

@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/sense",
"iot_class": "cloud_polling",
"loggers": ["sense_energy"],
"requirements": ["sense-energy==0.13.3"]
"requirements": ["sense-energy==0.13.4"]
}

View File

@ -603,7 +603,7 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]):
async def _async_update_data(self) -> None:
"""Fetch data."""
if self.update_sleep_period():
if self.update_sleep_period() or self.hass.is_stopping:
return
if self.sleep_period:

View File

@ -7,5 +7,5 @@
"iot_class": "cloud_polling",
"loggers": ["smarttub"],
"quality_scale": "platinum",
"requirements": ["python-smarttub==0.0.36"]
"requirements": ["python-smarttub==0.0.38"]
}

View File

@ -28,6 +28,10 @@
"deprecated_yaml_import_issue_auth_error": {
"title": "YAML import failed due to an authentication error",
"description": "Configuring {integration_title} using YAML is being removed but there was an authentication error while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually."
},
"deprecated_yaml_import_issue_cannot_connect": {
"title": "YAML import failed due to a connection error",
"description": "Configuring {integration_title} using YAML is being removed but there was a connect error while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually."
}
},
"entity": {

View File

@ -8,7 +8,7 @@
"documentation": "https://www.home-assistant.io/integrations/sonos",
"iot_class": "local_push",
"loggers": ["soco"],
"requirements": ["soco==0.30.4", "sonos-websocket==0.1.3"],
"requirements": ["soco==0.30.6", "sonos-websocket==0.1.3"],
"ssdp": [
{
"st": "urn:schemas-upnp-org:device:ZonePlayer:1"

View File

@ -9,6 +9,6 @@
"iot_class": "cloud_polling",
"loggers": ["spotipy"],
"quality_scale": "silver",
"requirements": ["spotifyaio==0.8.7"],
"requirements": ["spotifyaio==0.8.8"],
"zeroconf": ["_spotify-connect._tcp.local."]
}

View File

@ -17,7 +17,8 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"unknown": "Unexpected error"
}
},
"entity": {

View File

@ -23,10 +23,10 @@
"state_characteristic": {
"description": "Read the documention for further details on available options and how to use them.",
"data": {
"state_characteristic": "State_characteristic"
"state_characteristic": "Statistic characteristic"
},
"data_description": {
"state_characteristic": "The characteristic that should be used as the state of the statistics sensor."
"state_characteristic": "The statistic characteristic that should be used as the state of the sensor."
}
},
"options": {

View File

@ -37,13 +37,13 @@
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"incorrect_pin": "Incorrect PIN",
"bad_pin_format": "PIN should be 4 digits",
"two_factor_request_failed": "Request for 2FA code failed, please try again",
"bad_validation_code_format": "Validation code should be 6 digits",
"incorrect_validation_code": "Incorrect validation code"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"two_factor_request_failed": "Request for 2FA code failed, please try again"
}
},
"options": {

View File

@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["tibber"],
"quality_scale": "silver",
"requirements": ["pyTibber==0.30.4"]
"requirements": ["pyTibber==0.30.8"]
}

View File

@ -16,6 +16,7 @@
"already_configured": "The selected agreement is already configured.",
"unknown_authorize_url_generation": "[%key:common::config_flow::abort::unknown_authorize_url_generation%]",
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
"connection_error": "[%key:common::config_flow::error::cannot_connect%]",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"no_agreements": "This account has no Toon displays.",
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",

View File

@ -60,10 +60,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
return await self._entry_from_data(auth)
except AuthError as err:
if err.code == "invalid_security_code":
errors[KEY_SECURITY_CODE] = err.code
else:
errors["base"] = err.code
errors["base"] = err.code
else:
user_input = {}

View File

@ -14,7 +14,7 @@
}
},
"error": {
"invalid_key": "Failed to register with provided key. If this keeps happening, try restarting the gateway.",
"invalid_security_code": "Failed to register with provided key. If this keeps happening, try restarting the gateway.",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"timeout": "Timeout validating the code.",
"cannot_authenticate": "Cannot authenticate, is Gateway paired with another server like e.g. Homekit?"

View File

@ -25,6 +25,9 @@
"tariffs": "A list of supported tariffs, leave empty if only a single tariff is needed."
}
}
},
"error": {
"tariffs_not_unique": "Tariffs must be unique"
}
},
"options": {

View File

@ -109,7 +109,7 @@ class DomainConfigFlow(ConfigFlow, domain=DOMAIN):
try:
info = await validate_input(self.hass, user_input)
except InvalidHost:
errors[CONF_HOST] = "wrong_host"
errors["base"] = "invalid_host"
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:

View File

@ -14,6 +14,7 @@
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"invalid_host": "[%key:common::config_flow::error::invalid_host%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {

View File

@ -22,7 +22,7 @@ from .const import _LOGGER, DOMAIN, LINE_TYPES
from .coordinator import VodafoneStationRouter
NOT_AVAILABLE: list = ["", "N/A", "0.0.0.0"]
UPTIME_DEVIATION = 45
UPTIME_DEVIATION = 60
@dataclass(frozen=True, kw_only=True)
@ -43,12 +43,10 @@ def _calculate_uptime(
) -> datetime:
"""Calculate device uptime."""
assert isinstance(last_value, datetime)
delta_uptime = coordinator.api.convert_uptime(coordinator.data.sensors[key])
if (
not last_value
not isinstance(last_value, datetime)
or abs((delta_uptime - last_value).total_seconds()) > UPTIME_DEVIATION
):
return delta_uptime

View File

@ -1,4 +1,5 @@
{
"title": "Water heater",
"device_automation": {
"action_type": {
"turn_on": "[%key:common::device_automation::action_type::turn_on%]",
@ -7,7 +8,7 @@
},
"entity_component": {
"_": {
"name": "Water heater",
"name": "[%key:component::water_heater::title%]",
"state": {
"off": "[%key:common::state::off%]",
"eco": "Eco",

View File

@ -9,5 +9,5 @@
"iot_class": "cloud_push",
"loggers": ["aiowithings"],
"quality_scale": "platinum",
"requirements": ["aiowithings==3.1.1"]
"requirements": ["aiowithings==3.1.3"]
}

View File

@ -33,6 +33,7 @@ from homeassistant.config_entries import (
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.selector import FileSelector, FileSelectorConfig
from homeassistant.util import dt as dt_util
@ -104,25 +105,26 @@ async def list_serial_ports(hass: HomeAssistant) -> list[ListPortInfo]:
yellow_radio.description = "Yellow Zigbee module"
yellow_radio.manufacturer = "Nabu Casa"
# Present the multi-PAN addon as a setup option, if it's available
multipan_manager = await silabs_multiprotocol_addon.get_multiprotocol_addon_manager(
hass
)
try:
addon_info = await multipan_manager.async_get_addon_info()
except (AddonError, KeyError):
addon_info = None
if addon_info is not None and addon_info.state != AddonState.NOT_INSTALLED:
addon_port = ListPortInfo(
device=silabs_multiprotocol_addon.get_zigbee_socket(),
skip_link_detection=True,
if is_hassio(hass):
# Present the multi-PAN addon as a setup option, if it's available
multipan_manager = (
await silabs_multiprotocol_addon.get_multiprotocol_addon_manager(hass)
)
addon_port.description = "Multiprotocol add-on"
addon_port.manufacturer = "Nabu Casa"
ports.append(addon_port)
try:
addon_info = await multipan_manager.async_get_addon_info()
except (AddonError, KeyError):
addon_info = None
if addon_info is not None and addon_info.state != AddonState.NOT_INSTALLED:
addon_port = ListPortInfo(
device=silabs_multiprotocol_addon.get_zigbee_socket(),
skip_link_detection=True,
)
addon_port.description = "Multiprotocol add-on"
addon_port.manufacturer = "Nabu Casa"
ports.append(addon_port)
return ports

View File

@ -21,7 +21,7 @@
"zha",
"universal_silabs_flasher"
],
"requirements": ["universal-silabs-flasher==0.0.24", "zha==0.0.37"],
"requirements": ["universal-silabs-flasher==0.0.25", "zha==0.0.39"],
"usb": [
{
"vid": "10C4",

View File

@ -4,7 +4,6 @@ from __future__ import annotations
import functools
import logging
import math
from typing import Any
from zha.exceptions import ZHAException
@ -97,6 +96,7 @@ class ZHAFirmwareUpdateEntity(
| UpdateEntityFeature.SPECIFIC_VERSION
| UpdateEntityFeature.RELEASE_NOTES
)
_attr_display_precision = 2 # 40 byte chunks with ~200KB files increments by 0.02%
def __init__(self, entity_data: EntityData, **kwargs: Any) -> None:
"""Initialize the ZHA siren."""
@ -115,20 +115,19 @@ class ZHAFirmwareUpdateEntity(
def in_progress(self) -> bool | int | None:
"""Update installation progress.
Should return a boolean (True if in progress, False if not).
"""
return self.entity_data.entity.in_progress
@property
def update_percentage(self) -> int | float | None:
"""Update installation progress.
Needs UpdateEntityFeature.PROGRESS flag to be set for it to be used.
Can either return a boolean (True if in progress, False if not)
or an integer to indicate the progress in from 0 to 100%.
Can either return a number to indicate the progress from 0 to 100% or None.
"""
if not self.entity_data.entity.in_progress:
return self.entity_data.entity.in_progress
# Stay in an indeterminate state until we actually send something
if self.entity_data.entity.progress == 0:
return True
# Rescale 0-100% to 2-100% to avoid 0 and 1 colliding with None, False, and True
return int(math.ceil(2 + 98 * self.entity_data.entity.progress / 100))
return self.entity_data.entity.update_percentage
@property
def latest_version(self) -> str | None:

View File

@ -25,7 +25,7 @@ if TYPE_CHECKING:
APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2024
MINOR_VERSION: Final = 11
PATCH_VERSION: Final = "1"
PATCH_VERSION: Final = "2"
__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)

View File

@ -5,7 +5,7 @@ aiodiscover==2.1.0
aiodns==3.2.0
aiohasupervisor==0.2.1
aiohttp-fast-zlib==0.1.1
aiohttp==3.10.10
aiohttp==3.10.11
aiohttp_cors==0.7.0
aiozoneinfo==0.2.1
astral==2.2
@ -26,7 +26,7 @@ ciso8601==2.3.1
cryptography==43.0.1
dbus-fast==2.24.3
fnv-hash-fast==1.0.2
go2rtc-client==0.1.0
go2rtc-client==0.1.1
ha-av==10.1.1
ha-ffmpeg==3.2.2
habluetooth==3.6.0

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "homeassistant"
version = "2024.11.1"
version = "2024.11.2"
license = {text = "Apache-2.0"}
description = "Open-source home automation platform running on Python 3."
readme = "README.rst"
@ -28,7 +28,7 @@ dependencies = [
# change behavior based on presence of supervisor. Deprecated with #127228
# Lib can be removed with 2025.11
"aiohasupervisor==0.2.1",
"aiohttp==3.10.10",
"aiohttp==3.10.11",
"aiohttp_cors==0.7.0",
"aiohttp-fast-zlib==0.1.1",
"aiozoneinfo==0.2.1",

View File

@ -5,7 +5,7 @@
# Home Assistant Core
aiodns==3.2.0
aiohasupervisor==0.2.1
aiohttp==3.10.10
aiohttp==3.10.11
aiohttp_cors==0.7.0
aiohttp-fast-zlib==0.1.1
aiozoneinfo==0.2.1

View File

@ -354,7 +354,7 @@ aiorecollect==2023.09.0
aioridwell==2024.01.0
# homeassistant.components.ruckus_unleashed
aioruckus==0.41
aioruckus==0.42
# homeassistant.components.russound_rio
aiorussound==4.0.5
@ -381,7 +381,7 @@ aiosolaredge==0.2.0
aiosteamist==1.0.0
# homeassistant.components.cambridge_audio
aiostreammagic==2.8.4
aiostreammagic==2.8.5
# homeassistant.components.switcher_kis
aioswitcher==4.4.0
@ -414,7 +414,7 @@ aiowatttime==0.1.1
aiowebostv==0.4.2
# homeassistant.components.withings
aiowithings==3.1.1
aiowithings==3.1.3
# homeassistant.components.yandex_transport
aioymaps==1.2.5
@ -986,7 +986,7 @@ gitterpy==0.1.7
glances-api==0.8.0
# homeassistant.components.go2rtc
go2rtc-client==0.1.0
go2rtc-client==0.1.1
# homeassistant.components.goalzero
goalzero==0.2.2
@ -1011,7 +1011,7 @@ google-cloud-texttospeech==2.17.2
google-generativeai==0.8.2
# homeassistant.components.nest
google-nest-sdm==6.1.4
google-nest-sdm==6.1.5
# homeassistant.components.google_photos
google-photos-library-api==0.12.1
@ -1454,7 +1454,7 @@ nextdns==3.3.0
nibe==2.11.0
# homeassistant.components.nice_go
nice-go==0.3.9
nice-go==0.3.10
# homeassistant.components.niko_home_control
niko-home-control==0.2.1
@ -1735,7 +1735,7 @@ pyRFXtrx==0.31.1
pySDCP==1
# homeassistant.components.tibber
pyTibber==0.30.4
pyTibber==0.30.8
# homeassistant.components.dlink
pyW215==0.7.0
@ -2155,7 +2155,7 @@ pypck==0.7.24
pypjlink2==1.2.1
# homeassistant.components.plaato
pyplaato==0.0.18
pyplaato==0.0.19
# homeassistant.components.point
pypoint==3.0.0
@ -2356,7 +2356,7 @@ python-juicenet==1.1.0
python-kasa[speedups]==0.7.7
# homeassistant.components.linkplay
python-linkplay==0.0.17
python-linkplay==0.0.20
# homeassistant.components.lirc
# python-lirc==1.2.3
@ -2396,7 +2396,7 @@ python-ripple-api==0.0.3
python-roborock==2.7.2
# homeassistant.components.smarttub
python-smarttub==0.0.36
python-smarttub==0.0.38
# homeassistant.components.songpal
python-songpal==0.16.2
@ -2547,7 +2547,7 @@ renault-api==0.2.7
renson-endura-delta==1.7.1
# homeassistant.components.reolink
reolink-aio==0.10.4
reolink-aio==0.11.1
# homeassistant.components.idteck_prox
rfk101py==0.0.1
@ -2556,7 +2556,7 @@ rfk101py==0.0.1
rflink==0.0.66
# homeassistant.components.ring
ring-doorbell==0.9.8
ring-doorbell==0.9.12
# homeassistant.components.fleetgo
ritassist==0.9.2
@ -2623,7 +2623,7 @@ sendgrid==6.8.2
# homeassistant.components.emulated_kasa
# homeassistant.components.sense
sense-energy==0.13.3
sense-energy==0.13.4
# homeassistant.components.sensirion_ble
sensirion-ble==0.1.1
@ -2683,7 +2683,7 @@ smhi-pkg==1.0.18
snapcast==2.3.6
# homeassistant.components.sonos
soco==0.30.4
soco==0.30.6
# homeassistant.components.solaredge_local
solaredge-local==0.2.3
@ -2707,7 +2707,7 @@ speak2mary==1.4.0
speedtest-cli==2.1.3
# homeassistant.components.spotify
spotifyaio==0.8.7
spotifyaio==0.8.8
# homeassistant.components.sql
sqlparse==0.5.0
@ -2900,7 +2900,7 @@ unifi_ap==0.0.1
unifiled==0.11
# homeassistant.components.zha
universal-silabs-flasher==0.0.24
universal-silabs-flasher==0.0.25
# homeassistant.components.upb
upb-lib==0.5.8
@ -3066,7 +3066,7 @@ zeroconf==0.136.0
zeversolar==0.3.2
# homeassistant.components.zha
zha==0.0.37
zha==0.0.39
# homeassistant.components.zhong_hong
zhong-hong-hvac==1.0.13

View File

@ -336,7 +336,7 @@ aiorecollect==2023.09.0
aioridwell==2024.01.0
# homeassistant.components.ruckus_unleashed
aioruckus==0.41
aioruckus==0.42
# homeassistant.components.russound_rio
aiorussound==4.0.5
@ -363,7 +363,7 @@ aiosolaredge==0.2.0
aiosteamist==1.0.0
# homeassistant.components.cambridge_audio
aiostreammagic==2.8.4
aiostreammagic==2.8.5
# homeassistant.components.switcher_kis
aioswitcher==4.4.0
@ -396,7 +396,7 @@ aiowatttime==0.1.1
aiowebostv==0.4.2
# homeassistant.components.withings
aiowithings==3.1.1
aiowithings==3.1.3
# homeassistant.components.yandex_transport
aioymaps==1.2.5
@ -836,7 +836,7 @@ gios==5.0.0
glances-api==0.8.0
# homeassistant.components.go2rtc
go2rtc-client==0.1.0
go2rtc-client==0.1.1
# homeassistant.components.goalzero
goalzero==0.2.2
@ -861,7 +861,7 @@ google-cloud-texttospeech==2.17.2
google-generativeai==0.8.2
# homeassistant.components.nest
google-nest-sdm==6.1.4
google-nest-sdm==6.1.5
# homeassistant.components.google_photos
google-photos-library-api==0.12.1
@ -1214,7 +1214,7 @@ nextdns==3.3.0
nibe==2.11.0
# homeassistant.components.nice_go
nice-go==0.3.9
nice-go==0.3.10
# homeassistant.components.nfandroidtv
notifications-android-tv==0.1.5
@ -1412,7 +1412,7 @@ pyElectra==1.2.4
pyRFXtrx==0.31.1
# homeassistant.components.tibber
pyTibber==0.30.4
pyTibber==0.30.8
# homeassistant.components.dlink
pyW215==0.7.0
@ -1739,7 +1739,7 @@ pypck==0.7.24
pypjlink2==1.2.1
# homeassistant.components.plaato
pyplaato==0.0.18
pyplaato==0.0.19
# homeassistant.components.point
pypoint==3.0.0
@ -1883,7 +1883,7 @@ python-juicenet==1.1.0
python-kasa[speedups]==0.7.7
# homeassistant.components.linkplay
python-linkplay==0.0.17
python-linkplay==0.0.20
# homeassistant.components.matter
python-matter-server==6.6.0
@ -1917,7 +1917,7 @@ python-rabbitair==0.0.8
python-roborock==2.7.2
# homeassistant.components.smarttub
python-smarttub==0.0.36
python-smarttub==0.0.38
# homeassistant.components.songpal
python-songpal==0.16.2
@ -2038,13 +2038,13 @@ renault-api==0.2.7
renson-endura-delta==1.7.1
# homeassistant.components.reolink
reolink-aio==0.10.4
reolink-aio==0.11.1
# homeassistant.components.rflink
rflink==0.0.66
# homeassistant.components.ring
ring-doorbell==0.9.8
ring-doorbell==0.9.12
# homeassistant.components.roku
rokuecp==0.19.3
@ -2090,7 +2090,7 @@ securetar==2024.2.1
# homeassistant.components.emulated_kasa
# homeassistant.components.sense
sense-energy==0.13.3
sense-energy==0.13.4
# homeassistant.components.sensirion_ble
sensirion-ble==0.1.1
@ -2138,7 +2138,7 @@ smhi-pkg==1.0.18
snapcast==2.3.6
# homeassistant.components.sonos
soco==0.30.4
soco==0.30.6
# homeassistant.components.solarlog
solarlog_cli==0.3.2
@ -2159,7 +2159,7 @@ speak2mary==1.4.0
speedtest-cli==2.1.3
# homeassistant.components.spotify
spotifyaio==0.8.7
spotifyaio==0.8.8
# homeassistant.components.sql
sqlparse==0.5.0
@ -2307,7 +2307,7 @@ ultraheat-api==0.5.7
unifi-discovery==1.2.0
# homeassistant.components.zha
universal-silabs-flasher==0.0.24
universal-silabs-flasher==0.0.25
# homeassistant.components.upb
upb-lib==0.5.8
@ -2449,7 +2449,7 @@ zeroconf==0.136.0
zeversolar==0.3.2
# homeassistant.components.zha
zha==0.0.37
zha==0.0.39
# homeassistant.components.zwave_js
zwave-js-server-python==0.58.1

View File

@ -4,6 +4,7 @@ from dataclasses import dataclass
from pathlib import Path
from homeassistant import core
from homeassistant.components.go2rtc.const import RECOMMENDED_VERSION as GO2RTC_VERSION
from homeassistant.const import Platform
from homeassistant.util import executor, thread
from script.gen_requirements_all import gather_recursive_requirements
@ -112,8 +113,6 @@ LABEL "com.github.actions.icon"="terminal"
LABEL "com.github.actions.color"="gray-dark"
"""
_GO2RTC_VERSION = "1.9.6"
def _get_package_versions(file: Path, packages: set[str]) -> dict[str, str]:
package_versions: dict[str, str] = {}
@ -162,6 +161,8 @@ def _generate_hassfest_dockerimage(
packages.update(
gather_recursive_requirements(platform.value, already_checked_domains)
)
# Add go2rtc requirements as this file needs the go2rtc integration
packages.update(gather_recursive_requirements("go2rtc", already_checked_domains))
return File(
_HASSFEST_TEMPLATE.format(
@ -197,7 +198,7 @@ def _generate_files(config: Config) -> list[File]:
DOCKERFILE_TEMPLATE.format(
timeout=timeout,
**package_versions,
go2rtc=_GO2RTC_VERSION,
go2rtc=GO2RTC_VERSION,
),
config.root / "Dockerfile",
),

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.2 hassil==1.7.4 home-assistant-intents==2024.11.6 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2
PyTurboJPEG==1.7.5 go2rtc-client==0.1.1 ha-ffmpeg==3.2.2 hassil==1.7.4 home-assistant-intents==2024.11.6 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>"

View File

@ -1,10 +1,8 @@
"""Test the Advantage Air Binary Sensor Platform."""
from datetime import timedelta
from unittest.mock import AsyncMock
from unittest.mock import AsyncMock, patch
from homeassistant.components.advantage_air import ADVANTAGE_AIR_SYNC_INTERVAL
from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
@ -70,22 +68,14 @@ async def test_binary_sensor_async_setup_entry(
assert not hass.states.get(entity_id)
mock_get.reset_mock()
entity_registry.async_update_entity(entity_id=entity_id, disabled_by=None)
await hass.async_block_till_done()
async_fire_time_changed(
hass,
dt_util.utcnow() + timedelta(seconds=ADVANTAGE_AIR_SYNC_INTERVAL + 1),
)
await hass.async_block_till_done(wait_background_tasks=True)
assert len(mock_get.mock_calls) == 1
with patch("homeassistant.config_entries.RELOAD_AFTER_UPDATE_DELAY", 1):
entity_registry.async_update_entity(entity_id=entity_id, disabled_by=None)
await hass.async_block_till_done()
async_fire_time_changed(
hass,
dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1),
)
await hass.async_block_till_done(wait_background_tasks=True)
assert len(mock_get.mock_calls) == 2
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=2))
await hass.async_block_till_done(wait_background_tasks=True)
assert len(mock_get.mock_calls) == 1
state = hass.states.get(entity_id)
assert state
@ -101,22 +91,14 @@ async def test_binary_sensor_async_setup_entry(
assert not hass.states.get(entity_id)
mock_get.reset_mock()
entity_registry.async_update_entity(entity_id=entity_id, disabled_by=None)
await hass.async_block_till_done()
async_fire_time_changed(
hass,
dt_util.utcnow() + timedelta(seconds=ADVANTAGE_AIR_SYNC_INTERVAL + 1),
)
await hass.async_block_till_done(wait_background_tasks=True)
assert len(mock_get.mock_calls) == 1
with patch("homeassistant.config_entries.RELOAD_AFTER_UPDATE_DELAY", 1):
entity_registry.async_update_entity(entity_id=entity_id, disabled_by=None)
await hass.async_block_till_done()
async_fire_time_changed(
hass,
dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1),
)
await hass.async_block_till_done(wait_background_tasks=True)
assert len(mock_get.mock_calls) == 2
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=2))
await hass.async_block_till_done(wait_background_tasks=True)
assert len(mock_get.mock_calls) == 1
state = hass.states.get(entity_id)
assert state

View File

@ -1,15 +1,13 @@
"""Test the Advantage Air Sensor Platform."""
from datetime import timedelta
from unittest.mock import AsyncMock
from unittest.mock import AsyncMock, patch
from homeassistant.components.advantage_air import ADVANTAGE_AIR_SYNC_INTERVAL
from homeassistant.components.advantage_air.const import DOMAIN as ADVANTAGE_AIR_DOMAIN
from homeassistant.components.advantage_air.sensor import (
ADVANTAGE_AIR_SERVICE_SET_TIME_TO,
ADVANTAGE_AIR_SET_COUNTDOWN_VALUE,
)
from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
@ -124,23 +122,15 @@ async def test_sensor_platform_disabled_entity(
assert not hass.states.get(entity_id)
entity_registry.async_update_entity(entity_id=entity_id, disabled_by=None)
await hass.async_block_till_done(wait_background_tasks=True)
mock_get.reset_mock()
async_fire_time_changed(
hass,
dt_util.utcnow() + timedelta(seconds=ADVANTAGE_AIR_SYNC_INTERVAL + 1),
)
await hass.async_block_till_done(wait_background_tasks=True)
assert len(mock_get.mock_calls) == 1
with patch("homeassistant.config_entries.RELOAD_AFTER_UPDATE_DELAY", 1):
entity_registry.async_update_entity(entity_id=entity_id, disabled_by=None)
await hass.async_block_till_done(wait_background_tasks=True)
async_fire_time_changed(
hass,
dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1),
)
await hass.async_block_till_done(wait_background_tasks=True)
assert len(mock_get.mock_calls) == 2
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=2))
await hass.async_block_till_done(wait_background_tasks=True)
assert len(mock_get.mock_calls) == 1
state = hass.states.get(entity_id)
assert state

View File

@ -489,3 +489,96 @@ async def test_alarm_control_panel_log_deprecated_state_warning_using_attr_state
)
# Test we only log once
assert "Entities should implement the 'alarm_state' property and" not in caplog.text
async def test_alarm_control_panel_deprecated_state_does_not_break_state(
hass: HomeAssistant,
code_format: CodeFormat | None,
supported_features: AlarmControlPanelEntityFeature,
code_arm_required: bool,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test using _attr_state attribute does not break state."""
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, [ALARM_CONTROL_PANEL_DOMAIN]
)
return True
mock_integration(
hass,
MockModule(
TEST_DOMAIN,
async_setup_entry=async_setup_entry_init,
),
)
class MockLegacyAlarmControlPanel(MockAlarmControlPanel):
"""Mocked alarm control entity."""
def __init__(
self,
supported_features: AlarmControlPanelEntityFeature = AlarmControlPanelEntityFeature(
0
),
code_format: CodeFormat | None = None,
code_arm_required: bool = True,
) -> None:
"""Initialize the alarm control."""
self._attr_state = "armed_away"
super().__init__(supported_features, code_format, code_arm_required)
def alarm_disarm(self, code: str | None = None) -> None:
"""Mock alarm disarm calls."""
self._attr_state = "disarmed"
entity = MockLegacyAlarmControlPanel(
supported_features=supported_features,
code_format=code_format,
code_arm_required=code_arm_required,
)
async def async_setup_entry_platform(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up test alarm control panel platform via config entry."""
async_add_entities([entity])
mock_platform(
hass,
f"{TEST_DOMAIN}.{ALARM_CONTROL_PANEL_DOMAIN}",
MockPlatform(async_setup_entry=async_setup_entry_platform),
)
with patch.object(
MockLegacyAlarmControlPanel,
"__module__",
"tests.custom_components.test.alarm_control_panel",
):
config_entry = MockConfigEntry(domain=TEST_DOMAIN)
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
state = hass.states.get(entity.entity_id)
assert state is not None
assert state.state == "armed_away"
with patch.object(
MockLegacyAlarmControlPanel,
"__module__",
"tests.custom_components.test.alarm_control_panel",
):
await help_test_async_alarm_control_panel_service(
hass, entity.entity_id, SERVICE_ALARM_DISARM
)
state = hass.states.get(entity.entity_id)
assert state is not None
assert state.state == "disarmed"

View File

@ -44,7 +44,7 @@ async def test_form_home(hass: HomeAssistant) -> None:
await hass.async_block_till_done()
assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["title"] == "CO2 Signal"
assert result2["title"] == "Electricity Maps"
assert result2["data"] == {
"api_key": "api_key",
}
@ -185,7 +185,7 @@ async def test_form_error_handling(
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "CO2 Signal"
assert result["title"] == "Electricity Maps"
assert result["data"] == {
"api_key": "api_key",
}

View File

@ -418,6 +418,44 @@ async def test_trigger_sentences(hass: HomeAssistant) -> None:
assert len(callback.mock_calls) == 0
@pytest.mark.parametrize(
("language", "expected"),
[("en", "English done"), ("de", "German done"), ("not_translated", "Done")],
)
@pytest.mark.usefixtures("init_components")
async def test_trigger_sentence_response_translation(
hass: HomeAssistant, language: str, expected: str
) -> None:
"""Test translation of default response 'done'."""
hass.config.language = language
agent = hass.data[DATA_DEFAULT_ENTITY]
assert isinstance(agent, default_agent.DefaultAgent)
translations = {
"en": {"component.conversation.conversation.agent.done": "English done"},
"de": {"component.conversation.conversation.agent.done": "German done"},
"not_translated": {},
}
with patch(
"homeassistant.components.conversation.default_agent.translation.async_get_translations",
return_value=translations.get(language),
):
unregister = agent.register_trigger(
["test sentence"], AsyncMock(return_value=None)
)
result = await conversation.async_converse(
hass, "test sentence", None, Context()
)
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
assert result.response.speech == {
"plain": {"speech": expected, "extra_data": None}
}
unregister()
@pytest.mark.usefixtures("init_components", "sl_setup")
async def test_shopping_list_add_item(hass: HomeAssistant) -> None:
"""Test adding an item to the shopping list through the default agent."""

View File

@ -275,7 +275,9 @@ async def test_limit_refetch(
with (
pytest.raises(aiohttp.ServerTimeoutError),
patch("asyncio.timeout", side_effect=TimeoutError()),
patch.object(
client.session._connector, "connect", side_effect=asyncio.TimeoutError
),
):
resp = await client.get("/api/camera_proxy/camera.config_test")

View File

@ -637,10 +637,6 @@ async def test_form_stream_other_error(hass: HomeAssistant, user_flow) -> None:
await hass.async_block_till_done()
@pytest.mark.parametrize( # Remove when translations fixed
"ignore_translations",
["component.generic.config.error.Some message"],
)
@respx.mock
@pytest.mark.usefixtures("fakeimg_png")
async def test_form_stream_worker_error(
@ -656,7 +652,8 @@ async def test_form_stream_worker_error(
TESTDATA,
)
assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"stream_source": "Some message"}
assert result2["errors"] == {"stream_source": "unknown_with_details"}
assert result2["description_placeholders"] == {"error": "Some message"}
@respx.mock

View File

@ -3,9 +3,11 @@
from collections.abc import Generator
from unittest.mock import AsyncMock, Mock, patch
from awesomeversion import AwesomeVersion
from go2rtc_client.rest import _StreamClient, _WebRTCClient
import pytest
from homeassistant.components.go2rtc.const import RECOMMENDED_VERSION
from homeassistant.components.go2rtc.server import Server
GO2RTC_PATH = "homeassistant.components.go2rtc"
@ -23,7 +25,9 @@ def rest_client() -> Generator[AsyncMock]:
client = mock_client.return_value
client.streams = streams = Mock(spec_set=_StreamClient)
streams.list.return_value = {}
client.validate_server_version = AsyncMock()
client.validate_server_version = AsyncMock(
return_value=AwesomeVersion(RECOMMENDED_VERSION)
)
client.webrtc = Mock(spec_set=_WebRTCClient)
yield client

View File

@ -3,9 +3,10 @@
from collections.abc import Callable, Generator
import logging
from typing import NamedTuple
from unittest.mock import AsyncMock, Mock, call, patch
from unittest.mock import AsyncMock, Mock, patch
from aiohttp.client_exceptions import ClientConnectionError, ServerConnectionError
from awesomeversion import AwesomeVersion
from go2rtc_client import Stream
from go2rtc_client.exceptions import Go2RtcClientError, Go2RtcVersionError
from go2rtc_client.models import Producer
@ -36,10 +37,12 @@ from homeassistant.components.go2rtc.const import (
CONF_DEBUG_UI,
DEBUG_UI_URL_MESSAGE,
DOMAIN,
RECOMMENDED_VERSION,
)
from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow
from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.typing import ConfigType
from homeassistant.setup import async_setup_component
@ -199,6 +202,7 @@ async def init_test_integration(
async def _test_setup_and_signaling(
hass: HomeAssistant,
issue_registry: ir.IssueRegistry,
rest_client: AsyncMock,
ws_client: Mock,
config: ConfigType,
@ -211,6 +215,7 @@ async def _test_setup_and_signaling(
assert await async_setup_component(hass, DOMAIN, config)
await hass.async_block_till_done(wait_background_tasks=True)
assert issue_registry.async_get_issue(DOMAIN, "recommended_version") is None
config_entries = hass.config_entries.async_entries(DOMAIN)
assert len(config_entries) == 1
assert config_entries[0].state == ConfigEntryState.LOADED
@ -238,7 +243,11 @@ async def _test_setup_and_signaling(
await test()
rest_client.streams.add.assert_called_once_with(
entity_id, ["rtsp://stream", f"ffmpeg:{camera.entity_id}#audio=opus"]
entity_id,
[
"rtsp://stream",
f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug",
],
)
# Stream exists but the source is different
@ -252,7 +261,11 @@ async def _test_setup_and_signaling(
await test()
rest_client.streams.add.assert_called_once_with(
entity_id, ["rtsp://stream", f"ffmpeg:{camera.entity_id}#audio=opus"]
entity_id,
[
"rtsp://stream",
f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug",
],
)
# If the stream is already added, the stream should not be added again.
@ -296,8 +309,9 @@ async def _test_setup_and_signaling(
],
)
@pytest.mark.parametrize("has_go2rtc_entry", [True, False])
async def test_setup_managed(
async def test_setup_go_binary(
hass: HomeAssistant,
issue_registry: ir.IssueRegistry,
rest_client: AsyncMock,
ws_client: Mock,
server: AsyncMock,
@ -308,131 +322,21 @@ async def test_setup_managed(
config: ConfigType,
ui_enabled: bool,
) -> None:
"""Test the go2rtc setup with managed go2rtc instance."""
"""Test the go2rtc config entry with binary."""
assert (len(hass.config_entries.async_entries(DOMAIN)) == 1) == has_go2rtc_entry
camera = init_test_integration
entity_id = camera.entity_id
stream_name_orginal = camera.entity_id + "_orginal"
assert camera.frontend_stream_type == StreamType.HLS
def after_setup() -> None:
server.assert_called_once_with(hass, "/usr/bin/go2rtc", enable_ui=ui_enabled)
server_start.assert_called_once()
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 == ConfigEntryState.LOADED
server.assert_called_once_with(hass, "/usr/bin/go2rtc", enable_ui=ui_enabled)
server_start.assert_called_once()
receive_message_callback = Mock(spec_set=WebRTCSendMessage)
async def test() -> None:
await camera.async_handle_async_webrtc_offer(
OFFER_SDP, "session_id", receive_message_callback
)
ws_client.send.assert_called_once_with(
WebRTCOffer(
OFFER_SDP,
camera.async_get_webrtc_client_configuration().configuration.ice_servers,
)
)
ws_client.subscribe.assert_called_once()
# Simulate the answer from the go2rtc server
callback = ws_client.subscribe.call_args[0][0]
callback(WebRTCAnswer(ANSWER_SDP))
receive_message_callback.assert_called_once_with(HAWebRTCAnswer(ANSWER_SDP))
await test()
stream_added_calls = [
call(stream_name_orginal, "rtsp://stream"),
call(
entity_id,
[
f"rtsp://127.0.0.1:18554/{stream_name_orginal}",
f"ffmpeg:{stream_name_orginal}#audio=opus",
],
),
]
assert rest_client.streams.add.call_args_list == stream_added_calls
# Stream original missing
rest_client.streams.add.reset_mock()
rest_client.streams.list.return_value = {
entity_id: Stream(
[
Producer(f"rtsp://127.0.0.1:18554/{stream_name_orginal}"),
Producer(f"ffmpeg:{stream_name_orginal}#audio=opus"),
]
)
}
receive_message_callback.reset_mock()
ws_client.reset_mock()
await test()
assert rest_client.streams.add.call_args_list == stream_added_calls
# Stream original source different
rest_client.streams.add.reset_mock()
rest_client.streams.list.return_value = {
stream_name_orginal: Stream([Producer("rtsp://different")]),
entity_id: Stream(
[
Producer(f"rtsp://127.0.0.1:18554/{stream_name_orginal}"),
Producer(f"ffmpeg:{stream_name_orginal}#audio=opus"),
]
),
}
receive_message_callback.reset_mock()
ws_client.reset_mock()
await test()
assert rest_client.streams.add.call_args_list == stream_added_calls
# Stream source different
rest_client.streams.add.reset_mock()
rest_client.streams.list.return_value = {
stream_name_orginal: Stream([Producer("rtsp://stream")]),
entity_id: Stream([Producer("rtsp://different")]),
}
receive_message_callback.reset_mock()
ws_client.reset_mock()
await test()
assert rest_client.streams.add.call_args_list == stream_added_calls
# If the stream is already added, the stream should not be added again.
rest_client.streams.add.reset_mock()
rest_client.streams.list.return_value = {
stream_name_orginal: Stream([Producer("rtsp://stream")]),
entity_id: Stream(
[
Producer(f"rtsp://127.0.0.1:18554/{stream_name_orginal}"),
Producer(f"ffmpeg:{stream_name_orginal}#audio=opus"),
]
),
}
receive_message_callback.reset_mock()
ws_client.reset_mock()
await test()
rest_client.streams.add.assert_not_called()
assert isinstance(camera._webrtc_provider, WebRTCProvider)
# Set stream source to None and provider should be skipped
rest_client.streams.list.return_value = {}
receive_message_callback.reset_mock()
camera.set_stream_source(None)
await camera.async_handle_async_webrtc_offer(
OFFER_SDP, "session_id", receive_message_callback
)
receive_message_callback.assert_called_once_with(
WebRTCError("go2rtc_webrtc_offer_failed", "Camera has no stream source")
await _test_setup_and_signaling(
hass,
issue_registry,
rest_client,
ws_client,
config,
after_setup,
init_test_integration,
)
await hass.async_stop()
@ -448,8 +352,9 @@ async def test_setup_managed(
],
)
@pytest.mark.parametrize("has_go2rtc_entry", [True, False])
async def test_setup_self_hosted(
async def test_setup(
hass: HomeAssistant,
issue_registry: ir.IssueRegistry,
rest_client: AsyncMock,
ws_client: Mock,
server: Mock,
@ -458,83 +363,22 @@ async def test_setup_self_hosted(
mock_is_docker_env: Mock,
has_go2rtc_entry: bool,
) -> None:
"""Test the go2rtc with selfhosted go2rtc instance."""
"""Test the go2rtc config entry without binary."""
assert (len(hass.config_entries.async_entries(DOMAIN)) == 1) == has_go2rtc_entry
config = {DOMAIN: {CONF_URL: "http://localhost:1984/"}}
camera = init_test_integration
entity_id = camera.entity_id
assert camera.frontend_stream_type == StreamType.HLS
def after_setup() -> None:
server.assert_not_called()
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 == ConfigEntryState.LOADED
server.assert_not_called()
receive_message_callback = Mock(spec_set=WebRTCSendMessage)
async def test() -> None:
await camera.async_handle_async_webrtc_offer(
OFFER_SDP, "session_id", receive_message_callback
)
ws_client.send.assert_called_once_with(
WebRTCOffer(
OFFER_SDP,
camera.async_get_webrtc_client_configuration().configuration.ice_servers,
)
)
ws_client.subscribe.assert_called_once()
# Simulate the answer from the go2rtc server
callback = ws_client.subscribe.call_args[0][0]
callback(WebRTCAnswer(ANSWER_SDP))
receive_message_callback.assert_called_once_with(HAWebRTCAnswer(ANSWER_SDP))
await test()
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()
rest_client.streams.list.return_value = {
entity_id: Stream([Producer("rtsp://stream")])
}
receive_message_callback.reset_mock()
ws_client.reset_mock()
await test()
rest_client.streams.add.assert_not_called()
assert isinstance(camera._webrtc_provider, WebRTCProvider)
# Set stream source to None and provider should be skipped
rest_client.streams.list.return_value = {}
receive_message_callback.reset_mock()
camera.set_stream_source(None)
await camera.async_handle_async_webrtc_offer(
OFFER_SDP, "session_id", receive_message_callback
)
receive_message_callback.assert_called_once_with(
WebRTCError("go2rtc_webrtc_offer_failed", "Camera has no stream source")
await _test_setup_and_signaling(
hass,
issue_registry,
rest_client,
ws_client,
config,
after_setup,
init_test_integration,
)
mock_get_binary.assert_not_called()
@ -886,3 +730,30 @@ async def test_config_entry_remove(hass: HomeAssistant) -> None:
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
assert not await hass.config_entries.async_setup(config_entry.entry_id)
assert len(hass.config_entries.async_entries(DOMAIN)) == 0
@pytest.mark.parametrize("config", [{DOMAIN: {CONF_URL: "http://localhost:1984"}}])
@pytest.mark.usefixtures("server")
async def test_setup_with_recommended_version_repair(
hass: HomeAssistant,
issue_registry: ir.IssueRegistry,
rest_client: AsyncMock,
config: ConfigType,
) -> None:
"""Test setup integration entry fails."""
rest_client.validate_server_version.return_value = AwesomeVersion("1.9.5")
assert await async_setup_component(hass, DOMAIN, config)
await hass.async_block_till_done(wait_background_tasks=True)
# Verify the issue is created
issue = issue_registry.async_get_issue(DOMAIN, "recommended_version")
assert issue
assert issue.is_fixable is False
assert issue.is_persistent is False
assert issue.severity == ir.IssueSeverity.WARNING
assert issue.issue_id == "recommended_version"
assert issue.translation_key == "recommended_version"
assert issue.translation_placeholders == {
"recommended_version": RECOMMENDED_VERSION,
"current_version": "1.9.5",
}

View File

@ -10,7 +10,12 @@ from homeassistant.components.alarm_control_panel import (
)
from homeassistant.components.homekit.const import ATTR_VALUE
from homeassistant.components.homekit.type_security_systems import SecuritySystem
from homeassistant.const import ATTR_CODE, ATTR_ENTITY_ID, STATE_UNKNOWN
from homeassistant.const import (
ATTR_CODE,
ATTR_ENTITY_ID,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.core import Event, HomeAssistant
from tests.common import async_mock_service
@ -307,3 +312,33 @@ async def test_supported_states(hass: HomeAssistant, hk_driver) -> None:
for val in valid_target_values.values():
assert val in test_config.get("target_values")
@pytest.mark.parametrize(
("state"),
[
(None),
("None"),
(STATE_UNKNOWN),
(STATE_UNAVAILABLE),
],
)
async def test_handle_non_alarm_states(
hass: HomeAssistant, hk_driver, events: list[Event], state: str
) -> None:
"""Test we can handle states that should not raise."""
code = "1234"
config = {ATTR_CODE: code}
entity_id = "alarm_control_panel.test"
hass.states.async_set(entity_id, state)
await hass.async_block_till_done()
acc = SecuritySystem(hass, hk_driver, "SecuritySystem", entity_id, 2, config)
acc.run()
await hass.async_block_till_done()
assert acc.aid == 2
assert acc.category == 11 # AlarmSystem
assert acc.char_current_state.value == 3
assert acc.char_target_state.value == 3

View File

@ -1,5 +1,6 @@
"""Test helpers for Husqvarna Automower."""
import asyncio
from collections.abc import Generator
import time
from unittest.mock import AsyncMock, patch
@ -101,10 +102,17 @@ async def setup_credentials(hass: HomeAssistant) -> None:
def mock_automower_client(values) -> Generator[AsyncMock]:
"""Mock a Husqvarna Automower client."""
async def listen() -> None:
"""Mock listen."""
listen_block = asyncio.Event()
await listen_block.wait()
pytest.fail("Listen was not cancelled!")
mock = AsyncMock(spec=AutomowerSession)
mock.auth = AsyncMock(side_effect=ClientWebSocketResponse)
mock.commands = AsyncMock(spec_set=_MowerCommands)
mock.get_status.return_value = values
mock.start_listening = AsyncMock(side_effect=listen)
with patch(
"homeassistant.components.husqvarna_automower.AutomowerSession",

View File

@ -1,14 +1,16 @@
"""Tests for init module."""
from datetime import datetime, timedelta
from asyncio import Event
from datetime import datetime
import http
import time
from unittest.mock import AsyncMock
from unittest.mock import AsyncMock, patch
from aioautomower.exceptions import (
ApiException,
AuthException,
HusqvarnaWSServerHandshakeError,
TimeoutException,
)
from aioautomower.model import MowerAttributes, WorkArea
from freezegun.api import FrozenDateTimeFactory
@ -127,28 +129,77 @@ async def test_update_failed(
assert entry.state is entry_state
@patch(
"homeassistant.components.husqvarna_automower.coordinator.DEFAULT_RECONNECT_TIME", 0
)
@pytest.mark.parametrize(
("method_path", "exception", "error_msg"),
[
(
["auth", "websocket_connect"],
HusqvarnaWSServerHandshakeError,
"Failed to connect to websocket.",
),
(
["start_listening"],
TimeoutException,
"Failed to listen to websocket.",
),
],
)
async def test_websocket_not_available(
hass: HomeAssistant,
mock_automower_client: AsyncMock,
mock_config_entry: MockConfigEntry,
caplog: pytest.LogCaptureFixture,
freezer: FrozenDateTimeFactory,
method_path: list[str],
exception: type[Exception],
error_msg: str,
) -> None:
"""Test trying reload the websocket."""
mock_automower_client.start_listening.side_effect = HusqvarnaWSServerHandshakeError(
"Boom"
)
"""Test trying to reload the websocket."""
calls = []
mock_called = Event()
mock_stall = Event()
async def mock_function():
mock_called.set()
await mock_stall.wait()
# Raise the first time the method is awaited
if not calls:
calls.append(None)
raise exception("Boom")
if mock_side_effect:
await mock_side_effect()
# Find the method to mock
mock = mock_automower_client
for itm in method_path:
mock = getattr(mock, itm)
mock_side_effect = mock.side_effect
mock.side_effect = mock_function
# Setup integration and verify log error message
await setup_integration(hass, mock_config_entry)
assert "Failed to connect to websocket. Trying to reconnect: Boom" in caplog.text
assert mock_automower_client.auth.websocket_connect.call_count == 1
assert mock_automower_client.start_listening.call_count == 1
assert mock_config_entry.state is ConfigEntryState.LOADED
freezer.tick(timedelta(seconds=2))
async_fire_time_changed(hass)
await mock_called.wait()
mock_called.clear()
# Allow the exception to be raised
mock_stall.set()
assert mock.call_count == 1
await hass.async_block_till_done()
assert mock_automower_client.auth.websocket_connect.call_count == 2
assert mock_automower_client.start_listening.call_count == 2
assert mock_config_entry.state is ConfigEntryState.LOADED
assert f"{error_msg} Trying to reconnect: Boom" in caplog.text
# Simulate a successful connection
caplog.clear()
await mock_called.wait()
mock_called.clear()
await hass.async_block_till_done()
assert mock.call_count == 2
assert "Trying to reconnect: Boom" not in caplog.text
# Simulate hass shutting down
await hass.async_stop()
assert mock.call_count == 2
async def test_device_info(

View File

@ -4,7 +4,6 @@ import json
from unittest.mock import patch
from pygti.exceptions import CannotConnect, InvalidAuth
import pytest
from homeassistant.components.hvv_departures.const import (
CONF_FILTER,
@ -313,10 +312,6 @@ async def test_options_flow(hass: HomeAssistant) -> None:
}
@pytest.mark.parametrize( # Remove when translations fixed
"ignore_translations",
["component.hvv_departures.options.error.invalid_auth"],
)
async def test_options_flow_invalid_auth(hass: HomeAssistant) -> None:
"""Test that options flow works."""
@ -360,10 +355,6 @@ async def test_options_flow_invalid_auth(hass: HomeAssistant) -> None:
assert result["errors"] == {"base": "invalid_auth"}
@pytest.mark.parametrize( # Remove when translations fixed
"ignore_translations",
["component.hvv_departures.options.error.cannot_connect"],
)
async def test_options_flow_cannot_connect(hass: HomeAssistant) -> None:
"""Test that options flow works."""

Some files were not shown because too many files have changed in this diff Show More