diff --git a/homeassistant/components/braviatv/coordinator.py b/homeassistant/components/braviatv/coordinator.py index d752651132fc8b..0b0ae819075bae 100644 --- a/homeassistant/components/braviatv/coordinator.py +++ b/homeassistant/components/braviatv/coordinator.py @@ -23,6 +23,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util from .const import ( CONF_NICKNAME, @@ -240,11 +241,13 @@ async def async_update_playing(self) -> None: self.source = None if start_datetime := playing_info.get("startDateTime"): start_datetime = datetime.fromisoformat(start_datetime) - current_datetime = datetime.now().replace(tzinfo=start_datetime.tzinfo) # pylint: disable=home-assistant-enforce-naive-now - self.media_position = int( - (current_datetime - start_datetime).total_seconds() - ) - self.media_position_updated_at = datetime.now() # pylint: disable=home-assistant-enforce-naive-now + if start_datetime.tzinfo is None: + start_datetime = start_datetime.replace( + tzinfo=dt_util.get_default_time_zone() + ) + now = dt_util.utcnow() + self.media_position = int((now - start_datetime).total_seconds()) + self.media_position_updated_at = now else: self.media_position = None self.media_position_updated_at = None diff --git a/homeassistant/components/midea/__init__.py b/homeassistant/components/midea/__init__.py index 51605275c33051..8667bc3fcfaa14 100644 --- a/homeassistant/components/midea/__init__.py +++ b/homeassistant/components/midea/__init__.py @@ -20,7 +20,11 @@ from .const import CONF_KEY, CONF_SUBTYPE from .entity import MideaConfigEntry -_PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.HUMIDIFIER] +_PLATFORMS: list[Platform] = [ + Platform.CLIMATE, + Platform.HUMIDIFIER, + Platform.SELECT, +] async def async_setup_entry(hass: HomeAssistant, entry: MideaConfigEntry) -> bool: diff --git a/homeassistant/components/midea/device_catalog.py b/homeassistant/components/midea/device_catalog.py index cef8589404aaa4..140b259bbc582c 100644 --- a/homeassistant/components/midea/device_catalog.py +++ b/homeassistant/components/midea/device_catalog.py @@ -8,6 +8,9 @@ DeviceType.CC: "MDV Wi-Fi Controller", DeviceType.CF: "Heat Pump", DeviceType.FB: "Electric Heater", + DeviceType.X40: "Integrated Ceiling Fan", DeviceType.A1: "Dehumidifier", + DeviceType.FA: "Fan", + DeviceType.FC: "Air Purifier", DeviceType.FD: "Humidifier", } diff --git a/homeassistant/components/midea/select.py b/homeassistant/components/midea/select.py new file mode 100644 index 00000000000000..02f3c0db46a4d5 --- /dev/null +++ b/homeassistant/components/midea/select.py @@ -0,0 +1,148 @@ +"""Select for Midea.""" + +from dataclasses import dataclass +from typing import override + +from midealocal.const import DeviceType + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .entity import MideaConfigEntry, MideaEntity, midea_api_call + +PARALLEL_UPDATES = 0 + + +@dataclass(kw_only=True, frozen=True) +class MideaSelectEntityDescription(SelectEntityDescription): + """Description for a Midea select entity.""" + + models: list[DeviceType] + options_attribute: str + """Device property name returning the list of valid option strings.""" + + +SELECTS: list[MideaSelectEntityDescription] = [ + MideaSelectEntityDescription( + key="direction", + translation_key="direction", + models=[DeviceType.X40], + options_attribute="directions", + ), + MideaSelectEntityDescription( + key="fan_speed", + translation_key="fan_speed", + models=[DeviceType.A1, DeviceType.FC, DeviceType.FD], + options_attribute="fan_speeds", + ), + MideaSelectEntityDescription( + key="water_level_set", + translation_key="water_level_set", + models=[DeviceType.A1], + options_attribute="water_level_sets", + ), + MideaSelectEntityDescription( + key="wind_lr_angle", + translation_key="wind_lr_angle", + models=[DeviceType.AC], + options_attribute="wind_lr_angles", + ), + MideaSelectEntityDescription( + key="wind_ud_angle", + translation_key="wind_ud_angle", + models=[DeviceType.AC], + options_attribute="wind_ud_angles", + ), + MideaSelectEntityDescription( + key="rate_select", + translation_key="rate_select", + models=[DeviceType.AC], + options_attribute="rate_selects", + ), + MideaSelectEntityDescription( + key="silent_level", + translation_key="silent_level", + models=[DeviceType.C3], + options_attribute="silent_modes", + ), + MideaSelectEntityDescription( + key="oscillation_mode", + translation_key="oscillation_mode", + models=[DeviceType.FA], + options_attribute="oscillation_modes", + ), + MideaSelectEntityDescription( + key="oscillation_angle", + translation_key="oscillation_angle", + models=[DeviceType.FA], + options_attribute="oscillation_angles", + ), + MideaSelectEntityDescription( + key="tilting_angle", + translation_key="tilting_angle", + models=[DeviceType.FA], + options_attribute="tilting_angles", + ), + MideaSelectEntityDescription( + key="detect_mode", + translation_key="detect_mode", + models=[DeviceType.FC], + options_attribute="detect_modes", + ), + MideaSelectEntityDescription( + key="mode", + translation_key="mode", + models=[DeviceType.FC], + options_attribute="modes", + ), + MideaSelectEntityDescription( + key="screen_display", + translation_key="screen_display", + models=[DeviceType.FC, DeviceType.FD], + options_attribute="screen_displays", + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: MideaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up selects for device.""" + device = config_entry.runtime_data + + async_add_entities( + MideaSelect(device, description) + for description in SELECTS + if device.device_type in description.models + and description.key in device.attributes + ) + + +class MideaSelect(MideaEntity, SelectEntity): + """Represent a Midea select.""" + + entity_description: MideaSelectEntityDescription + + @property + @override + def options(self) -> list[str]: + """Return the list of valid options.""" + return getattr(self._device, self.entity_description.options_attribute) + + @property + @override + def current_option(self) -> str | None: + """Return the currently selected option.""" + value = self._device.get_attribute(self.entity_description.key) + if not isinstance(value, str): + return None + return value + + @override + def select_option(self, option: str) -> None: + """Select an option.""" + with midea_api_call(): + self._device.set_attribute(attr=self.entity_description.key, value=option) diff --git a/homeassistant/components/midea/strings.json b/homeassistant/components/midea/strings.json index 3ab55b4f63496a..b5c855de31cf72 100644 --- a/homeassistant/components/midea/strings.json +++ b/homeassistant/components/midea/strings.json @@ -105,6 +105,115 @@ "climate_zone2": { "name": "Zone 2 thermostat" } + }, + "select": { + "detect_mode": { + "name": "Detect mode", + "state": { + "methanal": "Formaldehyde", + "off": "Off", + "pm_25": "PM2.5" + } + }, + "direction": { + "name": "Direction", + "state": { + "oscillate": "Oscillate" + } + }, + "fan_speed": { + "name": "Fan speed", + "state": { + "auto": "Auto", + "high": "High", + "low": "Low", + "lowest": "Lowest", + "medium": "Medium", + "off": "Off", + "standby": "Standby" + } + }, + "mode": { + "name": "Mode", + "state": { + "auto": "Auto", + "fast": "Fast", + "manual": "Manual", + "sleep": "Sleep", + "smoke": "Smoke", + "standby": "Standby" + } + }, + "oscillation_angle": { + "name": "Oscillation angle", + "state": { + "off": "Off" + } + }, + "oscillation_mode": { + "name": "Oscillation mode", + "state": { + "both": "Both", + "curve_8": "Curve 8", + "curve_w": "Curve W", + "off": "Off", + "oscillation": "Oscillation", + "reserved": "Reserved", + "tilting": "Tilting" + } + }, + "rate_select": { + "name": "Power rate limit" + }, + "screen_display": { + "name": "Screen display", + "state": { + "bright": "Bright", + "dim": "Dim", + "off": "Off" + } + }, + "silent_level": { + "name": "Silent level", + "state": { + "off": "Off", + "silent": "Silent", + "super_silent": "Super silent" + } + }, + "tilting_angle": { + "name": "Tilting angle", + "state": { + "minus_60": "Minus 60", + "off": "Off", + "plus_60": "Plus 60" + } + }, + "water_level_set": { + "name": "Water level setting" + }, + "wind_lr_angle": { + "name": "Airflow horizontal", + "state": { + "left": "Left", + "left_mid": "Left middle", + "middle": "Middle", + "off": "Off", + "right": "Right", + "right_mid": "Right middle" + } + }, + "wind_ud_angle": { + "name": "Airflow vertical", + "state": { + "down": "Down", + "down_mid": "Down middle", + "middle": "Middle", + "off": "Off", + "up": "Up", + "up_mid": "Up middle" + } + } } }, "exceptions": { diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 57e5b3c0507163..52aef5522ac416 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -64,6 +64,7 @@ async_manage_deprecated_firmware_issue, async_manage_open_wifi_ap_issue, async_manage_outbound_websocket_incorrectly_enabled_issue, + async_manage_rtsp_disabled_issue, ) from .services import async_setup_services from .utils import ( @@ -393,6 +394,7 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ShellyConfigEntry) entry, ) async_manage_open_wifi_ap_issue(hass, entry) + async_manage_rtsp_disabled_issue(hass, entry) remove_empty_sub_devices(hass, entry) elif ( sleep_period is None diff --git a/homeassistant/components/shelly/camera.py b/homeassistant/components/shelly/camera.py index 5d19c330e75f6e..8e8662d9069252 100644 --- a/homeassistant/components/shelly/camera.py +++ b/homeassistant/components/shelly/camera.py @@ -37,6 +37,7 @@ class RpcCameraEntityDescription(RpcEntityDescription, CameraEntityDescription): stream=0, translation_key="stream", translation_placeholders={"stream_id": "0"}, + removal_condition=lambda config, _, key: not config[key]["rtsp"]["enable"], ), "stream_1": RpcCameraEntityDescription( key="camera", @@ -44,6 +45,7 @@ class RpcCameraEntityDescription(RpcEntityDescription, CameraEntityDescription): translation_key="stream", translation_placeholders={"stream_id": "1"}, entity_registry_enabled_default=False, + removal_condition=lambda config, _, key: not config[key]["rtsp"]["enable"], ), } @@ -94,8 +96,7 @@ def available(self) -> bool: if not available: return False - config = self.coordinator.device.config[self.key] - return not self.status["privacy"] and config["rtsp"]["enable"] + return not self.status["privacy"] @override @property diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index 2a1ca6fee8bd95..132d3de2f848f2 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -247,6 +247,7 @@ class BLEScannerMode(StrEnum): ) DEPRECATED_FIRMWARE_ISSUE_ID = "deprecated_firmware_{unique}" OPEN_WIFI_AP_ISSUE_ID = "open_wifi_ap_{unique}" +RTSP_DISABLED_ISSUE_ID = "rtsp_disabled_{unique}" COIOT_UNCONFIGURED_ISSUE_ID = "coiot_unconfigured_{unique}" diff --git a/homeassistant/components/shelly/repairs.py b/homeassistant/components/shelly/repairs.py index 4dbd946230466e..462820da23b341 100644 --- a/homeassistant/components/shelly/repairs.py +++ b/homeassistant/components/shelly/repairs.py @@ -26,6 +26,7 @@ DOMAIN, OPEN_WIFI_AP_ISSUE_ID, OUTBOUND_WEBSOCKET_INCORRECTLY_ENABLED_ISSUE_ID, + RTSP_DISABLED_ISSUE_ID, BLEScannerMode, ) from .coordinator import ShellyConfigEntry @@ -33,6 +34,8 @@ get_coiot_address, get_coiot_port, get_device_entry_gen, + get_rpc_key_id, + get_rpc_key_instances, get_rpc_ws_url, ) @@ -201,6 +204,53 @@ def async_manage_open_wifi_ap_issue( ir.async_delete_issue(hass, DOMAIN, issue_id) +@callback +def async_manage_rtsp_disabled_issue( + hass: HomeAssistant, + entry: ShellyConfigEntry, +) -> None: + """Manage the RTSP disabled issue.""" + issue_id = RTSP_DISABLED_ISSUE_ID.format(unique=entry.unique_id) + + if TYPE_CHECKING: + assert entry.runtime_data.rpc is not None + + device = entry.runtime_data.rpc.device + + if not device.initialized: + return + + camera_keys = get_rpc_key_instances(device.status, "camera") + if not camera_keys: + ir.async_delete_issue(hass, DOMAIN, issue_id) + return + + disabled = [ + key + for key in camera_keys + if key in device.config and not device.config[key]["rtsp"]["enable"] + ] + + if disabled: + ir.async_create_issue( + hass, + DOMAIN, + issue_id, + is_fixable=True, + is_persistent=False, + severity=ir.IssueSeverity.WARNING, + translation_key="rtsp_disabled", + translation_placeholders={ + "device_name": device.name, + "ip_address": device.ip_address, + }, + data={"entry_id": entry.entry_id}, + ) + return + + ir.async_delete_issue(hass, DOMAIN, issue_id) + + class ShellyBlockRepairsFlow(RepairsFlow): """Handler for an issue fixing flow.""" @@ -375,6 +425,52 @@ async def async_step_ignore( return self.async_abort(reason="issue_ignored") +class EnableRtspFlow(RepairsFlow): + """Handler for Enable RTSP flow.""" + + def __init__(self, device: RpcDevice, issue_id: str) -> None: + """Initialize.""" + self._device = device + self.issue_id = issue_id + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> RepairsFlowResult: + """Handle the first step of a fix flow.""" + issue_registry = ir.async_get(self.hass) + description_placeholders = None + if issue := issue_registry.async_get_issue(DOMAIN, self.issue_id): + description_placeholders = issue.translation_placeholders + + return self.async_show_menu( + menu_options=["confirm", "ignore"], + description_placeholders=description_placeholders, + ) + + async def async_step_confirm( + self, user_input: dict[str, str] | None = None + ) -> RepairsFlowResult: + """Handle the confirm step of a fix flow.""" + try: + for key in get_rpc_key_instances(self._device.status, "camera"): + if ( + key in self._device.config + and not self._device.config[key]["rtsp"]["enable"] + ): + await self._device.set_camera_rtsp(get_rpc_key_id(key), True) + except DeviceConnectionError, RpcCallError: + return self.async_abort(reason="cannot_connect") + + return self.async_create_entry(title="", data={}) + + async def async_step_ignore( + self, user_input: dict[str, str] | None = None + ) -> RepairsFlowResult: + """Handle the ignore step of a fix flow.""" + ir.async_ignore_issue(self.hass, DOMAIN, self.issue_id, True) + return self.async_abort(reason="issue_ignored") + + async def async_create_fix_flow( hass: HomeAssistant, issue_id: str, data: dict[str, str] | None ) -> RepairsFlow: @@ -408,4 +504,7 @@ async def async_create_fix_flow( if "open_wifi_ap" in issue_id: return DisableOpenWiFiApFlow(device, issue_id) + if "rtsp_disabled" in issue_id: + return EnableRtspFlow(device, issue_id) + return ConfirmRepairFlow() diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index f9a6ce2f14804f..2273091234cff3 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -765,14 +765,14 @@ "fix_flow": { "abort": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "issue_ignored": "Issue ignored" + "issue_ignored": "[%key:component::shelly::issues::coiot_unconfigured::fix_flow::abort::issue_ignored%]" }, "step": { "init": { "description": "Your Shelly device {device_name} with IP address {ip_address} has an open Wi-Fi access point enabled without a password. This is a security risk as anyone nearby can connect to the device.\n\nNote: If you disable the access point, the device may need to restart.", "menu_options": { "confirm": "Disable Wi-Fi access point", - "ignore": "Ignore" + "ignore": "[%key:component::shelly::issues::coiot_unconfigured::fix_flow::step::init::menu_options::ignore%]" }, "title": "[%key:component::shelly::issues::open_wifi_ap::title%]" } @@ -798,6 +798,25 @@ "description": "Home Assistant is not receiving push updates from the Shelly device {device_name} with IP address {ip_address}. Check the CoIoT configuration in the web panel of the device and your network configuration.", "title": "Shelly device {device_name} push update failure" }, + "rtsp_disabled": { + "fix_flow": { + "abort": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "issue_ignored": "[%key:component::shelly::issues::coiot_unconfigured::fix_flow::abort::issue_ignored%]" + }, + "step": { + "init": { + "description": "Your Shelly device {device_name} with IP address {ip_address} has camera RTSP streams disabled. RTSP must be enabled for camera entities to be created.\n\nSelect **Enable RTSP streams** to enable RTSP for all camera streams.", + "menu_options": { + "confirm": "Enable RTSP streams", + "ignore": "[%key:component::shelly::issues::coiot_unconfigured::fix_flow::step::init::menu_options::ignore%]" + }, + "title": "[%key:component::shelly::issues::rtsp_disabled::title%]" + } + } + }, + "title": "RTSP streams disabled on {device_name}" + }, "unsupported_firmware": { "description": "Your Shelly device {device_name} with IP address {ip_address} is running an unsupported firmware. Please update the firmware.\n\nIf the device does not offer an update, check internet connectivity (gateway, DNS, time) and restart the device.", "title": "Unsupported firmware for device {device_name}" diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 034fae1e015707..4c4d2fcad3110b 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -2,13 +2,15 @@ import asyncio import base64 -from collections.abc import Callable +from collections.abc import Callable, Mapping from contextlib import suppress +from dataclasses import asdict, dataclass, fields import logging from pathlib import Path -from typing import Any, override +from typing import Any, Self, override from awesomeversion import AwesomeVersion +from propcache.api import cached_property import voluptuous as vol from zwave_js_server.client import Client from zwave_js_server.exceptions import BaseZwaveJSServerError, FailedCommand @@ -16,12 +18,7 @@ from zwave_js_server.version import VersionInfo from homeassistant.components import usb -from homeassistant.components.hassio import ( - AddonError, - AddonInfo, - AddonManager, - AddonState, -) +from homeassistant.components.hassio import AddonError, AddonInfo, AddonState from homeassistant.config_entries import ( SOURCE_ESPHOME, SOURCE_IGNORE, @@ -50,21 +47,9 @@ from .const import ( ADDON_SLUG, CONF_ADDON_DEVICE, - CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY, - CONF_ADDON_LR_S2_AUTHENTICATED_KEY, CONF_ADDON_NETWORK_KEY, - CONF_ADDON_S0_LEGACY_KEY, - CONF_ADDON_S2_ACCESS_CONTROL_KEY, - CONF_ADDON_S2_AUTHENTICATED_KEY, - CONF_ADDON_S2_UNAUTHENTICATED_KEY, CONF_ADDON_SOCKET, CONF_INTEGRATION_CREATED_ADDON, - CONF_LR_S2_ACCESS_CONTROL_KEY, - CONF_LR_S2_AUTHENTICATED_KEY, - CONF_S0_LEGACY_KEY, - CONF_S2_ACCESS_CONTROL_KEY, - CONF_S2_AUTHENTICATED_KEY, - CONF_S2_UNAUTHENTICATED_KEY, CONF_SOCKET_PATH, CONF_USB_PATH, CONF_USE_ADDON, @@ -88,16 +73,70 @@ ADDON_SETUP_TIMEOUT_ROUNDS = 40 SERVER_CONNECT_TIMEOUT = 60 + +@dataclass +class SecurityKeys: + """Security keys of a Z-Wave network. + + The field names match the add-on config and config entry keys, + which use the same names. + """ + + s0_legacy_key: str | None = None + s2_access_control_key: str | None = None + s2_authenticated_key: str | None = None + s2_unauthenticated_key: str | None = None + lr_s2_access_control_key: str | None = None + lr_s2_authenticated_key: str | None = None + + @classmethod + def from_config( + cls, config: Mapping[str, Any], defaults: SecurityKeys | None = None + ) -> Self: + """Return keys from an add-on config or entry data, with defaults.""" + return cls( + **{ + field.name: config.get( + field.name, + ((getattr(defaults, field.name) if defaults else None) or ""), + ) + for field in fields(cls) + } + ) + + def updated_from_user_input(self, user_input: Mapping[str, Any]) -> SecurityKeys: + """Return keys updated from user input, with these keys as defaults.""" + return SecurityKeys( + **{ + field.name: user_input.get(field.name, getattr(self, field.name) or "") + for field in fields(self) + } + ) + + def to_dict(self) -> dict[str, str | None]: + """Return the keys as add-on config options or config entry data.""" + return asdict(self) + + def get_schema(self, *, suggested: bool = False) -> dict[vol.Optional, type[str]]: + """Return a data schema dict for the keys, prefilled from these keys.""" + if suggested: + return { + vol.Optional( + field.name, + description={"suggested_value": getattr(self, field.name)}, + ): str + for field in fields(self) + } + return { + vol.Optional(field.name, default=getattr(self, field.name)): str + for field in fields(self) + } + + ADDON_USER_INPUT_MAP = { CONF_ADDON_DEVICE: CONF_USB_PATH, CONF_ADDON_SOCKET: CONF_SOCKET_PATH, - CONF_ADDON_S0_LEGACY_KEY: CONF_S0_LEGACY_KEY, - CONF_ADDON_S2_ACCESS_CONTROL_KEY: CONF_S2_ACCESS_CONTROL_KEY, - CONF_ADDON_S2_AUTHENTICATED_KEY: CONF_S2_AUTHENTICATED_KEY, - CONF_ADDON_S2_UNAUTHENTICATED_KEY: CONF_S2_UNAUTHENTICATED_KEY, - CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY: CONF_LR_S2_ACCESS_CONTROL_KEY, - CONF_ADDON_LR_S2_AUTHENTICATED_KEY: CONF_LR_S2_AUTHENTICATED_KEY, -} +} | {field.name: field.name for field in fields(SecurityKeys)} CONF_ADDON_RF_REGION = "rf_region" @@ -200,29 +239,139 @@ async def async_get_usb_ports(hass: HomeAssistant) -> dict[str, str]: return non_na_ports or port_descriptions +class AddonFlowManager: + """Manage the Z-Wave JS add-on for the config flow. + + Wraps the add-on manager with flow-friendly error handling + and tracks the original add-on config for reverts. + """ + + def __init__(self, hass: HomeAssistant) -> None: + """Set up the add-on flow manager.""" + self.hass = hass + self.addon_manager = get_addon_manager(hass) + # Set to True if the add-on was running when its config was changed, + # meaning a restart instead of a start is needed. + self.restart_addon = False + # The add-on config before this flow changed it, for reverts. + self.original_config: dict[str, Any] | None = None + + async def async_get_addon_info(self) -> AddonInfo: + """Return Z-Wave JS add-on info.""" + try: + addon_info: AddonInfo = await self.addon_manager.async_get_addon_info() + except AddonError as err: + _LOGGER.error(err) + raise AbortFlow("addon_info_failed") from err + + return addon_info + + async def async_set_addon_config(self, config_updates: dict) -> None: + """Set Z-Wave JS add-on config.""" + addon_info = await self.async_get_addon_info() + addon_config = addon_info.options + + new_addon_config = addon_config | config_updates + + if new_addon_config.get(CONF_ADDON_DEVICE) is None: + new_addon_config.pop(CONF_ADDON_DEVICE, None) + if new_addon_config.get(CONF_ADDON_SOCKET) is None: + new_addon_config.pop(CONF_ADDON_SOCKET, None) + + if new_addon_config == addon_config: + return + + if addon_info.state is AddonState.RUNNING: + self.restart_addon = True + self.original_config = dict(addon_config) + # Remove legacy network_key + new_addon_config.pop(CONF_ADDON_NETWORK_KEY, None) + try: + await self.addon_manager.async_set_addon_options(new_addon_config) + except AddonError as err: + _LOGGER.error(err) + raise AbortFlow("addon_set_config_failed") from err + + async def async_install_addon(self) -> None: + """Install the Z-Wave JS add-on.""" + await self.addon_manager.async_schedule_install_addon() + + async def async_stop_addon(self) -> None: + """Stop the Z-Wave JS add-on.""" + await self.addon_manager.async_stop_addon() + + async def async_start_addon_and_wait( + self, ws_address: str | None + ) -> tuple[str, VersionInfo]: + """(Re)start the add-on and wait until the server is reachable. + + Return the server websocket address and version info. + """ + if self.restart_addon: + await self.addon_manager.async_schedule_restart_addon() + else: + await self.addon_manager.async_schedule_start_addon() + version_info: VersionInfo | None = None + # Sleep some seconds to let the add-on start properly before connecting. + for _ in range(ADDON_SETUP_TIMEOUT_ROUNDS): + await asyncio.sleep(ADDON_SETUP_TIMEOUT) + try: + if not ws_address: + discovery_info = ( + await self.addon_manager.async_get_addon_discovery_info() + ) + ws_address = ( + f"ws://{discovery_info['host']}:{discovery_info['port']}" + ) + version_info = await async_get_version_info(self.hass, ws_address) + except (AddonError, CannotConnect) as err: + _LOGGER.debug( + "Add-on not ready yet, waiting %s seconds: %s", + ADDON_SETUP_TIMEOUT, + err, + ) + else: + break + else: + raise CannotConnect("Failed to start Z-Wave JS add-on: timeout") + + assert version_info is not None + return ws_address, version_info + + async def async_get_addon_discovery_info(self) -> dict: + """Return add-on discovery info.""" + try: + discovery_info_config = ( + await self.addon_manager.async_get_addon_discovery_info() + ) + except AddonError as err: + _LOGGER.error(err) + raise AbortFlow("addon_get_discovery_info_failed") from err + + return discovery_info_config + + class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Z-Wave JS.""" + @cached_property + def _addon_setup(self) -> AddonFlowManager: + """Return the add-on flow manager.""" + return AddonFlowManager(self.hass) + VERSION = 1 def __init__(self) -> None: """Set up flow instance.""" - self.s0_legacy_key: str | None = None - self.s2_access_control_key: str | None = None - self.s2_authenticated_key: str | None = None - self.s2_unauthenticated_key: str | None = None - self.lr_s2_access_control_key: str | None = None - self.lr_s2_authenticated_key: str | None = None + self.security_keys = SecurityKeys() self.usb_path: str | None = None self.socket_path: str | None = None # ESPHome socket self.ws_address: str | None = None - self.restart_addon: bool = False # If we install the add-on we should uninstall it on entry remove. self.integration_created_addon = False self.install_task: asyncio.Task | None = None self.start_task: asyncio.Task | None = None self.version_info: VersionInfo | None = None - self.original_addon_config: dict[str, Any] | None = None self.revert_reason: str | None = None self.backup_task: asyncio.Task | None = None self.restore_backup_task: asyncio.Task | None = None @@ -245,7 +394,9 @@ async def async_step_install_addon( ) -> ConfigFlowResult: """Install Z-Wave JS add-on.""" if not self.install_task: - self.install_task = self.hass.async_create_task(self._async_install_addon()) + self.install_task = self.hass.async_create_task( + self._addon_setup.async_install_addon() + ) if not self.install_task.done(): return self.async_show_progress( @@ -280,7 +431,7 @@ async def async_step_start_addon( not self._rf_region or self._rf_region == "Automatic" ): # If the country is not set, we need to check the RF region add-on config. - addon_info = await self._async_get_addon_info() + addon_info = await self._addon_setup.async_get_addon_info() rf_region: str | None = addon_info.options.get(CONF_ADDON_RF_REGION) self._rf_region = rf_region if rf_region is None or rf_region == "Automatic": @@ -290,7 +441,7 @@ async def async_step_start_addon( # If we have updates to the add-on config, # set them before starting the add-on. self._addon_config_updates = {} - await self._async_set_addon_config(config_updates) + await self._addon_setup.async_set_addon_config(config_updates) if not self.start_task: self.start_task = self.hass.async_create_task(self._async_start_addon()) @@ -324,34 +475,11 @@ async def async_step_start_failed( async def _async_start_addon(self) -> None: """Start the Z-Wave JS add-on.""" - addon_manager: AddonManager = get_addon_manager(self.hass) self.version_info = None - if self.restart_addon: - await addon_manager.async_schedule_restart_addon() - else: - await addon_manager.async_schedule_start_addon() - # Sleep some seconds to let the add-on start properly before connecting. - for _ in range(ADDON_SETUP_TIMEOUT_ROUNDS): - await asyncio.sleep(ADDON_SETUP_TIMEOUT) - try: - if not self.ws_address: - discovery_info = await self._async_get_addon_discovery_info() - self.ws_address = ( - f"ws://{discovery_info['host']}:{discovery_info['port']}" - ) - self.version_info = await async_get_version_info( - self.hass, self.ws_address - ) - except (AbortFlow, CannotConnect) as err: - _LOGGER.debug( - "Add-on not ready yet, waiting %s seconds: %s", - ADDON_SETUP_TIMEOUT, - err, - ) - else: - break - else: - raise CannotConnect("Failed to start Z-Wave JS add-on: timeout") + ( + self.ws_address, + self.version_info, + ) = await self._addon_setup.async_start_addon_and_wait(self.ws_address) async def async_step_configure_addon( self, user_input: dict[str, Any] | None = None @@ -375,61 +503,6 @@ async def async_step_finish_addon_setup( return await self.async_step_finish_addon_setup_reconfigure(user_input) return await self.async_step_finish_addon_setup_user(user_input) - async def _async_get_addon_info(self) -> AddonInfo: - """Return and cache Z-Wave JS add-on info.""" - addon_manager: AddonManager = get_addon_manager(self.hass) - try: - addon_info: AddonInfo = await addon_manager.async_get_addon_info() - except AddonError as err: - _LOGGER.error(err) - raise AbortFlow("addon_info_failed") from err - - return addon_info - - async def _async_set_addon_config(self, config_updates: dict) -> None: - """Set Z-Wave JS add-on config.""" - addon_info = await self._async_get_addon_info() - addon_config = addon_info.options - - new_addon_config = addon_config | config_updates - - if new_addon_config.get(CONF_ADDON_DEVICE) is None: - new_addon_config.pop(CONF_ADDON_DEVICE, None) - if new_addon_config.get(CONF_ADDON_SOCKET) is None: - new_addon_config.pop(CONF_ADDON_SOCKET, None) - - if new_addon_config == addon_config: - return - - if addon_info.state is AddonState.RUNNING: - self.restart_addon = True - # Copy the add-on config to keep the objects separate. - self.original_addon_config = dict(addon_config) - # Remove legacy network_key - new_addon_config.pop(CONF_ADDON_NETWORK_KEY, None) - addon_manager: AddonManager = get_addon_manager(self.hass) - try: - await addon_manager.async_set_addon_options(new_addon_config) - except AddonError as err: - _LOGGER.error(err) - raise AbortFlow("addon_set_config_failed") from err - - async def _async_install_addon(self) -> None: - """Install the Z-Wave JS add-on.""" - addon_manager: AddonManager = get_addon_manager(self.hass) - await addon_manager.async_schedule_install_addon() - - async def _async_get_addon_discovery_info(self) -> dict: - """Return add-on discovery info.""" - addon_manager: AddonManager = get_addon_manager(self.hass) - try: - discovery_info_config = await addon_manager.async_get_addon_discovery_info() - except AddonError as err: - _LOGGER.error(err) - raise AbortFlow("addon_get_discovery_info_failed") from err - - return discovery_info_config - @override async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -561,7 +634,7 @@ async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResu usb.get_serial_by_id, discovery_info.device ) - addon_info = await self._async_get_addon_info() + addon_info = await self._addon_setup.async_get_addon_info() if ( addon_info.state not in (AddonState.NOT_INSTALLED, AddonState.INSTALLING) and (addon_device := addon_info.options.get(CONF_ADDON_DEVICE)) is not None @@ -783,7 +856,7 @@ async def async_step_on_supervisor( # entry can be updated, e.g. from a USB path to a socket. return self.async_abort(reason="addon_already_configured") - addon_info = await self._async_get_addon_info() + addon_info = await self._addon_setup.async_get_addon_info() if addon_info.state is AddonState.RUNNING: addon_config = addon_info.options @@ -792,22 +865,7 @@ async def async_step_on_supervisor( self.usb_path = addon_config.get(CONF_ADDON_DEVICE) self.socket_path = addon_config.get(CONF_ADDON_SOCKET) - self.s0_legacy_key = addon_config.get(CONF_ADDON_S0_LEGACY_KEY, "") - self.s2_access_control_key = addon_config.get( - CONF_ADDON_S2_ACCESS_CONTROL_KEY, "" - ) - self.s2_authenticated_key = addon_config.get( - CONF_ADDON_S2_AUTHENTICATED_KEY, "" - ) - self.s2_unauthenticated_key = addon_config.get( - CONF_ADDON_S2_UNAUTHENTICATED_KEY, "" - ) - self.lr_s2_access_control_key = addon_config.get( - CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY, "" - ) - self.lr_s2_authenticated_key = addon_config.get( - CONF_ADDON_LR_S2_AUTHENTICATED_KEY, "" - ) + self.security_keys = SecurityKeys.from_config(addon_config) if self._adapter_discovered: # Apply the discovered adapter to the add-on config and @@ -887,40 +945,17 @@ async def async_step_network_type( if user_input is not None: if user_input["network_type"] == NETWORK_TYPE_NEW: - addon_info = await self._async_get_addon_info() - addon_config = addon_info.options + addon_info = await self._addon_setup.async_get_addon_info() # Keep existing keys from the add-on config so the keys of a # previously configured network are not destroyed. # Keys left empty are generated by the add-on on start. - self.s0_legacy_key = addon_config.get(CONF_ADDON_S0_LEGACY_KEY, "") - self.s2_access_control_key = addon_config.get( - CONF_ADDON_S2_ACCESS_CONTROL_KEY, "" - ) - self.s2_authenticated_key = addon_config.get( - CONF_ADDON_S2_AUTHENTICATED_KEY, "" - ) - self.s2_unauthenticated_key = addon_config.get( - CONF_ADDON_S2_UNAUTHENTICATED_KEY, "" - ) - self.lr_s2_access_control_key = addon_config.get( - CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY, "" - ) - self.lr_s2_authenticated_key = addon_config.get( - CONF_ADDON_LR_S2_AUTHENTICATED_KEY, "" - ) + self.security_keys = SecurityKeys.from_config(addon_info.options) - addon_config_updates = { + self._addon_config_updates = { CONF_ADDON_DEVICE: self.usb_path, CONF_ADDON_SOCKET: self.socket_path, - CONF_ADDON_S0_LEGACY_KEY: self.s0_legacy_key, - CONF_ADDON_S2_ACCESS_CONTROL_KEY: self.s2_access_control_key, - CONF_ADDON_S2_AUTHENTICATED_KEY: self.s2_authenticated_key, - CONF_ADDON_S2_UNAUTHENTICATED_KEY: self.s2_unauthenticated_key, - CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY: self.lr_s2_access_control_key, - CONF_ADDON_LR_S2_AUTHENTICATED_KEY: self.lr_s2_authenticated_key, + **self.security_keys.to_dict(), } - - self._addon_config_updates = addon_config_updates return await self.async_step_start_addon() # Network already exists, go to security keys step @@ -941,80 +976,20 @@ async def async_step_configure_security_keys( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Ask for security keys for existing Z-Wave network.""" - addon_info = await self._async_get_addon_info() - addon_config = addon_info.options - - s0_legacy_key = addon_config.get( - CONF_ADDON_S0_LEGACY_KEY, self.s0_legacy_key or "" - ) - s2_access_control_key = addon_config.get( - CONF_ADDON_S2_ACCESS_CONTROL_KEY, self.s2_access_control_key or "" - ) - s2_authenticated_key = addon_config.get( - CONF_ADDON_S2_AUTHENTICATED_KEY, self.s2_authenticated_key or "" - ) - s2_unauthenticated_key = addon_config.get( - CONF_ADDON_S2_UNAUTHENTICATED_KEY, self.s2_unauthenticated_key or "" - ) - lr_s2_access_control_key = addon_config.get( - CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY, self.lr_s2_access_control_key or "" - ) - lr_s2_authenticated_key = addon_config.get( - CONF_ADDON_LR_S2_AUTHENTICATED_KEY, self.lr_s2_authenticated_key or "" - ) + addon_info = await self._addon_setup.async_get_addon_info() + default_keys = SecurityKeys.from_config(addon_info.options, self.security_keys) if user_input is not None: - self.s0_legacy_key = user_input.get(CONF_S0_LEGACY_KEY, s0_legacy_key) - self.s2_access_control_key = user_input.get( - CONF_S2_ACCESS_CONTROL_KEY, s2_access_control_key - ) - self.s2_authenticated_key = user_input.get( - CONF_S2_AUTHENTICATED_KEY, s2_authenticated_key - ) - self.s2_unauthenticated_key = user_input.get( - CONF_S2_UNAUTHENTICATED_KEY, s2_unauthenticated_key - ) - self.lr_s2_access_control_key = user_input.get( - CONF_LR_S2_ACCESS_CONTROL_KEY, lr_s2_access_control_key - ) - self.lr_s2_authenticated_key = user_input.get( - CONF_LR_S2_AUTHENTICATED_KEY, lr_s2_authenticated_key - ) + self.security_keys = default_keys.updated_from_user_input(user_input) - addon_config_updates = { + self._addon_config_updates = { CONF_ADDON_DEVICE: self.usb_path, CONF_ADDON_SOCKET: self.socket_path, - CONF_ADDON_S0_LEGACY_KEY: self.s0_legacy_key, - CONF_ADDON_S2_ACCESS_CONTROL_KEY: self.s2_access_control_key, - CONF_ADDON_S2_AUTHENTICATED_KEY: self.s2_authenticated_key, - CONF_ADDON_S2_UNAUTHENTICATED_KEY: self.s2_unauthenticated_key, - CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY: self.lr_s2_access_control_key, - CONF_ADDON_LR_S2_AUTHENTICATED_KEY: self.lr_s2_authenticated_key, + **self.security_keys.to_dict(), } - - self._addon_config_updates = addon_config_updates return await self.async_step_start_addon() - data_schema = vol.Schema( - { - vol.Optional(CONF_S0_LEGACY_KEY, default=s0_legacy_key): str, - vol.Optional( - CONF_S2_ACCESS_CONTROL_KEY, default=s2_access_control_key - ): str, - vol.Optional( - CONF_S2_AUTHENTICATED_KEY, default=s2_authenticated_key - ): str, - vol.Optional( - CONF_S2_UNAUTHENTICATED_KEY, default=s2_unauthenticated_key - ): str, - vol.Optional( - CONF_LR_S2_ACCESS_CONTROL_KEY, default=lr_s2_access_control_key - ): str, - vol.Optional( - CONF_LR_S2_AUTHENTICATED_KEY, default=lr_s2_authenticated_key - ): str, - } - ) + data_schema = vol.Schema(default_keys.get_schema()) return self.async_show_form( step_id="configure_security_keys", data_schema=data_schema @@ -1029,7 +1004,7 @@ async def async_step_finish_addon_setup_user( Set unique id and abort if already configured. """ if not self.ws_address: - discovery_info = await self._async_get_addon_discovery_info() + discovery_info = await self._addon_setup.async_get_addon_discovery_info() self.ws_address = f"ws://{discovery_info['host']}:{discovery_info['port']}" if ( @@ -1070,12 +1045,7 @@ async def async_step_finish_addon_setup_user( CONF_URL: self.ws_address, CONF_USB_PATH: self.usb_path, CONF_SOCKET_PATH: self.socket_path, - CONF_S0_LEGACY_KEY: self.s0_legacy_key, - CONF_S2_ACCESS_CONTROL_KEY: self.s2_access_control_key, - CONF_S2_AUTHENTICATED_KEY: self.s2_authenticated_key, - CONF_S2_UNAUTHENTICATED_KEY: self.s2_unauthenticated_key, - CONF_LR_S2_ACCESS_CONTROL_KEY: self.lr_s2_access_control_key, - CONF_LR_S2_AUTHENTICATED_KEY: self.lr_s2_authenticated_key, + **self.security_keys.to_dict(), }, error=( "migration_successful" @@ -1102,12 +1072,7 @@ def _async_create_entry_from_vars(self) -> ConfigFlowResult: CONF_URL: self.ws_address, CONF_USB_PATH: self.usb_path, CONF_SOCKET_PATH: self.socket_path, - CONF_S0_LEGACY_KEY: self.s0_legacy_key, - CONF_S2_ACCESS_CONTROL_KEY: self.s2_access_control_key, - CONF_S2_AUTHENTICATED_KEY: self.s2_authenticated_key, - CONF_S2_UNAUTHENTICATED_KEY: self.s2_unauthenticated_key, - CONF_LR_S2_ACCESS_CONTROL_KEY: self.lr_s2_access_control_key, - CONF_LR_S2_AUTHENTICATED_KEY: self.lr_s2_authenticated_key, + **self.security_keys.to_dict(), CONF_USE_ADDON: self.use_addon, CONF_INTEGRATION_CREATED_ADDON: self.integration_created_addon, }, @@ -1351,10 +1316,9 @@ async def async_step_on_supervisor_reconfigure( if config_entry.data.get(CONF_USE_ADDON): # Unload the config entry before stopping the add-on. await self._async_unload_entry_for_flow() - addon_manager = get_addon_manager(self.hass) _LOGGER.debug("Stopping Z-Wave JS app") try: - await addon_manager.async_stop_addon() + await self._addon_setup.async_stop_addon() except AddonError as err: _LOGGER.error(err) self._async_schedule_entry_reload() @@ -1369,7 +1333,7 @@ async def async_step_on_supervisor_reconfigure( # the flow changes the add-on config of the other entry. return self.async_abort(reason="addon_already_configured") - addon_info = await self._async_get_addon_info() + addon_info = await self._addon_setup.async_get_addon_info() if addon_info.state is AddonState.NOT_INSTALLED: return await self.async_step_install_addon() @@ -1380,7 +1344,7 @@ async def async_step_configure_addon_reconfigure( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Ask for config for Z-Wave JS add-on.""" - addon_info = await self._async_get_addon_info() + addon_info = await self._addon_setup.async_get_addon_info() addon_config = addon_info.options errors: dict[str, str] = {} @@ -1388,19 +1352,8 @@ async def async_step_configure_addon_reconfigure( if user_input is not None: # The revert helper only passes keys present in the original # add-on config, which may lack some of the security keys, - # so don't index the keys directly. - self.s0_legacy_key = user_input.get(CONF_S0_LEGACY_KEY, "") - self.s2_access_control_key = user_input.get(CONF_S2_ACCESS_CONTROL_KEY, "") - self.s2_authenticated_key = user_input.get(CONF_S2_AUTHENTICATED_KEY, "") - self.s2_unauthenticated_key = user_input.get( - CONF_S2_UNAUTHENTICATED_KEY, "" - ) - self.lr_s2_access_control_key = user_input.get( - CONF_LR_S2_ACCESS_CONTROL_KEY, "" - ) - self.lr_s2_authenticated_key = user_input.get( - CONF_LR_S2_AUTHENTICATED_KEY, "" - ) + # so treat missing keys as empty. + self.security_keys = SecurityKeys().updated_from_user_input(user_input) self.usb_path = user_input.get(CONF_USB_PATH) or None self.socket_path = user_input.get(CONF_SOCKET_PATH) or None @@ -1410,20 +1363,18 @@ async def async_step_configure_addon_reconfigure( addon_config_updates = { CONF_ADDON_DEVICE: self.usb_path, CONF_ADDON_SOCKET: self.socket_path, - CONF_ADDON_S0_LEGACY_KEY: self.s0_legacy_key, - CONF_ADDON_S2_ACCESS_CONTROL_KEY: self.s2_access_control_key, - CONF_ADDON_S2_AUTHENTICATED_KEY: self.s2_authenticated_key, - CONF_ADDON_S2_UNAUTHENTICATED_KEY: self.s2_unauthenticated_key, - CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY: self.lr_s2_access_control_key, - CONF_ADDON_LR_S2_AUTHENTICATED_KEY: self.lr_s2_authenticated_key, + **self.security_keys.to_dict(), } addon_config_updates = self._addon_config_updates | addon_config_updates self._addon_config_updates = {} - await self._async_set_addon_config(addon_config_updates) + await self._addon_setup.async_set_addon_config(addon_config_updates) - if addon_info.state is AddonState.RUNNING and not self.restart_addon: + if ( + addon_info.state is AddonState.RUNNING + and not self._addon_setup.restart_addon + ): return await self.async_step_finish_addon_setup_reconfigure() if ( @@ -1436,24 +1387,7 @@ async def async_step_configure_addon_reconfigure( usb_path = addon_config.get(CONF_ADDON_DEVICE, self.usb_path or "") socket_path = addon_config.get(CONF_ADDON_SOCKET, self.socket_path or "") - s0_legacy_key = addon_config.get( - CONF_ADDON_S0_LEGACY_KEY, self.s0_legacy_key or "" - ) - s2_access_control_key = addon_config.get( - CONF_ADDON_S2_ACCESS_CONTROL_KEY, self.s2_access_control_key or "" - ) - s2_authenticated_key = addon_config.get( - CONF_ADDON_S2_AUTHENTICATED_KEY, self.s2_authenticated_key or "" - ) - s2_unauthenticated_key = addon_config.get( - CONF_ADDON_S2_UNAUTHENTICATED_KEY, self.s2_unauthenticated_key or "" - ) - lr_s2_access_control_key = addon_config.get( - CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY, self.lr_s2_access_control_key or "" - ) - lr_s2_authenticated_key = addon_config.get( - CONF_ADDON_LR_S2_AUTHENTICATED_KEY, self.lr_s2_authenticated_key or "" - ) + default_keys = SecurityKeys.from_config(addon_config, self.security_keys) try: ports = await async_get_usb_ports(self.hass) @@ -1475,29 +1409,7 @@ async def async_step_configure_addon_reconfigure( vol.Optional( CONF_SOCKET_PATH, description={"suggested_value": socket_path} ): str, - vol.Optional( - CONF_S0_LEGACY_KEY, description={"suggested_value": s0_legacy_key} - ): str, - vol.Optional( - CONF_S2_ACCESS_CONTROL_KEY, - description={"suggested_value": s2_access_control_key}, - ): str, - vol.Optional( - CONF_S2_AUTHENTICATED_KEY, - description={"suggested_value": s2_authenticated_key}, - ): str, - vol.Optional( - CONF_S2_UNAUTHENTICATED_KEY, - description={"suggested_value": s2_unauthenticated_key}, - ): str, - vol.Optional( - CONF_LR_S2_ACCESS_CONTROL_KEY, - description={"suggested_value": lr_s2_access_control_key}, - ): str, - vol.Optional( - CONF_LR_S2_AUTHENTICATED_KEY, - description={"suggested_value": lr_s2_authenticated_key}, - ): str, + **default_keys.get_schema(suggested=True), } ) @@ -1529,7 +1441,7 @@ async def async_step_choose_serial_port( _LOGGER.error("Failed to get USB ports: %s", err) return self.async_abort(reason="usb_ports_failed") - addon_info = await self._async_get_addon_info() + addon_info = await self._addon_setup.async_get_addon_info() addon_config = addon_info.options old_usb_path = addon_config.get(CONF_ADDON_DEVICE, "") # Remove the old controller from the ports list. @@ -1602,12 +1514,7 @@ async def async_step_finish_addon_setup_migrate( CONF_URL: ws_address, CONF_USB_PATH: self.usb_path, CONF_SOCKET_PATH: self.socket_path, - CONF_S0_LEGACY_KEY: self.s0_legacy_key, - CONF_S2_ACCESS_CONTROL_KEY: self.s2_access_control_key, - CONF_S2_AUTHENTICATED_KEY: self.s2_authenticated_key, - CONF_S2_UNAUTHENTICATED_KEY: self.s2_unauthenticated_key, - CONF_LR_S2_ACCESS_CONTROL_KEY: self.lr_s2_access_control_key, - CONF_LR_S2_AUTHENTICATED_KEY: self.lr_s2_authenticated_key, + **self.security_keys.to_dict(), CONF_USE_ADDON: True, CONF_INTEGRATION_CREATED_ADDON: self.integration_created_addon, }, @@ -1626,13 +1533,13 @@ async def async_step_finish_addon_setup_reconfigure( config_entry = self._reconfigure_config_entry assert config_entry is not None if self.revert_reason: - self.original_addon_config = None + self._addon_setup.original_config = None reason = self.revert_reason self.revert_reason = None return await self.async_revert_addon_config(reason=reason) if not self.ws_address: - discovery_info = await self._async_get_addon_discovery_info() + discovery_info = await self._addon_setup.async_get_addon_discovery_info() self.ws_address = f"ws://{discovery_info['host']}:{discovery_info['port']}" if not self.version_info: @@ -1651,12 +1558,7 @@ async def async_step_finish_addon_setup_reconfigure( CONF_URL: self.ws_address, CONF_USB_PATH: self.usb_path, CONF_SOCKET_PATH: self.socket_path, - CONF_S0_LEGACY_KEY: self.s0_legacy_key, - CONF_S2_ACCESS_CONTROL_KEY: self.s2_access_control_key, - CONF_S2_AUTHENTICATED_KEY: self.s2_authenticated_key, - CONF_S2_UNAUTHENTICATED_KEY: self.s2_unauthenticated_key, - CONF_LR_S2_ACCESS_CONTROL_KEY: self.lr_s2_access_control_key, - CONF_LR_S2_AUTHENTICATED_KEY: self.lr_s2_authenticated_key, + **self.security_keys.to_dict(), CONF_USE_ADDON: True, CONF_INTEGRATION_CREATED_ADDON: self.integration_created_addon, } @@ -1705,12 +1607,11 @@ async def async_step_esphome( if existing_socket_path == discovery_info.socket_path: # Config entry already has correct config return self.async_abort(reason="already_configured") - manager = get_addon_manager(self.hass) - await self._async_set_addon_config( + await self._addon_setup.async_set_addon_config( {CONF_ADDON_SOCKET: discovery_info.socket_path} ) - if self.restart_addon: - await manager.async_stop_addon() + if self._addon_setup.restart_addon: + await self._addon_setup.async_stop_addon() self.hass.config_entries.async_update_entry( existing_entry, data={ @@ -1797,7 +1698,7 @@ async def async_revert_addon_config(self, reason: str) -> ConfigFlowResult: reason, ) - if self.revert_reason or not self.original_addon_config: + if self.revert_reason or not self._addon_setup.original_config: config_entry = self._reconfigure_config_entry assert config_entry is not None self._async_schedule_entry_reload() @@ -1806,7 +1707,7 @@ async def async_revert_addon_config(self, reason: str) -> ConfigFlowResult: self.revert_reason = reason addon_config_input = { ADDON_USER_INPUT_MAP[addon_key]: addon_val - for addon_key, addon_val in self.original_addon_config.items() + for addon_key, addon_val in self._addon_setup.original_config.items() if addon_key in ADDON_USER_INPUT_MAP } _LOGGER.debug("Reverting app options, reason: %s", reason) diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index 6b7ddb3777ebf3..2529005c293ae0 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -16,6 +16,7 @@ from aiohttp.hdrs import CONTENT_TYPE, USER_AGENT from aiohttp.web_exceptions import HTTPBadGateway, HTTPGatewayTimeout from aiohttp_asyncmdnsresolver.api import AsyncDualMDNSResolver +from multidict import CIMultiDict from yarl import URL from homeassistant import config_entries @@ -300,8 +301,10 @@ def _async_create_clientsession( # It's important that we identify as Home Assistant # If a package requires a different user agent, override it by passing a headers # dictionary to the request method. + default_headers = CIMultiDict(clientsession.headers) + default_headers[USER_AGENT] = SERVER_SOFTWARE clientsession._default_headers = MappingProxyType( # type: ignore[assignment] # noqa: SLF001 - {USER_AGENT: SERVER_SOFTWARE}, + default_headers ) clientsession.close = warn_use( # type: ignore[method-assign] diff --git a/tests/auth/test_init.py b/tests/auth/test_init.py index dbc36fa295ff87..eab90646580bd9 100644 --- a/tests/auth/test_init.py +++ b/tests/auth/test_init.py @@ -32,6 +32,8 @@ flush_store, ) +INVALID_SIGNING_KEY = b"invalid-signing-key-for-testing0" + @pytest.fixture def mock_hass(hass: HomeAssistant) -> HomeAssistant: @@ -1338,7 +1340,7 @@ async def test_reject_token_with_invalid_json_payload(mock_hass) -> None: """Test rejecting access tokens with invalid json payload.""" jws = jwt.PyJWS() token_with_invalid_json = jws.encode( - b"invalid", b"invalid", "HS256", {"alg": "HS256", "typ": "JWT"} + b"invalid", INVALID_SIGNING_KEY, "HS256", {"alg": "HS256", "typ": "JWT"} ) manager = await auth.auth_manager_from_config(mock_hass, [], []) assert manager.async_validate_access_token(token_with_invalid_json) is None @@ -1348,7 +1350,7 @@ async def test_reject_token_with_not_dict_json_payload(mock_hass) -> None: """Test rejecting access tokens with not a dict json payload.""" jws = jwt.PyJWS() token_not_a_dict_json = jws.encode( - b'["invalid"]', b"invalid", "HS256", {"alg": "HS256", "typ": "JWT"} + b'["invalid"]', INVALID_SIGNING_KEY, "HS256", {"alg": "HS256", "typ": "JWT"} ) manager = await auth.auth_manager_from_config(mock_hass, [], []) assert manager.async_validate_access_token(token_not_a_dict_json) is None diff --git a/tests/components/aurora_abb_powerone/test_init.py b/tests/components/aurora_abb_powerone/test_init.py index 2797e7bd984007..211b01436ce1f3 100644 --- a/tests/components/aurora_abb_powerone/test_init.py +++ b/tests/components/aurora_abb_powerone/test_init.py @@ -3,10 +3,13 @@ from unittest.mock import patch from homeassistant.components.aurora_abb_powerone.const import ATTR_FIRMWARE, DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_MODEL, ATTR_SERIAL_NUMBER, CONF_ADDRESS, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from .test_sensor import _simulated_returns + from tests.common import MockConfigEntry @@ -15,6 +18,15 @@ async def test_unload_entry(hass: HomeAssistant) -> None: with ( patch("aurorapy.client.AuroraSerialClient.connect", return_value=None), + patch( + "aurorapy.client.AuroraSerialClient.measure", + side_effect=_simulated_returns, + ), + patch("aurorapy.client.AuroraSerialClient.alarms", return_value=["No alarm"]), + patch( + "aurorapy.client.AuroraSerialClient.cumulated_energy", + side_effect=_simulated_returns, + ), patch( "aurorapy.client.AuroraSerialClient.serial_number", return_value="9876543", @@ -45,5 +57,8 @@ async def test_unload_entry(hass: HomeAssistant) -> None: mock_entry.add_to_hass(hass) assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() + assert mock_entry.state is ConfigEntryState.LOADED + assert await hass.config_entries.async_unload(mock_entry.entry_id) await hass.async_block_till_done() + assert mock_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/aurora_abb_powerone/test_sensor.py b/tests/components/aurora_abb_powerone/test_sensor.py index 2fe1f0c629204f..e3abf9ef0f51f7 100644 --- a/tests/components/aurora_abb_powerone/test_sensor.py +++ b/tests/components/aurora_abb_powerone/test_sensor.py @@ -278,6 +278,7 @@ async def test_sensor_unknown_error( await hass.async_block_till_done() with ( + patch("homeassistant.components.aurora_abb_powerone.coordinator.sleep"), patch("aurorapy.client.AuroraSerialClient.connect", return_value=None), patch( "aurorapy.client.AuroraSerialClient.measure", diff --git a/tests/components/braviatv/test_coordinator.py b/tests/components/braviatv/test_coordinator.py new file mode 100644 index 00000000000000..4af24a8802ac37 --- /dev/null +++ b/tests/components/braviatv/test_coordinator.py @@ -0,0 +1,48 @@ +"""Test the BraviaTV coordinator.""" + +from datetime import UTC, datetime +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.components.braviatv.const import CONF_USE_PSK, DOMAIN +from homeassistant.components.braviatv.coordinator import BraviaTVCoordinator +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + "start_datetime", + [ + "2026-08-22T12:00:00", # naive, treated as local time (CEST UTC+2) + "2026-08-22T12:00:00+02:00", # aware + ], +) +@pytest.mark.freeze_time("2026-08-22T12:00:00+00:00") +async def test_async_update_playing( + hass: HomeAssistant, + start_datetime: str, +) -> None: + """Test updating playing info with a start datetime.""" + await hass.config.async_set_time_zone("Europe/Warsaw") + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "localhost", + CONF_MAC: "AA:BB:CC:DD:EE:FF", + CONF_USE_PSK: True, + CONF_PIN: "12345qwerty", + }, + ) + client = AsyncMock() + client.get_playing_info.return_value = {"startDateTime": start_datetime} + coordinator = BraviaTVCoordinator(hass, config_entry, client) + + await coordinator.async_update_playing() + + assert coordinator.media_position == 7200 + assert coordinator.media_position_updated_at == datetime( + 2026, 8, 22, 12, 0, 0, tzinfo=UTC + ) diff --git a/tests/components/cloud/conftest.py b/tests/components/cloud/conftest.py index b8a93cb6e50113..809b5a58ba5835 100644 --- a/tests/components/cloud/conftest.py +++ b/tests/components/cloud/conftest.py @@ -31,6 +31,8 @@ from . import mock_cloud, mock_cloud_prefs +ID_TOKEN_SIGNING_KEY = "cloud-test-id-token-signing-key-0" + @pytest.fixture(autouse=True) async def load_homeassistant(hass: HomeAssistant) -> None: @@ -171,7 +173,7 @@ async def mock_login( "custom:sub-exp": "2018-01-03", "cognito:username": "abcdefghjkl", }, - "test", + ID_TOKEN_SIGNING_KEY, ) mock_cloud.access_token = "test_access_token" mock_cloud.refresh_token = "test_refresh_token" @@ -258,7 +260,7 @@ def mock_cloud_login(hass: HomeAssistant, mock_cloud_setup: None) -> Generator[N "custom:sub-exp": "2300-01-03", "cognito:username": "abcdefghjkl", }, - "test", + ID_TOKEN_SIGNING_KEY, ) with patch.object(hass.data[DATA_CLOUD].auth, "async_check_token"): yield @@ -283,5 +285,5 @@ def mock_expired_cloud_login(hass: HomeAssistant, mock_cloud_setup: None) -> Non "custom:sub-exp": "2018-01-01", "cognito:username": "abcdefghjkl", }, - "test", + ID_TOKEN_SIGNING_KEY, ) diff --git a/tests/components/doorbird/test_repairs.py b/tests/components/doorbird/test_repairs.py index a76eda351f21dd..b697c186ef66bb 100644 --- a/tests/components/doorbird/test_repairs.py +++ b/tests/components/doorbird/test_repairs.py @@ -7,7 +7,7 @@ from homeassistant.setup import async_setup_component from . import mock_not_found_exception -from .conftest import DoorbirdMockerType +from .conftest import DoorbirdMockerType, patch_doorbird_api_entry_points from tests.components.repairs import process_repair_fix_flow, start_repair_fix_flow from tests.typing import ClientSessionGenerator @@ -39,6 +39,8 @@ async def test_change_schedule_fails( assert "404" in placeholders["error"] assert data["step_id"] == "confirm" - data = await process_repair_fix_flow(client, flow_id) + with patch_doorbird_api_entry_points(doorbird_entry.api): + data = await process_repair_fix_flow(client, flow_id) + await hass.async_block_till_done() assert data["type"] == "create_entry" diff --git a/tests/components/elmax/conftest.py b/tests/components/elmax/conftest.py index 778dab3bde52fb..1b7bec1566122a 100644 --- a/tests/components/elmax/conftest.py +++ b/tests/components/elmax/conftest.py @@ -27,6 +27,8 @@ from tests.common import load_fixture +TOKEN_SIGNING_KEY = "elmax-test-token-signing-key-0123" + MOCK_DIRECT_BASE_URI = ( f"{'https' if MOCK_DIRECT_SSL else 'http'}://{MOCK_DIRECT_HOST}:{MOCK_DIRECT_PORT}" ) @@ -82,7 +84,7 @@ def httpx_mock_direct_fixture(base_uri: str) -> Generator[respx.MockRouter]: expiration = datetime.now() + timedelta(hours=1) # pylint: disable=home-assistant-enforce-naive-now decoded_jwt["payload"]["exp"] = int(expiration.timestamp()) jws_string = jwt.encode( - payload=decoded_jwt["payload"], algorithm="HS256", key="test" + payload=decoded_jwt["payload"], algorithm="HS256", key=TOKEN_SIGNING_KEY ) login_json["token"] = f"JWT {jws_string}" login_route.return_value = Response(200, json=login_json) diff --git a/tests/components/enphase_envoy/__init__.py b/tests/components/enphase_envoy/__init__.py index 199d950c02a29f..808220b822bed2 100644 --- a/tests/components/enphase_envoy/__init__.py +++ b/tests/components/enphase_envoy/__init__.py @@ -10,6 +10,8 @@ from tests.common import MockConfigEntry +TOKEN_SIGNING_KEY = "envoy-test-signing-key-0123456789" + async def setup_integration( hass: HomeAssistant, @@ -31,6 +33,6 @@ def envoy_token(days_to_expiry: int = 365) -> str: "name": "envoy", "exp": (dt_util.utcnow() + timedelta(days=days_to_expiry)).timestamp(), }, - key="secret", + key=TOKEN_SIGNING_KEY, algorithm="HS256", ) diff --git a/tests/components/enphase_envoy/test_init.py b/tests/components/enphase_envoy/test_init.py index 9c1f0c9e99851e..3c8ee548d0df4f 100644 --- a/tests/components/enphase_envoy/test_init.py +++ b/tests/components/enphase_envoy/test_init.py @@ -42,7 +42,7 @@ ) from homeassistant.setup import async_setup_component -from . import setup_integration +from . import TOKEN_SIGNING_KEY, setup_integration from tests.common import MockConfigEntry, async_capture_events, async_fire_time_changed from tests.typing import WebSocketGenerator @@ -72,7 +72,7 @@ async def test_token_in_config_file( """Test coordinator with token provided from config.""" token = encode( payload={"name": "envoy", "exp": 1907837780}, - key="secret", + key=TOKEN_SIGNING_KEY, algorithm="HS256", ) entry = MockConfigEntry( @@ -105,7 +105,7 @@ async def test_expired_token_in_config( current_token = encode( # some time in 2021 payload={"name": "envoy", "exp": 1627314600}, - key="secret", + key=TOKEN_SIGNING_KEY, algorithm="HS256", ) @@ -305,7 +305,7 @@ async def test_coordinator_token_refresh_error( token = encode( # some time in 2021 payload={"name": "envoy", "exp": 1627314600}, - key="secret", + key=TOKEN_SIGNING_KEY, algorithm="HS256", ) entry = MockConfigEntry( @@ -344,7 +344,7 @@ async def test_coordinator_first_update_auth_error( current_token = encode( # some time in future payload={"name": "envoy", "exp": 1927314600}, - key="secret", + key=TOKEN_SIGNING_KEY, algorithm="HS256", ) @@ -796,7 +796,7 @@ async def test_retry_timeout_settings( """Test coordinator with token provided from config.""" token = encode( payload={"name": "envoy", "exp": 1907837780}, - key="secret", + key=TOKEN_SIGNING_KEY, algorithm="HS256", ) entry = MockConfigEntry( diff --git a/tests/components/flume/conftest.py b/tests/components/flume/conftest.py index 83e7de0d06f7e5..11ca957172d28a 100644 --- a/tests/components/flume/conftest.py +++ b/tests/components/flume/conftest.py @@ -97,6 +97,9 @@ def config_entry_fixture(hass: HomeAssistant) -> MockConfigEntry: return config_entry +TOKEN_SIGNING_KEY = "flume-test-token-signing-key-0123" + + def encode_access_token() -> str: """Encode the payload of the access token.""" expiration_time = datetime.datetime.now() + datetime.timedelta(hours=12) # pylint: disable=home-assistant-enforce-naive-now @@ -104,7 +107,7 @@ def encode_access_token() -> str: "user_id": USER_ID, "exp": int(expiration_time.timestamp()), } - return jwt.encode(payload, key="secret") + return jwt.encode(payload, key=TOKEN_SIGNING_KEY) @pytest.fixture(name="access_token") diff --git a/tests/components/google_assistant/test_http.py b/tests/components/google_assistant/test_http.py index 8544cd3475943d..a55ff1458d3aa7 100644 --- a/tests/components/google_assistant/test_http.py +++ b/tests/components/google_assistant/test_http.py @@ -43,20 +43,33 @@ DUMMY_PRIVATE_KEY = ( "-----BEGIN PRIVATE KEY-----\n" - "MIICdwIBADANBgkqhkiG9w0BAQEFAASCAmEwggJdAgEAAoGBAKYscIlwm7soDs" - "HAz6L6YvUkCvkrX19rS6yeYOmovvhoK5WeYGWUsd8V72zmsyHB7XO94YgJVjvx" - "fzn5K8bLePjFzwoSJjZvhBJ/ZQ05d8VmbvgyWUoPdG9oEa4fZ/lCYrXoaFdTot" - "2xcJvrb/ZuiRl4s4eZpNeFYvVK/Am7UeFPAgMBAAECgYAUetOfzLYUudofvPCaKH" - "u7tKZ5kQPfEa0w6BAPnBF1Mfl1JiDBRDMryFtKs6AOIAVwx00dY/Ex0BCbB3+Cr" - "58H7t4NaPTJxCpmR09pK7o17B7xAdQv8+SynFNud9/5vQ5AEXMOLNwKiU7wpXT6" - "Z7ZIibUBOR7ewsWgsHCDpN1iqQJBAOMODPTPSiQMwRAUHIc6GPleFSJnIz2PAoG" - "3JOG9KFAL6RtIc19lob2ZXdbQdzKtjSkWo+O5W20WDNAl1k32h6MCQQC7W4ZCIY" - "67mPbL6CxXfHjpSGF4Dr9VWJ7ZrKHr6XUoOIcEvsn/pHvWonjMdy93rQMSfOE8B" - "Kd/I1+GHRmNVgplAkAnSo4paxmsZVyfeKt7Jy2dMY+8tVZe17maUuQaAE7Sk00S" - "gJYegwrbMYgQnWCTL39HBfj0dmYA2Zj8CCAuu6O7AkEAryFiYjaUAO9+4iNoL27" - "+ZrFtypeeadyov7gKs0ZKaQpNyzW8A+Zwi7TbTeSqzic/E+z/bOa82q7p/6b714" - "1xsQJBANCAcIwMcVb6KVCHlQbOtKspo5Eh4ZQi8bGl+IcwbQ6JSxeTx915IfAld" - "gbuU047wOB04dYCFB2yLDiUGVXTifU=\n" + "MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDHpAmcxB6bPA" + "peq/upM27z/ml+gKghe8xsW0czDSb12p0T4cJgZ7UWlZfl1JmB+WPcvf3Gfe/q" + "V5JPxjrQzT1oUP6wZKSH914MeWHImJcp+QhS7n0muGjYvi6VfMIkKvjKlqVVcd" + "xV9bkWw+YOHhC+hUi0/rmQw8Dch2NMVMyNamt/PzU8FWQ61w4Bwe0jp0CbxWYk" + "HWvDmxYrMXdFs2Q4LxWln8EGuytS9HZAIQxz/UBCBOXDA/q4OqQV/2hpnt6t0H" + "Dlpp90YDoHw5d4ySgo80Iz6UDrFUt8G0MJq/8MaGgvOH+ZZ4CtcA/Xes7Uejwy" + "/2jhe9dytlrE56z0NotxAgMBAAECggEAM/knDXpbM3OiiXoBls+Oi5PImAfbfX" + "gSxITQ2OAMLAYhTYtBBMMK+FmyhUFfQ2CPGGkX16RyoJHyw7TqG/DKk00+uOJC" + "mSkTgXDaPZRICkPMYHa4+ysYFJESZJVpn2vWgDtOyJtPTsudR2lxi2xVVJwzTP" + "dhjOgBXggbGESdShUcDQj0NeooRfMrj7VMUy8uZ/KjTWXgPyTALVl1udvzGtmy" + "2Q/PcUo2RMDKE9azWtV91qoSgiqF4je+IueeT5qgRKPF15r4OWiYv74zM7iseo" + "lCgP0QXou+iD26gWnAGxLqo8nG7tqsyPSl0NP4oIwWvcbNP+Ys1+r+PtuGCOmB" + "hwKBgQDujHrxccTHAmbxnPpxrai/8d24Mpul+IB34CBK3dSkePxHXitE9q6KhT" + "fZaqTDfOzzU9B0P0ohx0U9DLC42m6sLCkCLDa4BEwgsaFG5e/mj+w36cNgt56r" + "VKTyleNX4Dhq5oz3azyzVE6rQ8EzLNgvgiN6zr2Gy2+Y1aHFFkQPNwKBgQDWPu" + "RixtIhapdqRs1g6R+4prVXzUDWmw1N8y0JJ8DJFSuAyrCblfKSZlrHLq0CZfEp" + "2uNJ6+brmnDFo0XMwyhOi8Q4EIx/bZr+tK+ZLJ34ZRuzasglALGdYtLfo3T3A7" + "Ca6ThLMy1V+FZPUOP3bgjqmFViQ+/bPdHFrjeCr0/+lwKBgQCxvnrc7KhyoJeT" + "8COsEHlsjAto9EyFnmQa7iUho6iN5JgVlVUoTaZAEINMvOmHv83OgOURuRbDlH" + "dCxfHnytor77ueotMiyhDvS2ugKDRY12RrRQMPTcIsZyWAm66KC8f930uqD31r" + "IaZ8dj++oetzesR0/Ra7GVpNxuCCudR8gQKBgBqO2UjVVJ8H05U9CaCFxYTiRY" + "CI1QzFU7Th/CcyYleK5EWm2pWu1M8JGR+vzYqKkIabt6kmMQ3rqycUwkZLuudh" + "tAUvJ/tz3s7MHyhhu4NbJT/scLsFhv73jSRj4s/sCSxq1KudwHTzv989K8U0Qq" + "6yC4OO4GDRHPvgSMlOaiApAoGBANOE05ONrxrWTKfn+ydTDOIyIlXdVDG0twDE" + "vuUvo/6+5BxvuZ0N+s333DA2iRDbfTCTnJizOC/NSGGxzfJ3D6lYOp2a/iC1t3" + "IC0fOw+YC6Gq6kN+qaIcyM0Nmsa7rG72Nq987HDwHwL41HLXTDuQEfqO4DsQgC" + "WKkTkZh32J2/\n" "-----END PRIVATE KEY-----\n" ) DUMMY_CONFIG = GOOGLE_ASSISTANT_SCHEMA( @@ -101,14 +114,16 @@ async def test_get_jwt(hass: HomeAssistant) -> None: jwt = ( "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9." - "eyJpc3MiOiJkdW1teUBkdW1teS5pYW0uZ3NlcnZpY2VhY2NvdW50LmNvbSI" - "sInNjb3BlIjoiaHR0cHM6Ly93d3cuZ29vZ2xlYXBpcy5jb20vYXV0aC9ob21" - "lZ3JhcGgiLCJhdWQiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20vby9" - "vYXV0aDIvdG9rZW4iLCJpYXQiOjE1NzEwMTEyMDAsImV4cCI6MTU3MTAxND" - "gwMH0." - "akHbMhOflXdIDHVvUVwO0AoJONVOPUdCghN6hAdVz4gxjarrQeGYc_Qn2r8" - "4bEvCU7t6EvimKKr0fyupyzBAzfvKULs5mTHO3h2CwSgvOBMv8LnILboJmb" - "O4JcgdnRV7d9G3ktQs7wWSCXJsI5i5jUr1Wfi9zWwxn2ebaAAgrp8" + "eyJpc3MiOiJkdW1teUBkdW1teS5pYW0uZ3NlcnZpY2VhY2NvdW50LmNvbSIsInN" + "jb3BlIjoiaHR0cHM6Ly93d3cuZ29vZ2xlYXBpcy5jb20vYXV0aC9ob21lZ3JhcG" + "giLCJhdWQiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20vby9vYXV0aDIvd" + "G9rZW4iLCJpYXQiOjE1NzEwMTEyMDAsImV4cCI6MTU3MTAxNDgwMH0." + "Tt88OV1IndxiJLdBTPBCW5AlsWWlBRAU9bK8c28PlYSlJBHRd0dQCjYh-4lL1t-" + "RfrLJCqiq9_O9xa4n7Ge59xM9pb_ifFCaYzkUpJlVy5XJYVu7hE-0AV_xAygKjN" + "7nVLpcCFsygoh-sr2bkJpDKzcEpPRlH2lAjkMisVVibt_-oix9m0KO0qZ-7uqV5" + "YG2uLiHvolJ0F2oSc4MJGIOTG7Hf3qWSk_MiVLD0t1Jdp1xniHLzlYht0xSVZ0m" + "b1wflqM9VwERuAbCzXRabNJs85XzeR8aOwk38xwobUk0JXSAaNISoQTC47OwEY8" + "DvSmDgMbYf5aG5yEKCZnYngt6Pg" ) res = _get_homegraph_jwt( datetime(2019, 10, 14, tzinfo=UTC), diff --git a/tests/components/midea/snapshots/test_select.ambr b/tests/components/midea/snapshots/test_select.ambr new file mode 100644 index 00000000000000..4472f0ce225330 --- /dev/null +++ b/tests/components/midea/snapshots/test_select.ambr @@ -0,0 +1,1055 @@ +# serializer version: 1 +# name: test_select_state_snapshot[a1][select.bedroom_ac_fan_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'lowest', + 'low', + 'medium', + 'high', + 'auto', + 'off', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.bedroom_ac_fan_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Fan speed', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Fan speed', + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'fan_speed', + 'unique_id': '12345678_fan_speed', + 'unit_of_measurement': None, + }) +# --- +# name: test_select_state_snapshot[a1][select.bedroom_ac_fan_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Bedroom AC Fan speed', + : list([ + 'lowest', + 'low', + 'medium', + 'high', + 'auto', + 'off', + ]), + }), + 'context': , + 'entity_id': 'select.bedroom_ac_fan_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'medium', + }) +# --- +# name: test_select_state_snapshot[a1][select.bedroom_ac_water_level_setting-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + '25', + '50', + '75', + '100', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.bedroom_ac_water_level_setting', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Water level setting', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Water level setting', + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_level_set', + 'unique_id': '12345678_water_level_set', + 'unit_of_measurement': None, + }) +# --- +# name: test_select_state_snapshot[a1][select.bedroom_ac_water_level_setting-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Bedroom AC Water level setting', + : list([ + '25', + '50', + '75', + '100', + ]), + }), + 'context': , + 'entity_id': 'select.bedroom_ac_water_level_setting', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_select_state_snapshot[ac][select.bedroom_ac_airflow_horizontal-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'off', + 'left', + 'left_mid', + 'middle', + 'right_mid', + 'right', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.bedroom_ac_airflow_horizontal', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Airflow horizontal', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Airflow horizontal', + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wind_lr_angle', + 'unique_id': '12345678_wind_lr_angle', + 'unit_of_measurement': None, + }) +# --- +# name: test_select_state_snapshot[ac][select.bedroom_ac_airflow_horizontal-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Bedroom AC Airflow horizontal', + : list([ + 'off', + 'left', + 'left_mid', + 'middle', + 'right_mid', + 'right', + ]), + }), + 'context': , + 'entity_id': 'select.bedroom_ac_airflow_horizontal', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_select_state_snapshot[ac][select.bedroom_ac_airflow_vertical-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'off', + 'up', + 'up_mid', + 'middle', + 'down_mid', + 'down', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.bedroom_ac_airflow_vertical', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Airflow vertical', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Airflow vertical', + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wind_ud_angle', + 'unique_id': '12345678_wind_ud_angle', + 'unit_of_measurement': None, + }) +# --- +# name: test_select_state_snapshot[ac][select.bedroom_ac_airflow_vertical-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Bedroom AC Airflow vertical', + : list([ + 'off', + 'up', + 'up_mid', + 'middle', + 'down_mid', + 'down', + ]), + }), + 'context': , + 'entity_id': 'select.bedroom_ac_airflow_vertical', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_select_state_snapshot[ac][select.bedroom_ac_power_rate_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + '1', + '20', + '40', + '60', + '80', + '100', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.bedroom_ac_power_rate_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Power rate limit', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power rate limit', + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'rate_select', + 'unique_id': '12345678_rate_select', + 'unit_of_measurement': None, + }) +# --- +# name: test_select_state_snapshot[ac][select.bedroom_ac_power_rate_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Bedroom AC Power rate limit', + : list([ + '1', + '20', + '40', + '60', + '80', + '100', + ]), + }), + 'context': , + 'entity_id': 'select.bedroom_ac_power_rate_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_select_state_snapshot[c3][select.bedroom_ac_silent_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'off', + 'silent', + 'super_silent', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.bedroom_ac_silent_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Silent level', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Silent level', + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'silent_level', + 'unique_id': '12345678_silent_level', + 'unit_of_measurement': None, + }) +# --- +# name: test_select_state_snapshot[c3][select.bedroom_ac_silent_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Bedroom AC Silent level', + : list([ + 'off', + 'silent', + 'super_silent', + ]), + }), + 'context': , + 'entity_id': 'select.bedroom_ac_silent_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_select_state_snapshot[fa][select.bedroom_ac_oscillation_angle-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'off', + '30', + '60', + '90', + '120', + '180', + '360', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.bedroom_ac_oscillation_angle', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Oscillation angle', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Oscillation angle', + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'oscillation_angle', + 'unique_id': '12345678_oscillation_angle', + 'unit_of_measurement': None, + }) +# --- +# name: test_select_state_snapshot[fa][select.bedroom_ac_oscillation_angle-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Bedroom AC Oscillation angle', + : list([ + 'off', + '30', + '60', + '90', + '120', + '180', + '360', + ]), + }), + 'context': , + 'entity_id': 'select.bedroom_ac_oscillation_angle', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '90', + }) +# --- +# name: test_select_state_snapshot[fa][select.bedroom_ac_oscillation_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'off', + 'oscillation', + 'tilting', + 'curve_w', + 'curve_8', + 'reserved', + 'both', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.bedroom_ac_oscillation_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Oscillation mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Oscillation mode', + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'oscillation_mode', + 'unique_id': '12345678_oscillation_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_select_state_snapshot[fa][select.bedroom_ac_oscillation_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Bedroom AC Oscillation mode', + : list([ + 'off', + 'oscillation', + 'tilting', + 'curve_w', + 'curve_8', + 'reserved', + 'both', + ]), + }), + 'context': , + 'entity_id': 'select.bedroom_ac_oscillation_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_select_state_snapshot[fa][select.bedroom_ac_tilting_angle-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'off', + '30', + '60', + '90', + '120', + '180', + '360', + 'plus_60', + 'minus_60', + '40', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.bedroom_ac_tilting_angle', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Tilting angle', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Tilting angle', + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tilting_angle', + 'unique_id': '12345678_tilting_angle', + 'unit_of_measurement': None, + }) +# --- +# name: test_select_state_snapshot[fa][select.bedroom_ac_tilting_angle-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Bedroom AC Tilting angle', + : list([ + 'off', + '30', + '60', + '90', + '120', + '180', + '360', + 'plus_60', + 'minus_60', + '40', + ]), + }), + 'context': , + 'entity_id': 'select.bedroom_ac_tilting_angle', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_select_state_snapshot[fc][select.bedroom_ac_detect_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'off', + 'pm_25', + 'methanal', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.bedroom_ac_detect_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Detect mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Detect mode', + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'detect_mode', + 'unique_id': '12345678_detect_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_select_state_snapshot[fc][select.bedroom_ac_detect_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Bedroom AC Detect mode', + : list([ + 'off', + 'pm_25', + 'methanal', + ]), + }), + 'context': , + 'entity_id': 'select.bedroom_ac_detect_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_select_state_snapshot[fc][select.bedroom_ac_fan_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'auto', + 'standby', + 'low', + 'medium', + 'high', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.bedroom_ac_fan_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Fan speed', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Fan speed', + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'fan_speed', + 'unique_id': '12345678_fan_speed', + 'unit_of_measurement': None, + }) +# --- +# name: test_select_state_snapshot[fc][select.bedroom_ac_fan_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Bedroom AC Fan speed', + : list([ + 'auto', + 'standby', + 'low', + 'medium', + 'high', + ]), + }), + 'context': , + 'entity_id': 'select.bedroom_ac_fan_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_select_state_snapshot[fc][select.bedroom_ac_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'standby', + 'auto', + 'manual', + 'sleep', + 'fast', + 'smoke', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.bedroom_ac_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Mode', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Mode', + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'mode', + 'unique_id': '12345678_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_select_state_snapshot[fc][select.bedroom_ac_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Bedroom AC Mode', + : list([ + 'standby', + 'auto', + 'manual', + 'sleep', + 'fast', + 'smoke', + ]), + }), + 'context': , + 'entity_id': 'select.bedroom_ac_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_select_state_snapshot[fc][select.bedroom_ac_screen_display-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'bright', + 'dim', + 'off', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.bedroom_ac_screen_display', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Screen display', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Screen display', + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'screen_display', + 'unique_id': '12345678_screen_display', + 'unit_of_measurement': None, + }) +# --- +# name: test_select_state_snapshot[fc][select.bedroom_ac_screen_display-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Bedroom AC Screen display', + : list([ + 'bright', + 'dim', + 'off', + ]), + }), + 'context': , + 'entity_id': 'select.bedroom_ac_screen_display', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'bright', + }) +# --- +# name: test_select_state_snapshot[fd][select.bedroom_ac_fan_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'lowest', + 'low', + 'medium', + 'high', + 'auto', + 'off', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.bedroom_ac_fan_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Fan speed', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Fan speed', + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'fan_speed', + 'unique_id': '12345678_fan_speed', + 'unit_of_measurement': None, + }) +# --- +# name: test_select_state_snapshot[fd][select.bedroom_ac_fan_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Bedroom AC Fan speed', + : list([ + 'lowest', + 'low', + 'medium', + 'high', + 'auto', + 'off', + ]), + }), + 'context': , + 'entity_id': 'select.bedroom_ac_fan_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_select_state_snapshot[fd][select.bedroom_ac_screen_display-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'bright', + 'dim', + 'off', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.bedroom_ac_screen_display', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Screen display', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Screen display', + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'screen_display', + 'unique_id': '12345678_screen_display', + 'unit_of_measurement': None, + }) +# --- +# name: test_select_state_snapshot[fd][select.bedroom_ac_screen_display-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Bedroom AC Screen display', + : list([ + 'bright', + 'dim', + 'off', + ]), + }), + 'context': , + 'entity_id': 'select.bedroom_ac_screen_display', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'bright', + }) +# --- +# name: test_select_state_snapshot[x40][select.bedroom_ac_direction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + '60', + '70', + '80', + '90', + '100', + 'oscillate', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.bedroom_ac_direction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Direction', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Direction', + 'platform': 'midea', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'direction', + 'unique_id': '12345678_direction', + 'unit_of_measurement': None, + }) +# --- +# name: test_select_state_snapshot[x40][select.bedroom_ac_direction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Bedroom AC Direction', + : list([ + '60', + '70', + '80', + '90', + '100', + 'oscillate', + ]), + }), + 'context': , + 'entity_id': 'select.bedroom_ac_direction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '90', + }) +# --- diff --git a/tests/components/midea/test_select.py b/tests/components/midea/test_select.py new file mode 100644 index 00000000000000..29259f2cfd86dd --- /dev/null +++ b/tests/components/midea/test_select.py @@ -0,0 +1,359 @@ +"""Tests for midea select.py.""" + +from collections.abc import Callable +from unittest.mock import patch + +from midealocal.const import DeviceType +from midealocal.devices.a1 import DeviceAttributes as A1Attributes, MideaA1Device +from midealocal.devices.ac import DeviceAttributes as ACAttributes, MideaACDevice +from midealocal.devices.c3 import DeviceAttributes as C3Attributes, MideaC3Device +from midealocal.devices.cc import DeviceAttributes as CCAttributes +from midealocal.devices.fa import DeviceAttributes as FAAttributes, MideaFADevice +from midealocal.devices.fc import DeviceAttributes as FCAttributes, MideaFCDevice +from midealocal.devices.fd import DeviceAttributes as FDAttributes, MideaFDDevice +from midealocal.devices.x40 import DeviceAttributes as X40Attributes, MideaX40Device +from midealocal.exceptions import SocketException +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.select import ( + ATTR_OPTION, + ATTR_OPTIONS, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import setup_integration +from .conftest import DummyDevice, entity_entries +from .const import TEST_DEVICE_ID + +from tests.common import MockConfigEntry, snapshot_platform + + +async def _assert_service_call( + hass: HomeAssistant, + entity_id: str, + option: str, + expected_calls: list[tuple], + device: DummyDevice, +) -> None: + """Call select.select_option and assert the fake device recorded the right call.""" + device.calls.clear() + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: option}, + blocking=True, + ) + assert device.calls == expected_calls + + +def _x40_device() -> DummyDevice: + device = DummyDevice( + DeviceType.X40, + attributes={X40Attributes.direction: "90"}, + ) + device.directions = list(MideaX40Device._directions) + return device + + +def _a1_device() -> DummyDevice: + device = DummyDevice( + DeviceType.A1, + attributes={ + A1Attributes.fan_speed: "medium", + A1Attributes.water_level_set: "50", + }, + ) + device.fan_speeds = list(MideaA1Device._default_speeds.values()) + device.water_level_sets = list(MideaA1Device._water_level_sets) + return device + + +def _ac_device() -> DummyDevice: + device = DummyDevice( + DeviceType.AC, + attributes={ + ACAttributes.power: True, + ACAttributes.mode: 1, + ACAttributes.target_temperature: 22.0, + ACAttributes.indoor_temperature: 21.0, + ACAttributes.wind_lr_angle: "off", + ACAttributes.wind_ud_angle: "off", + ACAttributes.rate_select: "100", + }, + ) + device.wind_lr_angles = list(MideaACDevice._wind_lr_angles.values()) + device.wind_ud_angles = list(MideaACDevice._wind_ud_angles.values()) + device.rate_selects = list(MideaACDevice._rate_selects.values()) + return device + + +def _c3_device() -> DummyDevice: + device = DummyDevice( + DeviceType.C3, + attributes={C3Attributes.silent_level: "off"}, + ) + device.silent_modes = list(MideaC3Device._silent_modes) + return device + + +def _fa_device() -> DummyDevice: + device = DummyDevice( + DeviceType.FA, + attributes={ + FAAttributes.oscillation_mode: "off", + FAAttributes.oscillation_angle: "90", + FAAttributes.tilting_angle: "off", + }, + ) + device.oscillation_modes = list(MideaFADevice._oscillation_modes) + device.oscillation_angles = list(MideaFADevice._oscillation_angles) + device.tilting_angles = list(MideaFADevice._tilting_angles) + return device + + +def _fc_device() -> DummyDevice: + device = DummyDevice( + DeviceType.FC, + attributes={ + FCAttributes.detect_mode: "off", + FCAttributes.mode: "auto", + FCAttributes.fan_speed: "auto", + FCAttributes.screen_display: "bright", + }, + ) + device.detect_modes = list(MideaFCDevice._detect_modes) + device.modes = list(MideaFCDevice._modes.values()) + device.fan_speeds = list(MideaFCDevice._speeds.values()) + device.screen_displays = list(MideaFCDevice._screen_displays.values()) + return device + + +def _fd_device() -> DummyDevice: + device = DummyDevice( + DeviceType.FD, + attributes={ + FDAttributes.fan_speed: "auto", + FDAttributes.screen_display: "bright", + }, + ) + device.fan_speeds = list(MideaFDDevice._speeds_old.values()) + device.screen_displays = list(MideaFDDevice._screen_displays.values()) + return device + + +ALL_SELECT_DEVICES = [ + pytest.param(_x40_device(), id="x40"), + pytest.param(_a1_device(), id="a1"), + pytest.param(_ac_device(), id="ac"), + pytest.param(_c3_device(), id="c3"), + pytest.param(_fa_device(), id="fa"), + pytest.param(_fc_device(), id="fc"), + pytest.param(_fd_device(), id="fd"), +] + + +@pytest.mark.parametrize("device", ALL_SELECT_DEVICES) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_select_state_snapshot( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + device: DummyDevice, +) -> None: + """Test async_setup_entry creates the right select entities per device type.""" + config_entry = mock_config_entry(device) + with patch("homeassistant.components.midea._PLATFORMS", [Platform.SELECT]): + await setup_integration(hass, config_entry, device) + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +async def test_x40_direction_select( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test X40's direction select reflects state and can be changed.""" + device = _x40_device() + config_entry = mock_config_entry(device) + with patch("homeassistant.components.midea._PLATFORMS", [Platform.SELECT]): + await setup_integration(hass, config_entry, device) + + entity_entry = entity_entries(hass, config_entry)[f"{TEST_DEVICE_ID}_direction"] + assert (state := hass.states.get(entity_entry.entity_id)) is not None + assert state.state == "90" + assert state.attributes[ATTR_OPTIONS] == device.directions + + await _assert_service_call( + hass, + entity_entry.entity_id, + "oscillate", + [("set_attribute", "direction", "oscillate")], + device, + ) + + +async def test_ac_angle_and_rate_selects( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test AC's wind angle and rate selects.""" + device = _ac_device() + config_entry = mock_config_entry(device) + with patch("homeassistant.components.midea._PLATFORMS", [Platform.SELECT]): + await setup_integration(hass, config_entry, device) + + entities = entity_entries(hass, config_entry) + assert f"{TEST_DEVICE_ID}_wind_lr_angle" in entities + assert f"{TEST_DEVICE_ID}_wind_ud_angle" in entities + assert f"{TEST_DEVICE_ID}_rate_select" in entities + + await _assert_service_call( + hass, + entities[f"{TEST_DEVICE_ID}_wind_lr_angle"].entity_id, + "left", + [("set_attribute", "wind_lr_angle", "left")], + device, + ) + await _assert_service_call( + hass, + entities[f"{TEST_DEVICE_ID}_rate_select"].entity_id, + "20", + [("set_attribute", "rate_select", "20")], + device, + ) + + +async def test_fa_selects_do_not_overlap_fan_platform( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test FA exposes oscillation_mode/angle and tilting_angle selects.""" + device = _fa_device() + config_entry = mock_config_entry(device) + with patch("homeassistant.components.midea._PLATFORMS", [Platform.SELECT]): + await setup_integration(hass, config_entry, device) + + entities = entity_entries(hass, config_entry) + assert f"{TEST_DEVICE_ID}_oscillation_mode" in entities + assert f"{TEST_DEVICE_ID}_oscillation_angle" in entities + assert f"{TEST_DEVICE_ID}_tilting_angle" in entities + + await _assert_service_call( + hass, + entities[f"{TEST_DEVICE_ID}_oscillation_mode"].entity_id, + "oscillation", + [("set_attribute", "oscillation_mode", "oscillation")], + device, + ) + + +async def test_fc_selects( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test FC exposes detect_mode, mode, fan_speed and screen_display selects.""" + device = _fc_device() + config_entry = mock_config_entry(device) + with patch("homeassistant.components.midea._PLATFORMS", [Platform.SELECT]): + await setup_integration(hass, config_entry, device) + + entities = entity_entries(hass, config_entry) + assert f"{TEST_DEVICE_ID}_detect_mode" in entities + assert f"{TEST_DEVICE_ID}_mode" in entities + assert f"{TEST_DEVICE_ID}_fan_speed" in entities + assert f"{TEST_DEVICE_ID}_screen_display" in entities + + await _assert_service_call( + hass, + entities[f"{TEST_DEVICE_ID}_screen_display"].entity_id, + "dim", + [("set_attribute", "screen_display", "dim")], + device, + ) + + +async def test_select_unknown_when_attribute_becomes_non_str( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test current_option gracefully reports unknown if a later update clears it.""" + device = _x40_device() + config_entry = mock_config_entry(device) + with patch("homeassistant.components.midea._PLATFORMS", [Platform.SELECT]): + await setup_integration(hass, config_entry, device) + + entity_entry = entity_entries(hass, config_entry)[f"{TEST_DEVICE_ID}_direction"] + assert (state := hass.states.get(entity_entry.entity_id)) + assert state.state == "90" + + device.attributes[X40Attributes.direction] = None + device.notify_update({X40Attributes.direction: None}) + await hass.async_block_till_done() + + assert (state := hass.states.get(entity_entry.entity_id)) + assert state.state == "unknown" + + +async def test_select_not_created_when_attribute_missing( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test no select entity is created when the device does not report the attribute.""" + device = DummyDevice(DeviceType.X40, attributes={}) + device.directions = ["60", "70", "80", "90", "100", "oscillate"] + config_entry = mock_config_entry(device) + with patch("homeassistant.components.midea._PLATFORMS", [Platform.SELECT]): + await setup_integration(hass, config_entry, device) + + assert entity_entries(hass, config_entry) == {} + + +async def test_select_not_created_for_other_device_type( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test no select entity is created for a device type without one (e.g. CC).""" + device = DummyDevice( + DeviceType.CC, + attributes={ + CCAttributes.power: True, + CCAttributes.mode: 1, + CCAttributes.fan_speed: "high", + }, + ) + config_entry = mock_config_entry(device) + with patch("homeassistant.components.midea._PLATFORMS", [Platform.SELECT]): + await setup_integration(hass, config_entry, device) + + assert entity_entries(hass, config_entry) == {} + + +async def test_select_option_raises_on_device_communication_error( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test a device communication failure surfaces as a HomeAssistantError.""" + device = _x40_device() + config_entry = mock_config_entry(device) + with patch("homeassistant.components.midea._PLATFORMS", [Platform.SELECT]): + await setup_integration(hass, config_entry, device) + + entity_entry = entity_entries(hass, config_entry)[f"{TEST_DEVICE_ID}_direction"] + + with ( + patch.object(device, "set_attribute", side_effect=SocketException("offline")), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_entry.entity_id, ATTR_OPTION: "60"}, + blocking=True, + ) diff --git a/tests/components/network/test_init.py b/tests/components/network/test_init.py index 6309eaa183c32b..769851fb823db5 100644 --- a/tests/components/network/test_init.py +++ b/tests/components/network/test_init.py @@ -770,9 +770,13 @@ async def test_websocket_network_url( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test the network/url websocket command.""" - assert await async_setup_component(hass, DOMAIN, {}) + with patch( + "homeassistant.components.network.util.async_get_source_ip", + return_value="10.10.10.10", + ): + assert await async_setup_component(hass, DOMAIN, {}) - client = await hass_ws_client(hass) + client = await hass_ws_client(hass) with ( patch( diff --git a/tests/components/shelly/test_camera.py b/tests/components/shelly/test_camera.py index 89366affd43bd1..fd49c646c309f6 100644 --- a/tests/components/shelly/test_camera.py +++ b/tests/components/shelly/test_camera.py @@ -10,6 +10,7 @@ from homeassistant.components.camera import ( DATA_COMPONENT, + DOMAIN as CAMERA_DOMAIN, CameraState, get_camera_from_entity_id, ) @@ -24,7 +25,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_registry import EntityRegistry -from . import MOCK_MAC, init_integration, patch_platforms +from . import MOCK_MAC, init_integration, patch_platforms, register_entity from tests.common import snapshot_platform @@ -191,3 +192,43 @@ async def test_camera_properties_when_device_not_initialized( assert camera.is_on is False assert camera.available is False + + +async def test_camera_not_created_when_rtsp_disabled( + hass: HomeAssistant, + mock_camera_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + entity_registry: EntityRegistry, +) -> None: + """Test camera entities are not created when RTSP is disabled.""" + new_config = deepcopy(mock_camera_rpc_device.config) + new_config["camera:0"]["rtsp"]["enable"] = False + monkeypatch.setattr(mock_camera_rpc_device, "config", new_config) + + await init_integration(hass, 3, model=MODEL_CAMERA) + + assert hass.states.get(CAMERA_ENTITY_ID) is None + assert entity_registry.async_get(CAMERA_ENTITY_ID) is None + + +async def test_rpc_camera_removal_when_rtsp_disabled( + hass: HomeAssistant, + mock_camera_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + entity_registry: EntityRegistry, +) -> None: + """Test RPC camera is removed due to removal_condition when RTSP disabled.""" + entity_id = register_entity( + hass, CAMERA_DOMAIN, "test_name_stream_0", "camera:0-stream_0" + ) + + assert entity_registry.async_get(entity_id) is not None + + new_config = deepcopy(mock_camera_rpc_device.config) + new_config["camera:0"]["rtsp"]["enable"] = False + monkeypatch.setattr(mock_camera_rpc_device, "config", new_config) + + await init_integration(hass, 3, model=MODEL_CAMERA) + + assert entity_registry.async_get(entity_id) is None + assert hass.states.get(entity_id) is None diff --git a/tests/components/shelly/test_repairs.py b/tests/components/shelly/test_repairs.py index 05c156af584bf2..8cdc9437aaa78d 100644 --- a/tests/components/shelly/test_repairs.py +++ b/tests/components/shelly/test_repairs.py @@ -3,7 +3,7 @@ from typing import Any from unittest.mock import Mock, patch -from aioshelly.const import MODEL_PLUG, MODEL_WALL_DISPLAY +from aioshelly.const import MODEL_CAMERA, MODEL_PLUG, MODEL_WALL_DISPLAY from aioshelly.exceptions import DeviceConnectionError, NotInitialized, RpcCallError import pytest @@ -16,6 +16,7 @@ OPEN_WIFI_AP_ISSUE_ID, OUTBOUND_WEBSOCKET_INCORRECTLY_ENABLED_ISSUE_ID, PUSH_UPDATE_ISSUE_ID, + RTSP_DISABLED_ISSUE_ID, BLEScannerMode, DeprecatedFirmwareInfo, ) @@ -761,3 +762,132 @@ async def test_plug_1_push_update_issue_created( assert issue_registry.async_get_issue(DOMAIN, issue_id) assert len(issue_registry.issues) == 1 + + +async def test_rtsp_disabled_issue( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_camera_rpc_device: Mock, + issue_registry: ir.IssueRegistry, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test repair issue when camera RTSP is disabled.""" + monkeypatch.setitem( + mock_camera_rpc_device.config["camera:0"]["rtsp"], "enable", False + ) + + issue_id = RTSP_DISABLED_ISSUE_ID.format(unique=MOCK_MAC) + assert await async_setup_component(hass, "repairs", {}) + await hass.async_block_till_done() + await init_integration(hass, 3, MODEL_CAMERA) + + assert issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 1 + + client = await hass_client() + result = await start_repair_fix_flow(client, DOMAIN, issue_id) + + assert result["step_id"] == "init" + assert result["type"] == "menu" + + result = await process_repair_fix_flow( + client, result["flow_id"], {"next_step_id": "confirm"} + ) + assert result["type"] == "create_entry" + assert mock_camera_rpc_device.set_camera_rtsp.call_count == 1 + assert mock_camera_rpc_device.set_camera_rtsp.call_args[0] == (0, True) + + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 0 + + +async def test_no_rtsp_disabled_issue_when_enabled( + hass: HomeAssistant, + mock_camera_rpc_device: Mock, + issue_registry: ir.IssueRegistry, +) -> None: + """Test no repair issue when camera RTSP is enabled.""" + issue_id = RTSP_DISABLED_ISSUE_ID.format(unique=MOCK_MAC) + await init_integration(hass, 3, MODEL_CAMERA) + + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 0 + + +async def test_rtsp_disabled_issue_ignore( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_camera_rpc_device: Mock, + issue_registry: ir.IssueRegistry, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test ignoring the RTSP disabled issue.""" + monkeypatch.setitem( + mock_camera_rpc_device.config["camera:0"]["rtsp"], "enable", False + ) + + issue_id = RTSP_DISABLED_ISSUE_ID.format(unique=MOCK_MAC) + assert await async_setup_component(hass, "repairs", {}) + await hass.async_block_till_done() + await init_integration(hass, 3, MODEL_CAMERA) + + assert issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 1 + + client = await hass_client() + result = await start_repair_fix_flow(client, DOMAIN, issue_id) + + assert result["step_id"] == "init" + assert result["type"] == "menu" + + result = await process_repair_fix_flow( + client, result["flow_id"], {"next_step_id": "ignore"} + ) + assert result["type"] == "abort" + assert result["reason"] == "issue_ignored" + assert mock_camera_rpc_device.set_camera_rtsp.call_count == 0 + + assert (issue := issue_registry.async_get_issue(DOMAIN, issue_id)) + assert issue.dismissed_version + + +@pytest.mark.parametrize( + "exception", [DeviceConnectionError, RpcCallError(999, "Unknown error")] +) +async def test_rtsp_disabled_issue_exc( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_camera_rpc_device: Mock, + issue_registry: ir.IssueRegistry, + monkeypatch: pytest.MonkeyPatch, + exception: Exception, +) -> None: + """Test repair issue handling when set_camera_rtsp ends with an exception.""" + mock_camera_rpc_device.set_camera_rtsp.side_effect = exception + monkeypatch.setitem( + mock_camera_rpc_device.config["camera:0"]["rtsp"], "enable", False + ) + + issue_id = RTSP_DISABLED_ISSUE_ID.format(unique=MOCK_MAC) + assert await async_setup_component(hass, "repairs", {}) + await hass.async_block_till_done() + await init_integration(hass, 3, MODEL_CAMERA) + + assert issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 1 + + client = await hass_client() + result = await start_repair_fix_flow(client, DOMAIN, issue_id) + + assert result["step_id"] == "init" + assert result["type"] == "menu" + + result = await process_repair_fix_flow( + client, result["flow_id"], {"next_step_id": "confirm"} + ) + assert result["type"] == "abort" + assert result["reason"] == "cannot_connect" + assert mock_camera_rpc_device.set_camera_rtsp.call_count == 1 + + assert issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 1 diff --git a/tests/components/voip/conftest.py b/tests/components/voip/conftest.py index 9590c29f79b9b7..b1f8d9a8760f7d 100644 --- a/tests/components/voip/conftest.py +++ b/tests/components/voip/conftest.py @@ -1,15 +1,22 @@ """Test helpers for VoIP integration.""" -from unittest.mock import AsyncMock, Mock, patch +from collections.abc import Generator +from unittest.mock import AsyncMock, Mock, create_autospec, patch import pytest from voip_utils import CallInfo from voip_utils.sip import get_sip_endpoint +from homeassistant.components import assist_satellite, voip +from homeassistant.components.assist_satellite import AssistSatelliteEntity from homeassistant.components.voip import DOMAIN +from homeassistant.components.voip.assist_satellite import VoipAssistSatellite from homeassistant.components.voip.devices import VoIPDevice, VoIPDevices from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_component import EntityComponent from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -24,6 +31,40 @@ async def load_homeassistant(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "homeassistant", {}) +@pytest.fixture(autouse=True) +def reduce_satellite_delays() -> Generator[None]: + """Shorten the delays that the satellite always waits out. + + Tests must send audio chunks more often than _HANGUP_SEC, or the satellite + treats the gap as the caller hanging up. Timeouts that only elapse when audio + never arrives are left alone: they cost nothing unless a test exercises them. + """ + with ( + patch("homeassistant.components.voip.assist_satellite._HANGUP_SEC", 0.2), + patch( + "homeassistant.components.voip.assist_satellite._ANNOUNCEMENT_BEFORE_DELAY", + 0.1, + ), + patch( + "homeassistant.components.voip.assist_satellite._ANNOUNCEMENT_AFTER_DELAY", + 0.1, + ), + ): + yield + + +@pytest.fixture +def silent_tones() -> Generator[None]: + """Give every tone empty audio. + + A real tone is up to two seconds streamed in real time, which outlasts the + shortened hangup window. The tone paths still run, so the processing tone + still gates _send_tts. + """ + with patch.object(VoipAssistSatellite, "_load_pcm", return_value=b""): + yield + + @pytest.fixture def config_entry(hass: HomeAssistant) -> MockConfigEntry: """Create a config entry.""" @@ -89,3 +130,39 @@ async def voip_device( # to make sure all platforms are set up await hass.async_block_till_done() return device + + +@pytest.fixture +def satellite( + hass: HomeAssistant, + voip_device: VoIPDevice, +): + """Create VoipAssistSatellite for use in tests.""" + satellite = async_get_satellite_entity(hass, voip.DOMAIN, voip_device.voip_id) + assert isinstance(satellite, VoipAssistSatellite) + + mock_send_audio = create_autospec(satellite.send_audio) + satellite.send_audio = mock_send_audio + + yield satellite + + if satellite.voip_device.is_active: + satellite.disconnect() + + +def async_get_satellite_entity( + hass: HomeAssistant, domain: str, unique_id_prefix: str +) -> AssistSatelliteEntity | None: + """Get Assist satellite entity.""" + ent_reg = er.async_get(hass) + satellite_entity_id = ent_reg.async_get_entity_id( + Platform.ASSIST_SATELLITE, domain, f"{unique_id_prefix}-assist_satellite" + ) + if satellite_entity_id is None: + return None + assert not satellite_entity_id.endswith("none") + + component: EntityComponent[AssistSatelliteEntity] = hass.data[ + assist_satellite.DOMAIN + ] + return component.get_entity(satellite_entity_id) diff --git a/tests/components/voip/test_voip.py b/tests/components/voip/test_voip.py index 64dc3c22cf45c0..270ae5fc4b533e 100644 --- a/tests/components/voip/test_voip.py +++ b/tests/components/voip/test_voip.py @@ -12,7 +12,6 @@ from voip_utils import CallInfo from homeassistant.components import assist_pipeline, assist_satellite, tts, voip -from homeassistant.components.assist_satellite import AssistSatelliteEntity # pylint: disable-next=home-assistant-component-root-import from homeassistant.components.assist_satellite.entity import AssistSatelliteState @@ -20,11 +19,10 @@ from homeassistant.components.voip.assist_satellite import Tones, VoipAssistSatellite from homeassistant.components.voip.devices import VoIPDevice, VoIPDevices from homeassistant.components.voip.voip import PreRecordMessageProtocol, make_protocol -from homeassistant.const import STATE_OFF, STATE_ON, Platform +from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_component import EntityComponent from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -39,21 +37,6 @@ def mock_tts_cache_dir_autouse(mock_tts_cache_dir: Path) -> None: """Mock the TTS cache dir with empty dir.""" -@pytest.fixture -def satellite( - hass: HomeAssistant, - voip_device: VoIPDevice, -): - """Create VoipAssistSatellite for use in tests.""" - satellite = async_get_satellite_entity(hass, voip.DOMAIN, voip_device.voip_id) - assert isinstance(satellite, VoipAssistSatellite) - - yield satellite - - if satellite.voip_device.is_active: - satellite.disconnect() - - def _empty_wav(framerate=16000) -> bytes: """Return bytes of an empty WAV file.""" with io.BytesIO() as wav_io: @@ -66,24 +49,6 @@ def _empty_wav(framerate=16000) -> bytes: return wav_io.getvalue() -def async_get_satellite_entity( - hass: HomeAssistant, domain: str, unique_id_prefix: str -) -> AssistSatelliteEntity | None: - """Get Assist satellite entity.""" - ent_reg = er.async_get(hass) - satellite_entity_id = ent_reg.async_get_entity_id( - Platform.ASSIST_SATELLITE, domain, f"{unique_id_prefix}-assist_satellite" - ) - if satellite_entity_id is None: - return None - assert not satellite_entity_id.endswith("none") - - component: EntityComponent[AssistSatelliteEntity] = hass.data[ - assist_satellite.DOMAIN - ] - return component.get_entity(satellite_entity_id) - - async def test_is_valid_call( hass: HomeAssistant, voip_devices: VoIPDevices, @@ -483,7 +448,6 @@ async def async_send_audio(audio_bytes: bytes, **kwargs): satellite._tone_bytes[tone] = tone_bytes satellite.connection_made(Mock()) - satellite.send_audio = Mock() original_send_tts = satellite._send_tts @@ -511,6 +475,7 @@ async def send_tts(*args, **kwargs): await done.wait() +@pytest.mark.usefixtures("silent_tones") async def test_tts_wrong_extension( hass: HomeAssistant, satellite: VoipAssistSatellite, @@ -588,19 +553,13 @@ async def send_tts(*args, **kwargs): # silence (assumes relaxed VAD sensitivity) satellite.on_chunk(bytes(_ONE_SECOND)) await asyncio.sleep(0.2) - satellite.on_chunk(bytes(_ONE_SECOND)) - await asyncio.sleep(0.2) - satellite.on_chunk(bytes(_ONE_SECOND)) - await asyncio.sleep(0.2) - satellite.on_chunk(bytes(_ONE_SECOND)) - await asyncio.sleep(0.2) - satellite.on_chunk(bytes(_ONE_SECOND)) # Wait for mock pipeline to exhaust the audio stream async with asyncio.timeout(3): await done.wait() +@pytest.mark.usefixtures("silent_tones") async def test_tts_wrong_wav_format( hass: HomeAssistant, satellite: VoipAssistSatellite, @@ -678,19 +637,13 @@ async def send_tts(*args, **kwargs): # silence (assumes relaxed VAD sensitivity) satellite.on_chunk(bytes(_ONE_SECOND)) await asyncio.sleep(0.2) - satellite.on_chunk(bytes(_ONE_SECOND)) - await asyncio.sleep(0.2) - satellite.on_chunk(bytes(_ONE_SECOND)) - await asyncio.sleep(0.2) - satellite.on_chunk(bytes(_ONE_SECOND)) - await asyncio.sleep(0.2) - satellite.on_chunk(bytes(_ONE_SECOND)) # Wait for mock pipeline to exhaust the audio stream async with asyncio.timeout(3): await done.wait() +@pytest.mark.usefixtures("silent_tones") async def test_empty_tts_output( hass: HomeAssistant, satellite: VoipAssistSatellite, @@ -757,16 +710,9 @@ async def async_pipeline_from_audio_stream(*args, **kwargs): # silence (assumes relaxed VAD sensitivity) satellite.on_chunk(bytes(_ONE_SECOND)) - await asyncio.sleep(0.2) - satellite.on_chunk(bytes(_ONE_SECOND)) - await asyncio.sleep(0.2) - satellite.on_chunk(bytes(_ONE_SECOND)) - await asyncio.sleep(0.2) - satellite.on_chunk(bytes(_ONE_SECOND)) - await asyncio.sleep(0.2) - satellite.on_chunk(bytes(_ONE_SECOND)) - # Wait for mock pipeline to finish + # No more chunks: another chunk would start a second pipeline run, which + # clears _tts_done again. async with asyncio.timeout(2): await satellite._tts_done.wait() @@ -877,9 +823,9 @@ async def test_announce( # Trigger announcement satellite.on_chunk(bytes(_ONE_SECOND)) - await asyncio.sleep(0.2) + await asyncio.sleep(0.05) satellite.on_chunk(bytes(_ONE_SECOND)) - await asyncio.sleep(0.2) + await asyncio.sleep(0.05) satellite.on_chunk(bytes(_ONE_SECOND)) async with asyncio.timeout(2): await announce_task @@ -937,9 +883,9 @@ async def test_voip_id_is_ip_address( # Trigger announcement satellite.on_chunk(bytes(_ONE_SECOND)) - await asyncio.sleep(0.2) + await asyncio.sleep(0.05) satellite.on_chunk(bytes(_ONE_SECOND)) - await asyncio.sleep(0.2) + await asyncio.sleep(0.05) satellite.on_chunk(bytes(_ONE_SECOND)) async with asyncio.timeout(2): await announce_task @@ -1032,11 +978,11 @@ async def test_announce_disconnect( # Trigger announcement satellite.on_chunk(bytes(_ONE_SECOND)) - await asyncio.sleep(0.2) + await asyncio.sleep(0.05) satellite.on_chunk(bytes(_ONE_SECOND)) - await asyncio.sleep(0.2) + await asyncio.sleep(0.05) satellite.on_chunk(bytes(_ONE_SECOND)) - await asyncio.sleep(0.2) + await asyncio.sleep(0.05) assert satellite._announcement is announcement assert voip_device.is_active @@ -1196,18 +1142,17 @@ async def async_pipeline_from_audio_stream( # Trigger announcement and wait for it to finish satellite.on_chunk(bytes(_ONE_SECOND)) - await asyncio.sleep(0.2) + await asyncio.sleep(0.05) satellite.on_chunk(bytes(_ONE_SECOND)) - await asyncio.sleep(0.2) + await asyncio.sleep(0.05) satellite.on_chunk(bytes(_ONE_SECOND)) async with asyncio.timeout(2): await tts_sent.wait() # Trigger pipeline satellite.on_chunk(bytes(_ONE_SECOND)) - await asyncio.sleep(0.2) + await asyncio.sleep(0.05) satellite.on_chunk(bytes(_ONE_SECOND)) - await asyncio.sleep(3) async with asyncio.timeout(3): # Wait for Conversation end await conversation_task diff --git a/tests/helpers/test_aiohttp_client.py b/tests/helpers/test_aiohttp_client.py index 385eba59f50cce..e763ad208c4e65 100644 --- a/tests/helpers/test_aiohttp_client.py +++ b/tests/helpers/test_aiohttp_client.py @@ -393,6 +393,17 @@ async def test_client_session_immutable_headers(hass: HomeAssistant) -> None: session.headers.update({"user-agent": "bla"}) +async def test_create_clientsession_keeps_headers(hass: HomeAssistant) -> None: + """Test default headers passed to the session are kept.""" + session = client.async_create_clientsession( + hass, headers={"Authorization": "Basic bla", "User-Agent": "bla"} + ) + + assert session.headers["authorization"] == "Basic bla" + # We always identify as Home Assistant + assert session.headers["user-agent"] == client.SERVER_SOFTWARE + + @pytest.mark.usefixtures("disable_mock_zeroconf_resolver") @pytest.mark.usefixtures("mock_async_zeroconf") async def test_async_mdnsresolver( diff --git a/tests/pylint/quality_scale/test_test_before_configure.py b/tests/pylint/quality_scale/test_test_before_configure.py index 6485ac9528c424..b49f7747d7c3b1 100644 --- a/tests/pylint/quality_scale/test_test_before_configure.py +++ b/tests/pylint/quality_scale/test_test_before_configure.py @@ -7,7 +7,7 @@ from astroid import nodes from pylint.testutils import MessageTest, UnittestLinter from pylint_home_assistant.checkers.quality_scale.test_before_configure import ( - TestBeforeConfigureChecker, + TestBeforeConfigureChecker as BeforeConfigureChecker, ) from pylint_home_assistant.helpers.integration import clear_caches from pylint_home_assistant.helpers.quality_scale import clear_quality_scale_cache @@ -30,11 +30,11 @@ async def async_step_user(self, user_input=None): @pytest.fixture(name="configure_checker") -def configure_checker_fixture(linter: UnittestLinter) -> TestBeforeConfigureChecker: +def configure_checker_fixture(linter: UnittestLinter) -> BeforeConfigureChecker: """Fixture to provide a test before configure checker.""" clear_quality_scale_cache() clear_caches() - return TestBeforeConfigureChecker(linter) + return BeforeConfigureChecker(linter) def _make_integration( @@ -157,7 +157,7 @@ async def async_step_user(self, user_input=None): ) def test_before_configure_evidence_present( linter: UnittestLinter, - configure_checker: TestBeforeConfigureChecker, + configure_checker: BeforeConfigureChecker, tmp_path: Path, flow_body: str, ) -> None: @@ -215,7 +215,7 @@ async def async_step_user(self, user_input=None): ) def test_before_configure_missing_fires( linter: UnittestLinter, - configure_checker: TestBeforeConfigureChecker, + configure_checker: BeforeConfigureChecker, tmp_path: Path, flow_source: str, manifest: dict | None, @@ -233,7 +233,7 @@ def test_before_configure_missing_fires( def test_before_configure_oauth_flow_skipped( linter: UnittestLinter, - configure_checker: TestBeforeConfigureChecker, + configure_checker: BeforeConfigureChecker, tmp_path: Path, ) -> None: """No warning for OAuth flows; the token exchange is the connection test.""" @@ -255,7 +255,7 @@ async def async_oauth_create_entry(self, data): def test_before_configure_inherited_evidence( linter: UnittestLinter, - configure_checker: TestBeforeConfigureChecker, + configure_checker: BeforeConfigureChecker, tmp_path: Path, ) -> None: """No warning when surfacing evidence lives in an inherited flow class's module.""" @@ -291,7 +291,7 @@ def _async_flow_finished(self): def test_before_configure_inherited_entry_creation_fires( linter: UnittestLinter, - configure_checker: TestBeforeConfigureChecker, + configure_checker: BeforeConfigureChecker, tmp_path: Path, ) -> None: """Warning when entry creation is inherited and nothing surfaces failures.""" @@ -323,7 +323,7 @@ class MyConfigFlow(BaseSharedFlow, domain="test_integration"): def test_before_configure_non_config_flow_class( linter: UnittestLinter, - configure_checker: TestBeforeConfigureChecker, + configure_checker: BeforeConfigureChecker, tmp_path: Path, ) -> None: """No warning for classes that are not config flows.""" @@ -373,7 +373,7 @@ def make(self): ) def test_before_configure_not_fired( linter: UnittestLinter, - configure_checker: TestBeforeConfigureChecker, + configure_checker: BeforeConfigureChecker, tmp_path: Path, module_name: str, rules: dict | None, diff --git a/tests/pylint/type_hints/test_type_hints.py b/tests/pylint/type_hints/test_type_hints.py index 5efa6bc0a8979e..832d406e3804c2 100644 --- a/tests/pylint/type_hints/test_type_hints.py +++ b/tests/pylint/type_hints/test_type_hints.py @@ -5,6 +5,7 @@ from unittest.mock import patch import astroid +from astroid import nodes from pylint.checkers import BaseChecker import pylint.testutils from pylint.testutils.unittest_linter import UnittestLinter @@ -440,7 +441,7 @@ def test_invalid_flow_step( type_hint_checker: BaseChecker, code: str, expected_messages_fn: Callable[ - [astroid.NodeNG], tuple[pylint.testutils.MessageTest, ...] + [nodes.NodeNG], tuple[pylint.testutils.MessageTest, ...] ], ) -> None: """Ensure invalid hints are rejected for flow step.""" diff --git a/tests/test_circular_imports.py b/tests/test_circular_imports.py index d6e730aae5e737..edc9126a082fb5 100644 --- a/tests/test_circular_imports.py +++ b/tests/test_circular_imports.py @@ -1,6 +1,8 @@ """Test to check for circular imports in core components.""" -import asyncio +from concurrent.futures import ThreadPoolExecutor +import os +import subprocess import sys import pytest @@ -12,27 +14,57 @@ STAGE_1_INTEGRATIONS, ) - -@pytest.mark.timeout(30) # cloud can take > 9s -@pytest.mark.parametrize( - "component", - sorted( - { - *CORE_INTEGRATIONS, - *( - domain - for name, domains, timeout in STAGE_0_INTEGRATIONS - for domain in domains - ), - *STAGE_1_INTEGRATIONS, - *DEFAULT_INTEGRATIONS, - } - ), +COMPONENTS = sorted( + { + *CORE_INTEGRATIONS, + *( + domain + for name, domains, timeout in STAGE_0_INTEGRATIONS + for domain in domains + ), + *STAGE_1_INTEGRATIONS, + *DEFAULT_INTEGRATIONS, + } ) -async def test_circular_imports(component: str) -> None: + +# Each import is a whole interpreter loading a component's dependency tree, so it +# is both CPU and memory hungry. The suite already runs one xdist worker per CPU; +# a small fixed ceiling keeps this from oversubscribing the host, and measuring +# showed nothing to gain above it. +MAX_CONCURRENT_IMPORTS = 4 +IMPORT_TIMEOUT = 120 + + +def _import_component(component: str) -> tuple[int, str]: + """Import a component in a clean interpreter, returning its exit code.""" + try: + result = subprocess.run( + [sys.executable, "-c", f"import homeassistant.components.{component}"], + capture_output=True, + check=False, + text=True, + timeout=IMPORT_TIMEOUT, + ) + except subprocess.TimeoutExpired: + return 1, f"importing timed out after {IMPORT_TIMEOUT} seconds" + return result.returncode, result.stderr + + +@pytest.fixture(scope="session", autouse=True) +def component_imports() -> dict[str, tuple[int, str]]: + """Import every component, several interpreters at a time.""" + workers = min(MAX_CONCURRENT_IMPORTS, os.process_cpu_count() or 1) + with ThreadPoolExecutor(max_workers=workers) as executor: + return dict( + zip(COMPONENTS, executor.map(_import_component, COMPONENTS), strict=True) + ) + + +@pytest.mark.timeout(600) # the first test imports every component +@pytest.mark.parametrize("component", COMPONENTS) +def test_circular_imports( + component: str, component_imports: dict[str, tuple[int, str]] +) -> None: """Check that components can be imported without circular imports.""" - process = await asyncio.create_subprocess_exec( - sys.executable, "-c", f"import homeassistant.components.{component}" - ) - await process.communicate() - assert process.returncode == 0 + returncode, stderr = component_imports[component] + assert returncode == 0, stderr