diff --git a/.strict-typing b/.strict-typing index cacab1a415115d..e950da8d25d0b3 100644 --- a/.strict-typing +++ b/.strict-typing @@ -326,6 +326,7 @@ homeassistant.components.london_underground.* homeassistant.components.lookin.* homeassistant.components.lovelace.* homeassistant.components.luftdaten.* +homeassistant.components.lunatone.* homeassistant.components.madvr.* homeassistant.components.manual.* homeassistant.components.mastodon.* diff --git a/CODEOWNERS b/CODEOWNERS index 5b1c185bbf75ea..ccd8cbadb6bdf1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -910,6 +910,8 @@ build.json @home-assistant/supervisor /homeassistant/components/luci/ @mzdrale /homeassistant/components/luftdaten/ @fabaff @frenck /tests/components/luftdaten/ @fabaff @frenck +/homeassistant/components/lunatone/ @MoonDevLT +/tests/components/lunatone/ @MoonDevLT /homeassistant/components/lupusec/ @majuss @suaveolent /tests/components/lupusec/ @majuss @suaveolent /homeassistant/components/lutron/ @cdheiser @wilburCForce diff --git a/build.yaml b/build.yaml index 0499e2bfa2fd8b..60b6fa5ef3a10d 100644 --- a/build.yaml +++ b/build.yaml @@ -1,10 +1,10 @@ image: ghcr.io/home-assistant/{arch}-homeassistant build_from: - aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.09.3 - armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.09.3 - armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.09.3 - amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.09.3 - i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.09.3 + aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.10.0 + armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.10.0 + armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.10.0 + amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.10.0 + i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.10.0 codenotary: signer: notary@home-assistant.io base_image: notary@home-assistant.io diff --git a/homeassistant/components/airtouch4/__init__.py b/homeassistant/components/airtouch4/__init__.py index 1a4c87a940c8d9..b7a96ddc77e346 100644 --- a/homeassistant/components/airtouch4/__init__.py +++ b/homeassistant/components/airtouch4/__init__.py @@ -2,17 +2,14 @@ from airtouch4pyapi import AirTouch -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .coordinator import AirtouchDataUpdateCoordinator +from .coordinator import AirTouch4ConfigEntry, AirtouchDataUpdateCoordinator PLATFORMS = [Platform.CLIMATE] -type AirTouch4ConfigEntry = ConfigEntry[AirtouchDataUpdateCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: AirTouch4ConfigEntry) -> bool: """Set up AirTouch4 from a config entry.""" @@ -22,7 +19,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirTouch4ConfigEntry) -> info = airtouch.GetAcs() if not info: raise ConfigEntryNotReady - coordinator = AirtouchDataUpdateCoordinator(hass, airtouch) + coordinator = AirtouchDataUpdateCoordinator(hass, entry, airtouch) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator diff --git a/homeassistant/components/airtouch4/coordinator.py b/homeassistant/components/airtouch4/coordinator.py index 5a0805664165e7..e0feb205250934 100644 --- a/homeassistant/components/airtouch4/coordinator.py +++ b/homeassistant/components/airtouch4/coordinator.py @@ -2,26 +2,34 @@ import logging +from airtouch4pyapi import AirTouch from airtouch4pyapi.airtouch import AirTouchStatus from homeassistant.components.climate import SCAN_INTERVAL +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +type AirTouch4ConfigEntry = ConfigEntry[AirtouchDataUpdateCoordinator] + class AirtouchDataUpdateCoordinator(DataUpdateCoordinator): """Class to manage fetching Airtouch data.""" - def __init__(self, hass, airtouch): + def __init__( + self, hass: HomeAssistant, entry: AirTouch4ConfigEntry, airtouch: AirTouch + ) -> None: """Initialize global Airtouch data updater.""" self.airtouch = airtouch super().__init__( hass, _LOGGER, + config_entry=entry, name=DOMAIN, update_interval=SCAN_INTERVAL, ) diff --git a/homeassistant/components/analytics/analytics.py b/homeassistant/components/analytics/analytics.py index 6a2943ccd89739..e788fdf9714db2 100644 --- a/homeassistant/components/analytics/analytics.py +++ b/homeassistant/components/analytics/analytics.py @@ -629,7 +629,6 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: # noqa: C901 devices_info.append( { - "entities": [], "entry_type": device_entry.entry_type, "has_configuration_url": device_entry.configuration_url is not None, "hw_version": device_entry.hw_version, @@ -638,6 +637,7 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: # noqa: C901 "model_id": device_entry.model_id, "sw_version": device_entry.sw_version, "via_device": device_entry.via_device_id, + "entities": [], } ) diff --git a/homeassistant/components/firefly_iii/config_flow.py b/homeassistant/components/firefly_iii/config_flow.py index ceebaa914a914a..a2d068501799e0 100644 --- a/homeassistant/components/firefly_iii/config_flow.py +++ b/homeassistant/components/firefly_iii/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -84,6 +85,48 @@ async def async_step_user( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth when Firefly III API authentication fails.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reauth: ask for a new API key and validate.""" + errors: dict[str, str] = {} + reauth_entry = self._get_reauth_entry() + if user_input is not None: + try: + await _validate_input( + self.hass, + data={ + **reauth_entry.data, + CONF_API_KEY: user_input[CONF_API_KEY], + }, + ) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except FireflyClientTimeout: + errors["base"] = "timeout_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_update_reload_and_abort( + reauth_entry, + data_updates={CONF_API_KEY: user_input[CONF_API_KEY]}, + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}), + errors=errors, + ) + class CannotConnect(HomeAssistantError): """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/firefly_iii/coordinator.py b/homeassistant/components/firefly_iii/coordinator.py index 3b64b3197cdb81..2d4ff3aaa1cec7 100644 --- a/homeassistant/components/firefly_iii/coordinator.py +++ b/homeassistant/components/firefly_iii/coordinator.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -72,7 +72,7 @@ async def _async_setup(self) -> None: try: await self.firefly.get_about() except FireflyAuthenticationError as err: - raise ConfigEntryError( + raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="invalid_auth", translation_placeholders={"error": repr(err)}, @@ -109,7 +109,7 @@ async def _async_update_data(self) -> FireflyCoordinatorData: budgets = await self.firefly.get_budgets() bills = await self.firefly.get_bills() except FireflyAuthenticationError as err: - raise UpdateFailed( + raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="invalid_auth", translation_placeholders={"error": repr(err)}, diff --git a/homeassistant/components/firefly_iii/strings.json b/homeassistant/components/firefly_iii/strings.json index 14fc692b7baa98..4d5831d8d71681 100644 --- a/homeassistant/components/firefly_iii/strings.json +++ b/homeassistant/components/firefly_iii/strings.json @@ -13,6 +13,15 @@ "verify_ssl": "Verify the SSL certificate of the Firefly instance" }, "description": "You can create an API key in the Firefly UI. Go to **Options > Profile** and select the **OAuth** tab. Create a new personal access token and copy it (it will only display once)." + }, + "reauth_confirm": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "The new API access token for authenticating with Firefly III" + }, + "description": "The access token for your Firefly III instance is invalid and needs to be updated. Go to **Options > Profile** and select the **OAuth** tab. Create a new personal access token and copy it (it will only display once)." } }, "error": { @@ -22,7 +31,8 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "exceptions": { diff --git a/homeassistant/components/growatt_server/__init__.py b/homeassistant/components/growatt_server/__init__.py index 3927078878067a..6483e7a543c672 100644 --- a/homeassistant/components/growatt_server/__init__.py +++ b/homeassistant/components/growatt_server/__init__.py @@ -1,14 +1,18 @@ """The Growatt server PV inverter sensor integration.""" from collections.abc import Mapping +import logging import growattServer -from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError from .const import ( + AUTH_API_TOKEN, + AUTH_PASSWORD, + CONF_AUTH_TYPE, CONF_PLANT_ID, DEFAULT_PLANT_ID, DEFAULT_URL, @@ -19,36 +23,110 @@ from .coordinator import GrowattConfigEntry, GrowattCoordinator from .models import GrowattRuntimeData +_LOGGER = logging.getLogger(__name__) -def get_device_list( + +def get_device_list_classic( api: growattServer.GrowattApi, config: Mapping[str, str] ) -> tuple[list[dict[str, str]], str]: """Retrieve the device list for the selected plant.""" plant_id = config[CONF_PLANT_ID] # Log in to api and fetch first plant if no plant id is defined. - login_response = api.login(config[CONF_USERNAME], config[CONF_PASSWORD]) - if ( - not login_response["success"] - and login_response["msg"] == LOGIN_INVALID_AUTH_CODE - ): - raise ConfigEntryError("Username, Password or URL may be incorrect!") + try: + login_response = api.login(config[CONF_USERNAME], config[CONF_PASSWORD]) + # DEBUG: Log the actual response structure + except Exception as ex: + _LOGGER.error("DEBUG - Login response: %s", login_response) + raise ConfigEntryError( + f"Error communicating with Growatt API during login: {ex}" + ) from ex + + if not login_response.get("success"): + msg = login_response.get("msg", "Unknown error") + _LOGGER.debug("Growatt login failed: %s", msg) + if msg == LOGIN_INVALID_AUTH_CODE: + raise ConfigEntryAuthFailed("Username, Password or URL may be incorrect!") + raise ConfigEntryError(f"Growatt login failed: {msg}") + user_id = login_response["user"]["id"] + if plant_id == DEFAULT_PLANT_ID: - plant_info = api.plant_list(user_id) + try: + plant_info = api.plant_list(user_id) + except Exception as ex: + raise ConfigEntryError( + f"Error communicating with Growatt API during plant list: {ex}" + ) from ex + if not plant_info or "data" not in plant_info or not plant_info["data"]: + raise ConfigEntryError("No plants found for this account.") plant_id = plant_info["data"][0]["plantId"] # Get a list of devices for specified plant to add sensors for. - devices = api.device_list(plant_id) + try: + devices = api.device_list(plant_id) + except Exception as ex: + raise ConfigEntryError( + f"Error communicating with Growatt API during device list: {ex}" + ) from ex + return devices, plant_id +def get_device_list_v1( + api, config: Mapping[str, str] +) -> tuple[list[dict[str, str]], str]: + """Device list logic for Open API V1. + + Note: Plant selection (including auto-selection if only one plant exists) + is handled in the config flow before this function is called. This function + only fetches devices for the already-selected plant_id. + """ + plant_id = config[CONF_PLANT_ID] + try: + devices_dict = api.device_list(plant_id) + except growattServer.GrowattV1ApiError as e: + raise ConfigEntryError( + f"API error during device list: {e} (Code: {getattr(e, 'error_code', None)}, Message: {getattr(e, 'error_msg', None)})" + ) from e + devices = devices_dict.get("devices", []) + # Only MIN device (type = 7) support implemented in current V1 API + supported_devices = [ + { + "deviceSn": device.get("device_sn", ""), + "deviceType": "min", + } + for device in devices + if device.get("type") == 7 + ] + + for device in devices: + if device.get("type") != 7: + _LOGGER.warning( + "Device %s with type %s not supported in Open API V1, skipping", + device.get("device_sn", ""), + device.get("type"), + ) + return supported_devices, plant_id + + +def get_device_list( + api, config: Mapping[str, str], api_version: str +) -> tuple[list[dict[str, str]], str]: + """Dispatch to correct device list logic based on API version.""" + if api_version == "v1": + return get_device_list_v1(api, config) + if api_version == "classic": + return get_device_list_classic(api, config) + raise ConfigEntryError(f"Unknown API version: {api_version}") + + async def async_setup_entry( hass: HomeAssistant, config_entry: GrowattConfigEntry ) -> bool: """Set up Growatt from a config entry.""" + config = config_entry.data - username = config[CONF_USERNAME] url = config.get(CONF_URL, DEFAULT_URL) # If the URL has been deprecated then change to the default instead @@ -58,11 +136,24 @@ async def async_setup_entry( new_data[CONF_URL] = url hass.config_entries.async_update_entry(config_entry, data=new_data) - # Initialise the library with the username & a random id each time it is started - api = growattServer.GrowattApi(add_random_user_id=True, agent_identifier=username) - api.server_url = url + # Determine API version + if config.get(CONF_AUTH_TYPE) == AUTH_API_TOKEN: + api_version = "v1" + token = config[CONF_TOKEN] + api = growattServer.OpenApiV1(token=token) + elif config.get(CONF_AUTH_TYPE) == AUTH_PASSWORD: + api_version = "classic" + username = config[CONF_USERNAME] + api = growattServer.GrowattApi( + add_random_user_id=True, agent_identifier=username + ) + api.server_url = url + else: + raise ConfigEntryError("Unknown authentication type in config entry.") - devices, plant_id = await hass.async_add_executor_job(get_device_list, api, config) + devices, plant_id = await hass.async_add_executor_job( + get_device_list, api, config, api_version + ) # Create a coordinator for the total sensors total_coordinator = GrowattCoordinator( @@ -75,7 +166,7 @@ async def async_setup_entry( hass, config_entry, device["deviceSn"], device["deviceType"], plant_id ) for device in devices - if device["deviceType"] in ["inverter", "tlx", "storage", "mix"] + if device["deviceType"] in ["inverter", "tlx", "storage", "mix", "min"] } # Perform the first refresh for the total coordinator diff --git a/homeassistant/components/growatt_server/config_flow.py b/homeassistant/components/growatt_server/config_flow.py index e676d8fae3268b..4bd61beb68ea1b 100644 --- a/homeassistant/components/growatt_server/config_flow.py +++ b/homeassistant/components/growatt_server/config_flow.py @@ -1,22 +1,38 @@ """Config flow for growatt server integration.""" +import logging from typing import Any import growattServer +import requests import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAME +from homeassistant.const import ( + CONF_NAME, + CONF_PASSWORD, + CONF_TOKEN, + CONF_URL, + CONF_USERNAME, +) from homeassistant.core import callback from .const import ( + ABORT_NO_PLANTS, + AUTH_API_TOKEN, + AUTH_PASSWORD, + CONF_AUTH_TYPE, CONF_PLANT_ID, DEFAULT_URL, DOMAIN, + ERROR_CANNOT_CONNECT, + ERROR_INVALID_AUTH, LOGIN_INVALID_AUTH_CODE, SERVER_URLS, ) +_LOGGER = logging.getLogger(__name__) + class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN): """Config flow class.""" @@ -27,73 +43,188 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialise growatt server flow.""" - self.user_id = None + self.user_id: str | None = None self.data: dict[str, Any] = {} - - @callback - def _async_show_user_form(self, errors=None): - """Show the form to the user.""" - data_schema = vol.Schema( - { - vol.Required(CONF_USERNAME): str, - vol.Required(CONF_PASSWORD): str, - vol.Required(CONF_URL, default=DEFAULT_URL): vol.In(SERVER_URLS), - } - ) - - return self.async_show_form( - step_id="user", data_schema=data_schema, errors=errors - ) + self.auth_type: str | None = None + self.plants: list[dict[str, Any]] = [] async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the start of the config flow.""" - if not user_input: - return self._async_show_user_form() + return self.async_show_menu( + step_id="user", + menu_options=["password_auth", "token_auth"], + ) - # Initialise the library with the username & a random id each time it is started + async def async_step_password_auth( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle username/password authentication.""" + if user_input is None: + return self._async_show_password_form() + + self.auth_type = AUTH_PASSWORD + + # Traditional username/password authentication self.api = growattServer.GrowattApi( add_random_user_id=True, agent_identifier=user_input[CONF_USERNAME] ) self.api.server_url = user_input[CONF_URL] - login_response = await self.hass.async_add_executor_job( - self.api.login, user_input[CONF_USERNAME], user_input[CONF_PASSWORD] - ) + + try: + login_response = await self.hass.async_add_executor_job( + self.api.login, user_input[CONF_USERNAME], user_input[CONF_PASSWORD] + ) + except requests.exceptions.RequestException as ex: + _LOGGER.error("Network error during Growatt API login: %s", ex) + return self._async_show_password_form({"base": ERROR_CANNOT_CONNECT}) + except (ValueError, KeyError, TypeError, AttributeError) as ex: + _LOGGER.error("Invalid response format during login: %s", ex) + return self._async_show_password_form({"base": ERROR_CANNOT_CONNECT}) if ( not login_response["success"] and login_response["msg"] == LOGIN_INVALID_AUTH_CODE ): - return self._async_show_user_form({"base": "invalid_auth"}) + return self._async_show_password_form({"base": ERROR_INVALID_AUTH}) + self.user_id = login_response["user"]["id"] + self.data = user_input + self.data[CONF_AUTH_TYPE] = self.auth_type + return await self.async_step_plant() + async def async_step_token_auth( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle API token authentication.""" + if user_input is None: + return self._async_show_token_form() + + self.auth_type = AUTH_API_TOKEN + + # Using token authentication + token = user_input[CONF_TOKEN] + self.api = growattServer.OpenApiV1(token=token) + + # Verify token by fetching plant list + try: + plant_response = await self.hass.async_add_executor_job(self.api.plant_list) + self.plants = plant_response.get("plants", []) + except requests.exceptions.RequestException as ex: + _LOGGER.error("Network error during Growatt V1 API plant list: %s", ex) + return self._async_show_token_form({"base": ERROR_CANNOT_CONNECT}) + except growattServer.GrowattV1ApiError as e: + _LOGGER.error( + "Growatt V1 API error: %s (Code: %s)", + e.error_msg or str(e), + getattr(e, "error_code", None), + ) + return self._async_show_token_form({"base": ERROR_INVALID_AUTH}) + except (ValueError, KeyError, TypeError, AttributeError) as ex: + _LOGGER.error( + "Invalid response format during Growatt V1 API plant list: %s", ex + ) + return self._async_show_token_form({"base": ERROR_CANNOT_CONNECT}) self.data = user_input + self.data[CONF_AUTH_TYPE] = self.auth_type return await self.async_step_plant() + @callback + def _async_show_password_form( + self, errors: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Show the username/password form to the user.""" + data_schema = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_URL, default=DEFAULT_URL): vol.In(SERVER_URLS), + } + ) + + return self.async_show_form( + step_id="password_auth", data_schema=data_schema, errors=errors + ) + + @callback + def _async_show_token_form( + self, errors: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Show the API token form to the user.""" + data_schema = vol.Schema( + { + vol.Required(CONF_TOKEN): str, + } + ) + + return self.async_show_form( + step_id="token_auth", + data_schema=data_schema, + errors=errors, + ) + async def async_step_plant( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle adding a "plant" to Home Assistant.""" - plant_info = await self.hass.async_add_executor_job( - self.api.plant_list, self.user_id - ) + if self.auth_type == AUTH_API_TOKEN: + # Using V1 API with token + if not self.plants: + return self.async_abort(reason=ABORT_NO_PLANTS) + + # Create dictionary of plant_id -> name + plant_dict = { + str(plant["plant_id"]): plant.get("name", "Unknown Plant") + for plant in self.plants + } - if not plant_info["data"]: - return self.async_abort(reason="no_plants") + if user_input is None and len(plant_dict) > 1: + data_schema = vol.Schema( + {vol.Required(CONF_PLANT_ID): vol.In(plant_dict)} + ) + return self.async_show_form(step_id="plant", data_schema=data_schema) - plants = {plant["plantId"]: plant["plantName"] for plant in plant_info["data"]} + if user_input is None: + # Single plant => mark it as selected + user_input = {CONF_PLANT_ID: list(plant_dict.keys())[0]} - if user_input is None and len(plant_info["data"]) > 1: - data_schema = vol.Schema({vol.Required(CONF_PLANT_ID): vol.In(plants)}) + user_input[CONF_NAME] = plant_dict[user_input[CONF_PLANT_ID]] - return self.async_show_form(step_id="plant", data_schema=data_schema) + else: + # Traditional API + try: + plant_info = await self.hass.async_add_executor_job( + self.api.plant_list, self.user_id + ) + except requests.exceptions.RequestException as ex: + _LOGGER.error("Network error during Growatt API plant list: %s", ex) + return self.async_abort(reason=ERROR_CANNOT_CONNECT) - if user_input is None: - # single plant => mark it as selected - user_input = {CONF_PLANT_ID: plant_info["data"][0]["plantId"]} + # Access plant_info["data"] - validate response structure + if not isinstance(plant_info, dict) or "data" not in plant_info: + _LOGGER.error( + "Invalid response format during plant list: missing 'data' key" + ) + return self.async_abort(reason=ERROR_CANNOT_CONNECT) + + plant_data = plant_info["data"] + + if not plant_data: + return self.async_abort(reason=ABORT_NO_PLANTS) + + plants = {plant["plantId"]: plant["plantName"] for plant in plant_data} + + if user_input is None and len(plant_data) > 1: + data_schema = vol.Schema({vol.Required(CONF_PLANT_ID): vol.In(plants)}) + return self.async_show_form(step_id="plant", data_schema=data_schema) + + if user_input is None: + # single plant => mark it as selected + user_input = {CONF_PLANT_ID: plant_data[0]["plantId"]} + + user_input[CONF_NAME] = plants[user_input[CONF_PLANT_ID]] - user_input[CONF_NAME] = plants[user_input[CONF_PLANT_ID]] await self.async_set_unique_id(user_input[CONF_PLANT_ID]) self._abort_if_unique_id_configured() self.data.update(user_input) diff --git a/homeassistant/components/growatt_server/const.py b/homeassistant/components/growatt_server/const.py index 4ad62aa812b02e..8689421b2ce311 100644 --- a/homeassistant/components/growatt_server/const.py +++ b/homeassistant/components/growatt_server/const.py @@ -4,6 +4,16 @@ CONF_PLANT_ID = "plant_id" + +# API key support +CONF_API_KEY = "api_key" + +# Auth types for config flow +AUTH_PASSWORD = "password" +AUTH_API_TOKEN = "api_token" +CONF_AUTH_TYPE = "auth_type" +DEFAULT_AUTH_TYPE = AUTH_PASSWORD + DEFAULT_PLANT_ID = "0" DEFAULT_NAME = "Growatt" @@ -29,3 +39,10 @@ PLATFORMS = [Platform.SENSOR] LOGIN_INVALID_AUTH_CODE = "502" + +# Config flow error types (also used as abort reasons) +ERROR_CANNOT_CONNECT = "cannot_connect" # Used for both form errors and aborts +ERROR_INVALID_AUTH = "invalid_auth" + +# Config flow abort reasons +ABORT_NO_PLANTS = "no_plants" diff --git a/homeassistant/components/growatt_server/coordinator.py b/homeassistant/components/growatt_server/coordinator.py index 931ae7e8bd54fa..2f00c542c13377 100644 --- a/homeassistant/components/growatt_server/coordinator.py +++ b/homeassistant/components/growatt_server/coordinator.py @@ -40,23 +40,31 @@ def __init__( plant_id: str, ) -> None: """Initialize the coordinator.""" - self.username = config_entry.data[CONF_USERNAME] - self.password = config_entry.data[CONF_PASSWORD] - self.url = config_entry.data.get(CONF_URL, DEFAULT_URL) - self.api = growattServer.GrowattApi( - add_random_user_id=True, agent_identifier=self.username + self.api_version = ( + "v1" if config_entry.data.get("auth_type") == "api_token" else "classic" ) - - # Set server URL - self.api.server_url = self.url - self.device_id = device_id self.device_type = device_type self.plant_id = plant_id - - # Initialize previous_values to store historical data self.previous_values: dict[str, Any] = {} + if self.api_version == "v1": + self.username = None + self.password = None + self.url = config_entry.data.get(CONF_URL, DEFAULT_URL) + self.token = config_entry.data["token"] + self.api = growattServer.OpenApiV1(token=self.token) + elif self.api_version == "classic": + self.username = config_entry.data.get(CONF_USERNAME) + self.password = config_entry.data[CONF_PASSWORD] + self.url = config_entry.data.get(CONF_URL, DEFAULT_URL) + self.api = growattServer.GrowattApi( + add_random_user_id=True, agent_identifier=self.username + ) + self.api.server_url = self.url + else: + raise ValueError(f"Unknown API version: {self.api_version}") + super().__init__( hass, _LOGGER, @@ -69,21 +77,54 @@ def _sync_update_data(self) -> dict[str, Any]: """Update data via library synchronously.""" _LOGGER.debug("Updating data for %s (%s)", self.device_id, self.device_type) - # Login in to the Growatt server - self.api.login(self.username, self.password) + # login only required for classic API + if self.api_version == "classic": + self.api.login(self.username, self.password) if self.device_type == "total": - total_info = self.api.plant_info(self.device_id) - del total_info["deviceList"] - plant_money_text, currency = total_info["plantMoneyText"].split("/") - total_info["plantMoneyText"] = plant_money_text - total_info["currency"] = currency + if self.api_version == "v1": + # The V1 Plant APIs do not provide the same information as the classic plant_info() API + # More specifically: + # 1. There is no monetary information to be found, so today and lifetime money is not available + # 2. There is no nominal power, this is provided by inverter min_energy() + # This means, for the total coordinator we can only fetch and map the following: + # todayEnergy -> today_energy + # totalEnergy -> total_energy + # invTodayPpv -> current_power + total_info = self.api.plant_energy_overview(self.plant_id) + total_info["todayEnergy"] = total_info["today_energy"] + total_info["totalEnergy"] = total_info["total_energy"] + total_info["invTodayPpv"] = total_info["current_power"] + else: + # Classic API: use plant_info as before + total_info = self.api.plant_info(self.device_id) + del total_info["deviceList"] + plant_money_text, currency = total_info["plantMoneyText"].split("/") + total_info["plantMoneyText"] = plant_money_text + total_info["currency"] = currency + _LOGGER.debug("Total info for plant %s: %r", self.plant_id, total_info) self.data = total_info elif self.device_type == "inverter": self.data = self.api.inverter_detail(self.device_id) + elif self.device_type == "min": + # Open API V1: min device + try: + min_details = self.api.min_detail(self.device_id) + min_settings = self.api.min_settings(self.device_id) + min_energy = self.api.min_energy(self.device_id) + except growattServer.GrowattV1ApiError as err: + _LOGGER.error( + "Error fetching min device data for %s: %s", self.device_id, err + ) + raise UpdateFailed(f"Error fetching min device data: {err}") from err + + min_info = {**min_details, **min_settings, **min_energy} + self.data = min_info + _LOGGER.debug("min_info for device %s: %r", self.device_id, min_info) elif self.device_type == "tlx": tlx_info = self.api.tlx_detail(self.device_id) self.data = tlx_info["data"] + _LOGGER.debug("tlx_info for device %s: %r", self.device_id, tlx_info) elif self.device_type == "storage": storage_info_detail = self.api.storage_params(self.device_id) storage_energy_overview = self.api.storage_energy_overview( diff --git a/homeassistant/components/growatt_server/sensor/__init__.py b/homeassistant/components/growatt_server/sensor/__init__.py index 3a78f26f09179d..d4e76c8d868937 100644 --- a/homeassistant/components/growatt_server/sensor/__init__.py +++ b/homeassistant/components/growatt_server/sensor/__init__.py @@ -51,7 +51,7 @@ async def async_setup_entry( sensor_descriptions: list = [] if device_coordinator.device_type == "inverter": sensor_descriptions = list(INVERTER_SENSOR_TYPES) - elif device_coordinator.device_type == "tlx": + elif device_coordinator.device_type in ("tlx", "min"): sensor_descriptions = list(TLX_SENSOR_TYPES) elif device_coordinator.device_type == "storage": sensor_descriptions = list(STORAGE_SENSOR_TYPES) diff --git a/homeassistant/components/growatt_server/strings.json b/homeassistant/components/growatt_server/strings.json index 50b146dacd61b2..fdede7fe115ede 100644 --- a/homeassistant/components/growatt_server/strings.json +++ b/homeassistant/components/growatt_server/strings.json @@ -2,26 +2,42 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "no_plants": "No plants have been found on this account" }, "error": { - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + "invalid_auth": "Authentication failed. Please check your credentials and try again.", + "cannot_connect": "Cannot connect to Growatt servers. Please check your internet connection and try again." }, "step": { - "plant": { - "data": { - "plant_id": "Plant" - }, - "title": "Select your plant" - }, "user": { + "title": "Choose authentication method", + "description": "Note: API Token authentication is currently only supported for MIN/TLX devices. For other device types, please use Username & Password authentication.", + "menu_options": { + "password_auth": "Username & Password", + "token_auth": "API Token (MIN/TLX only)" + } + }, + "password_auth": { + "title": "Enter your Growatt login credentials", "data": { - "name": "[%key:common::config_flow::data::name%]", - "password": "[%key:common::config_flow::data::password%]", "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", "url": "[%key:common::config_flow::data::url%]" + } + }, + "token_auth": { + "title": "Enter your API token", + "description": "Token authentication is only supported for MIN/TLX devices. For other device types, please use username/password authentication.", + "data": { + "token": "API Token" + } + }, + "plant": { + "data": { + "plant_id": "Plant" }, - "title": "Enter your Growatt information" + "title": "Select your plant" } } }, diff --git a/homeassistant/components/lunatone/__init__.py b/homeassistant/components/lunatone/__init__.py new file mode 100644 index 00000000000000..d507f91a4f3ab5 --- /dev/null +++ b/homeassistant/components/lunatone/__init__.py @@ -0,0 +1,64 @@ +"""The Lunatone integration.""" + +from typing import Final + +from lunatone_rest_api_client import Auth, Devices, Info + +from homeassistant.const import CONF_URL, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN +from .coordinator import ( + LunatoneConfigEntry, + LunatoneData, + LunatoneDevicesDataUpdateCoordinator, + LunatoneInfoDataUpdateCoordinator, +) + +PLATFORMS: Final[list[Platform]] = [Platform.LIGHT] + + +async def async_setup_entry(hass: HomeAssistant, entry: LunatoneConfigEntry) -> bool: + """Set up Lunatone from a config entry.""" + auth_api = Auth(async_get_clientsession(hass), entry.data[CONF_URL]) + info_api = Info(auth_api) + devices_api = Devices(auth_api) + + coordinator_info = LunatoneInfoDataUpdateCoordinator(hass, entry, info_api) + await coordinator_info.async_config_entry_first_refresh() + + if info_api.serial_number is None: + raise ConfigEntryError( + translation_domain=DOMAIN, translation_key="missing_device_info" + ) + + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, str(info_api.serial_number))}, + name=info_api.name, + manufacturer="Lunatone", + sw_version=info_api.version, + hw_version=info_api.data.device.pcb, + configuration_url=entry.data[CONF_URL], + serial_number=str(info_api.serial_number), + model_id=( + f"{info_api.data.device.article_number}{info_api.data.device.article_info}" + ), + ) + + coordinator_devices = LunatoneDevicesDataUpdateCoordinator(hass, entry, devices_api) + await coordinator_devices.async_config_entry_first_refresh() + + entry.runtime_data = LunatoneData(coordinator_info, coordinator_devices) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: LunatoneConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/lunatone/config_flow.py b/homeassistant/components/lunatone/config_flow.py new file mode 100644 index 00000000000000..4dc5d8c03ecf69 --- /dev/null +++ b/homeassistant/components/lunatone/config_flow.py @@ -0,0 +1,83 @@ +"""Config flow for Lunatone.""" + +from typing import Any, Final + +import aiohttp +from lunatone_rest_api_client import Auth, Info +import voluptuous as vol + +from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, + ConfigFlow, + ConfigFlowResult, +) +from homeassistant.const import CONF_URL +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +DATA_SCHEMA: Final[vol.Schema] = vol.Schema( + {vol.Required(CONF_URL, default="http://"): cv.string}, +) + + +def compose_title(name: str | None, serial_number: int) -> str: + """Compose a title string from a given name and serial number.""" + return f"{name or 'DALI Gateway'} {serial_number}" + + +class LunatoneConfigFlow(ConfigFlow, domain=DOMAIN): + """Lunatone config flow.""" + + VERSION = 1 + MINOR_VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initialized by the user.""" + errors: dict[str, str] = {} + if user_input is not None: + url = user_input[CONF_URL] + data = {CONF_URL: url} + self._async_abort_entries_match(data) + auth_api = Auth( + session=async_get_clientsession(self.hass), + base_url=url, + ) + info_api = Info(auth_api) + try: + await info_api.async_update() + except aiohttp.InvalidUrlClientError: + errors["base"] = "invalid_url" + except aiohttp.ClientConnectionError: + errors["base"] = "cannot_connect" + else: + if info_api.data is None or info_api.serial_number is None: + errors["base"] = "missing_device_info" + else: + await self.async_set_unique_id(str(info_api.serial_number)) + if self.source == SOURCE_RECONFIGURE: + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data_updates=data, + title=compose_title(info_api.name, info_api.serial_number), + ) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=compose_title(info_api.name, info_api.serial_number), + data={CONF_URL: url}, + ) + return self.async_show_form( + step_id="user", + data_schema=DATA_SCHEMA, + errors=errors, + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a reconfiguration flow initialized by the user.""" + return await self.async_step_user(user_input) diff --git a/homeassistant/components/lunatone/const.py b/homeassistant/components/lunatone/const.py new file mode 100644 index 00000000000000..ad7eb57affae67 --- /dev/null +++ b/homeassistant/components/lunatone/const.py @@ -0,0 +1,5 @@ +"""Constants for the Lunatone integration.""" + +from typing import Final + +DOMAIN: Final = "lunatone" diff --git a/homeassistant/components/lunatone/coordinator.py b/homeassistant/components/lunatone/coordinator.py new file mode 100644 index 00000000000000..f9f15ed4629b40 --- /dev/null +++ b/homeassistant/components/lunatone/coordinator.py @@ -0,0 +1,101 @@ +"""Coordinator for handling data fetching and updates.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +import logging + +import aiohttp +from lunatone_rest_api_client import Device, Devices, Info +from lunatone_rest_api_client.models import InfoData + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_DEVICES_SCAN_INTERVAL = timedelta(seconds=10) + + +@dataclass +class LunatoneData: + """Data for Lunatone integration.""" + + coordinator_info: LunatoneInfoDataUpdateCoordinator + coordinator_devices: LunatoneDevicesDataUpdateCoordinator + + +type LunatoneConfigEntry = ConfigEntry[LunatoneData] + + +class LunatoneInfoDataUpdateCoordinator(DataUpdateCoordinator[InfoData]): + """Data update coordinator for Lunatone info.""" + + config_entry: LunatoneConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: LunatoneConfigEntry, info_api: Info + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=f"{DOMAIN}-info", + always_update=False, + ) + self.info_api = info_api + + async def _async_update_data(self) -> InfoData: + """Update info data.""" + try: + await self.info_api.async_update() + except aiohttp.ClientConnectionError as ex: + raise UpdateFailed( + "Unable to retrieve info data from Lunatone REST API" + ) from ex + + if self.info_api.data is None: + raise UpdateFailed("Did not receive info data from Lunatone REST API") + return self.info_api.data + + +class LunatoneDevicesDataUpdateCoordinator(DataUpdateCoordinator[dict[int, Device]]): + """Data update coordinator for Lunatone devices.""" + + config_entry: LunatoneConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: LunatoneConfigEntry, + devices_api: Devices, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=f"{DOMAIN}-devices", + always_update=False, + update_interval=DEFAULT_DEVICES_SCAN_INTERVAL, + ) + self.devices_api = devices_api + + async def _async_update_data(self) -> dict[int, Device]: + """Update devices data.""" + try: + await self.devices_api.async_update() + except aiohttp.ClientConnectionError as ex: + raise UpdateFailed( + "Unable to retrieve devices data from Lunatone REST API" + ) from ex + + if self.devices_api.data is None: + raise UpdateFailed("Did not receive devices data from Lunatone REST API") + + return {device.id: device for device in self.devices_api.devices} diff --git a/homeassistant/components/lunatone/light.py b/homeassistant/components/lunatone/light.py new file mode 100644 index 00000000000000..416412aea6e1d0 --- /dev/null +++ b/homeassistant/components/lunatone/light.py @@ -0,0 +1,103 @@ +"""Platform for Lunatone light integration.""" + +from __future__ import annotations + +import asyncio +from typing import Any + +from homeassistant.components.light import ColorMode, LightEntity +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import LunatoneConfigEntry, LunatoneDevicesDataUpdateCoordinator + +PARALLEL_UPDATES = 0 +STATUS_UPDATE_DELAY = 0.04 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: LunatoneConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Lunatone Light platform.""" + coordinator_info = config_entry.runtime_data.coordinator_info + coordinator_devices = config_entry.runtime_data.coordinator_devices + + async_add_entities( + [ + LunatoneLight( + coordinator_devices, device_id, coordinator_info.data.device.serial + ) + for device_id in coordinator_devices.data + ] + ) + + +class LunatoneLight( + CoordinatorEntity[LunatoneDevicesDataUpdateCoordinator], LightEntity +): + """Representation of a Lunatone light.""" + + _attr_color_mode = ColorMode.ONOFF + _attr_supported_color_modes = {ColorMode.ONOFF} + _attr_has_entity_name = True + _attr_name = None + _attr_should_poll = False + + def __init__( + self, + coordinator: LunatoneDevicesDataUpdateCoordinator, + device_id: int, + interface_serial_number: int, + ) -> None: + """Initialize a LunatoneLight.""" + super().__init__(coordinator=coordinator) + self._device_id = device_id + self._interface_serial_number = interface_serial_number + self._device = self.coordinator.data.get(self._device_id) + self._attr_unique_id = f"{interface_serial_number}-device{device_id}" + + @property + def device_info(self) -> DeviceInfo: + """Return the device info.""" + assert self.unique_id + name = self._device.name if self._device is not None else None + return DeviceInfo( + identifiers={(DOMAIN, self.unique_id)}, + name=name, + via_device=(DOMAIN, str(self._interface_serial_number)), + ) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self._device is not None + + @property + def is_on(self) -> bool: + """Return True if light is on.""" + return self._device is not None and self._device.is_on + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._device = self.coordinator.data.get(self._device_id) + self.async_write_ha_state() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Instruct the light to turn on.""" + assert self._device + await self._device.switch_on() + await asyncio.sleep(STATUS_UPDATE_DELAY) + await self.coordinator.async_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Instruct the light to turn off.""" + assert self._device + await self._device.switch_off() + await asyncio.sleep(STATUS_UPDATE_DELAY) + await self.coordinator.async_refresh() diff --git a/homeassistant/components/lunatone/manifest.json b/homeassistant/components/lunatone/manifest.json new file mode 100644 index 00000000000000..8db658869d545f --- /dev/null +++ b/homeassistant/components/lunatone/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "lunatone", + "name": "Lunatone", + "codeowners": ["@MoonDevLT"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/lunatone", + "integration_type": "hub", + "iot_class": "local_polling", + "quality_scale": "silver", + "requirements": ["lunatone-rest-api-client==0.4.8"] +} diff --git a/homeassistant/components/lunatone/quality_scale.yaml b/homeassistant/components/lunatone/quality_scale.yaml new file mode 100644 index 00000000000000..c118c210d53390 --- /dev/null +++ b/homeassistant/components/lunatone/quality_scale.yaml @@ -0,0 +1,82 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: done + brands: done + common-modules: + status: exempt + comment: | + This integration has only one platform which uses a coordinator. + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + Entities of this integration does not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: no actions + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No options to configure + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: | + This integration does not require authentication. + test-coverage: done + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: todo + comment: Discovery not yet supported + discovery: + status: todo + comment: Discovery not yet supported + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: done + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/lunatone/strings.json b/homeassistant/components/lunatone/strings.json new file mode 100644 index 00000000000000..71f4b23b058cd8 --- /dev/null +++ b/homeassistant/components/lunatone/strings.json @@ -0,0 +1,36 @@ +{ + "config": { + "step": { + "confirm": { + "description": "[%key:common::config_flow::description::confirm_setup%]" + }, + "user": { + "description": "Connect to the API of your Lunatone DALI IoT Gateway.", + "data": { + "url": "[%key:common::config_flow::data::url%]" + }, + "data_description": { + "url": "The URL of the Lunatone gateway device." + } + }, + "reconfigure": { + "description": "Update the URL.", + "data": { + "url": "[%key:common::config_flow::data::url%]" + }, + "data_description": { + "url": "[%key:component::lunatone::config::step::user::data_description::url%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_url": "Failed to connect. Check the URL and if the device is connected to power", + "missing_device_info": "Failed to read device information. Check the network connection of the device" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + } + } +} diff --git a/homeassistant/components/pushover/const.py b/homeassistant/components/pushover/const.py index d890cf014b9418..eccd3e9e182a41 100644 --- a/homeassistant/components/pushover/const.py +++ b/homeassistant/components/pushover/const.py @@ -16,7 +16,6 @@ ATTR_CALLBACK_URL: Final = "callback_url" ATTR_EXPIRE: Final = "expire" ATTR_TTL: Final = "ttl" -ATTR_DATA: Final = "data" ATTR_TIMESTAMP: Final = "timestamp" CONF_USER_KEY: Final = "user_key" diff --git a/homeassistant/components/pushover/notify.py b/homeassistant/components/pushover/notify.py index af27fa26639c79..62c14b4dae8bab 100644 --- a/homeassistant/components/pushover/notify.py +++ b/homeassistant/components/pushover/notify.py @@ -67,7 +67,7 @@ def send_message(self, message: str = "", **kwargs: Any) -> None: # Extract params from data dict title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) - data = kwargs.get(ATTR_DATA, {}) + data = kwargs.get(ATTR_DATA) or {} url = data.get(ATTR_URL) url_title = data.get(ATTR_URL_TITLE) priority = data.get(ATTR_PRIORITY) diff --git a/homeassistant/components/satel_integra/manifest.json b/homeassistant/components/satel_integra/manifest.json index 71691b67981c92..0e5e9edbb2c47f 100644 --- a/homeassistant/components/satel_integra/manifest.json +++ b/homeassistant/components/satel_integra/manifest.json @@ -6,7 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/satel_integra", "iot_class": "local_push", "loggers": ["satel_integra"], - "quality_scale": "legacy", "requirements": ["satel-integra==0.3.7"], "single_config_entry": true } diff --git a/homeassistant/components/satel_integra/quality_scale.yaml b/homeassistant/components/satel_integra/quality_scale.yaml new file mode 100644 index 00000000000000..dc1c269dea26fc --- /dev/null +++ b/homeassistant/components/satel_integra/quality_scale.yaml @@ -0,0 +1,66 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: This integration does not provide any service actions. + appropriate-polling: + status: exempt + comment: This integration does not poll. + brands: done + common-modules: todo + config-flow-test-coverage: todo + config-flow: todo + dependency-transparency: todo + docs-actions: + status: exempt + comment: This integration does not provide any service actions. + docs-high-level-description: todo + docs-installation-instructions: todo + docs-removal-instructions: todo + entity-event-setup: todo + entity-unique-id: todo + has-entity-name: todo + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: todo + + # Silver + action-exceptions: todo + config-entry-unloading: todo + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: todo + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: todo + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/tuya/alarm_control_panel.py b/homeassistant/components/tuya/alarm_control_panel.py index 43105af0362ca4..c35c1f8c3de15c 100644 --- a/homeassistant/components/tuya/alarm_control_panel.py +++ b/homeassistant/components/tuya/alarm_control_panel.py @@ -145,9 +145,11 @@ def alarm_state(self) -> AlarmControlPanelState | None: """Return the state of the device.""" # When the alarm is triggered, only its 'state' is changing. From 'normal' to 'alarm'. # The 'mode' doesn't change, and stays as 'arm' or 'home'. - if self._master_state is not None: - if self.device.status.get(self._master_state.dpcode) == State.ALARM: - return AlarmControlPanelState.TRIGGERED + if ( + self._master_state is not None + and self.device.status.get(self._master_state.dpcode) == State.ALARM + ): + return AlarmControlPanelState.TRIGGERED if not (status := self.device.status.get(self.entity_description.key)): return None @@ -156,11 +158,13 @@ def alarm_state(self) -> AlarmControlPanelState | None: @property def changed_by(self) -> str | None: """Last change triggered by.""" - if self._master_state is not None and self._alarm_msg_dpcode is not None: - if self.device.status.get(self._master_state.dpcode) == State.ALARM: - encoded_msg = self.device.status.get(self._alarm_msg_dpcode) - if encoded_msg: - return b64decode(encoded_msg).decode("utf-16be") + if ( + self._master_state is not None + and self._alarm_msg_dpcode is not None + and self.device.status.get(self._master_state.dpcode) == State.ALARM + and (encoded_msg := self.device.status.get(self._alarm_msg_dpcode)) + ): + return b64decode(encoded_msg).decode("utf-16be") return None def alarm_disarm(self, code: str | None = None) -> None: diff --git a/homeassistant/components/uptimerobot/binary_sensor.py b/homeassistant/components/uptimerobot/binary_sensor.py index 0a0f973c6e008b..0fed98ed4a6f1b 100644 --- a/homeassistant/components/uptimerobot/binary_sensor.py +++ b/homeassistant/components/uptimerobot/binary_sensor.py @@ -2,6 +2,8 @@ from __future__ import annotations +from pyuptimerobot import UptimeRobotMonitor + from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, @@ -12,6 +14,7 @@ from .coordinator import UptimeRobotConfigEntry from .entity import UptimeRobotEntity +from .utils import new_device_listener # Coordinator is used to centralize the data updates PARALLEL_UPDATES = 0 @@ -25,29 +28,23 @@ async def async_setup_entry( """Set up the UptimeRobot binary_sensors.""" coordinator = entry.runtime_data - known_devices: set[int] = set() - - def _check_device() -> None: - entities: list[UptimeRobotBinarySensor] = [] - for monitor in coordinator.data: - if monitor.id in known_devices: - continue - known_devices.add(monitor.id) - entities.append( - UptimeRobotBinarySensor( - coordinator, - BinarySensorEntityDescription( - key=str(monitor.id), - device_class=BinarySensorDeviceClass.CONNECTIVITY, - ), - monitor=monitor, - ) + def _add_new_entities(new_monitors: list[UptimeRobotMonitor]) -> None: + """Add entities for new monitors.""" + entities = [ + UptimeRobotBinarySensor( + coordinator, + BinarySensorEntityDescription( + key=str(monitor.id), + device_class=BinarySensorDeviceClass.CONNECTIVITY, + ), + monitor=monitor, ) + for monitor in new_monitors + ] if entities: async_add_entities(entities) - _check_device() - entry.async_on_unload(coordinator.async_add_listener(_check_device)) + entry.async_on_unload(new_device_listener(coordinator, _add_new_entities)) class UptimeRobotBinarySensor(UptimeRobotEntity, BinarySensorEntity): diff --git a/homeassistant/components/uptimerobot/sensor.py b/homeassistant/components/uptimerobot/sensor.py index 60866154ac0e33..633ac8243ff1ce 100644 --- a/homeassistant/components/uptimerobot/sensor.py +++ b/homeassistant/components/uptimerobot/sensor.py @@ -2,6 +2,8 @@ from __future__ import annotations +from pyuptimerobot import UptimeRobotMonitor + from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -13,6 +15,7 @@ from .coordinator import UptimeRobotConfigEntry from .entity import UptimeRobotEntity +from .utils import new_device_listener SENSORS_INFO = { 0: "pause", @@ -34,38 +37,32 @@ async def async_setup_entry( """Set up the UptimeRobot sensors.""" coordinator = entry.runtime_data - known_devices: set[int] = set() - - def _check_device() -> None: - entities: list[UptimeRobotSensor] = [] - for monitor in coordinator.data: - if monitor.id in known_devices: - continue - known_devices.add(monitor.id) - entities.append( - UptimeRobotSensor( - coordinator, - SensorEntityDescription( - key=str(monitor.id), - entity_category=EntityCategory.DIAGNOSTIC, - device_class=SensorDeviceClass.ENUM, - options=[ - "down", - "not_checked_yet", - "pause", - "seems_down", - "up", - ], - translation_key="monitor_status", - ), - monitor=monitor, - ) + def _add_new_entities(new_monitors: list[UptimeRobotMonitor]) -> None: + """Add entities for new monitors.""" + entities = [ + UptimeRobotSensor( + coordinator, + SensorEntityDescription( + key=str(monitor.id), + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.ENUM, + options=[ + "down", + "not_checked_yet", + "pause", + "seems_down", + "up", + ], + translation_key="monitor_status", + ), + monitor=monitor, ) + for monitor in new_monitors + ] if entities: async_add_entities(entities) - _check_device() - entry.async_on_unload(coordinator.async_add_listener(_check_device)) + entry.async_on_unload(new_device_listener(coordinator, _add_new_entities)) class UptimeRobotSensor(UptimeRobotEntity, SensorEntity): diff --git a/homeassistant/components/uptimerobot/switch.py b/homeassistant/components/uptimerobot/switch.py index 41a46e9ff5cf74..b75f099db73b14 100644 --- a/homeassistant/components/uptimerobot/switch.py +++ b/homeassistant/components/uptimerobot/switch.py @@ -4,7 +4,11 @@ from typing import Any -from pyuptimerobot import UptimeRobotAuthenticationException, UptimeRobotException +from pyuptimerobot import ( + UptimeRobotAuthenticationException, + UptimeRobotException, + UptimeRobotMonitor, +) from homeassistant.components.switch import ( SwitchDeviceClass, @@ -18,6 +22,7 @@ from .const import API_ATTR_OK, DOMAIN from .coordinator import UptimeRobotConfigEntry from .entity import UptimeRobotEntity +from .utils import new_device_listener # Limit the number of parallel updates to 1 PARALLEL_UPDATES = 1 @@ -31,29 +36,23 @@ async def async_setup_entry( """Set up the UptimeRobot switches.""" coordinator = entry.runtime_data - known_devices: set[int] = set() - - def _check_device() -> None: - entities: list[UptimeRobotSwitch] = [] - for monitor in coordinator.data: - if monitor.id in known_devices: - continue - known_devices.add(monitor.id) - entities.append( - UptimeRobotSwitch( - coordinator, - SwitchEntityDescription( - key=str(monitor.id), - device_class=SwitchDeviceClass.SWITCH, - ), - monitor=monitor, - ) + def _add_new_entities(new_monitors: list[UptimeRobotMonitor]) -> None: + """Add entities for new monitors.""" + entities = [ + UptimeRobotSwitch( + coordinator, + SwitchEntityDescription( + key=str(monitor.id), + device_class=SwitchDeviceClass.SWITCH, + ), + monitor=monitor, ) + for monitor in new_monitors + ] if entities: async_add_entities(entities) - _check_device() - entry.async_on_unload(coordinator.async_add_listener(_check_device)) + entry.async_on_unload(new_device_listener(coordinator, _add_new_entities)) class UptimeRobotSwitch(UptimeRobotEntity, SwitchEntity): diff --git a/homeassistant/components/uptimerobot/utils.py b/homeassistant/components/uptimerobot/utils.py new file mode 100644 index 00000000000000..522324cf6f38fa --- /dev/null +++ b/homeassistant/components/uptimerobot/utils.py @@ -0,0 +1,34 @@ +"""Utility functions for the UptimeRobot integration.""" + +from collections.abc import Callable + +from pyuptimerobot import UptimeRobotMonitor + +from .coordinator import UptimeRobotDataUpdateCoordinator + + +def new_device_listener( + coordinator: UptimeRobotDataUpdateCoordinator, + new_devices_callback: Callable[[list[UptimeRobotMonitor]], None], +) -> Callable[[], None]: + """Subscribe to coordinator updates to check for new devices.""" + known_devices: set[int] = set() + + def _check_devices() -> None: + """Check for new devices and call callback with any new monitors.""" + if not coordinator.data: + return + + new_monitors: list[UptimeRobotMonitor] = [] + for monitor in coordinator.data: + if monitor.id not in known_devices: + known_devices.add(monitor.id) + new_monitors.append(monitor) + + if new_monitors: + new_devices_callback(new_monitors) + + # Check for devices immediately + _check_devices() + + return coordinator.async_add_listener(_check_devices) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 1d2c6fc21a70d0..8c162a7f10f7a0 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -370,6 +370,7 @@ "lookin", "loqed", "luftdaten", + "lunatone", "lupusec", "lutron", "lutron_caseta", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 71c3ee23c81d99..08f08b24d59771 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3727,6 +3727,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "lunatone": { + "name": "Lunatone", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "lupusec": { "name": "Lupus Electronics LUPUSEC", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index c05ec7019b2a9b..1813576cf23fc5 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3016,6 +3016,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.lunatone.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.madvr.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 137a7e60198c2b..bd7d10b0b120a1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1399,6 +1399,9 @@ loqedAPI==2.1.10 # homeassistant.components.luftdaten luftdaten==0.7.4 +# homeassistant.components.lunatone +lunatone-rest-api-client==0.4.8 + # homeassistant.components.lupusec lupupy==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c845c3a3a8d745..41fee2f799b727 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1200,6 +1200,9 @@ loqedAPI==2.1.10 # homeassistant.components.luftdaten luftdaten==0.7.4 +# homeassistant.components.lunatone +lunatone-rest-api-client==0.4.8 + # homeassistant.components.lupusec lupupy==0.3.2 diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 5f24e00f938a0a..01cca31a90dfea 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -853,7 +853,6 @@ class Rule: "rympro", "saj", "sanix", - "satel_integra", "schlage", "schluter", "scrape", diff --git a/tests/components/analytics/test_analytics.py b/tests/components/analytics/test_analytics.py index feffc952a49d50..ec35cc56d51a07 100644 --- a/tests/components/analytics/test_analytics.py +++ b/tests/components/analytics/test_analytics.py @@ -1075,7 +1075,6 @@ async def test_devices_payload_no_entities( "hue": { "devices": [ { - "entities": [], "entry_type": None, "has_configuration_url": True, "hw_version": "test-hw-version", @@ -1084,9 +1083,9 @@ async def test_devices_payload_no_entities( "model_id": "test-model-id", "sw_version": "test-sw-version", "via_device": None, + "entities": [], }, { - "entities": [], "entry_type": None, "has_configuration_url": False, "hw_version": None, @@ -1095,9 +1094,9 @@ async def test_devices_payload_no_entities( "model_id": None, "sw_version": None, "via_device": None, + "entities": [], }, { - "entities": [], "entry_type": None, "has_configuration_url": False, "hw_version": None, @@ -1106,9 +1105,9 @@ async def test_devices_payload_no_entities( "model_id": "test-model-id", "sw_version": None, "via_device": None, + "entities": [], }, { - "entities": [], "entry_type": None, "has_configuration_url": False, "hw_version": None, @@ -1117,6 +1116,7 @@ async def test_devices_payload_no_entities( "model_id": "test-model-id6", "sw_version": None, "via_device": ["hue", 0], + "entities": [], }, ], "entities": [], @@ -1233,6 +1233,14 @@ async def test_devices_payload_with_entities( "hue": { "devices": [ { + "entry_type": None, + "has_configuration_url": False, + "hw_version": None, + "manufacturer": "test-manufacturer", + "model": None, + "model_id": "test-model-id", + "sw_version": None, + "via_device": None, "entities": [ { "assumed_state": None, @@ -1259,6 +1267,8 @@ async def test_devices_payload_with_entities( "unit_of_measurement": None, }, ], + }, + { "entry_type": None, "has_configuration_url": False, "hw_version": None, @@ -1267,8 +1277,6 @@ async def test_devices_payload_with_entities( "model_id": "test-model-id", "sw_version": None, "via_device": None, - }, - { "entities": [ { "assumed_state": None, @@ -1279,14 +1287,6 @@ async def test_devices_payload_with_entities( "unit_of_measurement": None, }, ], - "entry_type": None, - "has_configuration_url": False, - "hw_version": None, - "manufacturer": "test-manufacturer", - "model": None, - "model_id": "test-model-id", - "sw_version": None, - "via_device": None, }, ], "entities": [ @@ -1402,7 +1402,6 @@ async def async_modify_analytics( "test": { "devices": [ { - "entities": [], "entry_type": None, "has_configuration_url": False, "hw_version": None, @@ -1411,6 +1410,7 @@ async def async_modify_analytics( "model_id": "test-model-id", "sw_version": None, "via_device": None, + "entities": [], }, ], "entities": [ diff --git a/tests/components/firefly_iii/test_config_flow.py b/tests/components/firefly_iii/test_config_flow.py index 99474ddccc3011..afe0a95831eb84 100644 --- a/tests/components/firefly_iii/test_config_flow.py +++ b/tests/components/firefly_iii/test_config_flow.py @@ -132,3 +132,96 @@ async def test_duplicate_entry( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +async def test_full_flow_reauth( + hass: HomeAssistant, + mock_firefly_client: AsyncMock, + mock_setup_entry: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the full flow of the config flow.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + # There is no user input + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_KEY: "new_api_key"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_API_KEY] == "new_api_key" + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "reason"), + [ + ( + FireflyAuthenticationError, + "invalid_auth", + ), + ( + FireflyConnectionError, + "cannot_connect", + ), + ( + FireflyTimeoutError, + "timeout_connect", + ), + ( + Exception("Some other error"), + "unknown", + ), + ], +) +async def test_reauth_flow_exceptions( + hass: HomeAssistant, + mock_firefly_client: AsyncMock, + mock_setup_entry: MagicMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + reason: str, +) -> None: + """Test we handle all exceptions in the reauth flow.""" + mock_config_entry.add_to_hass(hass) + mock_firefly_client.get_about.side_effect = exception + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_KEY: "new_api_key"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": reason} + + # Now test that we can recover from the error + mock_firefly_client.get_about.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_KEY: "new_api_key"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_API_KEY] == "new_api_key" + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/firefly_iii/test_init.py b/tests/components/firefly_iii/test_init.py new file mode 100644 index 00000000000000..fa7ab788eb975c --- /dev/null +++ b/tests/components/firefly_iii/test_init.py @@ -0,0 +1,38 @@ +"""Tests for the Firefly III integration.""" + +from unittest.mock import AsyncMock + +from pyfirefly.exceptions import ( + FireflyAuthenticationError, + FireflyConnectionError, + FireflyTimeoutError, +) +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("exception", "expected_state"), + [ + (FireflyAuthenticationError("bad creds"), ConfigEntryState.SETUP_ERROR), + (FireflyConnectionError("cannot connect"), ConfigEntryState.SETUP_RETRY), + (FireflyTimeoutError("timeout"), ConfigEntryState.SETUP_RETRY), + ], +) +async def test_setup_exceptions( + hass: HomeAssistant, + mock_firefly_client: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + expected_state: ConfigEntryState, +) -> None: + """Test the _async_setup.""" + mock_firefly_client.get_about.side_effect = exception + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state == expected_state diff --git a/tests/components/firefly_iii/test_sensor.py b/tests/components/firefly_iii/test_sensor.py index 9a26db29d18f82..aa674c27910385 100644 --- a/tests/components/firefly_iii/test_sensor.py +++ b/tests/components/firefly_iii/test_sensor.py @@ -2,15 +2,25 @@ from unittest.mock import AsyncMock, patch +from freezegun.api import FrozenDateTimeFactory +from pyfirefly.exceptions import ( + FireflyAuthenticationError, + FireflyConnectionError, + FireflyTimeoutError, +) +import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.const import Platform +from homeassistant.components.firefly_iii.coordinator import DEFAULT_SCAN_INTERVAL +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt as dt_util from . import setup_integration -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform async def test_all_entities( @@ -29,3 +39,32 @@ async def test_all_entities( await snapshot_platform( hass, entity_registry, snapshot, mock_config_entry.entry_id ) + + +@pytest.mark.parametrize( + ("exception"), + [ + FireflyAuthenticationError("bad creds"), + FireflyConnectionError("cannot connect"), + FireflyTimeoutError("timeout"), + ], +) +async def test_refresh_exceptions( + hass: HomeAssistant, + mock_firefly_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + exception: Exception, +) -> None: + """Test entities go unavailable after coordinator refresh failures.""" + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED + + mock_firefly_client.get_accounts.side_effect = exception + + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow()) + await hass.async_block_till_done() + + state = hass.states.get("sensor.firefly_iii_test_credit_card") + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/growatt_server/test_config_flow.py b/tests/components/growatt_server/test_config_flow.py index e17ea90047bf03..746511ed0bee47 100644 --- a/tests/components/growatt_server/test_config_flow.py +++ b/tests/components/growatt_server/test_config_flow.py @@ -3,25 +3,45 @@ from copy import deepcopy from unittest.mock import patch +import growattServer +import pytest +import requests + from homeassistant import config_entries from homeassistant.components.growatt_server.const import ( + ABORT_NO_PLANTS, + AUTH_API_TOKEN, + AUTH_PASSWORD, + CONF_AUTH_TYPE, CONF_PLANT_ID, DEFAULT_URL, DOMAIN, + ERROR_CANNOT_CONNECT, + ERROR_INVALID_AUTH, LOGIN_INVALID_AUTH_CODE, ) -from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME +from homeassistant.const import ( + CONF_NAME, + CONF_PASSWORD, + CONF_TOKEN, + CONF_URL, + CONF_USERNAME, +) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry -FIXTURE_USER_INPUT = { +FIXTURE_USER_INPUT_PASSWORD = { CONF_USERNAME: "username", CONF_PASSWORD: "password", CONF_URL: DEFAULT_URL, } +FIXTURE_USER_INPUT_TOKEN = { + CONF_TOKEN: "test_api_token_12345", +} + GROWATT_PLANT_LIST_RESPONSE = { "data": [ { @@ -44,67 +64,222 @@ }, "success": True, } + GROWATT_LOGIN_RESPONSE = {"user": {"id": 123456}, "userLevel": 1, "success": True} +# API token responses +GROWATT_V1_PLANT_LIST_RESPONSE = { + "plants": [ + { + "plant_id": 123456, + "name": "Test Plant V1", + "plant_uid": "test_uid_123", + } + ] +} + +GROWATT_V1_MULTIPLE_PLANTS_RESPONSE = { + "plants": [ + { + "plant_id": 123456, + "name": "Test Plant 1", + "plant_uid": "test_uid_123", + }, + { + "plant_id": 789012, + "name": "Test Plant 2", + "plant_uid": "test_uid_789", + }, + ] +} + -async def test_show_authenticate_form(hass: HomeAssistant) -> None: - """Test that the setup form is served.""" +# Menu navigation tests +async def test_show_auth_menu(hass: HomeAssistant) -> None: + """Test that the authentication menu is displayed.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] is FlowResultType.FORM + assert result["type"] is FlowResultType.MENU assert result["step_id"] == "user" + assert result["menu_options"] == ["password_auth", "token_auth"] -async def test_incorrect_login(hass: HomeAssistant) -> None: - """Test that it shows the appropriate error when an incorrect username/password/server is entered.""" +# Parametrized authentication form tests +@pytest.mark.parametrize( + ("auth_type", "expected_fields"), + [ + ("password_auth", [CONF_USERNAME, CONF_PASSWORD, CONF_URL]), + ("token_auth", [CONF_TOKEN]), + ], +) +async def test_auth_form_display( + hass: HomeAssistant, auth_type: str, expected_fields: list[str] +) -> None: + """Test that authentication forms are displayed correctly.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + # Select authentication method + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": auth_type} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == auth_type + for field in expected_fields: + assert field in result["data_schema"].schema + + +async def test_password_auth_incorrect_login(hass: HomeAssistant) -> None: + """Test password authentication with incorrect credentials, then recovery.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + # Select password authentication + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "password_auth"} + ) + with patch( "growattServer.GrowattApi.login", return_value={"msg": LOGIN_INVALID_AUTH_CODE, "success": False}, ): result = await hass.config_entries.flow.async_configure( - result["flow_id"], FIXTURE_USER_INPUT + result["flow_id"], FIXTURE_USER_INPUT_PASSWORD ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "invalid_auth"} + assert result["step_id"] == "password_auth" + assert result["errors"] == {"base": ERROR_INVALID_AUTH} + # Test recovery - retry with correct credentials + with ( + patch("growattServer.GrowattApi.login", return_value=GROWATT_LOGIN_RESPONSE), + patch( + "growattServer.GrowattApi.plant_list", + return_value=GROWATT_PLANT_LIST_RESPONSE, + ), + patch( + "homeassistant.components.growatt_server.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT_PASSWORD + ) -async def test_no_plants_on_account(hass: HomeAssistant) -> None: - """Test registering an integration and finishing flow with an entered plant_id.""" + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_USERNAME] == FIXTURE_USER_INPUT_PASSWORD[CONF_USERNAME] + assert result["data"][CONF_PASSWORD] == FIXTURE_USER_INPUT_PASSWORD[CONF_PASSWORD] + assert result["data"][CONF_PLANT_ID] == "123456" + assert result["data"][CONF_AUTH_TYPE] == AUTH_PASSWORD + + +async def test_password_auth_no_plants(hass: HomeAssistant) -> None: + """Test password authentication with no plants.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - user_input = FIXTURE_USER_INPUT.copy() - plant_list = deepcopy(GROWATT_PLANT_LIST_RESPONSE) - plant_list["data"] = [] + + # Select password authentication + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "password_auth"} + ) with ( patch("growattServer.GrowattApi.login", return_value=GROWATT_LOGIN_RESPONSE), - patch("growattServer.GrowattApi.plant_list", return_value=plant_list), + patch("growattServer.GrowattApi.plant_list", return_value={"data": []}), ): result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input + result["flow_id"], FIXTURE_USER_INPUT_PASSWORD ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "no_plants" + assert result["reason"] == ABORT_NO_PLANTS -async def test_multiple_plant_ids(hass: HomeAssistant) -> None: - """Test registering an integration and finishing flow with an entered plant_id.""" +async def test_token_auth_no_plants(hass: HomeAssistant) -> None: + """Test token authentication with no plants.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - user_input = FIXTURE_USER_INPUT.copy() + + # Select token authentication + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "token_auth"} + ) + + with patch("growattServer.OpenApiV1.plant_list", return_value={"plants": []}): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT_TOKEN + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == ABORT_NO_PLANTS + + +async def test_password_auth_single_plant(hass: HomeAssistant) -> None: + """Test password authentication with single plant.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + # Select password authentication + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "password_auth"} + ) + + with ( + patch("growattServer.GrowattApi.login", return_value=GROWATT_LOGIN_RESPONSE), + patch( + "growattServer.GrowattApi.plant_list", + return_value=GROWATT_PLANT_LIST_RESPONSE, + ), + patch( + "homeassistant.components.growatt_server.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT_PASSWORD + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_USERNAME] == FIXTURE_USER_INPUT_PASSWORD[CONF_USERNAME] + assert result["data"][CONF_PASSWORD] == FIXTURE_USER_INPUT_PASSWORD[CONF_PASSWORD] + assert result["data"][CONF_PLANT_ID] == "123456" + assert result["data"][CONF_AUTH_TYPE] == AUTH_PASSWORD + assert result["data"][CONF_NAME] == "Plant name" + assert result["result"].unique_id == "123456" + + +async def test_password_auth_multiple_plants(hass: HomeAssistant) -> None: + """Test password authentication with multiple plants.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + # Select password authentication + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "password_auth"} + ) + plant_list = deepcopy(GROWATT_PLANT_LIST_RESPONSE) - plant_list["data"].append(plant_list["data"][0]) + plant_list["data"].append( + { + "plantMoneyText": "300.0 (€)", + "plantName": "Plant name 2", + "plantId": "789012", + "isHaveStorage": "true", + "todayEnergy": "1.5 kWh", + "totalEnergy": "1.8 MWh", + "currentPower": "420.0 W", + } + ) with ( patch("growattServer.GrowattApi.login", return_value=GROWATT_LOGIN_RESPONSE), @@ -115,11 +290,14 @@ async def test_multiple_plant_ids(hass: HomeAssistant) -> None: ), ): result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input + result["flow_id"], FIXTURE_USER_INPUT_PASSWORD ) + + # Should show plant selection form assert result["type"] is FlowResultType.FORM assert result["step_id"] == "plant" + # Select first plant user_input = {CONF_PLANT_ID: "123456"} result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input @@ -127,23 +305,45 @@ async def test_multiple_plant_ids(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"][CONF_USERNAME] == FIXTURE_USER_INPUT[CONF_USERNAME] - assert result["data"][CONF_PASSWORD] == FIXTURE_USER_INPUT[CONF_PASSWORD] + assert result["data"][CONF_USERNAME] == FIXTURE_USER_INPUT_PASSWORD[CONF_USERNAME] + assert result["data"][CONF_PASSWORD] == FIXTURE_USER_INPUT_PASSWORD[CONF_PASSWORD] assert result["data"][CONF_PLANT_ID] == "123456" + assert result["data"][CONF_AUTH_TYPE] == AUTH_PASSWORD + assert result["result"].unique_id == "123456" + + +# Token authentication tests -async def test_one_plant_on_account(hass: HomeAssistant) -> None: - """Test registering an integration and finishing flow with an entered plant_id.""" +async def test_token_auth_api_error(hass: HomeAssistant) -> None: + """Test token authentication with API error, then recovery.""" + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - user_input = FIXTURE_USER_INPUT.copy() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "token_auth"} + ) + + # Any GrowattV1ApiError during token verification should result in invalid_auth + error = growattServer.GrowattV1ApiError("API error") + error.error_code = 100 + + with patch("growattServer.OpenApiV1.plant_list", side_effect=error): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT_TOKEN + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "token_auth" + assert result["errors"] == {"base": ERROR_INVALID_AUTH} + + # Test recovery - retry with valid token with ( - patch("growattServer.GrowattApi.login", return_value=GROWATT_LOGIN_RESPONSE), patch( - "growattServer.GrowattApi.plant_list", - return_value=GROWATT_PLANT_LIST_RESPONSE, + "growattServer.OpenApiV1.plant_list", + return_value=GROWATT_V1_PLANT_LIST_RESPONSE, ), patch( "homeassistant.components.growatt_server.async_setup_entry", @@ -151,23 +351,193 @@ async def test_one_plant_on_account(hass: HomeAssistant) -> None: ), ): result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input + result["flow_id"], FIXTURE_USER_INPUT_TOKEN + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_TOKEN] == FIXTURE_USER_INPUT_TOKEN[CONF_TOKEN] + assert result["data"][CONF_PLANT_ID] == "123456" + assert result["data"][CONF_AUTH_TYPE] == AUTH_API_TOKEN + + +async def test_token_auth_connection_error(hass: HomeAssistant) -> None: + """Test token authentication with network error, then recovery.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "token_auth"} + ) + + with patch( + "growattServer.OpenApiV1.plant_list", + side_effect=requests.exceptions.ConnectionError("Network error"), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT_TOKEN + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "token_auth" + assert result["errors"] == {"base": ERROR_CANNOT_CONNECT} + + # Test recovery - retry when network is available + with ( + patch( + "growattServer.OpenApiV1.plant_list", + return_value=GROWATT_V1_PLANT_LIST_RESPONSE, + ), + patch( + "homeassistant.components.growatt_server.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT_TOKEN + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_TOKEN] == FIXTURE_USER_INPUT_TOKEN[CONF_TOKEN] + assert result["data"][CONF_PLANT_ID] == "123456" + assert result["data"][CONF_AUTH_TYPE] == AUTH_API_TOKEN + + +async def test_token_auth_invalid_response(hass: HomeAssistant) -> None: + """Test token authentication with invalid response format, then recovery.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "token_auth"} + ) + + with patch( + "growattServer.OpenApiV1.plant_list", + return_value=None, # Invalid response format + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT_TOKEN + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "token_auth" + assert result["errors"] == {"base": ERROR_CANNOT_CONNECT} + + # Test recovery - retry with valid response + with ( + patch( + "growattServer.OpenApiV1.plant_list", + return_value=GROWATT_V1_PLANT_LIST_RESPONSE, + ), + patch( + "homeassistant.components.growatt_server.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT_TOKEN + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_TOKEN] == FIXTURE_USER_INPUT_TOKEN[CONF_TOKEN] + assert result["data"][CONF_PLANT_ID] == "123456" + assert result["data"][CONF_AUTH_TYPE] == AUTH_API_TOKEN + + +async def test_token_auth_single_plant(hass: HomeAssistant) -> None: + """Test token authentication with single plant.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + # Select token authentication + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "token_auth"} + ) + + with ( + patch( + "growattServer.OpenApiV1.plant_list", + return_value=GROWATT_V1_PLANT_LIST_RESPONSE, + ), + patch( + "homeassistant.components.growatt_server.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT_TOKEN ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"][CONF_USERNAME] == FIXTURE_USER_INPUT[CONF_USERNAME] - assert result["data"][CONF_PASSWORD] == FIXTURE_USER_INPUT[CONF_PASSWORD] + assert result["data"][CONF_TOKEN] == FIXTURE_USER_INPUT_TOKEN[CONF_TOKEN] assert result["data"][CONF_PLANT_ID] == "123456" + assert result["data"][CONF_AUTH_TYPE] == AUTH_API_TOKEN + assert result["data"][CONF_NAME] == "Test Plant V1" + assert result["result"].unique_id == "123456" -async def test_existing_plant_configured(hass: HomeAssistant) -> None: - """Test entering an existing plant_id.""" +async def test_token_auth_multiple_plants(hass: HomeAssistant) -> None: + """Test token authentication with multiple plants.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + # Select token authentication + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "token_auth"} + ) + + with ( + patch( + "growattServer.OpenApiV1.plant_list", + return_value=GROWATT_V1_MULTIPLE_PLANTS_RESPONSE, + ), + patch( + "homeassistant.components.growatt_server.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT_TOKEN + ) + + # Should show plant selection form + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "plant" + + # Select second plant + user_input = {CONF_PLANT_ID: "789012"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_TOKEN] == FIXTURE_USER_INPUT_TOKEN[CONF_TOKEN] + assert result["data"][CONF_PLANT_ID] == "789012" + assert result["data"][CONF_AUTH_TYPE] == AUTH_API_TOKEN + assert result["data"][CONF_NAME] == "Test Plant 2" + assert result["result"].unique_id == "789012" + + +async def test_password_auth_existing_plant_configured(hass: HomeAssistant) -> None: + """Test password authentication with existing plant_id.""" entry = MockConfigEntry(domain=DOMAIN, unique_id="123456") entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - user_input = FIXTURE_USER_INPUT.copy() + + # Select password authentication + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "password_auth"} + ) with ( patch("growattServer.GrowattApi.login", return_value=GROWATT_LOGIN_RESPONSE), @@ -177,8 +547,178 @@ async def test_existing_plant_configured(hass: HomeAssistant) -> None: ), ): result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input + result["flow_id"], FIXTURE_USER_INPUT_PASSWORD + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_token_auth_existing_plant_configured(hass: HomeAssistant) -> None: + """Test token authentication with existing plant_id.""" + entry = MockConfigEntry(domain=DOMAIN, unique_id="123456") + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + # Select token authentication + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "token_auth"} + ) + + with patch( + "growattServer.OpenApiV1.plant_list", + return_value=GROWATT_V1_PLANT_LIST_RESPONSE, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT_TOKEN ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +async def test_password_auth_connection_error(hass: HomeAssistant) -> None: + """Test password authentication with connection error, then recovery.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + # Select password authentication + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "password_auth"} + ) + + with patch( + "growattServer.GrowattApi.login", + side_effect=requests.exceptions.ConnectionError("Connection failed"), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT_PASSWORD + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "password_auth" + assert result["errors"] == {"base": ERROR_CANNOT_CONNECT} + + # Test recovery - retry when connection is available + with ( + patch("growattServer.GrowattApi.login", return_value=GROWATT_LOGIN_RESPONSE), + patch( + "growattServer.GrowattApi.plant_list", + return_value=GROWATT_PLANT_LIST_RESPONSE, + ), + patch( + "homeassistant.components.growatt_server.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT_PASSWORD + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_USERNAME] == FIXTURE_USER_INPUT_PASSWORD[CONF_USERNAME] + assert result["data"][CONF_PASSWORD] == FIXTURE_USER_INPUT_PASSWORD[CONF_PASSWORD] + assert result["data"][CONF_PLANT_ID] == "123456" + assert result["data"][CONF_AUTH_TYPE] == AUTH_PASSWORD + + +async def test_password_auth_invalid_response(hass: HomeAssistant) -> None: + """Test password authentication with invalid response format, then recovery.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + # Select password authentication + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "password_auth"} + ) + + with patch( + "growattServer.GrowattApi.login", + side_effect=ValueError("Invalid JSON response"), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT_PASSWORD + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "password_auth" + assert result["errors"] == {"base": ERROR_CANNOT_CONNECT} + + # Test recovery - retry with valid response + with ( + patch("growattServer.GrowattApi.login", return_value=GROWATT_LOGIN_RESPONSE), + patch( + "growattServer.GrowattApi.plant_list", + return_value=GROWATT_PLANT_LIST_RESPONSE, + ), + patch( + "homeassistant.components.growatt_server.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT_PASSWORD + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_USERNAME] == FIXTURE_USER_INPUT_PASSWORD[CONF_USERNAME] + assert result["data"][CONF_PASSWORD] == FIXTURE_USER_INPUT_PASSWORD[CONF_PASSWORD] + assert result["data"][CONF_PLANT_ID] == "123456" + assert result["data"][CONF_AUTH_TYPE] == AUTH_PASSWORD + + +async def test_password_auth_plant_list_error(hass: HomeAssistant) -> None: + """Test password authentication with plant list connection error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + # Select password authentication + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "password_auth"} + ) + + with ( + patch("growattServer.GrowattApi.login", return_value=GROWATT_LOGIN_RESPONSE), + patch( + "growattServer.GrowattApi.plant_list", + side_effect=requests.exceptions.ConnectionError("Connection failed"), + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT_PASSWORD + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == ERROR_CANNOT_CONNECT + + +async def test_password_auth_plant_list_invalid_format(hass: HomeAssistant) -> None: + """Test password authentication with invalid plant list format.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + # Select password authentication + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "password_auth"} + ) + + with ( + patch("growattServer.GrowattApi.login", return_value=GROWATT_LOGIN_RESPONSE), + patch( + "growattServer.GrowattApi.plant_list", + return_value={"invalid": "format"}, # Missing "data" key + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT_PASSWORD + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == ERROR_CANNOT_CONNECT diff --git a/tests/components/logbook/test_websocket_api.py b/tests/components/logbook/test_websocket_api.py index 80d52d02ee3292..4c88a5874a36e8 100644 --- a/tests/components/logbook/test_websocket_api.py +++ b/tests/components/logbook/test_websocket_api.py @@ -2277,11 +2277,11 @@ async def test_live_stream_with_one_second_commit_interval( hass.bus.async_fire("mock_event", {"device_id": device.id, "message": "5"}) - recieved_rows = [] + received_rows = [] msg = await asyncio.wait_for(websocket_client.receive_json(), 2) assert msg["id"] == 7 assert msg["type"] == "event" - recieved_rows.extend(msg["event"]["events"]) + received_rows.extend(msg["event"]["events"]) hass.bus.async_fire("mock_event", {"device_id": device.id, "message": "6"}) @@ -2289,14 +2289,14 @@ async def test_live_stream_with_one_second_commit_interval( hass.bus.async_fire("mock_event", {"device_id": device.id, "message": "7"}) - while len(recieved_rows) < 7: + while len(received_rows) < 7: msg = await asyncio.wait_for(websocket_client.receive_json(), 2.5) assert msg["id"] == 7 assert msg["type"] == "event" - recieved_rows.extend(msg["event"]["events"]) + received_rows.extend(msg["event"]["events"]) # Make sure we get rows back in order - assert recieved_rows == [ + assert received_rows == [ {"domain": "test", "message": "1", "name": "device name", "when": ANY}, {"domain": "test", "message": "2", "name": "device name", "when": ANY}, {"domain": "test", "message": "3", "name": "device name", "when": ANY}, @@ -3018,15 +3018,15 @@ def auto_off_listener(event): await hass.async_block_till_done() hass.states.async_set("binary_sensor.is_light", STATE_ON) - recieved_rows = [] - while len(recieved_rows) < 3: + received_rows = [] + while len(received_rows) < 3: msg = await asyncio.wait_for(websocket_client.receive_json(), 2.5) assert msg["id"] == 7 assert msg["type"] == "event" - recieved_rows.extend(msg["event"]["events"]) + received_rows.extend(msg["event"]["events"]) # Make sure we get rows back in order - assert recieved_rows == [ + assert received_rows == [ {"entity_id": "binary_sensor.is_light", "state": "unknown", "when": ANY}, {"entity_id": "binary_sensor.is_light", "state": "on", "when": ANY}, {"entity_id": "binary_sensor.is_light", "state": "off", "when": ANY}, diff --git a/tests/components/lunatone/__init__.py b/tests/components/lunatone/__init__.py new file mode 100644 index 00000000000000..bc9e44d2e099a5 --- /dev/null +++ b/tests/components/lunatone/__init__.py @@ -0,0 +1,76 @@ +"""Tests for the Lunatone integration.""" + +from typing import Final + +from lunatone_rest_api_client.models import ( + DeviceData, + DeviceInfoData, + DevicesData, + FeaturesStatus, + InfoData, +) +from lunatone_rest_api_client.models.common import ColorRGBData, ColorWAFData, Status +from lunatone_rest_api_client.models.devices import DeviceStatus + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +BASE_URL: Final = "http://10.0.0.131" +SERIAL_NUMBER: Final = 12345 +VERSION: Final = "v1.14.1/1.4.3" + +DEVICE_DATA_LIST: Final[list[DeviceData]] = [ + DeviceData( + id=1, + name="Device 1", + available=True, + status=DeviceStatus(), + features=FeaturesStatus( + switchable=Status[bool](status=False), + dimmable=Status[float](status=0.0), + colorKelvin=Status[int](status=1000), + colorRGB=Status[ColorRGBData](status=ColorRGBData(r=0, g=0, b=0)), + colorWAF=Status[ColorWAFData](status=ColorWAFData(w=0, a=0, f=0)), + ), + address=0, + line=0, + ), + DeviceData( + id=2, + name="Device 2", + available=True, + status=DeviceStatus(), + features=FeaturesStatus( + switchable=Status[bool](status=False), + dimmable=Status[float](status=0.0), + colorKelvin=Status[int](status=1000), + colorRGB=Status[ColorRGBData](status=ColorRGBData(r=0, g=0, b=0)), + colorWAF=Status[ColorWAFData](status=ColorWAFData(w=0, a=0, f=0)), + ), + address=1, + line=0, + ), +] +DEVICES_DATA: Final[DevicesData] = DevicesData(devices=DEVICE_DATA_LIST) +INFO_DATA: Final[InfoData] = InfoData( + name="Test", + version=VERSION, + device=DeviceInfoData( + serial=SERIAL_NUMBER, + gtin=192837465, + pcb="2a", + articleNumber=87654321, + productionYear=20, + productionWeek=1, + ), +) + + +async def setup_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Set up the Lunatone integration for testing.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/lunatone/conftest.py b/tests/components/lunatone/conftest.py new file mode 100644 index 00000000000000..5f60d084788c19 --- /dev/null +++ b/tests/components/lunatone/conftest.py @@ -0,0 +1,82 @@ +"""Fixtures for Lunatone tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, PropertyMock, patch + +from lunatone_rest_api_client import Device, Devices +import pytest + +from homeassistant.components.lunatone.const import DOMAIN +from homeassistant.const import CONF_URL + +from . import BASE_URL, DEVICES_DATA, INFO_DATA, SERIAL_NUMBER + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.lunatone.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_lunatone_devices() -> Generator[AsyncMock]: + """Mock a Lunatone devices object.""" + + def build_devices_mock(devices: Devices): + device_list = [] + for device_data in devices.data.devices: + device = AsyncMock(spec=Device) + device.data = device_data + device.id = device.data.id + device.name = device.data.name + device.is_on = device.data.features.switchable.status + device_list.append(device) + return device_list + + with patch( + "homeassistant.components.lunatone.Devices", autospec=True + ) as mock_devices: + devices = mock_devices.return_value + devices.data = DEVICES_DATA + type(devices).devices = PropertyMock( + side_effect=lambda d=devices: build_devices_mock(d) + ) + yield devices + + +@pytest.fixture +def mock_lunatone_info() -> Generator[AsyncMock]: + """Mock a Lunatone info object.""" + with ( + patch( + "homeassistant.components.lunatone.Info", + autospec=True, + ) as mock_info, + patch( + "homeassistant.components.lunatone.config_flow.Info", + new=mock_info, + ), + ): + info = mock_info.return_value + info.data = INFO_DATA + info.name = info.data.name + info.version = info.data.version + info.serial_number = info.data.device.serial + yield info + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title=f"Lunatone {SERIAL_NUMBER}", + domain=DOMAIN, + data={CONF_URL: BASE_URL}, + unique_id=str(SERIAL_NUMBER), + ) diff --git a/tests/components/lunatone/snapshots/test_light.ambr b/tests/components/lunatone/snapshots/test_light.ambr new file mode 100644 index 00000000000000..b2762be4540d1a --- /dev/null +++ b/tests/components/lunatone/snapshots/test_light.ambr @@ -0,0 +1,115 @@ +# serializer version: 1 +# name: test_setup[light.device_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.device_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'lunatone', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345-device1', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[light.device_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': None, + 'friendly_name': 'Device 1', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.device_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_setup[light.device_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.device_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'lunatone', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345-device2', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[light.device_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': None, + 'friendly_name': 'Device 2', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.device_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/lunatone/test_config_flow.py b/tests/components/lunatone/test_config_flow.py new file mode 100644 index 00000000000000..56bae075a199b5 --- /dev/null +++ b/tests/components/lunatone/test_config_flow.py @@ -0,0 +1,184 @@ +"""Define tests for the Lunatone config flow.""" + +from unittest.mock import AsyncMock + +import aiohttp +import pytest + +from homeassistant.components.lunatone.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import BASE_URL, SERIAL_NUMBER + +from tests.common import MockConfigEntry + + +async def test_full_flow( + hass: HomeAssistant, mock_lunatone_info: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test full user flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: BASE_URL}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == f"Test {SERIAL_NUMBER}" + assert result["data"] == {CONF_URL: BASE_URL} + + +async def test_full_flow_fail_because_of_missing_device_infos( + hass: HomeAssistant, + mock_lunatone_info: AsyncMock, +) -> None: + """Test full flow.""" + mock_lunatone_info.data = None + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: BASE_URL}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "missing_device_info"} + + +async def test_device_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test that the flow is aborted when the device is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "user" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_URL: BASE_URL}, + ) + + assert result2.get("type") is FlowResultType.ABORT + assert result2.get("reason") == "already_configured" + + +@pytest.mark.parametrize( + ("exception", "expected_error"), + [ + (aiohttp.InvalidUrlClientError(BASE_URL), "invalid_url"), + (aiohttp.ClientConnectionError(), "cannot_connect"), + ], +) +async def test_user_step_fail_with_error( + hass: HomeAssistant, + mock_lunatone_info: AsyncMock, + exception: Exception, + expected_error: str, +) -> None: + """Test user step with an error.""" + mock_lunatone_info.async_update.side_effect = exception + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: BASE_URL}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + + mock_lunatone_info.async_update.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: BASE_URL}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == f"Test {SERIAL_NUMBER}" + assert result["data"] == {CONF_URL: BASE_URL} + + +async def test_reconfigure( + hass: HomeAssistant, + mock_lunatone_info: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow.""" + url = "http://10.0.0.100" + + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_URL: url} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data == {CONF_URL: url} + + +@pytest.mark.parametrize( + ("exception", "expected_error"), + [ + (aiohttp.InvalidUrlClientError(BASE_URL), "invalid_url"), + (aiohttp.ClientConnectionError(), "cannot_connect"), + ], +) +async def test_reconfigure_fail_with_error( + hass: HomeAssistant, + mock_lunatone_info: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + expected_error: str, +) -> None: + """Test reconfigure flow with an error.""" + url = "http://10.0.0.100" + + mock_lunatone_info.async_update.side_effect = exception + + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_URL: url} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + + mock_lunatone_info.async_update.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_URL: url} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data == {CONF_URL: url} diff --git a/tests/components/lunatone/test_init.py b/tests/components/lunatone/test_init.py new file mode 100644 index 00000000000000..0e063b25adbf9c --- /dev/null +++ b/tests/components/lunatone/test_init.py @@ -0,0 +1,133 @@ +"""Tests for the Lunatone integration.""" + +from unittest.mock import AsyncMock + +import aiohttp + +from homeassistant.components.lunatone.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import BASE_URL, VERSION, setup_integration + +from tests.common import MockConfigEntry + + +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_lunatone_devices: AsyncMock, + mock_lunatone_info: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test the Lunatone configuration entry loading/unloading.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.unique_id)} + ) + assert device_entry is not None + assert device_entry.manufacturer == "Lunatone" + assert device_entry.sw_version == VERSION + assert device_entry.configuration_url == BASE_URL + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert not hass.data.get(DOMAIN) + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_config_entry_not_ready_info_api_fail( + hass: HomeAssistant, + mock_lunatone_info: AsyncMock, + mock_lunatone_devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Lunatone configuration entry not ready due to a failure in the info API.""" + mock_lunatone_info.async_update.side_effect = aiohttp.ClientConnectionError() + + await setup_integration(hass, mock_config_entry) + + mock_lunatone_info.async_update.assert_called_once() + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + mock_lunatone_info.async_update.side_effect = None + + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + mock_lunatone_info.async_update.assert_called() + assert mock_config_entry.state is ConfigEntryState.LOADED + + +async def test_config_entry_not_ready_devices_api_fail( + hass: HomeAssistant, + mock_lunatone_info: AsyncMock, + mock_lunatone_devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Lunatone configuration entry not ready due to a failure in the devices API.""" + mock_lunatone_devices.async_update.side_effect = aiohttp.ClientConnectionError() + + await setup_integration(hass, mock_config_entry) + + mock_lunatone_info.async_update.assert_called_once() + mock_lunatone_devices.async_update.assert_called_once() + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + mock_lunatone_devices.async_update.side_effect = None + + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + mock_lunatone_info.async_update.assert_called() + mock_lunatone_devices.async_update.assert_called() + assert mock_config_entry.state is ConfigEntryState.LOADED + + +async def test_config_entry_not_ready_no_info_data( + hass: HomeAssistant, + mock_lunatone_info: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Lunatone configuration entry not ready due to missing info data.""" + mock_lunatone_info.data = None + + await setup_integration(hass, mock_config_entry) + + mock_lunatone_info.async_update.assert_called_once() + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_config_entry_not_ready_no_devices_data( + hass: HomeAssistant, + mock_lunatone_info: AsyncMock, + mock_lunatone_devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Lunatone configuration entry not ready due to missing devices data.""" + mock_lunatone_devices.data = None + + await setup_integration(hass, mock_config_entry) + + mock_lunatone_info.async_update.assert_called_once() + mock_lunatone_devices.async_update.assert_called_once() + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_config_entry_not_ready_no_serial_number( + hass: HomeAssistant, + mock_lunatone_info: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Lunatone configuration entry not ready due to a missing serial number.""" + mock_lunatone_info.serial_number = None + + await setup_integration(hass, mock_config_entry) + + mock_lunatone_info.async_update.assert_called_once() + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR diff --git a/tests/components/lunatone/test_light.py b/tests/components/lunatone/test_light.py new file mode 100644 index 00000000000000..64262ad497b7a4 --- /dev/null +++ b/tests/components/lunatone/test_light.py @@ -0,0 +1,79 @@ +"""Tests for the Lunatone integration.""" + +from unittest.mock import AsyncMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry + +TEST_ENTITY_ID = "light.device_1" + + +async def test_setup( + hass: HomeAssistant, + mock_lunatone_devices: AsyncMock, + mock_lunatone_info: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the Lunatone configuration entry loading/unloading.""" + await setup_integration(hass, mock_config_entry) + + entities = hass.states.async_all(Platform.LIGHT) + for entity_state in entities: + entity_entry = entity_registry.async_get(entity_state.entity_id) + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + assert entity_state == snapshot(name=f"{entity_entry.entity_id}-state") + + +async def test_turn_on_off( + hass: HomeAssistant, + mock_lunatone_devices: AsyncMock, + mock_lunatone_info: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test the light can be turned on and off.""" + await setup_integration(hass, mock_config_entry) + + async def fake_update(): + device = mock_lunatone_devices.data.devices[0] + device.features.switchable.status = not device.features.switchable.status + + mock_lunatone_devices.async_update.side_effect = fake_update + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_ON + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, + blocking=True, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_OFF diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 62a5fd4fd3ae02..8659f277ad5c40 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -33,6 +33,7 @@ "co2bj_yrr3eiyiacm31ski", # https://github.com/orgs/home-assistant/discussions/842 "cobj_hcdy5zrq3ikzthws", # https://github.com/orgs/home-assistant/discussions/482 "cs_b9oyi2yofflroq1g", # https://github.com/home-assistant/core/issues/139966 + "cs_eguoms25tkxtf5u8", # https://github.com/home-assistant/core/issues/152361 "cs_ipmyy4nigpqcnd8q", # https://github.com/home-assistant/core/pull/148726 "cs_ka2wfrdoogpvgzfi", # https://github.com/home-assistant/core/issues/119865 "cs_qhxmvae667uap4zh", # https://github.com/home-assistant/core/issues/141278 diff --git a/tests/components/tuya/fixtures/cs_eguoms25tkxtf5u8.json b/tests/components/tuya/fixtures/cs_eguoms25tkxtf5u8.json new file mode 100644 index 00000000000000..d288905fc21b3a --- /dev/null +++ b/tests/components/tuya/fixtures/cs_eguoms25tkxtf5u8.json @@ -0,0 +1,88 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Arida Stavern ", + "category": "cs", + "product_id": "eguoms25tkxtf5u8", + "product_name": "Arida Stavern ", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-09-14T15:40:04+00:00", + "create_time": "2025-09-14T15:40:04+00:00", + "update_time": "2025-09-14T15:40:04+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "dehumidify_set_enum": { + "type": "Enum", + "value": { + "range": ["40", "50"] + } + }, + "countdown_set": { + "type": "Enum", + "value": { + "range": ["1h", "2h", "3h"] + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "dehumidify_set_enum": { + "type": "Enum", + "value": { + "range": ["40", "50"] + } + }, + "humidity_indoor": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_indoor": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "countdown_set": { + "type": "Enum", + "value": { + "range": ["1h", "2h", "3h"] + } + }, + "fault": { + "type": "Bitmap", + "value": { + "label": ["TILTED", "CHECK", "E_Saving", "FULL"] + } + } + }, + "status": { + "switch": true, + "dehumidify_set_enum": 60, + "humidity_indoor": 61, + "temp_indoor": 16, + "countdown_set": "CANCEL", + "fault": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_fan.ambr b/tests/components/tuya/snapshots/test_fan.ambr index 88dfbf14ee6d59..5330e5ca7290c0 100644 --- a/tests/components/tuya/snapshots/test_fan.ambr +++ b/tests/components/tuya/snapshots/test_fan.ambr @@ -1,4 +1,54 @@ # serializer version: 1 +# name: test_platform_setup_and_discovery[fan.arida_stavern-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.arida_stavern', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.8u5ftxkt52smougesc', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[fan.arida_stavern-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Arida Stavern ', + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.arida_stavern', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform_setup_and_discovery[fan.bree-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_humidifier.ambr b/tests/components/tuya/snapshots/test_humidifier.ambr index 631e1983e07bfa..f240c4b130dc48 100644 --- a/tests/components/tuya/snapshots/test_humidifier.ambr +++ b/tests/components/tuya/snapshots/test_humidifier.ambr @@ -1,4 +1,60 @@ # serializer version: 1 +# name: test_platform_setup_and_discovery[humidifier.arida_stavern-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_humidity': 100, + 'min_humidity': 0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'humidifier', + 'entity_category': None, + 'entity_id': 'humidifier.arida_stavern', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.8u5ftxkt52smougescswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[humidifier.arida_stavern-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_humidity': 61, + 'device_class': 'dehumidifier', + 'friendly_name': 'Arida Stavern ', + 'max_humidity': 100, + 'min_humidity': 0, + 'supported_features': , + }), + 'context': , + 'entity_id': 'humidifier.arida_stavern', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform_setup_and_discovery[humidifier.dehumidifer-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_init.ambr b/tests/components/tuya/snapshots/test_init.ambr index 6ac4ed9d711189..c8810beb0e230d 100644 --- a/tests/components/tuya/snapshots/test_init.ambr +++ b/tests/components/tuya/snapshots/test_init.ambr @@ -1115,6 +1115,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[8u5ftxkt52smougesc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + '8u5ftxkt52smougesc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Arida Stavern ', + 'model_id': 'eguoms25tkxtf5u8', + 'name': 'Arida Stavern ', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[97k3pwirjd] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/tuya/snapshots/test_select.ambr b/tests/components/tuya/snapshots/test_select.ambr index 81c28ae0b03a48..069b9199f0b227 100644 --- a/tests/components/tuya/snapshots/test_select.ambr +++ b/tests/components/tuya/snapshots/test_select.ambr @@ -296,6 +296,122 @@ 'state': 'mute', }) # --- +# name: test_platform_setup_and_discovery[select.arida_stavern_countdown-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '1h', + '2h', + '3h', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.arida_stavern_countdown', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Countdown', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'countdown', + 'unique_id': 'tuya.8u5ftxkt52smougesccountdown_set', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.arida_stavern_countdown-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Arida Stavern Countdown', + 'options': list([ + '1h', + '2h', + '3h', + ]), + }), + 'context': , + 'entity_id': 'select.arida_stavern_countdown', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[select.arida_stavern_target_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '40', + '50', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.arida_stavern_target_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Target humidity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'target_humidity', + 'unique_id': 'tuya.8u5ftxkt52smougescdehumidify_set_enum', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[select.arida_stavern_target_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Arida Stavern Target humidity', + 'options': list([ + '40', + '50', + ]), + }), + 'context': , + 'entity_id': 'select.arida_stavern_target_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_platform_setup_and_discovery[select.aubess_cooker_indicator_light_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index d71642619a7af1..442f6774a0aa7d 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -934,6 +934,115 @@ 'state': '0.071', }) # --- +# name: test_platform_setup_and_discovery[sensor.arida_stavern_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.arida_stavern_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'tuya.8u5ftxkt52smougeschumidity_indoor', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.arida_stavern_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Arida Stavern Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.arida_stavern_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '61.0', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.arida_stavern_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.arida_stavern_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'tuya.8u5ftxkt52smougesctemp_indoor', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.arida_stavern_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Arida Stavern Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.arida_stavern_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16.0', + }) +# --- # name: test_platform_setup_and_discovery[sensor.aubess_cooker_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({