mirror of
https://github.com/home-assistant/core.git
synced 2025-07-08 13:57:10 +00:00
2024.11.2 (#130713)
This commit is contained in:
commit
847afabed1
@ -90,7 +90,7 @@ repos:
|
|||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
language: script
|
language: script
|
||||||
types: [text]
|
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
|
- id: hassfest-mypy-config
|
||||||
name: hassfest-mypy-config
|
name: hassfest-mypy-config
|
||||||
entry: script/run-in-env.sh python3 -m script.hassfest -p mypy_config
|
entry: script/run-in-env.sh python3 -m script.hassfest -p mypy_config
|
||||||
|
@ -55,7 +55,7 @@ RUN \
|
|||||||
"armv7") go2rtc_suffix='arm' ;; \
|
"armv7") go2rtc_suffix='arm' ;; \
|
||||||
*) go2rtc_suffix=${BUILD_ARCH} ;; \
|
*) go2rtc_suffix=${BUILD_ARCH} ;; \
|
||||||
esac \
|
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 \
|
&& chmod +x /bin/go2rtc \
|
||||||
# Verify go2rtc can be executed
|
# Verify go2rtc can be executed
|
||||||
&& go2rtc --version
|
&& go2rtc --version
|
||||||
|
@ -6,7 +6,7 @@ import asyncio
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from functools import partial
|
from functools import partial
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Final, final
|
from typing import TYPE_CHECKING, Any, Final, final
|
||||||
|
|
||||||
from propcache import cached_property
|
from propcache import cached_property
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
@ -221,9 +221,15 @@ class AlarmControlPanelEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_A
|
|||||||
@property
|
@property
|
||||||
def state(self) -> str | None:
|
def state(self) -> str | None:
|
||||||
"""Return the current state."""
|
"""Return the current state."""
|
||||||
if (alarm_state := self.alarm_state) is None:
|
if (alarm_state := self.alarm_state) is not None:
|
||||||
return None
|
|
||||||
return alarm_state
|
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
|
@cached_property
|
||||||
def alarm_state(self) -> AlarmControlPanelState | None:
|
def alarm_state(self) -> AlarmControlPanelState | None:
|
||||||
|
@ -7,6 +7,6 @@
|
|||||||
"integration_type": "device",
|
"integration_type": "device",
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"loggers": ["aiostreammagic"],
|
"loggers": ["aiostreammagic"],
|
||||||
"requirements": ["aiostreammagic==2.8.4"],
|
"requirements": ["aiostreammagic==2.8.5"],
|
||||||
"zeroconf": ["_stream-magic._tcp.local.", "_smoip._tcp.local."]
|
"zeroconf": ["_stream-magic._tcp.local.", "_smoip._tcp.local."]
|
||||||
}
|
}
|
||||||
|
@ -51,8 +51,13 @@ CONTROL_ENTITIES: tuple[CambridgeAudioSelectEntityDescription, ...] = (
|
|||||||
CambridgeAudioSelectEntityDescription(
|
CambridgeAudioSelectEntityDescription(
|
||||||
key="display_brightness",
|
key="display_brightness",
|
||||||
translation_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,
|
entity_category=EntityCategory.CONFIG,
|
||||||
|
load_fn=lambda client: client.display.brightness != DisplayBrightness.NONE,
|
||||||
value_fn=lambda client: client.display.brightness,
|
value_fn=lambda client: client.display.brightness,
|
||||||
set_value_fn=lambda client, value: client.set_display_brightness(
|
set_value_fn=lambda client, value: client.set_display_brightness(
|
||||||
DisplayBrightness(value)
|
DisplayBrightness(value)
|
||||||
|
@ -168,7 +168,7 @@ class ElectricityMapsConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
)
|
)
|
||||||
|
|
||||||
return self.async_create_entry(
|
return self.async_create_entry(
|
||||||
title=get_extra_name(data) or "CO2 Signal",
|
title=get_extra_name(data) or "Electricity Maps",
|
||||||
data=data,
|
data=data,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -294,7 +294,7 @@ class DefaultAgent(ConversationEntity):
|
|||||||
self.hass, language, DOMAIN, [DOMAIN]
|
self.hass, language, DOMAIN, [DOMAIN]
|
||||||
)
|
)
|
||||||
response_text = translations.get(
|
response_text = translations.get(
|
||||||
f"component.{DOMAIN}.agent.done", "Done"
|
f"component.{DOMAIN}.conversation.agent.done", "Done"
|
||||||
)
|
)
|
||||||
|
|
||||||
response.async_set_speech(response_text)
|
response.async_set_speech(response_text)
|
||||||
|
@ -6,5 +6,5 @@
|
|||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"loggers": ["sense_energy"],
|
"loggers": ["sense_energy"],
|
||||||
"quality_scale": "internal",
|
"quality_scale": "internal",
|
||||||
"requirements": ["sense-energy==0.13.3"]
|
"requirements": ["sense-energy==0.13.4"]
|
||||||
}
|
}
|
||||||
|
@ -18,7 +18,7 @@
|
|||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"file_path": "The local file path to retrieve the sensor value from",
|
"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"
|
"unit_of_measurement": "Unit of measurement for the sensor"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -282,7 +282,7 @@ async def async_test_stream(
|
|||||||
return {CONF_STREAM_SOURCE: "timeout"}
|
return {CONF_STREAM_SOURCE: "timeout"}
|
||||||
await stream.stop()
|
await stream.stop()
|
||||||
except StreamWorkerError as err:
|
except StreamWorkerError as err:
|
||||||
return {CONF_STREAM_SOURCE: str(err)}
|
return {CONF_STREAM_SOURCE: "unknown_with_details", "error_details": str(err)}
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
return {CONF_STREAM_SOURCE: "stream_not_permitted"}
|
return {CONF_STREAM_SOURCE: "stream_not_permitted"}
|
||||||
except OSError as err:
|
except OSError as err:
|
||||||
@ -339,6 +339,7 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
"""Handle the start of the config flow."""
|
"""Handle the start of the config flow."""
|
||||||
errors = {}
|
errors = {}
|
||||||
|
description_placeholders = {}
|
||||||
hass = self.hass
|
hass = self.hass
|
||||||
if user_input:
|
if user_input:
|
||||||
# Secondary validation because serialised vol can't seem to handle this complexity:
|
# 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
|
# temporary preview for user to check the image
|
||||||
self.preview_cam = user_input
|
self.preview_cam = user_input
|
||||||
return await self.async_step_user_confirm_still()
|
return await self.async_step_user_confirm_still()
|
||||||
|
if "error_details" in errors:
|
||||||
|
description_placeholders["error"] = errors.pop("error_details")
|
||||||
elif self.user_input:
|
elif self.user_input:
|
||||||
user_input = self.user_input
|
user_input = self.user_input
|
||||||
else:
|
else:
|
||||||
@ -379,6 +382,7 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="user",
|
step_id="user",
|
||||||
data_schema=build_schema(user_input),
|
data_schema=build_schema(user_input),
|
||||||
|
description_placeholders=description_placeholders,
|
||||||
errors=errors,
|
errors=errors,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
"config": {
|
"config": {
|
||||||
"error": {
|
"error": {
|
||||||
"unknown": "[%key:common::config_flow::error::unknown%]",
|
"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.",
|
"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": "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.",
|
"unable_still_load_auth": "Unable to load valid image from still image URL: The camera may require a user name and password, or they are not correct.",
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
"config": {
|
"config": {
|
||||||
"step": {
|
"step": {
|
||||||
"user": {
|
"user": {
|
||||||
"title": "Add generic thermostat helper",
|
"title": "Add generic thermostat",
|
||||||
"description": "Create a climate entity that controls the temperature via a switch and sensor.",
|
"description": "Create a climate entity that controls the temperature via a switch and sensor.",
|
||||||
"data": {
|
"data": {
|
||||||
"ac_mode": "Cooling mode",
|
"ac_mode": "Cooling mode",
|
||||||
@ -17,8 +17,8 @@
|
|||||||
"data_description": {
|
"data_description": {
|
||||||
"ac_mode": "Set the actuator specified to be treated as a cooling device instead of a heating device.",
|
"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.",
|
"heater": "Switch entity used to cool or heat depending on A/C mode.",
|
||||||
"target_sensor": "Temperature sensor that reflect the current temperature.",
|
"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. This option will be ignored if the keep alive option is set.",
|
"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.",
|
"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."
|
"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."
|
||||||
}
|
}
|
||||||
|
@ -1,12 +1,10 @@
|
|||||||
"""The go2rtc component."""
|
"""The go2rtc component."""
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
import logging
|
import logging
|
||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
from aiohttp.client_exceptions import ClientConnectionError, ServerConnectionError
|
from aiohttp.client_exceptions import ClientConnectionError, ServerConnectionError
|
||||||
|
from awesomeversion import AwesomeVersion
|
||||||
from go2rtc_client import Go2RtcRestClient
|
from go2rtc_client import Go2RtcRestClient
|
||||||
from go2rtc_client.exceptions import Go2RtcClientError, Go2RtcVersionError
|
from go2rtc_client.exceptions import Go2RtcClientError, Go2RtcVersionError
|
||||||
from go2rtc_client.ws import (
|
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.const import CONF_URL, EVENT_HOMEASSISTANT_STOP
|
||||||
from homeassistant.core import Event, HomeAssistant, callback
|
from homeassistant.core import Event, HomeAssistant, callback
|
||||||
from homeassistant.exceptions import ConfigEntryNotReady
|
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.aiohttp_client import async_get_clientsession
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
from homeassistant.util.hass_dict import HassKey
|
from homeassistant.util.hass_dict import HassKey
|
||||||
@ -45,8 +47,8 @@ from .const import (
|
|||||||
CONF_DEBUG_UI,
|
CONF_DEBUG_UI,
|
||||||
DEBUG_UI_URL_MESSAGE,
|
DEBUG_UI_URL_MESSAGE,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
HA_MANAGED_RTSP_PORT,
|
|
||||||
HA_MANAGED_URL,
|
HA_MANAGED_URL,
|
||||||
|
RECOMMENDED_VERSION,
|
||||||
)
|
)
|
||||||
from .server import Server
|
from .server import Server
|
||||||
|
|
||||||
@ -94,22 +96,13 @@ CONFIG_SCHEMA = vol.Schema(
|
|||||||
extra=vol.ALLOW_EXTRA,
|
extra=vol.ALLOW_EXTRA,
|
||||||
)
|
)
|
||||||
|
|
||||||
_DATA_GO2RTC: HassKey[Go2RtcData] = HassKey(DOMAIN)
|
_DATA_GO2RTC: HassKey[str] = HassKey(DOMAIN)
|
||||||
_RETRYABLE_ERRORS = (ClientConnectionError, ServerConnectionError)
|
_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:
|
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||||
"""Set up WebRTC."""
|
"""Set up WebRTC."""
|
||||||
url: str | None = None
|
url: str | None = None
|
||||||
managed = False
|
|
||||||
if DOMAIN not in config and DEFAULT_CONFIG_DOMAIN not in config:
|
if DOMAIN not in config and DEFAULT_CONFIG_DOMAIN not in config:
|
||||||
await _remove_go2rtc_entries(hass)
|
await _remove_go2rtc_entries(hass)
|
||||||
return True
|
return True
|
||||||
@ -144,9 +137,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|||||||
hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, on_stop)
|
hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, on_stop)
|
||||||
|
|
||||||
url = HA_MANAGED_URL
|
url = HA_MANAGED_URL
|
||||||
managed = True
|
|
||||||
|
|
||||||
hass.data[_DATA_GO2RTC] = Go2RtcData(url, managed)
|
hass.data[_DATA_GO2RTC] = url
|
||||||
discovery_flow.async_create_flow(
|
discovery_flow.async_create_flow(
|
||||||
hass, DOMAIN, context={"source": SOURCE_SYSTEM}, data={}
|
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:
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Set up go2rtc from a config entry."""
|
"""Set up go2rtc from a config entry."""
|
||||||
data = hass.data[_DATA_GO2RTC]
|
url = hass.data[_DATA_GO2RTC]
|
||||||
|
|
||||||
# Validate the server URL
|
# Validate the server URL
|
||||||
try:
|
try:
|
||||||
client = Go2RtcRestClient(async_get_clientsession(hass), data.url)
|
client = Go2RtcRestClient(async_get_clientsession(hass), url)
|
||||||
await client.validate_server_version()
|
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:
|
except Go2RtcClientError as err:
|
||||||
if isinstance(err.__cause__, _RETRYABLE_ERRORS):
|
if isinstance(err.__cause__, _RETRYABLE_ERRORS):
|
||||||
raise ConfigEntryNotReady(
|
raise ConfigEntryNotReady(
|
||||||
f"Could not connect to go2rtc instance on {data.url}"
|
f"Could not connect to go2rtc instance on {url}"
|
||||||
) from err
|
) from err
|
||||||
_LOGGER.warning(
|
_LOGGER.warning("Could not connect to go2rtc instance on %s (%s)", url, err)
|
||||||
"Could not connect to go2rtc instance on %s (%s)", data.url, err
|
|
||||||
)
|
|
||||||
return False
|
return False
|
||||||
except Go2RtcVersionError as err:
|
except Go2RtcVersionError as err:
|
||||||
raise ConfigEntryNotReady(
|
raise ConfigEntryNotReady(
|
||||||
f"The go2rtc server version is not supported, {err}"
|
f"The go2rtc server version is not supported, {err}"
|
||||||
) from err
|
) from err
|
||||||
except Exception as err: # noqa: BLE001
|
except Exception as err: # noqa: BLE001
|
||||||
_LOGGER.warning(
|
_LOGGER.warning("Could not connect to go2rtc instance on %s (%s)", url, err)
|
||||||
"Could not connect to go2rtc instance on %s (%s)", data.url, err
|
|
||||||
)
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
provider = WebRTCProvider(hass, data)
|
provider = WebRTCProvider(hass, url)
|
||||||
async_register_webrtc_provider(hass, provider)
|
async_register_webrtc_provider(hass, provider)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@ -204,12 +206,12 @@ async def _get_binary(hass: HomeAssistant) -> str | None:
|
|||||||
class WebRTCProvider(CameraWebRTCProvider):
|
class WebRTCProvider(CameraWebRTCProvider):
|
||||||
"""WebRTC provider."""
|
"""WebRTC provider."""
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, data: Go2RtcData) -> None:
|
def __init__(self, hass: HomeAssistant, url: str) -> None:
|
||||||
"""Initialize the WebRTC provider."""
|
"""Initialize the WebRTC provider."""
|
||||||
self._hass = hass
|
self._hass = hass
|
||||||
self._data = data
|
self._url = url
|
||||||
self._session = async_get_clientsession(hass)
|
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] = {}
|
self._sessions: dict[str, Go2RtcWsClient] = {}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -231,7 +233,7 @@ class WebRTCProvider(CameraWebRTCProvider):
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Handle the WebRTC offer and return the answer via the provided callback."""
|
"""Handle the WebRTC offer and return the answer via the provided callback."""
|
||||||
self._sessions[session_id] = ws_client = Go2RtcWsClient(
|
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()):
|
if not (stream_source := await camera.stream_source()):
|
||||||
@ -242,34 +244,18 @@ class WebRTCProvider(CameraWebRTCProvider):
|
|||||||
|
|
||||||
streams = await self._rest_client.streams.list()
|
streams = await self._rest_client.streams.list()
|
||||||
|
|
||||||
if self._data.managed:
|
if (stream := streams.get(camera.entity_id)) is None or not any(
|
||||||
# HA manages the go2rtc instance
|
stream_source == producer.url for producer in stream.producers
|
||||||
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
|
|
||||||
):
|
):
|
||||||
await self._rest_client.streams.add(
|
await self._rest_client.streams.add(
|
||||||
camera.entity_id,
|
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
|
@callback
|
||||||
|
@ -6,4 +6,4 @@ CONF_DEBUG_UI = "debug_ui"
|
|||||||
DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time."
|
DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time."
|
||||||
HA_MANAGED_API_PORT = 11984
|
HA_MANAGED_API_PORT = 11984
|
||||||
HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/"
|
HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/"
|
||||||
HA_MANAGED_RTSP_PORT = 18554
|
RECOMMENDED_VERSION = "1.9.7"
|
||||||
|
@ -7,6 +7,6 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/go2rtc",
|
"documentation": "https://www.home-assistant.io/integrations/go2rtc",
|
||||||
"integration_type": "system",
|
"integration_type": "system",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"requirements": ["go2rtc-client==0.1.0"],
|
"requirements": ["go2rtc-client==0.1.1"],
|
||||||
"single_config_entry": true
|
"single_config_entry": true
|
||||||
}
|
}
|
||||||
|
@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant
|
|||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
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__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
_TERMINATE_TIMEOUT = 5
|
_TERMINATE_TIMEOUT = 5
|
||||||
@ -33,7 +33,7 @@ api:
|
|||||||
listen: "{api_ip}:{api_port}"
|
listen: "{api_ip}:{api_port}"
|
||||||
|
|
||||||
rtsp:
|
rtsp:
|
||||||
listen: "127.0.0.1:{rtsp_port}"
|
listen: "127.0.0.1:18554"
|
||||||
|
|
||||||
webrtc:
|
webrtc:
|
||||||
listen: ":18555/tcp"
|
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:
|
with NamedTemporaryFile(prefix="go2rtc_", suffix=".yaml", delete=False) as file:
|
||||||
file.write(
|
file.write(
|
||||||
_GO2RTC_CONFIG_FORMAT.format(
|
_GO2RTC_CONFIG_FORMAT.format(
|
||||||
api_ip=api_ip,
|
api_ip=api_ip, api_port=HA_MANAGED_API_PORT
|
||||||
api_port=HA_MANAGED_API_PORT,
|
|
||||||
rtsp_port=HA_MANAGED_RTSP_PORT,
|
|
||||||
).encode()
|
).encode()
|
||||||
)
|
)
|
||||||
return file.name
|
return file.name
|
||||||
|
8
homeassistant/components/go2rtc/strings.json
Normal file
8
homeassistant/components/go2rtc/strings.json
Normal 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}`."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -87,8 +87,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"create_event": {
|
"create_event": {
|
||||||
"name": "Creates event",
|
"name": "Create event",
|
||||||
"description": "Add a new calendar event.",
|
"description": "Adds a new calendar event.",
|
||||||
"fields": {
|
"fields": {
|
||||||
"summary": {
|
"summary": {
|
||||||
"name": "Summary",
|
"name": "Summary",
|
||||||
|
@ -18,6 +18,8 @@ from homeassistant.const import (
|
|||||||
SERVICE_ALARM_ARM_HOME,
|
SERVICE_ALARM_ARM_HOME,
|
||||||
SERVICE_ALARM_ARM_NIGHT,
|
SERVICE_ALARM_ARM_NIGHT,
|
||||||
SERVICE_ALARM_DISARM,
|
SERVICE_ALARM_DISARM,
|
||||||
|
STATE_UNAVAILABLE,
|
||||||
|
STATE_UNKNOWN,
|
||||||
)
|
)
|
||||||
from homeassistant.core import State, callback
|
from homeassistant.core import State, callback
|
||||||
|
|
||||||
@ -152,12 +154,12 @@ class SecuritySystem(HomeAccessory):
|
|||||||
@callback
|
@callback
|
||||||
def async_update_state(self, new_state: State) -> None:
|
def async_update_state(self, new_state: State) -> None:
|
||||||
"""Update security state after state changed."""
|
"""Update security state after state changed."""
|
||||||
hass_state = None
|
hass_state: str | AlarmControlPanelState = new_state.state
|
||||||
if new_state and new_state.state == "None":
|
if hass_state in {"None", STATE_UNKNOWN, STATE_UNAVAILABLE}:
|
||||||
# Bail out early for no state
|
# Bail out early for no state, unknown or unavailable
|
||||||
return
|
return
|
||||||
if new_state and new_state.state is not None:
|
if hass_state is not None:
|
||||||
hass_state = AlarmControlPanelState(new_state.state)
|
hass_state = AlarmControlPanelState(hass_state)
|
||||||
if (
|
if (
|
||||||
hass_state
|
hass_state
|
||||||
and (current_state := HASS_TO_HOMEKIT_CURRENT.get(hass_state)) is not None
|
and (current_state := HASS_TO_HOMEKIT_CURRENT.get(hass_state)) is not None
|
||||||
|
@ -130,10 +130,15 @@ class HueSceneEntity(HueSceneEntityBase):
|
|||||||
@property
|
@property
|
||||||
def is_dynamic(self) -> bool:
|
def is_dynamic(self) -> bool:
|
||||||
"""Return if this scene has a dynamic color palette."""
|
"""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
|
return True
|
||||||
if (
|
if (
|
||||||
self.resource.palette.color_temperature
|
self.resource.palette
|
||||||
|
and self.resource.palette.color_temperature
|
||||||
and len(self.resource.palette.color_temperature) > 1
|
and len(self.resource.palette.color_temperature) > 1
|
||||||
):
|
):
|
||||||
return True
|
return True
|
||||||
|
@ -95,7 +95,7 @@ class PowerViewNumber(ShadeEntity, RestoreNumber):
|
|||||||
self.entity_description = description
|
self.entity_description = description
|
||||||
self._attr_unique_id = f"{self._attr_unique_id}_{description.key}"
|
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."""
|
"""Update the current value."""
|
||||||
self._attr_native_value = value
|
self._attr_native_value = value
|
||||||
self.entity_description.store_value_fn(self.coordinator, self._shade.id, value)
|
self.entity_description.store_value_fn(self.coordinator, self._shade.id, value)
|
||||||
|
@ -8,6 +8,7 @@ from aioautomower.exceptions import (
|
|||||||
ApiException,
|
ApiException,
|
||||||
AuthException,
|
AuthException,
|
||||||
HusqvarnaWSServerHandshakeError,
|
HusqvarnaWSServerHandshakeError,
|
||||||
|
TimeoutException,
|
||||||
)
|
)
|
||||||
from aioautomower.model import MowerAttributes
|
from aioautomower.model import MowerAttributes
|
||||||
from aioautomower.session import AutomowerSession
|
from aioautomower.session import AutomowerSession
|
||||||
@ -22,6 +23,7 @@ from .const import DOMAIN
|
|||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
MAX_WS_RECONNECT_TIME = 600
|
MAX_WS_RECONNECT_TIME = 600
|
||||||
SCAN_INTERVAL = timedelta(minutes=8)
|
SCAN_INTERVAL = timedelta(minutes=8)
|
||||||
|
DEFAULT_RECONNECT_TIME = 2 # Define a default reconnect time
|
||||||
|
|
||||||
|
|
||||||
class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttributes]]):
|
class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttributes]]):
|
||||||
@ -40,8 +42,8 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib
|
|||||||
update_interval=SCAN_INTERVAL,
|
update_interval=SCAN_INTERVAL,
|
||||||
)
|
)
|
||||||
self.api = api
|
self.api = api
|
||||||
|
|
||||||
self.ws_connected: bool = False
|
self.ws_connected: bool = False
|
||||||
|
self.reconnect_time = DEFAULT_RECONNECT_TIME
|
||||||
|
|
||||||
async def _async_update_data(self) -> dict[str, MowerAttributes]:
|
async def _async_update_data(self) -> dict[str, MowerAttributes]:
|
||||||
"""Subscribe for websocket and poll data from the API."""
|
"""Subscribe for websocket and poll data from the API."""
|
||||||
@ -66,24 +68,28 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib
|
|||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
entry: ConfigEntry,
|
entry: ConfigEntry,
|
||||||
automower_client: AutomowerSession,
|
automower_client: AutomowerSession,
|
||||||
reconnect_time: int = 2,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Listen with the client."""
|
"""Listen with the client."""
|
||||||
try:
|
try:
|
||||||
await automower_client.auth.websocket_connect()
|
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()
|
await automower_client.start_listening()
|
||||||
except HusqvarnaWSServerHandshakeError as err:
|
except HusqvarnaWSServerHandshakeError as err:
|
||||||
_LOGGER.debug(
|
_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:
|
if not hass.is_stopping:
|
||||||
await asyncio.sleep(reconnect_time)
|
await asyncio.sleep(self.reconnect_time)
|
||||||
reconnect_time = min(reconnect_time * 2, MAX_WS_RECONNECT_TIME)
|
self.reconnect_time = min(self.reconnect_time * 2, MAX_WS_RECONNECT_TIME)
|
||||||
await self.client_listen(
|
entry.async_create_background_task(
|
||||||
hass=hass,
|
hass,
|
||||||
entry=entry,
|
self.client_listen(hass, entry, automower_client),
|
||||||
automower_client=automower_client,
|
"reconnect_task",
|
||||||
reconnect_time=reconnect_time,
|
|
||||||
)
|
)
|
||||||
|
@ -32,6 +32,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
|
"error": {
|
||||||
|
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||||
|
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
|
||||||
|
},
|
||||||
"step": {
|
"step": {
|
||||||
"init": {
|
"init": {
|
||||||
"title": "Options",
|
"title": "Options",
|
||||||
|
@ -7,8 +7,12 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
|
|||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||||
|
|
||||||
from .const import DOMAIN, SCAN_INTERVAL
|
from .const import DOMAIN
|
||||||
from .coordinator import HydrawiseDataUpdateCoordinator
|
from .coordinator import (
|
||||||
|
HydrawiseMainDataUpdateCoordinator,
|
||||||
|
HydrawiseUpdateCoordinators,
|
||||||
|
HydrawiseWaterUseDataUpdateCoordinator,
|
||||||
|
)
|
||||||
|
|
||||||
PLATFORMS: list[Platform] = [
|
PLATFORMS: list[Platform] = [
|
||||||
Platform.BINARY_SENSOR,
|
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])
|
auth.Auth(config_entry.data[CONF_USERNAME], config_entry.data[CONF_PASSWORD])
|
||||||
)
|
)
|
||||||
|
|
||||||
coordinator = HydrawiseDataUpdateCoordinator(hass, hydrawise, SCAN_INTERVAL)
|
main_coordinator = HydrawiseMainDataUpdateCoordinator(hass, hydrawise)
|
||||||
await coordinator.async_config_entry_first_refresh()
|
await main_coordinator.async_config_entry_first_refresh()
|
||||||
hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = coordinator
|
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)
|
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@ -21,7 +21,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
|||||||
from homeassistant.helpers.typing import VolDictType
|
from homeassistant.helpers.typing import VolDictType
|
||||||
|
|
||||||
from .const import DOMAIN, SERVICE_RESUME, SERVICE_START_WATERING, SERVICE_SUSPEND
|
from .const import DOMAIN, SERVICE_RESUME, SERVICE_START_WATERING, SERVICE_SUSPEND
|
||||||
from .coordinator import HydrawiseDataUpdateCoordinator
|
from .coordinator import HydrawiseUpdateCoordinators
|
||||||
from .entity import HydrawiseEntity
|
from .entity import HydrawiseEntity
|
||||||
|
|
||||||
|
|
||||||
@ -81,18 +81,16 @@ async def async_setup_entry(
|
|||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the Hydrawise binary_sensor platform."""
|
"""Set up the Hydrawise binary_sensor platform."""
|
||||||
coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN][
|
coordinators: HydrawiseUpdateCoordinators = hass.data[DOMAIN][config_entry.entry_id]
|
||||||
config_entry.entry_id
|
|
||||||
]
|
|
||||||
entities: list[HydrawiseBinarySensor] = []
|
entities: list[HydrawiseBinarySensor] = []
|
||||||
for controller in coordinator.data.controllers.values():
|
for controller in coordinators.main.data.controllers.values():
|
||||||
entities.extend(
|
entities.extend(
|
||||||
HydrawiseBinarySensor(coordinator, description, controller)
|
HydrawiseBinarySensor(coordinators.main, description, controller)
|
||||||
for description in CONTROLLER_BINARY_SENSORS
|
for description in CONTROLLER_BINARY_SENSORS
|
||||||
)
|
)
|
||||||
entities.extend(
|
entities.extend(
|
||||||
HydrawiseBinarySensor(
|
HydrawiseBinarySensor(
|
||||||
coordinator,
|
coordinators.main,
|
||||||
description,
|
description,
|
||||||
controller,
|
controller,
|
||||||
sensor_id=sensor.id,
|
sensor_id=sensor.id,
|
||||||
@ -103,7 +101,7 @@ async def async_setup_entry(
|
|||||||
)
|
)
|
||||||
entities.extend(
|
entities.extend(
|
||||||
HydrawiseZoneBinarySensor(
|
HydrawiseZoneBinarySensor(
|
||||||
coordinator, description, controller, zone_id=zone.id
|
coordinators.main, description, controller, zone_id=zone.id
|
||||||
)
|
)
|
||||||
for zone in controller.zones
|
for zone in controller.zones
|
||||||
for description in ZONE_BINARY_SENSORS
|
for description in ZONE_BINARY_SENSORS
|
||||||
|
@ -10,7 +10,8 @@ DEFAULT_WATERING_TIME = timedelta(minutes=15)
|
|||||||
|
|
||||||
MANUFACTURER = "Hydrawise"
|
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"
|
SIGNAL_UPDATE_HYDRAWISE = "hydrawise_update"
|
||||||
|
|
||||||
|
@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
from pydrawise import Hydrawise
|
from pydrawise import Hydrawise
|
||||||
from pydrawise.schema import Controller, ControllerWaterUseSummary, Sensor, User, Zone
|
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.helpers.update_coordinator import DataUpdateCoordinator
|
||||||
from homeassistant.util.dt import now
|
from homeassistant.util.dt import now
|
||||||
|
|
||||||
from .const import DOMAIN, LOGGER
|
from .const import DOMAIN, LOGGER, MAIN_SCAN_INTERVAL, WATER_USE_SCAN_INTERVAL
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@ -20,22 +19,39 @@ class HydrawiseData:
|
|||||||
"""Container for data fetched from the Hydrawise API."""
|
"""Container for data fetched from the Hydrawise API."""
|
||||||
|
|
||||||
user: User
|
user: User
|
||||||
controllers: dict[int, Controller]
|
controllers: dict[int, Controller] = field(default_factory=dict)
|
||||||
zones: dict[int, Zone]
|
zones: dict[int, Zone] = field(default_factory=dict)
|
||||||
sensors: dict[int, Sensor]
|
sensors: dict[int, Sensor] = field(default_factory=dict)
|
||||||
daily_water_summary: dict[int, ControllerWaterUseSummary]
|
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]):
|
class HydrawiseDataUpdateCoordinator(DataUpdateCoordinator[HydrawiseData]):
|
||||||
"""The Hydrawise Data Update Coordinator."""
|
"""Base class for Hydrawise Data Update Coordinators."""
|
||||||
|
|
||||||
api: Hydrawise
|
api: Hydrawise
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self, hass: HomeAssistant, api: Hydrawise, scan_interval: timedelta
|
class HydrawiseMainDataUpdateCoordinator(HydrawiseDataUpdateCoordinator):
|
||||||
) -> None:
|
"""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."""
|
"""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
|
self.api = api
|
||||||
|
|
||||||
async def _async_update_data(self) -> HydrawiseData:
|
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.
|
# Don't fetch zones. We'll fetch them for each controller later.
|
||||||
# This is to prevent 502 errors in some cases.
|
# This is to prevent 502 errors in some cases.
|
||||||
# See: https://github.com/home-assistant/core/issues/120128
|
# See: https://github.com/home-assistant/core/issues/120128
|
||||||
user = await self.api.get_user(fetch_zones=False)
|
data = HydrawiseData(user=await self.api.get_user(fetch_zones=False))
|
||||||
controllers = {}
|
for controller in data.user.controllers:
|
||||||
zones = {}
|
data.controllers[controller.id] = controller
|
||||||
sensors = {}
|
|
||||||
daily_water_summary: dict[int, ControllerWaterUseSummary] = {}
|
|
||||||
for controller in user.controllers:
|
|
||||||
controllers[controller.id] = controller
|
|
||||||
controller.zones = await self.api.get_zones(controller)
|
controller.zones = await self.api.get_zones(controller)
|
||||||
for zone in controller.zones:
|
for zone in controller.zones:
|
||||||
zones[zone.id] = zone
|
data.zones[zone.id] = zone
|
||||||
for sensor in controller.sensors:
|
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(
|
daily_water_summary[controller.id] = await self.api.get_water_use_summary(
|
||||||
controller,
|
controller,
|
||||||
now().replace(hour=0, minute=0, second=0, microsecond=0),
|
now().replace(hour=0, minute=0, second=0, microsecond=0),
|
||||||
now(),
|
now(),
|
||||||
)
|
)
|
||||||
|
main_data = self._main_coordinator.data
|
||||||
return HydrawiseData(
|
return HydrawiseData(
|
||||||
user=user,
|
user=main_data.user,
|
||||||
controllers=controllers,
|
controllers=main_data.controllers,
|
||||||
zones=zones,
|
zones=main_data.zones,
|
||||||
sensors=sensors,
|
sensors=main_data.sensors,
|
||||||
daily_water_summary=daily_water_summary,
|
daily_water_summary=daily_water_summary,
|
||||||
)
|
)
|
||||||
|
@ -19,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
|||||||
from homeassistant.util import dt as dt_util
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
from .coordinator import HydrawiseDataUpdateCoordinator
|
from .coordinator import HydrawiseUpdateCoordinators
|
||||||
from .entity import HydrawiseEntity
|
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
|
return daily_water_summary.total_use
|
||||||
|
|
||||||
|
|
||||||
CONTROLLER_SENSORS: tuple[HydrawiseSensorEntityDescription, ...] = (
|
WATER_USE_CONTROLLER_SENSORS: tuple[HydrawiseSensorEntityDescription, ...] = (
|
||||||
HydrawiseSensorEntityDescription(
|
HydrawiseSensorEntityDescription(
|
||||||
key="daily_active_water_time",
|
key="daily_active_water_time",
|
||||||
translation_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, ...] = (
|
FLOW_CONTROLLER_SENSORS: tuple[HydrawiseSensorEntityDescription, ...] = (
|
||||||
HydrawiseSensorEntityDescription(
|
HydrawiseSensorEntityDescription(
|
||||||
key="daily_total_water_use",
|
key="daily_total_water_use",
|
||||||
@ -150,13 +160,6 @@ ZONE_SENSORS: tuple[HydrawiseSensorEntityDescription, ...] = (
|
|||||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||||
value_fn=_get_zone_watering_time,
|
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]
|
FLOW_MEASUREMENT_KEYS = [x.key for x in FLOW_CONTROLLER_SENSORS]
|
||||||
@ -168,29 +171,37 @@ async def async_setup_entry(
|
|||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the Hydrawise sensor platform."""
|
"""Set up the Hydrawise sensor platform."""
|
||||||
coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN][
|
coordinators: HydrawiseUpdateCoordinators = hass.data[DOMAIN][config_entry.entry_id]
|
||||||
config_entry.entry_id
|
|
||||||
]
|
|
||||||
entities: list[HydrawiseSensor] = []
|
entities: list[HydrawiseSensor] = []
|
||||||
for controller in coordinator.data.controllers.values():
|
for controller in coordinators.main.data.controllers.values():
|
||||||
entities.extend(
|
entities.extend(
|
||||||
HydrawiseSensor(coordinator, description, controller)
|
HydrawiseSensor(coordinators.water_use, description, controller)
|
||||||
for description in CONTROLLER_SENSORS
|
for description in WATER_USE_CONTROLLER_SENSORS
|
||||||
)
|
)
|
||||||
entities.extend(
|
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 zone in controller.zones
|
||||||
for description in ZONE_SENSORS
|
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
|
# we have a flow sensor for this controller
|
||||||
entities.extend(
|
entities.extend(
|
||||||
HydrawiseSensor(coordinator, description, controller)
|
HydrawiseSensor(coordinators.water_use, description, controller)
|
||||||
for description in FLOW_CONTROLLER_SENSORS
|
for description in FLOW_CONTROLLER_SENSORS
|
||||||
)
|
)
|
||||||
entities.extend(
|
entities.extend(
|
||||||
HydrawiseSensor(
|
HydrawiseSensor(
|
||||||
coordinator,
|
coordinators.water_use,
|
||||||
description,
|
description,
|
||||||
controller,
|
controller,
|
||||||
zone_id=zone.id,
|
zone_id=zone.id,
|
||||||
|
@ -20,7 +20,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
|||||||
from homeassistant.util import dt as dt_util
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
from .const import DEFAULT_WATERING_TIME, DOMAIN
|
from .const import DEFAULT_WATERING_TIME, DOMAIN
|
||||||
from .coordinator import HydrawiseDataUpdateCoordinator
|
from .coordinator import HydrawiseUpdateCoordinators
|
||||||
from .entity import HydrawiseEntity
|
from .entity import HydrawiseEntity
|
||||||
|
|
||||||
|
|
||||||
@ -66,12 +66,10 @@ async def async_setup_entry(
|
|||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the Hydrawise switch platform."""
|
"""Set up the Hydrawise switch platform."""
|
||||||
coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN][
|
coordinators: HydrawiseUpdateCoordinators = hass.data[DOMAIN][config_entry.entry_id]
|
||||||
config_entry.entry_id
|
|
||||||
]
|
|
||||||
async_add_entities(
|
async_add_entities(
|
||||||
HydrawiseSwitch(coordinator, description, controller, zone_id=zone.id)
|
HydrawiseSwitch(coordinators.main, description, controller, zone_id=zone.id)
|
||||||
for controller in coordinator.data.controllers.values()
|
for controller in coordinators.main.data.controllers.values()
|
||||||
for zone in controller.zones
|
for zone in controller.zones
|
||||||
for description in SWITCH_TYPES
|
for description in SWITCH_TYPES
|
||||||
)
|
)
|
||||||
|
@ -17,7 +17,7 @@ from homeassistant.core import HomeAssistant
|
|||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
from .coordinator import HydrawiseDataUpdateCoordinator
|
from .coordinator import HydrawiseUpdateCoordinators
|
||||||
from .entity import HydrawiseEntity
|
from .entity import HydrawiseEntity
|
||||||
|
|
||||||
VALVE_TYPES: tuple[ValveEntityDescription, ...] = (
|
VALVE_TYPES: tuple[ValveEntityDescription, ...] = (
|
||||||
@ -34,12 +34,10 @@ async def async_setup_entry(
|
|||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the Hydrawise valve platform."""
|
"""Set up the Hydrawise valve platform."""
|
||||||
coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN][
|
coordinators: HydrawiseUpdateCoordinators = hass.data[DOMAIN][config_entry.entry_id]
|
||||||
config_entry.entry_id
|
|
||||||
]
|
|
||||||
async_add_entities(
|
async_add_entities(
|
||||||
HydrawiseValve(coordinator, description, controller, zone_id=zone.id)
|
HydrawiseValve(coordinators.main, description, controller, zone_id=zone.id)
|
||||||
for controller in coordinator.data.controllers.values()
|
for controller in coordinators.main.data.controllers.values()
|
||||||
for zone in controller.zones
|
for zone in controller.zones
|
||||||
for description in VALVE_TYPES
|
for description in VALVE_TYPES
|
||||||
)
|
)
|
||||||
|
@ -25,7 +25,8 @@
|
|||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
"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": {
|
"device_automation": {
|
||||||
|
@ -72,8 +72,11 @@ class ThinQFanEntity(ThinQEntity, FanEntity):
|
|||||||
super().__init__(coordinator, entity_description, property_id)
|
super().__init__(coordinator, entity_description, property_id)
|
||||||
|
|
||||||
self._ordered_named_fan_speeds = []
|
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:
|
if (fan_modes := self.data.fan_modes) is not None:
|
||||||
self._attr_speed_count = len(fan_modes)
|
self._attr_speed_count = len(fan_modes)
|
||||||
if self.speed_count == 4:
|
if self.speed_count == 4:
|
||||||
@ -98,7 +101,7 @@ class ThinQFanEntity(ThinQEntity, FanEntity):
|
|||||||
self._attr_percentage = 0
|
self._attr_percentage = 0
|
||||||
|
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"[%s:%s] update status: %s -> %s (percntage=%s)",
|
"[%s:%s] update status: %s -> %s (percentage=%s)",
|
||||||
self.coordinator.device_name,
|
self.coordinator.device_name,
|
||||||
self.property_id,
|
self.property_id,
|
||||||
self.data.is_on,
|
self.data.is_on,
|
||||||
@ -120,7 +123,7 @@ class ThinQFanEntity(ThinQEntity, FanEntity):
|
|||||||
return
|
return
|
||||||
|
|
||||||
_LOGGER.debug(
|
_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.coordinator.device_name,
|
||||||
self.property_id,
|
self.property_id,
|
||||||
percentage,
|
percentage,
|
||||||
|
@ -7,6 +7,6 @@
|
|||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["linkplay"],
|
"loggers": ["linkplay"],
|
||||||
"requirements": ["python-linkplay==0.0.17"],
|
"requirements": ["python-linkplay==0.0.20"],
|
||||||
"zeroconf": ["_linkplay._tcp.local."]
|
"zeroconf": ["_linkplay._tcp.local."]
|
||||||
}
|
}
|
||||||
|
@ -9,7 +9,7 @@ from typing import Any, Concatenate
|
|||||||
from linkplay.bridge import LinkPlayBridge
|
from linkplay.bridge import LinkPlayBridge
|
||||||
from linkplay.consts import EqualizerMode, LoopMode, PlayingMode, PlayingStatus
|
from linkplay.consts import EqualizerMode, LoopMode, PlayingMode, PlayingStatus
|
||||||
from linkplay.controller import LinkPlayController, LinkPlayMultiroom
|
from linkplay.controller import LinkPlayController, LinkPlayMultiroom
|
||||||
from linkplay.exceptions import LinkPlayException, LinkPlayRequestException
|
from linkplay.exceptions import LinkPlayRequestException
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.components import media_source
|
from homeassistant.components import media_source
|
||||||
@ -69,6 +69,8 @@ SOURCE_MAP: dict[PlayingMode, str] = {
|
|||||||
PlayingMode.FM: "FM Radio",
|
PlayingMode.FM: "FM Radio",
|
||||||
PlayingMode.RCA: "RCA",
|
PlayingMode.RCA: "RCA",
|
||||||
PlayingMode.UDISK: "USB",
|
PlayingMode.UDISK: "USB",
|
||||||
|
PlayingMode.SPOTIFY: "Spotify",
|
||||||
|
PlayingMode.TIDAL: "Tidal",
|
||||||
PlayingMode.FOLLOWER: "Follower",
|
PlayingMode.FOLLOWER: "Follower",
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -201,9 +203,8 @@ class LinkPlayMediaPlayerEntity(MediaPlayerEntity):
|
|||||||
try:
|
try:
|
||||||
await self._bridge.player.update_status()
|
await self._bridge.player.update_status()
|
||||||
self._update_properties()
|
self._update_properties()
|
||||||
except LinkPlayException:
|
except LinkPlayRequestException:
|
||||||
self._attr_available = False
|
self._attr_available = False
|
||||||
raise
|
|
||||||
|
|
||||||
@exception_wrap
|
@exception_wrap
|
||||||
async def async_select_source(self, source: str) -> None:
|
async def async_select_source(self, source: str) -> None:
|
||||||
@ -292,7 +293,15 @@ class LinkPlayMediaPlayerEntity(MediaPlayerEntity):
|
|||||||
@exception_wrap
|
@exception_wrap
|
||||||
async def async_play_preset(self, preset_number: int) -> None:
|
async def async_play_preset(self, preset_number: int) -> None:
|
||||||
"""Play preset number."""
|
"""Play preset number."""
|
||||||
|
try:
|
||||||
await self._bridge.player.play_preset(preset_number)
|
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
|
@exception_wrap
|
||||||
async def async_join_players(self, group_members: list[str]) -> None:
|
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_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_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_artist = self._bridge.player.artist
|
||||||
self._attr_media_title = self._bridge.player.title
|
self._attr_media_title = self._bridge.player.title
|
||||||
self._attr_media_album_name = self._bridge.player.album
|
self._attr_media_album_name = self._bridge.player.album
|
||||||
|
@ -11,5 +11,4 @@ play_preset:
|
|||||||
selector:
|
selector:
|
||||||
number:
|
number:
|
||||||
min: 1
|
min: 1
|
||||||
max: 10
|
|
||||||
mode: box
|
mode: box
|
||||||
|
@ -28,12 +28,12 @@
|
|||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
"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": {
|
"error": {
|
||||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
"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.",
|
"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."
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
|
@ -12,11 +12,12 @@ from homeassistant.core import HomeAssistant
|
|||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
from . import MyUplinkConfigEntry, MyUplinkDataCoordinator
|
from . import MyUplinkConfigEntry, MyUplinkDataCoordinator
|
||||||
|
from .const import F_SERIES
|
||||||
from .entity import MyUplinkEntity, MyUplinkSystemEntity
|
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]] = {
|
CATEGORY_BASED_DESCRIPTIONS: dict[str, dict[str, BinarySensorEntityDescription]] = {
|
||||||
"F730": {
|
F_SERIES: {
|
||||||
"43161": BinarySensorEntityDescription(
|
"43161": BinarySensorEntityDescription(
|
||||||
key="elect_add",
|
key="elect_add",
|
||||||
translation_key="elect_add",
|
translation_key="elect_add",
|
||||||
@ -50,6 +51,7 @@ def get_description(device_point: DevicePoint) -> BinarySensorEntityDescription
|
|||||||
2. Default to None
|
2. Default to None
|
||||||
"""
|
"""
|
||||||
prefix, _, _ = device_point.category.partition(" ")
|
prefix, _, _ = device_point.category.partition(" ")
|
||||||
|
prefix = transform_model_series(prefix)
|
||||||
return CATEGORY_BASED_DESCRIPTIONS.get(prefix, {}).get(device_point.parameter_id)
|
return CATEGORY_BASED_DESCRIPTIONS.get(prefix, {}).get(device_point.parameter_id)
|
||||||
|
|
||||||
|
|
||||||
|
@ -6,3 +6,5 @@ API_ENDPOINT = "https://api.myuplink.com"
|
|||||||
OAUTH2_AUTHORIZE = "https://api.myuplink.com/oauth/authorize"
|
OAUTH2_AUTHORIZE = "https://api.myuplink.com/oauth/authorize"
|
||||||
OAUTH2_TOKEN = "https://api.myuplink.com/oauth/token"
|
OAUTH2_TOKEN = "https://api.myuplink.com/oauth/token"
|
||||||
OAUTH2_SCOPES = ["WRITESYSTEM", "READSYSTEM", "offline_access"]
|
OAUTH2_SCOPES = ["WRITESYSTEM", "READSYSTEM", "offline_access"]
|
||||||
|
|
||||||
|
F_SERIES = "f-series"
|
||||||
|
@ -6,6 +6,8 @@ from homeassistant.components.number import NumberEntityDescription
|
|||||||
from homeassistant.components.sensor import SensorEntityDescription
|
from homeassistant.components.sensor import SensorEntityDescription
|
||||||
from homeassistant.const import Platform
|
from homeassistant.const import Platform
|
||||||
|
|
||||||
|
from .const import F_SERIES
|
||||||
|
|
||||||
|
|
||||||
def find_matching_platform(
|
def find_matching_platform(
|
||||||
device_point: DevicePoint,
|
device_point: DevicePoint,
|
||||||
@ -86,8 +88,9 @@ PARAMETER_ID_TO_EXCLUDE_F730 = (
|
|||||||
"47941",
|
"47941",
|
||||||
"47975",
|
"47975",
|
||||||
"48009",
|
"48009",
|
||||||
"48042",
|
|
||||||
"48072",
|
"48072",
|
||||||
|
"48442",
|
||||||
|
"49909",
|
||||||
"50113",
|
"50113",
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -110,7 +113,7 @@ def skip_entity(model: str, device_point: DevicePoint) -> bool:
|
|||||||
):
|
):
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
if "F730" in model:
|
if model.lower().startswith("f"):
|
||||||
# Entity names containing weekdays are used for advanced scheduling in the
|
# Entity names containing weekdays are used for advanced scheduling in the
|
||||||
# heat pump and should not be exposed in the integration
|
# heat pump and should not be exposed in the integration
|
||||||
if any(d in device_point.parameter_name.lower() for d in WEEKDAYS):
|
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:
|
if device_point.parameter_id in PARAMETER_ID_TO_EXCLUDE_F730:
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def transform_model_series(prefix: str) -> str:
|
||||||
|
"""Remap all F-series models."""
|
||||||
|
if prefix.lower().startswith("f"):
|
||||||
|
return F_SERIES
|
||||||
|
return prefix
|
||||||
|
@ -10,8 +10,9 @@ from homeassistant.exceptions import HomeAssistantError
|
|||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
from . import MyUplinkConfigEntry, MyUplinkDataCoordinator
|
from . import MyUplinkConfigEntry, MyUplinkDataCoordinator
|
||||||
|
from .const import F_SERIES
|
||||||
from .entity import MyUplinkEntity
|
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] = {
|
DEVICE_POINT_UNIT_DESCRIPTIONS: dict[str, NumberEntityDescription] = {
|
||||||
"DM": NumberEntityDescription(
|
"DM": NumberEntityDescription(
|
||||||
@ -22,7 +23,7 @@ DEVICE_POINT_UNIT_DESCRIPTIONS: dict[str, NumberEntityDescription] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
CATEGORY_BASED_DESCRIPTIONS: dict[str, dict[str, NumberEntityDescription]] = {
|
CATEGORY_BASED_DESCRIPTIONS: dict[str, dict[str, NumberEntityDescription]] = {
|
||||||
"F730": {
|
F_SERIES: {
|
||||||
"40940": NumberEntityDescription(
|
"40940": NumberEntityDescription(
|
||||||
key="degree_minutes",
|
key="degree_minutes",
|
||||||
translation_key="degree_minutes",
|
translation_key="degree_minutes",
|
||||||
@ -48,6 +49,7 @@ def get_description(device_point: DevicePoint) -> NumberEntityDescription | None
|
|||||||
3. Default to None
|
3. Default to None
|
||||||
"""
|
"""
|
||||||
prefix, _, _ = device_point.category.partition(" ")
|
prefix, _, _ = device_point.category.partition(" ")
|
||||||
|
prefix = transform_model_series(prefix)
|
||||||
description = CATEGORY_BASED_DESCRIPTIONS.get(prefix, {}).get(
|
description = CATEGORY_BASED_DESCRIPTIONS.get(prefix, {}).get(
|
||||||
device_point.parameter_id
|
device_point.parameter_id
|
||||||
)
|
)
|
||||||
|
@ -25,8 +25,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
|||||||
from homeassistant.helpers.typing import StateType
|
from homeassistant.helpers.typing import StateType
|
||||||
|
|
||||||
from . import MyUplinkConfigEntry, MyUplinkDataCoordinator
|
from . import MyUplinkConfigEntry, MyUplinkDataCoordinator
|
||||||
|
from .const import F_SERIES
|
||||||
from .entity import MyUplinkEntity
|
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] = {
|
DEVICE_POINT_UNIT_DESCRIPTIONS: dict[str, SensorEntityDescription] = {
|
||||||
"°C": SensorEntityDescription(
|
"°C": SensorEntityDescription(
|
||||||
@ -139,7 +140,7 @@ DEVICE_POINT_UNIT_DESCRIPTIONS: dict[str, SensorEntityDescription] = {
|
|||||||
MARKER_FOR_UNKNOWN_VALUE = -32768
|
MARKER_FOR_UNKNOWN_VALUE = -32768
|
||||||
|
|
||||||
CATEGORY_BASED_DESCRIPTIONS: dict[str, dict[str, SensorEntityDescription]] = {
|
CATEGORY_BASED_DESCRIPTIONS: dict[str, dict[str, SensorEntityDescription]] = {
|
||||||
"F730": {
|
F_SERIES: {
|
||||||
"43108": SensorEntityDescription(
|
"43108": SensorEntityDescription(
|
||||||
key="fan_mode",
|
key="fan_mode",
|
||||||
translation_key="fan_mode",
|
translation_key="fan_mode",
|
||||||
@ -200,6 +201,7 @@ def get_description(device_point: DevicePoint) -> SensorEntityDescription | None
|
|||||||
"""
|
"""
|
||||||
description = None
|
description = None
|
||||||
prefix, _, _ = device_point.category.partition(" ")
|
prefix, _, _ = device_point.category.partition(" ")
|
||||||
|
prefix = transform_model_series(prefix)
|
||||||
description = CATEGORY_BASED_DESCRIPTIONS.get(prefix, {}).get(
|
description = CATEGORY_BASED_DESCRIPTIONS.get(prefix, {}).get(
|
||||||
device_point.parameter_id
|
device_point.parameter_id
|
||||||
)
|
)
|
||||||
|
@ -12,11 +12,12 @@ from homeassistant.exceptions import HomeAssistantError
|
|||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
from . import MyUplinkConfigEntry, MyUplinkDataCoordinator
|
from . import MyUplinkConfigEntry, MyUplinkDataCoordinator
|
||||||
|
from .const import F_SERIES
|
||||||
from .entity import MyUplinkEntity
|
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]] = {
|
CATEGORY_BASED_DESCRIPTIONS: dict[str, dict[str, SwitchEntityDescription]] = {
|
||||||
"F730": {
|
F_SERIES: {
|
||||||
"50004": SwitchEntityDescription(
|
"50004": SwitchEntityDescription(
|
||||||
key="temporary_lux",
|
key="temporary_lux",
|
||||||
translation_key="temporary_lux",
|
translation_key="temporary_lux",
|
||||||
@ -47,6 +48,7 @@ def get_description(device_point: DevicePoint) -> SwitchEntityDescription | None
|
|||||||
2. Default to None
|
2. Default to None
|
||||||
"""
|
"""
|
||||||
prefix, _, _ = device_point.category.partition(" ")
|
prefix, _, _ = device_point.category.partition(" ")
|
||||||
|
prefix = transform_model_series(prefix)
|
||||||
return CATEGORY_BASED_DESCRIPTIONS.get(prefix, {}).get(device_point.parameter_id)
|
return CATEGORY_BASED_DESCRIPTIONS.get(prefix, {}).get(device_point.parameter_id)
|
||||||
|
|
||||||
|
|
||||||
|
@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC
|
||||||
import asyncio
|
import asyncio
|
||||||
from collections.abc import Callable
|
from collections.abc import Awaitable, Callable
|
||||||
import datetime
|
import datetime
|
||||||
import functools
|
import functools
|
||||||
import logging
|
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 import Device
|
||||||
from google_nest_sdm.device_manager import DeviceManager
|
from google_nest_sdm.device_manager import DeviceManager
|
||||||
from google_nest_sdm.exceptions import ApiException
|
from google_nest_sdm.exceptions import ApiException
|
||||||
|
from webrtc_models import RTCIceCandidate
|
||||||
|
|
||||||
from homeassistant.components.camera import (
|
from homeassistant.components.camera import (
|
||||||
Camera,
|
Camera,
|
||||||
@ -46,6 +47,11 @@ PLACEHOLDER = Path(__file__).parent / "placeholder.png"
|
|||||||
# Used to schedule an alarm to refresh the stream before expiration
|
# Used to schedule an alarm to refresh the stream before expiration
|
||||||
STREAM_EXPIRATION_BUFFER = datetime.timedelta(seconds=30)
|
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(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||||
@ -67,6 +73,68 @@ async def async_setup_entry(
|
|||||||
async_add_entities(entities)
|
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):
|
class NestCameraBaseEntity(Camera, ABC):
|
||||||
"""Devices that support cameras."""
|
"""Devices that support cameras."""
|
||||||
|
|
||||||
@ -86,41 +154,6 @@ class NestCameraBaseEntity(Camera, ABC):
|
|||||||
self.stream_options[CONF_EXTRA_PART_WAIT_TIME] = 3
|
self.stream_options[CONF_EXTRA_PART_WAIT_TIME] = 3
|
||||||
# The API "name" field is a unique device identifier.
|
# The API "name" field is a unique device identifier.
|
||||||
self._attr_unique_id = f"{self._device.name}-camera"
|
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:
|
async def async_added_to_hass(self) -> None:
|
||||||
"""Run when entity is added to register update signal handler."""
|
"""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)
|
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):
|
class NestRTSPEntity(NestCameraBaseEntity):
|
||||||
"""Nest cameras that use RTSP."""
|
"""Nest cameras that use RTSP."""
|
||||||
@ -146,6 +173,7 @@ class NestRTSPEntity(NestCameraBaseEntity):
|
|||||||
super().__init__(device)
|
super().__init__(device)
|
||||||
self._create_stream_url_lock = asyncio.Lock()
|
self._create_stream_url_lock = asyncio.Lock()
|
||||||
self._rtsp_live_stream_trait = device.traits[CameraLiveStreamTrait.NAME]
|
self._rtsp_live_stream_trait = device.traits[CameraLiveStreamTrait.NAME]
|
||||||
|
self._refresh_unsub: Callable[[], None] | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def use_stream_for_stills(self) -> bool:
|
def use_stream_for_stills(self) -> bool:
|
||||||
@ -173,20 +201,21 @@ class NestRTSPEntity(NestCameraBaseEntity):
|
|||||||
)
|
)
|
||||||
except ApiException as err:
|
except ApiException as err:
|
||||||
raise HomeAssistantError(f"Nest API error: {err}") from 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
|
assert self._rtsp_stream
|
||||||
if self._rtsp_stream.expires_at < utcnow():
|
if self._rtsp_stream.expires_at < utcnow():
|
||||||
_LOGGER.warning("Stream already expired")
|
_LOGGER.warning("Stream already expired")
|
||||||
return self._rtsp_stream.rtsp_stream_url
|
return self._rtsp_stream.rtsp_stream_url
|
||||||
|
|
||||||
def _stream_expires_at(self) -> datetime.datetime | None:
|
async def _async_refresh_stream(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:
|
|
||||||
"""Refresh stream to extend expiration time."""
|
"""Refresh stream to extend expiration time."""
|
||||||
if not self._rtsp_stream:
|
if not self._rtsp_stream:
|
||||||
return
|
return None
|
||||||
_LOGGER.debug("Extending RTSP stream")
|
_LOGGER.debug("Extending RTSP stream")
|
||||||
try:
|
try:
|
||||||
self._rtsp_stream = await self._rtsp_stream.extend_rtsp_stream()
|
self._rtsp_stream = await self._rtsp_stream.extend_rtsp_stream()
|
||||||
@ -197,14 +226,17 @@ class NestRTSPEntity(NestCameraBaseEntity):
|
|||||||
if self.stream:
|
if self.stream:
|
||||||
await self.stream.stop()
|
await self.stream.stop()
|
||||||
self.stream = None
|
self.stream = None
|
||||||
return
|
return None
|
||||||
# Update the stream worker with the latest valid url
|
# Update the stream worker with the latest valid url
|
||||||
if self.stream:
|
if self.stream:
|
||||||
self.stream.update_source(self._rtsp_stream.rtsp_stream_url)
|
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:
|
async def async_will_remove_from_hass(self) -> None:
|
||||||
"""Invalidates the RTSP token when unloaded."""
|
"""Invalidates the RTSP token when unloaded."""
|
||||||
await super().async_will_remove_from_hass()
|
await super().async_will_remove_from_hass()
|
||||||
|
if self._refresh_unsub is not None:
|
||||||
|
self._refresh_unsub()
|
||||||
if self._rtsp_stream:
|
if self._rtsp_stream:
|
||||||
try:
|
try:
|
||||||
await self._rtsp_stream.stop_stream()
|
await self._rtsp_stream.stop_stream()
|
||||||
@ -220,37 +252,23 @@ class NestWebRTCEntity(NestCameraBaseEntity):
|
|||||||
"""Initialize the camera."""
|
"""Initialize the camera."""
|
||||||
super().__init__(device)
|
super().__init__(device)
|
||||||
self._webrtc_sessions: dict[str, WebRtcStream] = {}
|
self._webrtc_sessions: dict[str, WebRtcStream] = {}
|
||||||
|
self._refresh_unsub: dict[str, Callable[[], None]] = {}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def frontend_stream_type(self) -> StreamType | None:
|
def frontend_stream_type(self) -> StreamType | None:
|
||||||
"""Return the type of stream supported by this camera."""
|
"""Return the type of stream supported by this camera."""
|
||||||
return StreamType.WEB_RTC
|
return StreamType.WEB_RTC
|
||||||
|
|
||||||
def _stream_expires_at(self) -> datetime.datetime | None:
|
async def _async_refresh_stream(self, session_id: str) -> 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:
|
|
||||||
"""Refresh stream to extend expiration time."""
|
"""Refresh stream to extend expiration time."""
|
||||||
now = utcnow()
|
if not (webrtc_stream := self._webrtc_sessions.get(session_id)):
|
||||||
for session_id, webrtc_stream in list(self._webrtc_sessions.items()):
|
return None
|
||||||
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)
|
_LOGGER.debug("Extending WebRTC stream %s", webrtc_stream.media_session_id)
|
||||||
try:
|
|
||||||
webrtc_stream = await webrtc_stream.extend_stream()
|
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:
|
if session_id in self._webrtc_sessions:
|
||||||
self._webrtc_sessions[session_id] = webrtc_stream
|
self._webrtc_sessions[session_id] = webrtc_stream
|
||||||
|
return webrtc_stream.expires_at
|
||||||
|
return None
|
||||||
|
|
||||||
async def async_camera_image(
|
async def async_camera_image(
|
||||||
self, width: int | None = None, height: int | None = None
|
self, width: int | None = None, height: int | None = None
|
||||||
@ -278,7 +296,18 @@ class NestWebRTCEntity(NestCameraBaseEntity):
|
|||||||
)
|
)
|
||||||
self._webrtc_sessions[session_id] = stream
|
self._webrtc_sessions[session_id] = stream
|
||||||
send_message(WebRTCAnswer(stream.answer_sdp))
|
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
|
@callback
|
||||||
def close_webrtc_session(self, session_id: str) -> None:
|
def close_webrtc_session(self, session_id: str) -> None:
|
||||||
@ -287,6 +316,8 @@ class NestWebRTCEntity(NestCameraBaseEntity):
|
|||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"Closing WebRTC session %s, %s", session_id, stream.media_session_id
|
"Closing WebRTC session %s, %s", session_id, stream.media_session_id
|
||||||
)
|
)
|
||||||
|
unsub = self._refresh_unsub.pop(session_id)
|
||||||
|
unsub()
|
||||||
|
|
||||||
async def stop_stream() -> None:
|
async def stop_stream() -> None:
|
||||||
try:
|
try:
|
||||||
|
@ -20,5 +20,5 @@
|
|||||||
"iot_class": "cloud_push",
|
"iot_class": "cloud_push",
|
||||||
"loggers": ["google_nest_sdm"],
|
"loggers": ["google_nest_sdm"],
|
||||||
"quality_scale": "platinum",
|
"quality_scale": "platinum",
|
||||||
"requirements": ["google-nest-sdm==6.1.4"]
|
"requirements": ["google-nest-sdm==6.1.5"]
|
||||||
}
|
}
|
||||||
|
@ -7,5 +7,5 @@
|
|||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
"iot_class": "cloud_push",
|
"iot_class": "cloud_push",
|
||||||
"loggers": ["nice_go"],
|
"loggers": ["nice_go"],
|
||||||
"requirements": ["nice-go==0.3.9"]
|
"requirements": ["nice-go==0.3.10"]
|
||||||
}
|
}
|
||||||
|
@ -11,9 +11,11 @@
|
|||||||
"title": "Downloading model"
|
"title": "Downloading model"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"abort": {
|
||||||
|
"download_failed": "Model downloading failed"
|
||||||
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||||
"download_failed": "Model downloading failed",
|
|
||||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||||
},
|
},
|
||||||
"progress": {
|
"progress": {
|
||||||
|
@ -137,7 +137,7 @@ class OnewireOptionsFlowHandler(OptionsFlowWithConfigEntry):
|
|||||||
}
|
}
|
||||||
|
|
||||||
if not self.configurable_devices:
|
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)
|
return await self.async_step_device_selection(user_input=None)
|
||||||
|
|
||||||
|
@ -94,6 +94,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
|
"abort": {
|
||||||
|
"no_configurable_devices": "No configurable devices found"
|
||||||
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"device_not_selected": "Select devices to configure"
|
"device_not_selected": "Select devices to configure"
|
||||||
},
|
},
|
||||||
|
@ -18,11 +18,11 @@
|
|||||||
"error": {
|
"error": {
|
||||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||||
"unknown": "[%key:common::config_flow::error::unknown%]",
|
"unknown": "[%key:common::config_flow::error::unknown%]",
|
||||||
"pairing_failure": "Unable to pair: {error_id}",
|
|
||||||
"invalid_pin": "Invalid PIN"
|
"invalid_pin": "Invalid PIN"
|
||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
"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%]"
|
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -64,10 +64,10 @@ WEBHOOK_SCHEMA = vol.Schema(
|
|||||||
vol.Required(ATTR_DEVICE_NAME): cv.string,
|
vol.Required(ATTR_DEVICE_NAME): cv.string,
|
||||||
vol.Required(ATTR_DEVICE_ID): cv.positive_int,
|
vol.Required(ATTR_DEVICE_ID): cv.positive_int,
|
||||||
vol.Required(ATTR_TEMP_UNIT): vol.In(
|
vol.Required(ATTR_TEMP_UNIT): vol.In(
|
||||||
UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT
|
[UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT]
|
||||||
),
|
),
|
||||||
vol.Required(ATTR_VOLUME_UNIT): vol.In(
|
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_BPM): cv.positive_int,
|
||||||
vol.Required(ATTR_TEMP): vol.Coerce(float),
|
vol.Required(ATTR_TEMP): vol.Coerce(float),
|
||||||
|
@ -8,5 +8,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/plaato",
|
"documentation": "https://www.home-assistant.io/integrations/plaato",
|
||||||
"iot_class": "cloud_push",
|
"iot_class": "cloud_push",
|
||||||
"loggers": ["pyplaato"],
|
"loggers": ["pyplaato"],
|
||||||
"requirements": ["pyplaato==0.0.18"]
|
"requirements": ["pyplaato==0.0.19"]
|
||||||
}
|
}
|
||||||
|
@ -251,8 +251,8 @@ class PowerwallConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
"""Handle reauth confirmation."""
|
"""Handle reauth confirmation."""
|
||||||
errors: dict[str, str] | None = {}
|
errors: dict[str, str] | None = {}
|
||||||
description_placeholders: dict[str, str] = {}
|
description_placeholders: dict[str, str] = {}
|
||||||
if user_input is not None:
|
|
||||||
reauth_entry = self._get_reauth_entry()
|
reauth_entry = self._get_reauth_entry()
|
||||||
|
if user_input is not None:
|
||||||
errors, _, description_placeholders = await self._async_try_connect(
|
errors, _, description_placeholders = await self._async_try_connect(
|
||||||
{CONF_IP_ADDRESS: reauth_entry.data[CONF_IP_ADDRESS], **user_input}
|
{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
|
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(
|
return self.async_show_form(
|
||||||
step_id="reauth_confirm",
|
step_id="reauth_confirm",
|
||||||
data_schema=vol.Schema({vol.Optional(CONF_PASSWORD): str}),
|
data_schema=vol.Schema({vol.Optional(CONF_PASSWORD): str}),
|
||||||
|
@ -18,5 +18,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/reolink",
|
"documentation": "https://www.home-assistant.io/integrations/reolink",
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"loggers": ["reolink_aio"],
|
"loggers": ["reolink_aio"],
|
||||||
"requirements": ["reolink-aio==0.10.4"]
|
"requirements": ["reolink-aio==0.11.1"]
|
||||||
}
|
}
|
||||||
|
@ -96,7 +96,7 @@ class RingEvent(RingBaseEntity[RingListenCoordinator, RingDeviceT], EventEntity)
|
|||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _handle_coordinator_update(self) -> None:
|
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)
|
self._async_handle_event(alert.kind)
|
||||||
super()._handle_coordinator_update()
|
super()._handle_coordinator_update()
|
||||||
|
|
||||||
|
@ -30,5 +30,5 @@
|
|||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["ring_doorbell"],
|
"loggers": ["ring_doorbell"],
|
||||||
"quality_scale": "silver",
|
"quality_scale": "silver",
|
||||||
"requirements": ["ring-doorbell==0.9.8"]
|
"requirements": ["ring-doorbell==0.9.12"]
|
||||||
}
|
}
|
||||||
|
@ -47,7 +47,6 @@ class RoborockCoordinators:
|
|||||||
async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> bool:
|
||||||
"""Set up roborock from a config entry."""
|
"""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))
|
entry.async_on_unload(entry.add_update_listener(update_listener))
|
||||||
|
|
||||||
user_data = UserData.from_dict(entry.data[CONF_USER_DATA])
|
user_data = UserData.from_dict(entry.data[CONF_USER_DATA])
|
||||||
|
@ -7,5 +7,5 @@
|
|||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["aioruckus"],
|
"loggers": ["aioruckus"],
|
||||||
"requirements": ["aioruckus==0.41"]
|
"requirements": ["aioruckus==0.42"]
|
||||||
}
|
}
|
||||||
|
@ -20,5 +20,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/sense",
|
"documentation": "https://www.home-assistant.io/integrations/sense",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["sense_energy"],
|
"loggers": ["sense_energy"],
|
||||||
"requirements": ["sense-energy==0.13.3"]
|
"requirements": ["sense-energy==0.13.4"]
|
||||||
}
|
}
|
||||||
|
@ -603,7 +603,7 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]):
|
|||||||
|
|
||||||
async def _async_update_data(self) -> None:
|
async def _async_update_data(self) -> None:
|
||||||
"""Fetch data."""
|
"""Fetch data."""
|
||||||
if self.update_sleep_period():
|
if self.update_sleep_period() or self.hass.is_stopping:
|
||||||
return
|
return
|
||||||
|
|
||||||
if self.sleep_period:
|
if self.sleep_period:
|
||||||
|
@ -7,5 +7,5 @@
|
|||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["smarttub"],
|
"loggers": ["smarttub"],
|
||||||
"quality_scale": "platinum",
|
"quality_scale": "platinum",
|
||||||
"requirements": ["python-smarttub==0.0.36"]
|
"requirements": ["python-smarttub==0.0.38"]
|
||||||
}
|
}
|
||||||
|
@ -28,6 +28,10 @@
|
|||||||
"deprecated_yaml_import_issue_auth_error": {
|
"deprecated_yaml_import_issue_auth_error": {
|
||||||
"title": "YAML import failed due to an authentication 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."
|
"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": {
|
"entity": {
|
||||||
|
@ -8,7 +8,7 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/sonos",
|
"documentation": "https://www.home-assistant.io/integrations/sonos",
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"loggers": ["soco"],
|
"loggers": ["soco"],
|
||||||
"requirements": ["soco==0.30.4", "sonos-websocket==0.1.3"],
|
"requirements": ["soco==0.30.6", "sonos-websocket==0.1.3"],
|
||||||
"ssdp": [
|
"ssdp": [
|
||||||
{
|
{
|
||||||
"st": "urn:schemas-upnp-org:device:ZonePlayer:1"
|
"st": "urn:schemas-upnp-org:device:ZonePlayer:1"
|
||||||
|
@ -9,6 +9,6 @@
|
|||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["spotipy"],
|
"loggers": ["spotipy"],
|
||||||
"quality_scale": "silver",
|
"quality_scale": "silver",
|
||||||
"requirements": ["spotifyaio==0.8.7"],
|
"requirements": ["spotifyaio==0.8.8"],
|
||||||
"zeroconf": ["_spotify-connect._tcp.local."]
|
"zeroconf": ["_spotify-connect._tcp.local."]
|
||||||
}
|
}
|
||||||
|
@ -17,7 +17,8 @@
|
|||||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||||
},
|
},
|
||||||
"abort": {
|
"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": {
|
"entity": {
|
||||||
|
@ -23,10 +23,10 @@
|
|||||||
"state_characteristic": {
|
"state_characteristic": {
|
||||||
"description": "Read the documention for further details on available options and how to use them.",
|
"description": "Read the documention for further details on available options and how to use them.",
|
||||||
"data": {
|
"data": {
|
||||||
"state_characteristic": "State_characteristic"
|
"state_characteristic": "Statistic characteristic"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"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": {
|
"options": {
|
||||||
|
@ -37,13 +37,13 @@
|
|||||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||||
"incorrect_pin": "Incorrect PIN",
|
"incorrect_pin": "Incorrect PIN",
|
||||||
"bad_pin_format": "PIN should be 4 digits",
|
"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",
|
"bad_validation_code_format": "Validation code should be 6 digits",
|
||||||
"incorrect_validation_code": "Incorrect validation code"
|
"incorrect_validation_code": "Incorrect validation code"
|
||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
"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": {
|
"options": {
|
||||||
|
@ -8,5 +8,5 @@
|
|||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["tibber"],
|
"loggers": ["tibber"],
|
||||||
"quality_scale": "silver",
|
"quality_scale": "silver",
|
||||||
"requirements": ["pyTibber==0.30.4"]
|
"requirements": ["pyTibber==0.30.8"]
|
||||||
}
|
}
|
||||||
|
@ -16,6 +16,7 @@
|
|||||||
"already_configured": "The selected agreement is already configured.",
|
"already_configured": "The selected agreement is already configured.",
|
||||||
"unknown_authorize_url_generation": "[%key:common::config_flow::abort::unknown_authorize_url_generation%]",
|
"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%]",
|
"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%]",
|
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
|
||||||
"no_agreements": "This account has no Toon displays.",
|
"no_agreements": "This account has no Toon displays.",
|
||||||
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
|
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
|
||||||
|
@ -60,9 +60,6 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
|
|||||||
return await self._entry_from_data(auth)
|
return await self._entry_from_data(auth)
|
||||||
|
|
||||||
except AuthError as err:
|
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:
|
else:
|
||||||
user_input = {}
|
user_input = {}
|
||||||
|
@ -14,7 +14,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"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%]",
|
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||||
"timeout": "Timeout validating the code.",
|
"timeout": "Timeout validating the code.",
|
||||||
"cannot_authenticate": "Cannot authenticate, is Gateway paired with another server like e.g. Homekit?"
|
"cannot_authenticate": "Cannot authenticate, is Gateway paired with another server like e.g. Homekit?"
|
||||||
|
@ -25,6 +25,9 @@
|
|||||||
"tariffs": "A list of supported tariffs, leave empty if only a single tariff is needed."
|
"tariffs": "A list of supported tariffs, leave empty if only a single tariff is needed."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"tariffs_not_unique": "Tariffs must be unique"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
|
@ -109,7 +109,7 @@ class DomainConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
try:
|
try:
|
||||||
info = await validate_input(self.hass, user_input)
|
info = await validate_input(self.hass, user_input)
|
||||||
except InvalidHost:
|
except InvalidHost:
|
||||||
errors[CONF_HOST] = "wrong_host"
|
errors["base"] = "invalid_host"
|
||||||
except CannotConnect:
|
except CannotConnect:
|
||||||
errors["base"] = "cannot_connect"
|
errors["base"] = "cannot_connect"
|
||||||
except InvalidAuth:
|
except InvalidAuth:
|
||||||
|
@ -14,6 +14,7 @@
|
|||||||
"error": {
|
"error": {
|
||||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||||
|
"invalid_host": "[%key:common::config_flow::error::invalid_host%]",
|
||||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
|
@ -22,7 +22,7 @@ from .const import _LOGGER, DOMAIN, LINE_TYPES
|
|||||||
from .coordinator import VodafoneStationRouter
|
from .coordinator import VodafoneStationRouter
|
||||||
|
|
||||||
NOT_AVAILABLE: list = ["", "N/A", "0.0.0.0"]
|
NOT_AVAILABLE: list = ["", "N/A", "0.0.0.0"]
|
||||||
UPTIME_DEVIATION = 45
|
UPTIME_DEVIATION = 60
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, kw_only=True)
|
@dataclass(frozen=True, kw_only=True)
|
||||||
@ -43,12 +43,10 @@ def _calculate_uptime(
|
|||||||
) -> datetime:
|
) -> datetime:
|
||||||
"""Calculate device uptime."""
|
"""Calculate device uptime."""
|
||||||
|
|
||||||
assert isinstance(last_value, datetime)
|
|
||||||
|
|
||||||
delta_uptime = coordinator.api.convert_uptime(coordinator.data.sensors[key])
|
delta_uptime = coordinator.api.convert_uptime(coordinator.data.sensors[key])
|
||||||
|
|
||||||
if (
|
if (
|
||||||
not last_value
|
not isinstance(last_value, datetime)
|
||||||
or abs((delta_uptime - last_value).total_seconds()) > UPTIME_DEVIATION
|
or abs((delta_uptime - last_value).total_seconds()) > UPTIME_DEVIATION
|
||||||
):
|
):
|
||||||
return delta_uptime
|
return delta_uptime
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"title": "Water heater",
|
||||||
"device_automation": {
|
"device_automation": {
|
||||||
"action_type": {
|
"action_type": {
|
||||||
"turn_on": "[%key:common::device_automation::action_type::turn_on%]",
|
"turn_on": "[%key:common::device_automation::action_type::turn_on%]",
|
||||||
@ -7,7 +8,7 @@
|
|||||||
},
|
},
|
||||||
"entity_component": {
|
"entity_component": {
|
||||||
"_": {
|
"_": {
|
||||||
"name": "Water heater",
|
"name": "[%key:component::water_heater::title%]",
|
||||||
"state": {
|
"state": {
|
||||||
"off": "[%key:common::state::off%]",
|
"off": "[%key:common::state::off%]",
|
||||||
"eco": "Eco",
|
"eco": "Eco",
|
||||||
|
@ -9,5 +9,5 @@
|
|||||||
"iot_class": "cloud_push",
|
"iot_class": "cloud_push",
|
||||||
"loggers": ["aiowithings"],
|
"loggers": ["aiowithings"],
|
||||||
"quality_scale": "platinum",
|
"quality_scale": "platinum",
|
||||||
"requirements": ["aiowithings==3.1.1"]
|
"requirements": ["aiowithings==3.1.3"]
|
||||||
}
|
}
|
||||||
|
@ -33,6 +33,7 @@ from homeassistant.config_entries import (
|
|||||||
from homeassistant.const import CONF_NAME
|
from homeassistant.const import CONF_NAME
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
from homeassistant.helpers.hassio import is_hassio
|
||||||
from homeassistant.helpers.selector import FileSelector, FileSelectorConfig
|
from homeassistant.helpers.selector import FileSelector, FileSelectorConfig
|
||||||
from homeassistant.util import dt as dt_util
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
@ -104,9 +105,10 @@ async def list_serial_ports(hass: HomeAssistant) -> list[ListPortInfo]:
|
|||||||
yellow_radio.description = "Yellow Zigbee module"
|
yellow_radio.description = "Yellow Zigbee module"
|
||||||
yellow_radio.manufacturer = "Nabu Casa"
|
yellow_radio.manufacturer = "Nabu Casa"
|
||||||
|
|
||||||
|
if is_hassio(hass):
|
||||||
# Present the multi-PAN addon as a setup option, if it's available
|
# Present the multi-PAN addon as a setup option, if it's available
|
||||||
multipan_manager = await silabs_multiprotocol_addon.get_multiprotocol_addon_manager(
|
multipan_manager = (
|
||||||
hass
|
await silabs_multiprotocol_addon.get_multiprotocol_addon_manager(hass)
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
@ -21,7 +21,7 @@
|
|||||||
"zha",
|
"zha",
|
||||||
"universal_silabs_flasher"
|
"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": [
|
"usb": [
|
||||||
{
|
{
|
||||||
"vid": "10C4",
|
"vid": "10C4",
|
||||||
|
@ -4,7 +4,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import functools
|
import functools
|
||||||
import logging
|
import logging
|
||||||
import math
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from zha.exceptions import ZHAException
|
from zha.exceptions import ZHAException
|
||||||
@ -97,6 +96,7 @@ class ZHAFirmwareUpdateEntity(
|
|||||||
| UpdateEntityFeature.SPECIFIC_VERSION
|
| UpdateEntityFeature.SPECIFIC_VERSION
|
||||||
| UpdateEntityFeature.RELEASE_NOTES
|
| 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:
|
def __init__(self, entity_data: EntityData, **kwargs: Any) -> None:
|
||||||
"""Initialize the ZHA siren."""
|
"""Initialize the ZHA siren."""
|
||||||
@ -115,20 +115,19 @@ class ZHAFirmwareUpdateEntity(
|
|||||||
def in_progress(self) -> bool | int | None:
|
def in_progress(self) -> bool | int | None:
|
||||||
"""Update installation progress.
|
"""Update installation progress.
|
||||||
|
|
||||||
Needs UpdateEntityFeature.PROGRESS flag to be set for it to be used.
|
Should return a boolean (True if in progress, False if not).
|
||||||
|
|
||||||
Can either return a boolean (True if in progress, False if not)
|
|
||||||
or an integer to indicate the progress in from 0 to 100%.
|
|
||||||
"""
|
"""
|
||||||
if not self.entity_data.entity.in_progress:
|
|
||||||
return self.entity_data.entity.in_progress
|
return self.entity_data.entity.in_progress
|
||||||
|
|
||||||
# Stay in an indeterminate state until we actually send something
|
@property
|
||||||
if self.entity_data.entity.progress == 0:
|
def update_percentage(self) -> int | float | None:
|
||||||
return True
|
"""Update installation progress.
|
||||||
|
|
||||||
# Rescale 0-100% to 2-100% to avoid 0 and 1 colliding with None, False, and True
|
Needs UpdateEntityFeature.PROGRESS flag to be set for it to be used.
|
||||||
return int(math.ceil(2 + 98 * self.entity_data.entity.progress / 100))
|
|
||||||
|
Can either return a number to indicate the progress from 0 to 100% or None.
|
||||||
|
"""
|
||||||
|
return self.entity_data.entity.update_percentage
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def latest_version(self) -> str | None:
|
def latest_version(self) -> str | None:
|
||||||
|
@ -25,7 +25,7 @@ if TYPE_CHECKING:
|
|||||||
APPLICATION_NAME: Final = "HomeAssistant"
|
APPLICATION_NAME: Final = "HomeAssistant"
|
||||||
MAJOR_VERSION: Final = 2024
|
MAJOR_VERSION: Final = 2024
|
||||||
MINOR_VERSION: Final = 11
|
MINOR_VERSION: Final = 11
|
||||||
PATCH_VERSION: Final = "1"
|
PATCH_VERSION: Final = "2"
|
||||||
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
|
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
|
||||||
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
|
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
|
||||||
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0)
|
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0)
|
||||||
|
@ -5,7 +5,7 @@ aiodiscover==2.1.0
|
|||||||
aiodns==3.2.0
|
aiodns==3.2.0
|
||||||
aiohasupervisor==0.2.1
|
aiohasupervisor==0.2.1
|
||||||
aiohttp-fast-zlib==0.1.1
|
aiohttp-fast-zlib==0.1.1
|
||||||
aiohttp==3.10.10
|
aiohttp==3.10.11
|
||||||
aiohttp_cors==0.7.0
|
aiohttp_cors==0.7.0
|
||||||
aiozoneinfo==0.2.1
|
aiozoneinfo==0.2.1
|
||||||
astral==2.2
|
astral==2.2
|
||||||
@ -26,7 +26,7 @@ ciso8601==2.3.1
|
|||||||
cryptography==43.0.1
|
cryptography==43.0.1
|
||||||
dbus-fast==2.24.3
|
dbus-fast==2.24.3
|
||||||
fnv-hash-fast==1.0.2
|
fnv-hash-fast==1.0.2
|
||||||
go2rtc-client==0.1.0
|
go2rtc-client==0.1.1
|
||||||
ha-av==10.1.1
|
ha-av==10.1.1
|
||||||
ha-ffmpeg==3.2.2
|
ha-ffmpeg==3.2.2
|
||||||
habluetooth==3.6.0
|
habluetooth==3.6.0
|
||||||
|
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "homeassistant"
|
name = "homeassistant"
|
||||||
version = "2024.11.1"
|
version = "2024.11.2"
|
||||||
license = {text = "Apache-2.0"}
|
license = {text = "Apache-2.0"}
|
||||||
description = "Open-source home automation platform running on Python 3."
|
description = "Open-source home automation platform running on Python 3."
|
||||||
readme = "README.rst"
|
readme = "README.rst"
|
||||||
@ -28,7 +28,7 @@ dependencies = [
|
|||||||
# change behavior based on presence of supervisor. Deprecated with #127228
|
# change behavior based on presence of supervisor. Deprecated with #127228
|
||||||
# Lib can be removed with 2025.11
|
# Lib can be removed with 2025.11
|
||||||
"aiohasupervisor==0.2.1",
|
"aiohasupervisor==0.2.1",
|
||||||
"aiohttp==3.10.10",
|
"aiohttp==3.10.11",
|
||||||
"aiohttp_cors==0.7.0",
|
"aiohttp_cors==0.7.0",
|
||||||
"aiohttp-fast-zlib==0.1.1",
|
"aiohttp-fast-zlib==0.1.1",
|
||||||
"aiozoneinfo==0.2.1",
|
"aiozoneinfo==0.2.1",
|
||||||
|
@ -5,7 +5,7 @@
|
|||||||
# Home Assistant Core
|
# Home Assistant Core
|
||||||
aiodns==3.2.0
|
aiodns==3.2.0
|
||||||
aiohasupervisor==0.2.1
|
aiohasupervisor==0.2.1
|
||||||
aiohttp==3.10.10
|
aiohttp==3.10.11
|
||||||
aiohttp_cors==0.7.0
|
aiohttp_cors==0.7.0
|
||||||
aiohttp-fast-zlib==0.1.1
|
aiohttp-fast-zlib==0.1.1
|
||||||
aiozoneinfo==0.2.1
|
aiozoneinfo==0.2.1
|
||||||
|
@ -354,7 +354,7 @@ aiorecollect==2023.09.0
|
|||||||
aioridwell==2024.01.0
|
aioridwell==2024.01.0
|
||||||
|
|
||||||
# homeassistant.components.ruckus_unleashed
|
# homeassistant.components.ruckus_unleashed
|
||||||
aioruckus==0.41
|
aioruckus==0.42
|
||||||
|
|
||||||
# homeassistant.components.russound_rio
|
# homeassistant.components.russound_rio
|
||||||
aiorussound==4.0.5
|
aiorussound==4.0.5
|
||||||
@ -381,7 +381,7 @@ aiosolaredge==0.2.0
|
|||||||
aiosteamist==1.0.0
|
aiosteamist==1.0.0
|
||||||
|
|
||||||
# homeassistant.components.cambridge_audio
|
# homeassistant.components.cambridge_audio
|
||||||
aiostreammagic==2.8.4
|
aiostreammagic==2.8.5
|
||||||
|
|
||||||
# homeassistant.components.switcher_kis
|
# homeassistant.components.switcher_kis
|
||||||
aioswitcher==4.4.0
|
aioswitcher==4.4.0
|
||||||
@ -414,7 +414,7 @@ aiowatttime==0.1.1
|
|||||||
aiowebostv==0.4.2
|
aiowebostv==0.4.2
|
||||||
|
|
||||||
# homeassistant.components.withings
|
# homeassistant.components.withings
|
||||||
aiowithings==3.1.1
|
aiowithings==3.1.3
|
||||||
|
|
||||||
# homeassistant.components.yandex_transport
|
# homeassistant.components.yandex_transport
|
||||||
aioymaps==1.2.5
|
aioymaps==1.2.5
|
||||||
@ -986,7 +986,7 @@ gitterpy==0.1.7
|
|||||||
glances-api==0.8.0
|
glances-api==0.8.0
|
||||||
|
|
||||||
# homeassistant.components.go2rtc
|
# homeassistant.components.go2rtc
|
||||||
go2rtc-client==0.1.0
|
go2rtc-client==0.1.1
|
||||||
|
|
||||||
# homeassistant.components.goalzero
|
# homeassistant.components.goalzero
|
||||||
goalzero==0.2.2
|
goalzero==0.2.2
|
||||||
@ -1011,7 +1011,7 @@ google-cloud-texttospeech==2.17.2
|
|||||||
google-generativeai==0.8.2
|
google-generativeai==0.8.2
|
||||||
|
|
||||||
# homeassistant.components.nest
|
# homeassistant.components.nest
|
||||||
google-nest-sdm==6.1.4
|
google-nest-sdm==6.1.5
|
||||||
|
|
||||||
# homeassistant.components.google_photos
|
# homeassistant.components.google_photos
|
||||||
google-photos-library-api==0.12.1
|
google-photos-library-api==0.12.1
|
||||||
@ -1454,7 +1454,7 @@ nextdns==3.3.0
|
|||||||
nibe==2.11.0
|
nibe==2.11.0
|
||||||
|
|
||||||
# homeassistant.components.nice_go
|
# homeassistant.components.nice_go
|
||||||
nice-go==0.3.9
|
nice-go==0.3.10
|
||||||
|
|
||||||
# homeassistant.components.niko_home_control
|
# homeassistant.components.niko_home_control
|
||||||
niko-home-control==0.2.1
|
niko-home-control==0.2.1
|
||||||
@ -1735,7 +1735,7 @@ pyRFXtrx==0.31.1
|
|||||||
pySDCP==1
|
pySDCP==1
|
||||||
|
|
||||||
# homeassistant.components.tibber
|
# homeassistant.components.tibber
|
||||||
pyTibber==0.30.4
|
pyTibber==0.30.8
|
||||||
|
|
||||||
# homeassistant.components.dlink
|
# homeassistant.components.dlink
|
||||||
pyW215==0.7.0
|
pyW215==0.7.0
|
||||||
@ -2155,7 +2155,7 @@ pypck==0.7.24
|
|||||||
pypjlink2==1.2.1
|
pypjlink2==1.2.1
|
||||||
|
|
||||||
# homeassistant.components.plaato
|
# homeassistant.components.plaato
|
||||||
pyplaato==0.0.18
|
pyplaato==0.0.19
|
||||||
|
|
||||||
# homeassistant.components.point
|
# homeassistant.components.point
|
||||||
pypoint==3.0.0
|
pypoint==3.0.0
|
||||||
@ -2356,7 +2356,7 @@ python-juicenet==1.1.0
|
|||||||
python-kasa[speedups]==0.7.7
|
python-kasa[speedups]==0.7.7
|
||||||
|
|
||||||
# homeassistant.components.linkplay
|
# homeassistant.components.linkplay
|
||||||
python-linkplay==0.0.17
|
python-linkplay==0.0.20
|
||||||
|
|
||||||
# homeassistant.components.lirc
|
# homeassistant.components.lirc
|
||||||
# python-lirc==1.2.3
|
# python-lirc==1.2.3
|
||||||
@ -2396,7 +2396,7 @@ python-ripple-api==0.0.3
|
|||||||
python-roborock==2.7.2
|
python-roborock==2.7.2
|
||||||
|
|
||||||
# homeassistant.components.smarttub
|
# homeassistant.components.smarttub
|
||||||
python-smarttub==0.0.36
|
python-smarttub==0.0.38
|
||||||
|
|
||||||
# homeassistant.components.songpal
|
# homeassistant.components.songpal
|
||||||
python-songpal==0.16.2
|
python-songpal==0.16.2
|
||||||
@ -2547,7 +2547,7 @@ renault-api==0.2.7
|
|||||||
renson-endura-delta==1.7.1
|
renson-endura-delta==1.7.1
|
||||||
|
|
||||||
# homeassistant.components.reolink
|
# homeassistant.components.reolink
|
||||||
reolink-aio==0.10.4
|
reolink-aio==0.11.1
|
||||||
|
|
||||||
# homeassistant.components.idteck_prox
|
# homeassistant.components.idteck_prox
|
||||||
rfk101py==0.0.1
|
rfk101py==0.0.1
|
||||||
@ -2556,7 +2556,7 @@ rfk101py==0.0.1
|
|||||||
rflink==0.0.66
|
rflink==0.0.66
|
||||||
|
|
||||||
# homeassistant.components.ring
|
# homeassistant.components.ring
|
||||||
ring-doorbell==0.9.8
|
ring-doorbell==0.9.12
|
||||||
|
|
||||||
# homeassistant.components.fleetgo
|
# homeassistant.components.fleetgo
|
||||||
ritassist==0.9.2
|
ritassist==0.9.2
|
||||||
@ -2623,7 +2623,7 @@ sendgrid==6.8.2
|
|||||||
|
|
||||||
# homeassistant.components.emulated_kasa
|
# homeassistant.components.emulated_kasa
|
||||||
# homeassistant.components.sense
|
# homeassistant.components.sense
|
||||||
sense-energy==0.13.3
|
sense-energy==0.13.4
|
||||||
|
|
||||||
# homeassistant.components.sensirion_ble
|
# homeassistant.components.sensirion_ble
|
||||||
sensirion-ble==0.1.1
|
sensirion-ble==0.1.1
|
||||||
@ -2683,7 +2683,7 @@ smhi-pkg==1.0.18
|
|||||||
snapcast==2.3.6
|
snapcast==2.3.6
|
||||||
|
|
||||||
# homeassistant.components.sonos
|
# homeassistant.components.sonos
|
||||||
soco==0.30.4
|
soco==0.30.6
|
||||||
|
|
||||||
# homeassistant.components.solaredge_local
|
# homeassistant.components.solaredge_local
|
||||||
solaredge-local==0.2.3
|
solaredge-local==0.2.3
|
||||||
@ -2707,7 +2707,7 @@ speak2mary==1.4.0
|
|||||||
speedtest-cli==2.1.3
|
speedtest-cli==2.1.3
|
||||||
|
|
||||||
# homeassistant.components.spotify
|
# homeassistant.components.spotify
|
||||||
spotifyaio==0.8.7
|
spotifyaio==0.8.8
|
||||||
|
|
||||||
# homeassistant.components.sql
|
# homeassistant.components.sql
|
||||||
sqlparse==0.5.0
|
sqlparse==0.5.0
|
||||||
@ -2900,7 +2900,7 @@ unifi_ap==0.0.1
|
|||||||
unifiled==0.11
|
unifiled==0.11
|
||||||
|
|
||||||
# homeassistant.components.zha
|
# homeassistant.components.zha
|
||||||
universal-silabs-flasher==0.0.24
|
universal-silabs-flasher==0.0.25
|
||||||
|
|
||||||
# homeassistant.components.upb
|
# homeassistant.components.upb
|
||||||
upb-lib==0.5.8
|
upb-lib==0.5.8
|
||||||
@ -3066,7 +3066,7 @@ zeroconf==0.136.0
|
|||||||
zeversolar==0.3.2
|
zeversolar==0.3.2
|
||||||
|
|
||||||
# homeassistant.components.zha
|
# homeassistant.components.zha
|
||||||
zha==0.0.37
|
zha==0.0.39
|
||||||
|
|
||||||
# homeassistant.components.zhong_hong
|
# homeassistant.components.zhong_hong
|
||||||
zhong-hong-hvac==1.0.13
|
zhong-hong-hvac==1.0.13
|
||||||
|
@ -336,7 +336,7 @@ aiorecollect==2023.09.0
|
|||||||
aioridwell==2024.01.0
|
aioridwell==2024.01.0
|
||||||
|
|
||||||
# homeassistant.components.ruckus_unleashed
|
# homeassistant.components.ruckus_unleashed
|
||||||
aioruckus==0.41
|
aioruckus==0.42
|
||||||
|
|
||||||
# homeassistant.components.russound_rio
|
# homeassistant.components.russound_rio
|
||||||
aiorussound==4.0.5
|
aiorussound==4.0.5
|
||||||
@ -363,7 +363,7 @@ aiosolaredge==0.2.0
|
|||||||
aiosteamist==1.0.0
|
aiosteamist==1.0.0
|
||||||
|
|
||||||
# homeassistant.components.cambridge_audio
|
# homeassistant.components.cambridge_audio
|
||||||
aiostreammagic==2.8.4
|
aiostreammagic==2.8.5
|
||||||
|
|
||||||
# homeassistant.components.switcher_kis
|
# homeassistant.components.switcher_kis
|
||||||
aioswitcher==4.4.0
|
aioswitcher==4.4.0
|
||||||
@ -396,7 +396,7 @@ aiowatttime==0.1.1
|
|||||||
aiowebostv==0.4.2
|
aiowebostv==0.4.2
|
||||||
|
|
||||||
# homeassistant.components.withings
|
# homeassistant.components.withings
|
||||||
aiowithings==3.1.1
|
aiowithings==3.1.3
|
||||||
|
|
||||||
# homeassistant.components.yandex_transport
|
# homeassistant.components.yandex_transport
|
||||||
aioymaps==1.2.5
|
aioymaps==1.2.5
|
||||||
@ -836,7 +836,7 @@ gios==5.0.0
|
|||||||
glances-api==0.8.0
|
glances-api==0.8.0
|
||||||
|
|
||||||
# homeassistant.components.go2rtc
|
# homeassistant.components.go2rtc
|
||||||
go2rtc-client==0.1.0
|
go2rtc-client==0.1.1
|
||||||
|
|
||||||
# homeassistant.components.goalzero
|
# homeassistant.components.goalzero
|
||||||
goalzero==0.2.2
|
goalzero==0.2.2
|
||||||
@ -861,7 +861,7 @@ google-cloud-texttospeech==2.17.2
|
|||||||
google-generativeai==0.8.2
|
google-generativeai==0.8.2
|
||||||
|
|
||||||
# homeassistant.components.nest
|
# homeassistant.components.nest
|
||||||
google-nest-sdm==6.1.4
|
google-nest-sdm==6.1.5
|
||||||
|
|
||||||
# homeassistant.components.google_photos
|
# homeassistant.components.google_photos
|
||||||
google-photos-library-api==0.12.1
|
google-photos-library-api==0.12.1
|
||||||
@ -1214,7 +1214,7 @@ nextdns==3.3.0
|
|||||||
nibe==2.11.0
|
nibe==2.11.0
|
||||||
|
|
||||||
# homeassistant.components.nice_go
|
# homeassistant.components.nice_go
|
||||||
nice-go==0.3.9
|
nice-go==0.3.10
|
||||||
|
|
||||||
# homeassistant.components.nfandroidtv
|
# homeassistant.components.nfandroidtv
|
||||||
notifications-android-tv==0.1.5
|
notifications-android-tv==0.1.5
|
||||||
@ -1412,7 +1412,7 @@ pyElectra==1.2.4
|
|||||||
pyRFXtrx==0.31.1
|
pyRFXtrx==0.31.1
|
||||||
|
|
||||||
# homeassistant.components.tibber
|
# homeassistant.components.tibber
|
||||||
pyTibber==0.30.4
|
pyTibber==0.30.8
|
||||||
|
|
||||||
# homeassistant.components.dlink
|
# homeassistant.components.dlink
|
||||||
pyW215==0.7.0
|
pyW215==0.7.0
|
||||||
@ -1739,7 +1739,7 @@ pypck==0.7.24
|
|||||||
pypjlink2==1.2.1
|
pypjlink2==1.2.1
|
||||||
|
|
||||||
# homeassistant.components.plaato
|
# homeassistant.components.plaato
|
||||||
pyplaato==0.0.18
|
pyplaato==0.0.19
|
||||||
|
|
||||||
# homeassistant.components.point
|
# homeassistant.components.point
|
||||||
pypoint==3.0.0
|
pypoint==3.0.0
|
||||||
@ -1883,7 +1883,7 @@ python-juicenet==1.1.0
|
|||||||
python-kasa[speedups]==0.7.7
|
python-kasa[speedups]==0.7.7
|
||||||
|
|
||||||
# homeassistant.components.linkplay
|
# homeassistant.components.linkplay
|
||||||
python-linkplay==0.0.17
|
python-linkplay==0.0.20
|
||||||
|
|
||||||
# homeassistant.components.matter
|
# homeassistant.components.matter
|
||||||
python-matter-server==6.6.0
|
python-matter-server==6.6.0
|
||||||
@ -1917,7 +1917,7 @@ python-rabbitair==0.0.8
|
|||||||
python-roborock==2.7.2
|
python-roborock==2.7.2
|
||||||
|
|
||||||
# homeassistant.components.smarttub
|
# homeassistant.components.smarttub
|
||||||
python-smarttub==0.0.36
|
python-smarttub==0.0.38
|
||||||
|
|
||||||
# homeassistant.components.songpal
|
# homeassistant.components.songpal
|
||||||
python-songpal==0.16.2
|
python-songpal==0.16.2
|
||||||
@ -2038,13 +2038,13 @@ renault-api==0.2.7
|
|||||||
renson-endura-delta==1.7.1
|
renson-endura-delta==1.7.1
|
||||||
|
|
||||||
# homeassistant.components.reolink
|
# homeassistant.components.reolink
|
||||||
reolink-aio==0.10.4
|
reolink-aio==0.11.1
|
||||||
|
|
||||||
# homeassistant.components.rflink
|
# homeassistant.components.rflink
|
||||||
rflink==0.0.66
|
rflink==0.0.66
|
||||||
|
|
||||||
# homeassistant.components.ring
|
# homeassistant.components.ring
|
||||||
ring-doorbell==0.9.8
|
ring-doorbell==0.9.12
|
||||||
|
|
||||||
# homeassistant.components.roku
|
# homeassistant.components.roku
|
||||||
rokuecp==0.19.3
|
rokuecp==0.19.3
|
||||||
@ -2090,7 +2090,7 @@ securetar==2024.2.1
|
|||||||
|
|
||||||
# homeassistant.components.emulated_kasa
|
# homeassistant.components.emulated_kasa
|
||||||
# homeassistant.components.sense
|
# homeassistant.components.sense
|
||||||
sense-energy==0.13.3
|
sense-energy==0.13.4
|
||||||
|
|
||||||
# homeassistant.components.sensirion_ble
|
# homeassistant.components.sensirion_ble
|
||||||
sensirion-ble==0.1.1
|
sensirion-ble==0.1.1
|
||||||
@ -2138,7 +2138,7 @@ smhi-pkg==1.0.18
|
|||||||
snapcast==2.3.6
|
snapcast==2.3.6
|
||||||
|
|
||||||
# homeassistant.components.sonos
|
# homeassistant.components.sonos
|
||||||
soco==0.30.4
|
soco==0.30.6
|
||||||
|
|
||||||
# homeassistant.components.solarlog
|
# homeassistant.components.solarlog
|
||||||
solarlog_cli==0.3.2
|
solarlog_cli==0.3.2
|
||||||
@ -2159,7 +2159,7 @@ speak2mary==1.4.0
|
|||||||
speedtest-cli==2.1.3
|
speedtest-cli==2.1.3
|
||||||
|
|
||||||
# homeassistant.components.spotify
|
# homeassistant.components.spotify
|
||||||
spotifyaio==0.8.7
|
spotifyaio==0.8.8
|
||||||
|
|
||||||
# homeassistant.components.sql
|
# homeassistant.components.sql
|
||||||
sqlparse==0.5.0
|
sqlparse==0.5.0
|
||||||
@ -2307,7 +2307,7 @@ ultraheat-api==0.5.7
|
|||||||
unifi-discovery==1.2.0
|
unifi-discovery==1.2.0
|
||||||
|
|
||||||
# homeassistant.components.zha
|
# homeassistant.components.zha
|
||||||
universal-silabs-flasher==0.0.24
|
universal-silabs-flasher==0.0.25
|
||||||
|
|
||||||
# homeassistant.components.upb
|
# homeassistant.components.upb
|
||||||
upb-lib==0.5.8
|
upb-lib==0.5.8
|
||||||
@ -2449,7 +2449,7 @@ zeroconf==0.136.0
|
|||||||
zeversolar==0.3.2
|
zeversolar==0.3.2
|
||||||
|
|
||||||
# homeassistant.components.zha
|
# homeassistant.components.zha
|
||||||
zha==0.0.37
|
zha==0.0.39
|
||||||
|
|
||||||
# homeassistant.components.zwave_js
|
# homeassistant.components.zwave_js
|
||||||
zwave-js-server-python==0.58.1
|
zwave-js-server-python==0.58.1
|
||||||
|
@ -4,6 +4,7 @@ from dataclasses import dataclass
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from homeassistant import core
|
from homeassistant import core
|
||||||
|
from homeassistant.components.go2rtc.const import RECOMMENDED_VERSION as GO2RTC_VERSION
|
||||||
from homeassistant.const import Platform
|
from homeassistant.const import Platform
|
||||||
from homeassistant.util import executor, thread
|
from homeassistant.util import executor, thread
|
||||||
from script.gen_requirements_all import gather_recursive_requirements
|
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"
|
LABEL "com.github.actions.color"="gray-dark"
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_GO2RTC_VERSION = "1.9.6"
|
|
||||||
|
|
||||||
|
|
||||||
def _get_package_versions(file: Path, packages: set[str]) -> dict[str, str]:
|
def _get_package_versions(file: Path, packages: set[str]) -> dict[str, str]:
|
||||||
package_versions: dict[str, str] = {}
|
package_versions: dict[str, str] = {}
|
||||||
@ -162,6 +161,8 @@ def _generate_hassfest_dockerimage(
|
|||||||
packages.update(
|
packages.update(
|
||||||
gather_recursive_requirements(platform.value, already_checked_domains)
|
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(
|
return File(
|
||||||
_HASSFEST_TEMPLATE.format(
|
_HASSFEST_TEMPLATE.format(
|
||||||
@ -197,7 +198,7 @@ def _generate_files(config: Config) -> list[File]:
|
|||||||
DOCKERFILE_TEMPLATE.format(
|
DOCKERFILE_TEMPLATE.format(
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
**package_versions,
|
**package_versions,
|
||||||
go2rtc=_GO2RTC_VERSION,
|
go2rtc=GO2RTC_VERSION,
|
||||||
),
|
),
|
||||||
config.root / "Dockerfile",
|
config.root / "Dockerfile",
|
||||||
),
|
),
|
||||||
|
@ -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 \
|
-c /usr/src/homeassistant/homeassistant/package_constraints.txt \
|
||||||
-r /usr/src/homeassistant/requirements.txt \
|
-r /usr/src/homeassistant/requirements.txt \
|
||||||
stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.7.1 \
|
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 "name"="hassfest"
|
||||||
LABEL "maintainer"="Home Assistant <hello@home-assistant.io>"
|
LABEL "maintainer"="Home Assistant <hello@home-assistant.io>"
|
||||||
|
@ -1,10 +1,8 @@
|
|||||||
"""Test the Advantage Air Binary Sensor Platform."""
|
"""Test the Advantage Air Binary Sensor Platform."""
|
||||||
|
|
||||||
from datetime import timedelta
|
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.const import STATE_OFF, STATE_ON
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers import entity_registry as er
|
from homeassistant.helpers import entity_registry as er
|
||||||
@ -70,23 +68,15 @@ async def test_binary_sensor_async_setup_entry(
|
|||||||
assert not hass.states.get(entity_id)
|
assert not hass.states.get(entity_id)
|
||||||
|
|
||||||
mock_get.reset_mock()
|
mock_get.reset_mock()
|
||||||
|
|
||||||
|
with patch("homeassistant.config_entries.RELOAD_AFTER_UPDATE_DELAY", 1):
|
||||||
entity_registry.async_update_entity(entity_id=entity_id, disabled_by=None)
|
entity_registry.async_update_entity(entity_id=entity_id, disabled_by=None)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
async_fire_time_changed(
|
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=2))
|
||||||
hass,
|
|
||||||
dt_util.utcnow() + timedelta(seconds=ADVANTAGE_AIR_SYNC_INTERVAL + 1),
|
|
||||||
)
|
|
||||||
await hass.async_block_till_done(wait_background_tasks=True)
|
await hass.async_block_till_done(wait_background_tasks=True)
|
||||||
assert len(mock_get.mock_calls) == 1
|
assert len(mock_get.mock_calls) == 1
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
state = hass.states.get(entity_id)
|
state = hass.states.get(entity_id)
|
||||||
assert state
|
assert state
|
||||||
assert state.state == STATE_ON
|
assert state.state == STATE_ON
|
||||||
@ -101,23 +91,15 @@ async def test_binary_sensor_async_setup_entry(
|
|||||||
assert not hass.states.get(entity_id)
|
assert not hass.states.get(entity_id)
|
||||||
|
|
||||||
mock_get.reset_mock()
|
mock_get.reset_mock()
|
||||||
|
|
||||||
|
with patch("homeassistant.config_entries.RELOAD_AFTER_UPDATE_DELAY", 1):
|
||||||
entity_registry.async_update_entity(entity_id=entity_id, disabled_by=None)
|
entity_registry.async_update_entity(entity_id=entity_id, disabled_by=None)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
async_fire_time_changed(
|
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=2))
|
||||||
hass,
|
|
||||||
dt_util.utcnow() + timedelta(seconds=ADVANTAGE_AIR_SYNC_INTERVAL + 1),
|
|
||||||
)
|
|
||||||
await hass.async_block_till_done(wait_background_tasks=True)
|
await hass.async_block_till_done(wait_background_tasks=True)
|
||||||
assert len(mock_get.mock_calls) == 1
|
assert len(mock_get.mock_calls) == 1
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
state = hass.states.get(entity_id)
|
state = hass.states.get(entity_id)
|
||||||
assert state
|
assert state
|
||||||
assert state.state == STATE_OFF
|
assert state.state == STATE_OFF
|
||||||
|
@ -1,15 +1,13 @@
|
|||||||
"""Test the Advantage Air Sensor Platform."""
|
"""Test the Advantage Air Sensor Platform."""
|
||||||
|
|
||||||
from datetime import timedelta
|
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.const import DOMAIN as ADVANTAGE_AIR_DOMAIN
|
||||||
from homeassistant.components.advantage_air.sensor import (
|
from homeassistant.components.advantage_air.sensor import (
|
||||||
ADVANTAGE_AIR_SERVICE_SET_TIME_TO,
|
ADVANTAGE_AIR_SERVICE_SET_TIME_TO,
|
||||||
ADVANTAGE_AIR_SET_COUNTDOWN_VALUE,
|
ADVANTAGE_AIR_SET_COUNTDOWN_VALUE,
|
||||||
)
|
)
|
||||||
from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY
|
|
||||||
from homeassistant.const import ATTR_ENTITY_ID
|
from homeassistant.const import ATTR_ENTITY_ID
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers import entity_registry as er
|
from homeassistant.helpers import entity_registry as er
|
||||||
@ -124,24 +122,16 @@ async def test_sensor_platform_disabled_entity(
|
|||||||
|
|
||||||
assert not hass.states.get(entity_id)
|
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()
|
mock_get.reset_mock()
|
||||||
|
|
||||||
async_fire_time_changed(
|
with patch("homeassistant.config_entries.RELOAD_AFTER_UPDATE_DELAY", 1):
|
||||||
hass,
|
entity_registry.async_update_entity(entity_id=entity_id, disabled_by=None)
|
||||||
dt_util.utcnow() + timedelta(seconds=ADVANTAGE_AIR_SYNC_INTERVAL + 1),
|
await hass.async_block_till_done(wait_background_tasks=True)
|
||||||
)
|
|
||||||
|
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=2))
|
||||||
await hass.async_block_till_done(wait_background_tasks=True)
|
await hass.async_block_till_done(wait_background_tasks=True)
|
||||||
assert len(mock_get.mock_calls) == 1
|
assert len(mock_get.mock_calls) == 1
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
state = hass.states.get(entity_id)
|
state = hass.states.get(entity_id)
|
||||||
assert state
|
assert state
|
||||||
assert int(state.state) == 25
|
assert int(state.state) == 25
|
||||||
|
@ -489,3 +489,96 @@ async def test_alarm_control_panel_log_deprecated_state_warning_using_attr_state
|
|||||||
)
|
)
|
||||||
# Test we only log once
|
# Test we only log once
|
||||||
assert "Entities should implement the 'alarm_state' property and" not in caplog.text
|
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"
|
||||||
|
@ -44,7 +44,7 @@ async def test_form_home(hass: HomeAssistant) -> None:
|
|||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
assert result2["type"] is FlowResultType.CREATE_ENTRY
|
assert result2["type"] is FlowResultType.CREATE_ENTRY
|
||||||
assert result2["title"] == "CO2 Signal"
|
assert result2["title"] == "Electricity Maps"
|
||||||
assert result2["data"] == {
|
assert result2["data"] == {
|
||||||
"api_key": "api_key",
|
"api_key": "api_key",
|
||||||
}
|
}
|
||||||
@ -185,7 +185,7 @@ async def test_form_error_handling(
|
|||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||||
assert result["title"] == "CO2 Signal"
|
assert result["title"] == "Electricity Maps"
|
||||||
assert result["data"] == {
|
assert result["data"] == {
|
||||||
"api_key": "api_key",
|
"api_key": "api_key",
|
||||||
}
|
}
|
||||||
|
@ -418,6 +418,44 @@ async def test_trigger_sentences(hass: HomeAssistant) -> None:
|
|||||||
assert len(callback.mock_calls) == 0
|
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")
|
@pytest.mark.usefixtures("init_components", "sl_setup")
|
||||||
async def test_shopping_list_add_item(hass: HomeAssistant) -> None:
|
async def test_shopping_list_add_item(hass: HomeAssistant) -> None:
|
||||||
"""Test adding an item to the shopping list through the default agent."""
|
"""Test adding an item to the shopping list through the default agent."""
|
||||||
|
@ -275,7 +275,9 @@ async def test_limit_refetch(
|
|||||||
|
|
||||||
with (
|
with (
|
||||||
pytest.raises(aiohttp.ServerTimeoutError),
|
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")
|
resp = await client.get("/api/camera_proxy/camera.config_test")
|
||||||
|
|
||||||
|
@ -637,10 +637,6 @@ async def test_form_stream_other_error(hass: HomeAssistant, user_flow) -> None:
|
|||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize( # Remove when translations fixed
|
|
||||||
"ignore_translations",
|
|
||||||
["component.generic.config.error.Some message"],
|
|
||||||
)
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
@pytest.mark.usefixtures("fakeimg_png")
|
@pytest.mark.usefixtures("fakeimg_png")
|
||||||
async def test_form_stream_worker_error(
|
async def test_form_stream_worker_error(
|
||||||
@ -656,7 +652,8 @@ async def test_form_stream_worker_error(
|
|||||||
TESTDATA,
|
TESTDATA,
|
||||||
)
|
)
|
||||||
assert result2["type"] is FlowResultType.FORM
|
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
|
@respx.mock
|
||||||
|
@ -3,9 +3,11 @@
|
|||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
from unittest.mock import AsyncMock, Mock, patch
|
from unittest.mock import AsyncMock, Mock, patch
|
||||||
|
|
||||||
|
from awesomeversion import AwesomeVersion
|
||||||
from go2rtc_client.rest import _StreamClient, _WebRTCClient
|
from go2rtc_client.rest import _StreamClient, _WebRTCClient
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from homeassistant.components.go2rtc.const import RECOMMENDED_VERSION
|
||||||
from homeassistant.components.go2rtc.server import Server
|
from homeassistant.components.go2rtc.server import Server
|
||||||
|
|
||||||
GO2RTC_PATH = "homeassistant.components.go2rtc"
|
GO2RTC_PATH = "homeassistant.components.go2rtc"
|
||||||
@ -23,7 +25,9 @@ def rest_client() -> Generator[AsyncMock]:
|
|||||||
client = mock_client.return_value
|
client = mock_client.return_value
|
||||||
client.streams = streams = Mock(spec_set=_StreamClient)
|
client.streams = streams = Mock(spec_set=_StreamClient)
|
||||||
streams.list.return_value = {}
|
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)
|
client.webrtc = Mock(spec_set=_WebRTCClient)
|
||||||
yield client
|
yield client
|
||||||
|
|
||||||
|
@ -3,9 +3,10 @@
|
|||||||
from collections.abc import Callable, Generator
|
from collections.abc import Callable, Generator
|
||||||
import logging
|
import logging
|
||||||
from typing import NamedTuple
|
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 aiohttp.client_exceptions import ClientConnectionError, ServerConnectionError
|
||||||
|
from awesomeversion import AwesomeVersion
|
||||||
from go2rtc_client import Stream
|
from go2rtc_client import Stream
|
||||||
from go2rtc_client.exceptions import Go2RtcClientError, Go2RtcVersionError
|
from go2rtc_client.exceptions import Go2RtcClientError, Go2RtcVersionError
|
||||||
from go2rtc_client.models import Producer
|
from go2rtc_client.models import Producer
|
||||||
@ -36,10 +37,12 @@ from homeassistant.components.go2rtc.const import (
|
|||||||
CONF_DEBUG_UI,
|
CONF_DEBUG_UI,
|
||||||
DEBUG_UI_URL_MESSAGE,
|
DEBUG_UI_URL_MESSAGE,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
|
RECOMMENDED_VERSION,
|
||||||
)
|
)
|
||||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow
|
from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow
|
||||||
from homeassistant.const import CONF_URL
|
from homeassistant.const import CONF_URL
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers import issue_registry as ir
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
from homeassistant.setup import async_setup_component
|
from homeassistant.setup import async_setup_component
|
||||||
|
|
||||||
@ -199,6 +202,7 @@ async def init_test_integration(
|
|||||||
|
|
||||||
async def _test_setup_and_signaling(
|
async def _test_setup_and_signaling(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
|
issue_registry: ir.IssueRegistry,
|
||||||
rest_client: AsyncMock,
|
rest_client: AsyncMock,
|
||||||
ws_client: Mock,
|
ws_client: Mock,
|
||||||
config: ConfigType,
|
config: ConfigType,
|
||||||
@ -211,6 +215,7 @@ async def _test_setup_and_signaling(
|
|||||||
|
|
||||||
assert await async_setup_component(hass, DOMAIN, config)
|
assert await async_setup_component(hass, DOMAIN, config)
|
||||||
await hass.async_block_till_done(wait_background_tasks=True)
|
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)
|
config_entries = hass.config_entries.async_entries(DOMAIN)
|
||||||
assert len(config_entries) == 1
|
assert len(config_entries) == 1
|
||||||
assert config_entries[0].state == ConfigEntryState.LOADED
|
assert config_entries[0].state == ConfigEntryState.LOADED
|
||||||
@ -238,7 +243,11 @@ async def _test_setup_and_signaling(
|
|||||||
await test()
|
await test()
|
||||||
|
|
||||||
rest_client.streams.add.assert_called_once_with(
|
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
|
# Stream exists but the source is different
|
||||||
@ -252,7 +261,11 @@ async def _test_setup_and_signaling(
|
|||||||
await test()
|
await test()
|
||||||
|
|
||||||
rest_client.streams.add.assert_called_once_with(
|
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.
|
# 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])
|
@pytest.mark.parametrize("has_go2rtc_entry", [True, False])
|
||||||
async def test_setup_managed(
|
async def test_setup_go_binary(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
|
issue_registry: ir.IssueRegistry,
|
||||||
rest_client: AsyncMock,
|
rest_client: AsyncMock,
|
||||||
ws_client: Mock,
|
ws_client: Mock,
|
||||||
server: AsyncMock,
|
server: AsyncMock,
|
||||||
@ -308,131 +322,21 @@ async def test_setup_managed(
|
|||||||
config: ConfigType,
|
config: ConfigType,
|
||||||
ui_enabled: bool,
|
ui_enabled: bool,
|
||||||
) -> None:
|
) -> 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
|
assert (len(hass.config_entries.async_entries(DOMAIN)) == 1) == has_go2rtc_entry
|
||||||
camera = init_test_integration
|
|
||||||
|
|
||||||
entity_id = camera.entity_id
|
def after_setup() -> None:
|
||||||
stream_name_orginal = camera.entity_id + "_orginal"
|
|
||||||
assert camera.frontend_stream_type == StreamType.HLS
|
|
||||||
|
|
||||||
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.assert_called_once_with(hass, "/usr/bin/go2rtc", enable_ui=ui_enabled)
|
||||||
server_start.assert_called_once()
|
server_start.assert_called_once()
|
||||||
|
|
||||||
receive_message_callback = Mock(spec_set=WebRTCSendMessage)
|
await _test_setup_and_signaling(
|
||||||
|
hass,
|
||||||
async def test() -> None:
|
issue_registry,
|
||||||
await camera.async_handle_async_webrtc_offer(
|
rest_client,
|
||||||
OFFER_SDP, "session_id", receive_message_callback
|
ws_client,
|
||||||
)
|
config,
|
||||||
ws_client.send.assert_called_once_with(
|
after_setup,
|
||||||
WebRTCOffer(
|
init_test_integration,
|
||||||
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 hass.async_stop()
|
await hass.async_stop()
|
||||||
@ -448,8 +352,9 @@ async def test_setup_managed(
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
@pytest.mark.parametrize("has_go2rtc_entry", [True, False])
|
@pytest.mark.parametrize("has_go2rtc_entry", [True, False])
|
||||||
async def test_setup_self_hosted(
|
async def test_setup(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
|
issue_registry: ir.IssueRegistry,
|
||||||
rest_client: AsyncMock,
|
rest_client: AsyncMock,
|
||||||
ws_client: Mock,
|
ws_client: Mock,
|
||||||
server: Mock,
|
server: Mock,
|
||||||
@ -458,83 +363,22 @@ async def test_setup_self_hosted(
|
|||||||
mock_is_docker_env: Mock,
|
mock_is_docker_env: Mock,
|
||||||
has_go2rtc_entry: bool,
|
has_go2rtc_entry: bool,
|
||||||
) -> None:
|
) -> 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
|
assert (len(hass.config_entries.async_entries(DOMAIN)) == 1) == has_go2rtc_entry
|
||||||
|
|
||||||
config = {DOMAIN: {CONF_URL: "http://localhost:1984/"}}
|
config = {DOMAIN: {CONF_URL: "http://localhost:1984/"}}
|
||||||
camera = init_test_integration
|
|
||||||
|
|
||||||
entity_id = camera.entity_id
|
def after_setup() -> None:
|
||||||
assert camera.frontend_stream_type == StreamType.HLS
|
|
||||||
|
|
||||||
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()
|
server.assert_not_called()
|
||||||
|
|
||||||
receive_message_callback = Mock(spec_set=WebRTCSendMessage)
|
await _test_setup_and_signaling(
|
||||||
|
hass,
|
||||||
async def test() -> None:
|
issue_registry,
|
||||||
await camera.async_handle_async_webrtc_offer(
|
rest_client,
|
||||||
OFFER_SDP, "session_id", receive_message_callback
|
ws_client,
|
||||||
)
|
config,
|
||||||
ws_client.send.assert_called_once_with(
|
after_setup,
|
||||||
WebRTCOffer(
|
init_test_integration,
|
||||||
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")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
mock_get_binary.assert_not_called()
|
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 len(hass.config_entries.async_entries(DOMAIN)) == 1
|
||||||
assert not await hass.config_entries.async_setup(config_entry.entry_id)
|
assert not await hass.config_entries.async_setup(config_entry.entry_id)
|
||||||
assert len(hass.config_entries.async_entries(DOMAIN)) == 0
|
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",
|
||||||
|
}
|
||||||
|
@ -10,7 +10,12 @@ from homeassistant.components.alarm_control_panel import (
|
|||||||
)
|
)
|
||||||
from homeassistant.components.homekit.const import ATTR_VALUE
|
from homeassistant.components.homekit.const import ATTR_VALUE
|
||||||
from homeassistant.components.homekit.type_security_systems import SecuritySystem
|
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 homeassistant.core import Event, HomeAssistant
|
||||||
|
|
||||||
from tests.common import async_mock_service
|
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():
|
for val in valid_target_values.values():
|
||||||
assert val in test_config.get("target_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
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
"""Test helpers for Husqvarna Automower."""
|
"""Test helpers for Husqvarna Automower."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
import time
|
import time
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
@ -101,10 +102,17 @@ async def setup_credentials(hass: HomeAssistant) -> None:
|
|||||||
def mock_automower_client(values) -> Generator[AsyncMock]:
|
def mock_automower_client(values) -> Generator[AsyncMock]:
|
||||||
"""Mock a Husqvarna Automower client."""
|
"""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 = AsyncMock(spec=AutomowerSession)
|
||||||
mock.auth = AsyncMock(side_effect=ClientWebSocketResponse)
|
mock.auth = AsyncMock(side_effect=ClientWebSocketResponse)
|
||||||
mock.commands = AsyncMock(spec_set=_MowerCommands)
|
mock.commands = AsyncMock(spec_set=_MowerCommands)
|
||||||
mock.get_status.return_value = values
|
mock.get_status.return_value = values
|
||||||
|
mock.start_listening = AsyncMock(side_effect=listen)
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"homeassistant.components.husqvarna_automower.AutomowerSession",
|
"homeassistant.components.husqvarna_automower.AutomowerSession",
|
||||||
|
@ -1,14 +1,16 @@
|
|||||||
"""Tests for init module."""
|
"""Tests for init module."""
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
from asyncio import Event
|
||||||
|
from datetime import datetime
|
||||||
import http
|
import http
|
||||||
import time
|
import time
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
from aioautomower.exceptions import (
|
from aioautomower.exceptions import (
|
||||||
ApiException,
|
ApiException,
|
||||||
AuthException,
|
AuthException,
|
||||||
HusqvarnaWSServerHandshakeError,
|
HusqvarnaWSServerHandshakeError,
|
||||||
|
TimeoutException,
|
||||||
)
|
)
|
||||||
from aioautomower.model import MowerAttributes, WorkArea
|
from aioautomower.model import MowerAttributes, WorkArea
|
||||||
from freezegun.api import FrozenDateTimeFactory
|
from freezegun.api import FrozenDateTimeFactory
|
||||||
@ -127,28 +129,77 @@ async def test_update_failed(
|
|||||||
assert entry.state is entry_state
|
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(
|
async def test_websocket_not_available(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
mock_automower_client: AsyncMock,
|
mock_automower_client: AsyncMock,
|
||||||
mock_config_entry: MockConfigEntry,
|
mock_config_entry: MockConfigEntry,
|
||||||
caplog: pytest.LogCaptureFixture,
|
caplog: pytest.LogCaptureFixture,
|
||||||
freezer: FrozenDateTimeFactory,
|
freezer: FrozenDateTimeFactory,
|
||||||
|
method_path: list[str],
|
||||||
|
exception: type[Exception],
|
||||||
|
error_msg: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test trying reload the websocket."""
|
"""Test trying to reload the websocket."""
|
||||||
mock_automower_client.start_listening.side_effect = HusqvarnaWSServerHandshakeError(
|
calls = []
|
||||||
"Boom"
|
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)
|
await setup_integration(hass, mock_config_entry)
|
||||||
assert "Failed to connect to websocket. Trying to reconnect: Boom" in caplog.text
|
await mock_called.wait()
|
||||||
assert mock_automower_client.auth.websocket_connect.call_count == 1
|
mock_called.clear()
|
||||||
assert mock_automower_client.start_listening.call_count == 1
|
# Allow the exception to be raised
|
||||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
mock_stall.set()
|
||||||
freezer.tick(timedelta(seconds=2))
|
assert mock.call_count == 1
|
||||||
async_fire_time_changed(hass)
|
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
assert mock_automower_client.auth.websocket_connect.call_count == 2
|
assert f"{error_msg} Trying to reconnect: Boom" in caplog.text
|
||||||
assert mock_automower_client.start_listening.call_count == 2
|
|
||||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
# 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(
|
async def test_device_info(
|
||||||
|
@ -4,7 +4,6 @@ import json
|
|||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from pygti.exceptions import CannotConnect, InvalidAuth
|
from pygti.exceptions import CannotConnect, InvalidAuth
|
||||||
import pytest
|
|
||||||
|
|
||||||
from homeassistant.components.hvv_departures.const import (
|
from homeassistant.components.hvv_departures.const import (
|
||||||
CONF_FILTER,
|
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:
|
async def test_options_flow_invalid_auth(hass: HomeAssistant) -> None:
|
||||||
"""Test that options flow works."""
|
"""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"}
|
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:
|
async def test_options_flow_cannot_connect(hass: HomeAssistant) -> None:
|
||||||
"""Test that options flow works."""
|
"""Test that options flow works."""
|
||||||
|
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user