Compare commits

...

23 Commits

Author SHA1 Message Date
Franck Nijhof
211ce43127 Bump version to 2024.11.0b7 2024-11-05 20:33:48 +01:00
G Johansson
f5555df990 Bump holidays to 0.60 (#129909) 2024-11-05 20:33:39 +01:00
Paul Bottein
82c2422990 Update frontend to 20241105.0 (#129906) 2024-11-05 20:33:36 +01:00
Erik Montnemery
734ebc1adb Improve improv BLE error handling (#129902) 2024-11-05 20:33:33 +01:00
Paulus Schoutsen
eb3371beef Change Ollama default to llama3.2 (#129901) 2024-11-05 20:33:30 +01:00
Manu
e1ef1063fe Prevent update entity becoming unavailable on device disconnect in IronOS (#129840)
* Don't render update entity unavailable when Pinecil device disconnects

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

* backward compatibility added

* clean

* cleaner backward compatibility approach

* don't introduce default unique_id

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

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

* Update homeassistant/components/camera/__init__.py

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

* Implement suggestion

* Add tests

* Shorten test name

* Fix test

---------

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2024-11-05 16:38:44 +01:00
Kunal Aggarwal
3f5e395e2f Adding new on values for Tuya Presence Detection Sensor (#129801) 2024-11-05 16:38:41 +01:00
Joost Lekkerkerker
00ea1cab9f Add basic testing framework to LG ThinQ (#127785)
Co-authored-by: jangwon.lee <jangwon.lee@lge.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
Co-authored-by: YunseonPark-LGE <34848373+YunseonPark-LGE@users.noreply.github.com>
Co-authored-by: LG-ThinQ-Integration <LG-ThinQ-Integration@lge.com>
Co-authored-by: Franck Nijhof <git@frenck.dev>
2024-11-05 16:38:37 +01:00
60 changed files with 1719 additions and 282 deletions

View File

@@ -7,7 +7,11 @@ from typing import Any
from bimmer_connected.api.authentication import MyBMWAuthentication
from bimmer_connected.api.regions import get_region_from_name
from bimmer_connected.models import MyBMWAPIError, MyBMWAuthError
from bimmer_connected.models import (
MyBMWAPIError,
MyBMWAuthError,
MyBMWCaptchaMissingError,
)
from httpx import RequestError
import voluptuous as vol
@@ -54,6 +58,8 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
try:
await auth.login()
except MyBMWCaptchaMissingError as ex:
raise MissingCaptcha from ex
except MyBMWAuthError as ex:
raise InvalidAuth from ex
except (MyBMWAPIError, RequestError) as ex:
@@ -98,6 +104,8 @@ class BMWConfigFlow(ConfigFlow, domain=DOMAIN):
CONF_REFRESH_TOKEN: info.get(CONF_REFRESH_TOKEN),
CONF_GCID: info.get(CONF_GCID),
}
except MissingCaptcha:
errors["base"] = "missing_captcha"
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
@@ -192,3 +200,7 @@ class CannotConnect(HomeAssistantError):
class InvalidAuth(HomeAssistantError):
"""Error to indicate there is invalid auth."""
class MissingCaptcha(HomeAssistantError):
"""Error to indicate the captcha token is missing."""

View File

@@ -7,7 +7,12 @@ import logging
from bimmer_connected.account import MyBMWAccount
from bimmer_connected.api.regions import get_region_from_name
from bimmer_connected.models import GPSPosition, MyBMWAPIError, MyBMWAuthError
from bimmer_connected.models import (
GPSPosition,
MyBMWAPIError,
MyBMWAuthError,
MyBMWCaptchaMissingError,
)
from httpx import RequestError
from homeassistant.config_entries import ConfigEntry
@@ -61,6 +66,12 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator[None]):
try:
await self.account.get_vehicles()
except MyBMWCaptchaMissingError as err:
# If a captcha is required (user/password login flow), always trigger the reauth flow
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="missing_captcha",
) from err
except MyBMWAuthError as err:
# Allow one retry interval before raising AuthFailed to avoid flaky API issues
if self.last_update_success:

View File

@@ -7,5 +7,5 @@
"iot_class": "cloud_polling",
"loggers": ["bimmer_connected"],
"quality_scale": "platinum",
"requirements": ["bimmer-connected[china]==0.16.3"]
"requirements": ["bimmer-connected[china]==0.16.4"]
}

View File

@@ -11,7 +11,8 @@
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"missing_captcha": "Captcha validation missing"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
@@ -200,6 +201,9 @@
"exceptions": {
"invalid_poi": {
"message": "Invalid data for point of interest: {poi_exception}"
},
"missing_captcha": {
"message": "Login requires captcha validation"
}
}
}

View File

@@ -484,9 +484,13 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
self._create_stream_lock: asyncio.Lock | None = None
self._webrtc_provider: CameraWebRTCProvider | None = None
self._legacy_webrtc_provider: CameraWebRTCLegacyProvider | None = None
self._webrtc_sync_offer = (
self._supports_native_sync_webrtc = (
type(self).async_handle_web_rtc_offer != Camera.async_handle_web_rtc_offer
)
self._supports_native_async_webrtc = (
type(self).async_handle_async_webrtc_offer
!= Camera.async_handle_async_webrtc_offer
)
@cached_property
def entity_picture(self) -> str:
@@ -623,7 +627,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
Integrations can override with a native WebRTC implementation.
"""
if self._webrtc_sync_offer:
if self._supports_native_sync_webrtc:
try:
answer = await self.async_handle_web_rtc_offer(offer_sdp)
except ValueError as ex:
@@ -788,18 +792,25 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
providers or inputs to the state attributes change.
"""
old_provider = self._webrtc_provider
new_provider = await self._async_get_supported_webrtc_provider(
async_get_supported_provider
)
old_legacy_provider = self._legacy_webrtc_provider
new_provider = None
new_legacy_provider = None
if new_provider is None:
# Only add the legacy provider if the new provider is not available
new_legacy_provider = await self._async_get_supported_webrtc_provider(
async_get_supported_legacy_provider
# Skip all providers if the camera has a native WebRTC implementation
if not (
self._supports_native_sync_webrtc or self._supports_native_async_webrtc
):
# Camera doesn't have a native WebRTC implementation
new_provider = await self._async_get_supported_webrtc_provider(
async_get_supported_provider
)
if new_provider is None:
# Only add the legacy provider if the new provider is not available
new_legacy_provider = await self._async_get_supported_webrtc_provider(
async_get_supported_legacy_provider
)
if old_provider != new_provider or old_legacy_provider != new_legacy_provider:
self._webrtc_provider = new_provider
self._legacy_webrtc_provider = new_legacy_provider
@@ -827,7 +838,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
"""Return the WebRTC client configuration and extend it with the registered ice servers."""
config = self._async_get_webrtc_client_configuration()
if not self._webrtc_sync_offer:
if not self._supports_native_sync_webrtc:
# Until 2024.11, the frontend was not resolving any ice servers
# The async approach was added 2024.11 and new integrations need to use it
ice_servers = [
@@ -867,12 +878,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
"""Return the camera capabilities."""
frontend_stream_types = set()
if CameraEntityFeature.STREAM in self.supported_features_compat:
if (
type(self).async_handle_web_rtc_offer
!= Camera.async_handle_web_rtc_offer
or type(self).async_handle_async_webrtc_offer
!= Camera.async_handle_async_webrtc_offer
):
if self._supports_native_sync_webrtc or self._supports_native_async_webrtc:
# The camera has a native WebRTC implementation
frontend_stream_types.add(StreamType.WEB_RTC)
else:

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["pyfibaro"],
"requirements": ["pyfibaro==0.7.8"]
"requirements": ["pyfibaro==0.8.0"]
}

View File

@@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20241104.0"]
"requirements": ["home-assistant-frontend==20241105.0"]
}

View File

@@ -5,7 +5,7 @@ import shutil
from aiohttp.client_exceptions import ClientConnectionError, ServerConnectionError
from go2rtc_client import Go2RtcRestClient
from go2rtc_client.exceptions import Go2RtcClientError
from go2rtc_client.exceptions import Go2RtcClientError, Go2RtcVersionError
from go2rtc_client.ws import (
Go2RtcWsClient,
ReceiveMessages,
@@ -38,7 +38,7 @@ from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey
from homeassistant.util.package import is_docker_env
from .const import CONF_DEBUG_UI, DEBUG_UI_URL_MESSAGE, DEFAULT_URL, DOMAIN
from .const import CONF_DEBUG_UI, DEBUG_UI_URL_MESSAGE, DOMAIN, HA_MANAGED_URL
from .server import Server
_LOGGER = logging.getLogger(__name__)
@@ -114,14 +114,18 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
server = Server(
hass, binary, enable_ui=config.get(DOMAIN, {}).get(CONF_DEBUG_UI, False)
)
await server.start()
try:
await server.start()
except Exception: # noqa: BLE001
_LOGGER.warning("Could not start go2rtc server", exc_info=True)
return False
async def on_stop(event: Event) -> None:
await server.stop()
hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, on_stop)
url = DEFAULT_URL
url = HA_MANAGED_URL
hass.data[_DATA_GO2RTC] = url
discovery_flow.async_create_flow(
@@ -143,7 +147,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# Validate the server URL
try:
client = Go2RtcRestClient(async_get_clientsession(hass), url)
await client.streams.list()
await client.validate_server_version()
except Go2RtcClientError as err:
if isinstance(err.__cause__, _RETRYABLE_ERRORS):
raise ConfigEntryNotReady(
@@ -151,6 +155,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
) from err
_LOGGER.warning("Could not connect to go2rtc instance on %s (%s)", url, err)
return False
except Go2RtcVersionError as err:
raise ConfigEntryNotReady(
f"The go2rtc server version is not supported, {err}"
) from err
except Exception as err: # noqa: BLE001
_LOGGER.warning("Could not connect to go2rtc instance on %s (%s)", url, err)
return False

View File

@@ -4,4 +4,5 @@ DOMAIN = "go2rtc"
CONF_DEBUG_UI = "debug_ui"
DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time."
DEFAULT_URL = "http://localhost:1984/"
HA_MANAGED_API_PORT = 11984
HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/"

View File

@@ -1,6 +1,7 @@
"""Go2rtc server."""
import asyncio
from collections import deque
from contextlib import suppress
import logging
from tempfile import NamedTemporaryFile
@@ -11,13 +12,14 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DEFAULT_URL
from .const import HA_MANAGED_API_PORT, HA_MANAGED_URL
_LOGGER = logging.getLogger(__name__)
_TERMINATE_TIMEOUT = 5
_SETUP_TIMEOUT = 30
_SUCCESSFUL_BOOT_MESSAGE = "INF [api] listen addr="
_LOCALHOST_IP = "127.0.0.1"
_LOG_BUFFER_SIZE = 512
_RESPAWN_COOLDOWN = 1
# Default configuration for HA
@@ -26,16 +28,27 @@ _RESPAWN_COOLDOWN = 1
# - Clear default ice servers
_GO2RTC_CONFIG_FORMAT = r"""
api:
listen: "{api_ip}:1984"
listen: "{api_ip}:{api_port}"
rtsp:
# ffmpeg needs rtsp for opus audio transcoding
listen: "127.0.0.1:8554"
listen: "127.0.0.1:18554"
webrtc:
listen: ":18555/tcp"
ice_servers: []
"""
_LOG_LEVEL_MAP = {
"TRC": logging.DEBUG,
"DBG": logging.DEBUG,
"INF": logging.DEBUG,
"WRN": logging.WARNING,
"ERR": logging.WARNING,
"FTL": logging.ERROR,
"PNC": logging.ERROR,
}
class Go2RTCServerStartError(HomeAssistantError):
"""Raised when server does not start."""
@@ -52,7 +65,11 @@ def _create_temp_file(api_ip: str) -> str:
# Set delete=False to prevent the file from being deleted when the file is closed
# Linux is clearing tmp folder on reboot, so no need to delete it manually
with NamedTemporaryFile(prefix="go2rtc_", suffix=".yaml", delete=False) as file:
file.write(_GO2RTC_CONFIG_FORMAT.format(api_ip=api_ip).encode())
file.write(
_GO2RTC_CONFIG_FORMAT.format(
api_ip=api_ip, api_port=HA_MANAGED_API_PORT
).encode()
)
return file.name
@@ -65,6 +82,7 @@ class Server:
"""Initialize the server."""
self._hass = hass
self._binary = binary
self._log_buffer: deque[str] = deque(maxlen=_LOG_BUFFER_SIZE)
self._process: asyncio.subprocess.Process | None = None
self._startup_complete = asyncio.Event()
self._api_ip = _LOCALHOST_IP
@@ -109,19 +127,34 @@ class Server:
except TimeoutError as err:
msg = "Go2rtc server didn't start correctly"
_LOGGER.exception(msg)
self._log_server_output(logging.WARNING)
await self._stop()
raise Go2RTCServerStartError from err
# Check the server version
client = Go2RtcRestClient(async_get_clientsession(self._hass), HA_MANAGED_URL)
await client.validate_server_version()
async def _log_output(self, process: asyncio.subprocess.Process) -> None:
"""Log the output of the process."""
assert process.stdout is not None
async for line in process.stdout:
msg = line[:-1].decode().strip()
_LOGGER.debug(msg)
self._log_buffer.append(msg)
loglevel = logging.WARNING
if len(split_msg := msg.split(" ", 2)) == 3:
loglevel = _LOG_LEVEL_MAP.get(split_msg[1], loglevel)
_LOGGER.log(loglevel, msg)
if not self._startup_complete.is_set() and _SUCCESSFUL_BOOT_MESSAGE in msg:
self._startup_complete.set()
def _log_server_output(self, loglevel: int) -> None:
"""Log captured process output, then clear the log buffer."""
for line in list(self._log_buffer): # Copy the deque to avoid mutation error
_LOGGER.log(loglevel, line)
self._log_buffer.clear()
async def _watchdog(self) -> None:
"""Keep respawning go2rtc servers.
@@ -149,6 +182,8 @@ class Server:
await asyncio.sleep(_RESPAWN_COOLDOWN)
try:
await self._stop()
_LOGGER.warning("Go2rtc unexpectedly stopped, server log:")
self._log_server_output(logging.WARNING)
_LOGGER.debug("Spawning new go2rtc server")
with suppress(Go2RTCServerStartError):
await self._start()
@@ -169,12 +204,12 @@ class Server:
async def _monitor_api(self) -> None:
"""Raise if the go2rtc process terminates."""
client = Go2RtcRestClient(async_get_clientsession(self._hass), DEFAULT_URL)
client = Go2RtcRestClient(async_get_clientsession(self._hass), HA_MANAGED_URL)
_LOGGER.debug("Monitoring go2rtc API")
try:
while True:
await client.streams.list()
await client.validate_server_version()
await asyncio.sleep(10)
except Exception as err:
_LOGGER.debug("go2rtc API did not reply", exc_info=True)

View File

@@ -103,6 +103,7 @@ PLACEHOLDER_KEY_ADDON_URL = "addon_url"
PLACEHOLDER_KEY_REFERENCE = "reference"
PLACEHOLDER_KEY_COMPONENTS = "components"
ISSUE_KEY_ADDON_BOOT_FAIL = "issue_addon_boot_fail"
ISSUE_KEY_SYSTEM_DOCKER_CONFIG = "issue_system_docker_config"
ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING = "issue_addon_detached_addon_missing"
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED = "issue_addon_detached_addon_removed"

View File

@@ -36,6 +36,7 @@ from .const import (
EVENT_SUPERVISOR_EVENT,
EVENT_SUPERVISOR_UPDATE,
EVENT_SUPPORTED_CHANGED,
ISSUE_KEY_ADDON_BOOT_FAIL,
ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING,
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
ISSUE_KEY_SYSTEM_DOCKER_CONFIG,
@@ -94,6 +95,7 @@ UNHEALTHY_REASONS = {
# Keys (type + context) of issues that when found should be made into a repair
ISSUE_KEYS_FOR_REPAIRS = {
ISSUE_KEY_ADDON_BOOT_FAIL,
"issue_mount_mount_failed",
"issue_system_multiple_data_disks",
"issue_system_reboot_required",

View File

@@ -14,6 +14,7 @@ from homeassistant.data_entry_flow import FlowResult
from . import get_addons_info, get_issues_info
from .const import (
ISSUE_KEY_ADDON_BOOT_FAIL,
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
ISSUE_KEY_SYSTEM_DOCKER_CONFIG,
PLACEHOLDER_KEY_ADDON,
@@ -181,8 +182,8 @@ class DockerConfigIssueRepairFlow(SupervisorIssueRepairFlow):
return placeholders
class DetachedAddonIssueRepairFlow(SupervisorIssueRepairFlow):
"""Handler for detached addon issue fixing flows."""
class AddonIssueRepairFlow(SupervisorIssueRepairFlow):
"""Handler for addon issue fixing flows."""
@property
def description_placeholders(self) -> dict[str, str] | None:
@@ -210,7 +211,10 @@ async def async_create_fix_flow(
issue = supervisor_issues and supervisor_issues.get_issue(issue_id)
if issue and issue.key == ISSUE_KEY_SYSTEM_DOCKER_CONFIG:
return DockerConfigIssueRepairFlow(issue_id)
if issue and issue.key == ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED:
return DetachedAddonIssueRepairFlow(issue_id)
if issue and issue.key in {
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
ISSUE_KEY_ADDON_BOOT_FAIL,
}:
return AddonIssueRepairFlow(issue_id)
return SupervisorIssueRepairFlow(issue_id)

View File

@@ -17,6 +17,23 @@
}
},
"issues": {
"issue_addon_boot_fail": {
"title": "Add-on failed to start at boot",
"fix_flow": {
"step": {
"fix_menu": {
"description": "Add-on {addon} is set to start at boot but failed to start. Usually this occurs when the configuration is incorrect or the same port is used in multiple add-ons. Check the configuration as well as logs for {addon} and Supervisor.\n\nUse Start to try again or Disable to turn off the start at boot option.",
"menu_options": {
"addon_execute_start": "Start",
"addon_disable_boot": "Disable"
}
}
},
"abort": {
"apply_suggestion_fail": "Could not apply the fix. Check the Supervisor logs for more details."
}
}
},
"issue_addon_detached_addon_missing": {
"title": "Missing repository for an installed add-on",
"description": "Repository for add-on {addon} is missing. This means it will not get updates, and backups may not be restored correctly as the supervisor may not be able to build/download the resources required.\n\nPlease check the [add-on's documentation]({addon_url}) for installation instructions and add the repository to the store."

View File

@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/holiday",
"iot_class": "local_polling",
"requirements": ["holidays==0.59", "babel==2.15.0"]
"requirements": ["holidays==0.60", "babel==2.15.0"]
}

View File

@@ -37,11 +37,8 @@
"set_light_color": {
"message": "Error while trying to set color of {entity_id}: {description}"
},
"set_light_effect": {
"message": "Error while trying to set effect of {entity_id}: {description}"
},
"set_setting": {
"message": "Error while trying to set \"{value}\" to \"{key}\" setting for {entity_id}: {description}"
"message": "Error while trying to assign the value \"{value}\" to the setting \"{key}\" for {entity_id}: {description}"
},
"turn_on": {
"message": "Error while trying to turn on {entity_id} ({key}): {description}"

View File

@@ -120,12 +120,22 @@ class ImprovBLEConfigFlow(ConfigFlow, domain=DOMAIN):
assert self._discovery_info is not None
service_data = self._discovery_info.service_data
improv_service_data = ImprovServiceData.from_bytes(
service_data[SERVICE_DATA_UUID]
)
try:
improv_service_data = ImprovServiceData.from_bytes(
service_data[SERVICE_DATA_UUID]
)
except improv_ble_errors.InvalidCommand as err:
_LOGGER.warning(
"Aborting improv flow, device %s sent invalid improv data: '%s'",
self._discovery_info.address,
service_data[SERVICE_DATA_UUID].hex(),
)
raise AbortFlow("invalid_improv_data") from err
if improv_service_data.state in (State.PROVISIONING, State.PROVISIONED):
_LOGGER.debug(
"Aborting improv flow, device is already provisioned: %s",
"Aborting improv flow, device %s is already provisioned: %s",
self._discovery_info.address,
improv_service_data.state,
)
raise AbortFlow("already_provisioned")

View File

@@ -92,4 +92,7 @@ class IronOSUpdate(IronOSBaseEntity, UpdateEntity):
@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self.firmware_update.last_update_success
return (
self.installed_version is not None
and self.firmware_update.last_update_success
)

View File

@@ -255,73 +255,9 @@ WATER_INFO_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = {
translation_key=ThinQProperty.WATER_TYPE,
),
}
TIMER_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = {
TimerProperty.RELATIVE_TO_START: SensorEntityDescription(
key=TimerProperty.RELATIVE_TO_START,
translation_key=TimerProperty.RELATIVE_TO_START,
),
TimerProperty.RELATIVE_TO_START_WM: SensorEntityDescription(
key=TimerProperty.RELATIVE_TO_START,
translation_key=TimerProperty.RELATIVE_TO_START_WM,
),
TimerProperty.RELATIVE_TO_STOP: SensorEntityDescription(
key=TimerProperty.RELATIVE_TO_STOP,
translation_key=TimerProperty.RELATIVE_TO_STOP,
),
TimerProperty.RELATIVE_TO_STOP_WM: SensorEntityDescription(
key=TimerProperty.RELATIVE_TO_STOP,
translation_key=TimerProperty.RELATIVE_TO_STOP_WM,
),
TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP: SensorEntityDescription(
key=TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP,
translation_key=TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP,
),
TimerProperty.ABSOLUTE_TO_START: SensorEntityDescription(
key=TimerProperty.ABSOLUTE_TO_START,
translation_key=TimerProperty.ABSOLUTE_TO_START,
),
TimerProperty.ABSOLUTE_TO_STOP: SensorEntityDescription(
key=TimerProperty.ABSOLUTE_TO_STOP,
translation_key=TimerProperty.ABSOLUTE_TO_STOP,
),
TimerProperty.REMAIN: SensorEntityDescription(
key=TimerProperty.REMAIN,
translation_key=TimerProperty.REMAIN,
),
TimerProperty.TARGET: SensorEntityDescription(
key=TimerProperty.TARGET,
translation_key=TimerProperty.TARGET,
),
TimerProperty.RUNNING: SensorEntityDescription(
key=TimerProperty.RUNNING,
translation_key=TimerProperty.RUNNING,
),
TimerProperty.TOTAL: SensorEntityDescription(
key=TimerProperty.TOTAL,
translation_key=TimerProperty.TOTAL,
),
TimerProperty.LIGHT_START: SensorEntityDescription(
key=TimerProperty.LIGHT_START,
translation_key=TimerProperty.LIGHT_START,
),
ThinQProperty.ELAPSED_DAY_STATE: SensorEntityDescription(
key=ThinQProperty.ELAPSED_DAY_STATE,
native_unit_of_measurement=UnitOfTime.DAYS,
translation_key=ThinQProperty.ELAPSED_DAY_STATE,
),
ThinQProperty.ELAPSED_DAY_TOTAL: SensorEntityDescription(
key=ThinQProperty.ELAPSED_DAY_TOTAL,
native_unit_of_measurement=UnitOfTime.DAYS,
translation_key=ThinQProperty.ELAPSED_DAY_TOTAL,
),
}
WASHER_SENSORS: tuple[SensorEntityDescription, ...] = (
RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE],
TIMER_SENSOR_DESC[TimerProperty.RELATIVE_TO_START_WM],
TIMER_SENSOR_DESC[TimerProperty.RELATIVE_TO_STOP_WM],
TIMER_SENSOR_DESC[TimerProperty.REMAIN],
TIMER_SENSOR_DESC[TimerProperty.TOTAL],
)
DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = {
DeviceType.AIR_CONDITIONER: (
@@ -332,9 +268,6 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] =
AIR_QUALITY_SENSOR_DESC[ThinQProperty.ODOR_LEVEL],
AIR_QUALITY_SENSOR_DESC[ThinQProperty.TOTAL_POLLUTION_LEVEL],
FILTER_INFO_SENSOR_DESC[ThinQProperty.FILTER_LIFETIME],
TIMER_SENSOR_DESC[TimerProperty.RELATIVE_TO_START],
TIMER_SENSOR_DESC[TimerProperty.RELATIVE_TO_STOP],
TIMER_SENSOR_DESC[TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP],
),
DeviceType.AIR_PURIFIER_FAN: (
AIR_QUALITY_SENSOR_DESC[ThinQProperty.PM1],
@@ -345,7 +278,6 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] =
AIR_QUALITY_SENSOR_DESC[ThinQProperty.MONITORING_ENABLED],
AIR_QUALITY_SENSOR_DESC[ThinQProperty.ODOR_LEVEL],
AIR_QUALITY_SENSOR_DESC[ThinQProperty.TOTAL_POLLUTION_LEVEL],
TIMER_SENSOR_DESC[TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP],
),
DeviceType.AIR_PURIFIER: (
AIR_QUALITY_SENSOR_DESC[ThinQProperty.PM1],
@@ -361,7 +293,6 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] =
DeviceType.COOKTOP: (
RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE],
POWER_SENSOR_DESC[ThinQProperty.POWER_LEVEL],
TIMER_SENSOR_DESC[TimerProperty.REMAIN],
),
DeviceType.DEHUMIDIFIER: (
JOB_MODE_SENSOR_DESC[ThinQProperty.CURRENT_JOB_MODE],
@@ -372,9 +303,6 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] =
PREFERENCE_SENSOR_DESC[ThinQProperty.RINSE_LEVEL],
PREFERENCE_SENSOR_DESC[ThinQProperty.SOFTENING_LEVEL],
RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE],
TIMER_SENSOR_DESC[TimerProperty.RELATIVE_TO_START_WM],
TIMER_SENSOR_DESC[TimerProperty.REMAIN],
TIMER_SENSOR_DESC[TimerProperty.TOTAL],
),
DeviceType.DRYER: WASHER_SENSORS,
DeviceType.HOME_BREW: (
@@ -385,10 +313,7 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] =
RECIPE_SENSOR_DESC[ThinQProperty.FLAVOR_INFO],
RECIPE_SENSOR_DESC[ThinQProperty.BEER_REMAIN],
RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE],
TIMER_SENSOR_DESC[ThinQProperty.ELAPSED_DAY_STATE],
TIMER_SENSOR_DESC[ThinQProperty.ELAPSED_DAY_TOTAL],
),
DeviceType.HOOD: (TIMER_SENSOR_DESC[TimerProperty.REMAIN],),
DeviceType.HUMIDIFIER: (
AIR_QUALITY_SENSOR_DESC[ThinQProperty.PM1],
AIR_QUALITY_SENSOR_DESC[ThinQProperty.PM2],
@@ -397,9 +322,6 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] =
AIR_QUALITY_SENSOR_DESC[ThinQProperty.TEMPERATURE],
AIR_QUALITY_SENSOR_DESC[ThinQProperty.MONITORING_ENABLED],
AIR_QUALITY_SENSOR_DESC[ThinQProperty.TOTAL_POLLUTION_LEVEL],
TIMER_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_START],
TIMER_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_STOP],
TIMER_SENSOR_DESC[TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP],
),
DeviceType.KIMCHI_REFRIGERATOR: (
REFRIGERATION_SENSOR_DESC[ThinQProperty.FRESH_AIR_FILTER],
@@ -408,15 +330,10 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] =
translation_key=ThinQProperty.TARGET_TEMPERATURE,
),
),
DeviceType.MICROWAVE_OVEN: (
RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE],
TIMER_SENSOR_DESC[TimerProperty.REMAIN],
),
DeviceType.MICROWAVE_OVEN: (RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE],),
DeviceType.OVEN: (
RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE],
TEMPERATURE_SENSOR_DESC[ThinQProperty.TARGET_TEMPERATURE],
TIMER_SENSOR_DESC[TimerProperty.REMAIN],
TIMER_SENSOR_DESC[TimerProperty.TARGET],
),
DeviceType.PLANT_CULTIVATOR: (
LIGHT_SENSOR_DESC[ThinQProperty.BRIGHTNESS],
@@ -427,7 +344,6 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] =
TEMPERATURE_SENSOR_DESC[ThinQProperty.DAY_TARGET_TEMPERATURE],
TEMPERATURE_SENSOR_DESC[ThinQProperty.NIGHT_TARGET_TEMPERATURE],
TEMPERATURE_SENSOR_DESC[ThinQProperty.TEMPERATURE_STATE],
TIMER_SENSOR_DESC[TimerProperty.LIGHT_START],
),
DeviceType.REFRIGERATOR: (
REFRIGERATION_SENSOR_DESC[ThinQProperty.FRESH_AIR_FILTER],
@@ -436,7 +352,6 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] =
DeviceType.ROBOT_CLEANER: (
RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE],
JOB_MODE_SENSOR_DESC[ThinQProperty.CURRENT_JOB_MODE],
TIMER_SENSOR_DESC[TimerProperty.RUNNING],
),
DeviceType.STICK_CLEANER: (
BATTERY_SENSOR_DESC[ThinQProperty.BATTERY_PERCENT],

View File

@@ -8,6 +8,6 @@
"iot_class": "calculated",
"loggers": ["yt_dlp"],
"quality_scale": "internal",
"requirements": ["yt-dlp==2024.11.04"],
"requirements": ["yt-dlp[default]==2024.11.04"],
"single_config_entry": true
}

View File

@@ -24,8 +24,12 @@ MAX_HISTORY_SECONDS = 60 * 60 # 1 hour
MODEL_NAMES = [ # https://ollama.com/library
"alfred",
"all-minilm",
"aya-expanse",
"aya",
"bakllava",
"bespoke-minicheck",
"bge-large",
"bge-m3",
"codebooga",
"codegeex4",
"codegemma",
@@ -33,18 +37,19 @@ MODEL_NAMES = [ # https://ollama.com/library
"codeqwen",
"codestral",
"codeup",
"command-r",
"command-r-plus",
"command-r",
"dbrx",
"deepseek-coder",
"deepseek-coder-v2",
"deepseek-coder",
"deepseek-llm",
"deepseek-v2.5",
"deepseek-v2",
"dolphincoder",
"dolphin-llama3",
"dolphin-mistral",
"dolphin-mixtral",
"dolphin-phi",
"dolphincoder",
"duckdb-nsql",
"everythinglm",
"falcon",
@@ -55,74 +60,97 @@ MODEL_NAMES = [ # https://ollama.com/library
"glm4",
"goliath",
"granite-code",
"granite3-dense",
"granite3-guardian" "granite3-moe",
"hermes3",
"internlm2",
"llama2",
"llama-guard3",
"llama-pro",
"llama2-chinese",
"llama2-uncensored",
"llama3",
"llama2",
"llama3-chatqa",
"llama3-gradient",
"llama3-groq-tool-use",
"llama-pro",
"llava",
"llama3.1",
"llama3.2",
"llama3",
"llava-llama3",
"llava-phi3",
"llava",
"magicoder",
"mathstral",
"meditron",
"medllama2",
"megadolphin",
"mistral",
"mistrallite",
"minicpm-v",
"mistral-large",
"mistral-nemo",
"mistral-openorca",
"mistral-small",
"mistral",
"mistrallite",
"mixtral",
"moondream",
"mxbai-embed-large",
"nemotron-mini",
"nemotron",
"neural-chat",
"nexusraven",
"nomic-embed-text",
"notus",
"notux",
"nous-hermes",
"nous-hermes2",
"nous-hermes2-mixtral",
"nous-hermes2",
"nuextract",
"open-orca-platypus2",
"openchat",
"openhermes",
"open-orca-platypus2",
"orca2",
"orca-mini",
"orca2",
"paraphrase-multilingual",
"phi",
"phi3.5",
"phi3",
"phind-codellama",
"qwen",
"qwen2-math",
"qwen2.5-coder",
"qwen2.5",
"qwen2",
"reader-lm",
"reflection",
"samantha-mistral",
"shieldgemma",
"smollm",
"smollm2",
"snowflake-arctic-embed",
"solar-pro",
"solar",
"sqlcoder",
"stable-beluga",
"stable-code",
"stablelm2",
"stablelm-zephyr",
"stablelm2",
"starcoder",
"starcoder2",
"starling-lm",
"tinydolphin",
"tinyllama",
"vicuna",
"wizard-math",
"wizard-vicuna-uncensored",
"wizard-vicuna",
"wizardcoder",
"wizardlm-uncensored",
"wizardlm",
"wizardlm2",
"wizardlm-uncensored",
"wizard-math",
"wizard-vicuna",
"wizard-vicuna-uncensored",
"xwinlm",
"yarn-llama2",
"yarn-mistral",
"yi-coder",
"yi",
"zephyr",
]
DEFAULT_MODEL = "llama3.1:latest"
DEFAULT_MODEL = "llama3.2:latest"

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/palazzetti",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["pypalazzetti==0.1.6"]
"requirements": ["pypalazzetti==0.1.10"]
}

View File

@@ -151,7 +151,7 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = {
TuyaBinarySensorEntityDescription(
key=DPCode.PRESENCE_STATE,
device_class=BinarySensorDeviceClass.OCCUPANCY,
on_value="presence",
on_value={"presence", "small_move", "large_move"},
),
),
# Formaldehyde Detector

View File

@@ -6,7 +6,7 @@ import logging
from homeassistant.components.select import SelectEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_UNIQUE_ID
from homeassistant.const import CONF_NAME, CONF_UNIQUE_ID
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device import async_device_info_to_link_from_entity
from homeassistant.helpers.device_registry import DeviceInfo
@@ -36,9 +36,9 @@ async def async_setup_entry(
)
tariff_select = TariffSelect(
name,
tariffs,
unique_id,
name=name,
tariffs=tariffs,
unique_id=unique_id,
device_info=device_info,
)
async_add_entities([tariff_select])
@@ -62,13 +62,15 @@ async def async_setup_platform(
conf_meter_unique_id: str | None = hass.data[DATA_UTILITY][meter].get(
CONF_UNIQUE_ID
)
conf_meter_name = hass.data[DATA_UTILITY][meter].get(CONF_NAME, meter)
async_add_entities(
[
TariffSelect(
meter,
discovery_info[CONF_TARIFFS],
conf_meter_unique_id,
name=conf_meter_name,
tariffs=discovery_info[CONF_TARIFFS],
yaml_slug=meter,
unique_id=conf_meter_unique_id,
)
]
)
@@ -82,12 +84,16 @@ class TariffSelect(SelectEntity, RestoreEntity):
def __init__(
self,
name,
tariffs,
unique_id,
tariffs: list[str],
*,
yaml_slug: str | None = None,
unique_id: str | None = None,
device_info: DeviceInfo | None = None,
) -> None:
"""Initialize a tariff selector."""
self._attr_name = name
if yaml_slug: # Backwards compatibility with YAML configuration entries
self.entity_id = f"select.{yaml_slug}"
self._attr_unique_id = unique_id
self._attr_device_info = device_info
self._current_tariff: str | None = None

View File

@@ -7,5 +7,5 @@
"iot_class": "local_polling",
"loggers": ["holidays"],
"quality_scale": "internal",
"requirements": ["holidays==0.59"]
"requirements": ["holidays==0.60"]
}

View File

@@ -25,7 +25,7 @@ if TYPE_CHECKING:
APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2024
MINOR_VERSION: Final = 11
PATCH_VERSION: Final = "0b5"
PATCH_VERSION: Final = "0b7"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0)

View File

@@ -33,7 +33,7 @@ habluetooth==3.6.0
hass-nabucasa==0.83.0
hassil==1.7.4
home-assistant-bluetooth==1.13.0
home-assistant-frontend==20241104.0
home-assistant-frontend==20241105.0
home-assistant-intents==2024.10.30
httpx==0.27.2
ifaddr==0.2.0

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "homeassistant"
version = "2024.11.0b5"
version = "2024.11.0b7"
license = {text = "Apache-2.0"}
description = "Open-source home automation platform running on Python 3."
readme = "README.rst"

View File

@@ -572,7 +572,7 @@ beautifulsoup4==4.12.3
# beewi-smartclim==0.0.10
# homeassistant.components.bmw_connected_drive
bimmer-connected[china]==0.16.3
bimmer-connected[china]==0.16.4
# homeassistant.components.bizkaibus
bizkaibus==0.1.1
@@ -1121,10 +1121,10 @@ hole==0.8.0
# homeassistant.components.holiday
# homeassistant.components.workday
holidays==0.59
holidays==0.60
# homeassistant.components.frontend
home-assistant-frontend==20241104.0
home-assistant-frontend==20241105.0
# homeassistant.components.conversation
home-assistant-intents==2024.10.30
@@ -1904,7 +1904,7 @@ pyevilgenius==2.0.0
pyezviz==0.2.1.2
# homeassistant.components.fibaro
pyfibaro==0.7.8
pyfibaro==0.8.0
# homeassistant.components.fido
pyfido==2.1.2
@@ -2143,7 +2143,7 @@ pyoverkiz==1.14.1
pyownet==0.10.0.post1
# homeassistant.components.palazzetti
pypalazzetti==0.1.6
pypalazzetti==0.1.10
# homeassistant.components.elv
pypca==0.0.7
@@ -3051,7 +3051,7 @@ youless-api==2.1.2
youtubeaio==1.1.5
# homeassistant.components.media_extractor
yt-dlp==2024.11.04
yt-dlp[default]==2024.11.04
# homeassistant.components.zamg
zamg==0.3.6

View File

@@ -506,7 +506,7 @@ base36==0.1.1
beautifulsoup4==4.12.3
# homeassistant.components.bmw_connected_drive
bimmer-connected[china]==0.16.3
bimmer-connected[china]==0.16.4
# homeassistant.components.eq3btsmart
# homeassistant.components.esphome
@@ -947,10 +947,10 @@ hole==0.8.0
# homeassistant.components.holiday
# homeassistant.components.workday
holidays==0.59
holidays==0.60
# homeassistant.components.frontend
home-assistant-frontend==20241104.0
home-assistant-frontend==20241105.0
# homeassistant.components.conversation
home-assistant-intents==2024.10.30
@@ -1533,7 +1533,7 @@ pyevilgenius==2.0.0
pyezviz==0.2.1.2
# homeassistant.components.fibaro
pyfibaro==0.7.8
pyfibaro==0.8.0
# homeassistant.components.fido
pyfido==2.1.2
@@ -1730,7 +1730,7 @@ pyoverkiz==1.14.1
pyownet==0.10.0.post1
# homeassistant.components.palazzetti
pypalazzetti==0.1.6
pypalazzetti==0.1.10
# homeassistant.components.lcn
pypck==0.7.24
@@ -2437,7 +2437,7 @@ youless-api==2.1.2
youtubeaio==1.1.5
# homeassistant.components.media_extractor
yt-dlp==2024.11.04
yt-dlp[default]==2024.11.04
# homeassistant.components.zamg
zamg==0.3.6

View File

@@ -4,8 +4,13 @@ from copy import deepcopy
from unittest.mock import patch
from bimmer_connected.api.authentication import MyBMWAuthentication
from bimmer_connected.models import MyBMWAPIError, MyBMWAuthError
from bimmer_connected.models import (
MyBMWAPIError,
MyBMWAuthError,
MyBMWCaptchaMissingError,
)
from httpx import RequestError
import pytest
from homeassistant import config_entries
from homeassistant.components.bmw_connected_drive.config_flow import DOMAIN
@@ -311,3 +316,31 @@ async def test_reconfigure_unique_id_abort(hass: HomeAssistant) -> None:
assert result2["type"] is FlowResultType.ABORT
assert result2["reason"] == "account_mismatch"
assert config_entry.data == FIXTURE_COMPLETE_ENTRY
@pytest.mark.usefixtures("bmw_fixture")
async def test_captcha_flow_not_set(hass: HomeAssistant) -> None:
"""Test the external flow with captcha failing once and succeeding the second time."""
TEST_REGION = "north_america"
# Start flow and open form
# Start flow and open form
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
# Add login data
with patch(
"bimmer_connected.api.authentication.MyBMWAuthentication._login_row_na",
side_effect=MyBMWCaptchaMissingError(
"Missing hCaptcha token for North America login"
),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={**FIXTURE_USER_INPUT, CONF_REGION: TEST_REGION},
)
assert result["errors"]["base"] == "missing_captcha"

View File

@@ -1,13 +1,19 @@
"""Test BMW coordinator."""
from copy import deepcopy
from datetime import timedelta
from unittest.mock import patch
from bimmer_connected.models import MyBMWAPIError, MyBMWAuthError
from bimmer_connected.models import (
MyBMWAPIError,
MyBMWAuthError,
MyBMWCaptchaMissingError,
)
from freezegun.api import FrozenDateTimeFactory
import pytest
from homeassistant.components.bmw_connected_drive import DOMAIN as BMW_DOMAIN
from homeassistant.const import CONF_REGION
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import issue_registry as ir
@@ -122,3 +128,38 @@ async def test_init_reauth(
f"config_entry_reauth_{BMW_DOMAIN}_{config_entry.entry_id}",
)
assert reauth_issue.active is True
@pytest.mark.usefixtures("bmw_fixture")
async def test_captcha_reauth(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test the reauth form."""
TEST_REGION = "north_america"
config_entry_fixure = deepcopy(FIXTURE_CONFIG_ENTRY)
config_entry_fixure["data"][CONF_REGION] = TEST_REGION
config_entry = MockConfigEntry(**config_entry_fixure)
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
coordinator = config_entry.runtime_data.coordinator
assert coordinator.last_update_success is True
freezer.tick(timedelta(minutes=10, seconds=1))
with patch(
"bimmer_connected.account.MyBMWAccount.get_vehicles",
side_effect=MyBMWCaptchaMissingError(
"Missing hCaptcha token for North America login"
),
):
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert coordinator.last_update_success is False
assert isinstance(coordinator.last_exception, ConfigEntryAuthFailed) is True
assert coordinator.last_exception.translation_key == "missing_captcha"

View File

@@ -6,6 +6,16 @@ components. Instead call the service directly.
from unittest.mock import Mock
from webrtc_models import RTCIceCandidate
from homeassistant.components.camera import (
Camera,
CameraWebRTCProvider,
WebRTCAnswer,
WebRTCSendMessage,
)
from homeassistant.core import callback
EMPTY_8_6_JPEG = b"empty_8_6"
WEBRTC_ANSWER = "a=sendonly"
STREAM_SOURCE = "rtsp://127.0.0.1/stream"
@@ -23,3 +33,43 @@ def mock_turbo_jpeg(
mocked_turbo_jpeg.scale_with_quality.return_value = EMPTY_8_6_JPEG
mocked_turbo_jpeg.encode.return_value = EMPTY_8_6_JPEG
return mocked_turbo_jpeg
class SomeTestProvider(CameraWebRTCProvider):
"""Test provider."""
def __init__(self) -> None:
"""Initialize the provider."""
self._is_supported = True
@property
def domain(self) -> str:
"""Return the integration domain of the provider."""
return "some_test"
@callback
def async_is_supported(self, stream_source: str) -> bool:
"""Determine if the provider supports the stream source."""
return self._is_supported
async def async_handle_async_webrtc_offer(
self,
camera: Camera,
offer_sdp: str,
session_id: str,
send_message: WebRTCSendMessage,
) -> None:
"""Handle the WebRTC offer and return the answer via the provided callback.
Return value determines if the offer was handled successfully.
"""
send_message(WebRTCAnswer(answer="answer"))
async def async_on_webrtc_candidate(
self, session_id: str, candidate: RTCIceCandidate
) -> None:
"""Handle the WebRTC candidate."""
@callback
def async_close_session(self, session_id: str) -> None:
"""Close the session."""

View File

@@ -4,6 +4,7 @@ from collections.abc import AsyncGenerator, Generator
from unittest.mock import AsyncMock, Mock, PropertyMock, patch
import pytest
from webrtc_models import RTCIceCandidate
from homeassistant.components import camera
from homeassistant.components.camera.const import StreamType
@@ -14,7 +15,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.setup import async_setup_component
from .common import STREAM_SOURCE, WEBRTC_ANSWER
from .common import STREAM_SOURCE, WEBRTC_ANSWER, SomeTestProvider
from tests.common import (
MockConfigEntry,
@@ -155,16 +156,15 @@ def mock_stream_source_fixture() -> Generator[AsyncMock]:
@pytest.fixture
async def mock_camera_webrtc_native_sync_offer(hass: HomeAssistant) -> None:
"""Initialize a test camera with native sync WebRTC support."""
async def mock_test_webrtc_cameras(hass: HomeAssistant) -> None:
"""Initialize a test WebRTC cameras."""
# Cannot use the fixture mock_camera_web_rtc as it's mocking Camera.async_handle_web_rtc_offer
# and native support is checked by verify the function "async_handle_web_rtc_offer" was
# overwritten(implemented) or not
class MockCamera(camera.Camera):
"""Mock Camera Entity."""
class BaseCamera(camera.Camera):
"""Base Camera."""
_attr_name = "Test"
_attr_supported_features: camera.CameraEntityFeature = (
camera.CameraEntityFeature.STREAM
)
@@ -173,9 +173,30 @@ async def mock_camera_webrtc_native_sync_offer(hass: HomeAssistant) -> None:
async def stream_source(self) -> str | None:
return STREAM_SOURCE
class SyncCamera(BaseCamera):
"""Mock Camera with native sync WebRTC support."""
_attr_name = "Sync"
async def async_handle_web_rtc_offer(self, offer_sdp: str) -> str | None:
return WEBRTC_ANSWER
class AsyncCamera(BaseCamera):
"""Mock Camera with native async WebRTC support."""
_attr_name = "Async"
async def async_handle_async_webrtc_offer(
self, offer_sdp: str, session_id: str, send_message: WebRTCSendMessage
) -> None:
send_message(WebRTCAnswer(WEBRTC_ANSWER))
async def async_on_webrtc_candidate(
self, session_id: str, candidate: RTCIceCandidate
) -> None:
"""Handle a WebRTC candidate."""
# Do nothing
domain = "test"
entry = MockConfigEntry(domain=domain)
@@ -208,10 +229,24 @@ async def mock_camera_webrtc_native_sync_offer(hass: HomeAssistant) -> None:
),
)
setup_test_component_platform(
hass, camera.DOMAIN, [MockCamera()], from_config_entry=True
hass, camera.DOMAIN, [SyncCamera(), AsyncCamera()], from_config_entry=True
)
mock_platform(hass, f"{domain}.config_flow", Mock())
with mock_config_flow(domain, ConfigFlow):
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
@pytest.fixture
async def register_test_provider(
hass: HomeAssistant,
) -> AsyncGenerator[SomeTestProvider]:
"""Add WebRTC test provider."""
await async_setup_component(hass, "camera", {})
provider = SomeTestProvider()
unsub = camera.async_register_webrtc_provider(hass, provider)
await hass.async_block_till_done()
yield provider
unsub()

View File

@@ -979,7 +979,7 @@ async def test_camera_capabilities_hls(
)
@pytest.mark.usefixtures("mock_camera_webrtc_native_sync_offer")
@pytest.mark.usefixtures("mock_test_webrtc_cameras")
async def test_camera_capabilities_webrtc(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
@@ -987,5 +987,21 @@ async def test_camera_capabilities_webrtc(
"""Test WebRTC camera capabilities."""
await _test_capabilities(
hass, hass_ws_client, "camera.test", {StreamType.WEB_RTC}, {StreamType.WEB_RTC}
hass, hass_ws_client, "camera.sync", {StreamType.WEB_RTC}, {StreamType.WEB_RTC}
)
@pytest.mark.parametrize(
("entity_id", "expect_native_async_webrtc"),
[("camera.sync", False), ("camera.async", True)],
)
@pytest.mark.usefixtures("mock_test_webrtc_cameras", "register_test_provider")
async def test_webrtc_provider_not_added_for_native_webrtc(
hass: HomeAssistant, entity_id: str, expect_native_async_webrtc: bool
) -> None:
"""Test that a WebRTC provider is not added to a camera when the camera has native WebRTC support."""
camera_obj = get_camera_from_entity_id(hass, entity_id)
assert camera_obj
assert camera_obj._webrtc_provider is None
assert camera_obj._supports_native_sync_webrtc is not expect_native_async_webrtc
assert camera_obj._supports_native_async_webrtc is expect_native_async_webrtc

View File

@@ -34,7 +34,7 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import issue_registry as ir
from homeassistant.setup import async_setup_component
from .common import STREAM_SOURCE, WEBRTC_ANSWER
from .common import STREAM_SOURCE, WEBRTC_ANSWER, SomeTestProvider
from tests.common import (
MockConfigEntry,
@@ -51,46 +51,6 @@ HLS_STREAM_SOURCE = "http://127.0.0.1/example.m3u"
TEST_INTEGRATION_DOMAIN = "test"
class SomeTestProvider(CameraWebRTCProvider):
"""Test provider."""
def __init__(self) -> None:
"""Initialize the provider."""
self._is_supported = True
@property
def domain(self) -> str:
"""Return the integration domain of the provider."""
return "some_test"
@callback
def async_is_supported(self, stream_source: str) -> bool:
"""Determine if the provider supports the stream source."""
return self._is_supported
async def async_handle_async_webrtc_offer(
self,
camera: Camera,
offer_sdp: str,
session_id: str,
send_message: WebRTCSendMessage,
) -> None:
"""Handle the WebRTC offer and return the answer via the provided callback.
Return value determines if the offer was handled successfully.
"""
send_message(WebRTCAnswer(answer="answer"))
async def async_on_webrtc_candidate(
self, session_id: str, candidate: RTCIceCandidate
) -> None:
"""Handle the WebRTC candidate."""
@callback
def async_close_session(self, session_id: str) -> None:
"""Close the session."""
class Go2RTCProvider(SomeTestProvider):
"""go2rtc provider."""
@@ -179,20 +139,6 @@ async def init_test_integration(
return test_camera
@pytest.fixture
async def register_test_provider(
hass: HomeAssistant,
) -> AsyncGenerator[SomeTestProvider]:
"""Add WebRTC test provider."""
await async_setup_component(hass, "camera", {})
provider = SomeTestProvider()
unsub = async_register_webrtc_provider(hass, provider)
await hass.async_block_till_done()
yield provider
unsub()
@pytest.mark.usefixtures("mock_camera", "mock_stream", "mock_stream_source")
async def test_async_register_webrtc_provider(
hass: HomeAssistant,
@@ -393,7 +339,7 @@ async def test_ws_get_client_config(
}
@pytest.mark.usefixtures("mock_camera_webrtc_native_sync_offer")
@pytest.mark.usefixtures("mock_test_webrtc_cameras")
async def test_ws_get_client_config_sync_offer(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
@@ -403,7 +349,7 @@ async def test_ws_get_client_config_sync_offer(
client = await hass_ws_client(hass)
await client.send_json_auto_id(
{"type": "camera/webrtc/get_client_config", "entity_id": "camera.test"}
{"type": "camera/webrtc/get_client_config", "entity_id": "camera.sync"}
)
msg = await client.receive_json()

View File

@@ -23,6 +23,7 @@ def rest_client() -> Generator[AsyncMock]:
client = mock_client.return_value
client.streams = streams = Mock(spec_set=_StreamClient)
streams.list.return_value = {}
client.validate_server_version = AsyncMock()
client.webrtc = Mock(spec_set=_WebRTCClient)
yield client

View File

@@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, Mock, patch
from aiohttp.client_exceptions import ClientConnectionError, ServerConnectionError
from go2rtc_client import Stream
from go2rtc_client.exceptions import Go2RtcClientError
from go2rtc_client.exceptions import Go2RtcClientError, Go2RtcVersionError
from go2rtc_client.models import Producer
from go2rtc_client.ws import (
ReceiveMessages,
@@ -494,6 +494,8 @@ ERR_CONNECT = "Could not connect to go2rtc instance"
ERR_CONNECT_RETRY = (
"Could not connect to go2rtc instance on http://localhost:1984/; Retrying"
)
ERR_START_SERVER = "Could not start go2rtc server"
ERR_UNSUPPORTED_VERSION = "The go2rtc server version is not supported"
_INVALID_CONFIG = "Invalid config for 'go2rtc': "
ERR_INVALID_URL = _INVALID_CONFIG + "invalid url"
ERR_EXCLUSIVE = _INVALID_CONFIG + DEBUG_UI_URL_MESSAGE
@@ -526,8 +528,10 @@ async def test_non_user_setup_with_error(
("config", "go2rtc_binary", "is_docker_env", "expected_log_message"),
[
({DEFAULT_CONFIG_DOMAIN: {}}, None, True, ERR_BINARY_NOT_FOUND),
({DEFAULT_CONFIG_DOMAIN: {}}, "/usr/bin/go2rtc", True, ERR_START_SERVER),
({DOMAIN: {}}, None, False, ERR_URL_REQUIRED),
({DOMAIN: {}}, None, True, ERR_BINARY_NOT_FOUND),
({DOMAIN: {}}, "/usr/bin/go2rtc", True, ERR_START_SERVER),
({DOMAIN: {CONF_URL: "invalid"}}, None, True, ERR_INVALID_URL),
(
{DOMAIN: {CONF_URL: "http://localhost:1984", CONF_DEBUG_UI: True}},
@@ -559,8 +563,6 @@ async def test_setup_with_setup_error(
@pytest.mark.parametrize(
("config", "go2rtc_binary", "is_docker_env", "expected_log_message"),
[
({DEFAULT_CONFIG_DOMAIN: {}}, "/usr/bin/go2rtc", True, ERR_CONNECT),
({DOMAIN: {}}, "/usr/bin/go2rtc", True, ERR_CONNECT),
({DOMAIN: {CONF_URL: "http://localhost:1984/"}}, None, True, ERR_CONNECT),
],
)
@@ -584,7 +586,7 @@ async def test_setup_with_setup_entry_error(
assert expected_log_message in caplog.text
@pytest.mark.parametrize("config", [{DOMAIN: {}}, {DEFAULT_CONFIG_DOMAIN: {}}])
@pytest.mark.parametrize("config", [{DOMAIN: {CONF_URL: "http://localhost:1984/"}}])
@pytest.mark.parametrize(
("cause", "expected_config_entry_state", "expected_log_message"),
[
@@ -598,7 +600,7 @@ async def test_setup_with_setup_entry_error(
@pytest.mark.usefixtures(
"mock_get_binary", "mock_go2rtc_entry", "mock_is_docker_env", "server"
)
async def test_setup_with_retryable_setup_entry_error(
async def test_setup_with_retryable_setup_entry_error_custom_server(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
rest_client: AsyncMock,
@@ -610,7 +612,78 @@ async def test_setup_with_retryable_setup_entry_error(
"""Test setup integration entry fails."""
go2rtc_error = Go2RtcClientError()
go2rtc_error.__cause__ = cause
rest_client.streams.list.side_effect = go2rtc_error
rest_client.validate_server_version.side_effect = go2rtc_error
assert await async_setup_component(hass, DOMAIN, config)
await hass.async_block_till_done(wait_background_tasks=True)
config_entries = hass.config_entries.async_entries(DOMAIN)
assert len(config_entries) == 1
assert config_entries[0].state == expected_config_entry_state
assert expected_log_message in caplog.text
@pytest.mark.parametrize("config", [{DOMAIN: {}}, {DEFAULT_CONFIG_DOMAIN: {}}])
@pytest.mark.parametrize(
("cause", "expected_config_entry_state", "expected_log_message"),
[
(ClientConnectionError(), ConfigEntryState.NOT_LOADED, ERR_START_SERVER),
(ServerConnectionError(), ConfigEntryState.NOT_LOADED, ERR_START_SERVER),
(None, ConfigEntryState.NOT_LOADED, ERR_START_SERVER),
(Exception(), ConfigEntryState.NOT_LOADED, ERR_START_SERVER),
],
)
@pytest.mark.parametrize("has_go2rtc_entry", [True, False])
@pytest.mark.usefixtures(
"mock_get_binary", "mock_go2rtc_entry", "mock_is_docker_env", "server"
)
async def test_setup_with_retryable_setup_entry_error_default_server(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
rest_client: AsyncMock,
has_go2rtc_entry: bool,
config: ConfigType,
cause: Exception,
expected_config_entry_state: ConfigEntryState,
expected_log_message: str,
) -> None:
"""Test setup integration entry fails."""
go2rtc_error = Go2RtcClientError()
go2rtc_error.__cause__ = cause
rest_client.validate_server_version.side_effect = go2rtc_error
assert not await async_setup_component(hass, DOMAIN, config)
await hass.async_block_till_done(wait_background_tasks=True)
config_entries = hass.config_entries.async_entries(DOMAIN)
assert len(config_entries) == has_go2rtc_entry
for config_entry in config_entries:
assert config_entry.state == expected_config_entry_state
assert expected_log_message in caplog.text
@pytest.mark.parametrize("config", [{DOMAIN: {}}, {DEFAULT_CONFIG_DOMAIN: {}}])
@pytest.mark.parametrize(
("go2rtc_error", "expected_config_entry_state", "expected_log_message"),
[
(
Go2RtcVersionError("1.9.4", "1.9.5", "2.0.0"),
ConfigEntryState.SETUP_RETRY,
ERR_UNSUPPORTED_VERSION,
),
],
)
@pytest.mark.parametrize("has_go2rtc_entry", [True, False])
@pytest.mark.usefixtures(
"mock_get_binary", "mock_go2rtc_entry", "mock_is_docker_env", "server"
)
async def test_setup_with_version_error(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
rest_client: AsyncMock,
config: ConfigType,
go2rtc_error: Exception,
expected_config_entry_state: ConfigEntryState,
expected_log_message: str,
) -> None:
"""Test setup integration entry fails."""
rest_client.validate_server_version.side_effect = [None, go2rtc_error]
assert await async_setup_component(hass, DOMAIN, config)
await hass.async_block_till_done(wait_background_tasks=True)
config_entries = hass.config_entries.async_entries(DOMAIN)

View File

@@ -38,6 +38,42 @@ def mock_tempfile() -> Generator[Mock]:
yield file
def _assert_server_output_logged(
server_stdout: list[str],
caplog: pytest.LogCaptureFixture,
loglevel: int,
expect_logged: bool,
) -> None:
"""Check server stdout was logged."""
for entry in server_stdout:
assert (
(
"homeassistant.components.go2rtc.server",
loglevel,
entry,
)
in caplog.record_tuples
) is expect_logged
def assert_server_output_logged(
server_stdout: list[str],
caplog: pytest.LogCaptureFixture,
loglevel: int,
) -> None:
"""Check server stdout was logged."""
_assert_server_output_logged(server_stdout, caplog, loglevel, True)
def assert_server_output_not_logged(
server_stdout: list[str],
caplog: pytest.LogCaptureFixture,
loglevel: int,
) -> None:
"""Check server stdout was logged."""
_assert_server_output_logged(server_stdout, caplog, loglevel, False)
@pytest.mark.parametrize(
("enable_ui", "api_ip"),
[
@@ -47,6 +83,7 @@ def mock_tempfile() -> Generator[Mock]:
)
async def test_server_run_success(
mock_create_subprocess: AsyncMock,
rest_client: AsyncMock,
server_stdout: list[str],
server: Server,
caplog: pytest.LogCaptureFixture,
@@ -70,32 +107,31 @@ async def test_server_run_success(
mock_tempfile.write.assert_called_once_with(
f"""
api:
listen: "{api_ip}:1984"
listen: "{api_ip}:11984"
rtsp:
# ffmpeg needs rtsp for opus audio transcoding
listen: "127.0.0.1:8554"
listen: "127.0.0.1:18554"
webrtc:
listen: ":18555/tcp"
ice_servers: []
""".encode()
)
# Check that server read the log lines
for entry in server_stdout:
assert (
"homeassistant.components.go2rtc.server",
logging.DEBUG,
entry,
) in caplog.record_tuples
# Verify go2rtc binary stdout was logged with debug level
assert_server_output_logged(server_stdout, caplog, logging.DEBUG)
await server.stop()
mock_create_subprocess.return_value.terminate.assert_called_once()
# Verify go2rtc binary stdout was not logged with warning level
assert_server_output_not_logged(server_stdout, caplog, logging.WARNING)
@pytest.mark.usefixtures("mock_tempfile")
async def test_server_timeout_on_stop(
mock_create_subprocess: MagicMock, server: Server
mock_create_subprocess: MagicMock, rest_client: AsyncMock, server: Server
) -> None:
"""Test server run where the process takes too long to terminate."""
# Start server thread
@@ -138,13 +174,9 @@ async def test_server_failed_to_start(
):
await server.start()
# Verify go2rtc binary stdout was logged
for entry in server_stdout:
assert (
"homeassistant.components.go2rtc.server",
logging.DEBUG,
entry,
) in caplog.record_tuples
# Verify go2rtc binary stdout was logged with debug and warning level
assert_server_output_logged(server_stdout, caplog, logging.DEBUG)
assert_server_output_logged(server_stdout, caplog, logging.WARNING)
assert (
"homeassistant.components.go2rtc.server",
@@ -163,12 +195,83 @@ async def test_server_failed_to_start(
)
@pytest.mark.parametrize(
("server_stdout", "expected_loglevel"),
[
(
[
"09:00:03.466 TRC [api] register path path=/",
"09:00:03.466 DBG build vcs.time=2024-10-28T19:47:55Z version=go1.23.2",
"09:00:03.466 INF go2rtc platform=linux/amd64 revision=780f378 version=1.9.5",
"09:00:03.467 INF [api] listen addr=127.0.0.1:1984",
"09:00:03.466 WRN warning message",
'09:00:03.466 ERR [api] listen error="listen tcp 127.0.0.1:11984: bind: address already in use"',
"09:00:03.466 FTL fatal message",
"09:00:03.466 PNC panic message",
"exit with signal: interrupt", # Example of stderr write
],
[
logging.DEBUG,
logging.DEBUG,
logging.DEBUG,
logging.DEBUG,
logging.WARNING,
logging.WARNING,
logging.ERROR,
logging.ERROR,
logging.WARNING,
],
)
],
)
@patch("homeassistant.components.go2rtc.server._RESPAWN_COOLDOWN", 0)
async def test_log_level_mapping(
hass: HomeAssistant,
mock_create_subprocess: MagicMock,
server_stdout: list[str],
rest_client: AsyncMock,
server: Server,
caplog: pytest.LogCaptureFixture,
expected_loglevel: list[int],
) -> None:
"""Log level mapping."""
evt = asyncio.Event()
async def wait_event() -> None:
await evt.wait()
mock_create_subprocess.return_value.wait.side_effect = wait_event
await server.start()
await asyncio.sleep(0.1)
await hass.async_block_till_done()
# Verify go2rtc binary stdout was logged with default level
for i, entry in enumerate(server_stdout):
assert (
"homeassistant.components.go2rtc.server",
expected_loglevel[i],
entry,
) in caplog.record_tuples
evt.set()
await asyncio.sleep(0.1)
await hass.async_block_till_done()
assert_server_output_logged(server_stdout, caplog, logging.WARNING)
await server.stop()
@patch("homeassistant.components.go2rtc.server._RESPAWN_COOLDOWN", 0)
async def test_server_restart_process_exit(
hass: HomeAssistant,
mock_create_subprocess: AsyncMock,
server_stdout: list[str],
rest_client: AsyncMock,
server: Server,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test that the server is restarted when it exits."""
evt = asyncio.Event()
@@ -186,10 +289,16 @@ async def test_server_restart_process_exit(
await hass.async_block_till_done()
mock_create_subprocess.assert_not_awaited()
# Verify go2rtc binary stdout was not yet logged with warning level
assert_server_output_not_logged(server_stdout, caplog, logging.WARNING)
evt.set()
await asyncio.sleep(0.1)
mock_create_subprocess.assert_awaited_once()
# Verify go2rtc binary stdout was logged with warning level
assert_server_output_logged(server_stdout, caplog, logging.WARNING)
await server.stop()
@@ -197,8 +306,10 @@ async def test_server_restart_process_exit(
async def test_server_restart_process_error(
hass: HomeAssistant,
mock_create_subprocess: AsyncMock,
server_stdout: list[str],
rest_client: AsyncMock,
server: Server,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test that the server is restarted on error."""
mock_create_subprocess.return_value.wait.side_effect = [Exception, None, None, None]
@@ -207,10 +318,16 @@ async def test_server_restart_process_error(
mock_create_subprocess.assert_awaited_once()
mock_create_subprocess.reset_mock()
# Verify go2rtc binary stdout was not yet logged with warning level
assert_server_output_not_logged(server_stdout, caplog, logging.WARNING)
await asyncio.sleep(0.1)
await hass.async_block_till_done()
mock_create_subprocess.assert_awaited_once()
# Verify go2rtc binary stdout was logged with warning level
assert_server_output_logged(server_stdout, caplog, logging.WARNING)
await server.stop()
@@ -218,8 +335,10 @@ async def test_server_restart_process_error(
async def test_server_restart_api_error(
hass: HomeAssistant,
mock_create_subprocess: AsyncMock,
server_stdout: list[str],
rest_client: AsyncMock,
server: Server,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test that the server is restarted on error."""
rest_client.streams.list.side_effect = Exception
@@ -228,10 +347,16 @@ async def test_server_restart_api_error(
mock_create_subprocess.assert_awaited_once()
mock_create_subprocess.reset_mock()
# Verify go2rtc binary stdout was not yet logged with warning level
assert_server_output_not_logged(server_stdout, caplog, logging.WARNING)
await asyncio.sleep(0.1)
await hass.async_block_till_done()
mock_create_subprocess.assert_awaited_once()
# Verify go2rtc binary stdout was logged with warning level
assert_server_output_logged(server_stdout, caplog, logging.WARNING)
await server.stop()
@@ -239,6 +364,7 @@ async def test_server_restart_api_error(
async def test_server_restart_error(
hass: HomeAssistant,
mock_create_subprocess: AsyncMock,
server_stdout: list[str],
rest_client: AsyncMock,
server: Server,
caplog: pytest.LogCaptureFixture,
@@ -251,10 +377,16 @@ async def test_server_restart_error(
mock_create_subprocess.assert_awaited_once()
mock_create_subprocess.reset_mock()
# Verify go2rtc binary stdout was not yet logged with warning level
assert_server_output_not_logged(server_stdout, caplog, logging.WARNING)
await asyncio.sleep(0.1)
await hass.async_block_till_done()
mock_create_subprocess.assert_awaited_once()
# Verify go2rtc binary stdout was logged with warning level
assert_server_output_logged(server_stdout, caplog, logging.WARNING)
assert "Unexpected error when restarting go2rtc server" in caplog.text
await server.stop()

View File

@@ -868,3 +868,104 @@ async def test_supervisor_issue_detached_addon_removed(
str(aioclient_mock.mock_calls[-1][1])
== "http://127.0.0.1/resolution/suggestion/1235"
)
@pytest.mark.parametrize(
"all_setup_requests", [{"include_addons": True}], indirect=True
)
@pytest.mark.usefixtures("all_setup_requests")
async def test_supervisor_issue_addon_boot_fail(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
hass_client: ClientSessionGenerator,
issue_registry: ir.IssueRegistry,
) -> None:
"""Test fix flow for supervisor issue."""
mock_resolution_info(
aioclient_mock,
issues=[
{
"uuid": "1234",
"type": "boot_fail",
"context": "addon",
"reference": "test",
"suggestions": [
{
"uuid": "1235",
"type": "execute_start",
"context": "addon",
"reference": "test",
},
{
"uuid": "1236",
"type": "disable_boot",
"context": "addon",
"reference": "test",
},
],
},
],
)
assert await async_setup_component(hass, "hassio", {})
repair_issue = issue_registry.async_get_issue(domain="hassio", issue_id="1234")
assert repair_issue
client = await hass_client()
resp = await client.post(
"/api/repairs/issues/fix",
json={"handler": "hassio", "issue_id": repair_issue.issue_id},
)
assert resp.status == HTTPStatus.OK
data = await resp.json()
flow_id = data["flow_id"]
assert data == {
"type": "menu",
"flow_id": flow_id,
"handler": "hassio",
"step_id": "fix_menu",
"data_schema": [
{
"type": "select",
"options": [
["addon_execute_start", "addon_execute_start"],
["addon_disable_boot", "addon_disable_boot"],
],
"name": "next_step_id",
}
],
"menu_options": ["addon_execute_start", "addon_disable_boot"],
"description_placeholders": {
"reference": "test",
"addon": "test",
},
}
resp = await client.post(
f"/api/repairs/issues/fix/{flow_id}",
json={"next_step_id": "addon_execute_start"},
)
assert resp.status == HTTPStatus.OK
data = await resp.json()
flow_id = data["flow_id"]
assert data == {
"type": "create_entry",
"flow_id": flow_id,
"handler": "hassio",
"description": None,
"description_placeholders": None,
}
assert not issue_registry.async_get_issue(domain="hassio", issue_id="1234")
assert aioclient_mock.mock_calls[-1][0] == "post"
assert (
str(aioclient_mock.mock_calls[-1][1])
== "http://127.0.0.1/resolution/suggestion/1235"
)

View File

@@ -161,7 +161,9 @@ async def test_number_entity_error(
with pytest.raises(HomeConnectError):
getattr(problematic_appliance, mock_attr)()
with pytest.raises(ServiceValidationError, match=r"Error.*set.*setting.*"):
with pytest.raises(
ServiceValidationError, match=r"Error.*assign.*value.*to.*setting.*"
):
await hass.services.async_call(
NUMBER_DOMAIN,
SERVICE_SET_VALUE,

View File

@@ -135,7 +135,9 @@ async def test_time_entity_error(
with pytest.raises(HomeConnectError):
getattr(problematic_appliance, mock_attr)()
with pytest.raises(ServiceValidationError, match=r"Error.*set.*setting.*"):
with pytest.raises(
ServiceValidationError, match=r"Error.*assign.*value.*to.*setting.*"
):
await hass.services.async_call(
TIME_DOMAIN,
SERVICE_SET_VALUE,

View File

@@ -25,6 +25,25 @@ IMPROV_BLE_DISCOVERY_INFO = BluetoothServiceInfoBleak(
)
BAD_IMPROV_BLE_DISCOVERY_INFO = BluetoothServiceInfoBleak(
name="00123456",
address="AA:BB:CC:DD:EE:F0",
rssi=-60,
manufacturer_data={},
service_uuids=[SERVICE_UUID],
service_data={SERVICE_DATA_UUID: b"\x00\x00\x00\x00\x00\x00"},
source="local",
device=generate_ble_device(address="AA:BB:CC:DD:EE:F0", name="00123456"),
advertisement=generate_advertisement_data(
service_uuids=[SERVICE_UUID],
service_data={SERVICE_DATA_UUID: b"\x00\x00\x00\x00\x00\x00"},
),
time=0,
connectable=True,
tx_power=-127,
)
PROVISIONED_IMPROV_BLE_DISCOVERY_INFO = BluetoothServiceInfoBleak(
name="00123456",
address="AA:BB:CC:DD:EE:F0",

View File

@@ -15,6 +15,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResult, FlowResultType
from . import (
BAD_IMPROV_BLE_DISCOVERY_INFO,
IMPROV_BLE_DISCOVERY_INFO,
NOT_IMPROV_BLE_DISCOVERY_INFO,
PROVISIONED_IMPROV_BLE_DISCOVERY_INFO,
@@ -649,3 +650,20 @@ async def test_provision_retry(hass: HomeAssistant, exc, error) -> None:
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "provision"
assert result["errors"] == {"base": error}
async def test_provision_fails_invalid_data(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
) -> None:
"""Test bluetooth flow with error due to invalid data."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_BLUETOOTH},
data=BAD_IMPROV_BLE_DISCOVERY_INFO,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "invalid_improv_data"
assert (
"Aborting improv flow, device AA:BB:CC:DD:EE:F0 sent invalid improv data: '000000000000'"
in caplog.text
)

View File

@@ -1 +1,13 @@
"""Tests for the lgthinq integration."""
"""Tests for the LG ThinQ integration."""
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
"""Fixture for setting up the component."""
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()

View File

@@ -11,7 +11,7 @@ from homeassistant.const import CONF_ACCESS_TOKEN, CONF_COUNTRY
from .const import MOCK_CONNECT_CLIENT_ID, MOCK_COUNTRY, MOCK_PAT, MOCK_UUID
from tests.common import MockConfigEntry
from tests.common import MockConfigEntry, load_json_object_fixture
def mock_thinq_api_response(
@@ -45,6 +45,15 @@ def mock_config_entry() -> MockConfigEntry:
)
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Mock setting up a config entry."""
with patch(
"homeassistant.components.lg_thinq.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture
def mock_uuid() -> Generator[AsyncMock]:
"""Mock a uuid."""
@@ -59,22 +68,37 @@ def mock_uuid() -> Generator[AsyncMock]:
@pytest.fixture
def mock_thinq_api() -> Generator[AsyncMock]:
def mock_thinq_api(mock_thinq_mqtt_client: AsyncMock) -> Generator[AsyncMock]:
"""Mock a thinq api."""
with (
patch("thinqconnect.ThinQApi", autospec=True) as mock_api,
patch("homeassistant.components.lg_thinq.ThinQApi", autospec=True) as mock_api,
patch(
"homeassistant.components.lg_thinq.config_flow.ThinQApi",
new=mock_api,
),
):
thinq_api = mock_api.return_value
thinq_api.async_get_device_list = AsyncMock(
return_value=mock_thinq_api_response(status=200, body={})
thinq_api.async_get_device_list.return_value = [
load_json_object_fixture("air_conditioner/device.json", DOMAIN)
]
thinq_api.async_get_device_profile.return_value = load_json_object_fixture(
"air_conditioner/profile.json", DOMAIN
)
thinq_api.async_get_device_status.return_value = load_json_object_fixture(
"air_conditioner/status.json", DOMAIN
)
yield thinq_api
@pytest.fixture
def mock_thinq_mqtt_client() -> Generator[AsyncMock]:
"""Mock a thinq api."""
with patch(
"homeassistant.components.lg_thinq.mqtt.ThinQMQTTClient", autospec=True
) as mock_api:
yield mock_api
@pytest.fixture
def mock_invalid_thinq_api(mock_thinq_api: AsyncMock) -> AsyncMock:
"""Mock an invalid thinq api."""

View File

@@ -0,0 +1,9 @@
{
"deviceId": "MW2-2E247F93-B570-46A6-B827-920E9E10F966",
"deviceInfo": {
"deviceType": "DEVICE_AIR_CONDITIONER",
"modelName": "PAC_910604_WW",
"alias": "Test air conditioner",
"reportable": true
}
}

View File

@@ -0,0 +1,154 @@
{
"notification": {
"push": ["WATER_IS_FULL"]
},
"property": {
"airConJobMode": {
"currentJobMode": {
"mode": ["r", "w"],
"type": "enum",
"value": {
"r": ["AIR_CLEAN", "COOL", "AIR_DRY"],
"w": ["AIR_CLEAN", "COOL", "AIR_DRY"]
}
}
},
"airFlow": {
"windStrength": {
"mode": ["r", "w"],
"type": "enum",
"value": {
"r": ["LOW", "HIGH", "MID"],
"w": ["LOW", "HIGH", "MID"]
}
}
},
"airQualitySensor": {
"PM1": {
"mode": ["r"],
"type": "number"
},
"PM10": {
"mode": ["r"],
"type": "number"
},
"PM2": {
"mode": ["r"],
"type": "number"
},
"humidity": {
"mode": ["r"],
"type": "number"
},
"monitoringEnabled": {
"mode": ["r", "w"],
"type": "enum",
"value": {
"r": ["ON_WORKING", "ALWAYS"],
"w": ["ON_WORKING", "ALWAYS"]
}
},
"oder": {
"mode": ["r"],
"type": "number"
},
"totalPollution": {
"mode": ["r"],
"type": "number"
}
},
"operation": {
"airCleanOperationMode": {
"mode": ["w"],
"type": "enum",
"value": {
"w": ["START", "STOP"]
}
},
"airConOperationMode": {
"mode": ["r", "w"],
"type": "enum",
"value": {
"r": ["POWER_ON", "POWER_OFF"],
"w": ["POWER_ON", "POWER_OFF"]
}
}
},
"powerSave": {
"powerSaveEnabled": {
"mode": ["r", "w"],
"type": "boolean",
"value": {
"r": [false, true],
"w": [false, true]
}
}
},
"temperature": {
"coolTargetTemperature": {
"mode": ["w"],
"type": "range",
"value": {
"w": {
"max": 30,
"min": 18,
"step": 1
}
}
},
"currentTemperature": {
"mode": ["r"],
"type": "number"
},
"targetTemperature": {
"mode": ["r", "w"],
"type": "range",
"value": {
"r": {
"max": 30,
"min": 18,
"step": 1
},
"w": {
"max": 30,
"min": 18,
"step": 1
}
}
},
"unit": {
"mode": ["r"],
"type": "enum",
"value": {
"r": ["C", "F"]
}
}
},
"timer": {
"relativeHourToStart": {
"mode": ["r", "w"],
"type": "number"
},
"relativeHourToStop": {
"mode": ["r", "w"],
"type": "number"
},
"relativeMinuteToStart": {
"mode": ["r", "w"],
"type": "number"
},
"relativeMinuteToStop": {
"mode": ["r", "w"],
"type": "number"
},
"absoluteHourToStart": {
"mode": ["r", "w"],
"type": "number"
},
"absoluteMinuteToStart": {
"mode": ["r", "w"],
"type": "number"
}
}
}
}

View File

@@ -0,0 +1,43 @@
{
"airConJobMode": {
"currentJobMode": "COOL"
},
"airFlow": {
"windStrength": "MID"
},
"airQualitySensor": {
"PM1": 12,
"PM10": 7,
"PM2": 24,
"humidity": 40,
"monitoringEnabled": "ON_WORKING",
"totalPollution": 3,
"totalPollutionLevel": "GOOD"
},
"filterInfo": {
"filterLifetime": 540,
"usedTime": 180
},
"operation": {
"airConOperationMode": "POWER_ON"
},
"powerSave": {
"powerSaveEnabled": false
},
"sleepTimer": {
"relativeStopTimer": "UNSET"
},
"temperature": {
"currentTemperature": 25,
"targetTemperature": 19,
"unit": "C"
},
"timer": {
"relativeStartTimer": "UNSET",
"relativeStopTimer": "UNSET",
"absoluteStartTimer": "SET",
"absoluteStopTimer": "UNSET",
"absoluteHourToStart": 13,
"absoluteMinuteToStart": 14
}
}

View File

@@ -0,0 +1,86 @@
# serializer version: 1
# name: test_all_entities[climate.test_air_conditioner-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'fan_modes': list([
'low',
'high',
'mid',
]),
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.COOL: 'cool'>,
<HVACMode.DRY: 'dry'>,
]),
'max_temp': 30,
'min_temp': 18,
'preset_modes': list([
'air_clean',
]),
'target_temp_step': 1,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'climate',
'entity_category': None,
'entity_id': 'climate.test_air_conditioner',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'lg_thinq',
'previous_unique_id': None,
'supported_features': <ClimateEntityFeature: 409>,
'translation_key': <ExtendedProperty.CLIMATE_AIR_CONDITIONER: 'climate_air_conditioner'>,
'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_climate_air_conditioner',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[climate.test_air_conditioner-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_humidity': 40,
'current_temperature': 25,
'fan_mode': 'mid',
'fan_modes': list([
'low',
'high',
'mid',
]),
'friendly_name': 'Test air conditioner',
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.COOL: 'cool'>,
<HVACMode.DRY: 'dry'>,
]),
'max_temp': 30,
'min_temp': 18,
'preset_mode': None,
'preset_modes': list([
'air_clean',
]),
'supported_features': <ClimateEntityFeature: 409>,
'target_temp_step': 1,
'temperature': 19,
}),
'context': <ANY>,
'entity_id': 'climate.test_air_conditioner',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'cool',
})
# ---

View File

@@ -0,0 +1,55 @@
# serializer version: 1
# name: test_all_entities[event.test_air_conditioner_notification-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'event_types': list([
'water_is_full',
]),
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'event',
'entity_category': None,
'entity_id': 'event.test_air_conditioner_notification',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Notification',
'platform': 'lg_thinq',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': <ThinQPropertyEx.NOTIFICATION: 'notification'>,
'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_notification',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[event.test_air_conditioner_notification-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'event_type': None,
'event_types': list([
'water_is_full',
]),
'friendly_name': 'Test air conditioner Notification',
}),
'context': <ANY>,
'entity_id': 'event.test_air_conditioner_notification',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---

View File

@@ -0,0 +1,113 @@
# serializer version: 1
# name: test_all_entities[number.test_air_conditioner_schedule_turn_off-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'max': 100.0,
'min': 0.0,
'mode': <NumberMode.BOX: 'box'>,
'step': 1.0,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': None,
'entity_id': 'number.test_air_conditioner_schedule_turn_off',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Schedule turn-off',
'platform': 'lg_thinq',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': <Property.RELATIVE_HOUR_TO_STOP: 'relative_hour_to_stop'>,
'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_relative_hour_to_stop',
'unit_of_measurement': <UnitOfTime.HOURS: 'h'>,
})
# ---
# name: test_all_entities[number.test_air_conditioner_schedule_turn_off-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Test air conditioner Schedule turn-off',
'max': 100.0,
'min': 0.0,
'mode': <NumberMode.BOX: 'box'>,
'step': 1.0,
'unit_of_measurement': <UnitOfTime.HOURS: 'h'>,
}),
'context': <ANY>,
'entity_id': 'number.test_air_conditioner_schedule_turn_off',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_all_entities[number.test_air_conditioner_schedule_turn_on-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'max': 100.0,
'min': 0.0,
'mode': <NumberMode.BOX: 'box'>,
'step': 1.0,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': None,
'entity_id': 'number.test_air_conditioner_schedule_turn_on',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Schedule turn-on',
'platform': 'lg_thinq',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': <Property.RELATIVE_HOUR_TO_START: 'relative_hour_to_start'>,
'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_relative_hour_to_start',
'unit_of_measurement': <UnitOfTime.HOURS: 'h'>,
})
# ---
# name: test_all_entities[number.test_air_conditioner_schedule_turn_on-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Test air conditioner Schedule turn-on',
'max': 100.0,
'min': 0.0,
'mode': <NumberMode.BOX: 'box'>,
'step': 1.0,
'unit_of_measurement': <UnitOfTime.HOURS: 'h'>,
}),
'context': <ANY>,
'entity_id': 'number.test_air_conditioner_schedule_turn_on',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---

View File

@@ -0,0 +1,205 @@
# serializer version: 1
# name: test_all_entities[sensor.test_air_conditioner_humidity-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.test_air_conditioner_humidity',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.HUMIDITY: 'humidity'>,
'original_icon': None,
'original_name': 'Humidity',
'platform': 'lg_thinq',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_humidity',
'unit_of_measurement': '%',
})
# ---
# name: test_all_entities[sensor.test_air_conditioner_humidity-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'humidity',
'friendly_name': 'Test air conditioner Humidity',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'sensor.test_air_conditioner_humidity',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '40',
})
# ---
# name: test_all_entities[sensor.test_air_conditioner_pm1-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.test_air_conditioner_pm1',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.PM1: 'pm1'>,
'original_icon': None,
'original_name': 'PM1',
'platform': 'lg_thinq',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_pm1',
'unit_of_measurement': 'µg/m³',
})
# ---
# name: test_all_entities[sensor.test_air_conditioner_pm1-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'pm1',
'friendly_name': 'Test air conditioner PM1',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'µg/m³',
}),
'context': <ANY>,
'entity_id': 'sensor.test_air_conditioner_pm1',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '12',
})
# ---
# name: test_all_entities[sensor.test_air_conditioner_pm10-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.test_air_conditioner_pm10',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.PM10: 'pm10'>,
'original_icon': None,
'original_name': 'PM10',
'platform': 'lg_thinq',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_pm10',
'unit_of_measurement': 'µg/m³',
})
# ---
# name: test_all_entities[sensor.test_air_conditioner_pm10-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'pm10',
'friendly_name': 'Test air conditioner PM10',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'µg/m³',
}),
'context': <ANY>,
'entity_id': 'sensor.test_air_conditioner_pm10',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '7',
})
# ---
# name: test_all_entities[sensor.test_air_conditioner_pm2_5-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.test_air_conditioner_pm2_5',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.PM25: 'pm25'>,
'original_icon': None,
'original_name': 'PM2.5',
'platform': 'lg_thinq',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_pm2',
'unit_of_measurement': 'µg/m³',
})
# ---
# name: test_all_entities[sensor.test_air_conditioner_pm2_5-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'pm25',
'friendly_name': 'Test air conditioner PM2.5',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'µg/m³',
}),
'context': <ANY>,
'entity_id': 'sensor.test_air_conditioner_pm2_5',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '24',
})
# ---

View File

@@ -0,0 +1,29 @@
"""Tests for the LG Thinq climate platform."""
from unittest.mock import AsyncMock, patch
import pytest
from syrupy import SnapshotAssertion
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from tests.common import MockConfigEntry, snapshot_platform
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_all_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
mock_thinq_api: AsyncMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test all entities."""
with patch("homeassistant.components.lg_thinq.PLATFORMS", [Platform.CLIMATE]):
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)

View File

@@ -14,7 +14,10 @@ from tests.common import MockConfigEntry
async def test_config_flow(
hass: HomeAssistant, mock_thinq_api: AsyncMock, mock_uuid: AsyncMock
hass: HomeAssistant,
mock_thinq_api: AsyncMock,
mock_uuid: AsyncMock,
mock_setup_entry: AsyncMock,
) -> None:
"""Test that an thinq entry is normally created."""
result = await hass.config_entries.flow.async_init(

View File

@@ -0,0 +1,29 @@
"""Tests for the LG Thinq event platform."""
from unittest.mock import AsyncMock, patch
import pytest
from syrupy import SnapshotAssertion
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from tests.common import MockConfigEntry, snapshot_platform
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_all_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
mock_thinq_api: AsyncMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test all entities."""
with patch("homeassistant.components.lg_thinq.PLATFORMS", [Platform.EVENT]):
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)

View File

@@ -0,0 +1,26 @@
"""Tests for the LG ThinQ integration."""
from unittest.mock import AsyncMock
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def test_load_unload_entry(
hass: HomeAssistant,
mock_thinq_api: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test load and unload entry."""
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.LOADED
await hass.config_entries.async_remove(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED

View File

@@ -0,0 +1,29 @@
"""Tests for the LG Thinq number platform."""
from unittest.mock import AsyncMock, patch
import pytest
from syrupy import SnapshotAssertion
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from tests.common import MockConfigEntry, snapshot_platform
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_all_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
mock_thinq_api: AsyncMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test all entities."""
with patch("homeassistant.components.lg_thinq.PLATFORMS", [Platform.NUMBER]):
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)

View File

@@ -0,0 +1,29 @@
"""Tests for the LG Thinq sensor platform."""
from unittest.mock import AsyncMock, patch
import pytest
from syrupy import SnapshotAssertion
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from tests.common import MockConfigEntry, snapshot_platform
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_all_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
mock_thinq_api: AsyncMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test all entities."""
with patch("homeassistant.components.lg_thinq.PLATFORMS", [Platform.SENSOR]):
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)

View File

@@ -3,10 +3,72 @@
from homeassistant.components.utility_meter.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry
async def test_select_entity_name_config_entry(
hass: HomeAssistant,
) -> None:
"""Test for Utility Meter select platform."""
config_entry_config = {
"cycle": "none",
"delta_values": False,
"name": "Energy bill",
"net_consumption": False,
"offset": 0,
"periodically_resetting": True,
"source": "sensor.energy",
"tariffs": ["peak", "offpeak"],
}
source_config_entry = MockConfigEntry()
source_config_entry.add_to_hass(hass)
utility_meter_config_entry = MockConfigEntry(
data={},
domain=DOMAIN,
options=config_entry_config,
title=config_entry_config["name"],
)
utility_meter_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(utility_meter_config_entry.entry_id)
await hass.async_block_till_done()
state = hass.states.get("select.energy_bill")
assert state is not None
assert state.attributes.get("friendly_name") == "Energy bill"
async def test_select_entity_name_yaml(
hass: HomeAssistant,
) -> None:
"""Test for Utility Meter select platform."""
yaml_config = {
"utility_meter": {
"energy_bill": {
"name": "Energy bill",
"source": "sensor.energy",
"tariffs": ["peak", "offpeak"],
"unique_id": "1234abcd",
}
}
}
assert await async_setup_component(hass, DOMAIN, yaml_config)
await hass.async_block_till_done()
state = hass.states.get("select.energy_bill")
assert state is not None
assert state.attributes.get("friendly_name") == "Energy bill"
async def test_device_id(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,