diff --git a/.strict-typing b/.strict-typing index 62796327123804..aef8efec0b0893 100644 --- a/.strict-typing +++ b/.strict-typing @@ -370,6 +370,7 @@ homeassistant.components.luftdaten.* homeassistant.components.lunatone.* homeassistant.components.lutron.* homeassistant.components.madvr.* +homeassistant.components.mammotion.* homeassistant.components.manual.* homeassistant.components.marantz_infrared.* homeassistant.components.mastodon.* diff --git a/CODEOWNERS b/CODEOWNERS index 63bcae0502c127..ba8022ebe17cda 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1097,6 +1097,8 @@ CLAUDE.md @home-assistant/core /tests/components/lyric/ @timmo001 /homeassistant/components/madvr/ @iloveicedgreentea /tests/components/madvr/ @iloveicedgreentea +/homeassistant/components/mammotion/ @mikey0000 +/tests/components/mammotion/ @mikey0000 /homeassistant/components/marantz_infrared/ @balloob /tests/components/marantz_infrared/ @balloob /homeassistant/components/mastodon/ @fabaff @andrew-codechimp diff --git a/homeassistant/components/mammotion/__init__.py b/homeassistant/components/mammotion/__init__.py new file mode 100644 index 00000000000000..4f7470ab153da4 --- /dev/null +++ b/homeassistant/components/mammotion/__init__.py @@ -0,0 +1,217 @@ +"""The Mammotion integration.""" + +import contextlib +from typing import Any + +from aiohttp import ClientConnectorError +from pymammotion.aliyun.model.dev_by_account_response import Device +from pymammotion.client import MammotionClient +from pymammotion.homeassistant import HomeAssistantMowerApi +from pymammotion.transport.base import LoginFailedError, TransportType +from Tea.exceptions import UnretryableException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_PASSWORD, + EVENT_HOMEASSISTANT_FINAL_WRITE, + EVENT_HOMEASSISTANT_STOP, + Platform, +) +from homeassistant.core import Event, HomeAssistant +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, +) +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import DeviceEntry + +from .config import MammotionConfigStore +from .const import ( + CONF_ACCOUNTNAME, + CONF_AEP_DATA, + CONF_MAMMOTION_DEVICE_RECORDS, + CONF_MAMMOTION_MQTT, + DEVICE_SUPPORT, + DOMAIN, + EXPIRED_CREDENTIAL_EXCEPTIONS, + LOGGER, +) +from .coordinator import MammotionMowerUpdateCoordinator +from .models import MammotionDevices, MammotionMowerData + +PLATFORMS: list[Platform] = [Platform.LAWN_MOWER] + +type MammotionConfigEntry = ConfigEntry[MammotionDevices] + + +async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> bool: + """Set up Mammotion Luba from a config entry.""" + + api = HomeAssistantMowerApi( + ha_version="1.0.0", session=async_get_clientsession(hass) + ) + mammotion = api.mammotion + account = entry.data.get(CONF_ACCOUNTNAME) + password = entry.data.get(CONF_PASSWORD) + + mammotion_mowers: list[MammotionMowerData] = [] + mammotion_devices: MammotionDevices = MammotionDevices([]) + store = MammotionConfigStore(hass, entry.entry_id) + await store.async_load_mower_data() + + if account and password: + session = async_get_clientsession(hass) + cached = _load_cached_credentials(entry) + + async def credentials_updated() -> None: + """Persist credentials the library rotated behind our back.""" + store_cloud_credentials(hass, entry, mammotion) + + async def unrecoverable_auth_error( + account_id: str, transport_type: TransportType, err: Exception + ) -> None: + """Surface a dead cloud session as a single reauth prompt.""" + LOGGER.error( + "Mammotion cloud authentication failed for %s on %s: %s", + account_id, + transport_type.value, + err, + ) + entry.async_start_reauth(hass) + + # Wired before login so a rotation during it is not lost. + mammotion.on_credentials_updated = credentials_updated + mammotion.on_unrecoverable_auth_error = unrecoverable_auth_error + + try: + if cached: + try: + await mammotion.restore_credentials( + account, password, cached, session + ) + except EXPIRED_CREDENTIAL_EXCEPTIONS: + await mammotion.login_and_initiate_cloud(account, password, session) + else: + await mammotion.login_and_initiate_cloud(account, password, session) + except ClientConnectorError as err: + raise ConfigEntryNotReady(err) from err + except LoginFailedError as err: + raise ConfigEntryAuthFailed(err) from err + except UnretryableException as err: + raise ConfigEntryError(err) from err + + store_cloud_credentials(hass, entry, mammotion) + + device_list: list[Device] = [ + device + for device in ( + *mammotion.aliyun_device_list, + *mammotion.mammotion_device_list, + ) + if device.device_name.startswith(DEVICE_SUPPORT) + ] + + for device in device_list: + update_coordinator = MammotionMowerUpdateCoordinator( + hass, entry, device, api, store + ) + + update_coordinator.restore_data() + await update_coordinator.async_config_entry_first_refresh() + + mammotion_mowers.append( + MammotionMowerData( + name=device.device_name, + api=api, + coordinator=update_coordinator, + device=device, + ) + ) + + mammotion_devices.mowers = mammotion_mowers + entry.runtime_data = mammotion_devices + + async def shutdown_mammotion(_: Event | None = None) -> None: + await mammotion.stop() + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown_mammotion) + ) + + entry.async_on_unload( + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_FINAL_WRITE, store.async_flush_mower_data + ) + ) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +def store_cloud_credentials( + hass: HomeAssistant, + config_entry: MammotionConfigEntry, + mammotion: MammotionClient, +) -> None: + """Store cloud credentials in config entry.""" + cache = mammotion.to_cache() + if not cache: + return + hass.config_entries.async_update_entry( + config_entry, data={**config_entry.data, **cache} + ) + + +def _load_cached_credentials(entry: MammotionConfigEntry) -> dict[str, Any]: + """Return the config entry's cached credential data, keyed as the library expects.""" + has_aliyun = bool(entry.data.get(CONF_AEP_DATA)) + has_mammotion = bool(entry.data.get(CONF_MAMMOTION_MQTT)) and bool( + entry.data.get(CONF_MAMMOTION_DEVICE_RECORDS) + ) + return dict(entry.data) if (has_aliyun or has_mammotion) else {} + + +async def async_unload_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> bool: + """Unload a config entry.""" + + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + for mower in entry.runtime_data.mowers: + mower.coordinator.store_cloud_credentials() + with contextlib.suppress(TimeoutError): + await mower.api.mammotion.remove_device(mower.name) + + if mowers := entry.runtime_data.mowers: + await mowers[0].coordinator.store.async_flush_mower_data() + await mowers[0].api.mammotion.stop() + return unload_ok + + +async def async_remove_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> None: + """Remove a config entry.""" + store = MammotionConfigStore(hass, entry.entry_id) + await store.async_remove() + + +async def async_remove_config_entry_device( + hass: HomeAssistant, config_entry: MammotionConfigEntry, device_entry: DeviceEntry +) -> bool: + """Remove a config entry from a device.""" + mower_names = ( + next( + identifier[1] + for identifier in device_entry.identifiers + if identifier[0] == DOMAIN + ), + ) + mower = next( + ( + mower + for mower in config_entry.runtime_data.mowers + if mower.name in mower_names + ), + None, + ) + + return not bool(mower) diff --git a/homeassistant/components/mammotion/config.py b/homeassistant/components/mammotion/config.py new file mode 100644 index 00000000000000..c68f8559d6dc74 --- /dev/null +++ b/homeassistant/components/mammotion/config.py @@ -0,0 +1,45 @@ +"""Config storage for Mammotion integration.""" + +from typing import Any + +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.helpers.storage import Store + +from .const import DOMAIN + +STORAGE_VERSION = 1 +STORAGE_MINOR_VERSION = 0 + + +class MammotionConfigStore(Store[dict[str, Any]]): + """Store the mower state of a single config entry.""" + + def __init__(self, hass: HomeAssistant, entry_id: str) -> None: + """Initialize the configuration store.""" + super().__init__( + hass, + version=STORAGE_VERSION, + minor_version=STORAGE_MINOR_VERSION, + key=f"{DOMAIN}.{entry_id}", + ) + self.mower_data: dict[str, Any] = {} + self._dirty = False + + async def async_load_mower_data(self) -> None: + """Load the persisted mower data into memory.""" + self.mower_data = await self.async_load() or {} + + @callback + def async_update_mower_data(self, device_name: str, data: dict[str, Any]) -> None: + """Update a mower in memory; the data is only written on shutdown.""" + if self.mower_data.get(device_name) == data: + return + self.mower_data[device_name] = data + self._dirty = True + + async def async_flush_mower_data(self, _event: Event | None = None) -> None: + """Write the in-memory mower data to disk if it changed.""" + if not self._dirty: + return + self._dirty = False + await self.async_save(dict(self.mower_data)) diff --git a/homeassistant/components/mammotion/config_flow.py b/homeassistant/components/mammotion/config_flow.py new file mode 100644 index 00000000000000..cfaf7a270ce2f6 --- /dev/null +++ b/homeassistant/components/mammotion/config_flow.py @@ -0,0 +1,326 @@ +"""Config flow for Mammotion.""" + +from collections.abc import Mapping +from typing import Any, override + +from aiohttp import ClientError +from bleak.backends.device import BLEDevice +from pymammotion.http.http import MammotionHTTP +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components import bluetooth +from homeassistant.components.bluetooth import ( + BluetoothServiceInfo, + async_discovered_service_info, +) +from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_ADDRESS, CONF_PASSWORD +from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, format_mac + +from .const import ( + CONF_ACCOUNT_ID, + CONF_ACCOUNTNAME, + CONF_BLE_DEVICES, + DEVICE_SUPPORT, + DOMAIN, + LOGGER, +) + + +class MammotionConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Mammotion.""" + + def __init__(self) -> None: + """Initialize the config flow.""" + self._config: dict = {} + self._discovered_devices: dict[str, str] = {} + self._discovered_device: BLEDevice | None = None + + def _find_bluetooth_device( + self, device: BLEDevice + ) -> tuple[ConfigEntry, dr.DeviceEntry] | None: + """Return the entry and device entry owning this mower, if configured.""" + device_registry = dr.async_get(self.hass) + + for entry in self.hass.config_entries.async_entries(DOMAIN): + if not entry.data.get(CONF_ACCOUNT_ID): + continue + + for device_entry in dr.async_entries_for_config_entry( + device_registry, entry.entry_id + ): + identifiers = {device_id[1] for device_id in device_entry.identifiers} + if device.name in identifiers: + return entry, device_entry + return None + + async def check_and_update_bluetooth_device( + self, device: BLEDevice + ) -> ConfigEntry | None: + """Check if the device is already configured and update ble mac if needed.""" + if (found := self._find_bluetooth_device(device)) is None: + return None + + entry, device_entry = found + await self.async_set_unique_id(entry.data.get(CONF_ACCOUNT_ID)) + formatted_ble = format_mac(device.address) if device else None + + if ( + CONNECTION_BLUETOOTH, + formatted_ble, + ) not in device_entry.connections and formatted_ble is not None: + dr.async_get(self.hass).async_update_device( + device_entry.id, + merge_connections={(CONNECTION_BLUETOOTH, formatted_ble)}, + ) + if entry.state is config_entries.ConfigEntryState.LOADED: + # reload the entry now we have a ble address + self.hass.config_entries.async_schedule_reload(entry.entry_id) + return entry + + @override + async def async_step_bluetooth( + self, discovery_info: BluetoothServiceInfo | None + ) -> ConfigFlowResult: + """Handle the bluetooth discovery step.""" + LOGGER.debug("Discovered bluetooth device: %s", discovery_info) + + if discovery_info is None: + return self.async_abort(reason="no_devices_found") + + await self.async_set_unique_id(format_mac(discovery_info.address)) + self._abort_if_unique_id_configured() + + device = bluetooth.async_ble_device_from_address( + self.hass, discovery_info.address + ) + + if device is None: + return self.async_abort(reason="no_longer_present") + + if device.name is None or not device.name.startswith(DEVICE_SUPPORT): + return self.async_abort(reason="not_supported") + + self.context["title_placeholders"] = {"name": device.name} + + self._discovered_device = device + + if entry := await self.check_and_update_bluetooth_device(device): + ble_devices = { + **entry.data.get(CONF_BLE_DEVICES, {}), + self._discovered_device.name: format_mac( + self._discovered_device.address + ), + } + + self._abort_if_unique_id_configured( + updates={CONF_BLE_DEVICES: ble_devices}, reload_on_update=False + ) + + return await self.async_step_bluetooth_confirm() + + async def async_step_bluetooth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm discovery.""" + + assert self._discovered_device is not None + assert self._discovered_device.name is not None + device = self._discovered_device + name = device.name or "" + if entry := await self.check_and_update_bluetooth_device(device): + existing_devices = { + name: format_mac(device.address), + **entry.data.get(CONF_BLE_DEVICES, {}), + } + self._abort_if_unique_id_configured( + updates={CONF_BLE_DEVICES: existing_devices}, reload_on_update=False + ) + + ble_devices: dict[str, str] = {name: format_mac(device.address)} + self._config = { + CONF_BLE_DEVICES: ble_devices, + } + + if user_input is not None: + return await self.async_step_wifi() + + return self.async_show_form( + step_id="bluetooth_confirm", + description_placeholders={"name": name}, + ) + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the user step to pick discovered device.""" + + if user_input is not None: + if address := user_input.get(CONF_ADDRESS): + self._config = { + CONF_BLE_DEVICES: { + self._discovered_devices[address]: format_mac(address) + } + } + return await self.async_step_wifi() + + current_addresses = self._async_current_ids() + for discovery_info in async_discovered_service_info(self.hass): + address = discovery_info.address + name = discovery_info.name + if address in current_addresses: + continue + if name is None or not name.startswith(DEVICE_SUPPORT): + continue + + device = bluetooth.async_ble_device_from_address( + self.hass, discovery_info.address + ) + + if device and self._find_bluetooth_device(device) is None: + self._discovered_devices[address] = discovery_info.name + + if not self._discovered_devices: + return await self.async_step_wifi() + + return self.async_show_form( + last_step=False, + data_schema=vol.Schema( + { + vol.Optional(CONF_ADDRESS): vol.In(self._discovered_devices), + }, + ), + step_id="user", + ) + + async def _async_validate_login( + self, account: str, password: str + ) -> tuple[dict[str, str], str | None]: + """Validate the credentials and return errors and the account ID.""" + errors: dict[str, str] = {} + mammotion_http = MammotionHTTP( + account, password, session=async_get_clientsession(self.hass) + ) + + try: + await mammotion_http.login_v2(account, password) + except ClientError, TimeoutError, OSError: + errors["base"] = "cannot_connect" + return errors, None + + if (login_info := mammotion_http.login_info) is None: + errors["base"] = "invalid_auth" + return errors, None + + return errors, login_info.userInformation.userAccount + + async def async_step_wifi( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the user step for Wi-Fi control.""" + errors: dict[str, str] = {} + + if user_input is not None: + account = user_input[CONF_ACCOUNTNAME] + password = user_input[CONF_PASSWORD] + errors, user_account = await self._async_validate_login(account, password) + + if not errors: + await self.async_set_unique_id(user_account, raise_on_progress=False) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=account, + data={ + CONF_ACCOUNTNAME: account, + CONF_PASSWORD: password, + CONF_ACCOUNT_ID: user_account, + **self._config, + }, + ) + + schema = { + vol.Required(CONF_ACCOUNTNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + } + + return self.async_show_form( + step_id="wifi", data_schema=vol.Schema(schema), errors=errors + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle re-authentication after the cloud rejected our credentials.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm re-authentication.""" + entry = self._get_reauth_entry() + errors: dict[str, str] = {} + + if user_input is not None: + account = entry.data[CONF_ACCOUNTNAME] + password = user_input[CONF_PASSWORD] + errors, user_account = await self._async_validate_login(account, password) + + if not errors: + await self.async_set_unique_id(user_account) + self._abort_if_unique_id_mismatch() + + return self.async_update_reload_and_abort( + entry, data_updates={CONF_PASSWORD: password} + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema({vol.Required(CONF_PASSWORD): cv.string}), + description_placeholders={CONF_ACCOUNTNAME: entry.data[CONF_ACCOUNTNAME]}, + errors=errors, + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration.""" + entry = self._get_reconfigure_entry() + errors: dict[str, str] = {} + + if user_input is not None: + account = user_input[CONF_ACCOUNTNAME] + password = user_input[CONF_PASSWORD] + errors, user_account = await self._async_validate_login(account, password) + + if not errors: + await self.async_set_unique_id(user_account) + self._abort_if_unique_id_mismatch() + + return self.async_update_reload_and_abort( + entry, + data_updates={ + CONF_ACCOUNTNAME: account, + CONF_PASSWORD: password, + CONF_ACCOUNT_ID: user_account, + }, + ) + + schema = { + vol.Required( + CONF_ACCOUNTNAME, default=entry.data.get(CONF_ACCOUNTNAME) + ): cv.string, + vol.Required( + CONF_PASSWORD, default=entry.data.get(CONF_PASSWORD) + ): cv.string, + } + + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema(schema), + errors=errors, + ) diff --git a/homeassistant/components/mammotion/const.py b/homeassistant/components/mammotion/const.py new file mode 100644 index 00000000000000..f6762d7e6b1147 --- /dev/null +++ b/homeassistant/components/mammotion/const.py @@ -0,0 +1,45 @@ +"""Constants for the Mammotion Luba integration.""" + +import logging +from typing import Final + +from bleak.exc import BleakError +from bleak_retry_connector import BleakNotFoundError +from pymammotion.aliyun.exceptions import ( + CheckSessionException, + CloudSetupError, + DeviceOfflineException, +) +from pymammotion.transport.base import NoTransportAvailableError + +from .exceptions import CommandFailedError + +DOMAIN: Final = "mammotion" + +DEVICE_SUPPORT = ("Luba", "Yuka") + +LOGGER: Final = logging.getLogger(__package__) + +COMMAND_EXCEPTIONS = ( + BleakNotFoundError, + BleakError, + NoTransportAvailableError, + TimeoutError, + DeviceOfflineException, + CommandFailedError, +) + +EXPIRED_CREDENTIAL_EXCEPTIONS = (CheckSessionException, CloudSetupError) + +CONF_ACCOUNTNAME: Final = "account_name" +CONF_ACCOUNT_ID: Final = "mammotion_account_id" +CONF_BLE_DEVICES: Final = "ble_devices" +CONF_AUTH_DATA: Final = "auth_data" +CONF_CONNECT_DATA: Final = "connect_response" +CONF_AEP_DATA: Final = "aep_data" +CONF_SESSION_DATA: Final = "session_data" +CONF_REGION_DATA: Final = "region_data" +CONF_DEVICE_DATA: Final = "device_data" +CONF_MAMMOTION_DATA: Final = "mammotion_data" +CONF_MAMMOTION_MQTT: Final = "mammotion_mqtt" +CONF_MAMMOTION_DEVICE_RECORDS: Final = "mammotion_device_records" diff --git a/homeassistant/components/mammotion/coordinator.py b/homeassistant/components/mammotion/coordinator.py new file mode 100644 index 00000000000000..e406a151ba6005 --- /dev/null +++ b/homeassistant/components/mammotion/coordinator.py @@ -0,0 +1,130 @@ +"""Provides the mammotion DataUpdateCoordinator.""" + +from datetime import timedelta +from typing import TYPE_CHECKING, Any, override + +from mashumaro.exceptions import InvalidFieldValue +from pymammotion.aliyun.exceptions import DeviceOfflineException +from pymammotion.aliyun.model.dev_by_account_response import Device +from pymammotion.data.model.device import MowingDevice +from pymammotion.homeassistant import HomeAssistantMowerApi +from pymammotion.transport import NoTransportAvailableError +from pymammotion.transport.base import AuthError + +from homeassistant.const import CONF_PASSWORD +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .config import MammotionConfigStore +from .const import CONF_ACCOUNTNAME, DOMAIN, LOGGER +from .exceptions import CommandFailedError + +if TYPE_CHECKING: + from . import MammotionConfigEntry + +DEFAULT_INTERVAL = timedelta(minutes=1) + + +class MammotionBaseUpdateCoordinator(DataUpdateCoordinator[MowingDevice]): + """Mammotion DataUpdateCoordinator.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: MammotionConfigEntry, + device: Device, + api: HomeAssistantMowerApi, + update_interval: timedelta, + ) -> None: + """Initialize global mammotion data updater.""" + super().__init__( + hass=hass, + logger=LOGGER, + name=DOMAIN, + update_interval=update_interval, + config_entry=config_entry, + ) + assert config_entry.unique_id + self.device: Device = device + self.device_name = device.device_name + self.api: HomeAssistantMowerApi = api + self.account = config_entry.data[CONF_ACCOUNTNAME] + self.password = config_entry.data[CONF_PASSWORD] + self.update_failures = 0 + + async def async_refresh_login(self) -> None: + """Refresh login credentials asynchronously.""" + await self.api.mammotion.refresh_login(self.account) + self.store_cloud_credentials() + + def store_cloud_credentials(self) -> None: + """Store cloud credentials in config entry.""" + if config_entry := self.config_entry: + cache = self.api.mammotion.to_cache() + if not cache: + return + self.hass.config_entries.async_update_entry( + config_entry, data={**config_entry.data, **cache} + ) + + def is_online(self) -> bool: + """Check if device is online.""" + return self.api.is_online(self.device_name) + + async def async_send_command(self, command: str, **kwargs: Any) -> None: + """Send command via api.""" + if not await self.api.async_send_command(self.device_name, command, **kwargs): + raise CommandFailedError(f"Command {command} failed for {self.device_name}") + + +class MammotionMowerUpdateCoordinator(MammotionBaseUpdateCoordinator): + """Class to manage fetching mammotion report data.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: MammotionConfigEntry, + device: Device, + api: HomeAssistantMowerApi, + store: MammotionConfigStore, + ) -> None: + """Initialize mammotion data updater.""" + super().__init__( + hass=hass, + config_entry=config_entry, + device=device, + api=api, + update_interval=DEFAULT_INTERVAL, + ) + self.store = store + + @callback + def restore_data(self) -> None: + """Restore saved data.""" + mower_state = MowingDevice() + if mower_data := self.store.mower_data.get(self.device_name): + try: + mower_state = MowingDevice().from_dict(mower_data) + except InvalidFieldValue: + mower_state = MowingDevice() + + self.data = mower_state + if handle := self.api.mammotion.mower(self.device_name): + handle.restore_device(mower_state) + + @override + async def _async_update_data(self) -> MowingDevice: + """Get data from the device.""" + try: + data = await self.api.update(self.device_name) + except DeviceOfflineException, NoTransportAvailableError: + return self.data + except AuthError as err: + raise ConfigEntryAuthFailed(err) from err + + if data is None: + raise UpdateFailed(f"No data returned for {self.device_name}") + self.store.async_update_mower_data(self.device_name, data.to_dict()) + + return data diff --git a/homeassistant/components/mammotion/entity.py b/homeassistant/components/mammotion/entity.py new file mode 100644 index 00000000000000..2036268ac447a9 --- /dev/null +++ b/homeassistant/components/mammotion/entity.py @@ -0,0 +1,89 @@ +"""Base class for entities.""" + +from typing import cast, override + +from homeassistant.helpers.device_registry import ( + CONNECTION_BLUETOOTH, + CONNECTION_NETWORK_MAC, + DeviceInfo, + format_mac, +) +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import MammotionBaseUpdateCoordinator + + +class MammotionBaseEntity(CoordinatorEntity[MammotionBaseUpdateCoordinator]): + """Base entity for Mammotion devices.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: MammotionBaseUpdateCoordinator, key: str) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.device_name}_{key}" + + @override + @property + def device_info(self) -> DeviceInfo: + """Return the device info.""" + mower = self.coordinator.api.mammotion.get_device_by_name( + self.coordinator.device_name + ) + + swversion: str | None = None + model_id: str | None = None + connections: set[tuple[str, str]] = set() + + if mower is not None: + swversion = mower.device_firmwares.device_version + + if mower.mower_state.model_id != "": + model_id = mower.mower_state.model_id + if ( + mower.mqtt_properties is not None + and mower.mqtt_properties.params.items.extMod is not None + ): + model_id = cast(str, mower.mqtt_properties.params.items.extMod.value) + + if mower.mower_state.ble_mac != "": + connections.add( + ( + CONNECTION_BLUETOOTH, + format_mac(mower.mower_state.ble_mac), + ) + ) + + if mower.mower_state.wifi_mac != "": + connections.add( + ( + CONNECTION_NETWORK_MAC, + format_mac(mower.mower_state.wifi_mac), + ) + ) + + nick_name = self.coordinator.device.nick_name + device_name = ( + self.coordinator.device_name + if nick_name is None or nick_name == "" + else self.coordinator.device.nick_name + ) + + return DeviceInfo( + identifiers={(DOMAIN, self.coordinator.device.device_name)}, + manufacturer="Mammotion", + serial_number=self.coordinator.device_name.split("-", 1)[-1], + model_id=model_id, + name=device_name, + sw_version=swversion, + model=self.coordinator.device.product_model or model_id, + suggested_area="Garden", + connections=connections, + ) + + @override + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self.coordinator.is_online() diff --git a/homeassistant/components/mammotion/exceptions.py b/homeassistant/components/mammotion/exceptions.py new file mode 100644 index 00000000000000..78bf6790315b81 --- /dev/null +++ b/homeassistant/components/mammotion/exceptions.py @@ -0,0 +1,7 @@ +"""Exceptions for the Mammotion integration.""" + +from homeassistant.exceptions import HomeAssistantError + + +class CommandFailedError(HomeAssistantError): + """Error to indicate a command was not carried out by the device.""" diff --git a/homeassistant/components/mammotion/lawn_mower.py b/homeassistant/components/mammotion/lawn_mower.py new file mode 100644 index 00000000000000..d9eddf9eb58234 --- /dev/null +++ b/homeassistant/components/mammotion/lawn_mower.py @@ -0,0 +1,183 @@ +"""Luba lawn mowers.""" + +from typing import override + +from pymammotion.data.model.report_info import DeviceData, ReportData +from pymammotion.utility.constant.device_constant import WorkMode + +from homeassistant.components.lawn_mower import ( + LawnMowerActivity, + LawnMowerEntity, + LawnMowerEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import MammotionConfigEntry, MammotionMowerUpdateCoordinator +from .const import COMMAND_EXCEPTIONS, DOMAIN, LOGGER +from .entity import MammotionBaseEntity + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: MammotionConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Luba config entry.""" + mammotion_devices = entry.runtime_data.mowers + entities: list[MammotionLawnMowerEntity] = [ + MammotionLawnMowerEntity(mower.coordinator) for mower in mammotion_devices + ] + async_add_entities(entities) + + +class MammotionLawnMowerEntity(MammotionBaseEntity, LawnMowerEntity): + """Representation of a Mammotion lawn mower.""" + + _attr_name = None + _attr_supported_features = ( + LawnMowerEntityFeature.DOCK + | LawnMowerEntityFeature.PAUSE + | LawnMowerEntityFeature.START_MOWING + ) + + def __init__(self, coordinator: MammotionMowerUpdateCoordinator) -> None: + """Initialize the lawn mower.""" + super().__init__(coordinator, "mower") + + @property + def rpt_dev_status(self) -> DeviceData: + """Return the device status.""" + return self.coordinator.data.report_data.dev + + @property + def report_data(self) -> ReportData: + """Return the report data.""" + return self.coordinator.data.report_data + + @override + @property + def activity(self) -> LawnMowerActivity | None: + """Return the state of the mower.""" + + charge_state = self.rpt_dev_status.charge_state + mode = self.rpt_dev_status.sys_status + + LOGGER.debug("activity mode %s", mode) + if mode in (WorkMode.MODE_PAUSE, WorkMode.MODE_CHARGING_PAUSE) or ( + mode == WorkMode.MODE_READY and charge_state == 0 + ): + return LawnMowerActivity.PAUSED + if mode == WorkMode.MODE_WORKING: + return LawnMowerActivity.MOWING + if mode == WorkMode.MODE_RETURNING: + return LawnMowerActivity.RETURNING + if mode == WorkMode.MODE_LOCK: + return LawnMowerActivity.ERROR + if mode == WorkMode.MODE_READY and charge_state != 0: + return LawnMowerActivity.DOCKED + return None + + @override + async def async_start_mowing(self) -> None: + """Start mowing.""" + trans_key = "start_failed" + + mode = self.rpt_dev_status.sys_status + if mode is None: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="device_not_ready" + ) + + if mode == WorkMode.MODE_PAUSE: + trans_key = "resume_failed" + try: + await self.coordinator.async_send_command("resume_execute_task") + except COMMAND_EXCEPTIONS as exc: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key=trans_key + ) from exc + finally: + await self.coordinator.api.async_request_iot_sync( + self.coordinator.device_name + ) + + else: + try: + await self.coordinator.async_send_command("start_job") + except COMMAND_EXCEPTIONS as exc: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key=trans_key + ) from exc + finally: + await self.coordinator.api.async_request_iot_sync( + self.coordinator.device_name + ) + + @override + async def async_dock(self) -> None: + """Start docking.""" + trans_key = "dock_failed" + + charge_state = self.rpt_dev_status.charge_state + mode = self.rpt_dev_status.sys_status + if mode is None: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="device_not_ready" + ) + + # MODE_RETURNING is left untouched; the mower is already docking + if charge_state == 0 and mode in ( + WorkMode.MODE_WORKING, + WorkMode.MODE_PAUSE, + WorkMode.MODE_READY, + ): + try: + if mode == WorkMode.MODE_WORKING: + trans_key = "pause_failed" + await self.coordinator.async_send_command("pause_execute_task") + + trans_key = "dock_failed" + await self.coordinator.async_send_command("return_to_dock") + except COMMAND_EXCEPTIONS as exc: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key=trans_key + ) from exc + finally: + await self.coordinator.api.async_request_iot_sync( + self.coordinator.device_name + ) + + @override + async def async_pause(self) -> None: + """Pause mower.""" + trans_key = "pause_failed" + + mode = self.rpt_dev_status.sys_status + if mode is None: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="device_not_ready" + ) + + if mode in ( + WorkMode.MODE_WORKING, + WorkMode.MODE_RETURNING, + ): + try: + if mode == WorkMode.MODE_WORKING: + trans_key = "pause_failed" + await self.coordinator.async_send_command("pause_execute_task") + if mode == WorkMode.MODE_RETURNING: + trans_key = "dock_cancel_failed" + await self.coordinator.async_send_command("cancel_return_to_dock") + except COMMAND_EXCEPTIONS as exc: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key=trans_key + ) from exc + finally: + await self.coordinator.api.async_request_iot_sync( + self.coordinator.device_name + ) diff --git a/homeassistant/components/mammotion/manifest.json b/homeassistant/components/mammotion/manifest.json new file mode 100644 index 00000000000000..6b2a5de12d8e6e --- /dev/null +++ b/homeassistant/components/mammotion/manifest.json @@ -0,0 +1,25 @@ +{ + "domain": "mammotion", + "name": "Mammotion", + "bluetooth": [ + { + "connectable": true, + "local_name": "Luba-*", + "service_uuid": "0000ffff-0000-1000-8000-00805f9b34fb" + }, + { + "connectable": true, + "local_name": "Yuka-*", + "service_uuid": "0000ffff-0000-1000-8000-00805f9b34fb" + } + ], + "codeowners": ["@mikey0000"], + "config_flow": true, + "dependencies": ["bluetooth"], + "documentation": "https://www.home-assistant.io/integrations/mammotion", + "integration_type": "device", + "iot_class": "cloud_polling", + "loggers": ["pymammotion"], + "quality_scale": "bronze", + "requirements": ["pymammotion==0.8.14"] +} diff --git a/homeassistant/components/mammotion/models.py b/homeassistant/components/mammotion/models.py new file mode 100644 index 00000000000000..a85ff1ab904c37 --- /dev/null +++ b/homeassistant/components/mammotion/models.py @@ -0,0 +1,25 @@ +"""Models for the Mammotion integration.""" + +from dataclasses import dataclass + +from pymammotion.aliyun.model.dev_by_account_response import Device +from pymammotion.homeassistant import HomeAssistantMowerApi + +from .coordinator import MammotionMowerUpdateCoordinator + + +@dataclass +class MammotionMowerData: + """Data for a mower.""" + + name: str + api: HomeAssistantMowerApi + coordinator: MammotionMowerUpdateCoordinator + device: Device + + +@dataclass +class MammotionDevices: + """Data for the Mammotion integration.""" + + mowers: list[MammotionMowerData] diff --git a/homeassistant/components/mammotion/quality_scale.yaml b/homeassistant/components/mammotion/quality_scale.yaml new file mode 100644 index 00000000000000..cd550366dee03c --- /dev/null +++ b/homeassistant/components/mammotion/quality_scale.yaml @@ -0,0 +1,76 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + No custom actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + No custom actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-conditions: done + docs-triggers: done + entity-event-setup: + status: exempt + comment: | + Does not subscribe to event explicitly. + 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: todo + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: done + discovery: done + docs-data-update: todo + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: done + docs-use-cases: todo + dynamic-devices: done + entity-category: done + entity-device-class: done + entity-disabled-by-default: todo + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: done + repair-issues: + status: exempt + comment: | + Does not have any repairs + stale-devices: done + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/mammotion/strings.json b/homeassistant/components/mammotion/strings.json new file mode 100644 index 00000000000000..1f4072a9f14bd3 --- /dev/null +++ b/homeassistant/components/mammotion/strings.json @@ -0,0 +1,93 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "bluetooth_and_account_mismatch": "Bluetooth device not found in your account", + "missing_wifi_data": "Missing Wi-Fi configuration data", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "no_devices_found_in_account": "No devices present in your account", + "no_longer_present": "Device is no longer present", + "not_supported": "Device not supported", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "unique_id_mismatch": "Please ensure you reconfigure using the same Mammotion account" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "flow_title": "Configure your Mammotion lawn mower", + "step": { + "bluetooth_confirm": { + "description": "Set up {name}" + }, + "reauth_confirm": { + "data": { + "password": "[%key:component::mammotion::config::step::wifi::data::password%]" + }, + "data_description": { + "password": "[%key:component::mammotion::config::step::wifi::data_description::password%]" + }, + "description": "The Mammotion cloud rejected the credentials for {account_name}. Enter the password again to reconnect.", + "title": "[%key:common::config_flow::title::reauth%]" + }, + "reconfigure": { + "data": { + "account_name": "[%key:component::mammotion::config::step::wifi::data::account_name%]", + "password": "[%key:component::mammotion::config::step::wifi::data::password%]" + }, + "data_description": { + "account_name": "[%key:component::mammotion::config::step::wifi::data_description::account_name%]", + "password": "[%key:component::mammotion::config::step::wifi::data_description::password%]" + }, + "description": "Enter your Mammotion account email or ID and password", + "title": "Update configuration" + }, + "user": { + "data": { + "address": "Device" + }, + "data_description": { + "address": "Bluetooth address of the mower" + }, + "description": "Select your mower" + }, + "wifi": { + "data": { + "account_name": "Mammotion email or account number", + "password": "Mammotion account password" + }, + "data_description": { + "account_name": "Mammotion email or account number for your shared Mammotion account.", + "password": "Mammotion shared account password" + } + } + } + }, + "entity": {}, + "exceptions": { + "command_failed": { + "message": "Failed to send command to the mower." + }, + "device_not_ready": { + "message": "Device is not ready." + }, + "dock_cancel_failed": { + "message": "Failed to stop the mower returning to the dock." + }, + "dock_failed": { + "message": "Failed to send the mower to the dock." + }, + "pause_failed": { + "message": "Failed to pause the mower." + }, + "resume_failed": { + "message": "Failed to resume the mower." + }, + "start_failed": { + "message": "Failed to start the mower." + } + } +} diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index befb6ab7ac5094..e3011afd99e8a7 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -540,6 +540,18 @@ "domain": "led_ble", "local_name": "LD-0003", }, + { + "connectable": True, + "domain": "mammotion", + "local_name": "Luba-*", + "service_uuid": "0000ffff-0000-1000-8000-00805f9b34fb", + }, + { + "connectable": True, + "domain": "mammotion", + "local_name": "Yuka-*", + "service_uuid": "0000ffff-0000-1000-8000-00805f9b34fb", + }, { "domain": "medcom_ble", "service_uuid": "39b31fec-b63a-4ef7-b163-a7317872007f", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 9d191fd34776a0..1a70066915d181 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -458,6 +458,7 @@ "lyric", "madvr", "mailgun", + "mammotion", "marantz_infrared", "mastodon", "matter", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 4f2eda4b5bc219..c291be26ded25c 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4139,6 +4139,12 @@ "config_flow": true, "iot_class": "cloud_push" }, + "mammotion": { + "name": "Mammotion", + "integration_type": "device", + "config_flow": true, + "iot_class": "cloud_polling" + }, "marantz": { "name": "Marantz", "integrations": { diff --git a/mypy.ini b/mypy.ini index 5efa32b96073aa..aec2fa29aaaca1 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3457,6 +3457,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.mammotion.*] +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.manual.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index f0dfc4a6069141..790b7253544f27 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2368,6 +2368,9 @@ pylutron==0.4.2 # homeassistant.components.mailgun pymailgunner==1.4 +# homeassistant.components.mammotion +pymammotion==0.8.14 + # homeassistant.components.firmata pymata-express==1.19 diff --git a/tests/components/mammotion/__init__.py b/tests/components/mammotion/__init__.py new file mode 100644 index 00000000000000..3dab6d043f997e --- /dev/null +++ b/tests/components/mammotion/__init__.py @@ -0,0 +1,33 @@ +"""Tests for the Mammotion integration.""" + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo + +from tests.common import MockConfigEntry + +BLE_DEVICE_LUBA = BluetoothServiceInfo( + name="Luba-ABC123", + address="AA:BB:CC:DD:EE:FF", + rssi=-38, + manufacturer_data={}, + service_uuids=["0000ffff-0000-1000-8000-00805f9b34fb"], + service_data={}, + source="local", +) + +BLE_DEVICE_YUKA = BluetoothServiceInfo( + name="Yuka-XYZ789", + address="11:22:33:44:55:66", + rssi=-45, + manufacturer_data={}, + service_uuids=["0000ffff-0000-1000-8000-00805f9b34fb"], + service_data={}, + source="local", +) + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Set up the Mammotion integration for testing.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/mammotion/conftest.py b/tests/components/mammotion/conftest.py new file mode 100644 index 00000000000000..2f239fa946cdc6 --- /dev/null +++ b/tests/components/mammotion/conftest.py @@ -0,0 +1,83 @@ +"""Fixtures for Mammotion tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +from pymammotion.data.model.device import MowingDevice +import pytest + +from homeassistant.components.mammotion.const import ( + CONF_ACCOUNT_ID, + CONF_ACCOUNTNAME, + DOMAIN, +) +from homeassistant.const import CONF_PASSWORD + +from tests.common import MockConfigEntry + +DEFAULT_NAME = "Luba-ABC123" + + +@pytest.fixture(autouse=True) +def mock_bluetooth(enable_bluetooth: None) -> None: + """Auto mock bluetooth.""" + + +@pytest.fixture +def mock_setup_entry() -> Generator[MagicMock]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.mammotion.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="user@example.com", + data={ + CONF_ACCOUNTNAME: "user@example.com", + CONF_PASSWORD: "password", + CONF_ACCOUNT_ID: "user123", + }, + unique_id="user123", + ) + + +@pytest.fixture +def mock_mowing_device() -> MowingDevice: + """Return the state of the mower as reported by the device.""" + return MowingDevice() + + +@pytest.fixture +def mock_mower_api(mock_mowing_device: MowingDevice) -> Generator[MagicMock]: + """Mock the pymammotion mower API.""" + device = Mock() + device.device_name = DEFAULT_NAME + device.nick_name = "Luba" + device.product_model = "Luba 2 AWD" + + api = MagicMock() + api.update = AsyncMock(return_value=mock_mowing_device) + api.is_online = Mock(return_value=True) + api.async_send_command = AsyncMock(return_value=True) + api.async_request_iot_sync = AsyncMock() + api.mammotion.login_and_initiate_cloud = AsyncMock() + api.mammotion.restore_credentials = AsyncMock() + api.mammotion.stop = AsyncMock() + api.mammotion.remove_device = AsyncMock() + api.mammotion.to_cache = Mock(return_value={}) + api.mammotion.get_device_by_name = Mock(return_value=None) + api.mammotion.mower = Mock(return_value=None) + api.mammotion.aliyun_device_list = [device] + api.mammotion.mammotion_device_list = [] + + with patch( + "homeassistant.components.mammotion.HomeAssistantMowerApi", + return_value=api, + ): + yield api diff --git a/tests/components/mammotion/snapshots/test_lawn_mower.ambr b/tests/components/mammotion/snapshots/test_lawn_mower.ambr new file mode 100644 index 00000000000000..4633eccb6dd219 --- /dev/null +++ b/tests/components/mammotion/snapshots/test_lawn_mower.ambr @@ -0,0 +1,52 @@ +# serializer version: 1 +# name: test_all_entities[lawn_mower.garden_luba-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'lawn_mower', + 'entity_category': None, + 'entity_id': 'lawn_mower.garden_luba', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'mammotion', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'Luba-ABC123_mower', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[lawn_mower.garden_luba-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Luba', + : , + }), + 'context': , + 'entity_id': 'lawn_mower.garden_luba', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/mammotion/test_config_flow.py b/tests/components/mammotion/test_config_flow.py new file mode 100644 index 00000000000000..b293a59500e999 --- /dev/null +++ b/tests/components/mammotion/test_config_flow.py @@ -0,0 +1,679 @@ +"""Test the Mammotion Luba config flow.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +from aiohttp import ClientConnectionError +from bleak.backends.device import BLEDevice +import pytest + +from homeassistant import config_entries +from homeassistant.components.mammotion.const import ( + CONF_ACCOUNT_ID, + CONF_ACCOUNTNAME, + CONF_BLE_DEVICES, + DOMAIN, +) +from homeassistant.const import CONF_ADDRESS, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import device_registry as dr + +from tests.common import MockConfigEntry + + +def _get_mock_device(name="Luba-ABC123", address="AA:BB:CC:DD:EE:FF"): + device = MagicMock(spec=BLEDevice) + device.name = name + device.address = address + return device + + +def _get_discovery_info(name="Luba-ABC123", address="aa:bb:cc:dd:ee:ff"): + discovery_info = MagicMock() + discovery_info.name = name + discovery_info.address = address.upper() + return discovery_info + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_bluetooth_discovery_success(hass: HomeAssistant) -> None: + """Test successful bluetooth discovery flow.""" + discovery_info = _get_discovery_info() + device = _get_mock_device() + + with patch( + "homeassistant.components.bluetooth.async_ble_device_from_address", + return_value=device, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=discovery_info, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "wifi" + + mock_http = MagicMock() + mock_http.login_info.userInformation.userAccount = "user123" + mock_http.login_v2 = AsyncMock(return_value=None) + + with patch( + "homeassistant.components.mammotion.config_flow.MammotionHTTP", + return_value=mock_http, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ACCOUNTNAME: "user@example.com", + CONF_PASSWORD: "password", + }, + ) + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "user@example.com" + assert result2["data"] == { + CONF_ACCOUNTNAME: "user@example.com", + CONF_PASSWORD: "password", + CONF_ACCOUNT_ID: "user123", + CONF_BLE_DEVICES: {"Luba-ABC123": "aa:bb:cc:dd:ee:ff"}, + } + + +async def test_bluetooth_discovery_already_configured(hass: HomeAssistant) -> None: + """Test discovery aborts if already configured.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_ACCOUNT_ID: "user123"}, + unique_id="aa:bb:cc:dd:ee:ff", + ) + entry.add_to_hass(hass) + + discovery_info = _get_discovery_info() + device = _get_mock_device() + + with patch( + "homeassistant.components.bluetooth.async_ble_device_from_address", + return_value=device, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=discovery_info, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_bluetooth_discovery_not_supported(hass: HomeAssistant) -> None: + """Test discovery aborts if device name is not supported.""" + discovery_info = _get_discovery_info(name="Unknown-Device") + device = _get_mock_device(name="Unknown-Device") + + with patch( + "homeassistant.components.bluetooth.async_ble_device_from_address", + return_value=device, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=discovery_info, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "not_supported" + + +async def test_bluetooth_discovery_no_device(hass: HomeAssistant) -> None: + """Test discovery aborts if device is None (no longer present).""" + discovery_info = _get_discovery_info() + + with patch( + "homeassistant.components.bluetooth.async_ble_device_from_address", + return_value=None, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=discovery_info, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_longer_present" + + +async def test_user_step_pick_discovery(hass: HomeAssistant) -> None: + """Test user step picking a discovered device.""" + discovery_info = _get_discovery_info() + device = _get_mock_device() + + with ( + patch( + "homeassistant.components.mammotion.config_flow.async_discovered_service_info", + return_value=[discovery_info], + ), + patch( + "homeassistant.components.bluetooth.async_ble_device_from_address", + return_value=device, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + mock_mammotion = MagicMock() + mock_mammotion.login_v2 = AsyncMock() + mock_mammotion.login_info.userInformation.userAccount = "user123" + + with patch( + "homeassistant.components.mammotion.config_flow.MammotionHTTP", + return_value=mock_mammotion, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ADDRESS: "AA:BB:CC:DD:EE:FF"} + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "wifi" + + +async def test_user_step_manual_entry(hass: HomeAssistant) -> None: + """Test user step with manual entry.""" + device = _get_mock_device() + + mock_mammotion = MagicMock() + mock_mammotion.login_v2 = AsyncMock() + mock_mammotion.login_info.userInformation.userAccount = "user123" + + with ( + patch( + "homeassistant.components.bluetooth.async_ble_device_from_address", + return_value=device, + ), + patch( + "homeassistant.components.mammotion.config_flow.MammotionHTTP", + return_value=mock_mammotion, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data={} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "wifi" + + +async def test_user_step_no_discovery(hass: HomeAssistant) -> None: + """Test user step with no discovered devices goes to wifi.""" + with patch( + "homeassistant.components.mammotion.config_flow.async_discovered_service_info", + return_value=[], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "wifi" + + +async def test_wifi_step_invalid_auth(hass: HomeAssistant) -> None: + """Test wifi step returns error on invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mock_http = MagicMock() + mock_http.login_info = None + mock_http.login_v2 = AsyncMock(return_value=None) + + with patch( + "homeassistant.components.mammotion.config_flow.MammotionHTTP", + return_value=mock_http, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ACCOUNTNAME: "user@example.com", + CONF_PASSWORD: "wrong", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "wifi" + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_wifi_step_connection_error(hass: HomeAssistant) -> None: + """Test wifi step returns error on connection issue.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mock_http = MagicMock() + mock_http.login_v2 = AsyncMock(side_effect=ClientConnectionError("Conn Err")) + + with patch( + "homeassistant.components.mammotion.config_flow.MammotionHTTP", + return_value=mock_http, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ACCOUNTNAME: "user@example.com", + CONF_PASSWORD: "password", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "wifi" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_reconfigure_flow(hass: HomeAssistant) -> None: + """Test reconfiguration flow.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_ACCOUNTNAME: "old@example.com", CONF_PASSWORD: "old_password"}, + unique_id="user123", + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_http = MagicMock() + mock_http.login_v2 = AsyncMock(return_value=None) + mock_http.login_info.userInformation.userAccount = "user123" + + with patch( + "homeassistant.components.mammotion.config_flow.MammotionHTTP", + return_value=mock_http, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ACCOUNTNAME: "new@example.com", + CONF_PASSWORD: "new_password", + }, + ) + + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "reconfigure_successful" + + entry = hass.config_entries.async_get_entry(entry.entry_id) + assert entry.data[CONF_ACCOUNTNAME] == "new@example.com" + assert entry.data[CONF_PASSWORD] == "new_password" + assert entry.data[CONF_ACCOUNT_ID] == "user123" + + +async def test_reconfigure_flow_invalid_auth(hass: HomeAssistant) -> None: + """Test reconfiguration flow shows an error on invalid credentials.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_ACCOUNTNAME: "old@example.com", CONF_PASSWORD: "old_password"}, + unique_id="user123", + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + + mock_http = MagicMock() + mock_http.login_v2 = AsyncMock(return_value=None) + mock_http.login_info = None + + with patch( + "homeassistant.components.mammotion.config_flow.MammotionHTTP", + return_value=mock_http, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ACCOUNTNAME: "new@example.com", + CONF_PASSWORD: "wrong", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "reconfigure" + assert result2["errors"] == {"base": "invalid_auth"} + + assert entry.data[CONF_ACCOUNTNAME] == "old@example.com" + + +async def test_reconfigure_flow_account_mismatch(hass: HomeAssistant) -> None: + """Test reconfiguration flow aborts when a different account is used.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_ACCOUNTNAME: "old@example.com", CONF_PASSWORD: "old_password"}, + unique_id="user123", + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + + mock_http = MagicMock() + mock_http.login_v2 = AsyncMock(return_value=None) + mock_http.login_info.userInformation.userAccount = "other_user" + + with patch( + "homeassistant.components.mammotion.config_flow.MammotionHTTP", + return_value=mock_http, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ACCOUNTNAME: "other@example.com", + CONF_PASSWORD: "password", + }, + ) + + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "unique_id_mismatch" + + assert entry.data[CONF_ACCOUNTNAME] == "old@example.com" + + +async def test_bluetooth_discovery_update_existing_entry( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test bluetooth discovery updates existing entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_ACCOUNT_ID: "user123"}, + unique_id="user123", + state=config_entries.ConfigEntryState.LOADED, + ) + entry.add_to_hass(hass) + + device_entry = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, "Luba-ABC123")}, + connections=set(), + ) + + discovery_info = _get_discovery_info() + device = _get_mock_device() + + with patch( + "homeassistant.components.bluetooth.async_ble_device_from_address", + return_value=device, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=discovery_info, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + device_entry = device_registry.async_get(device_entry.id) + assert (dr.CONNECTION_BLUETOOTH, "aa:bb:cc:dd:ee:ff") in device_entry.connections + + +async def test_bluetooth_step_no_discovery_info(hass: HomeAssistant) -> None: + """Test bluetooth step with no discovery info.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=None, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_user_step_filtering(hass: HomeAssistant) -> None: + """Test user step filters discovered devices.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="AA:BB:CC:DD:EE:FF", + ) + + entry.add_to_hass(hass) + + discovery_info_configured = _get_discovery_info( + name="existing entry", address="AA:BB:CC:DD:EE:FF" + ) + discovery_info_unsupported = _get_discovery_info( + name="Unsupported", address="11:22:33:44:55:66" + ) + discovery_info_valid = _get_discovery_info( + name="Luba-NEW", address="99:88:77:66:55:44" + ) + + device_valid = _get_mock_device(name="Luba-NEW", address="99:88:77:66:55:44") + + with ( + patch( + "homeassistant.components.mammotion.config_flow.async_discovered_service_info", + return_value=[ + discovery_info_configured, + discovery_info_unsupported, + discovery_info_valid, + ], + ), + patch( + "homeassistant.components.bluetooth.async_ble_device_from_address", + return_value=device_valid, + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + +async def test_bluetooth_confirm_race_condition( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test bluetooth confirm step race condition where device is configured during flow.""" + discovery_info = _get_discovery_info() + device = _get_mock_device() + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_ACCOUNT_ID: "user123", CONF_BLE_DEVICES: {}}, + unique_id="user123", + ) + entry.add_to_hass(hass) + + device_entry = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, "Luba-ABC123")}, + connections={(dr.CONNECTION_BLUETOOTH, "aa:bb:cc:dd:ee:ff")}, + ) + + with ( + patch( + "homeassistant.components.bluetooth.async_ble_device_from_address", + return_value=device, + ), + patch( + "homeassistant.helpers.device_registry.async_entries_for_config_entry", + side_effect=[[], [device_entry]], + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=discovery_info, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_bluetooth_discovery_skip_no_account_id(hass: HomeAssistant) -> None: + """Test bluetooth discovery skips entries without account ID.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={}, # No account ID + unique_id="user123", + ) + entry.add_to_hass(hass) + + discovery_info = _get_discovery_info() + device = _get_mock_device() + + with patch( + "homeassistant.components.bluetooth.async_ble_device_from_address", + return_value=device, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=discovery_info, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + +@pytest.mark.parametrize( + ("stored_connections", "stored_ble_devices", "expect_reload"), + [ + pytest.param(set(), {}, True, id="new_ble_address_reloads_once"), + pytest.param( + {(dr.CONNECTION_BLUETOOTH, "aa:bb:cc:dd:ee:ff")}, + {}, + False, + id="known_address_missing_from_entry_data", + ), + pytest.param( + {(dr.CONNECTION_BLUETOOTH, "aa:bb:cc:dd:ee:ff")}, + {"Luba-ABC123": "aa:bb:cc:dd:ee:ff"}, + False, + id="known_address_already_in_entry_data", + ), + ], +) +async def test_bluetooth_discovery_only_reloads_for_new_address( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + stored_connections: set[tuple[str, str]], + stored_ble_devices: dict[str, str], + expect_reload: bool, +) -> None: + """Test an advertisement for a known address does not reload a loaded entry. + + A reload stands up a second client alongside the running one, and the two + MQTT sessions share a client_id, so the broker rejects both. Writing + CONF_BLE_DEVICES must therefore not trigger a reload of its own — the + device-registry merge above already schedules the single one a genuinely + new address needs. + """ + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_ACCOUNT_ID: "user123", CONF_BLE_DEVICES: stored_ble_devices}, + unique_id="user123", + state=config_entries.ConfigEntryState.LOADED, + ) + entry.add_to_hass(hass) + + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, "Luba-ABC123")}, + connections=stored_connections, + ) + + with ( + patch( + "homeassistant.components.bluetooth.async_ble_device_from_address", + return_value=_get_mock_device(), + ), + patch.object( + hass.config_entries, "async_schedule_reload" + ) as mock_schedule_reload, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=_get_discovery_info(), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert (mock_schedule_reload.call_count > 0) is expect_reload + + +async def test_user_step_does_not_reload_loaded_entry( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test listing candidates never reloads an entry that already owns a mower.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_ACCOUNT_ID: "user123"}, + unique_id="user123", + state=config_entries.ConfigEntryState.LOADED, + ) + entry.add_to_hass(hass) + + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, "Luba-ABC123")}, + connections=set(), + ) + + with ( + patch( + "homeassistant.components.mammotion.config_flow.async_discovered_service_info", + return_value=[_get_discovery_info()], + ), + patch( + "homeassistant.components.bluetooth.async_ble_device_from_address", + return_value=_get_mock_device(), + ), + patch.object( + hass.config_entries, "async_schedule_reload" + ) as mock_schedule_reload, + ): + await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mock_schedule_reload.assert_not_called() + + +async def test_reauth_flow( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test reauth updates the password and reloads the entry.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + with ( + patch( + "homeassistant.components.mammotion.config_flow.MammotionHTTP" + ) as mock_http, + patch( + "homeassistant.components.mammotion.async_setup_entry", return_value=True + ), + ): + mock_http.return_value.login_v2 = AsyncMock() + mock_http.return_value.login_info.userInformation.userAccount = "user123" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PASSWORD: "new-password"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_PASSWORD] == "new-password" diff --git a/tests/components/mammotion/test_init.py b/tests/components/mammotion/test_init.py new file mode 100644 index 00000000000000..625fe078b9d26e --- /dev/null +++ b/tests/components/mammotion/test_init.py @@ -0,0 +1,264 @@ +"""Tests for the Mammotion integration setup.""" + +import asyncio +from typing import Any +from unittest.mock import MagicMock, Mock + +from aiohttp import ClientConnectorError +from freezegun.api import FrozenDateTimeFactory +from pymammotion.data.model.device import MowingDevice +from pymammotion.transport.base import AuthError, LoginFailedError +from Tea.exceptions import UnretryableException + +from homeassistant.components.mammotion.const import DOMAIN +from homeassistant.components.mammotion.coordinator import DEFAULT_INTERVAL +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.const import EVENT_HOMEASSISTANT_FINAL_WRITE +from homeassistant.core import HomeAssistant + +from . import setup_integration +from .conftest import DEFAULT_NAME + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_load_unload_entry( + hass: HomeAssistant, + mock_mower_api: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test loading and unloading the config entry.""" + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + mock_mower_api.mammotion.stop.assert_awaited_once() + mock_mower_api.mammotion.remove_device.assert_awaited_once_with(DEFAULT_NAME) + + +async def test_setup_retry_on_failed_refresh( + hass: HomeAssistant, + mock_mower_api: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the entry is retried when the first data fetch fails.""" + mock_mower_api.update.return_value = None + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_retry_on_connection_error( + hass: HomeAssistant, + mock_mower_api: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the entry is retried when the cloud is unreachable.""" + mock_mower_api.mammotion.login_and_initiate_cloud.side_effect = ( + ClientConnectorError(Mock(), OSError("boom")) + ) + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_state_restored_from_store( + hass: HomeAssistant, + mock_mower_api: MagicMock, + mock_config_entry: MockConfigEntry, + hass_storage: dict[str, Any], +) -> None: + """Test the stored mower state is restored into the library on setup.""" + handle = Mock() + mock_mower_api.mammotion.mower.return_value = handle + storage_key = f"{DOMAIN}.{mock_config_entry.entry_id}" + hass_storage[storage_key] = { + "version": 1, + "minor_version": 0, + "key": storage_key, + "data": {DEFAULT_NAME: MowingDevice().to_dict()}, + } + + await setup_integration(hass, mock_config_entry) + + handle.restore_device.assert_called_once() + + +async def test_state_persisted_on_final_write( + hass: HomeAssistant, + mock_mower_api: MagicMock, + mock_config_entry: MockConfigEntry, + hass_storage: dict[str, Any], + freezer: FrozenDateTimeFactory, +) -> None: + """Test mower state is not written on every poll but on Home Assistant stop.""" + await setup_integration(hass, mock_config_entry) + storage_key = f"{DOMAIN}.{mock_config_entry.entry_id}" + + assert storage_key not in hass_storage + + freezer.tick(DEFAULT_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert storage_key not in hass_storage + + hass.bus.async_fire(EVENT_HOMEASSISTANT_FINAL_WRITE) + await hass.async_block_till_done() + + assert DEFAULT_NAME in hass_storage[storage_key]["data"] + + +async def test_state_persisted_on_unload( + hass: HomeAssistant, + mock_mower_api: MagicMock, + mock_config_entry: MockConfigEntry, + hass_storage: dict[str, Any], +) -> None: + """Test pending mower state is flushed when the entry is unloaded.""" + await setup_integration(hass, mock_config_entry) + storage_key = f"{DOMAIN}.{mock_config_entry.entry_id}" + + assert storage_key not in hass_storage + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert DEFAULT_NAME in hass_storage[storage_key]["data"] + + +async def test_remove_entry_keeps_other_entry_state( + hass: HomeAssistant, + mock_mower_api: MagicMock, + mock_config_entry: MockConfigEntry, + hass_storage: dict[str, Any], +) -> None: + """Test removing an entry leaves the stored state of other entries intact.""" + other_key = f"{DOMAIN}.other_entry_id" + hass_storage[other_key] = { + "version": 1, + "minor_version": 0, + "key": other_key, + "data": {"Luba-OTHER": MowingDevice().to_dict()}, + } + await setup_integration(hass, mock_config_entry) + + await hass.config_entries.async_remove(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert f"{DOMAIN}.{mock_config_entry.entry_id}" not in hass_storage + assert other_key in hass_storage + + +async def test_setup_error_on_unretryable_error( + hass: HomeAssistant, + mock_mower_api: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the entry errors out on an unretryable login failure.""" + mock_mower_api.mammotion.login_and_initiate_cloud.side_effect = ( + UnretryableException(Mock(), OSError("boom")) + ) + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_client_stopped_before_unload_returns( + hass: HomeAssistant, + mock_mower_api: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the client is torn down before unload completes. + + A reload sets the entry up again as soon as unload returns. If the old + client is still holding its MQTT session, both sessions connect with the + same client_id and the broker rejects them. + """ + await setup_integration(hass, mock_config_entry) + + stopped = False + + async def _stop() -> None: + # Suspend, as disconnecting a real MQTT transport does. + await asyncio.sleep(0) + nonlocal stopped + stopped = True + + mock_mower_api.mammotion.stop.side_effect = _stop + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + + assert stopped, "unload returned while the client still held its connection" + + +async def test_reload_does_not_overlap_clients( + hass: HomeAssistant, + mock_mower_api: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a reload stops the old client before the new one logs in.""" + await setup_integration(hass, mock_config_entry) + + call_order: list[str] = [] + + async def _stop() -> None: + await asyncio.sleep(0) + call_order.append("stop") + + async def _login(*args: Any, **kwargs: Any) -> None: + call_order.append("login") + + mock_mower_api.mammotion.stop.side_effect = _stop + mock_mower_api.mammotion.restore_credentials.side_effect = _login + mock_mower_api.mammotion.login_and_initiate_cloud.side_effect = _login + + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert call_order == ["stop", "login"] + + +async def test_login_failure_starts_reauth( + hass: HomeAssistant, + mock_mower_api: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test stored credentials that no longer work prompt for reauth.""" + mock_mower_api.mammotion.login_and_initiate_cloud.side_effect = LoginFailedError( + "user123", "bad password" + ) + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN) + assert len(flows) == 1 + assert flows[0]["context"]["source"] == SOURCE_REAUTH + + +async def test_auth_error_while_polling_starts_reauth( + hass: HomeAssistant, + mock_mower_api: MagicMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test an auth failure during a poll prompts for reauth.""" + await setup_integration(hass, mock_config_entry) + + mock_mower_api.update.side_effect = AuthError("user123", "token rejected") + + freezer.tick(DEFAULT_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN) + assert len(flows) == 1 + assert flows[0]["context"]["source"] == SOURCE_REAUTH diff --git a/tests/components/mammotion/test_lawn_mower.py b/tests/components/mammotion/test_lawn_mower.py new file mode 100644 index 00000000000000..39500ede565137 --- /dev/null +++ b/tests/components/mammotion/test_lawn_mower.py @@ -0,0 +1,235 @@ +"""Test for the Mammotion lawn_mower platform.""" + +from datetime import timedelta +from unittest.mock import MagicMock + +from freezegun.api import FrozenDateTimeFactory +from pymammotion.data.model.device import MowingDevice +from pymammotion.utility.constant.device_constant import WorkMode +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.lawn_mower import ( + DOMAIN as LAWN_MOWER_DOMAIN, + SERVICE_DOCK, + SERVICE_PAUSE, + SERVICE_START_MOWING, + LawnMowerActivity, +) +from homeassistant.components.mammotion.const import DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + +ENTITY_ID = "lawn_mower.garden_luba" + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_mower_api: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("sys_status", "charge_state", "expected_state"), + [ + pytest.param(WorkMode.MODE_WORKING, 0, LawnMowerActivity.MOWING, id="mowing"), + pytest.param(WorkMode.MODE_PAUSE, 0, LawnMowerActivity.PAUSED, id="paused"), + pytest.param( + WorkMode.MODE_READY, 0, LawnMowerActivity.PAUSED, id="ready-undocked" + ), + pytest.param(WorkMode.MODE_READY, 1, LawnMowerActivity.DOCKED, id="docked"), + pytest.param( + WorkMode.MODE_RETURNING, 0, LawnMowerActivity.RETURNING, id="returning" + ), + pytest.param(WorkMode.MODE_LOCK, 0, LawnMowerActivity.ERROR, id="locked"), + pytest.param(999, 0, STATE_UNKNOWN, id="unknown-mode"), + ], +) +@pytest.mark.usefixtures("mock_mower_api") +async def test_activity( + hass: HomeAssistant, + mock_mowing_device: MowingDevice, + mock_config_entry: MockConfigEntry, + sys_status: int, + charge_state: int, + expected_state: str, +) -> None: + """Test the mower state reflects the reported device status.""" + mock_mowing_device.report_data.dev.sys_status = sys_status + mock_mowing_device.report_data.dev.charge_state = charge_state + + await setup_integration(hass, mock_config_entry) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == expected_state + + +@pytest.mark.parametrize( + ("service", "sys_status", "charge_state", "expected_commands"), + [ + pytest.param( + SERVICE_START_MOWING, WorkMode.MODE_READY, 0, ["start_job"], id="start" + ), + pytest.param( + SERVICE_START_MOWING, + WorkMode.MODE_PAUSE, + 0, + ["resume_execute_task"], + id="resume", + ), + pytest.param( + SERVICE_DOCK, + WorkMode.MODE_WORKING, + 0, + ["pause_execute_task", "return_to_dock"], + id="dock-while-mowing", + ), + pytest.param( + SERVICE_DOCK, WorkMode.MODE_READY, 0, ["return_to_dock"], id="dock-ready" + ), + pytest.param( + SERVICE_DOCK, WorkMode.MODE_RETURNING, 0, [], id="dock-already-returning" + ), + pytest.param(SERVICE_DOCK, WorkMode.MODE_READY, 1, [], id="dock-when-docked"), + pytest.param( + SERVICE_PAUSE, WorkMode.MODE_WORKING, 0, ["pause_execute_task"], id="pause" + ), + pytest.param( + SERVICE_PAUSE, + WorkMode.MODE_RETURNING, + 0, + ["cancel_return_to_dock"], + id="pause-returning", + ), + pytest.param(SERVICE_PAUSE, WorkMode.MODE_READY, 0, [], id="pause-idle"), + ], +) +async def test_services( + hass: HomeAssistant, + mock_mower_api: MagicMock, + mock_mowing_device: MowingDevice, + mock_config_entry: MockConfigEntry, + service: str, + sys_status: int, + charge_state: int, + expected_commands: list[str], +) -> None: + """Test the lawn mower services send the expected commands.""" + mock_mowing_device.report_data.dev.sys_status = sys_status + mock_mowing_device.report_data.dev.charge_state = charge_state + + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + LAWN_MOWER_DOMAIN, service, {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True + ) + + assert [ + call.args[1] for call in mock_mower_api.async_send_command.call_args_list + ] == expected_commands + assert mock_mower_api.async_request_iot_sync.call_count == ( + 1 if expected_commands else 0 + ) + + +@pytest.mark.parametrize( + ("service", "sys_status", "expected_translation_key"), + [ + pytest.param( + SERVICE_START_MOWING, WorkMode.MODE_READY, "start_failed", id="start" + ), + pytest.param( + SERVICE_START_MOWING, WorkMode.MODE_PAUSE, "resume_failed", id="resume" + ), + pytest.param(SERVICE_DOCK, WorkMode.MODE_WORKING, "pause_failed", id="dock"), + pytest.param(SERVICE_PAUSE, WorkMode.MODE_WORKING, "pause_failed", id="pause"), + ], +) +async def test_services_command_failure( + hass: HomeAssistant, + mock_mower_api: MagicMock, + mock_mowing_device: MowingDevice, + mock_config_entry: MockConfigEntry, + service: str, + sys_status: int, + expected_translation_key: str, +) -> None: + """Test the lawn mower services raise on command failures.""" + mock_mowing_device.report_data.dev.sys_status = sys_status + mock_mowing_device.report_data.dev.charge_state = 0 + mock_mower_api.async_send_command.return_value = False + + await setup_integration(hass, mock_config_entry) + + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + LAWN_MOWER_DOMAIN, service, {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True + ) + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == expected_translation_key + assert mock_mower_api.async_request_iot_sync.call_count == 1 + + +@pytest.mark.parametrize("service", [SERVICE_START_MOWING, SERVICE_DOCK, SERVICE_PAUSE]) +@pytest.mark.usefixtures("mock_mower_api") +async def test_services_device_not_ready( + hass: HomeAssistant, + mock_mowing_device: MowingDevice, + mock_config_entry: MockConfigEntry, + service: str, +) -> None: + """Test the lawn mower services raise when the device is not ready.""" + mock_mowing_device.report_data.dev.sys_status = None + + await setup_integration(hass, mock_config_entry) + + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + LAWN_MOWER_DOMAIN, service, {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True + ) + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "device_not_ready" + + +async def test_availability( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_mower_api: MagicMock, + mock_mowing_device: MowingDevice, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the entity becomes unavailable on failed updates or offline device.""" + await setup_integration(hass, mock_config_entry) + assert hass.states.get(ENTITY_ID).state != STATE_UNAVAILABLE + + mock_mower_api.update.return_value = None + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE + + mock_mower_api.update.return_value = mock_mowing_device + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass.states.get(ENTITY_ID).state != STATE_UNAVAILABLE + + mock_mower_api.is_online.return_value = False + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE