diff --git a/homeassistant/components/blebox/__init__.py b/homeassistant/components/blebox/__init__.py index c4039fe5b8eae7..3d1ce13a2b6e5d 100644 --- a/homeassistant/components/blebox/__init__.py +++ b/homeassistant/components/blebox/__init__.py @@ -1,9 +1,7 @@ """The BleBox devices integration.""" -import logging - from blebox_uniapi.box import Box -from blebox_uniapi.error import Error +from blebox_uniapi.error import ConnectionError, Error, HttpError, UnauthorizedRequest from blebox_uniapi.session import ApiHost from homeassistant.const import ( @@ -14,14 +12,16 @@ Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, +) from .const import DEFAULT_SETUP_TIMEOUT from .coordinator import BleBoxConfigEntry, BleBoxCoordinator from .helpers import get_maybe_authenticated_session -_LOGGER = logging.getLogger(__name__) - PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, @@ -50,9 +50,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: BleBoxConfigEntry) -> bo try: product = await Box.async_from_host(api_host) - except Error as ex: - _LOGGER.error("Identify failed at %s:%d (%s)", api_host.host, api_host.port, ex) + except UnauthorizedRequest as ex: + raise ConfigEntryAuthFailed from ex + except ( + ConnectionError, + HttpError, + ) as ex: raise ConfigEntryNotReady from ex + except Error as ex: + raise ConfigEntryError from ex coordinator = BleBoxCoordinator(hass, entry, product) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/blebox/config_flow.py b/homeassistant/components/blebox/config_flow.py index 67c4b9299421a4..69fe46fe0475e9 100644 --- a/homeassistant/components/blebox/config_flow.py +++ b/homeassistant/components/blebox/config_flow.py @@ -1,5 +1,6 @@ """Config flow for BleBox devices integration.""" +from collections.abc import Mapping import logging from typing import Any @@ -26,6 +27,7 @@ DEFAULT_PORT, DEFAULT_SETUP_TIMEOUT, DOMAIN, + INVALID_AUTH, UNKNOWN, UNSUPPORTED_VERSION, ) @@ -46,6 +48,7 @@ LOG_MSG = { UNSUPPORTED_VERSION: "Outdated firmware", CANNOT_CONNECT: "Failed to identify device", + INVALID_AUTH: "Authentication failed", UNKNOWN: "Unknown error while identifying device", } @@ -87,7 +90,7 @@ async def _async_from_host_or_form( ) except UnauthorizedRequest as ex: return None, self.handle_step_exception( - ex, schema, host, port, CANNOT_CONNECT, _LOGGER.error, step_id + ex, schema, host, port, INVALID_AUTH, _LOGGER.error, step_id ) except Error as ex: return None, self.handle_step_exception( @@ -115,6 +118,8 @@ async def async_step_zeroconf( try: product = await Box.async_from_host(api_host) + except UnauthorizedRequest: + return self.async_abort(reason="authorization_required") except UnsupportedBoxVersion: return self.async_abort(reason="unsupported_device_version") except UnsupportedBoxResponse: @@ -246,3 +251,58 @@ async def async_step_reconfigure( reconfigure_entry, data_updates=data_updates, ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle reauthentication upon an API authentication error.""" + self.context["title_placeholders"] = { + "name": self._get_reauth_entry().title, + "host": entry_data[CONF_HOST], + } + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reauthentication confirmation.""" + errors: dict[str, str] = {} + reauth_entry = self._get_reauth_entry() + host = reauth_entry.data[CONF_HOST] + port = reauth_entry.data[CONF_PORT] + + if user_input is not None: + username = user_input.get(CONF_USERNAME) + password = user_input.get(CONF_PASSWORD) + websession = get_maybe_authenticated_session(self.hass, password, username) + api_host = ApiHost( + host, port, DEFAULT_SETUP_TIMEOUT, websession, self.hass.loop, _LOGGER + ) + try: + await Box.async_from_host(api_host) + except UnauthorizedRequest: + errors["base"] = INVALID_AUTH + except Error: + errors["base"] = CANNOT_CONNECT + except RuntimeError: + errors["base"] = UNKNOWN + else: + return self.async_update_reload_and_abort( + reauth_entry, + data_updates={ + CONF_USERNAME: username, + CONF_PASSWORD: password, + }, + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Inclusive(CONF_USERNAME, "auth"): str, + vol.Inclusive(CONF_PASSWORD, "auth"): str, + } + ), + errors=errors, + description_placeholders={"address": f"{host}:{port}"}, + ) diff --git a/homeassistant/components/blebox/const.py b/homeassistant/components/blebox/const.py index 102c74e815812b..bd330d188cd980 100644 --- a/homeassistant/components/blebox/const.py +++ b/homeassistant/components/blebox/const.py @@ -7,6 +7,7 @@ # translation strings ADDRESS_ALREADY_CONFIGURED = "address_already_configured" CANNOT_CONNECT = "cannot_connect" +INVALID_AUTH = "invalid_auth" UNSUPPORTED_VERSION = "unsupported_version" UNKNOWN = "unknown" diff --git a/homeassistant/components/blebox/coordinator.py b/homeassistant/components/blebox/coordinator.py index 45056bfd29986b..3ad7cf234f1ef8 100644 --- a/homeassistant/components/blebox/coordinator.py +++ b/homeassistant/components/blebox/coordinator.py @@ -4,10 +4,11 @@ import logging from blebox_uniapi.box import Box -from blebox_uniapi.error import Error +from blebox_uniapi.error import Error, UnauthorizedRequest from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -40,6 +41,8 @@ async def _async_update_data(self) -> None: """Fetch data from the BleBox device.""" try: await self.box.async_update_data() + except UnauthorizedRequest as err: + raise ConfigEntryAuthFailed from err except Error as err: raise UpdateFailed( translation_domain=DOMAIN, diff --git a/homeassistant/components/blebox/sensor.py b/homeassistant/components/blebox/sensor.py index 2f88eeebcd690b..00c8d87cc780c4 100644 --- a/homeassistant/components/blebox/sensor.py +++ b/homeassistant/components/blebox/sensor.py @@ -24,6 +24,7 @@ UnitOfEnergy, UnitOfFrequency, UnitOfPower, + UnitOfReactiveEnergy, UnitOfReactivePower, UnitOfSpeed, UnitOfTemperature, @@ -97,6 +98,20 @@ class BleBoxSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=LIGHT_LUX, state_class=SensorStateClass.MEASUREMENT, ), + BleBoxSensorEntityDescription( + key="forwardReactiveEnergy", + translation_key="forward_reactive_energy", + device_class=SensorDeviceClass.REACTIVE_ENERGY, + native_unit_of_measurement=UnitOfReactiveEnergy.KILO_VOLT_AMPERE_REACTIVE_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + BleBoxSensorEntityDescription( + key="reverseReactiveEnergy", + translation_key="reverse_reactive_energy", + device_class=SensorDeviceClass.REACTIVE_ENERGY, + native_unit_of_measurement=UnitOfReactiveEnergy.KILO_VOLT_AMPERE_REACTIVE_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + ), BleBoxSensorEntityDescription( key="forwardActiveEnergy", translation_key="forward_active_energy", diff --git a/homeassistant/components/blebox/strings.json b/homeassistant/components/blebox/strings.json index b7cf1e68349807..b49a295a9e1288 100644 --- a/homeassistant/components/blebox/strings.json +++ b/homeassistant/components/blebox/strings.json @@ -3,16 +3,33 @@ "abort": { "address_already_configured": "A BleBox device is already configured at {address}.", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "authorization_required": "The BleBox device requires authentication.", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", - "unique_id_mismatch": "The device identifier does not match the previously configured device." + "unique_id_mismatch": "The device identifier does not match the previously configured device.", + "unsupported_device_response": "The BleBox device returned an unrecognized response.", + "unsupported_device_version": "[%key:component::blebox::config::error::unsupported_version%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]", "unsupported_version": "BleBox device has outdated firmware. Please upgrade it first." }, "flow_title": "{name} ({host})", "step": { + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "password": "The password for your BleBox device.", + "username": "The username for your BleBox device." + }, + "description": "Enter credentials for the BleBox device at {address}.", + "title": "Reauthenticate your BleBox device" + }, "reconfigure": { "data": { "host": "[%key:common::config_flow::data::ip%]", @@ -64,6 +81,10 @@ "current_n": { "name": "Current {index}" }, "forward_active_energy": { "name": "Forward active energy" }, "forward_active_energy_n": { "name": "Forward active energy {index}" }, + "forward_reactive_energy": { "name": "Forward reactive energy" }, + "forward_reactive_energy_n": { + "name": "Forward reactive energy {index}" + }, "frequency": { "name": "Frequency" }, "frequency_n": { "name": "Frequency {index}" }, "open_status": { @@ -81,6 +102,10 @@ "reactive_power_n": { "name": "Reactive power {index}" }, "reverse_active_energy": { "name": "Reverse active energy" }, "reverse_active_energy_n": { "name": "Reverse active energy {index}" }, + "reverse_reactive_energy": { "name": "Reverse reactive energy" }, + "reverse_reactive_energy_n": { + "name": "Reverse reactive energy {index}" + }, "temperature": { "name": "Temperature" }, "temperature_n": { "name": "Temperature {index}" }, "voltage": { "name": "Voltage" }, diff --git a/homeassistant/components/cloud/system_health.py b/homeassistant/components/cloud/system_health.py index ac50c2fb49b418..31d524ac399c6b 100644 --- a/homeassistant/components/cloud/system_health.py +++ b/homeassistant/components/cloud/system_health.py @@ -31,12 +31,24 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: data["relayer_region"] = client.relayer_region data["remote_enabled"] = client.prefs.remote_enabled data["remote_connected"] = cloud.remote.is_connected + data["remote_server"] = cloud.remote.snitun_server data["alexa_enabled"] = client.prefs.alexa_enabled data["google_enabled"] = client.prefs.google_enabled data["cloud_ice_servers_enabled"] = client.prefs.cloud_ice_servers_enabled - data["remote_server"] = cloud.remote.snitun_server data["certificate_status"] = cloud.remote.certificate_status data["instance_id"] = client.prefs.instance_id + data["iot_state"] = cloud.iot.state + data["iot_tries"] = cloud.iot.tries + + if (cert := cloud.remote.certificate) is not None: + data["certificate_expire_date"] = cert.expire_date + data["certificate_fingerprint"] = cert.fingerprint + if cert.alternative_names: + data["certificate_alternative_names"] = cert.alternative_names + + if (disconnect := cloud.iot.last_disconnect_reason) is not None: + data["iot_last_disconnect_clean"] = disconnect.clean + data["iot_last_disconnect_reason"] = disconnect.reason data["can_reach_cert_server"] = system_health.async_check_can_reach_url( hass, f"https://{cloud.acme_server}/directory" diff --git a/homeassistant/components/hypontech/__init__.py b/homeassistant/components/hypontech/__init__.py index 6c07aa20e0b996..701a6099afa304 100644 --- a/homeassistant/components/hypontech/__init__.py +++ b/homeassistant/components/hypontech/__init__.py @@ -7,6 +7,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession +from .const import CONF_OEM, DEFAULT_OEM from .coordinator import HypontechConfigEntry, HypontechDataCoordinator _PLATFORMS: list[Platform] = [Platform.SENSOR] @@ -19,6 +20,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HypontechConfigEntry) -> entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], session, + oem=int(entry.data.get(CONF_OEM, DEFAULT_OEM)), ) try: await hypontech_cloud.connect() diff --git a/homeassistant/components/hypontech/config_flow.py b/homeassistant/components/hypontech/config_flow.py index 90eb9e7f4e6b44..7db1acc7eae8ce 100644 --- a/homeassistant/components/hypontech/config_flow.py +++ b/homeassistant/components/hypontech/config_flow.py @@ -4,18 +4,29 @@ import logging from typing import Any -from hyponcloud import AuthenticationError, HyponCloud +from hyponcloud import KNOWN_OEMS, AdminInfo, AuthenticationError, HyponCloud import voluptuous as vol from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) -from .const import DOMAIN +from .const import CONF_OEM, DEFAULT_OEM, DOMAIN _LOGGER = logging.getLogger(__name__) -STEP_USER_DATA_SCHEMA = vol.Schema( +OEM_OPTIONS = [ + SelectOptionDict(value=str(oem.id), label=f"{oem.name} ({oem.monitoring_url})") + for oem in KNOWN_OEMS +] + +STEP_REAUTH_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, @@ -23,52 +34,128 @@ ) +def _data_schema(default_oem: int = DEFAULT_OEM) -> vol.Schema: + """Return the config flow data schema.""" + return vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_OEM, default=str(default_oem)): SelectSelector( + SelectSelectorConfig( + options=OEM_OPTIONS, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + } + ) + + +def _entry_data(user_input: Mapping[str, Any]) -> dict[str, Any]: + """Normalize config entry data from user input.""" + return {**user_input, CONF_OEM: int(user_input[CONF_OEM])} + + +def _unique_id(account_id: str, oem: int) -> str: + """Return a backwards-compatible unique id for the account and OEM.""" + if oem == DEFAULT_OEM: + return account_id + return f"{oem}:{account_id}" + + class HypontechConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Hypontech Cloud.""" + def __init__(self) -> None: + """Initialize the config flow.""" + self._default_oem = DEFAULT_OEM + + async def _async_validate_input( + self, entry_data: Mapping[str, Any] + ) -> tuple[AdminInfo | None, dict[str, str]]: + """Validate user input.""" + errors: dict[str, str] = {} + session = async_get_clientsession(self.hass) + hypon = HyponCloud( + entry_data[CONF_USERNAME], + entry_data[CONF_PASSWORD], + session, + oem=entry_data[CONF_OEM], + ) + try: + await hypon.connect() + return await hypon.get_admin_info(), errors + except AuthenticationError: + errors["base"] = "invalid_auth" + except TimeoutError, ConnectionError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + return None, errors + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" errors: dict[str, str] = {} + default_oem = self._default_oem if user_input is not None: - session = async_get_clientsession(self.hass) - hypon = HyponCloud( - user_input[CONF_USERNAME], user_input[CONF_PASSWORD], session - ) - try: - await hypon.connect() - admin_info = await hypon.get_admin_info() - except AuthenticationError: - errors["base"] = "invalid_auth" - except TimeoutError, ConnectionError: - errors["base"] = "cannot_connect" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: - await self.async_set_unique_id(admin_info.id) + entry_data = _entry_data(user_input) + default_oem = entry_data[CONF_OEM] + admin_info, errors = await self._async_validate_input(entry_data) + if admin_info is not None: + await self.async_set_unique_id( + _unique_id(admin_info.id, entry_data[CONF_OEM]) + ) if self.source == SOURCE_USER: self._abort_if_unique_id_configured() return self.async_create_entry( - title=user_input[CONF_USERNAME], - data=user_input, + title=entry_data[CONF_USERNAME], + data=entry_data, ) self._abort_if_unique_id_mismatch(reason="wrong_account") return self.async_update_reload_and_abort( self._get_reauth_entry(), data_updates={ - CONF_USERNAME: user_input[CONF_USERNAME], - CONF_PASSWORD: user_input[CONF_PASSWORD], + CONF_USERNAME: entry_data[CONF_USERNAME], + CONF_PASSWORD: entry_data[CONF_PASSWORD], }, ) return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + step_id="user", data_schema=_data_schema(default_oem), errors=errors ) async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: """Handle reauthentication.""" - return await self.async_step_user() + self._default_oem = int(entry_data.get(CONF_OEM, DEFAULT_OEM)) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reauthentication confirmation.""" + errors: dict[str, str] = {} + if user_input is not None: + entry_data = {**user_input, CONF_OEM: self._default_oem} + admin_info, errors = await self._async_validate_input(entry_data) + if admin_info is not None: + await self.async_set_unique_id( + _unique_id(admin_info.id, entry_data[CONF_OEM]) + ) + self._abort_if_unique_id_mismatch(reason="wrong_account") + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data_updates={ + CONF_USERNAME: entry_data[CONF_USERNAME], + CONF_PASSWORD: entry_data[CONF_PASSWORD], + }, + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=STEP_REAUTH_DATA_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/hypontech/const.py b/homeassistant/components/hypontech/const.py index 4f290ee882d460..df68b3d2832ca2 100644 --- a/homeassistant/components/hypontech/const.py +++ b/homeassistant/components/hypontech/const.py @@ -4,4 +4,7 @@ DOMAIN = "hypontech" +CONF_OEM = "oem" +DEFAULT_OEM = 0 + LOGGER: Logger = getLogger(__package__) diff --git a/homeassistant/components/hypontech/coordinator.py b/homeassistant/components/hypontech/coordinator.py index db0a483ce31819..56cceba47d02be 100644 --- a/homeassistant/components/hypontech/coordinator.py +++ b/homeassistant/components/hypontech/coordinator.py @@ -5,6 +5,7 @@ from datetime import timedelta from hyponcloud import ( + KNOWN_OEMS, HyponCloud, OverviewData, PlantData, @@ -16,7 +17,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN, LOGGER +from .const import CONF_OEM, DEFAULT_OEM, DOMAIN, LOGGER @dataclass @@ -37,6 +38,8 @@ class HypontechCoordinatorData: type HypontechConfigEntry = ConfigEntry[HypontechDataCoordinator] +OEM_NAMES = {oem.id: oem.name for oem in KNOWN_OEMS} + class HypontechDataCoordinator(DataUpdateCoordinator[HypontechCoordinatorData]): """Coordinator used for all sensors.""" @@ -60,6 +63,7 @@ def __init__( ) self.api = api self.account_id = account_id + self.oem_name = OEM_NAMES[int(config_entry.data.get(CONF_OEM, DEFAULT_OEM))] async def _async_update_data(self) -> HypontechCoordinatorData: try: diff --git a/homeassistant/components/hypontech/entity.py b/homeassistant/components/hypontech/entity.py index cb80e7a35f5a75..d19bca95b6ced5 100644 --- a/homeassistant/components/hypontech/entity.py +++ b/homeassistant/components/hypontech/entity.py @@ -18,7 +18,7 @@ def __init__(self, coordinator: HypontechDataCoordinator) -> None: self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, coordinator.account_id)}, name="Overview", - manufacturer="Hypontech", + manufacturer=coordinator.oem_name, ) @@ -35,7 +35,7 @@ def __init__(self, coordinator: HypontechDataCoordinator, plant_id: str) -> None self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, plant_id)}, name=plant.info.plant_name, - manufacturer="Hypontech", + manufacturer=coordinator.oem_name, model=plant.info.plant_type, ) diff --git a/homeassistant/components/hypontech/strings.json b/homeassistant/components/hypontech/strings.json index 637c87d977247c..5c664462a60fd4 100644 --- a/homeassistant/components/hypontech/strings.json +++ b/homeassistant/components/hypontech/strings.json @@ -24,10 +24,12 @@ }, "user": { "data": { + "oem": "Manufacturer", "password": "[%key:common::config_flow::data::password%]", "username": "[%key:common::config_flow::data::username%]" }, "data_description": { + "oem": "The brand that sold the equipment", "password": "Your Hypontech Cloud account password.", "username": "Your Hypontech Cloud account username." } diff --git a/homeassistant/components/melcloud_home/climate.py b/homeassistant/components/melcloud_home/climate.py index f4766cc30acafe..f5dab2bd6dca1c 100644 --- a/homeassistant/components/melcloud_home/climate.py +++ b/homeassistant/components/melcloud_home/climate.py @@ -24,6 +24,8 @@ from .coordinator import MelCloudHomeConfigEntry, MelCloudHomeCoordinator from .entity import MelCloudHomeATAUnitEntity, MelCloudHomeATWZoneEntity +PARALLEL_UPDATES = 1 + ATA_HVAC_MODE_TO_OPERATION: dict[HVACMode, ATAOperationMode] = { HVACMode.HEAT: ATAOperationMode.HEAT, HVACMode.COOL: ATAOperationMode.COOL, diff --git a/homeassistant/components/melcloud_home/quality_scale.yaml b/homeassistant/components/melcloud_home/quality_scale.yaml index d98b8a46cba73b..e7ddea4dcf7ed2 100644 --- a/homeassistant/components/melcloud_home/quality_scale.yaml +++ b/homeassistant/components/melcloud_home/quality_scale.yaml @@ -35,7 +35,7 @@ rules: entity-unavailable: todo integration-owner: done log-when-unavailable: todo - parallel-updates: todo + parallel-updates: done reauthentication-flow: todo test-coverage: todo diff --git a/homeassistant/components/opendisplay/__init__.py b/homeassistant/components/opendisplay/__init__.py index fb695f84ab4929..c36564d9aef627 100644 --- a/homeassistant/components/opendisplay/__init__.py +++ b/homeassistant/components/opendisplay/__init__.py @@ -62,13 +62,15 @@ def _get_encryption_key(entry: OpenDisplayConfigEntry) -> bytes | None: return None if len(raw) != 32: raise ConfigEntryAuthFailed( - "Stored OpenDisplay encryption key is invalid; reauthentication required" + translation_domain=DOMAIN, + translation_key="authentication_error", ) try: return bytes.fromhex(raw) except ValueError as err: raise ConfigEntryAuthFailed( - "Stored OpenDisplay encryption key is invalid; reauthentication required" + translation_domain=DOMAIN, + translation_key="authentication_error", ) from err @@ -108,11 +110,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenDisplayConfigEntry) is_flex = device.is_flex except (AuthenticationFailedError, AuthenticationRequiredError) as err: raise ConfigEntryAuthFailed( - f"Encryption key rejected by OpenDisplay device: {err}" + translation_domain=DOMAIN, + translation_key="authentication_error", ) from err except (BLEConnectionError, BLETimeoutError, OpenDisplayError) as err: raise ConfigEntryNotReady( - f"Failed to connect to OpenDisplay device: {err}" + translation_domain=DOMAIN, + translation_key="setup_connection_error", ) from err device_config = device.config if TYPE_CHECKING: diff --git a/homeassistant/components/opendisplay/quality_scale.yaml b/homeassistant/components/opendisplay/quality_scale.yaml index 7d488733d2c117..6a14ae56adc751 100644 --- a/homeassistant/components/opendisplay/quality_scale.yaml +++ b/homeassistant/components/opendisplay/quality_scale.yaml @@ -57,7 +57,7 @@ rules: entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: todo + exception-translations: done icon-translations: done reconfiguration-flow: status: exempt diff --git a/homeassistant/components/opendisplay/strings.json b/homeassistant/components/opendisplay/strings.json index 90eb2b716afaee..520a705b61b2a7 100644 --- a/homeassistant/components/opendisplay/strings.json +++ b/homeassistant/components/opendisplay/strings.json @@ -86,6 +86,9 @@ "media_download_error": { "message": "Failed to download media: {error}" }, + "setup_connection_error": { + "message": "Failed to connect to OpenDisplay device." + }, "upload_error": { "message": "Failed to upload image to the display." } diff --git a/homeassistant/components/openevse/manifest.json b/homeassistant/components/openevse/manifest.json index e53fd05b5bd6dc..ff0e3902d0b8aa 100644 --- a/homeassistant/components/openevse/manifest.json +++ b/homeassistant/components/openevse/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["openevsehttp"], - "quality_scale": "bronze", + "quality_scale": "silver", "requirements": ["python-openevse-http==1.0.1"], "zeroconf": ["_openevse._tcp.local."] } diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index 2953f915138ed0..e35d5154e4344a 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -13,7 +13,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["boto3", "botocore", "pyoverkiz", "s3transfer"], - "requirements": ["pyoverkiz[nexity]==2.0.0"], + "requirements": ["pyoverkiz[nexity]==2.0.1"], "zeroconf": [ { "name": "gateway*", diff --git a/homeassistant/components/yoto/manifest.json b/homeassistant/components/yoto/manifest.json index c84bc1b0cce5b2..8a48d97a131455 100644 --- a/homeassistant/components/yoto/manifest.json +++ b/homeassistant/components/yoto/manifest.json @@ -10,5 +10,5 @@ "iot_class": "cloud_push", "loggers": ["yoto_api"], "quality_scale": "bronze", - "requirements": ["yoto-api==4.0.2"] + "requirements": ["yoto-api==4.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5b254cfcdc9c7c..a244e3dec4dc29 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2438,7 +2438,7 @@ pyotgw==2.2.3 pyotp==2.9.0 # homeassistant.components.overkiz -pyoverkiz[nexity]==2.0.0 +pyoverkiz[nexity]==2.0.1 # homeassistant.components.palazzetti pypalazzetti==0.1.20 @@ -3436,7 +3436,7 @@ yeelightsunflower==0.0.10 yolink-api==0.6.5 # homeassistant.components.yoto -yoto-api==4.0.2 +yoto-api==4.1.0 # homeassistant.components.youless youless-api==2.2.0 diff --git a/tests/components/aqvify/test_init.py b/tests/components/aqvify/test_init.py index 1dc22ec52f7a3a..ff2f3b71a75e59 100644 --- a/tests/components/aqvify/test_init.py +++ b/tests/components/aqvify/test_init.py @@ -1,18 +1,22 @@ """Test the Aqvify init.""" -from unittest.mock import MagicMock +from datetime import timedelta +from unittest.mock import MagicMock, Mock +from aiohttp import ClientResponseError +from freezegun.api import FrozenDateTimeFactory from pyaqvify import AqvifyAuthException import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant import homeassistant.helpers.device_registry as dr from . import setup_integration -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_load_unload_entry( @@ -38,8 +42,9 @@ async def test_load_unload_entry( (None, ConfigEntryState.LOADED), (AqvifyAuthException, ConfigEntryState.SETUP_ERROR), (TimeoutError, ConfigEntryState.SETUP_RETRY), + (ClientResponseError(Mock(), Mock(), status=500), ConfigEntryState.SETUP_RETRY), ], - ids=["no_error", "auth_error", "timeout_error"], + ids=["no_error", "auth_error", "timeout_error", "communications_error"], ) async def test_setup_entry_with_error( hass: HomeAssistant, @@ -93,3 +98,104 @@ async def test_setup_entry_auth_error_triggers_reauth( flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 assert flows[0]["step_id"] == "reauth_confirm" + + +WATER_LEVEL_SENSOR = "sensor.device_1_water_level" +EXPECTED_WATER_LEVEL = "-0.136786005" + + +@pytest.mark.parametrize( + ("exception", "log_message", "expected_state"), + [ + ( + TimeoutError, + "Timeout occurred while communicating", + EXPECTED_WATER_LEVEL, + ), + ( + ClientResponseError(Mock(), Mock(), status=500), + "An error occurred while communicating", + EXPECTED_WATER_LEVEL, + ), + ( + AqvifyAuthException, + "Authentication failed", + "unavailable", + ), + ], + ids=["timeout_error", "communications_error", "auth_error"], +) +async def test_coordinator_get_devices_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_aqvify_client: MagicMock, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, + exception: Exception, + log_message: str, + expected_state: str, +) -> None: + """Tests that the coordinator handles errors from async_get_devices.""" + + await setup_integration(hass, mock_config_entry) + + mock_aqvify_client.async_get_devices.side_effect = exception + + caplog.clear() + freezer.tick(delta=timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(WATER_LEVEL_SENSOR).state == STATE_UNAVAILABLE + assert log_message in caplog.text + + mock_aqvify_client.async_get_devices.side_effect = None + freezer.tick(delta=timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(WATER_LEVEL_SENSOR).state == expected_state + + +@pytest.mark.parametrize( + ("exception", "log_message", "expected_state"), + [ + (TimeoutError, "Timeout occurred while communicating", EXPECTED_WATER_LEVEL), + ( + ClientResponseError(Mock(), Mock(), status=500), + "An error occurred while communicating", + EXPECTED_WATER_LEVEL, + ), + (AqvifyAuthException, "Invalid API key.", "unavailable"), + ], + ids=["timeout_error", "communications_error", "auth_error"], +) +async def test_coordinator_get_device_data_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_aqvify_client: MagicMock, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, + exception: Exception, + log_message: str, + expected_state: str, +) -> None: + """Tests that the coordinator handles errors from async_get_device_latest_data.""" + + await setup_integration(hass, mock_config_entry) + + mock_aqvify_client.async_get_device_latest_data.side_effect = exception + + caplog.clear() + freezer.tick(delta=timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(WATER_LEVEL_SENSOR).state == STATE_UNAVAILABLE + assert log_message in caplog.text + mock_aqvify_client.async_get_device_latest_data.side_effect = None + freezer.tick(delta=timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(WATER_LEVEL_SENSOR).state == expected_state diff --git a/tests/components/blebox/test_config_flow.py b/tests/components/blebox/test_config_flow.py index b44887fed3c5bf..e93a8dea410d74 100644 --- a/tests/components/blebox/test_config_flow.py +++ b/tests/components/blebox/test_config_flow.py @@ -27,6 +27,20 @@ from tests.common import MockConfigEntry +@pytest.fixture(name="zeroconf_data") +def zeroconf_data_fixture() -> ZeroconfServiceInfo: + """Return ZeroconfServiceInfo for a BleBox device.""" + return ZeroconfServiceInfo( + ip_address=ip_address("172.100.123.4"), + ip_addresses=[ip_address("172.100.123.4")], + port=80, + hostname="bbx-bbtest123456.local.", + type="_bbxsrv._tcp.local.", + name="bbx-bbtest123456._bbxsrv._tcp.local.", + properties={"_raw": {}}, + ) + + def create_valid_feature_mock(path="homeassistant.components.blebox.Products"): """Return a valid, complete BleBox feature mock.""" feature = mock_only_feature( @@ -173,7 +187,7 @@ async def test_flow_with_auth_failure(hass: HomeAssistant, product_class_mock) - context={"source": config_entries.SOURCE_USER}, data={config_flow.CONF_HOST: "172.2.3.4", config_flow.CONF_PORT: 80}, ) - assert result["errors"] == {"base": "cannot_connect"} + assert result["errors"] == {"base": "invalid_auth"} async def test_async_setup(hass: HomeAssistant) -> None: @@ -225,20 +239,14 @@ async def test_async_remove_entry( assert config_entry.state is ConfigEntryState.NOT_LOADED -async def test_flow_with_zeroconf(hass: HomeAssistant) -> None: +async def test_flow_with_zeroconf( + hass: HomeAssistant, zeroconf_data: ZeroconfServiceInfo +) -> None: """Test setup from zeroconf discovery.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=ZeroconfServiceInfo( - ip_address=ip_address("172.100.123.4"), - ip_addresses=[ip_address("172.100.123.4")], - port=80, - hostname="bbx-bbtest123456.local.", - type="_bbxsrv._tcp.local.", - name="bbx-bbtest123456._bbxsrv._tcp.local.", - properties={"_raw": {}}, - ), + data=zeroconf_data, ) assert result["type"] is FlowResultType.FORM @@ -252,7 +260,9 @@ async def test_flow_with_zeroconf(hass: HomeAssistant) -> None: async def test_flow_with_zeroconf_when_already_configured( - hass: HomeAssistant, config_entry: MockConfigEntry + hass: HomeAssistant, + config_entry: MockConfigEntry, + zeroconf_data: ZeroconfServiceInfo, ) -> None: """Test behaviour if device already configured.""" config_entry.add_to_hass(hass) @@ -267,22 +277,16 @@ async def test_flow_with_zeroconf_when_already_configured( result2 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=ZeroconfServiceInfo( - ip_address=ip_address("172.100.123.4"), - ip_addresses=[ip_address("172.100.123.4")], - port=80, - hostname="bbx-bbtest123456.local.", - type="_bbxsrv._tcp.local.", - name="bbx-bbtest123456._bbxsrv._tcp.local.", - properties={"_raw": {}}, - ), + data=zeroconf_data, ) assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "already_configured" -async def test_flow_with_zeroconf_when_device_unsupported(hass: HomeAssistant) -> None: +async def test_flow_with_zeroconf_when_device_unsupported( + hass: HomeAssistant, zeroconf_data: ZeroconfServiceInfo +) -> None: """Test behaviour when device is not supported.""" with patch( "homeassistant.components.blebox.config_flow.Box.async_from_host", @@ -291,25 +295,16 @@ async def test_flow_with_zeroconf_when_device_unsupported(hass: HomeAssistant) - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=ZeroconfServiceInfo( - ip_address=ip_address("172.100.123.4"), - ip_addresses=[ip_address("172.100.123.4")], - port=80, - hostname="bbx-bbtest123456.local.", - type="_bbxsrv._tcp.local.", - name="bbx-bbtest123456._bbxsrv._tcp.local.", - properties={"_raw": {}}, - ), + data=zeroconf_data, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "unsupported_device_version" async def test_flow_with_zeroconf_when_device_response_unsupported( - hass: HomeAssistant, + hass: HomeAssistant, zeroconf_data: ZeroconfServiceInfo ) -> None: """Test behaviour when device returned unsupported response.""" - with patch( "homeassistant.components.blebox.config_flow.Box.async_from_host", side_effect=blebox_uniapi.error.UnsupportedBoxResponse, @@ -317,20 +312,29 @@ async def test_flow_with_zeroconf_when_device_response_unsupported( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=ZeroconfServiceInfo( - ip_address=ip_address("172.100.123.4"), - ip_addresses=[ip_address("172.100.123.4")], - port=80, - hostname="bbx-bbtest123456.local.", - type="_bbxsrv._tcp.local.", - name="bbx-bbtest123456._bbxsrv._tcp.local.", - properties={"_raw": {}}, - ), + data=zeroconf_data, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "unsupported_device_response" +async def test_flow_with_zeroconf_when_unauthorized( + hass: HomeAssistant, zeroconf_data: ZeroconfServiceInfo +) -> None: + """Test behaviour when device requires authentication during zeroconf discovery.""" + with patch( + "homeassistant.components.blebox.config_flow.Box.async_from_host", + side_effect=blebox_uniapi.error.UnauthorizedRequest, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf_data, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "authorization_required" + + def create_product_mock(unique_id: str = "abcd0123ef5678"): """Return a product mock with a given unique_id.""" product = create_autospec(blebox_uniapi.box.Box, True, True) @@ -398,7 +402,7 @@ async def test_reconfigure_flow_unique_id_mismatch( [ pytest.param(blebox_uniapi.error.Error, "cannot_connect", id="api_error"), pytest.param( - blebox_uniapi.error.UnauthorizedRequest, "cannot_connect", id="auth_failure" + blebox_uniapi.error.UnauthorizedRequest, "invalid_auth", id="auth_failure" ), pytest.param( blebox_uniapi.error.UnsupportedBoxVersion, @@ -442,3 +446,107 @@ async def test_reconfigure_flow_recovers_after_error( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" + + +async def test_reauth_flow_works( + hass: HomeAssistant, config_entry: MockConfigEntry, product_class_mock +) -> None: + """Test that reauth flow updates credentials and reloads.""" + config_entry.add_to_hass(hass) + + result = await config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + with product_class_mock as box_class: + box_class.async_from_host = AsyncMock( + return_value=create_product_mock("abcd0123ef5678") + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {config_flow.CONF_USERNAME: "admin", config_flow.CONF_PASSWORD: "secret"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert config_entry.data[config_flow.CONF_USERNAME] == "admin" + assert config_entry.data[config_flow.CONF_PASSWORD] == "secret" + + +async def test_reauth_flow_works_without_credentials( + hass: HomeAssistant, product_class_mock +) -> None: + """Test that reauth flow clears credentials when submitted without them.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + config_flow.CONF_HOST: "172.100.123.4", + config_flow.CONF_PORT: 80, + config_flow.CONF_USERNAME: "admin", + config_flow.CONF_PASSWORD: "secret", + }, + ) + config_entry.add_to_hass(hass) + + result = await config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + + with product_class_mock as box_class: + box_class.async_from_host = AsyncMock( + return_value=create_product_mock("abcd0123ef5678") + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert config_entry.data[config_flow.CONF_USERNAME] is None + assert config_entry.data[config_flow.CONF_PASSWORD] is None + + +@pytest.mark.parametrize( + ("exception", "expected_error"), + [ + pytest.param( + blebox_uniapi.error.UnauthorizedRequest, "invalid_auth", id="auth_failure" + ), + pytest.param(blebox_uniapi.error.Error, "cannot_connect", id="api_error"), + pytest.param(RuntimeError, "unknown", id="runtime_error"), + ], +) +async def test_reauth_flow_recovers_after_error( + hass: HomeAssistant, + config_entry: MockConfigEntry, + product_class_mock, + exception: type[Exception], + expected_error: str, +) -> None: + """Test that reauth shows the correct error and allows a successful retry.""" + config_entry.add_to_hass(hass) + + result = await config_entry.start_reauth_flow(hass) + + with product_class_mock as box_class: + box_class.async_from_host = AsyncMock(side_effect=exception) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {config_flow.CONF_USERNAME: "admin", config_flow.CONF_PASSWORD: "secret"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": expected_error} + + with product_class_mock as box_class: + box_class.async_from_host = AsyncMock( + return_value=create_product_mock("abcd0123ef5678") + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {config_flow.CONF_USERNAME: "admin", config_flow.CONF_PASSWORD: "secret"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" diff --git a/tests/components/blebox/test_init.py b/tests/components/blebox/test_init.py index c448d099dbec14..cd8d0e8e4da1d1 100644 --- a/tests/components/blebox/test_init.py +++ b/tests/components/blebox/test_init.py @@ -1,7 +1,5 @@ """BleBox devices setup tests.""" -import logging - import blebox_uniapi import pytest @@ -17,36 +15,42 @@ from tests.common import MockConfigEntry +@pytest.mark.parametrize( + ("exception", "expected_state"), + [ + (blebox_uniapi.error.ConnectionError, ConfigEntryState.SETUP_RETRY), + (blebox_uniapi.error.HttpError, ConfigEntryState.SETUP_RETRY), + (blebox_uniapi.error.UnsupportedBoxVersion, ConfigEntryState.SETUP_ERROR), + (blebox_uniapi.error.UnsupportedBoxResponse, ConfigEntryState.SETUP_ERROR), + (blebox_uniapi.error.UnauthorizedRequest, ConfigEntryState.SETUP_ERROR), + (blebox_uniapi.error.Error, ConfigEntryState.SETUP_ERROR), + ], +) async def test_setup_failure( hass: HomeAssistant, config_entry: MockConfigEntry, - caplog: pytest.LogCaptureFixture, + exception: type[Exception], + expected_state: ConfigEntryState, ) -> None: - """Test that setup failure is handled and logged.""" - - patch_product_identify(None, side_effect=blebox_uniapi.error.ClientError) - - caplog.set_level(logging.ERROR) + """Test that setup failures map to the correct config entry state.""" + patch_product_identify(None, side_effect=exception) await async_setup_config_entry(hass, config_entry) + assert config_entry.state is expected_state - assert "Identify failed at 172.100.123.4:80 ()" in caplog.text - assert config_entry.state is ConfigEntryState.SETUP_RETRY - -async def test_setup_failure_on_connection( +async def test_setup_auth_failure_triggers_reauth( hass: HomeAssistant, config_entry: MockConfigEntry, - caplog: pytest.LogCaptureFixture, ) -> None: - """Test that setup failure is handled and logged.""" - - patch_product_identify(None, side_effect=blebox_uniapi.error.ConnectionError) - - caplog.set_level(logging.ERROR) + """Test that UnauthorizedRequest during setup triggers a reauth flow.""" + patch_product_identify(None, side_effect=blebox_uniapi.error.UnauthorizedRequest) await async_setup_config_entry(hass, config_entry) - assert "Identify failed at 172.100.123.4:80 ()" in caplog.text - assert config_entry.state is ConfigEntryState.SETUP_RETRY + assert config_entry.state is ConfigEntryState.SETUP_ERROR + flows = hass.config_entries.flow.async_progress() + assert any( + f["handler"] == "blebox" and f["context"]["source"] == "reauth" for f in flows + ) async def test_unload_config_entry( diff --git a/tests/components/cloud/conftest.py b/tests/components/cloud/conftest.py index 0610567fe9bbae..b8a93cb6e50113 100644 --- a/tests/components/cloud/conftest.py +++ b/tests/components/cloud/conftest.py @@ -70,7 +70,7 @@ async def cloud_fixture() -> AsyncGenerator[MagicMock]: ) mock_cloud.auth = MagicMock(spec=CognitoAuth) mock_cloud.iot = MagicMock( - spec=CloudIoT, last_disconnect_reason=None, state=STATE_CONNECTED + spec=CloudIoT, last_disconnect_reason=None, state=STATE_CONNECTED, tries=0 ) mock_cloud.voice = MagicMock(spec=Voice) mock_cloud.files = MagicMock(spec=Files) diff --git a/tests/components/cloud/snapshots/test_http_api.ambr b/tests/components/cloud/snapshots/test_http_api.ambr index 1c872bde630761..6dea0edece864e 100644 --- a/tests/components/cloud/snapshots/test_http_api.ambr +++ b/tests/components/cloud/snapshots/test_http_api.ambr @@ -88,12 +88,14 @@ relayer_region | xx-earth-616 remote_enabled | True remote_connected | False + remote_server | us-west-1 alexa_enabled | True google_enabled | False cloud_ice_servers_enabled | True - remote_server | us-west-1 certificate_status | ready instance_id | 12345678901234567890 + iot_state | connected + iot_tries | 0 can_reach_cert_server | Exception: Unexpected exception can_reach_cloud_auth | Failed: unreachable can_reach_cloud | ok @@ -204,12 +206,14 @@ relayer_region | xx-earth-616 remote_enabled | True remote_connected | False + remote_server | us-west-1 alexa_enabled | True google_enabled | False cloud_ice_servers_enabled | True - remote_server | us-west-1 certificate_status | ready instance_id | 12345678901234567890 + iot_state | connected + iot_tries | 0 can_reach_cert_server | Exception: Unexpected exception can_reach_cloud_auth | Failed: unreachable can_reach_cloud | ok @@ -284,12 +288,14 @@ relayer_region | xx-earth-616 remote_enabled | True remote_connected | False + remote_server | us-west-1 alexa_enabled | True google_enabled | False cloud_ice_servers_enabled | True - remote_server | us-west-1 certificate_status | ready instance_id | 12345678901234567890 + iot_state | connected + iot_tries | 0 can_reach_cert_server | Exception: Unexpected exception can_reach_cloud_auth | Failed: unreachable can_reach_cloud | ok diff --git a/tests/components/cloud/test_system_health.py b/tests/components/cloud/test_system_health.py index 6293f44067dff1..3b1e772e613b0d 100644 --- a/tests/components/cloud/test_system_health.py +++ b/tests/components/cloud/test_system_health.py @@ -2,15 +2,18 @@ import asyncio from collections.abc import Callable, Coroutine +from datetime import datetime from typing import Any from unittest.mock import MagicMock from aiohttp import ClientError -from hass_nabucasa.remote import CertificateStatus +from hass_nabucasa.iot_base import DisconnectReason +from hass_nabucasa.remote import Certificate, CertificateStatus from homeassistant.components.cloud.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from homeassistant.util.dt import UTC from tests.common import get_system_health_info from tests.test_util.aiohttp import AiohttpClientMocker @@ -47,6 +50,11 @@ async def test_cloud_system_health( cloud.remote.snitun_server = "us-west-1" cloud.remote.certificate_status = CertificateStatus.READY + cloud.remote.certificate = None + cloud.remote.latency_by_location = {} + cloud.iot.state = "connected" + cloud.iot.tries = 0 + cloud.iot.last_disconnect_reason = None await cloud.client.async_system_message({"region": "xx-earth-616"}) await set_cloud_prefs( @@ -80,4 +88,66 @@ async def test_cloud_system_health( "can_reach_cloud_auth": {"type": "failed", "error": "unreachable"}, "can_reach_cloud": "ok", "instance_id": cloud.client.prefs.instance_id, + "iot_state": "connected", + "iot_tries": 0, } + + +async def test_cloud_system_health_with_cert_and_disconnect( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + cloud: MagicMock, + set_cloud_prefs: Callable[[dict[str, Any]], Coroutine[Any, Any, None]], +) -> None: + """Test cloud system health with certificate details and disconnect reason.""" + aioclient_mock.get("https://cloud.bla.com/status", text="") + aioclient_mock.get("https://cert-server/directory", text="") + aioclient_mock.get( + "https://cognito-idp.us-east-1.amazonaws.com/AAAA/.well-known/jwks.json", + text="", + ) + assert await async_setup_component(hass, "system_health", {}) + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "user_pool_id": "AAAA", + "region": "us-east-1", + "acme_server": "cert-server", + "relayer_server": "cloud.bla.com", + }, + }, + ) + await hass.async_block_till_done() + await cloud.login("test-user", "test-pass") + + expire = datetime(2026, 8, 1, tzinfo=UTC) + cloud.remote.snitun_server = "eu-central-1" + cloud.remote.certificate_status = CertificateStatus.READY + cloud.remote.certificate = Certificate( + common_name="my-home.ui.nabu.casa", + expire_date=expire, + fingerprint="abc123def456", + alternative_names=["custom.example.com"], + ) + cloud.iot.state = "connected" + cloud.iot.tries = 2 + cloud.iot.last_disconnect_reason = DisconnectReason( + clean=False, reason="ping_timeout" + ) + + await set_cloud_prefs({"remote_enabled": True}) + + info = await get_system_health_info(hass, "cloud") + + for key, val in info.items(): + if asyncio.iscoroutine(val): + info[key] = await val + + assert info["certificate_expire_date"] == expire + assert info["certificate_fingerprint"] == "abc123def456" + assert info["certificate_alternative_names"] == ["custom.example.com"] + assert info["iot_last_disconnect_clean"] is False + assert info["iot_last_disconnect_reason"] == "ping_timeout" + assert info["iot_tries"] == 2 diff --git a/tests/components/govee_light_local/conftest.py b/tests/components/govee_light_local/conftest.py index a8b6955c3844f8..7dd7072c16e129 100644 --- a/tests/components/govee_light_local/conftest.py +++ b/tests/components/govee_light_local/conftest.py @@ -4,11 +4,15 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch -from govee_local_api import GoveeLightCapabilities, GoveeLightFeatures +from govee_local_api import GoveeDevice, GoveeLightCapabilities, GoveeLightFeatures from govee_local_api.light_capabilities import COMMON_FEATURES, SCENE_CODES import pytest +from homeassistant.components.govee_light_local.const import DOMAIN from homeassistant.components.govee_light_local.coordinator import GoveeController +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry @pytest.fixture(name="mock_govee_api") @@ -56,3 +60,37 @@ def fixture_mock_setup_entry() -> Generator[AsyncMock]: segments=[], scenes=SCENE_CODES, ) + + +async def setup_light( + hass: HomeAssistant, + mock_govee_api: AsyncMock, + capabilities: GoveeLightCapabilities = DEFAULT_CAPABILITIES, + *, + ip: str = "192.168.1.100", + fingerprint: str = "asdawdqwdqwd", + sku: str = "H615A", +) -> tuple[MockConfigEntry, GoveeDevice]: + """Set up a single mocked Govee light device and return its entry and device. + + The returned tuple lets tests that need to mutate the device after setup + (e.g. ``device.update(...)`` in availability tests) access the underlying + ``GoveeDevice`` directly. Tests that only need the entry or neither can + discard the unused half with ``_``. + """ + device = GoveeDevice( + controller=mock_govee_api, + ip=ip, + fingerprint=fingerprint, + sku=sku, + capabilities=capabilities, + ) + mock_govee_api.devices = [device] + + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry, device diff --git a/tests/components/govee_light_local/test_light.py b/tests/components/govee_light_local/test_light.py index b524cffe6aae2c..cb7d7119acd917 100644 --- a/tests/components/govee_light_local/test_light.py +++ b/tests/components/govee_light_local/test_light.py @@ -36,7 +36,7 @@ from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util -from .conftest import DEFAULT_CAPABILITIES, SCENE_CAPABILITIES +from .conftest import DEFAULT_CAPABILITIES, SCENE_CAPABILITIES, setup_light from tests.common import MockConfigEntry, async_fire_time_changed @@ -45,22 +45,7 @@ async def test_light_known_device( hass: HomeAssistant, mock_govee_api: AsyncMock ) -> None: """Test adding a known device.""" - - mock_govee_api.devices = [ - GoveeDevice( - controller=mock_govee_api, - ip="192.168.1.100", - fingerprint="asdawdqwdqwd", - sku="H615A", - capabilities=DEFAULT_CAPABILITIES, - ) - ] - - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) - - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + entry, _ = await setup_light(hass, mock_govee_api) assert len(hass.states.async_all()) == 1 @@ -80,22 +65,14 @@ async def test_light_unknown_device( hass: HomeAssistant, mock_govee_api: AsyncMock ) -> None: """Test adding an unknown device.""" - - mock_govee_api.devices = [ - GoveeDevice( - controller=mock_govee_api, - ip="192.168.1.101", - fingerprint="unkown_device", - sku="XYZK", - capabilities=ON_OFF_CAPABILITIES, - ) - ] - - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) - - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + await setup_light( + hass, + mock_govee_api, + ON_OFF_CAPABILITIES, + ip="192.168.1.101", + fingerprint="unkown_device", + sku="XYZK", + ) assert len(hass.states.async_all()) == 1 @@ -107,22 +84,8 @@ async def test_light_unknown_device( async def test_light_remove(hass: HomeAssistant, mock_govee_api: AsyncMock) -> None: """Test remove device.""" + entry, _ = await setup_light(hass, mock_govee_api, fingerprint="asdawdqwdqwd1") - mock_govee_api.devices = [ - GoveeDevice( - controller=mock_govee_api, - ip="192.168.1.100", - fingerprint="asdawdqwdqwd1", - sku="H615A", - capabilities=DEFAULT_CAPABILITIES, - ) - ] - - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) - - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() assert hass.states.get("light.H615A") is not None # Remove 1 @@ -199,22 +162,7 @@ async def test_light_setup_error( async def test_light_on_off(hass: HomeAssistant, mock_govee_api: AsyncMock) -> None: """Test light on and then off.""" - - mock_govee_api.devices = [ - GoveeDevice( - controller=mock_govee_api, - ip="192.168.1.100", - fingerprint="asdawdqwdqwd", - sku="H615A", - capabilities=DEFAULT_CAPABILITIES, - ) - ] - - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) - - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + _, device = await setup_light(hass, mock_govee_api) assert len(hass.states.async_all()) == 1 @@ -233,7 +181,7 @@ async def test_light_on_off(hass: HomeAssistant, mock_govee_api: AsyncMock) -> N light = hass.states.get("light.H615A") assert light is not None assert light.state == "on" - mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) + mock_govee_api.turn_on_off.assert_awaited_with(device, True) # Turn off await hass.services.async_call( @@ -247,7 +195,7 @@ async def test_light_on_off(hass: HomeAssistant, mock_govee_api: AsyncMock) -> N light = hass.states.get("light.H615A") assert light is not None assert light.state == "off" - mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], False) + mock_govee_api.turn_on_off.assert_awaited_with(device, False) @pytest.mark.parametrize( @@ -280,21 +228,7 @@ async def test_turn_on_call_order( mock_call_kwargs: dict[str, Any], ) -> None: """Test that turn_on is called after set_brightness/set_color/set_preset.""" - mock_govee_api.devices = [ - GoveeDevice( - controller=mock_govee_api, - ip="192.168.1.100", - fingerprint="asdawdqwdqwd", - sku="H615A", - capabilities=SCENE_CAPABILITIES, - ) - ] - - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) - - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + _, device = await setup_light(hass, mock_govee_api, SCENE_CAPABILITIES) assert len(hass.states.async_all()) == 1 @@ -312,32 +246,16 @@ async def test_turn_on_call_order( mock_govee_api.assert_has_calls( [ - call.set_brightness(mock_govee_api.devices[0], 50), - getattr(call, mock_call)( - mock_govee_api.devices[0], *mock_call_args, **mock_call_kwargs - ), - call.turn_on_off(mock_govee_api.devices[0], True), + call.set_brightness(device, 50), + getattr(call, mock_call)(device, *mock_call_args, **mock_call_kwargs), + call.turn_on_off(device, True), ] ) async def test_light_brightness(hass: HomeAssistant, mock_govee_api: AsyncMock) -> None: """Test changing brightness.""" - mock_govee_api.devices = [ - GoveeDevice( - controller=mock_govee_api, - ip="192.168.1.100", - fingerprint="asdawdqwdqwd", - sku="H615A", - capabilities=DEFAULT_CAPABILITIES, - ) - ] - - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) - - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + _, device = await setup_light(hass, mock_govee_api) assert len(hass.states.async_all()) == 1 @@ -356,7 +274,7 @@ async def test_light_brightness(hass: HomeAssistant, mock_govee_api: AsyncMock) light = hass.states.get("light.H615A") assert light is not None assert light.state == "on" - mock_govee_api.set_brightness.assert_awaited_with(mock_govee_api.devices[0], 50) + mock_govee_api.set_brightness.assert_awaited_with(device, 50) assert light.attributes[ATTR_BRIGHTNESS] == 127 await hass.services.async_call( @@ -371,7 +289,7 @@ async def test_light_brightness(hass: HomeAssistant, mock_govee_api: AsyncMock) assert light is not None assert light.state == "on" assert light.attributes[ATTR_BRIGHTNESS] == 255 - mock_govee_api.set_brightness.assert_awaited_with(mock_govee_api.devices[0], 100) + mock_govee_api.set_brightness.assert_awaited_with(device, 100) await hass.services.async_call( LIGHT_DOMAIN, @@ -385,26 +303,12 @@ async def test_light_brightness(hass: HomeAssistant, mock_govee_api: AsyncMock) assert light is not None assert light.state == "on" assert light.attributes[ATTR_BRIGHTNESS] == 255 - mock_govee_api.set_brightness.assert_awaited_with(mock_govee_api.devices[0], 100) + mock_govee_api.set_brightness.assert_awaited_with(device, 100) async def test_light_color(hass: HomeAssistant, mock_govee_api: AsyncMock) -> None: """Test changing color.""" - mock_govee_api.devices = [ - GoveeDevice( - controller=mock_govee_api, - ip="192.168.1.100", - fingerprint="asdawdqwdqwd", - sku="H615A", - capabilities=DEFAULT_CAPABILITIES, - ) - ] - - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) - - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + _, device = await setup_light(hass, mock_govee_api) assert len(hass.states.async_all()) == 1 @@ -427,7 +331,7 @@ async def test_light_color(hass: HomeAssistant, mock_govee_api: AsyncMock) -> No assert light.attributes[ATTR_COLOR_MODE] == ColorMode.RGB mock_govee_api.set_color.assert_awaited_with( - mock_govee_api.devices[0], rgb=(100, 255, 50), temperature=None + device, rgb=(100, 255, 50), temperature=None ) await hass.services.async_call( @@ -444,29 +348,12 @@ async def test_light_color(hass: HomeAssistant, mock_govee_api: AsyncMock) -> No assert light.attributes[ATTR_COLOR_TEMP_KELVIN] == 4400 assert light.attributes[ATTR_COLOR_MODE] == ColorMode.COLOR_TEMP - mock_govee_api.set_color.assert_awaited_with( - mock_govee_api.devices[0], rgb=None, temperature=4400 - ) + mock_govee_api.set_color.assert_awaited_with(device, rgb=None, temperature=4400) async def test_scene_on(hass: HomeAssistant, mock_govee_api: AsyncMock) -> None: """Test turning on scene.""" - - mock_govee_api.devices = [ - GoveeDevice( - controller=mock_govee_api, - ip="192.168.1.100", - fingerprint="asdawdqwdqwd", - sku="H615A", - capabilities=SCENE_CAPABILITIES, - ) - ] - - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) - - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + _, device = await setup_light(hass, mock_govee_api, SCENE_CAPABILITIES) assert len(hass.states.async_all()) == 1 @@ -486,29 +373,14 @@ async def test_scene_on(hass: HomeAssistant, mock_govee_api: AsyncMock) -> None: assert light is not None assert light.state == "on" assert light.attributes[ATTR_EFFECT] == "sunrise" - mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) + mock_govee_api.turn_on_off.assert_awaited_with(device, True) async def test_scene_restore_rgb( hass: HomeAssistant, mock_govee_api: AsyncMock ) -> None: """Test restore rgb color.""" - - mock_govee_api.devices = [ - GoveeDevice( - controller=mock_govee_api, - ip="192.168.1.100", - fingerprint="asdawdqwdqwd", - sku="H615A", - capabilities=SCENE_CAPABILITIES, - ) - ] - - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) - - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + _, device = await setup_light(hass, mock_govee_api, SCENE_CAPABILITIES) assert len(hass.states.async_all()) == 1 @@ -538,7 +410,7 @@ async def test_scene_restore_rgb( assert light.state == "on" assert light.attributes[ATTR_RGB_COLOR] == initial_color assert light.attributes[ATTR_BRIGHTNESS] == 255 - mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) + mock_govee_api.turn_on_off.assert_awaited_with(device, True) # Activate scene await hass.services.async_call( @@ -553,7 +425,7 @@ async def test_scene_restore_rgb( assert light is not None assert light.state == "on" assert light.attributes[ATTR_EFFECT] == "sunrise" - mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) + mock_govee_api.turn_on_off.assert_awaited_with(device, True) # Deactivate scene await hass.services.async_call( @@ -576,22 +448,7 @@ async def test_scene_restore_temperature( hass: HomeAssistant, mock_govee_api: AsyncMock ) -> None: """Test restore color temperature.""" - - mock_govee_api.devices = [ - GoveeDevice( - controller=mock_govee_api, - ip="192.168.1.100", - fingerprint="asdawdqwdqwd", - sku="H615A", - capabilities=SCENE_CAPABILITIES, - ) - ] - - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) - - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + _, device = await setup_light(hass, mock_govee_api, SCENE_CAPABILITIES) assert len(hass.states.async_all()) == 1 @@ -613,7 +470,7 @@ async def test_scene_restore_temperature( assert light is not None assert light.state == "on" assert light.attributes[ATTR_COLOR_TEMP_KELVIN] == initial_color - mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) + mock_govee_api.turn_on_off.assert_awaited_with(device, True) # Activate scene await hass.services.async_call( @@ -628,7 +485,7 @@ async def test_scene_restore_temperature( assert light is not None assert light.state == "on" assert light.attributes[ATTR_EFFECT] == "sunrise" - mock_govee_api.set_scene.assert_awaited_with(mock_govee_api.devices[0], "sunrise") + mock_govee_api.set_scene.assert_awaited_with(device, "sunrise") # Deactivate scene await hass.services.async_call( @@ -650,20 +507,7 @@ async def test_update_callback_registered_and_triggers_state_update( hass: HomeAssistant, mock_govee_api: AsyncMock ) -> None: """Test that update callback is registered and triggers state update.""" - device = GoveeDevice( - controller=mock_govee_api, - ip="192.168.1.100", - fingerprint="asdawdqwdqwd", - sku="H615A", - capabilities=DEFAULT_CAPABILITIES, - ) - mock_govee_api.devices = [device] - - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) - - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + _, device = await setup_light(hass, mock_govee_api) assert device.update_callback is not None @@ -685,20 +529,7 @@ async def test_update_callback_cleared_on_remove( hass: HomeAssistant, mock_govee_api: AsyncMock ) -> None: """Test that update callback is cleared when entity is removed.""" - device = GoveeDevice( - controller=mock_govee_api, - ip="192.168.1.100", - fingerprint="asdawdqwdqwd", - sku="H615A", - capabilities=DEFAULT_CAPABILITIES, - ) - mock_govee_api.devices = [device] - - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) - - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + entry, device = await setup_light(hass, mock_govee_api) assert device.update_callback is not None @@ -710,22 +541,7 @@ async def test_update_callback_cleared_on_remove( async def test_scene_none(hass: HomeAssistant, mock_govee_api: AsyncMock) -> None: """Test turn on 'none' scene.""" - - mock_govee_api.devices = [ - GoveeDevice( - controller=mock_govee_api, - ip="192.168.1.100", - fingerprint="asdawdqwdqwd", - sku="H615A", - capabilities=SCENE_CAPABILITIES, - ) - ] - - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) - - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + _, device = await setup_light(hass, mock_govee_api, SCENE_CAPABILITIES) assert len(hass.states.async_all()) == 1 @@ -755,7 +571,7 @@ async def test_scene_none(hass: HomeAssistant, mock_govee_api: AsyncMock) -> Non assert light.state == "on" assert light.attributes[ATTR_RGB_COLOR] == initial_color assert light.attributes[ATTR_BRIGHTNESS] == 255 - mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) + mock_govee_api.turn_on_off.assert_awaited_with(device, True) # Activate scene await hass.services.async_call( @@ -808,20 +624,7 @@ async def test_device_availability( timeout, goes unavailable past it, and recovers when a status response refreshes ``lastseen``. """ - device = GoveeDevice( - controller=mock_govee_api, - ip="192.168.1.100", - fingerprint="asdawdqwdqwd", - sku="H615A", - capabilities=DEFAULT_CAPABILITIES, - ) - mock_govee_api.devices = [device] - - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) - - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + _, device = await setup_light(hass, mock_govee_api) state = hass.states.get("light.H615A") assert state is not None diff --git a/tests/components/hypontech/conftest.py b/tests/components/hypontech/conftest.py index eacc6303a684a0..549668377a3a8e 100644 --- a/tests/components/hypontech/conftest.py +++ b/tests/components/hypontech/conftest.py @@ -110,4 +110,5 @@ def mock_hyponcloud( mock_client.get_monitor.side_effect = lambda plant_id, *args, **kwargs: ( load_monitor_fixture[plant_id] ) + mock_client.hyponcloud_class = mock_hyponcloud yield mock_client diff --git a/tests/components/hypontech/test_config_flow.py b/tests/components/hypontech/test_config_flow.py index e35a71d72c1525..70bf4e25b909a5 100644 --- a/tests/components/hypontech/test_config_flow.py +++ b/tests/components/hypontech/test_config_flow.py @@ -1,22 +1,52 @@ """Test the Hypontech Cloud config flow.""" -from unittest.mock import AsyncMock +from typing import cast +from unittest.mock import AsyncMock, Mock -from hyponcloud import AuthenticationError +from hyponcloud import KNOWN_OEMS, AuthenticationError import pytest -from homeassistant.components.hypontech.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER +from homeassistant.components.hypontech.config_flow import OEM_OPTIONS +from homeassistant.components.hypontech.const import CONF_OEM, DEFAULT_OEM, DOMAIN +from homeassistant.config_entries import SOURCE_USER, ConfigFlowResult from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry +NEXEN_OEM = 4 +TEST_ACCOUNT_ID = "2123456789123456789" TEST_USER_INPUT = { CONF_USERNAME: "test@example.com", CONF_PASSWORD: "test-password", + CONF_OEM: str(DEFAULT_OEM), } +TEST_ENTRY_DATA = { + CONF_USERNAME: "test@example.com", + CONF_PASSWORD: "test-password", + CONF_OEM: DEFAULT_OEM, +} +TEST_REAUTH_INPUT = { + CONF_USERNAME: "test@example.com", + CONF_PASSWORD: "test-password", +} + + +def assert_oem_not_in_schema(result: ConfigFlowResult) -> None: + """Assert the form data schema does not contain the OEM field.""" + assert CONF_OEM not in {field.schema for field in result["data_schema"].schema} + + +def test_oem_options_include_portal_url() -> None: + """Test OEM options include their portal URLs.""" + assert [ + { + "value": str(oem.id), + "label": f"{oem.name} ({oem.monitoring_url})", + } + for oem in KNOWN_OEMS + ] == OEM_OPTIONS async def test_user_flow( @@ -35,11 +65,32 @@ async def test_user_flow( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "test@example.com" - assert result["data"] == TEST_USER_INPUT - assert result["result"].unique_id == "2123456789123456789" + assert result["data"] == TEST_ENTRY_DATA + assert result["result"].unique_id == TEST_ACCOUNT_ID assert len(mock_setup_entry.mock_calls) == 1 +async def test_user_flow_with_oem( + hass: HomeAssistant, mock_hyponcloud: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test a successful user flow with a non-default OEM.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {**TEST_USER_INPUT, CONF_OEM: str(NEXEN_OEM)} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test@example.com" + assert result["data"] == {**TEST_ENTRY_DATA, CONF_OEM: NEXEN_OEM} + assert result["result"].unique_id == f"{NEXEN_OEM}:{TEST_ACCOUNT_ID}" + assert len(mock_setup_entry.mock_calls) == 1 + hyponcloud_class = cast(Mock, mock_hyponcloud.hyponcloud_class) + assert hyponcloud_class.call_args.kwargs["oem"] == NEXEN_OEM + + @pytest.mark.parametrize( ("side_effect", "error_message"), [ @@ -104,16 +155,47 @@ async def test_reauth_flow( result = await mock_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" + assert result["step_id"] == "reauth_confirm" + assert_oem_not_in_schema(result) result = await hass.config_entries.flow.async_configure( result["flow_id"], - {**TEST_USER_INPUT, CONF_PASSWORD: "password"}, + {**TEST_REAUTH_INPUT, CONF_PASSWORD: "password"}, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" assert mock_config_entry.data[CONF_PASSWORD] == "password" + assert CONF_OEM not in mock_config_entry.data + + +async def test_reauth_flow_uses_stored_oem( + hass: HomeAssistant, mock_hyponcloud: AsyncMock +) -> None: + """Test reauthentication uses the stored OEM without exposing it.""" + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={**TEST_ENTRY_DATA, CONF_OEM: NEXEN_OEM}, + unique_id=f"{NEXEN_OEM}:{TEST_ACCOUNT_ID}", + ) + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert_oem_not_in_schema(result) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {**TEST_REAUTH_INPUT, CONF_PASSWORD: "new-password"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_PASSWORD] == "new-password" + assert mock_config_entry.data[CONF_OEM] == NEXEN_OEM + hyponcloud_class = cast(Mock, mock_hyponcloud.hyponcloud_class) + assert hyponcloud_class.call_args.kwargs["oem"] == NEXEN_OEM @pytest.mark.parametrize( @@ -136,12 +218,13 @@ async def test_reauth_flow_errors( mock_config_entry.add_to_hass(hass) result = await mock_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" + assert result["step_id"] == "reauth_confirm" + assert_oem_not_in_schema(result) mock_hyponcloud.connect.side_effect = side_effect result = await hass.config_entries.flow.async_configure( result["flow_id"], - {**TEST_USER_INPUT, CONF_PASSWORD: "new-password"}, + {**TEST_REAUTH_INPUT, CONF_PASSWORD: "new-password"}, ) assert result["type"] is FlowResultType.FORM @@ -150,7 +233,7 @@ async def test_reauth_flow_errors( mock_hyponcloud.connect.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], - {**TEST_USER_INPUT, CONF_PASSWORD: "new-password"}, + {**TEST_REAUTH_INPUT, CONF_PASSWORD: "new-password"}, ) assert result["type"] is FlowResultType.ABORT @@ -164,13 +247,14 @@ async def test_reauth_flow_wrong_account( mock_config_entry.add_to_hass(hass) result = await mock_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" + assert result["step_id"] == "reauth_confirm" + assert_oem_not_in_schema(result) mock_hyponcloud.get_admin_info.return_value.id = "different_account_id_456" result = await hass.config_entries.flow.async_configure( result["flow_id"], - {**TEST_USER_INPUT, CONF_USERNAME: "different@example.com"}, + {**TEST_REAUTH_INPUT, CONF_USERNAME: "different@example.com"}, ) assert result["type"] is FlowResultType.ABORT diff --git a/tests/components/hypontech/test_init.py b/tests/components/hypontech/test_init.py index b49de5954faa40..429c4136680426 100644 --- a/tests/components/hypontech/test_init.py +++ b/tests/components/hypontech/test_init.py @@ -1,11 +1,14 @@ """Test the Hypontech Cloud init.""" -from unittest.mock import AsyncMock +from typing import cast +from unittest.mock import AsyncMock, Mock from hyponcloud import AuthenticationError, RequestError import pytest +from homeassistant.components.hypontech.const import CONF_OEM, DOMAIN from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from . import setup_integration @@ -35,6 +38,28 @@ async def test_setup_entry( assert mock_config_entry.state is expected_state +async def test_setup_entry_uses_oem( + hass: HomeAssistant, + mock_hyponcloud: AsyncMock, +) -> None: + """Test setup entry passes the stored OEM to the API.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: "test@example.com", + CONF_PASSWORD: "test-password", + CONF_OEM: 4, + }, + unique_id="4:2123456789123456789", + ) + + await setup_integration(hass, config_entry) + + assert config_entry.state is ConfigEntryState.LOADED + hyponcloud_class = cast(Mock, mock_hyponcloud.hyponcloud_class) + assert hyponcloud_class.call_args.kwargs["oem"] == 4 + + async def test_setup_and_unload_entry( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/hypontech/test_sensor.py b/tests/components/hypontech/test_sensor.py index 7f349fcc90c6cc..52b84036190ec7 100644 --- a/tests/components/hypontech/test_sensor.py +++ b/tests/components/hypontech/test_sensor.py @@ -4,9 +4,10 @@ from syrupy.assertion import SnapshotAssertion -from homeassistant.const import Platform +from homeassistant.components.hypontech.const import CONF_OEM, DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from . import setup_integration @@ -25,3 +26,36 @@ async def test_sensors( await setup_integration(hass, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_device_manufacturer_uses_oem( + hass: HomeAssistant, + mock_hyponcloud: AsyncMock, +) -> None: + """Test device manufacturer uses the selected OEM.""" + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: "test@example.com", + CONF_PASSWORD: "test-password", + CONF_OEM: 4, + }, + unique_id="4:2123456789123456789", + ) + + with patch("homeassistant.components.hypontech._PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + device_registry = dr.async_get(hass) + overview_device = device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.unique_id)} + ) + assert overview_device + assert overview_device.manufacturer == "Nexen" + + plant = mock_hyponcloud.get_list.return_value[0] + plant_device = device_registry.async_get_device( + identifiers={(DOMAIN, plant.plant_id)} + ) + assert plant_device + assert plant_device.manufacturer == "Nexen"