From 6e653a9023aa2c2e4294073304cc5c4857a2bd63 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Wed, 15 May 2024 20:34:36 +1200 Subject: [PATCH 01/66] initial start of HA mammotion --- .../components/mammotion/__init__.py | 26 ++++ .../components/mammotion/config_flow.py | 68 ++++++++++ homeassistant/components/mammotion/const.py | 8 ++ .../components/mammotion/coordinator.py | 120 ++++++++++++++++++ .../components/mammotion/lawn_mower.py | 74 +++++++++++ .../components/mammotion/manifest.json | 23 ++++ .../components/mammotion/strings.json | 16 +++ homeassistant/generated/integrations.json | 11 ++ 8 files changed, 346 insertions(+) create mode 100644 homeassistant/components/mammotion/__init__.py create mode 100644 homeassistant/components/mammotion/config_flow.py create mode 100644 homeassistant/components/mammotion/const.py create mode 100644 homeassistant/components/mammotion/coordinator.py create mode 100644 homeassistant/components/mammotion/lawn_mower.py create mode 100644 homeassistant/components/mammotion/manifest.json create mode 100644 homeassistant/components/mammotion/strings.json diff --git a/homeassistant/components/mammotion/__init__.py b/homeassistant/components/mammotion/__init__.py new file mode 100644 index 0000000000000..d7912ab9a9074 --- /dev/null +++ b/homeassistant/components/mammotion/__init__.py @@ -0,0 +1,26 @@ +"""The Mammotion Luba integration.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN + +PLATFORMS: list[Platform] = [Platform.LAWN_MOWER] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Mammotion Luba from a config entry.""" + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/mammotion/config_flow.py b/homeassistant/components/mammotion/config_flow.py new file mode 100644 index 0000000000000..c9cec4c61d246 --- /dev/null +++ b/homeassistant/components/mammotion/config_flow.py @@ -0,0 +1,68 @@ +"""Config flow for Mammotion Luba.""" +from bleak import BLEDevice +from homeassistant.components import bluetooth +from homeassistant.components.bluetooth import BluetoothServiceInfo +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_ADDRESS +from typing import Any +from .const import DOMAIN + + +class LubaConfigFlow(ConfigFlow, domain=DOMAIN): + + _address: str | None = None + _discovered_devices: dict[str, BLEDevice] = {} + + async def async_step_bluetooth( + self, discovery_info: BluetoothServiceInfo + ) -> ConfigFlowResult: + await self.async_set_unique_id(discovery_info.address) + self._abort_if_unique_id_configured() + + device = bluetooth.async_ble_device_from_address( + self.hass, discovery_info.address + ) + + self._address = device.address + self._discovered_devices = {device.address: device} + + self.context["title_placeholders"] = {"name": device.name} + + 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._address + device = self._discovered_devices[self._address] + + if user_input is not None: + return self.async_create_entry(title=device.name, data={ + CONF_ADDRESS: device.address, + }) + + self._set_confirm_only() + return self.async_show_form( + step_id="bluetooth_confirm", + description_placeholders=self.context["title_placeholders"], + ) + + 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: + address = user_input[CONF_ADDRESS] + await self.async_set_unique_id(address, raise_on_progress=False) + self._abort_if_unique_id_configured() + + device = self._discovered_devices[address] + + self.context["title_placeholders"] = { + "name": device.name, + } + + return self.async_create_entry(title=device.name, data={ + CONF_ADDRESS: device.address, + }) diff --git a/homeassistant/components/mammotion/const.py b/homeassistant/components/mammotion/const.py new file mode 100644 index 0000000000000..ae1ff03f0d82e --- /dev/null +++ b/homeassistant/components/mammotion/const.py @@ -0,0 +1,8 @@ +"""Constants for the Mammotion Luba integration.""" + +import logging +from typing import Final + +DOMAIN: Final = "luba" + +LOGGER: Final = logging.getLogger(__package__) diff --git a/homeassistant/components/mammotion/coordinator.py b/homeassistant/components/mammotion/coordinator.py new file mode 100644 index 0000000000000..8dc6298945865 --- /dev/null +++ b/homeassistant/components/mammotion/coordinator.py @@ -0,0 +1,120 @@ +"""Provides the mammotion DataUpdateCoordinator.""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +from typing import TYPE_CHECKING + +import mammotion + +from homeassistant.components import bluetooth +from homeassistant.components.bluetooth.active_update_coordinator import ( + ActiveBluetoothDataUpdateCoordinator, +) +from homeassistant.core import CoreState, HomeAssistant, callback + +if TYPE_CHECKING: + from bleak.backends.device import BLEDevice + + +_LOGGER = logging.getLogger(__name__) + +DEVICE_STARTUP_TIMEOUT = 30 + + +class MammotionDataUpdateCoordinator(ActiveBluetoothDataUpdateCoordinator[None]): + """Class to manage fetching mammotion data.""" + + def __init__( + self, + hass: HomeAssistant, + logger: logging.Logger, + ble_device: BLEDevice, + device: mammotion.MammotionLubaDevice, + base_unique_id: str, + device_name: str, + connectable: bool, + ) -> None: + """Initialize global mammotion data updater.""" + super().__init__( + hass=hass, + logger=logger, + address=ble_device.address, + needs_poll_method=self._needs_poll, + poll_method=self._async_update, + mode=bluetooth.BluetoothScanningMode.ACTIVE, + connectable=connectable, + ) + self.ble_device = ble_device + self.device = device + self.device_name = device_name + self.base_unique_id = base_unique_id + self._ready_event = asyncio.Event() + self._was_unavailable = True + + @callback + def _needs_poll( + self, + service_info: bluetooth.BluetoothServiceInfoBleak, + seconds_since_last_poll: float | None, + ) -> bool: + # Only poll if hass is running, we need to poll, + # and we actually have a way to connect to the device + return ( + self.hass.state is CoreState.running + and self.device.poll_needed(seconds_since_last_poll) + and bool( + bluetooth.async_ble_device_from_address( + self.hass, service_info.device.address, connectable=True + ) + ) + ) + + async def _async_update( + self, service_info: bluetooth.BluetoothServiceInfoBleak + ) -> None: + """Poll the device.""" + await self.device.update() + + @callback + def _async_handle_unavailable( + self, service_info: bluetooth.BluetoothServiceInfoBleak + ) -> None: + """Handle the device going unavailable.""" + super()._async_handle_unavailable(service_info) + self._was_unavailable = True + + @callback + def _async_handle_bluetooth_event( + self, + service_info: bluetooth.BluetoothServiceInfoBleak, + change: bluetooth.BluetoothChange, + ) -> None: + """Handle a Bluetooth event.""" + self.ble_device = service_info.device + if not ( + adv := mammotion.parse_advertisement_data( + service_info.device, service_info.advertisement, self.model + ) + ): + return + if "modelName" in adv.data: + self._ready_event.set() + _LOGGER.debug( + "%s: mammotion Luba data: %s", self.ble_device.address, self.device.data + ) + if not self.device.advertisement_changed(adv) and not self._was_unavailable: + return + self._was_unavailable = False + self.device.update_from_advertisement(adv) + super()._async_handle_bluetooth_event(service_info, change) + + async def async_wait_ready(self) -> bool: + """Wait for the device to be ready.""" + with contextlib.suppress(TimeoutError): + async with asyncio.timeout(DEVICE_STARTUP_TIMEOUT): + await self._ready_event.wait() + return True + return False diff --git a/homeassistant/components/mammotion/lawn_mower.py b/homeassistant/components/mammotion/lawn_mower.py new file mode 100644 index 0000000000000..cc33b12635e87 --- /dev/null +++ b/homeassistant/components/mammotion/lawn_mower.py @@ -0,0 +1,74 @@ +"""Luba lawn mowers.""" +from __future__ import annotations + +from homeassistant.components.lawn_mower import ( + LawnMowerActivity, + LawnMowerEntity, + LawnMowerEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up luba lawn mower.""" + async_add_entities( + [ + LubaLawnMower( + "uuid of Luba", + "Luba (serial number or name)", + LawnMowerActivity.PAUSED, # find out what state Luba is in + LawnMowerEntityFeature.DOCK + | LawnMowerEntityFeature.PAUSE + | LawnMowerEntityFeature.START_MOWING, + ), + ] + ) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Luba config entry.""" + await async_setup_platform(hass, {}, async_add_entities) + + +class LubaLawnMower(LawnMowerEntity): + """Representation of a Luba lawn mower.""" + + def __init__( + self, + unique_id: str, + name: str, + activity: LawnMowerActivity, + features: LawnMowerEntityFeature = LawnMowerEntityFeature(0), + ) -> None: + """Initialize the lawn mower.""" + self._attr_name = name + self._attr_unique_id = unique_id + self._attr_supported_features = features + self._attr_activity = activity + + async def async_start_mowing(self) -> None: + """Start mowing.""" + self._attr_activity = LawnMowerActivity.MOWING + self.async_write_ha_state() + + async def async_dock(self) -> None: + """Start docking.""" + self._attr_activity = LawnMowerActivity.DOCKED + self.async_write_ha_state() + + async def async_pause(self) -> None: + """Pause mower.""" + self._attr_activity = LawnMowerActivity.PAUSED + self.async_write_ha_state() diff --git a/homeassistant/components/mammotion/manifest.json b/homeassistant/components/mammotion/manifest.json new file mode 100644 index 0000000000000..942a4abec58ae --- /dev/null +++ b/homeassistant/components/mammotion/manifest.json @@ -0,0 +1,23 @@ +{ + "domain": "mammotion", + "name": "Mammotion", + "version": "0.0.1", + "integration_type": "device", + "bluetooth": [ + { + "local_name": "Luba-*", + "service_uuid": "0000ffff-0000-1000-8000-00805f9b34fb" + }, + { + "local_name": "Yuka-*", + "service_uuid": "0000ffff-0000-1000-8000-00805f9b34fb" + } + ], + "codeowners": ["@mikey0000"], + "config_flow": true, + "dependencies": ["bluetooth_adapters"], + "documentation": "https://www.home-assistant.io/integrations/mammotion", + "iot_class": "local_polling", + "requirements": ["pyluba==0.0.5"] +} + diff --git a/homeassistant/components/mammotion/strings.json b/homeassistant/components/mammotion/strings.json new file mode 100644 index 0000000000000..2200bea45f08f --- /dev/null +++ b/homeassistant/components/mammotion/strings.json @@ -0,0 +1,16 @@ +{ + "config": { + "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "step": { + "user": { + "description": "[%key:component::bluetooth::config::step::user::description%]", + "data": { + "address": "[%key:common::config_flow::data::device%]" + } + }, + "bluetooth_confirm": { + "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" + } + } + } +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 58949fe0594e8..eec867f62eca2 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4038,6 +4038,17 @@ "config_flow": true, "iot_class": "cloud_push" }, + "mammotion": { + "name": "Mammotion", + "integrations": { + "mammotion": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push", + "name": "Mammotion Bluetooth" + } + } + }, "marantz": { "name": "Marantz", "integrations": { From a4c2d296035589f54c02aacc34973ae9786e64b5 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Sat, 18 May 2024 10:47:32 +1200 Subject: [PATCH 02/66] tie the coordinator to init --- .../components/mammotion/__init__.py | 52 ++++++++++++++++++- .../components/mammotion/coordinator.py | 7 ++- 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/mammotion/__init__.py b/homeassistant/components/mammotion/__init__.py index d7912ab9a9074..ea6c60661e05c 100644 --- a/homeassistant/components/mammotion/__init__.py +++ b/homeassistant/components/mammotion/__init__.py @@ -1,18 +1,68 @@ """The Mammotion Luba integration.""" from __future__ import annotations +import mammotion + from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import ( + CONF_ADDRESS, + CONF_MAC, + CONF_NAME, + Platform, +) from homeassistant.core import HomeAssistant from .const import DOMAIN +from .coordinator import MammotionDataUpdateCoordinator PLATFORMS: list[Platform] = [Platform.LAWN_MOWER] +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Mammotion Luba from a config entry.""" + assert entry.unique_id is not None + hass.data.setdefault(DOMAIN, {}) + if CONF_ADDRESS not in entry.data and CONF_MAC in entry.data: + # Bleak uses addresses not mac addresses which are actually + # UUIDs on some platforms (MacOS). + mac = entry.data[CONF_MAC] + if "-" not in mac: + mac = dr.format_mac(mac) + hass.config_entries.async_update_entry( + entry, + data={**entry.data, CONF_ADDRESS: mac}, + ) + + if not entry.options: + hass.config_entries.async_update_entry( + entry, + options={CONF_RETRY_COUNT: DEFAULT_RETRY_COUNT}, + ) + + address: str = entry.data[CONF_ADDRESS] + ble_device = bluetooth.async_ble_device_from_address( + hass, address.upper(), connectable=True + ) + if not ble_device: + raise ConfigEntryNotReady( + f"Could not find Mammotion lawn mower with address {address}" + ) + + device = MammotionBaseBLEDevice(ble_device) + + coordinator = hass.data[DOMAIN][entry.entry_id] = MammotionDataUpdateCoordinator(hass, _LOGGER, ble_device, device, + entry.unique_id, + entry.data.get(CONF_NAME, entry.title)) + + entry.async_on_unload(coordinator.async_start()) + if not await coordinator.async_wait_ready(): + raise ConfigEntryNotReady(f"{address} is not advertising state") + + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/mammotion/coordinator.py b/homeassistant/components/mammotion/coordinator.py index 8dc6298945865..f897c99c27746 100644 --- a/homeassistant/components/mammotion/coordinator.py +++ b/homeassistant/components/mammotion/coordinator.py @@ -34,8 +34,7 @@ def __init__( ble_device: BLEDevice, device: mammotion.MammotionLubaDevice, base_unique_id: str, - device_name: str, - connectable: bool, + device_name: str ) -> None: """Initialize global mammotion data updater.""" super().__init__( @@ -45,7 +44,7 @@ def __init__( needs_poll_method=self._needs_poll, poll_method=self._async_update, mode=bluetooth.BluetoothScanningMode.ACTIVE, - connectable=connectable, + connectable=True, ) self.ble_device = ble_device self.device = device @@ -76,7 +75,7 @@ async def _async_update( self, service_info: bluetooth.BluetoothServiceInfoBleak ) -> None: """Poll the device.""" - await self.device.update() + await self.device.start_sync() @callback def _async_handle_unavailable( From 3b6a74af815d45efd3a07292ed61e55bde90ed5e Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Sun, 19 May 2024 10:40:10 +1200 Subject: [PATCH 03/66] tidy up and fix a few more things --- .../components/mammotion/__init__.py | 21 ++++++++++++++----- .../components/mammotion/config_flow.py | 17 ++++++++++++++- homeassistant/components/mammotion/const.py | 4 +++- .../components/mammotion/coordinator.py | 2 +- .../components/mammotion/manifest.json | 4 ++-- 5 files changed, 38 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/mammotion/__init__.py b/homeassistant/components/mammotion/__init__.py index ea6c60661e05c..827edfbb1ed89 100644 --- a/homeassistant/components/mammotion/__init__.py +++ b/homeassistant/components/mammotion/__init__.py @@ -1,8 +1,12 @@ """The Mammotion Luba integration.""" from __future__ import annotations -import mammotion +import logging + +from pyluba.mammotion.devices import MammotionBaseBLEDevice + +from homeassistant.components import bluetooth from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_ADDRESS, @@ -11,15 +15,17 @@ Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr -from .const import DOMAIN +from .const import DOMAIN, DEFAULT_RETRY_COUNT, CONF_RETRY_COUNT from .coordinator import MammotionDataUpdateCoordinator PLATFORMS: list[Platform] = [Platform.LAWN_MOWER] - _LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Mammotion Luba from a config entry.""" @@ -54,8 +60,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: device = MammotionBaseBLEDevice(ble_device) coordinator = hass.data[DOMAIN][entry.entry_id] = MammotionDataUpdateCoordinator(hass, _LOGGER, ble_device, device, - entry.unique_id, - entry.data.get(CONF_NAME, entry.title)) + entry.unique_id, + entry.data.get(CONF_NAME, + entry.title)) entry.async_on_unload(coordinator.async_start()) if not await coordinator.async_wait_ready(): @@ -67,6 +74,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" diff --git a/homeassistant/components/mammotion/config_flow.py b/homeassistant/components/mammotion/config_flow.py index c9cec4c61d246..5da493edd964f 100644 --- a/homeassistant/components/mammotion/config_flow.py +++ b/homeassistant/components/mammotion/config_flow.py @@ -1,4 +1,6 @@ """Config flow for Mammotion Luba.""" +import logging + from bleak import BLEDevice from homeassistant.components import bluetooth from homeassistant.components.bluetooth import BluetoothServiceInfo @@ -7,15 +9,28 @@ from typing import Any from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) + + +class MammotionConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Mammotion""" -class LubaConfigFlow(ConfigFlow, domain=DOMAIN): + VERSION = 1 _address: str | None = None _discovered_devices: dict[str, BLEDevice] = {} + + def __init__(self) -> None: + """Initialize the config flow.""" + pass + + async def async_step_bluetooth( self, discovery_info: BluetoothServiceInfo ) -> ConfigFlowResult: + """Handle the bluetooth discovery step.""" + _LOGGER.debug("Discovered bluetooth device: %s", discovery_info.as_dict()) await self.async_set_unique_id(discovery_info.address) self._abort_if_unique_id_configured() diff --git a/homeassistant/components/mammotion/const.py b/homeassistant/components/mammotion/const.py index ae1ff03f0d82e..2102873636e63 100644 --- a/homeassistant/components/mammotion/const.py +++ b/homeassistant/components/mammotion/const.py @@ -3,6 +3,8 @@ import logging from typing import Final -DOMAIN: Final = "luba" +DOMAIN: Final = "mammotion" +DEFAULT_RETRY_COUNT = 3 +CONF_RETRY_COUNT = "retry_count" LOGGER: Final = logging.getLogger(__package__) diff --git a/homeassistant/components/mammotion/coordinator.py b/homeassistant/components/mammotion/coordinator.py index f897c99c27746..a1f0e3795d61d 100644 --- a/homeassistant/components/mammotion/coordinator.py +++ b/homeassistant/components/mammotion/coordinator.py @@ -7,7 +7,7 @@ import logging from typing import TYPE_CHECKING -import mammotion +import pyluba from homeassistant.components import bluetooth from homeassistant.components.bluetooth.active_update_coordinator import ( diff --git a/homeassistant/components/mammotion/manifest.json b/homeassistant/components/mammotion/manifest.json index 942a4abec58ae..e28b54a81777a 100644 --- a/homeassistant/components/mammotion/manifest.json +++ b/homeassistant/components/mammotion/manifest.json @@ -11,13 +11,13 @@ { "local_name": "Yuka-*", "service_uuid": "0000ffff-0000-1000-8000-00805f9b34fb" - } + } ], "codeowners": ["@mikey0000"], "config_flow": true, "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/mammotion", "iot_class": "local_polling", - "requirements": ["pyluba==0.0.5"] + "requirements": ["pyluba==0.0.6"] } From 8373f28657dab4402eb59849db5d1f7f1fb18cbb Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Sun, 19 May 2024 01:32:36 +0000 Subject: [PATCH 04/66] fix config flow and other small issues --- .../components/mammotion/__init__.py | 24 ++++----- .../components/mammotion/config_flow.py | 53 ++++++++++++++----- .../components/mammotion/coordinator.py | 37 ++++++------- .../components/mammotion/manifest.json | 40 +++++++------- .../components/mammotion/strings.json | 31 ++++++----- requirements_all.txt | 3 ++ 6 files changed, 113 insertions(+), 75 deletions(-) diff --git a/homeassistant/components/mammotion/__init__.py b/homeassistant/components/mammotion/__init__.py index 827edfbb1ed89..cc1905c711b36 100644 --- a/homeassistant/components/mammotion/__init__.py +++ b/homeassistant/components/mammotion/__init__.py @@ -1,6 +1,6 @@ """The Mammotion Luba integration.""" -from __future__ import annotations +from __future__ import annotations import logging @@ -8,17 +8,12 @@ from homeassistant.components import bluetooth from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_ADDRESS, - CONF_MAC, - CONF_NAME, - Platform, -) +from homeassistant.const import CONF_ADDRESS, CONF_MAC, CONF_NAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr -from .const import DOMAIN, DEFAULT_RETRY_COUNT, CONF_RETRY_COUNT +from .const import CONF_RETRY_COUNT, DEFAULT_RETRY_COUNT, DOMAIN from .coordinator import MammotionDataUpdateCoordinator PLATFORMS: list[Platform] = [Platform.LAWN_MOWER] @@ -59,10 +54,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: device = MammotionBaseBLEDevice(ble_device) - coordinator = hass.data[DOMAIN][entry.entry_id] = MammotionDataUpdateCoordinator(hass, _LOGGER, ble_device, device, - entry.unique_id, - entry.data.get(CONF_NAME, - entry.title)) + coordinator = hass.data[DOMAIN][entry.entry_id] = MammotionDataUpdateCoordinator( + hass, + _LOGGER, + ble_device, + device, + entry.unique_id, + entry.data.get(CONF_NAME, entry.title), + ) entry.async_on_unload(coordinator.async_start()) if not await coordinator.async_wait_ready(): @@ -74,6 +73,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True + async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/mammotion/config_flow.py b/homeassistant/components/mammotion/config_flow.py index 5da493edd964f..564e39ac06b91 100644 --- a/homeassistant/components/mammotion/config_flow.py +++ b/homeassistant/components/mammotion/config_flow.py @@ -1,30 +1,34 @@ """Config flow for Mammotion Luba.""" + import logging +from typing import Any from bleak import BLEDevice +import voluptuous as vol + from homeassistant.components import bluetooth -from homeassistant.components.bluetooth import BluetoothServiceInfo +from homeassistant.components.bluetooth import ( + BluetoothServiceInfo, + async_discovered_service_info, +) from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_ADDRESS -from typing import Any + from .const import DOMAIN _LOGGER = logging.getLogger(__name__) class MammotionConfigFlow(ConfigFlow, domain=DOMAIN): - """Handle a config flow for Mammotion""" + """Handle a config flow for Mammotion.""" VERSION = 1 _address: str | None = None _discovered_devices: dict[str, BLEDevice] = {} - def __init__(self) -> None: """Initialize the config flow.""" - pass - async def async_step_bluetooth( self, discovery_info: BluetoothServiceInfo @@ -53,9 +57,12 @@ async def async_step_bluetooth_confirm( device = self._discovered_devices[self._address] if user_input is not None: - return self.async_create_entry(title=device.name, data={ - CONF_ADDRESS: device.address, - }) + return self.async_create_entry( + title=device.name, + data={ + CONF_ADDRESS: device.address, + }, + ) self._set_confirm_only() return self.async_show_form( @@ -78,6 +85,28 @@ async def async_step_user( "name": device.name, } - return self.async_create_entry(title=device.name, data={ - CONF_ADDRESS: device.address, - }) + return self.async_create_entry( + title=device.name, + data={ + CONF_ADDRESS: device.address, + }, + ) + + current_addresses = self._async_current_ids() + for discovery_info in async_discovered_service_info(self.hass): + address = discovery_info.address + if address in current_addresses or address in self._discovered_devices: + continue + + self._discovered_devices[address] = ( + device.title or device.get_device_name() or discovery_info.name + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_ADDRESS): vol.In(self._discovered_devices), + }, + ), + ) diff --git a/homeassistant/components/mammotion/coordinator.py b/homeassistant/components/mammotion/coordinator.py index a1f0e3795d61d..42fbcc6d05bfc 100644 --- a/homeassistant/components/mammotion/coordinator.py +++ b/homeassistant/components/mammotion/coordinator.py @@ -7,7 +7,7 @@ import logging from typing import TYPE_CHECKING -import pyluba +from pyluba.mammotion.devices import MammotionBaseBLEDevice from homeassistant.components import bluetooth from homeassistant.components.bluetooth.active_update_coordinator import ( @@ -32,9 +32,9 @@ def __init__( hass: HomeAssistant, logger: logging.Logger, ble_device: BLEDevice, - device: mammotion.MammotionLubaDevice, + device: MammotionBaseBLEDevice, base_unique_id: str, - device_name: str + device_name: str, ) -> None: """Initialize global mammotion data updater.""" super().__init__( @@ -93,21 +93,22 @@ def _async_handle_bluetooth_event( ) -> None: """Handle a Bluetooth event.""" self.ble_device = service_info.device - if not ( - adv := mammotion.parse_advertisement_data( - service_info.device, service_info.advertisement, self.model - ) - ): - return - if "modelName" in adv.data: - self._ready_event.set() - _LOGGER.debug( - "%s: mammotion Luba data: %s", self.ble_device.address, self.device.data - ) - if not self.device.advertisement_changed(adv) and not self._was_unavailable: - return - self._was_unavailable = False - self.device.update_from_advertisement(adv) + print(service_info) + # if not ( + # adv := parse_advertisement_data( + # service_info.device, service_info.advertisement, self.model + # ) + # ): + # return + # if "modelName" in adv.data: + # self._ready_event.set() + # _LOGGER.debug( + # "%s: mammotion Luba data: %s", self.ble_device.address, self.device.data + # ) + # if not self.device.advertisement_changed(adv) and not self._was_unavailable: + # return + # self._was_unavailable = False + # self.device.update_from_advertisement(adv) super()._async_handle_bluetooth_event(service_info, change) async def async_wait_ready(self) -> bool: diff --git a/homeassistant/components/mammotion/manifest.json b/homeassistant/components/mammotion/manifest.json index e28b54a81777a..a4963ce863d31 100644 --- a/homeassistant/components/mammotion/manifest.json +++ b/homeassistant/components/mammotion/manifest.json @@ -1,23 +1,21 @@ { - "domain": "mammotion", - "name": "Mammotion", - "version": "0.0.1", - "integration_type": "device", - "bluetooth": [ - { - "local_name": "Luba-*", - "service_uuid": "0000ffff-0000-1000-8000-00805f9b34fb" - }, - { - "local_name": "Yuka-*", - "service_uuid": "0000ffff-0000-1000-8000-00805f9b34fb" - } - ], - "codeowners": ["@mikey0000"], - "config_flow": true, - "dependencies": ["bluetooth_adapters"], - "documentation": "https://www.home-assistant.io/integrations/mammotion", - "iot_class": "local_polling", - "requirements": ["pyluba==0.0.6"] + "domain": "mammotion", + "name": "Mammotion", + "integration_type": "device", + "bluetooth": [ + { + "local_name": "Luba-*", + "service_uuid": "0000ffff-0000-1000-8000-00805f9b34fb" + }, + { + "local_name": "Yuka-*", + "service_uuid": "0000ffff-0000-1000-8000-00805f9b34fb" + } + ], + "codeowners": ["@mikey0000"], + "config_flow": true, + "dependencies": ["bluetooth_adapters"], + "documentation": "https://www.home-assistant.io/integrations/mammotion", + "iot_class": "local_polling", + "requirements": ["pyluba==0.0.8"] } - diff --git a/homeassistant/components/mammotion/strings.json b/homeassistant/components/mammotion/strings.json index 2200bea45f08f..1f9443fad59f2 100644 --- a/homeassistant/components/mammotion/strings.json +++ b/homeassistant/components/mammotion/strings.json @@ -1,16 +1,23 @@ { - "config": { - "flow_title": "[%key:component::bluetooth::config::flow_title%]", - "step": { - "user": { - "description": "[%key:component::bluetooth::config::step::user::description%]", - "data": { - "address": "[%key:common::config_flow::data::device%]" - } - }, - "bluetooth_confirm": { - "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" - } + "config": { + "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "step": { + "user": { + "description": "[%key:component::bluetooth::config::step::user::description%]", + "data": { + "address": "[%key:common::config_flow::data::device%]" } + }, + "bluetooth_confirm": { + "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" + } + }, + "abort": { + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" } + } } diff --git a/requirements_all.txt b/requirements_all.txt index 9f13463d26484..5e2b2a7c4ee67 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2324,6 +2324,9 @@ pylitejet==0.6.3 # homeassistant.components.litterrobot pylitterbot==2025.5.0 +# homeassistant.components.mammotion +pyluba==0.0.8 + # homeassistant.components.lutron_caseta pylutron-caseta==0.28.0 From 76693d40f42f38448911e19f942ef412bc8e6fd8 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Fri, 24 May 2024 16:37:14 +1200 Subject: [PATCH 05/66] further fixes that make activity work --- .../components/mammotion/__init__.py | 10 +- .../components/mammotion/config_flow.py | 26 +++-- .../components/mammotion/coordinator.py | 92 +++------------- .../components/mammotion/lawn_mower.py | 104 ++++++++++++++---- .../components/mammotion/manifest.json | 4 +- homeassistant/generated/bluetooth.py | 10 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 11 +- requirements_all.txt | 2 +- 9 files changed, 132 insertions(+), 128 deletions(-) diff --git a/homeassistant/components/mammotion/__init__.py b/homeassistant/components/mammotion/__init__.py index cc1905c711b36..8abf1a1744433 100644 --- a/homeassistant/components/mammotion/__init__.py +++ b/homeassistant/components/mammotion/__init__.py @@ -4,6 +4,7 @@ import logging +from bleak_retry_connector import BleakNotFoundError from pyluba.mammotion.devices import MammotionBaseBLEDevice from homeassistant.components import bluetooth @@ -63,11 +64,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.data.get(CONF_NAME, entry.title), ) - entry.async_on_unload(coordinator.async_start()) - if not await coordinator.async_wait_ready(): - raise ConfigEntryNotReady(f"{address} is not advertising state") - - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + try: + await coordinator.async_config_entry_first_refresh() + except BleakNotFoundError as err: + raise ConfigEntryNotReady from err await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/mammotion/config_flow.py b/homeassistant/components/mammotion/config_flow.py index 564e39ac06b91..854429efec105 100644 --- a/homeassistant/components/mammotion/config_flow.py +++ b/homeassistant/components/mammotion/config_flow.py @@ -19,6 +19,11 @@ _LOGGER = logging.getLogger(__name__) +def format_unique_id(address: str) -> str: + """Format the unique ID for a mammotion lawnmower.""" + return address.replace(":", "").lower() + + class MammotionConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Mammotion.""" @@ -34,14 +39,16 @@ async def async_step_bluetooth( self, discovery_info: BluetoothServiceInfo ) -> ConfigFlowResult: """Handle the bluetooth discovery step.""" - _LOGGER.debug("Discovered bluetooth device: %s", discovery_info.as_dict()) - await self.async_set_unique_id(discovery_info.address) + _LOGGER.debug("Discovered bluetooth device: %s", discovery_info) + await self.async_set_unique_id(format_unique_id(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: + # TODO return an error + return self._address = device.address self._discovered_devices = {device.address: device} @@ -79,16 +86,15 @@ async def async_step_user( await self.async_set_unique_id(address, raise_on_progress=False) self._abort_if_unique_id_configured() - device = self._discovered_devices[address] - + name = self._discovered_devices[address] self.context["title_placeholders"] = { - "name": device.name, + "name": name, } return self.async_create_entry( - title=device.name, + title=name, data={ - CONF_ADDRESS: device.address, + CONF_ADDRESS: address, }, ) @@ -98,9 +104,7 @@ async def async_step_user( if address in current_addresses or address in self._discovered_devices: continue - self._discovered_devices[address] = ( - device.title or device.get_device_name() or discovery_info.name - ) + self._discovered_devices[address] = discovery_info.name return self.async_show_form( step_id="user", diff --git a/homeassistant/components/mammotion/coordinator.py b/homeassistant/components/mammotion/coordinator.py index 42fbcc6d05bfc..ba75fa3ee6749 100644 --- a/homeassistant/components/mammotion/coordinator.py +++ b/homeassistant/components/mammotion/coordinator.py @@ -2,29 +2,26 @@ from __future__ import annotations -import asyncio -import contextlib +from datetime import timedelta import logging from typing import TYPE_CHECKING from pyluba.mammotion.devices import MammotionBaseBLEDevice from homeassistant.components import bluetooth -from homeassistant.components.bluetooth.active_update_coordinator import ( - ActiveBluetoothDataUpdateCoordinator, -) -from homeassistant.core import CoreState, HomeAssistant, callback +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator if TYPE_CHECKING: from bleak.backends.device import BLEDevice - +MOWER_SCAN_INTERVAL = timedelta(minutes=1) _LOGGER = logging.getLogger(__name__) DEVICE_STARTUP_TIMEOUT = 30 -class MammotionDataUpdateCoordinator(ActiveBluetoothDataUpdateCoordinator[None]): +class MammotionDataUpdateCoordinator(DataUpdateCoordinator): """Class to manage fetching mammotion data.""" def __init__( @@ -40,81 +37,22 @@ def __init__( super().__init__( hass=hass, logger=logger, - address=ble_device.address, - needs_poll_method=self._needs_poll, - poll_method=self._async_update, - mode=bluetooth.BluetoothScanningMode.ACTIVE, - connectable=True, + name="Mammotion Lawn Mower data", + update_interval=MOWER_SCAN_INTERVAL, ) self.ble_device = ble_device self.device = device self.device_name = device_name self.base_unique_id = base_unique_id - self._ready_event = asyncio.Event() self._was_unavailable = True - @callback - def _needs_poll( - self, - service_info: bluetooth.BluetoothServiceInfoBleak, - seconds_since_last_poll: float | None, - ) -> bool: - # Only poll if hass is running, we need to poll, - # and we actually have a way to connect to the device - return ( - self.hass.state is CoreState.running - and self.device.poll_needed(seconds_since_last_poll) - and bool( - bluetooth.async_ble_device_from_address( - self.hass, service_info.device.address, connectable=True - ) - ) - ) - - async def _async_update( - self, service_info: bluetooth.BluetoothServiceInfoBleak - ) -> None: + async def _async_update_data(self) -> dict: """Poll the device.""" - await self.device.start_sync() - - @callback - def _async_handle_unavailable( - self, service_info: bluetooth.BluetoothServiceInfoBleak - ) -> None: - """Handle the device going unavailable.""" - super()._async_handle_unavailable(service_info) - self._was_unavailable = True - - @callback - def _async_handle_bluetooth_event( - self, - service_info: bluetooth.BluetoothServiceInfoBleak, - change: bluetooth.BluetoothChange, - ) -> None: - """Handle a Bluetooth event.""" - self.ble_device = service_info.device - print(service_info) - # if not ( - # adv := parse_advertisement_data( - # service_info.device, service_info.advertisement, self.model - # ) - # ): - # return - # if "modelName" in adv.data: - # self._ready_event.set() - # _LOGGER.debug( - # "%s: mammotion Luba data: %s", self.ble_device.address, self.device.data - # ) - # if not self.device.advertisement_changed(adv) and not self._was_unavailable: - # return - # self._was_unavailable = False - # self.device.update_from_advertisement(adv) - super()._async_handle_bluetooth_event(service_info, change) + if bool( + bluetooth.async_ble_device_from_address( + self.hass, self.ble_device.address, connectable=True + ) + ): + return await self.device.start_sync("key", 0) - async def async_wait_ready(self) -> bool: - """Wait for the device to be ready.""" - with contextlib.suppress(TimeoutError): - async with asyncio.timeout(DEVICE_STARTUP_TIMEOUT): - await self._ready_event.wait() - return True - return False + return self.device.raw_data diff --git a/homeassistant/components/mammotion/lawn_mower.py b/homeassistant/components/mammotion/lawn_mower.py index cc33b12635e87..2495e2e292180 100644 --- a/homeassistant/components/mammotion/lawn_mower.py +++ b/homeassistant/components/mammotion/lawn_mower.py @@ -1,62 +1,110 @@ """Luba lawn mowers.""" + from __future__ import annotations +from pyluba.utility.constant.device_constant import work_mode + from homeassistant.components.lawn_mower import ( LawnMowerActivity, LawnMowerEntity, LawnMowerEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import MammotionDataUpdateCoordinator + +SUPPORTED_FEATURES = ( + LawnMowerEntityFeature.DOCK + | LawnMowerEntityFeature.PAUSE + | LawnMowerEntityFeature.START_MOWING +) async def async_setup_platform( hass: HomeAssistant, config: ConfigType, + coordinator: MammotionDataUpdateCoordinator, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up luba lawn mower.""" + async_add_entities( [ - LubaLawnMower( - "uuid of Luba", - "Luba (serial number or name)", - LawnMowerActivity.PAUSED, # find out what state Luba is in - LawnMowerEntityFeature.DOCK - | LawnMowerEntityFeature.PAUSE - | LawnMowerEntityFeature.START_MOWING, - ), - ] + MammotionLawnMowerEntity(config.get("title"), coordinator), + ], + update_before_add=True, ) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Luba config entry.""" - await async_setup_platform(hass, {}, async_add_entities) + coordinator: MammotionDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + await async_setup_platform( + hass, {"title": entry.title}, coordinator, async_add_entities + ) -class LubaLawnMower(LawnMowerEntity): +class MammotionLawnMowerEntity( + CoordinatorEntity[MammotionDataUpdateCoordinator], LawnMowerEntity +): """Representation of a Luba lawn mower.""" + _attr_supported_features = SUPPORTED_FEATURES + _attr_has_entity_name = True + def __init__( - self, - unique_id: str, - name: str, - activity: LawnMowerActivity, - features: LawnMowerEntityFeature = LawnMowerEntityFeature(0), + self, device_name: str, coordinator: MammotionDataUpdateCoordinator ) -> None: """Initialize the lawn mower.""" - self._attr_name = name - self._attr_unique_id = unique_id - self._attr_supported_features = features - self._attr_activity = activity + super().__init__(coordinator) + self._attr_name = device_name + self._attr_unique_id = f"{device_name}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device_name)}, + manufacturer="Mammotion", + name=device_name, + suggested_area="Garden", + ) + + def _get_mower_activity(self) -> LawnMowerActivity: + mode = "FAIL" + if "sys" in self.coordinator.device.raw_data: + if "toappReportData" in self.coordinator.device.raw_data["sys"]: + mode = self.coordinator.device.raw_data["sys"]["toappReportData"][ + "dev" + ]["sysStatus"] + print(mode) + if mode == work_mode.MODE_PAUSE.value: + return LawnMowerActivity.PAUSED + if mode == work_mode.MODE_WORKING.value: + return LawnMowerActivity.MOWING + if mode == work_mode.MODE_LOCK.value: + return LawnMowerActivity.ERROR + if ( + mode == work_mode.MODE_CHARGING.value + or mode == work_mode.MODE_READY.value + or mode == work_mode.MODE_RETURNING.value + ): + return LawnMowerActivity.DOCKED + + return self._attr_activity + + @property + def activity(self) -> LawnMowerActivity: + """Return the state of the mower.""" + # productkey = coordinator.device.raw_data['net']['toappWifiIotStatus']['productkey'] + # devicename = coordinator.device.raw_data['net']['toappWifiIotStatus']['devicename'] + return self._get_mower_activity() async def async_start_mowing(self) -> None: """Start mowing.""" @@ -72,3 +120,11 @@ async def async_pause(self) -> None: """Pause mower.""" self._attr_activity = LawnMowerActivity.PAUSED self.async_write_ha_state() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + print("coordinator callback") + print(self.coordinator.device.raw_data) + self._attr_activity = self._get_mower_activity() + self.async_write_ha_state() diff --git a/homeassistant/components/mammotion/manifest.json b/homeassistant/components/mammotion/manifest.json index a4963ce863d31..5876b693aba40 100644 --- a/homeassistant/components/mammotion/manifest.json +++ b/homeassistant/components/mammotion/manifest.json @@ -1,7 +1,6 @@ { "domain": "mammotion", "name": "Mammotion", - "integration_type": "device", "bluetooth": [ { "local_name": "Luba-*", @@ -16,6 +15,7 @@ "config_flow": true, "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/mammotion", + "integration_type": "device", "iot_class": "local_polling", - "requirements": ["pyluba==0.0.8"] + "requirements": ["pyluba==0.0.12"] } diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index befb6ab7ac509..ce8823c2e3e96 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -540,6 +540,16 @@ "domain": "led_ble", "local_name": "LD-0003", }, + { + "domain": "mammotion", + "local_name": "Luba-*", + "service_uuid": "0000ffff-0000-1000-8000-00805f9b34fb", + }, + { + "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 789334a9d9fda..30300ab4dffcd 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -441,6 +441,7 @@ "lyric", "madvr", "mailgun", + "mammotion", "marantz_infrared", "mastodon", "matter", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index eec867f62eca2..3fbc43c76545d 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4040,14 +4040,9 @@ }, "mammotion": { "name": "Mammotion", - "integrations": { - "mammotion": { - "integration_type": "hub", - "config_flow": true, - "iot_class": "local_push", - "name": "Mammotion Bluetooth" - } - } + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" }, "marantz": { "name": "Marantz", diff --git a/requirements_all.txt b/requirements_all.txt index 5e2b2a7c4ee67..9b5bc00cc166b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2325,7 +2325,7 @@ pylitejet==0.6.3 pylitterbot==2025.5.0 # homeassistant.components.mammotion -pyluba==0.0.8 +pyluba==0.0.12 # homeassistant.components.lutron_caseta pylutron-caseta==0.28.0 From 076d4491303853b295aec00a8240148240275a6c Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Sun, 11 Aug 2024 15:40:28 +1200 Subject: [PATCH 06/66] tmp --- homeassistant/components/mammotion/config_flow.py | 10 +++++++--- homeassistant/components/mammotion/const.py | 2 ++ homeassistant/components/mammotion/manifest.json | 10 ++++++---- homeassistant/components/mammotion/strings.json | 1 + homeassistant/generated/bluetooth.py | 2 ++ requirements_all.txt | 2 +- 6 files changed, 19 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/mammotion/config_flow.py b/homeassistant/components/mammotion/config_flow.py index 854429efec105..3ab10aa532c35 100644 --- a/homeassistant/components/mammotion/config_flow.py +++ b/homeassistant/components/mammotion/config_flow.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_ADDRESS -from .const import DOMAIN +from .const import DEVICE_SUPPORT, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -43,12 +43,16 @@ async def async_step_bluetooth( await self.async_set_unique_id(format_unique_id(discovery_info.address)) self._abort_if_unique_id_configured() + match_found = any(model in discovery_info.name for model in DEVICE_SUPPORT) + if not match_found: + return self.async_abort(reason="not_supported") + device = bluetooth.async_ble_device_from_address( self.hass, discovery_info.address ) if device is None: - # TODO return an error - return + return self.async_abort(reason="unknown") + self._address = device.address self._discovered_devices = {device.address: device} diff --git a/homeassistant/components/mammotion/const.py b/homeassistant/components/mammotion/const.py index 2102873636e63..98b93c62a0699 100644 --- a/homeassistant/components/mammotion/const.py +++ b/homeassistant/components/mammotion/const.py @@ -5,6 +5,8 @@ DOMAIN: Final = "mammotion" +DEVICE_SUPPORT = ("Luba", "Yuka") + DEFAULT_RETRY_COUNT = 3 CONF_RETRY_COUNT = "retry_count" LOGGER: Final = logging.getLogger(__package__) diff --git a/homeassistant/components/mammotion/manifest.json b/homeassistant/components/mammotion/manifest.json index 5876b693aba40..c911cdd4d34be 100644 --- a/homeassistant/components/mammotion/manifest.json +++ b/homeassistant/components/mammotion/manifest.json @@ -4,11 +4,13 @@ "bluetooth": [ { "local_name": "Luba-*", - "service_uuid": "0000ffff-0000-1000-8000-00805f9b34fb" + "service_uuid": "0000ffff-0000-1000-8000-00805f9b34fb", + "connectable": true }, { "local_name": "Yuka-*", - "service_uuid": "0000ffff-0000-1000-8000-00805f9b34fb" + "service_uuid": "0000ffff-0000-1000-8000-00805f9b34fb", + "connectable": true } ], "codeowners": ["@mikey0000"], @@ -16,6 +18,6 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/mammotion", "integration_type": "device", - "iot_class": "local_polling", - "requirements": ["pyluba==0.0.12"] + "iot_class": "local_push", + "requirements": ["pyluba==0.0.13"] } diff --git a/homeassistant/components/mammotion/strings.json b/homeassistant/components/mammotion/strings.json index 1f9443fad59f2..c8104feac916e 100644 --- a/homeassistant/components/mammotion/strings.json +++ b/homeassistant/components/mammotion/strings.json @@ -13,6 +13,7 @@ } }, "abort": { + "not_supported": "Device not supported", "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index ce8823c2e3e96..0aea0a8e8a84a 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -541,11 +541,13 @@ "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", diff --git a/requirements_all.txt b/requirements_all.txt index 9b5bc00cc166b..fcb7aa44fb9b5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2325,7 +2325,7 @@ pylitejet==0.6.3 pylitterbot==2025.5.0 # homeassistant.components.mammotion -pyluba==0.0.12 +pyluba==0.0.13 # homeassistant.components.lutron_caseta pylutron-caseta==0.28.0 From 43765ea0feba9337afb9e1e083f83c42996f5a8f Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Tue, 20 Aug 2024 11:34:38 +1200 Subject: [PATCH 07/66] development mammotion --- .../components/mammotion/__init__.py | 50 +--- .../components/mammotion/config_flow.py | 225 +++++++++++++----- homeassistant/components/mammotion/const.py | 18 ++ .../components/mammotion/coordinator.py | 166 ++++++++++--- homeassistant/components/mammotion/icons,json | 27 +++ .../components/mammotion/lawn_mower.py | 188 ++++++++------- .../components/mammotion/manifest.json | 2 +- .../components/mammotion/strings.json | 179 +++++++++++++- requirements_all.txt | 6 +- 9 files changed, 631 insertions(+), 230 deletions(-) create mode 100644 homeassistant/components/mammotion/icons,json diff --git a/homeassistant/components/mammotion/__init__.py b/homeassistant/components/mammotion/__init__.py index 8abf1a1744433..772bdb037f29a 100644 --- a/homeassistant/components/mammotion/__init__.py +++ b/homeassistant/components/mammotion/__init__.py @@ -2,31 +2,24 @@ from __future__ import annotations -import logging - -from bleak_retry_connector import BleakNotFoundError -from pyluba.mammotion.devices import MammotionBaseBLEDevice - -from homeassistant.components import bluetooth from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ADDRESS, CONF_MAC, CONF_NAME, Platform +from homeassistant.const import CONF_ADDRESS, CONF_MAC, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr -from .const import CONF_RETRY_COUNT, DEFAULT_RETRY_COUNT, DOMAIN +from .const import CONF_RETRY_COUNT, DEFAULT_RETRY_COUNT from .coordinator import MammotionDataUpdateCoordinator PLATFORMS: list[Platform] = [Platform.LAWN_MOWER] -_LOGGER = logging.getLogger(__name__) +type MammotionConfigEntry = ConfigEntry[MammotionDataUpdateCoordinator] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> bool: """Set up Mammotion Luba from a config entry.""" assert entry.unique_id is not None - hass.data.setdefault(DOMAIN, {}) + if CONF_ADDRESS not in entry.data and CONF_MAC in entry.data: # Bleak uses addresses not mac addresses which are actually # UUIDs on some platforms (MacOS). @@ -44,31 +37,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: options={CONF_RETRY_COUNT: DEFAULT_RETRY_COUNT}, ) - address: str = entry.data[CONF_ADDRESS] - ble_device = bluetooth.async_ble_device_from_address( - hass, address.upper(), connectable=True - ) - if not ble_device: - raise ConfigEntryNotReady( - f"Could not find Mammotion lawn mower with address {address}" - ) - - device = MammotionBaseBLEDevice(ble_device) - - coordinator = hass.data[DOMAIN][entry.entry_id] = MammotionDataUpdateCoordinator( - hass, - _LOGGER, - ble_device, - device, - entry.unique_id, - entry.data.get(CONF_NAME, entry.title), - ) - - try: - await coordinator.async_config_entry_first_refresh() - except BleakNotFoundError as err: - raise ConfigEntryNotReady from err + coordinator = MammotionDataUpdateCoordinator(hass) + await coordinator.async_setup() + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -81,7 +54,4 @@ async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> Non async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/mammotion/config_flow.py b/homeassistant/components/mammotion/config_flow.py index 3ab10aa532c35..2f5f9a5866b7b 100644 --- a/homeassistant/components/mammotion/config_flow.py +++ b/homeassistant/components/mammotion/config_flow.py @@ -1,9 +1,10 @@ """Config flow for Mammotion Luba.""" -import logging from typing import Any from bleak import BLEDevice +from pymammotion.aliyun.cloud_gateway import CloudIOTGateway +from pymammotion.http.http import connect_http import voluptuous as vol from homeassistant.components import bluetooth @@ -11,110 +12,228 @@ BluetoothServiceInfo, async_discovered_service_info, ) -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_ADDRESS - -from .const import DEVICE_SUPPORT, DOMAIN - -_LOGGER = logging.getLogger(__name__) - - -def format_unique_id(address: str) -> str: - """Format the unique ID for a mammotion lawnmower.""" - return address.replace(":", "").lower() +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + OptionsFlow, + OptionsFlowWithConfigEntry, +) +from homeassistant.const import CONF_ADDRESS, CONF_PASSWORD +from homeassistant.core import callback +from homeassistant.helpers import config_validation as cv + +from .const import ( + CONF_ACCOUNTNAME, + CONF_DEVICELIST, + CONF_STAY_CONNECTED_BLUETOOTH, + CONF_USE_WIFI, + DEVICE_SUPPORT, + DOMAIN, + LOGGER, +) class MammotionConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Mammotion.""" - VERSION = 1 - - _address: str | None = None - _discovered_devices: dict[str, BLEDevice] = {} - def __init__(self) -> None: """Initialize the config flow.""" + self._discovered_device: BLEDevice | None = None + self._discovered_devices: dict[str, str] = {} async def async_step_bluetooth( self, discovery_info: BluetoothServiceInfo ) -> ConfigFlowResult: """Handle the bluetooth discovery step.""" - _LOGGER.debug("Discovered bluetooth device: %s", discovery_info) - await self.async_set_unique_id(format_unique_id(discovery_info.address)) - self._abort_if_unique_id_configured() - match_found = any(model in discovery_info.name for model in DEVICE_SUPPORT) - if not match_found: - return self.async_abort(reason="not_supported") + LOGGER.debug("Discovered bluetooth device: %s", discovery_info) + if discovery_info is None: + return self.async_abort(reason="no_device") + + await self.async_set_unique_id(discovery_info.address) + self._abort_if_unique_id_configured( + updates={CONF_ADDRESS: discovery_info.address} + ) device = bluetooth.async_ble_device_from_address( self.hass, discovery_info.address ) + if device is None: - return self.async_abort(reason="unknown") + return self.async_abort(reason="no_longer_present") - self._address = device.address - self._discovered_devices = {device.address: device} + 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 + 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._address - device = self._discovered_devices[self._address] + + assert self._discovered_device if user_input is not None: - return self.async_create_entry( - title=device.name, - data={ - CONF_ADDRESS: device.address, - }, - ) + return await self.async_step_wifi(user_input) - self._set_confirm_only() return self.async_show_form( - step_id="bluetooth_confirm", - description_placeholders=self.context["title_placeholders"], + last_step=False, + description_placeholders={"name": self._discovered_device.name}, ) 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: - address = user_input[CONF_ADDRESS] - await self.async_set_unique_id(address, raise_on_progress=False) - self._abort_if_unique_id_configured() + address = user_input.get(CONF_ADDRESS) + if address is not None: + await self.async_set_unique_id(address, raise_on_progress=False) + self._abort_if_unique_id_configured() - name = self._discovered_devices[address] - self.context["title_placeholders"] = { - "name": name, - } + name = self._discovered_devices.get(address) + if name is None: + return self.async_abort(reason="no_longer_present") - return self.async_create_entry( - title=name, - data={ - CONF_ADDRESS: address, - }, - ) + if user_input.get(CONF_USE_WIFI) is False: + return self.async_create_entry( + title=name, + data={CONF_ADDRESS: address}, + ) + + return await self.async_step_wifi(user_input) 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 or address in self._discovered_devices: continue - + if name is None or not name.startswith(DEVICE_SUPPORT): + continue self._discovered_devices[address] = discovery_info.name + if not self._discovered_devices: + return await self.async_step_wifi(user_input) + return self.async_show_form( - step_id="user", + last_step=False, data_schema=vol.Schema( { - vol.Required(CONF_ADDRESS): vol.In(self._discovered_devices), + vol.Optional(CONF_ADDRESS): vol.In(self._discovered_devices), }, ), ) + + async def async_step_wifi(self, user_input: dict[str, Any]) -> ConfigFlowResult: + """Handle the user step for Wi-Fi control.""" + + if user_input is not None and ( + user_input.get(CONF_ACCOUNTNAME) is not None + or user_input.get(CONF_USE_WIFI) is True + ): + account = user_input.get(CONF_ACCOUNTNAME) + password = user_input.get(CONF_PASSWORD) + address = user_input.get(CONF_ADDRESS) + name = self._discovered_devices.get(address) + if address is None or name is None: + try: + cloud_client = CloudIOTGateway() + mammotion_http = await connect_http(account, password) + country_code = ( + mammotion_http.login.userInformation.domainAbbreviation + ) + await self.hass.async_add_executor_job( + cloud_client.get_region, + country_code, + mammotion_http.login.authorization_code, + ) + await cloud_client.connect() + await cloud_client.login_by_oauth( + country_code, mammotion_http.login.authorization_code + ) + await self.hass.async_add_executor_job(cloud_client.aep_handle) + await self.hass.async_add_executor_job( + cloud_client.session_by_auth_code + ) + + device_list = await self.hass.async_add_executor_job( + cloud_client.list_binding_by_account + ) + if device_list.data.total == 1: + device = device_list.data.data[0] + name = device.deviceName + await self.async_set_unique_id(name, raise_on_progress=False) + self._abort_if_unique_id_configured() + if device_list.data.total == 0: + return self.async_abort(reason="no_devices") + + if device_list.data.total > 1: + # figure out how to present it + pass + + except Exception as e: + return self.async_abort(reason=str(e)) + + return self.async_create_entry( + title=name, + data={ + CONF_ADDRESS: address, + CONF_ACCOUNTNAME: account, + CONF_PASSWORD: password, + CONF_DEVICELIST: device_list, + }, + ) + + schema = { + vol.Optional(CONF_ACCOUNTNAME): cv.string, + vol.Optional(CONF_PASSWORD): cv.string, + vol.Optional(CONF_USE_WIFI, default=True): cv.boolean, + } + + if user_input.get(CONF_ADDRESS) is None: + schema = { + vol.Required(CONF_ACCOUNTNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + } + + return self.async_show_form(data_schema=vol.Schema(schema)) + + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> OptionsFlow: + """Create the options flow.""" + return MammotionConfigFlowHandler(config_entry) + + +class MammotionConfigFlowHandler(OptionsFlowWithConfigEntry): + """Handles options flow for the component.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage the options for the custom component.""" + if user_input: + return self.async_create_entry(title="", data=user_input) + + options_schema = vol.Schema( + { + vol.Optional( + CONF_STAY_CONNECTED_BLUETOOTH, + default=self.options.get(CONF_STAY_CONNECTED_BLUETOOTH, False), + ): cv.boolean + } + ) + + return self.async_show_form( + data_schema=options_schema, + ) diff --git a/homeassistant/components/mammotion/const.py b/homeassistant/components/mammotion/const.py index 98b93c62a0699..77b85f86fc791 100644 --- a/homeassistant/components/mammotion/const.py +++ b/homeassistant/components/mammotion/const.py @@ -3,10 +3,28 @@ import logging from typing import Final +from bleak_retry_connector import BleakError, BleakNotFoundError +from pymammotion.mammotion.devices.mammotion import CharacteristicMissingError + DOMAIN: Final = "mammotion" DEVICE_SUPPORT = ("Luba", "Yuka") +ATTR_DIRECTION = "direction" + DEFAULT_RETRY_COUNT = 3 CONF_RETRY_COUNT = "retry_count" LOGGER: Final = logging.getLogger(__package__) + +COMMAND_EXCEPTIONS = ( + BleakNotFoundError, + CharacteristicMissingError, + BleakError, + TimeoutError, +) + +CONF_USE_BLUETOOTH: Final = "use_bluetooth" +CONF_STAY_CONNECTED_BLUETOOTH: Final = "stay_connected_bluetooth" +CONF_USE_WIFI: Final = "use_wifi" +CONF_ACCOUNTNAME: Final = "account_name" +CONF_DEVICELIST: Final = "device_list" diff --git a/homeassistant/components/mammotion/coordinator.py b/homeassistant/components/mammotion/coordinator.py index ba75fa3ee6749..87ec21b41e955 100644 --- a/homeassistant/components/mammotion/coordinator.py +++ b/homeassistant/components/mammotion/coordinator.py @@ -2,57 +2,163 @@ from __future__ import annotations +from dataclasses import asdict from datetime import timedelta -import logging from typing import TYPE_CHECKING -from pyluba.mammotion.devices import MammotionBaseBLEDevice +from pymammotion.data.model.account import Credentials +from pymammotion.data.model.device import MowingDevice +from pymammotion.mammotion.devices.mammotion import ( + ConnectionPreference, + MammotionDevice, +) +from pymammotion.proto.mctrl_sys import RptAct, RptInfoType +from pymammotion.utility.constant import WorkMode from homeassistant.components import bluetooth +from homeassistant.const import CONF_ADDRESS, CONF_PASSWORD from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -if TYPE_CHECKING: - from bleak.backends.device import BLEDevice +from .const import COMMAND_EXCEPTIONS, CONF_ACCOUNTNAME, DOMAIN, LOGGER -MOWER_SCAN_INTERVAL = timedelta(minutes=1) -_LOGGER = logging.getLogger(__name__) +if TYPE_CHECKING: + from . import MammotionConfigEntry -DEVICE_STARTUP_TIMEOUT = 30 +SCAN_INTERVAL = timedelta(minutes=1) -class MammotionDataUpdateCoordinator(DataUpdateCoordinator): +class MammotionDataUpdateCoordinator(DataUpdateCoordinator[MowingDevice]): """Class to manage fetching mammotion data.""" + address: str + config_entry: MammotionConfigEntry + device_name: str + device: MammotionDevice + def __init__( self, hass: HomeAssistant, - logger: logging.Logger, - ble_device: BLEDevice, - device: MammotionBaseBLEDevice, - base_unique_id: str, - device_name: str, ) -> None: """Initialize global mammotion data updater.""" super().__init__( hass=hass, - logger=logger, - name="Mammotion Lawn Mower data", - update_interval=MOWER_SCAN_INTERVAL, + logger=LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + self.update_failures = 0 + + async def async_setup(self) -> None: + """Set coordinator up.""" + ble_device = None + credentials = Credentials() + preference = ConnectionPreference.BLUETOOTH + address = self.config_entry.data.get(CONF_ADDRESS) + if address: + ble_device = bluetooth.async_ble_device_from_address(self.hass, address) + if not ble_device: + raise ConfigEntryNotReady( + f"Could not find Mammotion lawn mower with address {address}" + ) + + self.device_name = ble_device.name or "Unknown" + self.address = ble_device.address + self.device._ble_device.update_device(ble_device) + + account = self.config_entry.data.get(CONF_ACCOUNTNAME) + password = self.config_entry.data.get(CONF_PASSWORD) + if account and password: + preference = ConnectionPreference.WIFI + credentials.email = account + credentials.password = password + + self.device = await self.hass.async_add_executor_job( + MammotionDevice, ble_device, credentials, preference + ) + + try: + await self.device.start_sync(0) + except COMMAND_EXCEPTIONS as exc: + raise ConfigEntryNotReady("Unable to setup Mammotion device") from exc + + async def async_sync_maps(self) -> None: + """Get map data from the device.""" + await self.device.start_map_sync() + + async def async_start_stop_blades(self, start_stop: bool) -> None: + if start_stop: + await self.async_send_command("set_blade_control", on_off=1) + else: + await self.async_send_command("set_blade_control", on_off=0) + + async def async_blade_height(self, height: int) -> None: + await self.async_send_command("set_blade_height", height=height) + + async def async_rtk_dock_location(self): + """RTK and dock location.""" + await self.async_send_command("allpowerfull_rw", id=5, rw=1, context=1) + + async def async_request_iot_sync(self) -> None: + await self.async_send_command( + "request_iot_sys", + rpt_act=RptAct.RPT_START, + rpt_info_type=[ + RptInfoType.RIT_CONNECT, + RptInfoType.RIT_DEV_STA, + RptInfoType.RIT_DEV_LOCAL, + RptInfoType.RIT_RTK, + RptInfoType.RIT_WORK, + ], + timeout=1000, + period=3000, + no_change_period=4000, + count=0, ) - self.ble_device = ble_device - self.device = device - self.device_name = device_name - self.base_unique_id = base_unique_id - self._was_unavailable = True - - async def _async_update_data(self) -> dict: - """Poll the device.""" - if bool( - bluetooth.async_ble_device_from_address( - self.hass, self.ble_device.address, connectable=True + + async def async_send_command(self, command: str, **kwargs) -> None: + try: + await self.device.command(command, **kwargs) + except COMMAND_EXCEPTIONS as exc: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="command_failed" + ) from exc + + async def _async_update_data(self) -> MowingDevice: + """Get data from the device.""" + if not ( + ble_device := bluetooth.async_ble_device_from_address( + self.hass, self.address ) ): - return await self.device.start_sync("key", 0) + self.update_failures += 1 + raise UpdateFailed("Could not find device") + + self.device.update_device(ble_device) + try: + if len(self.device.luba_msg.net.toapp_devinfo_resp.resp_ids) == 0: + await self.device.start_sync(0) + if self.device.luba_msg.report_data.dev.sys_status != WorkMode.MODE_WORKING: + await self.async_send_command("get_report_cfg") + + else: + await self.async_request_iot_sync() + + except COMMAND_EXCEPTIONS as exc: + self.update_failures += 1 + raise UpdateFailed(f"Updating Mammotion device failed: {exc}") from exc + + LOGGER.debug("Updated Mammotion device %s", self.device_name) + LOGGER.debug("================= Debug Log =================") + LOGGER.debug("Mammotion device data: %s", asdict(self.device.luba_msg)) + LOGGER.debug("==================================") + + self.update_failures = 0 + return self.device.luba_msg - return self.device.raw_data + async def _async_setup(self) -> None: + try: + await self.device.start_sync(0) + except COMMAND_EXCEPTIONS as exc: + raise UpdateFailed(f"Setting up Mammotion device failed: {exc}") from exc diff --git a/homeassistant/components/mammotion/icons,json b/homeassistant/components/mammotion/icons,json new file mode 100644 index 0000000000000..fd96c40e89722 --- /dev/null +++ b/homeassistant/components/mammotion/icons,json @@ -0,0 +1,27 @@ +{ + "entity": { + "sensor": { + "gps_stars": { + "default": "mdi:satellite-uplink" + }, + "blade_height": { + "default": "mdi:altimeter" + }, + "area": { + "default": "mdi:tape-measure" + }, + "progress": { + "default": "mdi:percent-box" + }, + "l1_satellites": { + "default": "mdi:satellite-variant" + }, + "l2_satellites": { + "default": "mdi:satellite-variant" + }, + "position_mode": { + "default": "mdi:map-marker" + } + } + } +} diff --git a/homeassistant/components/mammotion/lawn_mower.py b/homeassistant/components/mammotion/lawn_mower.py index 2495e2e292180..6fe9fe1a254d5 100644 --- a/homeassistant/components/mammotion/lawn_mower.py +++ b/homeassistant/components/mammotion/lawn_mower.py @@ -2,129 +2,135 @@ from __future__ import annotations -from pyluba.utility.constant.device_constant import work_mode +from pymammotion.mammotion.devices.mammotion import has_field +from pymammotion.proto.luba_msg import RptDevStatus +from pymammotion.utility.constant.device_constant import WorkMode from homeassistant.components.lawn_mower import ( LawnMowerActivity, LawnMowerEntity, LawnMowerEntityFeature, ) -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN +from . import MammotionConfigEntry +from .const import COMMAND_EXCEPTIONS, DOMAIN, LOGGER from .coordinator import MammotionDataUpdateCoordinator - -SUPPORTED_FEATURES = ( - LawnMowerEntityFeature.DOCK - | LawnMowerEntityFeature.PAUSE - | LawnMowerEntityFeature.START_MOWING -) +from .entity import MammotionBaseEntity -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - coordinator: MammotionDataUpdateCoordinator, + entry: MammotionConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up luba lawn mower.""" + """Set up the Luba config entry.""" + coordinator = entry.runtime_data + async_add_entities([MammotionLawnMowerEntity(coordinator)]) - async_add_entities( - [ - MammotionLawnMowerEntity(config.get("title"), coordinator), - ], - update_before_add=True, - ) +class MammotionLawnMowerEntity(MammotionBaseEntity, LawnMowerEntity): + """Representation of a Mammotion lawn mower.""" -async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Luba config entry.""" - coordinator: MammotionDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - await async_setup_platform( - hass, {"title": entry.title}, coordinator, async_add_entities + _attr_supported_features = ( + LawnMowerEntityFeature.DOCK + | LawnMowerEntityFeature.PAUSE + | LawnMowerEntityFeature.START_MOWING ) + def __init__(self, coordinator: MammotionDataUpdateCoordinator) -> None: + """Initialize the lawn mower.""" + super().__init__(coordinator, "mower") + self._attr_name = None # main feature of device + + @property + def rpt_dev_status(self) -> RptDevStatus | None: + """Return the device status.""" + if has_field(self.coordinator.data.sys.toapp_report_data.dev): + return self.coordinator.data.sys.toapp_report_data.dev + return None + + @property + def activity(self) -> LawnMowerActivity | None: + """Return the state of the mower.""" -class MammotionLawnMowerEntity( - CoordinatorEntity[MammotionDataUpdateCoordinator], LawnMowerEntity -): - """Representation of a Luba lawn mower.""" + if self.rpt_dev_status is None: + return None - _attr_supported_features = SUPPORTED_FEATURES - _attr_has_entity_name = True + mode = self.rpt_dev_status.sys_status + charge_state = self.rpt_dev_status.charge_state - def __init__( - self, device_name: str, coordinator: MammotionDataUpdateCoordinator - ) -> None: - """Initialize the lawn mower.""" - super().__init__(coordinator) - self._attr_name = device_name - self._attr_unique_id = f"{device_name}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, device_name)}, - manufacturer="Mammotion", - name=device_name, - suggested_area="Garden", - ) - - def _get_mower_activity(self) -> LawnMowerActivity: - mode = "FAIL" - if "sys" in self.coordinator.device.raw_data: - if "toappReportData" in self.coordinator.device.raw_data["sys"]: - mode = self.coordinator.device.raw_data["sys"]["toappReportData"][ - "dev" - ]["sysStatus"] - print(mode) - if mode == work_mode.MODE_PAUSE.value: + LOGGER.debug("activity mode %s", mode) + if ( + mode == WorkMode.MODE_PAUSE + or mode == WorkMode.MODE_READY + and charge_state == 0 + ): return LawnMowerActivity.PAUSED - if mode == work_mode.MODE_WORKING.value: + if mode in (WorkMode.MODE_WORKING, WorkMode.MODE_RETURNING): return LawnMowerActivity.MOWING - if mode == work_mode.MODE_LOCK.value: + if mode == WorkMode.MODE_LOCK: return LawnMowerActivity.ERROR - if ( - mode == work_mode.MODE_CHARGING.value - or mode == work_mode.MODE_READY.value - or mode == work_mode.MODE_RETURNING.value - ): + if mode == WorkMode.MODE_READY and charge_state != 0: return LawnMowerActivity.DOCKED - - return self._attr_activity - - @property - def activity(self) -> LawnMowerActivity: - """Return the state of the mower.""" - # productkey = coordinator.device.raw_data['net']['toappWifiIotStatus']['productkey'] - # devicename = coordinator.device.raw_data['net']['toappWifiIotStatus']['devicename'] - return self._get_mower_activity() + return None async def async_start_mowing(self) -> None: """Start mowing.""" - self._attr_activity = LawnMowerActivity.MOWING - self.async_write_ha_state() + # check if job in progress + # + if self.rpt_dev_status is None: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="device_not_ready" + ) + if self.rpt_dev_status.sys_status == WorkMode.MODE_PAUSE: + try: + await self.coordinator.device.command("resume_execute_task") + return await self.coordinator.async_request_iot_sync() + except COMMAND_EXCEPTIONS as exc: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="resume_failed" + ) from exc + try: + await self.coordinator.device.command("start_job") + await self.coordinator.async_request_iot_sync() + except COMMAND_EXCEPTIONS as exc: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="start_failed" + ) from exc + finally: + self.coordinator.async_set_updated_data(self.coordinator.device.luba_msg) async def async_dock(self) -> None: """Start docking.""" - self._attr_activity = LawnMowerActivity.DOCKED - self.async_write_ha_state() + + mode = self.rpt_dev_status.sys_status + + try: + if mode == WorkMode.MODE_RETURNING: + await self.coordinator.device.command("cancel_return_to_dock") + return await self.coordinator.device.command("get_report_cfg") + if mode == WorkMode.MODE_WORKING: + await self.coordinator.device.command("pause_execute_task") + await self.coordinator.device.command("return_to_dock") + await self.coordinator.async_request_iot_sync() + except COMMAND_EXCEPTIONS as exc: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="dock_failed" + ) from exc + finally: + self.coordinator.async_set_updated_data(self.coordinator.device.luba_msg) async def async_pause(self) -> None: """Pause mower.""" - self._attr_activity = LawnMowerActivity.PAUSED - self.async_write_ha_state() - - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - print("coordinator callback") - print(self.coordinator.device.raw_data) - self._attr_activity = self._get_mower_activity() - self.async_write_ha_state() + try: + await self.coordinator.device.command("pause_execute_task") + await self.coordinator.async_request_iot_sync() + except COMMAND_EXCEPTIONS as exc: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="pause_failed" + ) from exc + finally: + self.coordinator.async_set_updated_data(self.coordinator.device.luba_msg) diff --git a/homeassistant/components/mammotion/manifest.json b/homeassistant/components/mammotion/manifest.json index c911cdd4d34be..ee17ee5483dae 100644 --- a/homeassistant/components/mammotion/manifest.json +++ b/homeassistant/components/mammotion/manifest.json @@ -19,5 +19,5 @@ "documentation": "https://www.home-assistant.io/integrations/mammotion", "integration_type": "device", "iot_class": "local_push", - "requirements": ["pyluba==0.0.13"] + "requirements": ["pymammotion==0.2.0"] } diff --git a/homeassistant/components/mammotion/strings.json b/homeassistant/components/mammotion/strings.json index c8104feac916e..ab84bf6db9555 100644 --- a/homeassistant/components/mammotion/strings.json +++ b/homeassistant/components/mammotion/strings.json @@ -1,17 +1,5 @@ { "config": { - "flow_title": "[%key:component::bluetooth::config::flow_title%]", - "step": { - "user": { - "description": "[%key:component::bluetooth::config::step::user::description%]", - "data": { - "address": "[%key:common::config_flow::data::device%]" - } - }, - "bluetooth_confirm": { - "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" - } - }, "abort": { "not_supported": "Device not supported", "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", @@ -19,6 +7,173 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "flow_title": "Configure your Mammotion lawn mower", + "step": { + "bluetooth_confirm": { + "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" + }, + "user": { + "data": { + "address": "[%key:common::config_flow::data::device%]", + "use_wifi": "Use Wi-fi", + "account_name": "Mammotion email or account number", + "password": "Mammotion account password" + }, + "description": "Setup your mower" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "title": "Update Configuration", + "stay_connected_bluetooth": "Keep bluetooth connected" + } + } + } + }, + "entity": { + "sensor": { + "battery_percent": { + "name": "Battery" + }, + "ble_rssi": { + "name": "BLE RSSI" + }, + "wifi_rssi": { + "name": "WiFi RSSI" + }, + "gps_stars": { + "name": "Satellites (Robot)" + }, + "blade_height": { + "name": "Blade height" + }, + "area": { + "name": "Area" + }, + "mowing_speed": { + "name": "Mowing speed" + }, + "progress": { + "name": "Progress" + }, + "total_time": { + "name": "Total time" + }, + "elapsed_time": { + "name": "Elapsed time" + }, + "left_time": { + "name": "Time left" + }, + "l1_satellites": { + "name": "L1 Satellites (Co-Viewing)" + }, + "l2_satellites": { + "name": "L2 Satellites (Co-Viewing)" + }, + "position_mode": { + "name": "RTK position" + }, + "position_type": { + "name": "Device position type" + }, + "activity_mode": { + "name": "Activity mode" + } + }, + "button": { + "start_map_sync": { + "name": "Sync maps" + }, + "resync_rtk_dock": { + "name": "Sync RTK and dock", + "description": "Syncs RTK and dock location for when you move them." + } + }, + "switch": { + "blades_on_off": { + "name": "Blades On/Off", + "description": "Turn the blades on or off." + }, + "mowing_on_off": { + "name": "Mowing On/Off", + "description": "Start or stop mowing." + }, + "dump_grass_on_off": { + "name": "Dump Grass On/Off", + "description": "Enable or disable grass dumping." + }, + "rain_detection_on_off": { + "name": "Rain Detection On/Off", + "description": "Turn rain detection on or off." + }, + "side_led_on_off": { + "name": "Side LED On/Off", + "description": "Enable or disable the side LED." + }, + "perimeter_first_on_off": { + "name": "Perimeter First", + "description": "Perimeter first or lines/zigzag first mowing." + } + }, + "select": { + "cutting_mode": { + "name": "Cutting Mode", + "description": "Select the cutting mode for the mower." + }, + "border_patrol_mode": { + "name": "Border Patrol Mode", + "description": "Select the border patrol mode for the mower." + }, + "obstacle_laps_mode": { + "name": "Obstacle Laps Mode", + "description": "Select the obstacle laps mode for the mower." + }, + "mow_order": { + "name": "Mow Order", + "description": "Select the order in which the areas should be mowed." + } + }, + "number": { + "start_progress": { + "name": "Start Progress", + "description": "Set the start progress percentage." + }, + "blade_height": { + "name": "Blade Height", + "description": "Adjust the height of the cutter in increments." + }, + "working_speed": { + "name": "Working Speed", + "description": "Set the working speed of the mower." + } + }, + "device_tracker": { + "name": "Device Tracking" + } + }, + "exceptions": { + "device_not_ready": { + "message": "Device is not ready." + }, + "pause_failed": { + "message": "Failed to pause the mower." + }, + "resume_failed": { + "message": "Failed to resume the mower." + }, + "start_failed": { + "message": "Failed to start the mower." + }, + "dock_failed": { + "message": "Failed to send the mower to the dock." + }, + "command_failed": { + "message": "Failed to send command to the mower." } } } diff --git a/requirements_all.txt b/requirements_all.txt index fcb7aa44fb9b5..4e67eee03f7aa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2324,9 +2324,6 @@ pylitejet==0.6.3 # homeassistant.components.litterrobot pylitterbot==2025.5.0 -# homeassistant.components.mammotion -pyluba==0.0.13 - # homeassistant.components.lutron_caseta pylutron-caseta==0.28.0 @@ -2336,6 +2333,9 @@ pylutron==0.4.1 # homeassistant.components.mailgun pymailgunner==1.4 +# homeassistant.components.mammotion +pymammotion==0.2.0 + # homeassistant.components.firmata pymata-express==1.19 From 88fd516487a09594fe4474aabdcc709b5959a5c5 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Sat, 24 Aug 2024 08:52:53 +1200 Subject: [PATCH 08/66] further updates --- .../components/mammotion/__init__.py | 11 +- .../components/mammotion/binary_sensor.py | 72 ++++++ homeassistant/components/mammotion/button.py | 66 +++++ .../components/mammotion/config_flow.py | 77 +++--- homeassistant/components/mammotion/const.py | 5 +- .../components/mammotion/coordinator.py | 65 +++-- .../components/mammotion/device_tracker.py | 73 ++++++ .../components/mammotion/diagnostics.py | 22 ++ homeassistant/components/mammotion/entity.py | 58 +++++ .../components/mammotion/lawn_mower.py | 12 +- .../components/mammotion/manifest.json | 2 +- homeassistant/components/mammotion/number.py | 137 +++++++++++ homeassistant/components/mammotion/select.py | 96 ++++++++ homeassistant/components/mammotion/sensor.py | 226 ++++++++++++++++++ homeassistant/components/mammotion/switch.py | 102 ++++++++ 15 files changed, 944 insertions(+), 80 deletions(-) create mode 100644 homeassistant/components/mammotion/binary_sensor.py create mode 100644 homeassistant/components/mammotion/button.py create mode 100644 homeassistant/components/mammotion/device_tracker.py create mode 100644 homeassistant/components/mammotion/diagnostics.py create mode 100644 homeassistant/components/mammotion/entity.py create mode 100644 homeassistant/components/mammotion/number.py create mode 100644 homeassistant/components/mammotion/select.py create mode 100644 homeassistant/components/mammotion/sensor.py create mode 100644 homeassistant/components/mammotion/switch.py diff --git a/homeassistant/components/mammotion/__init__.py b/homeassistant/components/mammotion/__init__.py index 772bdb037f29a..abb1f37528bc6 100644 --- a/homeassistant/components/mammotion/__init__.py +++ b/homeassistant/components/mammotion/__init__.py @@ -10,7 +10,16 @@ from .const import CONF_RETRY_COUNT, DEFAULT_RETRY_COUNT from .coordinator import MammotionDataUpdateCoordinator -PLATFORMS: list[Platform] = [Platform.LAWN_MOWER] +PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.LAWN_MOWER, + Platform.DEVICE_TRACKER, + Platform.SENSOR, + Platform.BUTTON, + Platform.SWITCH, + Platform.NUMBER, + # Platform.SELECT +] type MammotionConfigEntry = ConfigEntry[MammotionDataUpdateCoordinator] diff --git a/homeassistant/components/mammotion/binary_sensor.py b/homeassistant/components/mammotion/binary_sensor.py new file mode 100644 index 0000000000000..24b0d87493a31 --- /dev/null +++ b/homeassistant/components/mammotion/binary_sensor.py @@ -0,0 +1,72 @@ +"""Mammotion binary sensor entities.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from pymammotion.proto.luba_msg import LubaMsg + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import MammotionConfigEntry +from .coordinator import MammotionDataUpdateCoordinator +from .entity import MammotionBaseEntity + + +@dataclass(frozen=True, kw_only=True) +class MammotionBinarySensorEntityDescription( + BinarySensorEntityDescription, +): + """Describes Mammotion binary sensor entity.""" + + is_on_fn: Callable[[LubaMsg], bool | None] + + +BINARY_SENSORS: tuple[MammotionBinarySensorEntityDescription, ...] = ( + MammotionBinarySensorEntityDescription( + key="charging", + device_class=BinarySensorDeviceClass.BATTERY_CHARGING, + is_on_fn=lambda mower_data: mower_data.sys.toapp_report_data.dev.charge_state + in (1, 2), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: MammotionConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Mammotion sensor entity.""" + coordinator = entry.runtime_data + + async_add_entities( + MammotionBinarySensorEntity(coordinator, entity_description) + for entity_description in BINARY_SENSORS + ) + + +class MammotionBinarySensorEntity(MammotionBaseEntity, BinarySensorEntity): + """Mammotion sensor entity.""" + + entity_description: MammotionBinarySensorEntityDescription + + def __init__( + self, + coordinator: MammotionDataUpdateCoordinator, + entity_description: MammotionBinarySensorEntityDescription, + ) -> None: + """Initialize the binary sensor entity.""" + super().__init__(coordinator, entity_description.key) + self.entity_description = entity_description + self._attr_translation_key = entity_description.translation_key + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + return self.entity_description.is_on_fn(self.coordinator.data) diff --git a/homeassistant/components/mammotion/button.py b/homeassistant/components/mammotion/button.py new file mode 100644 index 0000000000000..18b0a6cc21e8e --- /dev/null +++ b/homeassistant/components/mammotion/button.py @@ -0,0 +1,66 @@ +"""Mammotion button sensor entities.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import MammotionConfigEntry +from .coordinator import MammotionDataUpdateCoordinator +from .entity import MammotionBaseEntity + + +@dataclass(frozen=True, kw_only=True) +class MammotionButtonSensorEntityDescription(ButtonEntityDescription): + """Describes Mammotion button sensor entity.""" + + press_fn: Callable[[MammotionDataUpdateCoordinator], Awaitable[None]] + + +BUTTON_SENSORS: tuple[MammotionButtonSensorEntityDescription, ...] = ( + MammotionButtonSensorEntityDescription( + key="start_map_sync", + press_fn=lambda coordinator: coordinator.async_sync_maps(), + ), + MammotionButtonSensorEntityDescription( + key="resync_rtk_dock", + press_fn=lambda coordinator: coordinator.async_rtk_dock_location(), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: MammotionConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Mammotion button sensor entity.""" + coordinator = entry.runtime_data + + async_add_entities( + MammotionButtonSensorEntity(coordinator, entity_description) + for entity_description in BUTTON_SENSORS + ) + + +class MammotionButtonSensorEntity(MammotionBaseEntity, ButtonEntity): + """Mammotion button sensor entity.""" + + entity_description: MammotionButtonSensorEntityDescription + _attr_has_entity_name = True + + def __init__( + self, + coordinator: MammotionDataUpdateCoordinator, + entity_description: MammotionButtonSensorEntityDescription, + ) -> None: + """Initialize the button sensor entity.""" + super().__init__(coordinator, entity_description.key) + self.entity_description = entity_description + self._attr_translation_key = entity_description.key + + async def async_press(self) -> None: + """Handle the button press.""" + await self.entity_description.press_fn(self.coordinator) diff --git a/homeassistant/components/mammotion/config_flow.py b/homeassistant/components/mammotion/config_flow.py index 2f5f9a5866b7b..3c13756cf35af 100644 --- a/homeassistant/components/mammotion/config_flow.py +++ b/homeassistant/components/mammotion/config_flow.py @@ -3,7 +3,6 @@ from typing import Any from bleak import BLEDevice -from pymammotion.aliyun.cloud_gateway import CloudIOTGateway from pymammotion.http.http import connect_http import voluptuous as vol @@ -25,7 +24,7 @@ from .const import ( CONF_ACCOUNTNAME, - CONF_DEVICELIST, + CONF_DEVICE_NAME, CONF_STAY_CONNECTED_BLUETOOTH, CONF_USE_WIFI, DEVICE_SUPPORT, @@ -39,6 +38,7 @@ class MammotionConfigFlow(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize the config flow.""" + self._config = {} self._discovered_device: BLEDevice | None = None self._discovered_devices: dict[str, str] = {} @@ -51,7 +51,7 @@ async def async_step_bluetooth( if discovery_info is None: return self.async_abort(reason="no_device") - await self.async_set_unique_id(discovery_info.address) + await self.async_set_unique_id(discovery_info.name) self._abort_if_unique_id_configured( updates={CONF_ADDRESS: discovery_info.address} ) @@ -95,19 +95,23 @@ async def async_step_user( if user_input is not None: address = user_input.get(CONF_ADDRESS) if address is not None: - await self.async_set_unique_id(address, raise_on_progress=False) - self._abort_if_unique_id_configured() - name = self._discovered_devices.get(address) if name is None: return self.async_abort(reason="no_longer_present") + await self.async_set_unique_id(name, raise_on_progress=False) + self._abort_if_unique_id_configured() + if user_input.get(CONF_USE_WIFI) is False: return self.async_create_entry( title=name, data={CONF_ADDRESS: address}, ) + self._config = { + CONF_ADDRESS: address, + } + return await self.async_step_wifi(user_input) current_addresses = self._async_current_ids() @@ -134,61 +138,37 @@ async def async_step_user( async def async_step_wifi(self, user_input: dict[str, Any]) -> ConfigFlowResult: """Handle the user step for Wi-Fi control.""" - + print("step_wifi") + print(user_input) if user_input is not None and ( user_input.get(CONF_ACCOUNTNAME) is not None or user_input.get(CONF_USE_WIFI) is True ): account = user_input.get(CONF_ACCOUNTNAME) password = user_input.get(CONF_PASSWORD) - address = user_input.get(CONF_ADDRESS) + address = self._config.get(CONF_ADDRESS) + device_name = user_input.get(CONF_DEVICE_NAME) name = self._discovered_devices.get(address) + print(self._config) if address is None or name is None: - try: - cloud_client = CloudIOTGateway() - mammotion_http = await connect_http(account, password) - country_code = ( - mammotion_http.login.userInformation.domainAbbreviation - ) - await self.hass.async_add_executor_job( - cloud_client.get_region, - country_code, - mammotion_http.login.authorization_code, - ) - await cloud_client.connect() - await cloud_client.login_by_oauth( - country_code, mammotion_http.login.authorization_code - ) - await self.hass.async_add_executor_job(cloud_client.aep_handle) - await self.hass.async_add_executor_job( - cloud_client.session_by_auth_code - ) + if device_name is not None: + await self.async_set_unique_id(device_name, raise_on_progress=False) + self._abort_if_unique_id_configured() + else: + return self.async_abort(reason="no_device_name") - device_list = await self.hass.async_add_executor_job( - cloud_client.list_binding_by_account - ) - if device_list.data.total == 1: - device = device_list.data.data[0] - name = device.deviceName - await self.async_set_unique_id(name, raise_on_progress=False) - self._abort_if_unique_id_configured() - if device_list.data.total == 0: - return self.async_abort(reason="no_devices") - - if device_list.data.total > 1: - # figure out how to present it - pass - - except Exception as e: - return self.async_abort(reason=str(e)) + try: + await connect_http(account, password) + except Exception as err: + return self.async_abort(reason=str(err)) return self.async_create_entry( title=name, data={ - CONF_ADDRESS: address, + **self._config, CONF_ACCOUNTNAME: account, CONF_PASSWORD: password, - CONF_DEVICELIST: device_list, + CONF_DEVICE_NAME: name or device_name, }, ) @@ -200,8 +180,9 @@ async def async_step_wifi(self, user_input: dict[str, Any]) -> ConfigFlowResult: if user_input.get(CONF_ADDRESS) is None: schema = { - vol.Required(CONF_ACCOUNTNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_DEVICE_NAME): vol.All(cv.string, vol.Strip), + vol.Required(CONF_ACCOUNTNAME): vol.All(cv.string, vol.Strip), + vol.Required(CONF_PASSWORD): vol.All(cv.string, vol.Strip), } return self.async_show_form(data_schema=vol.Schema(schema)) diff --git a/homeassistant/components/mammotion/const.py b/homeassistant/components/mammotion/const.py index 77b85f86fc791..e22b24cd73353 100644 --- a/homeassistant/components/mammotion/const.py +++ b/homeassistant/components/mammotion/const.py @@ -23,8 +23,7 @@ TimeoutError, ) -CONF_USE_BLUETOOTH: Final = "use_bluetooth" CONF_STAY_CONNECTED_BLUETOOTH: Final = "stay_connected_bluetooth" -CONF_USE_WIFI: Final = "use_wifi" CONF_ACCOUNTNAME: Final = "account_name" -CONF_DEVICELIST: Final = "device_list" +CONF_USE_WIFI: Final = "use_wifi" +CONF_DEVICE_NAME: Final = "device_name" diff --git a/homeassistant/components/mammotion/coordinator.py b/homeassistant/components/mammotion/coordinator.py index 87ec21b41e955..eea457c59a871 100644 --- a/homeassistant/components/mammotion/coordinator.py +++ b/homeassistant/components/mammotion/coordinator.py @@ -10,7 +10,8 @@ from pymammotion.data.model.device import MowingDevice from pymammotion.mammotion.devices.mammotion import ( ConnectionPreference, - MammotionDevice, + Mammotion, + create_devices, ) from pymammotion.proto.mctrl_sys import RptAct, RptInfoType from pymammotion.utility.constant import WorkMode @@ -21,7 +22,13 @@ from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import COMMAND_EXCEPTIONS, CONF_ACCOUNTNAME, DOMAIN, LOGGER +from .const import ( + COMMAND_EXCEPTIONS, + CONF_ACCOUNTNAME, + CONF_DEVICE_NAME, + DOMAIN, + LOGGER, +) if TYPE_CHECKING: from . import MammotionConfigEntry @@ -35,7 +42,7 @@ class MammotionDataUpdateCoordinator(DataUpdateCoordinator[MowingDevice]): address: str config_entry: MammotionConfigEntry device_name: str - device: MammotionDevice + devices: Mammotion def __init__( self, @@ -56,6 +63,8 @@ async def async_setup(self) -> None: credentials = Credentials() preference = ConnectionPreference.BLUETOOTH address = self.config_entry.data.get(CONF_ADDRESS) + name = self.config_entry.data.get(CONF_DEVICE_NAME) + if address: ble_device = bluetooth.async_ble_device_from_address(self.hass, address) if not ble_device: @@ -65,27 +74,28 @@ async def async_setup(self) -> None: self.device_name = ble_device.name or "Unknown" self.address = ble_device.address - self.device._ble_device.update_device(ble_device) account = self.config_entry.data.get(CONF_ACCOUNTNAME) password = self.config_entry.data.get(CONF_PASSWORD) if account and password: + if name: + self.device_name = name preference = ConnectionPreference.WIFI credentials.email = account credentials.password = password - self.device = await self.hass.async_add_executor_job( - MammotionDevice, ble_device, credentials, preference - ) - + self.devices = await create_devices(ble_device, credentials, preference) + print("creating devices") try: - await self.device.start_sync(0) + if preference is not ConnectionPreference.WIFI: + await self.devices.start_sync(self.device_name, 0) + except COMMAND_EXCEPTIONS as exc: raise ConfigEntryNotReady("Unable to setup Mammotion device") from exc async def async_sync_maps(self) -> None: """Get map data from the device.""" - await self.device.start_map_sync() + await self.devices.start_map_sync(self.device_name) async def async_start_stop_blades(self, start_stop: bool) -> None: if start_stop: @@ -117,9 +127,11 @@ async def async_request_iot_sync(self) -> None: count=0, ) - async def async_send_command(self, command: str, **kwargs) -> None: + async def async_send_command(self, command: str, **kwargs: any) -> None: try: - await self.device.command(command, **kwargs) + await self.devices.send_command_with_args( + self.device_name, command, **kwargs + ) except COMMAND_EXCEPTIONS as exc: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="command_failed" @@ -127,6 +139,7 @@ async def async_send_command(self, command: str, **kwargs) -> None: async def _async_update_data(self) -> MowingDevice: """Get data from the device.""" + device = self.devices.get_device_by_name(self.device_name) if not ( ble_device := bluetooth.async_ble_device_from_address( self.hass, self.address @@ -135,11 +148,11 @@ async def _async_update_data(self) -> MowingDevice: self.update_failures += 1 raise UpdateFailed("Could not find device") - self.device.update_device(ble_device) + device.ble().update_device(ble_device) try: - if len(self.device.luba_msg.net.toapp_devinfo_resp.resp_ids) == 0: - await self.device.start_sync(0) - if self.device.luba_msg.report_data.dev.sys_status != WorkMode.MODE_WORKING: + if len(device.mower_state().net.toapp_devinfo_resp.resp_ids) == 0: + await self.devices.start_sync(self.device_name, 0) + if device.mower_state().report_data.dev.sys_status != WorkMode.MODE_WORKING: await self.async_send_command("get_report_cfg") else: @@ -151,14 +164,18 @@ async def _async_update_data(self) -> MowingDevice: LOGGER.debug("Updated Mammotion device %s", self.device_name) LOGGER.debug("================= Debug Log =================") - LOGGER.debug("Mammotion device data: %s", asdict(self.device.luba_msg)) + LOGGER.debug( + "Mammotion device data: %s", + asdict(self.devices.get_device_by_name(self.device_name).mower_state()), + ) LOGGER.debug("==================================") self.update_failures = 0 - return self.device.luba_msg - - async def _async_setup(self) -> None: - try: - await self.device.start_sync(0) - except COMMAND_EXCEPTIONS as exc: - raise UpdateFailed(f"Setting up Mammotion device failed: {exc}") from exc + return self.devices.get_device_by_name(self.device_name).mower_state() + + # TODO when submitting to HA use this 2024.8 and up + # async def _async_setup(self) -> None: + # try: + # await self.async_setup() + # except COMMAND_EXCEPTIONS as exc: + # raise UpdateFailed(f"Setting up Mammotion device failed: {exc}") from exc diff --git a/homeassistant/components/mammotion/device_tracker.py b/homeassistant/components/mammotion/device_tracker.py new file mode 100644 index 0000000000000..66904fdf2bf75 --- /dev/null +++ b/homeassistant/components/mammotion/device_tracker.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.components.device_tracker import SourceType, TrackerEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import MammotionConfigEntry +from .const import ATTR_DIRECTION +from .coordinator import MammotionDataUpdateCoordinator +from .entity import MammotionBaseEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: MammotionConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the RTK tracker from config entry.""" + coordinator = config_entry.runtime_data + + async_add_entities([MammotionTracker(coordinator)]) + + +class MammotionTracker(MammotionBaseEntity, TrackerEntity): + """Mammotion device tracker.""" + + _attr_force_update = False + _attr_translation_key = "device_tracker" + _attr_icon = "mdi:car" + + def __init__(self, coordinator: MammotionDataUpdateCoordinator) -> None: + """Initialize the Tracker.""" + super().__init__(coordinator, f"{coordinator.device_name}_gps") + + self._attr_name = coordinator.device_name + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return entity specific state attributes.""" + return { + ATTR_DIRECTION: self.coordinator.devices.mower( + self.coordinator.device_name + ).location.orientation + } + + @property + def latitude(self) -> float | None: + """Return latitude value of the device.""" + return self.coordinator.devices.mower( + self.coordinator.device_name + ).location.device.latitude + + @property + def longitude(self) -> float | None: + """Return longitude value of the device.""" + return self.coordinator.devices.mower( + self.coordinator.device_name + ).location.device.longitude + + @property + def battery_level(self) -> int | None: + """Return the battery level of the device.""" + return self.coordinator.data.report_data.dev.battery_val + + @property + def source_type(self) -> SourceType: + """Return the source type, e.g., GPS or router, of the device.""" + return SourceType.GPS diff --git a/homeassistant/components/mammotion/diagnostics.py b/homeassistant/components/mammotion/diagnostics.py new file mode 100644 index 0000000000000..1c08e891dd627 --- /dev/null +++ b/homeassistant/components/mammotion/diagnostics.py @@ -0,0 +1,22 @@ +"""Diagnostics support for Mammotion.""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.core import HomeAssistant + +from . import MammotionConfigEntry + +TO_REDACT: list[str] = [] + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, + entry: MammotionConfigEntry, +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator = entry.runtime_data + return async_redact_data(asdict(coordinator.data), TO_REDACT) diff --git a/homeassistant/components/mammotion/entity.py b/homeassistant/components/mammotion/entity.py new file mode 100644 index 0000000000000..da6d3dc0cbd94 --- /dev/null +++ b/homeassistant/components/mammotion/entity.py @@ -0,0 +1,58 @@ +"""Base class for entities.""" + +from pymammotion.utility.device_type import DeviceType + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import CONF_RETRY_COUNT, DOMAIN +from .coordinator import MammotionDataUpdateCoordinator + + +class MammotionBaseEntity(CoordinatorEntity[MammotionDataUpdateCoordinator]): + """Representation of a Luba lawn mower.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: MammotionDataUpdateCoordinator, key: str) -> None: + """Initialize the lawn mower.""" + super().__init__(coordinator) + swversion = "0.0.0" + if ( + len( + coordinator.devices.mower( + coordinator.device_name + ).net.toapp_devinfo_resp.resp_ids + ) + > 0 + ): + swversion = ( + coordinator.devices.mower(coordinator.device_name) + .net.toapp_devinfo_resp.resp_ids[0] + .info + ) + + self._attr_unique_id = f"{coordinator.device_name}_{key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.device_name)}, + manufacturer="Mammotion", + serial_number=coordinator.device_name.split("-", 1)[-1], + name=coordinator.device_name, + sw_version=swversion, + model=DeviceType.value_of_str( + coordinator.device_name, + coordinator.devices.mower( + coordinator.device_name + ).net.toapp_wifi_iot_status.productkey, + ).get_model(), + suggested_area="Garden", + ) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return ( + self.coordinator.data is not None + and self.coordinator.update_failures + <= self.coordinator.config_entry.options[CONF_RETRY_COUNT] + ) diff --git a/homeassistant/components/mammotion/lawn_mower.py b/homeassistant/components/mammotion/lawn_mower.py index 6fe9fe1a254d5..573776515a3b5 100644 --- a/homeassistant/components/mammotion/lawn_mower.py +++ b/homeassistant/components/mammotion/lawn_mower.py @@ -101,7 +101,9 @@ async def async_start_mowing(self) -> None: translation_domain=DOMAIN, translation_key="start_failed" ) from exc finally: - self.coordinator.async_set_updated_data(self.coordinator.device.luba_msg) + self.coordinator.async_set_updated_data( + self.coordinator.devices.mower(self.coordinator.device_name) + ) async def async_dock(self) -> None: """Start docking.""" @@ -121,7 +123,9 @@ async def async_dock(self) -> None: translation_domain=DOMAIN, translation_key="dock_failed" ) from exc finally: - self.coordinator.async_set_updated_data(self.coordinator.device.luba_msg) + self.coordinator.async_set_updated_data( + self.coordinator.devices.mower(self.coordinator.device_name) + ) async def async_pause(self) -> None: """Pause mower.""" @@ -133,4 +137,6 @@ async def async_pause(self) -> None: translation_domain=DOMAIN, translation_key="pause_failed" ) from exc finally: - self.coordinator.async_set_updated_data(self.coordinator.device.luba_msg) + self.coordinator.async_set_updated_data( + self.coordinator.devices.mower(self.coordinator.device_name) + ) diff --git a/homeassistant/components/mammotion/manifest.json b/homeassistant/components/mammotion/manifest.json index ee17ee5483dae..1ef9c14b31a0d 100644 --- a/homeassistant/components/mammotion/manifest.json +++ b/homeassistant/components/mammotion/manifest.json @@ -19,5 +19,5 @@ "documentation": "https://www.home-assistant.io/integrations/mammotion", "integration_type": "device", "iot_class": "local_push", - "requirements": ["pymammotion==0.2.0"] + "requirements": ["pymammotion==0.2.4"] } diff --git a/homeassistant/components/mammotion/number.py b/homeassistant/components/mammotion/number.py new file mode 100644 index 0000000000000..e8c7a09d854a1 --- /dev/null +++ b/homeassistant/components/mammotion/number.py @@ -0,0 +1,137 @@ +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from pymammotion.data.model.device_config import DeviceLimits + +from homeassistant.components.number import ( + NumberEntity, + NumberEntityDescription, + NumberMode, +) +from homeassistant.const import PERCENTAGE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import MammotionConfigEntry +from .coordinator import MammotionDataUpdateCoordinator +from .entity import MammotionBaseEntity + + +@dataclass(frozen=True, kw_only=True) +class MammotionNumberEntityDescription(NumberEntityDescription): + """Describes Mammotion number entity.""" + + set_fn: Callable[[MammotionDataUpdateCoordinator, float], Awaitable[None]] + + +NUMBER_ENTITIES: tuple[MammotionNumberEntityDescription, ...] = ( + MammotionNumberEntityDescription( + key="start_progress", + min_value=0, + max_value=100, + step=1, + mode=NumberMode.SLIDER, + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.CONFIG, + set_fn=lambda coordinator, value: value, + ), +) + + +NUMBER_WORKING_ENTITIES: tuple[MammotionNumberEntityDescription, ...] = ( + MammotionNumberEntityDescription( + key="blade_height", + step=5.0, + min_value=30.0, # ToDo: To be dynamiclly set based on model (h\non H) + max_value=70.0, # ToDo: To be dynamiclly set based on model (h\non H) + entity_category=EntityCategory.CONFIG, + set_fn=lambda coordinator, value: coordinator.async_blade_height(value), + ), + MammotionNumberEntityDescription( + key="working_speed", + entity_category=EntityCategory.CONFIG, + step=0.1, + min_value=0.2, + max_value=0.6, + set_fn=lambda coordinator, value: value, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: MammotionConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Mammotion number entities.""" + coordinator = entry.runtime_data + limits = coordinator.devices.mower(coordinator.device_name).limits + + entities: list[MammotionNumberEntity] = [] + + for entity_description in NUMBER_WORKING_ENTITIES: + entity = MammotionWorkingNumberEntity(coordinator, entity_description, limits) + entities.append(entity) + + for entity_description in NUMBER_ENTITIES: + entity = MammotionNumberEntity(coordinator, entity_description) + entities.append(entity) + + async_add_entities(entities) + + +class MammotionNumberEntity(MammotionBaseEntity, NumberEntity): + entity_description: MammotionNumberEntityDescription + _attr_has_entity_name = True + + def __init__( + self, + coordinator: MammotionDataUpdateCoordinator, + entity_description: MammotionNumberEntityDescription, + ) -> None: + super().__init__(coordinator, entity_description.key) + self.entity_description = entity_description + self._attr_translation_key = entity_description.key + self._attr_native_min_value = entity_description.min_value + self._attr_native_max_value = entity_description.max_value + self._attr_native_step = entity_description.step + self._attr_native_value = self._attr_native_min_value # Default value + + async def async_set_native_value(self, value: float) -> None: + self._attr_native_value = value + await self.entity_description.set_fn(self.coordinator, value) + self.async_write_ha_state() + + +class MammotionWorkingNumberEntity(MammotionNumberEntity): + """Mammotion working number entity.""" + + def __init__( + self, + coordinator: MammotionDataUpdateCoordinator, + entity_description: MammotionNumberEntityDescription, + limits: DeviceLimits, + ) -> None: + super().__init__(coordinator, entity_description) + + min_attr = f"{entity_description.key}_min" + max_attr = f"{entity_description.key}_max" + + if hasattr(limits, min_attr) and hasattr(limits, max_attr): + self._attr_native_min_value = getattr(limits, min_attr) + self._attr_native_max_value = getattr(limits, max_attr) + else: + # Fallback to the values from entity_description + self._attr_native_min_value = entity_description.min_value + self._attr_native_max_value = entity_description.max_value + + @property + def native_min_value(self) -> float: + """Return the minimum value.""" + return self._attr_native_min_value + + @property + def native_max_value(self) -> float: + """Return the maximum value.""" + return self._attr_native_max_value diff --git a/homeassistant/components/mammotion/select.py b/homeassistant/components/mammotion/select.py new file mode 100644 index 0000000000000..142a3cef47881 --- /dev/null +++ b/homeassistant/components/mammotion/select.py @@ -0,0 +1,96 @@ +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from pymammotion.data.model.mowing_modes import ( + BorderPatrolMode, + CuttingMode, + MowOrder, + ObstacleLapsMode, +) + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import MammotionConfigEntry +from .coordinator import MammotionDataUpdateCoordinator +from .entity import MammotionBaseEntity + + +@dataclass(frozen=True, kw_only=True) +class MammotionSelectEntityDescription(SelectEntityDescription): + """Describes Mammotion select entity.""" + + key: str + options: list[str] + select_fn: Callable[[MammotionDataUpdateCoordinator, str], Awaitable[None]] + + +SELECT_ENTITIES: tuple[MammotionSelectEntityDescription, ...] = ( + MammotionSelectEntityDescription( + key="cutting_mode", + entity_category=EntityCategory.CONFIG, + options=[mode.name for mode in CuttingMode], + select_fn=lambda coordinator, value: CuttingMode[value], + ), + MammotionSelectEntityDescription( + key="border_patrol_mode", + entity_category=EntityCategory.CONFIG, + options=[mode.name for mode in BorderPatrolMode], + select_fn=lambda coordinator, value: BorderPatrolMode[value], + ), + MammotionSelectEntityDescription( + key="obstacle_laps_mode", + entity_category=EntityCategory.CONFIG, + options=[mode.name for mode in ObstacleLapsMode], + select_fn=lambda coordinator, value: ObstacleLapsMode[value], + ), + MammotionSelectEntityDescription( + key="mow_order", + entity_category=EntityCategory.CONFIG, + options=[order.name for order in MowOrder], + select_fn=lambda coordinator, value: MowOrder[value], + ), +) + + +# Define the setup entry function +async def async_setup_entry( + hass: HomeAssistant, + entry: MammotionConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Mammotion select entity.""" + coordinator = entry.runtime_data + + async_add_entities( + MammotionSelectEntity(coordinator, entity_description) + for entity_description in SELECT_ENTITIES + ) + + +# Define the select entity class with entity_category: config +class MammotionSelectEntity(MammotionBaseEntity, SelectEntity): + """Representation of a Mammotion select entities.""" + + _attr_entity_category = EntityCategory.CONFIG + + entity_description: MammotionSelectEntityDescription + _attr_has_entity_name = True + + def __init__( + self, + coordinator: MammotionDataUpdateCoordinator, + entity_description: MammotionSelectEntityDescription, + ) -> None: + super().__init__(coordinator, entity_description.key) + self.coordinator = coordinator + self.entity_description = entity_description + self._attr_translation_key = entity_description.key + self._attr_options = entity_description.options + + async def async_select_option(self, option: str) -> None: + self._attr_current_option = option + await self.entity_description.select_fn(self.coordinator, option) + self.async_write_ha_state() diff --git a/homeassistant/components/mammotion/sensor.py b/homeassistant/components/mammotion/sensor.py new file mode 100644 index 0000000000000..4757900e8f9bc --- /dev/null +++ b/homeassistant/components/mammotion/sensor.py @@ -0,0 +1,226 @@ +"""Creates the sensor entities for the mower.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from pymammotion.data.model.device import MowingDevice +from pymammotion.data.model.enums import RTKStatus +from pymammotion.utility.constant.device_constant import PosType, device_mode +from pymammotion.utility.device_type import DeviceType + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + AREA_SQUARE_METERS, + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + UnitOfLength, + UnitOfSpeed, + UnitOfTime, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.util.unit_conversion import SpeedConverter + +from . import MammotionConfigEntry +from .coordinator import MammotionDataUpdateCoordinator +from .entity import MammotionBaseEntity + +SPEED_UNITS = SpeedConverter.VALID_UNITS + + +@dataclass(frozen=True, kw_only=True) +class MammotionSensorEntityDescription(SensorEntityDescription): + """Describes Mammotion sensor entity.""" + + value_fn: Callable[[MowingDevice], StateType] + + +LUBA_SENSOR_ONLY_TYPES: tuple[MammotionSensorEntityDescription, ...] = ( + MammotionSensorEntityDescription( + key="blade_height", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DISTANCE, + native_unit_of_measurement=UnitOfLength.MILLIMETERS, + value_fn=lambda mower_data: mower_data.report_data.work.knife_height, + ), +) + +SENSOR_TYPES: tuple[MammotionSensorEntityDescription, ...] = ( + MammotionSensorEntityDescription( + key="battery_percent", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda mower_data: mower_data.report_data.dev.battery_val, + ), + MammotionSensorEntityDescription( + key="ble_rssi", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + value_fn=lambda mower_data: mower_data.report_data.connect.ble_rssi, + ), + MammotionSensorEntityDescription( + key="wifi_rssi", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + value_fn=lambda mower_data: mower_data.report_data.connect.wifi_rssi, + ), + MammotionSensorEntityDescription( + key="gps_stars", + state_class=SensorStateClass.MEASUREMENT, + device_class=None, + native_unit_of_measurement=None, + value_fn=lambda mower_data: mower_data.report_data.rtk.gps_stars, + ), + MammotionSensorEntityDescription( + key="area", + state_class=SensorStateClass.MEASUREMENT, + device_class=None, + native_unit_of_measurement=AREA_SQUARE_METERS, + value_fn=lambda mower_data: mower_data.report_data.work.area & 65535, + ), + MammotionSensorEntityDescription( + key="mowing_speed", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.SPEED, + native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, + value_fn=lambda mower_data: mower_data.report_data.work.man_run_speed / 100, + ), + MammotionSensorEntityDescription( + key="progress", + state_class=SensorStateClass.MEASUREMENT, + device_class=None, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda mower_data: mower_data.report_data.work.area >> 16, + ), + MammotionSensorEntityDescription( + key="total_time", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MINUTES, + value_fn=lambda mower_data: mower_data.report_data.work.progress & 65535, + ), + MammotionSensorEntityDescription( + key="elapsed_time", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MINUTES, + value_fn=lambda mower_data: (mower_data.report_data.work.progress & 65535) + - (mower_data.report_data.work.progress >> 16), + ), + MammotionSensorEntityDescription( + key="left_time", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MINUTES, + value_fn=lambda mower_data: mower_data.report_data.work.progress >> 16, + ), + MammotionSensorEntityDescription( + key="l1_satellites", + state_class=SensorStateClass.MEASUREMENT, + device_class=None, + native_unit_of_measurement=None, + value_fn=lambda mower_data: (mower_data.report_data.rtk.co_view_stars >> 0) + & 255, + ), + MammotionSensorEntityDescription( + key="l2_satellites", + state_class=SensorStateClass.MEASUREMENT, + device_class=None, + native_unit_of_measurement=None, + value_fn=lambda mower_data: (mower_data.report_data.rtk.co_view_stars >> 8) + & 255, + ), + MammotionSensorEntityDescription( + key="activity_mode", + state_class=None, + device_class=SensorDeviceClass.ENUM, + value_fn=lambda mower_data: device_mode(mower_data.report_data.dev.sys_status), + ), + MammotionSensorEntityDescription( + key="position_mode", + state_class=None, + device_class=SensorDeviceClass.ENUM, + native_unit_of_measurement=None, + value_fn=lambda mower_data: str( + RTKStatus.from_value(mower_data.report_data.rtk.status) + ), # Note: This will not work for Luba2 & Yuka. Only for Luba1 + ), + MammotionSensorEntityDescription( + key="position_type", + state_class=None, + device_class=SensorDeviceClass.ENUM, + native_unit_of_measurement=None, + value_fn=lambda mower_data: str( + PosType(mower_data.location.position_type).name + ), # Note: This will not work for Luba2 & Yuka. Only for Luba1 + ), + # MammotionSensorEntityDescription( + # key="lawn_mower_position", + # state_class=None, + # device_class=None, # Set device class to "geo_location" + # native_unit_of_measurement=None, + # value_fn=lambda mower_data: f"{mower_data.location.device.latitude}, {mower_data.location.device.longitude}" + # ) + # ToDo: We still need to add the following. + # - RTK Status - None, Single, Fix, Float, Unknown (RTKStatusFragment.java) + # - Signal quality (Robot) + # - Signal quality (Ref. Station) + # - LoRa number + # - Multi-point turn + # - Transverse mode + # - WiFi status + # - Side LED + # - Possibly more I forgot about + # 'real_pos_x': -142511, 'real_pos_y': -20548, 'real_toward': 50915, (robot position) +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: MammotionConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up sensor platform.""" + coordinator = entry.runtime_data + + if not DeviceType.is_yuka(coordinator.device_name): + async_add_entities( + MammotionSensorEntity(coordinator, description) + for description in LUBA_SENSOR_ONLY_TYPES + ) + + async_add_entities( + MammotionSensorEntity(coordinator, description) for description in SENSOR_TYPES + ) + + +class MammotionSensorEntity(MammotionBaseEntity, SensorEntity): + """Defining the Mammotion Sensor.""" + + entity_description: MammotionSensorEntityDescription + _attr_has_entity_name = True + + def __init__( + self, + coordinator: MammotionDataUpdateCoordinator, + description: MammotionSensorEntityDescription, + ) -> None: + """Set up MammotionSensor.""" + super().__init__(coordinator, description.key) + self.entity_description = description + self._attr_translation_key = description.key + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + current_value = self.entity_description.value_fn(self.coordinator.data) + return current_value diff --git a/homeassistant/components/mammotion/switch.py b/homeassistant/components/mammotion/switch.py new file mode 100644 index 0000000000000..7fc6c25ba6083 --- /dev/null +++ b/homeassistant/components/mammotion/switch.py @@ -0,0 +1,102 @@ +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory + +from . import MammotionConfigEntry +from .coordinator import MammotionDataUpdateCoordinator +from .entity import MammotionBaseEntity + + +@dataclass(frozen=True, kw_only=True) +class MammotionSwitchEntityDescription(SwitchEntityDescription): + """Describes Mammotion switch entity.""" + + key: str + set_fn: Callable[[MammotionDataUpdateCoordinator, bool], Awaitable[None]] + + +YUKA_SWITCH_ENTITIES: tuple[MammotionSwitchEntityDescription, ...] = ( + MammotionSwitchEntityDescription( + key="mowing_on_off", + entity_category=EntityCategory.CONFIG, + set_fn=lambda coordinator, value: print(f"Mowing {'on' if value else 'off'}"), + ), + MammotionSwitchEntityDescription( + key="dump_grass_on_off", + entity_category=EntityCategory.CONFIG, + set_fn=lambda coordinator, value: print( + f"Dump grass {'on' if value else 'off'}" + ), + ), +) + +SWITCH_ENTITIES: tuple[MammotionSwitchEntityDescription, ...] = ( + MammotionSwitchEntityDescription( + key="blades_on_off", + set_fn=lambda coordinator, value: coordinator.async_start_stop_blades(value), + ), + MammotionSwitchEntityDescription( + key="rain_detection_on_off", + entity_category=EntityCategory.CONFIG, + set_fn=lambda coordinator, value: print( + f"Rain detection {'on' if value else 'off'}" + ), + ), + MammotionSwitchEntityDescription( + key="side_led_on_off", + entity_category=EntityCategory.CONFIG, + set_fn=lambda coordinator, value: print(f"Side LED {'on' if value else 'off'}"), + ), + MammotionSwitchEntityDescription( + key="perimeter_first_on_off", + entity_category=EntityCategory.CONFIG, + set_fn=lambda coordinator, value: print( + f"perimeter mow first {'on' if value else 'off'}" + ), + ), +) + + +# Example setup usage +async def async_setup_entry( + hass: HomeAssistant, entry: MammotionConfigEntry, async_add_entities: Callable +) -> None: + """Set up the Mammotion switch entities.""" + coordinator = entry.runtime_data + + async_add_entities( + MammotionSwitchEntity(coordinator, entity_description) + for entity_description in SWITCH_ENTITIES + ) + + +class MammotionSwitchEntity(MammotionBaseEntity, SwitchEntity): + entity_description: MammotionSwitchEntityDescription + _attr_has_entity_name = True + + def __init__( + self, + coordinator: MammotionDataUpdateCoordinator, + entity_description: MammotionSwitchEntityDescription, + ) -> None: + super().__init__(coordinator, entity_description.key) + self.coordinator = coordinator + self.entity_description = entity_description + self._attr_translation_key = entity_description.key + self._attr_is_on = False # Default state + + async def async_turn_on(self, **kwargs) -> None: + self._attr_is_on = True + await self.entity_description.set_fn(self.coordinator, True) + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs) -> None: + self._attr_is_on = False + await self.entity_description.set_fn(self.coordinator, False) + self.async_write_ha_state() + + async def async_update(self) -> None: + """Update the entity state.""" From d0672c31c6774fb4981aa99200fa7383cf58b55e Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Mon, 26 Aug 2024 16:09:08 +1200 Subject: [PATCH 09/66] further work --- .../components/mammotion/coordinator.py | 65 ++++++++++--------- .../components/mammotion/lawn_mower.py | 14 ++-- .../components/mammotion/manifest.json | 2 +- 3 files changed, 44 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/mammotion/coordinator.py b/homeassistant/components/mammotion/coordinator.py index eea457c59a871..82fd616675dbf 100644 --- a/homeassistant/components/mammotion/coordinator.py +++ b/homeassistant/components/mammotion/coordinator.py @@ -42,7 +42,7 @@ class MammotionDataUpdateCoordinator(DataUpdateCoordinator[MowingDevice]): address: str config_entry: MammotionConfigEntry device_name: str - devices: Mammotion + devices: Mammotion | None = None def __init__( self, @@ -65,27 +65,28 @@ async def async_setup(self) -> None: address = self.config_entry.data.get(CONF_ADDRESS) name = self.config_entry.data.get(CONF_DEVICE_NAME) - if address: - ble_device = bluetooth.async_ble_device_from_address(self.hass, address) - if not ble_device: - raise ConfigEntryNotReady( - f"Could not find Mammotion lawn mower with address {address}" - ) - - self.device_name = ble_device.name or "Unknown" - self.address = ble_device.address - - account = self.config_entry.data.get(CONF_ACCOUNTNAME) - password = self.config_entry.data.get(CONF_PASSWORD) - if account and password: - if name: - self.device_name = name - preference = ConnectionPreference.WIFI - credentials.email = account - credentials.password = password - - self.devices = await create_devices(ble_device, credentials, preference) - print("creating devices") + if self.devices is None or self.devices.get_device_by_name(name) is None: + if address: + ble_device = bluetooth.async_ble_device_from_address(self.hass, address) + if not ble_device and credentials is None: + raise ConfigEntryNotReady( + f"Could not find Mammotion lawn mower with address {address}" + ) + if ble_device is not None: + self.device_name = ble_device.name or "Unknown" + self.address = address + + account = self.config_entry.data.get(CONF_ACCOUNTNAME) + password = self.config_entry.data.get(CONF_PASSWORD) + if account and password: + if name: + self.device_name = name + preference = ConnectionPreference.WIFI + credentials.email = account + credentials.password = password + + self.devices = await create_devices(ble_device, credentials, preference) + print("creating devices") try: if preference is not ConnectionPreference.WIFI: await self.devices.start_sync(self.device_name, 0) @@ -140,17 +141,23 @@ async def async_send_command(self, command: str, **kwargs: any) -> None: async def _async_update_data(self) -> MowingDevice: """Get data from the device.""" device = self.devices.get_device_by_name(self.device_name) - if not ( - ble_device := bluetooth.async_ble_device_from_address( - self.hass, self.address - ) - ): + ble_device = bluetooth.async_ble_device_from_address(self.hass, self.address) + + if not ble_device and device.cloud() is None: self.update_failures += 1 raise UpdateFailed("Could not find device") - device.ble().update_device(ble_device) + if ble_device and device.ble() is not None: + device.ble().update_device(ble_device) + else: + device.add_ble(ble_device) + try: - if len(device.mower_state().net.toapp_devinfo_resp.resp_ids) == 0: + print(device.mower_state().net.toapp_wifi_iot_status.productkey) + if ( + len(device.mower_state().net.toapp_devinfo_resp.resp_ids) == 0 + or device.mower_state().net.toapp_wifi_iot_status.productkey is None + ): await self.devices.start_sync(self.device_name, 0) if device.mower_state().report_data.dev.sys_status != WorkMode.MODE_WORKING: await self.async_send_command("get_report_cfg") diff --git a/homeassistant/components/mammotion/lawn_mower.py b/homeassistant/components/mammotion/lawn_mower.py index 573776515a3b5..87f714c13965f 100644 --- a/homeassistant/components/mammotion/lawn_mower.py +++ b/homeassistant/components/mammotion/lawn_mower.py @@ -87,14 +87,14 @@ async def async_start_mowing(self) -> None: ) if self.rpt_dev_status.sys_status == WorkMode.MODE_PAUSE: try: - await self.coordinator.device.command("resume_execute_task") + await self.coordinator.devices.command("resume_execute_task") return await self.coordinator.async_request_iot_sync() except COMMAND_EXCEPTIONS as exc: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="resume_failed" ) from exc try: - await self.coordinator.device.command("start_job") + await self.coordinator.devices.command("start_job") await self.coordinator.async_request_iot_sync() except COMMAND_EXCEPTIONS as exc: raise HomeAssistantError( @@ -112,11 +112,11 @@ async def async_dock(self) -> None: try: if mode == WorkMode.MODE_RETURNING: - await self.coordinator.device.command("cancel_return_to_dock") - return await self.coordinator.device.command("get_report_cfg") + await self.coordinator.devices.command("cancel_return_to_dock") + return await self.coordinator.devices.command("get_report_cfg") if mode == WorkMode.MODE_WORKING: - await self.coordinator.device.command("pause_execute_task") - await self.coordinator.device.command("return_to_dock") + await self.coordinator.devices.command("pause_execute_task") + await self.coordinator.devices.command("return_to_dock") await self.coordinator.async_request_iot_sync() except COMMAND_EXCEPTIONS as exc: raise HomeAssistantError( @@ -130,7 +130,7 @@ async def async_dock(self) -> None: async def async_pause(self) -> None: """Pause mower.""" try: - await self.coordinator.device.command("pause_execute_task") + await self.coordinator.devices.command("pause_execute_task") await self.coordinator.async_request_iot_sync() except COMMAND_EXCEPTIONS as exc: raise HomeAssistantError( diff --git a/homeassistant/components/mammotion/manifest.json b/homeassistant/components/mammotion/manifest.json index 1ef9c14b31a0d..2aa1eb99c8b49 100644 --- a/homeassistant/components/mammotion/manifest.json +++ b/homeassistant/components/mammotion/manifest.json @@ -19,5 +19,5 @@ "documentation": "https://www.home-assistant.io/integrations/mammotion", "integration_type": "device", "iot_class": "local_push", - "requirements": ["pymammotion==0.2.4"] + "requirements": ["pymammotion==0.2.6"] } From 576313180d7857ab22c4ae63f95188c5d2a8ce34 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Thu, 29 Aug 2024 13:28:39 +1200 Subject: [PATCH 10/66] lots of changes --- .../components/mammotion/__init__.py | 45 +++++++-- .../components/mammotion/config_flow.py | 62 +++++++++++-- homeassistant/components/mammotion/const.py | 5 + .../components/mammotion/coordinator.py | 92 ++++++++++++------- .../components/mammotion/device_tracker.py | 6 +- homeassistant/components/mammotion/entity.py | 6 +- .../components/mammotion/lawn_mower.py | 20 ++-- .../components/mammotion/manifest.json | 2 +- homeassistant/components/mammotion/number.py | 2 +- .../components/mammotion/strings.json | 9 ++ requirements_all.txt | 2 +- 11 files changed, 184 insertions(+), 67 deletions(-) diff --git a/homeassistant/components/mammotion/__init__.py b/homeassistant/components/mammotion/__init__.py index abb1f37528bc6..7d1e3408868ba 100644 --- a/homeassistant/components/mammotion/__init__.py +++ b/homeassistant/components/mammotion/__init__.py @@ -7,7 +7,16 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from .const import CONF_RETRY_COUNT, DEFAULT_RETRY_COUNT +from .const import ( + CONF_AEP_DATA, + CONF_AUTH_DATA, + CONF_DEVICE_DATA, + CONF_REGION_DATA, + CONF_RETRY_COUNT, + CONF_SESSION_DATA, + CONF_USE_WIFI, + DEFAULT_RETRY_COUNT, +) from .coordinator import MammotionDataUpdateCoordinator PLATFORMS: list[Platform] = [ @@ -26,7 +35,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> bool: """Set up Mammotion Luba from a config entry.""" - assert entry.unique_id is not None if CONF_ADDRESS not in entry.data and CONF_MAC in entry.data: @@ -46,11 +54,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> options={CONF_RETRY_COUNT: DEFAULT_RETRY_COUNT}, ) - coordinator = MammotionDataUpdateCoordinator(hass) - - await coordinator.async_setup() - await coordinator.async_config_entry_first_refresh() - entry.runtime_data = coordinator + mammotion_coordinator = MammotionDataUpdateCoordinator(hass) + + await mammotion_coordinator.async_setup() + + # config_updates = {} + if CONF_AUTH_DATA not in entry.data: + config_updates = { + **entry.data, + CONF_AUTH_DATA: mammotion_coordinator.manager.cloud_client.get_login_by_oauth_response(), + CONF_REGION_DATA: mammotion_coordinator.manager.cloud_client.get_region_response(), + CONF_AEP_DATA: mammotion_coordinator.manager.cloud_client.get_aep_response(), + CONF_SESSION_DATA: mammotion_coordinator.manager.cloud_client.get_session_by_authcode_response(), + CONF_DEVICE_DATA: mammotion_coordinator.manager.cloud_client.get_devices_by_account_response(), + } + hass.config_entries.async_update_entry(entry, data=config_updates) + + use_wifi = entry.data.get(CONF_USE_WIFI) + if use_wifi is False: + await mammotion_coordinator.async_config_entry_first_refresh() + entry.runtime_data = mammotion_coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -63,4 +86,10 @@ async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> Non async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + if entry.runtime_data.manager.mqtt.is_connected: + await hass.async_add_executor_job( + entry.runtime_data.manager.mqtt.disconnect + ) + return unload_ok diff --git a/homeassistant/components/mammotion/config_flow.py b/homeassistant/components/mammotion/config_flow.py index 3c13756cf35af..ad063b3416776 100644 --- a/homeassistant/components/mammotion/config_flow.py +++ b/homeassistant/components/mammotion/config_flow.py @@ -1,6 +1,6 @@ """Config flow for Mammotion Luba.""" -from typing import Any +from typing import TYPE_CHECKING, Any from bleak import BLEDevice from pymammotion.http.http import connect_http @@ -93,7 +93,7 @@ async def async_step_user( """Handle the user step to pick discovered device.""" if user_input is not None: - address = user_input.get(CONF_ADDRESS) + address = user_input.get(CONF_ADDRESS) or self._config.get(CONF_ADDRESS) if address is not None: name = self._discovered_devices.get(address) if name is None: @@ -138,8 +138,6 @@ async def async_step_user( async def async_step_wifi(self, user_input: dict[str, Any]) -> ConfigFlowResult: """Handle the user step for Wi-Fi control.""" - print("step_wifi") - print(user_input) if user_input is not None and ( user_input.get(CONF_ACCOUNTNAME) is not None or user_input.get(CONF_USE_WIFI) is True @@ -149,13 +147,10 @@ async def async_step_wifi(self, user_input: dict[str, Any]) -> ConfigFlowResult: address = self._config.get(CONF_ADDRESS) device_name = user_input.get(CONF_DEVICE_NAME) name = self._discovered_devices.get(address) - print(self._config) if address is None or name is None: if device_name is not None: await self.async_set_unique_id(device_name, raise_on_progress=False) self._abort_if_unique_id_configured() - else: - return self.async_abort(reason="no_device_name") try: await connect_http(account, password) @@ -163,7 +158,7 @@ async def async_step_wifi(self, user_input: dict[str, Any]) -> ConfigFlowResult: return self.async_abort(reason=str(err)) return self.async_create_entry( - title=name, + title=name or device_name, data={ **self._config, CONF_ACCOUNTNAME: account, @@ -178,7 +173,7 @@ async def async_step_wifi(self, user_input: dict[str, Any]) -> ConfigFlowResult: vol.Optional(CONF_USE_WIFI, default=True): cv.boolean, } - if user_input.get(CONF_ADDRESS) is None: + if self._config.get(CONF_ADDRESS) is None: schema = { vol.Required(CONF_DEVICE_NAME): vol.All(cv.string, vol.Strip), vol.Required(CONF_ACCOUNTNAME): vol.All(cv.string, vol.Strip), @@ -195,6 +190,55 @@ def async_get_options_flow( """Create the options flow.""" return MammotionConfigFlowHandler(config_entry) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration.""" + entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + if TYPE_CHECKING: + assert entry + + errors: dict[str, str] | None = None + user_input = user_input or {} + if user_input: + if not errors: + return self.async_update_reload_and_abort( + entry, + data={ + **entry.data, + **user_input, + }, + reason="reconfigure_successful", + ) + + 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, + vol.Optional( + CONF_USE_WIFI, default=entry.data.get(CONF_USE_WIFI) or True + ): cv.boolean, + } + + if user_input is not None and entry.data.get(CONF_ADDRESS) is None: + schema = { + vol.Required( + CONF_ACCOUNTNAME, default=entry.data.get(CONF_ACCOUNTNAME) + ): vol.All(cv.string, vol.Strip), + vol.Required( + CONF_PASSWORD, default=entry.data.get(CONF_PASSWORD) + ): vol.All(cv.string, vol.Strip), + } + + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema(schema), + errors=errors, + ) + class MammotionConfigFlowHandler(OptionsFlowWithConfigEntry): """Handles options flow for the component.""" diff --git a/homeassistant/components/mammotion/const.py b/homeassistant/components/mammotion/const.py index e22b24cd73353..2002240c734a5 100644 --- a/homeassistant/components/mammotion/const.py +++ b/homeassistant/components/mammotion/const.py @@ -27,3 +27,8 @@ CONF_ACCOUNTNAME: Final = "account_name" CONF_USE_WIFI: Final = "use_wifi" CONF_DEVICE_NAME: Final = "device_name" +CONF_AUTH_DATA: Final = "auth_data" +CONF_AEP_DATA: Final = "aep_data" +CONF_SESSION_DATA: Final = "session_data" +CONF_REGION_DATA: Final = "region_data" +CONF_DEVICE_DATA: Final = "device_data" diff --git a/homeassistant/components/mammotion/coordinator.py b/homeassistant/components/mammotion/coordinator.py index 82fd616675dbf..7b3326cafe838 100644 --- a/homeassistant/components/mammotion/coordinator.py +++ b/homeassistant/components/mammotion/coordinator.py @@ -39,10 +39,10 @@ class MammotionDataUpdateCoordinator(DataUpdateCoordinator[MowingDevice]): """Class to manage fetching mammotion data.""" - address: str + address: str | None = None config_entry: MammotionConfigEntry - device_name: str - devices: Mammotion | None = None + device_name: str = "" + manager: Mammotion | None = None def __init__( self, @@ -60,12 +60,22 @@ def __init__( async def async_setup(self) -> None: """Set coordinator up.""" ble_device = None - credentials = Credentials() + credentials = None preference = ConnectionPreference.BLUETOOTH address = self.config_entry.data.get(CONF_ADDRESS) name = self.config_entry.data.get(CONF_DEVICE_NAME) + account = self.config_entry.data.get(CONF_ACCOUNTNAME) + password = self.config_entry.data.get(CONF_PASSWORD) + + if self.manager is None or self.manager.get_device_by_name(name) is None: + if account and password: + if name: + self.device_name = name + preference = ConnectionPreference.WIFI + credentials = Credentials() + credentials.email = account + credentials.password = password - if self.devices is None or self.devices.get_device_by_name(name) is None: if address: ble_device = bluetooth.async_ble_device_from_address(self.hass, address) if not ble_device and credentials is None: @@ -76,27 +86,41 @@ async def async_setup(self) -> None: self.device_name = ble_device.name or "Unknown" self.address = address - account = self.config_entry.data.get(CONF_ACCOUNTNAME) - password = self.config_entry.data.get(CONF_PASSWORD) - if account and password: - if name: - self.device_name = name - preference = ConnectionPreference.WIFI - credentials.email = account - credentials.password = password + self.manager = await create_devices(ble_device, credentials, preference) + + device = self.manager.get_device_by_name(self.device_name) + if device is None: + try: + device_list = self.manager.cloud_client.get_devices_by_account_response().data.data + mowing_devices = [ + dev + for dev in device_list + if ( + dev.productModel is None + or dev.productModel != "ReferenceStation" + ) + ] + if len(mowing_devices) > 0: + self.device_name = mowing_devices[0].deviceName + device = self.manager.get_device_by_name(self.device_name) + except: + raise ConfigEntryNotReady( + f"Could not find Mammotion lawn mower with address {self.device_name}" + ) - self.devices = await create_devices(ble_device, credentials, preference) - print("creating devices") try: if preference is not ConnectionPreference.WIFI: - await self.devices.start_sync(self.device_name, 0) + await device.ble().start_sync(0) + else: + device.cloud().on_ready_callback = lambda: device.cloud().start_sync(0) + device.cloud().set_notifiction_callback(self._async_update_cloud) except COMMAND_EXCEPTIONS as exc: raise ConfigEntryNotReady("Unable to setup Mammotion device") from exc async def async_sync_maps(self) -> None: """Get map data from the device.""" - await self.devices.start_map_sync(self.device_name) + await self.manager.start_map_sync(self.device_name) async def async_start_stop_blades(self, start_stop: bool) -> None: if start_stop: @@ -130,7 +154,7 @@ async def async_request_iot_sync(self) -> None: async def async_send_command(self, command: str, **kwargs: any) -> None: try: - await self.devices.send_command_with_args( + await self.manager.send_command_with_args( self.device_name, command, **kwargs ) except COMMAND_EXCEPTIONS as exc: @@ -138,27 +162,33 @@ async def async_send_command(self, command: str, **kwargs: any) -> None: translation_domain=DOMAIN, translation_key="command_failed" ) from exc + async def _async_update_cloud(self): + self.async_set_updated_data(self.manager.mower(self.device_name)) + async def _async_update_data(self) -> MowingDevice: """Get data from the device.""" - device = self.devices.get_device_by_name(self.device_name) - ble_device = bluetooth.async_ble_device_from_address(self.hass, self.address) + device = self.manager.get_device_by_name(self.device_name) - if not ble_device and device.cloud() is None: - self.update_failures += 1 - raise UpdateFailed("Could not find device") + if self.address: + ble_device = bluetooth.async_ble_device_from_address( + self.hass, self.address + ) - if ble_device and device.ble() is not None: - device.ble().update_device(ble_device) - else: - device.add_ble(ble_device) + if not ble_device and device.cloud() is None: + self.update_failures += 1 + raise UpdateFailed("Could not find device") + + if ble_device and device.ble() is not None: + device.ble().update_device(ble_device) + else: + device.add_ble(ble_device) try: - print(device.mower_state().net.toapp_wifi_iot_status.productkey) if ( len(device.mower_state().net.toapp_devinfo_resp.resp_ids) == 0 or device.mower_state().net.toapp_wifi_iot_status.productkey is None ): - await self.devices.start_sync(self.device_name, 0) + await self.manager.start_sync(self.device_name, 0) if device.mower_state().report_data.dev.sys_status != WorkMode.MODE_WORKING: await self.async_send_command("get_report_cfg") @@ -173,12 +203,12 @@ async def _async_update_data(self) -> MowingDevice: LOGGER.debug("================= Debug Log =================") LOGGER.debug( "Mammotion device data: %s", - asdict(self.devices.get_device_by_name(self.device_name).mower_state()), + asdict(self.manager.get_device_by_name(self.device_name).mower_state()), ) LOGGER.debug("==================================") self.update_failures = 0 - return self.devices.get_device_by_name(self.device_name).mower_state() + return self.manager.get_device_by_name(self.device_name).mower_state() # TODO when submitting to HA use this 2024.8 and up # async def _async_setup(self) -> None: diff --git a/homeassistant/components/mammotion/device_tracker.py b/homeassistant/components/mammotion/device_tracker.py index 66904fdf2bf75..d997f003c81cf 100644 --- a/homeassistant/components/mammotion/device_tracker.py +++ b/homeassistant/components/mammotion/device_tracker.py @@ -43,7 +43,7 @@ def __init__(self, coordinator: MammotionDataUpdateCoordinator) -> None: def extra_state_attributes(self) -> dict[str, Any]: """Return entity specific state attributes.""" return { - ATTR_DIRECTION: self.coordinator.devices.mower( + ATTR_DIRECTION: self.coordinator.manager.mower( self.coordinator.device_name ).location.orientation } @@ -51,14 +51,14 @@ def extra_state_attributes(self) -> dict[str, Any]: @property def latitude(self) -> float | None: """Return latitude value of the device.""" - return self.coordinator.devices.mower( + return self.coordinator.manager.mower( self.coordinator.device_name ).location.device.latitude @property def longitude(self) -> float | None: """Return longitude value of the device.""" - return self.coordinator.devices.mower( + return self.coordinator.manager.mower( self.coordinator.device_name ).location.device.longitude diff --git a/homeassistant/components/mammotion/entity.py b/homeassistant/components/mammotion/entity.py index da6d3dc0cbd94..23d41ce73f96a 100644 --- a/homeassistant/components/mammotion/entity.py +++ b/homeassistant/components/mammotion/entity.py @@ -20,14 +20,14 @@ def __init__(self, coordinator: MammotionDataUpdateCoordinator, key: str) -> Non swversion = "0.0.0" if ( len( - coordinator.devices.mower( + coordinator.manager.mower( coordinator.device_name ).net.toapp_devinfo_resp.resp_ids ) > 0 ): swversion = ( - coordinator.devices.mower(coordinator.device_name) + coordinator.manager.mower(coordinator.device_name) .net.toapp_devinfo_resp.resp_ids[0] .info ) @@ -41,7 +41,7 @@ def __init__(self, coordinator: MammotionDataUpdateCoordinator, key: str) -> Non sw_version=swversion, model=DeviceType.value_of_str( coordinator.device_name, - coordinator.devices.mower( + coordinator.manager.mower( coordinator.device_name ).net.toapp_wifi_iot_status.productkey, ).get_model(), diff --git a/homeassistant/components/mammotion/lawn_mower.py b/homeassistant/components/mammotion/lawn_mower.py index 87f714c13965f..9efb953b7cf3e 100644 --- a/homeassistant/components/mammotion/lawn_mower.py +++ b/homeassistant/components/mammotion/lawn_mower.py @@ -87,14 +87,14 @@ async def async_start_mowing(self) -> None: ) if self.rpt_dev_status.sys_status == WorkMode.MODE_PAUSE: try: - await self.coordinator.devices.command("resume_execute_task") + await self.coordinator.async_send_command("resume_execute_task") return await self.coordinator.async_request_iot_sync() except COMMAND_EXCEPTIONS as exc: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="resume_failed" ) from exc try: - await self.coordinator.devices.command("start_job") + await self.coordinator.async_send_command("start_job") await self.coordinator.async_request_iot_sync() except COMMAND_EXCEPTIONS as exc: raise HomeAssistantError( @@ -102,7 +102,7 @@ async def async_start_mowing(self) -> None: ) from exc finally: self.coordinator.async_set_updated_data( - self.coordinator.devices.mower(self.coordinator.device_name) + self.coordinator.manager.mower(self.coordinator.device_name) ) async def async_dock(self) -> None: @@ -112,11 +112,11 @@ async def async_dock(self) -> None: try: if mode == WorkMode.MODE_RETURNING: - await self.coordinator.devices.command("cancel_return_to_dock") - return await self.coordinator.devices.command("get_report_cfg") + await self.coordinator.async_send_command("cancel_return_to_dock") + return await self.coordinator.async_send_command("get_report_cfg") if mode == WorkMode.MODE_WORKING: - await self.coordinator.devices.command("pause_execute_task") - await self.coordinator.devices.command("return_to_dock") + await self.coordinator.async_send_command("pause_execute_task") + await self.coordinator.async_send_command("return_to_dock") await self.coordinator.async_request_iot_sync() except COMMAND_EXCEPTIONS as exc: raise HomeAssistantError( @@ -124,13 +124,13 @@ async def async_dock(self) -> None: ) from exc finally: self.coordinator.async_set_updated_data( - self.coordinator.devices.mower(self.coordinator.device_name) + self.coordinator.manager.mower(self.coordinator.device_name) ) async def async_pause(self) -> None: """Pause mower.""" try: - await self.coordinator.devices.command("pause_execute_task") + await self.coordinator.async_send_command("pause_execute_task") await self.coordinator.async_request_iot_sync() except COMMAND_EXCEPTIONS as exc: raise HomeAssistantError( @@ -138,5 +138,5 @@ async def async_pause(self) -> None: ) from exc finally: self.coordinator.async_set_updated_data( - self.coordinator.devices.mower(self.coordinator.device_name) + self.coordinator.manager.mower(self.coordinator.device_name) ) diff --git a/homeassistant/components/mammotion/manifest.json b/homeassistant/components/mammotion/manifest.json index 2aa1eb99c8b49..961415e93da0e 100644 --- a/homeassistant/components/mammotion/manifest.json +++ b/homeassistant/components/mammotion/manifest.json @@ -19,5 +19,5 @@ "documentation": "https://www.home-assistant.io/integrations/mammotion", "integration_type": "device", "iot_class": "local_push", - "requirements": ["pymammotion==0.2.6"] + "requirements": ["pymammotion==0.2.18"] } diff --git a/homeassistant/components/mammotion/number.py b/homeassistant/components/mammotion/number.py index e8c7a09d854a1..10f08c332df1d 100644 --- a/homeassistant/components/mammotion/number.py +++ b/homeassistant/components/mammotion/number.py @@ -66,7 +66,7 @@ async def async_setup_entry( ) -> None: """Set up the Mammotion number entities.""" coordinator = entry.runtime_data - limits = coordinator.devices.mower(coordinator.device_name).limits + limits = coordinator.manager.mower(coordinator.device_name).limits entities: list[MammotionNumberEntity] = [] diff --git a/homeassistant/components/mammotion/strings.json b/homeassistant/components/mammotion/strings.json index ab84bf6db9555..37b677e41c2f5 100644 --- a/homeassistant/components/mammotion/strings.json +++ b/homeassistant/components/mammotion/strings.json @@ -13,8 +13,17 @@ "bluetooth_confirm": { "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" }, + "reconfigure": { + "data": { + "device_name": "Device name e.g Luba-****, Yuka-**** which needs to be unique", + "use_wifi": "Use Wi-fi", + "account_name": "Mammotion email or account number", + "password": "Mammotion account password" + } + }, "user": { "data": { + "device_name": "Device name e.g Luba-****, Yuka-**** which needs to be unique", "address": "[%key:common::config_flow::data::device%]", "use_wifi": "Use Wi-fi", "account_name": "Mammotion email or account number", diff --git a/requirements_all.txt b/requirements_all.txt index 4e67eee03f7aa..a0ebdc03c088f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2334,7 +2334,7 @@ pylutron==0.4.1 pymailgunner==1.4 # homeassistant.components.mammotion -pymammotion==0.2.0 +pymammotion==0.2.18 # homeassistant.components.firmata pymata-express==1.19 From 28f3adac215a424ee3797a7ca6a89253f0843d08 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Mon, 2 Sep 2024 12:51:20 +1200 Subject: [PATCH 11/66] update core to hacs version --- .../components/mammotion/__init__.py | 10 +- homeassistant/components/mammotion/button.py | 23 ++- .../components/mammotion/config_flow.py | 142 ++++++++++++++---- .../components/mammotion/coordinator.py | 70 +++++++-- homeassistant/components/mammotion/entity.py | 51 ++++--- .../components/mammotion/manifest.json | 2 +- homeassistant/components/mammotion/number.py | 25 ++- homeassistant/components/mammotion/select.py | 30 ++-- homeassistant/components/mammotion/sensor.py | 19 ++- .../components/mammotion/strings.json | 82 +++++----- requirements_all.txt | 2 +- 11 files changed, 308 insertions(+), 148 deletions(-) diff --git a/homeassistant/components/mammotion/__init__.py b/homeassistant/components/mammotion/__init__.py index 7d1e3408868ba..2685589d05fec 100644 --- a/homeassistant/components/mammotion/__init__.py +++ b/homeassistant/components/mammotion/__init__.py @@ -27,7 +27,7 @@ Platform.BUTTON, Platform.SWITCH, Platform.NUMBER, - # Platform.SELECT + Platform.SELECT ] type MammotionConfigEntry = ConfigEntry[MammotionDataUpdateCoordinator] @@ -59,7 +59,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> await mammotion_coordinator.async_setup() # config_updates = {} - if CONF_AUTH_DATA not in entry.data: + if CONF_AUTH_DATA not in entry.data and mammotion_coordinator.manager.cloud_client: config_updates = { **entry.data, CONF_AUTH_DATA: mammotion_coordinator.manager.cloud_client.get_login_by_oauth_response(), @@ -88,8 +88,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: - if entry.runtime_data.manager.mqtt.is_connected: - await hass.async_add_executor_job( - entry.runtime_data.manager.mqtt.disconnect - ) + if entry.runtime_data.manager.mqtt and entry.runtime_data.manager.mqtt.is_connected: + await hass.async_add_executor_job(entry.runtime_data.manager.mqtt.disconnect) return unload_ok diff --git a/homeassistant/components/mammotion/button.py b/homeassistant/components/mammotion/button.py index 18b0a6cc21e8e..e1827597730e3 100644 --- a/homeassistant/components/mammotion/button.py +++ b/homeassistant/components/mammotion/button.py @@ -1,7 +1,8 @@ """Mammotion button sensor entities.""" -from collections.abc import Awaitable, Callable +from collections.abc import Callable from dataclasses import dataclass +from typing import Awaitable from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant @@ -28,6 +29,26 @@ class MammotionButtonSensorEntityDescription(ButtonEntityDescription): key="resync_rtk_dock", press_fn=lambda coordinator: coordinator.async_rtk_dock_location(), ), + MammotionButtonSensorEntityDescription( + key="release_from_dock", + press_fn=lambda coordinator: coordinator.async_leave_dock(), + ), + MammotionButtonSensorEntityDescription( + key="emergency_nudge_forward", + press_fn=lambda coordinator: coordinator.async_move_forward(0.3), + ), + MammotionButtonSensorEntityDescription( + key="emergency_nudge_left", + press_fn=lambda coordinator: coordinator.async_move_left(0.3), + ), + MammotionButtonSensorEntityDescription( + key="emergency_nudge_right", + press_fn=lambda coordinator: coordinator.async_move_right(0.3), + ), + MammotionButtonSensorEntityDescription( + key="emergency_nudge_back", + press_fn=lambda coordinator: coordinator.async_move_back(0.3), + ), ) diff --git a/homeassistant/components/mammotion/config_flow.py b/homeassistant/components/mammotion/config_flow.py index ad063b3416776..8f62c00ed1e9a 100644 --- a/homeassistant/components/mammotion/config_flow.py +++ b/homeassistant/components/mammotion/config_flow.py @@ -1,10 +1,17 @@ """Config flow for Mammotion Luba.""" -from typing import TYPE_CHECKING, Any +from typing import Any, TYPE_CHECKING +import voluptuous as vol from bleak import BLEDevice +from homeassistant.helpers.selector import ( + SelectSelectorConfig, + SelectOptionDict, + SelectSelectorMode, + SelectSelector, +) from pymammotion.http.http import connect_http -import voluptuous as vol +from pymammotion.mammotion.devices.mammotion import Mammotion from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( @@ -12,24 +19,25 @@ async_discovered_service_info, ) from homeassistant.config_entries import ( - ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, OptionsFlowWithConfigEntry, + ConfigEntry, + OptionsFlow, ) from homeassistant.const import CONF_ADDRESS, CONF_PASSWORD from homeassistant.core import callback + from homeassistant.helpers import config_validation as cv from .const import ( - CONF_ACCOUNTNAME, - CONF_DEVICE_NAME, - CONF_STAY_CONNECTED_BLUETOOTH, - CONF_USE_WIFI, DEVICE_SUPPORT, DOMAIN, LOGGER, + CONF_USE_WIFI, + CONF_STAY_CONNECTED_BLUETOOTH, + CONF_ACCOUNTNAME, + CONF_DEVICE_NAME, ) @@ -46,7 +54,6 @@ async def async_step_bluetooth( self, discovery_info: BluetoothServiceInfo ) -> 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_device") @@ -79,10 +86,15 @@ async def async_step_bluetooth_confirm( assert self._discovered_device + self._config = { + CONF_ADDRESS: self._discovered_device.address, + } + if user_input is not None: return await self.async_step_wifi(user_input) return self.async_show_form( + step_id="bluetooth_confirm", last_step=False, description_placeholders={"name": self._discovered_device.name}, ) @@ -102,16 +114,12 @@ async def async_step_user( await self.async_set_unique_id(name, raise_on_progress=False) self._abort_if_unique_id_configured() - if user_input.get(CONF_USE_WIFI) is False: - return self.async_create_entry( - title=name, - data={CONF_ADDRESS: address}, - ) - self._config = { CONF_ADDRESS: address, } + self._discovered_device = bluetooth.async_ble_device_from_address(self.hass, address) + return await self.async_step_wifi(user_input) current_addresses = self._async_current_ids() @@ -144,27 +152,20 @@ async def async_step_wifi(self, user_input: dict[str, Any]) -> ConfigFlowResult: ): account = user_input.get(CONF_ACCOUNTNAME) password = user_input.get(CONF_PASSWORD) - address = self._config.get(CONF_ADDRESS) - device_name = user_input.get(CONF_DEVICE_NAME) - name = self._discovered_devices.get(address) - if address is None or name is None: - if device_name is not None: - await self.async_set_unique_id(device_name, raise_on_progress=False) - self._abort_if_unique_id_configured() try: - await connect_http(account, password) + response = await connect_http(account, password) + if response.login_info is None: + return self.async_abort(reason=str(response.msg)) except Exception as err: return self.async_abort(reason=str(err)) + return await self.async_step_wifi_confirm(user_input) + + if user_input is not None and user_input.get(CONF_USE_WIFI) is False: return self.async_create_entry( - title=name or device_name, - data={ - **self._config, - CONF_ACCOUNTNAME: account, - CONF_PASSWORD: password, - CONF_DEVICE_NAME: name or device_name, - }, + title=self._discovered_device.name, + data={CONF_ADDRESS: self._discovered_device.address}, ) schema = { @@ -175,12 +176,87 @@ async def async_step_wifi(self, user_input: dict[str, Any]) -> ConfigFlowResult: if self._config.get(CONF_ADDRESS) is None: schema = { - vol.Required(CONF_DEVICE_NAME): vol.All(cv.string, vol.Strip), vol.Required(CONF_ACCOUNTNAME): vol.All(cv.string, vol.Strip), vol.Required(CONF_PASSWORD): vol.All(cv.string, vol.Strip), } - return self.async_show_form(data_schema=vol.Schema(schema)) + return self.async_show_form(step_id="wifi", data_schema=vol.Schema(schema)) + + async def async_step_wifi_confirm( + self, user_input: dict[str, Any] + ) -> ConfigFlowResult: + """Confirm device discovery.""" + + device_name = user_input.get(CONF_DEVICE_NAME) + address = self._config.get(CONF_ADDRESS) + name = self._discovered_devices.get(address) + + if user_input is not None and (device_name or name): + account = user_input.get(CONF_ACCOUNTNAME) + password = user_input.get(CONF_PASSWORD) + + if name: + cloud_client = await Mammotion.login(account, password) + devices = cloud_client.get_devices_by_account_response().data.data + found_device = [ + device for device in devices if device.deviceName == name + ] + if not found_device: + return self.async_abort( + reason=f"{device_name or name} not found in account: {account}" + ) + + await self.async_set_unique_id(device_name or name, raise_on_progress=False) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=name or device_name, + data={ + CONF_ACCOUNTNAME: account, + CONF_PASSWORD: password, + CONF_DEVICE_NAME: name or device_name, + **self._config, + }, + ) + + account = user_input.get(CONF_ACCOUNTNAME) + password = user_input.get(CONF_PASSWORD) + self._config = { + **self._config, + **user_input, + } + cloud_client = await Mammotion.login(account, password) + + mowing_devices = [ + dev + for dev in cloud_client.get_devices_by_account_response().data.data + if (dev.productModel is None or dev.productModel != "ReferenceStation") + ] + + machine_options = [ + SelectOptionDict( + value=device.deviceName, + label=device.deviceName, + ) + for device in mowing_devices + ] + + machine_selection_schema = vol.Schema( + { + vol.Required( + CONF_DEVICE_NAME, default=machine_options[0]["value"] + ): SelectSelector( + SelectSelectorConfig( + options=machine_options, + mode=SelectSelectorMode.DROPDOWN, + ) + ) + } + ) + + return self.async_show_form( + step_id="wifi_confirm", data_schema=machine_selection_schema + ) @staticmethod @callback @@ -219,7 +295,7 @@ async def async_step_reconfigure( CONF_PASSWORD, default=entry.data.get(CONF_PASSWORD) ): cv.string, vol.Optional( - CONF_USE_WIFI, default=entry.data.get(CONF_USE_WIFI) or True + CONF_USE_WIFI, default=entry.data.get(CONF_USE_WIFI, True) ): cv.boolean, } diff --git a/homeassistant/components/mammotion/coordinator.py b/homeassistant/components/mammotion/coordinator.py index 7b3326cafe838..a401ee731f52b 100644 --- a/homeassistant/components/mammotion/coordinator.py +++ b/homeassistant/components/mammotion/coordinator.py @@ -6,6 +6,7 @@ from datetime import timedelta from typing import TYPE_CHECKING +from homeassistant.helpers import device_registry as dr from pymammotion.data.model.account import Credentials from pymammotion.data.model.device import MowingDevice from pymammotion.mammotion.devices.mammotion import ( @@ -18,7 +19,7 @@ from homeassistant.components import bluetooth from homeassistant.const import CONF_ADDRESS, CONF_PASSWORD -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -89,7 +90,7 @@ async def async_setup(self) -> None: self.manager = await create_devices(ble_device, credentials, preference) device = self.manager.get_device_by_name(self.device_name) - if device is None: + if device is None and self.manager.cloud_client: try: device_list = self.manager.cloud_client.get_devices_by_account_response().data.data mowing_devices = [ @@ -105,15 +106,16 @@ async def async_setup(self) -> None: device = self.manager.get_device_by_name(self.device_name) except: raise ConfigEntryNotReady( - f"Could not find Mammotion lawn mower with address {self.device_name}" + f"Could not find Mammotion lawn mower with name {self.device_name}" ) try: - if preference is not ConnectionPreference.WIFI: - await device.ble().start_sync(0) - else: + if preference is ConnectionPreference.WIFI: device.cloud().on_ready_callback = lambda: device.cloud().start_sync(0) - device.cloud().set_notifiction_callback(self._async_update_cloud) + device.cloud().set_notification_callback(self._async_update_cloud) + else: + await device.ble().start_sync(0) + except COMMAND_EXCEPTIONS as exc: raise ConfigEntryNotReady("Unable to setup Mammotion device") from exc @@ -128,8 +130,32 @@ async def async_start_stop_blades(self, start_stop: bool) -> None: else: await self.async_send_command("set_blade_control", on_off=0) - async def async_blade_height(self, height: int) -> None: - await self.async_send_command("set_blade_height", height=height) + async def async_blade_height(self, height: int) -> int: + await self.async_send_command("set_blade_height", height=float(height)) + return height + + async def async_leave_dock(self) -> None: + await self.async_send_command("leave_dock") + + async def async_move_forward(self, speed: float) -> None: + device = self.manager.get_device_by_name(self.device_name) + if self.manager.get_device_by_name(self.device_name).ble(): + await device.ble().move_forward(speed) + + async def async_move_left(self, speed: float) -> None: + device = self.manager.get_device_by_name(self.device_name) + if self.manager.get_device_by_name(self.device_name).ble(): + await device.ble().move_left(speed) + + async def async_move_right(self, speed: float) -> None: + device = self.manager.get_device_by_name(self.device_name) + if self.manager.get_device_by_name(self.device_name).ble(): + await device.ble().move_right(speed) + + async def async_move_back(self, speed: float) -> None: + device = self.manager.get_device_by_name(self.device_name) + if self.manager.get_device_by_name(self.device_name).ble(): + await device.ble().move_back(speed) async def async_rtk_dock_location(self): """RTK and dock location.""" @@ -165,9 +191,35 @@ async def async_send_command(self, command: str, **kwargs: any) -> None: async def _async_update_cloud(self): self.async_set_updated_data(self.manager.mower(self.device_name)) + async def check_firmware_version(self) -> None: + mower = self.manager.mower(self.device_name) + device_registry = dr.async_get(self.hass) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, self.device_name)} + ) + assert device_entry + + new_swversion = None + if ( + len( + mower.net.toapp_devinfo_resp.resp_ids + ) + > 0 + ): + new_swversion = ( + mower + .net.toapp_devinfo_resp.resp_ids[0] + .info + ) + + if new_swversion is not None or new_swversion != device_entry.sw_version: + device_registry.async_update_device(device_entry.id, sw_version=new_swversion) + + async def _async_update_data(self) -> MowingDevice: """Get data from the device.""" device = self.manager.get_device_by_name(self.device_name) + await self.check_firmware_version() if self.address: ble_device = bluetooth.async_ble_device_from_address( diff --git a/homeassistant/components/mammotion/entity.py b/homeassistant/components/mammotion/entity.py index 23d41ce73f96a..12d36a5e6d0cd 100644 --- a/homeassistant/components/mammotion/entity.py +++ b/homeassistant/components/mammotion/entity.py @@ -1,10 +1,10 @@ """Base class for entities.""" -from pymammotion.utility.device_type import DeviceType - from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity +from pymammotion.utility.device_type import DeviceType +from . import DEFAULT_RETRY_COUNT from .const import CONF_RETRY_COUNT, DOMAIN from .coordinator import MammotionDataUpdateCoordinator @@ -17,34 +17,45 @@ class MammotionBaseEntity(CoordinatorEntity[MammotionDataUpdateCoordinator]): def __init__(self, coordinator: MammotionDataUpdateCoordinator, key: str) -> None: """Initialize the lawn mower.""" super().__init__(coordinator) - swversion = "0.0.0" + self._attr_unique_id = f"{coordinator.device_name}_{key}" + + @property + def device_info(self) -> DeviceInfo: + mower = self.coordinator.manager.mower( + self.coordinator.device_name + ) + swversion = None if ( len( - coordinator.manager.mower( - coordinator.device_name - ).net.toapp_devinfo_resp.resp_ids + mower.net.toapp_devinfo_resp.resp_ids ) > 0 ): swversion = ( - coordinator.manager.mower(coordinator.device_name) + mower .net.toapp_devinfo_resp.resp_ids[0] .info ) - self._attr_unique_id = f"{coordinator.device_name}_{key}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, coordinator.device_name)}, + product_key = mower.net.toapp_wifi_iot_status.productkey + if product_key is None or product_key == "": + if self.coordinator.manager.cloud_client: + device_list = self.coordinator.manager.cloud_client.get_devices_by_account_response().data.data + device = next((device for device in device_list if device.deviceName == self.coordinator.device_name), None) + product_key = device.productKey + + device_model = DeviceType.value_of_str( + self.coordinator.device_name, + product_key, + ).get_model() + + return DeviceInfo( + identifiers={(DOMAIN, self.coordinator.device_name)}, manufacturer="Mammotion", - serial_number=coordinator.device_name.split("-", 1)[-1], - name=coordinator.device_name, + serial_number=self.coordinator.device_name.split("-", 1)[-1], + name=self.coordinator.device_name, sw_version=swversion, - model=DeviceType.value_of_str( - coordinator.device_name, - coordinator.manager.mower( - coordinator.device_name - ).net.toapp_wifi_iot_status.productkey, - ).get_model(), + model=device_model, suggested_area="Garden", ) @@ -54,5 +65,7 @@ def available(self) -> bool: return ( self.coordinator.data is not None and self.coordinator.update_failures - <= self.coordinator.config_entry.options[CONF_RETRY_COUNT] + <= self.coordinator.config_entry.options.get( + CONF_RETRY_COUNT, DEFAULT_RETRY_COUNT + ) ) diff --git a/homeassistant/components/mammotion/manifest.json b/homeassistant/components/mammotion/manifest.json index 961415e93da0e..bb00d863d33f9 100644 --- a/homeassistant/components/mammotion/manifest.json +++ b/homeassistant/components/mammotion/manifest.json @@ -19,5 +19,5 @@ "documentation": "https://www.home-assistant.io/integrations/mammotion", "integration_type": "device", "iot_class": "local_push", - "requirements": ["pymammotion==0.2.18"] + "requirements": ["pymammotion==0.2.21"] } diff --git a/homeassistant/components/mammotion/number.py b/homeassistant/components/mammotion/number.py index 10f08c332df1d..4b843b0e62a30 100644 --- a/homeassistant/components/mammotion/number.py +++ b/homeassistant/components/mammotion/number.py @@ -1,7 +1,5 @@ -from collections.abc import Awaitable, Callable from dataclasses import dataclass - -from pymammotion.data.model.device_config import DeviceLimits +from typing import Awaitable, Callable from homeassistant.components.number import ( NumberEntity, @@ -12,6 +10,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback +from pymammotion.data.model.device_config import DeviceLimits from . import MammotionConfigEntry from .coordinator import MammotionDataUpdateCoordinator @@ -22,8 +21,6 @@ class MammotionNumberEntityDescription(NumberEntityDescription): """Describes Mammotion number entity.""" - set_fn: Callable[[MammotionDataUpdateCoordinator, float], Awaitable[None]] - NUMBER_ENTITIES: tuple[MammotionNumberEntityDescription, ...] = ( MammotionNumberEntityDescription( @@ -33,8 +30,7 @@ class MammotionNumberEntityDescription(NumberEntityDescription): step=1, mode=NumberMode.SLIDER, native_unit_of_measurement=PERCENTAGE, - entity_category=EntityCategory.CONFIG, - set_fn=lambda coordinator, value: value, + entity_category=EntityCategory.CONFIG ), ) @@ -42,19 +38,17 @@ class MammotionNumberEntityDescription(NumberEntityDescription): NUMBER_WORKING_ENTITIES: tuple[MammotionNumberEntityDescription, ...] = ( MammotionNumberEntityDescription( key="blade_height", - step=5.0, - min_value=30.0, # ToDo: To be dynamiclly set based on model (h\non H) - max_value=70.0, # ToDo: To be dynamiclly set based on model (h\non H) - entity_category=EntityCategory.CONFIG, - set_fn=lambda coordinator, value: coordinator.async_blade_height(value), + step=5, + min_value=30, # ToDo: To be dynamiclly set based on model (h\non H) + max_value=70, # ToDo: To be dynamiclly set based on model (h\non H) + entity_category=EntityCategory.CONFIG ), MammotionNumberEntityDescription( key="working_speed", entity_category=EntityCategory.CONFIG, step=0.1, min_value=0.2, - max_value=0.6, - set_fn=lambda coordinator, value: value, + max_value=0.6 ), ) @@ -98,9 +92,8 @@ def __init__( self._attr_native_step = entity_description.step self._attr_native_value = self._attr_native_min_value # Default value - async def async_set_native_value(self, value: float) -> None: + async def async_set_native_value(self, value: float | int) -> None: self._attr_native_value = value - await self.entity_description.set_fn(self.coordinator, value) self.async_write_ha_state() diff --git a/homeassistant/components/mammotion/select.py b/homeassistant/components/mammotion/select.py index 142a3cef47881..45854a828e229 100644 --- a/homeassistant/components/mammotion/select.py +++ b/homeassistant/components/mammotion/select.py @@ -1,6 +1,10 @@ -from collections.abc import Awaitable, Callable from dataclasses import dataclass +from typing import Awaitable, Callable +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from pymammotion.data.model.mowing_modes import ( BorderPatrolMode, CuttingMode, @@ -8,11 +12,6 @@ ObstacleLapsMode, ) -from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback - from . import MammotionConfigEntry from .coordinator import MammotionDataUpdateCoordinator from .entity import MammotionBaseEntity @@ -24,33 +23,28 @@ class MammotionSelectEntityDescription(SelectEntityDescription): key: str options: list[str] - select_fn: Callable[[MammotionDataUpdateCoordinator, str], Awaitable[None]] SELECT_ENTITIES: tuple[MammotionSelectEntityDescription, ...] = ( MammotionSelectEntityDescription( key="cutting_mode", entity_category=EntityCategory.CONFIG, - options=[mode.name for mode in CuttingMode], - select_fn=lambda coordinator, value: CuttingMode[value], + options=[mode.name for mode in CuttingMode] ), MammotionSelectEntityDescription( key="border_patrol_mode", entity_category=EntityCategory.CONFIG, - options=[mode.name for mode in BorderPatrolMode], - select_fn=lambda coordinator, value: BorderPatrolMode[value], + options=[mode.name for mode in BorderPatrolMode] ), MammotionSelectEntityDescription( key="obstacle_laps_mode", entity_category=EntityCategory.CONFIG, - options=[mode.name for mode in ObstacleLapsMode], - select_fn=lambda coordinator, value: ObstacleLapsMode[value], + options=[mode.name for mode in ObstacleLapsMode] ), MammotionSelectEntityDescription( key="mow_order", entity_category=EntityCategory.CONFIG, - options=[order.name for order in MowOrder], - select_fn=lambda coordinator, value: MowOrder[value], + options=[order.name for order in MowOrder] ), ) @@ -89,8 +83,4 @@ def __init__( self.entity_description = entity_description self._attr_translation_key = entity_description.key self._attr_options = entity_description.options - - async def async_select_option(self, option: str) -> None: - self._attr_current_option = option - await self.entity_description.select_fn(self.coordinator, option) - self.async_write_ha_state() + self._attr_current_option = entity_description.options[0] diff --git a/homeassistant/components/mammotion/sensor.py b/homeassistant/components/mammotion/sensor.py index 4757900e8f9bc..1c3a0160f1fce 100644 --- a/homeassistant/components/mammotion/sensor.py +++ b/homeassistant/components/mammotion/sensor.py @@ -3,11 +3,6 @@ from collections.abc import Callable from dataclasses import dataclass -from pymammotion.data.model.device import MowingDevice -from pymammotion.data.model.enums import RTKStatus -from pymammotion.utility.constant.device_constant import PosType, device_mode -from pymammotion.utility.device_type import DeviceType - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -26,6 +21,11 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.unit_conversion import SpeedConverter +from pymammotion.data.model.device import MowingDevice +from pymammotion.data.model.enums import RTKStatus +from pymammotion.proto.luba_msg import ReportInfoData +from pymammotion.utility.constant.device_constant import PosType, device_mode +from pymammotion.utility.device_type import DeviceType from . import MammotionConfigEntry from .coordinator import MammotionDataUpdateCoordinator @@ -163,6 +163,15 @@ class MammotionSensorEntityDescription(SensorEntityDescription): PosType(mower_data.location.position_type).name ), # Note: This will not work for Luba2 & Yuka. Only for Luba1 ), + MammotionSensorEntityDescription( + key="work_area", + state_class=None, + device_class=SensorDeviceClass.ENUM, + native_unit_of_measurement=None, + value_fn=lambda mower_data: str( + mower_data.location.work_zone or "Not working" + ), + ), # MammotionSensorEntityDescription( # key="lawn_mower_position", # state_class=None, diff --git a/homeassistant/components/mammotion/strings.json b/homeassistant/components/mammotion/strings.json index 37b677e41c2f5..34996211cefd3 100644 --- a/homeassistant/components/mammotion/strings.json +++ b/homeassistant/components/mammotion/strings.json @@ -15,7 +15,6 @@ }, "reconfigure": { "data": { - "device_name": "Device name e.g Luba-****, Yuka-**** which needs to be unique", "use_wifi": "Use Wi-fi", "account_name": "Mammotion email or account number", "password": "Mammotion account password" @@ -23,13 +22,18 @@ }, "user": { "data": { - "device_name": "Device name e.g Luba-****, Yuka-**** which needs to be unique", - "address": "[%key:common::config_flow::data::device%]", - "use_wifi": "Use Wi-fi", + "address": "Device" + }, + "description": "Select your mower" + }, + "wifi": { + "data": { + "use_wifi": "Use Wi-fi (un-tick and submit to use bluetooth)", "account_name": "Mammotion email or account number", "password": "Mammotion account password" }, - "description": "Setup your mower" + "title": "Connect to Wi-Fi", + "description": "Enter your Mammotion account email or id and password" } } }, @@ -92,73 +96,77 @@ }, "activity_mode": { "name": "Activity mode" + }, + "work_area": { + "name": "Work area hash" } }, "button": { "start_map_sync": { "name": "Sync maps" }, - "resync_rtk_dock": { - "name": "Sync RTK and dock", - "description": "Syncs RTK and dock location for when you move them." + "resync_rtk_dock": { + "name": "Sync RTK and dock" + }, + "release_from_dock": { + "name": "Undock" + }, + "emergency_nudge_forward": { + "name": "Emergency nudge forward" + }, + "emergency_nudge_left": { + "name": "Emergency nudge left" + }, + "emergency_nudge_right": { + "name": "Emergency nudge right" + }, + "emergency_nudge_back": { + "name": "Emergency nudge back" } }, "switch": { "blades_on_off": { - "name": "Blades On/Off", - "description": "Turn the blades on or off." + "name": "Blades On/Off" }, "mowing_on_off": { - "name": "Mowing On/Off", - "description": "Start or stop mowing." + "name": "Mowing On/Off" }, "dump_grass_on_off": { - "name": "Dump Grass On/Off", - "description": "Enable or disable grass dumping." + "name": "Dump Grass On/Off" }, "rain_detection_on_off": { - "name": "Rain Detection On/Off", - "description": "Turn rain detection on or off." + "name": "Rain Detection On/Off" }, "side_led_on_off": { - "name": "Side LED On/Off", - "description": "Enable or disable the side LED." + "name": "Side LED On/Off" }, - "perimeter_first_on_off": { - "name": "Perimeter First", - "description": "Perimeter first or lines/zigzag first mowing." + "perimeter_first_on_off": { + "name": "Perimeter First" } }, "select": { - "cutting_mode": { - "name": "Cutting Mode", - "description": "Select the cutting mode for the mower." + "cutting_mode": { + "name": "Cutting Mode" }, "border_patrol_mode": { - "name": "Border Patrol Mode", - "description": "Select the border patrol mode for the mower." + "name": "Border Patrol Mode" }, "obstacle_laps_mode": { - "name": "Obstacle Laps Mode", - "description": "Select the obstacle laps mode for the mower." + "name": "Obstacle Laps Mode" }, "mow_order": { - "name": "Mow Order", - "description": "Select the order in which the areas should be mowed." + "name": "Mow Order" } }, "number": { - "start_progress": { - "name": "Start Progress", - "description": "Set the start progress percentage." + "start_progress": { + "name": "Start Progress" }, "blade_height": { - "name": "Blade Height", - "description": "Adjust the height of the cutter in increments." + "name": "Blade Height" }, "working_speed": { - "name": "Working Speed", - "description": "Set the working speed of the mower." + "name": "Working Speed" } }, "device_tracker": { diff --git a/requirements_all.txt b/requirements_all.txt index a0ebdc03c088f..4208b70b442cc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2334,7 +2334,7 @@ pylutron==0.4.1 pymailgunner==1.4 # homeassistant.components.mammotion -pymammotion==0.2.18 +pymammotion==0.2.21 # homeassistant.components.firmata pymata-express==1.19 From 1cf44a0f6836744c09b00c7eee56b0951f4a2935 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Thu, 24 Oct 2024 09:18:57 +1300 Subject: [PATCH 12/66] migrate over hacs updates --- .../components/mammotion/__init__.py | 27 +- .../components/mammotion/binary_sensor.py | 6 + homeassistant/components/mammotion/button.py | 19 +- .../components/mammotion/config_flow.py | 129 +++-- homeassistant/components/mammotion/const.py | 9 +- .../components/mammotion/coordinator.py | 546 ++++++++++++++---- .../components/mammotion/device_tracker.py | 5 +- homeassistant/components/mammotion/entity.py | 57 +- .../mammotion/{icons,json => icons.json} | 0 .../components/mammotion/lawn_mower.py | 277 +++++++-- .../components/mammotion/manifest.json | 3 +- homeassistant/components/mammotion/number.py | 132 ++++- homeassistant/components/mammotion/select.py | 133 ++++- homeassistant/components/mammotion/sensor.py | 14 +- .../components/mammotion/services.yaml | 211 +++++++ .../components/mammotion/strings.json | 240 +++++--- homeassistant/components/mammotion/switch.py | 270 ++++++++- requirements_all.txt | 2 +- 18 files changed, 1633 insertions(+), 447 deletions(-) rename homeassistant/components/mammotion/{icons,json => icons.json} (100%) create mode 100644 homeassistant/components/mammotion/services.yaml diff --git a/homeassistant/components/mammotion/__init__.py b/homeassistant/components/mammotion/__init__.py index 2685589d05fec..39bab9e333578 100644 --- a/homeassistant/components/mammotion/__init__.py +++ b/homeassistant/components/mammotion/__init__.py @@ -8,14 +8,17 @@ from homeassistant.helpers import device_registry as dr from .const import ( + CONF_ACCOUNTNAME, CONF_AEP_DATA, CONF_AUTH_DATA, + CONF_CONNECT_DATA, CONF_DEVICE_DATA, CONF_REGION_DATA, CONF_RETRY_COUNT, CONF_SESSION_DATA, CONF_USE_WIFI, DEFAULT_RETRY_COUNT, + DOMAIN, ) from .coordinator import MammotionDataUpdateCoordinator @@ -27,7 +30,7 @@ Platform.BUTTON, Platform.SWITCH, Platform.NUMBER, - Platform.SELECT + Platform.SELECT, ] type MammotionConfigEntry = ConfigEntry[MammotionDataUpdateCoordinator] @@ -54,25 +57,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> options={CONF_RETRY_COUNT: DEFAULT_RETRY_COUNT}, ) - mammotion_coordinator = MammotionDataUpdateCoordinator(hass) - + mammotion_coordinator = MammotionDataUpdateCoordinator(hass, entry) await mammotion_coordinator.async_setup() - # config_updates = {} - if CONF_AUTH_DATA not in entry.data and mammotion_coordinator.manager.cloud_client: - config_updates = { - **entry.data, - CONF_AUTH_DATA: mammotion_coordinator.manager.cloud_client.get_login_by_oauth_response(), - CONF_REGION_DATA: mammotion_coordinator.manager.cloud_client.get_region_response(), - CONF_AEP_DATA: mammotion_coordinator.manager.cloud_client.get_aep_response(), - CONF_SESSION_DATA: mammotion_coordinator.manager.cloud_client.get_session_by_authcode_response(), - CONF_DEVICE_DATA: mammotion_coordinator.manager.cloud_client.get_devices_by_account_response(), - } - hass.config_entries.async_update_entry(entry, data=config_updates) - - use_wifi = entry.data.get(CONF_USE_WIFI) - if use_wifi is False: - await mammotion_coordinator.async_config_entry_first_refresh() + await mammotion_coordinator.async_config_entry_first_refresh() entry.runtime_data = mammotion_coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -88,6 +76,5 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: - if entry.runtime_data.manager.mqtt and entry.runtime_data.manager.mqtt.is_connected: - await hass.async_add_executor_job(entry.runtime_data.manager.mqtt.disconnect) + await entry.runtime_data.manager.remove_device(entry.runtime_data.device_name) return unload_ok diff --git a/homeassistant/components/mammotion/binary_sensor.py b/homeassistant/components/mammotion/binary_sensor.py index 24b0d87493a31..261cb07e62766 100644 --- a/homeassistant/components/mammotion/binary_sensor.py +++ b/homeassistant/components/mammotion/binary_sensor.py @@ -36,6 +36,12 @@ class MammotionBinarySensorEntityDescription( ), ) +""" +TODO: +read_and_set_sidelight(true, 1) is read +read_and_set_sidelight(bool, 0) is write +""" + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/mammotion/button.py b/homeassistant/components/mammotion/button.py index e1827597730e3..a7796d8ee943f 100644 --- a/homeassistant/components/mammotion/button.py +++ b/homeassistant/components/mammotion/button.py @@ -1,8 +1,7 @@ """Mammotion button sensor entities.""" -from collections.abc import Callable +from collections.abc import Awaitable, Callable from dataclasses import dataclass -from typing import Awaitable from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant @@ -35,19 +34,27 @@ class MammotionButtonSensorEntityDescription(ButtonEntityDescription): ), MammotionButtonSensorEntityDescription( key="emergency_nudge_forward", - press_fn=lambda coordinator: coordinator.async_move_forward(0.3), + press_fn=lambda coordinator: coordinator.async_move_forward(0.4), ), MammotionButtonSensorEntityDescription( key="emergency_nudge_left", - press_fn=lambda coordinator: coordinator.async_move_left(0.3), + press_fn=lambda coordinator: coordinator.async_move_left(0.4), ), MammotionButtonSensorEntityDescription( key="emergency_nudge_right", - press_fn=lambda coordinator: coordinator.async_move_right(0.3), + press_fn=lambda coordinator: coordinator.async_move_right(0.4), ), MammotionButtonSensorEntityDescription( key="emergency_nudge_back", - press_fn=lambda coordinator: coordinator.async_move_back(0.3), + press_fn=lambda coordinator: coordinator.async_move_back(0.4), + ), + MammotionButtonSensorEntityDescription( + key="cancel_task", + press_fn=lambda coordinator: coordinator.async_cancel_task(), + ), + MammotionButtonSensorEntityDescription( + key="clear_all_mapdata", + press_fn=lambda coordinator: coordinator.clear_all_maps(), ), ) diff --git a/homeassistant/components/mammotion/config_flow.py b/homeassistant/components/mammotion/config_flow.py index 8f62c00ed1e9a..7b8dd30cf9969 100644 --- a/homeassistant/components/mammotion/config_flow.py +++ b/homeassistant/components/mammotion/config_flow.py @@ -1,17 +1,13 @@ """Config flow for Mammotion Luba.""" -from typing import Any, TYPE_CHECKING +from typing import TYPE_CHECKING, Any -import voluptuous as vol -from bleak import BLEDevice -from homeassistant.helpers.selector import ( - SelectSelectorConfig, - SelectOptionDict, - SelectSelectorMode, - SelectSelector, -) +from aiohttp.web_exceptions import HTTPException +from bleak.backends.device import BLEDevice +from pymammotion.aliyun.cloud_gateway import CloudIOTGateway from pymammotion.http.http import connect_http from pymammotion.mammotion.devices.mammotion import Mammotion +import voluptuous as vol from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( @@ -19,25 +15,30 @@ async_discovered_service_info, ) from homeassistant.config_entries import ( + ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlowWithConfigEntry, - ConfigEntry, OptionsFlow, + OptionsFlowWithConfigEntry, ) from homeassistant.const import CONF_ADDRESS, CONF_PASSWORD from homeassistant.core import callback - from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) from .const import ( + CONF_ACCOUNTNAME, + CONF_DEVICE_NAME, + CONF_STAY_CONNECTED_BLUETOOTH, + CONF_USE_WIFI, DEVICE_SUPPORT, DOMAIN, LOGGER, - CONF_USE_WIFI, - CONF_STAY_CONNECTED_BLUETOOTH, - CONF_ACCOUNTNAME, - CONF_DEVICE_NAME, ) @@ -46,17 +47,19 @@ class MammotionConfigFlow(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize the config flow.""" - self._config = {} + self._config: dict = {} + self._stay_connected = False + self._cloud_client: CloudIOTGateway | None = None self._discovered_device: BLEDevice | None = None self._discovered_devices: dict[str, str] = {} async def async_step_bluetooth( - self, discovery_info: BluetoothServiceInfo + self, discovery_info: BluetoothServiceInfo | None = 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_device") + return self.async_abort(reason="no_devices_found") await self.async_set_unique_id(discovery_info.name) self._abort_if_unique_id_configured( @@ -97,6 +100,14 @@ async def async_step_bluetooth_confirm( step_id="bluetooth_confirm", last_step=False, description_placeholders={"name": self._discovered_device.name}, + data_schema=vol.Schema( + { + vol.Optional( + CONF_STAY_CONNECTED_BLUETOOTH, + default=False, + ): cv.boolean + }, + ), ) async def async_step_user( @@ -117,8 +128,13 @@ async def async_step_user( self._config = { CONF_ADDRESS: address, } + self._stay_connected = user_input.get( + CONF_STAY_CONNECTED_BLUETOOTH, False + ) - self._discovered_device = bluetooth.async_ble_device_from_address(self.hass, address) + self._discovered_device = bluetooth.async_ble_device_from_address( + self.hass, address + ) return await self.async_step_wifi(user_input) @@ -130,6 +146,11 @@ async def async_step_user( continue if name is None or not name.startswith(DEVICE_SUPPORT): continue + if self.hass.config_entries.async_entry_for_domain_unique_id( + self.handler, name + ): + continue + self._discovered_devices[address] = discovery_info.name if not self._discovered_devices: @@ -140,24 +161,30 @@ async def async_step_user( data_schema=vol.Schema( { vol.Optional(CONF_ADDRESS): vol.In(self._discovered_devices), + vol.Optional( + CONF_STAY_CONNECTED_BLUETOOTH, + default=False, + ): cv.boolean, }, ), ) - async def async_step_wifi(self, user_input: dict[str, Any]) -> ConfigFlowResult: + async def async_step_wifi( + self, user_input: dict[str, Any] | None + ) -> ConfigFlowResult: """Handle the user step for Wi-Fi control.""" if user_input is not None and ( user_input.get(CONF_ACCOUNTNAME) is not None or user_input.get(CONF_USE_WIFI) is True ): - account = user_input.get(CONF_ACCOUNTNAME) - password = user_input.get(CONF_PASSWORD) + account = user_input.get(CONF_ACCOUNTNAME, "") + password = user_input.get(CONF_PASSWORD, "") try: response = await connect_http(account, password) if response.login_info is None: return self.async_abort(reason=str(response.msg)) - except Exception as err: + except HTTPException as err: return self.async_abort(reason=str(err)) return await self.async_step_wifi_confirm(user_input) @@ -165,7 +192,11 @@ async def async_step_wifi(self, user_input: dict[str, Any]) -> ConfigFlowResult: if user_input is not None and user_input.get(CONF_USE_WIFI) is False: return self.async_create_entry( title=self._discovered_device.name, - data={CONF_ADDRESS: self._discovered_device.address}, + data={ + CONF_ADDRESS: self._discovered_device.address, + CONF_USE_WIFI: user_input.get(CONF_USE_WIFI), + }, + options={CONF_STAY_CONNECTED_BLUETOOTH: self._stay_connected}, ) schema = { @@ -190,33 +221,44 @@ async def async_step_wifi_confirm( device_name = user_input.get(CONF_DEVICE_NAME) address = self._config.get(CONF_ADDRESS) name = self._discovered_devices.get(address) + mammotion = Mammotion() if user_input is not None and (device_name or name): account = user_input.get(CONF_ACCOUNTNAME) password = user_input.get(CONF_PASSWORD) + if self._cloud_client is None: + try: + if mammotion.mqtt_list.get(account) is None: + self._cloud_client = await Mammotion().login(account, password) + else: + self._cloud_client = mammotion.mqtt_list.get( + account + ).cloud_client + except HTTPException as err: + return self.async_abort(reason=str(err)) + mowing_devices = self._cloud_client.devices_by_account_response.data.data if name: - cloud_client = await Mammotion.login(account, password) - devices = cloud_client.get_devices_by_account_response().data.data found_device = [ - device for device in devices if device.deviceName == name + device for device in mowing_devices if device.deviceName == name ] if not found_device: - return self.async_abort( - reason=f"{device_name or name} not found in account: {account}" - ) + return self.async_abort(reason="bluetooth_and_account_mismatch") - await self.async_set_unique_id(device_name or name, raise_on_progress=False) - self._abort_if_unique_id_configured() + if not name: + await self.async_set_unique_id(device_name, raise_on_progress=False) + self._abort_if_unique_id_configured() return self.async_create_entry( title=name or device_name, data={ CONF_ACCOUNTNAME: account, CONF_PASSWORD: password, - CONF_DEVICE_NAME: name or device_name, + CONF_DEVICE_NAME: device_name or name, + CONF_USE_WIFI: user_input.get(CONF_USE_WIFI, True), **self._config, }, + options={CONF_STAY_CONNECTED_BLUETOOTH: self._stay_connected}, ) account = user_input.get(CONF_ACCOUNTNAME) @@ -225,14 +267,23 @@ async def async_step_wifi_confirm( **self._config, **user_input, } - cloud_client = await Mammotion.login(account, password) + try: + if mammotion.mqtt_list.get(account) is None: + self._cloud_client = await Mammotion().login(account, password) + else: + self._cloud_client = mammotion.mqtt_list.get(account).cloud_client + except HTTPException as err: + return self.async_abort(reason=str(err)) mowing_devices = [ dev - for dev in cloud_client.get_devices_by_account_response().data.data + for dev in self._cloud_client.devices_by_account_response.data.data if (dev.productModel is None or dev.productModel != "ReferenceStation") ] + if len(mowing_devices) == 0: + return self.async_abort(reason="no_devices_found_in_account") + machine_options = [ SelectOptionDict( value=device.deviceName, @@ -324,13 +375,15 @@ async def async_step_init( ) -> ConfigFlowResult: """Manage the options for the custom component.""" if user_input: - return self.async_create_entry(title="", data=user_input) + return self.async_create_entry(data=user_input) options_schema = vol.Schema( { vol.Optional( CONF_STAY_CONNECTED_BLUETOOTH, - default=self.options.get(CONF_STAY_CONNECTED_BLUETOOTH, False), + default=self.config_entry.options.get( + CONF_STAY_CONNECTED_BLUETOOTH, False + ), ): cv.boolean } ) diff --git a/homeassistant/components/mammotion/const.py b/homeassistant/components/mammotion/const.py index 2002240c734a5..c351edf461ead 100644 --- a/homeassistant/components/mammotion/const.py +++ b/homeassistant/components/mammotion/const.py @@ -3,8 +3,10 @@ import logging from typing import Final -from bleak_retry_connector import BleakError, BleakNotFoundError -from pymammotion.mammotion.devices.mammotion import CharacteristicMissingError +from bleak.exc import BleakError +from bleak_retry_connector import BleakNotFoundError +from pymammotion.aliyun.cloud_gateway import CheckSessionException, SetupException +from pymammotion.mammotion.devices.mammotion_bluetooth import CharacteristicMissingError DOMAIN: Final = "mammotion" @@ -23,11 +25,14 @@ TimeoutError, ) +EXPIRED_CREDENTIAL_EXCEPTIONS = (CheckSessionException, SetupException) + CONF_STAY_CONNECTED_BLUETOOTH: Final = "stay_connected_bluetooth" CONF_ACCOUNTNAME: Final = "account_name" CONF_USE_WIFI: Final = "use_wifi" CONF_DEVICE_NAME: Final = "device_name" CONF_AUTH_DATA: Final = "auth_data" +CONF_CONNECT_DATA: Final = "connect_data" CONF_AEP_DATA: Final = "aep_data" CONF_SESSION_DATA: Final = "session_data" CONF_REGION_DATA: Final = "region_data" diff --git a/homeassistant/components/mammotion/coordinator.py b/homeassistant/components/mammotion/coordinator.py index a401ee731f52b..3cb691838974f 100644 --- a/homeassistant/components/mammotion/coordinator.py +++ b/homeassistant/components/mammotion/coordinator.py @@ -2,80 +2,261 @@ from __future__ import annotations +import asyncio from dataclasses import asdict from datetime import timedelta -from typing import TYPE_CHECKING - -from homeassistant.helpers import device_registry as dr +from typing import TYPE_CHECKING, Any, cast + +from aiohttp import ClientConnectorError +import betterproto +from mashumaro.exceptions import InvalidFieldValue +from pymammotion import CloudIOTGateway +from pymammotion.aliyun.cloud_gateway import DeviceOfflineException +from pymammotion.aliyun.model.aep_response import AepResponse +from pymammotion.aliyun.model.connect_response import ConnectResponse +from pymammotion.aliyun.model.dev_by_account_response import ListingDevByAccountResponse +from pymammotion.aliyun.model.login_by_oauth_response import LoginByOAuthResponse +from pymammotion.aliyun.model.regions_response import RegionResponse +from pymammotion.aliyun.model.session_by_authcode_response import ( + SessionByAuthCodeResponse, +) +from pymammotion.data.model import GenerateRouteInformation, HashList from pymammotion.data.model.account import Credentials from pymammotion.data.model.device import MowingDevice -from pymammotion.mammotion.devices.mammotion import ( - ConnectionPreference, - Mammotion, - create_devices, -) -from pymammotion.proto.mctrl_sys import RptAct, RptInfoType -from pymammotion.utility.constant import WorkMode +from pymammotion.data.model.device_config import OperationSettings, create_path_order +from pymammotion.mammotion.devices.mammotion import ConnectionPreference, Mammotion +from pymammotion.proto import has_field +from pymammotion.proto.luba_msg import LubaMsg +from pymammotion.proto.mctrl_sys import RptAct, RptDevStatus, RptInfoType +from pymammotion.utility.device_type import DeviceType from homeassistant.components import bluetooth from homeassistant.const import CONF_ADDRESS, CONF_PASSWORD -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.storage import Store +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( COMMAND_EXCEPTIONS, CONF_ACCOUNTNAME, + CONF_AEP_DATA, + CONF_AUTH_DATA, + CONF_CONNECT_DATA, + CONF_DEVICE_DATA, CONF_DEVICE_NAME, + CONF_REGION_DATA, + CONF_SESSION_DATA, + CONF_STAY_CONNECTED_BLUETOOTH, + CONF_USE_WIFI, DOMAIN, + EXPIRED_CREDENTIAL_EXCEPTIONS, LOGGER, ) if TYPE_CHECKING: from . import MammotionConfigEntry -SCAN_INTERVAL = timedelta(minutes=1) - -class MammotionDataUpdateCoordinator(DataUpdateCoordinator[MowingDevice]): - """Class to manage fetching mammotion data.""" +class MammotionBaseUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): + """Mammotion DataUpdateCoordinator.""" - address: str | None = None - config_entry: MammotionConfigEntry - device_name: str = "" - manager: Mammotion | None = None + manager: Mammotion = None def __init__( self, hass: HomeAssistant, + config_entry: MammotionConfigEntry, + update_interval: timedelta, ) -> None: """Initialize global mammotion data updater.""" super().__init__( hass=hass, logger=LOGGER, name=DOMAIN, - update_interval=SCAN_INTERVAL, + update_interval=update_interval, ) + self.device_name = None + assert config_entry.unique_id + self.config_entry = config_entry + self._operation_settings = OperationSettings() self.update_failures = 0 + self.enabled = True + + async def set_scheduled_updates(self, enabled: bool) -> None: + self.enabled = enabled + device = self.manager.get_device_by_name(self.device_name) + if self.enabled: + if device.has_cloud(): + await device.cloud().start() + else: + if device.has_cloud(): + await device.cloud().stop() + device.cloud().mqtt.disconnect() + if device.has_ble(): + await device.ble().stop() + + async def async_login(self) -> None: + """Login to cloud servers.""" + if ( + self.manager.get_device_by_name(self.device_name) + and self.manager.get_device_by_name(self.device_name).has_cloud() + ): + await self.hass.async_add_executor_job( + self.manager.get_device_by_name(self.device_name) + .cloud() + .mqtt.disconnect + ) + + account = self.config_entry.data.get(CONF_ACCOUNTNAME) + password = self.config_entry.data.get(CONF_PASSWORD) + await self.manager.login_and_initiate_cloud(account, password, True) + self.store_cloud_credentials() + + async def async_send_command(self, command: str, **kwargs: Any) -> None: + """Send command.""" + try: + await self.manager.send_command_with_args( + self.device_name, command, **kwargs + ) + except EXPIRED_CREDENTIAL_EXCEPTIONS: + self.update_failures += 1 + await self.async_login() + except DeviceOfflineException: + """Device is offline try bluetooth if we have it.""" + try: + if self.manager.get_device_by_name(self.device_name).has_ble(): + await ( + self.manager.get_device_by_name(self.device_name) + .ble() + .queue_command(command, **kwargs) + ) + except COMMAND_EXCEPTIONS as exc: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="command_failed" + ) from exc + + def store_cloud_credentials(self) -> None: + """Store cloud credentials in config entry.""" + # config_updates = {} + mammotion_cloud = self.manager.mqtt_list.get( + self.config_entry.data.get(CONF_ACCOUNTNAME, "") + ) + cloud_client = mammotion_cloud.cloud_client if mammotion_cloud else None + + if cloud_client is not None: + config_updates = { + **self.config_entry.data, + CONF_CONNECT_DATA: cloud_client.connect_response, + CONF_AUTH_DATA: cloud_client.login_by_oauth_response, + CONF_REGION_DATA: cloud_client.region_response, + CONF_AEP_DATA: cloud_client.aep_response, + CONF_SESSION_DATA: cloud_client.session_by_authcode_response, + CONF_DEVICE_DATA: cloud_client.devices_by_account_response, + } + self.hass.config_entries.async_update_entry( + self.config_entry, data=config_updates + ) + + async def _async_update_notification(self) -> None: + """Update data from incoming messages.""" + mower = self.manager.mower(self.device_name) + self.async_set_updated_data(mower) + + async def check_and_restore_cloud(self) -> CloudIOTGateway | None: + """Check and restore previous cloud connection.""" + + auth_data = self.config_entry.data.get(CONF_AUTH_DATA) + region_data = self.config_entry.data.get(CONF_REGION_DATA) + aep_data = self.config_entry.data.get(CONF_AEP_DATA) + session_data = self.config_entry.data.get(CONF_SESSION_DATA) + device_data = self.config_entry.data.get(CONF_DEVICE_DATA) + connect_data = self.config_entry.data.get(CONF_CONNECT_DATA) + + if all( + data is None + for data in [ + auth_data, + region_data, + aep_data, + session_data, + device_data, + connect_data, + ] + ): + return None + + cloud_client = CloudIOTGateway( + connect_response=ConnectResponse.from_dict(connect_data) + if isinstance(connect_data, dict) + else connect_data, + aep_response=AepResponse.from_dict(aep_data) + if isinstance(aep_data, dict) + else aep_data, + region_response=RegionResponse.from_dict(region_data) + if isinstance(region_data, dict) + else region_data, + session_by_authcode_response=SessionByAuthCodeResponse.from_dict( + session_data + ) + if isinstance(session_data, dict) + else session_data, + dev_by_account=ListingDevByAccountResponse.from_dict(device_data) + if isinstance(device_data, dict) + else device_data, + login_by_oauth_response=LoginByOAuthResponse.from_dict(auth_data) + if isinstance(auth_data, dict) + else auth_data, + ) + + await self.hass.async_add_executor_job(cloud_client.check_or_refresh_session) + + return cloud_client async def async_setup(self) -> None: """Set coordinator up.""" ble_device = None credentials = None - preference = ConnectionPreference.BLUETOOTH + preference = ( + ConnectionPreference.WIFI + if self.config_entry.data.get(CONF_USE_WIFI, False) + else ConnectionPreference.BLUETOOTH + ) address = self.config_entry.data.get(CONF_ADDRESS) name = self.config_entry.data.get(CONF_DEVICE_NAME) account = self.config_entry.data.get(CONF_ACCOUNTNAME) password = self.config_entry.data.get(CONF_PASSWORD) + stay_connected_ble = self.config_entry.options.get( + CONF_STAY_CONNECTED_BLUETOOTH, False + ) + + if name: + self.device_name = name if self.manager is None or self.manager.get_device_by_name(name) is None: + self.manager = Mammotion() if account and password: - if name: - self.device_name = name - preference = ConnectionPreference.WIFI credentials = Credentials() credentials.email = account credentials.password = password + try: + cloud_client = await self.check_and_restore_cloud() + if cloud_client is None: + await self.manager.login_and_initiate_cloud(account, password) + else: + await self.manager.initiate_cloud_connection( + account, cloud_client + ) + except ClientConnectorError as err: + raise ConfigEntryNotReady(err) + except EXPIRED_CREDENTIAL_EXCEPTIONS as exc: + LOGGER.debug(exc) + await self.async_login() + + # address previous bugs + if address is None and preference == ConnectionPreference.BLUETOOTH: + preference = ConnectionPreference.WIFI if address: ble_device = bluetooth.async_ble_device_from_address(self.hass, address) @@ -85,182 +266,303 @@ async def async_setup(self) -> None: ) if ble_device is not None: self.device_name = ble_device.name or "Unknown" - self.address = address + self.manager.add_ble_device(ble_device, preference) - self.manager = await create_devices(ble_device, credentials, preference) + if self.device_name is not None: + device = self.manager.get_device_by_name(self.device_name) + else: + device_names = self.manager.devices.devices.keys() + if len(device_names) == 0: + raise ConfigEntryNotReady("no_devices") + self.device_name = device_names[0] + device = self.manager.get_device_by_name(device_names[0]) + device.preference = preference - device = self.manager.get_device_by_name(self.device_name) - if device is None and self.manager.cloud_client: - try: - device_list = self.manager.cloud_client.get_devices_by_account_response().data.data - mowing_devices = [ - dev - for dev in device_list - if ( - dev.productModel is None - or dev.productModel != "ReferenceStation" - ) - ] - if len(mowing_devices) > 0: - self.device_name = mowing_devices[0].deviceName - device = self.manager.get_device_by_name(self.device_name) - except: - raise ConfigEntryNotReady( - f"Could not find Mammotion lawn mower with name {self.device_name}" - ) + if ble_device and device: + device.ble().set_disconnect_strategy(not stay_connected_ble) + + await self.async_restore_data() try: - if preference is ConnectionPreference.WIFI: - device.cloud().on_ready_callback = lambda: device.cloud().start_sync(0) - device.cloud().set_notification_callback(self._async_update_cloud) - else: + if preference is ConnectionPreference.WIFI and device.has_cloud(): + self.store_cloud_credentials() + device.cloud().set_notification_callback( + self._async_update_notification + ) + await device.cloud().start_sync(0) + elif device.has_ble(): + device.ble().set_notification_callback(self._async_update_notification) await device.ble().start_sync(0) - + else: + raise ConfigEntryNotReady( + "No configuration available to setup Mammotion lawn mower" + ) except COMMAND_EXCEPTIONS as exc: raise ConfigEntryNotReady("Unable to setup Mammotion device") from exc + async def async_restore_data(self) -> None: + """Restore saved data.""" + store = Store(self.hass, version=1, key=self.device_name) + restored_data = await store.async_load() + try: + if restored_data: + device_dict = LubaMsg().to_dict(casing=betterproto.Casing.SNAKE) + mower_state = MowingDevice().from_dict(restored_data) + mower_state.update_raw(device_dict) + self.manager.get_device_by_name( + self.device_name + ).mower_state = mower_state + except InvalidFieldValue: + """invalid""" + self.data = MowingDevice() + self.manager.get_device_by_name(self.device_name).mower_state = self.data + + async def async_save_data(self, data: MowingDevice) -> None: + """Get map data from the device.""" + store = Store(self.hass, version=1, key=self.device_name) + stored_data = asdict(data) + stored_data["device"] = None + await store.async_save(stored_data) + + +class MammotionDataUpdateCoordinator(MammotionBaseUpdateCoordinator[MowingDevice]): + """Class to manage fetching mammotion data.""" + + def __init__(self, hass: HomeAssistant, config_entry: MammotionConfigEntry) -> None: + """Initialize global mammotion data updater.""" + super().__init__( + hass=hass, + config_entry=config_entry, + update_interval=timedelta(minutes=1), + ) + async def async_sync_maps(self) -> None: """Get map data from the device.""" await self.manager.start_map_sync(self.device_name) async def async_start_stop_blades(self, start_stop: bool) -> None: - if start_stop: - await self.async_send_command("set_blade_control", on_off=1) + """Start stop blades.""" + if DeviceType.is_luba1(self.device_name): + if start_stop: + await self.async_send_command("set_blade_control", on_off=1) + else: + await self.async_send_command("set_blade_control", on_off=0) + elif start_stop: + await self.async_send_command( + "operate_on_device", + main_ctrl=1, + cut_knife_ctrl=1, + cut_knife_height=60, + max_run_speed=1.2, + ) else: - await self.async_send_command("set_blade_control", on_off=0) + await self.async_send_command( + "operate_on_device", + main_ctrl=0, + cut_knife_ctrl=0, + cut_knife_height=60, + max_run_speed=1.2, + ) + + async def async_set_sidelight(self, on_off: int) -> None: + """Set Sidelight.""" + await self.async_send_command( + "read_and_set_sidelight", is_sidelight=bool(on_off), operate=0 + ) + + async def async_read_sidelight(self) -> None: + """Set Sidelight.""" + await self.async_send_command( + "read_and_set_sidelight", is_sidelight=False, operate=1 + ) async def async_blade_height(self, height: int) -> int: + """Set blade height.""" await self.async_send_command("set_blade_height", height=float(height)) return height async def async_leave_dock(self) -> None: + """Leave dock.""" await self.async_send_command("leave_dock") + async def async_cancel_task(self) -> None: + """Cancel task.""" + await self.async_send_command("cancel_job") + async def async_move_forward(self, speed: float) -> None: - device = self.manager.get_device_by_name(self.device_name) - if self.manager.get_device_by_name(self.device_name).ble(): - await device.ble().move_forward(speed) + """Move forward.""" + await self.async_send_command("move_forward", linear=speed) async def async_move_left(self, speed: float) -> None: - device = self.manager.get_device_by_name(self.device_name) - if self.manager.get_device_by_name(self.device_name).ble(): - await device.ble().move_left(speed) + """Move left.""" + await self.async_send_command("move_left", angular=speed) async def async_move_right(self, speed: float) -> None: - device = self.manager.get_device_by_name(self.device_name) - if self.manager.get_device_by_name(self.device_name).ble(): - await device.ble().move_right(speed) + """Move right.""" + await self.async_send_command("move_right", angular=speed) async def async_move_back(self, speed: float) -> None: - device = self.manager.get_device_by_name(self.device_name) - if self.manager.get_device_by_name(self.device_name).ble(): - await device.ble().move_back(speed) + """Move back.""" + await self.async_send_command("move_back", linear=speed) - async def async_rtk_dock_location(self): + async def async_rtk_dock_location(self) -> None: """RTK and dock location.""" await self.async_send_command("allpowerfull_rw", id=5, rw=1, context=1) - async def async_request_iot_sync(self) -> None: + async def async_request_iot_sync(self, stop: bool = False) -> None: + """Sync specific info from device.""" await self.async_send_command( "request_iot_sys", - rpt_act=RptAct.RPT_START, + rpt_act=RptAct.RPT_STOP if stop else RptAct.RPT_START, rpt_info_type=[ - RptInfoType.RIT_CONNECT, RptInfoType.RIT_DEV_STA, RptInfoType.RIT_DEV_LOCAL, - RptInfoType.RIT_RTK, RptInfoType.RIT_WORK, ], - timeout=1000, + timeout=10000, period=3000, no_change_period=4000, count=0, ) - async def async_send_command(self, command: str, **kwargs: any) -> None: - try: - await self.manager.send_command_with_args( - self.device_name, command, **kwargs - ) - except COMMAND_EXCEPTIONS as exc: - raise HomeAssistantError( - translation_domain=DOMAIN, translation_key="command_failed" - ) from exc + async def async_plan_route(self, operation_settings: OperationSettings) -> None: + """Plan mow.""" + + if has_field(self.data.sys.toapp_report_data.dev): + dev = cast(RptDevStatus, self.data.sys.toapp_report_data.dev) + if has_field(dev.collector_status): + if dev.collector_status.collector_installation_status == 0: + operation_settings.is_dump = False + + if DeviceType.is_yuka(self.device_name): + operation_settings.blade_height = -10 + + route_information = GenerateRouteInformation( + one_hashs=operation_settings.areas, + rain_tactics=operation_settings.rain_tactics, + speed=operation_settings.speed, + ultra_wave=operation_settings.ultra_wave, # touch no touch etc + toward=operation_settings.toward, # is just angle + toward_included_angle=operation_settings.toward_included_angle + if operation_settings.channel_mode == 1 + else 0, # crossing angle relative to grid + toward_mode=operation_settings.toward_mode, + blade_height=operation_settings.blade_height, + channel_mode=operation_settings.channel_mode, # single, double, segment or none + channel_width=operation_settings.channel_width, + job_mode=operation_settings.job_mode, # taskMode grid or border first + edge_mode=operation_settings.mowing_laps, # perimeter laps + path_order=create_path_order(operation_settings, self.device_name), + obstacle_laps=operation_settings.obstacle_laps, + ) - async def _async_update_cloud(self): - self.async_set_updated_data(self.manager.mower(self.device_name)) + if DeviceType.is_luba1(self.device_name): + route_information.toward_mode = 0 + route_information.toward_included_angle = 0 + + await self.async_send_command( + "generate_route_information", generate_route_information=route_information + ) + + async def clear_all_maps(self) -> None: + """Clear all map data stored.""" + data = self.manager.get_device_by_name(self.device_name).mower_state + data.map = HashList() async def check_firmware_version(self) -> None: + """Check if firmware version is udpated.""" mower = self.manager.mower(self.device_name) device_registry = dr.async_get(self.hass) device_entry = device_registry.async_get_device( identifiers={(DOMAIN, self.device_name)} ) - assert device_entry + if device_entry is None: + return new_swversion = None - if ( - len( - mower.net.toapp_devinfo_resp.resp_ids - ) - > 0 - ): - new_swversion = ( - mower - .net.toapp_devinfo_resp.resp_ids[0] - .info - ) + if len(mower.net.toapp_devinfo_resp.resp_ids) > 0: + new_swversion = mower.net.toapp_devinfo_resp.resp_ids[0].info if new_swversion is not None or new_swversion != device_entry.sw_version: - device_registry.async_update_device(device_entry.id, sw_version=new_swversion) + device_registry.async_update_device( + device_entry.id, sw_version=new_swversion + ) + model_id = None + if has_field(mower.sys.device_product_type_info): + model_id = mower.sys.device_product_type_info.main_product_type + + if model_id is not None or model_id != device_entry.model_id: + device_registry.async_update_device(device_entry.id, model_id=model_id) + + def clear_update_failures(self) -> None: + self.update_failures = 0 async def _async_update_data(self) -> MowingDevice: """Get data from the device.""" + + if not self.enabled: + return self.data + device = self.manager.get_device_by_name(self.device_name) - await self.check_firmware_version() - if self.address: - ble_device = bluetooth.async_ble_device_from_address( - self.hass, self.address - ) + if self.update_failures > 3 and device.preference is ConnectionPreference.WIFI: + """Don't hammer the mammotion/ali servers""" + loop = asyncio.get_running_loop() + loop.call_later(600, self.clear_update_failures) - if not ble_device and device.cloud() is None: - self.update_failures += 1 - raise UpdateFailed("Could not find device") + return self.data - if ble_device and device.ble() is not None: - device.ble().update_device(ble_device) - else: - device.add_ble(ble_device) + await self.check_firmware_version() - try: - if ( - len(device.mower_state().net.toapp_devinfo_resp.resp_ids) == 0 - or device.mower_state().net.toapp_wifi_iot_status.productkey is None + if device.has_ble() and device.preference is ConnectionPreference.BLUETOOTH: + if ble_device := bluetooth.async_ble_device_from_address( + self.hass, device.ble().get_address(), True ): - await self.manager.start_sync(self.device_name, 0) - if device.mower_state().report_data.dev.sys_status != WorkMode.MODE_WORKING: - await self.async_send_command("get_report_cfg") + device.ble().update_device(ble_device) - else: - await self.async_request_iot_sync() + if ( + len(device.mower_state.net.toapp_devinfo_resp.resp_ids) == 0 + or device.mower_state.net.toapp_wifi_iot_status.productkey is None + ): + await self.manager.start_sync(self.device_name, 0) - except COMMAND_EXCEPTIONS as exc: - self.update_failures += 1 - raise UpdateFailed(f"Updating Mammotion device failed: {exc}") from exc + if not device.mower_state.sys.todev_time_ctrl_light: + await self.async_read_sidelight() + + if ( + not has_field(device.mower_state.sys.device_product_type_info) + or device.mower_state.mqtt_properties is None + ): + await self.async_send_command("get_device_product_model") + + if ( + len(device.mower_state.map.hashlist) == 0 + or len(device.mower_state.map.missing_hashlist) > 0 + ): + await self.manager.start_map_sync(self.device_name) + + # if not device.has_queued_commands(): + await self.async_send_command("get_report_cfg") LOGGER.debug("Updated Mammotion device %s", self.device_name) LOGGER.debug("================= Debug Log =================") LOGGER.debug( "Mammotion device data: %s", - asdict(self.manager.get_device_by_name(self.device_name).mower_state()), + asdict(self.manager.get_device_by_name(self.device_name).mower_state), ) LOGGER.debug("==================================") self.update_failures = 0 - return self.manager.get_device_by_name(self.device_name).mower_state() + data = self.manager.get_device_by_name(self.device_name).mower_state + await self.async_save_data(data) + return data + + @property + def operation_settings(self) -> OperationSettings: + """Return operation settings for planning.""" + return self._operation_settings # TODO when submitting to HA use this 2024.8 and up # async def _async_setup(self) -> None: diff --git a/homeassistant/components/mammotion/device_tracker.py b/homeassistant/components/mammotion/device_tracker.py index d997f003c81cf..34092878e05fe 100644 --- a/homeassistant/components/mammotion/device_tracker.py +++ b/homeassistant/components/mammotion/device_tracker.py @@ -6,6 +6,7 @@ from homeassistant.components.device_tracker import SourceType, TrackerEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity from . import MammotionConfigEntry from .const import ATTR_DIRECTION @@ -26,12 +27,12 @@ async def async_setup_entry( async_add_entities([MammotionTracker(coordinator)]) -class MammotionTracker(MammotionBaseEntity, TrackerEntity): +class MammotionTracker(MammotionBaseEntity, TrackerEntity, RestoreEntity): """Mammotion device tracker.""" _attr_force_update = False _attr_translation_key = "device_tracker" - _attr_icon = "mdi:car" + _attr_icon = "mdi:robot-mower" def __init__(self, coordinator: MammotionDataUpdateCoordinator) -> None: """Initialize the Tracker.""" diff --git a/homeassistant/components/mammotion/entity.py b/homeassistant/components/mammotion/entity.py index 12d36a5e6d0cd..53219def80e30 100644 --- a/homeassistant/components/mammotion/entity.py +++ b/homeassistant/components/mammotion/entity.py @@ -1,11 +1,12 @@ """Base class for entities.""" +from pymammotion.proto import has_field +from pymammotion.utility.device_type import DeviceType + from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from pymammotion.utility.device_type import DeviceType -from . import DEFAULT_RETRY_COUNT -from .const import CONF_RETRY_COUNT, DOMAIN +from .const import CONF_ACCOUNTNAME, CONF_RETRY_COUNT, DEFAULT_RETRY_COUNT, DOMAIN from .coordinator import MammotionDataUpdateCoordinator @@ -21,38 +22,46 @@ def __init__(self, coordinator: MammotionDataUpdateCoordinator, key: str) -> Non @property def device_info(self) -> DeviceInfo: - mower = self.coordinator.manager.mower( - self.coordinator.device_name - ) + mower = self.coordinator.manager.mower(self.coordinator.device_name) swversion = None - if ( - len( - mower.net.toapp_devinfo_resp.resp_ids - ) - > 0 - ): - swversion = ( - mower - .net.toapp_devinfo_resp.resp_ids[0] - .info - ) + if len(mower.net.toapp_devinfo_resp.resp_ids) > 0: + swversion = mower.net.toapp_devinfo_resp.resp_ids[0].info product_key = mower.net.toapp_wifi_iot_status.productkey if product_key is None or product_key == "": - if self.coordinator.manager.cloud_client: - device_list = self.coordinator.manager.cloud_client.get_devices_by_account_response().data.data - device = next((device for device in device_list if device.deviceName == self.coordinator.device_name), None) - product_key = device.productKey + if self.coordinator.manager.mqtt_list.get( + self.coordinator.config_entry.data.get(CONF_ACCOUNTNAME) + ): + mammotion_cloud = self.coordinator.manager.mqtt_list.get( + self.coordinator.device_name + ) + if mammotion_cloud is not None: + device_list = mammotion_cloud.cloud_client.devices_by_account_response.data.data + device = [ + device + for device in device_list + if device.deviceName == self.coordinator.device_name + ].pop() + + product_key = device.productKey device_model = DeviceType.value_of_str( - self.coordinator.device_name, - product_key, - ).get_model() + self.coordinator.device_name, + product_key, + ).get_model() + + model_id = None + if mower is not None: + if has_field(mower.sys.device_product_type_info): + model_id = mower.sys.device_product_type_info.main_product_type + if mower.mqtt_properties is not None: + model_id = mower.mqtt_properties.params.items.extMod.value return DeviceInfo( identifiers={(DOMAIN, self.coordinator.device_name)}, manufacturer="Mammotion", serial_number=self.coordinator.device_name.split("-", 1)[-1], + model_id=model_id, name=self.coordinator.device_name, sw_version=swversion, model=device_model, diff --git a/homeassistant/components/mammotion/icons,json b/homeassistant/components/mammotion/icons.json similarity index 100% rename from homeassistant/components/mammotion/icons,json rename to homeassistant/components/mammotion/icons.json diff --git a/homeassistant/components/mammotion/lawn_mower.py b/homeassistant/components/mammotion/lawn_mower.py index 9efb953b7cf3e..ded88293fe5df 100644 --- a/homeassistant/components/mammotion/lawn_mower.py +++ b/homeassistant/components/mammotion/lawn_mower.py @@ -2,9 +2,15 @@ from __future__ import annotations -from pymammotion.mammotion.devices.mammotion import has_field +from typing import Any + +from pymammotion.data.model.device_config import OperationSettings +from pymammotion.data.model.report_info import ReportData +from pymammotion.proto import has_field from pymammotion.proto.luba_msg import RptDevStatus from pymammotion.utility.constant.device_constant import WorkMode +from pymammotion.utility.device_type import DeviceType +import voluptuous as vol from homeassistant.components.lawn_mower import ( LawnMowerActivity, @@ -13,6 +19,7 @@ ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import MammotionConfigEntry @@ -20,6 +27,62 @@ from .coordinator import MammotionDataUpdateCoordinator from .entity import MammotionBaseEntity +SERVICE_START_MOWING = "start_mow" +SERVICE_CANCEL_JOB = "cancel_job" + +START_MOW_SCHEMA = { + vol.Optional("is_mow", default=True): cv.boolean, + vol.Optional("is_dump", default=True): cv.boolean, + vol.Optional("is_edge", default=False): cv.boolean, + vol.Optional("collect_grass_frequency", default=10): vol.All( + vol.Coerce(int), vol.Range(min=5, max=100) + ), + vol.Optional("border_mode", default=1): vol.In([0, 1]), + vol.Optional("job_version", default=0): vol.Coerce(int), + vol.Optional("job_id", default=0): vol.Coerce(int), + vol.Optional("speed", default=0.3): vol.All( + vol.Coerce(float), vol.Range(min=0.2, max=1.2) + ), + vol.Optional("ultra_wave", default=2): vol.In([0, 1, 2, 10]), + vol.Optional("channel_mode", default=0): vol.In([0, 1, 2, 3]), + vol.Optional("channel_width", default=25): vol.All( + vol.Coerce(int), vol.Range(min=20, max=35) + ), + vol.Optional("rain_tactics", default=1): vol.In([0, 1]), + vol.Optional("blade_height", default=25): vol.All( + vol.Coerce(int), vol.Range(min=15, max=100) + ), + vol.Optional("toward", default=0): vol.All( + vol.Coerce(int), vol.Range(min=-180, max=180) + ), + vol.Optional("toward_included_angle", default=0): vol.All( + vol.Coerce(int), vol.Range(min=-180, max=180) + ), + vol.Optional("toward_mode", default=0): vol.In([0, 1, 2]), + vol.Optional("mowing_laps", default=1): vol.In([0, 1, 2, 3, 4]), + vol.Optional("obstacle_laps", default=1): vol.In([0, 1, 2, 3, 4]), + vol.Optional("start_progress", default=0): vol.All( + vol.Coerce(int), vol.Range(min=0, max=100) + ), + vol.Required("areas"): vol.All( + cv.ensure_list, [cv.entity_id] + ), # This assumes `areas` are entity IDs from the integration +} + + +def get_entity_attribute( + hass: HomeAssistant, entity_id: str, attribute_name: str +) -> str | None: + # Get the state object of the entity + entity = hass.states.get(entity_id) + + # Check if the entity exists and has attributes + if entity and attribute_name in entity.attributes: + # Return the specific attribute + return entity.attributes.get(attribute_name, None) + # Return None if the entity or attribute does not exist + return None + async def async_setup_entry( hass: HomeAssistant, @@ -30,6 +93,14 @@ async def async_setup_entry( coordinator = entry.runtime_data async_add_entities([MammotionLawnMowerEntity(coordinator)]) + platform = entity_platform.async_get_current_platform() + + platform.async_register_entity_service( + SERVICE_START_MOWING, START_MOW_SCHEMA, "async_start_mowing" + ) + + platform.async_register_entity_service(SERVICE_CANCEL_JOB, None, "async_cancel") + class MammotionLawnMowerEntity(MammotionBaseEntity, LawnMowerEntity): """Representation of a Mammotion lawn mower.""" @@ -46,97 +117,197 @@ def __init__(self, coordinator: MammotionDataUpdateCoordinator) -> None: self._attr_name = None # main feature of device @property - def rpt_dev_status(self) -> RptDevStatus | None: + def rpt_dev_status(self) -> RptDevStatus: """Return the device status.""" if has_field(self.coordinator.data.sys.toapp_report_data.dev): return self.coordinator.data.sys.toapp_report_data.dev - return None + return RptDevStatus() + + @property + def report_data(self) -> ReportData: + return self.coordinator.data.report_data @property def activity(self) -> LawnMowerActivity | None: """Return the state of the mower.""" - if self.rpt_dev_status is None: - return None - - mode = self.rpt_dev_status.sys_status charge_state = self.rpt_dev_status.charge_state + mode = self.rpt_dev_status.sys_status + if mode is None: + return None LOGGER.debug("activity mode %s", mode) - if ( - mode == WorkMode.MODE_PAUSE - or mode == WorkMode.MODE_READY - and charge_state == 0 + if mode == WorkMode.MODE_PAUSE or ( + mode == WorkMode.MODE_READY and charge_state == 0 ): return LawnMowerActivity.PAUSED - if mode in (WorkMode.MODE_WORKING, WorkMode.MODE_RETURNING): + 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 - async def async_start_mowing(self) -> None: + async def async_start_mowing(self, **kwargs: Any) -> None: """Start mowing.""" + trans_key = "pause_failed" + + if kwargs: + await self.async_cancel() + entity_ids = kwargs.get("areas", []) + + attributes = [ + # TODO this should not need to be cast. + int(entity_hash) + for entity_id in entity_ids + if (entity_hash := get_entity_attribute(self.hass, entity_id, "hash")) + is not None + ] + + kwargs["areas"] = attributes + operational_settings = OperationSettings.from_dict(kwargs) + if DeviceType.is_yuka(self.coordinator.device_name): + operational_settings.blade_height = -10 + LOGGER.debug(kwargs) + else: + operational_settings = self.coordinator.operation_settings + # check if job in progress # - if self.rpt_dev_status is None: + mode = self.rpt_dev_status.sys_status + if mode is None: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="device_not_ready" ) - if self.rpt_dev_status.sys_status == WorkMode.MODE_PAUSE: + + if mode in ( + WorkMode.MODE_PAUSE, + WorkMode.MODE_READY, + WorkMode.MODE_RETURNING, + ): try: - await self.coordinator.async_send_command("resume_execute_task") - return await self.coordinator.async_request_iot_sync() + if mode == WorkMode.MODE_RETURNING: + trans_key = "dock_cancel_failed" + await self.coordinator.async_send_command("cancel_return_to_dock") + await self.coordinator.async_request_iot_sync() + # TODO is rpt_dev_status updated on iot sync? + mode = self.rpt_dev_status.sys_status + if mode == WorkMode.MODE_PAUSE: + trans_key = "resume_failed" + await self.coordinator.async_send_command("resume_execute_task") + if mode == WorkMode.MODE_READY: + trans_key = "start_failed" + await self.coordinator.async_plan_route(operational_settings) + await self.coordinator.async_send_command("start_job") + except COMMAND_EXCEPTIONS as exc: raise HomeAssistantError( - translation_domain=DOMAIN, translation_key="resume_failed" + translation_domain=DOMAIN, translation_key=trans_key ) from exc - try: - await self.coordinator.async_send_command("start_job") - await self.coordinator.async_request_iot_sync() - except COMMAND_EXCEPTIONS as exc: - raise HomeAssistantError( - translation_domain=DOMAIN, translation_key="start_failed" - ) from exc - finally: - self.coordinator.async_set_updated_data( - self.coordinator.manager.mower(self.coordinator.device_name) - ) + finally: + await self.coordinator.async_request_iot_sync() async def async_dock(self) -> None: """Start docking.""" + trans_key = "pause_failed" + charge_state = self.rpt_dev_status.charge_state mode = self.rpt_dev_status.sys_status - - try: - if mode == WorkMode.MODE_RETURNING: - await self.coordinator.async_send_command("cancel_return_to_dock") - return await self.coordinator.async_send_command("get_report_cfg") - if mode == WorkMode.MODE_WORKING: - await self.coordinator.async_send_command("pause_execute_task") - await self.coordinator.async_send_command("return_to_dock") - await self.coordinator.async_request_iot_sync() - except COMMAND_EXCEPTIONS as exc: + if mode is None: raise HomeAssistantError( - translation_domain=DOMAIN, translation_key="dock_failed" - ) from exc - finally: - self.coordinator.async_set_updated_data( - self.coordinator.manager.mower(self.coordinator.device_name) + translation_domain=DOMAIN, translation_key="device_not_ready" ) + if charge_state == 0 and mode in ( + WorkMode.MODE_WORKING, + WorkMode.MODE_PAUSE, + WorkMode.MODE_READY, + 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") + else: + 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.async_request_iot_sync() + async def async_pause(self) -> None: """Pause mower.""" - try: - await self.coordinator.async_send_command("pause_execute_task") - await self.coordinator.async_request_iot_sync() - except COMMAND_EXCEPTIONS as exc: + 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.async_request_iot_sync() + + async def async_cancel(self) -> None: + """Cancel Job.""" + trans_key = "pause_failed" + + mode = self.rpt_dev_status.sys_status + if mode is None: raise HomeAssistantError( - translation_domain=DOMAIN, translation_key="pause_failed" - ) from exc - finally: - self.coordinator.async_set_updated_data( - self.coordinator.manager.mower(self.coordinator.device_name) + translation_domain=DOMAIN, translation_key="device_not_ready" ) + + if mode in ( + WorkMode.MODE_PAUSE, + WorkMode.MODE_WORKING, + WorkMode.MODE_RETURNING, + ): + try: + if mode != WorkMode.MODE_PAUSE: + 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_failed" + await self.coordinator.async_send_command( + "cancel_return_to_dock" + ) + await self.coordinator.async_request_iot_sync() + mode = self.rpt_dev_status.sys_status + + if mode == WorkMode.MODE_PAUSE: + trans_key = "pause_failed" + await self.coordinator.async_send_command("cancel_job") + + except COMMAND_EXCEPTIONS as exc: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key=trans_key + ) from exc + finally: + await self.coordinator.async_request_iot_sync() diff --git a/homeassistant/components/mammotion/manifest.json b/homeassistant/components/mammotion/manifest.json index bb00d863d33f9..307ac95dabbdc 100644 --- a/homeassistant/components/mammotion/manifest.json +++ b/homeassistant/components/mammotion/manifest.json @@ -17,7 +17,8 @@ "config_flow": true, "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/mammotion", + "loggers": ["pymammotion"], "integration_type": "device", "iot_class": "local_push", - "requirements": ["pymammotion==0.2.21"] + "requirements": ["pymammotion==0.2.77"] } diff --git a/homeassistant/components/mammotion/number.py b/homeassistant/components/mammotion/number.py index 4b843b0e62a30..9aa1f94f51163 100644 --- a/homeassistant/components/mammotion/number.py +++ b/homeassistant/components/mammotion/number.py @@ -1,16 +1,26 @@ +from collections.abc import Callable from dataclasses import dataclass -from typing import Awaitable, Callable + +from pymammotion.data.model.device_config import DeviceLimits +from pymammotion.utility.device_type import DeviceType from homeassistant.components.number import ( + NumberDeviceClass, NumberEntity, NumberEntityDescription, NumberMode, ) -from homeassistant.const import PERCENTAGE +from homeassistant.const import ( + AREA_SQUARE_METERS, + DEGREE, + PERCENTAGE, + UnitOfLength, + UnitOfSpeed, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback -from pymammotion.data.model.device_config import DeviceLimits +from homeassistant.helpers.restore_state import RestoreEntity from . import MammotionConfigEntry from .coordinator import MammotionDataUpdateCoordinator @@ -18,37 +28,95 @@ @dataclass(frozen=True, kw_only=True) -class MammotionNumberEntityDescription(NumberEntityDescription): +class MammotionConfigNumberEntityDescription(NumberEntityDescription): """Describes Mammotion number entity.""" + set_fn: Callable[[MammotionDataUpdateCoordinator, int], None] + -NUMBER_ENTITIES: tuple[MammotionNumberEntityDescription, ...] = ( - MammotionNumberEntityDescription( +NUMBER_ENTITIES: tuple[MammotionConfigNumberEntityDescription, ...] = ( + MammotionConfigNumberEntityDescription( key="start_progress", min_value=0, max_value=100, step=1, mode=NumberMode.SLIDER, native_unit_of_measurement=PERCENTAGE, - entity_category=EntityCategory.CONFIG + set_fn=lambda coordinator, value: setattr( + coordinator.operation_settings, "start_progress", value + ), + ), + MammotionConfigNumberEntityDescription( + key="cutting_angle", + step=1, + native_unit_of_measurement=DEGREE, + min_value=-180, + max_value=180, + set_fn=lambda coordinator, value: setattr( + coordinator.operation_settings, "toward", value + ), + ), + MammotionConfigNumberEntityDescription( + key="toward_included_angle", + step=1, + native_unit_of_measurement=DEGREE, + min_value=-180, + max_value=180, + set_fn=lambda coordinator, value: setattr( + coordinator.operation_settings, "toward_included_angle", value + ), ), ) +YUKA_NUMBER_ENTITIES: tuple[MammotionConfigNumberEntityDescription, ...] = ( + MammotionConfigNumberEntityDescription( + key="dumping_interval", + min_value=5, + max_value=100, + step=1, + mode=NumberMode.SLIDER, + native_unit_of_measurement=AREA_SQUARE_METERS, + set_fn=lambda coordinator, value: setattr( + coordinator.operation_settings, "collect_grass_frequency", value + ), + ), +) -NUMBER_WORKING_ENTITIES: tuple[MammotionNumberEntityDescription, ...] = ( - MammotionNumberEntityDescription( +LUBA_WORKING_ENTITIES: tuple[MammotionConfigNumberEntityDescription, ...] = ( + MammotionConfigNumberEntityDescription( key="blade_height", step=5, - min_value=30, # ToDo: To be dynamiclly set based on model (h\non H) + min_value=25, # ToDo: To be dynamiclly set based on model (h\non H) max_value=70, # ToDo: To be dynamiclly set based on model (h\non H) - entity_category=EntityCategory.CONFIG + set_fn=lambda coordinator, value: setattr( + coordinator.operation_settings, "blade_height", value + ), ), - MammotionNumberEntityDescription( +) + + +NUMBER_WORKING_ENTITIES: tuple[MammotionConfigNumberEntityDescription, ...] = ( + MammotionConfigNumberEntityDescription( key="working_speed", - entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.SPEED, + native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, step=0.1, min_value=0.2, - max_value=0.6 + max_value=0.6, + set_fn=lambda coordinator, value: setattr( + coordinator.operation_settings, "speed", value + ), + ), + MammotionConfigNumberEntityDescription( + key="path_spacing", + step=1, + device_class=NumberDeviceClass.DISTANCE, + native_unit_of_measurement=UnitOfLength.CENTIMETERS, + min_value=20, + max_value=35, + set_fn=lambda coordinator, value: setattr( + coordinator.operation_settings, "channel_width", value + ), ), ) @@ -62,27 +130,39 @@ async def async_setup_entry( coordinator = entry.runtime_data limits = coordinator.manager.mower(coordinator.device_name).limits - entities: list[MammotionNumberEntity] = [] + entities: list[MammotionConfigNumberEntity] = [] for entity_description in NUMBER_WORKING_ENTITIES: entity = MammotionWorkingNumberEntity(coordinator, entity_description, limits) entities.append(entity) for entity_description in NUMBER_ENTITIES: - entity = MammotionNumberEntity(coordinator, entity_description) + entity = MammotionConfigNumberEntity(coordinator, entity_description) entities.append(entity) + if DeviceType.is_yuka(coordinator.device_name): + for entity_description in YUKA_NUMBER_ENTITIES: + entity = MammotionConfigNumberEntity(coordinator, entity_description) + entities.append(entity) + else: + for entity_description in LUBA_WORKING_ENTITIES: + entity = MammotionWorkingNumberEntity( + coordinator, entity_description, limits + ) + entities.append(entity) + async_add_entities(entities) -class MammotionNumberEntity(MammotionBaseEntity, NumberEntity): - entity_description: MammotionNumberEntityDescription +class MammotionConfigNumberEntity(MammotionBaseEntity, NumberEntity, RestoreEntity): + entity_description: MammotionConfigNumberEntityDescription _attr_has_entity_name = True + _attr_entity_category = EntityCategory.CONFIG def __init__( self, coordinator: MammotionDataUpdateCoordinator, - entity_description: MammotionNumberEntityDescription, + entity_description: MammotionConfigNumberEntityDescription, ) -> None: super().__init__(coordinator, entity_description.key) self.entity_description = entity_description @@ -91,19 +171,24 @@ def __init__( self._attr_native_max_value = entity_description.max_value self._attr_native_step = entity_description.step self._attr_native_value = self._attr_native_min_value # Default value + if self.entity_description.native_unit_of_measurement == DEGREE: + self._attr_native_value = 0 + if self.entity_description.key == "toward_included_angle": + self._attr_native_value = 90 async def async_set_native_value(self, value: float | int) -> None: self._attr_native_value = value + self.entity_description.set_fn(self.coordinator, value) self.async_write_ha_state() -class MammotionWorkingNumberEntity(MammotionNumberEntity): +class MammotionWorkingNumberEntity(MammotionConfigNumberEntity): """Mammotion working number entity.""" def __init__( self, coordinator: MammotionDataUpdateCoordinator, - entity_description: MammotionNumberEntityDescription, + entity_description: MammotionConfigNumberEntityDescription, limits: DeviceLimits, ) -> None: super().__init__(coordinator, entity_description) @@ -128,3 +213,8 @@ def native_min_value(self) -> float: def native_max_value(self) -> float: """Return the maximum value.""" return self._attr_native_max_value + + async def async_set_native_value(self, value: float | int) -> None: + self._attr_native_value = value + self.entity_description.set_fn(self.coordinator, value) + self.async_write_ha_state() diff --git a/homeassistant/components/mammotion/select.py b/homeassistant/components/mammotion/select.py index 45854a828e229..d58159349e9dc 100644 --- a/homeassistant/components/mammotion/select.py +++ b/homeassistant/components/mammotion/select.py @@ -1,16 +1,21 @@ +from collections.abc import Callable from dataclasses import dataclass -from typing import Awaitable, Callable -from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback from pymammotion.data.model.mowing_modes import ( BorderPatrolMode, + BypassStrategy, CuttingMode, MowOrder, ObstacleLapsMode, + PathAngleSetting, ) +from pymammotion.utility.device_type import DeviceType + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity from . import MammotionConfigEntry from .coordinator import MammotionDataUpdateCoordinator @@ -18,33 +23,84 @@ @dataclass(frozen=True, kw_only=True) -class MammotionSelectEntityDescription(SelectEntityDescription): +class MammotionConfigSelectEntityDescription(SelectEntityDescription): """Describes Mammotion select entity.""" key: str options: list[str] + set_fn: Callable[[MammotionDataUpdateCoordinator, str], None] + +SELECT_ENTITIES: tuple[MammotionConfigSelectEntityDescription, ...] = ( + MammotionConfigSelectEntityDescription( + key="channel_mode", + options=[mode.name for mode in CuttingMode], + set_fn=lambda coordinator, value: setattr( + coordinator.operation_settings, "channel_mode", CuttingMode[value] + ), + ), + MammotionConfigSelectEntityDescription( + key="mowing_laps", + options=[mode.name for mode in BorderPatrolMode], + set_fn=lambda coordinator, value: setattr( + coordinator.operation_settings, "mowing_laps", BorderPatrolMode[value] + ), + ), + MammotionConfigSelectEntityDescription( + key="obstacle_laps", + options=[mode.name for mode in ObstacleLapsMode], + set_fn=lambda coordinator, value: setattr( + coordinator.operation_settings, "obstacle_laps", ObstacleLapsMode[value] + ), + ), + MammotionConfigSelectEntityDescription( + key="border_mode", + options=[order.name for order in MowOrder], + set_fn=lambda coordinator, value: setattr( + coordinator.operation_settings, "border_mode", MowOrder[value] + ), + ), +) -SELECT_ENTITIES: tuple[MammotionSelectEntityDescription, ...] = ( - MammotionSelectEntityDescription( - key="cutting_mode", - entity_category=EntityCategory.CONFIG, - options=[mode.name for mode in CuttingMode] +LUBA1_SELECT_ENTITIES: tuple[MammotionConfigSelectEntityDescription, ...] = ( + MammotionConfigSelectEntityDescription( + key="cutting_angle_mode", + options=[ + angle_type.name + for angle_type in PathAngleSetting + if angle_type != PathAngleSetting.random_angle + ], + set_fn=lambda coordinator, value: setattr( + coordinator.operation_settings, "toward_mode", PathAngleSetting[value] + ), ), - MammotionSelectEntityDescription( - key="border_patrol_mode", - entity_category=EntityCategory.CONFIG, - options=[mode.name for mode in BorderPatrolMode] + MammotionConfigSelectEntityDescription( + key="bypass_mode", + options=[ + strategy.name + for strategy in BypassStrategy + if strategy != BypassStrategy.no_touch + ], + set_fn=lambda coordinator, value: setattr( + coordinator.operation_settings, "ultra_wave", BypassStrategy[value] + ), ), - MammotionSelectEntityDescription( - key="obstacle_laps_mode", - entity_category=EntityCategory.CONFIG, - options=[mode.name for mode in ObstacleLapsMode] +) + +LUBA_PRO_SELECT_ENTITIES: tuple[MammotionConfigSelectEntityDescription, ...] = ( + MammotionConfigSelectEntityDescription( + key="cutting_angle_mode", + options=[angle_type.name for angle_type in PathAngleSetting], + set_fn=lambda coordinator, value: setattr( + coordinator.operation_settings, "toward_mode", PathAngleSetting[value] + ), ), - MammotionSelectEntityDescription( - key="mow_order", - entity_category=EntityCategory.CONFIG, - options=[order.name for order in MowOrder] + MammotionConfigSelectEntityDescription( + key="bypass_mode", + options=[strategy.name for strategy in BypassStrategy], + set_fn=lambda coordinator, value: setattr( + coordinator.operation_settings, "ultra_wave", BypassStrategy[value] + ), ), ) @@ -57,26 +113,38 @@ async def async_setup_entry( ) -> None: """Set up the Mammotion select entity.""" coordinator = entry.runtime_data + entities = [] + + for entity_description in SELECT_ENTITIES: + entities.append(MammotionConfigSelectEntity(coordinator, entity_description)) - async_add_entities( - MammotionSelectEntity(coordinator, entity_description) - for entity_description in SELECT_ENTITIES - ) + if DeviceType.is_luba1(coordinator.device_name): + for entity_description in LUBA1_SELECT_ENTITIES: + entities.append( + MammotionConfigSelectEntity(coordinator, entity_description) + ) + else: + for entity_description in LUBA_PRO_SELECT_ENTITIES: + entities.append( + MammotionConfigSelectEntity(coordinator, entity_description) + ) + + async_add_entities(entities) # Define the select entity class with entity_category: config -class MammotionSelectEntity(MammotionBaseEntity, SelectEntity): +class MammotionConfigSelectEntity(MammotionBaseEntity, SelectEntity, RestoreEntity): """Representation of a Mammotion select entities.""" _attr_entity_category = EntityCategory.CONFIG - entity_description: MammotionSelectEntityDescription + entity_description: MammotionConfigSelectEntityDescription _attr_has_entity_name = True def __init__( self, coordinator: MammotionDataUpdateCoordinator, - entity_description: MammotionSelectEntityDescription, + entity_description: MammotionConfigSelectEntityDescription, ) -> None: super().__init__(coordinator, entity_description.key) self.coordinator = coordinator @@ -84,3 +152,8 @@ def __init__( self._attr_translation_key = entity_description.key self._attr_options = entity_description.options self._attr_current_option = entity_description.options[0] + + async def async_select_option(self, option: str) -> None: + self._attr_current_option = option + self.entity_description.set_fn(self.coordinator, option) + self.async_write_ha_state() diff --git a/homeassistant/components/mammotion/sensor.py b/homeassistant/components/mammotion/sensor.py index 1c3a0160f1fce..4458644d646f6 100644 --- a/homeassistant/components/mammotion/sensor.py +++ b/homeassistant/components/mammotion/sensor.py @@ -3,6 +3,11 @@ from collections.abc import Callable from dataclasses import dataclass +from pymammotion.data.model.device import MowingDevice +from pymammotion.data.model.enums import RTKStatus +from pymammotion.utility.constant.device_constant import PosType, device_mode +from pymammotion.utility.device_type import DeviceType + from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -21,11 +26,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.unit_conversion import SpeedConverter -from pymammotion.data.model.device import MowingDevice -from pymammotion.data.model.enums import RTKStatus -from pymammotion.proto.luba_msg import ReportInfoData -from pymammotion.utility.constant.device_constant import PosType, device_mode -from pymammotion.utility.device_type import DeviceType from . import MammotionConfigEntry from .coordinator import MammotionDataUpdateCoordinator @@ -168,9 +168,7 @@ class MammotionSensorEntityDescription(SensorEntityDescription): state_class=None, device_class=SensorDeviceClass.ENUM, native_unit_of_measurement=None, - value_fn=lambda mower_data: str( - mower_data.location.work_zone or "Not working" - ), + value_fn=lambda mower_data: str(mower_data.location.work_zone or "Not working"), ), # MammotionSensorEntityDescription( # key="lawn_mower_position", diff --git a/homeassistant/components/mammotion/services.yaml b/homeassistant/components/mammotion/services.yaml new file mode 100644 index 0000000000000..95058a494b169 --- /dev/null +++ b/homeassistant/components/mammotion/services.yaml @@ -0,0 +1,211 @@ +cancel_job: + target: + entity: + integration: mammotion + domain: lawn_mower +start_mow: + target: + entity: + integration: mammotion + domain: lawn_mower + fields: + is_mow: + example: true + default: true + required: false + selector: + boolean: + is_dump: + example: true + default: true + required: false + selector: + boolean: + is_edge: + example: false + default: false + required: false + selector: + boolean: + collect_grass_frequency: + example: 10 + default: 10 + required: false + selector: + number: + min: 5 + max: 100 + unit_of_measurement: "m²" + border_mode: + example: 0 + default: 0 + required: false + selector: + select: + options: + - value: 0 + label: "Perimeter First" + - value: 1 + label: "ZigZag/Chessboard First" + job_version: + example: 0 + default: 0 + required: false + selector: + number: + job_id: + example: 0 + default: 0 + required: false + selector: + number: + speed: + example: 0.3 + default: 0.3 + required: false + selector: + number: + min: 0.2 + max: 1.2 + step: 0.1 + mode: box + unit_of_measurement: "m/s" + ultra_wave: + example: 2 + default: 2 + selector: + select: + options: + - value: 0 + label: "Direct Touch" + - value: 1 + label: "Slow Touch" + - value: 2 + label: "Less Touch" + - value: 10 + label: "No Touch" + required: false + channel_mode: + example: 0 + default: 0 + required: false + selector: + select: + options: + - value: 0 + label: "Zigzag Path" + - value: 1 + label: "Chessboard Path" + - value: 2 + label: "Adaptive Zigzag Path" + - value: 3 + label: "Perimeter Only" + channel_width: + example: 25 + default: 25 + required: false + selector: + number: + min: 20 + max: 35 + rain_tactics: + example: 1 + default: 1 + required: false + selector: + options: + - value: 0 + label: "Off" + - value: 1 + label: "On" + blade_height: + example: 0 + default: 25 + required: false + selector: + number: + min: 15 + max: 100 + step: 5 + unit_of_measurement: "mm" + toward: + example: 0 + default: 0 + required: false + selector: + number: + min: -180 + max: 180 + unit_of_measurement: degrees + toward_included_angle: + example: 0 + default: 0 + required: false + selector: + number: + min: -180 + max: 180 + unit_of_measurement: degrees + toward_mode: + example: 0 + default: 0 + selector: + select: + options: + - value: 0 + label: "Relative Angle" + - value: 1 + label: "Absolute Angle" + - value: 2 + label: "Random Angle" + required: false + mowing_laps: + example: 1 + default: 1 + selector: + select: + options: + - value: 0 + label: "None" + - value: 1 + label: "One Lap" + - value: 2 + label: "Two Laps" + - value: 3 + label: "Three Laps" + - value: 4 + label: "Four Laps" + required: false + obstacle_laps: + example: 1 + default: 1 + selector: + select: + options: + - value: 0 + label: "None" + - value: 1 + label: "One Lap" + - value: 2 + label: "Two Laps" + - value: 3 + label: "Three Laps" + - value: 4 + label: "Four Laps" + required: false + start_progress: + example: 0 + default: 0 + required: false + selector: + number: + min: 0 + max: 100 + unit_of_measurement: "%" + areas: + required: true + selector: + entity: + multiple: true + integration: mammotion + domain: switch diff --git a/homeassistant/components/mammotion/strings.json b/homeassistant/components/mammotion/strings.json index 34996211cefd3..8c9b1acfcc23d 100644 --- a/homeassistant/components/mammotion/strings.json +++ b/homeassistant/components/mammotion/strings.json @@ -1,50 +1,24 @@ { "config": { "abort": { - "not_supported": "Device not supported", - "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "already_configured": "Device is already configured", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "unknown": "[%key:common::config_flow::error::unknown%]" - }, - "flow_title": "Configure your Mammotion lawn mower", - "step": { - "bluetooth_confirm": { - "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" - }, - "reconfigure": { - "data": { - "use_wifi": "Use Wi-fi", - "account_name": "Mammotion email or account number", - "password": "Mammotion account password" - } - }, - "user": { - "data": { - "address": "Device" - }, - "description": "Select your mower" - }, - "wifi": { - "data": { - "use_wifi": "Use Wi-fi (un-tick and submit to use bluetooth)", - "account_name": "Mammotion email or account number", - "password": "Mammotion account password" - }, - "title": "Connect to Wi-Fi", - "description": "Enter your Mammotion account email or id and password" - } + "no_devices_found": "Could not find devices", + "no_longer_present": "Device is no longer present", + "not_supported": "Device not supported", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" } }, - "options": { - "step": { - "init": { - "data": { - "title": "Update Configuration", - "stay_connected_bluetooth": "Keep bluetooth connected" - } - } + "flow_title": "Configure your Mammotion lawn mower", + "step": { + "bluetooth_confirm": { + "description": "Setup {name}" + }, + "user": { + "data": { + "address": "Device" + }, + "description": "Select your mower" } }, "entity": { @@ -96,81 +70,170 @@ }, "activity_mode": { "name": "Activity mode" - }, - "work_area": { - "name": "Work area hash" } }, "button": { "start_map_sync": { "name": "Sync maps" }, - "resync_rtk_dock": { - "name": "Sync RTK and dock" - }, - "release_from_dock": { - "name": "Undock" - }, - "emergency_nudge_forward": { - "name": "Emergency nudge forward" - }, - "emergency_nudge_left": { - "name": "Emergency nudge left" - }, - "emergency_nudge_right": { - "name": "Emergency nudge right" - }, - "emergency_nudge_back": { - "name": "Emergency nudge back" + "resync_rtk_dock": { + "name": "Sync RTK and dock", + "description": "Syncs RTK and dock location for when you move them." } }, "switch": { - "blades_on_off": { - "name": "Blades On/Off" + "blade_status": { + "name": "Blades On/Off", + "description": "Turn the blades on or off." }, - "mowing_on_off": { - "name": "Mowing On/Off" + "is_mow": { + "name": "Mowing On/Off", + "description": "Start or stop mowing." }, - "dump_grass_on_off": { - "name": "Dump Grass On/Off" + "is_dump": { + "name": "Dump Grass On/Off", + "description": "Enable or disable grass dumping." }, - "rain_detection_on_off": { - "name": "Rain Detection On/Off" + "rain_tactics": { + "name": "Rain Detection On/Off", + "description": "Turn rain detection on or off." }, - "side_led_on_off": { - "name": "Side LED On/Off" + "side_led": { + "name": "Side LED On/Off", + "description": "Enable or disable the side LED." }, - "perimeter_first_on_off": { - "name": "Perimeter First" + "perimeter_first_on_off": { + "name": "Perimeter First", + "description": "Perimeter first or lines/zigzag first mowing." } }, "select": { - "cutting_mode": { - "name": "Cutting Mode" + "channel_mode": { + "name": "Cutting Mode", + "description": "Select the cutting mode for the mower." }, - "border_patrol_mode": { - "name": "Border Patrol Mode" + "mowing_laps": { + "name": "Border Patrol Mode", + "description": "Select the border patrol mode for the mower." }, - "obstacle_laps_mode": { - "name": "Obstacle Laps Mode" + "obstacle_laps": { + "name": "Obstacle Laps Mode", + "description": "Select the obstacle laps mode for the mower." }, - "mow_order": { - "name": "Mow Order" + "mowing_laps": { + "name": "Mow Order", + "description": "Select the order in which the areas should be mowed." } }, "number": { - "start_progress": { - "name": "Start Progress" + "start_progress": { + "name": "Start Progress", + "description": "Set the start progress percentage." }, "blade_height": { - "name": "Blade Height" + "name": "Blade Height", + "description": "Adjust the height of the cutter in increments." }, "working_speed": { - "name": "Working Speed" + "name": "Working Speed", + "description": "Set the working speed of the mower." } + } + }, + "services": { + "cancel_job": { + "name": "Cancel current task", + "description": "Stops the mower and clears the current task." }, - "device_tracker": { - "name": "Device Tracking" + "start_mow": { + "name": "Start Mowing", + "description": "Start the mowing operation with custom settings.", + "fields": { + "is_mow": { + "name": "Is Mow", + "description": "Whether mowing is active." + }, + "is_dump": { + "name": "Is Dump", + "description": "Whether grass dumping is active." + }, + "is_edge": { + "name": "Is Edge", + "description": "Whether edge mode is active." + }, + "collect_grass_frequency": { + "name": "Grass Collection Frequency", + "description": "Frequency to collect grass (in minutes)." + }, + "border_mode": { + "name": "Mow Order", + "description": "Job mode for cutting." + }, + "job_version": { + "name": "Job Version", + "description": "Job version." + }, + "job_id": { + "name": "Job ID", + "description": "Job ID." + }, + "speed": { + "name": "Speed", + "description": "Mowing speed." + }, + "ultra_wave": { + "name": "Ultra Wave", + "description": "Bypass strategy for mowing." + }, + "channel_mode": { + "name": "Channel Mode", + "description": "Channel mode (grid, single, double, or single2)." + }, + "channel_width": { + "name": "Channel Width", + "description": "Width of the mowing channel (in cm)." + }, + "rain_tactics": { + "name": "Rain Tactics", + "description": "Rain handling tactics." + }, + "blade_height": { + "name": "Blade Height", + "description": "Height of the blade." + }, + "path_order": { + "name": "Path Order", + "description": "Mowing path order (border first or grid first)." + }, + "toward": { + "name": "Toward", + "description": "Direction angle for mowing." + }, + "toward_included_angle": { + "name": "Toward Included Angle", + "description": "Type of angle to use (relative, absolute, or random)." + }, + "toward_mode": { + "name": "Toward Mode", + "description": "Toward mode." + }, + "mowing_laps": { + "name": "Border Patrol Mode", + "description": "Border patrol mode (number of laps)." + }, + "obstacle_laps": { + "name": "Obstacle Laps", + "description": "Number of laps around obstacles." + }, + "start_progress": { + "name": "Start Progress", + "description": "Starting progress percentage." + }, + "areas": { + "name": "Areas", + "description": "List of areas to mow (represented as integers)." + } + } } }, "exceptions": { @@ -189,6 +252,9 @@ "dock_failed": { "message": "Failed to send the mower to the dock." }, + "dock_cancel_failed": { + "message": "Failed to stop the mower returning to the dock." + }, "command_failed": { "message": "Failed to send command to the mower." } diff --git a/homeassistant/components/mammotion/switch.py b/homeassistant/components/mammotion/switch.py index 7fc6c25ba6083..62648c40d171b 100644 --- a/homeassistant/components/mammotion/switch.py +++ b/homeassistant/components/mammotion/switch.py @@ -1,9 +1,14 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass +from typing import Any, cast + +from pymammotion.data.model.hash_list import AreaHashNameList +from pymammotion.utility.device_type import DeviceType from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.restore_state import RestoreEntity from . import MammotionConfigEntry from .coordinator import MammotionDataUpdateCoordinator @@ -18,43 +23,77 @@ class MammotionSwitchEntityDescription(SwitchEntityDescription): set_fn: Callable[[MammotionDataUpdateCoordinator, bool], Awaitable[None]] -YUKA_SWITCH_ENTITIES: tuple[MammotionSwitchEntityDescription, ...] = ( - MammotionSwitchEntityDescription( - key="mowing_on_off", - entity_category=EntityCategory.CONFIG, - set_fn=lambda coordinator, value: print(f"Mowing {'on' if value else 'off'}"), +@dataclass(frozen=True, kw_only=True) +class MammotionUpdateSwitchEntityDescription(SwitchEntityDescription): + """Describes Mammotion switch entity.""" + + key: str + set_fn: Callable[[MammotionDataUpdateCoordinator, bool], Awaitable[None]] + is_on_func: Callable[[MammotionDataUpdateCoordinator], bool] + + +@dataclass(frozen=True, kw_only=True) +class MammotionConfigSwitchEntityDescription(SwitchEntityDescription): + """Describes Mammotion Config switch entity.""" + + key: str + set_fn: Callable[[MammotionDataUpdateCoordinator, bool], None] + + +@dataclass(frozen=True, kw_only=True) +class MammotionConfigAreaSwitchEntityDescription(SwitchEntityDescription): + """Describes the Areas entities.""" + + key: str + area: int + set_fn: Callable[[MammotionDataUpdateCoordinator, bool, int], None] + + +YUKA_CONFIG_SWITCH_ENTITIES: tuple[MammotionConfigSwitchEntityDescription, ...] = ( + MammotionConfigSwitchEntityDescription( + key="is_mow", + set_fn=lambda coordinator, value: setattr( + coordinator.operation_settings, "is_mow", value + ), ), - MammotionSwitchEntityDescription( - key="dump_grass_on_off", - entity_category=EntityCategory.CONFIG, - set_fn=lambda coordinator, value: print( - f"Dump grass {'on' if value else 'off'}" + MammotionConfigSwitchEntityDescription( + key="is_dump", + set_fn=lambda coordinator, value: setattr( + coordinator.operation_settings, "is_dump", value + ), + ), + MammotionConfigSwitchEntityDescription( + key="is_edge", + set_fn=lambda coordinator, value: setattr( + coordinator.operation_settings, "is_edge", value ), ), ) SWITCH_ENTITIES: tuple[MammotionSwitchEntityDescription, ...] = ( MammotionSwitchEntityDescription( - key="blades_on_off", + key="blade_status", set_fn=lambda coordinator, value: coordinator.async_start_stop_blades(value), ), MammotionSwitchEntityDescription( - key="rain_detection_on_off", - entity_category=EntityCategory.CONFIG, - set_fn=lambda coordinator, value: print( - f"Rain detection {'on' if value else 'off'}" - ), + key="side_led", + set_fn=lambda coordinator, value: coordinator.async_set_sidelight(int(value)), ), - MammotionSwitchEntityDescription( - key="side_led_on_off", - entity_category=EntityCategory.CONFIG, - set_fn=lambda coordinator, value: print(f"Side LED {'on' if value else 'off'}"), +) + +UPDATE_SWITCH_ENTITIES: tuple[MammotionUpdateSwitchEntityDescription, ...] = ( + MammotionUpdateSwitchEntityDescription( + key="schedule_updates", + is_on_func=lambda coordinator: coordinator.enabled, + set_fn=lambda coordinator, value: coordinator.set_scheduled_updates(value), ), - MammotionSwitchEntityDescription( - key="perimeter_first_on_off", - entity_category=EntityCategory.CONFIG, - set_fn=lambda coordinator, value: print( - f"perimeter mow first {'on' if value else 'off'}" +) + +CONFIG_SWITCH_ENTITIES: tuple[MammotionConfigSwitchEntityDescription, ...] = ( + MammotionConfigSwitchEntityDescription( + key="rain_tactics", + set_fn=lambda coordinator, value: setattr( + coordinator.operation_settings, "rain_tactics", cast(value, int) ), ), ) @@ -66,11 +105,69 @@ async def async_setup_entry( ) -> None: """Set up the Mammotion switch entities.""" coordinator = entry.runtime_data + added_areas: set[str] = set() + + @callback + def add_entities() -> None: + """Handle addition of mowing areas.""" + + switch_entities: list[MammotionConfigAreaSwitchEntity] = [] + areas = list(map(str, coordinator.data.map.area.keys())) + area_name_hashes = [f"{area.hash}" for area in coordinator.data.map.area_name] + area_name = coordinator.data.map.area_name + new_areas = (set(areas) | set(area_name_hashes)) - added_areas + if new_areas: + for area_id in new_areas: + existing_name: AreaHashNameList = next( + (area for area in area_name if str(area.hash) == str(area_id)), None + ) + name = ( + existing_name.name + if (existing_name is None or existing_name != "") + else f"Area {area_id}" + ) + base_area_switch_entity = MammotionConfigAreaSwitchEntityDescription( + key=f"{area_id}", + area=area_id, + name=f"{name}", + set_fn=lambda coord, + bool_val, + value: coord.operation_settings.areas.append(value) + if bool_val + else coord.operation_settings.areas.remove(value), + ) + switch_entities.append( + MammotionConfigAreaSwitchEntity( + coordinator, + base_area_switch_entity, + ) + ) + added_areas.add(area_id) + + if switch_entities: + async_add_entities(switch_entities) - async_add_entities( - MammotionSwitchEntity(coordinator, entity_description) - for entity_description in SWITCH_ENTITIES - ) + add_entities() + coordinator.async_add_listener(add_entities) + + entities = [] + for entity_description in SWITCH_ENTITIES: + entity = MammotionSwitchEntity(coordinator, entity_description) + entities.append(entity) + + for entity_description in CONFIG_SWITCH_ENTITIES: + config_entity = MammotionConfigSwitchEntity(coordinator, entity_description) + entities.append(config_entity) + + for entity_description in UPDATE_SWITCH_ENTITIES: + config_entity = MammotionUpdateSwitchEntity(coordinator, entity_description) + entities.append(config_entity) + + if DeviceType.is_yuka(coordinator.device_name): + for entity_description in YUKA_CONFIG_SWITCH_ENTITIES: + config_entity = MammotionConfigSwitchEntity(coordinator, entity_description) + entities.append(config_entity) + async_add_entities(entities) class MammotionSwitchEntity(MammotionBaseEntity, SwitchEntity): @@ -88,15 +185,124 @@ def __init__( self._attr_translation_key = entity_description.key self._attr_is_on = False # Default state - async def async_turn_on(self, **kwargs) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: + self._attr_is_on = True + await self.entity_description.set_fn(self.coordinator, True) + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + self._attr_is_on = False + await self.entity_description.set_fn(self.coordinator, False) + self.async_write_ha_state() + + async def async_update(self) -> None: + """Update the entity state.""" + + +class MammotionUpdateSwitchEntity(MammotionBaseEntity, SwitchEntity, RestoreEntity): + entity_description: MammotionUpdateSwitchEntityDescription + _attr_has_entity_name = True + + def __init__( + self, + coordinator: MammotionDataUpdateCoordinator, + entity_description: MammotionUpdateSwitchEntityDescription, + ) -> None: + super().__init__(coordinator, entity_description.key) + self.coordinator = coordinator + self.entity_description = entity_description + self._attr_translation_key = entity_description.key + self._attr_is_on = True # Default state + + @property + def is_on(self) -> bool: + return self.entity_description.is_on_func(self.coordinator) + + async def async_turn_on(self, **kwargs: Any) -> None: self._attr_is_on = True await self.entity_description.set_fn(self.coordinator, True) self.async_write_ha_state() - async def async_turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: self._attr_is_on = False await self.entity_description.set_fn(self.coordinator, False) self.async_write_ha_state() + +class MammotionConfigSwitchEntity(MammotionBaseEntity, SwitchEntity, RestoreEntity): + entity_description: MammotionConfigSwitchEntityDescription + _attr_has_entity_name = True + _attr_entity_category = EntityCategory.CONFIG + + def __init__( + self, + coordinator: MammotionDataUpdateCoordinator, + entity_description: MammotionConfigSwitchEntityDescription, + ) -> None: + super().__init__(coordinator, entity_description.key) + self.coordinator = coordinator + self.entity_description = entity_description + self._attr_translation_key = entity_description.key + + @property + def is_on(self) -> bool: + """Return if settings is on or off.""" + return getattr( + self.coordinator.operation_settings, self.entity_description.key, False + ) + + async def async_turn_on(self, **kwargs: Any) -> None: + self._attr_is_on = True + self.entity_description.set_fn(self.coordinator, True) + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + self._attr_is_on = False + self.entity_description.set_fn(self.coordinator, False) + self.async_write_ha_state() + + async def async_update(self) -> None: + """Update the entity state.""" + + +class MammotionConfigAreaSwitchEntity(MammotionBaseEntity, SwitchEntity, RestoreEntity): + entity_description: MammotionConfigAreaSwitchEntityDescription + _attr_has_entity_name = True + _attr_entity_category = EntityCategory.CONFIG + + def __init__( + self, + coordinator: MammotionDataUpdateCoordinator, + entity_description: MammotionConfigAreaSwitchEntityDescription, + ) -> None: + super().__init__(coordinator, entity_description.key) + self.coordinator = coordinator + self.entity_description = entity_description + self._attr_translation_key = entity_description.key + # TODO this should not need to be cast. + self._attr_extra_state_attributes = {"hash": entity_description.area} + # TODO grab defaults from operation_settings + self._attr_is_on = False # Default state + + async def async_turn_on(self, **kwargs: Any) -> None: + self._attr_is_on = True + self.entity_description.set_fn( + # TODO this should not need to be cast. + self.coordinator, + True, + int(self.entity_description.area), + ) + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + self._attr_is_on = False + self.entity_description.set_fn( + # TODO this should not need to be cast. + self.coordinator, + False, + int(self.entity_description.area), + ) + self.async_write_ha_state() + async def async_update(self) -> None: """Update the entity state.""" diff --git a/requirements_all.txt b/requirements_all.txt index 4208b70b442cc..10dc03d64662f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2334,7 +2334,7 @@ pylutron==0.4.1 pymailgunner==1.4 # homeassistant.components.mammotion -pymammotion==0.2.21 +pymammotion==0.2.77 # homeassistant.components.firmata pymata-express==1.19 From a5ec6a7a2420bc794e45269be3e06eae71c9b649 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Thu, 31 Oct 2024 11:42:50 +1300 Subject: [PATCH 13/66] update mammotion code --- .../components/mammotion/binary_sensor.py | 3 +- homeassistant/components/mammotion/button.py | 3 +- .../components/mammotion/config_flow.py | 9 +- homeassistant/components/mammotion/const.py | 1 + .../components/mammotion/coordinator.py | 109 ++++---- homeassistant/components/mammotion/entity.py | 5 +- .../components/mammotion/lawn_mower.py | 33 ++- .../components/mammotion/manifest.json | 2 +- homeassistant/components/mammotion/number.py | 7 +- homeassistant/components/mammotion/select.py | 13 +- homeassistant/components/mammotion/sensor.py | 42 ++- .../components/mammotion/strings.json | 247 +++++++++++++----- homeassistant/components/mammotion/switch.py | 8 +- 13 files changed, 320 insertions(+), 162 deletions(-) diff --git a/homeassistant/components/mammotion/binary_sensor.py b/homeassistant/components/mammotion/binary_sensor.py index 261cb07e62766..d878945847942 100644 --- a/homeassistant/components/mammotion/binary_sensor.py +++ b/homeassistant/components/mammotion/binary_sensor.py @@ -3,8 +3,6 @@ from collections.abc import Callable from dataclasses import dataclass -from pymammotion.proto.luba_msg import LubaMsg - from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, @@ -12,6 +10,7 @@ ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from pymammotion.proto.luba_msg import LubaMsg from . import MammotionConfigEntry from .coordinator import MammotionDataUpdateCoordinator diff --git a/homeassistant/components/mammotion/button.py b/homeassistant/components/mammotion/button.py index a7796d8ee943f..337c6a7b9cf5d 100644 --- a/homeassistant/components/mammotion/button.py +++ b/homeassistant/components/mammotion/button.py @@ -1,7 +1,8 @@ """Mammotion button sensor entities.""" -from collections.abc import Awaitable, Callable +from collections.abc import Callable from dataclasses import dataclass +from typing import Awaitable from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/mammotion/config_flow.py b/homeassistant/components/mammotion/config_flow.py index 7b8dd30cf9969..654b2d3b8d516 100644 --- a/homeassistant/components/mammotion/config_flow.py +++ b/homeassistant/components/mammotion/config_flow.py @@ -2,13 +2,9 @@ from typing import TYPE_CHECKING, Any +import voluptuous as vol from aiohttp.web_exceptions import HTTPException from bleak.backends.device import BLEDevice -from pymammotion.aliyun.cloud_gateway import CloudIOTGateway -from pymammotion.http.http import connect_http -from pymammotion.mammotion.devices.mammotion import Mammotion -import voluptuous as vol - from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( BluetoothServiceInfo, @@ -30,6 +26,9 @@ SelectSelectorConfig, SelectSelectorMode, ) +from pymammotion.aliyun.cloud_gateway import CloudIOTGateway +from pymammotion.http.http import connect_http +from pymammotion.mammotion.devices.mammotion import Mammotion from .const import ( CONF_ACCOUNTNAME, diff --git a/homeassistant/components/mammotion/const.py b/homeassistant/components/mammotion/const.py index c351edf461ead..58df114fe6211 100644 --- a/homeassistant/components/mammotion/const.py +++ b/homeassistant/components/mammotion/const.py @@ -37,3 +37,4 @@ CONF_SESSION_DATA: Final = "session_data" CONF_REGION_DATA: Final = "region_data" CONF_DEVICE_DATA: Final = "device_data" +CONF_MAMMOTION_DATA: Final = "mammotion_data" diff --git a/homeassistant/components/mammotion/coordinator.py b/homeassistant/components/mammotion/coordinator.py index 3cb691838974f..5c40663a56589 100644 --- a/homeassistant/components/mammotion/coordinator.py +++ b/homeassistant/components/mammotion/coordinator.py @@ -7,11 +7,20 @@ from datetime import timedelta from typing import TYPE_CHECKING, Any, cast -from aiohttp import ClientConnectorError import betterproto +from aiohttp import ClientConnectorError +from homeassistant.components import bluetooth +from homeassistant.const import CONF_ADDRESS, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.storage import Store +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from mashumaro.exceptions import InvalidFieldValue from pymammotion import CloudIOTGateway -from pymammotion.aliyun.cloud_gateway import DeviceOfflineException +from pymammotion.aliyun.cloud_gateway import ( + DeviceOfflineException, +) from pymammotion.aliyun.model.aep_response import AepResponse from pymammotion.aliyun.model.connect_response import ConnectResponse from pymammotion.aliyun.model.dev_by_account_response import ListingDevByAccountResponse @@ -24,20 +33,17 @@ from pymammotion.data.model.account import Credentials from pymammotion.data.model.device import MowingDevice from pymammotion.data.model.device_config import OperationSettings, create_path_order -from pymammotion.mammotion.devices.mammotion import ConnectionPreference, Mammotion +from pymammotion.http.http import MammotionHTTP +from pymammotion.http.model.http import LoginResponseData, Response +from pymammotion.mammotion.devices.mammotion import ( + ConnectionPreference, + Mammotion, +) from pymammotion.proto import has_field from pymammotion.proto.luba_msg import LubaMsg from pymammotion.proto.mctrl_sys import RptAct, RptDevStatus, RptInfoType from pymammotion.utility.device_type import DeviceType -from homeassistant.components import bluetooth -from homeassistant.const import CONF_ADDRESS, CONF_PASSWORD -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError -from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.storage import Store -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator - from .const import ( COMMAND_EXCEPTIONS, CONF_ACCOUNTNAME, @@ -46,6 +52,7 @@ CONF_CONNECT_DATA, CONF_DEVICE_DATA, CONF_DEVICE_NAME, + CONF_MAMMOTION_DATA, CONF_REGION_DATA, CONF_SESSION_DATA, CONF_STAY_CONNECTED_BLUETOOTH, @@ -63,6 +70,7 @@ class MammotionBaseUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): """Mammotion DataUpdateCoordinator.""" manager: Mammotion = None + device_name: str | None = None def __init__( self, @@ -77,7 +85,6 @@ def __init__( name=DOMAIN, update_interval=update_interval, ) - self.device_name = None assert config_entry.unique_id self.config_entry = config_entry self._operation_settings = OperationSettings() @@ -114,24 +121,30 @@ async def async_login(self) -> None: await self.manager.login_and_initiate_cloud(account, password, True) self.store_cloud_credentials() - async def async_send_command(self, command: str, **kwargs: Any) -> None: + async def async_send_command(self, command: str, **kwargs: Any) -> bool: """Send command.""" try: await self.manager.send_command_with_args( self.device_name, command, **kwargs ) + return True except EXPIRED_CREDENTIAL_EXCEPTIONS: self.update_failures += 1 await self.async_login() + return False except DeviceOfflineException: """Device is offline try bluetooth if we have it.""" try: - if self.manager.get_device_by_name(self.device_name).has_ble(): - await ( - self.manager.get_device_by_name(self.device_name) - .ble() - .queue_command(command, **kwargs) - ) + if device := self.manager.get_device_by_name(self.device_name): + if device.has_ble(): + # if we don't do this it will stay connected and no longer update over wifi + device.ble().set_disconnect_strategy(True) + await ( + self.manager.get_device_by_name(self.device_name) + .ble() + .queue_command(command, **kwargs) + ) + return True except COMMAND_EXCEPTIONS as exc: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="command_failed" @@ -154,6 +167,7 @@ def store_cloud_credentials(self) -> None: CONF_AEP_DATA: cloud_client.aep_response, CONF_SESSION_DATA: cloud_client.session_by_authcode_response, CONF_DEVICE_DATA: cloud_client.devices_by_account_response, + CONF_MAMMOTION_DATA: cloud_client.mammotion_http.response, } self.hass.config_entries.async_update_entry( self.config_entry, data=config_updates @@ -173,8 +187,9 @@ async def check_and_restore_cloud(self) -> CloudIOTGateway | None: session_data = self.config_entry.data.get(CONF_SESSION_DATA) device_data = self.config_entry.data.get(CONF_DEVICE_DATA) connect_data = self.config_entry.data.get(CONF_CONNECT_DATA) + mammotion_data = self.config_entry.data.get(CONF_MAMMOTION_DATA) - if all( + if any( data is None for data in [ auth_data, @@ -183,6 +198,7 @@ async def check_and_restore_cloud(self) -> CloudIOTGateway | None: session_data, device_data, connect_data, + mammotion_data, ] ): return None @@ -210,6 +226,11 @@ async def check_and_restore_cloud(self) -> CloudIOTGateway | None: else auth_data, ) + if isinstance(mammotion_data, dict): + mammotion_data = Response[LoginResponseData].from_dict(mammotion_data) + + cloud_client.set_http(MammotionHTTP(response=mammotion_data)) + await self.hass.async_add_executor_job(cloud_client.check_or_refresh_session) return cloud_client @@ -270,12 +291,12 @@ async def async_setup(self) -> None: if self.device_name is not None: device = self.manager.get_device_by_name(self.device_name) + elif device_name := next(iter(self.manager.devices.devices.keys())): + self.device_name = device_name + device = self.manager.get_device_by_name(device_name) else: - device_names = self.manager.devices.devices.keys() - if len(device_names) == 0: - raise ConfigEntryNotReady("no_devices") - self.device_name = device_names[0] - device = self.manager.get_device_by_name(device_names[0]) + raise ConfigEntryNotReady("no_devices") + device.preference = preference if ble_device and device: @@ -286,6 +307,8 @@ async def async_setup(self) -> None: try: if preference is ConnectionPreference.WIFI and device.has_cloud(): self.store_cloud_credentials() + if mqtt_client := self.manager.mqtt_list.get(account): + device.mower_state.error_codes = await mqtt_client.cloud_client.mammotion_http.get_all_error_codes() device.cloud().set_notification_callback( self._async_update_notification ) @@ -379,37 +402,41 @@ async def async_read_sidelight(self) -> None: async def async_blade_height(self, height: int) -> int: """Set blade height.""" - await self.async_send_command("set_blade_height", height=float(height)) + await self.send_command_and_update("set_blade_height", height=float(height)) return height async def async_leave_dock(self) -> None: """Leave dock.""" - await self.async_send_command("leave_dock") + await self.send_command_and_update("leave_dock") async def async_cancel_task(self) -> None: """Cancel task.""" - await self.async_send_command("cancel_job") + await self.send_command_and_update("cancel_job") async def async_move_forward(self, speed: float) -> None: """Move forward.""" - await self.async_send_command("move_forward", linear=speed) + await self.send_command_and_update("move_forward", linear=speed) async def async_move_left(self, speed: float) -> None: """Move left.""" - await self.async_send_command("move_left", angular=speed) + await self.send_command_and_update("move_left", angular=speed) async def async_move_right(self, speed: float) -> None: """Move right.""" - await self.async_send_command("move_right", angular=speed) + await self.send_command_and_update("move_right", angular=speed) async def async_move_back(self, speed: float) -> None: """Move back.""" - await self.async_send_command("move_back", linear=speed) + await self.send_command_and_update("move_back", linear=speed) async def async_rtk_dock_location(self) -> None: """RTK and dock location.""" await self.async_send_command("allpowerfull_rw", id=5, rw=1, context=1) + async def send_command_and_update(self, command_str: str, **kwargs: Any) -> None: + await self.async_send_command(command_str, **kwargs) + await self.async_request_iot_sync() + async def async_request_iot_sync(self, stop: bool = False) -> None: """Sync specific info from device.""" await self.async_send_command( @@ -426,7 +453,7 @@ async def async_request_iot_sync(self, stop: bool = False) -> None: count=0, ) - async def async_plan_route(self, operation_settings: OperationSettings) -> None: + async def async_plan_route(self, operation_settings: OperationSettings) -> bool: """Plan mow.""" if has_field(self.data.sys.toapp_report_data.dev): @@ -461,7 +488,7 @@ async def async_plan_route(self, operation_settings: OperationSettings) -> None: route_information.toward_mode = 0 route_information.toward_included_angle = 0 - await self.async_send_command( + return await self.async_send_command( "generate_route_information", generate_route_information=route_information ) @@ -471,7 +498,7 @@ async def clear_all_maps(self) -> None: data.map = HashList() async def check_firmware_version(self) -> None: - """Check if firmware version is udpated.""" + """Check if firmware version is updated.""" mower = self.manager.mower(self.device_name) device_registry = dr.async_get(self.hass) device_entry = device_registry.async_get_device( @@ -522,19 +549,13 @@ async def _async_update_data(self) -> MowingDevice: ): device.ble().update_device(ble_device) - if ( - len(device.mower_state.net.toapp_devinfo_resp.resp_ids) == 0 - or device.mower_state.net.toapp_wifi_iot_status.productkey is None - ): + if len(device.mower_state.net.toapp_devinfo_resp.resp_ids) == 0: await self.manager.start_sync(self.device_name, 0) - if not device.mower_state.sys.todev_time_ctrl_light: + if not has_field(device.mower_state.sys.todev_time_ctrl_light): await self.async_read_sidelight() - if ( - not has_field(device.mower_state.sys.device_product_type_info) - or device.mower_state.mqtt_properties is None - ): + if not has_field(device.mower_state.sys.device_product_type_info): await self.async_send_command("get_device_product_model") if ( diff --git a/homeassistant/components/mammotion/entity.py b/homeassistant/components/mammotion/entity.py index 53219def80e30..c1b89d26e9ec0 100644 --- a/homeassistant/components/mammotion/entity.py +++ b/homeassistant/components/mammotion/entity.py @@ -1,10 +1,9 @@ """Base class for entities.""" -from pymammotion.proto import has_field -from pymammotion.utility.device_type import DeviceType - from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity +from pymammotion.proto import has_field +from pymammotion.utility.device_type import DeviceType from .const import CONF_ACCOUNTNAME, CONF_RETRY_COUNT, DEFAULT_RETRY_COUNT, DOMAIN from .coordinator import MammotionDataUpdateCoordinator diff --git a/homeassistant/components/mammotion/lawn_mower.py b/homeassistant/components/mammotion/lawn_mower.py index ded88293fe5df..de1114bae13e1 100644 --- a/homeassistant/components/mammotion/lawn_mower.py +++ b/homeassistant/components/mammotion/lawn_mower.py @@ -4,14 +4,7 @@ from typing import Any -from pymammotion.data.model.device_config import OperationSettings -from pymammotion.data.model.report_info import ReportData -from pymammotion.proto import has_field -from pymammotion.proto.luba_msg import RptDevStatus -from pymammotion.utility.constant.device_constant import WorkMode -from pymammotion.utility.device_type import DeviceType import voluptuous as vol - from homeassistant.components.lawn_mower import ( LawnMowerActivity, LawnMowerEntity, @@ -19,8 +12,15 @@ ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv, entity_platform +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback +from pymammotion.data.model.device_config import OperationSettings +from pymammotion.data.model.report_info import ReportData +from pymammotion.proto import has_field +from pymammotion.proto.luba_msg import RptDevStatus +from pymammotion.utility.constant.device_constant import WorkMode +from pymammotion.utility.device_type import DeviceType from . import MammotionConfigEntry from .const import COMMAND_EXCEPTIONS, DOMAIN, LOGGER @@ -80,8 +80,9 @@ def get_entity_attribute( if entity and attribute_name in entity.attributes: # Return the specific attribute return entity.attributes.get(attribute_name, None) - # Return None if the entity or attribute does not exist - return None + else: + # Return None if the entity or attribute does not exist + return None async def async_setup_entry( @@ -197,11 +198,17 @@ async def async_start_mowing(self, **kwargs: Any) -> None: mode = self.rpt_dev_status.sys_status if mode == WorkMode.MODE_PAUSE: trans_key = "resume_failed" - await self.coordinator.async_send_command("resume_execute_task") + charge_state = self.rpt_dev_status.charge_state + if charge_state != 0: + await self.coordinator.async_send_command( + "break_point_anywhere_continue" + ) + else: + await self.coordinator.async_send_command("resume_execute_task") if mode == WorkMode.MODE_READY: trans_key = "start_failed" - await self.coordinator.async_plan_route(operational_settings) - await self.coordinator.async_send_command("start_job") + if await self.coordinator.async_plan_route(operational_settings): + await self.coordinator.async_send_command("start_job") except COMMAND_EXCEPTIONS as exc: raise HomeAssistantError( diff --git a/homeassistant/components/mammotion/manifest.json b/homeassistant/components/mammotion/manifest.json index 307ac95dabbdc..8a095ba29e5d0 100644 --- a/homeassistant/components/mammotion/manifest.json +++ b/homeassistant/components/mammotion/manifest.json @@ -20,5 +20,5 @@ "loggers": ["pymammotion"], "integration_type": "device", "iot_class": "local_push", - "requirements": ["pymammotion==0.2.77"] + "requirements": ["pymammotion==0.2.97"] } diff --git a/homeassistant/components/mammotion/number.py b/homeassistant/components/mammotion/number.py index 9aa1f94f51163..9ebae77c1a547 100644 --- a/homeassistant/components/mammotion/number.py +++ b/homeassistant/components/mammotion/number.py @@ -1,8 +1,5 @@ -from collections.abc import Callable from dataclasses import dataclass - -from pymammotion.data.model.device_config import DeviceLimits -from pymammotion.utility.device_type import DeviceType +from typing import Callable from homeassistant.components.number import ( NumberDeviceClass, @@ -21,6 +18,8 @@ from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity +from pymammotion.data.model.device_config import DeviceLimits +from pymammotion.utility.device_type import DeviceType from . import MammotionConfigEntry from .coordinator import MammotionDataUpdateCoordinator diff --git a/homeassistant/components/mammotion/select.py b/homeassistant/components/mammotion/select.py index d58159349e9dc..92c0b4ded71b4 100644 --- a/homeassistant/components/mammotion/select.py +++ b/homeassistant/components/mammotion/select.py @@ -1,6 +1,11 @@ -from collections.abc import Callable from dataclasses import dataclass +from typing import Callable +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity from pymammotion.data.model.mowing_modes import ( BorderPatrolMode, BypassStrategy, @@ -11,12 +16,6 @@ ) from pymammotion.utility.device_type import DeviceType -from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.restore_state import RestoreEntity - from . import MammotionConfigEntry from .coordinator import MammotionDataUpdateCoordinator from .entity import MammotionBaseEntity diff --git a/homeassistant/components/mammotion/sensor.py b/homeassistant/components/mammotion/sensor.py index 4458644d646f6..67940593cfb18 100644 --- a/homeassistant/components/mammotion/sensor.py +++ b/homeassistant/components/mammotion/sensor.py @@ -3,11 +3,6 @@ from collections.abc import Callable from dataclasses import dataclass -from pymammotion.data.model.device import MowingDevice -from pymammotion.data.model.enums import RTKStatus -from pymammotion.utility.constant.device_constant import PosType, device_mode -from pymammotion.utility.device_type import DeviceType - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -26,6 +21,14 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.unit_conversion import SpeedConverter +from pymammotion.data.model.device import MowingDevice +from pymammotion.data.model.enums import RTKStatus +from pymammotion.utility.constant.device_constant import ( + PosType, + device_connection, + device_mode, +) +from pymammotion.utility.device_type import DeviceType from . import MammotionConfigEntry from .coordinator import MammotionDataUpdateCoordinator @@ -73,6 +76,35 @@ class MammotionSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, value_fn=lambda mower_data: mower_data.report_data.connect.wifi_rssi, ), + MammotionSensorEntityDescription( + key="connect_type", + device_class=SensorDeviceClass.ENUM, + native_unit_of_measurement=None, + value_fn=lambda mower_data: device_connection( + mower_data.report_data.connect.connect_type, + mower_data.report_data.connect.used_net, + ), + ), + MammotionSensorEntityDescription( + key="maintenance_distance", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DISTANCE, + native_unit_of_measurement=UnitOfLength.METERS, + value_fn=lambda mower_data: mower_data.report_data.maintenance.mileage, + ), + MammotionSensorEntityDescription( + key="maintenance_work_time", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + value_fn=lambda mower_data: mower_data.report_data.maintenance.work_time, + ), + MammotionSensorEntityDescription( + key="maintenance_bat_cycles", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=None, + value_fn=lambda mower_data: mower_data.report_data.maintenance.bat_cycles, + ), MammotionSensorEntityDescription( key="gps_stars", state_class=SensorStateClass.MEASUREMENT, diff --git a/homeassistant/components/mammotion/strings.json b/homeassistant/components/mammotion/strings.json index 8c9b1acfcc23d..aa6a97b1c4471 100644 --- a/homeassistant/components/mammotion/strings.json +++ b/homeassistant/components/mammotion/strings.json @@ -2,23 +2,55 @@ "config": { "abort": { "already_configured": "Device is already configured", - "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "already_in_progress": "Configuration flow is already in progress", "no_devices_found": "Could not find devices", + "no_devices_found_in_account": "No devices present in your account", + "bluetooth_and_account_mismatch": "Bluetooth device not found in your account", "no_longer_present": "Device is no longer present", "not_supported": "Device not supported", - "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" - } - }, - "flow_title": "Configure your Mammotion lawn mower", - "step": { - "bluetooth_confirm": { - "description": "Setup {name}" + "reconfigure_successful": "Re-configure successful" }, - "user": { - "data": { - "address": "Device" + "flow_title": "Configure your Mammotion lawn mower", + "step": { + "bluetooth_confirm": { + "description": "Setup {name}", + "data": { + "stay_connected_bluetooth": "Keep bluetooth connected" + } + }, + "reconfigure": { + "data": { + "use_wifi": "Use Wi-fi", + "account_name": "Mammotion email or account number", + "password": "Mammotion account password" + } }, - "description": "Select your mower" + "user": { + "data": { + "address": "Device", + "stay_connected_bluetooth": "Keep bluetooth connected" + }, + "description": "Select your mower" + }, + "wifi": { + "data": { + "use_wifi": "Use Wi-fi (un-tick and submit to use bluetooth)", + "account_name": "Mammotion email or account number", + "password": "Mammotion account password" + }, + "title": "Connect to Wi-Fi", + "description": "Enter your Mammotion account email or id and password" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "title": "Update Configuration", + "stay_connected_bluetooth": "Keep bluetooth connected" + } + } } }, "entity": { @@ -32,6 +64,9 @@ "wifi_rssi": { "name": "WiFi RSSI" }, + "connect_type": { + "name": "Connection" + }, "gps_stars": { "name": "Satellites (Robot)" }, @@ -70,6 +105,9 @@ }, "activity_mode": { "name": "Activity mode" + }, + "work_area": { + "name": "Work area hash" } }, "button": { @@ -77,67 +115,136 @@ "name": "Sync maps" }, "resync_rtk_dock": { - "name": "Sync RTK and dock", - "description": "Syncs RTK and dock location for when you move them." + "name": "Sync RTK and dock" + }, + "release_from_dock": { + "name": "Undock" + }, + "emergency_nudge_forward": { + "name": "Emergency nudge forward" + }, + "emergency_nudge_left": { + "name": "Emergency nudge left" + }, + "emergency_nudge_right": { + "name": "Emergency nudge right" + }, + "emergency_nudge_back": { + "name": "Emergency nudge back" + }, + "cancel_task": { + "name": "Cancel current task" + }, + "clear_all_mapdata": { + "name": "Clear maps and area names" } }, "switch": { "blade_status": { - "name": "Blades On/Off", - "description": "Turn the blades on or off." + "name": "Blades on/off" }, "is_mow": { - "name": "Mowing On/Off", - "description": "Start or stop mowing." + "name": "Mowing on/off" }, "is_dump": { - "name": "Dump Grass On/Off", - "description": "Enable or disable grass dumping." + "name": "Dump grass on/off" + }, + "is_edge": { + "name": "Edge cutting" }, "rain_tactics": { - "name": "Rain Detection On/Off", - "description": "Turn rain detection on or off." + "name": "Rain detection On/Off" }, "side_led": { - "name": "Side LED On/Off", - "description": "Enable or disable the side LED." + "name": "Side LED on/off" }, "perimeter_first_on_off": { - "name": "Perimeter First", - "description": "Perimeter first or lines/zigzag first mowing." + "name": "Perimeter first" + }, + "schedule_updates": { + "name": "Turn Updates On/Off" } }, "select": { "channel_mode": { - "name": "Cutting Mode", - "description": "Select the cutting mode for the mower." + "name": "Cutting Path Mode", + "state": { + "single_grid": "Zigzag Path", + "double_grid": "Chessboard Path", + "segment_grid": "Adaptive Zigzag Path", + "no_grid": "Perimeter Only" + } }, "mowing_laps": { - "name": "Border Patrol Mode", - "description": "Select the border patrol mode for the mower." + "name": "Perimeter Mowing Laps", + "state": { + "none": "None", + "one": "One", + "two": "Two", + "three": "Three", + "four": "Four" + } }, "obstacle_laps": { - "name": "Obstacle Laps Mode", - "description": "Select the obstacle laps mode for the mower." + "name": "No-go Zone Mowing Laps", + "state": { + "none": "None", + "one": "One", + "two": "Two", + "three": "Three", + "four": "Four" + } }, - "mowing_laps": { + "border_mode": { "name": "Mow Order", - "description": "Select the order in which the areas should be mowed." + "state": { + "border_first": "Perimeter first", + "grid_first": "ZigZag / Chessboard first" + } + }, + "bypass_mode": { + "name": "Obstacle avoidance mode", + "state": { + "direct_touch": "Direct touch", + "slow_touch": "Slow touch", + "less_touch": "Less touch", + "no_touch": "No Touch" + } + }, + "cutting_angle_mode": { + "name": "Cutting path angle mode", + "state": { + "relative_angle": "Relative angle", + "absolute_angle": "Absolute angle", + "random_angle": "Random angle" + } } }, "number": { "start_progress": { - "name": "Start Progress", - "description": "Set the start progress percentage." + "name": "Start Progress" }, "blade_height": { - "name": "Blade Height", - "description": "Adjust the height of the cutter in increments." + "name": "Blade Height" }, "working_speed": { - "name": "Working Speed", - "description": "Set the working speed of the mower." + "name": "Working Speed" + }, + "cutting_angle": { + "name": "Cutting Path Angle" + }, + "path_spacing": { + "name": "Path Spacing" + }, + "dumping_interval": { + "name": "Dumping Frequency" + }, + "toward_included_angle": { + "name": "Crossing Angle" } + }, + "device_tracker": { + "name": "Device Tracking" } }, "services": { @@ -150,24 +257,24 @@ "description": "Start the mowing operation with custom settings.", "fields": { "is_mow": { - "name": "Is Mow", - "description": "Whether mowing is active." + "name": "Is Mowing", + "description": "Whether mowing is active. (Yuka)" }, "is_dump": { - "name": "Is Dump", - "description": "Whether grass dumping is active." + "name": "Is Dumping", + "description": "Whether grass dumping is active. (Yuka)" }, "is_edge": { - "name": "Is Edge", - "description": "Whether edge mode is active." + "name": "Edge mowing", + "description": "Whether edge mode is active. (Yuka)" }, "collect_grass_frequency": { "name": "Grass Collection Frequency", - "description": "Frequency to collect grass (in minutes)." + "description": "Frequency to collect grass (in meters squared). (Yuka)" }, "border_mode": { "name": "Mow Order", - "description": "Job mode for cutting." + "description": "Mowing path order (Perimeter first or grid first)." }, "job_version": { "name": "Job Version", @@ -182,55 +289,51 @@ "description": "Mowing speed." }, "ultra_wave": { - "name": "Ultra Wave", - "description": "Bypass strategy for mowing." + "name": "Obstacle Detection", + "description": "Obstacle Avoidance Mode." }, "channel_mode": { - "name": "Channel Mode", - "description": "Channel mode (grid, single, double, or single2)." + "name": "Cutting Path Mode", + "description": "Cutting Path (zigzag, chessboard, adaptive zigzag, or perimeter only)." }, "channel_width": { - "name": "Channel Width", - "description": "Width of the mowing channel (in cm)." + "name": "Path Width", + "description": "Width of the mowing path (in cm)." }, "rain_tactics": { - "name": "Rain Tactics", - "description": "Rain handling tactics." + "name": "Rain Detection", + "description": "Rain detection." }, "blade_height": { "name": "Blade Height", "description": "Height of the blade." }, - "path_order": { - "name": "Path Order", - "description": "Mowing path order (border first or grid first)." - }, "toward": { - "name": "Toward", - "description": "Direction angle for mowing." + "name": "Starting Path Angle", + "description": "Starting direction for mowing." }, "toward_included_angle": { - "name": "Toward Included Angle", - "description": "Type of angle to use (relative, absolute, or random)." + "name": "Crossing Angle", + "description": "When selecting grid change the second angle (default is 90 degrees)." }, "toward_mode": { - "name": "Toward Mode", - "description": "Toward mode." + "name": "Cutting Angle Mode", + "description": "Anglular direction of mow." }, "mowing_laps": { - "name": "Border Patrol Mode", - "description": "Border patrol mode (number of laps)." + "name": "Perimeter Mowing Laps", + "description": "Number of laps around the mowing area perimeter." }, "obstacle_laps": { - "name": "Obstacle Laps", - "description": "Number of laps around obstacles." + "name": "No-go zone Mowing Laps", + "description": "Number of laps around No-go zones." }, "start_progress": { "name": "Start Progress", "description": "Starting progress percentage." }, "areas": { - "name": "Areas", + "name": "Area Selection", "description": "List of areas to mow (represented as integers)." } } @@ -259,4 +362,4 @@ "message": "Failed to send command to the mower." } } -} +} \ No newline at end of file diff --git a/homeassistant/components/mammotion/switch.py b/homeassistant/components/mammotion/switch.py index 62648c40d171b..e7eed3a402814 100644 --- a/homeassistant/components/mammotion/switch.py +++ b/homeassistant/components/mammotion/switch.py @@ -1,14 +1,12 @@ -from collections.abc import Awaitable, Callable from dataclasses import dataclass -from typing import Any, cast - -from pymammotion.data.model.hash_list import AreaHashNameList -from pymammotion.utility.device_type import DeviceType +from typing import Any, Awaitable, Callable, cast from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.restore_state import RestoreEntity +from pymammotion.data.model.hash_list import AreaHashNameList +from pymammotion.utility.device_type import DeviceType from . import MammotionConfigEntry from .coordinator import MammotionDataUpdateCoordinator From 6d67bfb2ac2c9e3cde8207386fbc3acd0743205f Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Mon, 3 Feb 2025 08:38:57 +1300 Subject: [PATCH 14/66] further work to fix up various issues --- .../components/mammotion/__init__.py | 184 +++++++++- .../components/mammotion/binary_sensor.py | 5 +- .../components/mammotion/config_flow.py | 190 +++++----- homeassistant/components/mammotion/const.py | 2 + .../components/mammotion/coordinator.py | 330 +++++++++++++----- .../components/mammotion/device_tracker.py | 7 +- homeassistant/components/mammotion/icons.json | 138 ++++++++ .../components/mammotion/lawn_mower.py | 24 +- .../components/mammotion/manifest.json | 2 +- homeassistant/components/mammotion/models.py | 32 ++ homeassistant/components/mammotion/number.py | 23 +- homeassistant/components/mammotion/select.py | 68 +++- homeassistant/components/mammotion/sensor.py | 50 ++- homeassistant/components/mammotion/switch.py | 58 +-- requirements_all.txt | 2 +- 15 files changed, 858 insertions(+), 257 deletions(-) create mode 100644 homeassistant/components/mammotion/models.py diff --git a/homeassistant/components/mammotion/__init__.py b/homeassistant/components/mammotion/__init__.py index 39bab9e333578..9b28a255ca9d4 100644 --- a/homeassistant/components/mammotion/__init__.py +++ b/homeassistant/components/mammotion/__init__.py @@ -2,10 +2,28 @@ from __future__ import annotations +from aiohttp import ClientConnectorError +from pymammotion import CloudIOTGateway +from pymammotion.aliyun.model.aep_response import AepResponse +from pymammotion.aliyun.model.connect_response import ConnectResponse +from pymammotion.aliyun.model.dev_by_account_response import ListingDevByAccountResponse +from pymammotion.aliyun.model.login_by_oauth_response import LoginByOAuthResponse +from pymammotion.aliyun.model.regions_response import RegionResponse +from pymammotion.aliyun.model.session_by_authcode_response import ( + SessionByAuthCodeResponse, +) +from pymammotion.data.model.account import Credentials +from pymammotion.http.http import MammotionHTTP +from pymammotion.http.model.http import LoginResponseData, Response +from pymammotion.mammotion.devices.mammotion import Mammotion +from pymammotion.utility.device_config import DeviceConfig + from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ADDRESS, CONF_MAC, Platform +from homeassistant.const import CONF_ADDRESS, CONF_MAC, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo from .const import ( CONF_ACCOUNTNAME, @@ -13,14 +31,24 @@ CONF_AUTH_DATA, CONF_CONNECT_DATA, CONF_DEVICE_DATA, + CONF_DEVICE_NAME, + CONF_MAMMOTION_DATA, CONF_REGION_DATA, CONF_RETRY_COUNT, CONF_SESSION_DATA, - CONF_USE_WIFI, + CONF_STAY_CONNECTED_BLUETOOTH, DEFAULT_RETRY_COUNT, DOMAIN, + EXPIRED_CREDENTIAL_EXCEPTIONS, + LOGGER, ) -from .coordinator import MammotionDataUpdateCoordinator +from .coordinator import ( + MammotionDataUpdateCoordinator, + MammotionDeviceVersionUpdateCoordinator, + MammotionMaintenanceUpdateCoordinator, + MammotionReportUpdateCoordinator, +) +from .models import MammotionDevices, MammotionMowerData PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, @@ -33,13 +61,16 @@ Platform.SELECT, ] -type MammotionConfigEntry = ConfigEntry[MammotionDataUpdateCoordinator] +type MammotionConfigEntry = ConfigEntry[MammotionDevices] async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> bool: """Set up Mammotion Luba from a config entry.""" assert entry.unique_id is not None + # if not entry.unique_id: + # hass.config_entries.async_update_entry(entry, unique_id="some_uuid") + if CONF_ADDRESS not in entry.data and CONF_MAC in entry.data: # Bleak uses addresses not mac addresses which are actually # UUIDs on some platforms (MacOS). @@ -54,19 +85,148 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> if not entry.options: hass.config_entries.async_update_entry( entry, - options={CONF_RETRY_COUNT: DEFAULT_RETRY_COUNT}, + options={CONF_STAY_CONNECTED_BLUETOOTH: False}, ) - mammotion_coordinator = MammotionDataUpdateCoordinator(hass, entry) - await mammotion_coordinator.async_setup() - - await mammotion_coordinator.async_config_entry_first_refresh() - entry.runtime_data = mammotion_coordinator + device_name = entry.data.get(CONF_DEVICE_NAME) + mammotion = Mammotion() + account = entry.data.get(CONF_ACCOUNTNAME) + password = entry.data.get(CONF_PASSWORD) + + mammotion_devices: list[MammotionMowerData] = [] + + if account and password: + credentials = Credentials() + credentials.email = account + credentials.password = password + try: + cloud_client = await check_and_restore_cloud(hass, entry) + if cloud_client is None: + await mammotion.login_and_initiate_cloud(account, password) + else: + await mammotion.initiate_cloud_connection(account, cloud_client) + except ClientConnectorError as err: + raise ConfigEntryNotReady(err) + except EXPIRED_CREDENTIAL_EXCEPTIONS as exc: + LOGGER.debug(exc) + await mammotion.login_and_initiate_cloud(account, password, True) + + if mqtt_client := mammotion.mqtt_list.get(account): + for ( + device + ) in mqtt_client.cloud_client.devices_by_account_response.data.data: + maintenance_coordinator = MammotionMaintenanceUpdateCoordinator( + hass, entry, device, mammotion + ) + version_coordinator = MammotionDeviceVersionUpdateCoordinator( + hass, entry, device, mammotion + ) + report_coordinator = MammotionReportUpdateCoordinator( + hass, entry, device, mammotion + ) + # other coordinator + await maintenance_coordinator.async_config_entry_first_refresh() + await version_coordinator.async_config_entry_first_refresh() + await report_coordinator.async_config_entry_first_refresh() + + # maintenance_coordinator. + device_info = DeviceInfo( + identifiers={(DOMAIN, device.deviceName)}, + manufacturer="Mammotion", + serial_number=device.deviceName.split("-", 1)[-1], + model_id=device.productModel, + name=device.nickName, + model=device.productName, + suggested_area="Garden", + ) + + device_config = DeviceConfig() + if ( + device_limits := device_config.get_working_parameters( + device.productKey + ) + is None + ): + device_limits = device_config.get_working_parameters( + version_coordinator.data.main_product_type + ) + + mammotion_devices.append( + MammotionMowerData( + name=device.deviceName, + device=device_info, + device_limits=device_limits, + api=mammotion, + maintenance_coordinator=maintenance_coordinator, + reporting_coordinator=report_coordinator, + version_coordinator=version_coordinator, + ) + ) + + entry.runtime_data = mammotion_devices await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True +async def check_and_restore_cloud( + hass: HomeAssistant, entry: MammotionConfigEntry +) -> CloudIOTGateway | None: + """Check and restore previous cloud connection.""" + + auth_data = entry.data.get(CONF_AUTH_DATA) + region_data = entry.data.get(CONF_REGION_DATA) + aep_data = entry.data.get(CONF_AEP_DATA) + session_data = entry.data.get(CONF_SESSION_DATA) + device_data = entry.data.get(CONF_DEVICE_DATA) + connect_data = entry.data.get(CONF_CONNECT_DATA) + mammotion_data = entry.data.get(CONF_MAMMOTION_DATA) + + if any( + data is None + for data in [ + auth_data, + region_data, + aep_data, + session_data, + device_data, + connect_data, + mammotion_data, + ] + ): + return None + + cloud_client = CloudIOTGateway( + connect_response=ConnectResponse.from_dict(connect_data) + if isinstance(connect_data, dict) + else connect_data, + aep_response=AepResponse.from_dict(aep_data) + if isinstance(aep_data, dict) + else aep_data, + region_response=RegionResponse.from_dict(region_data) + if isinstance(region_data, dict) + else region_data, + session_by_authcode_response=SessionByAuthCodeResponse.from_dict(session_data) + if isinstance(session_data, dict) + else session_data, + dev_by_account=ListingDevByAccountResponse.from_dict(device_data) + if isinstance(device_data, dict) + else device_data, + login_by_oauth_response=LoginByOAuthResponse.from_dict(auth_data) + if isinstance(auth_data, dict) + else auth_data, + ) + + if isinstance(mammotion_data, dict): + mammotion_data = Response[LoginResponseData].from_dict(mammotion_data) + + cloud_client.set_http(MammotionHTTP(response=mammotion_data)) + + await hass.async_add_executor_job(cloud_client.check_or_refresh_session) + + return cloud_client + + async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) @@ -74,7 +234,7 @@ async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> Non async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: + + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): await entry.runtime_data.manager.remove_device(entry.runtime_data.device_name) return unload_ok diff --git a/homeassistant/components/mammotion/binary_sensor.py b/homeassistant/components/mammotion/binary_sensor.py index d878945847942..36f521ad5d09c 100644 --- a/homeassistant/components/mammotion/binary_sensor.py +++ b/homeassistant/components/mammotion/binary_sensor.py @@ -3,6 +3,8 @@ from collections.abc import Callable from dataclasses import dataclass +from pymammotion.data.model.device import MowingDevice + from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, @@ -10,7 +12,6 @@ ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from pymammotion.proto.luba_msg import LubaMsg from . import MammotionConfigEntry from .coordinator import MammotionDataUpdateCoordinator @@ -23,7 +24,7 @@ class MammotionBinarySensorEntityDescription( ): """Describes Mammotion binary sensor entity.""" - is_on_fn: Callable[[LubaMsg], bool | None] + is_on_fn: Callable[[MowingDevice], bool | None] BINARY_SENSORS: tuple[MammotionBinarySensorEntityDescription, ...] = ( diff --git a/homeassistant/components/mammotion/config_flow.py b/homeassistant/components/mammotion/config_flow.py index 654b2d3b8d516..2fdf50cf76f36 100644 --- a/homeassistant/components/mammotion/config_flow.py +++ b/homeassistant/components/mammotion/config_flow.py @@ -2,9 +2,14 @@ from typing import TYPE_CHECKING, Any -import voluptuous as vol from aiohttp.web_exceptions import HTTPException from bleak.backends.device import BLEDevice +from pymammotion.aliyun.cloud_gateway import CloudIOTGateway +from pymammotion.http.http import MammotionHTTP +from pymammotion.mammotion.devices.mammotion import Mammotion +import voluptuous as vol + +from homeassistant import config_entries from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( BluetoothServiceInfo, @@ -19,19 +24,14 @@ ) from homeassistant.const import CONF_ADDRESS, CONF_PASSWORD from homeassistant.core import callback -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.selector import ( - SelectOptionDict, - SelectSelector, - SelectSelectorConfig, - SelectSelectorMode, -) -from pymammotion.aliyun.cloud_gateway import CloudIOTGateway -from pymammotion.http.http import connect_http -from pymammotion.mammotion.devices.mammotion import Mammotion +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, format_mac from .const import ( + CONF_ACCOUNT_ID, CONF_ACCOUNTNAME, + CONF_BLE_DEVICES, CONF_DEVICE_NAME, CONF_STAY_CONNECTED_BLUETOOTH, CONF_USE_WIFI, @@ -60,11 +60,6 @@ async def async_step_bluetooth( if discovery_info is None: return self.async_abort(reason="no_devices_found") - await self.async_set_unique_id(discovery_info.name) - self._abort_if_unique_id_configured( - updates={CONF_ADDRESS: discovery_info.address} - ) - device = bluetooth.async_ble_device_from_address( self.hass, discovery_info.address ) @@ -79,6 +74,11 @@ async def async_step_bluetooth( self._discovered_device = device + await self.async_set_unique_id(discovery_info.name) + self._abort_if_unique_id_configured( + updates={CONF_ADDRESS: discovery_info.address} + ) + return await self.async_step_bluetooth_confirm() async def async_step_bluetooth_confirm( @@ -87,11 +87,59 @@ async def async_step_bluetooth_confirm( """Confirm discovery.""" assert self._discovered_device - + ble_devices: dict[str, str] = { + self._discovered_device.name: self._discovered_device.address + } self._config = { - CONF_ADDRESS: self._discovered_device.address, + CONF_BLE_DEVICES: ble_devices, } + try: + # Look for account-based configurations + device_registry = dr.async_get(self.hass) + current_entries = self.hass.config_entries.async_entries(DOMAIN) + + for entry in current_entries: + if not entry.data.get(CONF_ACCOUNT_ID): + continue + + device_entries = dr.async_entries_for_config_entry( + device_registry, entry.entry_id + ) + + for device in device_entries: + # Check both MAC address and any other identifiers + identifiers = {id[1] for id in device.identifiers} + if device.name in identifiers: + # Found matching device in account + if entry.state == config_entries.ConfigEntryState.LOADED: + # # Update existing entry with BLE info + + formatted_ble = format_mac(self._discovered_device.address) + + device_registry.async_update_device( + device.id, + connections={(CONNECTION_BLUETOOTH, formatted_ble)}, + ) + # reload the entry now we have a ble address + self.hass.config_entries.async_schedule_reload( + entry.entry_id + ) + return self.async_show_form( + step_id="bluetooth_confirm", + last_step=True, + description_placeholders={ + "name": self._discovered_device.name + }, + ) + + # Entry exists but not loaded + return self.async_abort(reason="existing_account_not_loaded") + + except Exception as ex: + # _LOGGER.exception("Error checking for existing account") + raise ConfigEntryNotReady from ex + if user_input is not None: return await self.async_step_wifi(user_input) @@ -117,13 +165,6 @@ async def async_step_user( if user_input is not None: address = user_input.get(CONF_ADDRESS) or self._config.get(CONF_ADDRESS) if address is not None: - name = self._discovered_devices.get(address) - if name is None: - return self.async_abort(reason="no_longer_present") - - await self.async_set_unique_id(name, raise_on_progress=False) - self._abort_if_unique_id_configured() - self._config = { CONF_ADDRESS: address, } @@ -178,15 +219,34 @@ async def async_step_wifi( ): account = user_input.get(CONF_ACCOUNTNAME, "") password = user_input.get(CONF_PASSWORD, "") + mammotion_http = MammotionHTTP() try: - response = await connect_http(account, password) - if response.login_info is None: - return self.async_abort(reason=str(response.msg)) + await mammotion_http.login(account, password) + if mammotion_http.login_info is None: + return self.async_abort(reason=str(mammotion_http.msg)) except HTTPException as err: return self.async_abort(reason=str(err)) - return await self.async_step_wifi_confirm(user_input) + user_account = mammotion_http.login_info.userInformation.userAccount + + 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, + CONF_DEVICE_NAME: self._discovered_device.name + if self._discovered_device + else None, + CONF_USE_WIFI: user_input.get(CONF_USE_WIFI, True), + **self._config, + }, + options={CONF_STAY_CONNECTED_BLUETOOTH: self._stay_connected}, + ) if user_input is not None and user_input.get(CONF_USE_WIFI) is False: return self.async_create_entry( @@ -217,12 +277,11 @@ async def async_step_wifi_confirm( ) -> ConfigFlowResult: """Confirm device discovery.""" - device_name = user_input.get(CONF_DEVICE_NAME) address = self._config.get(CONF_ADDRESS) name = self._discovered_devices.get(address) mammotion = Mammotion() - if user_input is not None and (device_name or name): + if user_input is not None: account = user_input.get(CONF_ACCOUNTNAME) password = user_input.get(CONF_PASSWORD) @@ -236,78 +295,25 @@ async def async_step_wifi_confirm( ).cloud_client except HTTPException as err: return self.async_abort(reason=str(err)) - mowing_devices = self._cloud_client.devices_by_account_response.data.data - if name: - found_device = [ - device for device in mowing_devices if device.deviceName == name - ] - if not found_device: - return self.async_abort(reason="bluetooth_and_account_mismatch") - - if not name: - await self.async_set_unique_id(device_name, raise_on_progress=False) - self._abort_if_unique_id_configured() + user_account = ( + self._cloud_client.mammotion_http.login_info.userInformation.userAccount + ) + + await self.async_set_unique_id(user_account, raise_on_progress=False) + self._abort_if_unique_id_configured() return self.async_create_entry( - title=name or device_name, + title=user_account, data={ CONF_ACCOUNTNAME: account, CONF_PASSWORD: password, - CONF_DEVICE_NAME: device_name or name, + CONF_DEVICE_NAME: name, CONF_USE_WIFI: user_input.get(CONF_USE_WIFI, True), **self._config, }, options={CONF_STAY_CONNECTED_BLUETOOTH: self._stay_connected}, ) - account = user_input.get(CONF_ACCOUNTNAME) - password = user_input.get(CONF_PASSWORD) - self._config = { - **self._config, - **user_input, - } - try: - if mammotion.mqtt_list.get(account) is None: - self._cloud_client = await Mammotion().login(account, password) - else: - self._cloud_client = mammotion.mqtt_list.get(account).cloud_client - except HTTPException as err: - return self.async_abort(reason=str(err)) - - mowing_devices = [ - dev - for dev in self._cloud_client.devices_by_account_response.data.data - if (dev.productModel is None or dev.productModel != "ReferenceStation") - ] - - if len(mowing_devices) == 0: - return self.async_abort(reason="no_devices_found_in_account") - - machine_options = [ - SelectOptionDict( - value=device.deviceName, - label=device.deviceName, - ) - for device in mowing_devices - ] - - machine_selection_schema = vol.Schema( - { - vol.Required( - CONF_DEVICE_NAME, default=machine_options[0]["value"] - ): SelectSelector( - SelectSelectorConfig( - options=machine_options, - mode=SelectSelectorMode.DROPDOWN, - ) - ) - } - ) - - return self.async_show_form( - step_id="wifi_confirm", data_schema=machine_selection_schema - ) - @staticmethod @callback def async_get_options_flow( diff --git a/homeassistant/components/mammotion/const.py b/homeassistant/components/mammotion/const.py index 58df114fe6211..b7c8ccd542e55 100644 --- a/homeassistant/components/mammotion/const.py +++ b/homeassistant/components/mammotion/const.py @@ -29,8 +29,10 @@ CONF_STAY_CONNECTED_BLUETOOTH: Final = "stay_connected_bluetooth" CONF_ACCOUNTNAME: Final = "account_name" +CONF_ACCOUNT_ID: Final = "mammotion_account_id" CONF_USE_WIFI: Final = "use_wifi" CONF_DEVICE_NAME: Final = "device_name" +CONF_BLE_DEVICES: Final = "ble_devices" CONF_AUTH_DATA: Final = "auth_data" CONF_CONNECT_DATA: Final = "connect_data" CONF_AEP_DATA: Final = "aep_data" diff --git a/homeassistant/components/mammotion/coordinator.py b/homeassistant/components/mammotion/coordinator.py index 5c40663a56589..251876abb7ede 100644 --- a/homeassistant/components/mammotion/coordinator.py +++ b/homeassistant/components/mammotion/coordinator.py @@ -7,23 +7,16 @@ from datetime import timedelta from typing import TYPE_CHECKING, Any, cast -import betterproto from aiohttp import ClientConnectorError -from homeassistant.components import bluetooth -from homeassistant.const import CONF_ADDRESS, CONF_PASSWORD -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError -from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.storage import Store -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from mashumaro.exceptions import InvalidFieldValue +import betterproto from pymammotion import CloudIOTGateway -from pymammotion.aliyun.cloud_gateway import ( - DeviceOfflineException, -) +from pymammotion.aliyun.cloud_gateway import DeviceOfflineException from pymammotion.aliyun.model.aep_response import AepResponse from pymammotion.aliyun.model.connect_response import ConnectResponse -from pymammotion.aliyun.model.dev_by_account_response import ListingDevByAccountResponse +from pymammotion.aliyun.model.dev_by_account_response import ( + Device, + ListingDevByAccountResponse, +) from pymammotion.aliyun.model.login_by_oauth_response import LoginByOAuthResponse from pymammotion.aliyun.model.regions_response import RegionResponse from pymammotion.aliyun.model.session_by_authcode_response import ( @@ -33,17 +26,22 @@ from pymammotion.data.model.account import Credentials from pymammotion.data.model.device import MowingDevice from pymammotion.data.model.device_config import OperationSettings, create_path_order +from pymammotion.data.model.report_info import Maintain from pymammotion.http.http import MammotionHTTP from pymammotion.http.model.http import LoginResponseData, Response -from pymammotion.mammotion.devices.mammotion import ( - ConnectionPreference, - Mammotion, -) +from pymammotion.mammotion.devices.mammotion import ConnectionPreference, Mammotion from pymammotion.proto import has_field -from pymammotion.proto.luba_msg import LubaMsg from pymammotion.proto.mctrl_sys import RptAct, RptDevStatus, RptInfoType +from pymammotion.utility.constant import WorkMode from pymammotion.utility.device_type import DeviceType +from homeassistant.components import bluetooth +from homeassistant.const import CONF_ADDRESS, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + from .const import ( COMMAND_EXCEPTIONS, CONF_ACCOUNTNAME, @@ -66,16 +64,26 @@ from . import MammotionConfigEntry +MAINTENENCE_INTERVAL = timedelta(minutes=60) +DEFAULT_INTERVAL = timedelta(minutes=1) +WORKING_INTERVAL = timedelta(seconds=5) +REPORT_INTERVAL = timedelta(minutes=1) +DEVICE_VERSION_INTERVAL = timedelta(days=1) + + class MammotionBaseUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): """Mammotion DataUpdateCoordinator.""" manager: Mammotion = None - device_name: str | None = None + device: Device | None = None + updated_once: bool def __init__( self, hass: HomeAssistant, config_entry: MammotionConfigEntry, + device: Device, + mammotion: Mammotion, update_interval: timedelta, ) -> None: """Initialize global mammotion data updater.""" @@ -87,9 +95,13 @@ def __init__( ) assert config_entry.unique_id self.config_entry = config_entry + self.device = device + self.device_name = device.deviceName + self.manager = mammotion self._operation_settings = OperationSettings() self.update_failures = 0 self.enabled = True + self.updated_once = False async def set_scheduled_updates(self, enabled: bool) -> None: self.enabled = enabled @@ -125,7 +137,7 @@ async def async_send_command(self, command: str, **kwargs: Any) -> bool: """Send command.""" try: await self.manager.send_command_with_args( - self.device_name, command, **kwargs + self.device.deviceName, command, **kwargs ) return True except EXPIRED_CREDENTIAL_EXCEPTIONS: @@ -144,12 +156,39 @@ async def async_send_command(self, command: str, **kwargs: Any) -> bool: .ble() .queue_command(command, **kwargs) ) - return True + return True + raise DeviceOfflineException() except COMMAND_EXCEPTIONS as exc: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="command_failed" ) from exc + async def check_firmware_version(self) -> None: + """Check if firmware version is updated.""" + mower = self.manager.mower(self.device_name) + device_registry = dr.async_get(self.hass) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, self.device_name)} + ) + if device_entry is None: + return + + new_swversion = None + if len(mower.net.toapp_devinfo_resp.resp_ids) > 0: + new_swversion = mower.net.toapp_devinfo_resp.resp_ids[0].info + + if new_swversion is not None or new_swversion != device_entry.sw_version: + device_registry.async_update_device( + device_entry.id, sw_version=new_swversion + ) + + model_id = None + if has_field(mower.sys.device_product_type_info): + model_id = mower.sys.device_product_type_info.main_product_type + + if model_id is not None or model_id != device_entry.model_id: + device_registry.async_update_device(device_entry.id, model_id=model_id) + def store_cloud_credentials(self) -> None: """Store cloud credentials in config entry.""" # config_updates = {} @@ -173,10 +212,13 @@ def store_cloud_credentials(self) -> None: self.config_entry, data=config_updates ) - async def _async_update_notification(self) -> None: + async def _async_update_notification(self, res: tuple[str, Any | None]) -> None: """Update data from incoming messages.""" - mower = self.manager.mower(self.device_name) - self.async_set_updated_data(mower) + if res[0] == "sys" and res[1] is not None: + sys_msg = betterproto.which_one_of(res[1], "SubSysMsg") + if sys_msg[0] == "toapp_report_data": + mower = self.manager.mower(self.device_name) + self.async_set_updated_data(mower) async def check_and_restore_cloud(self) -> CloudIOTGateway | None: """Check and restore previous cloud connection.""" @@ -302,7 +344,7 @@ async def async_setup(self) -> None: if ble_device and device: device.ble().set_disconnect_strategy(not stay_connected_ble) - await self.async_restore_data() + # await self.async_restore_data() try: if preference is ConnectionPreference.WIFI and device.has_cloud(): @@ -324,29 +366,98 @@ async def async_setup(self) -> None: except COMMAND_EXCEPTIONS as exc: raise ConfigEntryNotReady("Unable to setup Mammotion device") from exc - async def async_restore_data(self) -> None: - """Restore saved data.""" - store = Store(self.hass, version=1, key=self.device_name) - restored_data = await store.async_load() + # async def async_restore_data(self) -> None: + # """Restore saved data.""" + # store = Store(self.hass, version=1, key=self.device_name) + # restored_data = await store.async_load() + # try: + # if restored_data: + # mower_state = MowingDevice().from_dict(restored_data) + # self.manager.get_device_by_name( + # self.device_name + # ).mower_state = mower_state + # except InvalidFieldValue: + # """invalid""" + # self.data = MowingDevice() + # self.manager.get_device_by_name(self.device_name).mower_state = self.data + # + # async def async_save_data(self, data: MowingDevice) -> None: + # """Get map data from the device.""" + # store = Store(self.hass, version=1, key=self.device_name) + # stored_data = asdict(data) + # del stored_data["device"] + # await store.async_save(stored_data) + + +class MammotionReportUpdateCoordinator(MammotionBaseUpdateCoordinator[MowingDevice]): + def __init__( + self, + hass: HomeAssistant, + config_entry: MammotionConfigEntry, + device: Device, + mammotion: Mammotion, + ) -> None: + """Initialize global mammotion data updater.""" + super().__init__( + hass=hass, + config_entry=config_entry, + device=device, + mammotion=mammotion, + update_interval=REPORT_INTERVAL, + ) + + def clear_update_failures(self) -> None: + self.update_failures = 0 + + async def _async_update_data(self) -> MowingDevice: + """Get data from the device.""" + + if not self.enabled: + return self.data + + device = self.manager.get_device_by_name(self.device_name) + + if self.update_failures > 3 and device.preference is ConnectionPreference.WIFI: + """Don't hammer the mammotion/ali servers""" + loop = asyncio.get_running_loop() + loop.call_later(600, self.clear_update_failures) + + return self.data + + if device.has_ble() and device.preference is ConnectionPreference.BLUETOOTH: + if ble_device := bluetooth.async_ble_device_from_address( + self.hass, device.ble().get_address(), True + ): + device.ble().update_device(ble_device) try: - if restored_data: - device_dict = LubaMsg().to_dict(casing=betterproto.Casing.SNAKE) - mower_state = MowingDevice().from_dict(restored_data) - mower_state.update_raw(device_dict) - self.manager.get_device_by_name( - self.device_name - ).mower_state = mower_state - except InvalidFieldValue: - """invalid""" - self.data = MowingDevice() - self.manager.get_device_by_name(self.device_name).mower_state = self.data - - async def async_save_data(self, data: MowingDevice) -> None: - """Get map data from the device.""" - store = Store(self.hass, version=1, key=self.device_name) - stored_data = asdict(data) - stored_data["device"] = None - await store.async_save(stored_data) + await self.async_send_command("get_report_cfg") + + except DeviceOfflineException: + """Device is offline try bluetooth if we have it.""" + return self.data + + LOGGER.debug("Updated Mammotion device %s", self.device_name) + LOGGER.debug("================= Debug Log =================") + LOGGER.debug( + "Mammotion device data: %s", + asdict( + self.manager.get_device_by_name(self.device_name).mower_state.device + ), + ) + LOGGER.debug("==================================") + + self.update_failures = 0 + data = self.manager.get_device_by_name(self.device_name).mower_state + # await self.async_save_data(data) + + if data.report_data.dev.sys_status is WorkMode.MODE_WORKING: + self.update_interval = WORKING_INTERVAL + else: + self.update_interval = DEFAULT_INTERVAL + + self.updated_once = True + + return data class MammotionDataUpdateCoordinator(MammotionBaseUpdateCoordinator[MowingDevice]): @@ -357,7 +468,7 @@ def __init__(self, hass: HomeAssistant, config_entry: MammotionConfigEntry) -> N super().__init__( hass=hass, config_entry=config_entry, - update_interval=timedelta(minutes=1), + update_interval=DEFAULT_INTERVAL, ) async def async_sync_maps(self) -> None: @@ -400,6 +511,10 @@ async def async_read_sidelight(self) -> None: "read_and_set_sidelight", is_sidelight=False, operate=1 ) + async def set_traversal_mode(self, id: int) -> None: + """Set traversal mode.""" + await self.async_send_command("traverse_mode", id=id) + async def async_blade_height(self, height: int) -> int: """Set blade height.""" await self.send_command_and_update("set_blade_height", height=float(height)) @@ -446,6 +561,9 @@ async def async_request_iot_sync(self, stop: bool = False) -> None: RptInfoType.RIT_DEV_STA, RptInfoType.RIT_DEV_LOCAL, RptInfoType.RIT_WORK, + RptInfoType.RIT_MAINTAIN, + RptInfoType.RIT_BASESTATION_INFO, + RptInfoType.RIT_FW_INFO, ], timeout=10000, period=3000, @@ -497,32 +615,6 @@ async def clear_all_maps(self) -> None: data = self.manager.get_device_by_name(self.device_name).mower_state data.map = HashList() - async def check_firmware_version(self) -> None: - """Check if firmware version is updated.""" - mower = self.manager.mower(self.device_name) - device_registry = dr.async_get(self.hass) - device_entry = device_registry.async_get_device( - identifiers={(DOMAIN, self.device_name)} - ) - if device_entry is None: - return - - new_swversion = None - if len(mower.net.toapp_devinfo_resp.resp_ids) > 0: - new_swversion = mower.net.toapp_devinfo_resp.resp_ids[0].info - - if new_swversion is not None or new_swversion != device_entry.sw_version: - device_registry.async_update_device( - device_entry.id, sw_version=new_swversion - ) - - model_id = None - if has_field(mower.sys.device_product_type_info): - model_id = mower.sys.device_product_type_info.main_product_type - - if model_id is not None or model_id != device_entry.model_id: - device_registry.async_update_device(device_entry.id, model_id=model_id) - def clear_update_failures(self) -> None: self.update_failures = 0 @@ -541,8 +633,6 @@ async def _async_update_data(self) -> MowingDevice: return self.data - await self.check_firmware_version() - if device.has_ble() and device.preference is ConnectionPreference.BLUETOOTH: if ble_device := bluetooth.async_ble_device_from_address( self.hass, device.ble().get_address(), True @@ -564,7 +654,6 @@ async def _async_update_data(self) -> MowingDevice: ): await self.manager.start_map_sync(self.device_name) - # if not device.has_queued_commands(): await self.async_send_command("get_report_cfg") LOGGER.debug("Updated Mammotion device %s", self.device_name) @@ -577,7 +666,13 @@ async def _async_update_data(self) -> MowingDevice: self.update_failures = 0 data = self.manager.get_device_by_name(self.device_name).mower_state - await self.async_save_data(data) + # await self.async_save_data(data) + + if data.report_data.dev.sys_status is WorkMode.MODE_WORKING: + self.update_interval = WORKING_INTERVAL + else: + self.update_interval = DEFAULT_INTERVAL + return data @property @@ -591,3 +686,82 @@ def operation_settings(self) -> OperationSettings: # await self.async_setup() # except COMMAND_EXCEPTIONS as exc: # raise UpdateFailed(f"Setting up Mammotion device failed: {exc}") from exc + + +class MammotionMaintenanceUpdateCoordinator(MammotionBaseUpdateCoordinator[Maintain]): + """Class to manage fetching mammotion data.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: MammotionConfigEntry, + device: Device, + mammotion: Mammotion, + ) -> None: + """Initialize global mammotion data updater.""" + super().__init__( + hass=hass, + config_entry=config_entry, + device=device, + mammotion=mammotion, + update_interval=MAINTENENCE_INTERVAL, + ) + + async def _async_update_data(self) -> Maintain: + """Get data from the device.""" + + if not self.enabled: + return self.data + try: + await self.async_send_command("get_maintenance") + + except DeviceOfflineException: + """Device is offline try bluetooth if we have it.""" + return self.data + + self.updated_once = True + + return self.manager.get_device_by_name( + self.device.deviceName + ).mower_state.report_data.maintenance + + +class MammotionDeviceVersionUpdateCoordinator(MammotionBaseUpdateCoordinator[Maintain]): + """Class to manage fetching mammotion data.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: MammotionConfigEntry, + device: Device, + mammotion: Mammotion, + ) -> None: + """Initialize global mammotion data updater.""" + super().__init__( + hass=hass, + config_entry=config_entry, + device=device, + mammotion=mammotion, + update_interval=DEVICE_VERSION_INTERVAL, + ) + + async def _async_update_data(self): + """Get data from the device.""" + + if not self.enabled: + return self.data + + try: + await self.async_send_command("get_device_version_main") + await self.async_send_command("get_device_version_info") + + await self.check_firmware_version() + except DeviceOfflineException: + """Device is offline try bluetooth if we have it.""" + return self.data + + self.updated_once = True + + return self.manager.get_device_by_name( + self.device.deviceName + ).mower_state.sys.device_product_type_info diff --git a/homeassistant/components/mammotion/device_tracker.py b/homeassistant/components/mammotion/device_tracker.py index 34092878e05fe..f30170cdf6fc8 100644 --- a/homeassistant/components/mammotion/device_tracker.py +++ b/homeassistant/components/mammotion/device_tracker.py @@ -32,7 +32,7 @@ class MammotionTracker(MammotionBaseEntity, TrackerEntity, RestoreEntity): _attr_force_update = False _attr_translation_key = "device_tracker" - _attr_icon = "mdi:robot-mower" + _attr_source_type = SourceType.GPS def __init__(self, coordinator: MammotionDataUpdateCoordinator) -> None: """Initialize the Tracker.""" @@ -67,8 +67,3 @@ def longitude(self) -> float | None: def battery_level(self) -> int | None: """Return the battery level of the device.""" return self.coordinator.data.report_data.dev.battery_val - - @property - def source_type(self) -> SourceType: - """Return the source type, e.g., GPS or router, of the device.""" - return SourceType.GPS diff --git a/homeassistant/components/mammotion/icons.json b/homeassistant/components/mammotion/icons.json index fd96c40e89722..4d5a2fd0cf86c 100644 --- a/homeassistant/components/mammotion/icons.json +++ b/homeassistant/components/mammotion/icons.json @@ -1,5 +1,39 @@ { "entity": { + "device_tracker": { + "device_tracker": { + "default": "mdi:map-marker-radius" + } + }, + "button": { + "start_map_sync": { + "default": "mdi:map-clock" + }, + "resync_rtk_dock": { + "default": "mdi:sync" + }, + "release_from_dock": { + "default": "mdi:ray-start-arrow" + }, + "emergency_nudge_forward": { + "default": "mdi:arrow-up" + }, + "emergency_nudge_left": { + "default": "mdi:arrow-left" + }, + "emergency_nudge_right": { + "default": "mdi:arrow-right" + }, + "emergency_nudge_back": { + "default": "mdi:arrow-down" + }, + "cancel_task": { + "default": "mdi:cancel" + }, + "clear_all_mapdata": { + "default": "mdi:map-marker-remove" + } + }, "sensor": { "gps_stars": { "default": "mdi:satellite-uplink" @@ -22,6 +56,110 @@ "position_mode": { "default": "mdi:map-marker" } + }, + "select": { + "channel_mode": { + "default": "mdi:map-marker-path", + "state": { + "single_grid": "mdi:sawtooth-wave", + "double_grid": "mdi:checkerboard", + "segment_grid": "mdi:square-wave", + "no_grid": "mdi:dots-square" + } + }, + "mowing_laps": { + "default": "mdi:go-kart-track", + "state": { + "none": "mdi:numeric-0", + "one": "mdi:numeric-1", + "two": "mdi:numeric-2", + "three": "mdi:numeric-3", + "four": "mdi:numeric-4" + } + }, + "obstacle_laps": { + "default": "mdi:go-kart-track", + "state": { + "none": "mdi:numeric-0", + "one": "mdi:numeric-1", + "two": "mdi:numeric-2", + "three": "mdi:numeric-3", + "four": "mdi:numeric-4" + } + }, + "border_mode": { + "default": "mdi:checkerboard", + "state": { + "border_first": "mdi:dots-circle", + "grid_first": "mdi:checkerboard" + } + }, + "bypass_mode": { + "default": "mdi:arrow-collapse-right" + }, + "cutting_angle_mode": { + "default": "mdi:angle-acute" + } + }, + "number": { + "start_progress": { + "default": "mdi:progress-helper" + }, + "blade_height": { + "default": "mdi:altimeter" + }, + "working_speed": { + "default": "mdi:speedometer" + }, + "cutting_angle": { + "default": "mdi:angle-acute" + }, + "path_spacing": { + "default": "mdi:keyboard-space" + }, + "dumping_interval": { + "default": "mdi:dump-truck" + }, + "toward_included_angle": { + "default": "mdi:angle-right" + } + }, + "switch": { + "area": { + "default": "mdi:texture-box" + }, + "blade_status": { + "default": "mdi:saw-blade" + }, + "is_mow": { + "default": "mdi:mower", + "state": { + "off": "mdi:mower", + "on": "mdi:mower-on" + } + }, + "is_dump": { + "default": "mdi:dump-truck" + }, + "is_edge": { + "default": "mdi:border-outside" + }, + "rain_tactics": { + "default": "mdi:weather-rainy" + }, + "side_led": { + "default": "mdi:led-off", + "state": { + "off": "mdi:led-off", + "on": "mdi:led-on" + } + }, + "perimeter_first_on_off": { + "default": "mdi:dots-square" + }, + "schedule_updates": { + "default": "mdi:update" + } } } } diff --git a/homeassistant/components/mammotion/lawn_mower.py b/homeassistant/components/mammotion/lawn_mower.py index de1114bae13e1..4351e1963e65e 100644 --- a/homeassistant/components/mammotion/lawn_mower.py +++ b/homeassistant/components/mammotion/lawn_mower.py @@ -4,7 +4,14 @@ from typing import Any +from pymammotion.data.model.device_config import OperationSettings +from pymammotion.data.model.report_info import ReportData +from pymammotion.proto import has_field +from pymammotion.proto.luba_msg import RptDevStatus +from pymammotion.utility.constant.device_constant import WorkMode +from pymammotion.utility.device_type import DeviceType import voluptuous as vol + from homeassistant.components.lawn_mower import ( LawnMowerActivity, LawnMowerEntity, @@ -12,15 +19,8 @@ ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers import entity_platform +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback -from pymammotion.data.model.device_config import OperationSettings -from pymammotion.data.model.report_info import ReportData -from pymammotion.proto import has_field -from pymammotion.proto.luba_msg import RptDevStatus -from pymammotion.utility.constant.device_constant import WorkMode -from pymammotion.utility.device_type import DeviceType from . import MammotionConfigEntry from .const import COMMAND_EXCEPTIONS, DOMAIN, LOGGER @@ -80,9 +80,8 @@ def get_entity_attribute( if entity and attribute_name in entity.attributes: # Return the specific attribute return entity.attributes.get(attribute_name, None) - else: - # Return None if the entity or attribute does not exist - return None + # Return None if the entity or attribute does not exist + return None async def async_setup_entry( @@ -207,6 +206,9 @@ async def async_start_mowing(self, **kwargs: Any) -> None: await self.coordinator.async_send_command("resume_execute_task") if mode == WorkMode.MODE_READY: trans_key = "start_failed" + if self.report_data.work.area >> 16 != 0: + await self.coordinator.async_send_command("resume_execute_task") + return if await self.coordinator.async_plan_route(operational_settings): await self.coordinator.async_send_command("start_job") diff --git a/homeassistant/components/mammotion/manifest.json b/homeassistant/components/mammotion/manifest.json index 8a095ba29e5d0..73ddf8bc70a57 100644 --- a/homeassistant/components/mammotion/manifest.json +++ b/homeassistant/components/mammotion/manifest.json @@ -20,5 +20,5 @@ "loggers": ["pymammotion"], "integration_type": "device", "iot_class": "local_push", - "requirements": ["pymammotion==0.2.97"] + "requirements": ["pymammotion==0.4.0a3"] } diff --git a/homeassistant/components/mammotion/models.py b/homeassistant/components/mammotion/models.py new file mode 100644 index 0000000000000..33a9c03a4fb5d --- /dev/null +++ b/homeassistant/components/mammotion/models.py @@ -0,0 +1,32 @@ +from dataclasses import dataclass + +from pymammotion.data.model.device_limits import DeviceLimits +from pymammotion.mammotion.devices.mammotion import Mammotion + +from homeassistant.helpers.device_registry import DeviceInfo + +from . import ( + MammotionDeviceVersionUpdateCoordinator, + MammotionMaintenanceUpdateCoordinator, +) +from .coordinator import MammotionReportUpdateCoordinator + + +@dataclass +class MammotionMowerData: + """Data for a mower information.""" + + name: str + api: Mammotion + maintenance_coordinator: MammotionMaintenanceUpdateCoordinator + reporting_coordinator: MammotionReportUpdateCoordinator + version_coordinator: MammotionDeviceVersionUpdateCoordinator + device_limits: DeviceLimits + device: DeviceInfo + + +@dataclass +class MammotionDevices: + """Data for the Mammotion integration.""" + + mowers: list[MammotionMowerData] diff --git a/homeassistant/components/mammotion/number.py b/homeassistant/components/mammotion/number.py index 9ebae77c1a547..c6df50e689cbf 100644 --- a/homeassistant/components/mammotion/number.py +++ b/homeassistant/components/mammotion/number.py @@ -1,5 +1,8 @@ +from collections.abc import Callable from dataclasses import dataclass -from typing import Callable + +from pymammotion.data.model.device_limits import DeviceLimits +from pymammotion.utility.device_type import DeviceType from homeassistant.components.number import ( NumberDeviceClass, @@ -8,9 +11,9 @@ NumberMode, ) from homeassistant.const import ( - AREA_SQUARE_METERS, DEGREE, PERCENTAGE, + UnitOfArea, UnitOfLength, UnitOfSpeed, ) @@ -18,8 +21,6 @@ from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from pymammotion.data.model.device_config import DeviceLimits -from pymammotion.utility.device_type import DeviceType from . import MammotionConfigEntry from .coordinator import MammotionDataUpdateCoordinator @@ -30,7 +31,7 @@ class MammotionConfigNumberEntityDescription(NumberEntityDescription): """Describes Mammotion number entity.""" - set_fn: Callable[[MammotionDataUpdateCoordinator, int], None] + set_fn: Callable[[MammotionDataUpdateCoordinator, float], None] NUMBER_ENTITIES: tuple[MammotionConfigNumberEntityDescription, ...] = ( @@ -74,7 +75,7 @@ class MammotionConfigNumberEntityDescription(NumberEntityDescription): max_value=100, step=1, mode=NumberMode.SLIDER, - native_unit_of_measurement=AREA_SQUARE_METERS, + native_unit_of_measurement=UnitOfArea.SQUARE_METERS, set_fn=lambda coordinator, value: setattr( coordinator.operation_settings, "collect_grass_frequency", value ), @@ -84,9 +85,10 @@ class MammotionConfigNumberEntityDescription(NumberEntityDescription): LUBA_WORKING_ENTITIES: tuple[MammotionConfigNumberEntityDescription, ...] = ( MammotionConfigNumberEntityDescription( key="blade_height", - step=5, + step=1, min_value=25, # ToDo: To be dynamiclly set based on model (h\non H) max_value=70, # ToDo: To be dynamiclly set based on model (h\non H) + mode=NumberMode.BOX, set_fn=lambda coordinator, value: setattr( coordinator.operation_settings, "blade_height", value ), @@ -175,7 +177,8 @@ def __init__( if self.entity_description.key == "toward_included_angle": self._attr_native_value = 90 - async def async_set_native_value(self, value: float | int) -> None: + async def async_set_native_value(self, value: float) -> None: + """Set native value for number.""" self._attr_native_value = value self.entity_description.set_fn(self.coordinator, value) self.async_write_ha_state() @@ -190,6 +193,7 @@ def __init__( entity_description: MammotionConfigNumberEntityDescription, limits: DeviceLimits, ) -> None: + """Init MammotionWorkingNumberEntity.""" super().__init__(coordinator, entity_description) min_attr = f"{entity_description.key}_min" @@ -213,7 +217,8 @@ def native_max_value(self) -> float: """Return the maximum value.""" return self._attr_native_max_value - async def async_set_native_value(self, value: float | int) -> None: + async def async_set_native_value(self, value: float) -> None: + """Set native value for number.""" self._attr_native_value = value self.entity_description.set_fn(self.coordinator, value) self.async_write_ha_state() diff --git a/homeassistant/components/mammotion/select.py b/homeassistant/components/mammotion/select.py index 92c0b4ded71b4..166508927b47d 100644 --- a/homeassistant/components/mammotion/select.py +++ b/homeassistant/components/mammotion/select.py @@ -1,11 +1,6 @@ +from collections.abc import Awaitable, Callable from dataclasses import dataclass -from typing import Callable -from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.restore_state import RestoreEntity from pymammotion.data.model.mowing_modes import ( BorderPatrolMode, BypassStrategy, @@ -13,9 +8,16 @@ MowOrder, ObstacleLapsMode, PathAngleSetting, + TraversalMode, ) from pymammotion.utility.device_type import DeviceType +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity + from . import MammotionConfigEntry from .coordinator import MammotionDataUpdateCoordinator from .entity import MammotionBaseEntity @@ -25,11 +27,31 @@ class MammotionConfigSelectEntityDescription(SelectEntityDescription): """Describes Mammotion select entity.""" + key: str + options: list[str] + set_fn: Callable[[MammotionDataUpdateCoordinator, str], Awaitable[None]] + + +@dataclass(frozen=True, kw_only=True) +class MammotionAsyncConfigSelectEntityDescription(MammotionBaseEntity, SelectEntity): + """Describes Mammotion select entity with async functionality.""" + key: str options: list[str] set_fn: Callable[[MammotionDataUpdateCoordinator, str], None] +ASYNC_SELECT_ENTITIES: tuple[MammotionAsyncConfigSelectEntityDescription, ...] = ( + MammotionAsyncConfigSelectEntityDescription( + key="traversal_mode", + options=[mode.name for mode in TraversalMode], + set_fn=lambda coordinator, value: coordinator.set_traversal_mode( + TraversalMode[value] + ), + ), +) + + SELECT_ENTITIES: tuple[MammotionConfigSelectEntityDescription, ...] = ( MammotionConfigSelectEntityDescription( key="channel_mode", @@ -117,6 +139,11 @@ async def async_setup_entry( for entity_description in SELECT_ENTITIES: entities.append(MammotionConfigSelectEntity(coordinator, entity_description)) + for entity_description in ASYNC_SELECT_ENTITIES: + entities.append( + MammotionAsyncConfigSelectEntity(coordinator, entity_description) + ) + if DeviceType.is_luba1(coordinator.device_name): for entity_description in LUBA1_SELECT_ENTITIES: entities.append( @@ -156,3 +183,32 @@ async def async_select_option(self, option: str) -> None: self._attr_current_option = option self.entity_description.set_fn(self.coordinator, option) self.async_write_ha_state() + + +# Define the select entity class with entity_category: config +class MammotionAsyncConfigSelectEntity( + MammotionBaseEntity, SelectEntity, RestoreEntity +): + """Representation of a Mammotion select entities.""" + + _attr_entity_category = EntityCategory.CONFIG + + entity_description: MammotionConfigSelectEntityDescription + _attr_has_entity_name = True + + def __init__( + self, + coordinator: MammotionDataUpdateCoordinator, + entity_description: MammotionConfigSelectEntityDescription, + ) -> None: + super().__init__(coordinator, entity_description.key) + self.coordinator = coordinator + self.entity_description = entity_description + self._attr_translation_key = entity_description.key + self._attr_options = entity_description.options + self._attr_current_option = entity_description.options[0] + + async def async_select_option(self, option: str) -> None: + self._attr_current_option = option + await self.entity_description.set_fn(self.coordinator, option) + self.async_write_ha_state() diff --git a/homeassistant/components/mammotion/sensor.py b/homeassistant/components/mammotion/sensor.py index 67940593cfb18..a3d2fafc2488e 100644 --- a/homeassistant/components/mammotion/sensor.py +++ b/homeassistant/components/mammotion/sensor.py @@ -15,7 +15,7 @@ SIGNAL_STRENGTH_DECIBELS_MILLIWATT, UnitOfLength, UnitOfSpeed, - UnitOfTime, + UnitOfTime, UnitOfArea, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -25,6 +25,7 @@ from pymammotion.data.model.enums import RTKStatus from pymammotion.utility.constant.device_constant import ( PosType, + camera_brightness, device_connection, device_mode, ) @@ -54,6 +55,17 @@ class MammotionSensorEntityDescription(SensorEntityDescription): ), ) +LUBA_2_YUKA_ONLY_TYPES: tuple[MammotionSensorEntityDescription, ...] = ( + MammotionSensorEntityDescription( + key="camera_brightness", + state_class=None, + device_class=SensorDeviceClass.ENUM, + value_fn=lambda mower_data: camera_brightness( + mower_data.report_data.vision_info.brightness + ), + ), +) + SENSOR_TYPES: tuple[MammotionSensorEntityDescription, ...] = ( MammotionSensorEntityDescription( key="battery_percent", @@ -116,7 +128,7 @@ class MammotionSensorEntityDescription(SensorEntityDescription): key="area", state_class=SensorStateClass.MEASUREMENT, device_class=None, - native_unit_of_measurement=AREA_SQUARE_METERS, + native_unit_of_measurement=UnitOfArea.SQUARE_METERS, value_fn=lambda mower_data: mower_data.report_data.work.area & 65535, ), MammotionSensorEntityDescription( @@ -171,6 +183,13 @@ class MammotionSensorEntityDescription(SensorEntityDescription): value_fn=lambda mower_data: (mower_data.report_data.rtk.co_view_stars >> 8) & 255, ), + # MammotionSensorEntityDescription( + # key="vlsam_status", + # state_class=SensorStateClass.MEASUREMENT, + # device_class=None, + # native_unit_of_measurement=None, + # value_fn=lambda mower_data: (mower_data.report_data.dev.vslam_status & 65280) >> 8, + # ), MammotionSensorEntityDescription( key="activity_mode", state_class=None, @@ -184,7 +203,7 @@ class MammotionSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=None, value_fn=lambda mower_data: str( RTKStatus.from_value(mower_data.report_data.rtk.status) - ), # Note: This will not work for Luba2 & Yuka. Only for Luba1 + ), ), MammotionSensorEntityDescription( key="position_type", @@ -193,7 +212,7 @@ class MammotionSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=None, value_fn=lambda mower_data: str( PosType(mower_data.location.position_type).name - ), # Note: This will not work for Luba2 & Yuka. Only for Luba1 + ), ), MammotionSensorEntityDescription( key="work_area", @@ -229,13 +248,21 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up sensor platform.""" - coordinator = entry.runtime_data + mowers = entry.runtime_data + + for mower in mowers: + + if not DeviceType.is_yuka(mower.device_name): + async_add_entities( + MammotionSensorEntity(coordinator, description) + for description in LUBA_SENSOR_ONLY_TYPES + ) - if not DeviceType.is_yuka(coordinator.device_name): - async_add_entities( - MammotionSensorEntity(coordinator, description) - for description in LUBA_SENSOR_ONLY_TYPES - ) + if not DeviceType.is_luba1(coordinator.device_name): + async_add_entities( + MammotionSensorEntity(coordinator, description) + for description in LUBA_2_YUKA_ONLY_TYPES + ) async_add_entities( MammotionSensorEntity(coordinator, description) for description in SENSOR_TYPES @@ -261,5 +288,4 @@ def __init__( @property def native_value(self) -> StateType: """Return the state of the sensor.""" - current_value = self.entity_description.value_fn(self.coordinator.data) - return current_value + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/mammotion/switch.py b/homeassistant/components/mammotion/switch.py index e7eed3a402814..ddb9b47627880 100644 --- a/homeassistant/components/mammotion/switch.py +++ b/homeassistant/components/mammotion/switch.py @@ -1,12 +1,16 @@ +"""Support for Mammotion switches.""" + +from collections.abc import Awaitable, Callable from dataclasses import dataclass -from typing import Any, Awaitable, Callable, cast +from typing import Any + +from pymammotion.data.model.hash_list import AreaHashNameList +from pymammotion.utility.device_type import DeviceType from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.restore_state import RestoreEntity -from pymammotion.data.model.hash_list import AreaHashNameList -from pymammotion.utility.device_type import DeviceType from . import MammotionConfigEntry from .coordinator import MammotionDataUpdateCoordinator @@ -18,32 +22,34 @@ class MammotionSwitchEntityDescription(SwitchEntityDescription): """Describes Mammotion switch entity.""" key: str - set_fn: Callable[[MammotionDataUpdateCoordinator, bool], Awaitable[None]] @dataclass(frozen=True, kw_only=True) -class MammotionUpdateSwitchEntityDescription(SwitchEntityDescription): +class MammotionAsyncSwitchEntityDescription(MammotionSwitchEntityDescription): """Describes Mammotion switch entity.""" - key: str set_fn: Callable[[MammotionDataUpdateCoordinator, bool], Awaitable[None]] + + +@dataclass(frozen=True, kw_only=True) +class MammotionUpdateSwitchEntityDescription(MammotionAsyncSwitchEntityDescription): + """Describes Mammotion switch entity.""" + is_on_func: Callable[[MammotionDataUpdateCoordinator], bool] @dataclass(frozen=True, kw_only=True) -class MammotionConfigSwitchEntityDescription(SwitchEntityDescription): +class MammotionConfigSwitchEntityDescription(MammotionSwitchEntityDescription): """Describes Mammotion Config switch entity.""" - key: str set_fn: Callable[[MammotionDataUpdateCoordinator, bool], None] @dataclass(frozen=True, kw_only=True) -class MammotionConfigAreaSwitchEntityDescription(SwitchEntityDescription): +class MammotionConfigAreaSwitchEntityDescription(MammotionSwitchEntityDescription): """Describes the Areas entities.""" - key: str - area: int + area: str set_fn: Callable[[MammotionDataUpdateCoordinator, bool, int], None] @@ -68,12 +74,12 @@ class MammotionConfigAreaSwitchEntityDescription(SwitchEntityDescription): ), ) -SWITCH_ENTITIES: tuple[MammotionSwitchEntityDescription, ...] = ( - MammotionSwitchEntityDescription( +SWITCH_ENTITIES: tuple[MammotionAsyncSwitchEntityDescription, ...] = ( + MammotionAsyncSwitchEntityDescription( key="blade_status", set_fn=lambda coordinator, value: coordinator.async_start_stop_blades(value), ), - MammotionSwitchEntityDescription( + MammotionAsyncSwitchEntityDescription( key="side_led", set_fn=lambda coordinator, value: coordinator.async_set_sidelight(int(value)), ), @@ -91,7 +97,7 @@ class MammotionConfigAreaSwitchEntityDescription(SwitchEntityDescription): MammotionConfigSwitchEntityDescription( key="rain_tactics", set_fn=lambda coordinator, value: setattr( - coordinator.operation_settings, "rain_tactics", cast(value, int) + coordinator.operation_settings, "rain_tactics", int(value) ), ), ) @@ -116,23 +122,25 @@ def add_entities() -> None: new_areas = (set(areas) | set(area_name_hashes)) - added_areas if new_areas: for area_id in new_areas: - existing_name: AreaHashNameList = next( + existing_name: AreaHashNameList | None = next( (area for area in area_name if str(area.hash) == str(area_id)), None ) name = ( existing_name.name - if (existing_name is None or existing_name != "") - else f"Area {area_id}" + if (existing_name and existing_name.name) + else f"{area_id}" ) base_area_switch_entity = MammotionConfigAreaSwitchEntityDescription( key=f"{area_id}", + translation_key="area", + translation_placeholders={"name": name}, area=area_id, name=f"{name}", - set_fn=lambda coord, - bool_val, - value: coord.operation_settings.areas.append(value) - if bool_val - else coord.operation_settings.areas.remove(value), + set_fn=lambda coord, bool_val, value: ( + coord.operation_settings.areas.append(value) + if bool_val + else coord.operation_settings.areas.remove(value) + ), ) switch_entities.append( MammotionConfigAreaSwitchEntity( @@ -276,7 +284,6 @@ def __init__( super().__init__(coordinator, entity_description.key) self.coordinator = coordinator self.entity_description = entity_description - self._attr_translation_key = entity_description.key # TODO this should not need to be cast. self._attr_extra_state_attributes = {"hash": entity_description.area} # TODO grab defaults from operation_settings @@ -301,6 +308,3 @@ async def async_turn_off(self, **kwargs: Any) -> None: int(self.entity_description.area), ) self.async_write_ha_state() - - async def async_update(self) -> None: - """Update the entity state.""" diff --git a/requirements_all.txt b/requirements_all.txt index 10dc03d64662f..e44bfb83bd904 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2334,7 +2334,7 @@ pylutron==0.4.1 pymailgunner==1.4 # homeassistant.components.mammotion -pymammotion==0.2.77 +pymammotion==0.4.0a3 # homeassistant.components.firmata pymata-express==1.19 From ccfd5796c79d14fe1bd42717e37d9ada85c52dd3 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Sun, 16 Feb 2025 18:38:38 +1300 Subject: [PATCH 15/66] initially working now --- .../components/mammotion/__init__.py | 26 ++- .../components/mammotion/binary_sensor.py | 18 +-- homeassistant/components/mammotion/button.py | 18 +-- .../components/mammotion/coordinator.py | 45 +++--- .../components/mammotion/device_tracker.py | 11 +- homeassistant/components/mammotion/entity.py | 35 ++-- .../components/mammotion/lawn_mower.py | 21 ++- .../components/mammotion/manifest.json | 2 +- homeassistant/components/mammotion/models.py | 5 +- homeassistant/components/mammotion/number.py | 44 +++--- homeassistant/components/mammotion/select.py | 62 +++++--- homeassistant/components/mammotion/sensor.py | 48 +++--- homeassistant/components/mammotion/switch.py | 149 ++++++++++-------- requirements_all.txt | 2 +- 14 files changed, 255 insertions(+), 231 deletions(-) diff --git a/homeassistant/components/mammotion/__init__.py b/homeassistant/components/mammotion/__init__.py index 9b28a255ca9d4..1038252dec46b 100644 --- a/homeassistant/components/mammotion/__init__.py +++ b/homeassistant/components/mammotion/__init__.py @@ -61,7 +61,7 @@ Platform.SELECT, ] -type MammotionConfigEntry = ConfigEntry[MammotionDevices] +type MammotionConfigEntry = ConfigEntry[list[MammotionMowerData]] async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> bool: @@ -129,17 +129,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> await version_coordinator.async_config_entry_first_refresh() await report_coordinator.async_config_entry_first_refresh() - # maintenance_coordinator. - device_info = DeviceInfo( - identifiers={(DOMAIN, device.deviceName)}, - manufacturer="Mammotion", - serial_number=device.deviceName.split("-", 1)[-1], - model_id=device.productModel, - name=device.nickName, - model=device.productName, - suggested_area="Garden", - ) - device_config = DeviceConfig() if ( device_limits := device_config.get_working_parameters( @@ -147,14 +136,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> ) is None ): - device_limits = device_config.get_working_parameters( - version_coordinator.data.main_product_type - ) + if version_coordinator.data.model_id == "": + device_limits = device_config.get_best_default( + device.productKey + ) + else: + device_limits = device_config.get_working_parameters( + version_coordinator.data.model_id + ) mammotion_devices.append( MammotionMowerData( name=device.deviceName, - device=device_info, + device=device, device_limits=device_limits, api=mammotion, maintenance_coordinator=maintenance_coordinator, diff --git a/homeassistant/components/mammotion/binary_sensor.py b/homeassistant/components/mammotion/binary_sensor.py index 36f521ad5d09c..bbbc873f6dbd1 100644 --- a/homeassistant/components/mammotion/binary_sensor.py +++ b/homeassistant/components/mammotion/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import MammotionConfigEntry -from .coordinator import MammotionDataUpdateCoordinator +from .coordinator import MammotionBaseUpdateCoordinator from .entity import MammotionBaseEntity @@ -31,8 +31,7 @@ class MammotionBinarySensorEntityDescription( MammotionBinarySensorEntityDescription( key="charging", device_class=BinarySensorDeviceClass.BATTERY_CHARGING, - is_on_fn=lambda mower_data: mower_data.sys.toapp_report_data.dev.charge_state - in (1, 2), + is_on_fn=lambda mower_data: mower_data.report_data.dev.charge_state in (1, 2), ), ) @@ -49,12 +48,13 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Mammotion sensor entity.""" - coordinator = entry.runtime_data + mammotion_devices = entry.runtime_data - async_add_entities( - MammotionBinarySensorEntity(coordinator, entity_description) - for entity_description in BINARY_SENSORS - ) + for mower in mammotion_devices: + async_add_entities( + MammotionBinarySensorEntity(mower.reporting_coordinator, entity_description) + for entity_description in BINARY_SENSORS + ) class MammotionBinarySensorEntity(MammotionBaseEntity, BinarySensorEntity): @@ -64,7 +64,7 @@ class MammotionBinarySensorEntity(MammotionBaseEntity, BinarySensorEntity): def __init__( self, - coordinator: MammotionDataUpdateCoordinator, + coordinator: MammotionBaseUpdateCoordinator, entity_description: MammotionBinarySensorEntityDescription, ) -> None: """Initialize the binary sensor entity.""" diff --git a/homeassistant/components/mammotion/button.py b/homeassistant/components/mammotion/button.py index 337c6a7b9cf5d..d0b7a647f7e86 100644 --- a/homeassistant/components/mammotion/button.py +++ b/homeassistant/components/mammotion/button.py @@ -1,15 +1,14 @@ """Mammotion button sensor entities.""" -from collections.abc import Callable +from collections.abc import Awaitable, Callable from dataclasses import dataclass -from typing import Awaitable from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import MammotionConfigEntry -from .coordinator import MammotionDataUpdateCoordinator +from .coordinator import MammotionBaseUpdateCoordinator, MammotionDataUpdateCoordinator from .entity import MammotionBaseEntity @@ -66,12 +65,13 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Mammotion button sensor entity.""" - coordinator = entry.runtime_data + mammotion_devices = entry.runtime_data - async_add_entities( - MammotionButtonSensorEntity(coordinator, entity_description) - for entity_description in BUTTON_SENSORS - ) + for mower in mammotion_devices: + async_add_entities( + MammotionButtonSensorEntity(mower.reporting_coordinator, entity_description) + for entity_description in BUTTON_SENSORS + ) class MammotionButtonSensorEntity(MammotionBaseEntity, ButtonEntity): @@ -82,7 +82,7 @@ class MammotionButtonSensorEntity(MammotionBaseEntity, ButtonEntity): def __init__( self, - coordinator: MammotionDataUpdateCoordinator, + coordinator: MammotionBaseUpdateCoordinator, entity_description: MammotionButtonSensorEntityDescription, ) -> None: """Initialize the button sensor entity.""" diff --git a/homeassistant/components/mammotion/coordinator.py b/homeassistant/components/mammotion/coordinator.py index 251876abb7ede..fa1d01c251639 100644 --- a/homeassistant/components/mammotion/coordinator.py +++ b/homeassistant/components/mammotion/coordinator.py @@ -24,7 +24,7 @@ ) from pymammotion.data.model import GenerateRouteInformation, HashList from pymammotion.data.model.account import Credentials -from pymammotion.data.model.device import MowingDevice +from pymammotion.data.model.device import MowerInfo, MowingDevice from pymammotion.data.model.device_config import OperationSettings, create_path_order from pymammotion.data.model.report_info import Maintain from pymammotion.http.http import MammotionHTTP @@ -173,21 +173,19 @@ async def check_firmware_version(self) -> None: if device_entry is None: return - new_swversion = None - if len(mower.net.toapp_devinfo_resp.resp_ids) > 0: - new_swversion = mower.net.toapp_devinfo_resp.resp_ids[0].info + new_swversion = self.data.mower_state.sw_version if new_swversion is not None or new_swversion != device_entry.sw_version: device_registry.async_update_device( device_entry.id, sw_version=new_swversion ) - model_id = None - if has_field(mower.sys.device_product_type_info): - model_id = mower.sys.device_product_type_info.main_product_type - - if model_id is not None or model_id != device_entry.model_id: - device_registry.async_update_device(device_entry.id, model_id=model_id) + # model_id = None + # if has_field(mower.sys.device_product_type_info): + # model_id = mower.sys.device_product_type_info.main_product_type + # + # if model_id is not None or model_id != device_entry.model_id: + # device_registry.async_update_device(device_entry.id, model_id=model_id) def store_cloud_credentials(self) -> None: """Store cloud credentials in config entry.""" @@ -333,7 +331,7 @@ async def async_setup(self) -> None: if self.device_name is not None: device = self.manager.get_device_by_name(self.device_name) - elif device_name := next(iter(self.manager.devices.devices.keys())): + elif device_name := next(iter(self.manager.device_manager.devices.keys())): self.device_name = device_name device = self.manager.get_device_by_name(device_name) else: @@ -366,6 +364,11 @@ async def async_setup(self) -> None: except COMMAND_EXCEPTIONS as exc: raise ConfigEntryNotReady("Unable to setup Mammotion device") from exc + @property + def operation_settings(self) -> OperationSettings: + """Return operation settings for planning.""" + return self._operation_settings + # async def async_restore_data(self) -> None: # """Restore saved data.""" # store = Store(self.hass, version=1, key=self.device_name) @@ -434,7 +437,8 @@ async def _async_update_data(self) -> MowingDevice: except DeviceOfflineException: """Device is offline try bluetooth if we have it.""" - return self.data + data = self.manager.get_device_by_name(self.device_name).mower_state + return data LOGGER.debug("Updated Mammotion device %s", self.device_name) LOGGER.debug("================= Debug Log =================") @@ -717,7 +721,8 @@ async def _async_update_data(self) -> Maintain: except DeviceOfflineException: """Device is offline try bluetooth if we have it.""" - return self.data + data = self.manager.get_device_by_name(self.device_name).mower_state + return data self.updated_once = True @@ -726,7 +731,9 @@ async def _async_update_data(self) -> Maintain: ).mower_state.report_data.maintenance -class MammotionDeviceVersionUpdateCoordinator(MammotionBaseUpdateCoordinator[Maintain]): +class MammotionDeviceVersionUpdateCoordinator( + MammotionBaseUpdateCoordinator[MowerInfo] +): """Class to manage fetching mammotion data.""" def __init__( @@ -758,10 +765,12 @@ async def _async_update_data(self): await self.check_firmware_version() except DeviceOfflineException: """Device is offline try bluetooth if we have it.""" - return self.data + data = self.manager.get_device_by_name( + self.device_name + ).mower_state.mower_state + return data + data = self.manager.get_device_by_name(self.device_name).mower_state.mower_state self.updated_once = True - return self.manager.get_device_by_name( - self.device.deviceName - ).mower_state.sys.device_product_type_info + return data diff --git a/homeassistant/components/mammotion/device_tracker.py b/homeassistant/components/mammotion/device_tracker.py index f30170cdf6fc8..5be227f7a0030 100644 --- a/homeassistant/components/mammotion/device_tracker.py +++ b/homeassistant/components/mammotion/device_tracker.py @@ -10,7 +10,7 @@ from . import MammotionConfigEntry from .const import ATTR_DIRECTION -from .coordinator import MammotionDataUpdateCoordinator +from .coordinator import MammotionBaseUpdateCoordinator from .entity import MammotionBaseEntity _LOGGER = logging.getLogger(__name__) @@ -18,13 +18,14 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: MammotionConfigEntry, + entry: MammotionConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the RTK tracker from config entry.""" - coordinator = config_entry.runtime_data + mammotion_devices = entry.runtime_data - async_add_entities([MammotionTracker(coordinator)]) + for mower in mammotion_devices: + async_add_entities([MammotionTracker(mower.reporting_coordinator)]) class MammotionTracker(MammotionBaseEntity, TrackerEntity, RestoreEntity): @@ -34,7 +35,7 @@ class MammotionTracker(MammotionBaseEntity, TrackerEntity, RestoreEntity): _attr_translation_key = "device_tracker" _attr_source_type = SourceType.GPS - def __init__(self, coordinator: MammotionDataUpdateCoordinator) -> None: + def __init__(self, coordinator: MammotionBaseUpdateCoordinator) -> None: """Initialize the Tracker.""" super().__init__(coordinator, f"{coordinator.device_name}_gps") diff --git a/homeassistant/components/mammotion/entity.py b/homeassistant/components/mammotion/entity.py index c1b89d26e9ec0..846c675176d6e 100644 --- a/homeassistant/components/mammotion/entity.py +++ b/homeassistant/components/mammotion/entity.py @@ -2,31 +2,27 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from pymammotion.proto import has_field -from pymammotion.utility.device_type import DeviceType from .const import CONF_ACCOUNTNAME, CONF_RETRY_COUNT, DEFAULT_RETRY_COUNT, DOMAIN -from .coordinator import MammotionDataUpdateCoordinator +from .coordinator import MammotionBaseUpdateCoordinator -class MammotionBaseEntity(CoordinatorEntity[MammotionDataUpdateCoordinator]): +class MammotionBaseEntity(CoordinatorEntity[MammotionBaseUpdateCoordinator]): """Representation of a Luba lawn mower.""" _attr_has_entity_name = True - def __init__(self, coordinator: MammotionDataUpdateCoordinator, key: str) -> None: + def __init__(self, coordinator: MammotionBaseUpdateCoordinator, key: str) -> None: """Initialize the lawn mower.""" super().__init__(coordinator) self._attr_unique_id = f"{coordinator.device_name}_{key}" @property def device_info(self) -> DeviceInfo: - mower = self.coordinator.manager.mower(self.coordinator.device_name) - swversion = None - if len(mower.net.toapp_devinfo_resp.resp_ids) > 0: - swversion = mower.net.toapp_devinfo_resp.resp_ids[0].info + mower = self.coordinator.data + swversion = mower.mower_state.swversion - product_key = mower.net.toapp_wifi_iot_status.productkey + product_key = mower.mower_state.product_key if product_key is None or product_key == "": if self.coordinator.manager.mqtt_list.get( self.coordinator.config_entry.data.get(CONF_ACCOUNTNAME) @@ -39,31 +35,26 @@ def device_info(self) -> DeviceInfo: device = [ device for device in device_list - if device.deviceName == self.coordinator.device_name + if device.deviceName == self.coordinator.device.deviceName ].pop() - product_key = device.productKey - - device_model = DeviceType.value_of_str( - self.coordinator.device_name, - product_key, - ).get_model() + mower.mower_state.product_key = device.productKey model_id = None if mower is not None: - if has_field(mower.sys.device_product_type_info): - model_id = mower.sys.device_product_type_info.main_product_type + if mower.mower_state.model_id != "": + model_id = mower.mower_state.model_id if mower.mqtt_properties is not None: model_id = mower.mqtt_properties.params.items.extMod.value return DeviceInfo( - identifiers={(DOMAIN, self.coordinator.device_name)}, + identifiers={(DOMAIN, self.coordinator.device.deviceName)}, manufacturer="Mammotion", serial_number=self.coordinator.device_name.split("-", 1)[-1], model_id=model_id, - name=self.coordinator.device_name, + name=self.coordinator.device.nickName, sw_version=swversion, - model=device_model, + model=self.coordinator.device.productModel, suggested_area="Garden", ) diff --git a/homeassistant/components/mammotion/lawn_mower.py b/homeassistant/components/mammotion/lawn_mower.py index 4351e1963e65e..abd3584341756 100644 --- a/homeassistant/components/mammotion/lawn_mower.py +++ b/homeassistant/components/mammotion/lawn_mower.py @@ -5,9 +5,7 @@ from typing import Any from pymammotion.data.model.device_config import OperationSettings -from pymammotion.data.model.report_info import ReportData -from pymammotion.proto import has_field -from pymammotion.proto.luba_msg import RptDevStatus +from pymammotion.data.model.report_info import DeviceData, ReportData from pymammotion.utility.constant.device_constant import WorkMode from pymammotion.utility.device_type import DeviceType import voluptuous as vol @@ -22,9 +20,8 @@ from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import MammotionConfigEntry +from . import MammotionConfigEntry, MammotionReportUpdateCoordinator from .const import COMMAND_EXCEPTIONS, DOMAIN, LOGGER -from .coordinator import MammotionDataUpdateCoordinator from .entity import MammotionBaseEntity SERVICE_START_MOWING = "start_mow" @@ -90,8 +87,10 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Luba config entry.""" - coordinator = entry.runtime_data - async_add_entities([MammotionLawnMowerEntity(coordinator)]) + mammotion_devices = entry.runtime_data + + for mower in mammotion_devices: + async_add_entities([MammotionLawnMowerEntity(mower.reporting_coordinator)]) platform = entity_platform.async_get_current_platform() @@ -111,17 +110,15 @@ class MammotionLawnMowerEntity(MammotionBaseEntity, LawnMowerEntity): | LawnMowerEntityFeature.START_MOWING ) - def __init__(self, coordinator: MammotionDataUpdateCoordinator) -> None: + def __init__(self, coordinator: MammotionReportUpdateCoordinator) -> None: """Initialize the lawn mower.""" super().__init__(coordinator, "mower") self._attr_name = None # main feature of device @property - def rpt_dev_status(self) -> RptDevStatus: + def rpt_dev_status(self) -> DeviceData: """Return the device status.""" - if has_field(self.coordinator.data.sys.toapp_report_data.dev): - return self.coordinator.data.sys.toapp_report_data.dev - return RptDevStatus() + return self.coordinator.data.report_data.dev @property def report_data(self) -> ReportData: diff --git a/homeassistant/components/mammotion/manifest.json b/homeassistant/components/mammotion/manifest.json index 73ddf8bc70a57..6ce6896f8577b 100644 --- a/homeassistant/components/mammotion/manifest.json +++ b/homeassistant/components/mammotion/manifest.json @@ -20,5 +20,5 @@ "loggers": ["pymammotion"], "integration_type": "device", "iot_class": "local_push", - "requirements": ["pymammotion==0.4.0a3"] + "requirements": ["pymammotion==0.4.0a4"] } diff --git a/homeassistant/components/mammotion/models.py b/homeassistant/components/mammotion/models.py index 33a9c03a4fb5d..01dfa16d8931f 100644 --- a/homeassistant/components/mammotion/models.py +++ b/homeassistant/components/mammotion/models.py @@ -1,10 +1,9 @@ from dataclasses import dataclass +from pymammotion.aliyun.model.dev_by_account_response import Device from pymammotion.data.model.device_limits import DeviceLimits from pymammotion.mammotion.devices.mammotion import Mammotion -from homeassistant.helpers.device_registry import DeviceInfo - from . import ( MammotionDeviceVersionUpdateCoordinator, MammotionMaintenanceUpdateCoordinator, @@ -22,7 +21,7 @@ class MammotionMowerData: reporting_coordinator: MammotionReportUpdateCoordinator version_coordinator: MammotionDeviceVersionUpdateCoordinator device_limits: DeviceLimits - device: DeviceInfo + device: Device @dataclass diff --git a/homeassistant/components/mammotion/number.py b/homeassistant/components/mammotion/number.py index c6df50e689cbf..05c590afb56cb 100644 --- a/homeassistant/components/mammotion/number.py +++ b/homeassistant/components/mammotion/number.py @@ -128,31 +128,39 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Mammotion number entities.""" - coordinator = entry.runtime_data - limits = coordinator.manager.mower(coordinator.device_name).limits + mammotion_devices = entry.runtime_data - entities: list[MammotionConfigNumberEntity] = [] + for mower in mammotion_devices: + limits = mower.device_limits - for entity_description in NUMBER_WORKING_ENTITIES: - entity = MammotionWorkingNumberEntity(coordinator, entity_description, limits) - entities.append(entity) + entities: list[MammotionConfigNumberEntity] = [] - for entity_description in NUMBER_ENTITIES: - entity = MammotionConfigNumberEntity(coordinator, entity_description) - entities.append(entity) - - if DeviceType.is_yuka(coordinator.device_name): - for entity_description in YUKA_NUMBER_ENTITIES: - entity = MammotionConfigNumberEntity(coordinator, entity_description) - entities.append(entity) - else: - for entity_description in LUBA_WORKING_ENTITIES: + for entity_description in NUMBER_WORKING_ENTITIES: entity = MammotionWorkingNumberEntity( - coordinator, entity_description, limits + mower.reporting_coordinator, entity_description, limits ) entities.append(entity) - async_add_entities(entities) + for entity_description in NUMBER_ENTITIES: + entity = MammotionConfigNumberEntity( + mower.reporting_coordinator, entity_description + ) + entities.append(entity) + + if DeviceType.is_yuka(mower.device.deviceName): + for entity_description in YUKA_NUMBER_ENTITIES: + entity = MammotionConfigNumberEntity( + mower.reporting_coordinator, entity_description + ) + entities.append(entity) + else: + for entity_description in LUBA_WORKING_ENTITIES: + entity = MammotionWorkingNumberEntity( + mower.reporting_coordinator, entity_description, limits + ) + entities.append(entity) + + async_add_entities(entities) class MammotionConfigNumberEntity(MammotionBaseEntity, NumberEntity, RestoreEntity): diff --git a/homeassistant/components/mammotion/select.py b/homeassistant/components/mammotion/select.py index 166508927b47d..bc14416eac213 100644 --- a/homeassistant/components/mammotion/select.py +++ b/homeassistant/components/mammotion/select.py @@ -1,4 +1,4 @@ -from collections.abc import Awaitable, Callable +from collections.abc import Callable from dataclasses import dataclass from pymammotion.data.model.mowing_modes import ( @@ -18,8 +18,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from . import MammotionConfigEntry -from .coordinator import MammotionDataUpdateCoordinator +from . import MammotionConfigEntry, MammotionReportUpdateCoordinator +from .coordinator import MammotionBaseUpdateCoordinator from .entity import MammotionBaseEntity @@ -29,7 +29,7 @@ class MammotionConfigSelectEntityDescription(SelectEntityDescription): key: str options: list[str] - set_fn: Callable[[MammotionDataUpdateCoordinator, str], Awaitable[None]] + set_fn: Callable[[MammotionBaseUpdateCoordinator, str], None] @dataclass(frozen=True, kw_only=True) @@ -38,7 +38,7 @@ class MammotionAsyncConfigSelectEntityDescription(MammotionBaseEntity, SelectEnt key: str options: list[str] - set_fn: Callable[[MammotionDataUpdateCoordinator, str], None] + set_fn: Callable[[MammotionBaseUpdateCoordinator, str], None] ASYNC_SELECT_ENTITIES: tuple[MammotionAsyncConfigSelectEntityDescription, ...] = ( @@ -133,29 +133,41 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Mammotion select entity.""" - coordinator = entry.runtime_data - entities = [] + mammotion_devices = entry.runtime_data - for entity_description in SELECT_ENTITIES: - entities.append(MammotionConfigSelectEntity(coordinator, entity_description)) + for mower in mammotion_devices: + entities = [] - for entity_description in ASYNC_SELECT_ENTITIES: - entities.append( - MammotionAsyncConfigSelectEntity(coordinator, entity_description) - ) - - if DeviceType.is_luba1(coordinator.device_name): - for entity_description in LUBA1_SELECT_ENTITIES: + for entity_description in SELECT_ENTITIES: entities.append( - MammotionConfigSelectEntity(coordinator, entity_description) + MammotionConfigSelectEntity( + mower.reporting_coordinator, entity_description + ) ) - else: - for entity_description in LUBA_PRO_SELECT_ENTITIES: + + for entity_description in ASYNC_SELECT_ENTITIES: entities.append( - MammotionConfigSelectEntity(coordinator, entity_description) + MammotionAsyncConfigSelectEntity( + mower.reporting_coordinator, entity_description + ) ) - async_add_entities(entities) + if DeviceType.is_luba1(mower.device.deviceName): + for entity_description in LUBA1_SELECT_ENTITIES: + entities.append( + MammotionConfigSelectEntity( + mower.reporting_coordinator, entity_description + ) + ) + else: + for entity_description in LUBA_PRO_SELECT_ENTITIES: + entities.append( + MammotionConfigSelectEntity( + mower.reporting_coordinator, entity_description + ) + ) + + async_add_entities(entities) # Define the select entity class with entity_category: config @@ -169,7 +181,7 @@ class MammotionConfigSelectEntity(MammotionBaseEntity, SelectEntity, RestoreEnti def __init__( self, - coordinator: MammotionDataUpdateCoordinator, + coordinator: MammotionReportUpdateCoordinator, entity_description: MammotionConfigSelectEntityDescription, ) -> None: super().__init__(coordinator, entity_description.key) @@ -193,13 +205,13 @@ class MammotionAsyncConfigSelectEntity( _attr_entity_category = EntityCategory.CONFIG - entity_description: MammotionConfigSelectEntityDescription + entity_description: MammotionAsyncConfigSelectEntityDescription _attr_has_entity_name = True def __init__( self, - coordinator: MammotionDataUpdateCoordinator, - entity_description: MammotionConfigSelectEntityDescription, + coordinator: MammotionReportUpdateCoordinator, + entity_description: MammotionAsyncConfigSelectEntityDescription, ) -> None: super().__init__(coordinator, entity_description.key) self.coordinator = coordinator diff --git a/homeassistant/components/mammotion/sensor.py b/homeassistant/components/mammotion/sensor.py index a3d2fafc2488e..080bd4a392be6 100644 --- a/homeassistant/components/mammotion/sensor.py +++ b/homeassistant/components/mammotion/sensor.py @@ -3,6 +3,16 @@ from collections.abc import Callable from dataclasses import dataclass +from pymammotion.data.model.device import MowingDevice +from pymammotion.data.model.enums import RTKStatus +from pymammotion.utility.constant.device_constant import ( + PosType, + camera_brightness, + device_connection, + device_mode, +) +from pymammotion.utility.device_type import DeviceType + from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -10,29 +20,19 @@ SensorStateClass, ) from homeassistant.const import ( - AREA_SQUARE_METERS, PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + UnitOfArea, UnitOfLength, UnitOfSpeed, - UnitOfTime, UnitOfArea, + UnitOfTime, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.unit_conversion import SpeedConverter -from pymammotion.data.model.device import MowingDevice -from pymammotion.data.model.enums import RTKStatus -from pymammotion.utility.constant.device_constant import ( - PosType, - camera_brightness, - device_connection, - device_mode, -) -from pymammotion.utility.device_type import DeviceType -from . import MammotionConfigEntry -from .coordinator import MammotionDataUpdateCoordinator +from . import MammotionConfigEntry, MammotionReportUpdateCoordinator from .entity import MammotionBaseEntity SPEED_UNITS = SpeedConverter.VALID_UNITS @@ -248,25 +248,25 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up sensor platform.""" - mowers = entry.runtime_data - - for mower in mowers: + mammotion_devices = entry.runtime_data - if not DeviceType.is_yuka(mower.device_name): + for mower in mammotion_devices: + if not DeviceType.is_yuka(mower.device.deviceName): async_add_entities( - MammotionSensorEntity(coordinator, description) + MammotionSensorEntity(mower.reporting_coordinator, description) for description in LUBA_SENSOR_ONLY_TYPES ) - if not DeviceType.is_luba1(coordinator.device_name): + if not DeviceType.is_luba1(mower.device.deviceName): async_add_entities( - MammotionSensorEntity(coordinator, description) + MammotionSensorEntity(mower.reporting_coordinator, description) for description in LUBA_2_YUKA_ONLY_TYPES ) - async_add_entities( - MammotionSensorEntity(coordinator, description) for description in SENSOR_TYPES - ) + async_add_entities( + MammotionSensorEntity(mower.reporting_coordinator, description) + for description in SENSOR_TYPES + ) class MammotionSensorEntity(MammotionBaseEntity, SensorEntity): @@ -277,7 +277,7 @@ class MammotionSensorEntity(MammotionBaseEntity, SensorEntity): def __init__( self, - coordinator: MammotionDataUpdateCoordinator, + coordinator: MammotionReportUpdateCoordinator, description: MammotionSensorEntityDescription, ) -> None: """Set up MammotionSensor.""" diff --git a/homeassistant/components/mammotion/switch.py b/homeassistant/components/mammotion/switch.py index ddb9b47627880..dfe4eba742a31 100644 --- a/homeassistant/components/mammotion/switch.py +++ b/homeassistant/components/mammotion/switch.py @@ -13,7 +13,7 @@ from homeassistant.helpers.restore_state import RestoreEntity from . import MammotionConfigEntry -from .coordinator import MammotionDataUpdateCoordinator +from .coordinator import MammotionBaseUpdateCoordinator from .entity import MammotionBaseEntity @@ -28,21 +28,21 @@ class MammotionSwitchEntityDescription(SwitchEntityDescription): class MammotionAsyncSwitchEntityDescription(MammotionSwitchEntityDescription): """Describes Mammotion switch entity.""" - set_fn: Callable[[MammotionDataUpdateCoordinator, bool], Awaitable[None]] + set_fn: Callable[[MammotionBaseUpdateCoordinator, bool], Awaitable[None]] @dataclass(frozen=True, kw_only=True) class MammotionUpdateSwitchEntityDescription(MammotionAsyncSwitchEntityDescription): """Describes Mammotion switch entity.""" - is_on_func: Callable[[MammotionDataUpdateCoordinator], bool] + is_on_func: Callable[[MammotionBaseUpdateCoordinator], bool] @dataclass(frozen=True, kw_only=True) class MammotionConfigSwitchEntityDescription(MammotionSwitchEntityDescription): """Describes Mammotion Config switch entity.""" - set_fn: Callable[[MammotionDataUpdateCoordinator, bool], None] + set_fn: Callable[[MammotionBaseUpdateCoordinator, bool], None] @dataclass(frozen=True, kw_only=True) @@ -50,7 +50,7 @@ class MammotionConfigAreaSwitchEntityDescription(MammotionSwitchEntityDescriptio """Describes the Areas entities.""" area: str - set_fn: Callable[[MammotionDataUpdateCoordinator, bool, int], None] + set_fn: Callable[[MammotionBaseUpdateCoordinator, bool, int], None] YUKA_CONFIG_SWITCH_ENTITIES: tuple[MammotionConfigSwitchEntityDescription, ...] = ( @@ -108,72 +108,85 @@ async def async_setup_entry( hass: HomeAssistant, entry: MammotionConfigEntry, async_add_entities: Callable ) -> None: """Set up the Mammotion switch entities.""" - coordinator = entry.runtime_data - added_areas: set[str] = set() - - @callback - def add_entities() -> None: - """Handle addition of mowing areas.""" - - switch_entities: list[MammotionConfigAreaSwitchEntity] = [] - areas = list(map(str, coordinator.data.map.area.keys())) - area_name_hashes = [f"{area.hash}" for area in coordinator.data.map.area_name] - area_name = coordinator.data.map.area_name - new_areas = (set(areas) | set(area_name_hashes)) - added_areas - if new_areas: - for area_id in new_areas: - existing_name: AreaHashNameList | None = next( - (area for area in area_name if str(area.hash) == str(area_id)), None - ) - name = ( - existing_name.name - if (existing_name and existing_name.name) - else f"{area_id}" - ) - base_area_switch_entity = MammotionConfigAreaSwitchEntityDescription( - key=f"{area_id}", - translation_key="area", - translation_placeholders={"name": name}, - area=area_id, - name=f"{name}", - set_fn=lambda coord, bool_val, value: ( - coord.operation_settings.areas.append(value) - if bool_val - else coord.operation_settings.areas.remove(value) - ), - ) - switch_entities.append( - MammotionConfigAreaSwitchEntity( - coordinator, - base_area_switch_entity, + mammotion_devices = entry.runtime_data + + for mower in mammotion_devices: + added_areas: set[str] = set() + + coordinator = mower.reporting_coordinator + + @callback + def add_entities() -> None: + """Handle addition of mowing areas.""" + if coordinator.data is None: + return + + switch_entities: list[MammotionConfigAreaSwitchEntity] = [] + areas = list(map(str, coordinator.data.map.area.keys())) + area_name_hashes = [ + f"{area.hash}" for area in coordinator.data.map.area_name + ] + area_name = coordinator.data.map.area_name + new_areas = (set(areas) | set(area_name_hashes)) - added_areas + if new_areas: + for area_id in new_areas: + existing_name: AreaHashNameList | None = next( + (area for area in area_name if str(area.hash) == str(area_id)), + None, ) - ) - added_areas.add(area_id) - - if switch_entities: - async_add_entities(switch_entities) - - add_entities() - coordinator.async_add_listener(add_entities) + name = ( + existing_name.name + if (existing_name and existing_name.name) + else f"{area_id}" + ) + base_area_switch_entity = ( + MammotionConfigAreaSwitchEntityDescription( + key=f"{area_id}", + translation_key="area", + translation_placeholders={"name": name}, + area=area_id, + name=f"{name}", + set_fn=lambda coord, bool_val, value: ( + coord.operation_settings.areas.append(value) + if bool_val + else coord.operation_settings.areas.remove(value) + ), + ) + ) + switch_entities.append( + MammotionConfigAreaSwitchEntity( + coordinator, + base_area_switch_entity, + ) + ) + added_areas.add(area_id) - entities = [] - for entity_description in SWITCH_ENTITIES: - entity = MammotionSwitchEntity(coordinator, entity_description) - entities.append(entity) + if switch_entities: + async_add_entities(switch_entities) - for entity_description in CONFIG_SWITCH_ENTITIES: - config_entity = MammotionConfigSwitchEntity(coordinator, entity_description) - entities.append(config_entity) + add_entities() + coordinator.async_add_listener(add_entities) - for entity_description in UPDATE_SWITCH_ENTITIES: - config_entity = MammotionUpdateSwitchEntity(coordinator, entity_description) - entities.append(config_entity) + entities = [] + for entity_description in SWITCH_ENTITIES: + entity = MammotionSwitchEntity(coordinator, entity_description) + entities.append(entity) - if DeviceType.is_yuka(coordinator.device_name): - for entity_description in YUKA_CONFIG_SWITCH_ENTITIES: + for entity_description in CONFIG_SWITCH_ENTITIES: config_entity = MammotionConfigSwitchEntity(coordinator, entity_description) entities.append(config_entity) - async_add_entities(entities) + + for entity_description in UPDATE_SWITCH_ENTITIES: + config_entity = MammotionUpdateSwitchEntity(coordinator, entity_description) + entities.append(config_entity) + + if DeviceType.is_yuka(coordinator.device_name): + for entity_description in YUKA_CONFIG_SWITCH_ENTITIES: + config_entity = MammotionConfigSwitchEntity( + coordinator, entity_description + ) + entities.append(config_entity) + async_add_entities(entities) class MammotionSwitchEntity(MammotionBaseEntity, SwitchEntity): @@ -182,7 +195,7 @@ class MammotionSwitchEntity(MammotionBaseEntity, SwitchEntity): def __init__( self, - coordinator: MammotionDataUpdateCoordinator, + coordinator: MammotionBaseUpdateCoordinator, entity_description: MammotionSwitchEntityDescription, ) -> None: super().__init__(coordinator, entity_description.key) @@ -211,7 +224,7 @@ class MammotionUpdateSwitchEntity(MammotionBaseEntity, SwitchEntity, RestoreEnti def __init__( self, - coordinator: MammotionDataUpdateCoordinator, + coordinator: MammotionBaseUpdateCoordinator, entity_description: MammotionUpdateSwitchEntityDescription, ) -> None: super().__init__(coordinator, entity_description.key) @@ -242,7 +255,7 @@ class MammotionConfigSwitchEntity(MammotionBaseEntity, SwitchEntity, RestoreEnti def __init__( self, - coordinator: MammotionDataUpdateCoordinator, + coordinator: MammotionBaseUpdateCoordinator, entity_description: MammotionConfigSwitchEntityDescription, ) -> None: super().__init__(coordinator, entity_description.key) @@ -278,7 +291,7 @@ class MammotionConfigAreaSwitchEntity(MammotionBaseEntity, SwitchEntity, Restore def __init__( self, - coordinator: MammotionDataUpdateCoordinator, + coordinator: MammotionBaseUpdateCoordinator, entity_description: MammotionConfigAreaSwitchEntityDescription, ) -> None: super().__init__(coordinator, entity_description.key) diff --git a/requirements_all.txt b/requirements_all.txt index e44bfb83bd904..f078b6bd80e59 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2334,7 +2334,7 @@ pylutron==0.4.1 pymailgunner==1.4 # homeassistant.components.mammotion -pymammotion==0.4.0a3 +pymammotion==0.4.0a4 # homeassistant.components.firmata pymata-express==1.19 From a6c18e8ccb6f0c1a39c96665b8eb348f05826645 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Wed, 19 Feb 2025 17:53:41 +1300 Subject: [PATCH 16/66] few more tweaks so timeouts get retried and we now have online / offline state for mowers --- .../components/mammotion/__init__.py | 3 ++ .../components/mammotion/coordinator.py | 46 ++++++++++++------- .../components/mammotion/manifest.json | 2 +- requirements_all.txt | 2 +- 4 files changed, 34 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/mammotion/__init__.py b/homeassistant/components/mammotion/__init__.py index 1038252dec46b..8d358588f4663 100644 --- a/homeassistant/components/mammotion/__init__.py +++ b/homeassistant/components/mammotion/__init__.py @@ -38,6 +38,7 @@ CONF_SESSION_DATA, CONF_STAY_CONNECTED_BLUETOOTH, DEFAULT_RETRY_COUNT, + DEVICE_SUPPORT, DOMAIN, EXPIRED_CREDENTIAL_EXCEPTIONS, LOGGER, @@ -115,6 +116,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> for ( device ) in mqtt_client.cloud_client.devices_by_account_response.data.data: + if not device.deviceName.startswith(DEVICE_SUPPORT): + continue maintenance_coordinator = MammotionMaintenanceUpdateCoordinator( hass, entry, device, mammotion ) diff --git a/homeassistant/components/mammotion/coordinator.py b/homeassistant/components/mammotion/coordinator.py index fa1d01c251639..196603abe6983 100644 --- a/homeassistant/components/mammotion/coordinator.py +++ b/homeassistant/components/mammotion/coordinator.py @@ -10,7 +10,10 @@ from aiohttp import ClientConnectorError import betterproto from pymammotion import CloudIOTGateway -from pymammotion.aliyun.cloud_gateway import DeviceOfflineException +from pymammotion.aliyun.cloud_gateway import ( + DeviceOfflineException, + GatewayTimeoutException, +) from pymammotion.aliyun.model.aep_response import AepResponse from pymammotion.aliyun.model.connect_response import ConnectResponse from pymammotion.aliyun.model.dev_by_account_response import ( @@ -135,15 +138,26 @@ async def async_login(self) -> None: async def async_send_command(self, command: str, **kwargs: Any) -> bool: """Send command.""" + if not self.manager.get_device_by_name(self.device_name).mower_state.online: + return False + try: await self.manager.send_command_with_args( self.device.deviceName, command, **kwargs ) + self.update_failures = 0 return True except EXPIRED_CREDENTIAL_EXCEPTIONS: self.update_failures += 1 await self.async_login() return False + except GatewayTimeoutException as exc: + self.update_failures += 1 + if self.update_failures > 5: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="command_failed" + ) from exc + await self.async_send_command(command, **kwargs) except DeviceOfflineException: """Device is offline try bluetooth if we have it.""" try: @@ -173,19 +187,16 @@ async def check_firmware_version(self) -> None: if device_entry is None: return - new_swversion = self.data.mower_state.sw_version + new_swversion = mower.mower_state.swversion if new_swversion is not None or new_swversion != device_entry.sw_version: device_registry.async_update_device( device_entry.id, sw_version=new_swversion ) - # model_id = None - # if has_field(mower.sys.device_product_type_info): - # model_id = mower.sys.device_product_type_info.main_product_type - # - # if model_id is not None or model_id != device_entry.model_id: - # device_registry.async_update_device(device_entry.id, model_id=model_id) + if model_id := mower.mower_state.model_id: + if model_id is not None or model_id != device_entry.model_id: + device_registry.async_update_device(device_entry.id, model_id=model_id) def store_cloud_credentials(self) -> None: """Store cloud credentials in config entry.""" @@ -437,16 +448,16 @@ async def _async_update_data(self) -> MowingDevice: except DeviceOfflineException: """Device is offline try bluetooth if we have it.""" - data = self.manager.get_device_by_name(self.device_name).mower_state + device = self.manager.get_device_by_name(self.device_name) + device.mower_state.online = False + data = device.mower_state return data LOGGER.debug("Updated Mammotion device %s", self.device_name) LOGGER.debug("================= Debug Log =================") LOGGER.debug( "Mammotion device data: %s", - asdict( - self.manager.get_device_by_name(self.device_name).mower_state.device - ), + asdict(self.manager.get_device_by_name(self.device_name).mower_state), ) LOGGER.debug("==================================") @@ -721,7 +732,9 @@ async def _async_update_data(self) -> Maintain: except DeviceOfflineException: """Device is offline try bluetooth if we have it.""" - data = self.manager.get_device_by_name(self.device_name).mower_state + device = self.manager.get_device_by_name(self.device_name) + device.mower_state.online = False + data = device.mower_state return data self.updated_once = True @@ -757,7 +770,6 @@ async def _async_update_data(self): if not self.enabled: return self.data - try: await self.async_send_command("get_device_version_main") await self.async_send_command("get_device_version_info") @@ -765,9 +777,9 @@ async def _async_update_data(self): await self.check_firmware_version() except DeviceOfflineException: """Device is offline try bluetooth if we have it.""" - data = self.manager.get_device_by_name( - self.device_name - ).mower_state.mower_state + device = self.manager.get_device_by_name(self.device_name) + device.mower_state.online = False + data = device.mower_state.mower_state return data data = self.manager.get_device_by_name(self.device_name).mower_state.mower_state diff --git a/homeassistant/components/mammotion/manifest.json b/homeassistant/components/mammotion/manifest.json index 6ce6896f8577b..005aa8f5077ad 100644 --- a/homeassistant/components/mammotion/manifest.json +++ b/homeassistant/components/mammotion/manifest.json @@ -20,5 +20,5 @@ "loggers": ["pymammotion"], "integration_type": "device", "iot_class": "local_push", - "requirements": ["pymammotion==0.4.0a4"] + "requirements": ["pymammotion==0.4.0a8"] } diff --git a/requirements_all.txt b/requirements_all.txt index f078b6bd80e59..4b1ced3f70c51 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2334,7 +2334,7 @@ pylutron==0.4.1 pymailgunner==1.4 # homeassistant.components.mammotion -pymammotion==0.4.0a4 +pymammotion==0.4.0a8 # homeassistant.components.firmata pymata-express==1.19 From 35da0fd81748a5752524238b469c2b9759f3dc4b Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Mon, 24 Feb 2025 17:24:49 +1300 Subject: [PATCH 17/66] added map coordinator and start sorting bluetooth --- .../components/mammotion/__init__.py | 37 +- homeassistant/components/mammotion/button.py | 4 +- homeassistant/components/mammotion/camera.py | 146 ++++++ .../components/mammotion/coordinator.py | 483 +++++++++--------- .../components/mammotion/manifest.json | 2 +- homeassistant/components/mammotion/models.py | 6 +- homeassistant/components/mammotion/number.py | 8 +- homeassistant/components/mammotion/switch.py | 2 +- requirements_all.txt | 2 +- 9 files changed, 415 insertions(+), 275 deletions(-) create mode 100644 homeassistant/components/mammotion/camera.py diff --git a/homeassistant/components/mammotion/__init__.py b/homeassistant/components/mammotion/__init__.py index 8d358588f4663..db4ce4da9c4cf 100644 --- a/homeassistant/components/mammotion/__init__.py +++ b/homeassistant/components/mammotion/__init__.py @@ -18,8 +18,9 @@ from pymammotion.mammotion.devices.mammotion import Mammotion from pymammotion.utility.device_config import DeviceConfig +from homeassistant.components import bluetooth from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ADDRESS, CONF_MAC, CONF_PASSWORD, Platform +from homeassistant.const import CONF_ADDRESS, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr @@ -44,9 +45,10 @@ LOGGER, ) from .coordinator import ( - MammotionDataUpdateCoordinator, + MammotionBaseUpdateCoordinator, MammotionDeviceVersionUpdateCoordinator, MammotionMaintenanceUpdateCoordinator, + MammotionMapUpdateCoordinator, MammotionReportUpdateCoordinator, ) from .models import MammotionDevices, MammotionMowerData @@ -67,21 +69,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> bool: """Set up Mammotion Luba from a config entry.""" - assert entry.unique_id is not None - - # if not entry.unique_id: - # hass.config_entries.async_update_entry(entry, unique_id="some_uuid") - - if CONF_ADDRESS not in entry.data and CONF_MAC in entry.data: - # Bleak uses addresses not mac addresses which are actually - # UUIDs on some platforms (MacOS). - mac = entry.data[CONF_MAC] - if "-" not in mac: - mac = dr.format_mac(mac) - hass.config_entries.async_update_entry( - entry, - data={**entry.data, CONF_ADDRESS: mac}, - ) if not entry.options: hass.config_entries.async_update_entry( @@ -90,10 +77,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> ) device_name = entry.data.get(CONF_DEVICE_NAME) + address = entry.data.get(CONF_ADDRESS) mammotion = Mammotion() account = entry.data.get(CONF_ACCOUNTNAME) password = entry.data.get(CONF_PASSWORD) + stay_connected_ble = entry.data.get(CONF_STAY_CONNECTED_BLUETOOTH, False) + mammotion_devices: list[MammotionMowerData] = [] if account and password: @@ -127,10 +117,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> report_coordinator = MammotionReportUpdateCoordinator( hass, entry, device, mammotion ) + map_coordinator = MammotionMapUpdateCoordinator( + hass, entry, device, mammotion + ) # other coordinator await maintenance_coordinator.async_config_entry_first_refresh() await version_coordinator.async_config_entry_first_refresh() await report_coordinator.async_config_entry_first_refresh() + await map_coordinator.async_config_entry_first_refresh() device_config = DeviceConfig() if ( @@ -148,6 +142,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> version_coordinator.data.model_id ) + if address: + device = mammotion.get_device_by_name(device.deviceName) + ble_device = bluetooth.async_ble_device_from_address(hass, address) + if ble_device: + device.add_ble(ble_device) + # set preferences and set disconnection strategy + # device.ble().set_disconnect_strategy(not stay_connected_ble) + mammotion_devices.append( MammotionMowerData( name=device.deviceName, @@ -157,6 +159,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> maintenance_coordinator=maintenance_coordinator, reporting_coordinator=report_coordinator, version_coordinator=version_coordinator, + map_coordinator=map_coordinator, ) ) diff --git a/homeassistant/components/mammotion/button.py b/homeassistant/components/mammotion/button.py index d0b7a647f7e86..5c3ff8571d5b3 100644 --- a/homeassistant/components/mammotion/button.py +++ b/homeassistant/components/mammotion/button.py @@ -8,7 +8,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import MammotionConfigEntry -from .coordinator import MammotionBaseUpdateCoordinator, MammotionDataUpdateCoordinator +from .coordinator import MammotionBaseUpdateCoordinator from .entity import MammotionBaseEntity @@ -16,7 +16,7 @@ class MammotionButtonSensorEntityDescription(ButtonEntityDescription): """Describes Mammotion button sensor entity.""" - press_fn: Callable[[MammotionDataUpdateCoordinator], Awaitable[None]] + press_fn: Callable[[MammotionBaseUpdateCoordinator], Awaitable[None]] BUTTON_SENSORS: tuple[MammotionButtonSensorEntityDescription, ...] = ( diff --git a/homeassistant/components/mammotion/camera.py b/homeassistant/components/mammotion/camera.py new file mode 100644 index 0000000000000..70487c7fa0d57 --- /dev/null +++ b/homeassistant/components/mammotion/camera.py @@ -0,0 +1,146 @@ +"""Mammotion camera entities.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from pymammotion.aliyun.model.stream_subscription_response import ( + StreamSubscriptionResponse, +) +from pymammotion.utility.device_type import DeviceType + +from homeassistant.components.camera import Camera, CameraEntityDescription, StreamType +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import MammotionConfigEntry +from .coordinator import MammotionBaseUpdateCoordinator +from .entity import MammotionBaseEntity + + +@dataclass(frozen=True, kw_only=True) +class MammotionCameraEntityDescription(CameraEntityDescription): + """Describes Mammotion camera entity.""" + + stream_fn: Callable[[MammotionBaseUpdateCoordinator], StreamSubscriptionResponse] + + +CAMERAS: tuple[MammotionCameraEntityDescription, ...] = ( + MammotionCameraEntityDescription( + key="webrtc_camera", + stream_fn=lambda coordinator: coordinator.get_stream_subscription(), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: MammotionConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Mammotion camera entities.""" + mowers = entry.runtime_data + for mower in mowers: + if not DeviceType.is_luba1(mower.device.deviceName): + print("CAMERA API THING: ") + api = await mower.api.get_stream_subscription(mower.device.deviceName) + print(api) + # async_add_entities( + # MammotionWebRTCCamera(mower.reporting_coordinator, entity_description) + # for entity_description in CAMERAS + # ) + + +class MammotionWebRTCCamera(MammotionBaseEntity, Camera): + """Mammotion WebRTC camera entity.""" + + entity_description: MammotionCameraEntityDescription + _attr_has_entity_name = True + + def __init__( + self, + coordinator: MammotionBaseUpdateCoordinator, + entity_description: MammotionCameraEntityDescription, + ) -> None: + """Initialize the WebRTC camera entity.""" + super().__init__(coordinator, entity_description.key) + self.entity_description = entity_description + self._attr_translation_key = entity_description.key + self._stream_data: StreamSubscriptionResponse | None = None + + @property + def frontend_stream_type(self) -> StreamType | None: + """Return the type of stream supported by this camera.""" + return StreamType.WEB_RTC + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return entity specific state attributes.""" + if self._stream_data is None: + return {} + + return { + "app_id": self._stream_data.appid, + "channel_name": self._stream_data.channelName, + "uid": self._stream_data.uid, + } + + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: + """Return a still image response from the camera.""" + # WebRTC cameras typically don't support still images + return None + + async def async_get_stream_source(self) -> str | None: + """Return the source of the stream.""" + try: + self._stream_data = self.entity_description.stream_fn(self.coordinator) + print(self._stream_data) + if not self._stream_data: + return None + + # Construct WebRTC offer using the stream data + # This is a simplified example - adjust based on your WebRTC implementation + return { + "sdp": self._create_webrtc_offer(), + "type": "offer", + } + except Exception: + # _LOGGER.error("Failed to get stream source: %s", ex) + return None + + def _create_webrtc_offer(self) -> str: + """Create WebRTC offer from stream data.""" + if not self._stream_data: + return "" + + # Create SDP offer using the stream data + # This is a placeholder - implement according to your WebRTC requirements + sdp = f"""v=0 + o=- {self._stream_data.uid} 2 IN IP4 0.0.0.0 + s=- + t=0 0 + a=group:BUNDLE 0 + a=msid-semantic: WMS + m=video 9 UDP/TLS/RTP/SAVPF 96 + c=IN IP4 0.0.0.0 + a=rtcp:9 IN IP4 0.0.0.0 + a=ice-ufrag:{self._stream_data.token[:8]} + a=ice-pwd:{self._stream_data.token[8:24]} + a=fingerprint:sha-256 {self._stream_data.token[24:]} + a=setup:actpass + a=mid:0 + a=extmap:1 urn:ietf:params:rtp-hdrext:toffset + a=sendrecv + a=rtcp-mux + a=rtcp-rsize + a=rtpmap:96 H264/90000 + a=rtcp-fb:96 nack + a=rtcp-fb:96 nack pli + a=rtcp-fb:96 goog-remb + a=fmtp:96 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f + """ + return sdp diff --git a/homeassistant/components/mammotion/coordinator.py b/homeassistant/components/mammotion/coordinator.py index 196603abe6983..73061d5812f42 100644 --- a/homeassistant/components/mammotion/coordinator.py +++ b/homeassistant/components/mammotion/coordinator.py @@ -5,9 +5,8 @@ import asyncio from dataclasses import asdict from datetime import timedelta -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any -from aiohttp import ClientConnectorError import betterproto from pymammotion import CloudIOTGateway from pymammotion.aliyun.cloud_gateway import ( @@ -26,25 +25,24 @@ SessionByAuthCodeResponse, ) from pymammotion.data.model import GenerateRouteInformation, HashList -from pymammotion.data.model.account import Credentials from pymammotion.data.model.device import MowerInfo, MowingDevice from pymammotion.data.model.device_config import OperationSettings, create_path_order from pymammotion.data.model.report_info import Maintain from pymammotion.http.http import MammotionHTTP from pymammotion.http.model.http import LoginResponseData, Response from pymammotion.mammotion.devices.mammotion import ConnectionPreference, Mammotion -from pymammotion.proto import has_field -from pymammotion.proto.mctrl_sys import RptAct, RptDevStatus, RptInfoType +from pymammotion.proto.mctrl_sys import RptAct, RptInfoType from pymammotion.utility.constant import WorkMode from pymammotion.utility.device_type import DeviceType from homeassistant.components import bluetooth -from homeassistant.const import CONF_ADDRESS, CONF_PASSWORD +from homeassistant.const import CONF_PASSWORD from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from ...helpers.storage import Store from .const import ( COMMAND_EXCEPTIONS, CONF_ACCOUNTNAME, @@ -52,12 +50,9 @@ CONF_AUTH_DATA, CONF_CONNECT_DATA, CONF_DEVICE_DATA, - CONF_DEVICE_NAME, CONF_MAMMOTION_DATA, CONF_REGION_DATA, CONF_SESSION_DATA, - CONF_STAY_CONNECTED_BLUETOOTH, - CONF_USE_WIFI, DOMAIN, EXPIRED_CREDENTIAL_EXCEPTIONS, LOGGER, @@ -72,6 +67,7 @@ WORKING_INTERVAL = timedelta(seconds=5) REPORT_INTERVAL = timedelta(minutes=1) DEVICE_VERSION_INTERVAL = timedelta(days=1) +MAP_INTERVAL = timedelta(minutes=30) class MammotionBaseUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): @@ -286,205 +282,60 @@ async def check_and_restore_cloud(self) -> CloudIOTGateway | None: return cloud_client - async def async_setup(self) -> None: - """Set coordinator up.""" - ble_device = None - credentials = None - preference = ( - ConnectionPreference.WIFI - if self.config_entry.data.get(CONF_USE_WIFI, False) - else ConnectionPreference.BLUETOOTH - ) - address = self.config_entry.data.get(CONF_ADDRESS) - name = self.config_entry.data.get(CONF_DEVICE_NAME) - account = self.config_entry.data.get(CONF_ACCOUNTNAME) - password = self.config_entry.data.get(CONF_PASSWORD) - stay_connected_ble = self.config_entry.options.get( - CONF_STAY_CONNECTED_BLUETOOTH, False - ) - - if name: - self.device_name = name - - if self.manager is None or self.manager.get_device_by_name(name) is None: - self.manager = Mammotion() - if account and password: - credentials = Credentials() - credentials.email = account - credentials.password = password - try: - cloud_client = await self.check_and_restore_cloud() - if cloud_client is None: - await self.manager.login_and_initiate_cloud(account, password) - else: - await self.manager.initiate_cloud_connection( - account, cloud_client - ) - except ClientConnectorError as err: - raise ConfigEntryNotReady(err) - except EXPIRED_CREDENTIAL_EXCEPTIONS as exc: - LOGGER.debug(exc) - await self.async_login() - - # address previous bugs - if address is None and preference == ConnectionPreference.BLUETOOTH: - preference = ConnectionPreference.WIFI - - if address: - ble_device = bluetooth.async_ble_device_from_address(self.hass, address) - if not ble_device and credentials is None: - raise ConfigEntryNotReady( - f"Could not find Mammotion lawn mower with address {address}" - ) - if ble_device is not None: - self.device_name = ble_device.name or "Unknown" - self.manager.add_ble_device(ble_device, preference) - - if self.device_name is not None: - device = self.manager.get_device_by_name(self.device_name) - elif device_name := next(iter(self.manager.device_manager.devices.keys())): - self.device_name = device_name - device = self.manager.get_device_by_name(device_name) - else: - raise ConfigEntryNotReady("no_devices") - - device.preference = preference - - if ble_device and device: - device.ble().set_disconnect_strategy(not stay_connected_ble) - - # await self.async_restore_data() - - try: - if preference is ConnectionPreference.WIFI and device.has_cloud(): - self.store_cloud_credentials() - if mqtt_client := self.manager.mqtt_list.get(account): - device.mower_state.error_codes = await mqtt_client.cloud_client.mammotion_http.get_all_error_codes() - device.cloud().set_notification_callback( - self._async_update_notification - ) - await device.cloud().start_sync(0) - elif device.has_ble(): - device.ble().set_notification_callback(self._async_update_notification) - await device.ble().start_sync(0) - else: - raise ConfigEntryNotReady( - "No configuration available to setup Mammotion lawn mower" - ) - - except COMMAND_EXCEPTIONS as exc: - raise ConfigEntryNotReady("Unable to setup Mammotion device") from exc - - @property - def operation_settings(self) -> OperationSettings: - """Return operation settings for planning.""" - return self._operation_settings - - # async def async_restore_data(self) -> None: - # """Restore saved data.""" - # store = Store(self.hass, version=1, key=self.device_name) - # restored_data = await store.async_load() + # async def async_setup(self) -> None: + # """Set coordinator up.""" + # + # preference = ( + # ConnectionPreference.WIFI + # if self.config_entry.data.get(CONF_USE_WIFI, False) + # else ConnectionPreference.BLUETOOTH + # ) + # address = self.config_entry.data.get(CONF_ADDRESS) + # stay_connected_ble = self.config_entry.options.get( + # CONF_STAY_CONNECTED_BLUETOOTH, False + # ) + # + # + # + # # address previous bugs + # if address is None and preference == ConnectionPreference.BLUETOOTH: + # preference = ConnectionPreference.WIFI + # + # if address: + # ble_device = bluetooth.async_ble_device_from_address(self.hass, address) + # if not ble_device and credentials is None: + # raise ConfigEntryNotReady( + # f"Could not find Mammotion lawn mower with address {address}" + # ) + # if ble_device is not None: + # self.device_name = ble_device.name or "Unknown" + # self.manager.add_ble_device(ble_device, preference) + # + # + # if ble_device and device: + # device.ble().set_disconnect_strategy(not stay_connected_ble) + # + # # await self.async_restore_data() + # # try: - # if restored_data: - # mower_state = MowingDevice().from_dict(restored_data) - # self.manager.get_device_by_name( - # self.device_name - # ).mower_state = mower_state - # except InvalidFieldValue: - # """invalid""" - # self.data = MowingDevice() - # self.manager.get_device_by_name(self.device_name).mower_state = self.data + # if preference is ConnectionPreference.WIFI and device.has_cloud(): + # self.store_cloud_credentials() + # if mqtt_client := self.manager.mqtt_list.get(account): + # device.mower_state.error_codes = await mqtt_client.cloud_client.mammotion_http.get_all_error_codes() + # device.cloud().set_notification_callback( + # self._async_update_notification + # ) + # await device.cloud().start_sync(0) + # elif device.has_ble(): + # device.ble().set_notification_callback(self._async_update_notification) + # await device.ble().start_sync(0) + # else: + # raise ConfigEntryNotReady( + # "No configuration available to setup Mammotion lawn mower" + # ) # - # async def async_save_data(self, data: MowingDevice) -> None: - # """Get map data from the device.""" - # store = Store(self.hass, version=1, key=self.device_name) - # stored_data = asdict(data) - # del stored_data["device"] - # await store.async_save(stored_data) - - -class MammotionReportUpdateCoordinator(MammotionBaseUpdateCoordinator[MowingDevice]): - def __init__( - self, - hass: HomeAssistant, - config_entry: MammotionConfigEntry, - device: Device, - mammotion: Mammotion, - ) -> None: - """Initialize global mammotion data updater.""" - super().__init__( - hass=hass, - config_entry=config_entry, - device=device, - mammotion=mammotion, - update_interval=REPORT_INTERVAL, - ) - - def clear_update_failures(self) -> None: - self.update_failures = 0 - - async def _async_update_data(self) -> MowingDevice: - """Get data from the device.""" - - if not self.enabled: - return self.data - - device = self.manager.get_device_by_name(self.device_name) - - if self.update_failures > 3 and device.preference is ConnectionPreference.WIFI: - """Don't hammer the mammotion/ali servers""" - loop = asyncio.get_running_loop() - loop.call_later(600, self.clear_update_failures) - - return self.data - - if device.has_ble() and device.preference is ConnectionPreference.BLUETOOTH: - if ble_device := bluetooth.async_ble_device_from_address( - self.hass, device.ble().get_address(), True - ): - device.ble().update_device(ble_device) - try: - await self.async_send_command("get_report_cfg") - - except DeviceOfflineException: - """Device is offline try bluetooth if we have it.""" - device = self.manager.get_device_by_name(self.device_name) - device.mower_state.online = False - data = device.mower_state - return data - - LOGGER.debug("Updated Mammotion device %s", self.device_name) - LOGGER.debug("================= Debug Log =================") - LOGGER.debug( - "Mammotion device data: %s", - asdict(self.manager.get_device_by_name(self.device_name).mower_state), - ) - LOGGER.debug("==================================") - - self.update_failures = 0 - data = self.manager.get_device_by_name(self.device_name).mower_state - # await self.async_save_data(data) - - if data.report_data.dev.sys_status is WorkMode.MODE_WORKING: - self.update_interval = WORKING_INTERVAL - else: - self.update_interval = DEFAULT_INTERVAL - - self.updated_once = True - - return data - - -class MammotionDataUpdateCoordinator(MammotionBaseUpdateCoordinator[MowingDevice]): - """Class to manage fetching mammotion data.""" - - def __init__(self, hass: HomeAssistant, config_entry: MammotionConfigEntry) -> None: - """Initialize global mammotion data updater.""" - super().__init__( - hass=hass, - config_entry=config_entry, - update_interval=DEFAULT_INTERVAL, - ) + # except COMMAND_EXCEPTIONS as exc: + # raise ConfigEntryNotReady("Unable to setup Mammotion device") from exc async def async_sync_maps(self) -> None: """Get map data from the device.""" @@ -589,11 +440,10 @@ async def async_request_iot_sync(self, stop: bool = False) -> None: async def async_plan_route(self, operation_settings: OperationSettings) -> bool: """Plan mow.""" - if has_field(self.data.sys.toapp_report_data.dev): - dev = cast(RptDevStatus, self.data.sys.toapp_report_data.dev) - if has_field(dev.collector_status): - if dev.collector_status.collector_installation_status == 0: - operation_settings.is_dump = False + if self.data.report_data.dev: + dev = self.data.report_data.dev + if dev.collector_status.collector_installation_status == 0: + operation_settings.is_dump = False if DeviceType.is_yuka(self.device_name): operation_settings.blade_height = -10 @@ -633,14 +483,38 @@ async def clear_all_maps(self) -> None: def clear_update_failures(self) -> None: self.update_failures = 0 - async def _async_update_data(self) -> MowingDevice: - """Get data from the device.""" + @property + def operation_settings(self) -> OperationSettings: + """Return operation settings for planning.""" + return self._operation_settings - if not self.enabled: - return self.data + # async def async_restore_data(self) -> None: + # """Restore saved data.""" + # store = Store(self.hass, version=1, key=self.device_name) + # restored_data = await store.async_load() + # try: + # if restored_data: + # mower_state = MowingDevice().from_dict(restored_data) + # self.manager.get_device_by_name( + # self.device_name + # ).mower_state = mower_state + # except InvalidFieldValue: + # """invalid""" + # self.data = MowingDevice() + # self.manager.get_device_by_name(self.device_name).mower_state = self.data + # + async def async_save_data(self, data: MowingDevice) -> None: + """Get map data from the device.""" + store = Store(self.hass, version=1, key=self.device_name) + stored_data = asdict(data) + await store.async_save(stored_data) + async def _async_update_data(self): device = self.manager.get_device_by_name(self.device_name) + if not self.enabled or not device.mower_state.online: + return self.data + if self.update_failures > 3 and device.preference is ConnectionPreference.WIFI: """Don't hammer the mammotion/ali servers""" loop = asyncio.get_running_loop() @@ -648,28 +522,47 @@ async def _async_update_data(self) -> MowingDevice: return self.data + +class MammotionReportUpdateCoordinator(MammotionBaseUpdateCoordinator[MowingDevice]): + def __init__( + self, + hass: HomeAssistant, + config_entry: MammotionConfigEntry, + device: Device, + mammotion: Mammotion, + ) -> None: + """Initialize global mammotion data updater.""" + super().__init__( + hass=hass, + config_entry=config_entry, + device=device, + mammotion=mammotion, + update_interval=REPORT_INTERVAL, + ) + + def clear_update_failures(self) -> None: + self.update_failures = 0 + + async def _async_update_data(self) -> MowingDevice: + """Get data from the device.""" + await super()._async_update_data() + + device = self.manager.get_device_by_name(self.device_name) + if device.has_ble() and device.preference is ConnectionPreference.BLUETOOTH: if ble_device := bluetooth.async_ble_device_from_address( self.hass, device.ble().get_address(), True ): device.ble().update_device(ble_device) + try: + await self.async_send_command("get_report_cfg") - if len(device.mower_state.net.toapp_devinfo_resp.resp_ids) == 0: - await self.manager.start_sync(self.device_name, 0) - - if not has_field(device.mower_state.sys.todev_time_ctrl_light): - await self.async_read_sidelight() - - if not has_field(device.mower_state.sys.device_product_type_info): - await self.async_send_command("get_device_product_model") - - if ( - len(device.mower_state.map.hashlist) == 0 - or len(device.mower_state.map.missing_hashlist) > 0 - ): - await self.manager.start_map_sync(self.device_name) - - await self.async_send_command("get_report_cfg") + except DeviceOfflineException: + """Device is offline try bluetooth if we have it.""" + device = self.manager.get_device_by_name(self.device_name) + device.mower_state.online = False + data = device.mower_state + return data LOGGER.debug("Updated Mammotion device %s", self.device_name) LOGGER.debug("================= Debug Log =================") @@ -681,26 +574,65 @@ async def _async_update_data(self) -> MowingDevice: self.update_failures = 0 data = self.manager.get_device_by_name(self.device_name).mower_state - # await self.async_save_data(data) + await self.async_save_data(data) if data.report_data.dev.sys_status is WorkMode.MODE_WORKING: self.update_interval = WORKING_INTERVAL else: self.update_interval = DEFAULT_INTERVAL + self.updated_once = True + return data - @property - def operation_settings(self) -> OperationSettings: - """Return operation settings for planning.""" - return self._operation_settings - # TODO when submitting to HA use this 2024.8 and up - # async def _async_setup(self) -> None: - # try: - # await self.async_setup() - # except COMMAND_EXCEPTIONS as exc: - # raise UpdateFailed(f"Setting up Mammotion device failed: {exc}") from exc +# class MammotionDataUpdateCoordinator(MammotionBaseUpdateCoordinator[MowingDevice]): +# """Class to manage fetching mammotion data.""" +# +# def __init__(self, hass: HomeAssistant, config_entry: MammotionConfigEntry) -> None: +# """Initialize global mammotion data updater.""" +# super().__init__( +# hass=hass, +# config_entry=config_entry, +# update_interval=DEFAULT_INTERVAL, +# ) +# +# +# async def _async_update_data(self) -> MowingDevice: +# """Get data from the device.""" +# +# if not self.enabled: +# return self.data +# +# device = self.manager.get_device_by_name(self.device_name) +# +# if self.update_failures > 3 and device.preference is ConnectionPreference.WIFI: +# """Don't hammer the mammotion/ali servers""" +# loop = asyncio.get_running_loop() +# loop.call_later(600, self.clear_update_failures) +# +# return self.data +# +# await self.async_send_command("get_report_cfg") +# +# LOGGER.debug("Updated Mammotion device %s", self.device_name) +# LOGGER.debug("================= Debug Log =================") +# LOGGER.debug( +# "Mammotion device data: %s", +# asdict(self.manager.get_device_by_name(self.device_name).mower_state), +# ) +# LOGGER.debug("==================================") +# +# self.update_failures = 0 +# data = self.manager.get_device_by_name(self.device_name).mower_state +# # await self.async_save_data(data) +# +# if data.report_data.dev.sys_status is WorkMode.MODE_WORKING: +# self.update_interval = WORKING_INTERVAL +# else: +# self.update_interval = DEFAULT_INTERVAL +# +# return data class MammotionMaintenanceUpdateCoordinator(MammotionBaseUpdateCoordinator[Maintain]): @@ -724,9 +656,8 @@ def __init__( async def _async_update_data(self) -> Maintain: """Get data from the device.""" + await super()._async_update_data() - if not self.enabled: - return self.data try: await self.async_send_command("get_maintenance") @@ -767,9 +698,8 @@ def __init__( async def _async_update_data(self): """Get data from the device.""" + await super()._async_update_data() - if not self.enabled: - return self.data try: await self.async_send_command("get_device_version_main") await self.async_send_command("get_device_version_info") @@ -786,3 +716,62 @@ async def _async_update_data(self): self.updated_once = True return data + + +class MammotionMapUpdateCoordinator(MammotionBaseUpdateCoordinator[MowerInfo]): + """Class to manage fetching mammotion data.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: MammotionConfigEntry, + device: Device, + mammotion: Mammotion, + ) -> None: + """Initialize global mammotion data updater.""" + super().__init__( + hass=hass, + config_entry=config_entry, + device=device, + mammotion=mammotion, + update_interval=MAP_INTERVAL, + ) + + def _map_callback(self) -> None: + """Trigger a resync when the bol hash changes.""" + # TODO setup callback to get bol hash data + + async def _async_update_data(self): + """Get data from the device.""" + await super()._async_update_data() + device = self.manager.get_device_by_name(self.device_name) + + try: + if ( + len(device.mower_state.map.hashlist) == 0 + or len(device.mower_state.map.missing_hashlist) > 0 + ): + await self.manager.start_map_sync(self.device_name) + + except DeviceOfflineException: + """Device is offline try bluetooth if we have it.""" + device.mower_state.online = False + data = device.mower_state.mower_state + return data + + data = self.manager.get_device_by_name(self.device_name).mower_state.mower_state + self.updated_once = True + + return data + + async def _async_setup(self) -> None: + """Setup coordinator with initial calls to get map data.""" + device = self.manager.get_device_by_name(self.device_name) + + if not self.enabled or not device.mower_state.online: + return + try: + await self.async_rtk_dock_location() + except DeviceOfflineException: + """Device is offline try bluetooth if we have it.""" + device.mower_state.online = False diff --git a/homeassistant/components/mammotion/manifest.json b/homeassistant/components/mammotion/manifest.json index 005aa8f5077ad..a299dc142d46b 100644 --- a/homeassistant/components/mammotion/manifest.json +++ b/homeassistant/components/mammotion/manifest.json @@ -20,5 +20,5 @@ "loggers": ["pymammotion"], "integration_type": "device", "iot_class": "local_push", - "requirements": ["pymammotion==0.4.0a8"] + "requirements": ["pymammotion==0.4.0a9"] } diff --git a/homeassistant/components/mammotion/models.py b/homeassistant/components/mammotion/models.py index 01dfa16d8931f..ab6cf1029d92b 100644 --- a/homeassistant/components/mammotion/models.py +++ b/homeassistant/components/mammotion/models.py @@ -4,11 +4,12 @@ from pymammotion.data.model.device_limits import DeviceLimits from pymammotion.mammotion.devices.mammotion import Mammotion -from . import ( +from .coordinator import ( MammotionDeviceVersionUpdateCoordinator, MammotionMaintenanceUpdateCoordinator, + MammotionMapUpdateCoordinator, + MammotionReportUpdateCoordinator, ) -from .coordinator import MammotionReportUpdateCoordinator @dataclass @@ -20,6 +21,7 @@ class MammotionMowerData: maintenance_coordinator: MammotionMaintenanceUpdateCoordinator reporting_coordinator: MammotionReportUpdateCoordinator version_coordinator: MammotionDeviceVersionUpdateCoordinator + map_coordinator: MammotionMapUpdateCoordinator device_limits: DeviceLimits device: Device diff --git a/homeassistant/components/mammotion/number.py b/homeassistant/components/mammotion/number.py index 05c590afb56cb..de9ff5ef8365e 100644 --- a/homeassistant/components/mammotion/number.py +++ b/homeassistant/components/mammotion/number.py @@ -23,7 +23,7 @@ from homeassistant.helpers.restore_state import RestoreEntity from . import MammotionConfigEntry -from .coordinator import MammotionDataUpdateCoordinator +from .coordinator import MammotionBaseUpdateCoordinator from .entity import MammotionBaseEntity @@ -31,7 +31,7 @@ class MammotionConfigNumberEntityDescription(NumberEntityDescription): """Describes Mammotion number entity.""" - set_fn: Callable[[MammotionDataUpdateCoordinator, float], None] + set_fn: Callable[[MammotionBaseUpdateCoordinator, float], None] NUMBER_ENTITIES: tuple[MammotionConfigNumberEntityDescription, ...] = ( @@ -170,7 +170,7 @@ class MammotionConfigNumberEntity(MammotionBaseEntity, NumberEntity, RestoreEnti def __init__( self, - coordinator: MammotionDataUpdateCoordinator, + coordinator: MammotionBaseUpdateCoordinator, entity_description: MammotionConfigNumberEntityDescription, ) -> None: super().__init__(coordinator, entity_description.key) @@ -197,7 +197,7 @@ class MammotionWorkingNumberEntity(MammotionConfigNumberEntity): def __init__( self, - coordinator: MammotionDataUpdateCoordinator, + coordinator: MammotionBaseUpdateCoordinator, entity_description: MammotionConfigNumberEntityDescription, limits: DeviceLimits, ) -> None: diff --git a/homeassistant/components/mammotion/switch.py b/homeassistant/components/mammotion/switch.py index dfe4eba742a31..c19e008bffd3c 100644 --- a/homeassistant/components/mammotion/switch.py +++ b/homeassistant/components/mammotion/switch.py @@ -112,7 +112,7 @@ async def async_setup_entry( for mower in mammotion_devices: added_areas: set[str] = set() - + # TODO create maps coordinator coordinator = mower.reporting_coordinator @callback diff --git a/requirements_all.txt b/requirements_all.txt index 4b1ced3f70c51..ccb4425cd8de5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2334,7 +2334,7 @@ pylutron==0.4.1 pymailgunner==1.4 # homeassistant.components.mammotion -pymammotion==0.4.0a8 +pymammotion==0.4.0a9 # homeassistant.components.firmata pymata-express==1.19 From bfb5006daf7418890bf9b7b1f517072543cd9f54 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Thu, 27 Feb 2025 12:58:07 +1300 Subject: [PATCH 18/66] fix entity ids --- .../components/mammotion/__init__.py | 44 +++- .../components/mammotion/binary_sensor.py | 4 + homeassistant/components/mammotion/button.py | 9 +- homeassistant/components/mammotion/camera.py | 63 ++--- .../components/mammotion/config_flow.py | 1 + .../components/mammotion/coordinator.py | 217 +++++------------- .../components/mammotion/device_tracker.py | 7 +- homeassistant/components/mammotion/entity.py | 9 +- .../components/mammotion/lawn_mower.py | 1 + homeassistant/components/mammotion/number.py | 7 + homeassistant/components/mammotion/select.py | 12 +- homeassistant/components/mammotion/sensor.py | 12 +- 12 files changed, 156 insertions(+), 230 deletions(-) diff --git a/homeassistant/components/mammotion/__init__.py b/homeassistant/components/mammotion/__init__.py index db4ce4da9c4cf..1b65748c0c086 100644 --- a/homeassistant/components/mammotion/__init__.py +++ b/homeassistant/components/mammotion/__init__.py @@ -15,7 +15,7 @@ from pymammotion.data.model.account import Credentials from pymammotion.http.http import MammotionHTTP from pymammotion.http.model.http import LoginResponseData, Response -from pymammotion.mammotion.devices.mammotion import Mammotion +from pymammotion.mammotion.devices.mammotion import ConnectionPreference, Mammotion from pymammotion.utility.device_config import DeviceConfig from homeassistant.components import bluetooth @@ -38,6 +38,7 @@ CONF_RETRY_COUNT, CONF_SESSION_DATA, CONF_STAY_CONNECTED_BLUETOOTH, + CONF_USE_WIFI, DEFAULT_RETRY_COUNT, DEVICE_SUPPORT, DOMAIN, @@ -83,6 +84,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> password = entry.data.get(CONF_PASSWORD) stay_connected_ble = entry.data.get(CONF_STAY_CONNECTED_BLUETOOTH, False) + use_wifi = entry.data.get(CONF_USE_WIFI, True) mammotion_devices: list[MammotionMowerData] = [] @@ -96,6 +98,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> await mammotion.login_and_initiate_cloud(account, password) else: await mammotion.initiate_cloud_connection(account, cloud_client) + store_cloud_credentials(hass, entry, cloud_client) except ClientConnectorError as err: raise ConfigEntryNotReady(err) except EXPIRED_CREDENTIAL_EXCEPTIONS as exc: @@ -142,13 +145,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> version_coordinator.data.model_id ) + mammotion_device = mammotion.get_device_by_name(device.deviceName) if address: - device = mammotion.get_device_by_name(device.deviceName) ble_device = bluetooth.async_ble_device_from_address(hass, address) if ble_device: - device.add_ble(ble_device) - # set preferences and set disconnection strategy - # device.ble().set_disconnect_strategy(not stay_connected_ble) + mammotion_device.add_ble(ble_device) + mammotion_device.ble().set_disconnect_strategy( + not stay_connected_ble + ) + if not use_wifi: + mammotion_device.preference = ConnectionPreference.BLUETOOTH + await mammotion_device.cloud().stop() + mammotion_device.cloud().mqtt.disconnect() if mammotion_device.cloud().mqtt.is_connected() else None mammotion_devices.append( MammotionMowerData( @@ -169,6 +177,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> return True +def store_cloud_credentials(hass, config_entry, cloud_client: CloudIOTGateway) -> None: + """Store cloud credentials in config entry.""" + + if cloud_client is not None: + config_updates = { + **config_entry.data, + CONF_CONNECT_DATA: cloud_client.connect_response, + CONF_AUTH_DATA: cloud_client.login_by_oauth_response, + CONF_REGION_DATA: cloud_client.region_response, + CONF_AEP_DATA: cloud_client.aep_response, + CONF_SESSION_DATA: cloud_client.session_by_authcode_response, + CONF_DEVICE_DATA: cloud_client.devices_by_account_response, + CONF_MAMMOTION_DATA: cloud_client.mammotion_http.response, + } + hass.config_entries.async_update_entry(config_entry, data=config_updates) + + async def check_and_restore_cloud( hass: HomeAssistant, entry: MammotionConfigEntry ) -> CloudIOTGateway | None: @@ -227,14 +252,17 @@ async def check_and_restore_cloud( return cloud_client -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def _async_update_listener( + hass: HomeAssistant, entry: MammotionConfigEntry +) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +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): - await entry.runtime_data.manager.remove_device(entry.runtime_data.device_name) + for mower in entry.runtime_data: + await mower.api.remove_device(mower.name) return unload_ok diff --git a/homeassistant/components/mammotion/binary_sensor.py b/homeassistant/components/mammotion/binary_sensor.py index bbbc873f6dbd1..30e7751d5a726 100644 --- a/homeassistant/components/mammotion/binary_sensor.py +++ b/homeassistant/components/mammotion/binary_sensor.py @@ -6,6 +6,7 @@ from pymammotion.data.model.device import MowingDevice from homeassistant.components.binary_sensor import ( + ENTITY_ID_FORMAT, BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, @@ -69,6 +70,9 @@ def __init__( ) -> None: """Initialize the binary sensor entity.""" super().__init__(coordinator, entity_description.key) + self.entity_id = ENTITY_ID_FORMAT.format( + f"{coordinator.device_name}_{entity_description.key}" + ) self.entity_description = entity_description self._attr_translation_key = entity_description.translation_key diff --git a/homeassistant/components/mammotion/button.py b/homeassistant/components/mammotion/button.py index 5c3ff8571d5b3..7f9b4876a3086 100644 --- a/homeassistant/components/mammotion/button.py +++ b/homeassistant/components/mammotion/button.py @@ -3,7 +3,11 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass -from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.components.button import ( + ENTITY_ID_FORMAT, + ButtonEntity, + ButtonEntityDescription, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -89,6 +93,9 @@ def __init__( super().__init__(coordinator, entity_description.key) self.entity_description = entity_description self._attr_translation_key = entity_description.key + self.entity_id = ENTITY_ID_FORMAT.format( + f"{coordinator.device_name}_{entity_description.key}" + ) async def async_press(self) -> None: """Handle the button press.""" diff --git a/homeassistant/components/mammotion/camera.py b/homeassistant/components/mammotion/camera.py index 70487c7fa0d57..d4afda8dae80a 100644 --- a/homeassistant/components/mammotion/camera.py +++ b/homeassistant/components/mammotion/camera.py @@ -11,7 +11,13 @@ ) from pymammotion.utility.device_type import DeviceType -from homeassistant.components.camera import Camera, CameraEntityDescription, StreamType +from homeassistant.components.camera import ( + ENTITY_ID_FORMAT, + Camera, + CameraEntityDescription, + StreamType, + WebRTCSendMessage, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -69,6 +75,9 @@ def __init__( self.entity_description = entity_description self._attr_translation_key = entity_description.key self._stream_data: StreamSubscriptionResponse | None = None + self.entity_id = ENTITY_ID_FORMAT.format( + f"{coordinator.device_name}_{entity_description.key}" + ) @property def frontend_stream_type(self) -> StreamType | None: @@ -94,53 +103,7 @@ async def async_camera_image( # WebRTC cameras typically don't support still images return None - async def async_get_stream_source(self) -> str | None: + async def async_handle_async_webrtc_offer( + self, offer_sdp: str, session_id: str, send_message: WebRTCSendMessage + ) -> None: """Return the source of the stream.""" - try: - self._stream_data = self.entity_description.stream_fn(self.coordinator) - print(self._stream_data) - if not self._stream_data: - return None - - # Construct WebRTC offer using the stream data - # This is a simplified example - adjust based on your WebRTC implementation - return { - "sdp": self._create_webrtc_offer(), - "type": "offer", - } - except Exception: - # _LOGGER.error("Failed to get stream source: %s", ex) - return None - - def _create_webrtc_offer(self) -> str: - """Create WebRTC offer from stream data.""" - if not self._stream_data: - return "" - - # Create SDP offer using the stream data - # This is a placeholder - implement according to your WebRTC requirements - sdp = f"""v=0 - o=- {self._stream_data.uid} 2 IN IP4 0.0.0.0 - s=- - t=0 0 - a=group:BUNDLE 0 - a=msid-semantic: WMS - m=video 9 UDP/TLS/RTP/SAVPF 96 - c=IN IP4 0.0.0.0 - a=rtcp:9 IN IP4 0.0.0.0 - a=ice-ufrag:{self._stream_data.token[:8]} - a=ice-pwd:{self._stream_data.token[8:24]} - a=fingerprint:sha-256 {self._stream_data.token[24:]} - a=setup:actpass - a=mid:0 - a=extmap:1 urn:ietf:params:rtp-hdrext:toffset - a=sendrecv - a=rtcp-mux - a=rtcp-rsize - a=rtpmap:96 H264/90000 - a=rtcp-fb:96 nack - a=rtcp-fb:96 nack pli - a=rtcp-fb:96 goog-remb - a=fmtp:96 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f - """ - return sdp diff --git a/homeassistant/components/mammotion/config_flow.py b/homeassistant/components/mammotion/config_flow.py index 2fdf50cf76f36..527f263703ce8 100644 --- a/homeassistant/components/mammotion/config_flow.py +++ b/homeassistant/components/mammotion/config_flow.py @@ -92,6 +92,7 @@ async def async_step_bluetooth_confirm( } self._config = { CONF_BLE_DEVICES: ble_devices, + CONF_ADDRESS: self._discovered_device.address, } try: diff --git a/homeassistant/components/mammotion/coordinator.py b/homeassistant/components/mammotion/coordinator.py index 73061d5812f42..7c9d106ed29f6 100644 --- a/homeassistant/components/mammotion/coordinator.py +++ b/homeassistant/components/mammotion/coordinator.py @@ -8,28 +8,15 @@ from typing import TYPE_CHECKING, Any import betterproto -from pymammotion import CloudIOTGateway from pymammotion.aliyun.cloud_gateway import ( DeviceOfflineException, GatewayTimeoutException, ) -from pymammotion.aliyun.model.aep_response import AepResponse -from pymammotion.aliyun.model.connect_response import ConnectResponse -from pymammotion.aliyun.model.dev_by_account_response import ( - Device, - ListingDevByAccountResponse, -) -from pymammotion.aliyun.model.login_by_oauth_response import LoginByOAuthResponse -from pymammotion.aliyun.model.regions_response import RegionResponse -from pymammotion.aliyun.model.session_by_authcode_response import ( - SessionByAuthCodeResponse, -) +from pymammotion.aliyun.model.dev_by_account_response import Device from pymammotion.data.model import GenerateRouteInformation, HashList from pymammotion.data.model.device import MowerInfo, MowingDevice from pymammotion.data.model.device_config import OperationSettings, create_path_order from pymammotion.data.model.report_info import Maintain -from pymammotion.http.http import MammotionHTTP -from pymammotion.http.model.http import LoginResponseData, Response from pymammotion.mammotion.devices.mammotion import ConnectionPreference, Mammotion from pymammotion.proto.mctrl_sys import RptAct, RptInfoType from pymammotion.utility.constant import WorkMode @@ -132,6 +119,29 @@ async def async_login(self) -> None: await self.manager.login_and_initiate_cloud(account, password, True) self.store_cloud_credentials() + def store_cloud_credentials(self) -> None: + """Store cloud credentials in config entry.""" + # config_updates = {} + mammotion_cloud = self.manager.mqtt_list.get( + self.config_entry.data.get(CONF_ACCOUNTNAME, "") + ) + cloud_client = mammotion_cloud.cloud_client if mammotion_cloud else None + + if cloud_client is not None: + config_updates = { + **self.config_entry.data, + CONF_CONNECT_DATA: cloud_client.connect_response, + CONF_AUTH_DATA: cloud_client.login_by_oauth_response, + CONF_REGION_DATA: cloud_client.region_response, + CONF_AEP_DATA: cloud_client.aep_response, + CONF_SESSION_DATA: cloud_client.session_by_authcode_response, + CONF_DEVICE_DATA: cloud_client.devices_by_account_response, + CONF_MAMMOTION_DATA: cloud_client.mammotion_http.response, + } + self.hass.config_entries.async_update_entry( + self.config_entry, data=config_updates + ) + async def async_send_command(self, command: str, **kwargs: Any) -> bool: """Send command.""" if not self.manager.get_device_by_name(self.device_name).mower_state.online: @@ -194,94 +204,6 @@ async def check_firmware_version(self) -> None: if model_id is not None or model_id != device_entry.model_id: device_registry.async_update_device(device_entry.id, model_id=model_id) - def store_cloud_credentials(self) -> None: - """Store cloud credentials in config entry.""" - # config_updates = {} - mammotion_cloud = self.manager.mqtt_list.get( - self.config_entry.data.get(CONF_ACCOUNTNAME, "") - ) - cloud_client = mammotion_cloud.cloud_client if mammotion_cloud else None - - if cloud_client is not None: - config_updates = { - **self.config_entry.data, - CONF_CONNECT_DATA: cloud_client.connect_response, - CONF_AUTH_DATA: cloud_client.login_by_oauth_response, - CONF_REGION_DATA: cloud_client.region_response, - CONF_AEP_DATA: cloud_client.aep_response, - CONF_SESSION_DATA: cloud_client.session_by_authcode_response, - CONF_DEVICE_DATA: cloud_client.devices_by_account_response, - CONF_MAMMOTION_DATA: cloud_client.mammotion_http.response, - } - self.hass.config_entries.async_update_entry( - self.config_entry, data=config_updates - ) - - async def _async_update_notification(self, res: tuple[str, Any | None]) -> None: - """Update data from incoming messages.""" - if res[0] == "sys" and res[1] is not None: - sys_msg = betterproto.which_one_of(res[1], "SubSysMsg") - if sys_msg[0] == "toapp_report_data": - mower = self.manager.mower(self.device_name) - self.async_set_updated_data(mower) - - async def check_and_restore_cloud(self) -> CloudIOTGateway | None: - """Check and restore previous cloud connection.""" - - auth_data = self.config_entry.data.get(CONF_AUTH_DATA) - region_data = self.config_entry.data.get(CONF_REGION_DATA) - aep_data = self.config_entry.data.get(CONF_AEP_DATA) - session_data = self.config_entry.data.get(CONF_SESSION_DATA) - device_data = self.config_entry.data.get(CONF_DEVICE_DATA) - connect_data = self.config_entry.data.get(CONF_CONNECT_DATA) - mammotion_data = self.config_entry.data.get(CONF_MAMMOTION_DATA) - - if any( - data is None - for data in [ - auth_data, - region_data, - aep_data, - session_data, - device_data, - connect_data, - mammotion_data, - ] - ): - return None - - cloud_client = CloudIOTGateway( - connect_response=ConnectResponse.from_dict(connect_data) - if isinstance(connect_data, dict) - else connect_data, - aep_response=AepResponse.from_dict(aep_data) - if isinstance(aep_data, dict) - else aep_data, - region_response=RegionResponse.from_dict(region_data) - if isinstance(region_data, dict) - else region_data, - session_by_authcode_response=SessionByAuthCodeResponse.from_dict( - session_data - ) - if isinstance(session_data, dict) - else session_data, - dev_by_account=ListingDevByAccountResponse.from_dict(device_data) - if isinstance(device_data, dict) - else device_data, - login_by_oauth_response=LoginByOAuthResponse.from_dict(auth_data) - if isinstance(auth_data, dict) - else auth_data, - ) - - if isinstance(mammotion_data, dict): - mammotion_data = Response[LoginResponseData].from_dict(mammotion_data) - - cloud_client.set_http(MammotionHTTP(response=mammotion_data)) - - await self.hass.async_add_executor_job(cloud_client.check_or_refresh_session) - - return cloud_client - # async def async_setup(self) -> None: # """Set coordinator up.""" # @@ -488,21 +410,21 @@ def operation_settings(self) -> OperationSettings: """Return operation settings for planning.""" return self._operation_settings - # async def async_restore_data(self) -> None: - # """Restore saved data.""" - # store = Store(self.hass, version=1, key=self.device_name) - # restored_data = await store.async_load() - # try: - # if restored_data: - # mower_state = MowingDevice().from_dict(restored_data) - # self.manager.get_device_by_name( - # self.device_name - # ).mower_state = mower_state - # except InvalidFieldValue: - # """invalid""" - # self.data = MowingDevice() - # self.manager.get_device_by_name(self.device_name).mower_state = self.data - # + async def async_restore_data(self) -> None: + """Restore saved data.""" + store = Store(self.hass, version=1, key=self.device_name) + restored_data = await store.async_load() + try: + if restored_data: + mower_state = MowingDevice().from_dict(restored_data) + self.manager.get_device_by_name( + self.device_name + ).mower_state = mower_state + except InvalidFieldValue: + """invalid""" + self.data = MowingDevice() + self.manager.get_device_by_name(self.device_name).mower_state = self.data + async def async_save_data(self, data: MowingDevice) -> None: """Get map data from the device.""" store = Store(self.hass, version=1, key=self.device_name) @@ -585,54 +507,21 @@ async def _async_update_data(self) -> MowingDevice: return data + async def _async_update_notification(self, res: tuple[str, Any | None]) -> None: + """Update data from incoming messages.""" + if res[0] == "sys" and res[1] is not None: + sys_msg = betterproto.which_one_of(res[1], "SubSysMsg") + if sys_msg[0] == "toapp_report_data": + mower = self.manager.mower(self.device_name) + self.async_set_updated_data(mower) + + async def _async_setup(self) -> None: + device = self.manager.get_device_by_name(self.device_name) -# class MammotionDataUpdateCoordinator(MammotionBaseUpdateCoordinator[MowingDevice]): -# """Class to manage fetching mammotion data.""" -# -# def __init__(self, hass: HomeAssistant, config_entry: MammotionConfigEntry) -> None: -# """Initialize global mammotion data updater.""" -# super().__init__( -# hass=hass, -# config_entry=config_entry, -# update_interval=DEFAULT_INTERVAL, -# ) -# -# -# async def _async_update_data(self) -> MowingDevice: -# """Get data from the device.""" -# -# if not self.enabled: -# return self.data -# -# device = self.manager.get_device_by_name(self.device_name) -# -# if self.update_failures > 3 and device.preference is ConnectionPreference.WIFI: -# """Don't hammer the mammotion/ali servers""" -# loop = asyncio.get_running_loop() -# loop.call_later(600, self.clear_update_failures) -# -# return self.data -# -# await self.async_send_command("get_report_cfg") -# -# LOGGER.debug("Updated Mammotion device %s", self.device_name) -# LOGGER.debug("================= Debug Log =================") -# LOGGER.debug( -# "Mammotion device data: %s", -# asdict(self.manager.get_device_by_name(self.device_name).mower_state), -# ) -# LOGGER.debug("==================================") -# -# self.update_failures = 0 -# data = self.manager.get_device_by_name(self.device_name).mower_state -# # await self.async_save_data(data) -# -# if data.report_data.dev.sys_status is WorkMode.MODE_WORKING: -# self.update_interval = WORKING_INTERVAL -# else: -# self.update_interval = DEFAULT_INTERVAL -# -# return data + if device.has_cloud(): + device.cloud().set_notification_callback(self._async_update_notification) + elif device.has_ble(): + device.ble().set_notification_callback(self._async_update_notification) class MammotionMaintenanceUpdateCoordinator(MammotionBaseUpdateCoordinator[Maintain]): diff --git a/homeassistant/components/mammotion/device_tracker.py b/homeassistant/components/mammotion/device_tracker.py index 5be227f7a0030..5f574c338e7be 100644 --- a/homeassistant/components/mammotion/device_tracker.py +++ b/homeassistant/components/mammotion/device_tracker.py @@ -3,7 +3,11 @@ import logging from typing import Any -from homeassistant.components.device_tracker import SourceType, TrackerEntity +from homeassistant.components.device_tracker import ( + ENTITY_ID_FORMAT, + SourceType, + TrackerEntity, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity @@ -40,6 +44,7 @@ def __init__(self, coordinator: MammotionBaseUpdateCoordinator) -> None: super().__init__(coordinator, f"{coordinator.device_name}_gps") self._attr_name = coordinator.device_name + self.entity_id = ENTITY_ID_FORMAT.format(f"{coordinator.device_name}") @property def extra_state_attributes(self) -> dict[str, Any]: diff --git a/homeassistant/components/mammotion/entity.py b/homeassistant/components/mammotion/entity.py index 846c675176d6e..24a60f6bdfe65 100644 --- a/homeassistant/components/mammotion/entity.py +++ b/homeassistant/components/mammotion/entity.py @@ -47,12 +47,19 @@ def device_info(self) -> DeviceInfo: if mower.mqtt_properties is not None: model_id = mower.mqtt_properties.params.items.extMod.value + nick_name = self.coordinator.device.nickName + device_name = ( + self.coordinator.device_name + if nick_name is None or nick_name == "" + else self.coordinator.device.nickName + ) + return DeviceInfo( identifiers={(DOMAIN, self.coordinator.device.deviceName)}, manufacturer="Mammotion", serial_number=self.coordinator.device_name.split("-", 1)[-1], model_id=model_id, - name=self.coordinator.device.nickName, + name=device_name, sw_version=swversion, model=self.coordinator.device.productModel, suggested_area="Garden", diff --git a/homeassistant/components/mammotion/lawn_mower.py b/homeassistant/components/mammotion/lawn_mower.py index abd3584341756..01921b9f4d3a3 100644 --- a/homeassistant/components/mammotion/lawn_mower.py +++ b/homeassistant/components/mammotion/lawn_mower.py @@ -114,6 +114,7 @@ def __init__(self, coordinator: MammotionReportUpdateCoordinator) -> None: """Initialize the lawn mower.""" super().__init__(coordinator, "mower") self._attr_name = None # main feature of device + self.entity_id = f"lawn_mower_{coordinator.device_name}" @property def rpt_dev_status(self) -> DeviceData: diff --git a/homeassistant/components/mammotion/number.py b/homeassistant/components/mammotion/number.py index de9ff5ef8365e..85bac79c404b2 100644 --- a/homeassistant/components/mammotion/number.py +++ b/homeassistant/components/mammotion/number.py @@ -5,6 +5,7 @@ from pymammotion.utility.device_type import DeviceType from homeassistant.components.number import ( + ENTITY_ID_FORMAT, NumberDeviceClass, NumberEntity, NumberEntityDescription, @@ -184,6 +185,9 @@ def __init__( self._attr_native_value = 0 if self.entity_description.key == "toward_included_angle": self._attr_native_value = 90 + self.entity_id = ENTITY_ID_FORMAT.format( + f"{coordinator.device_name}_{entity_description.key}" + ) async def async_set_native_value(self, value: float) -> None: """Set native value for number.""" @@ -206,6 +210,9 @@ def __init__( min_attr = f"{entity_description.key}_min" max_attr = f"{entity_description.key}_max" + self.entity_id = ENTITY_ID_FORMAT.format( + f"{coordinator.device_name}_{entity_description.key}" + ) if hasattr(limits, min_attr) and hasattr(limits, max_attr): self._attr_native_min_value = getattr(limits, min_attr) diff --git a/homeassistant/components/mammotion/select.py b/homeassistant/components/mammotion/select.py index bc14416eac213..95d90aaef8e2e 100644 --- a/homeassistant/components/mammotion/select.py +++ b/homeassistant/components/mammotion/select.py @@ -12,7 +12,11 @@ ) from pymammotion.utility.device_type import DeviceType -from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.components.select import ( + ENTITY_ID_FORMAT, + SelectEntity, + SelectEntityDescription, +) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -190,6 +194,9 @@ def __init__( self._attr_translation_key = entity_description.key self._attr_options = entity_description.options self._attr_current_option = entity_description.options[0] + self.entity_id = ENTITY_ID_FORMAT.format( + f"{coordinator.device_name}_{entity_description.key}" + ) async def async_select_option(self, option: str) -> None: self._attr_current_option = option @@ -219,6 +226,9 @@ def __init__( self._attr_translation_key = entity_description.key self._attr_options = entity_description.options self._attr_current_option = entity_description.options[0] + self.entity_id = ENTITY_ID_FORMAT.format( + f"{coordinator.device_name}_{entity_description.key}" + ) async def async_select_option(self, option: str) -> None: self._attr_current_option = option diff --git a/homeassistant/components/mammotion/sensor.py b/homeassistant/components/mammotion/sensor.py index 080bd4a392be6..f894253e0e067 100644 --- a/homeassistant/components/mammotion/sensor.py +++ b/homeassistant/components/mammotion/sensor.py @@ -14,6 +14,7 @@ from pymammotion.utility.device_type import DeviceType from homeassistant.components.sensor import ( + ENTITY_ID_FORMAT, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -278,12 +279,15 @@ class MammotionSensorEntity(MammotionBaseEntity, SensorEntity): def __init__( self, coordinator: MammotionReportUpdateCoordinator, - description: MammotionSensorEntityDescription, + entity_description: MammotionSensorEntityDescription, ) -> None: """Set up MammotionSensor.""" - super().__init__(coordinator, description.key) - self.entity_description = description - self._attr_translation_key = description.key + super().__init__(coordinator, entity_description.key) + self.entity_description = entity_description + self._attr_translation_key = entity_description.key + self.entity_id = ENTITY_ID_FORMAT.format( + f"{coordinator.device_name}_{entity_description.key}" + ) @property def native_value(self) -> StateType: From 712bab225ae7d2c11177f7965059744d9ec073ac Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Thu, 27 Feb 2025 13:52:18 +1300 Subject: [PATCH 19/66] minor tweaks so we actually save the config --- homeassistant/components/mammotion/__init__.py | 12 ++++++++---- homeassistant/components/mammotion/config_flow.py | 6 ------ homeassistant/components/mammotion/lawn_mower.py | 2 +- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/mammotion/__init__.py b/homeassistant/components/mammotion/__init__.py index 1b65748c0c086..0887c1d6832a2 100644 --- a/homeassistant/components/mammotion/__init__.py +++ b/homeassistant/components/mammotion/__init__.py @@ -98,14 +98,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> await mammotion.login_and_initiate_cloud(account, password) else: await mammotion.initiate_cloud_connection(account, cloud_client) - store_cloud_credentials(hass, entry, cloud_client) except ClientConnectorError as err: raise ConfigEntryNotReady(err) except EXPIRED_CREDENTIAL_EXCEPTIONS as exc: LOGGER.debug(exc) await mammotion.login_and_initiate_cloud(account, password, True) + + if mqtt_client := mammotion.mqtt_list.get(account): + store_cloud_credentials(hass, entry, mqtt_client.cloud_client) for ( device ) in mqtt_client.cloud_client.devices_by_account_response.data.data: @@ -244,11 +246,13 @@ async def check_and_restore_cloud( if isinstance(mammotion_data, dict): mammotion_data = Response[LoginResponseData].from_dict(mammotion_data) - - cloud_client.set_http(MammotionHTTP(response=mammotion_data)) + mammotion_http = MammotionHTTP() + mammotion_http.response = mammotion_data + mammotion_http.login_info = mammotion_data.data + cloud_client.set_http(mammotion_http) await hass.async_add_executor_job(cloud_client.check_or_refresh_session) - + print("restore cloud") return cloud_client diff --git a/homeassistant/components/mammotion/config_flow.py b/homeassistant/components/mammotion/config_flow.py index 527f263703ce8..a30ed49e56a99 100644 --- a/homeassistant/components/mammotion/config_flow.py +++ b/homeassistant/components/mammotion/config_flow.py @@ -265,12 +265,6 @@ async def async_step_wifi( vol.Optional(CONF_USE_WIFI, default=True): cv.boolean, } - if self._config.get(CONF_ADDRESS) is None: - schema = { - vol.Required(CONF_ACCOUNTNAME): vol.All(cv.string, vol.Strip), - vol.Required(CONF_PASSWORD): vol.All(cv.string, vol.Strip), - } - return self.async_show_form(step_id="wifi", data_schema=vol.Schema(schema)) async def async_step_wifi_confirm( diff --git a/homeassistant/components/mammotion/lawn_mower.py b/homeassistant/components/mammotion/lawn_mower.py index 01921b9f4d3a3..b2d3bbab596e5 100644 --- a/homeassistant/components/mammotion/lawn_mower.py +++ b/homeassistant/components/mammotion/lawn_mower.py @@ -114,7 +114,7 @@ def __init__(self, coordinator: MammotionReportUpdateCoordinator) -> None: """Initialize the lawn mower.""" super().__init__(coordinator, "mower") self._attr_name = None # main feature of device - self.entity_id = f"lawn_mower_{coordinator.device_name}" + self.entity_id = f"lawn_mower.{coordinator.device_name}" @property def rpt_dev_status(self) -> DeviceData: From 1cb4253df53dbfa692624e51745176e050ef018d Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Sat, 22 Mar 2025 17:56:32 +1300 Subject: [PATCH 20/66] mammotion changes, still need to re-update it again --- .../components/mammotion/__init__.py | 79 +++++---- .../components/mammotion/binary_sensor.py | 4 - homeassistant/components/mammotion/button.py | 9 +- homeassistant/components/mammotion/camera.py | 4 - .../components/mammotion/config_flow.py | 13 +- .../components/mammotion/coordinator.py | 163 +++++++++++------- .../components/mammotion/device_tracker.py | 7 +- homeassistant/components/mammotion/entity.py | 4 +- .../components/mammotion/lawn_mower.py | 1 - .../components/mammotion/manifest.json | 4 +- homeassistant/components/mammotion/number.py | 25 +-- homeassistant/components/mammotion/select.py | 12 +- homeassistant/components/mammotion/sensor.py | 9 +- .../components/mammotion/strings.json | 5 +- homeassistant/components/mammotion/switch.py | 2 +- requirements_all.txt | 2 +- 16 files changed, 179 insertions(+), 164 deletions(-) diff --git a/homeassistant/components/mammotion/__init__.py b/homeassistant/components/mammotion/__init__.py index 0887c1d6832a2..442bda730f213 100644 --- a/homeassistant/components/mammotion/__init__.py +++ b/homeassistant/components/mammotion/__init__.py @@ -21,11 +21,12 @@ from homeassistant.components import bluetooth from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, CONF_PASSWORD, Platform -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.core import HassJob, HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo +from ...helpers.event import async_call_later from .const import ( CONF_ACCOUNTNAME, CONF_AEP_DATA, @@ -71,12 +72,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> bool: """Set up Mammotion Luba from a config entry.""" - if not entry.options: - hass.config_entries.async_update_entry( - entry, - options={CONF_STAY_CONNECTED_BLUETOOTH: False}, - ) - device_name = entry.data.get(CONF_DEVICE_NAME) address = entry.data.get(CONF_ADDRESS) mammotion = Mammotion() @@ -84,6 +79,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> password = entry.data.get(CONF_PASSWORD) stay_connected_ble = entry.data.get(CONF_STAY_CONNECTED_BLUETOOTH, False) + + if not entry.options: + hass.config_entries.async_update_entry( + entry, + options={CONF_STAY_CONNECTED_BLUETOOTH: stay_connected_ble}, + ) + + stay_connected_ble = entry.options.get(CONF_STAY_CONNECTED_BLUETOOTH, False) + use_wifi = entry.data.get(CONF_USE_WIFI, True) mammotion_devices: list[MammotionMowerData] = [] @@ -104,8 +108,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> LOGGER.debug(exc) await mammotion.login_and_initiate_cloud(account, password, True) - - if mqtt_client := mammotion.mqtt_list.get(account): store_cloud_credentials(hass, entry, mqtt_client.cloud_client) for ( @@ -125,32 +127,31 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> map_coordinator = MammotionMapUpdateCoordinator( hass, entry, device, mammotion ) + await report_coordinator.async_restore_data() # other coordinator await maintenance_coordinator.async_config_entry_first_refresh() await version_coordinator.async_config_entry_first_refresh() await report_coordinator.async_config_entry_first_refresh() - await map_coordinator.async_config_entry_first_refresh() device_config = DeviceConfig() - if ( - device_limits := device_config.get_working_parameters( + device_limits = device_config.get_working_parameters( + version_coordinator.data.sub_model_id + ) + if device_limits is None: + device_limits = device_config.get_working_parameters( device.productKey ) - is None - ): - if version_coordinator.data.model_id == "": - device_limits = device_config.get_best_default( - device.productKey - ) - else: - device_limits = device_config.get_working_parameters( - version_coordinator.data.model_id - ) + + if device_limits is None: + device_limits = device_config.get_best_default(device.productKey) mammotion_device = mammotion.get_device_by_name(device.deviceName) + if mammotion_device is None: + raise ConfigEntryError() + if address: ble_device = bluetooth.async_ble_device_from_address(hass, address) - if ble_device: + if ble_device and ble_device.name == device_name: mammotion_device.add_ble(ble_device) mammotion_device.ble().set_disconnect_strategy( not stay_connected_ble @@ -159,6 +160,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> mammotion_device.preference = ConnectionPreference.BLUETOOTH await mammotion_device.cloud().stop() mammotion_device.cloud().mqtt.disconnect() if mammotion_device.cloud().mqtt.is_connected() else None + mammotion_device.remove_cloud() mammotion_devices.append( MammotionMowerData( @@ -172,6 +174,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> map_coordinator=map_coordinator, ) ) + try: + await map_coordinator.async_request_refresh() + except: + """Do nothing for now.""" entry.runtime_data = mammotion_devices await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -183,15 +189,19 @@ def store_cloud_credentials(hass, config_entry, cloud_client: CloudIOTGateway) - """Store cloud credentials in config entry.""" if cloud_client is not None: + mammotion_data = config_entry.data.get(CONF_MAMMOTION_DATA) + if cloud_client.mammotion_http is not None: + mammotion_data = cloud_client.mammotion_http.response + config_updates = { **config_entry.data, - CONF_CONNECT_DATA: cloud_client.connect_response, - CONF_AUTH_DATA: cloud_client.login_by_oauth_response, - CONF_REGION_DATA: cloud_client.region_response, - CONF_AEP_DATA: cloud_client.aep_response, - CONF_SESSION_DATA: cloud_client.session_by_authcode_response, - CONF_DEVICE_DATA: cloud_client.devices_by_account_response, - CONF_MAMMOTION_DATA: cloud_client.mammotion_http.response, + CONF_CONNECT_DATA: cloud_client.connect_response.to_dict(), + CONF_AUTH_DATA: cloud_client.login_by_oauth_response.to_dict(), + CONF_REGION_DATA: cloud_client.region_response.to_dict(), + CONF_AEP_DATA: cloud_client.aep_response.to_dict(), + CONF_SESSION_DATA: cloud_client.session_by_authcode_response.to_dict(), + CONF_DEVICE_DATA: cloud_client.devices_by_account_response.to_dict(), + CONF_MAMMOTION_DATA: mammotion_data.to_dict(), } hass.config_entries.async_update_entry(config_entry, data=config_updates) @@ -245,14 +255,13 @@ async def check_and_restore_cloud( ) if isinstance(mammotion_data, dict): - mammotion_data = Response[LoginResponseData].from_dict(mammotion_data) + response = Response[LoginResponseData].from_dict(mammotion_data) mammotion_http = MammotionHTTP() - mammotion_http.response = mammotion_data - mammotion_http.login_info = mammotion_data.data + mammotion_http.response = response + mammotion_http.login_info = response.data cloud_client.set_http(mammotion_http) await hass.async_add_executor_job(cloud_client.check_or_refresh_session) - print("restore cloud") return cloud_client diff --git a/homeassistant/components/mammotion/binary_sensor.py b/homeassistant/components/mammotion/binary_sensor.py index 30e7751d5a726..bbbc873f6dbd1 100644 --- a/homeassistant/components/mammotion/binary_sensor.py +++ b/homeassistant/components/mammotion/binary_sensor.py @@ -6,7 +6,6 @@ from pymammotion.data.model.device import MowingDevice from homeassistant.components.binary_sensor import ( - ENTITY_ID_FORMAT, BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, @@ -70,9 +69,6 @@ def __init__( ) -> None: """Initialize the binary sensor entity.""" super().__init__(coordinator, entity_description.key) - self.entity_id = ENTITY_ID_FORMAT.format( - f"{coordinator.device_name}_{entity_description.key}" - ) self.entity_description = entity_description self._attr_translation_key = entity_description.translation_key diff --git a/homeassistant/components/mammotion/button.py b/homeassistant/components/mammotion/button.py index 7f9b4876a3086..5c3ff8571d5b3 100644 --- a/homeassistant/components/mammotion/button.py +++ b/homeassistant/components/mammotion/button.py @@ -3,11 +3,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass -from homeassistant.components.button import ( - ENTITY_ID_FORMAT, - ButtonEntity, - ButtonEntityDescription, -) +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -93,9 +89,6 @@ def __init__( super().__init__(coordinator, entity_description.key) self.entity_description = entity_description self._attr_translation_key = entity_description.key - self.entity_id = ENTITY_ID_FORMAT.format( - f"{coordinator.device_name}_{entity_description.key}" - ) async def async_press(self) -> None: """Handle the button press.""" diff --git a/homeassistant/components/mammotion/camera.py b/homeassistant/components/mammotion/camera.py index d4afda8dae80a..20ffca0881cbc 100644 --- a/homeassistant/components/mammotion/camera.py +++ b/homeassistant/components/mammotion/camera.py @@ -12,7 +12,6 @@ from pymammotion.utility.device_type import DeviceType from homeassistant.components.camera import ( - ENTITY_ID_FORMAT, Camera, CameraEntityDescription, StreamType, @@ -75,9 +74,6 @@ def __init__( self.entity_description = entity_description self._attr_translation_key = entity_description.key self._stream_data: StreamSubscriptionResponse | None = None - self.entity_id = ENTITY_ID_FORMAT.format( - f"{coordinator.device_name}_{entity_description.key}" - ) @property def frontend_stream_type(self) -> StreamType | None: diff --git a/homeassistant/components/mammotion/config_flow.py b/homeassistant/components/mammotion/config_flow.py index a30ed49e56a99..ec014623ef0b3 100644 --- a/homeassistant/components/mammotion/config_flow.py +++ b/homeassistant/components/mammotion/config_flow.py @@ -20,7 +20,6 @@ ConfigFlow, ConfigFlowResult, OptionsFlow, - OptionsFlowWithConfigEntry, ) from homeassistant.const import CONF_ADDRESS, CONF_PASSWORD from homeassistant.core import callback @@ -367,9 +366,15 @@ async def async_step_reconfigure( ) -class MammotionConfigFlowHandler(OptionsFlowWithConfigEntry): +class MammotionConfigFlowHandler(OptionsFlow): """Handles options flow for the component.""" + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize options flow.""" + self.stay_connected_bluetooth = config_entry.options.get( + CONF_STAY_CONNECTED_BLUETOOTH, False + ) + async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -381,9 +386,7 @@ async def async_step_init( { vol.Optional( CONF_STAY_CONNECTED_BLUETOOTH, - default=self.config_entry.options.get( - CONF_STAY_CONNECTED_BLUETOOTH, False - ), + default=self.stay_connected_bluetooth, ): cv.boolean } ) diff --git a/homeassistant/components/mammotion/coordinator.py b/homeassistant/components/mammotion/coordinator.py index 7c9d106ed29f6..ebd613ddeb07f 100644 --- a/homeassistant/components/mammotion/coordinator.py +++ b/homeassistant/components/mammotion/coordinator.py @@ -3,11 +3,11 @@ from __future__ import annotations import asyncio -from dataclasses import asdict from datetime import timedelta from typing import TYPE_CHECKING, Any import betterproto +from mashumaro.exceptions import InvalidFieldValue from pymammotion.aliyun.cloud_gateway import ( DeviceOfflineException, GatewayTimeoutException, @@ -18,7 +18,7 @@ from pymammotion.data.model.device_config import OperationSettings, create_path_order from pymammotion.data.model.report_info import Maintain from pymammotion.mammotion.devices.mammotion import ConnectionPreference, Mammotion -from pymammotion.proto.mctrl_sys import RptAct, RptInfoType +from pymammotion.proto import RptAct, RptInfoType from pymammotion.utility.constant import WorkMode from pymammotion.utility.device_type import DeviceType @@ -27,9 +27,9 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.storage import Store from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from ...helpers.storage import Store from .const import ( COMMAND_EXCEPTIONS, CONF_ACCOUNTNAME, @@ -60,7 +60,7 @@ class MammotionBaseUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): """Mammotion DataUpdateCoordinator.""" - manager: Mammotion = None + manager: Mammotion | None = None device: Device | None = None updated_once: bool @@ -87,12 +87,11 @@ def __init__( self._operation_settings = OperationSettings() self.update_failures = 0 self.enabled = True - self.updated_once = False async def set_scheduled_updates(self, enabled: bool) -> None: - self.enabled = enabled device = self.manager.get_device_by_name(self.device_name) - if self.enabled: + device.mower_state.enabled = enabled + if device.mower_state.enabled: if device.has_cloud(): await device.cloud().start() else: @@ -142,14 +141,16 @@ def store_cloud_credentials(self) -> None: self.config_entry, data=config_updates ) - async def async_send_command(self, command: str, **kwargs: Any) -> bool: + async def async_send_command(self, command: str, **kwargs: Any) -> bool | None: """Send command.""" if not self.manager.get_device_by_name(self.device_name).mower_state.online: return False + device = self.manager.get_device_by_name(self.device_name) + try: await self.manager.send_command_with_args( - self.device.deviceName, command, **kwargs + self.device_name, command, **kwargs ) self.update_failures = 0 return True @@ -157,27 +158,26 @@ async def async_send_command(self, command: str, **kwargs: Any) -> bool: self.update_failures += 1 await self.async_login() return False - except GatewayTimeoutException as exc: + except GatewayTimeoutException: self.update_failures += 1 if self.update_failures > 5: - raise HomeAssistantError( - translation_domain=DOMAIN, translation_key="command_failed" - ) from exc + raise GatewayTimeoutException() + if self.update_failures > 0: + await asyncio.sleep(1) await self.async_send_command(command, **kwargs) except DeviceOfflineException: """Device is offline try bluetooth if we have it.""" try: - if device := self.manager.get_device_by_name(self.device_name): - if device.has_ble(): - # if we don't do this it will stay connected and no longer update over wifi - device.ble().set_disconnect_strategy(True) - await ( - self.manager.get_device_by_name(self.device_name) - .ble() - .queue_command(command, **kwargs) - ) - return True - raise DeviceOfflineException() + if device.has_ble(): + # if we don't do this it will stay connected and no longer update over wifi + device.ble().set_disconnect_strategy(True) + await ( + self.manager.get_device_by_name(self.device_name) + .ble() + .queue_command(command, **kwargs) + ) + return True + raise DeviceOfflineException() except COMMAND_EXCEPTIONS as exc: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="command_failed" @@ -193,7 +193,7 @@ async def check_firmware_version(self) -> None: if device_entry is None: return - new_swversion = mower.mower_state.swversion + new_swversion = mower.device_firmwares.device_version if new_swversion is not None or new_swversion != device_entry.sw_version: device_registry.async_update_device( @@ -334,7 +334,11 @@ async def async_move_back(self, speed: float) -> None: async def async_rtk_dock_location(self) -> None: """RTK and dock location.""" - await self.async_send_command("allpowerfull_rw", id=5, rw=1, context=1) + await self.async_send_command("allpowerfull_rw", rw_id=5, rw=1, context=1) + + async def async_get_area_list(self) -> None: + """Mowing area List.""" + await self.async_send_command("get_area_name_list", device_id=self.device.iotId) async def send_command_and_update(self, command_str: str, **kwargs: Any) -> None: await self.async_send_command(command_str, **kwargs) @@ -428,13 +432,12 @@ async def async_restore_data(self) -> None: async def async_save_data(self, data: MowingDevice) -> None: """Get map data from the device.""" store = Store(self.hass, version=1, key=self.device_name) - stored_data = asdict(data) - await store.async_save(stored_data) + await store.async_save(data.to_dict()) - async def _async_update_data(self): + async def _async_update_data(self) -> _DataT | None: device = self.manager.get_device_by_name(self.device_name) - if not self.enabled or not device.mower_state.online: + if not device.mower_state.enabled or not device.mower_state.online: return self.data if self.update_failures > 3 and device.preference is ConnectionPreference.WIFI: @@ -467,7 +470,8 @@ def clear_update_failures(self) -> None: async def _async_update_data(self) -> MowingDevice: """Get data from the device.""" - await super()._async_update_data() + if data := await super()._async_update_data(): + return data device = self.manager.get_device_by_name(self.device_name) @@ -488,10 +492,16 @@ async def _async_update_data(self) -> MowingDevice: LOGGER.debug("Updated Mammotion device %s", self.device_name) LOGGER.debug("================= Debug Log =================") - LOGGER.debug( - "Mammotion device data: %s", - asdict(self.manager.get_device_by_name(self.device_name).mower_state), - ) + if device.preference is ConnectionPreference.BLUETOOTH: + LOGGER.debug( + "Mammotion device data: %s", + self.manager.get_device_by_name(self.device_name).ble()._raw_data, + ) + if device.preference is ConnectionPreference.WIFI: + LOGGER.debug( + "Mammotion device data: %s", + self.manager.get_device_by_name(self.device_name).cloud()._raw_data, + ) LOGGER.debug("==================================") self.update_failures = 0 @@ -503,8 +513,6 @@ async def _async_update_data(self) -> MowingDevice: else: self.update_interval = DEFAULT_INTERVAL - self.updated_once = True - return data async def _async_update_notification(self, res: tuple[str, Any | None]) -> None: @@ -516,8 +524,12 @@ async def _async_update_notification(self, res: tuple[str, Any | None]) -> None: self.async_set_updated_data(mower) async def _async_setup(self) -> None: + """Setup report coordinator.""" device = self.manager.get_device_by_name(self.device_name) + if self.data is None: + self.data = device.mower_state + if device.has_cloud(): device.cloud().set_notification_callback(self._async_update_notification) elif device.has_ble(): @@ -545,7 +557,8 @@ def __init__( async def _async_update_data(self) -> Maintain: """Get data from the device.""" - await super()._async_update_data() + if data := await super()._async_update_data(): + return data try: await self.async_send_command("get_maintenance") @@ -556,13 +569,19 @@ async def _async_update_data(self) -> Maintain: device.mower_state.online = False data = device.mower_state return data - - self.updated_once = True + except GatewayTimeoutException: + """Gateway is timing out again.""" return self.manager.get_device_by_name( self.device.deviceName ).mower_state.report_data.maintenance + async def _async_setup(self) -> None: + """Setup maintenance coordinator.""" + device = self.manager.get_device_by_name(self.device_name) + if self.data is None: + self.data = device.mower_state.report_data.maintenance + class MammotionDeviceVersionUpdateCoordinator( MammotionBaseUpdateCoordinator[MowerInfo] @@ -582,30 +601,49 @@ def __init__( config_entry=config_entry, device=device, mammotion=mammotion, - update_interval=DEVICE_VERSION_INTERVAL, + update_interval=DEFAULT_INTERVAL, ) async def _async_update_data(self): """Get data from the device.""" - await super()._async_update_data() - - try: - await self.async_send_command("get_device_version_main") - await self.async_send_command("get_device_version_info") - - await self.check_firmware_version() - except DeviceOfflineException: - """Device is offline try bluetooth if we have it.""" - device = self.manager.get_device_by_name(self.device_name) - device.mower_state.online = False - data = device.mower_state.mower_state + if data := await super()._async_update_data(): return data + command_list = [ + "get_device_version_main", + "get_device_version_info", + "get_device_base_info", + "get_device_product_model", + ] + for command in command_list: + try: + await self.async_send_command(command) + + except DeviceOfflineException: + """Device is offline bluetooth has been attempted.""" + device = self.manager.get_device_by_name(self.device_name) + device.mower_state.online = False + return device.mower_state.mower_state + except GatewayTimeoutException: + """Gateway is timing out again.""" data = self.manager.get_device_by_name(self.device_name).mower_state.mower_state - self.updated_once = True + await self.check_firmware_version() + + if data.model_id: + self.update_interval = DEVICE_VERSION_INTERVAL return data + async def _async_setup(self) -> None: + device = self.manager.get_device_by_name(self.device_name) + if self.data is None: + self.data = device.mower_state.mower_state + + try: + await self.async_send_command("get_device_product_model") + except DeviceOfflineException: + """Device is offline bluetooth has been attempted.""" + class MammotionMapUpdateCoordinator(MammotionBaseUpdateCoordinator[MowerInfo]): """Class to manage fetching mammotion data.""" @@ -632,7 +670,8 @@ def _map_callback(self) -> None: async def _async_update_data(self): """Get data from the device.""" - await super()._async_update_data() + if data := await super()._async_update_data(): + return data device = self.manager.get_device_by_name(self.device_name) try: @@ -645,22 +684,28 @@ async def _async_update_data(self): except DeviceOfflineException: """Device is offline try bluetooth if we have it.""" device.mower_state.online = False - data = device.mower_state.mower_state - return data + return device.mower_state.mower_state + except GatewayTimeoutException: + """Gateway is timing out again.""" data = self.manager.get_device_by_name(self.device_name).mower_state.mower_state - self.updated_once = True return data async def _async_setup(self) -> None: """Setup coordinator with initial calls to get map data.""" device = self.manager.get_device_by_name(self.device_name) + if self.data is None: + self.data = device.mower_state.mower_state - if not self.enabled or not device.mower_state.online: + if not device.mower_state.enabled or not device.mower_state.online: return try: await self.async_rtk_dock_location() + if not DeviceType.is_luba1(self.device_name): + await self.async_get_area_list() except DeviceOfflineException: """Device is offline try bluetooth if we have it.""" device.mower_state.online = False + except GatewayTimeoutException: + """Gateway is timing out again.""" diff --git a/homeassistant/components/mammotion/device_tracker.py b/homeassistant/components/mammotion/device_tracker.py index 5f574c338e7be..5be227f7a0030 100644 --- a/homeassistant/components/mammotion/device_tracker.py +++ b/homeassistant/components/mammotion/device_tracker.py @@ -3,11 +3,7 @@ import logging from typing import Any -from homeassistant.components.device_tracker import ( - ENTITY_ID_FORMAT, - SourceType, - TrackerEntity, -) +from homeassistant.components.device_tracker import SourceType, TrackerEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity @@ -44,7 +40,6 @@ def __init__(self, coordinator: MammotionBaseUpdateCoordinator) -> None: super().__init__(coordinator, f"{coordinator.device_name}_gps") self._attr_name = coordinator.device_name - self.entity_id = ENTITY_ID_FORMAT.format(f"{coordinator.device_name}") @property def extra_state_attributes(self) -> dict[str, Any]: diff --git a/homeassistant/components/mammotion/entity.py b/homeassistant/components/mammotion/entity.py index 24a60f6bdfe65..29b2b6d08d3f5 100644 --- a/homeassistant/components/mammotion/entity.py +++ b/homeassistant/components/mammotion/entity.py @@ -20,7 +20,7 @@ def __init__(self, coordinator: MammotionBaseUpdateCoordinator, key: str) -> Non @property def device_info(self) -> DeviceInfo: mower = self.coordinator.data - swversion = mower.mower_state.swversion + swversion = mower.device_firmwares.device_version product_key = mower.mower_state.product_key if product_key is None or product_key == "": @@ -61,7 +61,7 @@ def device_info(self) -> DeviceInfo: model_id=model_id, name=device_name, sw_version=swversion, - model=self.coordinator.device.productModel, + model=self.coordinator.device.productModel or model_id, suggested_area="Garden", ) diff --git a/homeassistant/components/mammotion/lawn_mower.py b/homeassistant/components/mammotion/lawn_mower.py index b2d3bbab596e5..abd3584341756 100644 --- a/homeassistant/components/mammotion/lawn_mower.py +++ b/homeassistant/components/mammotion/lawn_mower.py @@ -114,7 +114,6 @@ def __init__(self, coordinator: MammotionReportUpdateCoordinator) -> None: """Initialize the lawn mower.""" super().__init__(coordinator, "mower") self._attr_name = None # main feature of device - self.entity_id = f"lawn_mower.{coordinator.device_name}" @property def rpt_dev_status(self) -> DeviceData: diff --git a/homeassistant/components/mammotion/manifest.json b/homeassistant/components/mammotion/manifest.json index a299dc142d46b..7de00e30f8be4 100644 --- a/homeassistant/components/mammotion/manifest.json +++ b/homeassistant/components/mammotion/manifest.json @@ -17,8 +17,8 @@ "config_flow": true, "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/mammotion", - "loggers": ["pymammotion"], "integration_type": "device", "iot_class": "local_push", - "requirements": ["pymammotion==0.4.0a9"] + "loggers": ["pymammotion"], + "requirements": ["pymammotion==0.4.3"] } diff --git a/homeassistant/components/mammotion/number.py b/homeassistant/components/mammotion/number.py index 85bac79c404b2..8e72e7797ea26 100644 --- a/homeassistant/components/mammotion/number.py +++ b/homeassistant/components/mammotion/number.py @@ -5,7 +5,6 @@ from pymammotion.utility.device_type import DeviceType from homeassistant.components.number import ( - ENTITY_ID_FORMAT, NumberDeviceClass, NumberEntity, NumberEntityDescription, @@ -87,8 +86,8 @@ class MammotionConfigNumberEntityDescription(NumberEntityDescription): MammotionConfigNumberEntityDescription( key="blade_height", step=1, - min_value=25, # ToDo: To be dynamiclly set based on model (h\non H) - max_value=70, # ToDo: To be dynamiclly set based on model (h\non H) + min_value=25, + max_value=70, mode=NumberMode.BOX, set_fn=lambda coordinator, value: setattr( coordinator.operation_settings, "blade_height", value @@ -133,7 +132,6 @@ async def async_setup_entry( for mower in mammotion_devices: limits = mower.device_limits - entities: list[MammotionConfigNumberEntity] = [] for entity_description in NUMBER_WORKING_ENTITIES: @@ -185,9 +183,6 @@ def __init__( self._attr_native_value = 0 if self.entity_description.key == "toward_included_angle": self._attr_native_value = 90 - self.entity_id = ENTITY_ID_FORMAT.format( - f"{coordinator.device_name}_{entity_description.key}" - ) async def async_set_native_value(self, value: float) -> None: """Set native value for number.""" @@ -208,20 +203,18 @@ def __init__( """Init MammotionWorkingNumberEntity.""" super().__init__(coordinator, entity_description) - min_attr = f"{entity_description.key}_min" - max_attr = f"{entity_description.key}_max" - self.entity_id = ENTITY_ID_FORMAT.format( - f"{coordinator.device_name}_{entity_description.key}" - ) - - if hasattr(limits, min_attr) and hasattr(limits, max_attr): - self._attr_native_min_value = getattr(limits, min_attr) - self._attr_native_max_value = getattr(limits, max_attr) + if hasattr(limits, entity_description.key): + self._attr_native_min_value = getattr(limits, entity_description.key).min + self._attr_native_max_value = getattr(limits, entity_description.key).max else: # Fallback to the values from entity_description self._attr_native_min_value = entity_description.min_value self._attr_native_max_value = entity_description.max_value + self._attr_native_value = max( + self._attr_native_value, self._attr_native_min_value + ) + @property def native_min_value(self) -> float: """Return the minimum value.""" diff --git a/homeassistant/components/mammotion/select.py b/homeassistant/components/mammotion/select.py index 95d90aaef8e2e..bc14416eac213 100644 --- a/homeassistant/components/mammotion/select.py +++ b/homeassistant/components/mammotion/select.py @@ -12,11 +12,7 @@ ) from pymammotion.utility.device_type import DeviceType -from homeassistant.components.select import ( - ENTITY_ID_FORMAT, - SelectEntity, - SelectEntityDescription, -) +from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -194,9 +190,6 @@ def __init__( self._attr_translation_key = entity_description.key self._attr_options = entity_description.options self._attr_current_option = entity_description.options[0] - self.entity_id = ENTITY_ID_FORMAT.format( - f"{coordinator.device_name}_{entity_description.key}" - ) async def async_select_option(self, option: str) -> None: self._attr_current_option = option @@ -226,9 +219,6 @@ def __init__( self._attr_translation_key = entity_description.key self._attr_options = entity_description.options self._attr_current_option = entity_description.options[0] - self.entity_id = ENTITY_ID_FORMAT.format( - f"{coordinator.device_name}_{entity_description.key}" - ) async def async_select_option(self, option: str) -> None: self._attr_current_option = option diff --git a/homeassistant/components/mammotion/sensor.py b/homeassistant/components/mammotion/sensor.py index f894253e0e067..73f8852e72474 100644 --- a/homeassistant/components/mammotion/sensor.py +++ b/homeassistant/components/mammotion/sensor.py @@ -14,7 +14,6 @@ from pymammotion.utility.device_type import DeviceType from homeassistant.components.sensor import ( - ENTITY_ID_FORMAT, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -93,10 +92,7 @@ class MammotionSensorEntityDescription(SensorEntityDescription): key="connect_type", device_class=SensorDeviceClass.ENUM, native_unit_of_measurement=None, - value_fn=lambda mower_data: device_connection( - mower_data.report_data.connect.connect_type, - mower_data.report_data.connect.used_net, - ), + value_fn=lambda mower_data: device_connection(mower_data.report_data.connect), ), MammotionSensorEntityDescription( key="maintenance_distance", @@ -285,9 +281,6 @@ def __init__( super().__init__(coordinator, entity_description.key) self.entity_description = entity_description self._attr_translation_key = entity_description.key - self.entity_id = ENTITY_ID_FORMAT.format( - f"{coordinator.device_name}_{entity_description.key}" - ) @property def native_value(self) -> StateType: diff --git a/homeassistant/components/mammotion/strings.json b/homeassistant/components/mammotion/strings.json index aa6a97b1c4471..3617f669dc499 100644 --- a/homeassistant/components/mammotion/strings.json +++ b/homeassistant/components/mammotion/strings.json @@ -140,6 +140,9 @@ } }, "switch": { + "area": { + "name": "Area {name}" + }, "blade_status": { "name": "Blades on/off" }, @@ -362,4 +365,4 @@ "message": "Failed to send command to the mower." } } -} \ No newline at end of file +} diff --git a/homeassistant/components/mammotion/switch.py b/homeassistant/components/mammotion/switch.py index c19e008bffd3c..98784cd9e9acb 100644 --- a/homeassistant/components/mammotion/switch.py +++ b/homeassistant/components/mammotion/switch.py @@ -88,7 +88,7 @@ class MammotionConfigAreaSwitchEntityDescription(MammotionSwitchEntityDescriptio UPDATE_SWITCH_ENTITIES: tuple[MammotionUpdateSwitchEntityDescription, ...] = ( MammotionUpdateSwitchEntityDescription( key="schedule_updates", - is_on_func=lambda coordinator: coordinator.enabled, + is_on_func=lambda coordinator: coordinator.data.enabled, set_fn=lambda coordinator, value: coordinator.set_scheduled_updates(value), ), ) diff --git a/requirements_all.txt b/requirements_all.txt index ccb4425cd8de5..997d0ec954642 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2334,7 +2334,7 @@ pylutron==0.4.1 pymailgunner==1.4 # homeassistant.components.mammotion -pymammotion==0.4.0a9 +pymammotion==0.4.3 # homeassistant.components.firmata pymata-express==1.19 From 0cf22e8b46ae6281565c0df5db2b516127bbc7aa Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Tue, 22 Apr 2025 11:50:47 +1200 Subject: [PATCH 21/66] prepare for merging to core --- .../components/mammotion/__init__.py | 63 ++-- .../components/mammotion/binary_sensor.py | 78 ----- homeassistant/components/mammotion/button.py | 95 ------ homeassistant/components/mammotion/camera.py | 105 ------ .../components/mammotion/config_flow.py | 84 ++--- homeassistant/components/mammotion/const.py | 9 + .../components/mammotion/coordinator.py | 261 +++++++------- .../components/mammotion/device_tracker.py | 70 ---- .../components/mammotion/diagnostics.py | 10 +- homeassistant/components/mammotion/entity.py | 23 +- .../components/mammotion/lawn_mower.py | 8 +- .../components/mammotion/manifest.json | 2 +- homeassistant/components/mammotion/number.py | 232 ------------- homeassistant/components/mammotion/select.py | 226 ------------ homeassistant/components/mammotion/sensor.py | 288 ---------------- .../components/mammotion/services.yaml | 88 ++--- .../components/mammotion/strings.json | 230 +++---------- homeassistant/components/mammotion/switch.py | 323 ------------------ requirements_all.txt | 2 +- 19 files changed, 315 insertions(+), 1882 deletions(-) delete mode 100644 homeassistant/components/mammotion/binary_sensor.py delete mode 100644 homeassistant/components/mammotion/button.py delete mode 100644 homeassistant/components/mammotion/camera.py delete mode 100644 homeassistant/components/mammotion/device_tracker.py delete mode 100644 homeassistant/components/mammotion/number.py delete mode 100644 homeassistant/components/mammotion/select.py delete mode 100644 homeassistant/components/mammotion/sensor.py delete mode 100644 homeassistant/components/mammotion/switch.py diff --git a/homeassistant/components/mammotion/__init__.py b/homeassistant/components/mammotion/__init__.py index 442bda730f213..29936bd59d5a7 100644 --- a/homeassistant/components/mammotion/__init__.py +++ b/homeassistant/components/mammotion/__init__.py @@ -4,6 +4,7 @@ from aiohttp import ClientConnectorError from pymammotion import CloudIOTGateway +from pymammotion.aliyun.cloud_gateway import CheckSessionException from pymammotion.aliyun.model.aep_response import AepResponse from pymammotion.aliyun.model.connect_response import ConnectResponse from pymammotion.aliyun.model.dev_by_account_response import ListingDevByAccountResponse @@ -17,16 +18,14 @@ from pymammotion.http.model.http import LoginResponseData, Response from pymammotion.mammotion.devices.mammotion import ConnectionPreference, Mammotion from pymammotion.utility.device_config import DeviceConfig +from Tea.exceptions import UnretryableException from homeassistant.components import bluetooth from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, CONF_PASSWORD, Platform -from homeassistant.core import HassJob, HomeAssistant +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.device_registry import DeviceInfo -from ...helpers.event import async_call_later from .const import ( CONF_ACCOUNTNAME, CONF_AEP_DATA, @@ -36,35 +35,22 @@ CONF_DEVICE_NAME, CONF_MAMMOTION_DATA, CONF_REGION_DATA, - CONF_RETRY_COUNT, CONF_SESSION_DATA, CONF_STAY_CONNECTED_BLUETOOTH, CONF_USE_WIFI, - DEFAULT_RETRY_COUNT, DEVICE_SUPPORT, - DOMAIN, EXPIRED_CREDENTIAL_EXCEPTIONS, LOGGER, ) from .coordinator import ( - MammotionBaseUpdateCoordinator, MammotionDeviceVersionUpdateCoordinator, MammotionMaintenanceUpdateCoordinator, MammotionMapUpdateCoordinator, MammotionReportUpdateCoordinator, ) -from .models import MammotionDevices, MammotionMowerData - -PLATFORMS: list[Platform] = [ - Platform.BINARY_SENSOR, - Platform.LAWN_MOWER, - Platform.DEVICE_TRACKER, - Platform.SENSOR, - Platform.BUTTON, - Platform.SWITCH, - Platform.NUMBER, - Platform.SELECT, -] +from .models import MammotionMowerData + +PLATFORMS: list[Platform] = [Platform.LAWN_MOWER] type MammotionConfigEntry = ConfigEntry[list[MammotionMowerData]] @@ -101,12 +87,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> if cloud_client is None: await mammotion.login_and_initiate_cloud(account, password) else: + # sometimes mammotion_data is missing.... + if cloud_client.mammotion_http is None: + mammotion_http = MammotionHTTP() + await mammotion_http.login(account, password) + cloud_client.set_http(mammotion_http) await mammotion.initiate_cloud_connection(account, cloud_client) except ClientConnectorError as err: - raise ConfigEntryNotReady(err) + raise ConfigEntryNotReady from err except EXPIRED_CREDENTIAL_EXCEPTIONS as exc: LOGGER.debug(exc) await mammotion.login_and_initiate_cloud(account, password, True) + except UnretryableException as err: + raise ConfigEntryError from err if mqtt_client := mammotion.mqtt_list.get(account): store_cloud_credentials(hass, entry, mqtt_client.cloud_client) @@ -147,7 +140,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> mammotion_device = mammotion.get_device_by_name(device.deviceName) if mammotion_device is None: - raise ConfigEntryError() + raise ConfigEntryError if address: ble_device = bluetooth.async_ble_device_from_address(hass, address) @@ -160,6 +153,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> mammotion_device.preference = ConnectionPreference.BLUETOOTH await mammotion_device.cloud().stop() mammotion_device.cloud().mqtt.disconnect() if mammotion_device.cloud().mqtt.is_connected() else None + # not entirely sure this is a good idea mammotion_device.remove_cloud() mammotion_devices.append( @@ -195,13 +189,13 @@ def store_cloud_credentials(hass, config_entry, cloud_client: CloudIOTGateway) - config_updates = { **config_entry.data, - CONF_CONNECT_DATA: cloud_client.connect_response.to_dict(), - CONF_AUTH_DATA: cloud_client.login_by_oauth_response.to_dict(), - CONF_REGION_DATA: cloud_client.region_response.to_dict(), - CONF_AEP_DATA: cloud_client.aep_response.to_dict(), - CONF_SESSION_DATA: cloud_client.session_by_authcode_response.to_dict(), - CONF_DEVICE_DATA: cloud_client.devices_by_account_response.to_dict(), - CONF_MAMMOTION_DATA: mammotion_data.to_dict(), + CONF_CONNECT_DATA: cloud_client.connect_response, + CONF_AUTH_DATA: cloud_client.login_by_oauth_response, + CONF_REGION_DATA: cloud_client.region_response, + CONF_AEP_DATA: cloud_client.aep_response, + CONF_SESSION_DATA: cloud_client.session_by_authcode_response, + CONF_DEVICE_DATA: cloud_client.devices_by_account_response, + CONF_MAMMOTION_DATA: mammotion_data, } hass.config_entries.async_update_entry(config_entry, data=config_updates) @@ -255,13 +249,16 @@ async def check_and_restore_cloud( ) if isinstance(mammotion_data, dict): - response = Response[LoginResponseData].from_dict(mammotion_data) + mammotion_data = Response[LoginResponseData].from_dict(mammotion_data) mammotion_http = MammotionHTTP() - mammotion_http.response = response - mammotion_http.login_info = response.data + mammotion_http.response = mammotion_data + mammotion_http.login_info = mammotion_data.data cloud_client.set_http(mammotion_http) - await hass.async_add_executor_job(cloud_client.check_or_refresh_session) + try: + await cloud_client.check_or_refresh_session() + except CheckSessionException: + return None return cloud_client diff --git a/homeassistant/components/mammotion/binary_sensor.py b/homeassistant/components/mammotion/binary_sensor.py deleted file mode 100644 index bbbc873f6dbd1..0000000000000 --- a/homeassistant/components/mammotion/binary_sensor.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Mammotion binary sensor entities.""" - -from collections.abc import Callable -from dataclasses import dataclass - -from pymammotion.data.model.device import MowingDevice - -from homeassistant.components.binary_sensor import ( - BinarySensorDeviceClass, - BinarySensorEntity, - BinarySensorEntityDescription, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from . import MammotionConfigEntry -from .coordinator import MammotionBaseUpdateCoordinator -from .entity import MammotionBaseEntity - - -@dataclass(frozen=True, kw_only=True) -class MammotionBinarySensorEntityDescription( - BinarySensorEntityDescription, -): - """Describes Mammotion binary sensor entity.""" - - is_on_fn: Callable[[MowingDevice], bool | None] - - -BINARY_SENSORS: tuple[MammotionBinarySensorEntityDescription, ...] = ( - MammotionBinarySensorEntityDescription( - key="charging", - device_class=BinarySensorDeviceClass.BATTERY_CHARGING, - is_on_fn=lambda mower_data: mower_data.report_data.dev.charge_state in (1, 2), - ), -) - -""" -TODO: -read_and_set_sidelight(true, 1) is read -read_and_set_sidelight(bool, 0) is write -""" - - -async def async_setup_entry( - hass: HomeAssistant, - entry: MammotionConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Mammotion sensor entity.""" - mammotion_devices = entry.runtime_data - - for mower in mammotion_devices: - async_add_entities( - MammotionBinarySensorEntity(mower.reporting_coordinator, entity_description) - for entity_description in BINARY_SENSORS - ) - - -class MammotionBinarySensorEntity(MammotionBaseEntity, BinarySensorEntity): - """Mammotion sensor entity.""" - - entity_description: MammotionBinarySensorEntityDescription - - def __init__( - self, - coordinator: MammotionBaseUpdateCoordinator, - entity_description: MammotionBinarySensorEntityDescription, - ) -> None: - """Initialize the binary sensor entity.""" - super().__init__(coordinator, entity_description.key) - self.entity_description = entity_description - self._attr_translation_key = entity_description.translation_key - - @property - def is_on(self) -> bool | None: - """Return true if the binary sensor is on.""" - return self.entity_description.is_on_fn(self.coordinator.data) diff --git a/homeassistant/components/mammotion/button.py b/homeassistant/components/mammotion/button.py deleted file mode 100644 index 5c3ff8571d5b3..0000000000000 --- a/homeassistant/components/mammotion/button.py +++ /dev/null @@ -1,95 +0,0 @@ -"""Mammotion button sensor entities.""" - -from collections.abc import Awaitable, Callable -from dataclasses import dataclass - -from homeassistant.components.button import ButtonEntity, ButtonEntityDescription -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from . import MammotionConfigEntry -from .coordinator import MammotionBaseUpdateCoordinator -from .entity import MammotionBaseEntity - - -@dataclass(frozen=True, kw_only=True) -class MammotionButtonSensorEntityDescription(ButtonEntityDescription): - """Describes Mammotion button sensor entity.""" - - press_fn: Callable[[MammotionBaseUpdateCoordinator], Awaitable[None]] - - -BUTTON_SENSORS: tuple[MammotionButtonSensorEntityDescription, ...] = ( - MammotionButtonSensorEntityDescription( - key="start_map_sync", - press_fn=lambda coordinator: coordinator.async_sync_maps(), - ), - MammotionButtonSensorEntityDescription( - key="resync_rtk_dock", - press_fn=lambda coordinator: coordinator.async_rtk_dock_location(), - ), - MammotionButtonSensorEntityDescription( - key="release_from_dock", - press_fn=lambda coordinator: coordinator.async_leave_dock(), - ), - MammotionButtonSensorEntityDescription( - key="emergency_nudge_forward", - press_fn=lambda coordinator: coordinator.async_move_forward(0.4), - ), - MammotionButtonSensorEntityDescription( - key="emergency_nudge_left", - press_fn=lambda coordinator: coordinator.async_move_left(0.4), - ), - MammotionButtonSensorEntityDescription( - key="emergency_nudge_right", - press_fn=lambda coordinator: coordinator.async_move_right(0.4), - ), - MammotionButtonSensorEntityDescription( - key="emergency_nudge_back", - press_fn=lambda coordinator: coordinator.async_move_back(0.4), - ), - MammotionButtonSensorEntityDescription( - key="cancel_task", - press_fn=lambda coordinator: coordinator.async_cancel_task(), - ), - MammotionButtonSensorEntityDescription( - key="clear_all_mapdata", - press_fn=lambda coordinator: coordinator.clear_all_maps(), - ), -) - - -async def async_setup_entry( - hass: HomeAssistant, - entry: MammotionConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Mammotion button sensor entity.""" - mammotion_devices = entry.runtime_data - - for mower in mammotion_devices: - async_add_entities( - MammotionButtonSensorEntity(mower.reporting_coordinator, entity_description) - for entity_description in BUTTON_SENSORS - ) - - -class MammotionButtonSensorEntity(MammotionBaseEntity, ButtonEntity): - """Mammotion button sensor entity.""" - - entity_description: MammotionButtonSensorEntityDescription - _attr_has_entity_name = True - - def __init__( - self, - coordinator: MammotionBaseUpdateCoordinator, - entity_description: MammotionButtonSensorEntityDescription, - ) -> None: - """Initialize the button sensor entity.""" - super().__init__(coordinator, entity_description.key) - self.entity_description = entity_description - self._attr_translation_key = entity_description.key - - async def async_press(self) -> None: - """Handle the button press.""" - await self.entity_description.press_fn(self.coordinator) diff --git a/homeassistant/components/mammotion/camera.py b/homeassistant/components/mammotion/camera.py deleted file mode 100644 index 20ffca0881cbc..0000000000000 --- a/homeassistant/components/mammotion/camera.py +++ /dev/null @@ -1,105 +0,0 @@ -"""Mammotion camera entities.""" - -from __future__ import annotations - -from collections.abc import Callable -from dataclasses import dataclass -from typing import Any - -from pymammotion.aliyun.model.stream_subscription_response import ( - StreamSubscriptionResponse, -) -from pymammotion.utility.device_type import DeviceType - -from homeassistant.components.camera import ( - Camera, - CameraEntityDescription, - StreamType, - WebRTCSendMessage, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from . import MammotionConfigEntry -from .coordinator import MammotionBaseUpdateCoordinator -from .entity import MammotionBaseEntity - - -@dataclass(frozen=True, kw_only=True) -class MammotionCameraEntityDescription(CameraEntityDescription): - """Describes Mammotion camera entity.""" - - stream_fn: Callable[[MammotionBaseUpdateCoordinator], StreamSubscriptionResponse] - - -CAMERAS: tuple[MammotionCameraEntityDescription, ...] = ( - MammotionCameraEntityDescription( - key="webrtc_camera", - stream_fn=lambda coordinator: coordinator.get_stream_subscription(), - ), -) - - -async def async_setup_entry( - hass: HomeAssistant, - entry: MammotionConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Mammotion camera entities.""" - mowers = entry.runtime_data - for mower in mowers: - if not DeviceType.is_luba1(mower.device.deviceName): - print("CAMERA API THING: ") - api = await mower.api.get_stream_subscription(mower.device.deviceName) - print(api) - # async_add_entities( - # MammotionWebRTCCamera(mower.reporting_coordinator, entity_description) - # for entity_description in CAMERAS - # ) - - -class MammotionWebRTCCamera(MammotionBaseEntity, Camera): - """Mammotion WebRTC camera entity.""" - - entity_description: MammotionCameraEntityDescription - _attr_has_entity_name = True - - def __init__( - self, - coordinator: MammotionBaseUpdateCoordinator, - entity_description: MammotionCameraEntityDescription, - ) -> None: - """Initialize the WebRTC camera entity.""" - super().__init__(coordinator, entity_description.key) - self.entity_description = entity_description - self._attr_translation_key = entity_description.key - self._stream_data: StreamSubscriptionResponse | None = None - - @property - def frontend_stream_type(self) -> StreamType | None: - """Return the type of stream supported by this camera.""" - return StreamType.WEB_RTC - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return entity specific state attributes.""" - if self._stream_data is None: - return {} - - return { - "app_id": self._stream_data.appid, - "channel_name": self._stream_data.channelName, - "uid": self._stream_data.uid, - } - - async def async_camera_image( - self, width: int | None = None, height: int | None = None - ) -> bytes | None: - """Return a still image response from the camera.""" - # WebRTC cameras typically don't support still images - return None - - async def async_handle_async_webrtc_offer( - self, offer_sdp: str, session_id: str, send_message: WebRTCSendMessage - ) -> None: - """Return the source of the stream.""" diff --git a/homeassistant/components/mammotion/config_flow.py b/homeassistant/components/mammotion/config_flow.py index ec014623ef0b3..b2f8e433b1e0b 100644 --- a/homeassistant/components/mammotion/config_flow.py +++ b/homeassistant/components/mammotion/config_flow.py @@ -23,7 +23,6 @@ ) from homeassistant.const import CONF_ADDRESS, CONF_PASSWORD from homeassistant.core import callback -from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, format_mac @@ -73,6 +72,41 @@ async def async_step_bluetooth( self._discovered_device = device + device_registry = dr.async_get(self.hass) + current_entries = self.hass.config_entries.async_entries(DOMAIN) + + for entry in current_entries: + if not entry.data.get(CONF_ACCOUNT_ID): + continue + + device_entries = dr.async_entries_for_config_entry( + device_registry, entry.entry_id + ) + + for device_entry in device_entries: + # Check both MAC address and any other identifiers + identifiers = {identifier[1] for identifier in device_entry.identifiers} + if device.name in identifiers: + if ( + entry.state == config_entries.ConfigEntryState.LOADED + and len(device_entry.connections) == 0 + ): + # # Update existing entry with BLE info + + formatted_ble = format_mac(self._discovered_device.address) + + device_registry.async_update_device( + device_entry.id, + new_connections={(CONNECTION_BLUETOOTH, formatted_ble)}, + ) + # reload the entry now we have a ble address + self.hass.config_entries.async_schedule_reload(entry.entry_id) + + await self.async_set_unique_id(entry.data.get(CONF_ACCOUNT_ID)) + self._abort_if_unique_id_configured( + updates={CONF_ADDRESS: discovery_info.address} + ) + await self.async_set_unique_id(discovery_info.name) self._abort_if_unique_id_configured( updates={CONF_ADDRESS: discovery_info.address} @@ -94,52 +128,6 @@ async def async_step_bluetooth_confirm( CONF_ADDRESS: self._discovered_device.address, } - try: - # Look for account-based configurations - device_registry = dr.async_get(self.hass) - current_entries = self.hass.config_entries.async_entries(DOMAIN) - - for entry in current_entries: - if not entry.data.get(CONF_ACCOUNT_ID): - continue - - device_entries = dr.async_entries_for_config_entry( - device_registry, entry.entry_id - ) - - for device in device_entries: - # Check both MAC address and any other identifiers - identifiers = {id[1] for id in device.identifiers} - if device.name in identifiers: - # Found matching device in account - if entry.state == config_entries.ConfigEntryState.LOADED: - # # Update existing entry with BLE info - - formatted_ble = format_mac(self._discovered_device.address) - - device_registry.async_update_device( - device.id, - connections={(CONNECTION_BLUETOOTH, formatted_ble)}, - ) - # reload the entry now we have a ble address - self.hass.config_entries.async_schedule_reload( - entry.entry_id - ) - return self.async_show_form( - step_id="bluetooth_confirm", - last_step=True, - description_placeholders={ - "name": self._discovered_device.name - }, - ) - - # Entry exists but not loaded - return self.async_abort(reason="existing_account_not_loaded") - - except Exception as ex: - # _LOGGER.exception("Error checking for existing account") - raise ConfigEntryNotReady from ex - if user_input is not None: return await self.async_step_wifi(user_input) @@ -254,6 +242,7 @@ async def async_step_wifi( data={ CONF_ADDRESS: self._discovered_device.address, CONF_USE_WIFI: user_input.get(CONF_USE_WIFI), + **self._config, }, options={CONF_STAY_CONNECTED_BLUETOOTH: self._stay_connected}, ) @@ -307,6 +296,7 @@ async def async_step_wifi_confirm( }, options={CONF_STAY_CONNECTED_BLUETOOTH: self._stay_connected}, ) + return self.async_abort(reason="missing_wifi_data") @staticmethod @callback diff --git a/homeassistant/components/mammotion/const.py b/homeassistant/components/mammotion/const.py index b7c8ccd542e55..288ec73a7fb82 100644 --- a/homeassistant/components/mammotion/const.py +++ b/homeassistant/components/mammotion/const.py @@ -7,6 +7,7 @@ from bleak_retry_connector import BleakNotFoundError from pymammotion.aliyun.cloud_gateway import CheckSessionException, SetupException from pymammotion.mammotion.devices.mammotion_bluetooth import CharacteristicMissingError +from pymammotion.utility.constant import WorkMode DOMAIN: Final = "mammotion" @@ -40,3 +41,11 @@ CONF_REGION_DATA: Final = "region_data" CONF_DEVICE_DATA: Final = "device_data" CONF_MAMMOTION_DATA: Final = "mammotion_data" + +NO_REQUEST_MODES = ( + WorkMode.MODE_JOB_DRAW, + WorkMode.MODE_OBSTACLE_DRAW, + WorkMode.MODE_CHANNEL_DRAW, + WorkMode.MODE_ERASER_DRAW, + WorkMode.MODE_UPDATING, +) diff --git a/homeassistant/components/mammotion/coordinator.py b/homeassistant/components/mammotion/coordinator.py index ebd613ddeb07f..3b43b3b8efc6c 100644 --- a/homeassistant/components/mammotion/coordinator.py +++ b/homeassistant/components/mammotion/coordinator.py @@ -11,13 +11,18 @@ from pymammotion.aliyun.cloud_gateway import ( DeviceOfflineException, GatewayTimeoutException, + NoConnectionException, ) from pymammotion.aliyun.model.dev_by_account_response import Device from pymammotion.data.model import GenerateRouteInformation, HashList from pymammotion.data.model.device import MowerInfo, MowingDevice from pymammotion.data.model.device_config import OperationSettings, create_path_order from pymammotion.data.model.report_info import Maintain -from pymammotion.mammotion.devices.mammotion import ConnectionPreference, Mammotion +from pymammotion.mammotion.devices.mammotion import ( + ConnectionPreference, + Mammotion, + MammotionMixedDeviceManager, +) from pymammotion.proto import RptAct, RptInfoType from pymammotion.utility.constant import WorkMode from pymammotion.utility.device_type import DeviceType @@ -43,13 +48,14 @@ DOMAIN, EXPIRED_CREDENTIAL_EXCEPTIONS, LOGGER, + NO_REQUEST_MODES, ) if TYPE_CHECKING: from . import MammotionConfigEntry -MAINTENENCE_INTERVAL = timedelta(minutes=60) +MAINTENANCE_INTERVAL = timedelta(minutes=60) DEFAULT_INTERVAL = timedelta(minutes=1) WORKING_INTERVAL = timedelta(seconds=5) REPORT_INTERVAL = timedelta(minutes=1) @@ -62,7 +68,6 @@ class MammotionBaseUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): manager: Mammotion | None = None device: Device | None = None - updated_once: bool def __init__( self, @@ -86,13 +91,16 @@ def __init__( self.manager = mammotion self._operation_settings = OperationSettings() self.update_failures = 0 - self.enabled = True async def set_scheduled_updates(self, enabled: bool) -> None: + """Set scheduled updates.""" device = self.manager.get_device_by_name(self.device_name) device.mower_state.enabled = enabled if device.mower_state.enabled: - if device.has_cloud(): + self.update_failures = 0 + if not device.mower_state.online: + device.mower_state.online = True + if device.has_cloud() and device.cloud().stopped: await device.cloud().start() else: if device.has_cloud(): @@ -101,7 +109,7 @@ async def set_scheduled_updates(self, enabled: bool) -> None: if device.has_ble(): await device.ble().stop() - async def async_login(self) -> None: + async def async_refresh_login(self) -> None: """Login to cloud servers.""" if ( self.manager.get_device_by_name(self.device_name) @@ -115,9 +123,18 @@ async def async_login(self) -> None: account = self.config_entry.data.get(CONF_ACCOUNTNAME) password = self.config_entry.data.get(CONF_PASSWORD) - await self.manager.login_and_initiate_cloud(account, password, True) + await self.manager.refresh_login(account, password) self.store_cloud_credentials() + async def device_offline(self, device: MammotionMixedDeviceManager) -> None: + """Device is offline.""" + device.mower_state.online = False + if device.has_cloud(): + await device.cloud().stop() + + loop = asyncio.get_running_loop() + loop.call_later(900, lambda: asyncio.create_task(self.clear_update_failures())) + def store_cloud_credentials(self) -> None: """Store cloud credentials in config entry.""" # config_updates = {} @@ -156,16 +173,15 @@ async def async_send_command(self, command: str, **kwargs: Any) -> bool | None: return True except EXPIRED_CREDENTIAL_EXCEPTIONS: self.update_failures += 1 - await self.async_login() + await self.async_refresh_login() + if self.update_failures < 5: + await self.async_send_command(command, **kwargs) return False - except GatewayTimeoutException: - self.update_failures += 1 - if self.update_failures > 5: - raise GatewayTimeoutException() - if self.update_failures > 0: - await asyncio.sleep(1) - await self.async_send_command(command, **kwargs) - except DeviceOfflineException: + except GatewayTimeoutException as ex: + LOGGER.error(f"Gateway timeout exception: {ex.iot_id}") + self.update_failures = 0 + return False + except (DeviceOfflineException, NoConnectionException) as ex: """Device is offline try bluetooth if we have it.""" try: if device.has_ble(): @@ -177,7 +193,7 @@ async def async_send_command(self, command: str, **kwargs: Any) -> bool | None: .queue_command(command, **kwargs) ) return True - raise DeviceOfflineException() + raise DeviceOfflineException(ex.args[0], self.device.iotId) except COMMAND_EXCEPTIONS as exc: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="command_failed" @@ -204,64 +220,15 @@ async def check_firmware_version(self) -> None: if model_id is not None or model_id != device_entry.model_id: device_registry.async_update_device(device_entry.id, model_id=model_id) - # async def async_setup(self) -> None: - # """Set coordinator up.""" - # - # preference = ( - # ConnectionPreference.WIFI - # if self.config_entry.data.get(CONF_USE_WIFI, False) - # else ConnectionPreference.BLUETOOTH - # ) - # address = self.config_entry.data.get(CONF_ADDRESS) - # stay_connected_ble = self.config_entry.options.get( - # CONF_STAY_CONNECTED_BLUETOOTH, False - # ) - # - # - # - # # address previous bugs - # if address is None and preference == ConnectionPreference.BLUETOOTH: - # preference = ConnectionPreference.WIFI - # - # if address: - # ble_device = bluetooth.async_ble_device_from_address(self.hass, address) - # if not ble_device and credentials is None: - # raise ConfigEntryNotReady( - # f"Could not find Mammotion lawn mower with address {address}" - # ) - # if ble_device is not None: - # self.device_name = ble_device.name or "Unknown" - # self.manager.add_ble_device(ble_device, preference) - # - # - # if ble_device and device: - # device.ble().set_disconnect_strategy(not stay_connected_ble) - # - # # await self.async_restore_data() - # - # try: - # if preference is ConnectionPreference.WIFI and device.has_cloud(): - # self.store_cloud_credentials() - # if mqtt_client := self.manager.mqtt_list.get(account): - # device.mower_state.error_codes = await mqtt_client.cloud_client.mammotion_http.get_all_error_codes() - # device.cloud().set_notification_callback( - # self._async_update_notification - # ) - # await device.cloud().start_sync(0) - # elif device.has_ble(): - # device.ble().set_notification_callback(self._async_update_notification) - # await device.ble().start_sync(0) - # else: - # raise ConfigEntryNotReady( - # "No configuration available to setup Mammotion lawn mower" - # ) - # - # except COMMAND_EXCEPTIONS as exc: - # raise ConfigEntryNotReady("Unable to setup Mammotion device") from exc - async def async_sync_maps(self) -> None: """Get map data from the device.""" - await self.manager.start_map_sync(self.device_name) + try: + await self.manager.start_map_sync(self.device_name) + except EXPIRED_CREDENTIAL_EXCEPTIONS: + self.update_failures += 1 + await self.async_refresh_login() + if self.update_failures < 5: + await self.async_sync_maps() async def async_start_stop_blades(self, start_stop: bool) -> None: """Start stop blades.""" @@ -299,13 +266,17 @@ async def async_read_sidelight(self) -> None: "read_and_set_sidelight", is_sidelight=False, operate=1 ) - async def set_traversal_mode(self, id: int) -> None: + async def set_traversal_mode(self, context: int) -> None: """Set traversal mode.""" - await self.async_send_command("traverse_mode", id=id) + await self.async_send_command("traverse_mode", context=context) + + async def set_turning_mode(self, context: int) -> None: + """Set turning mode.""" + await self.async_send_command("turning_mode", context=context) async def async_blade_height(self, height: int) -> int: """Set blade height.""" - await self.send_command_and_update("set_blade_height", height=float(height)) + await self.async_send_command("set_blade_height", height=height) return height async def async_leave_dock(self) -> None: @@ -341,6 +312,7 @@ async def async_get_area_list(self) -> None: await self.async_send_command("get_area_name_list", device_id=self.device.iotId) async def send_command_and_update(self, command_str: str, **kwargs: Any) -> None: + """Send command and update.""" await self.async_send_command(command_str, **kwargs) await self.async_request_iot_sync() @@ -355,7 +327,7 @@ async def async_request_iot_sync(self, stop: bool = False) -> None: RptInfoType.RIT_WORK, RptInfoType.RIT_MAINTAIN, RptInfoType.RIT_BASESTATION_INFO, - RptInfoType.RIT_FW_INFO, + RptInfoType.RIT_VIO, ], timeout=10000, period=3000, @@ -379,16 +351,16 @@ async def async_plan_route(self, operation_settings: OperationSettings) -> bool: rain_tactics=operation_settings.rain_tactics, speed=operation_settings.speed, ultra_wave=operation_settings.ultra_wave, # touch no touch etc - toward=operation_settings.toward, # is just angle - toward_included_angle=operation_settings.toward_included_angle + toward=operation_settings.toward, # is just angle (route angle) + toward_included_angle=operation_settings.toward_included_angle # demond_angle if operation_settings.channel_mode == 1 else 0, # crossing angle relative to grid toward_mode=operation_settings.toward_mode, blade_height=operation_settings.blade_height, - channel_mode=operation_settings.channel_mode, # single, double, segment or none - channel_width=operation_settings.channel_width, + channel_mode=operation_settings.channel_mode, # single, double, segment or none (route mode) + channel_width=operation_settings.channel_width, # path space job_mode=operation_settings.job_mode, # taskMode grid or border first - edge_mode=operation_settings.mowing_laps, # perimeter laps + edge_mode=operation_settings.mowing_laps, # perimeter/mowing laps path_order=create_path_order(operation_settings, self.device_name), obstacle_laps=operation_settings.obstacle_laps, ) @@ -397,6 +369,13 @@ async def async_plan_route(self, operation_settings: OperationSettings) -> bool: route_information.toward_mode = 0 route_information.toward_included_angle = 0 + # not sure if this is artificial limit + # if ( + # DeviceType.is_mini_or_x_series(self.device_name) + # and route_information.toward_mode == 0 + # ): + # route_information.toward = 0 + return await self.async_send_command( "generate_route_information", generate_route_information=route_information ) @@ -406,8 +385,14 @@ async def clear_all_maps(self) -> None: data = self.manager.get_device_by_name(self.device_name).mower_state data.map = HashList() - def clear_update_failures(self) -> None: + async def clear_update_failures(self) -> None: + """Clear update failures.""" self.update_failures = 0 + device = self.manager.get_device_by_name(self.device_name) + if not device.mower_state.online: + device.mower_state.online = True + if device.has_cloud() and device.cloud().stopped: + await device.cloud().start() @property def operation_settings(self) -> OperationSettings: @@ -440,15 +425,58 @@ async def _async_update_data(self) -> _DataT | None: if not device.mower_state.enabled or not device.mower_state.online: return self.data - if self.update_failures > 3 and device.preference is ConnectionPreference.WIFI: + # don't query the mower while users are doing map changes or its updating. + if device.mower_state.report_data.dev.sys_status in NO_REQUEST_MODES: + return self.data + + if self.update_failures > 5 and device.preference is ConnectionPreference.WIFI: """Don't hammer the mammotion/ali servers""" loop = asyncio.get_running_loop() - loop.call_later(600, self.clear_update_failures) + loop.call_later( + 60, lambda: asyncio.create_task(self.clear_update_failures()) + ) return self.data + if device.has_ble() and device.preference is ConnectionPreference.BLUETOOTH: + if ble_device := bluetooth.async_ble_device_from_address( + self.hass, device.ble().get_address(), True + ): + device.ble().update_device(ble_device) + return None + + async def find_entity_by_attribute_in_registry( + self, attribute_name, attribute_value + ): + """Find an entity using the entity registry based on attributes.""" + entity_registry = await self.hass.helpers.entity_registry.async_get_registry() + + for entity_id, entity_entry in entity_registry.entities.items(): + entity_state = self.hass.states.get(entity_id) + if ( + entity_state + and entity_state.attributes.get(attribute_name) == attribute_value + ): + return entity_id, entity_entry + + return None, None + + def get_area_entity_name(self, area_hash: int) -> str: + """Get string name of area hash.""" + try: + area = next( + item for item in self.data.map.area_name if item.hash == area_hash + ) + if area.name != "": + return area.name + return f"area {area_hash}" + except StopIteration: + return None + class MammotionReportUpdateCoordinator(MammotionBaseUpdateCoordinator[MowingDevice]): + """Mammotion report update coordinator.""" + def __init__( self, hass: HomeAssistant, @@ -465,9 +493,6 @@ def __init__( update_interval=REPORT_INTERVAL, ) - def clear_update_failures(self) -> None: - self.update_failures = 0 - async def _async_update_data(self) -> MowingDevice: """Get data from the device.""" if data := await super()._async_update_data(): @@ -475,20 +500,15 @@ async def _async_update_data(self) -> MowingDevice: device = self.manager.get_device_by_name(self.device_name) - if device.has_ble() and device.preference is ConnectionPreference.BLUETOOTH: - if ble_device := bluetooth.async_ble_device_from_address( - self.hass, device.ble().get_address(), True - ): - device.ble().update_device(ble_device) try: await self.async_send_command("get_report_cfg") - except DeviceOfflineException: + except DeviceOfflineException as ex: """Device is offline try bluetooth if we have it.""" - device = self.manager.get_device_by_name(self.device_name) - device.mower_state.online = False - data = device.mower_state - return data + if ex.iot_id == self.device.iotId: + device = self.manager.get_device_by_name(self.device_name) + await self.device_offline(device) + return device.mower_state LOGGER.debug("Updated Mammotion device %s", self.device_name) LOGGER.debug("================= Debug Log =================") @@ -524,7 +544,7 @@ async def _async_update_notification(self, res: tuple[str, Any | None]) -> None: self.async_set_updated_data(mower) async def _async_setup(self) -> None: - """Setup report coordinator.""" + """Set up Mammotion report coordinator.""" device = self.manager.get_device_by_name(self.device_name) if self.data is None: @@ -552,7 +572,7 @@ def __init__( config_entry=config_entry, device=device, mammotion=mammotion, - update_interval=MAINTENENCE_INTERVAL, + update_interval=MAINTENANCE_INTERVAL, ) async def _async_update_data(self) -> Maintain: @@ -563,12 +583,12 @@ async def _async_update_data(self) -> Maintain: try: await self.async_send_command("get_maintenance") - except DeviceOfflineException: + except DeviceOfflineException as ex: """Device is offline try bluetooth if we have it.""" - device = self.manager.get_device_by_name(self.device_name) - device.mower_state.online = False - data = device.mower_state - return data + if ex.iot_id == self.device.iotId: + device = self.manager.get_device_by_name(self.device_name) + await self.device_offline(device) + return device.mower_state.report_data.maintenance except GatewayTimeoutException: """Gateway is timing out again.""" @@ -577,7 +597,7 @@ async def _async_update_data(self) -> Maintain: ).mower_state.report_data.maintenance async def _async_setup(self) -> None: - """Setup maintenance coordinator.""" + """Set up Mammotion maintenance coordinator.""" device = self.manager.get_device_by_name(self.device_name) if self.data is None: self.data = device.mower_state.report_data.maintenance @@ -618,11 +638,12 @@ async def _async_update_data(self): try: await self.async_send_command(command) - except DeviceOfflineException: + except DeviceOfflineException as ex: """Device is offline bluetooth has been attempted.""" - device = self.manager.get_device_by_name(self.device_name) - device.mower_state.online = False - return device.mower_state.mower_state + if ex.iot_id == self.device.iotId: + device = self.manager.get_device_by_name(self.device_name) + await self.device_offline(device) + return device.mower_state.mower_state except GatewayTimeoutException: """Gateway is timing out again.""" @@ -677,23 +698,22 @@ async def _async_update_data(self): try: if ( len(device.mower_state.map.hashlist) == 0 - or len(device.mower_state.map.missing_hashlist) > 0 + or len(device.mower_state.map.missing_hashlist()) > 0 ): await self.manager.start_map_sync(self.device_name) - except DeviceOfflineException: + except DeviceOfflineException as ex: """Device is offline try bluetooth if we have it.""" - device.mower_state.online = False - return device.mower_state.mower_state + if ex.iot_id == self.device.iotId: + await self.device_offline(device) + return device.mower_state.mower_state except GatewayTimeoutException: """Gateway is timing out again.""" - data = self.manager.get_device_by_name(self.device_name).mower_state.mower_state - - return data + return self.manager.get_device_by_name(self.device_name).mower_state.mower_state async def _async_setup(self) -> None: - """Setup coordinator with initial calls to get map data.""" + """Set up coordinator with initial call to get map data.""" device = self.manager.get_device_by_name(self.device_name) if self.data is None: self.data = device.mower_state.mower_state @@ -704,8 +724,9 @@ async def _async_setup(self) -> None: await self.async_rtk_dock_location() if not DeviceType.is_luba1(self.device_name): await self.async_get_area_list() - except DeviceOfflineException: + except DeviceOfflineException as ex: """Device is offline try bluetooth if we have it.""" - device.mower_state.online = False + if ex.iot_id == self.device.iotId: + await self.device_offline(device) except GatewayTimeoutException: """Gateway is timing out again.""" diff --git a/homeassistant/components/mammotion/device_tracker.py b/homeassistant/components/mammotion/device_tracker.py deleted file mode 100644 index 5be227f7a0030..0000000000000 --- a/homeassistant/components/mammotion/device_tracker.py +++ /dev/null @@ -1,70 +0,0 @@ -from __future__ import annotations - -import logging -from typing import Any - -from homeassistant.components.device_tracker import SourceType, TrackerEntity -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.restore_state import RestoreEntity - -from . import MammotionConfigEntry -from .const import ATTR_DIRECTION -from .coordinator import MammotionBaseUpdateCoordinator -from .entity import MammotionBaseEntity - -_LOGGER = logging.getLogger(__name__) - - -async def async_setup_entry( - hass: HomeAssistant, - entry: MammotionConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the RTK tracker from config entry.""" - mammotion_devices = entry.runtime_data - - for mower in mammotion_devices: - async_add_entities([MammotionTracker(mower.reporting_coordinator)]) - - -class MammotionTracker(MammotionBaseEntity, TrackerEntity, RestoreEntity): - """Mammotion device tracker.""" - - _attr_force_update = False - _attr_translation_key = "device_tracker" - _attr_source_type = SourceType.GPS - - def __init__(self, coordinator: MammotionBaseUpdateCoordinator) -> None: - """Initialize the Tracker.""" - super().__init__(coordinator, f"{coordinator.device_name}_gps") - - self._attr_name = coordinator.device_name - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return entity specific state attributes.""" - return { - ATTR_DIRECTION: self.coordinator.manager.mower( - self.coordinator.device_name - ).location.orientation - } - - @property - def latitude(self) -> float | None: - """Return latitude value of the device.""" - return self.coordinator.manager.mower( - self.coordinator.device_name - ).location.device.latitude - - @property - def longitude(self) -> float | None: - """Return longitude value of the device.""" - return self.coordinator.manager.mower( - self.coordinator.device_name - ).location.device.longitude - - @property - def battery_level(self) -> int | None: - """Return the battery level of the device.""" - return self.coordinator.data.report_data.dev.battery_val diff --git a/homeassistant/components/mammotion/diagnostics.py b/homeassistant/components/mammotion/diagnostics.py index 1c08e891dd627..4da57f6bef74b 100644 --- a/homeassistant/components/mammotion/diagnostics.py +++ b/homeassistant/components/mammotion/diagnostics.py @@ -8,7 +8,7 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.core import HomeAssistant -from . import MammotionConfigEntry +from . import MammotionConfigEntry, MammotionMowerData TO_REDACT: list[str] = [] @@ -18,5 +18,9 @@ async def async_get_config_entry_diagnostics( entry: MammotionConfigEntry, ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator = entry.runtime_data - return async_redact_data(asdict(coordinator.data), TO_REDACT) + mammotion_devices: list[MammotionMowerData] = entry.runtime_data + data = {} + for device in mammotion_devices: + data[device.name] = asdict(device.reporting_coordinator.data) + + return async_redact_data(data, TO_REDACT) diff --git a/homeassistant/components/mammotion/entity.py b/homeassistant/components/mammotion/entity.py index 29b2b6d08d3f5..c286ef4cd112a 100644 --- a/homeassistant/components/mammotion/entity.py +++ b/homeassistant/components/mammotion/entity.py @@ -3,7 +3,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import CONF_ACCOUNTNAME, CONF_RETRY_COUNT, DEFAULT_RETRY_COUNT, DOMAIN +from .const import CONF_RETRY_COUNT, DEFAULT_RETRY_COUNT, DOMAIN from .coordinator import MammotionBaseUpdateCoordinator @@ -19,27 +19,10 @@ def __init__(self, coordinator: MammotionBaseUpdateCoordinator, key: str) -> Non @property def device_info(self) -> DeviceInfo: + """Return the device information.""" mower = self.coordinator.data swversion = mower.device_firmwares.device_version - product_key = mower.mower_state.product_key - if product_key is None or product_key == "": - if self.coordinator.manager.mqtt_list.get( - self.coordinator.config_entry.data.get(CONF_ACCOUNTNAME) - ): - mammotion_cloud = self.coordinator.manager.mqtt_list.get( - self.coordinator.device_name - ) - if mammotion_cloud is not None: - device_list = mammotion_cloud.cloud_client.devices_by_account_response.data.data - device = [ - device - for device in device_list - if device.deviceName == self.coordinator.device.deviceName - ].pop() - - mower.mower_state.product_key = device.productKey - model_id = None if mower is not None: if mower.mower_state.model_id != "": @@ -67,7 +50,7 @@ def device_info(self) -> DeviceInfo: @property def available(self) -> bool: - """Return True if entity is available.""" + """Return True if the entity is available.""" return ( self.coordinator.data is not None and self.coordinator.update_failures diff --git a/homeassistant/components/mammotion/lawn_mower.py b/homeassistant/components/mammotion/lawn_mower.py index abd3584341756..65e30b85bd498 100644 --- a/homeassistant/components/mammotion/lawn_mower.py +++ b/homeassistant/components/mammotion/lawn_mower.py @@ -40,10 +40,10 @@ vol.Optional("speed", default=0.3): vol.All( vol.Coerce(float), vol.Range(min=0.2, max=1.2) ), - vol.Optional("ultra_wave", default=2): vol.In([0, 1, 2, 10]), + vol.Optional("ultra_wave", default=2): vol.In([0, 1, 2, 10, 11]), vol.Optional("channel_mode", default=0): vol.In([0, 1, 2, 3]), vol.Optional("channel_width", default=25): vol.All( - vol.Coerce(int), vol.Range(min=20, max=35) + vol.Coerce(int), vol.Range(min=5, max=35) ), vol.Optional("rain_tactics", default=1): vol.In([0, 1]), vol.Optional("blade_height", default=25): vol.All( @@ -70,6 +70,7 @@ def get_entity_attribute( hass: HomeAssistant, entity_id: str, attribute_name: str ) -> str | None: + """Get an attribute from an entity.""" # Get the state object of the entity entity = hass.states.get(entity_id) @@ -113,7 +114,7 @@ class MammotionLawnMowerEntity(MammotionBaseEntity, LawnMowerEntity): def __init__(self, coordinator: MammotionReportUpdateCoordinator) -> None: """Initialize the lawn mower.""" super().__init__(coordinator, "mower") - self._attr_name = None # main feature of device + self._attr_name = None @property def rpt_dev_status(self) -> DeviceData: @@ -122,6 +123,7 @@ def rpt_dev_status(self) -> DeviceData: @property def report_data(self) -> ReportData: + """Return the report data.""" return self.coordinator.data.report_data @property diff --git a/homeassistant/components/mammotion/manifest.json b/homeassistant/components/mammotion/manifest.json index 7de00e30f8be4..24b76fee433ce 100644 --- a/homeassistant/components/mammotion/manifest.json +++ b/homeassistant/components/mammotion/manifest.json @@ -20,5 +20,5 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["pymammotion"], - "requirements": ["pymammotion==0.4.3"] + "requirements": ["pymammotion==0.4.21"] } diff --git a/homeassistant/components/mammotion/number.py b/homeassistant/components/mammotion/number.py deleted file mode 100644 index 8e72e7797ea26..0000000000000 --- a/homeassistant/components/mammotion/number.py +++ /dev/null @@ -1,232 +0,0 @@ -from collections.abc import Callable -from dataclasses import dataclass - -from pymammotion.data.model.device_limits import DeviceLimits -from pymammotion.utility.device_type import DeviceType - -from homeassistant.components.number import ( - NumberDeviceClass, - NumberEntity, - NumberEntityDescription, - NumberMode, -) -from homeassistant.const import ( - DEGREE, - PERCENTAGE, - UnitOfArea, - UnitOfLength, - UnitOfSpeed, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import EntityCategory -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.restore_state import RestoreEntity - -from . import MammotionConfigEntry -from .coordinator import MammotionBaseUpdateCoordinator -from .entity import MammotionBaseEntity - - -@dataclass(frozen=True, kw_only=True) -class MammotionConfigNumberEntityDescription(NumberEntityDescription): - """Describes Mammotion number entity.""" - - set_fn: Callable[[MammotionBaseUpdateCoordinator, float], None] - - -NUMBER_ENTITIES: tuple[MammotionConfigNumberEntityDescription, ...] = ( - MammotionConfigNumberEntityDescription( - key="start_progress", - min_value=0, - max_value=100, - step=1, - mode=NumberMode.SLIDER, - native_unit_of_measurement=PERCENTAGE, - set_fn=lambda coordinator, value: setattr( - coordinator.operation_settings, "start_progress", value - ), - ), - MammotionConfigNumberEntityDescription( - key="cutting_angle", - step=1, - native_unit_of_measurement=DEGREE, - min_value=-180, - max_value=180, - set_fn=lambda coordinator, value: setattr( - coordinator.operation_settings, "toward", value - ), - ), - MammotionConfigNumberEntityDescription( - key="toward_included_angle", - step=1, - native_unit_of_measurement=DEGREE, - min_value=-180, - max_value=180, - set_fn=lambda coordinator, value: setattr( - coordinator.operation_settings, "toward_included_angle", value - ), - ), -) - -YUKA_NUMBER_ENTITIES: tuple[MammotionConfigNumberEntityDescription, ...] = ( - MammotionConfigNumberEntityDescription( - key="dumping_interval", - min_value=5, - max_value=100, - step=1, - mode=NumberMode.SLIDER, - native_unit_of_measurement=UnitOfArea.SQUARE_METERS, - set_fn=lambda coordinator, value: setattr( - coordinator.operation_settings, "collect_grass_frequency", value - ), - ), -) - -LUBA_WORKING_ENTITIES: tuple[MammotionConfigNumberEntityDescription, ...] = ( - MammotionConfigNumberEntityDescription( - key="blade_height", - step=1, - min_value=25, - max_value=70, - mode=NumberMode.BOX, - set_fn=lambda coordinator, value: setattr( - coordinator.operation_settings, "blade_height", value - ), - ), -) - - -NUMBER_WORKING_ENTITIES: tuple[MammotionConfigNumberEntityDescription, ...] = ( - MammotionConfigNumberEntityDescription( - key="working_speed", - device_class=NumberDeviceClass.SPEED, - native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, - step=0.1, - min_value=0.2, - max_value=0.6, - set_fn=lambda coordinator, value: setattr( - coordinator.operation_settings, "speed", value - ), - ), - MammotionConfigNumberEntityDescription( - key="path_spacing", - step=1, - device_class=NumberDeviceClass.DISTANCE, - native_unit_of_measurement=UnitOfLength.CENTIMETERS, - min_value=20, - max_value=35, - set_fn=lambda coordinator, value: setattr( - coordinator.operation_settings, "channel_width", value - ), - ), -) - - -async def async_setup_entry( - hass: HomeAssistant, - entry: MammotionConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Mammotion number entities.""" - mammotion_devices = entry.runtime_data - - for mower in mammotion_devices: - limits = mower.device_limits - entities: list[MammotionConfigNumberEntity] = [] - - for entity_description in NUMBER_WORKING_ENTITIES: - entity = MammotionWorkingNumberEntity( - mower.reporting_coordinator, entity_description, limits - ) - entities.append(entity) - - for entity_description in NUMBER_ENTITIES: - entity = MammotionConfigNumberEntity( - mower.reporting_coordinator, entity_description - ) - entities.append(entity) - - if DeviceType.is_yuka(mower.device.deviceName): - for entity_description in YUKA_NUMBER_ENTITIES: - entity = MammotionConfigNumberEntity( - mower.reporting_coordinator, entity_description - ) - entities.append(entity) - else: - for entity_description in LUBA_WORKING_ENTITIES: - entity = MammotionWorkingNumberEntity( - mower.reporting_coordinator, entity_description, limits - ) - entities.append(entity) - - async_add_entities(entities) - - -class MammotionConfigNumberEntity(MammotionBaseEntity, NumberEntity, RestoreEntity): - entity_description: MammotionConfigNumberEntityDescription - _attr_has_entity_name = True - _attr_entity_category = EntityCategory.CONFIG - - def __init__( - self, - coordinator: MammotionBaseUpdateCoordinator, - entity_description: MammotionConfigNumberEntityDescription, - ) -> None: - super().__init__(coordinator, entity_description.key) - self.entity_description = entity_description - self._attr_translation_key = entity_description.key - self._attr_native_min_value = entity_description.min_value - self._attr_native_max_value = entity_description.max_value - self._attr_native_step = entity_description.step - self._attr_native_value = self._attr_native_min_value # Default value - if self.entity_description.native_unit_of_measurement == DEGREE: - self._attr_native_value = 0 - if self.entity_description.key == "toward_included_angle": - self._attr_native_value = 90 - - async def async_set_native_value(self, value: float) -> None: - """Set native value for number.""" - self._attr_native_value = value - self.entity_description.set_fn(self.coordinator, value) - self.async_write_ha_state() - - -class MammotionWorkingNumberEntity(MammotionConfigNumberEntity): - """Mammotion working number entity.""" - - def __init__( - self, - coordinator: MammotionBaseUpdateCoordinator, - entity_description: MammotionConfigNumberEntityDescription, - limits: DeviceLimits, - ) -> None: - """Init MammotionWorkingNumberEntity.""" - super().__init__(coordinator, entity_description) - - if hasattr(limits, entity_description.key): - self._attr_native_min_value = getattr(limits, entity_description.key).min - self._attr_native_max_value = getattr(limits, entity_description.key).max - else: - # Fallback to the values from entity_description - self._attr_native_min_value = entity_description.min_value - self._attr_native_max_value = entity_description.max_value - - self._attr_native_value = max( - self._attr_native_value, self._attr_native_min_value - ) - - @property - def native_min_value(self) -> float: - """Return the minimum value.""" - return self._attr_native_min_value - - @property - def native_max_value(self) -> float: - """Return the maximum value.""" - return self._attr_native_max_value - - async def async_set_native_value(self, value: float) -> None: - """Set native value for number.""" - self._attr_native_value = value - self.entity_description.set_fn(self.coordinator, value) - self.async_write_ha_state() diff --git a/homeassistant/components/mammotion/select.py b/homeassistant/components/mammotion/select.py deleted file mode 100644 index bc14416eac213..0000000000000 --- a/homeassistant/components/mammotion/select.py +++ /dev/null @@ -1,226 +0,0 @@ -from collections.abc import Callable -from dataclasses import dataclass - -from pymammotion.data.model.mowing_modes import ( - BorderPatrolMode, - BypassStrategy, - CuttingMode, - MowOrder, - ObstacleLapsMode, - PathAngleSetting, - TraversalMode, -) -from pymammotion.utility.device_type import DeviceType - -from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.restore_state import RestoreEntity - -from . import MammotionConfigEntry, MammotionReportUpdateCoordinator -from .coordinator import MammotionBaseUpdateCoordinator -from .entity import MammotionBaseEntity - - -@dataclass(frozen=True, kw_only=True) -class MammotionConfigSelectEntityDescription(SelectEntityDescription): - """Describes Mammotion select entity.""" - - key: str - options: list[str] - set_fn: Callable[[MammotionBaseUpdateCoordinator, str], None] - - -@dataclass(frozen=True, kw_only=True) -class MammotionAsyncConfigSelectEntityDescription(MammotionBaseEntity, SelectEntity): - """Describes Mammotion select entity with async functionality.""" - - key: str - options: list[str] - set_fn: Callable[[MammotionBaseUpdateCoordinator, str], None] - - -ASYNC_SELECT_ENTITIES: tuple[MammotionAsyncConfigSelectEntityDescription, ...] = ( - MammotionAsyncConfigSelectEntityDescription( - key="traversal_mode", - options=[mode.name for mode in TraversalMode], - set_fn=lambda coordinator, value: coordinator.set_traversal_mode( - TraversalMode[value] - ), - ), -) - - -SELECT_ENTITIES: tuple[MammotionConfigSelectEntityDescription, ...] = ( - MammotionConfigSelectEntityDescription( - key="channel_mode", - options=[mode.name for mode in CuttingMode], - set_fn=lambda coordinator, value: setattr( - coordinator.operation_settings, "channel_mode", CuttingMode[value] - ), - ), - MammotionConfigSelectEntityDescription( - key="mowing_laps", - options=[mode.name for mode in BorderPatrolMode], - set_fn=lambda coordinator, value: setattr( - coordinator.operation_settings, "mowing_laps", BorderPatrolMode[value] - ), - ), - MammotionConfigSelectEntityDescription( - key="obstacle_laps", - options=[mode.name for mode in ObstacleLapsMode], - set_fn=lambda coordinator, value: setattr( - coordinator.operation_settings, "obstacle_laps", ObstacleLapsMode[value] - ), - ), - MammotionConfigSelectEntityDescription( - key="border_mode", - options=[order.name for order in MowOrder], - set_fn=lambda coordinator, value: setattr( - coordinator.operation_settings, "border_mode", MowOrder[value] - ), - ), -) - -LUBA1_SELECT_ENTITIES: tuple[MammotionConfigSelectEntityDescription, ...] = ( - MammotionConfigSelectEntityDescription( - key="cutting_angle_mode", - options=[ - angle_type.name - for angle_type in PathAngleSetting - if angle_type != PathAngleSetting.random_angle - ], - set_fn=lambda coordinator, value: setattr( - coordinator.operation_settings, "toward_mode", PathAngleSetting[value] - ), - ), - MammotionConfigSelectEntityDescription( - key="bypass_mode", - options=[ - strategy.name - for strategy in BypassStrategy - if strategy != BypassStrategy.no_touch - ], - set_fn=lambda coordinator, value: setattr( - coordinator.operation_settings, "ultra_wave", BypassStrategy[value] - ), - ), -) - -LUBA_PRO_SELECT_ENTITIES: tuple[MammotionConfigSelectEntityDescription, ...] = ( - MammotionConfigSelectEntityDescription( - key="cutting_angle_mode", - options=[angle_type.name for angle_type in PathAngleSetting], - set_fn=lambda coordinator, value: setattr( - coordinator.operation_settings, "toward_mode", PathAngleSetting[value] - ), - ), - MammotionConfigSelectEntityDescription( - key="bypass_mode", - options=[strategy.name for strategy in BypassStrategy], - set_fn=lambda coordinator, value: setattr( - coordinator.operation_settings, "ultra_wave", BypassStrategy[value] - ), - ), -) - - -# Define the setup entry function -async def async_setup_entry( - hass: HomeAssistant, - entry: MammotionConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Mammotion select entity.""" - mammotion_devices = entry.runtime_data - - for mower in mammotion_devices: - entities = [] - - for entity_description in SELECT_ENTITIES: - entities.append( - MammotionConfigSelectEntity( - mower.reporting_coordinator, entity_description - ) - ) - - for entity_description in ASYNC_SELECT_ENTITIES: - entities.append( - MammotionAsyncConfigSelectEntity( - mower.reporting_coordinator, entity_description - ) - ) - - if DeviceType.is_luba1(mower.device.deviceName): - for entity_description in LUBA1_SELECT_ENTITIES: - entities.append( - MammotionConfigSelectEntity( - mower.reporting_coordinator, entity_description - ) - ) - else: - for entity_description in LUBA_PRO_SELECT_ENTITIES: - entities.append( - MammotionConfigSelectEntity( - mower.reporting_coordinator, entity_description - ) - ) - - async_add_entities(entities) - - -# Define the select entity class with entity_category: config -class MammotionConfigSelectEntity(MammotionBaseEntity, SelectEntity, RestoreEntity): - """Representation of a Mammotion select entities.""" - - _attr_entity_category = EntityCategory.CONFIG - - entity_description: MammotionConfigSelectEntityDescription - _attr_has_entity_name = True - - def __init__( - self, - coordinator: MammotionReportUpdateCoordinator, - entity_description: MammotionConfigSelectEntityDescription, - ) -> None: - super().__init__(coordinator, entity_description.key) - self.coordinator = coordinator - self.entity_description = entity_description - self._attr_translation_key = entity_description.key - self._attr_options = entity_description.options - self._attr_current_option = entity_description.options[0] - - async def async_select_option(self, option: str) -> None: - self._attr_current_option = option - self.entity_description.set_fn(self.coordinator, option) - self.async_write_ha_state() - - -# Define the select entity class with entity_category: config -class MammotionAsyncConfigSelectEntity( - MammotionBaseEntity, SelectEntity, RestoreEntity -): - """Representation of a Mammotion select entities.""" - - _attr_entity_category = EntityCategory.CONFIG - - entity_description: MammotionAsyncConfigSelectEntityDescription - _attr_has_entity_name = True - - def __init__( - self, - coordinator: MammotionReportUpdateCoordinator, - entity_description: MammotionAsyncConfigSelectEntityDescription, - ) -> None: - super().__init__(coordinator, entity_description.key) - self.coordinator = coordinator - self.entity_description = entity_description - self._attr_translation_key = entity_description.key - self._attr_options = entity_description.options - self._attr_current_option = entity_description.options[0] - - async def async_select_option(self, option: str) -> None: - self._attr_current_option = option - await self.entity_description.set_fn(self.coordinator, option) - self.async_write_ha_state() diff --git a/homeassistant/components/mammotion/sensor.py b/homeassistant/components/mammotion/sensor.py deleted file mode 100644 index 73f8852e72474..0000000000000 --- a/homeassistant/components/mammotion/sensor.py +++ /dev/null @@ -1,288 +0,0 @@ -"""Creates the sensor entities for the mower.""" - -from collections.abc import Callable -from dataclasses import dataclass - -from pymammotion.data.model.device import MowingDevice -from pymammotion.data.model.enums import RTKStatus -from pymammotion.utility.constant.device_constant import ( - PosType, - camera_brightness, - device_connection, - device_mode, -) -from pymammotion.utility.device_type import DeviceType - -from homeassistant.components.sensor import ( - SensorDeviceClass, - SensorEntity, - SensorEntityDescription, - SensorStateClass, -) -from homeassistant.const import ( - PERCENTAGE, - SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - UnitOfArea, - UnitOfLength, - UnitOfSpeed, - UnitOfTime, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import StateType -from homeassistant.util.unit_conversion import SpeedConverter - -from . import MammotionConfigEntry, MammotionReportUpdateCoordinator -from .entity import MammotionBaseEntity - -SPEED_UNITS = SpeedConverter.VALID_UNITS - - -@dataclass(frozen=True, kw_only=True) -class MammotionSensorEntityDescription(SensorEntityDescription): - """Describes Mammotion sensor entity.""" - - value_fn: Callable[[MowingDevice], StateType] - - -LUBA_SENSOR_ONLY_TYPES: tuple[MammotionSensorEntityDescription, ...] = ( - MammotionSensorEntityDescription( - key="blade_height", - state_class=SensorStateClass.MEASUREMENT, - device_class=SensorDeviceClass.DISTANCE, - native_unit_of_measurement=UnitOfLength.MILLIMETERS, - value_fn=lambda mower_data: mower_data.report_data.work.knife_height, - ), -) - -LUBA_2_YUKA_ONLY_TYPES: tuple[MammotionSensorEntityDescription, ...] = ( - MammotionSensorEntityDescription( - key="camera_brightness", - state_class=None, - device_class=SensorDeviceClass.ENUM, - value_fn=lambda mower_data: camera_brightness( - mower_data.report_data.vision_info.brightness - ), - ), -) - -SENSOR_TYPES: tuple[MammotionSensorEntityDescription, ...] = ( - MammotionSensorEntityDescription( - key="battery_percent", - state_class=SensorStateClass.MEASUREMENT, - device_class=SensorDeviceClass.BATTERY, - native_unit_of_measurement=PERCENTAGE, - value_fn=lambda mower_data: mower_data.report_data.dev.battery_val, - ), - MammotionSensorEntityDescription( - key="ble_rssi", - state_class=SensorStateClass.MEASUREMENT, - device_class=SensorDeviceClass.SIGNAL_STRENGTH, - native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - value_fn=lambda mower_data: mower_data.report_data.connect.ble_rssi, - ), - MammotionSensorEntityDescription( - key="wifi_rssi", - state_class=SensorStateClass.MEASUREMENT, - device_class=SensorDeviceClass.SIGNAL_STRENGTH, - native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - value_fn=lambda mower_data: mower_data.report_data.connect.wifi_rssi, - ), - MammotionSensorEntityDescription( - key="connect_type", - device_class=SensorDeviceClass.ENUM, - native_unit_of_measurement=None, - value_fn=lambda mower_data: device_connection(mower_data.report_data.connect), - ), - MammotionSensorEntityDescription( - key="maintenance_distance", - state_class=SensorStateClass.MEASUREMENT, - device_class=SensorDeviceClass.DISTANCE, - native_unit_of_measurement=UnitOfLength.METERS, - value_fn=lambda mower_data: mower_data.report_data.maintenance.mileage, - ), - MammotionSensorEntityDescription( - key="maintenance_work_time", - state_class=SensorStateClass.MEASUREMENT, - device_class=SensorDeviceClass.DURATION, - native_unit_of_measurement=UnitOfTime.SECONDS, - value_fn=lambda mower_data: mower_data.report_data.maintenance.work_time, - ), - MammotionSensorEntityDescription( - key="maintenance_bat_cycles", - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=None, - value_fn=lambda mower_data: mower_data.report_data.maintenance.bat_cycles, - ), - MammotionSensorEntityDescription( - key="gps_stars", - state_class=SensorStateClass.MEASUREMENT, - device_class=None, - native_unit_of_measurement=None, - value_fn=lambda mower_data: mower_data.report_data.rtk.gps_stars, - ), - MammotionSensorEntityDescription( - key="area", - state_class=SensorStateClass.MEASUREMENT, - device_class=None, - native_unit_of_measurement=UnitOfArea.SQUARE_METERS, - value_fn=lambda mower_data: mower_data.report_data.work.area & 65535, - ), - MammotionSensorEntityDescription( - key="mowing_speed", - state_class=SensorStateClass.MEASUREMENT, - device_class=SensorDeviceClass.SPEED, - native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, - value_fn=lambda mower_data: mower_data.report_data.work.man_run_speed / 100, - ), - MammotionSensorEntityDescription( - key="progress", - state_class=SensorStateClass.MEASUREMENT, - device_class=None, - native_unit_of_measurement=PERCENTAGE, - value_fn=lambda mower_data: mower_data.report_data.work.area >> 16, - ), - MammotionSensorEntityDescription( - key="total_time", - state_class=SensorStateClass.MEASUREMENT, - device_class=SensorDeviceClass.DURATION, - native_unit_of_measurement=UnitOfTime.MINUTES, - value_fn=lambda mower_data: mower_data.report_data.work.progress & 65535, - ), - MammotionSensorEntityDescription( - key="elapsed_time", - state_class=SensorStateClass.MEASUREMENT, - device_class=SensorDeviceClass.DURATION, - native_unit_of_measurement=UnitOfTime.MINUTES, - value_fn=lambda mower_data: (mower_data.report_data.work.progress & 65535) - - (mower_data.report_data.work.progress >> 16), - ), - MammotionSensorEntityDescription( - key="left_time", - state_class=SensorStateClass.MEASUREMENT, - device_class=SensorDeviceClass.DURATION, - native_unit_of_measurement=UnitOfTime.MINUTES, - value_fn=lambda mower_data: mower_data.report_data.work.progress >> 16, - ), - MammotionSensorEntityDescription( - key="l1_satellites", - state_class=SensorStateClass.MEASUREMENT, - device_class=None, - native_unit_of_measurement=None, - value_fn=lambda mower_data: (mower_data.report_data.rtk.co_view_stars >> 0) - & 255, - ), - MammotionSensorEntityDescription( - key="l2_satellites", - state_class=SensorStateClass.MEASUREMENT, - device_class=None, - native_unit_of_measurement=None, - value_fn=lambda mower_data: (mower_data.report_data.rtk.co_view_stars >> 8) - & 255, - ), - # MammotionSensorEntityDescription( - # key="vlsam_status", - # state_class=SensorStateClass.MEASUREMENT, - # device_class=None, - # native_unit_of_measurement=None, - # value_fn=lambda mower_data: (mower_data.report_data.dev.vslam_status & 65280) >> 8, - # ), - MammotionSensorEntityDescription( - key="activity_mode", - state_class=None, - device_class=SensorDeviceClass.ENUM, - value_fn=lambda mower_data: device_mode(mower_data.report_data.dev.sys_status), - ), - MammotionSensorEntityDescription( - key="position_mode", - state_class=None, - device_class=SensorDeviceClass.ENUM, - native_unit_of_measurement=None, - value_fn=lambda mower_data: str( - RTKStatus.from_value(mower_data.report_data.rtk.status) - ), - ), - MammotionSensorEntityDescription( - key="position_type", - state_class=None, - device_class=SensorDeviceClass.ENUM, - native_unit_of_measurement=None, - value_fn=lambda mower_data: str( - PosType(mower_data.location.position_type).name - ), - ), - MammotionSensorEntityDescription( - key="work_area", - state_class=None, - device_class=SensorDeviceClass.ENUM, - native_unit_of_measurement=None, - value_fn=lambda mower_data: str(mower_data.location.work_zone or "Not working"), - ), - # MammotionSensorEntityDescription( - # key="lawn_mower_position", - # state_class=None, - # device_class=None, # Set device class to "geo_location" - # native_unit_of_measurement=None, - # value_fn=lambda mower_data: f"{mower_data.location.device.latitude}, {mower_data.location.device.longitude}" - # ) - # ToDo: We still need to add the following. - # - RTK Status - None, Single, Fix, Float, Unknown (RTKStatusFragment.java) - # - Signal quality (Robot) - # - Signal quality (Ref. Station) - # - LoRa number - # - Multi-point turn - # - Transverse mode - # - WiFi status - # - Side LED - # - Possibly more I forgot about - # 'real_pos_x': -142511, 'real_pos_y': -20548, 'real_toward': 50915, (robot position) -) - - -async def async_setup_entry( - hass: HomeAssistant, - entry: MammotionConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up sensor platform.""" - mammotion_devices = entry.runtime_data - - for mower in mammotion_devices: - if not DeviceType.is_yuka(mower.device.deviceName): - async_add_entities( - MammotionSensorEntity(mower.reporting_coordinator, description) - for description in LUBA_SENSOR_ONLY_TYPES - ) - - if not DeviceType.is_luba1(mower.device.deviceName): - async_add_entities( - MammotionSensorEntity(mower.reporting_coordinator, description) - for description in LUBA_2_YUKA_ONLY_TYPES - ) - - async_add_entities( - MammotionSensorEntity(mower.reporting_coordinator, description) - for description in SENSOR_TYPES - ) - - -class MammotionSensorEntity(MammotionBaseEntity, SensorEntity): - """Defining the Mammotion Sensor.""" - - entity_description: MammotionSensorEntityDescription - _attr_has_entity_name = True - - def __init__( - self, - coordinator: MammotionReportUpdateCoordinator, - entity_description: MammotionSensorEntityDescription, - ) -> None: - """Set up MammotionSensor.""" - super().__init__(coordinator, entity_description.key) - self.entity_description = entity_description - self._attr_translation_key = entity_description.key - - @property - def native_value(self) -> StateType: - """Return the state of the sensor.""" - return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/mammotion/services.yaml b/homeassistant/components/mammotion/services.yaml index 95058a494b169..569a039088c31 100644 --- a/homeassistant/components/mammotion/services.yaml +++ b/homeassistant/components/mammotion/services.yaml @@ -42,11 +42,10 @@ start_mow: required: false selector: select: + translation_key: "border_mode" options: - - value: 0 - label: "Perimeter First" - - value: 1 - label: "ZigZag/Chessboard First" + - 0 + - 1 job_version: example: 0 default: 0 @@ -75,15 +74,13 @@ start_mow: default: 2 selector: select: + translation_key: "ultra_wave" options: - - value: 0 - label: "Direct Touch" - - value: 1 - label: "Slow Touch" - - value: 2 - label: "Less Touch" - - value: 10 - label: "No Touch" + - 0 + - 1 + - 2 + - 10 + - 11 required: false channel_mode: example: 0 @@ -91,33 +88,30 @@ start_mow: required: false selector: select: + translation_key: "channel_mode" options: - - value: 0 - label: "Zigzag Path" - - value: 1 - label: "Chessboard Path" - - value: 2 - label: "Adaptive Zigzag Path" - - value: 3 - label: "Perimeter Only" + - 0 + - 1 + - 2 + - 3 channel_width: example: 25 default: 25 required: false selector: number: - min: 20 + min: 5 max: 35 rain_tactics: example: 1 default: 1 required: false selector: - options: - - value: 0 - label: "Off" - - value: 1 - label: "On" + select: + translation_key: "rain_tactics" + options: + - 0 + - 1 blade_height: example: 0 default: 25 @@ -151,47 +145,37 @@ start_mow: default: 0 selector: select: + translation_key: "toward_mode" options: - - value: 0 - label: "Relative Angle" - - value: 1 - label: "Absolute Angle" - - value: 2 - label: "Random Angle" + - 0 + - 1 + - 2 required: false mowing_laps: example: 1 default: 1 selector: select: + translation_key: "mowing_laps" options: - - value: 0 - label: "None" - - value: 1 - label: "One Lap" - - value: 2 - label: "Two Laps" - - value: 3 - label: "Three Laps" - - value: 4 - label: "Four Laps" + - 0 + - 1 + - 2 + - 3 + - 4 required: false obstacle_laps: example: 1 default: 1 selector: select: + translation_key: "obstacle_laps" options: - - value: 0 - label: "None" - - value: 1 - label: "One Lap" - - value: 2 - label: "Two Laps" - - value: 3 - label: "Three Laps" - - value: 4 - label: "Four Laps" + - 0 + - 1 + - 2 + - 3 + - 4 required: false start_progress: example: 0 diff --git a/homeassistant/components/mammotion/strings.json b/homeassistant/components/mammotion/strings.json index 3617f669dc499..8eded58f139cf 100644 --- a/homeassistant/components/mammotion/strings.json +++ b/homeassistant/components/mammotion/strings.json @@ -53,201 +53,61 @@ } } }, - "entity": { - "sensor": { - "battery_percent": { - "name": "Battery" - }, - "ble_rssi": { - "name": "BLE RSSI" - }, - "wifi_rssi": { - "name": "WiFi RSSI" - }, - "connect_type": { - "name": "Connection" - }, - "gps_stars": { - "name": "Satellites (Robot)" - }, - "blade_height": { - "name": "Blade height" - }, - "area": { - "name": "Area" - }, - "mowing_speed": { - "name": "Mowing speed" - }, - "progress": { - "name": "Progress" - }, - "total_time": { - "name": "Total time" - }, - "elapsed_time": { - "name": "Elapsed time" - }, - "left_time": { - "name": "Time left" - }, - "l1_satellites": { - "name": "L1 Satellites (Co-Viewing)" - }, - "l2_satellites": { - "name": "L2 Satellites (Co-Viewing)" - }, - "position_mode": { - "name": "RTK position" - }, - "position_type": { - "name": "Device position type" - }, - "activity_mode": { - "name": "Activity mode" - }, - "work_area": { - "name": "Work area hash" + "entity": {}, + "selector": { + "border_mode": { + "options": { + "0": "Perimeter first", + "1": "ZigZag/chessboard first" } }, - "button": { - "start_map_sync": { - "name": "Sync maps" - }, - "resync_rtk_dock": { - "name": "Sync RTK and dock" - }, - "release_from_dock": { - "name": "Undock" - }, - "emergency_nudge_forward": { - "name": "Emergency nudge forward" - }, - "emergency_nudge_left": { - "name": "Emergency nudge left" - }, - "emergency_nudge_right": { - "name": "Emergency nudge right" - }, - "emergency_nudge_back": { - "name": "Emergency nudge back" - }, - "cancel_task": { - "name": "Cancel current task" - }, - "clear_all_mapdata": { - "name": "Clear maps and area names" + "ultra_wave": { + "options": { + "0": "Direct touch", + "1": "Slow touch", + "2": "Less touch", + "10": "No touch", + "11": "Sensitive" } }, - "switch": { - "area": { - "name": "Area {name}" - }, - "blade_status": { - "name": "Blades on/off" - }, - "is_mow": { - "name": "Mowing on/off" - }, - "is_dump": { - "name": "Dump grass on/off" - }, - "is_edge": { - "name": "Edge cutting" - }, - "rain_tactics": { - "name": "Rain detection On/Off" - }, - "side_led": { - "name": "Side LED on/off" - }, - "perimeter_first_on_off": { - "name": "Perimeter first" - }, - "schedule_updates": { - "name": "Turn Updates On/Off" + "channel_mode": { + "options": { + "0": "Zigzag path", + "1": "Chessboard path", + "2": "Adaptive zigzag path", + "3": "Perimeter only" } }, - "select": { - "channel_mode": { - "name": "Cutting Path Mode", - "state": { - "single_grid": "Zigzag Path", - "double_grid": "Chessboard Path", - "segment_grid": "Adaptive Zigzag Path", - "no_grid": "Perimeter Only" - } - }, - "mowing_laps": { - "name": "Perimeter Mowing Laps", - "state": { - "none": "None", - "one": "One", - "two": "Two", - "three": "Three", - "four": "Four" - } - }, - "obstacle_laps": { - "name": "No-go Zone Mowing Laps", - "state": { - "none": "None", - "one": "One", - "two": "Two", - "three": "Three", - "four": "Four" - } - }, - "border_mode": { - "name": "Mow Order", - "state": { - "border_first": "Perimeter first", - "grid_first": "ZigZag / Chessboard first" - } - }, - "bypass_mode": { - "name": "Obstacle avoidance mode", - "state": { - "direct_touch": "Direct touch", - "slow_touch": "Slow touch", - "less_touch": "Less touch", - "no_touch": "No Touch" - } - }, - "cutting_angle_mode": { - "name": "Cutting path angle mode", - "state": { - "relative_angle": "Relative angle", - "absolute_angle": "Absolute angle", - "random_angle": "Random angle" - } + "rain_tactics": { + "options": { + "0": "Off", + "1": "On" } }, - "number": { - "start_progress": { - "name": "Start Progress" - }, - "blade_height": { - "name": "Blade Height" - }, - "working_speed": { - "name": "Working Speed" - }, - "cutting_angle": { - "name": "Cutting Path Angle" - }, - "path_spacing": { - "name": "Path Spacing" - }, - "dumping_interval": { - "name": "Dumping Frequency" - }, - "toward_included_angle": { - "name": "Crossing Angle" + "toward_mode": { + "options": { + "0": "Relative angle", + "1": "Absolute angle", + "2": "Random angle" } }, - "device_tracker": { - "name": "Device Tracking" + "mowing_laps": { + "options": { + "0": "None", + "1": "One lap", + "2": "Two laps", + "3": "Three laps", + "4": "Four laps" + } + }, + "obstacle_laps": { + "options": { + "0": "None", + "1": "One lap", + "2": "Two laps", + "3": "Three laps", + "4": "Four laps" + } } }, "services": { diff --git a/homeassistant/components/mammotion/switch.py b/homeassistant/components/mammotion/switch.py deleted file mode 100644 index 98784cd9e9acb..0000000000000 --- a/homeassistant/components/mammotion/switch.py +++ /dev/null @@ -1,323 +0,0 @@ -"""Support for Mammotion switches.""" - -from collections.abc import Awaitable, Callable -from dataclasses import dataclass -from typing import Any - -from pymammotion.data.model.hash_list import AreaHashNameList -from pymammotion.utility.device_type import DeviceType - -from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity import EntityCategory -from homeassistant.helpers.restore_state import RestoreEntity - -from . import MammotionConfigEntry -from .coordinator import MammotionBaseUpdateCoordinator -from .entity import MammotionBaseEntity - - -@dataclass(frozen=True, kw_only=True) -class MammotionSwitchEntityDescription(SwitchEntityDescription): - """Describes Mammotion switch entity.""" - - key: str - - -@dataclass(frozen=True, kw_only=True) -class MammotionAsyncSwitchEntityDescription(MammotionSwitchEntityDescription): - """Describes Mammotion switch entity.""" - - set_fn: Callable[[MammotionBaseUpdateCoordinator, bool], Awaitable[None]] - - -@dataclass(frozen=True, kw_only=True) -class MammotionUpdateSwitchEntityDescription(MammotionAsyncSwitchEntityDescription): - """Describes Mammotion switch entity.""" - - is_on_func: Callable[[MammotionBaseUpdateCoordinator], bool] - - -@dataclass(frozen=True, kw_only=True) -class MammotionConfigSwitchEntityDescription(MammotionSwitchEntityDescription): - """Describes Mammotion Config switch entity.""" - - set_fn: Callable[[MammotionBaseUpdateCoordinator, bool], None] - - -@dataclass(frozen=True, kw_only=True) -class MammotionConfigAreaSwitchEntityDescription(MammotionSwitchEntityDescription): - """Describes the Areas entities.""" - - area: str - set_fn: Callable[[MammotionBaseUpdateCoordinator, bool, int], None] - - -YUKA_CONFIG_SWITCH_ENTITIES: tuple[MammotionConfigSwitchEntityDescription, ...] = ( - MammotionConfigSwitchEntityDescription( - key="is_mow", - set_fn=lambda coordinator, value: setattr( - coordinator.operation_settings, "is_mow", value - ), - ), - MammotionConfigSwitchEntityDescription( - key="is_dump", - set_fn=lambda coordinator, value: setattr( - coordinator.operation_settings, "is_dump", value - ), - ), - MammotionConfigSwitchEntityDescription( - key="is_edge", - set_fn=lambda coordinator, value: setattr( - coordinator.operation_settings, "is_edge", value - ), - ), -) - -SWITCH_ENTITIES: tuple[MammotionAsyncSwitchEntityDescription, ...] = ( - MammotionAsyncSwitchEntityDescription( - key="blade_status", - set_fn=lambda coordinator, value: coordinator.async_start_stop_blades(value), - ), - MammotionAsyncSwitchEntityDescription( - key="side_led", - set_fn=lambda coordinator, value: coordinator.async_set_sidelight(int(value)), - ), -) - -UPDATE_SWITCH_ENTITIES: tuple[MammotionUpdateSwitchEntityDescription, ...] = ( - MammotionUpdateSwitchEntityDescription( - key="schedule_updates", - is_on_func=lambda coordinator: coordinator.data.enabled, - set_fn=lambda coordinator, value: coordinator.set_scheduled_updates(value), - ), -) - -CONFIG_SWITCH_ENTITIES: tuple[MammotionConfigSwitchEntityDescription, ...] = ( - MammotionConfigSwitchEntityDescription( - key="rain_tactics", - set_fn=lambda coordinator, value: setattr( - coordinator.operation_settings, "rain_tactics", int(value) - ), - ), -) - - -# Example setup usage -async def async_setup_entry( - hass: HomeAssistant, entry: MammotionConfigEntry, async_add_entities: Callable -) -> None: - """Set up the Mammotion switch entities.""" - mammotion_devices = entry.runtime_data - - for mower in mammotion_devices: - added_areas: set[str] = set() - # TODO create maps coordinator - coordinator = mower.reporting_coordinator - - @callback - def add_entities() -> None: - """Handle addition of mowing areas.""" - if coordinator.data is None: - return - - switch_entities: list[MammotionConfigAreaSwitchEntity] = [] - areas = list(map(str, coordinator.data.map.area.keys())) - area_name_hashes = [ - f"{area.hash}" for area in coordinator.data.map.area_name - ] - area_name = coordinator.data.map.area_name - new_areas = (set(areas) | set(area_name_hashes)) - added_areas - if new_areas: - for area_id in new_areas: - existing_name: AreaHashNameList | None = next( - (area for area in area_name if str(area.hash) == str(area_id)), - None, - ) - name = ( - existing_name.name - if (existing_name and existing_name.name) - else f"{area_id}" - ) - base_area_switch_entity = ( - MammotionConfigAreaSwitchEntityDescription( - key=f"{area_id}", - translation_key="area", - translation_placeholders={"name": name}, - area=area_id, - name=f"{name}", - set_fn=lambda coord, bool_val, value: ( - coord.operation_settings.areas.append(value) - if bool_val - else coord.operation_settings.areas.remove(value) - ), - ) - ) - switch_entities.append( - MammotionConfigAreaSwitchEntity( - coordinator, - base_area_switch_entity, - ) - ) - added_areas.add(area_id) - - if switch_entities: - async_add_entities(switch_entities) - - add_entities() - coordinator.async_add_listener(add_entities) - - entities = [] - for entity_description in SWITCH_ENTITIES: - entity = MammotionSwitchEntity(coordinator, entity_description) - entities.append(entity) - - for entity_description in CONFIG_SWITCH_ENTITIES: - config_entity = MammotionConfigSwitchEntity(coordinator, entity_description) - entities.append(config_entity) - - for entity_description in UPDATE_SWITCH_ENTITIES: - config_entity = MammotionUpdateSwitchEntity(coordinator, entity_description) - entities.append(config_entity) - - if DeviceType.is_yuka(coordinator.device_name): - for entity_description in YUKA_CONFIG_SWITCH_ENTITIES: - config_entity = MammotionConfigSwitchEntity( - coordinator, entity_description - ) - entities.append(config_entity) - async_add_entities(entities) - - -class MammotionSwitchEntity(MammotionBaseEntity, SwitchEntity): - entity_description: MammotionSwitchEntityDescription - _attr_has_entity_name = True - - def __init__( - self, - coordinator: MammotionBaseUpdateCoordinator, - entity_description: MammotionSwitchEntityDescription, - ) -> None: - super().__init__(coordinator, entity_description.key) - self.coordinator = coordinator - self.entity_description = entity_description - self._attr_translation_key = entity_description.key - self._attr_is_on = False # Default state - - async def async_turn_on(self, **kwargs: Any) -> None: - self._attr_is_on = True - await self.entity_description.set_fn(self.coordinator, True) - self.async_write_ha_state() - - async def async_turn_off(self, **kwargs: Any) -> None: - self._attr_is_on = False - await self.entity_description.set_fn(self.coordinator, False) - self.async_write_ha_state() - - async def async_update(self) -> None: - """Update the entity state.""" - - -class MammotionUpdateSwitchEntity(MammotionBaseEntity, SwitchEntity, RestoreEntity): - entity_description: MammotionUpdateSwitchEntityDescription - _attr_has_entity_name = True - - def __init__( - self, - coordinator: MammotionBaseUpdateCoordinator, - entity_description: MammotionUpdateSwitchEntityDescription, - ) -> None: - super().__init__(coordinator, entity_description.key) - self.coordinator = coordinator - self.entity_description = entity_description - self._attr_translation_key = entity_description.key - self._attr_is_on = True # Default state - - @property - def is_on(self) -> bool: - return self.entity_description.is_on_func(self.coordinator) - - async def async_turn_on(self, **kwargs: Any) -> None: - self._attr_is_on = True - await self.entity_description.set_fn(self.coordinator, True) - self.async_write_ha_state() - - async def async_turn_off(self, **kwargs: Any) -> None: - self._attr_is_on = False - await self.entity_description.set_fn(self.coordinator, False) - self.async_write_ha_state() - - -class MammotionConfigSwitchEntity(MammotionBaseEntity, SwitchEntity, RestoreEntity): - entity_description: MammotionConfigSwitchEntityDescription - _attr_has_entity_name = True - _attr_entity_category = EntityCategory.CONFIG - - def __init__( - self, - coordinator: MammotionBaseUpdateCoordinator, - entity_description: MammotionConfigSwitchEntityDescription, - ) -> None: - super().__init__(coordinator, entity_description.key) - self.coordinator = coordinator - self.entity_description = entity_description - self._attr_translation_key = entity_description.key - - @property - def is_on(self) -> bool: - """Return if settings is on or off.""" - return getattr( - self.coordinator.operation_settings, self.entity_description.key, False - ) - - async def async_turn_on(self, **kwargs: Any) -> None: - self._attr_is_on = True - self.entity_description.set_fn(self.coordinator, True) - self.async_write_ha_state() - - async def async_turn_off(self, **kwargs: Any) -> None: - self._attr_is_on = False - self.entity_description.set_fn(self.coordinator, False) - self.async_write_ha_state() - - async def async_update(self) -> None: - """Update the entity state.""" - - -class MammotionConfigAreaSwitchEntity(MammotionBaseEntity, SwitchEntity, RestoreEntity): - entity_description: MammotionConfigAreaSwitchEntityDescription - _attr_has_entity_name = True - _attr_entity_category = EntityCategory.CONFIG - - def __init__( - self, - coordinator: MammotionBaseUpdateCoordinator, - entity_description: MammotionConfigAreaSwitchEntityDescription, - ) -> None: - super().__init__(coordinator, entity_description.key) - self.coordinator = coordinator - self.entity_description = entity_description - # TODO this should not need to be cast. - self._attr_extra_state_attributes = {"hash": entity_description.area} - # TODO grab defaults from operation_settings - self._attr_is_on = False # Default state - - async def async_turn_on(self, **kwargs: Any) -> None: - self._attr_is_on = True - self.entity_description.set_fn( - # TODO this should not need to be cast. - self.coordinator, - True, - int(self.entity_description.area), - ) - self.async_write_ha_state() - - async def async_turn_off(self, **kwargs: Any) -> None: - self._attr_is_on = False - self.entity_description.set_fn( - # TODO this should not need to be cast. - self.coordinator, - False, - int(self.entity_description.area), - ) - self.async_write_ha_state() diff --git a/requirements_all.txt b/requirements_all.txt index 997d0ec954642..f31549290e16f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2334,7 +2334,7 @@ pylutron==0.4.1 pymailgunner==1.4 # homeassistant.components.mammotion -pymammotion==0.4.3 +pymammotion==0.4.21 # homeassistant.components.firmata pymata-express==1.19 From 2b45839367e2623ef1beaa1c7ba667fbb7f790c9 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Tue, 29 Apr 2025 08:51:16 +1200 Subject: [PATCH 22/66] updates from PR comments, change strings, fix a few strings, remove services and diags --- .../components/mammotion/__init__.py | 61 +++--- homeassistant/components/mammotion/const.py | 2 +- .../components/mammotion/coordinator.py | 47 ++--- .../components/mammotion/diagnostics.py | 26 --- homeassistant/components/mammotion/entity.py | 7 +- homeassistant/components/mammotion/icons.json | 165 --------------- .../components/mammotion/lawn_mower.py | 178 +--------------- .../components/mammotion/manifest.json | 2 +- .../components/mammotion/services.yaml | 195 ------------------ .../components/mammotion/strings.json | 168 +-------------- requirements_all.txt | 2 +- 11 files changed, 69 insertions(+), 784 deletions(-) delete mode 100644 homeassistant/components/mammotion/diagnostics.py delete mode 100644 homeassistant/components/mammotion/icons.json delete mode 100644 homeassistant/components/mammotion/services.yaml diff --git a/homeassistant/components/mammotion/__init__.py b/homeassistant/components/mammotion/__init__.py index 29936bd59d5a7..a5fd4bcf5317b 100644 --- a/homeassistant/components/mammotion/__init__.py +++ b/homeassistant/components/mammotion/__init__.py @@ -4,7 +4,10 @@ from aiohttp import ClientConnectorError from pymammotion import CloudIOTGateway -from pymammotion.aliyun.cloud_gateway import CheckSessionException +from pymammotion.aliyun.cloud_gateway import ( + CheckSessionException, + DeviceOfflineException, +) from pymammotion.aliyun.model.aep_response import AepResponse from pymammotion.aliyun.model.connect_response import ConnectResponse from pymammotion.aliyun.model.dev_by_account_response import ListingDevByAccountResponse @@ -58,11 +61,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> bool: """Set up Mammotion Luba from a config entry.""" - device_name = entry.data.get(CONF_DEVICE_NAME) - address = entry.data.get(CONF_ADDRESS) + device_name = entry.data[CONF_DEVICE_NAME] + address = entry.data[CONF_ADDRESS] mammotion = Mammotion() - account = entry.data.get(CONF_ACCOUNTNAME) - password = entry.data.get(CONF_PASSWORD) + account = entry.data[CONF_ACCOUNTNAME] + password = entry.data[CONF_PASSWORD] stay_connected_ble = entry.data.get(CONF_STAY_CONNECTED_BLUETOOTH, False) @@ -121,7 +124,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> hass, entry, device, mammotion ) await report_coordinator.async_restore_data() - # other coordinator + # other coordinators await maintenance_coordinator.async_config_entry_first_refresh() await version_coordinator.async_config_entry_first_refresh() await report_coordinator.async_config_entry_first_refresh() @@ -153,7 +156,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> mammotion_device.preference = ConnectionPreference.BLUETOOTH await mammotion_device.cloud().stop() mammotion_device.cloud().mqtt.disconnect() if mammotion_device.cloud().mqtt.is_connected() else None - # not entirely sure this is a good idea mammotion_device.remove_cloud() mammotion_devices.append( @@ -170,8 +172,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> ) try: await map_coordinator.async_request_refresh() - except: - """Do nothing for now.""" + except DeviceOfflineException: + pass entry.runtime_data = mammotion_devices await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -179,7 +181,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> return True -def store_cloud_credentials(hass, config_entry, cloud_client: CloudIOTGateway) -> None: +def store_cloud_credentials( + hass: HomeAssistant, + config_entry: MammotionConfigEntry, + cloud_client: CloudIOTGateway, +) -> None: """Store cloud credentials in config entry.""" if cloud_client is not None: @@ -205,25 +211,22 @@ async def check_and_restore_cloud( ) -> CloudIOTGateway | None: """Check and restore previous cloud connection.""" - auth_data = entry.data.get(CONF_AUTH_DATA) - region_data = entry.data.get(CONF_REGION_DATA) - aep_data = entry.data.get(CONF_AEP_DATA) - session_data = entry.data.get(CONF_SESSION_DATA) - device_data = entry.data.get(CONF_DEVICE_DATA) - connect_data = entry.data.get(CONF_CONNECT_DATA) - mammotion_data = entry.data.get(CONF_MAMMOTION_DATA) - - if any( - data is None - for data in [ - auth_data, - region_data, - aep_data, - session_data, - device_data, - connect_data, - mammotion_data, - ] + auth_data = entry.data[CONF_AUTH_DATA] + region_data = entry.data[CONF_REGION_DATA] + aep_data = entry.data[CONF_AEP_DATA] + session_data = entry.data[CONF_SESSION_DATA] + device_data = entry.data[CONF_DEVICE_DATA] + connect_data = entry.data[CONF_CONNECT_DATA] + mammotion_data = entry.data[CONF_MAMMOTION_DATA] + + if None in ( + auth_data, + region_data, + aep_data, + session_data, + device_data, + connect_data, + mammotion_data, ): return None diff --git a/homeassistant/components/mammotion/const.py b/homeassistant/components/mammotion/const.py index 288ec73a7fb82..d77a36821a342 100644 --- a/homeassistant/components/mammotion/const.py +++ b/homeassistant/components/mammotion/const.py @@ -15,7 +15,7 @@ ATTR_DIRECTION = "direction" -DEFAULT_RETRY_COUNT = 3 +DEFAULT_RETRY_COUNT = 5 CONF_RETRY_COUNT = "retry_count" LOGGER: Final = logging.getLogger(__package__) diff --git a/homeassistant/components/mammotion/coordinator.py b/homeassistant/components/mammotion/coordinator.py index 3b43b3b8efc6c..3ce6157e646ea 100644 --- a/homeassistant/components/mammotion/coordinator.py +++ b/homeassistant/components/mammotion/coordinator.py @@ -45,6 +45,7 @@ CONF_MAMMOTION_DATA, CONF_REGION_DATA, CONF_SESSION_DATA, + DEFAULT_RETRY_COUNT, DOMAIN, EXPIRED_CREDENTIAL_EXCEPTIONS, LOGGER, @@ -174,7 +175,7 @@ async def async_send_command(self, command: str, **kwargs: Any) -> bool | None: except EXPIRED_CREDENTIAL_EXCEPTIONS: self.update_failures += 1 await self.async_refresh_login() - if self.update_failures < 5: + if self.update_failures < DEFAULT_RETRY_COUNT: await self.async_send_command(command, **kwargs) return False except GatewayTimeoutException as ex: @@ -182,7 +183,6 @@ async def async_send_command(self, command: str, **kwargs: Any) -> bool | None: self.update_failures = 0 return False except (DeviceOfflineException, NoConnectionException) as ex: - """Device is offline try bluetooth if we have it.""" try: if device.has_ble(): # if we don't do this it will stay connected and no longer update over wifi @@ -227,7 +227,7 @@ async def async_sync_maps(self) -> None: except EXPIRED_CREDENTIAL_EXCEPTIONS: self.update_failures += 1 await self.async_refresh_login() - if self.update_failures < 5: + if self.update_failures < DEFAULT_RETRY_COUNT: await self.async_sync_maps() async def async_start_stop_blades(self, start_stop: bool) -> None: @@ -410,7 +410,6 @@ async def async_restore_data(self) -> None: self.device_name ).mower_state = mower_state except InvalidFieldValue: - """invalid""" self.data = MowingDevice() self.manager.get_device_by_name(self.device_name).mower_state = self.data @@ -429,8 +428,10 @@ async def _async_update_data(self) -> _DataT | None: if device.mower_state.report_data.dev.sys_status in NO_REQUEST_MODES: return self.data - if self.update_failures > 5 and device.preference is ConnectionPreference.WIFI: - """Don't hammer the mammotion/ali servers""" + if ( + self.update_failures > DEFAULT_RETRY_COUNT + and device.preference is ConnectionPreference.WIFI + ): loop = asyncio.get_running_loop() loop.call_later( 60, lambda: asyncio.create_task(self.clear_update_failures()) @@ -504,26 +505,11 @@ async def _async_update_data(self) -> MowingDevice: await self.async_send_command("get_report_cfg") except DeviceOfflineException as ex: - """Device is offline try bluetooth if we have it.""" if ex.iot_id == self.device.iotId: device = self.manager.get_device_by_name(self.device_name) await self.device_offline(device) return device.mower_state - LOGGER.debug("Updated Mammotion device %s", self.device_name) - LOGGER.debug("================= Debug Log =================") - if device.preference is ConnectionPreference.BLUETOOTH: - LOGGER.debug( - "Mammotion device data: %s", - self.manager.get_device_by_name(self.device_name).ble()._raw_data, - ) - if device.preference is ConnectionPreference.WIFI: - LOGGER.debug( - "Mammotion device data: %s", - self.manager.get_device_by_name(self.device_name).cloud()._raw_data, - ) - LOGGER.debug("==================================") - self.update_failures = 0 data = self.manager.get_device_by_name(self.device_name).mower_state await self.async_save_data(data) @@ -584,13 +570,12 @@ async def _async_update_data(self) -> Maintain: await self.async_send_command("get_maintenance") except DeviceOfflineException as ex: - """Device is offline try bluetooth if we have it.""" if ex.iot_id == self.device.iotId: device = self.manager.get_device_by_name(self.device_name) await self.device_offline(device) return device.mower_state.report_data.maintenance except GatewayTimeoutException: - """Gateway is timing out again.""" + pass return self.manager.get_device_by_name( self.device.deviceName @@ -639,13 +624,12 @@ async def _async_update_data(self): await self.async_send_command(command) except DeviceOfflineException as ex: - """Device is offline bluetooth has been attempted.""" if ex.iot_id == self.device.iotId: device = self.manager.get_device_by_name(self.device_name) await self.device_offline(device) return device.mower_state.mower_state except GatewayTimeoutException: - """Gateway is timing out again.""" + pass data = self.manager.get_device_by_name(self.device_name).mower_state.mower_state await self.check_firmware_version() @@ -663,7 +647,7 @@ async def _async_setup(self) -> None: try: await self.async_send_command("get_device_product_model") except DeviceOfflineException: - """Device is offline bluetooth has been attempted.""" + return class MammotionMapUpdateCoordinator(MammotionBaseUpdateCoordinator[MowerInfo]): @@ -685,10 +669,6 @@ def __init__( update_interval=MAP_INTERVAL, ) - def _map_callback(self) -> None: - """Trigger a resync when the bol hash changes.""" - # TODO setup callback to get bol hash data - async def _async_update_data(self): """Get data from the device.""" if data := await super()._async_update_data(): @@ -699,16 +679,16 @@ async def _async_update_data(self): if ( len(device.mower_state.map.hashlist) == 0 or len(device.mower_state.map.missing_hashlist()) > 0 + or len(device.mower_state.map.plan) == 0 ): await self.manager.start_map_sync(self.device_name) except DeviceOfflineException as ex: - """Device is offline try bluetooth if we have it.""" if ex.iot_id == self.device.iotId: await self.device_offline(device) return device.mower_state.mower_state except GatewayTimeoutException: - """Gateway is timing out again.""" + pass return self.manager.get_device_by_name(self.device_name).mower_state.mower_state @@ -725,8 +705,7 @@ async def _async_setup(self) -> None: if not DeviceType.is_luba1(self.device_name): await self.async_get_area_list() except DeviceOfflineException as ex: - """Device is offline try bluetooth if we have it.""" if ex.iot_id == self.device.iotId: await self.device_offline(device) except GatewayTimeoutException: - """Gateway is timing out again.""" + return diff --git a/homeassistant/components/mammotion/diagnostics.py b/homeassistant/components/mammotion/diagnostics.py deleted file mode 100644 index 4da57f6bef74b..0000000000000 --- a/homeassistant/components/mammotion/diagnostics.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Diagnostics support for Mammotion.""" - -from __future__ import annotations - -from dataclasses import asdict -from typing import Any - -from homeassistant.components.diagnostics import async_redact_data -from homeassistant.core import HomeAssistant - -from . import MammotionConfigEntry, MammotionMowerData - -TO_REDACT: list[str] = [] - - -async def async_get_config_entry_diagnostics( - hass: HomeAssistant, - entry: MammotionConfigEntry, -) -> dict[str, Any]: - """Return diagnostics for a config entry.""" - mammotion_devices: list[MammotionMowerData] = entry.runtime_data - data = {} - for device in mammotion_devices: - data[device.name] = asdict(device.reporting_coordinator.data) - - return async_redact_data(data, TO_REDACT) diff --git a/homeassistant/components/mammotion/entity.py b/homeassistant/components/mammotion/entity.py index c286ef4cd112a..55c903e33fd23 100644 --- a/homeassistant/components/mammotion/entity.py +++ b/homeassistant/components/mammotion/entity.py @@ -3,7 +3,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import CONF_RETRY_COUNT, DEFAULT_RETRY_COUNT, DOMAIN +from .const import DEFAULT_RETRY_COUNT, DOMAIN from .coordinator import MammotionBaseUpdateCoordinator @@ -53,8 +53,5 @@ def available(self) -> bool: """Return True if the entity is available.""" return ( self.coordinator.data is not None - and self.coordinator.update_failures - <= self.coordinator.config_entry.options.get( - CONF_RETRY_COUNT, DEFAULT_RETRY_COUNT - ) + and self.coordinator.update_failures <= DEFAULT_RETRY_COUNT ) diff --git a/homeassistant/components/mammotion/icons.json b/homeassistant/components/mammotion/icons.json deleted file mode 100644 index 4d5a2fd0cf86c..0000000000000 --- a/homeassistant/components/mammotion/icons.json +++ /dev/null @@ -1,165 +0,0 @@ -{ - "entity": { - "device_tracker": { - "device_tracker": { - "default": "mdi:map-marker-radius" - } - }, - "button": { - "start_map_sync": { - "default": "mdi:map-clock" - }, - "resync_rtk_dock": { - "default": "mdi:sync" - }, - "release_from_dock": { - "default": "mdi:ray-start-arrow" - }, - "emergency_nudge_forward": { - "default": "mdi:arrow-up" - }, - "emergency_nudge_left": { - "default": "mdi:arrow-left" - }, - "emergency_nudge_right": { - "default": "mdi:arrow-right" - }, - "emergency_nudge_back": { - "default": "mdi:arrow-down" - }, - "cancel_task": { - "default": "mdi:cancel" - }, - "clear_all_mapdata": { - "default": "mdi:map-marker-remove" - } - }, - "sensor": { - "gps_stars": { - "default": "mdi:satellite-uplink" - }, - "blade_height": { - "default": "mdi:altimeter" - }, - "area": { - "default": "mdi:tape-measure" - }, - "progress": { - "default": "mdi:percent-box" - }, - "l1_satellites": { - "default": "mdi:satellite-variant" - }, - "l2_satellites": { - "default": "mdi:satellite-variant" - }, - "position_mode": { - "default": "mdi:map-marker" - } - }, - "select": { - "channel_mode": { - "default": "mdi:map-marker-path", - "state": { - "single_grid": "mdi:sawtooth-wave", - "double_grid": "mdi:checkerboard", - "segment_grid": "mdi:square-wave", - "no_grid": "mdi:dots-square" - } - }, - "mowing_laps": { - "default": "mdi:go-kart-track", - "state": { - "none": "mdi:numeric-0", - "one": "mdi:numeric-1", - "two": "mdi:numeric-2", - "three": "mdi:numeric-3", - "four": "mdi:numeric-4" - } - }, - "obstacle_laps": { - "default": "mdi:go-kart-track", - "state": { - "none": "mdi:numeric-0", - "one": "mdi:numeric-1", - "two": "mdi:numeric-2", - "three": "mdi:numeric-3", - "four": "mdi:numeric-4" - } - }, - "border_mode": { - "default": "mdi:checkerboard", - "state": { - "border_first": "mdi:dots-circle", - "grid_first": "mdi:checkerboard" - } - }, - "bypass_mode": { - "default": "mdi:arrow-collapse-right" - }, - "cutting_angle_mode": { - "default": "mdi:angle-acute" - } - }, - "number": { - "start_progress": { - "default": "mdi:progress-helper" - }, - "blade_height": { - "default": "mdi:altimeter" - }, - "working_speed": { - "default": "mdi:speedometer" - }, - "cutting_angle": { - "default": "mdi:angle-acute" - }, - "path_spacing": { - "default": "mdi:keyboard-space" - }, - "dumping_interval": { - "default": "mdi:dump-truck" - }, - "toward_included_angle": { - "default": "mdi:angle-right" - } - }, - "switch": { - "area": { - "default": "mdi:texture-box" - }, - "blade_status": { - "default": "mdi:saw-blade" - }, - "is_mow": { - "default": "mdi:mower", - "state": { - "off": "mdi:mower", - "on": "mdi:mower-on" - } - }, - "is_dump": { - "default": "mdi:dump-truck" - }, - "is_edge": { - "default": "mdi:border-outside" - }, - "rain_tactics": { - "default": "mdi:weather-rainy" - }, - "side_led": { - "default": "mdi:led-off", - "state": { - "off": "mdi:led-off", - "on": "mdi:led-on" - } - }, - "perimeter_first_on_off": { - "default": "mdi:dots-square" - }, - "schedule_updates": { - "default": "mdi:update" - } - } - } -} diff --git a/homeassistant/components/mammotion/lawn_mower.py b/homeassistant/components/mammotion/lawn_mower.py index 65e30b85bd498..59b61e59bae60 100644 --- a/homeassistant/components/mammotion/lawn_mower.py +++ b/homeassistant/components/mammotion/lawn_mower.py @@ -2,13 +2,8 @@ from __future__ import annotations -from typing import Any - -from pymammotion.data.model.device_config import OperationSettings from pymammotion.data.model.report_info import DeviceData, ReportData from pymammotion.utility.constant.device_constant import WorkMode -from pymammotion.utility.device_type import DeviceType -import voluptuous as vol from homeassistant.components.lawn_mower import ( LawnMowerActivity, @@ -17,55 +12,12 @@ ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import MammotionConfigEntry, MammotionReportUpdateCoordinator from .const import COMMAND_EXCEPTIONS, DOMAIN, LOGGER from .entity import MammotionBaseEntity -SERVICE_START_MOWING = "start_mow" -SERVICE_CANCEL_JOB = "cancel_job" - -START_MOW_SCHEMA = { - vol.Optional("is_mow", default=True): cv.boolean, - vol.Optional("is_dump", default=True): cv.boolean, - vol.Optional("is_edge", default=False): cv.boolean, - vol.Optional("collect_grass_frequency", default=10): vol.All( - vol.Coerce(int), vol.Range(min=5, max=100) - ), - vol.Optional("border_mode", default=1): vol.In([0, 1]), - vol.Optional("job_version", default=0): vol.Coerce(int), - vol.Optional("job_id", default=0): vol.Coerce(int), - vol.Optional("speed", default=0.3): vol.All( - vol.Coerce(float), vol.Range(min=0.2, max=1.2) - ), - vol.Optional("ultra_wave", default=2): vol.In([0, 1, 2, 10, 11]), - vol.Optional("channel_mode", default=0): vol.In([0, 1, 2, 3]), - vol.Optional("channel_width", default=25): vol.All( - vol.Coerce(int), vol.Range(min=5, max=35) - ), - vol.Optional("rain_tactics", default=1): vol.In([0, 1]), - vol.Optional("blade_height", default=25): vol.All( - vol.Coerce(int), vol.Range(min=15, max=100) - ), - vol.Optional("toward", default=0): vol.All( - vol.Coerce(int), vol.Range(min=-180, max=180) - ), - vol.Optional("toward_included_angle", default=0): vol.All( - vol.Coerce(int), vol.Range(min=-180, max=180) - ), - vol.Optional("toward_mode", default=0): vol.In([0, 1, 2]), - vol.Optional("mowing_laps", default=1): vol.In([0, 1, 2, 3, 4]), - vol.Optional("obstacle_laps", default=1): vol.In([0, 1, 2, 3, 4]), - vol.Optional("start_progress", default=0): vol.All( - vol.Coerce(int), vol.Range(min=0, max=100) - ), - vol.Required("areas"): vol.All( - cv.ensure_list, [cv.entity_id] - ), # This assumes `areas` are entity IDs from the integration -} - def get_entity_attribute( hass: HomeAssistant, entity_id: str, attribute_name: str @@ -89,32 +41,27 @@ async def async_setup_entry( ) -> None: """Set up the Luba config entry.""" mammotion_devices = entry.runtime_data - - for mower in mammotion_devices: - async_add_entities([MammotionLawnMowerEntity(mower.reporting_coordinator)]) - - platform = entity_platform.async_get_current_platform() - - platform.async_register_entity_service( - SERVICE_START_MOWING, START_MOW_SCHEMA, "async_start_mowing" + entities = [] + async_add_entities( + [ + MammotionLawnMowerEntity(mower.reporting_coordinator) + for mower in mammotion_devices + ] ) - - platform.async_register_entity_service(SERVICE_CANCEL_JOB, None, "async_cancel") + 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 + LawnMowerEntityFeature.DOCK | LawnMowerEntityFeature.PAUSE ) def __init__(self, coordinator: MammotionReportUpdateCoordinator) -> None: """Initialize the lawn mower.""" super().__init__(coordinator, "mower") - self._attr_name = None @property def rpt_dev_status(self) -> DeviceData: @@ -150,74 +97,6 @@ def activity(self) -> LawnMowerActivity | None: return LawnMowerActivity.DOCKED return None - async def async_start_mowing(self, **kwargs: Any) -> None: - """Start mowing.""" - trans_key = "pause_failed" - - if kwargs: - await self.async_cancel() - entity_ids = kwargs.get("areas", []) - - attributes = [ - # TODO this should not need to be cast. - int(entity_hash) - for entity_id in entity_ids - if (entity_hash := get_entity_attribute(self.hass, entity_id, "hash")) - is not None - ] - - kwargs["areas"] = attributes - operational_settings = OperationSettings.from_dict(kwargs) - if DeviceType.is_yuka(self.coordinator.device_name): - operational_settings.blade_height = -10 - LOGGER.debug(kwargs) - else: - operational_settings = self.coordinator.operation_settings - - # check if job in progress - # - 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_PAUSE, - WorkMode.MODE_READY, - WorkMode.MODE_RETURNING, - ): - try: - if mode == WorkMode.MODE_RETURNING: - trans_key = "dock_cancel_failed" - await self.coordinator.async_send_command("cancel_return_to_dock") - await self.coordinator.async_request_iot_sync() - # TODO is rpt_dev_status updated on iot sync? - mode = self.rpt_dev_status.sys_status - if mode == WorkMode.MODE_PAUSE: - trans_key = "resume_failed" - charge_state = self.rpt_dev_status.charge_state - if charge_state != 0: - await self.coordinator.async_send_command( - "break_point_anywhere_continue" - ) - else: - await self.coordinator.async_send_command("resume_execute_task") - if mode == WorkMode.MODE_READY: - trans_key = "start_failed" - if self.report_data.work.area >> 16 != 0: - await self.coordinator.async_send_command("resume_execute_task") - return - if await self.coordinator.async_plan_route(operational_settings): - 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.async_request_iot_sync() - async def async_dock(self) -> None: """Start docking.""" trans_key = "pause_failed" @@ -280,42 +159,3 @@ async def async_pause(self) -> None: ) from exc finally: await self.coordinator.async_request_iot_sync() - - async def async_cancel(self) -> None: - """Cancel Job.""" - 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_PAUSE, - WorkMode.MODE_WORKING, - WorkMode.MODE_RETURNING, - ): - try: - if mode != WorkMode.MODE_PAUSE: - 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_failed" - await self.coordinator.async_send_command( - "cancel_return_to_dock" - ) - await self.coordinator.async_request_iot_sync() - mode = self.rpt_dev_status.sys_status - - if mode == WorkMode.MODE_PAUSE: - trans_key = "pause_failed" - await self.coordinator.async_send_command("cancel_job") - - except COMMAND_EXCEPTIONS as exc: - raise HomeAssistantError( - translation_domain=DOMAIN, translation_key=trans_key - ) from exc - finally: - await self.coordinator.async_request_iot_sync() diff --git a/homeassistant/components/mammotion/manifest.json b/homeassistant/components/mammotion/manifest.json index 24b76fee433ce..5413ae7c3e052 100644 --- a/homeassistant/components/mammotion/manifest.json +++ b/homeassistant/components/mammotion/manifest.json @@ -20,5 +20,5 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["pymammotion"], - "requirements": ["pymammotion==0.4.21"] + "requirements": ["pymammotion==0.4.26"] } diff --git a/homeassistant/components/mammotion/services.yaml b/homeassistant/components/mammotion/services.yaml deleted file mode 100644 index 569a039088c31..0000000000000 --- a/homeassistant/components/mammotion/services.yaml +++ /dev/null @@ -1,195 +0,0 @@ -cancel_job: - target: - entity: - integration: mammotion - domain: lawn_mower -start_mow: - target: - entity: - integration: mammotion - domain: lawn_mower - fields: - is_mow: - example: true - default: true - required: false - selector: - boolean: - is_dump: - example: true - default: true - required: false - selector: - boolean: - is_edge: - example: false - default: false - required: false - selector: - boolean: - collect_grass_frequency: - example: 10 - default: 10 - required: false - selector: - number: - min: 5 - max: 100 - unit_of_measurement: "m²" - border_mode: - example: 0 - default: 0 - required: false - selector: - select: - translation_key: "border_mode" - options: - - 0 - - 1 - job_version: - example: 0 - default: 0 - required: false - selector: - number: - job_id: - example: 0 - default: 0 - required: false - selector: - number: - speed: - example: 0.3 - default: 0.3 - required: false - selector: - number: - min: 0.2 - max: 1.2 - step: 0.1 - mode: box - unit_of_measurement: "m/s" - ultra_wave: - example: 2 - default: 2 - selector: - select: - translation_key: "ultra_wave" - options: - - 0 - - 1 - - 2 - - 10 - - 11 - required: false - channel_mode: - example: 0 - default: 0 - required: false - selector: - select: - translation_key: "channel_mode" - options: - - 0 - - 1 - - 2 - - 3 - channel_width: - example: 25 - default: 25 - required: false - selector: - number: - min: 5 - max: 35 - rain_tactics: - example: 1 - default: 1 - required: false - selector: - select: - translation_key: "rain_tactics" - options: - - 0 - - 1 - blade_height: - example: 0 - default: 25 - required: false - selector: - number: - min: 15 - max: 100 - step: 5 - unit_of_measurement: "mm" - toward: - example: 0 - default: 0 - required: false - selector: - number: - min: -180 - max: 180 - unit_of_measurement: degrees - toward_included_angle: - example: 0 - default: 0 - required: false - selector: - number: - min: -180 - max: 180 - unit_of_measurement: degrees - toward_mode: - example: 0 - default: 0 - selector: - select: - translation_key: "toward_mode" - options: - - 0 - - 1 - - 2 - required: false - mowing_laps: - example: 1 - default: 1 - selector: - select: - translation_key: "mowing_laps" - options: - - 0 - - 1 - - 2 - - 3 - - 4 - required: false - obstacle_laps: - example: 1 - default: 1 - selector: - select: - translation_key: "obstacle_laps" - options: - - 0 - - 1 - - 2 - - 3 - - 4 - required: false - start_progress: - example: 0 - default: 0 - required: false - selector: - number: - min: 0 - max: 100 - unit_of_measurement: "%" - areas: - required: true - selector: - entity: - multiple: true - integration: mammotion - domain: switch diff --git a/homeassistant/components/mammotion/strings.json b/homeassistant/components/mammotion/strings.json index 8eded58f139cf..452cdef70ce2e 100644 --- a/homeassistant/components/mammotion/strings.json +++ b/homeassistant/components/mammotion/strings.json @@ -1,26 +1,26 @@ { "config": { "abort": { - "already_configured": "Device is already configured", - "already_in_progress": "Configuration flow is already in progress", - "no_devices_found": "Could not find devices", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", "no_devices_found_in_account": "No devices present in your account", "bluetooth_and_account_mismatch": "Bluetooth device not found in your account", "no_longer_present": "Device is no longer present", "not_supported": "Device not supported", - "reconfigure_successful": "Re-configure successful" + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "flow_title": "Configure your Mammotion lawn mower", "step": { "bluetooth_confirm": { - "description": "Setup {name}", + "description": "Set up {name}", "data": { - "stay_connected_bluetooth": "Keep bluetooth connected" + "stay_connected_bluetooth": "Keep Bluetooth connected" } }, "reconfigure": { "data": { - "use_wifi": "Use Wi-fi", + "use_wifi": "Use Wi-Fi", "account_name": "Mammotion email or account number", "password": "Mammotion account password" } @@ -28,13 +28,13 @@ "user": { "data": { "address": "Device", - "stay_connected_bluetooth": "Keep bluetooth connected" + "stay_connected_bluetooth": "Keep Bluetooth connected" }, "description": "Select your mower" }, "wifi": { "data": { - "use_wifi": "Use Wi-fi (un-tick and submit to use bluetooth)", + "use_wifi": "Use Wi-Fi (un-tick and submit to use bluetooth)", "account_name": "Mammotion email or account number", "password": "Mammotion account password" }, @@ -48,160 +48,12 @@ "init": { "data": { "title": "Update Configuration", - "stay_connected_bluetooth": "Keep bluetooth connected" + "stay_connected_bluetooth": "Keep Bluetooth connected" } } } }, "entity": {}, - "selector": { - "border_mode": { - "options": { - "0": "Perimeter first", - "1": "ZigZag/chessboard first" - } - }, - "ultra_wave": { - "options": { - "0": "Direct touch", - "1": "Slow touch", - "2": "Less touch", - "10": "No touch", - "11": "Sensitive" - } - }, - "channel_mode": { - "options": { - "0": "Zigzag path", - "1": "Chessboard path", - "2": "Adaptive zigzag path", - "3": "Perimeter only" - } - }, - "rain_tactics": { - "options": { - "0": "Off", - "1": "On" - } - }, - "toward_mode": { - "options": { - "0": "Relative angle", - "1": "Absolute angle", - "2": "Random angle" - } - }, - "mowing_laps": { - "options": { - "0": "None", - "1": "One lap", - "2": "Two laps", - "3": "Three laps", - "4": "Four laps" - } - }, - "obstacle_laps": { - "options": { - "0": "None", - "1": "One lap", - "2": "Two laps", - "3": "Three laps", - "4": "Four laps" - } - } - }, - "services": { - "cancel_job": { - "name": "Cancel current task", - "description": "Stops the mower and clears the current task." - }, - "start_mow": { - "name": "Start Mowing", - "description": "Start the mowing operation with custom settings.", - "fields": { - "is_mow": { - "name": "Is Mowing", - "description": "Whether mowing is active. (Yuka)" - }, - "is_dump": { - "name": "Is Dumping", - "description": "Whether grass dumping is active. (Yuka)" - }, - "is_edge": { - "name": "Edge mowing", - "description": "Whether edge mode is active. (Yuka)" - }, - "collect_grass_frequency": { - "name": "Grass Collection Frequency", - "description": "Frequency to collect grass (in meters squared). (Yuka)" - }, - "border_mode": { - "name": "Mow Order", - "description": "Mowing path order (Perimeter first or grid first)." - }, - "job_version": { - "name": "Job Version", - "description": "Job version." - }, - "job_id": { - "name": "Job ID", - "description": "Job ID." - }, - "speed": { - "name": "Speed", - "description": "Mowing speed." - }, - "ultra_wave": { - "name": "Obstacle Detection", - "description": "Obstacle Avoidance Mode." - }, - "channel_mode": { - "name": "Cutting Path Mode", - "description": "Cutting Path (zigzag, chessboard, adaptive zigzag, or perimeter only)." - }, - "channel_width": { - "name": "Path Width", - "description": "Width of the mowing path (in cm)." - }, - "rain_tactics": { - "name": "Rain Detection", - "description": "Rain detection." - }, - "blade_height": { - "name": "Blade Height", - "description": "Height of the blade." - }, - "toward": { - "name": "Starting Path Angle", - "description": "Starting direction for mowing." - }, - "toward_included_angle": { - "name": "Crossing Angle", - "description": "When selecting grid change the second angle (default is 90 degrees)." - }, - "toward_mode": { - "name": "Cutting Angle Mode", - "description": "Anglular direction of mow." - }, - "mowing_laps": { - "name": "Perimeter Mowing Laps", - "description": "Number of laps around the mowing area perimeter." - }, - "obstacle_laps": { - "name": "No-go zone Mowing Laps", - "description": "Number of laps around No-go zones." - }, - "start_progress": { - "name": "Start Progress", - "description": "Starting progress percentage." - }, - "areas": { - "name": "Area Selection", - "description": "List of areas to mow (represented as integers)." - } - } - } - }, "exceptions": { "device_not_ready": { "message": "Device is not ready." diff --git a/requirements_all.txt b/requirements_all.txt index f31549290e16f..6314521b0a99d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2334,7 +2334,7 @@ pylutron==0.4.1 pymailgunner==1.4 # homeassistant.components.mammotion -pymammotion==0.4.21 +pymammotion==0.4.26 # homeassistant.components.firmata pymata-express==1.19 From bb2e3845af1bab5396cd5c8ac43f3316214d0611 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Sat, 3 May 2025 18:53:54 +1200 Subject: [PATCH 23/66] fix Bluetooth string --- homeassistant/components/mammotion/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/mammotion/strings.json b/homeassistant/components/mammotion/strings.json index 452cdef70ce2e..21a244b9b7b57 100644 --- a/homeassistant/components/mammotion/strings.json +++ b/homeassistant/components/mammotion/strings.json @@ -34,7 +34,7 @@ }, "wifi": { "data": { - "use_wifi": "Use Wi-Fi (un-tick and submit to use bluetooth)", + "use_wifi": "Use Wi-Fi (un-tick and submit to use Bluetooth)", "account_name": "Mammotion email or account number", "password": "Mammotion account password" }, From 0d03550309c4060b31891f8218394e90877644a2 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Sat, 3 May 2025 19:09:17 +1200 Subject: [PATCH 24/66] remove unused device --- homeassistant/components/mammotion/coordinator.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/mammotion/coordinator.py b/homeassistant/components/mammotion/coordinator.py index 3ce6157e646ea..c61ec01e459e8 100644 --- a/homeassistant/components/mammotion/coordinator.py +++ b/homeassistant/components/mammotion/coordinator.py @@ -352,10 +352,10 @@ async def async_plan_route(self, operation_settings: OperationSettings) -> bool: speed=operation_settings.speed, ultra_wave=operation_settings.ultra_wave, # touch no touch etc toward=operation_settings.toward, # is just angle (route angle) - toward_included_angle=operation_settings.toward_included_angle # demond_angle + toward_included_angle=operation_settings.toward_included_angle if operation_settings.channel_mode == 1 else 0, # crossing angle relative to grid - toward_mode=operation_settings.toward_mode, + toward_mode=operation_settings.toward_mode, # blade_height=operation_settings.blade_height, channel_mode=operation_settings.channel_mode, # single, double, segment or none (route mode) channel_width=operation_settings.channel_width, # path space @@ -499,8 +499,6 @@ async def _async_update_data(self) -> MowingDevice: if data := await super()._async_update_data(): return data - device = self.manager.get_device_by_name(self.device_name) - try: await self.async_send_command("get_report_cfg") From 6d1e048668e463ed4ba51f3974f7b41b8db8aeec Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Tue, 17 Jun 2025 14:15:06 +1200 Subject: [PATCH 25/66] additions from HACS --- .../components/mammotion/__init__.py | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/mammotion/__init__.py b/homeassistant/components/mammotion/__init__.py index a5fd4bcf5317b..a2cda5d642199 100644 --- a/homeassistant/components/mammotion/__init__.py +++ b/homeassistant/components/mammotion/__init__.py @@ -25,7 +25,7 @@ from homeassistant.components import bluetooth from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ADDRESS, CONF_PASSWORD, Platform +from homeassistant.const import CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady @@ -33,9 +33,9 @@ CONF_ACCOUNTNAME, CONF_AEP_DATA, CONF_AUTH_DATA, + CONF_BLE_DEVICES, CONF_CONNECT_DATA, CONF_DEVICE_DATA, - CONF_DEVICE_NAME, CONF_MAMMOTION_DATA, CONF_REGION_DATA, CONF_SESSION_DATA, @@ -61,8 +61,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> bool: """Set up Mammotion Luba from a config entry.""" - device_name = entry.data[CONF_DEVICE_NAME] - address = entry.data[CONF_ADDRESS] + addresses = entry.data.get(CONF_BLE_DEVICES, {}) mammotion = Mammotion() account = entry.data[CONF_ACCOUNTNAME] password = entry.data[CONF_PASSWORD] @@ -145,10 +144,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> if mammotion_device is None: raise ConfigEntryError - if address: - ble_device = bluetooth.async_ble_device_from_address(hass, address) - if ble_device and ble_device.name == device_name: - mammotion_device.add_ble(ble_device) + if device_ble_address := addresses.get(device.deviceName, None): + mammotion_device.mower_state.mower_state.ble_mac = ( + device_ble_address + ) + ble_device = bluetooth.async_ble_device_from_address( + hass, device_ble_address + ) + if ble_device: + mammotion_device.add_ble(device, ble_device) mammotion_device.ble().set_disconnect_strategy( not stay_connected_ble ) From 0b07aad8e5511ee34d6513d36b9336ad3a7460bb Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Wed, 13 Aug 2025 08:38:36 +1200 Subject: [PATCH 26/66] update the mammotion integration based off hacs for core --- .../components/mammotion/__init__.py | 146 ++-- homeassistant/components/mammotion/config.py | 11 + .../components/mammotion/config_flow.py | 134 ++-- homeassistant/components/mammotion/const.py | 6 +- .../components/mammotion/coordinator.py | 709 ++++++++++-------- homeassistant/components/mammotion/entity.py | 61 +- homeassistant/components/mammotion/models.py | 3 + requirements_all.txt | 2 +- 8 files changed, 623 insertions(+), 449 deletions(-) create mode 100644 homeassistant/components/mammotion/config.py diff --git a/homeassistant/components/mammotion/__init__.py b/homeassistant/components/mammotion/__init__.py index a2cda5d642199..55e7a108dc453 100644 --- a/homeassistant/components/mammotion/__init__.py +++ b/homeassistant/components/mammotion/__init__.py @@ -4,10 +4,6 @@ from aiohttp import ClientConnectorError from pymammotion import CloudIOTGateway -from pymammotion.aliyun.cloud_gateway import ( - CheckSessionException, - DeviceOfflineException, -) from pymammotion.aliyun.model.aep_response import AepResponse from pymammotion.aliyun.model.connect_response import ConnectResponse from pymammotion.aliyun.model.dev_by_account_response import ListingDevByAccountResponse @@ -19,15 +15,18 @@ from pymammotion.data.model.account import Credentials from pymammotion.http.http import MammotionHTTP from pymammotion.http.model.http import LoginResponseData, Response +from pymammotion.http.model.response_factory import response_factory from pymammotion.mammotion.devices.mammotion import ConnectionPreference, Mammotion from pymammotion.utility.device_config import DeviceConfig from Tea.exceptions import UnretryableException from homeassistant.components import bluetooth +from homeassistant.components.http import StaticPathConfig from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.helpers.device_registry import DeviceEntry from .const import ( CONF_ACCOUNTNAME, @@ -36,16 +35,19 @@ CONF_BLE_DEVICES, CONF_CONNECT_DATA, CONF_DEVICE_DATA, + CONF_DEVICE_NAME, CONF_MAMMOTION_DATA, CONF_REGION_DATA, CONF_SESSION_DATA, CONF_STAY_CONNECTED_BLUETOOTH, CONF_USE_WIFI, DEVICE_SUPPORT, + DOMAIN, EXPIRED_CREDENTIAL_EXCEPTIONS, LOGGER, ) from .coordinator import ( + MammotionDeviceErrorUpdateCoordinator, MammotionDeviceVersionUpdateCoordinator, MammotionMaintenanceUpdateCoordinator, MammotionMapUpdateCoordinator, @@ -63,8 +65,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> addresses = entry.data.get(CONF_BLE_DEVICES, {}) mammotion = Mammotion() - account = entry.data[CONF_ACCOUNTNAME] - password = entry.data[CONF_PASSWORD] + account = entry.data.get(CONF_ACCOUNTNAME) + password = entry.data.get(CONF_PASSWORD) stay_connected_ble = entry.data.get(CONF_STAY_CONNECTED_BLUETOOTH, False) @@ -96,12 +98,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> cloud_client.set_http(mammotion_http) await mammotion.initiate_cloud_connection(account, cloud_client) except ClientConnectorError as err: - raise ConfigEntryNotReady from err + raise ConfigEntryNotReady(err) except EXPIRED_CREDENTIAL_EXCEPTIONS as exc: LOGGER.debug(exc) await mammotion.login_and_initiate_cloud(account, password, True) except UnretryableException as err: - raise ConfigEntryError from err + raise ConfigEntryError(err) if mqtt_client := mammotion.mqtt_list.get(account): store_cloud_credentials(hass, entry, mqtt_client.cloud_client) @@ -110,6 +112,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> ) in mqtt_client.cloud_client.devices_by_account_response.data.data: if not device.deviceName.startswith(DEVICE_SUPPORT): continue + + mammotion_device = mammotion.get_or_create_device_by_name( + device, mqtt_client + ) + + if device_ble_address := addresses.get(device.deviceName, None): + mammotion_device.state.mower_state.ble_mac = device_ble_address + ble_device = bluetooth.async_ble_device_from_address( + hass, device_ble_address.upper(), True + ) + if ble_device: + mammotion_device.add_ble(ble_device) + mammotion_device.ble().set_disconnect_strategy( + not stay_connected_ble + ) + maintenance_coordinator = MammotionMaintenanceUpdateCoordinator( hass, entry, device, mammotion ) @@ -122,15 +140,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> map_coordinator = MammotionMapUpdateCoordinator( hass, entry, device, mammotion ) + error_coordinator = MammotionDeviceErrorUpdateCoordinator( + hass, entry, device, mammotion + ) await report_coordinator.async_restore_data() # other coordinators await maintenance_coordinator.async_config_entry_first_refresh() await version_coordinator.async_config_entry_first_refresh() await report_coordinator.async_config_entry_first_refresh() + await error_coordinator.async_config_entry_first_refresh() device_config = DeviceConfig() device_limits = device_config.get_working_parameters( - version_coordinator.data.sub_model_id + version_coordinator.data.mower_state.sub_model_id ) if device_limits is None: device_limits = device_config.get_working_parameters( @@ -140,26 +162,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> if device_limits is None: device_limits = device_config.get_best_default(device.productKey) - mammotion_device = mammotion.get_device_by_name(device.deviceName) - if mammotion_device is None: - raise ConfigEntryError - - if device_ble_address := addresses.get(device.deviceName, None): - mammotion_device.mower_state.mower_state.ble_mac = ( - device_ble_address - ) - ble_device = bluetooth.async_ble_device_from_address( - hass, device_ble_address - ) - if ble_device: - mammotion_device.add_ble(device, ble_device) - mammotion_device.ble().set_disconnect_strategy( - not stay_connected_ble - ) if not use_wifi: mammotion_device.preference = ConnectionPreference.BLUETOOTH await mammotion_device.cloud().stop() mammotion_device.cloud().mqtt.disconnect() if mammotion_device.cloud().mqtt.is_connected() else None + # not entirely sure this is a good idea mammotion_device.remove_cloud() mammotion_devices.append( @@ -172,12 +179,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> reporting_coordinator=report_coordinator, version_coordinator=version_coordinator, map_coordinator=map_coordinator, + error_coordinator=error_coordinator, ) ) try: await map_coordinator.async_request_refresh() - except DeviceOfflineException: - pass + except: + """Do nothing for now.""" entry.runtime_data = mammotion_devices await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -215,25 +223,41 @@ async def check_and_restore_cloud( ) -> CloudIOTGateway | None: """Check and restore previous cloud connection.""" - auth_data = entry.data[CONF_AUTH_DATA] - region_data = entry.data[CONF_REGION_DATA] - aep_data = entry.data[CONF_AEP_DATA] - session_data = entry.data[CONF_SESSION_DATA] - device_data = entry.data[CONF_DEVICE_DATA] - connect_data = entry.data[CONF_CONNECT_DATA] - mammotion_data = entry.data[CONF_MAMMOTION_DATA] - - if None in ( - auth_data, - region_data, - aep_data, - session_data, - device_data, - connect_data, - mammotion_data, + auth_data = entry.data.get(CONF_AUTH_DATA) + region_data = entry.data.get(CONF_REGION_DATA) + aep_data = entry.data.get(CONF_AEP_DATA) + session_data = entry.data.get(CONF_SESSION_DATA) + device_data = entry.data.get(CONF_DEVICE_DATA) + connect_data = entry.data.get(CONF_CONNECT_DATA) + mammotion_data = entry.data.get(CONF_MAMMOTION_DATA) + + if any( + data is None + for data in [ + auth_data, + region_data, + aep_data, + session_data, + device_data, + connect_data, + mammotion_data, + ] ): return None + mammotion_response_data = ( + response_factory(Response[LoginResponseData], mammotion_data) + if isinstance(mammotion_data, dict) + else mammotion_data + ) + mammotion_http = MammotionHTTP() + mammotion_http.response = mammotion_response_data + mammotion_http.login_info = ( + LoginResponseData.from_dict(mammotion_response_data.data) + if isinstance(mammotion_response_data.data, dict) + else mammotion_response_data.data + ) + cloud_client = CloudIOTGateway( connect_response=ConnectResponse.from_dict(connect_data) if isinstance(connect_data, dict) @@ -253,19 +277,10 @@ async def check_and_restore_cloud( login_by_oauth_response=LoginByOAuthResponse.from_dict(auth_data) if isinstance(auth_data, dict) else auth_data, + mammotion_http=mammotion_http, ) - if isinstance(mammotion_data, dict): - mammotion_data = Response[LoginResponseData].from_dict(mammotion_data) - mammotion_http = MammotionHTTP() - mammotion_http.response = mammotion_data - mammotion_http.login_info = mammotion_data.data - cloud_client.set_http(mammotion_http) - - try: - await cloud_client.check_or_refresh_session() - except CheckSessionException: - return None + await cloud_client.check_or_refresh_session() return cloud_client @@ -281,5 +296,26 @@ async def async_unload_entry(hass: HomeAssistant, entry: MammotionConfigEntry) - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): for mower in entry.runtime_data: - await mower.api.remove_device(mower.name) + try: + await mower.api.remove_device(mower.name) + except TimeoutError: + """Do nothing as this sometimes occurs with disconnecting BLE.""" return unload_ok + + +async def async_remove_config_entry_device( + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry +) -> bool: + """Remove a config entry from a device.""" + mower_name = ( + next( + identifier[1] + for identifier in device_entry.identifiers + if identifier[0] == DOMAIN + ), + ) + mower = next( + (mower for mower in config_entry.runtime_data if mower.name == mower_name), None + ) + + return not bool(mower) diff --git a/homeassistant/components/mammotion/config.py b/homeassistant/components/mammotion/config.py new file mode 100644 index 0000000000000..af3de7a4fa258 --- /dev/null +++ b/homeassistant/components/mammotion/config.py @@ -0,0 +1,11 @@ +from homeassistant.helpers.storage import Store + +from .const import DOMAIN + + +class MammotionConfigStore(Store): + """A configuration store for Alexa.""" + + _STORAGE_VERSION = 1 + _STORAGE_MINOR_VERSION = 1 + _STORAGE_KEY = DOMAIN diff --git a/homeassistant/components/mammotion/config_flow.py b/homeassistant/components/mammotion/config_flow.py index b2f8e433b1e0b..7486a8cbd5eec 100644 --- a/homeassistant/components/mammotion/config_flow.py +++ b/homeassistant/components/mammotion/config_flow.py @@ -21,7 +21,7 @@ ConfigFlowResult, OptionsFlow, ) -from homeassistant.const import CONF_ADDRESS, CONF_PASSWORD +from homeassistant.const import CONF_PASSWORD from homeassistant.core import callback from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, format_mac @@ -50,6 +50,43 @@ def __init__(self) -> None: self._discovered_device: BLEDevice | None = None self._discovered_devices: dict[str, str] = {} + async def check_and_update_bluetooth_device(self, device: BLEDevice) -> ConfigEntry: + """Check if the device is already configured and update ble mac if needed.""" + device_registry = dr.async_get(self.hass) + current_entries = self.hass.config_entries.async_entries(DOMAIN) + + for entry in current_entries: + if not entry.data.get(CONF_ACCOUNT_ID): + continue + + device_entries = dr.async_entries_for_config_entry( + device_registry, entry.entry_id + ) + + for device_entry in device_entries: + # Check both MAC address and any other identifiers + identifiers = {device_id[1] for device_id in device_entry.identifiers} + if device.name in identifiers: + await self.async_set_unique_id(entry.data.get(CONF_ACCOUNT_ID)) + # # Update existing entry with BLE info + formatted_ble = format_mac(self._discovered_device.address) + + if ( + CONNECTION_BLUETOOTH, + formatted_ble, + ) not in device_entry.connections: + device_registry.async_update_device( + device_entry.id, + merge_connections={(CONNECTION_BLUETOOTH, formatted_ble)}, + ) + if entry.state == 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 + return None + async def async_step_bluetooth( self, discovery_info: BluetoothServiceInfo | None = None ) -> ConfigFlowResult: @@ -58,6 +95,9 @@ async def async_step_bluetooth( 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 ) @@ -72,45 +112,14 @@ async def async_step_bluetooth( self._discovered_device = device - device_registry = dr.async_get(self.hass) - current_entries = self.hass.config_entries.async_entries(DOMAIN) - - for entry in current_entries: - if not entry.data.get(CONF_ACCOUNT_ID): - continue - - device_entries = dr.async_entries_for_config_entry( - device_registry, entry.entry_id - ) - - for device_entry in device_entries: - # Check both MAC address and any other identifiers - identifiers = {identifier[1] for identifier in device_entry.identifiers} - if device.name in identifiers: - if ( - entry.state == config_entries.ConfigEntryState.LOADED - and len(device_entry.connections) == 0 - ): - # # Update existing entry with BLE info - - formatted_ble = format_mac(self._discovered_device.address) - - device_registry.async_update_device( - device_entry.id, - new_connections={(CONNECTION_BLUETOOTH, formatted_ble)}, - ) - # reload the entry now we have a ble address - self.hass.config_entries.async_schedule_reload(entry.entry_id) - - await self.async_set_unique_id(entry.data.get(CONF_ACCOUNT_ID)) - self._abort_if_unique_id_configured( - updates={CONF_ADDRESS: discovery_info.address} - ) - - await self.async_set_unique_id(discovery_info.name) - self._abort_if_unique_id_configured( - updates={CONF_ADDRESS: discovery_info.address} - ) + if entry := await self.check_and_update_bluetooth_device(device): + ble_devices = { + self._discovered_device.name: format_mac( + self._discovered_device.address + ), + **entry.data.get(CONF_BLE_DEVICES, {}), + } + self._abort_if_unique_id_configured(updates={CONF_BLE_DEVICES: ble_devices}) return await self.async_step_bluetooth_confirm() @@ -120,12 +129,23 @@ async def async_step_bluetooth_confirm( """Confirm discovery.""" assert self._discovered_device + + if entry := await self.check_and_update_bluetooth_device( + self._discovered_device + ): + ble_devices = { + self._discovered_device.name: format_mac( + self._discovered_device.address + ), + **entry.data.get(CONF_BLE_DEVICES, None), + } + self._abort_if_unique_id_configured(updates={CONF_BLE_DEVICES: ble_devices}) + ble_devices: dict[str, str] = { - self._discovered_device.name: self._discovered_device.address + self._discovered_device.name: format_mac(self._discovered_device.address) } self._config = { CONF_BLE_DEVICES: ble_devices, - CONF_ADDRESS: self._discovered_device.address, } if user_input is not None: @@ -151,18 +171,7 @@ async def async_step_user( """Handle the user step to pick discovered device.""" if user_input is not None: - address = user_input.get(CONF_ADDRESS) or self._config.get(CONF_ADDRESS) - if address is not None: - self._config = { - CONF_ADDRESS: address, - } - self._stay_connected = user_input.get( - CONF_STAY_CONNECTED_BLUETOOTH, False - ) - - self._discovered_device = bluetooth.async_ble_device_from_address( - self.hass, address - ) + self._stay_connected = user_input.get(CONF_STAY_CONNECTED_BLUETOOTH, False) return await self.async_step_wifi(user_input) @@ -188,7 +197,6 @@ async def async_step_user( last_step=False, data_schema=vol.Schema( { - vol.Optional(CONF_ADDRESS): vol.In(self._discovered_devices), vol.Optional( CONF_STAY_CONNECTED_BLUETOOTH, default=False, @@ -240,7 +248,6 @@ async def async_step_wifi( return self.async_create_entry( title=self._discovered_device.name, data={ - CONF_ADDRESS: self._discovered_device.address, CONF_USE_WIFI: user_input.get(CONF_USE_WIFI), **self._config, }, @@ -259,9 +266,6 @@ async def async_step_wifi_confirm( self, user_input: dict[str, Any] ) -> ConfigFlowResult: """Confirm device discovery.""" - - address = self._config.get(CONF_ADDRESS) - name = self._discovered_devices.get(address) mammotion = Mammotion() if user_input is not None: @@ -290,13 +294,11 @@ async def async_step_wifi_confirm( data={ CONF_ACCOUNTNAME: account, CONF_PASSWORD: password, - CONF_DEVICE_NAME: name, CONF_USE_WIFI: user_input.get(CONF_USE_WIFI, True), **self._config, }, options={CONF_STAY_CONNECTED_BLUETOOTH: self._stay_connected}, ) - return self.async_abort(reason="missing_wifi_data") @staticmethod @callback @@ -339,16 +341,6 @@ async def async_step_reconfigure( ): cv.boolean, } - if user_input is not None and entry.data.get(CONF_ADDRESS) is None: - schema = { - vol.Required( - CONF_ACCOUNTNAME, default=entry.data.get(CONF_ACCOUNTNAME) - ): vol.All(cv.string, vol.Strip), - vol.Required( - CONF_PASSWORD, default=entry.data.get(CONF_PASSWORD) - ): vol.All(cv.string, vol.Strip), - } - return self.async_show_form( step_id="reconfigure", data_schema=vol.Schema(schema), diff --git a/homeassistant/components/mammotion/const.py b/homeassistant/components/mammotion/const.py index d77a36821a342..7b991eb6e429d 100644 --- a/homeassistant/components/mammotion/const.py +++ b/homeassistant/components/mammotion/const.py @@ -15,7 +15,7 @@ ATTR_DIRECTION = "direction" -DEFAULT_RETRY_COUNT = 5 +DEFAULT_RETRY_COUNT = 3 CONF_RETRY_COUNT = "retry_count" LOGGER: Final = logging.getLogger(__package__) @@ -48,4 +48,8 @@ WorkMode.MODE_CHANNEL_DRAW, WorkMode.MODE_ERASER_DRAW, WorkMode.MODE_UPDATING, + WorkMode.MODE_EDIT_BOUNDARY, + WorkMode.MODE_UPDATING, + WorkMode.MODE_LOCK, + WorkMode.MODE_MANUAL_MOWING, ) diff --git a/homeassistant/components/mammotion/coordinator.py b/homeassistant/components/mammotion/coordinator.py index c61ec01e459e8..e688cd65cf366 100644 --- a/homeassistant/components/mammotion/coordinator.py +++ b/homeassistant/components/mammotion/coordinator.py @@ -2,28 +2,36 @@ from __future__ import annotations +from abc import abstractmethod import asyncio +from collections.abc import Mapping +import datetime from datetime import timedelta +import json +import time from typing import TYPE_CHECKING, Any import betterproto from mashumaro.exceptions import InvalidFieldValue from pymammotion.aliyun.cloud_gateway import ( DeviceOfflineException, + FailedRequestException, GatewayTimeoutException, NoConnectionException, ) from pymammotion.aliyun.model.dev_by_account_response import Device -from pymammotion.data.model import GenerateRouteInformation, HashList from pymammotion.data.model.device import MowerInfo, MowingDevice -from pymammotion.data.model.device_config import OperationSettings, create_path_order from pymammotion.data.model.report_info import Maintain +from pymammotion.data.mqtt.event import DeviceNotificationEventParams, ThingEventMessage +from pymammotion.data.mqtt.properties import OTAProgressItems, ThingPropertiesMessage +from pymammotion.data.mqtt.status import ThingStatusMessage +from pymammotion.http.model.http import ErrorInfo from pymammotion.mammotion.devices.mammotion import ( ConnectionPreference, Mammotion, MammotionMixedDeviceManager, ) -from pymammotion.proto import RptAct, RptInfoType +from pymammotion.proto import RptAct, RptInfoType, SystemUpdateBufMsg from pymammotion.utility.constant import WorkMode from pymammotion.utility.device_type import DeviceType @@ -32,9 +40,9 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.storage import Store from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from .config import MammotionConfigStore from .const import ( COMMAND_EXCEPTIONS, CONF_ACCOUNTNAME, @@ -45,7 +53,6 @@ CONF_MAMMOTION_DATA, CONF_REGION_DATA, CONF_SESSION_DATA, - DEFAULT_RETRY_COUNT, DOMAIN, EXPIRED_CREDENTIAL_EXCEPTIONS, LOGGER, @@ -67,9 +74,6 @@ class MammotionBaseUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): """Mammotion DataUpdateCoordinator.""" - manager: Mammotion | None = None - device: Device | None = None - def __init__( self, hass: HomeAssistant, @@ -87,49 +91,24 @@ def __init__( ) assert config_entry.unique_id self.config_entry = config_entry - self.device = device + self.device: Device = device self.device_name = device.deviceName - self.manager = mammotion - self._operation_settings = OperationSettings() + self.manager: Mammotion = mammotion self.update_failures = 0 - async def set_scheduled_updates(self, enabled: bool) -> None: - """Set scheduled updates.""" - device = self.manager.get_device_by_name(self.device_name) - device.mower_state.enabled = enabled - if device.mower_state.enabled: - self.update_failures = 0 - if not device.mower_state.online: - device.mower_state.online = True - if device.has_cloud() and device.cloud().stopped: - await device.cloud().start() - else: - if device.has_cloud(): - await device.cloud().stop() - device.cloud().mqtt.disconnect() - if device.has_ble(): - await device.ble().stop() + @abstractmethod + def get_coordinator_data(self, device: MammotionMixedDeviceManager) -> _DataT: + """Get coordinator data.""" async def async_refresh_login(self) -> None: - """Login to cloud servers.""" - if ( - self.manager.get_device_by_name(self.device_name) - and self.manager.get_device_by_name(self.device_name).has_cloud() - ): - await self.hass.async_add_executor_job( - self.manager.get_device_by_name(self.device_name) - .cloud() - .mqtt.disconnect - ) - + """Refresh login credentials asynchronously.""" account = self.config_entry.data.get(CONF_ACCOUNTNAME) password = self.config_entry.data.get(CONF_PASSWORD) await self.manager.refresh_login(account, password) self.store_cloud_credentials() async def device_offline(self, device: MammotionMixedDeviceManager) -> None: - """Device is offline.""" - device.mower_state.online = False + device.state.online = False if device.has_cloud(): await device.cloud().stop() @@ -161,7 +140,7 @@ def store_cloud_credentials(self) -> None: async def async_send_command(self, command: str, **kwargs: Any) -> bool | None: """Send command.""" - if not self.manager.get_device_by_name(self.device_name).mower_state.online: + if not self.manager.get_device_by_name(self.device_name).state.online: return False device = self.manager.get_device_by_name(self.device_name) @@ -172,17 +151,23 @@ async def async_send_command(self, command: str, **kwargs: Any) -> bool | None: ) self.update_failures = 0 return True + except FailedRequestException: + self.update_failures += 1 + if self.update_failures < 5: + return await self.async_send_command(command, **kwargs) + return False except EXPIRED_CREDENTIAL_EXCEPTIONS: self.update_failures += 1 await self.async_refresh_login() - if self.update_failures < DEFAULT_RETRY_COUNT: - await self.async_send_command(command, **kwargs) + if self.update_failures < 5: + return await self.async_send_command(command, **kwargs) return False except GatewayTimeoutException as ex: LOGGER.error(f"Gateway timeout exception: {ex.iot_id}") self.update_failures = 0 return False except (DeviceOfflineException, NoConnectionException) as ex: + """Device is offline try bluetooth if we have it.""" try: if device.has_ble(): # if we don't do this it will stay connected and no longer update over wifi @@ -201,120 +186,26 @@ async def async_send_command(self, command: str, **kwargs: Any) -> bool | None: async def check_firmware_version(self) -> None: """Check if firmware version is updated.""" - mower = self.manager.mower(self.device_name) - device_registry = dr.async_get(self.hass) - device_entry = device_registry.async_get_device( - identifiers={(DOMAIN, self.device_name)} - ) - if device_entry is None: - return - - new_swversion = mower.device_firmwares.device_version - - if new_swversion is not None or new_swversion != device_entry.sw_version: - device_registry.async_update_device( - device_entry.id, sw_version=new_swversion + if mower := self.manager.mower(self.device_name): + device_registry = dr.async_get(self.hass) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, self.device_name)} ) + if device_entry is None: + return - if model_id := mower.mower_state.model_id: - if model_id is not None or model_id != device_entry.model_id: - device_registry.async_update_device(device_entry.id, model_id=model_id) + new_swversion = mower.device_firmwares.device_version - async def async_sync_maps(self) -> None: - """Get map data from the device.""" - try: - await self.manager.start_map_sync(self.device_name) - except EXPIRED_CREDENTIAL_EXCEPTIONS: - self.update_failures += 1 - await self.async_refresh_login() - if self.update_failures < DEFAULT_RETRY_COUNT: - await self.async_sync_maps() - - async def async_start_stop_blades(self, start_stop: bool) -> None: - """Start stop blades.""" - if DeviceType.is_luba1(self.device_name): - if start_stop: - await self.async_send_command("set_blade_control", on_off=1) - else: - await self.async_send_command("set_blade_control", on_off=0) - elif start_stop: - await self.async_send_command( - "operate_on_device", - main_ctrl=1, - cut_knife_ctrl=1, - cut_knife_height=60, - max_run_speed=1.2, - ) - else: - await self.async_send_command( - "operate_on_device", - main_ctrl=0, - cut_knife_ctrl=0, - cut_knife_height=60, - max_run_speed=1.2, - ) + if new_swversion is not None or new_swversion != device_entry.sw_version: + device_registry.async_update_device( + device_entry.id, sw_version=new_swversion + ) - async def async_set_sidelight(self, on_off: int) -> None: - """Set Sidelight.""" - await self.async_send_command( - "read_and_set_sidelight", is_sidelight=bool(on_off), operate=0 - ) - - async def async_read_sidelight(self) -> None: - """Set Sidelight.""" - await self.async_send_command( - "read_and_set_sidelight", is_sidelight=False, operate=1 - ) - - async def set_traversal_mode(self, context: int) -> None: - """Set traversal mode.""" - await self.async_send_command("traverse_mode", context=context) - - async def set_turning_mode(self, context: int) -> None: - """Set turning mode.""" - await self.async_send_command("turning_mode", context=context) - - async def async_blade_height(self, height: int) -> int: - """Set blade height.""" - await self.async_send_command("set_blade_height", height=height) - return height - - async def async_leave_dock(self) -> None: - """Leave dock.""" - await self.send_command_and_update("leave_dock") - - async def async_cancel_task(self) -> None: - """Cancel task.""" - await self.send_command_and_update("cancel_job") - - async def async_move_forward(self, speed: float) -> None: - """Move forward.""" - await self.send_command_and_update("move_forward", linear=speed) - - async def async_move_left(self, speed: float) -> None: - """Move left.""" - await self.send_command_and_update("move_left", angular=speed) - - async def async_move_right(self, speed: float) -> None: - """Move right.""" - await self.send_command_and_update("move_right", angular=speed) - - async def async_move_back(self, speed: float) -> None: - """Move back.""" - await self.send_command_and_update("move_back", linear=speed) - - async def async_rtk_dock_location(self) -> None: - """RTK and dock location.""" - await self.async_send_command("allpowerfull_rw", rw_id=5, rw=1, context=1) - - async def async_get_area_list(self) -> None: - """Mowing area List.""" - await self.async_send_command("get_area_name_list", device_id=self.device.iotId) - - async def send_command_and_update(self, command_str: str, **kwargs: Any) -> None: - """Send command and update.""" - await self.async_send_command(command_str, **kwargs) - await self.async_request_iot_sync() + if model_id := mower.mower_state.model_id: + if model_id is not None or model_id != device_entry.model_id: + device_registry.async_update_device( + device_entry.id, model_id=model_id + ) async def async_request_iot_sync(self, stop: bool = False) -> None: """Sync specific info from device.""" @@ -335,149 +226,130 @@ async def async_request_iot_sync(self, stop: bool = False) -> None: count=0, ) - async def async_plan_route(self, operation_settings: OperationSettings) -> bool: - """Plan mow.""" - - if self.data.report_data.dev: - dev = self.data.report_data.dev - if dev.collector_status.collector_installation_status == 0: - operation_settings.is_dump = False - - if DeviceType.is_yuka(self.device_name): - operation_settings.blade_height = -10 - - route_information = GenerateRouteInformation( - one_hashs=operation_settings.areas, - rain_tactics=operation_settings.rain_tactics, - speed=operation_settings.speed, - ultra_wave=operation_settings.ultra_wave, # touch no touch etc - toward=operation_settings.toward, # is just angle (route angle) - toward_included_angle=operation_settings.toward_included_angle - if operation_settings.channel_mode == 1 - else 0, # crossing angle relative to grid - toward_mode=operation_settings.toward_mode, # - blade_height=operation_settings.blade_height, - channel_mode=operation_settings.channel_mode, # single, double, segment or none (route mode) - channel_width=operation_settings.channel_width, # path space - job_mode=operation_settings.job_mode, # taskMode grid or border first - edge_mode=operation_settings.mowing_laps, # perimeter/mowing laps - path_order=create_path_order(operation_settings, self.device_name), - obstacle_laps=operation_settings.obstacle_laps, - ) - - if DeviceType.is_luba1(self.device_name): - route_information.toward_mode = 0 - route_information.toward_included_angle = 0 - - # not sure if this is artificial limit - # if ( - # DeviceType.is_mini_or_x_series(self.device_name) - # and route_information.toward_mode == 0 - # ): - # route_information.toward = 0 - - return await self.async_send_command( - "generate_route_information", generate_route_information=route_information - ) - - async def clear_all_maps(self) -> None: - """Clear all map data stored.""" - data = self.manager.get_device_by_name(self.device_name).mower_state - data.map = HashList() - async def clear_update_failures(self) -> None: - """Clear update failures.""" self.update_failures = 0 device = self.manager.get_device_by_name(self.device_name) - if not device.mower_state.online: - device.mower_state.online = True + if not device.state.online: + device.state.online = True if device.has_cloud() and device.cloud().stopped: await device.cloud().start() - @property - def operation_settings(self) -> OperationSettings: - """Return operation settings for planning.""" - return self._operation_settings - async def async_restore_data(self) -> None: """Restore saved data.""" - store = Store(self.hass, version=1, key=self.device_name) - restored_data = await store.async_load() + store = MammotionConfigStore(self.hass, version=1, minor_version=1, key=DOMAIN) + restored_data: Mapping[str, Any] | None = await store.async_load() + try: - if restored_data: - mower_state = MowingDevice().from_dict(restored_data) - self.manager.get_device_by_name( - self.device_name - ).mower_state = mower_state + if mower_data := restored_data.get(self.device_name): + mower_state = MowingDevice().from_dict(mower_data) + if device := self.manager.get_device_by_name(self.device_name): + device.state = mower_state except InvalidFieldValue: + """invalid""" self.data = MowingDevice() - self.manager.get_device_by_name(self.device_name).mower_state = self.data + self.manager.get_device_by_name(self.device_name).state = self.data async def async_save_data(self, data: MowingDevice) -> None: """Get map data from the device.""" - store = Store(self.hass, version=1, key=self.device_name) - await store.async_save(data.to_dict()) + store = MammotionConfigStore(self.hass, version=1, minor_version=1, key=DOMAIN) + current_store = await store.async_load() + current_store[self.device_name] = data.to_dict() + await store.async_save(current_store) async def _async_update_data(self) -> _DataT | None: - device = self.manager.get_device_by_name(self.device_name) + if device := self.manager.get_device_by_name(self.device_name): + if not device.state.enabled or ( + not device.state.online + and device.preference is ConnectionPreference.WIFI + ): + if ( + not device.state.enabled + and device.has_cloud() + and device.cloud().mqtt.is_connected() + ): + device.cloud().mqtt.disconnect() + if not device.state.enabled and device.has_ble(): + if ( + device.ble().client is not None + and device.ble().client.is_connected + ): + await device.ble().client.disconnect() + return self.get_coordinator_data(device) - if not device.mower_state.enabled or not device.mower_state.online: - return self.data + if ( + device.state.mower_state.ble_mac != "" + and device.preference is ConnectionPreference.BLUETOOTH + ): + if ble_device := bluetooth.async_ble_device_from_address( + self.hass, device.state.mower_state.ble_mac.upper(), True + ): + if not device.has_ble(): + device.add_ble(ble_device) + else: + device.ble().update_device(ble_device) + + # don't query the mower while users are doing map changes or its updating. + if device.state.report_data.dev.sys_status in NO_REQUEST_MODES: + # MQTT we are likely to get an update, BLE we are not + if device.preference is ConnectionPreference.BLUETOOTH: + loop = asyncio.get_running_loop() + loop.call_later( + 300, + lambda: asyncio.create_task( + self.async_send_command("get_report_cfg") + ), + ) + return self.get_coordinator_data(device) - # don't query the mower while users are doing map changes or its updating. - if device.mower_state.report_data.dev.sys_status in NO_REQUEST_MODES: - return self.data + if ( + self.update_failures > 5 + and device.preference is ConnectionPreference.WIFI + ): + """Don't hammer the mammotion/ali servers""" + loop = asyncio.get_running_loop() + loop.call_later( + 60, lambda: asyncio.create_task(self.clear_update_failures()) + ) - if ( - self.update_failures > DEFAULT_RETRY_COUNT - and device.preference is ConnectionPreference.WIFI - ): - loop = asyncio.get_running_loop() - loop.call_later( - 60, lambda: asyncio.create_task(self.clear_update_failures()) - ) + return self.get_coordinator_data(device) + return None + return None - return self.data + async def _async_update_notification(self, res: tuple[str, Any | None]) -> None: + """Update data from incoming messages.""" - if device.has_ble() and device.preference is ConnectionPreference.BLUETOOTH: - if ble_device := bluetooth.async_ble_device_from_address( - self.hass, device.ble().get_address(), True - ): - device.ble().update_device(ble_device) - return None + async def _async_update_properties( + self, properties: ThingPropertiesMessage + ) -> None: + """Update data from incoming properties messages.""" - async def find_entity_by_attribute_in_registry( - self, attribute_name, attribute_value - ): - """Find an entity using the entity registry based on attributes.""" - entity_registry = await self.hass.helpers.entity_registry.async_get_registry() + async def _async_update_status(self, status: ThingStatusMessage) -> None: + """Update data from incoming status messages.""" - for entity_id, entity_entry in entity_registry.entities.items(): - entity_state = self.hass.states.get(entity_id) - if ( - entity_state - and entity_state.attributes.get(attribute_name) == attribute_value - ): - return entity_id, entity_entry + async def _async_update_event_message(self, event: ThingEventMessage) -> None: + """Update data from incoming event messages.""" + + async def _async_setup(self) -> None: + device = self.manager.get_device_by_name(self.device_name) - return None, None + if self.data is None: + self.data = device.state + if device.has_cloud(): + device.cloud().set_notification_callback(self._async_update_notification) + elif device.has_ble(): + device.ble().set_notification_callback(self._async_update_notification) - def get_area_entity_name(self, area_hash: int) -> str: - """Get string name of area hash.""" - try: - area = next( - item for item in self.data.map.area_name if item.hash == area_hash - ) - if area.name != "": - return area.name - return f"area {area_hash}" - except StopIteration: - return None + device.state_manager.properties_callback.add_subscribers( + self._async_update_properties + ) + device.state_manager.status_callback.add_subscribers(self._async_update_status) + device.state_manager.device_event_callback.add_subscribers( + self._async_update_event_message + ) -class MammotionReportUpdateCoordinator(MammotionBaseUpdateCoordinator[MowingDevice]): - """Mammotion report update coordinator.""" +class MammotionReportUpdateCoordinator(MammotionBaseUpdateCoordinator[MowingDevice]): def __init__( self, hass: HomeAssistant, @@ -494,25 +366,64 @@ def __init__( update_interval=REPORT_INTERVAL, ) + def get_coordinator_data(self, device: MammotionMixedDeviceManager) -> MowingDevice: + return device.state + async def _async_update_data(self) -> MowingDevice: """Get data from the device.""" if data := await super()._async_update_data(): return data + device = self.manager.get_device_by_name(self.device_name) + if device is None: + LOGGER.debug("device not found") + return data + try: - await self.async_send_command("get_report_cfg") + last_sent_time = 0 + if device.cloud(): + last_sent_time = device.cloud().command_sent_time + elif device.ble(): + last_sent_time = device.ble().command_sent_time + + if ( + self.update_interval + and last_sent_time < time.time() - self.update_interval.seconds + ): + await self.async_send_command("get_report_cfg") except DeviceOfflineException as ex: + """Device is offline.""" if ex.iot_id == self.device.iotId: device = self.manager.get_device_by_name(self.device_name) await self.device_offline(device) - return device.mower_state + return device.state + + LOGGER.debug("Updated Mammotion device %s", self.device_name) + LOGGER.debug("================= Debug Log =================") + if device.preference is ConnectionPreference.BLUETOOTH: + if device.ble(): + LOGGER.debug( + "Mammotion device data: %s", + device.ble()._raw_data, + ) + if device.preference is ConnectionPreference.WIFI: + if device.cloud(): + LOGGER.debug( + "Mammotion device data: %s", + device.cloud()._raw_data, + ) + LOGGER.debug("==================================") self.update_failures = 0 - data = self.manager.get_device_by_name(self.device_name).mower_state + data = self.manager.get_device_by_name(self.device_name).state await self.async_save_data(data) - if data.report_data.dev.sys_status is WorkMode.MODE_WORKING: + if data.report_data.dev.sys_status in ( + WorkMode.MODE_WORKING, + WorkMode.MODE_RETURNING, + WorkMode.MODE_PAUSE, + ): self.update_interval = WORKING_INTERVAL else: self.update_interval = DEFAULT_INTERVAL @@ -524,20 +435,8 @@ async def _async_update_notification(self, res: tuple[str, Any | None]) -> None: if res[0] == "sys" and res[1] is not None: sys_msg = betterproto.which_one_of(res[1], "SubSysMsg") if sys_msg[0] == "toapp_report_data": - mower = self.manager.mower(self.device_name) - self.async_set_updated_data(mower) - - async def _async_setup(self) -> None: - """Set up Mammotion report coordinator.""" - device = self.manager.get_device_by_name(self.device_name) - - if self.data is None: - self.data = device.mower_state - - if device.has_cloud(): - device.cloud().set_notification_callback(self._async_update_notification) - elif device.has_ble(): - device.ble().set_notification_callback(self._async_update_notification) + if mower := self.manager.mower(self.device_name): + self.async_set_updated_data(mower) class MammotionMaintenanceUpdateCoordinator(MammotionBaseUpdateCoordinator[Maintain]): @@ -559,6 +458,9 @@ def __init__( update_interval=MAINTENANCE_INTERVAL, ) + def get_coordinator_data(self, device: MammotionMixedDeviceManager) -> Maintain: + return device.state.report_data.maintenance + async def _async_update_data(self) -> Maintain: """Get data from the device.""" if data := await super()._async_update_data(): @@ -568,26 +470,28 @@ async def _async_update_data(self) -> Maintain: await self.async_send_command("get_maintenance") except DeviceOfflineException as ex: + """Device is offline.""" if ex.iot_id == self.device.iotId: device = self.manager.get_device_by_name(self.device_name) await self.device_offline(device) - return device.mower_state.report_data.maintenance + return device.state except GatewayTimeoutException: - pass + """Gateway is timing out again.""" return self.manager.get_device_by_name( self.device.deviceName - ).mower_state.report_data.maintenance + ).state.report_data.maintenance async def _async_setup(self) -> None: - """Set up Mammotion maintenance coordinator.""" + """Setup maintenance coordinator.""" + await super()._async_setup() device = self.manager.get_device_by_name(self.device_name) if self.data is None: - self.data = device.mower_state.report_data.maintenance + self.data = device.state.report_data.maintenance class MammotionDeviceVersionUpdateCoordinator( - MammotionBaseUpdateCoordinator[MowerInfo] + MammotionBaseUpdateCoordinator[MowingDevice] ): """Class to manage fetching mammotion data.""" @@ -607,10 +511,28 @@ def __init__( update_interval=DEFAULT_INTERVAL, ) + def get_coordinator_data(self, device: MammotionMixedDeviceManager) -> MowingDevice: + return device.state + + async def _async_update_properties( + self, properties: ThingPropertiesMessage + ) -> None: + """Update data from incoming properties messages.""" + if ota_progress := properties.params.items.otaProgress: + ota_progress.value = OTAProgressItems.from_dict(ota_progress.value) + self.data.update_check.progress = ota_progress.value.progress + self.data.update_check.isupgrading = True + if ota_progress.value.progress == 100: + self.data.update_check.isupgrading = False + self.data.update_check.upgradeable = False + self.data.device_firmwares.device_version = ota_progress.value.version + self.async_set_updated_data(self.data) + async def _async_update_data(self): """Get data from the device.""" if data := await super()._async_update_data(): return data + device = self.manager.get_device_by_name(self.device_name) command_list = [ "get_device_version_main", "get_device_version_info", @@ -622,30 +544,50 @@ async def _async_update_data(self): await self.async_send_command(command) except DeviceOfflineException as ex: + """Device is offline bluetooth has been attempted.""" if ex.iot_id == self.device.iotId: - device = self.manager.get_device_by_name(self.device_name) await self.device_offline(device) - return device.mower_state.mower_state + return device.state except GatewayTimeoutException: - pass + """Gateway is timing out again.""" - data = self.manager.get_device_by_name(self.device_name).mower_state.mower_state + data = self.manager.get_device_by_name(self.device_name).state await self.check_firmware_version() - if data.model_id: + ota_info = await device.mammotion_http.get_device_ota_firmware([device.iot_id]) + if check_versions := ota_info.data: + for check_version in check_versions: + if check_version.device_id == device.iot_id: + device.state.update_check = check_version + + if data.mower_state.model_id != "": self.update_interval = DEVICE_VERSION_INTERVAL return data async def _async_setup(self) -> None: + """Setup device version coordinator.""" + await super()._async_setup() device = self.manager.get_device_by_name(self.device_name) if self.data is None: - self.data = device.mower_state.mower_state + self.data = device.state try: - await self.async_send_command("get_device_product_model") + if device.state.mower_state.model_id == "": + await self.async_send_command("get_device_product_model") + if device.state.mower_state.wifi_mac == "": + await self.async_send_command("get_device_network_info") + + ota_info = await device.mammotion_http.get_device_ota_firmware( + [device.iot_id] + ) + if check_versions := ota_info.data: + for check_version in check_versions: + if check_version.device_id == device.iot_id: + device.state.update_check = check_version + except DeviceOfflineException: - return + """Device is offline bluetooth has been attempted.""" class MammotionMapUpdateCoordinator(MammotionBaseUpdateCoordinator[MowerInfo]): @@ -667,6 +609,13 @@ def __init__( update_interval=MAP_INTERVAL, ) + def get_coordinator_data(self, device: MammotionMixedDeviceManager) -> MowerInfo: + return device.state.mower_state + + def _map_callback(self) -> None: + """Trigger a resync when the bol hash changes.""" + # TODO setup callback to get bol hash data + async def _async_update_data(self): """Get data from the device.""" if data := await super()._async_update_data(): @@ -675,35 +624,175 @@ async def _async_update_data(self): try: if ( - len(device.mower_state.map.hashlist) == 0 - or len(device.mower_state.map.missing_hashlist()) > 0 - or len(device.mower_state.map.plan) == 0 + round(device.state.location.RTK.latitude, 0) == 0 + or round(device.state.location.dock.latitude, 0) == 0 + ): + await self.async_rtk_dock_location() + + if ( + len(device.state.map.hashlist) == 0 + or len(device.state.map.missing_hashlist()) > 0 + or len(device.state.map.plan) == 0 ): await self.manager.start_map_sync(self.device_name) except DeviceOfflineException as ex: + """Device is offline try bluetooth if we have it.""" if ex.iot_id == self.device.iotId: await self.device_offline(device) - return device.mower_state.mower_state + return device.state.mower_state except GatewayTimeoutException: - pass + """Gateway is timing out again.""" - return self.manager.get_device_by_name(self.device_name).mower_state.mower_state + return self.manager.get_device_by_name(self.device_name).state.mower_state async def _async_setup(self) -> None: - """Set up coordinator with initial call to get map data.""" + """Setup coordinator with initial call to get map data.""" + await super()._async_setup() device = self.manager.get_device_by_name(self.device_name) if self.data is None: - self.data = device.mower_state.mower_state + self.data = device.state.mower_state - if not device.mower_state.enabled or not device.mower_state.online: + if not device.state.enabled or not device.state.online: return try: await self.async_rtk_dock_location() if not DeviceType.is_luba1(self.device_name): await self.async_get_area_list() except DeviceOfflineException as ex: + """Device is offline try bluetooth if we have it.""" if ex.iot_id == self.device.iotId: await self.device_offline(device) except GatewayTimeoutException: - return + """Gateway is timing out again.""" + + +class MammotionDeviceErrorUpdateCoordinator( + MammotionBaseUpdateCoordinator[MowingDevice] +): + """Class to manage fetching mammotion data.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: MammotionConfigEntry, + device: Device, + mammotion: Mammotion, + ) -> None: + """Initialize global mammotion data updater.""" + super().__init__( + hass=hass, + config_entry=config_entry, + device=device, + mammotion=mammotion, + update_interval=DEFAULT_INTERVAL, + ) + + def get_coordinator_data(self, device: MammotionMixedDeviceManager) -> MowingDevice: + return device.state + + async def _async_update_event_message(self, event: ThingEventMessage) -> None: + if event.params.identifier == "device_warning_code_event": + event: DeviceNotificationEventParams = event.params + # '[{"c":-2801,"ct":1,"ft":1731493734000},{"c":-1008,"ct":1,"ft":1731493734000}]' + try: + warning_event = json.loads(event.value.data) + LOGGER.debug("warning event %s", warning_event) + await self._async_update_data() + if mower := self.manager.mower(self.device_name): + self.async_set_updated_data(mower) + except json.JSONDecodeError: + """Failed to parse warning event.""" + + async def _async_update_notification(self, res: tuple[str, Any | None]) -> None: + """Update data from incoming notifications messages.""" + if res[0] == "sys" and res[1] is not None: + sys_msg = betterproto.which_one_of(res[1], "SubSysMsg") + if sys_msg[0] == "system_update_buf" and sys_msg[1] is not None: + buffer_list: SystemUpdateBufMsg = sys_msg[1] + if buffer_list.update_buf_data[0] == 2: + if mower := self.manager.mower(self.device_name): + self.async_set_updated_data(mower) + + def get_error_message(self, number: int) -> str: + """Return error message.""" + try: + error_code: int = next(iter(self.data.errors.err_code_list)) + error_time = next(iter(self.data.errors.err_code_list_time)) + + error_datetime = datetime.datetime.fromtimestamp(error_time, datetime.UTC) + current_time_utc = datetime.datetime.now(datetime.UTC) + + error_time_passed = current_time_utc - error_datetime + + if error_time_passed.total_seconds() > 3600 * 24: + return "" + + error_code = abs(error_code) + error_info: ErrorInfo = self.data.errors.error_codes.get( + f"{error_code}", None + ) + + implication = ( + getattr(error_info, f"{self.hass.config.language}_implication") + if hasattr(error_info, f"{self.hass.config.language}_implication") + else error_info.en_implication + ) + solution = ( + getattr(error_info, f"{self.hass.config.language}_solution") + if hasattr(error_info, f"{self.hass.config.language}_solution") + else error_info.en_solution + ) + + if implication == "": + implication = error_info.en_implication + + if solution == "": + solution = error_info.en_solution + + return f"{error_code} {error_info.module} {implication} {solution} {error_time_passed.total_seconds() / 60} minutes ago" + + except StopIteration: + """Failed to get error code.""" + return "" + + async def _async_update_data(self): + """Get data from the device.""" + if data := await super()._async_update_data(): + return data + device = self.manager.get_device_by_name(self.device_name) + + try: + await self.async_send_command("allpowerfull_rw", rw_id=5, rw=1, context=2) + await self.async_send_command("allpowerfull_rw", rw_id=5, rw=1, context=3) + if not device.state.errors.error_codes: + device.state.errors.error_codes = ( + await device.mammotion_http.get_all_error_codes() + ) + except DeviceOfflineException as ex: + """Device is offline bluetooth has been attempted.""" + if ex.iot_id == self.device.iotId: + await self.device_offline(device) + return device.state + except GatewayTimeoutException: + """Gateway is timing out again.""" + + return data + + async def _async_setup(self) -> None: + """Setup device version coordinator.""" + await super()._async_setup() + device = self.manager.get_device_by_name(self.device_name) + if self.data is None: + self.data = device.state + + try: + # get current errors + await self.async_send_command("allpowerfull_rw", rw_id=5, rw=1, context=2) + await self.async_send_command("allpowerfull_rw", rw_id=5, rw=1, context=3) + if not device.state.errors.error_codes: + device.state.errors.error_codes = ( + await device.mammotion_http.get_all_error_codes() + ) + except DeviceOfflineException: + """Device is offline bluetooth has been attempted.""" diff --git a/homeassistant/components/mammotion/entity.py b/homeassistant/components/mammotion/entity.py index 55c903e33fd23..22a01872a201e 100644 --- a/homeassistant/components/mammotion/entity.py +++ b/homeassistant/components/mammotion/entity.py @@ -1,9 +1,14 @@ """Base class for entities.""" -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device_registry import ( + CONNECTION_BLUETOOTH, + CONNECTION_NETWORK_MAC, + DeviceInfo, + format_mac, +) from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DEFAULT_RETRY_COUNT, DOMAIN +from .const import CONF_RETRY_COUNT, DEFAULT_RETRY_COUNT, DOMAIN from .coordinator import MammotionBaseUpdateCoordinator @@ -19,16 +24,20 @@ def __init__(self, coordinator: MammotionBaseUpdateCoordinator, key: str) -> Non @property def device_info(self) -> DeviceInfo: - """Return the device information.""" - mower = self.coordinator.data - swversion = mower.device_firmwares.device_version + mower = self.coordinator.manager.get_device_by_name( + self.coordinator.device_name + ) + swversion = mower.state.device_firmwares.device_version model_id = None if mower is not None: - if mower.mower_state.model_id != "": - model_id = mower.mower_state.model_id - if mower.mqtt_properties is not None: - model_id = mower.mqtt_properties.params.items.extMod.value + if mower.state.mower_state.model_id != "": + model_id = mower.state.mower_state.model_id + if ( + mower.state.mqtt_properties is not None + and mower.state.mqtt_properties.params.items.extMod is not None + ): + model_id = mower.state.mqtt_properties.params.items.extMod.value nick_name = self.coordinator.device.nickName device_name = ( @@ -37,6 +46,32 @@ def device_info(self) -> DeviceInfo: else self.coordinator.device.nickName ) + connections: set[tuple[str, str]] = set() + + if mower.ble(): + connections.add( + ( + CONNECTION_BLUETOOTH, + format_mac(mower.ble().ble_device.address), + ) + ) + + if mower.state.mower_state.wifi_mac != "": + connections.add( + ( + CONNECTION_NETWORK_MAC, + format_mac(mower.state.mower_state.wifi_mac), + ) + ) + + if mower.state.mower_state.ble_mac != "": + connections.add( + ( + CONNECTION_BLUETOOTH, + format_mac(mower.state.mower_state.ble_mac), + ) + ) + return DeviceInfo( identifiers={(DOMAIN, self.coordinator.device.deviceName)}, manufacturer="Mammotion", @@ -46,12 +81,16 @@ def device_info(self) -> DeviceInfo: sw_version=swversion, model=self.coordinator.device.productModel or model_id, suggested_area="Garden", + connections=connections, ) @property def available(self) -> bool: - """Return True if the entity is available.""" + """Return True if entity is available.""" return ( self.coordinator.data is not None - and self.coordinator.update_failures <= DEFAULT_RETRY_COUNT + and self.coordinator.update_failures + <= self.coordinator.config_entry.options.get( + CONF_RETRY_COUNT, DEFAULT_RETRY_COUNT + ) ) diff --git a/homeassistant/components/mammotion/models.py b/homeassistant/components/mammotion/models.py index ab6cf1029d92b..5acd3ca022e1c 100644 --- a/homeassistant/components/mammotion/models.py +++ b/homeassistant/components/mammotion/models.py @@ -1,3 +1,4 @@ +"""Models for the Mammotion integration.""" from dataclasses import dataclass from pymammotion.aliyun.model.dev_by_account_response import Device @@ -5,6 +6,7 @@ from pymammotion.mammotion.devices.mammotion import Mammotion from .coordinator import ( + MammotionDeviceErrorUpdateCoordinator, MammotionDeviceVersionUpdateCoordinator, MammotionMaintenanceUpdateCoordinator, MammotionMapUpdateCoordinator, @@ -22,6 +24,7 @@ class MammotionMowerData: reporting_coordinator: MammotionReportUpdateCoordinator version_coordinator: MammotionDeviceVersionUpdateCoordinator map_coordinator: MammotionMapUpdateCoordinator + error_coordinator: MammotionDeviceErrorUpdateCoordinator device_limits: DeviceLimits device: Device diff --git a/requirements_all.txt b/requirements_all.txt index 6314521b0a99d..4b4501c5faffe 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2334,7 +2334,7 @@ pylutron==0.4.1 pymailgunner==1.4 # homeassistant.components.mammotion -pymammotion==0.4.26 +pymammotion==0.5.9 # homeassistant.components.firmata pymata-express==1.19 From 21a53fcb569626a2f1dd5d5c70d9d0d654093d62 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Wed, 27 Aug 2025 22:28:43 +1200 Subject: [PATCH 27/66] more work towards readiness --- .../components/mammotion/__init__.py | 43 ++- .../components/mammotion/coordinator.py | 245 +++++++++--------- .../components/mammotion/manifest.json | 4 +- requirements_all.txt | 2 +- tests/components/mammotion/__init__.py | 1 + tests/components/mammotion/conftest.py | 126 +++++++++ 6 files changed, 273 insertions(+), 148 deletions(-) create mode 100644 tests/components/mammotion/__init__.py create mode 100644 tests/components/mammotion/conftest.py diff --git a/homeassistant/components/mammotion/__init__.py b/homeassistant/components/mammotion/__init__.py index 55e7a108dc453..078fe028eca8f 100644 --- a/homeassistant/components/mammotion/__init__.py +++ b/homeassistant/components/mammotion/__init__.py @@ -21,7 +21,6 @@ from Tea.exceptions import UnretryableException from homeassistant.components import bluetooth -from homeassistant.components.http import StaticPathConfig from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant @@ -35,7 +34,6 @@ CONF_BLE_DEVICES, CONF_CONNECT_DATA, CONF_DEVICE_DATA, - CONF_DEVICE_NAME, CONF_MAMMOTION_DATA, CONF_REGION_DATA, CONF_SESSION_DATA, @@ -44,7 +42,6 @@ DEVICE_SUPPORT, DOMAIN, EXPIRED_CREDENTIAL_EXCEPTIONS, - LOGGER, ) from .coordinator import ( MammotionDeviceErrorUpdateCoordinator, @@ -98,12 +95,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> cloud_client.set_http(mammotion_http) await mammotion.initiate_cloud_connection(account, cloud_client) except ClientConnectorError as err: - raise ConfigEntryNotReady(err) - except EXPIRED_CREDENTIAL_EXCEPTIONS as exc: - LOGGER.debug(exc) + raise ConfigEntryNotReady(err) from err + except EXPIRED_CREDENTIAL_EXCEPTIONS: await mammotion.login_and_initiate_cloud(account, password, True) except UnretryableException as err: - raise ConfigEntryError(err) + raise ConfigEntryError(err) from err if mqtt_client := mammotion.mqtt_list.get(account): store_cloud_credentials(hass, entry, mqtt_client.cloud_client) @@ -123,10 +119,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> hass, device_ble_address.upper(), True ) if ble_device: - mammotion_device.add_ble(ble_device) - mammotion_device.ble().set_disconnect_strategy( - not stay_connected_ble - ) + ble = mammotion_device.add_ble(ble_device) + ble.set_disconnect_strategy(disconnect=not stay_connected_ble) maintenance_coordinator = MammotionMaintenanceUpdateCoordinator( hass, entry, device, mammotion @@ -164,10 +158,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> if not use_wifi: mammotion_device.preference = ConnectionPreference.BLUETOOTH - await mammotion_device.cloud().stop() - mammotion_device.cloud().mqtt.disconnect() if mammotion_device.cloud().mqtt.is_connected() else None - # not entirely sure this is a good idea - mammotion_device.remove_cloud() + if cloud := mammotion_device.cloud(): + await cloud.stop() + cloud.mqtt.disconnect() if cloud.mqtt.is_connected() else None + mammotion_device.remove_cloud() mammotion_devices.append( MammotionMowerData( @@ -182,10 +176,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> error_coordinator=error_coordinator, ) ) - try: - await map_coordinator.async_request_refresh() - except: - """Do nothing for now.""" + await map_coordinator.async_request_refresh() entry.runtime_data = mammotion_devices await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -223,13 +214,13 @@ async def check_and_restore_cloud( ) -> CloudIOTGateway | None: """Check and restore previous cloud connection.""" - auth_data = entry.data.get(CONF_AUTH_DATA) - region_data = entry.data.get(CONF_REGION_DATA) - aep_data = entry.data.get(CONF_AEP_DATA) - session_data = entry.data.get(CONF_SESSION_DATA) - device_data = entry.data.get(CONF_DEVICE_DATA) - connect_data = entry.data.get(CONF_CONNECT_DATA) - mammotion_data = entry.data.get(CONF_MAMMOTION_DATA) + auth_data = entry.data[CONF_AUTH_DATA] + region_data = entry.data[CONF_REGION_DATA] + aep_data = entry.data[CONF_AEP_DATA] + session_data = entry.data[CONF_SESSION_DATA] + device_data = entry.data[CONF_DEVICE_DATA] + connect_data = entry.data[CONF_CONNECT_DATA] + mammotion_data = entry.data[CONF_MAMMOTION_DATA] if any( data is None diff --git a/homeassistant/components/mammotion/coordinator.py b/homeassistant/components/mammotion/coordinator.py index e688cd65cf366..33592dffa3cc0 100644 --- a/homeassistant/components/mammotion/coordinator.py +++ b/homeassistant/components/mammotion/coordinator.py @@ -22,7 +22,7 @@ from pymammotion.aliyun.model.dev_by_account_response import Device from pymammotion.data.model.device import MowerInfo, MowingDevice from pymammotion.data.model.report_info import Maintain -from pymammotion.data.mqtt.event import DeviceNotificationEventParams, ThingEventMessage +from pymammotion.data.mqtt.event import ThingEventMessage from pymammotion.data.mqtt.properties import OTAProgressItems, ThingPropertiesMessage from pymammotion.data.mqtt.status import ThingStatusMessage from pymammotion.http.model.http import ErrorInfo @@ -88,12 +88,14 @@ def __init__( logger=LOGGER, name=DOMAIN, update_interval=update_interval, + config_entry=config_entry, ) assert config_entry.unique_id - self.config_entry = config_entry self.device: Device = device self.device_name = device.deviceName self.manager: Mammotion = mammotion + self.account = config_entry.data[CONF_ACCOUNTNAME] + self.password = config_entry.data[CONF_PASSWORD] self.update_failures = 0 @abstractmethod @@ -102,15 +104,15 @@ def get_coordinator_data(self, device: MammotionMixedDeviceManager) -> _DataT: async def async_refresh_login(self) -> None: """Refresh login credentials asynchronously.""" - account = self.config_entry.data.get(CONF_ACCOUNTNAME) - password = self.config_entry.data.get(CONF_PASSWORD) - await self.manager.refresh_login(account, password) + + await self.manager.refresh_login(self.account, self.password) self.store_cloud_credentials() async def device_offline(self, device: MammotionMixedDeviceManager) -> None: + """Device is set to offline.""" device.state.online = False - if device.has_cloud(): - await device.cloud().stop() + if cloud := device.cloud(): + await cloud.stop() loop = asyncio.get_running_loop() loop.call_later(900, lambda: asyncio.create_task(self.clear_update_failures())) @@ -118,25 +120,26 @@ async def device_offline(self, device: MammotionMixedDeviceManager) -> None: def store_cloud_credentials(self) -> None: """Store cloud credentials in config entry.""" # config_updates = {} - mammotion_cloud = self.manager.mqtt_list.get( - self.config_entry.data.get(CONF_ACCOUNTNAME, "") - ) - cloud_client = mammotion_cloud.cloud_client if mammotion_cloud else None - - if cloud_client is not None: - config_updates = { - **self.config_entry.data, - CONF_CONNECT_DATA: cloud_client.connect_response, - CONF_AUTH_DATA: cloud_client.login_by_oauth_response, - CONF_REGION_DATA: cloud_client.region_response, - CONF_AEP_DATA: cloud_client.aep_response, - CONF_SESSION_DATA: cloud_client.session_by_authcode_response, - CONF_DEVICE_DATA: cloud_client.devices_by_account_response, - CONF_MAMMOTION_DATA: cloud_client.mammotion_http.response, - } - self.hass.config_entries.async_update_entry( - self.config_entry, data=config_updates + if config_entry := self.config_entry: + mammotion_cloud = self.manager.mqtt_list.get( + config_entry.data.get(CONF_ACCOUNTNAME, "") ) + cloud_client = mammotion_cloud.cloud_client if mammotion_cloud else None + + if cloud_client is not None: + config_updates = { + **config_entry.data, + CONF_CONNECT_DATA: cloud_client.connect_response, + CONF_AUTH_DATA: cloud_client.login_by_oauth_response, + CONF_REGION_DATA: cloud_client.region_response, + CONF_AEP_DATA: cloud_client.aep_response, + CONF_SESSION_DATA: cloud_client.session_by_authcode_response, + CONF_DEVICE_DATA: cloud_client.devices_by_account_response, + CONF_MAMMOTION_DATA: cloud_client.mammotion_http.response, + } + self.hass.config_entries.async_update_entry( + config_entry, data=config_updates + ) async def async_send_command(self, command: str, **kwargs: Any) -> bool | None: """Send command.""" @@ -169,14 +172,11 @@ async def async_send_command(self, command: str, **kwargs: Any) -> bool | None: except (DeviceOfflineException, NoConnectionException) as ex: """Device is offline try bluetooth if we have it.""" try: - if device.has_ble(): + if ble := device.ble(): # if we don't do this it will stay connected and no longer update over wifi - device.ble().set_disconnect_strategy(True) - await ( - self.manager.get_device_by_name(self.device_name) - .ble() - .queue_command(command, **kwargs) - ) + ble.set_disconnect_strategy(disconnect=True) + await ble.queue_command(command, **kwargs) + return True raise DeviceOfflineException(ex.args[0], self.device.iotId) except COMMAND_EXCEPTIONS as exc: @@ -226,54 +226,37 @@ async def async_request_iot_sync(self, stop: bool = False) -> None: count=0, ) + async def async_rtk_dock_location(self) -> None: + """RTK and dock location.""" + await self.async_send_command("allpowerfull_rw", rw_id=5, rw=1, context=1) + + async def async_get_area_list(self) -> None: + """Mowing area List.""" + await self.async_send_command("get_area_name_list", device_id=self.device.iotId) + async def clear_update_failures(self) -> None: + """Clear update failures and start cloud connection.""" self.update_failures = 0 device = self.manager.get_device_by_name(self.device_name) if not device.state.online: device.state.online = True - if device.has_cloud() and device.cloud().stopped: - await device.cloud().start() - - async def async_restore_data(self) -> None: - """Restore saved data.""" - store = MammotionConfigStore(self.hass, version=1, minor_version=1, key=DOMAIN) - restored_data: Mapping[str, Any] | None = await store.async_load() - - try: - if mower_data := restored_data.get(self.device_name): - mower_state = MowingDevice().from_dict(mower_data) - if device := self.manager.get_device_by_name(self.device_name): - device.state = mower_state - except InvalidFieldValue: - """invalid""" - self.data = MowingDevice() - self.manager.get_device_by_name(self.device_name).state = self.data + if cloud := device.cloud(): + if cloud.stopped: + await cloud.start() - async def async_save_data(self, data: MowingDevice) -> None: - """Get map data from the device.""" - store = MammotionConfigStore(self.hass, version=1, minor_version=1, key=DOMAIN) - current_store = await store.async_load() - current_store[self.device_name] = data.to_dict() - await store.async_save(current_store) - - async def _async_update_data(self) -> _DataT | None: + async def async_pre_update_data(self) -> _DataT | None: if device := self.manager.get_device_by_name(self.device_name): if not device.state.enabled or ( not device.state.online and device.preference is ConnectionPreference.WIFI ): - if ( - not device.state.enabled - and device.has_cloud() - and device.cloud().mqtt.is_connected() - ): - device.cloud().mqtt.disconnect() - if not device.state.enabled and device.has_ble(): - if ( - device.ble().client is not None - and device.ble().client.is_connected - ): - await device.ble().client.disconnect() + if cloud := device.cloud(): + if not device.state.enabled and cloud.mqtt.is_connected(): + cloud.mqtt.disconnect() + if ble := device.ble(): + if not device.state.enabled: + if ble.client is not None and ble.client.is_connected: + await ble.client.disconnect() return self.get_coordinator_data(device) if ( @@ -283,10 +266,10 @@ async def _async_update_data(self) -> _DataT | None: if ble_device := bluetooth.async_ble_device_from_address( self.hass, device.state.mower_state.ble_mac.upper(), True ): - if not device.has_ble(): - device.add_ble(ble_device) + if ble := device.ble(): + ble.update_device(ble_device) else: - device.ble().update_device(ble_device) + device.add_ble(ble_device) # don't query the mower while users are doing map changes or its updating. if device.state.report_data.dev.sys_status in NO_REQUEST_MODES: @@ -334,10 +317,10 @@ async def _async_setup(self) -> None: if self.data is None: self.data = device.state - if device.has_cloud(): - device.cloud().set_notification_callback(self._async_update_notification) - elif device.has_ble(): - device.ble().set_notification_callback(self._async_update_notification) + if cloud := device.cloud(): + cloud.set_notification_callback(self._async_update_notification) + elif ble := device.ble(): + ble.set_notification_callback(self._async_update_notification) device.state_manager.properties_callback.add_subscribers( self._async_update_properties @@ -350,6 +333,7 @@ async def _async_setup(self) -> None: class MammotionReportUpdateCoordinator(MammotionBaseUpdateCoordinator[MowingDevice]): + """Class to manage fetching mammotion report data.""" def __init__( self, hass: HomeAssistant, @@ -357,7 +341,7 @@ def __init__( device: Device, mammotion: Mammotion, ) -> None: - """Initialize global mammotion data updater.""" + """Initialize mammotion data updater.""" super().__init__( hass=hass, config_entry=config_entry, @@ -367,11 +351,39 @@ def __init__( ) def get_coordinator_data(self, device: MammotionMixedDeviceManager) -> MowingDevice: + """Get device state for the coordinator.""" return device.state + async def async_restore_data(self) -> None: + """Restore saved data.""" + store = MammotionConfigStore(self.hass, version=1, minor_version=1, key=DOMAIN) + restored_data: Mapping[str, Any] | None = await store.async_load() + + if restored_data is None: + self.data = MowingDevice() + self.manager.get_device_by_name(self.device_name).state = self.data + return + + try: + if mower_data := restored_data.get(self.device_name): + mower_state = MowingDevice().from_dict(mower_data) + if device := self.manager.get_device_by_name(self.device_name): + device.state = mower_state + except InvalidFieldValue: + """invalid""" + self.data = MowingDevice() + self.manager.get_device_by_name(self.device_name).state = self.data + + async def async_save_data(self, data: MowingDevice) -> None: + """Get map data from the device.""" + store = MammotionConfigStore(self.hass, version=1, minor_version=1, key=DOMAIN) + current_store = await store.async_load() + current_store[self.device_name] = data.to_dict() + await store.async_save(current_store) + async def _async_update_data(self) -> MowingDevice: """Get data from the device.""" - if data := await super()._async_update_data(): + if data := await super().async_pre_update_data(): return data device = self.manager.get_device_by_name(self.device_name) @@ -381,10 +393,10 @@ async def _async_update_data(self) -> MowingDevice: try: last_sent_time = 0 - if device.cloud(): - last_sent_time = device.cloud().command_sent_time - elif device.ble(): - last_sent_time = device.ble().command_sent_time + if cloud := device.cloud(): + last_sent_time = cloud.command_sent_time + elif ble := device.ble(): + last_sent_time = ble.command_sent_time if ( self.update_interval @@ -399,22 +411,6 @@ async def _async_update_data(self) -> MowingDevice: await self.device_offline(device) return device.state - LOGGER.debug("Updated Mammotion device %s", self.device_name) - LOGGER.debug("================= Debug Log =================") - if device.preference is ConnectionPreference.BLUETOOTH: - if device.ble(): - LOGGER.debug( - "Mammotion device data: %s", - device.ble()._raw_data, - ) - if device.preference is ConnectionPreference.WIFI: - if device.cloud(): - LOGGER.debug( - "Mammotion device data: %s", - device.cloud()._raw_data, - ) - LOGGER.debug("==================================") - self.update_failures = 0 data = self.manager.get_device_by_name(self.device_name).state await self.async_save_data(data) @@ -459,11 +455,12 @@ def __init__( ) def get_coordinator_data(self, device: MammotionMixedDeviceManager) -> Maintain: + """Get device state for the coordinator.""" return device.state.report_data.maintenance async def _async_update_data(self) -> Maintain: """Get data from the device.""" - if data := await super()._async_update_data(): + if data := await super().async_pre_update_data(): return data try: @@ -512,6 +509,7 @@ def __init__( ) def get_coordinator_data(self, device: MammotionMixedDeviceManager) -> MowingDevice: + """Get device state for the coordinator.""" return device.state async def _async_update_properties( @@ -530,7 +528,7 @@ async def _async_update_properties( async def _async_update_data(self): """Get data from the device.""" - if data := await super()._async_update_data(): + if data := await super().async_pre_update_data(): return data device = self.manager.get_device_by_name(self.device_name) command_list = [ @@ -618,7 +616,7 @@ def _map_callback(self) -> None: async def _async_update_data(self): """Get data from the device.""" - if data := await super()._async_update_data(): + if data := await super().async_pre_update_data(): return data device = self.manager.get_device_by_name(self.device_name) @@ -689,14 +687,18 @@ def __init__( ) def get_coordinator_data(self, device: MammotionMixedDeviceManager) -> MowingDevice: + """Get device state for the coordinator.""" return device.state async def _async_update_event_message(self, event: ThingEventMessage) -> None: - if event.params.identifier == "device_warning_code_event": - event: DeviceNotificationEventParams = event.params + if ( + hasattr(event.params, "identifier") + and event.params.identifier == "device_warning_code_event" + ): + event_params = event.params # '[{"c":-2801,"ct":1,"ft":1731493734000},{"c":-1008,"ct":1,"ft":1731493734000}]' try: - warning_event = json.loads(event.value.data) + warning_event = json.loads(event_params.value.data) LOGGER.debug("warning event %s", warning_event) await self._async_update_data() if mower := self.manager.mower(self.device_name): @@ -714,24 +716,29 @@ async def _async_update_notification(self, res: tuple[str, Any | None]) -> None: if mower := self.manager.mower(self.device_name): self.async_set_updated_data(mower) + def get_error_code(self, number: int) -> int: + """Get error code from an error code list.""" + try: + return abs(next(iter(self.data.errors.err_code_list), None)) + except StopIteration: + return 0 + + def get_error_time(self, number: int) -> datetime.datetime | None: + """Get error time from an error code list.""" + try: + return datetime.datetime.fromtimestamp( + next(iter(self.data.errors.err_code_list_time), None), datetime.UTC + ) + except StopIteration: + return None + def get_error_message(self, number: int) -> str: """Return error message.""" try: error_code: int = next(iter(self.data.errors.err_code_list)) - error_time = next(iter(self.data.errors.err_code_list_time)) - - error_datetime = datetime.datetime.fromtimestamp(error_time, datetime.UTC) - current_time_utc = datetime.datetime.now(datetime.UTC) - - error_time_passed = current_time_utc - error_datetime - - if error_time_passed.total_seconds() > 3600 * 24: - return "" error_code = abs(error_code) - error_info: ErrorInfo = self.data.errors.error_codes.get( - f"{error_code}", None - ) + error_info: ErrorInfo = self.data.errors.error_codes[f"{error_code}"] implication = ( getattr(error_info, f"{self.hass.config.language}_implication") @@ -750,15 +757,15 @@ def get_error_message(self, number: int) -> str: if solution == "": solution = error_info.en_solution - return f"{error_code} {error_info.module} {implication} {solution} {error_time_passed.total_seconds() / 60} minutes ago" + return f"{error_info.module}: {implication}, {solution}" except StopIteration: """Failed to get error code.""" - return "" + return "No Error" async def _async_update_data(self): """Get data from the device.""" - if data := await super()._async_update_data(): + if data := await super().async_pre_update_data(): return data device = self.manager.get_device_by_name(self.device_name) diff --git a/homeassistant/components/mammotion/manifest.json b/homeassistant/components/mammotion/manifest.json index 5413ae7c3e052..f1348d16de1bb 100644 --- a/homeassistant/components/mammotion/manifest.json +++ b/homeassistant/components/mammotion/manifest.json @@ -15,10 +15,10 @@ ], "codeowners": ["@mikey0000"], "config_flow": true, - "dependencies": ["bluetooth_adapters"], + "dependencies": [], "documentation": "https://www.home-assistant.io/integrations/mammotion", "integration_type": "device", "iot_class": "local_push", "loggers": ["pymammotion"], - "requirements": ["pymammotion==0.4.26"] + "requirements": ["pymammotion==0.5.10"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4b4501c5faffe..4c72dd99b769e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2334,7 +2334,7 @@ pylutron==0.4.1 pymailgunner==1.4 # homeassistant.components.mammotion -pymammotion==0.5.9 +pymammotion==0.5.10 # 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 0000000000000..ce85aecb94c2c --- /dev/null +++ b/tests/components/mammotion/__init__.py @@ -0,0 +1 @@ +"""Tests for the Mammotion integration.""" diff --git a/tests/components/mammotion/conftest.py b/tests/components/mammotion/conftest.py new file mode 100644 index 0000000000000..dd0e4ac5fc0ed --- /dev/null +++ b/tests/components/mammotion/conftest.py @@ -0,0 +1,126 @@ +"""Fixtures for Mammotion tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +from bleak.backends.device import BLEDevice +from habluetooth.models import BluetoothServiceInfoBleak +import pytest + +from homeassistant.components.mammotion.const import CONF_ACCOUNTNAME, DOMAIN +from homeassistant.const import CONF_ADDRESS, CONF_PASSWORD + +from tests.common import MockConfigEntry +from tests.components.bluetooth import generate_advertisement_data, generate_ble_device + +DEFAULT_NAME = "Luba-ABC123" +MAMMOTION_SERVICE_INFO = BluetoothServiceInfoBleak( + name="Luba-ABC123", + address="AA:BB:CC:DD:EE:FF", + device=generate_ble_device( + address="AA:BB:CC:DD:EE:FF", + name="Luba-ABC123", + ), + rssi=-61, + manufacturer_data={}, + service_data={}, + service_uuids=["0000ffff-0000-1000-8000-00805f9b34fb"], + source="local", + advertisement=generate_advertisement_data( + manufacturer_data={}, + service_uuids=["0000ffff-0000-1000-8000-00805f9b34fb"], + ), + connectable=True, + time=0, + tx_power=None, +) + + +@pytest.fixture(autouse=True) +def mock_bluetooth(enable_bluetooth: None) -> None: + """Auto mock bluetooth.""" + + +@pytest.fixture +def mock_setup_entry(): + """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(name="discovery") +def mock_async_discovered_service_info() -> Generator[MagicMock]: + """Mock service discovery.""" + with patch( + "homeassistant.components.mammotion.config_flow.async_discovered_service_info", + return_value=[MAMMOTION_SERVICE_INFO], + ) as discovery: + yield discovery + + +@pytest.fixture(name="ble_device") +def mock_ble_device() -> Generator[MagicMock]: + """Mock BLEDevice.""" + with patch( + "homeassistant.components.bluetooth.async_ble_device_from_address", + return_value=BLEDevice( + address="AA:BB:CC:DD:EE:FF", name=DEFAULT_NAME, details={} + ), + ) as ble_device: + yield ble_device + + +@pytest.fixture +def mock_cloud_gateway(): + """Mock a CloudIOTGateway.""" + mock_cloud = Mock() + mock_cloud.mammotion_http = Mock() + mock_cloud.mammotion_http.login_info = Mock() + mock_cloud.mammotion_http.login_info.userInformation = Mock() + mock_cloud.mammotion_http.login_info.userInformation.userAccount = "user123" + return mock_cloud + + +@pytest.fixture +def mock_http_response(): + """Mock a successful HTTP login response.""" + mock_response = Mock() + mock_response.login_info = Mock() + mock_response.login_info.userInformation = Mock() + mock_response.login_info.userInformation.userAccount = "user123" + return mock_response + + +@pytest.fixture +def mock_mammotion(): + """Mock Mammotion class.""" + mock = AsyncMock() + mock.mqtt_list = {} + mock.login_and_initiate_cloud = AsyncMock() + return mock + + +@pytest.fixture +def mock_config_entry(): + """Return a mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ACCOUNTNAME: "user@example.com", + CONF_PASSWORD: "password", + CONF_ADDRESS: "AA:BB:CC:DD:EE:FF", + }, + unique_id="user123", + ) + + +@pytest.fixture +def mock_mower_coordinator(): + """Return a mocked mower coordinator.""" + coordinator = AsyncMock() + coordinator.data = Mock() + coordinator.data.report_data = Mock() + coordinator.data.report_data.dev = Mock() + return coordinator From a995a2842e7536357cbb007ab1bd36c5d9d75df5 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Thu, 28 Aug 2025 16:09:04 +1200 Subject: [PATCH 28/66] update dependency and re-add bluetooth dependency --- homeassistant/components/mammotion/manifest.json | 4 ++-- requirements_all.txt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mammotion/manifest.json b/homeassistant/components/mammotion/manifest.json index f1348d16de1bb..031c08456757d 100644 --- a/homeassistant/components/mammotion/manifest.json +++ b/homeassistant/components/mammotion/manifest.json @@ -15,10 +15,10 @@ ], "codeowners": ["@mikey0000"], "config_flow": true, - "dependencies": [], + "dependencies": ["bluetooth"], "documentation": "https://www.home-assistant.io/integrations/mammotion", "integration_type": "device", "iot_class": "local_push", "loggers": ["pymammotion"], - "requirements": ["pymammotion==0.5.10"] + "requirements": ["pymammotion==0.5.11"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4c72dd99b769e..ff1bb35c7c409 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2334,7 +2334,7 @@ pylutron==0.4.1 pymailgunner==1.4 # homeassistant.components.mammotion -pymammotion==0.5.10 +pymammotion==0.5.11 # homeassistant.components.firmata pymata-express==1.19 From e432c80250165d0837e0d770a6153dda7f8f0738 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Thu, 20 Nov 2025 10:13:09 +1300 Subject: [PATCH 29/66] update to the new library, remove all other coordinators, fix tests --- .../components/mammotion/__init__.py | 183 ++--- homeassistant/components/mammotion/config.py | 14 +- .../components/mammotion/config_flow.py | 111 ++- homeassistant/components/mammotion/const.py | 7 +- .../components/mammotion/coordinator.py | 696 +----------------- homeassistant/components/mammotion/entity.py | 40 +- .../components/mammotion/lawn_mower.py | 25 +- .../components/mammotion/manifest.json | 2 +- homeassistant/components/mammotion/models.py | 17 +- .../components/mammotion/strings.json | 8 +- requirements_all.txt | 2 +- tests/components/mammotion/__init__.py | 22 + tests/components/mammotion/conftest.py | 58 +- .../components/mammotion/test_config_flow.py | 365 +++++++++ tests/components/mammotion/test_lawn_mower.py | 309 ++++++++ 15 files changed, 932 insertions(+), 927 deletions(-) create mode 100644 tests/components/mammotion/test_config_flow.py create mode 100644 tests/components/mammotion/test_lawn_mower.py diff --git a/homeassistant/components/mammotion/__init__.py b/homeassistant/components/mammotion/__init__.py index 078fe028eca8f..a1242352988aa 100644 --- a/homeassistant/components/mammotion/__init__.py +++ b/homeassistant/components/mammotion/__init__.py @@ -6,13 +6,17 @@ from pymammotion import CloudIOTGateway from pymammotion.aliyun.model.aep_response import AepResponse from pymammotion.aliyun.model.connect_response import ConnectResponse -from pymammotion.aliyun.model.dev_by_account_response import ListingDevByAccountResponse +from pymammotion.aliyun.model.dev_by_account_response import ( + Device, + ListingDevAccountResponse, +) from pymammotion.aliyun.model.login_by_oauth_response import LoginByOAuthResponse from pymammotion.aliyun.model.regions_response import RegionResponse from pymammotion.aliyun.model.session_by_authcode_response import ( SessionByAuthCodeResponse, ) from pymammotion.data.model.account import Credentials +from pymammotion.homeassistant import HomeAssistantMowerApi from pymammotion.http.http import MammotionHTTP from pymammotion.http.model.http import LoginResponseData, Response from pymammotion.http.model.response_factory import response_factory @@ -27,6 +31,7 @@ from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from homeassistant.helpers.device_registry import DeviceEntry +from .config import MammotionConfigStore from .const import ( CONF_ACCOUNTNAME, CONF_AEP_DATA, @@ -43,18 +48,12 @@ DOMAIN, EXPIRED_CREDENTIAL_EXCEPTIONS, ) -from .coordinator import ( - MammotionDeviceErrorUpdateCoordinator, - MammotionDeviceVersionUpdateCoordinator, - MammotionMaintenanceUpdateCoordinator, - MammotionMapUpdateCoordinator, - MammotionReportUpdateCoordinator, -) -from .models import MammotionMowerData +from .coordinator import MammotionReportUpdateCoordinator +from .models import MammotionDevices, MammotionMowerData PLATFORMS: list[Platform] = [Platform.LAWN_MOWER] -type MammotionConfigEntry = ConfigEntry[list[MammotionMowerData]] +type MammotionConfigEntry = ConfigEntry[MammotionDevices] async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> bool: @@ -77,14 +76,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> use_wifi = entry.data.get(CONF_USE_WIFI, True) - mammotion_devices: list[MammotionMowerData] = [] + mammotion_mowers: list[MammotionMowerData] = [] + mammotion_devices: MammotionDevices = MammotionDevices([]) + cloud_client: CloudIOTGateway | None = None if account and password: credentials = Credentials() credentials.email = account credentials.password = password try: - cloud_client = await check_and_restore_cloud(hass, entry) + try: + cloud_client = await check_and_restore_cloud(hass, entry) + except KeyError: + """No entry found""" if cloud_client is None: await mammotion.login_and_initiate_cloud(account, password) else: @@ -101,83 +105,87 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> except UnretryableException as err: raise ConfigEntryError(err) from err - if mqtt_client := mammotion.mqtt_list.get(account): - store_cloud_credentials(hass, entry, mqtt_client.cloud_client) - for ( - device - ) in mqtt_client.cloud_client.devices_by_account_response.data.data: - if not device.deviceName.startswith(DEVICE_SUPPORT): - continue + aliyun_mqtt_client = mammotion.mqtt_list.get(f"{account}_aliyun") + mammotion_mqtt_client = mammotion.mqtt_list.get(f"{account}_mammotion") + if aliyun_mqtt_client or mammotion_mqtt_client: + if aliyun_mqtt_client: + mqtt_client = aliyun_mqtt_client + store_cloud_credentials(hass, entry, mqtt_client.cloud_client) + else: + mqtt_client = mammotion_mqtt_client + store_cloud_credentials(hass, entry, mqtt_client.cloud_client) + + device_list: list[Device] = [] + shimed_cloud_devices = [] + cloud_devices = [] + + if mammotion_mqtt_client: + shimed_cloud_devices = mammotion.shim_cloud_devices( + mammotion_mqtt_client.cloud_client.mammotion_http.device_records.records + ) + device_list.extend(shimed_cloud_devices) + if aliyun_mqtt_client: + cloud_devices = ( + aliyun_mqtt_client.cloud_client.devices_by_account_response.data.data + ) + device_list.extend(cloud_devices) + + for device in device_list: + if not device.device_name.startswith(DEVICE_SUPPORT): + continue + + if device in shimed_cloud_devices: mammotion_device = mammotion.get_or_create_device_by_name( - device, mqtt_client - ) - - if device_ble_address := addresses.get(device.deviceName, None): - mammotion_device.state.mower_state.ble_mac = device_ble_address - ble_device = bluetooth.async_ble_device_from_address( - hass, device_ble_address.upper(), True - ) - if ble_device: - ble = mammotion_device.add_ble(ble_device) - ble.set_disconnect_strategy(disconnect=not stay_connected_ble) - - maintenance_coordinator = MammotionMaintenanceUpdateCoordinator( - hass, entry, device, mammotion - ) - version_coordinator = MammotionDeviceVersionUpdateCoordinator( - hass, entry, device, mammotion + device, mammotion_mqtt_client, None ) - report_coordinator = MammotionReportUpdateCoordinator( - hass, entry, device, mammotion - ) - map_coordinator = MammotionMapUpdateCoordinator( - hass, entry, device, mammotion + elif device in cloud_devices: + mammotion_device = mammotion.get_or_create_device_by_name( + device, aliyun_mqtt_client, None ) - error_coordinator = MammotionDeviceErrorUpdateCoordinator( - hass, entry, device, mammotion + else: + mammotion_device = mammotion.get_or_create_device_by_name( + device, None, None ) - await report_coordinator.async_restore_data() - # other coordinators - await maintenance_coordinator.async_config_entry_first_refresh() - await version_coordinator.async_config_entry_first_refresh() - await report_coordinator.async_config_entry_first_refresh() - await error_coordinator.async_config_entry_first_refresh() - - device_config = DeviceConfig() - device_limits = device_config.get_working_parameters( - version_coordinator.data.mower_state.sub_model_id + + if device_ble_address := addresses.get(device.device_name, None): + mammotion_device.state.mower_state.ble_mac = device_ble_address + ble_device = bluetooth.async_ble_device_from_address( + hass, device_ble_address.upper(), True ) - if device_limits is None: - device_limits = device_config.get_working_parameters( - device.productKey - ) - - if device_limits is None: - device_limits = device_config.get_best_default(device.productKey) - - if not use_wifi: - mammotion_device.preference = ConnectionPreference.BLUETOOTH - if cloud := mammotion_device.cloud(): - await cloud.stop() - cloud.mqtt.disconnect() if cloud.mqtt.is_connected() else None - mammotion_device.remove_cloud() - - mammotion_devices.append( - MammotionMowerData( - name=device.deviceName, - device=device, - device_limits=device_limits, - api=mammotion, - maintenance_coordinator=maintenance_coordinator, - reporting_coordinator=report_coordinator, - version_coordinator=version_coordinator, - map_coordinator=map_coordinator, - error_coordinator=error_coordinator, - ) + if ble_device: + ble = mammotion_device.add_ble(ble_device) + ble.set_disconnect_strategy(disconnect=not stay_connected_ble) + + api = HomeAssistantMowerApi() + + report_coordinator = MammotionReportUpdateCoordinator( + hass, entry, device, api + ) + + await report_coordinator.async_restore_data() + + device_config = DeviceConfig() + device_limits = device_config.get_best_default(device.product_key) + + if not use_wifi: + mammotion_device.preference = ConnectionPreference.BLUETOOTH + if cloud := mammotion_device.cloud: + await cloud.stop() + cloud.mqtt.disconnect() if cloud.mqtt.is_connected() else None + mammotion_device.remove_cloud() + + mammotion_mowers.append( + MammotionMowerData( + name=device.device_name, + api=api, + reporting_coordinator=report_coordinator, + device_limits=device_limits, + device=device, ) - await map_coordinator.async_request_refresh() + ) + mammotion_devices.mowers = mammotion_mowers entry.runtime_data = mammotion_devices await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -262,7 +270,7 @@ async def check_and_restore_cloud( session_by_authcode_response=SessionByAuthCodeResponse.from_dict(session_data) if isinstance(session_data, dict) else session_data, - dev_by_account=ListingDevByAccountResponse.from_dict(device_data) + dev_by_account=ListingDevAccountResponse.from_dict(device_data) if isinstance(device_data, dict) else device_data, login_by_oauth_response=LoginByOAuthResponse.from_dict(auth_data) @@ -286,14 +294,21 @@ async def async_unload_entry(hass: HomeAssistant, entry: MammotionConfigEntry) - """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - for mower in entry.runtime_data: + for mower in entry.runtime_data.mowers: try: - await mower.api.remove_device(mower.name) + await mower.api.mammotion.remove_device(mower.name) except TimeoutError: """Do nothing as this sometimes occurs with disconnecting BLE.""" return unload_ok +async def async_remove_config_entry(hass: HomeAssistant, entry: MammotionConfigEntry): + """Remove a config entry.""" + await hass.config_entries.async_remove(entry.entry_id) + store = MammotionConfigStore(hass) + await store.async_remove() + + async def async_remove_config_entry_device( hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry ) -> bool: diff --git a/homeassistant/components/mammotion/config.py b/homeassistant/components/mammotion/config.py index af3de7a4fa258..d239a7b0c429e 100644 --- a/homeassistant/components/mammotion/config.py +++ b/homeassistant/components/mammotion/config.py @@ -1,3 +1,5 @@ +"""Config storage for Mammotion integration.""" + from homeassistant.helpers.storage import Store from .const import DOMAIN @@ -7,5 +9,15 @@ class MammotionConfigStore(Store): """A configuration store for Alexa.""" _STORAGE_VERSION = 1 - _STORAGE_MINOR_VERSION = 1 + _STORAGE_MINOR_VERSION = 0 _STORAGE_KEY = DOMAIN + + def __init__( + self, + hass, + version: int = _STORAGE_VERSION, + minor_version: int = _STORAGE_MINOR_VERSION, + key: str = _STORAGE_KEY, + ): + """Initialize the configuration store.""" + super().__init__(hass, version=version, minor_version=minor_version, key=key) diff --git a/homeassistant/components/mammotion/config_flow.py b/homeassistant/components/mammotion/config_flow.py index 7486a8cbd5eec..52e2b80bfa34d 100644 --- a/homeassistant/components/mammotion/config_flow.py +++ b/homeassistant/components/mammotion/config_flow.py @@ -1,4 +1,4 @@ -"""Config flow for Mammotion Luba.""" +"""Config flow for Mammotion.""" from typing import TYPE_CHECKING, Any @@ -6,7 +6,6 @@ from bleak.backends.device import BLEDevice from pymammotion.aliyun.cloud_gateway import CloudIOTGateway from pymammotion.http.http import MammotionHTTP -from pymammotion.mammotion.devices.mammotion import Mammotion import voluptuous as vol from homeassistant import config_entries @@ -50,7 +49,9 @@ def __init__(self) -> None: self._discovered_device: BLEDevice | None = None self._discovered_devices: dict[str, str] = {} - async def check_and_update_bluetooth_device(self, device: BLEDevice) -> ConfigEntry: + 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.""" device_registry = dr.async_get(self.hass) current_entries = self.hass.config_entries.async_entries(DOMAIN) @@ -69,7 +70,11 @@ async def check_and_update_bluetooth_device(self, device: BLEDevice) -> ConfigEn if device.name in identifiers: await self.async_set_unique_id(entry.data.get(CONF_ACCOUNT_ID)) # # Update existing entry with BLE info - formatted_ble = format_mac(self._discovered_device.address) + formatted_ble = ( + format_mac(self._discovered_device.address) + if self._discovered_device + else None + ) if ( CONNECTION_BLUETOOTH, @@ -133,13 +138,15 @@ async def async_step_bluetooth_confirm( if entry := await self.check_and_update_bluetooth_device( self._discovered_device ): - ble_devices = { + existing_devices = { self._discovered_device.name: format_mac( self._discovered_device.address ), **entry.data.get(CONF_BLE_DEVICES, None), } - self._abort_if_unique_id_configured(updates={CONF_BLE_DEVICES: ble_devices}) + self._abort_if_unique_id_configured( + updates={CONF_BLE_DEVICES: existing_devices} + ) ble_devices: dict[str, str] = { self._discovered_device.name: format_mac(self._discovered_device.address) @@ -149,6 +156,7 @@ async def async_step_bluetooth_confirm( } if user_input is not None: + self._stay_connected = user_input.get(CONF_STAY_CONNECTED_BLUETOOTH, False) return await self.async_step_wifi(user_input) return self.async_show_form( @@ -209,44 +217,47 @@ async def async_step_wifi( self, user_input: dict[str, Any] | None ) -> ConfigFlowResult: """Handle the user step for Wi-Fi control.""" + errors: dict[str, str] = {} + if user_input is not None and ( user_input.get(CONF_ACCOUNTNAME) is not None or user_input.get(CONF_USE_WIFI) is True ): account = user_input.get(CONF_ACCOUNTNAME, "") password = user_input.get(CONF_PASSWORD, "") - mammotion_http = MammotionHTTP() + mammotion_http = MammotionHTTP(account, password) try: - await mammotion_http.login(account, password) + await mammotion_http.login_v2(account, password) if mammotion_http.login_info is None: - return self.async_abort(reason=str(mammotion_http.msg)) - except HTTPException as err: - return self.async_abort(reason=str(err)) + errors["base"] = "invalid_auth" + except HTTPException: + errors["base"] = "cannot_connect" - user_account = mammotion_http.login_info.userInformation.userAccount + if login_info := mammotion_http.login_info: + user_account = login_info.userInformation.userAccount - await self.async_set_unique_id(user_account, raise_on_progress=False) - self._abort_if_unique_id_configured() + 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, - CONF_DEVICE_NAME: self._discovered_device.name - if self._discovered_device - else None, - CONF_USE_WIFI: user_input.get(CONF_USE_WIFI, True), - **self._config, - }, - options={CONF_STAY_CONNECTED_BLUETOOTH: self._stay_connected}, - ) + return self.async_create_entry( + title=account, + data={ + CONF_ACCOUNTNAME: account, + CONF_PASSWORD: password, + CONF_ACCOUNT_ID: user_account, + CONF_DEVICE_NAME: self._discovered_device.name + if self._discovered_device + else None, + CONF_USE_WIFI: user_input.get(CONF_USE_WIFI, True), + **self._config, + }, + options={CONF_STAY_CONNECTED_BLUETOOTH: self._stay_connected}, + ) if user_input is not None and user_input.get(CONF_USE_WIFI) is False: return self.async_create_entry( - title=self._discovered_device.name, + title=self._discovered_device.name if self._discovered_device else "", data={ CONF_USE_WIFI: user_input.get(CONF_USE_WIFI), **self._config, @@ -260,45 +271,9 @@ async def async_step_wifi( vol.Optional(CONF_USE_WIFI, default=True): cv.boolean, } - return self.async_show_form(step_id="wifi", data_schema=vol.Schema(schema)) - - async def async_step_wifi_confirm( - self, user_input: dict[str, Any] - ) -> ConfigFlowResult: - """Confirm device discovery.""" - mammotion = Mammotion() - - if user_input is not None: - account = user_input.get(CONF_ACCOUNTNAME) - password = user_input.get(CONF_PASSWORD) - - if self._cloud_client is None: - try: - if mammotion.mqtt_list.get(account) is None: - self._cloud_client = await Mammotion().login(account, password) - else: - self._cloud_client = mammotion.mqtt_list.get( - account - ).cloud_client - except HTTPException as err: - return self.async_abort(reason=str(err)) - user_account = ( - self._cloud_client.mammotion_http.login_info.userInformation.userAccount - ) - - await self.async_set_unique_id(user_account, raise_on_progress=False) - self._abort_if_unique_id_configured() - - return self.async_create_entry( - title=user_account, - data={ - CONF_ACCOUNTNAME: account, - CONF_PASSWORD: password, - CONF_USE_WIFI: user_input.get(CONF_USE_WIFI, True), - **self._config, - }, - options={CONF_STAY_CONNECTED_BLUETOOTH: self._stay_connected}, - ) + return self.async_show_form( + step_id="wifi", data_schema=vol.Schema(schema), errors=errors + ) @staticmethod @callback diff --git a/homeassistant/components/mammotion/const.py b/homeassistant/components/mammotion/const.py index 7b991eb6e429d..d76b05d5802ea 100644 --- a/homeassistant/components/mammotion/const.py +++ b/homeassistant/components/mammotion/const.py @@ -5,7 +5,11 @@ from bleak.exc import BleakError from bleak_retry_connector import BleakNotFoundError -from pymammotion.aliyun.cloud_gateway import CheckSessionException, SetupException +from pymammotion.aliyun.cloud_gateway import ( + CheckSessionException, + DeviceOfflineException, + SetupException, +) from pymammotion.mammotion.devices.mammotion_bluetooth import CharacteristicMissingError from pymammotion.utility.constant import WorkMode @@ -24,6 +28,7 @@ CharacteristicMissingError, BleakError, TimeoutError, + DeviceOfflineException, ) EXPIRED_CREDENTIAL_EXCEPTIONS = (CheckSessionException, SetupException) diff --git a/homeassistant/components/mammotion/coordinator.py b/homeassistant/components/mammotion/coordinator.py index 33592dffa3cc0..7d6a6293fcf39 100644 --- a/homeassistant/components/mammotion/coordinator.py +++ b/homeassistant/components/mammotion/coordinator.py @@ -3,48 +3,22 @@ from __future__ import annotations from abc import abstractmethod -import asyncio from collections.abc import Mapping -import datetime from datetime import timedelta -import json -import time from typing import TYPE_CHECKING, Any -import betterproto from mashumaro.exceptions import InvalidFieldValue -from pymammotion.aliyun.cloud_gateway import ( - DeviceOfflineException, - FailedRequestException, - GatewayTimeoutException, - NoConnectionException, -) from pymammotion.aliyun.model.dev_by_account_response import Device -from pymammotion.data.model.device import MowerInfo, MowingDevice -from pymammotion.data.model.report_info import Maintain -from pymammotion.data.mqtt.event import ThingEventMessage -from pymammotion.data.mqtt.properties import OTAProgressItems, ThingPropertiesMessage -from pymammotion.data.mqtt.status import ThingStatusMessage -from pymammotion.http.model.http import ErrorInfo -from pymammotion.mammotion.devices.mammotion import ( - ConnectionPreference, - Mammotion, - MammotionMixedDeviceManager, -) -from pymammotion.proto import RptAct, RptInfoType, SystemUpdateBufMsg -from pymammotion.utility.constant import WorkMode -from pymammotion.utility.device_type import DeviceType +from pymammotion.data.model.device import MowingDevice +from pymammotion.homeassistant import HomeAssistantMowerApi +from pymammotion.mammotion.devices.mammotion import MammotionMowerDeviceManager -from homeassistant.components import bluetooth from homeassistant.const import CONF_PASSWORD from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .config import MammotionConfigStore from .const import ( - COMMAND_EXCEPTIONS, CONF_ACCOUNTNAME, CONF_AEP_DATA, CONF_AUTH_DATA, @@ -54,21 +28,13 @@ CONF_REGION_DATA, CONF_SESSION_DATA, DOMAIN, - EXPIRED_CREDENTIAL_EXCEPTIONS, LOGGER, - NO_REQUEST_MODES, ) if TYPE_CHECKING: from . import MammotionConfigEntry - -MAINTENANCE_INTERVAL = timedelta(minutes=60) -DEFAULT_INTERVAL = timedelta(minutes=1) -WORKING_INTERVAL = timedelta(seconds=5) REPORT_INTERVAL = timedelta(minutes=1) -DEVICE_VERSION_INTERVAL = timedelta(days=1) -MAP_INTERVAL = timedelta(minutes=30) class MammotionBaseUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): @@ -79,7 +45,7 @@ def __init__( hass: HomeAssistant, config_entry: MammotionConfigEntry, device: Device, - mammotion: Mammotion, + api: HomeAssistantMowerApi, update_interval: timedelta, ) -> None: """Initialize global mammotion data updater.""" @@ -92,36 +58,26 @@ def __init__( ) assert config_entry.unique_id self.device: Device = device - self.device_name = device.deviceName - self.manager: Mammotion = mammotion + 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 @abstractmethod - def get_coordinator_data(self, device: MammotionMixedDeviceManager) -> _DataT: + def get_coordinator_data(self, device: MammotionMowerDeviceManager) -> _DataT: """Get coordinator data.""" async def async_refresh_login(self) -> None: """Refresh login credentials asynchronously.""" - - await self.manager.refresh_login(self.account, self.password) + await self.api.mammotion.refresh_login(self.account) self.store_cloud_credentials() - async def device_offline(self, device: MammotionMixedDeviceManager) -> None: - """Device is set to offline.""" - device.state.online = False - if cloud := device.cloud(): - await cloud.stop() - - loop = asyncio.get_running_loop() - loop.call_later(900, lambda: asyncio.create_task(self.clear_update_failures())) - def store_cloud_credentials(self) -> None: """Store cloud credentials in config entry.""" # config_updates = {} if config_entry := self.config_entry: - mammotion_cloud = self.manager.mqtt_list.get( + mammotion_cloud = self.api.mammotion.mqtt_list.get( config_entry.data.get(CONF_ACCOUNTNAME, "") ) cloud_client = mammotion_cloud.cloud_client if mammotion_cloud else None @@ -141,665 +97,69 @@ def store_cloud_credentials(self) -> None: config_entry, data=config_updates ) - async def async_send_command(self, command: str, **kwargs: Any) -> bool | None: - """Send command.""" - if not self.manager.get_device_by_name(self.device_name).state.online: - return False - - device = self.manager.get_device_by_name(self.device_name) - - try: - await self.manager.send_command_with_args( - self.device_name, command, **kwargs + def is_online(self) -> bool: + """Check if device is online.""" + if device := self.api.mammotion.get_device_by_name(self.device_name): + return device.state.online or ( + device.ble is not None and device.ble.client.is_connected ) - self.update_failures = 0 - return True - except FailedRequestException: - self.update_failures += 1 - if self.update_failures < 5: - return await self.async_send_command(command, **kwargs) - return False - except EXPIRED_CREDENTIAL_EXCEPTIONS: - self.update_failures += 1 - await self.async_refresh_login() - if self.update_failures < 5: - return await self.async_send_command(command, **kwargs) - return False - except GatewayTimeoutException as ex: - LOGGER.error(f"Gateway timeout exception: {ex.iot_id}") - self.update_failures = 0 - return False - except (DeviceOfflineException, NoConnectionException) as ex: - """Device is offline try bluetooth if we have it.""" - try: - if ble := device.ble(): - # if we don't do this it will stay connected and no longer update over wifi - ble.set_disconnect_strategy(disconnect=True) - await ble.queue_command(command, **kwargs) - - return True - raise DeviceOfflineException(ex.args[0], self.device.iotId) - except COMMAND_EXCEPTIONS as exc: - raise HomeAssistantError( - translation_domain=DOMAIN, translation_key="command_failed" - ) from exc - - async def check_firmware_version(self) -> None: - """Check if firmware version is updated.""" - if mower := self.manager.mower(self.device_name): - device_registry = dr.async_get(self.hass) - device_entry = device_registry.async_get_device( - identifiers={(DOMAIN, self.device_name)} - ) - if device_entry is None: - return - - new_swversion = mower.device_firmwares.device_version - - if new_swversion is not None or new_swversion != device_entry.sw_version: - device_registry.async_update_device( - device_entry.id, sw_version=new_swversion - ) - - if model_id := mower.mower_state.model_id: - if model_id is not None or model_id != device_entry.model_id: - device_registry.async_update_device( - device_entry.id, model_id=model_id - ) - - async def async_request_iot_sync(self, stop: bool = False) -> None: - """Sync specific info from device.""" - await self.async_send_command( - "request_iot_sys", - rpt_act=RptAct.RPT_STOP if stop else RptAct.RPT_START, - rpt_info_type=[ - RptInfoType.RIT_DEV_STA, - RptInfoType.RIT_DEV_LOCAL, - RptInfoType.RIT_WORK, - RptInfoType.RIT_MAINTAIN, - RptInfoType.RIT_BASESTATION_INFO, - RptInfoType.RIT_VIO, - ], - timeout=10000, - period=3000, - no_change_period=4000, - count=0, - ) - - async def async_rtk_dock_location(self) -> None: - """RTK and dock location.""" - await self.async_send_command("allpowerfull_rw", rw_id=5, rw=1, context=1) - - async def async_get_area_list(self) -> None: - """Mowing area List.""" - await self.async_send_command("get_area_name_list", device_id=self.device.iotId) - - async def clear_update_failures(self) -> None: - """Clear update failures and start cloud connection.""" - self.update_failures = 0 - device = self.manager.get_device_by_name(self.device_name) - if not device.state.online: - device.state.online = True - if cloud := device.cloud(): - if cloud.stopped: - await cloud.start() + return False - async def async_pre_update_data(self) -> _DataT | None: - if device := self.manager.get_device_by_name(self.device_name): - if not device.state.enabled or ( - not device.state.online - and device.preference is ConnectionPreference.WIFI - ): - if cloud := device.cloud(): - if not device.state.enabled and cloud.mqtt.is_connected(): - cloud.mqtt.disconnect() - if ble := device.ble(): - if not device.state.enabled: - if ble.client is not None and ble.client.is_connected: - await ble.client.disconnect() - return self.get_coordinator_data(device) - - if ( - device.state.mower_state.ble_mac != "" - and device.preference is ConnectionPreference.BLUETOOTH - ): - if ble_device := bluetooth.async_ble_device_from_address( - self.hass, device.state.mower_state.ble_mac.upper(), True - ): - if ble := device.ble(): - ble.update_device(ble_device) - else: - device.add_ble(ble_device) - - # don't query the mower while users are doing map changes or its updating. - if device.state.report_data.dev.sys_status in NO_REQUEST_MODES: - # MQTT we are likely to get an update, BLE we are not - if device.preference is ConnectionPreference.BLUETOOTH: - loop = asyncio.get_running_loop() - loop.call_later( - 300, - lambda: asyncio.create_task( - self.async_send_command("get_report_cfg") - ), - ) - return self.get_coordinator_data(device) - - if ( - self.update_failures > 5 - and device.preference is ConnectionPreference.WIFI - ): - """Don't hammer the mammotion/ali servers""" - loop = asyncio.get_running_loop() - loop.call_later( - 60, lambda: asyncio.create_task(self.clear_update_failures()) - ) - - return self.get_coordinator_data(device) - return None - return None - - async def _async_update_notification(self, res: tuple[str, Any | None]) -> None: - """Update data from incoming messages.""" - - async def _async_update_properties( - self, properties: ThingPropertiesMessage - ) -> None: - """Update data from incoming properties messages.""" - - async def _async_update_status(self, status: ThingStatusMessage) -> None: - """Update data from incoming status messages.""" - - async def _async_update_event_message(self, event: ThingEventMessage) -> None: - """Update data from incoming event messages.""" - - async def _async_setup(self) -> None: - device = self.manager.get_device_by_name(self.device_name) - - if self.data is None: - self.data = device.state - if cloud := device.cloud(): - cloud.set_notification_callback(self._async_update_notification) - elif ble := device.ble(): - ble.set_notification_callback(self._async_update_notification) - - device.state_manager.properties_callback.add_subscribers( - self._async_update_properties - ) - device.state_manager.status_callback.add_subscribers(self._async_update_status) - - device.state_manager.device_event_callback.add_subscribers( - self._async_update_event_message - ) + async def async_send_command(self, command: str, **kwargs: Any) -> bool | None: + """Send command via api.""" + return await self.api.async_send_command(self.device_name, command, **kwargs) class MammotionReportUpdateCoordinator(MammotionBaseUpdateCoordinator[MowingDevice]): """Class to manage fetching mammotion report data.""" + def __init__( self, hass: HomeAssistant, config_entry: MammotionConfigEntry, device: Device, - mammotion: Mammotion, + api: HomeAssistantMowerApi, ) -> None: """Initialize mammotion data updater.""" super().__init__( hass=hass, config_entry=config_entry, device=device, - mammotion=mammotion, + api=api, update_interval=REPORT_INTERVAL, ) - def get_coordinator_data(self, device: MammotionMixedDeviceManager) -> MowingDevice: + def get_coordinator_data(self, device: MammotionMowerDeviceManager) -> MowingDevice: """Get device state for the coordinator.""" return device.state async def async_restore_data(self) -> None: """Restore saved data.""" - store = MammotionConfigStore(self.hass, version=1, minor_version=1, key=DOMAIN) + store = MammotionConfigStore(self.hass) restored_data: Mapping[str, Any] | None = await store.async_load() if restored_data is None: self.data = MowingDevice() - self.manager.get_device_by_name(self.device_name).state = self.data + self.api.mammotion.get_device_by_name(self.device_name).state = self.data return try: if mower_data := restored_data.get(self.device_name): mower_state = MowingDevice().from_dict(mower_data) - if device := self.manager.get_device_by_name(self.device_name): + if device := self.api.mammotion.get_device_by_name(self.device_name): device.state = mower_state except InvalidFieldValue: """invalid""" self.data = MowingDevice() - self.manager.get_device_by_name(self.device_name).state = self.data + self.api.mammotion.get_device_by_name(self.device_name).state = self.data async def async_save_data(self, data: MowingDevice) -> None: """Get map data from the device.""" - store = MammotionConfigStore(self.hass, version=1, minor_version=1, key=DOMAIN) + store = MammotionConfigStore(self.hass) current_store = await store.async_load() current_store[self.device_name] = data.to_dict() await store.async_save(current_store) async def _async_update_data(self) -> MowingDevice: """Get data from the device.""" - if data := await super().async_pre_update_data(): - return data - - device = self.manager.get_device_by_name(self.device_name) - if device is None: - LOGGER.debug("device not found") - return data - - try: - last_sent_time = 0 - if cloud := device.cloud(): - last_sent_time = cloud.command_sent_time - elif ble := device.ble(): - last_sent_time = ble.command_sent_time - - if ( - self.update_interval - and last_sent_time < time.time() - self.update_interval.seconds - ): - await self.async_send_command("get_report_cfg") - - except DeviceOfflineException as ex: - """Device is offline.""" - if ex.iot_id == self.device.iotId: - device = self.manager.get_device_by_name(self.device_name) - await self.device_offline(device) - return device.state - - self.update_failures = 0 - data = self.manager.get_device_by_name(self.device_name).state - await self.async_save_data(data) - - if data.report_data.dev.sys_status in ( - WorkMode.MODE_WORKING, - WorkMode.MODE_RETURNING, - WorkMode.MODE_PAUSE, - ): - self.update_interval = WORKING_INTERVAL - else: - self.update_interval = DEFAULT_INTERVAL - - return data - - async def _async_update_notification(self, res: tuple[str, Any | None]) -> None: - """Update data from incoming messages.""" - if res[0] == "sys" and res[1] is not None: - sys_msg = betterproto.which_one_of(res[1], "SubSysMsg") - if sys_msg[0] == "toapp_report_data": - if mower := self.manager.mower(self.device_name): - self.async_set_updated_data(mower) - - -class MammotionMaintenanceUpdateCoordinator(MammotionBaseUpdateCoordinator[Maintain]): - """Class to manage fetching mammotion data.""" - - def __init__( - self, - hass: HomeAssistant, - config_entry: MammotionConfigEntry, - device: Device, - mammotion: Mammotion, - ) -> None: - """Initialize global mammotion data updater.""" - super().__init__( - hass=hass, - config_entry=config_entry, - device=device, - mammotion=mammotion, - update_interval=MAINTENANCE_INTERVAL, - ) - - def get_coordinator_data(self, device: MammotionMixedDeviceManager) -> Maintain: - """Get device state for the coordinator.""" - return device.state.report_data.maintenance - - async def _async_update_data(self) -> Maintain: - """Get data from the device.""" - if data := await super().async_pre_update_data(): - return data - - try: - await self.async_send_command("get_maintenance") - - except DeviceOfflineException as ex: - """Device is offline.""" - if ex.iot_id == self.device.iotId: - device = self.manager.get_device_by_name(self.device_name) - await self.device_offline(device) - return device.state - except GatewayTimeoutException: - """Gateway is timing out again.""" - - return self.manager.get_device_by_name( - self.device.deviceName - ).state.report_data.maintenance - - async def _async_setup(self) -> None: - """Setup maintenance coordinator.""" - await super()._async_setup() - device = self.manager.get_device_by_name(self.device_name) - if self.data is None: - self.data = device.state.report_data.maintenance - - -class MammotionDeviceVersionUpdateCoordinator( - MammotionBaseUpdateCoordinator[MowingDevice] -): - """Class to manage fetching mammotion data.""" - - def __init__( - self, - hass: HomeAssistant, - config_entry: MammotionConfigEntry, - device: Device, - mammotion: Mammotion, - ) -> None: - """Initialize global mammotion data updater.""" - super().__init__( - hass=hass, - config_entry=config_entry, - device=device, - mammotion=mammotion, - update_interval=DEFAULT_INTERVAL, - ) - - def get_coordinator_data(self, device: MammotionMixedDeviceManager) -> MowingDevice: - """Get device state for the coordinator.""" - return device.state - - async def _async_update_properties( - self, properties: ThingPropertiesMessage - ) -> None: - """Update data from incoming properties messages.""" - if ota_progress := properties.params.items.otaProgress: - ota_progress.value = OTAProgressItems.from_dict(ota_progress.value) - self.data.update_check.progress = ota_progress.value.progress - self.data.update_check.isupgrading = True - if ota_progress.value.progress == 100: - self.data.update_check.isupgrading = False - self.data.update_check.upgradeable = False - self.data.device_firmwares.device_version = ota_progress.value.version - self.async_set_updated_data(self.data) - - async def _async_update_data(self): - """Get data from the device.""" - if data := await super().async_pre_update_data(): - return data - device = self.manager.get_device_by_name(self.device_name) - command_list = [ - "get_device_version_main", - "get_device_version_info", - "get_device_base_info", - "get_device_product_model", - ] - for command in command_list: - try: - await self.async_send_command(command) - - except DeviceOfflineException as ex: - """Device is offline bluetooth has been attempted.""" - if ex.iot_id == self.device.iotId: - await self.device_offline(device) - return device.state - except GatewayTimeoutException: - """Gateway is timing out again.""" - - data = self.manager.get_device_by_name(self.device_name).state - await self.check_firmware_version() - - ota_info = await device.mammotion_http.get_device_ota_firmware([device.iot_id]) - if check_versions := ota_info.data: - for check_version in check_versions: - if check_version.device_id == device.iot_id: - device.state.update_check = check_version - - if data.mower_state.model_id != "": - self.update_interval = DEVICE_VERSION_INTERVAL - - return data - - async def _async_setup(self) -> None: - """Setup device version coordinator.""" - await super()._async_setup() - device = self.manager.get_device_by_name(self.device_name) - if self.data is None: - self.data = device.state - - try: - if device.state.mower_state.model_id == "": - await self.async_send_command("get_device_product_model") - if device.state.mower_state.wifi_mac == "": - await self.async_send_command("get_device_network_info") - - ota_info = await device.mammotion_http.get_device_ota_firmware( - [device.iot_id] - ) - if check_versions := ota_info.data: - for check_version in check_versions: - if check_version.device_id == device.iot_id: - device.state.update_check = check_version - - except DeviceOfflineException: - """Device is offline bluetooth has been attempted.""" - - -class MammotionMapUpdateCoordinator(MammotionBaseUpdateCoordinator[MowerInfo]): - """Class to manage fetching mammotion data.""" - - def __init__( - self, - hass: HomeAssistant, - config_entry: MammotionConfigEntry, - device: Device, - mammotion: Mammotion, - ) -> None: - """Initialize global mammotion data updater.""" - super().__init__( - hass=hass, - config_entry=config_entry, - device=device, - mammotion=mammotion, - update_interval=MAP_INTERVAL, - ) - - def get_coordinator_data(self, device: MammotionMixedDeviceManager) -> MowerInfo: - return device.state.mower_state - - def _map_callback(self) -> None: - """Trigger a resync when the bol hash changes.""" - # TODO setup callback to get bol hash data - - async def _async_update_data(self): - """Get data from the device.""" - if data := await super().async_pre_update_data(): - return data - device = self.manager.get_device_by_name(self.device_name) - - try: - if ( - round(device.state.location.RTK.latitude, 0) == 0 - or round(device.state.location.dock.latitude, 0) == 0 - ): - await self.async_rtk_dock_location() - - if ( - len(device.state.map.hashlist) == 0 - or len(device.state.map.missing_hashlist()) > 0 - or len(device.state.map.plan) == 0 - ): - await self.manager.start_map_sync(self.device_name) - - except DeviceOfflineException as ex: - """Device is offline try bluetooth if we have it.""" - if ex.iot_id == self.device.iotId: - await self.device_offline(device) - return device.state.mower_state - except GatewayTimeoutException: - """Gateway is timing out again.""" - - return self.manager.get_device_by_name(self.device_name).state.mower_state - - async def _async_setup(self) -> None: - """Setup coordinator with initial call to get map data.""" - await super()._async_setup() - device = self.manager.get_device_by_name(self.device_name) - if self.data is None: - self.data = device.state.mower_state - - if not device.state.enabled or not device.state.online: - return - try: - await self.async_rtk_dock_location() - if not DeviceType.is_luba1(self.device_name): - await self.async_get_area_list() - except DeviceOfflineException as ex: - """Device is offline try bluetooth if we have it.""" - if ex.iot_id == self.device.iotId: - await self.device_offline(device) - except GatewayTimeoutException: - """Gateway is timing out again.""" - - -class MammotionDeviceErrorUpdateCoordinator( - MammotionBaseUpdateCoordinator[MowingDevice] -): - """Class to manage fetching mammotion data.""" - - def __init__( - self, - hass: HomeAssistant, - config_entry: MammotionConfigEntry, - device: Device, - mammotion: Mammotion, - ) -> None: - """Initialize global mammotion data updater.""" - super().__init__( - hass=hass, - config_entry=config_entry, - device=device, - mammotion=mammotion, - update_interval=DEFAULT_INTERVAL, - ) - - def get_coordinator_data(self, device: MammotionMixedDeviceManager) -> MowingDevice: - """Get device state for the coordinator.""" - return device.state - - async def _async_update_event_message(self, event: ThingEventMessage) -> None: - if ( - hasattr(event.params, "identifier") - and event.params.identifier == "device_warning_code_event" - ): - event_params = event.params - # '[{"c":-2801,"ct":1,"ft":1731493734000},{"c":-1008,"ct":1,"ft":1731493734000}]' - try: - warning_event = json.loads(event_params.value.data) - LOGGER.debug("warning event %s", warning_event) - await self._async_update_data() - if mower := self.manager.mower(self.device_name): - self.async_set_updated_data(mower) - except json.JSONDecodeError: - """Failed to parse warning event.""" - - async def _async_update_notification(self, res: tuple[str, Any | None]) -> None: - """Update data from incoming notifications messages.""" - if res[0] == "sys" and res[1] is not None: - sys_msg = betterproto.which_one_of(res[1], "SubSysMsg") - if sys_msg[0] == "system_update_buf" and sys_msg[1] is not None: - buffer_list: SystemUpdateBufMsg = sys_msg[1] - if buffer_list.update_buf_data[0] == 2: - if mower := self.manager.mower(self.device_name): - self.async_set_updated_data(mower) - - def get_error_code(self, number: int) -> int: - """Get error code from an error code list.""" - try: - return abs(next(iter(self.data.errors.err_code_list), None)) - except StopIteration: - return 0 - - def get_error_time(self, number: int) -> datetime.datetime | None: - """Get error time from an error code list.""" - try: - return datetime.datetime.fromtimestamp( - next(iter(self.data.errors.err_code_list_time), None), datetime.UTC - ) - except StopIteration: - return None - - def get_error_message(self, number: int) -> str: - """Return error message.""" - try: - error_code: int = next(iter(self.data.errors.err_code_list)) - - error_code = abs(error_code) - error_info: ErrorInfo = self.data.errors.error_codes[f"{error_code}"] - - implication = ( - getattr(error_info, f"{self.hass.config.language}_implication") - if hasattr(error_info, f"{self.hass.config.language}_implication") - else error_info.en_implication - ) - solution = ( - getattr(error_info, f"{self.hass.config.language}_solution") - if hasattr(error_info, f"{self.hass.config.language}_solution") - else error_info.en_solution - ) - - if implication == "": - implication = error_info.en_implication - - if solution == "": - solution = error_info.en_solution - - return f"{error_info.module}: {implication}, {solution}" - - except StopIteration: - """Failed to get error code.""" - return "No Error" - - async def _async_update_data(self): - """Get data from the device.""" - if data := await super().async_pre_update_data(): - return data - device = self.manager.get_device_by_name(self.device_name) - - try: - await self.async_send_command("allpowerfull_rw", rw_id=5, rw=1, context=2) - await self.async_send_command("allpowerfull_rw", rw_id=5, rw=1, context=3) - if not device.state.errors.error_codes: - device.state.errors.error_codes = ( - await device.mammotion_http.get_all_error_codes() - ) - except DeviceOfflineException as ex: - """Device is offline bluetooth has been attempted.""" - if ex.iot_id == self.device.iotId: - await self.device_offline(device) - return device.state - except GatewayTimeoutException: - """Gateway is timing out again.""" - - return data - - async def _async_setup(self) -> None: - """Setup device version coordinator.""" - await super()._async_setup() - device = self.manager.get_device_by_name(self.device_name) - if self.data is None: - self.data = device.state - - try: - # get current errors - await self.async_send_command("allpowerfull_rw", rw_id=5, rw=1, context=2) - await self.async_send_command("allpowerfull_rw", rw_id=5, rw=1, context=3) - if not device.state.errors.error_codes: - device.state.errors.error_codes = ( - await device.mammotion_http.get_all_error_codes() - ) - except DeviceOfflineException: - """Device is offline bluetooth has been attempted.""" + return await self.api.update(self.device_name) diff --git a/homeassistant/components/mammotion/entity.py b/homeassistant/components/mammotion/entity.py index 22a01872a201e..4988cbb76d072 100644 --- a/homeassistant/components/mammotion/entity.py +++ b/homeassistant/components/mammotion/entity.py @@ -8,7 +8,7 @@ ) from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import CONF_RETRY_COUNT, DEFAULT_RETRY_COUNT, DOMAIN +from .const import DOMAIN from .coordinator import MammotionBaseUpdateCoordinator @@ -24,12 +24,13 @@ def __init__(self, coordinator: MammotionBaseUpdateCoordinator, key: str) -> Non @property def device_info(self) -> DeviceInfo: - mower = self.coordinator.manager.get_device_by_name( + """Return the device info.""" + mower = self.coordinator.api.mammotion.get_device_by_name( self.coordinator.device_name ) swversion = mower.state.device_firmwares.device_version - model_id = None + model_id: str | None = None if mower is not None: if mower.state.mower_state.model_id != "": model_id = mower.state.mower_state.model_id @@ -39,47 +40,46 @@ def device_info(self) -> DeviceInfo: ): model_id = mower.state.mqtt_properties.params.items.extMod.value - nick_name = self.coordinator.device.nickName + 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.nickName + else self.coordinator.device.nick_name ) connections: set[tuple[str, str]] = set() - if mower.ble(): + if mower.ble: connections.add( ( CONNECTION_BLUETOOTH, - format_mac(mower.ble().ble_device.address), + format_mac(mower.ble.ble_device.address), ) ) - - if mower.state.mower_state.wifi_mac != "": + elif mower.state.mower_state.ble_mac != "": connections.add( ( - CONNECTION_NETWORK_MAC, - format_mac(mower.state.mower_state.wifi_mac), + CONNECTION_BLUETOOTH, + format_mac(mower.state.mower_state.ble_mac), ) ) - if mower.state.mower_state.ble_mac != "": + if mower.state.mower_state.wifi_mac != "": connections.add( ( - CONNECTION_BLUETOOTH, - format_mac(mower.state.mower_state.ble_mac), + CONNECTION_NETWORK_MAC, + format_mac(mower.state.mower_state.wifi_mac), ) ) return DeviceInfo( - identifiers={(DOMAIN, self.coordinator.device.deviceName)}, + 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.productModel or model_id, + model=self.coordinator.device.product_model or model_id, suggested_area="Garden", connections=connections, ) @@ -87,10 +87,4 @@ def device_info(self) -> DeviceInfo: @property def available(self) -> bool: """Return True if entity is available.""" - return ( - self.coordinator.data is not None - and self.coordinator.update_failures - <= self.coordinator.config_entry.options.get( - CONF_RETRY_COUNT, DEFAULT_RETRY_COUNT - ) - ) + return self.coordinator.data is not None and self.coordinator.is_online() diff --git a/homeassistant/components/mammotion/lawn_mower.py b/homeassistant/components/mammotion/lawn_mower.py index 59b61e59bae60..99f71743d0585 100644 --- a/homeassistant/components/mammotion/lawn_mower.py +++ b/homeassistant/components/mammotion/lawn_mower.py @@ -12,7 +12,7 @@ ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import MammotionConfigEntry, MammotionReportUpdateCoordinator from .const import COMMAND_EXCEPTIONS, DOMAIN, LOGGER @@ -37,17 +37,14 @@ def get_entity_attribute( async def async_setup_entry( hass: HomeAssistant, entry: MammotionConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Luba config entry.""" - mammotion_devices = entry.runtime_data - entities = [] - async_add_entities( - [ - MammotionLawnMowerEntity(mower.reporting_coordinator) - for mower in mammotion_devices - ] - ) + mammotion_devices = entry.runtime_data.mowers + entities: list[MammotionLawnMowerEntity] = [ + MammotionLawnMowerEntity(mower.reporting_coordinator) + for mower in mammotion_devices + ] async_add_entities(entities) @@ -130,7 +127,9 @@ async def async_dock(self) -> None: translation_domain=DOMAIN, translation_key=trans_key ) from exc finally: - await self.coordinator.async_request_iot_sync() + await self.coordinator.api.async_request_iot_sync( + self.coordinator.device_name + ) async def async_pause(self) -> None: """Pause mower.""" @@ -158,4 +157,6 @@ async def async_pause(self) -> None: translation_domain=DOMAIN, translation_key=trans_key ) from exc finally: - await self.coordinator.async_request_iot_sync() + 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 index 031c08456757d..16e4a3482ac3c 100644 --- a/homeassistant/components/mammotion/manifest.json +++ b/homeassistant/components/mammotion/manifest.json @@ -20,5 +20,5 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["pymammotion"], - "requirements": ["pymammotion==0.5.11"] + "requirements": ["pymammotion==0.5.59"] } diff --git a/homeassistant/components/mammotion/models.py b/homeassistant/components/mammotion/models.py index 5acd3ca022e1c..6134338f4a9a6 100644 --- a/homeassistant/components/mammotion/models.py +++ b/homeassistant/components/mammotion/models.py @@ -1,17 +1,12 @@ """Models for the Mammotion integration.""" + from dataclasses import dataclass from pymammotion.aliyun.model.dev_by_account_response import Device from pymammotion.data.model.device_limits import DeviceLimits -from pymammotion.mammotion.devices.mammotion import Mammotion +from pymammotion.homeassistant import HomeAssistantMowerApi -from .coordinator import ( - MammotionDeviceErrorUpdateCoordinator, - MammotionDeviceVersionUpdateCoordinator, - MammotionMaintenanceUpdateCoordinator, - MammotionMapUpdateCoordinator, - MammotionReportUpdateCoordinator, -) +from .coordinator import MammotionReportUpdateCoordinator @dataclass @@ -19,12 +14,8 @@ class MammotionMowerData: """Data for a mower information.""" name: str - api: Mammotion - maintenance_coordinator: MammotionMaintenanceUpdateCoordinator + api: HomeAssistantMowerApi reporting_coordinator: MammotionReportUpdateCoordinator - version_coordinator: MammotionDeviceVersionUpdateCoordinator - map_coordinator: MammotionMapUpdateCoordinator - error_coordinator: MammotionDeviceErrorUpdateCoordinator device_limits: DeviceLimits device: Device diff --git a/homeassistant/components/mammotion/strings.json b/homeassistant/components/mammotion/strings.json index 21a244b9b7b57..3d8716a109c87 100644 --- a/homeassistant/components/mammotion/strings.json +++ b/homeassistant/components/mammotion/strings.json @@ -8,7 +8,13 @@ "bluetooth_and_account_mismatch": "Bluetooth device not found in your account", "no_longer_present": "Device is no longer present", "not_supported": "Device not supported", - "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "missing_wifi_data": "Missing Wi-Fi configuration data" + }, + "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": { diff --git a/requirements_all.txt b/requirements_all.txt index ff1bb35c7c409..b0c2d258d028c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2334,7 +2334,7 @@ pylutron==0.4.1 pymailgunner==1.4 # homeassistant.components.mammotion -pymammotion==0.5.11 +pymammotion==0.5.59 # homeassistant.components.firmata pymata-express==1.19 diff --git a/tests/components/mammotion/__init__.py b/tests/components/mammotion/__init__.py index ce85aecb94c2c..bd65f1b8ab7ee 100644 --- a/tests/components/mammotion/__init__.py +++ b/tests/components/mammotion/__init__.py @@ -1 +1,23 @@ """Tests for the Mammotion integration.""" + +from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo + +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", +) diff --git a/tests/components/mammotion/conftest.py b/tests/components/mammotion/conftest.py index dd0e4ac5fc0ed..05f03a751ebed 100644 --- a/tests/components/mammotion/conftest.py +++ b/tests/components/mammotion/conftest.py @@ -3,37 +3,11 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, Mock, patch -from bleak.backends.device import BLEDevice -from habluetooth.models import BluetoothServiceInfoBleak import pytest -from homeassistant.components.mammotion.const import CONF_ACCOUNTNAME, DOMAIN -from homeassistant.const import CONF_ADDRESS, CONF_PASSWORD - -from tests.common import MockConfigEntry -from tests.components.bluetooth import generate_advertisement_data, generate_ble_device +from . import BLE_DEVICE_LUBA, BLE_DEVICE_YUKA DEFAULT_NAME = "Luba-ABC123" -MAMMOTION_SERVICE_INFO = BluetoothServiceInfoBleak( - name="Luba-ABC123", - address="AA:BB:CC:DD:EE:FF", - device=generate_ble_device( - address="AA:BB:CC:DD:EE:FF", - name="Luba-ABC123", - ), - rssi=-61, - manufacturer_data={}, - service_data={}, - service_uuids=["0000ffff-0000-1000-8000-00805f9b34fb"], - source="local", - advertisement=generate_advertisement_data( - manufacturer_data={}, - service_uuids=["0000ffff-0000-1000-8000-00805f9b34fb"], - ), - connectable=True, - time=0, - tx_power=None, -) @pytest.fixture(autouse=True) @@ -55,23 +29,11 @@ def mock_async_discovered_service_info() -> Generator[MagicMock]: """Mock service discovery.""" with patch( "homeassistant.components.mammotion.config_flow.async_discovered_service_info", - return_value=[MAMMOTION_SERVICE_INFO], + return_value=[BLE_DEVICE_LUBA, BLE_DEVICE_YUKA], ) as discovery: yield discovery -@pytest.fixture(name="ble_device") -def mock_ble_device() -> Generator[MagicMock]: - """Mock BLEDevice.""" - with patch( - "homeassistant.components.bluetooth.async_ble_device_from_address", - return_value=BLEDevice( - address="AA:BB:CC:DD:EE:FF", name=DEFAULT_NAME, details={} - ), - ) as ble_device: - yield ble_device - - @pytest.fixture def mock_cloud_gateway(): """Mock a CloudIOTGateway.""" @@ -102,20 +64,6 @@ def mock_mammotion(): return mock -@pytest.fixture -def mock_config_entry(): - """Return a mocked config entry.""" - return MockConfigEntry( - domain=DOMAIN, - data={ - CONF_ACCOUNTNAME: "user@example.com", - CONF_PASSWORD: "password", - CONF_ADDRESS: "AA:BB:CC:DD:EE:FF", - }, - unique_id="user123", - ) - - @pytest.fixture def mock_mower_coordinator(): """Return a mocked mower coordinator.""" @@ -123,4 +71,6 @@ def mock_mower_coordinator(): coordinator.data = Mock() coordinator.data.report_data = Mock() coordinator.data.report_data.dev = Mock() + coordinator.api = Mock() + coordinator.api.async_request_iot_sync = AsyncMock() return coordinator diff --git a/tests/components/mammotion/test_config_flow.py b/tests/components/mammotion/test_config_flow.py new file mode 100644 index 0000000000000..af07c52d1a399 --- /dev/null +++ b/tests/components/mammotion/test_config_flow.py @@ -0,0 +1,365 @@ +"""Test the Mammotion Luba config flow.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +from aiohttp.web_exceptions import HTTPException +from bleak.backends.device import BLEDevice + +from homeassistant import config_entries +from homeassistant.components.mammotion.const import ( + CONF_ACCOUNT_ID, + CONF_ACCOUNTNAME, + CONF_BLE_DEVICES, + CONF_DEVICE_NAME, + CONF_STAY_CONNECTED_BLUETOOTH, + CONF_USE_WIFI, + DOMAIN, +) +from homeassistant.const import CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +# Helpers +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 + + +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" + assert result["description_placeholders"] == {"name": "Luba-ABC123"} + + # Confirm Bluetooth + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_STAY_CONNECTED_BLUETOOTH: True}, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "wifi" + + # Configure WiFi with credentials + 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, + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_ACCOUNTNAME: "user@example.com", + CONF_PASSWORD: "password", + CONF_USE_WIFI: True, + }, + ) + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == "user@example.com" + assert result3["data"] == { + CONF_ACCOUNTNAME: "user@example.com", + CONF_PASSWORD: "password", + CONF_ACCOUNT_ID: "user123", + CONF_DEVICE_NAME: "Luba-ABC123", + CONF_USE_WIFI: True, + CONF_BLE_DEVICES: {"Luba-ABC123": "aa:bb:cc:dd:ee:ff"}, + } + assert result3["options"] == {CONF_STAY_CONNECTED_BLUETOOTH: True} + + +async def test_bluetooth_discovery_bluetooth_only(hass: HomeAssistant) -> None: + """Test bluetooth discovery configuring usage without WiFi.""" + 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" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_STAY_CONNECTED_BLUETOOTH: False}, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "wifi" + + # Disable WiFi + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_USE_WIFI: False}, + ) + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == "Luba-ABC123" + assert result3["data"] == { + CONF_USE_WIFI: False, + CONF_BLE_DEVICES: {"Luba-ABC123": "aa:bb:cc:dd:ee:ff"}, + } + assert result3["options"] == {CONF_STAY_CONNECTED_BLUETOOTH: False} + + +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" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_STAY_CONNECTED_BLUETOOTH: True}, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["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", + CONF_USE_WIFI: True, + }, + ) + + 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=HTTPException(text="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", + CONF_USE_WIFI: True, + }, + ) + + 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", + CONF_USE_WIFI: True, + }, + unique_id="user123", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": entry.entry_id, + }, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ACCOUNTNAME: "new@example.com", + CONF_PASSWORD: "new_password", + CONF_USE_WIFI: False, + }, + ) + + 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_USE_WIFI] is False + + +async def test_options_flow(hass: HomeAssistant) -> None: + """Test options flow.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + options={CONF_STAY_CONNECTED_BLUETOOTH: False}, + unique_id="user123", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_STAY_CONNECTED_BLUETOOTH: True} + ) + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["data"][CONF_STAY_CONNECTED_BLUETOOTH] is True diff --git a/tests/components/mammotion/test_lawn_mower.py b/tests/components/mammotion/test_lawn_mower.py new file mode 100644 index 0000000000000..6ce488a0de056 --- /dev/null +++ b/tests/components/mammotion/test_lawn_mower.py @@ -0,0 +1,309 @@ +"""Test for the Mammotion lawn_mower platform.""" + +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +from pymammotion.utility.constant.device_constant import WorkMode +import pytest + +from homeassistant.components.lawn_mower import ( + LawnMowerActivity, + LawnMowerEntityFeature, +) +from homeassistant.components.mammotion import MammotionDevices +from homeassistant.components.mammotion.const import COMMAND_EXCEPTIONS, DOMAIN +from homeassistant.components.mammotion.lawn_mower import ( + MammotionLawnMowerEntity, + async_setup_entry, + get_entity_attribute, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from tests.common import MockConfigEntry + + +async def test_get_entity_attribute(hass: HomeAssistant) -> None: + """Test the get_entity_attribute function.""" + # Set up a mock state + hass.states.async_set("sensor.test", "on", {"test_attribute": "test_value"}) + + # Test getting an existing attribute + result = get_entity_attribute(hass, "sensor.test", "test_attribute") + assert result == "test_value" + + # Test getting a non-existent attribute + result = get_entity_attribute(hass, "sensor.test", "non_existent") + assert result is None + + # Test getting an attribute from a non-existent entity + result = get_entity_attribute(hass, "sensor.non_existent", "test_attribute") + assert result is None + + +async def test_async_setup_entry(hass: HomeAssistant, mock_mower_coordinator) -> None: + """Test setting up the lawn mower platform.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={}, + unique_id="test-unique-id", + ) + config_entry.runtime_data = MammotionDevices( + mowers=[MagicMock(reporting_coordinator=mock_mower_coordinator)] + ) + + with patch( + "homeassistant.components.mammotion.lawn_mower.MammotionLawnMowerEntity" + ): + await async_setup_entry(hass, config_entry, Mock()) + + +async def test_lawn_mower_entity_init(mock_mower_coordinator) -> None: + """Test initializing the lawn mower entity.""" + entity = MammotionLawnMowerEntity(mock_mower_coordinator) + + assert entity._attr_name is None + assert entity._attr_supported_features == ( + LawnMowerEntityFeature.DOCK | LawnMowerEntityFeature.PAUSE + ) + + +async def test_lawn_mower_activity_mowing(mock_mower_coordinator) -> None: + """Test the activity property when mowing.""" + entity = MammotionLawnMowerEntity(mock_mower_coordinator) + + mock_mower_coordinator.data.report_data.dev.sys_status = WorkMode.MODE_WORKING + + assert entity.activity == LawnMowerActivity.MOWING + + +async def test_lawn_mower_activity_paused(mock_mower_coordinator) -> None: + """Test the activity property when paused.""" + entity = MammotionLawnMowerEntity(mock_mower_coordinator) + + # Test MODE_PAUSE + mock_mower_coordinator.data.report_data.dev.sys_status = WorkMode.MODE_PAUSE + assert entity.activity == LawnMowerActivity.PAUSED + + # Test MODE_READY with charge_state 0 + mock_mower_coordinator.data.report_data.dev.sys_status = WorkMode.MODE_READY + mock_mower_coordinator.data.report_data.dev.charge_state = 0 + assert entity.activity == LawnMowerActivity.PAUSED + + +async def test_lawn_mower_activity_docked(mock_mower_coordinator) -> None: + """Test the activity property when docked.""" + entity = MammotionLawnMowerEntity(mock_mower_coordinator) + + mock_mower_coordinator.data.report_data.dev.sys_status = WorkMode.MODE_READY + mock_mower_coordinator.data.report_data.dev.charge_state = 1 + + assert entity.activity == LawnMowerActivity.DOCKED + + +async def test_lawn_mower_activity_returning(mock_mower_coordinator) -> None: + """Test the activity property when returning.""" + entity = MammotionLawnMowerEntity(mock_mower_coordinator) + + mock_mower_coordinator.data.report_data.dev.sys_status = WorkMode.MODE_RETURNING + + assert entity.activity == LawnMowerActivity.RETURNING + + +async def test_lawn_mower_activity_error(mock_mower_coordinator) -> None: + """Test the activity property when in error state.""" + entity = MammotionLawnMowerEntity(mock_mower_coordinator) + + mock_mower_coordinator.data.report_data.dev.sys_status = WorkMode.MODE_LOCK + + assert entity.activity == LawnMowerActivity.ERROR + + +async def test_lawn_mower_activity_none(mock_mower_coordinator) -> None: + """Test the activity property returns None for unknown states.""" + entity = MammotionLawnMowerEntity(mock_mower_coordinator) + + # Test None sys_status + mock_mower_coordinator.data.report_data.dev.sys_status = None + assert entity.activity is None + + # Test unhandled sys_status + mock_mower_coordinator.data.report_data.dev.sys_status = 999 + assert entity.activity is None + + +async def test_async_dock(mock_mower_coordinator) -> None: + """Test the async_dock method.""" + entity = MammotionLawnMowerEntity(mock_mower_coordinator) + mock_mower_coordinator.async_send_command = AsyncMock() + mock_mower_coordinator.async_request_iot_sync = AsyncMock() + + # Test working mode + mock_mower_coordinator.data.report_data.dev.sys_status = WorkMode.MODE_WORKING + mock_mower_coordinator.data.report_data.dev.charge_state = 0 + + await entity.async_dock() + + assert mock_mower_coordinator.async_send_command.call_count == 2 + assert ( + mock_mower_coordinator.async_send_command.call_args_list[0][0][0] + == "pause_execute_task" + ) + assert ( + mock_mower_coordinator.async_send_command.call_args_list[1][0][0] + == "return_to_dock" + ) + assert mock_mower_coordinator.api.async_request_iot_sync.call_count == 1 + + +async def test_async_dock_returning(mock_mower_coordinator) -> None: + """Test the async_dock method when already returning.""" + entity = MammotionLawnMowerEntity(mock_mower_coordinator) + mock_mower_coordinator.async_send_command = AsyncMock() + mock_mower_coordinator.async_request_iot_sync = AsyncMock() + + mock_mower_coordinator.data.report_data.dev.sys_status = WorkMode.MODE_RETURNING + mock_mower_coordinator.data.report_data.dev.charge_state = 0 + + await entity.async_dock() + + assert mock_mower_coordinator.async_send_command.call_count == 1 + assert ( + mock_mower_coordinator.async_send_command.call_args_list[0][0][0] + == "cancel_return_to_dock" + ) + assert mock_mower_coordinator.api.async_request_iot_sync.call_count == 1 + + +async def test_async_dock_ready(mock_mower_coordinator) -> None: + """Test the async_dock method when device is ready.""" + entity = MammotionLawnMowerEntity(mock_mower_coordinator) + mock_mower_coordinator.async_send_command = AsyncMock() + mock_mower_coordinator.async_request_iot_sync = AsyncMock() + + mock_mower_coordinator.data.report_data.dev.sys_status = WorkMode.MODE_READY + mock_mower_coordinator.data.report_data.dev.charge_state = 0 + + await entity.async_dock() + + assert mock_mower_coordinator.async_send_command.call_count == 1 + assert ( + mock_mower_coordinator.async_send_command.call_args_list[0][0][0] + == "return_to_dock" + ) + assert mock_mower_coordinator.api.async_request_iot_sync.call_count == 1 + + +async def test_async_dock_not_ready(mock_mower_coordinator) -> None: + """Test the async_dock method when device is not ready.""" + entity = MammotionLawnMowerEntity(mock_mower_coordinator) + + mock_mower_coordinator.data.report_data.dev.sys_status = None + + with patch.object(mock_mower_coordinator, "async_send_command"): + with pytest.raises(HomeAssistantError) as exc_info: + await entity.async_dock() + error = exc_info.value + assert error.translation_domain + + +async def test_async_dock_command_exception(mock_mower_coordinator) -> None: + """Test the async_dock method with command exceptions.""" + entity = MammotionLawnMowerEntity(mock_mower_coordinator) + mock_error = COMMAND_EXCEPTIONS[0]("Test error") + mock_mower_coordinator.async_send_command = AsyncMock(side_effect=mock_error) + mock_mower_coordinator.async_request_iot_sync = AsyncMock() + + mock_mower_coordinator.data.report_data.dev.sys_status = WorkMode.MODE_WORKING + mock_mower_coordinator.data.report_data.dev.charge_state = 0 + + with pytest.raises(HomeAssistantError) as exc_info: + await entity.async_dock() + error = exc_info.value + assert error.translation_domain == DOMAIN + assert error.translation_key == "pause_failed" + + assert mock_mower_coordinator.api.async_request_iot_sync.call_count == 1 + + +async def test_async_pause(mock_mower_coordinator) -> None: + """Test the async_pause method.""" + entity = MammotionLawnMowerEntity(mock_mower_coordinator) + mock_mower_coordinator.async_send_command = AsyncMock() + mock_mower_coordinator.async_request_iot_sync = AsyncMock() + + # Test working mode + mock_mower_coordinator.data.report_data.dev.sys_status = WorkMode.MODE_WORKING + + await entity.async_pause() + + assert mock_mower_coordinator.async_send_command.call_count == 1 + assert ( + mock_mower_coordinator.async_send_command.call_args_list[0][0][0] + == "pause_execute_task" + ) + assert mock_mower_coordinator.api.async_request_iot_sync.call_count == 1 + + +async def test_async_pause_returning(mock_mower_coordinator) -> None: + """Test the async_pause method when returning.""" + entity = MammotionLawnMowerEntity(mock_mower_coordinator) + mock_mower_coordinator.async_send_command = AsyncMock() + mock_mower_coordinator.async_request_iot_sync = AsyncMock() + + mock_mower_coordinator.data.report_data.dev.sys_status = WorkMode.MODE_RETURNING + + await entity.async_pause() + + assert mock_mower_coordinator.async_send_command.call_count == 1 + assert ( + mock_mower_coordinator.async_send_command.call_args_list[0][0][0] + == "cancel_return_to_dock" + ) + assert mock_mower_coordinator.api.async_request_iot_sync.call_count == 1 + + +async def test_async_pause_not_ready(mock_mower_coordinator) -> None: + """Test the async_pause method when device is not ready.""" + entity = MammotionLawnMowerEntity(mock_mower_coordinator) + + mock_mower_coordinator.data.report_data.dev.sys_status = None + + with patch.object(mock_mower_coordinator, "async_send_command"): + with pytest.raises(HomeAssistantError) as exc_info: + await entity.async_pause() + error = exc_info.value + assert error.translation_domain == DOMAIN + assert error.translation_key == "device_not_ready" + + +async def test_async_pause_not_working_or_returning(mock_mower_coordinator) -> None: + """Test the async_pause method when not in working or returning mode.""" + entity = MammotionLawnMowerEntity(mock_mower_coordinator) + mock_mower_coordinator.async_send_command = AsyncMock() + mock_mower_coordinator.async_request_iot_sync = AsyncMock() + + mock_mower_coordinator.data.report_data.dev.sys_status = WorkMode.MODE_READY + + # Should not call any commands + await entity.async_pause() + + assert mock_mower_coordinator.async_send_command.call_count == 0 + assert mock_mower_coordinator.api.async_request_iot_sync.call_count == 0 + + +async def test_async_pause_command_exception(mock_mower_coordinator) -> None: + """Test the async_pause method with command exceptions.""" + entity = MammotionLawnMowerEntity(mock_mower_coordinator) + mock_error = COMMAND_EXCEPTIONS[0]("Test error") + mock_mower_coordinator.async_send_command = AsyncMock(side_effect=mock_error) + mock_mower_coordinator.async_request_iot_sync = AsyncMock() + + mock_mower_coordinator.data.report_data.dev.sys_status = WorkMode.MODE_WORKING + + with pytest.raises(HomeAssistantError) as exc_info: + await entity.async_pause() + error = exc_info.value + assert error.translation_domain == DOMAIN + assert error.translation_key == "pause_failed" + + assert mock_mower_coordinator.api.async_request_iot_sync.call_count == 1 From b06f0a8794965be7c7be95d05b06616439a53a82 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Thu, 20 Nov 2025 10:40:43 +1300 Subject: [PATCH 30/66] fix generated files --- homeassistant/generated/bluetooth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index 0aea0a8e8a84a..e3011afd99e8a 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -541,7 +541,7 @@ "local_name": "LD-0003", }, { - "connectable": True, + "connectable": True, "domain": "mammotion", "local_name": "Luba-*", "service_uuid": "0000ffff-0000-1000-8000-00805f9b34fb", From 2c0ff57c02a10017091d131f7fdbd67f77312c61 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Thu, 20 Nov 2025 10:51:41 +1300 Subject: [PATCH 31/66] formatting and add quality scale yaml --- .../components/mammotion/manifest.json | 8 +- .../components/mammotion/quality_scale.yaml | 75 +++++++++++++++++++ .../components/mammotion/strings.json | 60 +++++++-------- 3 files changed, 109 insertions(+), 34 deletions(-) create mode 100644 homeassistant/components/mammotion/quality_scale.yaml diff --git a/homeassistant/components/mammotion/manifest.json b/homeassistant/components/mammotion/manifest.json index 16e4a3482ac3c..cd73900530526 100644 --- a/homeassistant/components/mammotion/manifest.json +++ b/homeassistant/components/mammotion/manifest.json @@ -3,14 +3,14 @@ "name": "Mammotion", "bluetooth": [ { + "connectable": true, "local_name": "Luba-*", - "service_uuid": "0000ffff-0000-1000-8000-00805f9b34fb", - "connectable": true + "service_uuid": "0000ffff-0000-1000-8000-00805f9b34fb" }, { + "connectable": true, "local_name": "Yuka-*", - "service_uuid": "0000ffff-0000-1000-8000-00805f9b34fb", - "connectable": true + "service_uuid": "0000ffff-0000-1000-8000-00805f9b34fb" } ], "codeowners": ["@mikey0000"], diff --git a/homeassistant/components/mammotion/quality_scale.yaml b/homeassistant/components/mammotion/quality_scale.yaml new file mode 100644 index 0000000000000..97bd2a84ae9fc --- /dev/null +++ b/homeassistant/components/mammotion/quality_scale.yaml @@ -0,0 +1,75 @@ +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: todo + docs-installation-instructions: todo + docs-removal-instructions: todo + 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: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + 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: todo + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/mammotion/strings.json b/homeassistant/components/mammotion/strings.json index 3d8716a109c87..56e2b5145317f 100644 --- a/homeassistant/components/mammotion/strings.json +++ b/homeassistant/components/mammotion/strings.json @@ -3,13 +3,13 @@ "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", - "bluetooth_and_account_mismatch": "Bluetooth device not found in your account", "no_longer_present": "Device is no longer present", "not_supported": "Device not supported", - "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", - "missing_wifi_data": "Missing Wi-Fi configuration data" + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -19,16 +19,16 @@ "flow_title": "Configure your Mammotion lawn mower", "step": { "bluetooth_confirm": { - "description": "Set up {name}", "data": { "stay_connected_bluetooth": "Keep Bluetooth connected" - } + }, + "description": "Set up {name}" }, "reconfigure": { "data": { - "use_wifi": "Use Wi-Fi", "account_name": "Mammotion email or account number", - "password": "Mammotion account password" + "password": "Mammotion account password", + "use_wifi": "Use Wi-Fi" } }, "user": { @@ -40,30 +40,29 @@ }, "wifi": { "data": { - "use_wifi": "Use Wi-Fi (un-tick and submit to use Bluetooth)", "account_name": "Mammotion email or account number", - "password": "Mammotion account password" + "password": "Mammotion account password", + "use_wifi": "Use Wi-Fi (un-tick and submit to use Bluetooth)" }, - "title": "Connect to Wi-Fi", - "description": "Enter your Mammotion account email or id and password" - } - } - }, - "options": { - "step": { - "init": { - "data": { - "title": "Update Configuration", - "stay_connected_bluetooth": "Keep Bluetooth connected" - } + "description": "Enter your Mammotion account email or id and password", + "title": "Connect to Wi-Fi" } } }, "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." }, @@ -72,15 +71,16 @@ }, "start_failed": { "message": "Failed to start the mower." - }, - "dock_failed": { - "message": "Failed to send the mower to the dock." - }, - "dock_cancel_failed": { - "message": "Failed to stop the mower returning to the dock." - }, - "command_failed": { - "message": "Failed to send command to the mower." + } + }, + "options": { + "step": { + "init": { + "data": { + "stay_connected_bluetooth": "Keep Bluetooth connected", + "title": "Update Configuration" + } + } } } } From aa1a57a0f1b40f78783aa5be0614f8e817d7217f Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Fri, 21 Nov 2025 21:47:12 +1300 Subject: [PATCH 32/66] fix pylint warnings and errors --- .../components/mammotion/__init__.py | 27 +++++++++---------- homeassistant/components/mammotion/config.py | 2 +- .../components/mammotion/coordinator.py | 7 +++-- 3 files changed, 16 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/mammotion/__init__.py b/homeassistant/components/mammotion/__init__.py index a1242352988aa..f90aac2294dd7 100644 --- a/homeassistant/components/mammotion/__init__.py +++ b/homeassistant/components/mammotion/__init__.py @@ -2,6 +2,8 @@ from __future__ import annotations +import contextlib + from aiohttp import ClientConnectorError from pymammotion import CloudIOTGateway from pymammotion.aliyun.model.aep_response import AepResponse @@ -85,10 +87,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> credentials.email = account credentials.password = password try: - try: + with contextlib.suppress(KeyError): cloud_client = await check_and_restore_cloud(hass, entry) - except KeyError: - """No entry found""" if cloud_client is None: await mammotion.login_and_initiate_cloud(account, password) else: @@ -108,13 +108,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> aliyun_mqtt_client = mammotion.mqtt_list.get(f"{account}_aliyun") mammotion_mqtt_client = mammotion.mqtt_list.get(f"{account}_mammotion") - if aliyun_mqtt_client or mammotion_mqtt_client: - if aliyun_mqtt_client: - mqtt_client = aliyun_mqtt_client - store_cloud_credentials(hass, entry, mqtt_client.cloud_client) - else: - mqtt_client = mammotion_mqtt_client - store_cloud_credentials(hass, entry, mqtt_client.cloud_client) + if aliyun_mqtt_client: + mqtt_client = aliyun_mqtt_client + store_cloud_credentials(hass, entry, mqtt_client.cloud_client) + elif mammotion_mqtt_client: + mqtt_client = mammotion_mqtt_client + store_cloud_credentials(hass, entry, mqtt_client.cloud_client) device_list: list[Device] = [] shimed_cloud_devices = [] @@ -232,7 +231,7 @@ async def check_and_restore_cloud( if any( data is None - for data in [ + for data in ( auth_data, region_data, aep_data, @@ -240,7 +239,7 @@ async def check_and_restore_cloud( device_data, connect_data, mammotion_data, - ] + ) ): return None @@ -295,10 +294,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: MammotionConfigEntry) - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): for mower in entry.runtime_data.mowers: - try: + with contextlib.suppress(TimeoutError): await mower.api.mammotion.remove_device(mower.name) - except TimeoutError: - """Do nothing as this sometimes occurs with disconnecting BLE.""" return unload_ok diff --git a/homeassistant/components/mammotion/config.py b/homeassistant/components/mammotion/config.py index d239a7b0c429e..7d8b22b415e96 100644 --- a/homeassistant/components/mammotion/config.py +++ b/homeassistant/components/mammotion/config.py @@ -18,6 +18,6 @@ def __init__( version: int = _STORAGE_VERSION, minor_version: int = _STORAGE_MINOR_VERSION, key: str = _STORAGE_KEY, - ): + ) -> None: """Initialize the configuration store.""" super().__init__(hass, version=version, minor_version=minor_version, key=key) diff --git a/homeassistant/components/mammotion/coordinator.py b/homeassistant/components/mammotion/coordinator.py index 7d6a6293fcf39..4546128e38087 100644 --- a/homeassistant/components/mammotion/coordinator.py +++ b/homeassistant/components/mammotion/coordinator.py @@ -100,8 +100,8 @@ def store_cloud_credentials(self) -> None: def is_online(self) -> bool: """Check if device is online.""" if device := self.api.mammotion.get_device_by_name(self.device_name): - return device.state.online or ( - device.ble is not None and device.ble.client.is_connected + return device.state.online or bool( + device.ble and device.ble.client and device.ble.client.is_connected ) return False @@ -149,14 +149,13 @@ async def async_restore_data(self) -> None: if device := self.api.mammotion.get_device_by_name(self.device_name): device.state = mower_state except InvalidFieldValue: - """invalid""" self.data = MowingDevice() self.api.mammotion.get_device_by_name(self.device_name).state = self.data async def async_save_data(self, data: MowingDevice) -> None: """Get map data from the device.""" store = MammotionConfigStore(self.hass) - current_store = await store.async_load() + current_store: dict[str, Any] = await store.async_load() or {} current_store[self.device_name] = data.to_dict() await store.async_save(current_store) From d4fdbbf3aaf99ed21e7b38226d26bcd5abcb248e Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Fri, 21 Nov 2025 22:12:05 +1300 Subject: [PATCH 33/66] remove if check --- homeassistant/components/mammotion/__init__.py | 9 ++++----- homeassistant/components/mammotion/quality_scale.yaml | 1 - 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/mammotion/__init__.py b/homeassistant/components/mammotion/__init__.py index f90aac2294dd7..25f78b56a01de 100644 --- a/homeassistant/components/mammotion/__init__.py +++ b/homeassistant/components/mammotion/__init__.py @@ -68,11 +68,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> stay_connected_ble = entry.data.get(CONF_STAY_CONNECTED_BLUETOOTH, False) - if not entry.options: - hass.config_entries.async_update_entry( - entry, - options={CONF_STAY_CONNECTED_BLUETOOTH: stay_connected_ble}, - ) + hass.config_entries.async_update_entry( + entry, + options={CONF_STAY_CONNECTED_BLUETOOTH: stay_connected_ble}, + ) stay_connected_ble = entry.options.get(CONF_STAY_CONNECTED_BLUETOOTH, False) diff --git a/homeassistant/components/mammotion/quality_scale.yaml b/homeassistant/components/mammotion/quality_scale.yaml index 97bd2a84ae9fc..491eef8dfc9b4 100644 --- a/homeassistant/components/mammotion/quality_scale.yaml +++ b/homeassistant/components/mammotion/quality_scale.yaml @@ -68,7 +68,6 @@ rules: comment: | Does not have any repairs stale-devices: done - # Platinum async-dependency: todo inject-websession: todo From 96ed80ef7bd48052c27b762bbf9913f1437f83a8 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Sat, 22 Nov 2025 09:08:08 +1300 Subject: [PATCH 34/66] Apply suggestions from code review Co-authored-by: Norbert Rittel --- homeassistant/components/mammotion/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mammotion/strings.json b/homeassistant/components/mammotion/strings.json index 56e2b5145317f..1a9af521a6eb7 100644 --- a/homeassistant/components/mammotion/strings.json +++ b/homeassistant/components/mammotion/strings.json @@ -42,9 +42,9 @@ "data": { "account_name": "Mammotion email or account number", "password": "Mammotion account password", - "use_wifi": "Use Wi-Fi (un-tick and submit to use Bluetooth)" + "use_wifi": "Use Wi-Fi (deselect to use Bluetooth)" }, - "description": "Enter your Mammotion account email or id and password", + "description": "Enter your Mammotion account email or ID and password", "title": "Connect to Wi-Fi" } } @@ -78,7 +78,7 @@ "init": { "data": { "stay_connected_bluetooth": "Keep Bluetooth connected", - "title": "Update Configuration" + "title": "Update configuration" } } } From dd68a3c3b430e0fd736e19ee534608f471a60bc3 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Mon, 24 Nov 2025 10:48:43 +1300 Subject: [PATCH 35/66] update test lawn mower --- tests/components/mammotion/test_lawn_mower.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/components/mammotion/test_lawn_mower.py b/tests/components/mammotion/test_lawn_mower.py index 6ce488a0de056..0c6c4396d60a5 100644 --- a/tests/components/mammotion/test_lawn_mower.py +++ b/tests/components/mammotion/test_lawn_mower.py @@ -135,7 +135,7 @@ async def test_async_dock(mock_mower_coordinator) -> None: """Test the async_dock method.""" entity = MammotionLawnMowerEntity(mock_mower_coordinator) mock_mower_coordinator.async_send_command = AsyncMock() - mock_mower_coordinator.async_request_iot_sync = AsyncMock() + mock_mower_coordinator.api.async_request_iot_sync = AsyncMock() # Test working mode mock_mower_coordinator.data.report_data.dev.sys_status = WorkMode.MODE_WORKING @@ -159,7 +159,7 @@ async def test_async_dock_returning(mock_mower_coordinator) -> None: """Test the async_dock method when already returning.""" entity = MammotionLawnMowerEntity(mock_mower_coordinator) mock_mower_coordinator.async_send_command = AsyncMock() - mock_mower_coordinator.async_request_iot_sync = AsyncMock() + mock_mower_coordinator.api.async_request_iot_sync = AsyncMock() mock_mower_coordinator.data.report_data.dev.sys_status = WorkMode.MODE_RETURNING mock_mower_coordinator.data.report_data.dev.charge_state = 0 @@ -178,7 +178,7 @@ async def test_async_dock_ready(mock_mower_coordinator) -> None: """Test the async_dock method when device is ready.""" entity = MammotionLawnMowerEntity(mock_mower_coordinator) mock_mower_coordinator.async_send_command = AsyncMock() - mock_mower_coordinator.async_request_iot_sync = AsyncMock() + mock_mower_coordinator.api.async_request_iot_sync = AsyncMock() mock_mower_coordinator.data.report_data.dev.sys_status = WorkMode.MODE_READY mock_mower_coordinator.data.report_data.dev.charge_state = 0 @@ -211,7 +211,7 @@ async def test_async_dock_command_exception(mock_mower_coordinator) -> None: entity = MammotionLawnMowerEntity(mock_mower_coordinator) mock_error = COMMAND_EXCEPTIONS[0]("Test error") mock_mower_coordinator.async_send_command = AsyncMock(side_effect=mock_error) - mock_mower_coordinator.async_request_iot_sync = AsyncMock() + mock_mower_coordinator.api.async_request_iot_sync = AsyncMock() mock_mower_coordinator.data.report_data.dev.sys_status = WorkMode.MODE_WORKING mock_mower_coordinator.data.report_data.dev.charge_state = 0 @@ -229,7 +229,7 @@ async def test_async_pause(mock_mower_coordinator) -> None: """Test the async_pause method.""" entity = MammotionLawnMowerEntity(mock_mower_coordinator) mock_mower_coordinator.async_send_command = AsyncMock() - mock_mower_coordinator.async_request_iot_sync = AsyncMock() + mock_mower_coordinator.api.async_request_iot_sync = AsyncMock() # Test working mode mock_mower_coordinator.data.report_data.dev.sys_status = WorkMode.MODE_WORKING @@ -248,7 +248,7 @@ async def test_async_pause_returning(mock_mower_coordinator) -> None: """Test the async_pause method when returning.""" entity = MammotionLawnMowerEntity(mock_mower_coordinator) mock_mower_coordinator.async_send_command = AsyncMock() - mock_mower_coordinator.async_request_iot_sync = AsyncMock() + mock_mower_coordinator.api.async_request_iot_sync = AsyncMock() mock_mower_coordinator.data.report_data.dev.sys_status = WorkMode.MODE_RETURNING @@ -280,7 +280,7 @@ async def test_async_pause_not_working_or_returning(mock_mower_coordinator) -> N """Test the async_pause method when not in working or returning mode.""" entity = MammotionLawnMowerEntity(mock_mower_coordinator) mock_mower_coordinator.async_send_command = AsyncMock() - mock_mower_coordinator.async_request_iot_sync = AsyncMock() + mock_mower_coordinator.api.async_request_iot_sync = AsyncMock() mock_mower_coordinator.data.report_data.dev.sys_status = WorkMode.MODE_READY @@ -296,7 +296,7 @@ async def test_async_pause_command_exception(mock_mower_coordinator) -> None: entity = MammotionLawnMowerEntity(mock_mower_coordinator) mock_error = COMMAND_EXCEPTIONS[0]("Test error") mock_mower_coordinator.async_send_command = AsyncMock(side_effect=mock_error) - mock_mower_coordinator.async_request_iot_sync = AsyncMock() + mock_mower_coordinator.api.async_request_iot_sync = AsyncMock() mock_mower_coordinator.data.report_data.dev.sys_status = WorkMode.MODE_WORKING From 84b8cea395013e4772e1f38fc72fadf575231d21 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Wed, 3 Dec 2025 11:34:58 +1300 Subject: [PATCH 36/66] further fixes to tests and config --- .strict-typing | 1 + .../components/mammotion/__init__.py | 7 +- homeassistant/components/mammotion/config.py | 3 +- .../components/mammotion/config_flow.py | 38 ++-- .../components/mammotion/manifest.json | 2 +- .../components/mammotion/strings.json | 20 +++ mypy.ini | 10 ++ requirements_all.txt | 2 +- .../components/mammotion/test_config_flow.py | 163 +++++++++++++++++- 9 files changed, 217 insertions(+), 29 deletions(-) diff --git a/.strict-typing b/.strict-typing index 66075b93743b9..ade94b1220910 100644 --- a/.strict-typing +++ b/.strict-typing @@ -359,6 +359,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/homeassistant/components/mammotion/__init__.py b/homeassistant/components/mammotion/__init__.py index 25f78b56a01de..6916e6f48c32c 100644 --- a/homeassistant/components/mammotion/__init__.py +++ b/homeassistant/components/mammotion/__init__.py @@ -91,8 +91,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> if cloud_client is None: await mammotion.login_and_initiate_cloud(account, password) else: - # sometimes mammotion_data is missing.... - if cloud_client.mammotion_http is None: + if cloud_client.mammotion_http.login_info is None: mammotion_http = MammotionHTTP() await mammotion_http.login(account, password) cloud_client.set_http(mammotion_http) @@ -298,7 +297,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: MammotionConfigEntry) - return unload_ok -async def async_remove_config_entry(hass: HomeAssistant, entry: MammotionConfigEntry): +async def async_remove_config_entry( + hass: HomeAssistant, entry: MammotionConfigEntry +) -> None: """Remove a config entry.""" await hass.config_entries.async_remove(entry.entry_id) store = MammotionConfigStore(hass) diff --git a/homeassistant/components/mammotion/config.py b/homeassistant/components/mammotion/config.py index 7d8b22b415e96..fc2e09ff1e796 100644 --- a/homeassistant/components/mammotion/config.py +++ b/homeassistant/components/mammotion/config.py @@ -1,5 +1,6 @@ """Config storage for Mammotion integration.""" +from homeassistant.core import HomeAssistant from homeassistant.helpers.storage import Store from .const import DOMAIN @@ -14,7 +15,7 @@ class MammotionConfigStore(Store): def __init__( self, - hass, + hass: HomeAssistant, version: int = _STORAGE_VERSION, minor_version: int = _STORAGE_MINOR_VERSION, key: str = _STORAGE_KEY, diff --git a/homeassistant/components/mammotion/config_flow.py b/homeassistant/components/mammotion/config_flow.py index 52e2b80bfa34d..68e7ee014547c 100644 --- a/homeassistant/components/mammotion/config_flow.py +++ b/homeassistant/components/mammotion/config_flow.py @@ -65,11 +65,9 @@ async def check_and_update_bluetooth_device( ) for device_entry in device_entries: - # Check both MAC address and any other identifiers identifiers = {device_id[1] for device_id in device_entry.identifiers} if device.name in identifiers: await self.async_set_unique_id(entry.data.get(CONF_ACCOUNT_ID)) - # # Update existing entry with BLE info formatted_ble = ( format_mac(self._discovered_device.address) if self._discovered_device @@ -79,7 +77,7 @@ async def check_and_update_bluetooth_device( if ( CONNECTION_BLUETOOTH, formatted_ble, - ) not in device_entry.connections: + ) not in device_entry.connections and formatted_ble is not None: device_registry.async_update_device( device_entry.id, merge_connections={(CONNECTION_BLUETOOTH, formatted_ble)}, @@ -133,24 +131,20 @@ async def async_step_bluetooth_confirm( ) -> ConfigFlowResult: """Confirm discovery.""" - assert self._discovered_device - - if entry := await self.check_and_update_bluetooth_device( - self._discovered_device - ): + assert self._discovered_device is not None + assert self._discovered_device.name is not None + device = self._discovered_device + name = device.name if device.name else "" + if entry := await self.check_and_update_bluetooth_device(device): existing_devices = { - self._discovered_device.name: format_mac( - self._discovered_device.address - ), + name: format_mac(device.address), **entry.data.get(CONF_BLE_DEVICES, None), } self._abort_if_unique_id_configured( updates={CONF_BLE_DEVICES: existing_devices} ) - ble_devices: dict[str, str] = { - self._discovered_device.name: format_mac(self._discovered_device.address) - } + ble_devices: dict[str, str] = {name: format_mac(device.address)} self._config = { CONF_BLE_DEVICES: ble_devices, } @@ -162,7 +156,7 @@ async def async_step_bluetooth_confirm( return self.async_show_form( step_id="bluetooth_confirm", last_step=False, - description_placeholders={"name": self._discovered_device.name}, + description_placeholders={"name": name}, data_schema=vol.Schema( { vol.Optional( @@ -187,14 +181,10 @@ async def async_step_user( for discovery_info in async_discovered_service_info(self.hass): address = discovery_info.address name = discovery_info.name - if address in current_addresses or address in self._discovered_devices: + if address in current_addresses: continue if name is None or not name.startswith(DEVICE_SUPPORT): continue - if self.hass.config_entries.async_entry_for_domain_unique_id( - self.handler, name - ): - continue self._discovered_devices[address] = discovery_info.name @@ -234,7 +224,7 @@ async def async_step_wifi( except HTTPException: errors["base"] = "cannot_connect" - if login_info := mammotion_http.login_info: + if not errors and (login_info := mammotion_http.login_info): user_account = login_info.userInformation.userAccount await self.async_set_unique_id(user_account, raise_on_progress=False) @@ -256,8 +246,12 @@ async def async_step_wifi( ) if user_input is not None and user_input.get(CONF_USE_WIFI) is False: + assert self._discovered_device is not None + assert self._discovered_device.name is not None return self.async_create_entry( - title=self._discovered_device.name if self._discovered_device else "", + title=self._discovered_device.name + if self._discovered_device.name + else "", data={ CONF_USE_WIFI: user_input.get(CONF_USE_WIFI), **self._config, diff --git a/homeassistant/components/mammotion/manifest.json b/homeassistant/components/mammotion/manifest.json index cd73900530526..3f15904a337c5 100644 --- a/homeassistant/components/mammotion/manifest.json +++ b/homeassistant/components/mammotion/manifest.json @@ -20,5 +20,5 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["pymammotion"], - "requirements": ["pymammotion==0.5.59"] + "requirements": ["pymammotion==0.5.64"] } diff --git a/homeassistant/components/mammotion/strings.json b/homeassistant/components/mammotion/strings.json index 1a9af521a6eb7..be888da6a672d 100644 --- a/homeassistant/components/mammotion/strings.json +++ b/homeassistant/components/mammotion/strings.json @@ -22,6 +22,9 @@ "data": { "stay_connected_bluetooth": "Keep Bluetooth connected" }, + "data_description": { + "stay_connected_bluetooth": "If you select this option, the integration will not disconnect from Bluetooth preventing any other device from connecting to the mower." + }, "description": "Set up {name}" }, "reconfigure": { @@ -29,6 +32,11 @@ "account_name": "Mammotion email or account number", "password": "Mammotion account password", "use_wifi": "Use Wi-Fi" + }, + "data_description": { + "account_name": "Mammotion email or account number for your shared mammotion account.", + "password": "Mammotion shared account password", + "use_wifi": "Connect using the cloud, can also connect over Bluetooth as well (deselect to only use Bluetooth)" } }, "user": { @@ -36,6 +44,10 @@ "address": "Device", "stay_connected_bluetooth": "Keep Bluetooth connected" }, + "data_description": { + "address": "Bluetooth address of the mower", + "stay_connected_bluetooth": "If you select this option, the integration will not disconnect from Bluetooth preventing any other device from connecting to the mower." + }, "description": "Select your mower" }, "wifi": { @@ -44,6 +56,11 @@ "password": "Mammotion account password", "use_wifi": "Use Wi-Fi (deselect to use Bluetooth)" }, + "data_description": { + "account_name": "Mammotion email or account number for your shared mammotion account.", + "password": "Mammotion shared account password", + "use_wifi": "Connect using the cloud, can also connect over Bluetooth as well (deselect to only use Bluetooth)" + }, "description": "Enter your Mammotion account email or ID and password", "title": "Connect to Wi-Fi" } @@ -79,6 +96,9 @@ "data": { "stay_connected_bluetooth": "Keep Bluetooth connected", "title": "Update configuration" + }, + "data_description": { + "stay_connected_bluetooth": "If you select this option, the integration will not disconnect from Bluetooth preventing any other device from connecting to the mower." } } } diff --git a/mypy.ini b/mypy.ini index 519bd1cb4c6b0..9ef7ed9e50ee1 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3347,6 +3347,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 b0c2d258d028c..5c1029e6c31ce 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2334,7 +2334,7 @@ pylutron==0.4.1 pymailgunner==1.4 # homeassistant.components.mammotion -pymammotion==0.5.59 +pymammotion==0.5.64 # homeassistant.components.firmata pymata-express==1.19 diff --git a/tests/components/mammotion/test_config_flow.py b/tests/components/mammotion/test_config_flow.py index af07c52d1a399..31e95ae90b227 100644 --- a/tests/components/mammotion/test_config_flow.py +++ b/tests/components/mammotion/test_config_flow.py @@ -18,12 +18,13 @@ from homeassistant.const import 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 # Helpers -def _get_mock_device(name="Luba-ABC123", address="aa:bb:cc:dd:ee:ff"): +def _get_mock_device(name="Luba-ABC123", address="AA:BB:CC:DD:EE:FF"): device = MagicMock(spec=BLEDevice) device.name = name device.address = address @@ -363,3 +364,163 @@ async def test_options_flow(hass: HomeAssistant) -> None: assert result2["type"] == FlowResultType.CREATE_ENTRY assert result2["data"][CONF_STAY_CONNECTED_BLUETOOTH] is True + + +async def test_bluetooth_discovery_update_existing_entry(hass: HomeAssistant) -> 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_registry = dr.async_get(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" + + # Verify device registry was updated + 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.""" + # 1. Device already configured + 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) -> 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) + + # Create a device entry that matches + device_registry = dr.async_get(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" From d5ecb2d75b5a51c1dfde28bb4c8bd07691eb9569 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Thu, 4 Dec 2025 09:16:51 +1300 Subject: [PATCH 37/66] change coordinator to be fixed to return MowerDevice --- homeassistant/components/mammotion/coordinator.py | 6 +++--- homeassistant/components/mammotion/entity.py | 6 +++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/mammotion/coordinator.py b/homeassistant/components/mammotion/coordinator.py index 4546128e38087..aec59e1df7c00 100644 --- a/homeassistant/components/mammotion/coordinator.py +++ b/homeassistant/components/mammotion/coordinator.py @@ -37,7 +37,7 @@ REPORT_INTERVAL = timedelta(minutes=1) -class MammotionBaseUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): +class MammotionBaseUpdateCoordinator(DataUpdateCoordinator[MowingDevice]): """Mammotion DataUpdateCoordinator.""" def __init__( @@ -65,7 +65,7 @@ def __init__( self.update_failures = 0 @abstractmethod - def get_coordinator_data(self, device: MammotionMowerDeviceManager) -> _DataT: + def get_coordinator_data(self, device: MammotionMowerDeviceManager) -> MowingDevice: """Get coordinator data.""" async def async_refresh_login(self) -> None: @@ -110,7 +110,7 @@ async def async_send_command(self, command: str, **kwargs: Any) -> bool | None: return await self.api.async_send_command(self.device_name, command, **kwargs) -class MammotionReportUpdateCoordinator(MammotionBaseUpdateCoordinator[MowingDevice]): +class MammotionReportUpdateCoordinator(MammotionBaseUpdateCoordinator): """Class to manage fetching mammotion report data.""" def __init__( diff --git a/homeassistant/components/mammotion/entity.py b/homeassistant/components/mammotion/entity.py index 4988cbb76d072..08942a66f4c4a 100644 --- a/homeassistant/components/mammotion/entity.py +++ b/homeassistant/components/mammotion/entity.py @@ -1,5 +1,7 @@ """Base class for entities.""" +from typing import cast + from homeassistant.helpers.device_registry import ( CONNECTION_BLUETOOTH, CONNECTION_NETWORK_MAC, @@ -38,7 +40,9 @@ def device_info(self) -> DeviceInfo: mower.state.mqtt_properties is not None and mower.state.mqtt_properties.params.items.extMod is not None ): - model_id = mower.state.mqtt_properties.params.items.extMod.value + model_id = cast( + str, mower.state.mqtt_properties.params.items.extMod.value + ) nick_name = self.coordinator.device.nick_name device_name = ( From 653ec96511776095edd22c84e9bfa0db94f73eff Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Thu, 4 Dec 2025 09:24:55 +1300 Subject: [PATCH 38/66] fix bad if statement --- homeassistant/components/mammotion/lawn_mower.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/mammotion/lawn_mower.py b/homeassistant/components/mammotion/lawn_mower.py index 99f71743d0585..48925cc2f0c95 100644 --- a/homeassistant/components/mammotion/lawn_mower.py +++ b/homeassistant/components/mammotion/lawn_mower.py @@ -76,8 +76,6 @@ def activity(self) -> LawnMowerActivity | None: charge_state = self.rpt_dev_status.charge_state mode = self.rpt_dev_status.sys_status - if mode is None: - return None LOGGER.debug("activity mode %s", mode) if mode == WorkMode.MODE_PAUSE or ( From 1abeb4b99d529485fb019ca7f52c9a03232ee282 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Thu, 4 Dec 2025 20:05:41 +1300 Subject: [PATCH 39/66] update requirements and fix codeowners --- CODEOWNERS | 426 ++++++++++++++--------------------------------------- 1 file changed, 107 insertions(+), 319 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 409c3e54bdfa3..fc401f2d024c9 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -15,7 +15,7 @@ .yamllint @home-assistant/core pyproject.toml @home-assistant/core requirements_test.txt @home-assistant/core -/.devcontainer/ @home-assistant/core @edenhaus +/.devcontainer/ @home-assistant/core /.github/ @home-assistant/core /.vscode/ @home-assistant/core /homeassistant/*.py @home-assistant/core @@ -37,13 +37,6 @@ build.json @home-assistant/supervisor # Other code /homeassistant/scripts/check_config.py @kellerza -# Agent Configurations -AGENTS.md @home-assistant/core -CLAUDE.md @home-assistant/core -/.agent/ @home-assistant/core -/.claude/ @home-assistant/core -/.gemini/ @home-assistant/core - # Integrations /homeassistant/components/abode/ @shred86 /tests/components/abode/ @shred86 @@ -68,8 +61,6 @@ CLAUDE.md @home-assistant/core /tests/components/agent_dvr/ @ispysoftware /homeassistant/components/ai_task/ @home-assistant/core /tests/components/ai_task/ @home-assistant/core -/homeassistant/components/aidot/ @s1eedz @HongBryan -/tests/components/aidot/ @s1eedz @HongBryan /homeassistant/components/air_quality/ @home-assistant/core /tests/components/air_quality/ @home-assistant/core /homeassistant/components/airgradient/ @airgradienthq @joostlek @@ -82,8 +73,6 @@ CLAUDE.md @home-assistant/core /tests/components/airobot/ @mettolen /homeassistant/components/airos/ @CoMPaTech /tests/components/airos/ @CoMPaTech -/homeassistant/components/airpatrol/ @antondalgren -/tests/components/airpatrol/ @antondalgren /homeassistant/components/airq/ @Sibgatulin @dl2080 /tests/components/airq/ @Sibgatulin @dl2080 /homeassistant/components/airthings/ @danielhiversen @LaStrada @@ -162,8 +151,6 @@ CLAUDE.md @home-assistant/core /tests/components/apsystems/ @mawoka-myblock @SonnenladenGmbH /homeassistant/components/aquacell/ @Jordi1990 /tests/components/aquacell/ @Jordi1990 -/homeassistant/components/aqvify/ @astrandb -/tests/components/aqvify/ @astrandb /homeassistant/components/aranet/ @aschmitz @thecode @anrijs /tests/components/aranet/ @aschmitz @thecode @anrijs /homeassistant/components/arcam_fmj/ @elupus @@ -181,6 +168,7 @@ CLAUDE.md @home-assistant/core /tests/components/asuswrt/ @kennedyshead @ollo69 @Vaskivskyi /homeassistant/components/atag/ @MatsNL /tests/components/atag/ @MatsNL +/homeassistant/components/aten_pe/ @mtdcr /homeassistant/components/atome/ @baqs /homeassistant/components/august/ @bdraco /tests/components/august/ @bdraco @@ -196,10 +184,7 @@ CLAUDE.md @home-assistant/core /tests/components/auth/ @home-assistant/core /homeassistant/components/automation/ @home-assistant/core /tests/components/automation/ @home-assistant/core -/homeassistant/components/autoskope/ @mcisk -/tests/components/autoskope/ @mcisk /homeassistant/components/avea/ @pattyland -/tests/components/avea/ @pattyland /homeassistant/components/awair/ @ahayworth @ricohageman /tests/components/awair/ @ahayworth @ricohageman /homeassistant/components/aws_s3/ @tomasbedrich @@ -225,19 +210,18 @@ CLAUDE.md @home-assistant/core /tests/components/balboa/ @garbled1 @natekspencer /homeassistant/components/bang_olufsen/ @mj23000 /tests/components/bang_olufsen/ @mj23000 -/homeassistant/components/battery/ @home-assistant/core -/tests/components/battery/ @home-assistant/core /homeassistant/components/bayesian/ @HarvsG /tests/components/bayesian/ @HarvsG +/homeassistant/components/beewi_smartclim/ @alemuro /homeassistant/components/binary_sensor/ @home-assistant/core /tests/components/binary_sensor/ @home-assistant/core /homeassistant/components/bizkaibus/ @UgaitzEtxebarria -/homeassistant/components/blebox/ @bbx-a @swistakm @bkobus-bbx -/tests/components/blebox/ @bbx-a @swistakm @bkobus-bbx -/homeassistant/components/blink/ @fronzbot -/tests/components/blink/ @fronzbot -/homeassistant/components/blue_current/ @gleeuwen @jtodorova23 -/tests/components/blue_current/ @gleeuwen @jtodorova23 +/homeassistant/components/blebox/ @bbx-a @swistakm +/tests/components/blebox/ @bbx-a @swistakm +/homeassistant/components/blink/ @fronzbot @mkmer +/tests/components/blink/ @fronzbot @mkmer +/homeassistant/components/blue_current/ @gleeuwen @NickKoepr @jtodorova23 +/tests/components/blue_current/ @gleeuwen @NickKoepr @jtodorova23 /homeassistant/components/bluemaestro/ @bdraco /tests/components/bluemaestro/ @bdraco /homeassistant/components/blueprint/ @home-assistant/core @@ -248,20 +232,20 @@ CLAUDE.md @home-assistant/core /tests/components/bluetooth/ @bdraco /homeassistant/components/bluetooth_adapters/ @bdraco /tests/components/bluetooth_adapters/ @bdraco +/homeassistant/components/bmw_connected_drive/ @gerard33 @rikroe +/tests/components/bmw_connected_drive/ @gerard33 @rikroe /homeassistant/components/bond/ @bdraco @prystupa @joshs85 @marciogranzotto /tests/components/bond/ @bdraco @prystupa @joshs85 @marciogranzotto /homeassistant/components/bosch_alarm/ @mag1024 @sanjay900 /tests/components/bosch_alarm/ @mag1024 @sanjay900 -/homeassistant/components/bosch_shc/ @tschamm @mosandlt -/tests/components/bosch_shc/ @tschamm @mosandlt -/homeassistant/components/brands/ @home-assistant/core -/tests/components/brands/ @home-assistant/core +/homeassistant/components/bosch_shc/ @tschamm +/tests/components/bosch_shc/ @tschamm /homeassistant/components/braviatv/ @bieniu @Drafteed /tests/components/braviatv/ @bieniu @Drafteed /homeassistant/components/bring/ @miaucl @tr4nt0r /tests/components/bring/ @miaucl @tr4nt0r -/homeassistant/components/broadlink/ @danielhiversen @felipediel @L-I-Am -/tests/components/broadlink/ @danielhiversen @felipediel @L-I-Am +/homeassistant/components/broadlink/ @danielhiversen @felipediel @L-I-Am @eifinger +/tests/components/broadlink/ @danielhiversen @felipediel @L-I-Am @eifinger /homeassistant/components/brother/ @bieniu /tests/components/brother/ @bieniu /homeassistant/components/brottsplatskartan/ @gjohansson-ST @@ -285,24 +269,14 @@ CLAUDE.md @home-assistant/core /tests/components/cambridge_audio/ @noahhusby /homeassistant/components/camera/ @home-assistant/core /tests/components/camera/ @home-assistant/core -/homeassistant/components/casper_glow/ @mikeodr -/tests/components/casper_glow/ @mikeodr /homeassistant/components/cast/ @emontnemery /tests/components/cast/ @emontnemery /homeassistant/components/ccm15/ @ocalvo /tests/components/ccm15/ @ocalvo -/homeassistant/components/centriconnect/ @gresrun -/tests/components/centriconnect/ @gresrun /homeassistant/components/cert_expiry/ @jjlawren /tests/components/cert_expiry/ @jjlawren /homeassistant/components/chacon_dio/ @cnico /tests/components/chacon_dio/ @cnico -/homeassistant/components/chef_iq/ @Invader444 -/tests/components/chef_iq/ @Invader444 -/homeassistant/components/chess_com/ @joostlek -/tests/components/chess_com/ @joostlek -/homeassistant/components/cielo_home/ @ihsan-cielo @mudasar-cielo -/tests/components/cielo_home/ @ihsan-cielo @mudasar-cielo /homeassistant/components/cisco_ios/ @fbradyirl /homeassistant/components/cisco_mobility_express/ @fbradyirl /homeassistant/components/cisco_webex_teams/ @fbradyirl @@ -312,8 +286,6 @@ CLAUDE.md @home-assistant/core /tests/components/cloud/ @home-assistant/cloud /homeassistant/components/cloudflare/ @ludeeus @ctalkington /tests/components/cloudflare/ @ludeeus @ctalkington -/homeassistant/components/cloudflare_r2/ @corrreia -/tests/components/cloudflare_r2/ @corrreia /homeassistant/components/co2signal/ @jpbede @VIKTORVAV99 /tests/components/co2signal/ @jpbede @VIKTORVAV99 /homeassistant/components/coinbase/ @tombrien @@ -334,8 +306,8 @@ CLAUDE.md @home-assistant/core /tests/components/config/ @home-assistant/core /homeassistant/components/configurator/ @home-assistant/core /tests/components/configurator/ @home-assistant/core -/homeassistant/components/control4/ @lawtancool @davidrecordon -/tests/components/control4/ @lawtancool @davidrecordon +/homeassistant/components/control4/ @lawtancool +/tests/components/control4/ @lawtancool /homeassistant/components/conversation/ @home-assistant/core @synesthesiam @arturpragacz /tests/components/conversation/ @home-assistant/core @synesthesiam @arturpragacz /homeassistant/components/cookidoo/ @miaucl @@ -354,8 +326,6 @@ CLAUDE.md @home-assistant/core /tests/components/cync/ @Kinachi249 /homeassistant/components/daikin/ @fredrike /tests/components/daikin/ @fredrike -/homeassistant/components/data_grand_lyon/ @Crocmagnon -/tests/components/data_grand_lyon/ @Crocmagnon /homeassistant/components/date/ @home-assistant/core /tests/components/date/ @home-assistant/core /homeassistant/components/datetime/ @home-assistant/core @@ -373,8 +343,6 @@ CLAUDE.md @home-assistant/core /tests/components/deluge/ @tkdrob /homeassistant/components/demo/ @home-assistant/core /tests/components/demo/ @home-assistant/core -/homeassistant/components/denon_rs232/ @balloob -/tests/components/denon_rs232/ @balloob /homeassistant/components/denonavr/ @ol-iver @starkillerOG /tests/components/denonavr/ @ol-iver @starkillerOG /homeassistant/components/derivative/ @afaucogney @karwosts @@ -409,10 +377,6 @@ CLAUDE.md @home-assistant/core /tests/components/dlna_dms/ @chishm /homeassistant/components/dnsip/ @gjohansson-ST /tests/components/dnsip/ @gjohansson-ST -/homeassistant/components/door/ @home-assistant/core -/tests/components/door/ @home-assistant/core -/homeassistant/components/doorbell/ @home-assistant/core -/tests/components/doorbell/ @home-assistant/core /homeassistant/components/doorbird/ @oblogic7 @bdraco @flacjacket /tests/components/doorbird/ @oblogic7 @bdraco @flacjacket /homeassistant/components/dormakaba_dkey/ @emontnemery @@ -423,8 +387,6 @@ CLAUDE.md @home-assistant/core /tests/components/dremel_3d_printer/ @tkdrob /homeassistant/components/drop_connect/ @ChandlerSystems @pfrazer /tests/components/drop_connect/ @ChandlerSystems @pfrazer -/homeassistant/components/dropbox/ @bdr99 -/tests/components/dropbox/ @bdr99 /homeassistant/components/droplet/ @sarahseidman /tests/components/droplet/ @sarahseidman /homeassistant/components/dsmr/ @Robbie1221 @@ -433,18 +395,16 @@ CLAUDE.md @home-assistant/core /tests/components/dsmr_reader/ @sorted-bits @glodenox @erwindouna /homeassistant/components/duckdns/ @tr4nt0r /tests/components/duckdns/ @tr4nt0r -/homeassistant/components/duco/ @ronaldvdmeer -/tests/components/duco/ @ronaldvdmeer +/homeassistant/components/duke_energy/ @hunterjm +/tests/components/duke_energy/ @hunterjm /homeassistant/components/duotecno/ @cereal2nd /tests/components/duotecno/ @cereal2nd -/homeassistant/components/dwd_weather_warnings/ @runningman84 @stephan192 -/tests/components/dwd_weather_warnings/ @runningman84 @stephan192 +/homeassistant/components/dwd_weather_warnings/ @runningman84 @stephan192 @andarotajo +/tests/components/dwd_weather_warnings/ @runningman84 @stephan192 @andarotajo /homeassistant/components/dynalite/ @ziv1234 /tests/components/dynalite/ @ziv1234 /homeassistant/components/eafm/ @Jc2k /tests/components/eafm/ @Jc2k -/homeassistant/components/earn_e_p1/ @Miggets7 -/tests/components/earn_e_p1/ @Miggets7 /homeassistant/components/easyenergy/ @klaasnicolaas /tests/components/easyenergy/ @klaasnicolaas /homeassistant/components/ecoforest/ @pjanuario @@ -455,13 +415,9 @@ CLAUDE.md @home-assistant/core /tests/components/ecovacs/ @mib1185 @edenhaus @Augar /homeassistant/components/ecowitt/ @pvizeli /tests/components/ecowitt/ @pvizeli -/homeassistant/components/edifier_infrared/ @abmantis -/tests/components/edifier_infrared/ @abmantis /homeassistant/components/efergy/ @tkdrob /tests/components/efergy/ @tkdrob /homeassistant/components/egardia/ @jeroenterheerdt -/homeassistant/components/egauge/ @neggert -/tests/components/egauge/ @neggert /homeassistant/components/eheimdigital/ @autinerd /tests/components/eheimdigital/ @autinerd /homeassistant/components/ekeybionyx/ @richardpolzer @@ -494,8 +450,6 @@ CLAUDE.md @home-assistant/core /tests/components/emulated_kasa/ @kbickar /homeassistant/components/energenie_power_sockets/ @gnumpi /tests/components/energenie_power_sockets/ @gnumpi -/homeassistant/components/energieleser/ @AjinkyaGokhale @amitkio -/tests/components/energieleser/ @AjinkyaGokhale @amitkio /homeassistant/components/energy/ @home-assistant/core /tests/components/energy/ @home-assistant/core /homeassistant/components/energyid/ @JrtPec @Molier @@ -506,14 +460,12 @@ CLAUDE.md @home-assistant/core /tests/components/enigma2/ @autinerd /homeassistant/components/enphase_envoy/ @bdraco @cgarwood @catsmanac /tests/components/enphase_envoy/ @bdraco @cgarwood @catsmanac -/homeassistant/components/entur_public_transport/ @hfurubotten @SanderBlom -/homeassistant/components/envertech_evt800/ @daniel-bergmann-00 -/tests/components/envertech_evt800/ @daniel-bergmann-00 +/homeassistant/components/entur_public_transport/ @hfurubotten /homeassistant/components/environment_canada/ @gwww @michaeldavie /tests/components/environment_canada/ @gwww @michaeldavie /homeassistant/components/ephember/ @ttroy50 @roberty99 -/homeassistant/components/epic_games_store/ @Quentame -/tests/components/epic_games_store/ @Quentame +/homeassistant/components/epic_games_store/ @hacf-fr @Quentame +/tests/components/epic_games_store/ @hacf-fr @Quentame /homeassistant/components/epion/ @lhgravendeel /tests/components/epion/ @lhgravendeel /homeassistant/components/epson/ @pszafer @@ -528,8 +480,6 @@ CLAUDE.md @home-assistant/core /tests/components/essent/ @jaapp /homeassistant/components/eufylife_ble/ @bdr99 /tests/components/eufylife_ble/ @bdr99 -/homeassistant/components/eurotronic_cometblue/ @rikroe -/tests/components/eurotronic_cometblue/ @rikroe /homeassistant/components/event/ @home-assistant/core /tests/components/event/ @home-assistant/core /homeassistant/components/evohome/ @zxdavb @@ -562,8 +512,6 @@ CLAUDE.md @home-assistant/core /tests/components/fireservicerota/ @cyberjunky /homeassistant/components/firmata/ @DaAwesomeP /tests/components/firmata/ @DaAwesomeP -/homeassistant/components/fish_audio/ @noambav -/tests/components/fish_audio/ @noambav /homeassistant/components/fitbit/ @allenporter /tests/components/fitbit/ @allenporter /homeassistant/components/fivem/ @Sander0542 @@ -578,8 +526,6 @@ CLAUDE.md @home-assistant/core /tests/components/flo/ @dmulcahey /homeassistant/components/flume/ @ChrisMandich @bdraco @jeeftor /tests/components/flume/ @ChrisMandich @bdraco @jeeftor -/homeassistant/components/fluss/ @fluss @Marcello17 -/tests/components/fluss/ @fluss @Marcello17 /homeassistant/components/flux_led/ @icemanch /tests/components/flux_led/ @icemanch /homeassistant/components/forecast_solar/ @klaasnicolaas @frenck @@ -589,18 +535,18 @@ CLAUDE.md @home-assistant/core /homeassistant/components/fortios/ @kimfrellsen /homeassistant/components/foscam/ @Foscam-wangzhengyu /tests/components/foscam/ @Foscam-wangzhengyu -/homeassistant/components/freebox/ @hacf-fr/reviewers @Quentame -/tests/components/freebox/ @hacf-fr/reviewers @Quentame +/homeassistant/components/freebox/ @hacf-fr @Quentame +/tests/components/freebox/ @hacf-fr @Quentame /homeassistant/components/freedompro/ @stefano055415 /tests/components/freedompro/ @stefano055415 -/homeassistant/components/freshr/ @SierraNL -/tests/components/freshr/ @SierraNL /homeassistant/components/fressnapf_tracker/ @eifinger /tests/components/fressnapf_tracker/ @eifinger /homeassistant/components/fritz/ @AaronDavidSchneider @chemelli74 @mib1185 /tests/components/fritz/ @AaronDavidSchneider @chemelli74 @mib1185 /homeassistant/components/fritzbox/ @mib1185 @flabbamann /tests/components/fritzbox/ @mib1185 @flabbamann +/homeassistant/components/fritzbox_callmonitor/ @cdce8p +/tests/components/fritzbox_callmonitor/ @cdce8p /homeassistant/components/fronius/ @farmio /tests/components/fronius/ @farmio /homeassistant/components/frontend/ @home-assistant/frontend @@ -611,18 +557,12 @@ CLAUDE.md @home-assistant/core /tests/components/fujitsu_fglair/ @crevetor /homeassistant/components/fully_kiosk/ @cgarwood /tests/components/fully_kiosk/ @cgarwood -/homeassistant/components/fumis/ @frenck -/tests/components/fumis/ @frenck /homeassistant/components/fyta/ @dontinelli /tests/components/fyta/ @dontinelli -/homeassistant/components/garage_door/ @home-assistant/core -/tests/components/garage_door/ @home-assistant/core /homeassistant/components/garages_amsterdam/ @klaasnicolaas /tests/components/garages_amsterdam/ @klaasnicolaas /homeassistant/components/gardena_bluetooth/ @elupus /tests/components/gardena_bluetooth/ @elupus -/homeassistant/components/gate/ @home-assistant/core -/tests/components/gate/ @home-assistant/core /homeassistant/components/gdacs/ @exxamalte /tests/components/gdacs/ @exxamalte /homeassistant/components/generic/ @davet2001 @@ -631,8 +571,6 @@ CLAUDE.md @home-assistant/core /tests/components/generic_hygrostat/ @Shulyaka /homeassistant/components/geniushub/ @manzanotti /tests/components/geniushub/ @manzanotti -/homeassistant/components/gentex_homelink/ @Gentex-Corporation/Homelink @rjones-gentex -/tests/components/gentex_homelink/ @Gentex-Corporation/Homelink @rjones-gentex /homeassistant/components/geo_json_events/ @exxamalte /tests/components/geo_json_events/ @exxamalte /homeassistant/components/geo_location/ @home-assistant/core @@ -645,8 +583,6 @@ CLAUDE.md @home-assistant/core /tests/components/geonetnz_quakes/ @exxamalte /homeassistant/components/geonetnz_volcano/ @exxamalte /tests/components/geonetnz_volcano/ @exxamalte -/homeassistant/components/ghost/ @johnonolan -/tests/components/ghost/ @johnonolan /homeassistant/components/gios/ @bieniu /tests/components/gios/ @bieniu /homeassistant/components/github/ @timmo001 @ludeeus @@ -695,10 +631,6 @@ CLAUDE.md @home-assistant/core /tests/components/gpsd/ @fabaff @jrieger /homeassistant/components/gree/ @cmroche /tests/components/gree/ @cmroche -/homeassistant/components/green_planet_energy/ @petschni -/tests/components/green_planet_energy/ @petschni -/homeassistant/components/greencell/ @BrzezowskiGC -/tests/components/greencell/ @BrzezowskiGC /homeassistant/components/greeneye_monitor/ @jkeljo /tests/components/greeneye_monitor/ @jkeljo /homeassistant/components/group/ @home-assistant/core @@ -707,8 +639,6 @@ CLAUDE.md @home-assistant/core /tests/components/growatt_server/ @johanzander /homeassistant/components/guardian/ @bachya /tests/components/guardian/ @bachya -/homeassistant/components/guntamatic/ @JensTimmerman -/tests/components/guntamatic/ @JensTimmerman /homeassistant/components/habitica/ @tr4nt0r /tests/components/habitica/ @tr4nt0r /homeassistant/components/hanna/ @bestycame @@ -721,21 +651,14 @@ CLAUDE.md @home-assistant/core /tests/components/harmony/ @ehendrix23 @bdraco @mkeesey @Aohzan /homeassistant/components/hassio/ @home-assistant/supervisor /tests/components/hassio/ @home-assistant/supervisor -/homeassistant/components/hdfury/ @glenndehaan -/tests/components/hdfury/ @glenndehaan /homeassistant/components/hdmi_cec/ @inytar /tests/components/hdmi_cec/ @inytar /homeassistant/components/heatmiser/ @andylockran -/homeassistant/components/hegel/ @boazca -/tests/components/hegel/ @boazca -/homeassistant/components/helty/ @ebaschiera -/tests/components/helty/ @ebaschiera /homeassistant/components/heos/ @andrewsayre /tests/components/heos/ @andrewsayre /homeassistant/components/here_travel_time/ @eifinger /tests/components/here_travel_time/ @eifinger -/homeassistant/components/hikvision/ @mezz64 @ptarjan -/tests/components/hikvision/ @mezz64 @ptarjan +/homeassistant/components/hikvision/ @mezz64 /homeassistant/components/hikvisioncam/ @fbradyirl /homeassistant/components/hisense_aehw4a1/ @bannhead /tests/components/hisense_aehw4a1/ @bannhead @@ -773,24 +696,18 @@ CLAUDE.md @home-assistant/core /tests/components/homekit_controller/ @Jc2k @bdraco /homeassistant/components/homematic/ @pvizeli /tests/components/homematic/ @pvizeli -/homeassistant/components/homematicip_cloud/ @hahn-th @lackas -/tests/components/homematicip_cloud/ @hahn-th @lackas -/homeassistant/components/homevolt/ @danielhiversen @liudger -/tests/components/homevolt/ @danielhiversen @liudger +/homeassistant/components/homematicip_cloud/ @hahn-th +/tests/components/homematicip_cloud/ @hahn-th /homeassistant/components/homewizard/ @DCSBL /tests/components/homewizard/ @DCSBL -/homeassistant/components/honeywell/ @mkmer -/tests/components/honeywell/ @mkmer -/homeassistant/components/honeywell_string_lights/ @balloob -/tests/components/honeywell_string_lights/ @balloob -/homeassistant/components/hr_energy_qube/ @MattieGit -/tests/components/hr_energy_qube/ @MattieGit -/homeassistant/components/html5/ @alexyao2015 @tr4nt0r -/tests/components/html5/ @alexyao2015 @tr4nt0r +/homeassistant/components/honeywell/ @rdfurman @mkmer +/tests/components/honeywell/ @rdfurman @mkmer +/homeassistant/components/html5/ @alexyao2015 +/tests/components/html5/ @alexyao2015 /homeassistant/components/http/ @home-assistant/core /tests/components/http/ @home-assistant/core -/homeassistant/components/huawei_lte/ @fphammerle -/tests/components/huawei_lte/ @fphammerle +/homeassistant/components/huawei_lte/ @scop @fphammerle +/tests/components/huawei_lte/ @scop @fphammerle /homeassistant/components/hue/ @marcelveldt /tests/components/hue/ @marcelveldt /homeassistant/components/hue_ble/ @flip-dots @@ -799,8 +716,6 @@ CLAUDE.md @home-assistant/core /tests/components/huisbaasje/ @dennisschroer /homeassistant/components/humidifier/ @home-assistant/core @Shulyaka /tests/components/humidifier/ @home-assistant/core @Shulyaka -/homeassistant/components/humidity/ @home-assistant/core -/tests/components/humidity/ @home-assistant/core /homeassistant/components/hunterdouglas_powerview/ @bdraco @kingy444 @trullock /tests/components/hunterdouglas_powerview/ @bdraco @kingy444 @trullock /homeassistant/components/husqvarna_automower/ @Thomas55555 @@ -815,8 +730,6 @@ CLAUDE.md @home-assistant/core /tests/components/hydrawise/ @dknowles2 @thomaskistler @ptcryan /homeassistant/components/hyperion/ @dermotduffy /tests/components/hyperion/ @dermotduffy -/homeassistant/components/hypontech/ @jcisio -/tests/components/hypontech/ @jcisio /homeassistant/components/ialarm/ @RyuzakiKK /tests/components/ialarm/ @RyuzakiKK /homeassistant/components/iammeter/ @lewei50 @@ -826,14 +739,10 @@ CLAUDE.md @home-assistant/core /tests/components/icloud/ @Quentame @nzapponi /homeassistant/components/idasen_desk/ @abmantis /tests/components/idasen_desk/ @abmantis -/homeassistant/components/idrive_e2/ @patrickvorgers -/tests/components/idrive_e2/ @patrickvorgers /homeassistant/components/igloohome/ @keithle888 /tests/components/igloohome/ @keithle888 /homeassistant/components/ign_sismologia/ @exxamalte /tests/components/ign_sismologia/ @exxamalte -/homeassistant/components/illuminance/ @home-assistant/core -/tests/components/illuminance/ @home-assistant/core /homeassistant/components/image/ @home-assistant/core /tests/components/image/ @home-assistant/core /homeassistant/components/image_processing/ @home-assistant/core @@ -848,20 +757,14 @@ CLAUDE.md @home-assistant/core /tests/components/imgw_pib/ @bieniu /homeassistant/components/immich/ @mib1185 /tests/components/immich/ @mib1185 -/homeassistant/components/imou/ @Imou-OpenPlatform -/tests/components/imou/ @Imou-OpenPlatform /homeassistant/components/improv_ble/ @emontnemery /tests/components/improv_ble/ @emontnemery /homeassistant/components/incomfort/ @jbouwh /tests/components/incomfort/ @jbouwh -/homeassistant/components/indevolt/ @xirt -/tests/components/indevolt/ @xirt /homeassistant/components/inels/ @epdevlab /tests/components/inels/ @epdevlab -/homeassistant/components/influxdb/ @mdegat01 @Robbie1221 -/tests/components/influxdb/ @mdegat01 @Robbie1221 -/homeassistant/components/infrared/ @home-assistant/core -/tests/components/infrared/ @home-assistant/core +/homeassistant/components/influxdb/ @mdegat01 +/tests/components/influxdb/ @mdegat01 /homeassistant/components/inkbird/ @bdraco /tests/components/inkbird/ @bdraco /homeassistant/components/input_boolean/ @home-assistant/core @@ -876,18 +779,14 @@ CLAUDE.md @home-assistant/core /tests/components/input_select/ @home-assistant/core /homeassistant/components/input_text/ @home-assistant/core /tests/components/input_text/ @home-assistant/core -/homeassistant/components/insteon/ @teharris1 @ssyrell -/tests/components/insteon/ @teharris1 @ssyrell +/homeassistant/components/insteon/ @teharris1 +/tests/components/insteon/ @teharris1 /homeassistant/components/integration/ @dgomes /tests/components/integration/ @dgomes -/homeassistant/components/intelliclima/ @dvdinth -/tests/components/intelliclima/ @dvdinth /homeassistant/components/intellifire/ @jeeftor /tests/components/intellifire/ @jeeftor /homeassistant/components/intent/ @home-assistant/core @synesthesiam @arturpragacz /tests/components/intent/ @home-assistant/core @synesthesiam @arturpragacz -/homeassistant/components/intent_script/ @arturpragacz -/tests/components/intent_script/ @arturpragacz /homeassistant/components/intesishome/ @jnimmo /homeassistant/components/iometer/ @jukrebs /tests/components/iometer/ @jukrebs @@ -933,8 +832,8 @@ CLAUDE.md @home-assistant/core /tests/components/jewish_calendar/ @tsvi /homeassistant/components/justnimbus/ @kvanzuijlen /tests/components/justnimbus/ @kvanzuijlen -/homeassistant/components/jvc_projector/ @SteveEasley -/tests/components/jvc_projector/ @SteveEasley +/homeassistant/components/jvc_projector/ @SteveEasley @msavazzi +/tests/components/jvc_projector/ @SteveEasley @msavazzi /homeassistant/components/kaiterra/ @Michsior14 /homeassistant/components/kaleidescape/ @SteveEasley /tests/components/kaleidescape/ @SteveEasley @@ -947,12 +846,8 @@ CLAUDE.md @home-assistant/core /homeassistant/components/keyboard_remote/ @bendavid @lanrat /homeassistant/components/keymitt_ble/ @spycle /tests/components/keymitt_ble/ @spycle -/homeassistant/components/kiosker/ @Claeysson -/tests/components/kiosker/ @Claeysson /homeassistant/components/kitchen_sink/ @home-assistant/core /tests/components/kitchen_sink/ @home-assistant/core -/homeassistant/components/klik_aan_klik_uit/ @Phunkafizer -/tests/components/klik_aan_klik_uit/ @Phunkafizer /homeassistant/components/kmtronic/ @dgomes /tests/components/kmtronic/ @dgomes /homeassistant/components/knocki/ @joostlek @jgatto1 @JakeBosh @@ -961,6 +856,8 @@ CLAUDE.md @home-assistant/core /tests/components/knx/ @Julius2342 @farmio @marvin-w /homeassistant/components/kodi/ @OnFreund /tests/components/kodi/ @OnFreund +/homeassistant/components/konnected/ @heythisisnate +/tests/components/konnected/ @heythisisnate /homeassistant/components/kostal_plenticore/ @stegm /tests/components/kostal_plenticore/ @stegm /homeassistant/components/kraken/ @eifinger @@ -997,22 +894,14 @@ CLAUDE.md @home-assistant/core /tests/components/lektrico/ @lektrico /homeassistant/components/letpot/ @jpelgrom /tests/components/letpot/ @jpelgrom -/homeassistant/components/lg_infrared/ @abmantis -/tests/components/lg_infrared/ @abmantis /homeassistant/components/lg_netcast/ @Drafteed @splinter98 /tests/components/lg_netcast/ @Drafteed @splinter98 /homeassistant/components/lg_thinq/ @LG-ThinQ-Integration /tests/components/lg_thinq/ @LG-ThinQ-Integration -/homeassistant/components/lg_tv_rs232/ @balloob -/tests/components/lg_tv_rs232/ @balloob /homeassistant/components/libre_hardware_monitor/ @Sab44 /tests/components/libre_hardware_monitor/ @Sab44 -/homeassistant/components/lichess/ @aryanhasgithub -/tests/components/lichess/ @aryanhasgithub /homeassistant/components/lidarr/ @tkdrob /tests/components/lidarr/ @tkdrob -/homeassistant/components/liebherr/ @mettolen -/tests/components/liebherr/ @mettolen /homeassistant/components/lifx/ @Djelibeybi /tests/components/lifx/ @Djelibeybi /homeassistant/components/light/ @home-assistant/core @@ -1026,8 +915,6 @@ CLAUDE.md @home-assistant/core /tests/components/litterrobot/ @natekspencer @tkdrob /homeassistant/components/livisi/ @StefanIacobLivisi @planbnet /tests/components/livisi/ @StefanIacobLivisi @planbnet -/homeassistant/components/llm/ @home-assistant/core -/tests/components/llm/ @home-assistant/core /homeassistant/components/local_calendar/ @allenporter /tests/components/local_calendar/ @allenporter /homeassistant/components/local_ip/ @issacg @@ -1040,8 +927,6 @@ CLAUDE.md @home-assistant/core /tests/components/logbook/ @home-assistant/core /homeassistant/components/logger/ @home-assistant/core /tests/components/logger/ @home-assistant/core -/homeassistant/components/lojack/ @devinslick -/tests/components/lojack/ @devinslick /homeassistant/components/london_underground/ @jpbede /tests/components/london_underground/ @jpbede /homeassistant/components/lookin/ @ANMalko @bdraco @@ -1065,6 +950,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 @@ -1092,8 +979,6 @@ CLAUDE.md @home-assistant/core /homeassistant/components/mediaroom/ @dgomes /homeassistant/components/melcloud/ @erwindouna /tests/components/melcloud/ @erwindouna -/homeassistant/components/melcloud_home/ @erwindouna -/tests/components/melcloud_home/ @erwindouna /homeassistant/components/melissa/ @kennedyshead /tests/components/melissa/ @kennedyshead /homeassistant/components/melnor/ @vanstinator @@ -1102,8 +987,8 @@ CLAUDE.md @home-assistant/core /tests/components/met/ @danielhiversen /homeassistant/components/met_eireann/ @DylanGore /tests/components/met_eireann/ @DylanGore -/homeassistant/components/meteo_france/ @hacf-fr/reviewers @oncleben31 @Quentame -/tests/components/meteo_france/ @hacf-fr/reviewers @oncleben31 @Quentame +/homeassistant/components/meteo_france/ @hacf-fr @oncleben31 @Quentame +/tests/components/meteo_france/ @hacf-fr @oncleben31 @Quentame /homeassistant/components/meteo_lt/ @xE1H /tests/components/meteo_lt/ @xE1H /homeassistant/components/meteoalarm/ @rolfberkenbosch @@ -1121,12 +1006,10 @@ CLAUDE.md @home-assistant/core /tests/components/mill/ @danielhiversen /homeassistant/components/min_max/ @gjohansson-ST /tests/components/min_max/ @gjohansson-ST -/homeassistant/components/minecraft_server/ @elmurato @zachdeibert -/tests/components/minecraft_server/ @elmurato @zachdeibert +/homeassistant/components/minecraft_server/ @elmurato +/tests/components/minecraft_server/ @elmurato /homeassistant/components/minio/ @tkislan /tests/components/minio/ @tkislan -/homeassistant/components/mitsubishi_comfort/ @nikolairahimi -/tests/components/mitsubishi_comfort/ @nikolairahimi /homeassistant/components/moat/ @bdraco /tests/components/moat/ @bdraco /homeassistant/components/mobile_app/ @home-assistant/core @@ -1137,8 +1020,6 @@ CLAUDE.md @home-assistant/core /tests/components/modern_forms/ @wonderslug /homeassistant/components/moehlenhoff_alpha2/ @j-a-n /tests/components/moehlenhoff_alpha2/ @j-a-n -/homeassistant/components/moisture/ @home-assistant/core -/tests/components/moisture/ @home-assistant/core /homeassistant/components/monarch_money/ @jeeftor /tests/components/monarch_money/ @jeeftor /homeassistant/components/monoprice/ @etsinko @OnFreund @@ -1149,8 +1030,6 @@ CLAUDE.md @home-assistant/core /tests/components/moon/ @fabaff @frenck /homeassistant/components/mopeka/ @bdraco /tests/components/mopeka/ @bdraco -/homeassistant/components/motion/ @home-assistant/core -/tests/components/motion/ @home-assistant/core /homeassistant/components/motion_blinds/ @starkillerOG /tests/components/motion_blinds/ @starkillerOG /homeassistant/components/motionblinds_ble/ @LennP @jerrybboy @@ -1161,8 +1040,7 @@ CLAUDE.md @home-assistant/core /tests/components/motionmount/ @laiho-vogels /homeassistant/components/mqtt/ @emontnemery @jbouwh @bdraco /tests/components/mqtt/ @emontnemery @jbouwh @bdraco -/homeassistant/components/mta/ @OnFreund -/tests/components/mta/ @OnFreund +/homeassistant/components/msteams/ @peroyvind /homeassistant/components/mullvad/ @meichthys /tests/components/mullvad/ @meichthys /homeassistant/components/music_assistant/ @music-assistant @arturpragacz @@ -1171,8 +1049,6 @@ CLAUDE.md @home-assistant/core /tests/components/mutesync/ @currentoor /homeassistant/components/my/ @home-assistant/core /tests/components/my/ @home-assistant/core -/homeassistant/components/myneomitis/ @Epyes -/tests/components/myneomitis/ @Epyes /homeassistant/components/mysensors/ @MartinHjelmare @functionpointer /tests/components/mysensors/ @MartinHjelmare @functionpointer /homeassistant/components/mystrom/ @fabaff @@ -1181,23 +1057,21 @@ CLAUDE.md @home-assistant/core /tests/components/myuplink/ @pajzo @astrandb /homeassistant/components/nam/ @bieniu /tests/components/nam/ @bieniu -/homeassistant/components/namecheapdns/ @tr4nt0r -/tests/components/namecheapdns/ @tr4nt0r -/homeassistant/components/nanoleaf/ @milanmeu @joostlek @loebi-ch @JaspervRijbroek @jonathanrobichaud4 -/tests/components/nanoleaf/ @milanmeu @joostlek @loebi-ch @JaspervRijbroek @jonathanrobichaud4 +/homeassistant/components/nanoleaf/ @milanmeu @joostlek +/tests/components/nanoleaf/ @milanmeu @joostlek /homeassistant/components/nasweb/ @nasWebio /tests/components/nasweb/ @nasWebio /homeassistant/components/nederlandse_spoorwegen/ @YarmoM @heindrichpaul /tests/components/nederlandse_spoorwegen/ @YarmoM @heindrichpaul -/homeassistant/components/ness_alarm/ @nickw444 @poshy163 -/tests/components/ness_alarm/ @nickw444 @poshy163 +/homeassistant/components/ness_alarm/ @nickw444 +/tests/components/ness_alarm/ @nickw444 /homeassistant/components/nest/ @allenporter /tests/components/nest/ @allenporter /homeassistant/components/netatmo/ @cgtobi /tests/components/netatmo/ @cgtobi /homeassistant/components/netdata/ @fabaff -/homeassistant/components/netgear/ @Quentame @starkillerOG -/tests/components/netgear/ @Quentame @starkillerOG +/homeassistant/components/netgear/ @hacf-fr @Quentame @starkillerOG +/tests/components/netgear/ @hacf-fr @Quentame @starkillerOG /homeassistant/components/netgear_lte/ @tkdrob /tests/components/netgear_lte/ @tkdrob /homeassistant/components/network/ @home-assistant/core @@ -1237,10 +1111,6 @@ CLAUDE.md @home-assistant/core /tests/components/notify_events/ @matrozov @papajojo /homeassistant/components/notion/ @bachya /tests/components/notion/ @bachya -/homeassistant/components/novy_cooker_hood/ @piitaya -/tests/components/novy_cooker_hood/ @piitaya -/homeassistant/components/nrgkick/ @andijakl -/tests/components/nrgkick/ @andijakl /homeassistant/components/nsw_fuel_station/ @nickw444 /tests/components/nsw_fuel_station/ @nickw444 /homeassistant/components/nsw_rural_fire_service_feed/ @exxamalte @@ -1265,8 +1135,6 @@ CLAUDE.md @home-assistant/core /tests/components/nzbget/ @chriscla /homeassistant/components/obihai/ @dshokouhi @ejpenney /tests/components/obihai/ @dshokouhi @ejpenney -/homeassistant/components/occupancy/ @home-assistant/core -/tests/components/occupancy/ @home-assistant/core /homeassistant/components/octoprint/ @rfleming71 /tests/components/octoprint/ @rfleming71 /homeassistant/components/ohmconnect/ @robbiet480 @@ -1275,34 +1143,24 @@ CLAUDE.md @home-assistant/core /homeassistant/components/ollama/ @synesthesiam /tests/components/ollama/ @synesthesiam /homeassistant/components/ombi/ @larssont -/homeassistant/components/omie/ @luuuis -/tests/components/omie/ @luuuis /homeassistant/components/onboarding/ @home-assistant/core /tests/components/onboarding/ @home-assistant/core /homeassistant/components/ondilo_ico/ @JeromeHXP /tests/components/ondilo_ico/ @JeromeHXP /homeassistant/components/onedrive/ @zweckj /tests/components/onedrive/ @zweckj -/homeassistant/components/onedrive_for_business/ @zweckj -/tests/components/onedrive_for_business/ @zweckj /homeassistant/components/onewire/ @garbled1 @epenet /tests/components/onewire/ @garbled1 @epenet /homeassistant/components/onkyo/ @arturpragacz @eclair4151 /tests/components/onkyo/ @arturpragacz @eclair4151 -/homeassistant/components/onvif/ @jterrace -/tests/components/onvif/ @jterrace +/homeassistant/components/onvif/ @hunterjm @jterrace +/tests/components/onvif/ @hunterjm @jterrace /homeassistant/components/open_meteo/ @frenck /tests/components/open_meteo/ @frenck -/homeassistant/components/open_router/ @joostlek @ab3lson -/tests/components/open_router/ @joostlek @ab3lson -/homeassistant/components/openai_conversation/ @Shulyaka -/tests/components/openai_conversation/ @Shulyaka -/homeassistant/components/opendisplay/ @g4bri3lDev -/tests/components/opendisplay/ @g4bri3lDev +/homeassistant/components/open_router/ @joostlek +/tests/components/open_router/ @joostlek /homeassistant/components/openerz/ @misialq /tests/components/openerz/ @misialq -/homeassistant/components/openevse/ @c00w @firstof9 -/tests/components/openevse/ @c00w @firstof9 /homeassistant/components/openexchangerates/ @MartinHjelmare /tests/components/openexchangerates/ @MartinHjelmare /homeassistant/components/opengarage/ @danielhiversen @@ -1311,8 +1169,6 @@ CLAUDE.md @home-assistant/core /tests/components/openhome/ @bazwilliams /homeassistant/components/openrgb/ @felipecrs /tests/components/openrgb/ @felipecrs -/homeassistant/components/opensensemap/ @AlCalzone -/tests/components/opensensemap/ @AlCalzone /homeassistant/components/opensky/ @joostlek /tests/components/opensky/ @joostlek /homeassistant/components/opentherm_gw/ @mvn23 @@ -1321,8 +1177,8 @@ CLAUDE.md @home-assistant/core /tests/components/openuv/ @bachya /homeassistant/components/openweathermap/ @fabaff @freekode @nzapponi @wittypluck /tests/components/openweathermap/ @fabaff @freekode @nzapponi @wittypluck -/homeassistant/components/opnsense/ @HarlemSquirrel @Snuffy2 -/tests/components/opnsense/ @HarlemSquirrel @Snuffy2 +/homeassistant/components/opnsense/ @mtreinish +/tests/components/opnsense/ @mtreinish /homeassistant/components/opower/ @tronikos /tests/components/opower/ @tronikos /homeassistant/components/oralb/ @bdraco @Lash-L @@ -1332,22 +1188,16 @@ CLAUDE.md @home-assistant/core /tests/components/osoenergy/ @osohotwateriot /homeassistant/components/otbr/ @home-assistant/core /tests/components/otbr/ @home-assistant/core -/homeassistant/components/ouman_eh_800/ @Markus98 -/tests/components/ouman_eh_800/ @Markus98 /homeassistant/components/ourgroceries/ @OnFreund /tests/components/ourgroceries/ @OnFreund /homeassistant/components/overkiz/ @imicknl /tests/components/overkiz/ @imicknl -/homeassistant/components/overseerr/ @joostlek @AmGarera -/tests/components/overseerr/ @joostlek @AmGarera -/homeassistant/components/ovhcloud_ai_endpoints/ @Crocmagnon -/tests/components/ovhcloud_ai_endpoints/ @Crocmagnon +/homeassistant/components/overseerr/ @joostlek +/tests/components/overseerr/ @joostlek /homeassistant/components/ovo_energy/ @timmo001 /tests/components/ovo_energy/ @timmo001 /homeassistant/components/p1_monitor/ @klaasnicolaas /tests/components/p1_monitor/ @klaasnicolaas -/homeassistant/components/paj_gps/ @skipperro -/tests/components/paj_gps/ @skipperro /homeassistant/components/palazzetti/ @dotvav /tests/components/palazzetti/ @dotvav /homeassistant/components/panel_custom/ @home-assistant/frontend @@ -1372,8 +1222,6 @@ CLAUDE.md @home-assistant/core /tests/components/pi_hole/ @shenxn /homeassistant/components/picnic/ @corneyl @codesalatdev /tests/components/picnic/ @corneyl @codesalatdev -/homeassistant/components/picotts/ @rooggiieerr -/tests/components/picotts/ @rooggiieerr /homeassistant/components/ping/ @jpbede /tests/components/ping/ @jpbede /homeassistant/components/plaato/ @JohNan @@ -1392,16 +1240,10 @@ CLAUDE.md @home-assistant/core /tests/components/poolsense/ @haemishkyd /homeassistant/components/portainer/ @erwindouna /tests/components/portainer/ @erwindouna -/homeassistant/components/power/ @home-assistant/core -/tests/components/power/ @home-assistant/core /homeassistant/components/powerfox/ @klaasnicolaas /tests/components/powerfox/ @klaasnicolaas -/homeassistant/components/powerfox_local/ @klaasnicolaas -/tests/components/powerfox_local/ @klaasnicolaas /homeassistant/components/powerwall/ @bdraco @jrester @daniel-simpson /tests/components/powerwall/ @bdraco @jrester @daniel-simpson -/homeassistant/components/prana/ @prana-dev-official -/tests/components/prana/ @prana-dev-official /homeassistant/components/private_ble_device/ @Jc2k /tests/components/private_ble_device/ @Jc2k /homeassistant/components/probe_plus/ @pantherale0 @@ -1416,12 +1258,9 @@ CLAUDE.md @home-assistant/core /tests/components/prosegur/ @dgomes /homeassistant/components/proximity/ @mib1185 /tests/components/proximity/ @mib1185 -/homeassistant/components/proxmoxve/ @Corbeno @erwindouna @CoMPaTech -/tests/components/proxmoxve/ @Corbeno @erwindouna @CoMPaTech +/homeassistant/components/proxmoxve/ @jhollowe @Corbeno /homeassistant/components/ps4/ @ktnrg45 /tests/components/ps4/ @ktnrg45 -/homeassistant/components/ptdevices/ @ParemTech-Inc @frogman85978 -/tests/components/ptdevices/ @ParemTech-Inc @frogman85978 /homeassistant/components/pterodactyl/ @elmurato /tests/components/pterodactyl/ @elmurato /homeassistant/components/pure_energie/ @klaasnicolaas @@ -1436,8 +1275,8 @@ CLAUDE.md @home-assistant/core /tests/components/pushover/ @engrbm87 /homeassistant/components/pvoutput/ @frenck /tests/components/pvoutput/ @frenck -/homeassistant/components/pvpc_hourly_pricing/ @azogue @chiro79 -/tests/components/pvpc_hourly_pricing/ @azogue @chiro79 +/homeassistant/components/pvpc_hourly_pricing/ @azogue +/tests/components/pvpc_hourly_pricing/ @azogue /homeassistant/components/pyload/ @tr4nt0r /tests/components/pyload/ @tr4nt0r /homeassistant/components/qbittorrent/ @geoffreylagaisse @finder39 @@ -1465,8 +1304,6 @@ CLAUDE.md @home-assistant/core /tests/components/radarr/ @tkdrob /homeassistant/components/radio_browser/ @frenck /tests/components/radio_browser/ @frenck -/homeassistant/components/radio_frequency/ @home-assistant/core -/tests/components/radio_frequency/ @home-assistant/core /homeassistant/components/radiotherm/ @vinnyfuria /tests/components/radiotherm/ @vinnyfuria /homeassistant/components/rainbird/ @konikvranik @allenporter @@ -1492,8 +1329,6 @@ CLAUDE.md @home-assistant/core /tests/components/recorder/ @home-assistant/core /homeassistant/components/recovery_mode/ @home-assistant/core /tests/components/recovery_mode/ @home-assistant/core -/homeassistant/components/redgtech/ @jonhsady @luan-nvg -/tests/components/redgtech/ @jonhsady @luan-nvg /homeassistant/components/refoss/ @ashionky /tests/components/refoss/ @ashionky /homeassistant/components/rehlko/ @bdraco @peterager @@ -1525,8 +1360,8 @@ CLAUDE.md @home-assistant/core /tests/components/ring/ @sdb9696 /homeassistant/components/risco/ @OnFreund /tests/components/risco/ @OnFreund -/homeassistant/components/rituals_perfume_genie/ @milanmeu @frenck @quebulm -/tests/components/rituals_perfume_genie/ @milanmeu @frenck @quebulm +/homeassistant/components/rituals_perfume_genie/ @milanmeu @frenck +/tests/components/rituals_perfume_genie/ @milanmeu @frenck /homeassistant/components/rmvtransport/ @cgtobi /tests/components/rmvtransport/ @cgtobi /homeassistant/components/roborock/ @Lash-L @allenporter @@ -1535,8 +1370,8 @@ CLAUDE.md @home-assistant/core /tests/components/roku/ @ctalkington /homeassistant/components/romy/ @xeniter /tests/components/romy/ @xeniter -/homeassistant/components/roomba/ @pschmitt @cyr-ius @shenxn -/tests/components/roomba/ @pschmitt @cyr-ius @shenxn +/homeassistant/components/roomba/ @pschmitt @cyr-ius @shenxn @Orhideous +/tests/components/roomba/ @pschmitt @cyr-ius @shenxn @Orhideous /homeassistant/components/roon/ @pavoni /tests/components/roon/ @pavoni /homeassistant/components/route_b_smart_meter/ @SeraphicRav @@ -1558,12 +1393,9 @@ CLAUDE.md @home-assistant/core /tests/components/rympro/ @OnFreund @elad-bar @maorcc /homeassistant/components/sabnzbd/ @shaiu @jpbede /tests/components/sabnzbd/ @shaiu @jpbede -/homeassistant/components/saj/ @fredericvl @edurenye -/tests/components/saj/ @fredericvl @edurenye -/homeassistant/components/samsung_infrared/ @lmaertin -/tests/components/samsung_infrared/ @lmaertin -/homeassistant/components/samsungtv/ @chemelli74 -/tests/components/samsungtv/ @chemelli74 +/homeassistant/components/saj/ @fredericvl +/homeassistant/components/samsungtv/ @chemelli74 @epenet +/tests/components/samsungtv/ @chemelli74 @epenet /homeassistant/components/sanix/ @tomaszsluszniak /tests/components/sanix/ @tomaszsluszniak /homeassistant/components/satel_integra/ @Tommatheussen @@ -1603,8 +1435,8 @@ CLAUDE.md @home-assistant/core /tests/components/sensorpush/ @bdraco /homeassistant/components/sensorpush_cloud/ @sstallion /tests/components/sensorpush_cloud/ @sstallion -/homeassistant/components/sensoterra/ @SanderBakkumCuriousInc @curious-florian @markruys -/tests/components/sensoterra/ @SanderBakkumCuriousInc @curious-florian @markruys +/homeassistant/components/sensoterra/ @markruys +/tests/components/sensoterra/ @markruys /homeassistant/components/sentry/ @dcramer @frenck /tests/components/sentry/ @dcramer @frenck /homeassistant/components/senz/ @milanmeu @@ -1659,8 +1491,8 @@ CLAUDE.md @home-assistant/core /tests/components/sma/ @kellerza @rklomp @erwindouna /homeassistant/components/smappee/ @bsmappee /tests/components/smappee/ @bsmappee -/homeassistant/components/smarla/ @explicatis @johannes-exp -/tests/components/smarla/ @explicatis @johannes-exp +/homeassistant/components/smarla/ @explicatis @rlint-explicatis +/tests/components/smarla/ @explicatis @rlint-explicatis /homeassistant/components/smart_meter_texas/ @grahamwetzler /tests/components/smart_meter_texas/ @grahamwetzler /homeassistant/components/smartthings/ @joostlek @@ -1686,8 +1518,6 @@ CLAUDE.md @home-assistant/core /homeassistant/components/solaredge_local/ @drobtravels @scheric /homeassistant/components/solarlog/ @Ernst79 @dontinelli /tests/components/solarlog/ @Ernst79 @dontinelli -/homeassistant/components/solarman/ @solarmanpv -/tests/components/solarman/ @solarmanpv /homeassistant/components/solax/ @squishykid @Darsstar /tests/components/solax/ @squishykid @Darsstar /homeassistant/components/soma/ @ratsept @@ -1705,17 +1535,18 @@ CLAUDE.md @home-assistant/core /homeassistant/components/speedtestdotnet/ @rohankapoorcom @engrbm87 /tests/components/speedtestdotnet/ @rohankapoorcom @engrbm87 /homeassistant/components/splunk/ @Bre77 -/tests/components/splunk/ @Bre77 /homeassistant/components/spotify/ @frenck @joostlek /tests/components/spotify/ @frenck @joostlek /homeassistant/components/sql/ @gjohansson-ST @dougiteixeira /tests/components/sql/ @gjohansson-ST @dougiteixeira /homeassistant/components/squeezebox/ @rajlaud @pssc @peteS-UK /tests/components/squeezebox/ @rajlaud @pssc @peteS-UK -/homeassistant/components/srp_energy/ @briglx @ammmze -/tests/components/srp_energy/ @briglx @ammmze +/homeassistant/components/srp_energy/ @briglx +/tests/components/srp_energy/ @briglx /homeassistant/components/starline/ @anonym-tsk /tests/components/starline/ @anonym-tsk +/homeassistant/components/starlink/ @boswelja +/tests/components/starlink/ @boswelja /homeassistant/components/statistics/ @ThomDietrich @gjohansson-ST /tests/components/statistics/ @ThomDietrich @gjohansson-ST /homeassistant/components/steam_online/ @tkdrob @@ -1761,15 +1592,13 @@ CLAUDE.md @home-assistant/core /tests/components/syncthing/ @zhulik /homeassistant/components/syncthru/ @nielstron /tests/components/syncthru/ @nielstron -/homeassistant/components/synology_dsm/ @Quentame @mib1185 -/tests/components/synology_dsm/ @Quentame @mib1185 +/homeassistant/components/synology_dsm/ @hacf-fr @Quentame @mib1185 +/tests/components/synology_dsm/ @hacf-fr @Quentame @mib1185 /homeassistant/components/synology_srm/ @aerialls /homeassistant/components/system_bridge/ @timmo001 /tests/components/system_bridge/ @timmo001 /homeassistant/components/systemmonitor/ @gjohansson-ST /tests/components/systemmonitor/ @gjohansson-ST -/homeassistant/components/systemnexa2/ @konsulten -/tests/components/systemnexa2/ @konsulten /homeassistant/components/tado/ @erwindouna /tests/components/tado/ @erwindouna /homeassistant/components/tag/ @home-assistant/core @@ -1793,14 +1622,8 @@ CLAUDE.md @home-assistant/core /tests/components/tedee/ @patrickhilker @zweckj /homeassistant/components/telegram_bot/ @hanwg /tests/components/telegram_bot/ @hanwg -/homeassistant/components/teleinfo/ @esciara -/tests/components/teleinfo/ @esciara /homeassistant/components/tellduslive/ @fredrike /tests/components/tellduslive/ @fredrike -/homeassistant/components/teltonika/ @karlbeecken -/tests/components/teltonika/ @karlbeecken -/homeassistant/components/temperature/ @home-assistant/core -/tests/components/temperature/ @home-assistant/core /homeassistant/components/template/ @Petro31 @home-assistant/core /tests/components/template/ @Petro31 @home-assistant/core /homeassistant/components/tesla_fleet/ @Bre77 @@ -1813,6 +1636,7 @@ CLAUDE.md @home-assistant/core /tests/components/tessie/ @Bre77 /homeassistant/components/text/ @home-assistant/core /tests/components/text/ @home-assistant/core +/homeassistant/components/tfiac/ @fredrike @mellado /homeassistant/components/thermobeacon/ @bdraco /tests/components/thermobeacon/ @bdraco /homeassistant/components/thermopro/ @bdraco @h3ss @@ -1846,8 +1670,6 @@ CLAUDE.md @home-assistant/core /tests/components/tomorrowio/ @raman325 @lymanepp /homeassistant/components/totalconnect/ @austinmroczek /tests/components/totalconnect/ @austinmroczek -/homeassistant/components/touchline/ @mnordseth -/tests/components/touchline/ @mnordseth /homeassistant/components/touchline_sl/ @jnsgruk /tests/components/touchline_sl/ @jnsgruk /homeassistant/components/tplink/ @rytilahti @bdraco @sdb9696 @@ -1868,16 +1690,12 @@ CLAUDE.md @home-assistant/core /tests/components/trafikverket_train/ @gjohansson-ST /homeassistant/components/trafikverket_weatherstation/ @gjohansson-ST /tests/components/trafikverket_weatherstation/ @gjohansson-ST -/homeassistant/components/trane/ @bdraco -/tests/components/trane/ @bdraco -/homeassistant/components/transmission/ @engrbm87 @JPHutchins @andrew-codechimp -/tests/components/transmission/ @engrbm87 @JPHutchins @andrew-codechimp +/homeassistant/components/transmission/ @engrbm87 @JPHutchins +/tests/components/transmission/ @engrbm87 @JPHutchins /homeassistant/components/trend/ @jpbede /tests/components/trend/ @jpbede /homeassistant/components/triggercmd/ @rvmey /tests/components/triggercmd/ @rvmey -/homeassistant/components/trmnl/ @joostlek -/tests/components/trmnl/ @joostlek /homeassistant/components/tts/ @home-assistant/core /tests/components/tts/ @home-assistant/core /homeassistant/components/tuya/ @Tuya @zlinoliver @@ -1888,18 +1706,12 @@ CLAUDE.md @home-assistant/core /tests/components/twinkly/ @dr1rrb @Robbie1221 @Olen /homeassistant/components/twitch/ @joostlek /tests/components/twitch/ @joostlek -/homeassistant/components/uhoo/ @getuhoo @joshsmonta -/tests/components/uhoo/ @getuhoo @joshsmonta /homeassistant/components/ukraine_alarm/ @PaulAnnekov /tests/components/ukraine_alarm/ @PaulAnnekov /homeassistant/components/unifi/ @Kane610 /tests/components/unifi/ @Kane610 -/homeassistant/components/unifi_access/ @imhotep @RaHehl -/tests/components/unifi_access/ @imhotep @RaHehl /homeassistant/components/unifi_direct/ @tofuSCHNITZEL -/tests/components/unifi_direct/ @tofuSCHNITZEL -/homeassistant/components/unifi_discovery/ @RaHehl -/tests/components/unifi_discovery/ @RaHehl +/homeassistant/components/unifiled/ @florisvdk /homeassistant/components/unifiprotect/ @RaHehl /tests/components/unifiprotect/ @RaHehl /homeassistant/components/upb/ @gwww @@ -1937,8 +1749,8 @@ CLAUDE.md @home-assistant/core /tests/components/vegehub/ @thulrus /homeassistant/components/velbus/ @Cereal2nd @brefra /tests/components/velbus/ @Cereal2nd @brefra -/homeassistant/components/velux/ @Julius2342 @pawlizio @wollew -/tests/components/velux/ @Julius2342 @pawlizio @wollew +/homeassistant/components/velux/ @Julius2342 @DeerMaximum @pawlizio @wollew +/tests/components/velux/ @Julius2342 @DeerMaximum @pawlizio @wollew /homeassistant/components/venstar/ @garbled1 @jhollowe /tests/components/venstar/ @garbled1 @jhollowe /homeassistant/components/versasense/ @imstevenxyz @@ -1946,18 +1758,14 @@ CLAUDE.md @home-assistant/core /tests/components/version/ @ludeeus /homeassistant/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak @sapuseven /tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak @sapuseven -/homeassistant/components/vicare/ @CFenner @lackas -/tests/components/vicare/ @CFenner @lackas +/homeassistant/components/vicare/ @CFenner +/tests/components/vicare/ @CFenner /homeassistant/components/victron_ble/ @rajlaud /tests/components/victron_ble/ @rajlaud -/homeassistant/components/victron_gx/ @tomer-w -/tests/components/victron_gx/ @tomer-w /homeassistant/components/victron_remote_monitoring/ @AndyTempel /tests/components/victron_remote_monitoring/ @AndyTempel /homeassistant/components/vilfo/ @ManneW /tests/components/vilfo/ @ManneW -/homeassistant/components/vistapool/ @fdebrus -/tests/components/vistapool/ @fdebrus /homeassistant/components/vivotek/ @HarlemSquirrel /tests/components/vivotek/ @HarlemSquirrel /homeassistant/components/vizio/ @raman325 @@ -1984,16 +1792,11 @@ CLAUDE.md @home-assistant/core /tests/components/waqi/ @joostlek /homeassistant/components/water_heater/ @home-assistant/core /tests/components/water_heater/ @home-assistant/core -/homeassistant/components/waterfurnace/ @sdague @masterkoppa -/tests/components/waterfurnace/ @sdague @masterkoppa /homeassistant/components/watergate/ @adam-the-hero /tests/components/watergate/ @adam-the-hero -/homeassistant/components/watts/ @theobld-ww @devender-verma-ww @ssi-spyro -/tests/components/watts/ @theobld-ww @devender-verma-ww @ssi-spyro +/homeassistant/components/watson_tts/ @rutkai /homeassistant/components/watttime/ @bachya /tests/components/watttime/ @bachya -/homeassistant/components/wattwaechter/ @smartcircuits -/tests/components/wattwaechter/ @smartcircuits /homeassistant/components/waze_travel_time/ @eifinger /tests/components/waze_travel_time/ @eifinger /homeassistant/components/weather/ @home-assistant/core @@ -2004,8 +1807,6 @@ CLAUDE.md @home-assistant/core /tests/components/weatherflow_cloud/ @jeeftor /homeassistant/components/weatherkit/ @tjhorner /tests/components/weatherkit/ @tjhorner -/homeassistant/components/web_rtc/ @home-assistant/core -/tests/components/web_rtc/ @home-assistant/core /homeassistant/components/webdav/ @jpbede /tests/components/webdav/ @jpbede /homeassistant/components/webhook/ @home-assistant/core @@ -2016,8 +1817,8 @@ CLAUDE.md @home-assistant/core /tests/components/webostv/ @thecode /homeassistant/components/websocket_api/ @home-assistant/core /tests/components/websocket_api/ @home-assistant/core -/homeassistant/components/weheat/ @barryvdh -/tests/components/weheat/ @barryvdh +/homeassistant/components/weheat/ @jesperraemaekers +/tests/components/weheat/ @jesperraemaekers /homeassistant/components/wemo/ @esev /tests/components/wemo/ @esev /homeassistant/components/whirlpool/ @abmantis @mkmer @@ -2026,35 +1827,29 @@ CLAUDE.md @home-assistant/core /tests/components/whois/ @frenck /homeassistant/components/wiffi/ @mampfes /tests/components/wiffi/ @mampfes -/homeassistant/components/wiim/ @Linkplay2020 -/tests/components/wiim/ @Linkplay2020 /homeassistant/components/wilight/ @leofig-rj /tests/components/wilight/ @leofig-rj -/homeassistant/components/window/ @home-assistant/core -/tests/components/window/ @home-assistant/core /homeassistant/components/wirelesstag/ @sergeymaysak /homeassistant/components/withings/ @joostlek /tests/components/withings/ @joostlek /homeassistant/components/wiz/ @sbidy @arturpragacz /tests/components/wiz/ @sbidy @arturpragacz -/homeassistant/components/wled/ @frenck @mik-laj -/tests/components/wled/ @frenck @mik-laj +/homeassistant/components/wled/ @frenck +/tests/components/wled/ @frenck /homeassistant/components/wmspro/ @mback2k /tests/components/wmspro/ @mback2k -/homeassistant/components/wolflink/ @adamkrol93 @EnjoyingM -/tests/components/wolflink/ @adamkrol93 @EnjoyingM +/homeassistant/components/wolflink/ @adamkrol93 @mtielen +/tests/components/wolflink/ @adamkrol93 @mtielen /homeassistant/components/workday/ @fabaff @gjohansson-ST /tests/components/workday/ @fabaff @gjohansson-ST /homeassistant/components/worldclock/ @fabaff /tests/components/worldclock/ @fabaff /homeassistant/components/ws66i/ @ssaenger /tests/components/ws66i/ @ssaenger -/homeassistant/components/wsdot/ @ucodery -/tests/components/wsdot/ @ucodery /homeassistant/components/wyoming/ @synesthesiam /tests/components/wyoming/ @synesthesiam -/homeassistant/components/xbox/ @tr4nt0r -/tests/components/xbox/ @tr4nt0r +/homeassistant/components/xbox/ @hunterjm @tr4nt0r +/tests/components/xbox/ @hunterjm @tr4nt0r /homeassistant/components/xiaomi_aqara/ @danielhiversen @syssi /tests/components/xiaomi_aqara/ @danielhiversen @syssi /homeassistant/components/xiaomi_ble/ @Jc2k @Ernst79 @@ -2063,8 +1858,6 @@ CLAUDE.md @home-assistant/core /tests/components/xiaomi_miio/ @rytilahti @syssi @starkillerOG /homeassistant/components/xiaomi_tv/ @simse /homeassistant/components/xmpp/ @fabaff @flowolf -/homeassistant/components/xthings_cloud/ @XthingsJacobs -/tests/components/xthings_cloud/ @XthingsJacobs /homeassistant/components/yale/ @bdraco /tests/components/yale/ @bdraco /homeassistant/components/yale_smart_alarm/ @gjohansson-ST @@ -2075,16 +1868,14 @@ CLAUDE.md @home-assistant/core /tests/components/yamaha_musiccast/ @vigonotion @micha91 /homeassistant/components/yandex_transport/ @rishatik92 @devbis /tests/components/yandex_transport/ @rishatik92 @devbis -/homeassistant/components/yardian/ @aeon-matrix -/tests/components/yardian/ @aeon-matrix +/homeassistant/components/yardian/ @h3l1o5 +/tests/components/yardian/ @h3l1o5 /homeassistant/components/yeelight/ @zewelor @shenxn @starkillerOG @alexyao2015 /tests/components/yeelight/ @zewelor @shenxn @starkillerOG @alexyao2015 /homeassistant/components/yeelightsunflower/ @lindsaymarkward /homeassistant/components/yi/ @bachya /homeassistant/components/yolink/ @matrixd2 /tests/components/yolink/ @matrixd2 -/homeassistant/components/yoto/ @cdnninja @piitaya -/tests/components/yoto/ @cdnninja @piitaya /homeassistant/components/youless/ @gjong /tests/components/youless/ @gjong /homeassistant/components/youtube/ @joostlek @@ -2097,20 +1888,17 @@ CLAUDE.md @home-assistant/core /tests/components/zeroconf/ @bdraco /homeassistant/components/zerproc/ @emlove /tests/components/zerproc/ @emlove -/homeassistant/components/zeversolar/ @kvanzuijlen @mhuiskes -/tests/components/zeversolar/ @kvanzuijlen @mhuiskes +/homeassistant/components/zeversolar/ @kvanzuijlen +/tests/components/zeversolar/ @kvanzuijlen /homeassistant/components/zha/ @dmulcahey @adminiuga @puddly @TheJulianJES /tests/components/zha/ @dmulcahey @adminiuga @puddly @TheJulianJES /homeassistant/components/zimi/ @markhannon /tests/components/zimi/ @markhannon -/homeassistant/components/zinvolt/ @joostlek -/tests/components/zinvolt/ @joostlek /homeassistant/components/zodiac/ @JulienTant /tests/components/zodiac/ @JulienTant /homeassistant/components/zone/ @home-assistant/core /tests/components/zone/ @home-assistant/core /homeassistant/components/zoneminder/ @rohankapoorcom @nabbi -/tests/components/zoneminder/ @rohankapoorcom @nabbi /homeassistant/components/zwave_js/ @home-assistant/z-wave /tests/components/zwave_js/ @home-assistant/z-wave /homeassistant/components/zwave_me/ @lawfulchaos @Z-Wave-Me @PoltoS From 9729e237f4ba644ad7f85b0297397a5cd2ae73fd Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Thu, 4 Dec 2025 22:02:06 +1300 Subject: [PATCH 40/66] meet the quality scale requirements --- homeassistant/components/mammotion/lawn_mower.py | 2 ++ homeassistant/components/mammotion/manifest.json | 1 + homeassistant/components/mammotion/quality_scale.yaml | 6 +++--- homeassistant/generated/integrations.json | 2 +- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/mammotion/lawn_mower.py b/homeassistant/components/mammotion/lawn_mower.py index 48925cc2f0c95..e85b2b16be762 100644 --- a/homeassistant/components/mammotion/lawn_mower.py +++ b/homeassistant/components/mammotion/lawn_mower.py @@ -18,6 +18,8 @@ from .const import COMMAND_EXCEPTIONS, DOMAIN, LOGGER from .entity import MammotionBaseEntity +PARALLEL_UPDATES = 0 + def get_entity_attribute( hass: HomeAssistant, entity_id: str, attribute_name: str diff --git a/homeassistant/components/mammotion/manifest.json b/homeassistant/components/mammotion/manifest.json index 3f15904a337c5..2b0ba22bc8255 100644 --- a/homeassistant/components/mammotion/manifest.json +++ b/homeassistant/components/mammotion/manifest.json @@ -20,5 +20,6 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["pymammotion"], + "quality_scale": "bronze", "requirements": ["pymammotion==0.5.64"] } diff --git a/homeassistant/components/mammotion/quality_scale.yaml b/homeassistant/components/mammotion/quality_scale.yaml index 491eef8dfc9b4..3356edcb55201 100644 --- a/homeassistant/components/mammotion/quality_scale.yaml +++ b/homeassistant/components/mammotion/quality_scale.yaml @@ -14,9 +14,9 @@ rules: status: exempt comment: | No custom actions. - docs-high-level-description: todo - docs-installation-instructions: todo - docs-removal-instructions: todo + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done entity-event-setup: status: exempt comment: | diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 3fbc43c76545d..9dbb7de37f443 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4042,7 +4042,7 @@ "name": "Mammotion", "integration_type": "device", "config_flow": true, - "iot_class": "local_polling" + "iot_class": "local_push" }, "marantz": { "name": "Marantz", From eb10e76a7f3c9978f7d53c2fa6377db6f2ff8705 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Thu, 4 Dec 2025 22:13:38 +1300 Subject: [PATCH 41/66] update strings --- .../components/mammotion/strings.json | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/mammotion/strings.json b/homeassistant/components/mammotion/strings.json index be888da6a672d..3a88436736622 100644 --- a/homeassistant/components/mammotion/strings.json +++ b/homeassistant/components/mammotion/strings.json @@ -29,24 +29,26 @@ }, "reconfigure": { "data": { - "account_name": "Mammotion email or account number", - "password": "Mammotion account password", - "use_wifi": "Use Wi-Fi" + "account_name": "[%key:component::mammotion::config::step::wifi::data::account_name%]", + "password": "[%key:component::mammotion::config::step::wifi::data::password%]", + "use_wifi": "[%key:component::mammotion::config::step::wifi::data::use_wifi%]" }, "data_description": { - "account_name": "Mammotion email or account number for your shared mammotion account.", - "password": "Mammotion shared account password", - "use_wifi": "Connect using the cloud, can also connect over Bluetooth as well (deselect to only use Bluetooth)" - } + "account_name": "[%key:component::mammotion::config::step::wifi::data_description::account_name%]", + "password": "[%key:component::mammotion::config::step::wifi::data_description::password%]", + "use_wifi": "[%key:component::mammotion::config::step::wifi::data_description::use_wifi%]" + }, + "description": "Enter your Mammotion account email or ID and password", + "title": "Connect to Wi-Fi" }, "user": { "data": { "address": "Device", - "stay_connected_bluetooth": "Keep Bluetooth connected" + "stay_connected_bluetooth": "[%key:component::mammotion::config::step::bluetooth_confirm::data::stay_connected_bluetooth%]" }, "data_description": { "address": "Bluetooth address of the mower", - "stay_connected_bluetooth": "If you select this option, the integration will not disconnect from Bluetooth preventing any other device from connecting to the mower." + "stay_connected_bluetooth": "[%key:component::mammotion::config::step::bluetooth_confirm::data_description::stay_connected_bluetooth%]" }, "description": "Select your mower" }, @@ -60,9 +62,7 @@ "account_name": "Mammotion email or account number for your shared mammotion account.", "password": "Mammotion shared account password", "use_wifi": "Connect using the cloud, can also connect over Bluetooth as well (deselect to only use Bluetooth)" - }, - "description": "Enter your Mammotion account email or ID and password", - "title": "Connect to Wi-Fi" + } } } }, @@ -94,11 +94,11 @@ "step": { "init": { "data": { - "stay_connected_bluetooth": "Keep Bluetooth connected", + "stay_connected_bluetooth": "[%key:component::mammotion::config::step::bluetooth_confirm::data::stay_connected_bluetooth%]", "title": "Update configuration" }, "data_description": { - "stay_connected_bluetooth": "If you select this option, the integration will not disconnect from Bluetooth preventing any other device from connecting to the mower." + "stay_connected_bluetooth": "[%key:component::mammotion::config::step::bluetooth_confirm::data_description::stay_connected_bluetooth%]" } } } From 54c95521b4e750beb5c4548fdb5e645033983b3e Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Fri, 5 Dec 2025 06:33:06 +1300 Subject: [PATCH 42/66] Update homeassistant/components/mammotion/strings.json Co-authored-by: Norbert Rittel --- homeassistant/components/mammotion/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/mammotion/strings.json b/homeassistant/components/mammotion/strings.json index 3a88436736622..cf36e47682cb6 100644 --- a/homeassistant/components/mammotion/strings.json +++ b/homeassistant/components/mammotion/strings.json @@ -59,7 +59,7 @@ "use_wifi": "Use Wi-Fi (deselect to use Bluetooth)" }, "data_description": { - "account_name": "Mammotion email or account number for your shared mammotion account.", + "account_name": "Mammotion email or account number for your shared Mammotion account.", "password": "Mammotion shared account password", "use_wifi": "Connect using the cloud, can also connect over Bluetooth as well (deselect to only use Bluetooth)" } From 6f881e0bb554a45692762c48d3beb0773433d487 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Fri, 5 Dec 2025 07:21:15 +1300 Subject: [PATCH 43/66] update version of pymammotion --- homeassistant/components/mammotion/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mammotion/manifest.json b/homeassistant/components/mammotion/manifest.json index 2b0ba22bc8255..ff1b5ac693db5 100644 --- a/homeassistant/components/mammotion/manifest.json +++ b/homeassistant/components/mammotion/manifest.json @@ -21,5 +21,5 @@ "iot_class": "local_push", "loggers": ["pymammotion"], "quality_scale": "bronze", - "requirements": ["pymammotion==0.5.64"] + "requirements": ["pymammotion==0.5.66"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5c1029e6c31ce..7ac56f99175b7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2334,7 +2334,7 @@ pylutron==0.4.1 pymailgunner==1.4 # homeassistant.components.mammotion -pymammotion==0.5.64 +pymammotion==0.5.66 # homeassistant.components.firmata pymata-express==1.19 From 5107a0a6a6f0d3a99864b3f6c8f5f9b2826f172d Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Fri, 5 Dec 2025 21:09:38 +1300 Subject: [PATCH 44/66] update config flow to fix issues around device discovery --- .../components/mammotion/config_flow.py | 18 +++-- homeassistant/components/mammotion/const.py | 1 - .../components/mammotion/coordinator.py | 5 +- .../components/mammotion/test_config_flow.py | 78 +++++++++++++++---- 4 files changed, 76 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/mammotion/config_flow.py b/homeassistant/components/mammotion/config_flow.py index 68e7ee014547c..d129b0bc27d3f 100644 --- a/homeassistant/components/mammotion/config_flow.py +++ b/homeassistant/components/mammotion/config_flow.py @@ -20,7 +20,7 @@ ConfigFlowResult, OptionsFlow, ) -from homeassistant.const import CONF_PASSWORD +from homeassistant.const import CONF_ADDRESS, CONF_PASSWORD from homeassistant.core import callback from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, format_mac @@ -29,7 +29,6 @@ CONF_ACCOUNT_ID, CONF_ACCOUNTNAME, CONF_BLE_DEVICES, - CONF_DEVICE_NAME, CONF_STAY_CONNECTED_BLUETOOTH, CONF_USE_WIFI, DEVICE_SUPPORT, @@ -174,7 +173,10 @@ async def async_step_user( if user_input is not None: self._stay_connected = user_input.get(CONF_STAY_CONNECTED_BLUETOOTH, False) - + if selected_address := user_input.get(CONF_ADDRESS): + self._discovered_device = bluetooth.async_ble_device_from_address( + self.hass, selected_address + ) return await self.async_step_wifi(user_input) current_addresses = self._async_current_ids() @@ -186,7 +188,11 @@ async def async_step_user( if name is None or not name.startswith(DEVICE_SUPPORT): continue - self._discovered_devices[address] = discovery_info.name + device = bluetooth.async_ble_device_from_address( + self.hass, discovery_info.address + ) + if device and not await self.check_and_update_bluetooth_device(device): + self._discovered_devices[address] = discovery_info.name if not self._discovered_devices: return await self.async_step_wifi(user_input) @@ -195,6 +201,7 @@ async def async_step_user( last_step=False, data_schema=vol.Schema( { + vol.Optional(CONF_ADDRESS): vol.In(self._discovered_devices), vol.Optional( CONF_STAY_CONNECTED_BLUETOOTH, default=False, @@ -236,9 +243,6 @@ async def async_step_wifi( CONF_ACCOUNTNAME: account, CONF_PASSWORD: password, CONF_ACCOUNT_ID: user_account, - CONF_DEVICE_NAME: self._discovered_device.name - if self._discovered_device - else None, CONF_USE_WIFI: user_input.get(CONF_USE_WIFI, True), **self._config, }, diff --git a/homeassistant/components/mammotion/const.py b/homeassistant/components/mammotion/const.py index d76b05d5802ea..ec2eff256ecc3 100644 --- a/homeassistant/components/mammotion/const.py +++ b/homeassistant/components/mammotion/const.py @@ -37,7 +37,6 @@ CONF_ACCOUNTNAME: Final = "account_name" CONF_ACCOUNT_ID: Final = "mammotion_account_id" CONF_USE_WIFI: Final = "use_wifi" -CONF_DEVICE_NAME: Final = "device_name" CONF_BLE_DEVICES: Final = "ble_devices" CONF_AUTH_DATA: Final = "auth_data" CONF_CONNECT_DATA: Final = "connect_data" diff --git a/homeassistant/components/mammotion/coordinator.py b/homeassistant/components/mammotion/coordinator.py index aec59e1df7c00..6c5db64947bc0 100644 --- a/homeassistant/components/mammotion/coordinator.py +++ b/homeassistant/components/mammotion/coordinator.py @@ -161,4 +161,7 @@ async def async_save_data(self, data: MowingDevice) -> None: async def _async_update_data(self) -> MowingDevice: """Get data from the device.""" - return await self.api.update(self.device_name) + data = await self.api.update(self.device_name) + await self.async_save_data(data) + + return data diff --git a/tests/components/mammotion/test_config_flow.py b/tests/components/mammotion/test_config_flow.py index 31e95ae90b227..1e5b899188d7b 100644 --- a/tests/components/mammotion/test_config_flow.py +++ b/tests/components/mammotion/test_config_flow.py @@ -10,12 +10,11 @@ CONF_ACCOUNT_ID, CONF_ACCOUNTNAME, CONF_BLE_DEVICES, - CONF_DEVICE_NAME, CONF_STAY_CONNECTED_BLUETOOTH, CONF_USE_WIFI, DOMAIN, ) -from homeassistant.const import CONF_PASSWORD +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 @@ -58,10 +57,16 @@ async def test_bluetooth_discovery_success(hass: HomeAssistant) -> None: assert result["description_placeholders"] == {"name": "Luba-ABC123"} # Confirm Bluetooth - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_STAY_CONNECTED_BLUETOOTH: True}, - ) + mock_mammotion = MagicMock() + mock_mammotion.login_v2 = AsyncMock() + with patch( + "homeassistant.components.mammotion.config_flow.MammotionHTTP", + return_value=mock_mammotion, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_STAY_CONNECTED_BLUETOOTH: True}, + ) assert result2["type"] == FlowResultType.FORM assert result2["step_id"] == "wifi" @@ -90,7 +95,6 @@ async def test_bluetooth_discovery_success(hass: HomeAssistant) -> None: CONF_ACCOUNTNAME: "user@example.com", CONF_PASSWORD: "password", CONF_ACCOUNT_ID: "user123", - CONF_DEVICE_NAME: "Luba-ABC123", CONF_USE_WIFI: True, CONF_BLE_DEVICES: {"Luba-ABC123": "aa:bb:cc:dd:ee:ff"}, } @@ -115,10 +119,16 @@ async def test_bluetooth_discovery_bluetooth_only(hass: HomeAssistant) -> None: assert result["type"] == FlowResultType.FORM assert result["step_id"] == "bluetooth_confirm" - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_STAY_CONNECTED_BLUETOOTH: False}, - ) + mock_mammotion = MagicMock() + mock_mammotion.login_v2 = AsyncMock() + with patch( + "homeassistant.components.mammotion.config_flow.MammotionHTTP", + return_value=mock_mammotion, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_STAY_CONNECTED_BLUETOOTH: False}, + ) assert result2["type"] == FlowResultType.FORM assert result2["step_id"] == "wifi" @@ -223,15 +233,51 @@ async def test_user_step_pick_discovery(hass: HomeAssistant) -> None: assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_STAY_CONNECTED_BLUETOOTH: True}, - ) + mock_mammotion = MagicMock() + mock_mammotion.login_v2 = AsyncMock() + with patch( + "homeassistant.components.mammotion.config_flow.MammotionHTTP", + return_value=mock_mammotion, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_STAY_CONNECTED_BLUETOOTH: True}, + ) 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() + + 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={ + CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", + CONF_STAY_CONNECTED_BLUETOOTH: True, + }, + ) + + 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( @@ -265,7 +311,6 @@ async def test_wifi_step_invalid_auth(hass: HomeAssistant) -> None: { CONF_ACCOUNTNAME: "user@example.com", CONF_PASSWORD: "wrong", - CONF_USE_WIFI: True, }, ) @@ -292,7 +337,6 @@ async def test_wifi_step_connection_error(hass: HomeAssistant) -> None: { CONF_ACCOUNTNAME: "user@example.com", CONF_PASSWORD: "password", - CONF_USE_WIFI: True, }, ) From 296998737378fcbd7f989a1237c823c752a99270 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Tue, 30 Dec 2025 13:00:11 +1300 Subject: [PATCH 45/66] stop mammotion on shutdown, bump library --- .../components/mammotion/__init__.py | 39 ++++++++++++------- .../components/mammotion/config_flow.py | 5 ++- .../components/mammotion/coordinator.py | 10 +++-- .../components/mammotion/lawn_mower.py | 7 ++-- .../components/mammotion/manifest.json | 2 +- homeassistant/components/mammotion/models.py | 4 +- .../components/mammotion/quality_scale.yaml | 10 ++--- requirements_all.txt | 2 +- 8 files changed, 48 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/mammotion/__init__.py b/homeassistant/components/mammotion/__init__.py index 6916e6f48c32c..3b0c3519a5aea 100644 --- a/homeassistant/components/mammotion/__init__.py +++ b/homeassistant/components/mammotion/__init__.py @@ -28,9 +28,10 @@ from homeassistant.components import bluetooth from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, Platform -from homeassistant.core import HomeAssistant +from homeassistant.const import CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceEntry from .config import MammotionConfigStore @@ -50,7 +51,7 @@ DOMAIN, EXPIRED_CREDENTIAL_EXCEPTIONS, ) -from .coordinator import MammotionReportUpdateCoordinator +from .coordinator import MammotionMowerUpdateCoordinator from .models import MammotionDevices, MammotionMowerData PLATFORMS: list[Platform] = [Platform.LAWN_MOWER] @@ -154,13 +155,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> ble = mammotion_device.add_ble(ble_device) ble.set_disconnect_strategy(disconnect=not stay_connected_ble) - api = HomeAssistantMowerApi() + api = HomeAssistantMowerApi(async_get_clientsession(hass)) - report_coordinator = MammotionReportUpdateCoordinator( - hass, entry, device, api - ) + coordinator = MammotionMowerUpdateCoordinator(hass, entry, device, api) - await report_coordinator.async_restore_data() + await coordinator.async_restore_data() device_config = DeviceConfig() device_limits = device_config.get_best_default(device.product_key) @@ -168,7 +167,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> if not use_wifi: mammotion_device.preference = ConnectionPreference.BLUETOOTH if cloud := mammotion_device.cloud: - await cloud.stop() + cloud.stop() cloud.mqtt.disconnect() if cloud.mqtt.is_connected() else None mammotion_device.remove_cloud() @@ -176,7 +175,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> MammotionMowerData( name=device.device_name, api=api, - reporting_coordinator=report_coordinator, + coordinator=coordinator, device_limits=device_limits, device=device, ) @@ -184,6 +183,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> mammotion_devices.mowers = mammotion_mowers entry.runtime_data = mammotion_devices + + async def shutdown_mammotion(_: Event | None = None) -> None: + await api.mammotion.stop() + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown_mammotion) + ) + entry.async_on_unload(shutdown_mammotion) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -307,10 +315,10 @@ async def async_remove_config_entry( async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry + hass: HomeAssistant, config_entry: MammotionConfigEntry, device_entry: DeviceEntry ) -> bool: """Remove a config entry from a device.""" - mower_name = ( + mower_names = ( next( identifier[1] for identifier in device_entry.identifiers @@ -318,7 +326,12 @@ async def async_remove_config_entry_device( ), ) mower = next( - (mower for mower in config_entry.runtime_data if mower.name == mower_name), None + ( + 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_flow.py b/homeassistant/components/mammotion/config_flow.py index d129b0bc27d3f..c698747fc0d13 100644 --- a/homeassistant/components/mammotion/config_flow.py +++ b/homeassistant/components/mammotion/config_flow.py @@ -25,6 +25,7 @@ from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, format_mac +from . import MammotionConfigEntry from .const import ( CONF_ACCOUNT_ID, CONF_ACCOUNTNAME, @@ -276,7 +277,7 @@ async def async_step_wifi( @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: MammotionConfigEntry, ) -> OptionsFlow: """Create the options flow.""" return MammotionConfigFlowHandler(config_entry) @@ -324,7 +325,7 @@ async def async_step_reconfigure( class MammotionConfigFlowHandler(OptionsFlow): """Handles options flow for the component.""" - def __init__(self, config_entry: ConfigEntry) -> None: + def __init__(self, config_entry: MammotionConfigEntry) -> None: """Initialize options flow.""" self.stay_connected_bluetooth = config_entry.options.get( CONF_STAY_CONNECTED_BLUETOOTH, False diff --git a/homeassistant/components/mammotion/coordinator.py b/homeassistant/components/mammotion/coordinator.py index 6c5db64947bc0..208b8b5cbb7a7 100644 --- a/homeassistant/components/mammotion/coordinator.py +++ b/homeassistant/components/mammotion/coordinator.py @@ -34,7 +34,7 @@ if TYPE_CHECKING: from . import MammotionConfigEntry -REPORT_INTERVAL = timedelta(minutes=1) +DEFAULT_INTERVAL = timedelta(minutes=1) class MammotionBaseUpdateCoordinator(DataUpdateCoordinator[MowingDevice]): @@ -64,6 +64,10 @@ def __init__( self.password = config_entry.data[CONF_PASSWORD] self.update_failures = 0 + def __del__(self) -> None: + """Cleanup and store credentials.""" + self.store_cloud_credentials() + @abstractmethod def get_coordinator_data(self, device: MammotionMowerDeviceManager) -> MowingDevice: """Get coordinator data.""" @@ -110,7 +114,7 @@ async def async_send_command(self, command: str, **kwargs: Any) -> bool | None: return await self.api.async_send_command(self.device_name, command, **kwargs) -class MammotionReportUpdateCoordinator(MammotionBaseUpdateCoordinator): +class MammotionMowerUpdateCoordinator(MammotionBaseUpdateCoordinator): """Class to manage fetching mammotion report data.""" def __init__( @@ -126,7 +130,7 @@ def __init__( config_entry=config_entry, device=device, api=api, - update_interval=REPORT_INTERVAL, + update_interval=DEFAULT_INTERVAL, ) def get_coordinator_data(self, device: MammotionMowerDeviceManager) -> MowingDevice: diff --git a/homeassistant/components/mammotion/lawn_mower.py b/homeassistant/components/mammotion/lawn_mower.py index e85b2b16be762..3b70003d9dfdd 100644 --- a/homeassistant/components/mammotion/lawn_mower.py +++ b/homeassistant/components/mammotion/lawn_mower.py @@ -14,7 +14,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import MammotionConfigEntry, MammotionReportUpdateCoordinator +from . import MammotionConfigEntry, MammotionMowerUpdateCoordinator from .const import COMMAND_EXCEPTIONS, DOMAIN, LOGGER from .entity import MammotionBaseEntity @@ -44,8 +44,7 @@ async def async_setup_entry( """Set up the Luba config entry.""" mammotion_devices = entry.runtime_data.mowers entities: list[MammotionLawnMowerEntity] = [ - MammotionLawnMowerEntity(mower.reporting_coordinator) - for mower in mammotion_devices + MammotionLawnMowerEntity(mower.coordinator) for mower in mammotion_devices ] async_add_entities(entities) @@ -58,7 +57,7 @@ class MammotionLawnMowerEntity(MammotionBaseEntity, LawnMowerEntity): LawnMowerEntityFeature.DOCK | LawnMowerEntityFeature.PAUSE ) - def __init__(self, coordinator: MammotionReportUpdateCoordinator) -> None: + def __init__(self, coordinator: MammotionMowerUpdateCoordinator) -> None: """Initialize the lawn mower.""" super().__init__(coordinator, "mower") diff --git a/homeassistant/components/mammotion/manifest.json b/homeassistant/components/mammotion/manifest.json index ff1b5ac693db5..f517539a5c053 100644 --- a/homeassistant/components/mammotion/manifest.json +++ b/homeassistant/components/mammotion/manifest.json @@ -21,5 +21,5 @@ "iot_class": "local_push", "loggers": ["pymammotion"], "quality_scale": "bronze", - "requirements": ["pymammotion==0.5.66"] + "requirements": ["pymammotion==0.5.69"] } diff --git a/homeassistant/components/mammotion/models.py b/homeassistant/components/mammotion/models.py index 6134338f4a9a6..34eb1e5800da7 100644 --- a/homeassistant/components/mammotion/models.py +++ b/homeassistant/components/mammotion/models.py @@ -6,7 +6,7 @@ from pymammotion.data.model.device_limits import DeviceLimits from pymammotion.homeassistant import HomeAssistantMowerApi -from .coordinator import MammotionReportUpdateCoordinator +from .coordinator import MammotionMowerUpdateCoordinator @dataclass @@ -15,7 +15,7 @@ class MammotionMowerData: name: str api: HomeAssistantMowerApi - reporting_coordinator: MammotionReportUpdateCoordinator + coordinator: MammotionMowerUpdateCoordinator device_limits: DeviceLimits device: Device diff --git a/homeassistant/components/mammotion/quality_scale.yaml b/homeassistant/components/mammotion/quality_scale.yaml index 3356edcb55201..69ba0c0e93a41 100644 --- a/homeassistant/components/mammotion/quality_scale.yaml +++ b/homeassistant/components/mammotion/quality_scale.yaml @@ -50,10 +50,10 @@ rules: discovery: done docs-data-update: todo docs-examples: todo - docs-known-limitations: todo + docs-known-limitations: done docs-supported-devices: todo docs-supported-functions: todo - docs-troubleshooting: todo + docs-troubleshooting: done docs-use-cases: todo dynamic-devices: done entity-category: done @@ -69,6 +69,6 @@ rules: Does not have any repairs stale-devices: done # Platinum - async-dependency: todo - inject-websession: todo - strict-typing: todo + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/requirements_all.txt b/requirements_all.txt index 7ac56f99175b7..89cf8cd8db54f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2334,7 +2334,7 @@ pylutron==0.4.1 pymailgunner==1.4 # homeassistant.components.mammotion -pymammotion==0.5.66 +pymammotion==0.5.69 # homeassistant.components.firmata pymata-express==1.19 From 36dc5e3b29ffd4555480e32952ac66469ede1666 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Fri, 2 Jan 2026 20:08:09 +1300 Subject: [PATCH 46/66] remove device limits from HA and move to the api --- homeassistant/components/mammotion/__init__.py | 5 ----- homeassistant/components/mammotion/models.py | 2 -- 2 files changed, 7 deletions(-) diff --git a/homeassistant/components/mammotion/__init__.py b/homeassistant/components/mammotion/__init__.py index 3b0c3519a5aea..ff6697de9cf2a 100644 --- a/homeassistant/components/mammotion/__init__.py +++ b/homeassistant/components/mammotion/__init__.py @@ -23,7 +23,6 @@ from pymammotion.http.model.http import LoginResponseData, Response from pymammotion.http.model.response_factory import response_factory from pymammotion.mammotion.devices.mammotion import ConnectionPreference, Mammotion -from pymammotion.utility.device_config import DeviceConfig from Tea.exceptions import UnretryableException from homeassistant.components import bluetooth @@ -161,9 +160,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> await coordinator.async_restore_data() - device_config = DeviceConfig() - device_limits = device_config.get_best_default(device.product_key) - if not use_wifi: mammotion_device.preference = ConnectionPreference.BLUETOOTH if cloud := mammotion_device.cloud: @@ -176,7 +172,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> name=device.device_name, api=api, coordinator=coordinator, - device_limits=device_limits, device=device, ) ) diff --git a/homeassistant/components/mammotion/models.py b/homeassistant/components/mammotion/models.py index 34eb1e5800da7..bb0ff4af8ef53 100644 --- a/homeassistant/components/mammotion/models.py +++ b/homeassistant/components/mammotion/models.py @@ -3,7 +3,6 @@ from dataclasses import dataclass from pymammotion.aliyun.model.dev_by_account_response import Device -from pymammotion.data.model.device_limits import DeviceLimits from pymammotion.homeassistant import HomeAssistantMowerApi from .coordinator import MammotionMowerUpdateCoordinator @@ -16,7 +15,6 @@ class MammotionMowerData: name: str api: HomeAssistantMowerApi coordinator: MammotionMowerUpdateCoordinator - device_limits: DeviceLimits device: Device From 1ec413ea9d5b0a381dc847a4457d8b39a5213420 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Fri, 23 Jan 2026 18:18:14 +1300 Subject: [PATCH 47/66] Update tests and config flow to remove bluetooth options --- .../components/mammotion/__init__.py | 56 ++------ .../components/mammotion/config_flow.py | 110 ++------------- homeassistant/components/mammotion/const.py | 2 - .../components/mammotion/lawn_mower.py | 39 +++++- .../components/mammotion/manifest.json | 4 +- .../components/mammotion/strings.json | 37 +----- homeassistant/generated/integrations.json | 2 +- requirements_all.txt | 2 +- .../components/mammotion/test_config_flow.py | 125 +++--------------- tests/components/mammotion/test_lawn_mower.py | 4 +- 10 files changed, 89 insertions(+), 292 deletions(-) diff --git a/homeassistant/components/mammotion/__init__.py b/homeassistant/components/mammotion/__init__.py index ff6697de9cf2a..be00583221fb5 100644 --- a/homeassistant/components/mammotion/__init__.py +++ b/homeassistant/components/mammotion/__init__.py @@ -1,8 +1,9 @@ -"""The Mammotion Luba integration.""" +"""The Mammotion integration.""" from __future__ import annotations import contextlib +from datetime import datetime from aiohttp import ClientConnectorError from pymammotion import CloudIOTGateway @@ -22,30 +23,27 @@ from pymammotion.http.http import MammotionHTTP from pymammotion.http.model.http import LoginResponseData, Response from pymammotion.http.model.response_factory import response_factory -from pymammotion.mammotion.devices.mammotion import ConnectionPreference, Mammotion +from pymammotion.mammotion.devices.mammotion import Mammotion from Tea.exceptions import UnretryableException -from homeassistant.components import bluetooth from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.event import async_call_later from .config import MammotionConfigStore from .const import ( CONF_ACCOUNTNAME, CONF_AEP_DATA, CONF_AUTH_DATA, - CONF_BLE_DEVICES, CONF_CONNECT_DATA, CONF_DEVICE_DATA, CONF_MAMMOTION_DATA, CONF_REGION_DATA, CONF_SESSION_DATA, - CONF_STAY_CONNECTED_BLUETOOTH, - CONF_USE_WIFI, DEVICE_SUPPORT, DOMAIN, EXPIRED_CREDENTIAL_EXCEPTIONS, @@ -61,22 +59,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> bool: """Set up Mammotion Luba from a config entry.""" - addresses = entry.data.get(CONF_BLE_DEVICES, {}) mammotion = Mammotion() account = entry.data.get(CONF_ACCOUNTNAME) password = entry.data.get(CONF_PASSWORD) - stay_connected_ble = entry.data.get(CONF_STAY_CONNECTED_BLUETOOTH, False) - - hass.config_entries.async_update_entry( - entry, - options={CONF_STAY_CONNECTED_BLUETOOTH: stay_connected_ble}, - ) - - stay_connected_ble = entry.options.get(CONF_STAY_CONNECTED_BLUETOOTH, False) - - use_wifi = entry.data.get(CONF_USE_WIFI, True) - mammotion_mowers: list[MammotionMowerData] = [] mammotion_devices: MammotionDevices = MammotionDevices([]) @@ -133,26 +119,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> continue if device in shimed_cloud_devices: - mammotion_device = mammotion.get_or_create_device_by_name( + mammotion.get_or_create_device_by_name( device, mammotion_mqtt_client, None ) elif device in cloud_devices: - mammotion_device = mammotion.get_or_create_device_by_name( - device, aliyun_mqtt_client, None - ) - else: - mammotion_device = mammotion.get_or_create_device_by_name( - device, None, None - ) - - if device_ble_address := addresses.get(device.device_name, None): - mammotion_device.state.mower_state.ble_mac = device_ble_address - ble_device = bluetooth.async_ble_device_from_address( - hass, device_ble_address.upper(), True - ) - if ble_device: - ble = mammotion_device.add_ble(ble_device) - ble.set_disconnect_strategy(disconnect=not stay_connected_ble) + mammotion.get_or_create_device_by_name(device, aliyun_mqtt_client, None) api = HomeAssistantMowerApi(async_get_clientsession(hass)) @@ -160,13 +131,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> await coordinator.async_restore_data() - if not use_wifi: - mammotion_device.preference = ConnectionPreference.BLUETOOTH - if cloud := mammotion_device.cloud: - cloud.stop() - cloud.mqtt.disconnect() if cloud.mqtt.is_connected() else None - mammotion_device.remove_cloud() - mammotion_mowers.append( MammotionMowerData( name=device.device_name, @@ -176,6 +140,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> ) ) + async def _start_coordinator( + _: datetime | None = None, + coordinator: MammotionMowerUpdateCoordinator = coordinator, + ) -> None: + await coordinator.async_config_entry_first_refresh() + + async_call_later(hass, 1, _start_coordinator) + mammotion_devices.mowers = mammotion_mowers entry.runtime_data = mammotion_devices diff --git a/homeassistant/components/mammotion/config_flow.py b/homeassistant/components/mammotion/config_flow.py index c698747fc0d13..0e86f4fc54c78 100644 --- a/homeassistant/components/mammotion/config_flow.py +++ b/homeassistant/components/mammotion/config_flow.py @@ -14,24 +14,15 @@ BluetoothServiceInfo, async_discovered_service_info, ) -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_ADDRESS, CONF_PASSWORD -from homeassistant.core import callback from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, format_mac -from . import MammotionConfigEntry from .const import ( CONF_ACCOUNT_ID, CONF_ACCOUNTNAME, CONF_BLE_DEVICES, - CONF_STAY_CONNECTED_BLUETOOTH, - CONF_USE_WIFI, DEVICE_SUPPORT, DOMAIN, LOGGER, @@ -44,10 +35,9 @@ class MammotionConfigFlow(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize the config flow.""" self._config: dict = {} - self._stay_connected = False self._cloud_client: CloudIOTGateway | None = None - self._discovered_device: BLEDevice | None = None self._discovered_devices: dict[str, str] = {} + self._discovered_device: BLEDevice | None = None async def check_and_update_bluetooth_device( self, device: BLEDevice @@ -68,11 +58,7 @@ async def check_and_update_bluetooth_device( identifiers = {device_id[1] for device_id in device_entry.identifiers} if device.name in identifiers: await self.async_set_unique_id(entry.data.get(CONF_ACCOUNT_ID)) - formatted_ble = ( - format_mac(self._discovered_device.address) - if self._discovered_device - else None - ) + formatted_ble = format_mac(device.address) if device else None if ( CONNECTION_BLUETOOTH, @@ -150,21 +136,11 @@ async def async_step_bluetooth_confirm( } if user_input is not None: - self._stay_connected = user_input.get(CONF_STAY_CONNECTED_BLUETOOTH, False) - return await self.async_step_wifi(user_input) + return await self.async_step_wifi() return self.async_show_form( step_id="bluetooth_confirm", - last_step=False, description_placeholders={"name": name}, - data_schema=vol.Schema( - { - vol.Optional( - CONF_STAY_CONNECTED_BLUETOOTH, - default=False, - ): cv.boolean - }, - ), ) async def async_step_user( @@ -173,12 +149,7 @@ async def async_step_user( """Handle the user step to pick discovered device.""" if user_input is not None: - self._stay_connected = user_input.get(CONF_STAY_CONNECTED_BLUETOOTH, False) - if selected_address := user_input.get(CONF_ADDRESS): - self._discovered_device = bluetooth.async_ble_device_from_address( - self.hass, selected_address - ) - return await self.async_step_wifi(user_input) + return await self.async_step_wifi() current_addresses = self._async_current_ids() for discovery_info in async_discovered_service_info(self.hass): @@ -196,31 +167,24 @@ async def async_step_user( self._discovered_devices[address] = discovery_info.name if not self._discovered_devices: - return await self.async_step_wifi(user_input) + 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), - vol.Optional( - CONF_STAY_CONNECTED_BLUETOOTH, - default=False, - ): cv.boolean, }, ), ) async def async_step_wifi( - self, user_input: dict[str, Any] | None + 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 and ( - user_input.get(CONF_ACCOUNTNAME) is not None - or user_input.get(CONF_USE_WIFI) is True - ): + if user_input is not None and user_input.get(CONF_ACCOUNTNAME): account = user_input.get(CONF_ACCOUNTNAME, "") password = user_input.get(CONF_PASSWORD, "") mammotion_http = MammotionHTTP(account, password) @@ -244,44 +208,19 @@ async def async_step_wifi( CONF_ACCOUNTNAME: account, CONF_PASSWORD: password, CONF_ACCOUNT_ID: user_account, - CONF_USE_WIFI: user_input.get(CONF_USE_WIFI, True), **self._config, }, - options={CONF_STAY_CONNECTED_BLUETOOTH: self._stay_connected}, ) - if user_input is not None and user_input.get(CONF_USE_WIFI) is False: - assert self._discovered_device is not None - assert self._discovered_device.name is not None - return self.async_create_entry( - title=self._discovered_device.name - if self._discovered_device.name - else "", - data={ - CONF_USE_WIFI: user_input.get(CONF_USE_WIFI), - **self._config, - }, - options={CONF_STAY_CONNECTED_BLUETOOTH: self._stay_connected}, - ) - schema = { vol.Optional(CONF_ACCOUNTNAME): cv.string, vol.Optional(CONF_PASSWORD): cv.string, - vol.Optional(CONF_USE_WIFI, default=True): cv.boolean, } return self.async_show_form( step_id="wifi", data_schema=vol.Schema(schema), errors=errors ) - @staticmethod - @callback - def async_get_options_flow( - config_entry: MammotionConfigEntry, - ) -> OptionsFlow: - """Create the options flow.""" - return MammotionConfigFlowHandler(config_entry) - async def async_step_reconfigure( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -310,9 +249,6 @@ async def async_step_reconfigure( vol.Required( CONF_PASSWORD, default=entry.data.get(CONF_PASSWORD) ): cv.string, - vol.Optional( - CONF_USE_WIFI, default=entry.data.get(CONF_USE_WIFI, True) - ): cv.boolean, } return self.async_show_form( @@ -320,33 +256,3 @@ async def async_step_reconfigure( data_schema=vol.Schema(schema), errors=errors, ) - - -class MammotionConfigFlowHandler(OptionsFlow): - """Handles options flow for the component.""" - - def __init__(self, config_entry: MammotionConfigEntry) -> None: - """Initialize options flow.""" - self.stay_connected_bluetooth = config_entry.options.get( - CONF_STAY_CONNECTED_BLUETOOTH, False - ) - - async def async_step_init( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Manage the options for the custom component.""" - if user_input: - return self.async_create_entry(data=user_input) - - options_schema = vol.Schema( - { - vol.Optional( - CONF_STAY_CONNECTED_BLUETOOTH, - default=self.stay_connected_bluetooth, - ): cv.boolean - } - ) - - return self.async_show_form( - data_schema=options_schema, - ) diff --git a/homeassistant/components/mammotion/const.py b/homeassistant/components/mammotion/const.py index ec2eff256ecc3..c74fb51fe0ddb 100644 --- a/homeassistant/components/mammotion/const.py +++ b/homeassistant/components/mammotion/const.py @@ -33,10 +33,8 @@ EXPIRED_CREDENTIAL_EXCEPTIONS = (CheckSessionException, SetupException) -CONF_STAY_CONNECTED_BLUETOOTH: Final = "stay_connected_bluetooth" CONF_ACCOUNTNAME: Final = "account_name" CONF_ACCOUNT_ID: Final = "mammotion_account_id" -CONF_USE_WIFI: Final = "use_wifi" CONF_BLE_DEVICES: Final = "ble_devices" CONF_AUTH_DATA: Final = "auth_data" CONF_CONNECT_DATA: Final = "connect_data" diff --git a/homeassistant/components/mammotion/lawn_mower.py b/homeassistant/components/mammotion/lawn_mower.py index 3b70003d9dfdd..c58bbfa53b0dc 100644 --- a/homeassistant/components/mammotion/lawn_mower.py +++ b/homeassistant/components/mammotion/lawn_mower.py @@ -54,7 +54,9 @@ class MammotionLawnMowerEntity(MammotionBaseEntity, LawnMowerEntity): _attr_name = None _attr_supported_features = ( - LawnMowerEntityFeature.DOCK | LawnMowerEntityFeature.PAUSE + LawnMowerEntityFeature.DOCK + | LawnMowerEntityFeature.PAUSE + | LawnMowerEntityFeature.START_MOWING ) def __init__(self, coordinator: MammotionMowerUpdateCoordinator) -> None: @@ -93,6 +95,41 @@ def activity(self) -> LawnMowerActivity | None: return LawnMowerActivity.DOCKED return None + async def async_start_mowing(self) -> None: + """Start mowing.""" + trans_key = "start_mowing_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 + ) + async def async_dock(self) -> None: """Start docking.""" trans_key = "pause_failed" diff --git a/homeassistant/components/mammotion/manifest.json b/homeassistant/components/mammotion/manifest.json index f517539a5c053..0970e95d6b4e6 100644 --- a/homeassistant/components/mammotion/manifest.json +++ b/homeassistant/components/mammotion/manifest.json @@ -18,8 +18,8 @@ "dependencies": ["bluetooth"], "documentation": "https://www.home-assistant.io/integrations/mammotion", "integration_type": "device", - "iot_class": "local_push", + "iot_class": "cloud_polling", "loggers": ["pymammotion"], "quality_scale": "bronze", - "requirements": ["pymammotion==0.5.69"] + "requirements": ["pymammotion==0.5.70"] } diff --git a/homeassistant/components/mammotion/strings.json b/homeassistant/components/mammotion/strings.json index cf36e47682cb6..60f2af2245a77 100644 --- a/homeassistant/components/mammotion/strings.json +++ b/homeassistant/components/mammotion/strings.json @@ -19,49 +19,37 @@ "flow_title": "Configure your Mammotion lawn mower", "step": { "bluetooth_confirm": { - "data": { - "stay_connected_bluetooth": "Keep Bluetooth connected" - }, - "data_description": { - "stay_connected_bluetooth": "If you select this option, the integration will not disconnect from Bluetooth preventing any other device from connecting to the mower." - }, "description": "Set up {name}" }, "reconfigure": { "data": { "account_name": "[%key:component::mammotion::config::step::wifi::data::account_name%]", - "password": "[%key:component::mammotion::config::step::wifi::data::password%]", - "use_wifi": "[%key:component::mammotion::config::step::wifi::data::use_wifi%]" + "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%]", - "use_wifi": "[%key:component::mammotion::config::step::wifi::data_description::use_wifi%]" + "password": "[%key:component::mammotion::config::step::wifi::data_description::password%]" }, "description": "Enter your Mammotion account email or ID and password", "title": "Connect to Wi-Fi" }, "user": { "data": { - "address": "Device", - "stay_connected_bluetooth": "[%key:component::mammotion::config::step::bluetooth_confirm::data::stay_connected_bluetooth%]" + "address": "Device" }, "data_description": { - "address": "Bluetooth address of the mower", - "stay_connected_bluetooth": "[%key:component::mammotion::config::step::bluetooth_confirm::data_description::stay_connected_bluetooth%]" + "address": "Bluetooth address of the mower" }, "description": "Select your mower" }, "wifi": { "data": { "account_name": "Mammotion email or account number", - "password": "Mammotion account password", - "use_wifi": "Use Wi-Fi (deselect to use Bluetooth)" + "password": "Mammotion account password" }, "data_description": { "account_name": "Mammotion email or account number for your shared Mammotion account.", - "password": "Mammotion shared account password", - "use_wifi": "Connect using the cloud, can also connect over Bluetooth as well (deselect to only use Bluetooth)" + "password": "Mammotion shared account password" } } } @@ -89,18 +77,5 @@ "start_failed": { "message": "Failed to start the mower." } - }, - "options": { - "step": { - "init": { - "data": { - "stay_connected_bluetooth": "[%key:component::mammotion::config::step::bluetooth_confirm::data::stay_connected_bluetooth%]", - "title": "Update configuration" - }, - "data_description": { - "stay_connected_bluetooth": "[%key:component::mammotion::config::step::bluetooth_confirm::data_description::stay_connected_bluetooth%]" - } - } - } } } diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 9dbb7de37f443..97adb1cc26cac 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4042,7 +4042,7 @@ "name": "Mammotion", "integration_type": "device", "config_flow": true, - "iot_class": "local_push" + "iot_class": "cloud_polling" }, "marantz": { "name": "Marantz", diff --git a/requirements_all.txt b/requirements_all.txt index 89cf8cd8db54f..65ed7645c84d4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2334,7 +2334,7 @@ pylutron==0.4.1 pymailgunner==1.4 # homeassistant.components.mammotion -pymammotion==0.5.69 +pymammotion==0.5.70 # homeassistant.components.firmata pymata-express==1.19 diff --git a/tests/components/mammotion/test_config_flow.py b/tests/components/mammotion/test_config_flow.py index 1e5b899188d7b..6697ac887f35b 100644 --- a/tests/components/mammotion/test_config_flow.py +++ b/tests/components/mammotion/test_config_flow.py @@ -10,8 +10,6 @@ CONF_ACCOUNT_ID, CONF_ACCOUNTNAME, CONF_BLE_DEVICES, - CONF_STAY_CONNECTED_BLUETOOTH, - CONF_USE_WIFI, DOMAIN, ) from homeassistant.const import CONF_ADDRESS, CONF_PASSWORD @@ -52,24 +50,15 @@ async def test_bluetooth_discovery_success(hass: HomeAssistant) -> None: data=discovery_info, ) + # Bluetooth discovery goes to bluetooth_confirm step assert result["type"] == FlowResultType.FORM assert result["step_id"] == "bluetooth_confirm" - assert result["description_placeholders"] == {"name": "Luba-ABC123"} - # Confirm Bluetooth - mock_mammotion = MagicMock() - mock_mammotion.login_v2 = AsyncMock() - with patch( - "homeassistant.components.mammotion.config_flow.MammotionHTTP", - return_value=mock_mammotion, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_STAY_CONNECTED_BLUETOOTH: True}, - ) + # Confirm bluetooth step + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result2["type"] == FlowResultType.FORM - assert result2["step_id"] == "wifi" + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "wifi" # Configure WiFi with credentials mock_http = MagicMock() @@ -80,72 +69,22 @@ async def test_bluetooth_discovery_success(hass: HomeAssistant) -> None: "homeassistant.components.mammotion.config_flow.MammotionHTTP", return_value=mock_http, ): - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], { CONF_ACCOUNTNAME: "user@example.com", CONF_PASSWORD: "password", - CONF_USE_WIFI: True, }, ) - assert result3["type"] == FlowResultType.CREATE_ENTRY - assert result3["title"] == "user@example.com" - assert result3["data"] == { + 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_USE_WIFI: True, - CONF_BLE_DEVICES: {"Luba-ABC123": "aa:bb:cc:dd:ee:ff"}, - } - assert result3["options"] == {CONF_STAY_CONNECTED_BLUETOOTH: True} - - -async def test_bluetooth_discovery_bluetooth_only(hass: HomeAssistant) -> None: - """Test bluetooth discovery configuring usage without WiFi.""" - 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" - - mock_mammotion = MagicMock() - mock_mammotion.login_v2 = AsyncMock() - with patch( - "homeassistant.components.mammotion.config_flow.MammotionHTTP", - return_value=mock_mammotion, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_STAY_CONNECTED_BLUETOOTH: False}, - ) - - assert result2["type"] == FlowResultType.FORM - assert result2["step_id"] == "wifi" - - # Disable WiFi - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - {CONF_USE_WIFI: False}, - ) - - assert result3["type"] == FlowResultType.CREATE_ENTRY - assert result3["title"] == "Luba-ABC123" - assert result3["data"] == { - CONF_USE_WIFI: False, CONF_BLE_DEVICES: {"Luba-ABC123": "aa:bb:cc:dd:ee:ff"}, } - assert result3["options"] == {CONF_STAY_CONNECTED_BLUETOOTH: False} async def test_bluetooth_discovery_already_configured(hass: HomeAssistant) -> None: @@ -235,13 +174,14 @@ async def test_user_step_pick_discovery(hass: HomeAssistant) -> None: 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_STAY_CONNECTED_BLUETOOTH: True}, + result["flow_id"], {CONF_ADDRESS: "AA:BB:CC:DD:EE:FF"} ) assert result2["type"] == FlowResultType.FORM @@ -254,6 +194,7 @@ async def test_user_step_manual_entry(hass: HomeAssistant) -> None: mock_mammotion = MagicMock() mock_mammotion.login_v2 = AsyncMock() + mock_mammotion.login_info.userInformation.userAccount = "user123" with ( patch( @@ -266,12 +207,7 @@ async def test_user_step_manual_entry(hass: HomeAssistant) -> None: ), ): result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={ - CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", - CONF_STAY_CONNECTED_BLUETOOTH: True, - }, + DOMAIN, context={"source": config_entries.SOURCE_USER}, data={} ) assert result["type"] == FlowResultType.FORM @@ -349,11 +285,7 @@ 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", - CONF_USE_WIFI: True, - }, + data={CONF_ACCOUNTNAME: "old@example.com", CONF_PASSWORD: "old_password"}, unique_id="user123", ) entry.add_to_hass(hass) @@ -374,7 +306,6 @@ async def test_reconfigure_flow(hass: HomeAssistant) -> None: { CONF_ACCOUNTNAME: "new@example.com", CONF_PASSWORD: "new_password", - CONF_USE_WIFI: False, }, ) @@ -384,30 +315,6 @@ async def test_reconfigure_flow(hass: HomeAssistant) -> None: 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_USE_WIFI] is False - - -async def test_options_flow(hass: HomeAssistant) -> None: - """Test options flow.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={}, - options={CONF_STAY_CONNECTED_BLUETOOTH: False}, - unique_id="user123", - ) - entry.add_to_hass(hass) - - result = await hass.config_entries.options.async_init(entry.entry_id) - - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "init" - - result2 = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={CONF_STAY_CONNECTED_BLUETOOTH: True} - ) - - assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["data"][CONF_STAY_CONNECTED_BLUETOOTH] is True async def test_bluetooth_discovery_update_existing_entry(hass: HomeAssistant) -> None: diff --git a/tests/components/mammotion/test_lawn_mower.py b/tests/components/mammotion/test_lawn_mower.py index 0c6c4396d60a5..9394a7c50b6e7 100644 --- a/tests/components/mammotion/test_lawn_mower.py +++ b/tests/components/mammotion/test_lawn_mower.py @@ -63,7 +63,9 @@ async def test_lawn_mower_entity_init(mock_mower_coordinator) -> None: assert entity._attr_name is None assert entity._attr_supported_features == ( - LawnMowerEntityFeature.DOCK | LawnMowerEntityFeature.PAUSE + LawnMowerEntityFeature.DOCK + | LawnMowerEntityFeature.PAUSE + | LawnMowerEntityFeature.START_MOWING ) From 84ed6ff9bd71a27c4a0b3609b6c9d25532ba93e4 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Fri, 3 Jul 2026 22:59:20 +1200 Subject: [PATCH 48/66] update pymammotion --- homeassistant/components/mammotion/__init__.py | 10 ++++++---- homeassistant/components/mammotion/manifest.json | 2 +- requirements_all.txt | 2 +- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/mammotion/__init__.py b/homeassistant/components/mammotion/__init__.py index be00583221fb5..04e6c14168da4 100644 --- a/homeassistant/components/mammotion/__init__.py +++ b/homeassistant/components/mammotion/__init__.py @@ -127,22 +127,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> api = HomeAssistantMowerApi(async_get_clientsession(hass)) - coordinator = MammotionMowerUpdateCoordinator(hass, entry, device, api) + update_coordinator = MammotionMowerUpdateCoordinator( + hass, entry, device, api + ) - await coordinator.async_restore_data() + await update_coordinator.async_restore_data() mammotion_mowers.append( MammotionMowerData( name=device.device_name, api=api, - coordinator=coordinator, + coordinator=update_coordinator, device=device, ) ) async def _start_coordinator( _: datetime | None = None, - coordinator: MammotionMowerUpdateCoordinator = coordinator, + coordinator: MammotionMowerUpdateCoordinator = update_coordinator, ) -> None: await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/mammotion/manifest.json b/homeassistant/components/mammotion/manifest.json index 0970e95d6b4e6..05f1a55e06fb9 100644 --- a/homeassistant/components/mammotion/manifest.json +++ b/homeassistant/components/mammotion/manifest.json @@ -21,5 +21,5 @@ "iot_class": "cloud_polling", "loggers": ["pymammotion"], "quality_scale": "bronze", - "requirements": ["pymammotion==0.5.70"] + "requirements": ["pymammotion==0.8.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index 65ed7645c84d4..952b8bf24c621 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2334,7 +2334,7 @@ pylutron==0.4.1 pymailgunner==1.4 # homeassistant.components.mammotion -pymammotion==0.5.70 +pymammotion==0.8.9 # homeassistant.components.firmata pymata-express==1.19 From 2c986a71d625dea8e197626bdd85bd1f0425a74a Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Fri, 3 Jul 2026 23:16:59 +1200 Subject: [PATCH 49/66] fix step bluetooth signature --- homeassistant/components/mammotion/config_flow.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mammotion/config_flow.py b/homeassistant/components/mammotion/config_flow.py index 0e86f4fc54c78..c1e872bd9b8a0 100644 --- a/homeassistant/components/mammotion/config_flow.py +++ b/homeassistant/components/mammotion/config_flow.py @@ -77,7 +77,7 @@ async def check_and_update_bluetooth_device( return None async def async_step_bluetooth( - self, discovery_info: BluetoothServiceInfo | None = None + self, discovery_info: BluetoothServiceInfo ) -> ConfigFlowResult: """Handle the bluetooth discovery step.""" LOGGER.debug("Discovered bluetooth device: %s", discovery_info) @@ -120,7 +120,7 @@ async def async_step_bluetooth_confirm( assert self._discovered_device is not None assert self._discovered_device.name is not None device = self._discovered_device - name = device.name if device.name else "" + name = device.name or "" if entry := await self.check_and_update_bluetooth_device(device): existing_devices = { name: format_mac(device.address), From 198e6ccc99379cfea4340acf91a799bb03ebe3cf Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Fri, 3 Jul 2026 23:27:55 +1200 Subject: [PATCH 50/66] fix up translation key --- homeassistant/components/mammotion/lawn_mower.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/mammotion/lawn_mower.py b/homeassistant/components/mammotion/lawn_mower.py index c58bbfa53b0dc..385e22d3decf6 100644 --- a/homeassistant/components/mammotion/lawn_mower.py +++ b/homeassistant/components/mammotion/lawn_mower.py @@ -97,7 +97,7 @@ def activity(self) -> LawnMowerActivity | None: async def async_start_mowing(self) -> None: """Start mowing.""" - trans_key = "start_mowing_failed" + trans_key = "start_failed" mode = self.rpt_dev_status.sys_status if mode is None: From 61b5c6853336515d7bb7d59c0d6e88efd545ca6c Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Sat, 4 Jul 2026 00:18:06 +1200 Subject: [PATCH 51/66] update the integration with the newest changes from the library and hacs --- .../components/mammotion/__init__.py | 199 ++++-------------- homeassistant/components/mammotion/config.py | 2 +- .../components/mammotion/config_flow.py | 10 +- homeassistant/components/mammotion/const.py | 31 +-- .../components/mammotion/coordinator.py | 76 ++----- homeassistant/components/mammotion/entity.py | 61 +++--- .../components/mammotion/lawn_mower.py | 21 +- tests/components/mammotion/conftest.py | 10 +- tests/components/mammotion/test_lawn_mower.py | 147 +++++++++---- 9 files changed, 216 insertions(+), 341 deletions(-) diff --git a/homeassistant/components/mammotion/__init__.py b/homeassistant/components/mammotion/__init__.py index 04e6c14168da4..1e73bba6215d5 100644 --- a/homeassistant/components/mammotion/__init__.py +++ b/homeassistant/components/mammotion/__init__.py @@ -1,29 +1,13 @@ """The Mammotion integration.""" -from __future__ import annotations - import contextlib from datetime import datetime +from typing import Any from aiohttp import ClientConnectorError -from pymammotion import CloudIOTGateway -from pymammotion.aliyun.model.aep_response import AepResponse -from pymammotion.aliyun.model.connect_response import ConnectResponse -from pymammotion.aliyun.model.dev_by_account_response import ( - Device, - ListingDevAccountResponse, -) -from pymammotion.aliyun.model.login_by_oauth_response import LoginByOAuthResponse -from pymammotion.aliyun.model.regions_response import RegionResponse -from pymammotion.aliyun.model.session_by_authcode_response import ( - SessionByAuthCodeResponse, -) -from pymammotion.data.model.account import Credentials +from pymammotion.aliyun.model.dev_by_account_response import Device +from pymammotion.client import MammotionClient from pymammotion.homeassistant import HomeAssistantMowerApi -from pymammotion.http.http import MammotionHTTP -from pymammotion.http.model.http import LoginResponseData, Response -from pymammotion.http.model.response_factory import response_factory -from pymammotion.mammotion.devices.mammotion import Mammotion from Tea.exceptions import UnretryableException from homeassistant.config_entries import ConfigEntry @@ -38,12 +22,8 @@ from .const import ( CONF_ACCOUNTNAME, CONF_AEP_DATA, - CONF_AUTH_DATA, - CONF_CONNECT_DATA, - CONF_DEVICE_DATA, - CONF_MAMMOTION_DATA, - CONF_REGION_DATA, - CONF_SESSION_DATA, + CONF_MAMMOTION_DEVICE_RECORDS, + CONF_MAMMOTION_MQTT, DEVICE_SUPPORT, DOMAIN, EXPIRED_CREDENTIAL_EXCEPTIONS, @@ -59,74 +39,41 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> bool: """Set up Mammotion Luba from a config entry.""" - mammotion = Mammotion() + api = HomeAssistantMowerApi(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([]) - cloud_client: CloudIOTGateway | None = None if account and password: - credentials = Credentials() - credentials.email = account - credentials.password = password + session = async_get_clientsession(hass) + cached = _load_cached_credentials(entry) try: - with contextlib.suppress(KeyError): - cloud_client = await check_and_restore_cloud(hass, entry) - if cloud_client is None: - await mammotion.login_and_initiate_cloud(account, password) + if cached: + await mammotion.restore_credentials(account, password, cached, session) else: - if cloud_client.mammotion_http.login_info is None: - mammotion_http = MammotionHTTP() - await mammotion_http.login(account, password) - cloud_client.set_http(mammotion_http) - await mammotion.initiate_cloud_connection(account, cloud_client) + await mammotion.login_and_initiate_cloud(account, password, session) except ClientConnectorError as err: raise ConfigEntryNotReady(err) from err except EXPIRED_CREDENTIAL_EXCEPTIONS: - await mammotion.login_and_initiate_cloud(account, password, True) + await mammotion.login_and_initiate_cloud(account, password, session) except UnretryableException as err: raise ConfigEntryError(err) from err - aliyun_mqtt_client = mammotion.mqtt_list.get(f"{account}_aliyun") - mammotion_mqtt_client = mammotion.mqtt_list.get(f"{account}_mammotion") - - if aliyun_mqtt_client: - mqtt_client = aliyun_mqtt_client - store_cloud_credentials(hass, entry, mqtt_client.cloud_client) - elif mammotion_mqtt_client: - mqtt_client = mammotion_mqtt_client - store_cloud_credentials(hass, entry, mqtt_client.cloud_client) - - device_list: list[Device] = [] - shimed_cloud_devices = [] - cloud_devices = [] + store_cloud_credentials(hass, entry, mammotion) - if mammotion_mqtt_client: - shimed_cloud_devices = mammotion.shim_cloud_devices( - mammotion_mqtt_client.cloud_client.mammotion_http.device_records.records + device_list: list[Device] = [ + device + for device in ( + *mammotion.aliyun_device_list, + *mammotion.mammotion_device_list, ) - device_list.extend(shimed_cloud_devices) - if aliyun_mqtt_client: - cloud_devices = ( - aliyun_mqtt_client.cloud_client.devices_by_account_response.data.data - ) - device_list.extend(cloud_devices) + if device.device_name.startswith(DEVICE_SUPPORT) + ] for device in device_list: - if not device.device_name.startswith(DEVICE_SUPPORT): - continue - - if device in shimed_cloud_devices: - mammotion.get_or_create_device_by_name( - device, mammotion_mqtt_client, None - ) - elif device in cloud_devices: - mammotion.get_or_create_device_by_name(device, aliyun_mqtt_client, None) - - api = HomeAssistantMowerApi(async_get_clientsession(hass)) - update_coordinator = MammotionMowerUpdateCoordinator( hass, entry, device, api ) @@ -154,7 +101,7 @@ async def _start_coordinator( entry.runtime_data = mammotion_devices async def shutdown_mammotion(_: Event | None = None) -> None: - await api.mammotion.stop() + await mammotion.stop() entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown_mammotion) @@ -169,99 +116,24 @@ async def shutdown_mammotion(_: Event | None = None) -> None: def store_cloud_credentials( hass: HomeAssistant, config_entry: MammotionConfigEntry, - cloud_client: CloudIOTGateway, + mammotion: MammotionClient, ) -> None: """Store cloud credentials in config entry.""" - - if cloud_client is not None: - mammotion_data = config_entry.data.get(CONF_MAMMOTION_DATA) - if cloud_client.mammotion_http is not None: - mammotion_data = cloud_client.mammotion_http.response - - config_updates = { - **config_entry.data, - CONF_CONNECT_DATA: cloud_client.connect_response, - CONF_AUTH_DATA: cloud_client.login_by_oauth_response, - CONF_REGION_DATA: cloud_client.region_response, - CONF_AEP_DATA: cloud_client.aep_response, - CONF_SESSION_DATA: cloud_client.session_by_authcode_response, - CONF_DEVICE_DATA: cloud_client.devices_by_account_response, - CONF_MAMMOTION_DATA: mammotion_data, - } - hass.config_entries.async_update_entry(config_entry, data=config_updates) - - -async def check_and_restore_cloud( - hass: HomeAssistant, entry: MammotionConfigEntry -) -> CloudIOTGateway | None: - """Check and restore previous cloud connection.""" - - auth_data = entry.data[CONF_AUTH_DATA] - region_data = entry.data[CONF_REGION_DATA] - aep_data = entry.data[CONF_AEP_DATA] - session_data = entry.data[CONF_SESSION_DATA] - device_data = entry.data[CONF_DEVICE_DATA] - connect_data = entry.data[CONF_CONNECT_DATA] - mammotion_data = entry.data[CONF_MAMMOTION_DATA] - - if any( - data is None - for data in ( - auth_data, - region_data, - aep_data, - session_data, - device_data, - connect_data, - mammotion_data, - ) - ): - return None - - mammotion_response_data = ( - response_factory(Response[LoginResponseData], mammotion_data) - if isinstance(mammotion_data, dict) - else mammotion_data - ) - mammotion_http = MammotionHTTP() - mammotion_http.response = mammotion_response_data - mammotion_http.login_info = ( - LoginResponseData.from_dict(mammotion_response_data.data) - if isinstance(mammotion_response_data.data, dict) - else mammotion_response_data.data - ) - - cloud_client = CloudIOTGateway( - connect_response=ConnectResponse.from_dict(connect_data) - if isinstance(connect_data, dict) - else connect_data, - aep_response=AepResponse.from_dict(aep_data) - if isinstance(aep_data, dict) - else aep_data, - region_response=RegionResponse.from_dict(region_data) - if isinstance(region_data, dict) - else region_data, - session_by_authcode_response=SessionByAuthCodeResponse.from_dict(session_data) - if isinstance(session_data, dict) - else session_data, - dev_by_account=ListingDevAccountResponse.from_dict(device_data) - if isinstance(device_data, dict) - else device_data, - login_by_oauth_response=LoginByOAuthResponse.from_dict(auth_data) - if isinstance(auth_data, dict) - else auth_data, - mammotion_http=mammotion_http, + cache = mammotion.to_cache() + if not cache: + return + hass.config_entries.async_update_entry( + config_entry, data={**config_entry.data, **cache} ) - await cloud_client.check_or_refresh_session() - return cloud_client - -async def _async_update_listener( - hass: HomeAssistant, entry: MammotionConfigEntry -) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) +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: @@ -269,6 +141,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: MammotionConfigEntry) - 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) return unload_ok diff --git a/homeassistant/components/mammotion/config.py b/homeassistant/components/mammotion/config.py index fc2e09ff1e796..e41af9a43aa19 100644 --- a/homeassistant/components/mammotion/config.py +++ b/homeassistant/components/mammotion/config.py @@ -7,7 +7,7 @@ class MammotionConfigStore(Store): - """A configuration store for Alexa.""" + """A configuration store for Mammotion.""" _STORAGE_VERSION = 1 _STORAGE_MINOR_VERSION = 0 diff --git a/homeassistant/components/mammotion/config_flow.py b/homeassistant/components/mammotion/config_flow.py index c1e872bd9b8a0..2ce2a901adc40 100644 --- a/homeassistant/components/mammotion/config_flow.py +++ b/homeassistant/components/mammotion/config_flow.py @@ -1,6 +1,6 @@ """Config flow for Mammotion.""" -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, override from aiohttp.web_exceptions import HTTPException from bleak.backends.device import BLEDevice @@ -68,7 +68,7 @@ async def check_and_update_bluetooth_device( device_entry.id, merge_connections={(CONNECTION_BLUETOOTH, formatted_ble)}, ) - if entry.state == config_entries.ConfigEntryState.LOADED: + 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 @@ -76,13 +76,12 @@ async def check_and_update_bluetooth_device( return entry return None + @override async def async_step_bluetooth( self, discovery_info: BluetoothServiceInfo ) -> 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() @@ -124,7 +123,7 @@ async def async_step_bluetooth_confirm( if entry := await self.check_and_update_bluetooth_device(device): existing_devices = { name: format_mac(device.address), - **entry.data.get(CONF_BLE_DEVICES, None), + **entry.data.get(CONF_BLE_DEVICES, {}), } self._abort_if_unique_id_configured( updates={CONF_BLE_DEVICES: existing_devices} @@ -143,6 +142,7 @@ async def async_step_bluetooth_confirm( description_placeholders={"name": name}, ) + @override async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/mammotion/const.py b/homeassistant/components/mammotion/const.py index c74fb51fe0ddb..f2c826cdf5389 100644 --- a/homeassistant/components/mammotion/const.py +++ b/homeassistant/components/mammotion/const.py @@ -5,53 +5,38 @@ from bleak.exc import BleakError from bleak_retry_connector import BleakNotFoundError -from pymammotion.aliyun.cloud_gateway import ( +from pymammotion.aliyun.exceptions import ( CheckSessionException, + CloudSetupError, DeviceOfflineException, - SetupException, ) -from pymammotion.mammotion.devices.mammotion_bluetooth import CharacteristicMissingError -from pymammotion.utility.constant import WorkMode +from pymammotion.transport.base import NoTransportAvailableError DOMAIN: Final = "mammotion" DEVICE_SUPPORT = ("Luba", "Yuka") -ATTR_DIRECTION = "direction" - -DEFAULT_RETRY_COUNT = 3 -CONF_RETRY_COUNT = "retry_count" LOGGER: Final = logging.getLogger(__package__) COMMAND_EXCEPTIONS = ( BleakNotFoundError, - CharacteristicMissingError, BleakError, + NoTransportAvailableError, TimeoutError, DeviceOfflineException, ) -EXPIRED_CREDENTIAL_EXCEPTIONS = (CheckSessionException, SetupException) +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_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" - -NO_REQUEST_MODES = ( - WorkMode.MODE_JOB_DRAW, - WorkMode.MODE_OBSTACLE_DRAW, - WorkMode.MODE_CHANNEL_DRAW, - WorkMode.MODE_ERASER_DRAW, - WorkMode.MODE_UPDATING, - WorkMode.MODE_EDIT_BOUNDARY, - WorkMode.MODE_UPDATING, - WorkMode.MODE_LOCK, - WorkMode.MODE_MANUAL_MOWING, -) +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 index 208b8b5cbb7a7..1f7565afbda42 100644 --- a/homeassistant/components/mammotion/coordinator.py +++ b/homeassistant/components/mammotion/coordinator.py @@ -1,35 +1,20 @@ """Provides the mammotion DataUpdateCoordinator.""" -from __future__ import annotations - -from abc import abstractmethod from collections.abc import Mapping from datetime import timedelta -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, override from mashumaro.exceptions import InvalidFieldValue from pymammotion.aliyun.model.dev_by_account_response import Device from pymammotion.data.model.device import MowingDevice from pymammotion.homeassistant import HomeAssistantMowerApi -from pymammotion.mammotion.devices.mammotion import MammotionMowerDeviceManager from homeassistant.const import CONF_PASSWORD from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .config import MammotionConfigStore -from .const import ( - CONF_ACCOUNTNAME, - CONF_AEP_DATA, - CONF_AUTH_DATA, - CONF_CONNECT_DATA, - CONF_DEVICE_DATA, - CONF_MAMMOTION_DATA, - CONF_REGION_DATA, - CONF_SESSION_DATA, - DOMAIN, - LOGGER, -) +from .const import CONF_ACCOUNTNAME, DOMAIN, LOGGER if TYPE_CHECKING: from . import MammotionConfigEntry @@ -64,14 +49,6 @@ def __init__( self.password = config_entry.data[CONF_PASSWORD] self.update_failures = 0 - def __del__(self) -> None: - """Cleanup and store credentials.""" - self.store_cloud_credentials() - - @abstractmethod - def get_coordinator_data(self, device: MammotionMowerDeviceManager) -> MowingDevice: - """Get coordinator data.""" - async def async_refresh_login(self) -> None: """Refresh login credentials asynchronously.""" await self.api.mammotion.refresh_login(self.account) @@ -79,35 +56,17 @@ async def async_refresh_login(self) -> None: def store_cloud_credentials(self) -> None: """Store cloud credentials in config entry.""" - # config_updates = {} if config_entry := self.config_entry: - mammotion_cloud = self.api.mammotion.mqtt_list.get( - config_entry.data.get(CONF_ACCOUNTNAME, "") + cache = self.api.mammotion.to_cache() + if not cache: + return + self.hass.config_entries.async_update_entry( + config_entry, data={**config_entry.data, **cache} ) - cloud_client = mammotion_cloud.cloud_client if mammotion_cloud else None - - if cloud_client is not None: - config_updates = { - **config_entry.data, - CONF_CONNECT_DATA: cloud_client.connect_response, - CONF_AUTH_DATA: cloud_client.login_by_oauth_response, - CONF_REGION_DATA: cloud_client.region_response, - CONF_AEP_DATA: cloud_client.aep_response, - CONF_SESSION_DATA: cloud_client.session_by_authcode_response, - CONF_DEVICE_DATA: cloud_client.devices_by_account_response, - CONF_MAMMOTION_DATA: cloud_client.mammotion_http.response, - } - self.hass.config_entries.async_update_entry( - config_entry, data=config_updates - ) def is_online(self) -> bool: """Check if device is online.""" - if device := self.api.mammotion.get_device_by_name(self.device_name): - return device.state.online or bool( - device.ble and device.ble.client and device.ble.client.is_connected - ) - return False + return self.api.is_online(self.device_name) async def async_send_command(self, command: str, **kwargs: Any) -> bool | None: """Send command via api.""" @@ -133,10 +92,6 @@ def __init__( update_interval=DEFAULT_INTERVAL, ) - def get_coordinator_data(self, device: MammotionMowerDeviceManager) -> MowingDevice: - """Get device state for the coordinator.""" - return device.state - async def async_restore_data(self) -> None: """Restore saved data.""" store = MammotionConfigStore(self.hass) @@ -144,17 +99,19 @@ async def async_restore_data(self) -> None: if restored_data is None: self.data = MowingDevice() - self.api.mammotion.get_device_by_name(self.device_name).state = self.data + if handle := self.api.mammotion.mower(self.device_name): + handle.restore_device(self.data) return try: if mower_data := restored_data.get(self.device_name): mower_state = MowingDevice().from_dict(mower_data) - if device := self.api.mammotion.get_device_by_name(self.device_name): - device.state = mower_state + if handle := self.api.mammotion.mower(self.device_name): + handle.restore_device(mower_state) except InvalidFieldValue: self.data = MowingDevice() - self.api.mammotion.get_device_by_name(self.device_name).state = self.data + if handle := self.api.mammotion.mower(self.device_name): + handle.restore_device(self.data) async def async_save_data(self, data: MowingDevice) -> None: """Get map data from the device.""" @@ -163,9 +120,12 @@ async def async_save_data(self, data: MowingDevice) -> None: current_store[self.device_name] = data.to_dict() await store.async_save(current_store) + @override async def _async_update_data(self) -> MowingDevice: """Get data from the device.""" data = await self.api.update(self.device_name) + if data is None: + raise UpdateFailed(f"No data returned for {self.device_name}") await self.async_save_data(data) return data diff --git a/homeassistant/components/mammotion/entity.py b/homeassistant/components/mammotion/entity.py index 08942a66f4c4a..9ed9ffc73b4c0 100644 --- a/homeassistant/components/mammotion/entity.py +++ b/homeassistant/components/mammotion/entity.py @@ -1,6 +1,6 @@ """Base class for entities.""" -from typing import cast +from typing import cast, override from homeassistant.helpers.device_registry import ( CONNECTION_BLUETOOTH, @@ -24,24 +24,43 @@ def __init__(self, coordinator: MammotionBaseUpdateCoordinator, key: str) -> Non 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 = mower.state.device_firmwares.device_version + swversion: str | None = None model_id: str | None = None + connections: set[tuple[str, str]] = set() + if mower is not None: - if mower.state.mower_state.model_id != "": - model_id = mower.state.mower_state.model_id + swversion = mower.device_firmwares.device_version + + if mower.mower_state.model_id != "": + model_id = mower.mower_state.model_id if ( - mower.state.mqtt_properties is not None - and mower.state.mqtt_properties.params.items.extMod is not None + mower.mqtt_properties is not None + and mower.mqtt_properties.params.items.extMod is not None ): - model_id = cast( - str, mower.state.mqtt_properties.params.items.extMod.value + 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 @@ -51,31 +70,6 @@ def device_info(self) -> DeviceInfo: else self.coordinator.device.nick_name ) - connections: set[tuple[str, str]] = set() - - if mower.ble: - connections.add( - ( - CONNECTION_BLUETOOTH, - format_mac(mower.ble.ble_device.address), - ) - ) - elif mower.state.mower_state.ble_mac != "": - connections.add( - ( - CONNECTION_BLUETOOTH, - format_mac(mower.state.mower_state.ble_mac), - ) - ) - - if mower.state.mower_state.wifi_mac != "": - connections.add( - ( - CONNECTION_NETWORK_MAC, - format_mac(mower.state.mower_state.wifi_mac), - ) - ) - return DeviceInfo( identifiers={(DOMAIN, self.coordinator.device.device_name)}, manufacturer="Mammotion", @@ -88,6 +82,7 @@ def device_info(self) -> DeviceInfo: connections=connections, ) + @override @property def available(self) -> bool: """Return True if entity is available.""" diff --git a/homeassistant/components/mammotion/lawn_mower.py b/homeassistant/components/mammotion/lawn_mower.py index 385e22d3decf6..283f7f0076aca 100644 --- a/homeassistant/components/mammotion/lawn_mower.py +++ b/homeassistant/components/mammotion/lawn_mower.py @@ -1,6 +1,6 @@ """Luba lawn mowers.""" -from __future__ import annotations +from typing import override from pymammotion.data.model.report_info import DeviceData, ReportData from pymammotion.utility.constant.device_constant import WorkMode @@ -21,21 +21,6 @@ PARALLEL_UPDATES = 0 -def get_entity_attribute( - hass: HomeAssistant, entity_id: str, attribute_name: str -) -> str | None: - """Get an attribute from an entity.""" - # Get the state object of the entity - entity = hass.states.get(entity_id) - - # Check if the entity exists and has attributes - if entity and attribute_name in entity.attributes: - # Return the specific attribute - return entity.attributes.get(attribute_name, None) - # Return None if the entity or attribute does not exist - return None - - async def async_setup_entry( hass: HomeAssistant, entry: MammotionConfigEntry, @@ -73,6 +58,7 @@ 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.""" @@ -95,6 +81,7 @@ def activity(self) -> LawnMowerActivity | None: return LawnMowerActivity.DOCKED return None + @override async def async_start_mowing(self) -> None: """Start mowing.""" trans_key = "start_failed" @@ -130,6 +117,7 @@ async def async_start_mowing(self) -> None: self.coordinator.device_name ) + @override async def async_dock(self) -> None: """Start docking.""" trans_key = "pause_failed" @@ -167,6 +155,7 @@ async def async_dock(self) -> None: self.coordinator.device_name ) + @override async def async_pause(self) -> None: """Pause mower.""" trans_key = "pause_failed" diff --git a/tests/components/mammotion/conftest.py b/tests/components/mammotion/conftest.py index 05f03a751ebed..0b5e807c33184 100644 --- a/tests/components/mammotion/conftest.py +++ b/tests/components/mammotion/conftest.py @@ -16,7 +16,7 @@ def mock_bluetooth(enable_bluetooth: None) -> None: @pytest.fixture -def mock_setup_entry(): +def mock_setup_entry() -> Generator[MagicMock]: """Mock setting up a config entry.""" with patch( "homeassistant.components.mammotion.async_setup_entry", return_value=True @@ -35,7 +35,7 @@ def mock_async_discovered_service_info() -> Generator[MagicMock]: @pytest.fixture -def mock_cloud_gateway(): +def mock_cloud_gateway() -> Mock: """Mock a CloudIOTGateway.""" mock_cloud = Mock() mock_cloud.mammotion_http = Mock() @@ -46,7 +46,7 @@ def mock_cloud_gateway(): @pytest.fixture -def mock_http_response(): +def mock_http_response() -> Mock: """Mock a successful HTTP login response.""" mock_response = Mock() mock_response.login_info = Mock() @@ -56,7 +56,7 @@ def mock_http_response(): @pytest.fixture -def mock_mammotion(): +def mock_mammotion() -> AsyncMock: """Mock Mammotion class.""" mock = AsyncMock() mock.mqtt_list = {} @@ -65,7 +65,7 @@ def mock_mammotion(): @pytest.fixture -def mock_mower_coordinator(): +def mock_mower_coordinator() -> AsyncMock: """Return a mocked mower coordinator.""" coordinator = AsyncMock() coordinator.data = Mock() diff --git a/tests/components/mammotion/test_lawn_mower.py b/tests/components/mammotion/test_lawn_mower.py index 9394a7c50b6e7..56883787311d4 100644 --- a/tests/components/mammotion/test_lawn_mower.py +++ b/tests/components/mammotion/test_lawn_mower.py @@ -14,7 +14,6 @@ from homeassistant.components.mammotion.lawn_mower import ( MammotionLawnMowerEntity, async_setup_entry, - get_entity_attribute, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -22,25 +21,9 @@ from tests.common import MockConfigEntry -async def test_get_entity_attribute(hass: HomeAssistant) -> None: - """Test the get_entity_attribute function.""" - # Set up a mock state - hass.states.async_set("sensor.test", "on", {"test_attribute": "test_value"}) - - # Test getting an existing attribute - result = get_entity_attribute(hass, "sensor.test", "test_attribute") - assert result == "test_value" - - # Test getting a non-existent attribute - result = get_entity_attribute(hass, "sensor.test", "non_existent") - assert result is None - - # Test getting an attribute from a non-existent entity - result = get_entity_attribute(hass, "sensor.non_existent", "test_attribute") - assert result is None - - -async def test_async_setup_entry(hass: HomeAssistant, mock_mower_coordinator) -> None: +async def test_async_setup_entry( + hass: HomeAssistant, mock_mower_coordinator: MagicMock +) -> None: """Test setting up the lawn mower platform.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -57,7 +40,7 @@ async def test_async_setup_entry(hass: HomeAssistant, mock_mower_coordinator) -> await async_setup_entry(hass, config_entry, Mock()) -async def test_lawn_mower_entity_init(mock_mower_coordinator) -> None: +async def test_lawn_mower_entity_init(mock_mower_coordinator: MagicMock) -> None: """Test initializing the lawn mower entity.""" entity = MammotionLawnMowerEntity(mock_mower_coordinator) @@ -69,7 +52,7 @@ async def test_lawn_mower_entity_init(mock_mower_coordinator) -> None: ) -async def test_lawn_mower_activity_mowing(mock_mower_coordinator) -> None: +async def test_lawn_mower_activity_mowing(mock_mower_coordinator: MagicMock) -> None: """Test the activity property when mowing.""" entity = MammotionLawnMowerEntity(mock_mower_coordinator) @@ -78,7 +61,7 @@ async def test_lawn_mower_activity_mowing(mock_mower_coordinator) -> None: assert entity.activity == LawnMowerActivity.MOWING -async def test_lawn_mower_activity_paused(mock_mower_coordinator) -> None: +async def test_lawn_mower_activity_paused(mock_mower_coordinator: MagicMock) -> None: """Test the activity property when paused.""" entity = MammotionLawnMowerEntity(mock_mower_coordinator) @@ -92,7 +75,7 @@ async def test_lawn_mower_activity_paused(mock_mower_coordinator) -> None: assert entity.activity == LawnMowerActivity.PAUSED -async def test_lawn_mower_activity_docked(mock_mower_coordinator) -> None: +async def test_lawn_mower_activity_docked(mock_mower_coordinator: MagicMock) -> None: """Test the activity property when docked.""" entity = MammotionLawnMowerEntity(mock_mower_coordinator) @@ -102,7 +85,7 @@ async def test_lawn_mower_activity_docked(mock_mower_coordinator) -> None: assert entity.activity == LawnMowerActivity.DOCKED -async def test_lawn_mower_activity_returning(mock_mower_coordinator) -> None: +async def test_lawn_mower_activity_returning(mock_mower_coordinator: MagicMock) -> None: """Test the activity property when returning.""" entity = MammotionLawnMowerEntity(mock_mower_coordinator) @@ -111,7 +94,7 @@ async def test_lawn_mower_activity_returning(mock_mower_coordinator) -> None: assert entity.activity == LawnMowerActivity.RETURNING -async def test_lawn_mower_activity_error(mock_mower_coordinator) -> None: +async def test_lawn_mower_activity_error(mock_mower_coordinator: MagicMock) -> None: """Test the activity property when in error state.""" entity = MammotionLawnMowerEntity(mock_mower_coordinator) @@ -120,7 +103,7 @@ async def test_lawn_mower_activity_error(mock_mower_coordinator) -> None: assert entity.activity == LawnMowerActivity.ERROR -async def test_lawn_mower_activity_none(mock_mower_coordinator) -> None: +async def test_lawn_mower_activity_none(mock_mower_coordinator: MagicMock) -> None: """Test the activity property returns None for unknown states.""" entity = MammotionLawnMowerEntity(mock_mower_coordinator) @@ -133,7 +116,95 @@ async def test_lawn_mower_activity_none(mock_mower_coordinator) -> None: assert entity.activity is None -async def test_async_dock(mock_mower_coordinator) -> None: +async def test_async_start_mowing(mock_mower_coordinator: MagicMock) -> None: + """Test the async_start_mowing method.""" + entity = MammotionLawnMowerEntity(mock_mower_coordinator) + mock_mower_coordinator.async_send_command = AsyncMock() + mock_mower_coordinator.api.async_request_iot_sync = AsyncMock() + + mock_mower_coordinator.data.report_data.dev.sys_status = WorkMode.MODE_READY + + await entity.async_start_mowing() + + assert mock_mower_coordinator.async_send_command.call_count == 1 + assert ( + mock_mower_coordinator.async_send_command.call_args_list[0][0][0] == "start_job" + ) + assert mock_mower_coordinator.api.async_request_iot_sync.call_count == 1 + + +async def test_async_start_mowing_resume(mock_mower_coordinator: MagicMock) -> None: + """Test the async_start_mowing method when paused.""" + entity = MammotionLawnMowerEntity(mock_mower_coordinator) + mock_mower_coordinator.async_send_command = AsyncMock() + mock_mower_coordinator.api.async_request_iot_sync = AsyncMock() + + mock_mower_coordinator.data.report_data.dev.sys_status = WorkMode.MODE_PAUSE + + await entity.async_start_mowing() + + assert mock_mower_coordinator.async_send_command.call_count == 1 + assert ( + mock_mower_coordinator.async_send_command.call_args_list[0][0][0] + == "resume_execute_task" + ) + assert mock_mower_coordinator.api.async_request_iot_sync.call_count == 1 + + +async def test_async_start_mowing_not_ready(mock_mower_coordinator: MagicMock) -> None: + """Test the async_start_mowing method when device is not ready.""" + entity = MammotionLawnMowerEntity(mock_mower_coordinator) + + mock_mower_coordinator.data.report_data.dev.sys_status = None + + with pytest.raises(HomeAssistantError) as exc_info: + await entity.async_start_mowing() + error = exc_info.value + assert error.translation_domain == DOMAIN + assert error.translation_key == "device_not_ready" + + +async def test_async_start_mowing_command_exception( + mock_mower_coordinator: MagicMock, +) -> None: + """Test the async_start_mowing method with command exceptions.""" + entity = MammotionLawnMowerEntity(mock_mower_coordinator) + mock_error = COMMAND_EXCEPTIONS[0]("Test error") + mock_mower_coordinator.async_send_command = AsyncMock(side_effect=mock_error) + mock_mower_coordinator.api.async_request_iot_sync = AsyncMock() + + mock_mower_coordinator.data.report_data.dev.sys_status = WorkMode.MODE_READY + + with pytest.raises(HomeAssistantError) as exc_info: + await entity.async_start_mowing() + error = exc_info.value + assert error.translation_domain == DOMAIN + assert error.translation_key == "start_failed" + + assert mock_mower_coordinator.api.async_request_iot_sync.call_count == 1 + + +async def test_async_start_mowing_resume_command_exception( + mock_mower_coordinator: MagicMock, +) -> None: + """Test the async_start_mowing resume path with command exceptions.""" + entity = MammotionLawnMowerEntity(mock_mower_coordinator) + mock_error = COMMAND_EXCEPTIONS[0]("Test error") + mock_mower_coordinator.async_send_command = AsyncMock(side_effect=mock_error) + mock_mower_coordinator.api.async_request_iot_sync = AsyncMock() + + mock_mower_coordinator.data.report_data.dev.sys_status = WorkMode.MODE_PAUSE + + with pytest.raises(HomeAssistantError) as exc_info: + await entity.async_start_mowing() + error = exc_info.value + assert error.translation_domain == DOMAIN + assert error.translation_key == "resume_failed" + + assert mock_mower_coordinator.api.async_request_iot_sync.call_count == 1 + + +async def test_async_dock(mock_mower_coordinator: MagicMock) -> None: """Test the async_dock method.""" entity = MammotionLawnMowerEntity(mock_mower_coordinator) mock_mower_coordinator.async_send_command = AsyncMock() @@ -157,7 +228,7 @@ async def test_async_dock(mock_mower_coordinator) -> None: assert mock_mower_coordinator.api.async_request_iot_sync.call_count == 1 -async def test_async_dock_returning(mock_mower_coordinator) -> None: +async def test_async_dock_returning(mock_mower_coordinator: MagicMock) -> None: """Test the async_dock method when already returning.""" entity = MammotionLawnMowerEntity(mock_mower_coordinator) mock_mower_coordinator.async_send_command = AsyncMock() @@ -176,7 +247,7 @@ async def test_async_dock_returning(mock_mower_coordinator) -> None: assert mock_mower_coordinator.api.async_request_iot_sync.call_count == 1 -async def test_async_dock_ready(mock_mower_coordinator) -> None: +async def test_async_dock_ready(mock_mower_coordinator: MagicMock) -> None: """Test the async_dock method when device is ready.""" entity = MammotionLawnMowerEntity(mock_mower_coordinator) mock_mower_coordinator.async_send_command = AsyncMock() @@ -195,7 +266,7 @@ async def test_async_dock_ready(mock_mower_coordinator) -> None: assert mock_mower_coordinator.api.async_request_iot_sync.call_count == 1 -async def test_async_dock_not_ready(mock_mower_coordinator) -> None: +async def test_async_dock_not_ready(mock_mower_coordinator: MagicMock) -> None: """Test the async_dock method when device is not ready.""" entity = MammotionLawnMowerEntity(mock_mower_coordinator) @@ -208,7 +279,7 @@ async def test_async_dock_not_ready(mock_mower_coordinator) -> None: assert error.translation_domain -async def test_async_dock_command_exception(mock_mower_coordinator) -> None: +async def test_async_dock_command_exception(mock_mower_coordinator: MagicMock) -> None: """Test the async_dock method with command exceptions.""" entity = MammotionLawnMowerEntity(mock_mower_coordinator) mock_error = COMMAND_EXCEPTIONS[0]("Test error") @@ -227,7 +298,7 @@ async def test_async_dock_command_exception(mock_mower_coordinator) -> None: assert mock_mower_coordinator.api.async_request_iot_sync.call_count == 1 -async def test_async_pause(mock_mower_coordinator) -> None: +async def test_async_pause(mock_mower_coordinator: MagicMock) -> None: """Test the async_pause method.""" entity = MammotionLawnMowerEntity(mock_mower_coordinator) mock_mower_coordinator.async_send_command = AsyncMock() @@ -246,7 +317,7 @@ async def test_async_pause(mock_mower_coordinator) -> None: assert mock_mower_coordinator.api.async_request_iot_sync.call_count == 1 -async def test_async_pause_returning(mock_mower_coordinator) -> None: +async def test_async_pause_returning(mock_mower_coordinator: MagicMock) -> None: """Test the async_pause method when returning.""" entity = MammotionLawnMowerEntity(mock_mower_coordinator) mock_mower_coordinator.async_send_command = AsyncMock() @@ -264,7 +335,7 @@ async def test_async_pause_returning(mock_mower_coordinator) -> None: assert mock_mower_coordinator.api.async_request_iot_sync.call_count == 1 -async def test_async_pause_not_ready(mock_mower_coordinator) -> None: +async def test_async_pause_not_ready(mock_mower_coordinator: MagicMock) -> None: """Test the async_pause method when device is not ready.""" entity = MammotionLawnMowerEntity(mock_mower_coordinator) @@ -278,7 +349,9 @@ async def test_async_pause_not_ready(mock_mower_coordinator) -> None: assert error.translation_key == "device_not_ready" -async def test_async_pause_not_working_or_returning(mock_mower_coordinator) -> None: +async def test_async_pause_not_working_or_returning( + mock_mower_coordinator: MagicMock, +) -> None: """Test the async_pause method when not in working or returning mode.""" entity = MammotionLawnMowerEntity(mock_mower_coordinator) mock_mower_coordinator.async_send_command = AsyncMock() @@ -293,7 +366,7 @@ async def test_async_pause_not_working_or_returning(mock_mower_coordinator) -> N assert mock_mower_coordinator.api.async_request_iot_sync.call_count == 0 -async def test_async_pause_command_exception(mock_mower_coordinator) -> None: +async def test_async_pause_command_exception(mock_mower_coordinator: MagicMock) -> None: """Test the async_pause method with command exceptions.""" entity = MammotionLawnMowerEntity(mock_mower_coordinator) mock_error = COMMAND_EXCEPTIONS[0]("Test error") From ec23e4d51f87036bf3e9cf8b0d41392aac99cbe1 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Sat, 4 Jul 2026 00:26:31 +1200 Subject: [PATCH 52/66] quality scale missing items and a couple exception items --- homeassistant/components/mammotion/config_flow.py | 6 ++---- homeassistant/components/mammotion/quality_scale.yaml | 2 ++ tests/components/mammotion/test_config_flow.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/mammotion/config_flow.py b/homeassistant/components/mammotion/config_flow.py index 2ce2a901adc40..f47455140a279 100644 --- a/homeassistant/components/mammotion/config_flow.py +++ b/homeassistant/components/mammotion/config_flow.py @@ -2,9 +2,8 @@ from typing import TYPE_CHECKING, Any, override -from aiohttp.web_exceptions import HTTPException +from aiohttp import ClientError from bleak.backends.device import BLEDevice -from pymammotion.aliyun.cloud_gateway import CloudIOTGateway from pymammotion.http.http import MammotionHTTP import voluptuous as vol @@ -35,7 +34,6 @@ class MammotionConfigFlow(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize the config flow.""" self._config: dict = {} - self._cloud_client: CloudIOTGateway | None = None self._discovered_devices: dict[str, str] = {} self._discovered_device: BLEDevice | None = None @@ -193,7 +191,7 @@ async def async_step_wifi( await mammotion_http.login_v2(account, password) if mammotion_http.login_info is None: errors["base"] = "invalid_auth" - except HTTPException: + except ClientError, TimeoutError, OSError: errors["base"] = "cannot_connect" if not errors and (login_info := mammotion_http.login_info): diff --git a/homeassistant/components/mammotion/quality_scale.yaml b/homeassistant/components/mammotion/quality_scale.yaml index 69ba0c0e93a41..cd550366dee03 100644 --- a/homeassistant/components/mammotion/quality_scale.yaml +++ b/homeassistant/components/mammotion/quality_scale.yaml @@ -17,6 +17,8 @@ rules: 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: | diff --git a/tests/components/mammotion/test_config_flow.py b/tests/components/mammotion/test_config_flow.py index 6697ac887f35b..8c9b75de74b19 100644 --- a/tests/components/mammotion/test_config_flow.py +++ b/tests/components/mammotion/test_config_flow.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, MagicMock, patch -from aiohttp.web_exceptions import HTTPException +from aiohttp import ClientConnectionError from bleak.backends.device import BLEDevice from homeassistant import config_entries @@ -262,7 +262,7 @@ async def test_wifi_step_connection_error(hass: HomeAssistant) -> None: ) mock_http = MagicMock() - mock_http.login_v2 = AsyncMock(side_effect=HTTPException(text="Conn Err")) + mock_http.login_v2 = AsyncMock(side_effect=ClientConnectionError("Conn Err")) with patch( "homeassistant.components.mammotion.config_flow.MammotionHTTP", From 884771e847213c451a44f8acba3fcbd363a04344 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Sat, 4 Jul 2026 20:06:59 +1200 Subject: [PATCH 53/66] try sort out codeowners --- CODEOWNERS | 424 ++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 319 insertions(+), 105 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index fc401f2d024c9..a6a5a3b2e4f2a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -15,7 +15,7 @@ .yamllint @home-assistant/core pyproject.toml @home-assistant/core requirements_test.txt @home-assistant/core -/.devcontainer/ @home-assistant/core +/.devcontainer/ @home-assistant/core @edenhaus /.github/ @home-assistant/core /.vscode/ @home-assistant/core /homeassistant/*.py @home-assistant/core @@ -37,6 +37,13 @@ build.json @home-assistant/supervisor # Other code /homeassistant/scripts/check_config.py @kellerza +# Agent Configurations +AGENTS.md @home-assistant/core +CLAUDE.md @home-assistant/core +/.agent/ @home-assistant/core +/.claude/ @home-assistant/core +/.gemini/ @home-assistant/core + # Integrations /homeassistant/components/abode/ @shred86 /tests/components/abode/ @shred86 @@ -61,6 +68,8 @@ build.json @home-assistant/supervisor /tests/components/agent_dvr/ @ispysoftware /homeassistant/components/ai_task/ @home-assistant/core /tests/components/ai_task/ @home-assistant/core +/homeassistant/components/aidot/ @s1eedz @HongBryan +/tests/components/aidot/ @s1eedz @HongBryan /homeassistant/components/air_quality/ @home-assistant/core /tests/components/air_quality/ @home-assistant/core /homeassistant/components/airgradient/ @airgradienthq @joostlek @@ -73,6 +82,8 @@ build.json @home-assistant/supervisor /tests/components/airobot/ @mettolen /homeassistant/components/airos/ @CoMPaTech /tests/components/airos/ @CoMPaTech +/homeassistant/components/airpatrol/ @antondalgren +/tests/components/airpatrol/ @antondalgren /homeassistant/components/airq/ @Sibgatulin @dl2080 /tests/components/airq/ @Sibgatulin @dl2080 /homeassistant/components/airthings/ @danielhiversen @LaStrada @@ -151,6 +162,8 @@ build.json @home-assistant/supervisor /tests/components/apsystems/ @mawoka-myblock @SonnenladenGmbH /homeassistant/components/aquacell/ @Jordi1990 /tests/components/aquacell/ @Jordi1990 +/homeassistant/components/aqvify/ @astrandb +/tests/components/aqvify/ @astrandb /homeassistant/components/aranet/ @aschmitz @thecode @anrijs /tests/components/aranet/ @aschmitz @thecode @anrijs /homeassistant/components/arcam_fmj/ @elupus @@ -168,7 +181,6 @@ build.json @home-assistant/supervisor /tests/components/asuswrt/ @kennedyshead @ollo69 @Vaskivskyi /homeassistant/components/atag/ @MatsNL /tests/components/atag/ @MatsNL -/homeassistant/components/aten_pe/ @mtdcr /homeassistant/components/atome/ @baqs /homeassistant/components/august/ @bdraco /tests/components/august/ @bdraco @@ -184,7 +196,10 @@ build.json @home-assistant/supervisor /tests/components/auth/ @home-assistant/core /homeassistant/components/automation/ @home-assistant/core /tests/components/automation/ @home-assistant/core +/homeassistant/components/autoskope/ @mcisk +/tests/components/autoskope/ @mcisk /homeassistant/components/avea/ @pattyland +/tests/components/avea/ @pattyland /homeassistant/components/awair/ @ahayworth @ricohageman /tests/components/awair/ @ahayworth @ricohageman /homeassistant/components/aws_s3/ @tomasbedrich @@ -210,18 +225,19 @@ build.json @home-assistant/supervisor /tests/components/balboa/ @garbled1 @natekspencer /homeassistant/components/bang_olufsen/ @mj23000 /tests/components/bang_olufsen/ @mj23000 +/homeassistant/components/battery/ @home-assistant/core +/tests/components/battery/ @home-assistant/core /homeassistant/components/bayesian/ @HarvsG /tests/components/bayesian/ @HarvsG -/homeassistant/components/beewi_smartclim/ @alemuro /homeassistant/components/binary_sensor/ @home-assistant/core /tests/components/binary_sensor/ @home-assistant/core /homeassistant/components/bizkaibus/ @UgaitzEtxebarria -/homeassistant/components/blebox/ @bbx-a @swistakm -/tests/components/blebox/ @bbx-a @swistakm -/homeassistant/components/blink/ @fronzbot @mkmer -/tests/components/blink/ @fronzbot @mkmer -/homeassistant/components/blue_current/ @gleeuwen @NickKoepr @jtodorova23 -/tests/components/blue_current/ @gleeuwen @NickKoepr @jtodorova23 +/homeassistant/components/blebox/ @bbx-a @swistakm @bkobus-bbx +/tests/components/blebox/ @bbx-a @swistakm @bkobus-bbx +/homeassistant/components/blink/ @fronzbot +/tests/components/blink/ @fronzbot +/homeassistant/components/blue_current/ @gleeuwen @jtodorova23 +/tests/components/blue_current/ @gleeuwen @jtodorova23 /homeassistant/components/bluemaestro/ @bdraco /tests/components/bluemaestro/ @bdraco /homeassistant/components/blueprint/ @home-assistant/core @@ -232,20 +248,20 @@ build.json @home-assistant/supervisor /tests/components/bluetooth/ @bdraco /homeassistant/components/bluetooth_adapters/ @bdraco /tests/components/bluetooth_adapters/ @bdraco -/homeassistant/components/bmw_connected_drive/ @gerard33 @rikroe -/tests/components/bmw_connected_drive/ @gerard33 @rikroe /homeassistant/components/bond/ @bdraco @prystupa @joshs85 @marciogranzotto /tests/components/bond/ @bdraco @prystupa @joshs85 @marciogranzotto /homeassistant/components/bosch_alarm/ @mag1024 @sanjay900 /tests/components/bosch_alarm/ @mag1024 @sanjay900 -/homeassistant/components/bosch_shc/ @tschamm -/tests/components/bosch_shc/ @tschamm +/homeassistant/components/bosch_shc/ @tschamm @mosandlt +/tests/components/bosch_shc/ @tschamm @mosandlt +/homeassistant/components/brands/ @home-assistant/core +/tests/components/brands/ @home-assistant/core /homeassistant/components/braviatv/ @bieniu @Drafteed /tests/components/braviatv/ @bieniu @Drafteed /homeassistant/components/bring/ @miaucl @tr4nt0r /tests/components/bring/ @miaucl @tr4nt0r -/homeassistant/components/broadlink/ @danielhiversen @felipediel @L-I-Am @eifinger -/tests/components/broadlink/ @danielhiversen @felipediel @L-I-Am @eifinger +/homeassistant/components/broadlink/ @danielhiversen @felipediel @L-I-Am +/tests/components/broadlink/ @danielhiversen @felipediel @L-I-Am /homeassistant/components/brother/ @bieniu /tests/components/brother/ @bieniu /homeassistant/components/brottsplatskartan/ @gjohansson-ST @@ -269,14 +285,24 @@ build.json @home-assistant/supervisor /tests/components/cambridge_audio/ @noahhusby /homeassistant/components/camera/ @home-assistant/core /tests/components/camera/ @home-assistant/core +/homeassistant/components/casper_glow/ @mikeodr +/tests/components/casper_glow/ @mikeodr /homeassistant/components/cast/ @emontnemery /tests/components/cast/ @emontnemery /homeassistant/components/ccm15/ @ocalvo /tests/components/ccm15/ @ocalvo +/homeassistant/components/centriconnect/ @gresrun +/tests/components/centriconnect/ @gresrun /homeassistant/components/cert_expiry/ @jjlawren /tests/components/cert_expiry/ @jjlawren /homeassistant/components/chacon_dio/ @cnico /tests/components/chacon_dio/ @cnico +/homeassistant/components/chef_iq/ @Invader444 +/tests/components/chef_iq/ @Invader444 +/homeassistant/components/chess_com/ @joostlek +/tests/components/chess_com/ @joostlek +/homeassistant/components/cielo_home/ @ihsan-cielo @mudasar-cielo +/tests/components/cielo_home/ @ihsan-cielo @mudasar-cielo /homeassistant/components/cisco_ios/ @fbradyirl /homeassistant/components/cisco_mobility_express/ @fbradyirl /homeassistant/components/cisco_webex_teams/ @fbradyirl @@ -286,6 +312,8 @@ build.json @home-assistant/supervisor /tests/components/cloud/ @home-assistant/cloud /homeassistant/components/cloudflare/ @ludeeus @ctalkington /tests/components/cloudflare/ @ludeeus @ctalkington +/homeassistant/components/cloudflare_r2/ @corrreia +/tests/components/cloudflare_r2/ @corrreia /homeassistant/components/co2signal/ @jpbede @VIKTORVAV99 /tests/components/co2signal/ @jpbede @VIKTORVAV99 /homeassistant/components/coinbase/ @tombrien @@ -306,8 +334,8 @@ build.json @home-assistant/supervisor /tests/components/config/ @home-assistant/core /homeassistant/components/configurator/ @home-assistant/core /tests/components/configurator/ @home-assistant/core -/homeassistant/components/control4/ @lawtancool -/tests/components/control4/ @lawtancool +/homeassistant/components/control4/ @lawtancool @davidrecordon +/tests/components/control4/ @lawtancool @davidrecordon /homeassistant/components/conversation/ @home-assistant/core @synesthesiam @arturpragacz /tests/components/conversation/ @home-assistant/core @synesthesiam @arturpragacz /homeassistant/components/cookidoo/ @miaucl @@ -326,6 +354,8 @@ build.json @home-assistant/supervisor /tests/components/cync/ @Kinachi249 /homeassistant/components/daikin/ @fredrike /tests/components/daikin/ @fredrike +/homeassistant/components/data_grand_lyon/ @Crocmagnon +/tests/components/data_grand_lyon/ @Crocmagnon /homeassistant/components/date/ @home-assistant/core /tests/components/date/ @home-assistant/core /homeassistant/components/datetime/ @home-assistant/core @@ -343,6 +373,8 @@ build.json @home-assistant/supervisor /tests/components/deluge/ @tkdrob /homeassistant/components/demo/ @home-assistant/core /tests/components/demo/ @home-assistant/core +/homeassistant/components/denon_rs232/ @balloob +/tests/components/denon_rs232/ @balloob /homeassistant/components/denonavr/ @ol-iver @starkillerOG /tests/components/denonavr/ @ol-iver @starkillerOG /homeassistant/components/derivative/ @afaucogney @karwosts @@ -377,6 +409,10 @@ build.json @home-assistant/supervisor /tests/components/dlna_dms/ @chishm /homeassistant/components/dnsip/ @gjohansson-ST /tests/components/dnsip/ @gjohansson-ST +/homeassistant/components/door/ @home-assistant/core +/tests/components/door/ @home-assistant/core +/homeassistant/components/doorbell/ @home-assistant/core +/tests/components/doorbell/ @home-assistant/core /homeassistant/components/doorbird/ @oblogic7 @bdraco @flacjacket /tests/components/doorbird/ @oblogic7 @bdraco @flacjacket /homeassistant/components/dormakaba_dkey/ @emontnemery @@ -387,6 +423,8 @@ build.json @home-assistant/supervisor /tests/components/dremel_3d_printer/ @tkdrob /homeassistant/components/drop_connect/ @ChandlerSystems @pfrazer /tests/components/drop_connect/ @ChandlerSystems @pfrazer +/homeassistant/components/dropbox/ @bdr99 +/tests/components/dropbox/ @bdr99 /homeassistant/components/droplet/ @sarahseidman /tests/components/droplet/ @sarahseidman /homeassistant/components/dsmr/ @Robbie1221 @@ -395,16 +433,18 @@ build.json @home-assistant/supervisor /tests/components/dsmr_reader/ @sorted-bits @glodenox @erwindouna /homeassistant/components/duckdns/ @tr4nt0r /tests/components/duckdns/ @tr4nt0r -/homeassistant/components/duke_energy/ @hunterjm -/tests/components/duke_energy/ @hunterjm +/homeassistant/components/duco/ @ronaldvdmeer +/tests/components/duco/ @ronaldvdmeer /homeassistant/components/duotecno/ @cereal2nd /tests/components/duotecno/ @cereal2nd -/homeassistant/components/dwd_weather_warnings/ @runningman84 @stephan192 @andarotajo -/tests/components/dwd_weather_warnings/ @runningman84 @stephan192 @andarotajo +/homeassistant/components/dwd_weather_warnings/ @runningman84 @stephan192 +/tests/components/dwd_weather_warnings/ @runningman84 @stephan192 /homeassistant/components/dynalite/ @ziv1234 /tests/components/dynalite/ @ziv1234 /homeassistant/components/eafm/ @Jc2k /tests/components/eafm/ @Jc2k +/homeassistant/components/earn_e_p1/ @Miggets7 +/tests/components/earn_e_p1/ @Miggets7 /homeassistant/components/easyenergy/ @klaasnicolaas /tests/components/easyenergy/ @klaasnicolaas /homeassistant/components/ecoforest/ @pjanuario @@ -415,9 +455,13 @@ build.json @home-assistant/supervisor /tests/components/ecovacs/ @mib1185 @edenhaus @Augar /homeassistant/components/ecowitt/ @pvizeli /tests/components/ecowitt/ @pvizeli +/homeassistant/components/edifier_infrared/ @abmantis +/tests/components/edifier_infrared/ @abmantis /homeassistant/components/efergy/ @tkdrob /tests/components/efergy/ @tkdrob /homeassistant/components/egardia/ @jeroenterheerdt +/homeassistant/components/egauge/ @neggert +/tests/components/egauge/ @neggert /homeassistant/components/eheimdigital/ @autinerd /tests/components/eheimdigital/ @autinerd /homeassistant/components/ekeybionyx/ @richardpolzer @@ -450,6 +494,8 @@ build.json @home-assistant/supervisor /tests/components/emulated_kasa/ @kbickar /homeassistant/components/energenie_power_sockets/ @gnumpi /tests/components/energenie_power_sockets/ @gnumpi +/homeassistant/components/energieleser/ @AjinkyaGokhale @amitkio +/tests/components/energieleser/ @AjinkyaGokhale @amitkio /homeassistant/components/energy/ @home-assistant/core /tests/components/energy/ @home-assistant/core /homeassistant/components/energyid/ @JrtPec @Molier @@ -460,12 +506,14 @@ build.json @home-assistant/supervisor /tests/components/enigma2/ @autinerd /homeassistant/components/enphase_envoy/ @bdraco @cgarwood @catsmanac /tests/components/enphase_envoy/ @bdraco @cgarwood @catsmanac -/homeassistant/components/entur_public_transport/ @hfurubotten +/homeassistant/components/entur_public_transport/ @hfurubotten @SanderBlom +/homeassistant/components/envertech_evt800/ @daniel-bergmann-00 +/tests/components/envertech_evt800/ @daniel-bergmann-00 /homeassistant/components/environment_canada/ @gwww @michaeldavie /tests/components/environment_canada/ @gwww @michaeldavie /homeassistant/components/ephember/ @ttroy50 @roberty99 -/homeassistant/components/epic_games_store/ @hacf-fr @Quentame -/tests/components/epic_games_store/ @hacf-fr @Quentame +/homeassistant/components/epic_games_store/ @Quentame +/tests/components/epic_games_store/ @Quentame /homeassistant/components/epion/ @lhgravendeel /tests/components/epion/ @lhgravendeel /homeassistant/components/epson/ @pszafer @@ -480,6 +528,8 @@ build.json @home-assistant/supervisor /tests/components/essent/ @jaapp /homeassistant/components/eufylife_ble/ @bdr99 /tests/components/eufylife_ble/ @bdr99 +/homeassistant/components/eurotronic_cometblue/ @rikroe +/tests/components/eurotronic_cometblue/ @rikroe /homeassistant/components/event/ @home-assistant/core /tests/components/event/ @home-assistant/core /homeassistant/components/evohome/ @zxdavb @@ -512,6 +562,8 @@ build.json @home-assistant/supervisor /tests/components/fireservicerota/ @cyberjunky /homeassistant/components/firmata/ @DaAwesomeP /tests/components/firmata/ @DaAwesomeP +/homeassistant/components/fish_audio/ @noambav +/tests/components/fish_audio/ @noambav /homeassistant/components/fitbit/ @allenporter /tests/components/fitbit/ @allenporter /homeassistant/components/fivem/ @Sander0542 @@ -526,6 +578,8 @@ build.json @home-assistant/supervisor /tests/components/flo/ @dmulcahey /homeassistant/components/flume/ @ChrisMandich @bdraco @jeeftor /tests/components/flume/ @ChrisMandich @bdraco @jeeftor +/homeassistant/components/fluss/ @fluss @Marcello17 +/tests/components/fluss/ @fluss @Marcello17 /homeassistant/components/flux_led/ @icemanch /tests/components/flux_led/ @icemanch /homeassistant/components/forecast_solar/ @klaasnicolaas @frenck @@ -535,18 +589,18 @@ build.json @home-assistant/supervisor /homeassistant/components/fortios/ @kimfrellsen /homeassistant/components/foscam/ @Foscam-wangzhengyu /tests/components/foscam/ @Foscam-wangzhengyu -/homeassistant/components/freebox/ @hacf-fr @Quentame -/tests/components/freebox/ @hacf-fr @Quentame +/homeassistant/components/freebox/ @hacf-fr/reviewers @Quentame +/tests/components/freebox/ @hacf-fr/reviewers @Quentame /homeassistant/components/freedompro/ @stefano055415 /tests/components/freedompro/ @stefano055415 +/homeassistant/components/freshr/ @SierraNL +/tests/components/freshr/ @SierraNL /homeassistant/components/fressnapf_tracker/ @eifinger /tests/components/fressnapf_tracker/ @eifinger /homeassistant/components/fritz/ @AaronDavidSchneider @chemelli74 @mib1185 /tests/components/fritz/ @AaronDavidSchneider @chemelli74 @mib1185 /homeassistant/components/fritzbox/ @mib1185 @flabbamann /tests/components/fritzbox/ @mib1185 @flabbamann -/homeassistant/components/fritzbox_callmonitor/ @cdce8p -/tests/components/fritzbox_callmonitor/ @cdce8p /homeassistant/components/fronius/ @farmio /tests/components/fronius/ @farmio /homeassistant/components/frontend/ @home-assistant/frontend @@ -557,12 +611,18 @@ build.json @home-assistant/supervisor /tests/components/fujitsu_fglair/ @crevetor /homeassistant/components/fully_kiosk/ @cgarwood /tests/components/fully_kiosk/ @cgarwood +/homeassistant/components/fumis/ @frenck +/tests/components/fumis/ @frenck /homeassistant/components/fyta/ @dontinelli /tests/components/fyta/ @dontinelli +/homeassistant/components/garage_door/ @home-assistant/core +/tests/components/garage_door/ @home-assistant/core /homeassistant/components/garages_amsterdam/ @klaasnicolaas /tests/components/garages_amsterdam/ @klaasnicolaas /homeassistant/components/gardena_bluetooth/ @elupus /tests/components/gardena_bluetooth/ @elupus +/homeassistant/components/gate/ @home-assistant/core +/tests/components/gate/ @home-assistant/core /homeassistant/components/gdacs/ @exxamalte /tests/components/gdacs/ @exxamalte /homeassistant/components/generic/ @davet2001 @@ -571,6 +631,8 @@ build.json @home-assistant/supervisor /tests/components/generic_hygrostat/ @Shulyaka /homeassistant/components/geniushub/ @manzanotti /tests/components/geniushub/ @manzanotti +/homeassistant/components/gentex_homelink/ @Gentex-Corporation/Homelink @rjones-gentex +/tests/components/gentex_homelink/ @Gentex-Corporation/Homelink @rjones-gentex /homeassistant/components/geo_json_events/ @exxamalte /tests/components/geo_json_events/ @exxamalte /homeassistant/components/geo_location/ @home-assistant/core @@ -583,6 +645,8 @@ build.json @home-assistant/supervisor /tests/components/geonetnz_quakes/ @exxamalte /homeassistant/components/geonetnz_volcano/ @exxamalte /tests/components/geonetnz_volcano/ @exxamalte +/homeassistant/components/ghost/ @johnonolan +/tests/components/ghost/ @johnonolan /homeassistant/components/gios/ @bieniu /tests/components/gios/ @bieniu /homeassistant/components/github/ @timmo001 @ludeeus @@ -631,6 +695,10 @@ build.json @home-assistant/supervisor /tests/components/gpsd/ @fabaff @jrieger /homeassistant/components/gree/ @cmroche /tests/components/gree/ @cmroche +/homeassistant/components/green_planet_energy/ @petschni +/tests/components/green_planet_energy/ @petschni +/homeassistant/components/greencell/ @BrzezowskiGC +/tests/components/greencell/ @BrzezowskiGC /homeassistant/components/greeneye_monitor/ @jkeljo /tests/components/greeneye_monitor/ @jkeljo /homeassistant/components/group/ @home-assistant/core @@ -639,6 +707,8 @@ build.json @home-assistant/supervisor /tests/components/growatt_server/ @johanzander /homeassistant/components/guardian/ @bachya /tests/components/guardian/ @bachya +/homeassistant/components/guntamatic/ @JensTimmerman +/tests/components/guntamatic/ @JensTimmerman /homeassistant/components/habitica/ @tr4nt0r /tests/components/habitica/ @tr4nt0r /homeassistant/components/hanna/ @bestycame @@ -651,14 +721,21 @@ build.json @home-assistant/supervisor /tests/components/harmony/ @ehendrix23 @bdraco @mkeesey @Aohzan /homeassistant/components/hassio/ @home-assistant/supervisor /tests/components/hassio/ @home-assistant/supervisor +/homeassistant/components/hdfury/ @glenndehaan +/tests/components/hdfury/ @glenndehaan /homeassistant/components/hdmi_cec/ @inytar /tests/components/hdmi_cec/ @inytar /homeassistant/components/heatmiser/ @andylockran +/homeassistant/components/hegel/ @boazca +/tests/components/hegel/ @boazca +/homeassistant/components/helty/ @ebaschiera +/tests/components/helty/ @ebaschiera /homeassistant/components/heos/ @andrewsayre /tests/components/heos/ @andrewsayre /homeassistant/components/here_travel_time/ @eifinger /tests/components/here_travel_time/ @eifinger -/homeassistant/components/hikvision/ @mezz64 +/homeassistant/components/hikvision/ @mezz64 @ptarjan +/tests/components/hikvision/ @mezz64 @ptarjan /homeassistant/components/hikvisioncam/ @fbradyirl /homeassistant/components/hisense_aehw4a1/ @bannhead /tests/components/hisense_aehw4a1/ @bannhead @@ -696,18 +773,24 @@ build.json @home-assistant/supervisor /tests/components/homekit_controller/ @Jc2k @bdraco /homeassistant/components/homematic/ @pvizeli /tests/components/homematic/ @pvizeli -/homeassistant/components/homematicip_cloud/ @hahn-th -/tests/components/homematicip_cloud/ @hahn-th +/homeassistant/components/homematicip_cloud/ @hahn-th @lackas +/tests/components/homematicip_cloud/ @hahn-th @lackas +/homeassistant/components/homevolt/ @danielhiversen @liudger +/tests/components/homevolt/ @danielhiversen @liudger /homeassistant/components/homewizard/ @DCSBL /tests/components/homewizard/ @DCSBL -/homeassistant/components/honeywell/ @rdfurman @mkmer -/tests/components/honeywell/ @rdfurman @mkmer -/homeassistant/components/html5/ @alexyao2015 -/tests/components/html5/ @alexyao2015 +/homeassistant/components/honeywell/ @mkmer +/tests/components/honeywell/ @mkmer +/homeassistant/components/honeywell_string_lights/ @balloob +/tests/components/honeywell_string_lights/ @balloob +/homeassistant/components/hr_energy_qube/ @MattieGit +/tests/components/hr_energy_qube/ @MattieGit +/homeassistant/components/html5/ @alexyao2015 @tr4nt0r +/tests/components/html5/ @alexyao2015 @tr4nt0r /homeassistant/components/http/ @home-assistant/core /tests/components/http/ @home-assistant/core -/homeassistant/components/huawei_lte/ @scop @fphammerle -/tests/components/huawei_lte/ @scop @fphammerle +/homeassistant/components/huawei_lte/ @fphammerle +/tests/components/huawei_lte/ @fphammerle /homeassistant/components/hue/ @marcelveldt /tests/components/hue/ @marcelveldt /homeassistant/components/hue_ble/ @flip-dots @@ -716,6 +799,8 @@ build.json @home-assistant/supervisor /tests/components/huisbaasje/ @dennisschroer /homeassistant/components/humidifier/ @home-assistant/core @Shulyaka /tests/components/humidifier/ @home-assistant/core @Shulyaka +/homeassistant/components/humidity/ @home-assistant/core +/tests/components/humidity/ @home-assistant/core /homeassistant/components/hunterdouglas_powerview/ @bdraco @kingy444 @trullock /tests/components/hunterdouglas_powerview/ @bdraco @kingy444 @trullock /homeassistant/components/husqvarna_automower/ @Thomas55555 @@ -730,6 +815,8 @@ build.json @home-assistant/supervisor /tests/components/hydrawise/ @dknowles2 @thomaskistler @ptcryan /homeassistant/components/hyperion/ @dermotduffy /tests/components/hyperion/ @dermotduffy +/homeassistant/components/hypontech/ @jcisio +/tests/components/hypontech/ @jcisio /homeassistant/components/ialarm/ @RyuzakiKK /tests/components/ialarm/ @RyuzakiKK /homeassistant/components/iammeter/ @lewei50 @@ -739,10 +826,14 @@ build.json @home-assistant/supervisor /tests/components/icloud/ @Quentame @nzapponi /homeassistant/components/idasen_desk/ @abmantis /tests/components/idasen_desk/ @abmantis +/homeassistant/components/idrive_e2/ @patrickvorgers +/tests/components/idrive_e2/ @patrickvorgers /homeassistant/components/igloohome/ @keithle888 /tests/components/igloohome/ @keithle888 /homeassistant/components/ign_sismologia/ @exxamalte /tests/components/ign_sismologia/ @exxamalte +/homeassistant/components/illuminance/ @home-assistant/core +/tests/components/illuminance/ @home-assistant/core /homeassistant/components/image/ @home-assistant/core /tests/components/image/ @home-assistant/core /homeassistant/components/image_processing/ @home-assistant/core @@ -757,14 +848,20 @@ build.json @home-assistant/supervisor /tests/components/imgw_pib/ @bieniu /homeassistant/components/immich/ @mib1185 /tests/components/immich/ @mib1185 +/homeassistant/components/imou/ @Imou-OpenPlatform +/tests/components/imou/ @Imou-OpenPlatform /homeassistant/components/improv_ble/ @emontnemery /tests/components/improv_ble/ @emontnemery /homeassistant/components/incomfort/ @jbouwh /tests/components/incomfort/ @jbouwh +/homeassistant/components/indevolt/ @xirt +/tests/components/indevolt/ @xirt /homeassistant/components/inels/ @epdevlab /tests/components/inels/ @epdevlab -/homeassistant/components/influxdb/ @mdegat01 -/tests/components/influxdb/ @mdegat01 +/homeassistant/components/influxdb/ @mdegat01 @Robbie1221 +/tests/components/influxdb/ @mdegat01 @Robbie1221 +/homeassistant/components/infrared/ @home-assistant/core +/tests/components/infrared/ @home-assistant/core /homeassistant/components/inkbird/ @bdraco /tests/components/inkbird/ @bdraco /homeassistant/components/input_boolean/ @home-assistant/core @@ -779,14 +876,18 @@ build.json @home-assistant/supervisor /tests/components/input_select/ @home-assistant/core /homeassistant/components/input_text/ @home-assistant/core /tests/components/input_text/ @home-assistant/core -/homeassistant/components/insteon/ @teharris1 -/tests/components/insteon/ @teharris1 +/homeassistant/components/insteon/ @teharris1 @ssyrell +/tests/components/insteon/ @teharris1 @ssyrell /homeassistant/components/integration/ @dgomes /tests/components/integration/ @dgomes +/homeassistant/components/intelliclima/ @dvdinth +/tests/components/intelliclima/ @dvdinth /homeassistant/components/intellifire/ @jeeftor /tests/components/intellifire/ @jeeftor /homeassistant/components/intent/ @home-assistant/core @synesthesiam @arturpragacz /tests/components/intent/ @home-assistant/core @synesthesiam @arturpragacz +/homeassistant/components/intent_script/ @arturpragacz +/tests/components/intent_script/ @arturpragacz /homeassistant/components/intesishome/ @jnimmo /homeassistant/components/iometer/ @jukrebs /tests/components/iometer/ @jukrebs @@ -832,8 +933,8 @@ build.json @home-assistant/supervisor /tests/components/jewish_calendar/ @tsvi /homeassistant/components/justnimbus/ @kvanzuijlen /tests/components/justnimbus/ @kvanzuijlen -/homeassistant/components/jvc_projector/ @SteveEasley @msavazzi -/tests/components/jvc_projector/ @SteveEasley @msavazzi +/homeassistant/components/jvc_projector/ @SteveEasley +/tests/components/jvc_projector/ @SteveEasley /homeassistant/components/kaiterra/ @Michsior14 /homeassistant/components/kaleidescape/ @SteveEasley /tests/components/kaleidescape/ @SteveEasley @@ -846,8 +947,12 @@ build.json @home-assistant/supervisor /homeassistant/components/keyboard_remote/ @bendavid @lanrat /homeassistant/components/keymitt_ble/ @spycle /tests/components/keymitt_ble/ @spycle +/homeassistant/components/kiosker/ @Claeysson +/tests/components/kiosker/ @Claeysson /homeassistant/components/kitchen_sink/ @home-assistant/core /tests/components/kitchen_sink/ @home-assistant/core +/homeassistant/components/klik_aan_klik_uit/ @Phunkafizer +/tests/components/klik_aan_klik_uit/ @Phunkafizer /homeassistant/components/kmtronic/ @dgomes /tests/components/kmtronic/ @dgomes /homeassistant/components/knocki/ @joostlek @jgatto1 @JakeBosh @@ -856,8 +961,6 @@ build.json @home-assistant/supervisor /tests/components/knx/ @Julius2342 @farmio @marvin-w /homeassistant/components/kodi/ @OnFreund /tests/components/kodi/ @OnFreund -/homeassistant/components/konnected/ @heythisisnate -/tests/components/konnected/ @heythisisnate /homeassistant/components/kostal_plenticore/ @stegm /tests/components/kostal_plenticore/ @stegm /homeassistant/components/kraken/ @eifinger @@ -894,14 +997,22 @@ build.json @home-assistant/supervisor /tests/components/lektrico/ @lektrico /homeassistant/components/letpot/ @jpelgrom /tests/components/letpot/ @jpelgrom +/homeassistant/components/lg_infrared/ @abmantis +/tests/components/lg_infrared/ @abmantis /homeassistant/components/lg_netcast/ @Drafteed @splinter98 /tests/components/lg_netcast/ @Drafteed @splinter98 /homeassistant/components/lg_thinq/ @LG-ThinQ-Integration /tests/components/lg_thinq/ @LG-ThinQ-Integration +/homeassistant/components/lg_tv_rs232/ @balloob +/tests/components/lg_tv_rs232/ @balloob /homeassistant/components/libre_hardware_monitor/ @Sab44 /tests/components/libre_hardware_monitor/ @Sab44 +/homeassistant/components/lichess/ @aryanhasgithub +/tests/components/lichess/ @aryanhasgithub /homeassistant/components/lidarr/ @tkdrob /tests/components/lidarr/ @tkdrob +/homeassistant/components/liebherr/ @mettolen +/tests/components/liebherr/ @mettolen /homeassistant/components/lifx/ @Djelibeybi /tests/components/lifx/ @Djelibeybi /homeassistant/components/light/ @home-assistant/core @@ -915,6 +1026,8 @@ build.json @home-assistant/supervisor /tests/components/litterrobot/ @natekspencer @tkdrob /homeassistant/components/livisi/ @StefanIacobLivisi @planbnet /tests/components/livisi/ @StefanIacobLivisi @planbnet +/homeassistant/components/llm/ @home-assistant/core +/tests/components/llm/ @home-assistant/core /homeassistant/components/local_calendar/ @allenporter /tests/components/local_calendar/ @allenporter /homeassistant/components/local_ip/ @issacg @@ -927,6 +1040,8 @@ build.json @home-assistant/supervisor /tests/components/logbook/ @home-assistant/core /homeassistant/components/logger/ @home-assistant/core /tests/components/logger/ @home-assistant/core +/homeassistant/components/lojack/ @devinslick +/tests/components/lojack/ @devinslick /homeassistant/components/london_underground/ @jpbede /tests/components/london_underground/ @jpbede /homeassistant/components/lookin/ @ANMalko @bdraco @@ -979,6 +1094,8 @@ build.json @home-assistant/supervisor /homeassistant/components/mediaroom/ @dgomes /homeassistant/components/melcloud/ @erwindouna /tests/components/melcloud/ @erwindouna +/homeassistant/components/melcloud_home/ @erwindouna +/tests/components/melcloud_home/ @erwindouna /homeassistant/components/melissa/ @kennedyshead /tests/components/melissa/ @kennedyshead /homeassistant/components/melnor/ @vanstinator @@ -987,8 +1104,8 @@ build.json @home-assistant/supervisor /tests/components/met/ @danielhiversen /homeassistant/components/met_eireann/ @DylanGore /tests/components/met_eireann/ @DylanGore -/homeassistant/components/meteo_france/ @hacf-fr @oncleben31 @Quentame -/tests/components/meteo_france/ @hacf-fr @oncleben31 @Quentame +/homeassistant/components/meteo_france/ @hacf-fr/reviewers @oncleben31 @Quentame +/tests/components/meteo_france/ @hacf-fr/reviewers @oncleben31 @Quentame /homeassistant/components/meteo_lt/ @xE1H /tests/components/meteo_lt/ @xE1H /homeassistant/components/meteoalarm/ @rolfberkenbosch @@ -1006,10 +1123,12 @@ build.json @home-assistant/supervisor /tests/components/mill/ @danielhiversen /homeassistant/components/min_max/ @gjohansson-ST /tests/components/min_max/ @gjohansson-ST -/homeassistant/components/minecraft_server/ @elmurato -/tests/components/minecraft_server/ @elmurato +/homeassistant/components/minecraft_server/ @elmurato @zachdeibert +/tests/components/minecraft_server/ @elmurato @zachdeibert /homeassistant/components/minio/ @tkislan /tests/components/minio/ @tkislan +/homeassistant/components/mitsubishi_comfort/ @nikolairahimi +/tests/components/mitsubishi_comfort/ @nikolairahimi /homeassistant/components/moat/ @bdraco /tests/components/moat/ @bdraco /homeassistant/components/mobile_app/ @home-assistant/core @@ -1020,6 +1139,8 @@ build.json @home-assistant/supervisor /tests/components/modern_forms/ @wonderslug /homeassistant/components/moehlenhoff_alpha2/ @j-a-n /tests/components/moehlenhoff_alpha2/ @j-a-n +/homeassistant/components/moisture/ @home-assistant/core +/tests/components/moisture/ @home-assistant/core /homeassistant/components/monarch_money/ @jeeftor /tests/components/monarch_money/ @jeeftor /homeassistant/components/monoprice/ @etsinko @OnFreund @@ -1030,6 +1151,8 @@ build.json @home-assistant/supervisor /tests/components/moon/ @fabaff @frenck /homeassistant/components/mopeka/ @bdraco /tests/components/mopeka/ @bdraco +/homeassistant/components/motion/ @home-assistant/core +/tests/components/motion/ @home-assistant/core /homeassistant/components/motion_blinds/ @starkillerOG /tests/components/motion_blinds/ @starkillerOG /homeassistant/components/motionblinds_ble/ @LennP @jerrybboy @@ -1040,7 +1163,8 @@ build.json @home-assistant/supervisor /tests/components/motionmount/ @laiho-vogels /homeassistant/components/mqtt/ @emontnemery @jbouwh @bdraco /tests/components/mqtt/ @emontnemery @jbouwh @bdraco -/homeassistant/components/msteams/ @peroyvind +/homeassistant/components/mta/ @OnFreund +/tests/components/mta/ @OnFreund /homeassistant/components/mullvad/ @meichthys /tests/components/mullvad/ @meichthys /homeassistant/components/music_assistant/ @music-assistant @arturpragacz @@ -1049,6 +1173,8 @@ build.json @home-assistant/supervisor /tests/components/mutesync/ @currentoor /homeassistant/components/my/ @home-assistant/core /tests/components/my/ @home-assistant/core +/homeassistant/components/myneomitis/ @Epyes +/tests/components/myneomitis/ @Epyes /homeassistant/components/mysensors/ @MartinHjelmare @functionpointer /tests/components/mysensors/ @MartinHjelmare @functionpointer /homeassistant/components/mystrom/ @fabaff @@ -1057,21 +1183,23 @@ build.json @home-assistant/supervisor /tests/components/myuplink/ @pajzo @astrandb /homeassistant/components/nam/ @bieniu /tests/components/nam/ @bieniu -/homeassistant/components/nanoleaf/ @milanmeu @joostlek -/tests/components/nanoleaf/ @milanmeu @joostlek +/homeassistant/components/namecheapdns/ @tr4nt0r +/tests/components/namecheapdns/ @tr4nt0r +/homeassistant/components/nanoleaf/ @milanmeu @joostlek @loebi-ch @JaspervRijbroek @jonathanrobichaud4 +/tests/components/nanoleaf/ @milanmeu @joostlek @loebi-ch @JaspervRijbroek @jonathanrobichaud4 /homeassistant/components/nasweb/ @nasWebio /tests/components/nasweb/ @nasWebio /homeassistant/components/nederlandse_spoorwegen/ @YarmoM @heindrichpaul /tests/components/nederlandse_spoorwegen/ @YarmoM @heindrichpaul -/homeassistant/components/ness_alarm/ @nickw444 -/tests/components/ness_alarm/ @nickw444 +/homeassistant/components/ness_alarm/ @nickw444 @poshy163 +/tests/components/ness_alarm/ @nickw444 @poshy163 /homeassistant/components/nest/ @allenporter /tests/components/nest/ @allenporter /homeassistant/components/netatmo/ @cgtobi /tests/components/netatmo/ @cgtobi /homeassistant/components/netdata/ @fabaff -/homeassistant/components/netgear/ @hacf-fr @Quentame @starkillerOG -/tests/components/netgear/ @hacf-fr @Quentame @starkillerOG +/homeassistant/components/netgear/ @Quentame @starkillerOG +/tests/components/netgear/ @Quentame @starkillerOG /homeassistant/components/netgear_lte/ @tkdrob /tests/components/netgear_lte/ @tkdrob /homeassistant/components/network/ @home-assistant/core @@ -1111,6 +1239,10 @@ build.json @home-assistant/supervisor /tests/components/notify_events/ @matrozov @papajojo /homeassistant/components/notion/ @bachya /tests/components/notion/ @bachya +/homeassistant/components/novy_cooker_hood/ @piitaya +/tests/components/novy_cooker_hood/ @piitaya +/homeassistant/components/nrgkick/ @andijakl +/tests/components/nrgkick/ @andijakl /homeassistant/components/nsw_fuel_station/ @nickw444 /tests/components/nsw_fuel_station/ @nickw444 /homeassistant/components/nsw_rural_fire_service_feed/ @exxamalte @@ -1135,6 +1267,8 @@ build.json @home-assistant/supervisor /tests/components/nzbget/ @chriscla /homeassistant/components/obihai/ @dshokouhi @ejpenney /tests/components/obihai/ @dshokouhi @ejpenney +/homeassistant/components/occupancy/ @home-assistant/core +/tests/components/occupancy/ @home-assistant/core /homeassistant/components/octoprint/ @rfleming71 /tests/components/octoprint/ @rfleming71 /homeassistant/components/ohmconnect/ @robbiet480 @@ -1143,24 +1277,34 @@ build.json @home-assistant/supervisor /homeassistant/components/ollama/ @synesthesiam /tests/components/ollama/ @synesthesiam /homeassistant/components/ombi/ @larssont +/homeassistant/components/omie/ @luuuis +/tests/components/omie/ @luuuis /homeassistant/components/onboarding/ @home-assistant/core /tests/components/onboarding/ @home-assistant/core /homeassistant/components/ondilo_ico/ @JeromeHXP /tests/components/ondilo_ico/ @JeromeHXP /homeassistant/components/onedrive/ @zweckj /tests/components/onedrive/ @zweckj +/homeassistant/components/onedrive_for_business/ @zweckj +/tests/components/onedrive_for_business/ @zweckj /homeassistant/components/onewire/ @garbled1 @epenet /tests/components/onewire/ @garbled1 @epenet /homeassistant/components/onkyo/ @arturpragacz @eclair4151 /tests/components/onkyo/ @arturpragacz @eclair4151 -/homeassistant/components/onvif/ @hunterjm @jterrace -/tests/components/onvif/ @hunterjm @jterrace +/homeassistant/components/onvif/ @jterrace +/tests/components/onvif/ @jterrace /homeassistant/components/open_meteo/ @frenck /tests/components/open_meteo/ @frenck -/homeassistant/components/open_router/ @joostlek -/tests/components/open_router/ @joostlek +/homeassistant/components/open_router/ @joostlek @ab3lson +/tests/components/open_router/ @joostlek @ab3lson +/homeassistant/components/openai_conversation/ @Shulyaka +/tests/components/openai_conversation/ @Shulyaka +/homeassistant/components/opendisplay/ @g4bri3lDev +/tests/components/opendisplay/ @g4bri3lDev /homeassistant/components/openerz/ @misialq /tests/components/openerz/ @misialq +/homeassistant/components/openevse/ @c00w @firstof9 +/tests/components/openevse/ @c00w @firstof9 /homeassistant/components/openexchangerates/ @MartinHjelmare /tests/components/openexchangerates/ @MartinHjelmare /homeassistant/components/opengarage/ @danielhiversen @@ -1169,6 +1313,8 @@ build.json @home-assistant/supervisor /tests/components/openhome/ @bazwilliams /homeassistant/components/openrgb/ @felipecrs /tests/components/openrgb/ @felipecrs +/homeassistant/components/opensensemap/ @AlCalzone +/tests/components/opensensemap/ @AlCalzone /homeassistant/components/opensky/ @joostlek /tests/components/opensky/ @joostlek /homeassistant/components/opentherm_gw/ @mvn23 @@ -1177,8 +1323,8 @@ build.json @home-assistant/supervisor /tests/components/openuv/ @bachya /homeassistant/components/openweathermap/ @fabaff @freekode @nzapponi @wittypluck /tests/components/openweathermap/ @fabaff @freekode @nzapponi @wittypluck -/homeassistant/components/opnsense/ @mtreinish -/tests/components/opnsense/ @mtreinish +/homeassistant/components/opnsense/ @HarlemSquirrel @Snuffy2 +/tests/components/opnsense/ @HarlemSquirrel @Snuffy2 /homeassistant/components/opower/ @tronikos /tests/components/opower/ @tronikos /homeassistant/components/oralb/ @bdraco @Lash-L @@ -1188,16 +1334,22 @@ build.json @home-assistant/supervisor /tests/components/osoenergy/ @osohotwateriot /homeassistant/components/otbr/ @home-assistant/core /tests/components/otbr/ @home-assistant/core +/homeassistant/components/ouman_eh_800/ @Markus98 +/tests/components/ouman_eh_800/ @Markus98 /homeassistant/components/ourgroceries/ @OnFreund /tests/components/ourgroceries/ @OnFreund /homeassistant/components/overkiz/ @imicknl /tests/components/overkiz/ @imicknl -/homeassistant/components/overseerr/ @joostlek -/tests/components/overseerr/ @joostlek +/homeassistant/components/overseerr/ @joostlek @AmGarera +/tests/components/overseerr/ @joostlek @AmGarera +/homeassistant/components/ovhcloud_ai_endpoints/ @Crocmagnon +/tests/components/ovhcloud_ai_endpoints/ @Crocmagnon /homeassistant/components/ovo_energy/ @timmo001 /tests/components/ovo_energy/ @timmo001 /homeassistant/components/p1_monitor/ @klaasnicolaas /tests/components/p1_monitor/ @klaasnicolaas +/homeassistant/components/paj_gps/ @skipperro +/tests/components/paj_gps/ @skipperro /homeassistant/components/palazzetti/ @dotvav /tests/components/palazzetti/ @dotvav /homeassistant/components/panel_custom/ @home-assistant/frontend @@ -1222,6 +1374,8 @@ build.json @home-assistant/supervisor /tests/components/pi_hole/ @shenxn /homeassistant/components/picnic/ @corneyl @codesalatdev /tests/components/picnic/ @corneyl @codesalatdev +/homeassistant/components/picotts/ @rooggiieerr +/tests/components/picotts/ @rooggiieerr /homeassistant/components/ping/ @jpbede /tests/components/ping/ @jpbede /homeassistant/components/plaato/ @JohNan @@ -1240,10 +1394,16 @@ build.json @home-assistant/supervisor /tests/components/poolsense/ @haemishkyd /homeassistant/components/portainer/ @erwindouna /tests/components/portainer/ @erwindouna +/homeassistant/components/power/ @home-assistant/core +/tests/components/power/ @home-assistant/core /homeassistant/components/powerfox/ @klaasnicolaas /tests/components/powerfox/ @klaasnicolaas +/homeassistant/components/powerfox_local/ @klaasnicolaas +/tests/components/powerfox_local/ @klaasnicolaas /homeassistant/components/powerwall/ @bdraco @jrester @daniel-simpson /tests/components/powerwall/ @bdraco @jrester @daniel-simpson +/homeassistant/components/prana/ @prana-dev-official +/tests/components/prana/ @prana-dev-official /homeassistant/components/private_ble_device/ @Jc2k /tests/components/private_ble_device/ @Jc2k /homeassistant/components/probe_plus/ @pantherale0 @@ -1258,9 +1418,12 @@ build.json @home-assistant/supervisor /tests/components/prosegur/ @dgomes /homeassistant/components/proximity/ @mib1185 /tests/components/proximity/ @mib1185 -/homeassistant/components/proxmoxve/ @jhollowe @Corbeno +/homeassistant/components/proxmoxve/ @Corbeno @erwindouna @CoMPaTech +/tests/components/proxmoxve/ @Corbeno @erwindouna @CoMPaTech /homeassistant/components/ps4/ @ktnrg45 /tests/components/ps4/ @ktnrg45 +/homeassistant/components/ptdevices/ @ParemTech-Inc @frogman85978 +/tests/components/ptdevices/ @ParemTech-Inc @frogman85978 /homeassistant/components/pterodactyl/ @elmurato /tests/components/pterodactyl/ @elmurato /homeassistant/components/pure_energie/ @klaasnicolaas @@ -1275,8 +1438,8 @@ build.json @home-assistant/supervisor /tests/components/pushover/ @engrbm87 /homeassistant/components/pvoutput/ @frenck /tests/components/pvoutput/ @frenck -/homeassistant/components/pvpc_hourly_pricing/ @azogue -/tests/components/pvpc_hourly_pricing/ @azogue +/homeassistant/components/pvpc_hourly_pricing/ @azogue @chiro79 +/tests/components/pvpc_hourly_pricing/ @azogue @chiro79 /homeassistant/components/pyload/ @tr4nt0r /tests/components/pyload/ @tr4nt0r /homeassistant/components/qbittorrent/ @geoffreylagaisse @finder39 @@ -1304,6 +1467,8 @@ build.json @home-assistant/supervisor /tests/components/radarr/ @tkdrob /homeassistant/components/radio_browser/ @frenck /tests/components/radio_browser/ @frenck +/homeassistant/components/radio_frequency/ @home-assistant/core +/tests/components/radio_frequency/ @home-assistant/core /homeassistant/components/radiotherm/ @vinnyfuria /tests/components/radiotherm/ @vinnyfuria /homeassistant/components/rainbird/ @konikvranik @allenporter @@ -1329,6 +1494,8 @@ build.json @home-assistant/supervisor /tests/components/recorder/ @home-assistant/core /homeassistant/components/recovery_mode/ @home-assistant/core /tests/components/recovery_mode/ @home-assistant/core +/homeassistant/components/redgtech/ @jonhsady @luan-nvg +/tests/components/redgtech/ @jonhsady @luan-nvg /homeassistant/components/refoss/ @ashionky /tests/components/refoss/ @ashionky /homeassistant/components/rehlko/ @bdraco @peterager @@ -1360,8 +1527,8 @@ build.json @home-assistant/supervisor /tests/components/ring/ @sdb9696 /homeassistant/components/risco/ @OnFreund /tests/components/risco/ @OnFreund -/homeassistant/components/rituals_perfume_genie/ @milanmeu @frenck -/tests/components/rituals_perfume_genie/ @milanmeu @frenck +/homeassistant/components/rituals_perfume_genie/ @milanmeu @frenck @quebulm +/tests/components/rituals_perfume_genie/ @milanmeu @frenck @quebulm /homeassistant/components/rmvtransport/ @cgtobi /tests/components/rmvtransport/ @cgtobi /homeassistant/components/roborock/ @Lash-L @allenporter @@ -1370,8 +1537,8 @@ build.json @home-assistant/supervisor /tests/components/roku/ @ctalkington /homeassistant/components/romy/ @xeniter /tests/components/romy/ @xeniter -/homeassistant/components/roomba/ @pschmitt @cyr-ius @shenxn @Orhideous -/tests/components/roomba/ @pschmitt @cyr-ius @shenxn @Orhideous +/homeassistant/components/roomba/ @pschmitt @cyr-ius @shenxn +/tests/components/roomba/ @pschmitt @cyr-ius @shenxn /homeassistant/components/roon/ @pavoni /tests/components/roon/ @pavoni /homeassistant/components/route_b_smart_meter/ @SeraphicRav @@ -1393,9 +1560,12 @@ build.json @home-assistant/supervisor /tests/components/rympro/ @OnFreund @elad-bar @maorcc /homeassistant/components/sabnzbd/ @shaiu @jpbede /tests/components/sabnzbd/ @shaiu @jpbede -/homeassistant/components/saj/ @fredericvl -/homeassistant/components/samsungtv/ @chemelli74 @epenet -/tests/components/samsungtv/ @chemelli74 @epenet +/homeassistant/components/saj/ @fredericvl @edurenye +/tests/components/saj/ @fredericvl @edurenye +/homeassistant/components/samsung_infrared/ @lmaertin +/tests/components/samsung_infrared/ @lmaertin +/homeassistant/components/samsungtv/ @chemelli74 +/tests/components/samsungtv/ @chemelli74 /homeassistant/components/sanix/ @tomaszsluszniak /tests/components/sanix/ @tomaszsluszniak /homeassistant/components/satel_integra/ @Tommatheussen @@ -1435,8 +1605,8 @@ build.json @home-assistant/supervisor /tests/components/sensorpush/ @bdraco /homeassistant/components/sensorpush_cloud/ @sstallion /tests/components/sensorpush_cloud/ @sstallion -/homeassistant/components/sensoterra/ @markruys -/tests/components/sensoterra/ @markruys +/homeassistant/components/sensoterra/ @SanderBakkumCuriousInc @curious-florian @markruys +/tests/components/sensoterra/ @SanderBakkumCuriousInc @curious-florian @markruys /homeassistant/components/sentry/ @dcramer @frenck /tests/components/sentry/ @dcramer @frenck /homeassistant/components/senz/ @milanmeu @@ -1491,8 +1661,8 @@ build.json @home-assistant/supervisor /tests/components/sma/ @kellerza @rklomp @erwindouna /homeassistant/components/smappee/ @bsmappee /tests/components/smappee/ @bsmappee -/homeassistant/components/smarla/ @explicatis @rlint-explicatis -/tests/components/smarla/ @explicatis @rlint-explicatis +/homeassistant/components/smarla/ @explicatis @johannes-exp +/tests/components/smarla/ @explicatis @johannes-exp /homeassistant/components/smart_meter_texas/ @grahamwetzler /tests/components/smart_meter_texas/ @grahamwetzler /homeassistant/components/smartthings/ @joostlek @@ -1518,6 +1688,8 @@ build.json @home-assistant/supervisor /homeassistant/components/solaredge_local/ @drobtravels @scheric /homeassistant/components/solarlog/ @Ernst79 @dontinelli /tests/components/solarlog/ @Ernst79 @dontinelli +/homeassistant/components/solarman/ @solarmanpv +/tests/components/solarman/ @solarmanpv /homeassistant/components/solax/ @squishykid @Darsstar /tests/components/solax/ @squishykid @Darsstar /homeassistant/components/soma/ @ratsept @@ -1535,18 +1707,17 @@ build.json @home-assistant/supervisor /homeassistant/components/speedtestdotnet/ @rohankapoorcom @engrbm87 /tests/components/speedtestdotnet/ @rohankapoorcom @engrbm87 /homeassistant/components/splunk/ @Bre77 +/tests/components/splunk/ @Bre77 /homeassistant/components/spotify/ @frenck @joostlek /tests/components/spotify/ @frenck @joostlek /homeassistant/components/sql/ @gjohansson-ST @dougiteixeira /tests/components/sql/ @gjohansson-ST @dougiteixeira /homeassistant/components/squeezebox/ @rajlaud @pssc @peteS-UK /tests/components/squeezebox/ @rajlaud @pssc @peteS-UK -/homeassistant/components/srp_energy/ @briglx -/tests/components/srp_energy/ @briglx +/homeassistant/components/srp_energy/ @briglx @ammmze +/tests/components/srp_energy/ @briglx @ammmze /homeassistant/components/starline/ @anonym-tsk /tests/components/starline/ @anonym-tsk -/homeassistant/components/starlink/ @boswelja -/tests/components/starlink/ @boswelja /homeassistant/components/statistics/ @ThomDietrich @gjohansson-ST /tests/components/statistics/ @ThomDietrich @gjohansson-ST /homeassistant/components/steam_online/ @tkdrob @@ -1592,13 +1763,15 @@ build.json @home-assistant/supervisor /tests/components/syncthing/ @zhulik /homeassistant/components/syncthru/ @nielstron /tests/components/syncthru/ @nielstron -/homeassistant/components/synology_dsm/ @hacf-fr @Quentame @mib1185 -/tests/components/synology_dsm/ @hacf-fr @Quentame @mib1185 +/homeassistant/components/synology_dsm/ @Quentame @mib1185 +/tests/components/synology_dsm/ @Quentame @mib1185 /homeassistant/components/synology_srm/ @aerialls /homeassistant/components/system_bridge/ @timmo001 /tests/components/system_bridge/ @timmo001 /homeassistant/components/systemmonitor/ @gjohansson-ST /tests/components/systemmonitor/ @gjohansson-ST +/homeassistant/components/systemnexa2/ @konsulten +/tests/components/systemnexa2/ @konsulten /homeassistant/components/tado/ @erwindouna /tests/components/tado/ @erwindouna /homeassistant/components/tag/ @home-assistant/core @@ -1622,8 +1795,14 @@ build.json @home-assistant/supervisor /tests/components/tedee/ @patrickhilker @zweckj /homeassistant/components/telegram_bot/ @hanwg /tests/components/telegram_bot/ @hanwg +/homeassistant/components/teleinfo/ @esciara +/tests/components/teleinfo/ @esciara /homeassistant/components/tellduslive/ @fredrike /tests/components/tellduslive/ @fredrike +/homeassistant/components/teltonika/ @karlbeecken +/tests/components/teltonika/ @karlbeecken +/homeassistant/components/temperature/ @home-assistant/core +/tests/components/temperature/ @home-assistant/core /homeassistant/components/template/ @Petro31 @home-assistant/core /tests/components/template/ @Petro31 @home-assistant/core /homeassistant/components/tesla_fleet/ @Bre77 @@ -1636,7 +1815,6 @@ build.json @home-assistant/supervisor /tests/components/tessie/ @Bre77 /homeassistant/components/text/ @home-assistant/core /tests/components/text/ @home-assistant/core -/homeassistant/components/tfiac/ @fredrike @mellado /homeassistant/components/thermobeacon/ @bdraco /tests/components/thermobeacon/ @bdraco /homeassistant/components/thermopro/ @bdraco @h3ss @@ -1670,6 +1848,8 @@ build.json @home-assistant/supervisor /tests/components/tomorrowio/ @raman325 @lymanepp /homeassistant/components/totalconnect/ @austinmroczek /tests/components/totalconnect/ @austinmroczek +/homeassistant/components/touchline/ @mnordseth +/tests/components/touchline/ @mnordseth /homeassistant/components/touchline_sl/ @jnsgruk /tests/components/touchline_sl/ @jnsgruk /homeassistant/components/tplink/ @rytilahti @bdraco @sdb9696 @@ -1690,12 +1870,16 @@ build.json @home-assistant/supervisor /tests/components/trafikverket_train/ @gjohansson-ST /homeassistant/components/trafikverket_weatherstation/ @gjohansson-ST /tests/components/trafikverket_weatherstation/ @gjohansson-ST -/homeassistant/components/transmission/ @engrbm87 @JPHutchins -/tests/components/transmission/ @engrbm87 @JPHutchins +/homeassistant/components/trane/ @bdraco +/tests/components/trane/ @bdraco +/homeassistant/components/transmission/ @engrbm87 @JPHutchins @andrew-codechimp +/tests/components/transmission/ @engrbm87 @JPHutchins @andrew-codechimp /homeassistant/components/trend/ @jpbede /tests/components/trend/ @jpbede /homeassistant/components/triggercmd/ @rvmey /tests/components/triggercmd/ @rvmey +/homeassistant/components/trmnl/ @joostlek +/tests/components/trmnl/ @joostlek /homeassistant/components/tts/ @home-assistant/core /tests/components/tts/ @home-assistant/core /homeassistant/components/tuya/ @Tuya @zlinoliver @@ -1706,12 +1890,18 @@ build.json @home-assistant/supervisor /tests/components/twinkly/ @dr1rrb @Robbie1221 @Olen /homeassistant/components/twitch/ @joostlek /tests/components/twitch/ @joostlek +/homeassistant/components/uhoo/ @getuhoo @joshsmonta +/tests/components/uhoo/ @getuhoo @joshsmonta /homeassistant/components/ukraine_alarm/ @PaulAnnekov /tests/components/ukraine_alarm/ @PaulAnnekov /homeassistant/components/unifi/ @Kane610 /tests/components/unifi/ @Kane610 +/homeassistant/components/unifi_access/ @imhotep @RaHehl +/tests/components/unifi_access/ @imhotep @RaHehl /homeassistant/components/unifi_direct/ @tofuSCHNITZEL -/homeassistant/components/unifiled/ @florisvdk +/tests/components/unifi_direct/ @tofuSCHNITZEL +/homeassistant/components/unifi_discovery/ @RaHehl +/tests/components/unifi_discovery/ @RaHehl /homeassistant/components/unifiprotect/ @RaHehl /tests/components/unifiprotect/ @RaHehl /homeassistant/components/upb/ @gwww @@ -1749,8 +1939,8 @@ build.json @home-assistant/supervisor /tests/components/vegehub/ @thulrus /homeassistant/components/velbus/ @Cereal2nd @brefra /tests/components/velbus/ @Cereal2nd @brefra -/homeassistant/components/velux/ @Julius2342 @DeerMaximum @pawlizio @wollew -/tests/components/velux/ @Julius2342 @DeerMaximum @pawlizio @wollew +/homeassistant/components/velux/ @Julius2342 @pawlizio @wollew +/tests/components/velux/ @Julius2342 @pawlizio @wollew /homeassistant/components/venstar/ @garbled1 @jhollowe /tests/components/venstar/ @garbled1 @jhollowe /homeassistant/components/versasense/ @imstevenxyz @@ -1758,14 +1948,18 @@ build.json @home-assistant/supervisor /tests/components/version/ @ludeeus /homeassistant/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak @sapuseven /tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak @sapuseven -/homeassistant/components/vicare/ @CFenner -/tests/components/vicare/ @CFenner +/homeassistant/components/vicare/ @CFenner @lackas +/tests/components/vicare/ @CFenner @lackas /homeassistant/components/victron_ble/ @rajlaud /tests/components/victron_ble/ @rajlaud +/homeassistant/components/victron_gx/ @tomer-w +/tests/components/victron_gx/ @tomer-w /homeassistant/components/victron_remote_monitoring/ @AndyTempel /tests/components/victron_remote_monitoring/ @AndyTempel /homeassistant/components/vilfo/ @ManneW /tests/components/vilfo/ @ManneW +/homeassistant/components/vistapool/ @fdebrus +/tests/components/vistapool/ @fdebrus /homeassistant/components/vivotek/ @HarlemSquirrel /tests/components/vivotek/ @HarlemSquirrel /homeassistant/components/vizio/ @raman325 @@ -1792,11 +1986,16 @@ build.json @home-assistant/supervisor /tests/components/waqi/ @joostlek /homeassistant/components/water_heater/ @home-assistant/core /tests/components/water_heater/ @home-assistant/core +/homeassistant/components/waterfurnace/ @sdague @masterkoppa +/tests/components/waterfurnace/ @sdague @masterkoppa /homeassistant/components/watergate/ @adam-the-hero /tests/components/watergate/ @adam-the-hero -/homeassistant/components/watson_tts/ @rutkai +/homeassistant/components/watts/ @theobld-ww @devender-verma-ww @ssi-spyro +/tests/components/watts/ @theobld-ww @devender-verma-ww @ssi-spyro /homeassistant/components/watttime/ @bachya /tests/components/watttime/ @bachya +/homeassistant/components/wattwaechter/ @smartcircuits +/tests/components/wattwaechter/ @smartcircuits /homeassistant/components/waze_travel_time/ @eifinger /tests/components/waze_travel_time/ @eifinger /homeassistant/components/weather/ @home-assistant/core @@ -1807,6 +2006,8 @@ build.json @home-assistant/supervisor /tests/components/weatherflow_cloud/ @jeeftor /homeassistant/components/weatherkit/ @tjhorner /tests/components/weatherkit/ @tjhorner +/homeassistant/components/web_rtc/ @home-assistant/core +/tests/components/web_rtc/ @home-assistant/core /homeassistant/components/webdav/ @jpbede /tests/components/webdav/ @jpbede /homeassistant/components/webhook/ @home-assistant/core @@ -1817,8 +2018,8 @@ build.json @home-assistant/supervisor /tests/components/webostv/ @thecode /homeassistant/components/websocket_api/ @home-assistant/core /tests/components/websocket_api/ @home-assistant/core -/homeassistant/components/weheat/ @jesperraemaekers -/tests/components/weheat/ @jesperraemaekers +/homeassistant/components/weheat/ @barryvdh +/tests/components/weheat/ @barryvdh /homeassistant/components/wemo/ @esev /tests/components/wemo/ @esev /homeassistant/components/whirlpool/ @abmantis @mkmer @@ -1827,29 +2028,35 @@ build.json @home-assistant/supervisor /tests/components/whois/ @frenck /homeassistant/components/wiffi/ @mampfes /tests/components/wiffi/ @mampfes +/homeassistant/components/wiim/ @Linkplay2020 +/tests/components/wiim/ @Linkplay2020 /homeassistant/components/wilight/ @leofig-rj /tests/components/wilight/ @leofig-rj +/homeassistant/components/window/ @home-assistant/core +/tests/components/window/ @home-assistant/core /homeassistant/components/wirelesstag/ @sergeymaysak /homeassistant/components/withings/ @joostlek /tests/components/withings/ @joostlek /homeassistant/components/wiz/ @sbidy @arturpragacz /tests/components/wiz/ @sbidy @arturpragacz -/homeassistant/components/wled/ @frenck -/tests/components/wled/ @frenck +/homeassistant/components/wled/ @frenck @mik-laj +/tests/components/wled/ @frenck @mik-laj /homeassistant/components/wmspro/ @mback2k /tests/components/wmspro/ @mback2k -/homeassistant/components/wolflink/ @adamkrol93 @mtielen -/tests/components/wolflink/ @adamkrol93 @mtielen +/homeassistant/components/wolflink/ @adamkrol93 @EnjoyingM +/tests/components/wolflink/ @adamkrol93 @EnjoyingM /homeassistant/components/workday/ @fabaff @gjohansson-ST /tests/components/workday/ @fabaff @gjohansson-ST /homeassistant/components/worldclock/ @fabaff /tests/components/worldclock/ @fabaff /homeassistant/components/ws66i/ @ssaenger /tests/components/ws66i/ @ssaenger +/homeassistant/components/wsdot/ @ucodery +/tests/components/wsdot/ @ucodery /homeassistant/components/wyoming/ @synesthesiam /tests/components/wyoming/ @synesthesiam -/homeassistant/components/xbox/ @hunterjm @tr4nt0r -/tests/components/xbox/ @hunterjm @tr4nt0r +/homeassistant/components/xbox/ @tr4nt0r +/tests/components/xbox/ @tr4nt0r /homeassistant/components/xiaomi_aqara/ @danielhiversen @syssi /tests/components/xiaomi_aqara/ @danielhiversen @syssi /homeassistant/components/xiaomi_ble/ @Jc2k @Ernst79 @@ -1858,6 +2065,8 @@ build.json @home-assistant/supervisor /tests/components/xiaomi_miio/ @rytilahti @syssi @starkillerOG /homeassistant/components/xiaomi_tv/ @simse /homeassistant/components/xmpp/ @fabaff @flowolf +/homeassistant/components/xthings_cloud/ @XthingsJacobs +/tests/components/xthings_cloud/ @XthingsJacobs /homeassistant/components/yale/ @bdraco /tests/components/yale/ @bdraco /homeassistant/components/yale_smart_alarm/ @gjohansson-ST @@ -1868,14 +2077,16 @@ build.json @home-assistant/supervisor /tests/components/yamaha_musiccast/ @vigonotion @micha91 /homeassistant/components/yandex_transport/ @rishatik92 @devbis /tests/components/yandex_transport/ @rishatik92 @devbis -/homeassistant/components/yardian/ @h3l1o5 -/tests/components/yardian/ @h3l1o5 +/homeassistant/components/yardian/ @aeon-matrix +/tests/components/yardian/ @aeon-matrix /homeassistant/components/yeelight/ @zewelor @shenxn @starkillerOG @alexyao2015 /tests/components/yeelight/ @zewelor @shenxn @starkillerOG @alexyao2015 /homeassistant/components/yeelightsunflower/ @lindsaymarkward /homeassistant/components/yi/ @bachya /homeassistant/components/yolink/ @matrixd2 /tests/components/yolink/ @matrixd2 +/homeassistant/components/yoto/ @cdnninja @piitaya +/tests/components/yoto/ @cdnninja @piitaya /homeassistant/components/youless/ @gjong /tests/components/youless/ @gjong /homeassistant/components/youtube/ @joostlek @@ -1888,17 +2099,20 @@ build.json @home-assistant/supervisor /tests/components/zeroconf/ @bdraco /homeassistant/components/zerproc/ @emlove /tests/components/zerproc/ @emlove -/homeassistant/components/zeversolar/ @kvanzuijlen -/tests/components/zeversolar/ @kvanzuijlen +/homeassistant/components/zeversolar/ @kvanzuijlen @mhuiskes +/tests/components/zeversolar/ @kvanzuijlen @mhuiskes /homeassistant/components/zha/ @dmulcahey @adminiuga @puddly @TheJulianJES /tests/components/zha/ @dmulcahey @adminiuga @puddly @TheJulianJES /homeassistant/components/zimi/ @markhannon /tests/components/zimi/ @markhannon +/homeassistant/components/zinvolt/ @joostlek +/tests/components/zinvolt/ @joostlek /homeassistant/components/zodiac/ @JulienTant /tests/components/zodiac/ @JulienTant /homeassistant/components/zone/ @home-assistant/core /tests/components/zone/ @home-assistant/core /homeassistant/components/zoneminder/ @rohankapoorcom @nabbi +/tests/components/zoneminder/ @rohankapoorcom @nabbi /homeassistant/components/zwave_js/ @home-assistant/z-wave /tests/components/zwave_js/ @home-assistant/z-wave /homeassistant/components/zwave_me/ @lawfulchaos @Z-Wave-Me @PoltoS From 99c5efcb27f914587adc09da54b195d2326b9021 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Tue, 14 Jul 2026 21:18:32 +1200 Subject: [PATCH 54/66] address copilot issues --- .../components/mammotion/__init__.py | 28 +- homeassistant/components/mammotion/config.py | 4 + .../components/mammotion/config_flow.py | 77 ++- .../components/mammotion/coordinator.py | 39 +- homeassistant/components/mammotion/entity.py | 6 +- .../components/mammotion/lawn_mower.py | 12 +- homeassistant/components/mammotion/models.py | 2 +- .../components/mammotion/strings.json | 5 +- tests/components/mammotion/__init__.py | 10 + tests/components/mammotion/conftest.py | 93 +-- .../components/mammotion/test_config_flow.py | 100 +++- tests/components/mammotion/test_lawn_mower.py | 553 +++++++----------- 12 files changed, 438 insertions(+), 491 deletions(-) diff --git a/homeassistant/components/mammotion/__init__.py b/homeassistant/components/mammotion/__init__.py index 1e73bba6215d5..6c413a53f0e2d 100644 --- a/homeassistant/components/mammotion/__init__.py +++ b/homeassistant/components/mammotion/__init__.py @@ -1,7 +1,6 @@ """The Mammotion integration.""" import contextlib -from datetime import datetime from typing import Any from aiohttp import ClientConnectorError @@ -16,7 +15,6 @@ from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceEntry -from homeassistant.helpers.event import async_call_later from .config import MammotionConfigStore from .const import ( @@ -46,19 +44,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> mammotion_mowers: list[MammotionMowerData] = [] mammotion_devices: MammotionDevices = MammotionDevices([]) + store = MammotionConfigStore(hass) if account and password: session = async_get_clientsession(hass) cached = _load_cached_credentials(entry) try: if cached: - await mammotion.restore_credentials(account, password, cached, session) + 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 EXPIRED_CREDENTIAL_EXCEPTIONS: - await mammotion.login_and_initiate_cloud(account, password, session) except UnretryableException as err: raise ConfigEntryError(err) from err @@ -75,10 +77,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> for device in device_list: update_coordinator = MammotionMowerUpdateCoordinator( - hass, entry, device, api + hass, entry, device, api, store ) await update_coordinator.async_restore_data() + await update_coordinator.async_config_entry_first_refresh() mammotion_mowers.append( MammotionMowerData( @@ -89,14 +92,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> ) ) - async def _start_coordinator( - _: datetime | None = None, - coordinator: MammotionMowerUpdateCoordinator = update_coordinator, - ) -> None: - await coordinator.async_config_entry_first_refresh() - - async_call_later(hass, 1, _start_coordinator) - mammotion_devices.mowers = mammotion_mowers entry.runtime_data = mammotion_devices @@ -147,11 +142,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: MammotionConfigEntry) - return unload_ok -async def async_remove_config_entry( - hass: HomeAssistant, entry: MammotionConfigEntry -) -> None: +async def async_remove_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> None: """Remove a config entry.""" - await hass.config_entries.async_remove(entry.entry_id) store = MammotionConfigStore(hass) await store.async_remove() diff --git a/homeassistant/components/mammotion/config.py b/homeassistant/components/mammotion/config.py index e41af9a43aa19..c7b3d37e90d2c 100644 --- a/homeassistant/components/mammotion/config.py +++ b/homeassistant/components/mammotion/config.py @@ -1,5 +1,7 @@ """Config storage for Mammotion integration.""" +import asyncio + from homeassistant.core import HomeAssistant from homeassistant.helpers.storage import Store @@ -22,3 +24,5 @@ def __init__( ) -> None: """Initialize the configuration store.""" super().__init__(hass, version=version, minor_version=minor_version, key=key) + # Serializes read-modify-write cycles shared between coordinators + self.lock = asyncio.Lock() diff --git a/homeassistant/components/mammotion/config_flow.py b/homeassistant/components/mammotion/config_flow.py index f47455140a279..a07acec1ad25d 100644 --- a/homeassistant/components/mammotion/config_flow.py +++ b/homeassistant/components/mammotion/config_flow.py @@ -1,6 +1,6 @@ """Config flow for Mammotion.""" -from typing import TYPE_CHECKING, Any, override +from typing import Any, override from aiohttp import ClientError from bleak.backends.device import BLEDevice @@ -76,11 +76,14 @@ async def check_and_update_bluetooth_device( @override async def async_step_bluetooth( - self, discovery_info: BluetoothServiceInfo + 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() @@ -100,10 +103,10 @@ async def async_step_bluetooth( 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 ), - **entry.data.get(CONF_BLE_DEVICES, {}), } self._abort_if_unique_id_configured(updates={CONF_BLE_DEVICES: ble_devices}) @@ -120,8 +123,8 @@ async def async_step_bluetooth_confirm( 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, {}), + name: format_mac(device.address), } self._abort_if_unique_id_configured( updates={CONF_BLE_DEVICES: existing_devices} @@ -176,27 +179,37 @@ async def async_step_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) + + 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 and user_input.get(CONF_ACCOUNTNAME): - account = user_input.get(CONF_ACCOUNTNAME, "") - password = user_input.get(CONF_PASSWORD, "") - mammotion_http = MammotionHTTP(account, password) - - try: - await mammotion_http.login_v2(account, password) - if mammotion_http.login_info is None: - errors["base"] = "invalid_auth" - except ClientError, TimeoutError, OSError: - errors["base"] = "cannot_connect" - - if not errors and (login_info := mammotion_http.login_info): - user_account = login_info.userInformation.userAccount + 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() @@ -211,8 +224,8 @@ async def async_step_wifi( ) schema = { - vol.Optional(CONF_ACCOUNTNAME): cv.string, - vol.Optional(CONF_PASSWORD): cv.string, + vol.Required(CONF_ACCOUNTNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, } return self.async_show_form( @@ -223,21 +236,25 @@ async def async_step_reconfigure( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle reconfiguration.""" - entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) - if TYPE_CHECKING: - assert entry + 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) - errors: dict[str, str] | None = None - user_input = user_input or {} - if user_input: 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={ - **entry.data, - **user_input, + data_updates={ + CONF_ACCOUNTNAME: account, + CONF_PASSWORD: password, + CONF_ACCOUNT_ID: user_account, }, - reason="reconfigure_successful", ) schema = { diff --git a/homeassistant/components/mammotion/coordinator.py b/homeassistant/components/mammotion/coordinator.py index 1f7565afbda42..e73b362b56d24 100644 --- a/homeassistant/components/mammotion/coordinator.py +++ b/homeassistant/components/mammotion/coordinator.py @@ -82,6 +82,7 @@ def __init__( config_entry: MammotionConfigEntry, device: Device, api: HomeAssistantMowerApi, + store: MammotionConfigStore, ) -> None: """Initialize mammotion data updater.""" super().__init__( @@ -91,34 +92,30 @@ def __init__( api=api, update_interval=DEFAULT_INTERVAL, ) + self.store = store async def async_restore_data(self) -> None: """Restore saved data.""" - store = MammotionConfigStore(self.hass) - restored_data: Mapping[str, Any] | None = await store.async_load() + async with self.store.lock: + restored_data: Mapping[str, Any] | None = await self.store.async_load() - if restored_data is None: - self.data = MowingDevice() - if handle := self.api.mammotion.mower(self.device_name): - handle.restore_device(self.data) - return - - try: - if mower_data := restored_data.get(self.device_name): + mower_state = MowingDevice() + if restored_data and (mower_data := restored_data.get(self.device_name)): + try: mower_state = MowingDevice().from_dict(mower_data) - if handle := self.api.mammotion.mower(self.device_name): - handle.restore_device(mower_state) - except InvalidFieldValue: - self.data = MowingDevice() - if handle := self.api.mammotion.mower(self.device_name): - handle.restore_device(self.data) + except InvalidFieldValue: + mower_state = MowingDevice() + + self.data = mower_state + if handle := self.api.mammotion.mower(self.device_name): + handle.restore_device(mower_state) async def async_save_data(self, data: MowingDevice) -> None: - """Get map data from the device.""" - store = MammotionConfigStore(self.hass) - current_store: dict[str, Any] = await store.async_load() or {} - current_store[self.device_name] = data.to_dict() - await store.async_save(current_store) + """Save mower data to the store.""" + async with self.store.lock: + current_store: dict[str, Any] = await self.store.async_load() or {} + current_store[self.device_name] = data.to_dict() + await self.store.async_save(current_store) @override async def _async_update_data(self) -> MowingDevice: diff --git a/homeassistant/components/mammotion/entity.py b/homeassistant/components/mammotion/entity.py index 9ed9ffc73b4c0..2036268ac447a 100644 --- a/homeassistant/components/mammotion/entity.py +++ b/homeassistant/components/mammotion/entity.py @@ -15,12 +15,12 @@ class MammotionBaseEntity(CoordinatorEntity[MammotionBaseUpdateCoordinator]): - """Representation of a Luba lawn mower.""" + """Base entity for Mammotion devices.""" _attr_has_entity_name = True def __init__(self, coordinator: MammotionBaseUpdateCoordinator, key: str) -> None: - """Initialize the lawn mower.""" + """Initialize the entity.""" super().__init__(coordinator) self._attr_unique_id = f"{coordinator.device_name}_{key}" @@ -86,4 +86,4 @@ def device_info(self) -> DeviceInfo: @property def available(self) -> bool: """Return True if entity is available.""" - return self.coordinator.data is not None and self.coordinator.is_online() + return super().available and self.coordinator.is_online() diff --git a/homeassistant/components/mammotion/lawn_mower.py b/homeassistant/components/mammotion/lawn_mower.py index 283f7f0076aca..52c069dde7170 100644 --- a/homeassistant/components/mammotion/lawn_mower.py +++ b/homeassistant/components/mammotion/lawn_mower.py @@ -120,7 +120,7 @@ async def async_start_mowing(self) -> None: @override async def async_dock(self) -> None: """Start docking.""" - trans_key = "pause_failed" + trans_key = "dock_failed" charge_state = self.rpt_dev_status.charge_state mode = self.rpt_dev_status.sys_status @@ -129,23 +129,19 @@ async def async_dock(self) -> None: 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, - 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") - else: - trans_key = "dock_failed" - await self.coordinator.async_send_command("return_to_dock") + 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 diff --git a/homeassistant/components/mammotion/models.py b/homeassistant/components/mammotion/models.py index bb0ff4af8ef53..a85ff1ab904c3 100644 --- a/homeassistant/components/mammotion/models.py +++ b/homeassistant/components/mammotion/models.py @@ -10,7 +10,7 @@ @dataclass class MammotionMowerData: - """Data for a mower information.""" + """Data for a mower.""" name: str api: HomeAssistantMowerApi diff --git a/homeassistant/components/mammotion/strings.json b/homeassistant/components/mammotion/strings.json index 60f2af2245a77..9f1ca3ed8d5dd 100644 --- a/homeassistant/components/mammotion/strings.json +++ b/homeassistant/components/mammotion/strings.json @@ -9,7 +9,8 @@ "no_devices_found_in_account": "No devices present in your account", "no_longer_present": "Device is no longer present", "not_supported": "Device not supported", - "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_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%]", @@ -31,7 +32,7 @@ "password": "[%key:component::mammotion::config::step::wifi::data_description::password%]" }, "description": "Enter your Mammotion account email or ID and password", - "title": "Connect to Wi-Fi" + "title": "Update configuration" }, "user": { "data": { diff --git a/tests/components/mammotion/__init__.py b/tests/components/mammotion/__init__.py index bd65f1b8ab7ee..3dab6d043f997 100644 --- a/tests/components/mammotion/__init__.py +++ b/tests/components/mammotion/__init__.py @@ -1,7 +1,10 @@ """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", @@ -21,3 +24,10 @@ 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 index 0b5e807c33184..2f239fa946cdc 100644 --- a/tests/components/mammotion/conftest.py +++ b/tests/components/mammotion/conftest.py @@ -3,9 +3,17 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, Mock, patch +from pymammotion.data.model.device import MowingDevice import pytest -from . import BLE_DEVICE_LUBA, BLE_DEVICE_YUKA +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" @@ -24,53 +32,52 @@ def mock_setup_entry() -> Generator[MagicMock]: yield mock_setup -@pytest.fixture(name="discovery") -def mock_async_discovered_service_info() -> Generator[MagicMock]: - """Mock service discovery.""" - with patch( - "homeassistant.components.mammotion.config_flow.async_discovered_service_info", - return_value=[BLE_DEVICE_LUBA, BLE_DEVICE_YUKA], - ) as discovery: - yield discovery - - @pytest.fixture -def mock_cloud_gateway() -> Mock: - """Mock a CloudIOTGateway.""" - mock_cloud = Mock() - mock_cloud.mammotion_http = Mock() - mock_cloud.mammotion_http.login_info = Mock() - mock_cloud.mammotion_http.login_info.userInformation = Mock() - mock_cloud.mammotion_http.login_info.userInformation.userAccount = "user123" - return mock_cloud +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_http_response() -> Mock: - """Mock a successful HTTP login response.""" - mock_response = Mock() - mock_response.login_info = Mock() - mock_response.login_info.userInformation = Mock() - mock_response.login_info.userInformation.userAccount = "user123" - return mock_response +def mock_mowing_device() -> MowingDevice: + """Return the state of the mower as reported by the device.""" + return MowingDevice() @pytest.fixture -def mock_mammotion() -> AsyncMock: - """Mock Mammotion class.""" - mock = AsyncMock() - mock.mqtt_list = {} - mock.login_and_initiate_cloud = AsyncMock() - return mock +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 = [] - -@pytest.fixture -def mock_mower_coordinator() -> AsyncMock: - """Return a mocked mower coordinator.""" - coordinator = AsyncMock() - coordinator.data = Mock() - coordinator.data.report_data = Mock() - coordinator.data.report_data.dev = Mock() - coordinator.api = Mock() - coordinator.api.async_request_iot_sync = AsyncMock() - return coordinator + with patch( + "homeassistant.components.mammotion.HomeAssistantMowerApi", + return_value=api, + ): + yield api diff --git a/tests/components/mammotion/test_config_flow.py b/tests/components/mammotion/test_config_flow.py index 8c9b75de74b19..7089c89a98323 100644 --- a/tests/components/mammotion/test_config_flow.py +++ b/tests/components/mammotion/test_config_flow.py @@ -4,6 +4,7 @@ from aiohttp import ClientConnectionError from bleak.backends.device import BLEDevice +import pytest from homeassistant import config_entries from homeassistant.components.mammotion.const import ( @@ -35,6 +36,7 @@ def _get_discovery_info(name="Luba-ABC123", address="aa:bb:cc:dd:ee:ff"): 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() @@ -290,24 +292,26 @@ async def test_reconfigure_flow(hass: HomeAssistant) -> None: ) entry.add_to_hass(hass) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={ - "source": config_entries.SOURCE_RECONFIGURE, - "entry_id": entry.entry_id, - }, - ) + result = await entry.start_reconfigure_flow(hass) assert result["type"] == FlowResultType.FORM assert result["step_id"] == "reconfigure" - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_ACCOUNTNAME: "new@example.com", - CONF_PASSWORD: "new_password", - }, - ) + 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" @@ -315,6 +319,74 @@ async def test_reconfigure_flow(hass: HomeAssistant) -> None: 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) -> None: diff --git a/tests/components/mammotion/test_lawn_mower.py b/tests/components/mammotion/test_lawn_mower.py index 56883787311d4..d74ba48d2e972 100644 --- a/tests/components/mammotion/test_lawn_mower.py +++ b/tests/components/mammotion/test_lawn_mower.py @@ -1,384 +1,235 @@ """Test for the Mammotion lawn_mower platform.""" -from unittest.mock import AsyncMock, MagicMock, Mock, patch +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, - LawnMowerEntityFeature, ) -from homeassistant.components.mammotion import MammotionDevices from homeassistant.components.mammotion.const import COMMAND_EXCEPTIONS, DOMAIN -from homeassistant.components.mammotion.lawn_mower import ( - MammotionLawnMowerEntity, - async_setup_entry, -) +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 tests.common import MockConfigEntry - - -async def test_async_setup_entry( - hass: HomeAssistant, mock_mower_coordinator: MagicMock -) -> None: - """Test setting up the lawn mower platform.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={}, - unique_id="test-unique-id", - ) - config_entry.runtime_data = MammotionDevices( - mowers=[MagicMock(reporting_coordinator=mock_mower_coordinator)] - ) - - with patch( - "homeassistant.components.mammotion.lawn_mower.MammotionLawnMowerEntity" - ): - await async_setup_entry(hass, config_entry, Mock()) - - -async def test_lawn_mower_entity_init(mock_mower_coordinator: MagicMock) -> None: - """Test initializing the lawn mower entity.""" - entity = MammotionLawnMowerEntity(mock_mower_coordinator) - - assert entity._attr_name is None - assert entity._attr_supported_features == ( - LawnMowerEntityFeature.DOCK - | LawnMowerEntityFeature.PAUSE - | LawnMowerEntityFeature.START_MOWING - ) - - -async def test_lawn_mower_activity_mowing(mock_mower_coordinator: MagicMock) -> None: - """Test the activity property when mowing.""" - entity = MammotionLawnMowerEntity(mock_mower_coordinator) - - mock_mower_coordinator.data.report_data.dev.sys_status = WorkMode.MODE_WORKING - - assert entity.activity == LawnMowerActivity.MOWING - - -async def test_lawn_mower_activity_paused(mock_mower_coordinator: MagicMock) -> None: - """Test the activity property when paused.""" - entity = MammotionLawnMowerEntity(mock_mower_coordinator) - - # Test MODE_PAUSE - mock_mower_coordinator.data.report_data.dev.sys_status = WorkMode.MODE_PAUSE - assert entity.activity == LawnMowerActivity.PAUSED - - # Test MODE_READY with charge_state 0 - mock_mower_coordinator.data.report_data.dev.sys_status = WorkMode.MODE_READY - mock_mower_coordinator.data.report_data.dev.charge_state = 0 - assert entity.activity == LawnMowerActivity.PAUSED - - -async def test_lawn_mower_activity_docked(mock_mower_coordinator: MagicMock) -> None: - """Test the activity property when docked.""" - entity = MammotionLawnMowerEntity(mock_mower_coordinator) - - mock_mower_coordinator.data.report_data.dev.sys_status = WorkMode.MODE_READY - mock_mower_coordinator.data.report_data.dev.charge_state = 1 - - assert entity.activity == LawnMowerActivity.DOCKED - - -async def test_lawn_mower_activity_returning(mock_mower_coordinator: MagicMock) -> None: - """Test the activity property when returning.""" - entity = MammotionLawnMowerEntity(mock_mower_coordinator) - - mock_mower_coordinator.data.report_data.dev.sys_status = WorkMode.MODE_RETURNING - - assert entity.activity == LawnMowerActivity.RETURNING - +from . import setup_integration -async def test_lawn_mower_activity_error(mock_mower_coordinator: MagicMock) -> None: - """Test the activity property when in error state.""" - entity = MammotionLawnMowerEntity(mock_mower_coordinator) +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform - mock_mower_coordinator.data.report_data.dev.sys_status = WorkMode.MODE_LOCK +ENTITY_ID = "lawn_mower.garden_luba" - assert entity.activity == LawnMowerActivity.ERROR - -async def test_lawn_mower_activity_none(mock_mower_coordinator: MagicMock) -> None: - """Test the activity property returns None for unknown states.""" - entity = MammotionLawnMowerEntity(mock_mower_coordinator) - - # Test None sys_status - mock_mower_coordinator.data.report_data.dev.sys_status = None - assert entity.activity is None - - # Test unhandled sys_status - mock_mower_coordinator.data.report_data.dev.sys_status = 999 - assert entity.activity is None - - -async def test_async_start_mowing(mock_mower_coordinator: MagicMock) -> None: - """Test the async_start_mowing method.""" - entity = MammotionLawnMowerEntity(mock_mower_coordinator) - mock_mower_coordinator.async_send_command = AsyncMock() - mock_mower_coordinator.api.async_request_iot_sync = AsyncMock() - - mock_mower_coordinator.data.report_data.dev.sys_status = WorkMode.MODE_READY - - await entity.async_start_mowing() - - assert mock_mower_coordinator.async_send_command.call_count == 1 - assert ( - mock_mower_coordinator.async_send_command.call_args_list[0][0][0] == "start_job" - ) - assert mock_mower_coordinator.api.async_request_iot_sync.call_count == 1 - - -async def test_async_start_mowing_resume(mock_mower_coordinator: MagicMock) -> None: - """Test the async_start_mowing method when paused.""" - entity = MammotionLawnMowerEntity(mock_mower_coordinator) - mock_mower_coordinator.async_send_command = AsyncMock() - mock_mower_coordinator.api.async_request_iot_sync = AsyncMock() - - mock_mower_coordinator.data.report_data.dev.sys_status = WorkMode.MODE_PAUSE - - await entity.async_start_mowing() - - assert mock_mower_coordinator.async_send_command.call_count == 1 - assert ( - mock_mower_coordinator.async_send_command.call_args_list[0][0][0] - == "resume_execute_task" - ) - assert mock_mower_coordinator.api.async_request_iot_sync.call_count == 1 - - -async def test_async_start_mowing_not_ready(mock_mower_coordinator: MagicMock) -> None: - """Test the async_start_mowing method when device is not ready.""" - entity = MammotionLawnMowerEntity(mock_mower_coordinator) - - mock_mower_coordinator.data.report_data.dev.sys_status = None - - with pytest.raises(HomeAssistantError) as exc_info: - await entity.async_start_mowing() - error = exc_info.value - assert error.translation_domain == DOMAIN - assert error.translation_key == "device_not_ready" - - -async def test_async_start_mowing_command_exception( - mock_mower_coordinator: MagicMock, +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_mower_api: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, ) -> None: - """Test the async_start_mowing method with command exceptions.""" - entity = MammotionLawnMowerEntity(mock_mower_coordinator) - mock_error = COMMAND_EXCEPTIONS[0]("Test error") - mock_mower_coordinator.async_send_command = AsyncMock(side_effect=mock_error) - mock_mower_coordinator.api.async_request_iot_sync = AsyncMock() - - mock_mower_coordinator.data.report_data.dev.sys_status = WorkMode.MODE_READY - - with pytest.raises(HomeAssistantError) as exc_info: - await entity.async_start_mowing() - error = exc_info.value - assert error.translation_domain == DOMAIN - assert error.translation_key == "start_failed" - - assert mock_mower_coordinator.api.async_request_iot_sync.call_count == 1 - - -async def test_async_start_mowing_resume_command_exception( - mock_mower_coordinator: MagicMock, + """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 async_start_mowing resume path with command exceptions.""" - entity = MammotionLawnMowerEntity(mock_mower_coordinator) - mock_error = COMMAND_EXCEPTIONS[0]("Test error") - mock_mower_coordinator.async_send_command = AsyncMock(side_effect=mock_error) - mock_mower_coordinator.api.async_request_iot_sync = AsyncMock() - - mock_mower_coordinator.data.report_data.dev.sys_status = WorkMode.MODE_PAUSE - - with pytest.raises(HomeAssistantError) as exc_info: - await entity.async_start_mowing() - error = exc_info.value - assert error.translation_domain == DOMAIN - assert error.translation_key == "resume_failed" - - assert mock_mower_coordinator.api.async_request_iot_sync.call_count == 1 - - -async def test_async_dock(mock_mower_coordinator: MagicMock) -> None: - """Test the async_dock method.""" - entity = MammotionLawnMowerEntity(mock_mower_coordinator) - mock_mower_coordinator.async_send_command = AsyncMock() - mock_mower_coordinator.api.async_request_iot_sync = AsyncMock() - - # Test working mode - mock_mower_coordinator.data.report_data.dev.sys_status = WorkMode.MODE_WORKING - mock_mower_coordinator.data.report_data.dev.charge_state = 0 - - await entity.async_dock() - - assert mock_mower_coordinator.async_send_command.call_count == 2 - assert ( - mock_mower_coordinator.async_send_command.call_args_list[0][0][0] - == "pause_execute_task" - ) - assert ( - mock_mower_coordinator.async_send_command.call_args_list[1][0][0] - == "return_to_dock" - ) - assert mock_mower_coordinator.api.async_request_iot_sync.call_count == 1 - - -async def test_async_dock_returning(mock_mower_coordinator: MagicMock) -> None: - """Test the async_dock method when already returning.""" - entity = MammotionLawnMowerEntity(mock_mower_coordinator) - mock_mower_coordinator.async_send_command = AsyncMock() - mock_mower_coordinator.api.async_request_iot_sync = AsyncMock() - - mock_mower_coordinator.data.report_data.dev.sys_status = WorkMode.MODE_RETURNING - mock_mower_coordinator.data.report_data.dev.charge_state = 0 + """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 entity.async_dock() + await setup_integration(hass, mock_config_entry) - assert mock_mower_coordinator.async_send_command.call_count == 1 - assert ( - mock_mower_coordinator.async_send_command.call_args_list[0][0][0] - == "cancel_return_to_dock" + await hass.services.async_call( + LAWN_MOWER_DOMAIN, service, {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True ) - assert mock_mower_coordinator.api.async_request_iot_sync.call_count == 1 - - -async def test_async_dock_ready(mock_mower_coordinator: MagicMock) -> None: - """Test the async_dock method when device is ready.""" - entity = MammotionLawnMowerEntity(mock_mower_coordinator) - mock_mower_coordinator.async_send_command = AsyncMock() - mock_mower_coordinator.api.async_request_iot_sync = AsyncMock() - - mock_mower_coordinator.data.report_data.dev.sys_status = WorkMode.MODE_READY - mock_mower_coordinator.data.report_data.dev.charge_state = 0 - await entity.async_dock() - - assert mock_mower_coordinator.async_send_command.call_count == 1 - assert ( - mock_mower_coordinator.async_send_command.call_args_list[0][0][0] - == "return_to_dock" + 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 ) - assert mock_mower_coordinator.api.async_request_iot_sync.call_count == 1 - - -async def test_async_dock_not_ready(mock_mower_coordinator: MagicMock) -> None: - """Test the async_dock method when device is not ready.""" - entity = MammotionLawnMowerEntity(mock_mower_coordinator) - mock_mower_coordinator.data.report_data.dev.sys_status = None - with patch.object(mock_mower_coordinator, "async_send_command"): - with pytest.raises(HomeAssistantError) as exc_info: - await entity.async_dock() - error = exc_info.value - assert error.translation_domain - - -async def test_async_dock_command_exception(mock_mower_coordinator: MagicMock) -> None: - """Test the async_dock method with command exceptions.""" - entity = MammotionLawnMowerEntity(mock_mower_coordinator) - mock_error = COMMAND_EXCEPTIONS[0]("Test error") - mock_mower_coordinator.async_send_command = AsyncMock(side_effect=mock_error) - mock_mower_coordinator.api.async_request_iot_sync = AsyncMock() +@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.side_effect = COMMAND_EXCEPTIONS[0]("boom") - mock_mower_coordinator.data.report_data.dev.sys_status = WorkMode.MODE_WORKING - mock_mower_coordinator.data.report_data.dev.charge_state = 0 + await setup_integration(hass, mock_config_entry) with pytest.raises(HomeAssistantError) as exc_info: - await entity.async_dock() - error = exc_info.value - assert error.translation_domain == DOMAIN - assert error.translation_key == "pause_failed" - - assert mock_mower_coordinator.api.async_request_iot_sync.call_count == 1 - - -async def test_async_pause(mock_mower_coordinator: MagicMock) -> None: - """Test the async_pause method.""" - entity = MammotionLawnMowerEntity(mock_mower_coordinator) - mock_mower_coordinator.async_send_command = AsyncMock() - mock_mower_coordinator.api.async_request_iot_sync = AsyncMock() - - # Test working mode - mock_mower_coordinator.data.report_data.dev.sys_status = WorkMode.MODE_WORKING - - await entity.async_pause() - - assert mock_mower_coordinator.async_send_command.call_count == 1 - assert ( - mock_mower_coordinator.async_send_command.call_args_list[0][0][0] - == "pause_execute_task" - ) - assert mock_mower_coordinator.api.async_request_iot_sync.call_count == 1 - - -async def test_async_pause_returning(mock_mower_coordinator: MagicMock) -> None: - """Test the async_pause method when returning.""" - entity = MammotionLawnMowerEntity(mock_mower_coordinator) - mock_mower_coordinator.async_send_command = AsyncMock() - mock_mower_coordinator.api.async_request_iot_sync = AsyncMock() - - mock_mower_coordinator.data.report_data.dev.sys_status = WorkMode.MODE_RETURNING - - await entity.async_pause() - - assert mock_mower_coordinator.async_send_command.call_count == 1 - assert ( - mock_mower_coordinator.async_send_command.call_args_list[0][0][0] - == "cancel_return_to_dock" - ) - assert mock_mower_coordinator.api.async_request_iot_sync.call_count == 1 - - -async def test_async_pause_not_ready(mock_mower_coordinator: MagicMock) -> None: - """Test the async_pause method when device is not ready.""" - entity = MammotionLawnMowerEntity(mock_mower_coordinator) - - mock_mower_coordinator.data.report_data.dev.sys_status = None - - with patch.object(mock_mower_coordinator, "async_send_command"): - with pytest.raises(HomeAssistantError) as exc_info: - await entity.async_pause() - error = exc_info.value - assert error.translation_domain == DOMAIN - assert error.translation_key == "device_not_ready" - - -async def test_async_pause_not_working_or_returning( - mock_mower_coordinator: MagicMock, + 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 async_pause method when not in working or returning mode.""" - entity = MammotionLawnMowerEntity(mock_mower_coordinator) - mock_mower_coordinator.async_send_command = AsyncMock() - mock_mower_coordinator.api.async_request_iot_sync = AsyncMock() - - mock_mower_coordinator.data.report_data.dev.sys_status = WorkMode.MODE_READY + """Test the lawn mower services raise when the device is not ready.""" + mock_mowing_device.report_data.dev.sys_status = None - # Should not call any commands - await entity.async_pause() - - assert mock_mower_coordinator.async_send_command.call_count == 0 - assert mock_mower_coordinator.api.async_request_iot_sync.call_count == 0 - - -async def test_async_pause_command_exception(mock_mower_coordinator: MagicMock) -> None: - """Test the async_pause method with command exceptions.""" - entity = MammotionLawnMowerEntity(mock_mower_coordinator) - mock_error = COMMAND_EXCEPTIONS[0]("Test error") - mock_mower_coordinator.async_send_command = AsyncMock(side_effect=mock_error) - mock_mower_coordinator.api.async_request_iot_sync = AsyncMock() - - mock_mower_coordinator.data.report_data.dev.sys_status = WorkMode.MODE_WORKING + await setup_integration(hass, mock_config_entry) with pytest.raises(HomeAssistantError) as exc_info: - await entity.async_pause() - error = exc_info.value - assert error.translation_domain == DOMAIN - assert error.translation_key == "pause_failed" - - assert mock_mower_coordinator.api.async_request_iot_sync.call_count == 1 + 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 From cfe01bfdad81b12c92c9e2a94a629f81b38e2786 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Fri, 17 Jul 2026 09:19:16 +1200 Subject: [PATCH 55/66] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/mammotion/config_flow.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/components/mammotion/config_flow.py b/homeassistant/components/mammotion/config_flow.py index a07acec1ad25d..6b0cf62c2bec3 100644 --- a/homeassistant/components/mammotion/config_flow.py +++ b/homeassistant/components/mammotion/config_flow.py @@ -150,6 +150,12 @@ async def async_step_user( """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() From 57d6e6294711dc0e11c4da496fbda6aac8828214 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Fri, 17 Jul 2026 11:00:56 +1200 Subject: [PATCH 56/66] bump pymammotion --- .../components/mammotion/manifest.json | 2 +- requirements_all.txt | 2 +- .../mammotion/snapshots/test_lawn_mower.ambr | 52 +++++++++++++ tests/components/mammotion/test_init.py | 74 +++++++++++++++++++ 4 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 tests/components/mammotion/snapshots/test_lawn_mower.ambr create mode 100644 tests/components/mammotion/test_init.py diff --git a/homeassistant/components/mammotion/manifest.json b/homeassistant/components/mammotion/manifest.json index 05f1a55e06fb9..1cd29830b97fd 100644 --- a/homeassistant/components/mammotion/manifest.json +++ b/homeassistant/components/mammotion/manifest.json @@ -21,5 +21,5 @@ "iot_class": "cloud_polling", "loggers": ["pymammotion"], "quality_scale": "bronze", - "requirements": ["pymammotion==0.8.9"] + "requirements": ["pymammotion==0.8.11"] } diff --git a/requirements_all.txt b/requirements_all.txt index 20a657e8a25e3..ba89b7310c100 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2356,7 +2356,7 @@ pylutron==0.4.2 pymailgunner==1.4 # homeassistant.components.mammotion -pymammotion==0.8.9 +pymammotion==0.8.11 # homeassistant.components.firmata pymata-express==1.19 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 0000000000000..4633eccb6dd21 --- /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_init.py b/tests/components/mammotion/test_init.py new file mode 100644 index 0000000000000..53aab260226fe --- /dev/null +++ b/tests/components/mammotion/test_init.py @@ -0,0 +1,74 @@ +"""Tests for the Mammotion integration setup.""" + +from unittest.mock import MagicMock, Mock + +from aiohttp import ClientConnectorError +from Tea.exceptions import UnretryableException + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration +from .conftest import DEFAULT_NAME + +from tests.common import MockConfigEntry + + +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_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 From 4045f86dd2a2c6e426a5d445c30668de6d3e7c7f Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Fri, 14 Aug 2026 17:07:15 +1200 Subject: [PATCH 57/66] small tweak to docked logic and library bump --- homeassistant/components/mammotion/__init__.py | 2 +- homeassistant/components/mammotion/lawn_mower.py | 2 +- homeassistant/components/mammotion/manifest.json | 2 +- requirements_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/mammotion/__init__.py b/homeassistant/components/mammotion/__init__.py index 6c413a53f0e2d..d62fb405da84e 100644 --- a/homeassistant/components/mammotion/__init__.py +++ b/homeassistant/components/mammotion/__init__.py @@ -37,7 +37,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> bool: """Set up Mammotion Luba from a config entry.""" - api = HomeAssistantMowerApi(async_get_clientsession(hass)) + 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) diff --git a/homeassistant/components/mammotion/lawn_mower.py b/homeassistant/components/mammotion/lawn_mower.py index 52c069dde7170..d9eddf9eb5823 100644 --- a/homeassistant/components/mammotion/lawn_mower.py +++ b/homeassistant/components/mammotion/lawn_mower.py @@ -67,7 +67,7 @@ def activity(self) -> LawnMowerActivity | None: mode = self.rpt_dev_status.sys_status LOGGER.debug("activity mode %s", mode) - if mode == WorkMode.MODE_PAUSE or ( + if mode in (WorkMode.MODE_PAUSE, WorkMode.MODE_CHARGING_PAUSE) or ( mode == WorkMode.MODE_READY and charge_state == 0 ): return LawnMowerActivity.PAUSED diff --git a/homeassistant/components/mammotion/manifest.json b/homeassistant/components/mammotion/manifest.json index 1cd29830b97fd..30c4054d9343a 100644 --- a/homeassistant/components/mammotion/manifest.json +++ b/homeassistant/components/mammotion/manifest.json @@ -21,5 +21,5 @@ "iot_class": "cloud_polling", "loggers": ["pymammotion"], "quality_scale": "bronze", - "requirements": ["pymammotion==0.8.11"] + "requirements": ["pymammotion==0.8.13"] } diff --git a/requirements_all.txt b/requirements_all.txt index ba89b7310c100..7ea3815763cc5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2356,7 +2356,7 @@ pylutron==0.4.2 pymailgunner==1.4 # homeassistant.components.mammotion -pymammotion==0.8.11 +pymammotion==0.8.13 # homeassistant.components.firmata pymata-express==1.19 From 93ef7f8adf4f07e4e78021771e47fd0867c499f7 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Fri, 14 Aug 2026 23:08:54 +1200 Subject: [PATCH 58/66] addressing more comments --- .../components/mammotion/__init__.py | 12 ++- homeassistant/components/mammotion/config.py | 62 +++++++++---- .../components/mammotion/coordinator.py | 20 +--- .../components/mammotion/test_config_flow.py | 16 ++-- tests/components/mammotion/test_init.py | 91 ++++++++++++++++++- 5 files changed, 154 insertions(+), 47 deletions(-) diff --git a/homeassistant/components/mammotion/__init__.py b/homeassistant/components/mammotion/__init__.py index d62fb405da84e..aa6ca4b8c8c35 100644 --- a/homeassistant/components/mammotion/__init__.py +++ b/homeassistant/components/mammotion/__init__.py @@ -37,14 +37,17 @@ 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)) + 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) + store = MammotionConfigStore(hass, entry.entry_id) + await store.async_load_mower_data() if account and password: session = async_get_clientsession(hass) @@ -80,7 +83,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> hass, entry, device, api, store ) - await update_coordinator.async_restore_data() + update_coordinator.restore_data() await update_coordinator.async_config_entry_first_refresh() mammotion_mowers.append( @@ -102,6 +105,7 @@ async def shutdown_mammotion(_: Event | None = None) -> None: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown_mammotion) ) entry.async_on_unload(shutdown_mammotion) + entry.async_on_unload(store.async_flush_mower_data) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -144,7 +148,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: MammotionConfigEntry) - async def async_remove_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> None: """Remove a config entry.""" - store = MammotionConfigStore(hass) + store = MammotionConfigStore(hass, entry.entry_id) await store.async_remove() diff --git a/homeassistant/components/mammotion/config.py b/homeassistant/components/mammotion/config.py index c7b3d37e90d2c..48d1d7118d547 100644 --- a/homeassistant/components/mammotion/config.py +++ b/homeassistant/components/mammotion/config.py @@ -1,28 +1,56 @@ """Config storage for Mammotion integration.""" -import asyncio +from typing import Any -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.storage import Store from .const import DOMAIN +SAVE_DELAY = 300 +STORAGE_VERSION = 1 +STORAGE_MINOR_VERSION = 0 -class MammotionConfigStore(Store): - """A configuration store for Mammotion.""" - _STORAGE_VERSION = 1 - _STORAGE_MINOR_VERSION = 0 - _STORAGE_KEY = DOMAIN +class MammotionConfigStore(Store[dict[str, Any]]): + """Store the mower state of a single config entry.""" - def __init__( - self, - hass: HomeAssistant, - version: int = _STORAGE_VERSION, - minor_version: int = _STORAGE_MINOR_VERSION, - key: str = _STORAGE_KEY, - ) -> None: + def __init__(self, hass: HomeAssistant, entry_id: str) -> None: """Initialize the configuration store.""" - super().__init__(hass, version=version, minor_version=minor_version, key=key) - # Serializes read-modify-write cycles shared between coordinators - self.lock = asyncio.Lock() + super().__init__( + hass, + version=STORAGE_VERSION, + minor_version=STORAGE_MINOR_VERSION, + key=f"{DOMAIN}.{entry_id}", + ) + # In-memory state of the entry's mowers, keyed by device name + self.mower_data: dict[str, Any] = {} + self._save_pending = 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, writing to disk at most once per SAVE_DELAY.""" + if self.mower_data.get(device_name) == data: + return + self.mower_data[device_name] = data + # A pending write keeps its own deadline: async_delay_save would push the + # write back on every call and never fire while polling continues. + if self._save_pending: + return + self._save_pending = True + self.async_delay_save(self._data_to_save, SAVE_DELAY) + + def _data_to_save(self) -> dict[str, Any]: + """Return a snapshot to persist; runs in the executor thread.""" + self._save_pending = False + return dict(self.mower_data) + + async def async_flush_mower_data(self) -> None: + """Write queued mower data to disk, cancelling the delayed write.""" + if not self._save_pending: + return + await self.async_save(self._data_to_save()) diff --git a/homeassistant/components/mammotion/coordinator.py b/homeassistant/components/mammotion/coordinator.py index e73b362b56d24..e8f1da44a8c01 100644 --- a/homeassistant/components/mammotion/coordinator.py +++ b/homeassistant/components/mammotion/coordinator.py @@ -1,6 +1,5 @@ """Provides the mammotion DataUpdateCoordinator.""" -from collections.abc import Mapping from datetime import timedelta from typing import TYPE_CHECKING, Any, override @@ -10,7 +9,7 @@ from pymammotion.homeassistant import HomeAssistantMowerApi from homeassistant.const import CONF_PASSWORD -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .config import MammotionConfigStore @@ -94,13 +93,11 @@ def __init__( ) self.store = store - async def async_restore_data(self) -> None: + @callback + def restore_data(self) -> None: """Restore saved data.""" - async with self.store.lock: - restored_data: Mapping[str, Any] | None = await self.store.async_load() - mower_state = MowingDevice() - if restored_data and (mower_data := restored_data.get(self.device_name)): + if mower_data := self.store.mower_data.get(self.device_name): try: mower_state = MowingDevice().from_dict(mower_data) except InvalidFieldValue: @@ -110,19 +107,12 @@ async def async_restore_data(self) -> None: if handle := self.api.mammotion.mower(self.device_name): handle.restore_device(mower_state) - async def async_save_data(self, data: MowingDevice) -> None: - """Save mower data to the store.""" - async with self.store.lock: - current_store: dict[str, Any] = await self.store.async_load() or {} - current_store[self.device_name] = data.to_dict() - await self.store.async_save(current_store) - @override async def _async_update_data(self) -> MowingDevice: """Get data from the device.""" data = await self.api.update(self.device_name) if data is None: raise UpdateFailed(f"No data returned for {self.device_name}") - await self.async_save_data(data) + self.store.async_update_mower_data(self.device_name, data.to_dict()) return data diff --git a/tests/components/mammotion/test_config_flow.py b/tests/components/mammotion/test_config_flow.py index 7089c89a98323..7a461b7d63005 100644 --- a/tests/components/mammotion/test_config_flow.py +++ b/tests/components/mammotion/test_config_flow.py @@ -52,17 +52,14 @@ async def test_bluetooth_discovery_success(hass: HomeAssistant) -> None: data=discovery_info, ) - # Bluetooth discovery goes to bluetooth_confirm step assert result["type"] == FlowResultType.FORM assert result["step_id"] == "bluetooth_confirm" - # Confirm bluetooth step result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result["type"] == FlowResultType.FORM assert result["step_id"] == "wifi" - # Configure WiFi with credentials mock_http = MagicMock() mock_http.login_info.userInformation.userAccount = "user123" mock_http.login_v2 = AsyncMock(return_value=None) @@ -389,7 +386,9 @@ async def test_reconfigure_flow_account_mismatch(hass: HomeAssistant) -> None: assert entry.data[CONF_ACCOUNTNAME] == "old@example.com" -async def test_bluetooth_discovery_update_existing_entry(hass: HomeAssistant) -> None: +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, @@ -399,7 +398,6 @@ async def test_bluetooth_discovery_update_existing_entry(hass: HomeAssistant) -> ) entry.add_to_hass(hass) - device_registry = dr.async_get(hass) device_entry = device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, "Luba-ABC123")}, @@ -422,7 +420,6 @@ async def test_bluetooth_discovery_update_existing_entry(hass: HomeAssistant) -> assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" - # Verify device registry was updated device_entry = device_registry.async_get(device_entry.id) assert (dr.CONNECTION_BLUETOOTH, "aa:bb:cc:dd:ee:ff") in device_entry.connections @@ -441,7 +438,6 @@ async def test_bluetooth_step_no_discovery_info(hass: HomeAssistant) -> None: async def test_user_step_filtering(hass: HomeAssistant) -> None: """Test user step filters discovered devices.""" - # 1. Device already configured entry = MockConfigEntry( domain=DOMAIN, unique_id="AA:BB:CC:DD:EE:FF", @@ -483,7 +479,9 @@ async def test_user_step_filtering(hass: HomeAssistant) -> None: assert result["step_id"] == "user" -async def test_bluetooth_confirm_race_condition(hass: HomeAssistant) -> None: +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() @@ -495,8 +493,6 @@ async def test_bluetooth_confirm_race_condition(hass: HomeAssistant) -> None: ) entry.add_to_hass(hass) - # Create a device entry that matches - device_registry = dr.async_get(hass) device_entry = device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, "Luba-ABC123")}, diff --git a/tests/components/mammotion/test_init.py b/tests/components/mammotion/test_init.py index 53aab260226fe..f3df57330c429 100644 --- a/tests/components/mammotion/test_init.py +++ b/tests/components/mammotion/test_init.py @@ -1,17 +1,23 @@ """Tests for the Mammotion integration setup.""" +from datetime import timedelta +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 Tea.exceptions import UnretryableException +from homeassistant.components.mammotion.config import SAVE_DELAY +from homeassistant.components.mammotion.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from . import setup_integration from .conftest import DEFAULT_NAME -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_load_unload_entry( @@ -59,6 +65,89 @@ async def test_setup_retry_on_connection_error( 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_after_delay( + 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 after the save delay.""" + 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(timedelta(seconds=SAVE_DELAY + 1)) + async_fire_time_changed(hass) + 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, From f7ff33e933556895331f7d11e68e020baaa573dd Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Sun, 16 Aug 2026 12:44:48 +1200 Subject: [PATCH 59/66] addressing copilot issues --- homeassistant/components/mammotion/config_flow.py | 5 ++++- homeassistant/components/mammotion/const.py | 3 +++ homeassistant/components/mammotion/coordinator.py | 6 ++++-- homeassistant/components/mammotion/exceptions.py | 7 +++++++ tests/components/mammotion/test_config_flow.py | 1 - tests/components/mammotion/test_lawn_mower.py | 4 ++-- 6 files changed, 20 insertions(+), 6 deletions(-) create mode 100644 homeassistant/components/mammotion/exceptions.py diff --git a/homeassistant/components/mammotion/config_flow.py b/homeassistant/components/mammotion/config_flow.py index 6b0cf62c2bec3..9d4808fcc2016 100644 --- a/homeassistant/components/mammotion/config_flow.py +++ b/homeassistant/components/mammotion/config_flow.py @@ -16,6 +16,7 @@ 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 ( @@ -190,7 +191,9 @@ async def _async_validate_login( ) -> 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) + mammotion_http = MammotionHTTP( + account, password, session=async_get_clientsession(self.hass) + ) try: await mammotion_http.login_v2(account, password) diff --git a/homeassistant/components/mammotion/const.py b/homeassistant/components/mammotion/const.py index f2c826cdf5389..f6762d7e6b114 100644 --- a/homeassistant/components/mammotion/const.py +++ b/homeassistant/components/mammotion/const.py @@ -12,6 +12,8 @@ ) from pymammotion.transport.base import NoTransportAvailableError +from .exceptions import CommandFailedError + DOMAIN: Final = "mammotion" DEVICE_SUPPORT = ("Luba", "Yuka") @@ -24,6 +26,7 @@ NoTransportAvailableError, TimeoutError, DeviceOfflineException, + CommandFailedError, ) EXPIRED_CREDENTIAL_EXCEPTIONS = (CheckSessionException, CloudSetupError) diff --git a/homeassistant/components/mammotion/coordinator.py b/homeassistant/components/mammotion/coordinator.py index e8f1da44a8c01..334d053c3ba48 100644 --- a/homeassistant/components/mammotion/coordinator.py +++ b/homeassistant/components/mammotion/coordinator.py @@ -14,6 +14,7 @@ from .config import MammotionConfigStore from .const import CONF_ACCOUNTNAME, DOMAIN, LOGGER +from .exceptions import CommandFailedError if TYPE_CHECKING: from . import MammotionConfigEntry @@ -67,9 +68,10 @@ 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) -> bool | None: + async def async_send_command(self, command: str, **kwargs: Any) -> None: """Send command via api.""" - return await self.api.async_send_command(self.device_name, command, **kwargs) + 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): diff --git a/homeassistant/components/mammotion/exceptions.py b/homeassistant/components/mammotion/exceptions.py new file mode 100644 index 0000000000000..78bf6790315b8 --- /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/tests/components/mammotion/test_config_flow.py b/tests/components/mammotion/test_config_flow.py index 7a461b7d63005..9eec8fcdcd6ab 100644 --- a/tests/components/mammotion/test_config_flow.py +++ b/tests/components/mammotion/test_config_flow.py @@ -21,7 +21,6 @@ from tests.common import MockConfigEntry -# Helpers def _get_mock_device(name="Luba-ABC123", address="AA:BB:CC:DD:EE:FF"): device = MagicMock(spec=BLEDevice) device.name = name diff --git a/tests/components/mammotion/test_lawn_mower.py b/tests/components/mammotion/test_lawn_mower.py index d74ba48d2e972..39500ede56513 100644 --- a/tests/components/mammotion/test_lawn_mower.py +++ b/tests/components/mammotion/test_lawn_mower.py @@ -16,7 +16,7 @@ SERVICE_START_MOWING, LawnMowerActivity, ) -from homeassistant.components.mammotion.const import COMMAND_EXCEPTIONS, DOMAIN +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 @@ -171,7 +171,7 @@ async def test_services_command_failure( """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.side_effect = COMMAND_EXCEPTIONS[0]("boom") + mock_mower_api.async_send_command.return_value = False await setup_integration(hass, mock_config_entry) From c36b6f2e346e36eb5a2d8dfdc262de503b427d71 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Sun, 16 Aug 2026 16:44:45 +1200 Subject: [PATCH 60/66] handle transient states which are ok, correctly fail on login failure --- homeassistant/components/mammotion/__init__.py | 3 ++- homeassistant/components/mammotion/coordinator.py | 8 +++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mammotion/__init__.py b/homeassistant/components/mammotion/__init__.py index aa6ca4b8c8c35..634c7665113f5 100644 --- a/homeassistant/components/mammotion/__init__.py +++ b/homeassistant/components/mammotion/__init__.py @@ -7,6 +7,7 @@ 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 from Tea.exceptions import UnretryableException from homeassistant.config_entries import ConfigEntry @@ -64,7 +65,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> await mammotion.login_and_initiate_cloud(account, password, session) except ClientConnectorError as err: raise ConfigEntryNotReady(err) from err - except UnretryableException as err: + except (UnretryableException, LoginFailedError) as err: raise ConfigEntryError(err) from err store_cloud_credentials(hass, entry, mammotion) diff --git a/homeassistant/components/mammotion/coordinator.py b/homeassistant/components/mammotion/coordinator.py index 334d053c3ba48..970f6e28a90cf 100644 --- a/homeassistant/components/mammotion/coordinator.py +++ b/homeassistant/components/mammotion/coordinator.py @@ -4,9 +4,11 @@ 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 homeassistant.const import CONF_PASSWORD from homeassistant.core import HomeAssistant, callback @@ -112,7 +114,11 @@ def restore_data(self) -> None: @override async def _async_update_data(self) -> MowingDevice: """Get data from the device.""" - data = await self.api.update(self.device_name) + try: + data = await self.api.update(self.device_name) + except DeviceOfflineException, NoTransportAvailableError: + return self.data + 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()) From cbddf3b7d1da1b73c48d3ce7ee40367bbfbc7b66 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Sun, 16 Aug 2026 16:52:12 +1200 Subject: [PATCH 61/66] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/mammotion/__init__.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mammotion/__init__.py b/homeassistant/components/mammotion/__init__.py index 634c7665113f5..b361707a84d61 100644 --- a/homeassistant/components/mammotion/__init__.py +++ b/homeassistant/components/mammotion/__init__.py @@ -102,11 +102,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> async def shutdown_mammotion(_: Event | None = None) -> None: await mammotion.stop() + def schedule_shutdown_mammotion() -> None: + hass.async_create_task(shutdown_mammotion()) + + def schedule_flush_mower_data() -> None: + hass.async_create_task(store.async_flush_mower_data()) + entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown_mammotion) ) - entry.async_on_unload(shutdown_mammotion) - entry.async_on_unload(store.async_flush_mower_data) + entry.async_on_unload(schedule_shutdown_mammotion) + entry.async_on_unload(schedule_flush_mower_data) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) From 48447420588b7b139fbeff3d00625788f7948a3a Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Sun, 16 Aug 2026 18:43:38 +1200 Subject: [PATCH 62/66] change write behaviour to when home assistant shuts down / stops or restarts --- .../components/mammotion/__init__.py | 12 +++++++++- homeassistant/components/mammotion/config.py | 24 ++++++------------- .../components/mammotion/config_flow.py | 1 + .../components/mammotion/manifest.json | 2 +- requirements_all.txt | 2 +- tests/components/mammotion/test_init.py | 15 ++++++++---- 6 files changed, 31 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/mammotion/__init__.py b/homeassistant/components/mammotion/__init__.py index b361707a84d61..54b98fe54fa51 100644 --- a/homeassistant/components/mammotion/__init__.py +++ b/homeassistant/components/mammotion/__init__.py @@ -11,7 +11,12 @@ from Tea.exceptions import UnretryableException from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.const import ( + CONF_PASSWORD, + EVENT_HOMEASSISTANT_FINAL_WRITE, + EVENT_HOMEASSISTANT_STOP, + Platform, +) from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -111,6 +116,11 @@ def schedule_flush_mower_data() -> None: 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, flush_mower_data) + ) + entry.async_on_unload(schedule_shutdown_mammotion) entry.async_on_unload(schedule_flush_mower_data) diff --git a/homeassistant/components/mammotion/config.py b/homeassistant/components/mammotion/config.py index 48d1d7118d547..b61004bf91da8 100644 --- a/homeassistant/components/mammotion/config.py +++ b/homeassistant/components/mammotion/config.py @@ -7,7 +7,6 @@ from .const import DOMAIN -SAVE_DELAY = 300 STORAGE_VERSION = 1 STORAGE_MINOR_VERSION = 0 @@ -25,7 +24,7 @@ def __init__(self, hass: HomeAssistant, entry_id: str) -> None: ) # In-memory state of the entry's mowers, keyed by device name self.mower_data: dict[str, Any] = {} - self._save_pending = False + self._dirty = False async def async_load_mower_data(self) -> None: """Load the persisted mower data into memory.""" @@ -33,24 +32,15 @@ async def async_load_mower_data(self) -> None: @callback def async_update_mower_data(self, device_name: str, data: dict[str, Any]) -> None: - """Update a mower in memory, writing to disk at most once per SAVE_DELAY.""" + """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 - # A pending write keeps its own deadline: async_delay_save would push the - # write back on every call and never fire while polling continues. - if self._save_pending: - return - self._save_pending = True - self.async_delay_save(self._data_to_save, SAVE_DELAY) - - def _data_to_save(self) -> dict[str, Any]: - """Return a snapshot to persist; runs in the executor thread.""" - self._save_pending = False - return dict(self.mower_data) + self._dirty = True async def async_flush_mower_data(self) -> None: - """Write queued mower data to disk, cancelling the delayed write.""" - if not self._save_pending: + """Write the in-memory mower data to disk if it changed.""" + if not self._dirty: return - await self.async_save(self._data_to_save()) + 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 index 9d4808fcc2016..5a05efd7be8d4 100644 --- a/homeassistant/components/mammotion/config_flow.py +++ b/homeassistant/components/mammotion/config_flow.py @@ -184,6 +184,7 @@ async def async_step_user( vol.Optional(CONF_ADDRESS): vol.In(self._discovered_devices), }, ), + step_id="user", ) async def _async_validate_login( diff --git a/homeassistant/components/mammotion/manifest.json b/homeassistant/components/mammotion/manifest.json index 30c4054d9343a..6b2a5de12d8e6 100644 --- a/homeassistant/components/mammotion/manifest.json +++ b/homeassistant/components/mammotion/manifest.json @@ -21,5 +21,5 @@ "iot_class": "cloud_polling", "loggers": ["pymammotion"], "quality_scale": "bronze", - "requirements": ["pymammotion==0.8.13"] + "requirements": ["pymammotion==0.8.14"] } diff --git a/requirements_all.txt b/requirements_all.txt index 82f0f2b8e33bb..790b7253544f2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2369,7 +2369,7 @@ pylutron==0.4.2 pymailgunner==1.4 # homeassistant.components.mammotion -pymammotion==0.8.13 +pymammotion==0.8.14 # homeassistant.components.firmata pymata-express==1.19 diff --git a/tests/components/mammotion/test_init.py b/tests/components/mammotion/test_init.py index f3df57330c429..1f7ef80e3c4bb 100644 --- a/tests/components/mammotion/test_init.py +++ b/tests/components/mammotion/test_init.py @@ -1,6 +1,5 @@ """Tests for the Mammotion integration setup.""" -from datetime import timedelta from typing import Any from unittest.mock import MagicMock, Mock @@ -9,9 +8,10 @@ from pymammotion.data.model.device import MowingDevice from Tea.exceptions import UnretryableException -from homeassistant.components.mammotion.config import SAVE_DELAY from homeassistant.components.mammotion.const import DOMAIN +from homeassistant.components.mammotion.coordinator import DEFAULT_INTERVAL from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import EVENT_HOMEASSISTANT_FINAL_WRITE from homeassistant.core import HomeAssistant from . import setup_integration @@ -87,23 +87,28 @@ async def test_state_restored_from_store( handle.restore_device.assert_called_once() -async def test_state_persisted_after_delay( +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 after the save delay.""" + """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(timedelta(seconds=SAVE_DELAY + 1)) + 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"] From c5411c867c11752644b265bc56d7ddb046068241 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Sun, 16 Aug 2026 18:55:00 +1200 Subject: [PATCH 63/66] fix call --- homeassistant/components/mammotion/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mammotion/__init__.py b/homeassistant/components/mammotion/__init__.py index 54b98fe54fa51..c4f5925220844 100644 --- a/homeassistant/components/mammotion/__init__.py +++ b/homeassistant/components/mammotion/__init__.py @@ -110,7 +110,7 @@ async def shutdown_mammotion(_: Event | None = None) -> None: def schedule_shutdown_mammotion() -> None: hass.async_create_task(shutdown_mammotion()) - def schedule_flush_mower_data() -> None: + def schedule_flush_mower_data(_: Event | None = None) -> None: hass.async_create_task(store.async_flush_mower_data()) entry.async_on_unload( @@ -118,7 +118,9 @@ def schedule_flush_mower_data() -> None: ) entry.async_on_unload( - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_FINAL_WRITE, flush_mower_data) + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_FINAL_WRITE, schedule_flush_mower_data + ) ) entry.async_on_unload(schedule_shutdown_mammotion) From dee194cf3c95c34abd5c694e288aa294aa60efcc Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Sun, 16 Aug 2026 20:57:07 +1200 Subject: [PATCH 64/66] fix issue with reload causing two clients to be active --- .../components/mammotion/__init__.py | 36 +++-- homeassistant/components/mammotion/config.py | 5 +- .../components/mammotion/config_flow.py | 103 +++++++++---- .../components/mammotion/strings.json | 11 ++ .../components/mammotion/test_config_flow.py | 135 ++++++++++++++++++ tests/components/mammotion/test_init.py | 57 ++++++++ 6 files changed, 304 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/mammotion/__init__.py b/homeassistant/components/mammotion/__init__.py index c4f5925220844..e28b73d89ef78 100644 --- a/homeassistant/components/mammotion/__init__.py +++ b/homeassistant/components/mammotion/__init__.py @@ -7,7 +7,7 @@ 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 +from pymammotion.transport.base import LoginFailedError, TransportType from Tea.exceptions import UnretryableException from homeassistant.config_entries import ConfigEntry @@ -31,6 +31,7 @@ DEVICE_SUPPORT, DOMAIN, EXPIRED_CREDENTIAL_EXCEPTIONS, + LOGGER, ) from .coordinator import MammotionMowerUpdateCoordinator from .models import MammotionDevices, MammotionMowerData @@ -58,6 +59,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> 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: @@ -107,24 +129,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: MammotionConfigEntry) -> async def shutdown_mammotion(_: Event | None = None) -> None: await mammotion.stop() - def schedule_shutdown_mammotion() -> None: - hass.async_create_task(shutdown_mammotion()) - - def schedule_flush_mower_data(_: Event | None = None) -> None: - hass.async_create_task(store.async_flush_mower_data()) - 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, schedule_flush_mower_data + EVENT_HOMEASSISTANT_FINAL_WRITE, store.async_flush_mower_data ) ) - entry.async_on_unload(schedule_shutdown_mammotion) - entry.async_on_unload(schedule_flush_mower_data) + entry.async_on_unload(shutdown_mammotion) + entry.async_on_unload(store.async_flush_mower_data) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/mammotion/config.py b/homeassistant/components/mammotion/config.py index b61004bf91da8..c68f8559d6dc7 100644 --- a/homeassistant/components/mammotion/config.py +++ b/homeassistant/components/mammotion/config.py @@ -2,7 +2,7 @@ from typing import Any -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers.storage import Store from .const import DOMAIN @@ -22,7 +22,6 @@ def __init__(self, hass: HomeAssistant, entry_id: str) -> None: minor_version=STORAGE_MINOR_VERSION, key=f"{DOMAIN}.{entry_id}", ) - # In-memory state of the entry's mowers, keyed by device name self.mower_data: dict[str, Any] = {} self._dirty = False @@ -38,7 +37,7 @@ def async_update_mower_data(self, device_name: str, data: dict[str, Any]) -> Non self.mower_data[device_name] = data self._dirty = True - async def async_flush_mower_data(self) -> None: + 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 diff --git a/homeassistant/components/mammotion/config_flow.py b/homeassistant/components/mammotion/config_flow.py index 5a05efd7be8d4..cfaf7a270ce2f 100644 --- a/homeassistant/components/mammotion/config_flow.py +++ b/homeassistant/components/mammotion/config_flow.py @@ -1,5 +1,6 @@ """Config flow for Mammotion.""" +from collections.abc import Mapping from typing import Any, override from aiohttp import ClientError @@ -38,43 +39,48 @@ def __init__(self) -> None: self._discovered_devices: dict[str, str] = {} self._discovered_device: BLEDevice | None = None - async def check_and_update_bluetooth_device( + def _find_bluetooth_device( self, device: BLEDevice - ) -> ConfigEntry | None: - """Check if the device is already configured and update ble mac if needed.""" + ) -> tuple[ConfigEntry, dr.DeviceEntry] | None: + """Return the entry and device entry owning this mower, if configured.""" device_registry = dr.async_get(self.hass) - current_entries = self.hass.config_entries.async_entries(DOMAIN) - for entry in current_entries: + for entry in self.hass.config_entries.async_entries(DOMAIN): if not entry.data.get(CONF_ACCOUNT_ID): continue - device_entries = dr.async_entries_for_config_entry( + for device_entry in dr.async_entries_for_config_entry( device_registry, entry.entry_id - ) - - for device_entry in device_entries: + ): identifiers = {device_id[1] for device_id in device_entry.identifiers} if device.name in identifiers: - 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: - device_registry.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 + 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 @@ -109,7 +115,10 @@ async def async_step_bluetooth( self._discovered_device.address ), } - self._abort_if_unique_id_configured(updates={CONF_BLE_DEVICES: ble_devices}) + + self._abort_if_unique_id_configured( + updates={CONF_BLE_DEVICES: ble_devices}, reload_on_update=False + ) return await self.async_step_bluetooth_confirm() @@ -124,11 +133,11 @@ async def async_step_bluetooth_confirm( name = device.name or "" if entry := await self.check_and_update_bluetooth_device(device): existing_devices = { - **entry.data.get(CONF_BLE_DEVICES, {}), name: format_mac(device.address), + **entry.data.get(CONF_BLE_DEVICES, {}), } self._abort_if_unique_id_configured( - updates={CONF_BLE_DEVICES: existing_devices} + updates={CONF_BLE_DEVICES: existing_devices}, reload_on_update=False ) ble_devices: dict[str, str] = {name: format_mac(device.address)} @@ -171,7 +180,8 @@ async def async_step_user( device = bluetooth.async_ble_device_from_address( self.hass, discovery_info.address ) - if device and not await self.check_and_update_bluetooth_device(device): + + if device and self._find_bluetooth_device(device) is None: self._discovered_devices[address] = discovery_info.name if not self._discovered_devices: @@ -242,6 +252,39 @@ async def async_step_wifi( 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: diff --git a/homeassistant/components/mammotion/strings.json b/homeassistant/components/mammotion/strings.json index 9f1ca3ed8d5dd..1f4072a9f14bd 100644 --- a/homeassistant/components/mammotion/strings.json +++ b/homeassistant/components/mammotion/strings.json @@ -9,6 +9,7 @@ "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" }, @@ -22,6 +23,16 @@ "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%]", diff --git a/tests/components/mammotion/test_config_flow.py b/tests/components/mammotion/test_config_flow.py index 9eec8fcdcd6ab..b293a59500e99 100644 --- a/tests/components/mammotion/test_config_flow.py +++ b/tests/components/mammotion/test_config_flow.py @@ -542,3 +542,138 @@ async def test_bluetooth_discovery_skip_no_account_id(hass: HomeAssistant) -> No 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 index 1f7ef80e3c4bb..6888fb94897f7 100644 --- a/tests/components/mammotion/test_init.py +++ b/tests/components/mammotion/test_init.py @@ -1,5 +1,6 @@ """Tests for the Mammotion integration setup.""" +import asyncio from typing import Any from unittest.mock import MagicMock, Mock @@ -166,3 +167,59 @@ async def test_setup_error_on_unretryable_error( 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"] From 1a08a4953b2f3fa190df0cabd1e088fbd9507d7c Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Sun, 16 Aug 2026 21:06:18 +1200 Subject: [PATCH 65/66] shift unload operations to unload entry --- .../components/mammotion/__init__.py | 27 +++---------------- 1 file changed, 4 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/mammotion/__init__.py b/homeassistant/components/mammotion/__init__.py index e28b73d89ef78..1ec238c64d33e 100644 --- a/homeassistant/components/mammotion/__init__.py +++ b/homeassistant/components/mammotion/__init__.py @@ -11,13 +11,8 @@ 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.const import CONF_PASSWORD, Platform +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceEntry @@ -126,22 +121,6 @@ async def unrecoverable_auth_error( 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 - ) - ) - - entry.async_on_unload(shutdown_mammotion) - entry.async_on_unload(store.async_flush_mower_data) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -176,6 +155,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: MammotionConfigEntry) - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): for mower in entry.runtime_data.mowers: mower.coordinator.store_cloud_credentials() + await mower.coordinator.store.async_flush_mower_data() + await mower.coordinator.api.mammotion.stop() with contextlib.suppress(TimeoutError): await mower.api.mammotion.remove_device(mower.name) return unload_ok From fab3fcdca8d35506ced1ab1a0b6f57ae098da1f2 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Sun, 16 Aug 2026 21:26:02 +1200 Subject: [PATCH 66/66] few more minor changes --- .../components/mammotion/__init__.py | 38 ++++++++++++++--- .../components/mammotion/coordinator.py | 4 ++ tests/components/mammotion/test_init.py | 41 ++++++++++++++++++- 3 files changed, 76 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/mammotion/__init__.py b/homeassistant/components/mammotion/__init__.py index 1ec238c64d33e..4f7470ab153da 100644 --- a/homeassistant/components/mammotion/__init__.py +++ b/homeassistant/components/mammotion/__init__.py @@ -11,9 +11,18 @@ from Tea.exceptions import UnretryableException from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, Platform -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +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 @@ -87,7 +96,9 @@ async def unrecoverable_auth_error( await mammotion.login_and_initiate_cloud(account, password, session) except ClientConnectorError as err: raise ConfigEntryNotReady(err) from err - except (UnretryableException, LoginFailedError) as 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) @@ -121,6 +132,19 @@ async def unrecoverable_auth_error( 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 @@ -155,10 +179,12 @@ async def async_unload_entry(hass: HomeAssistant, entry: MammotionConfigEntry) - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): for mower in entry.runtime_data.mowers: mower.coordinator.store_cloud_credentials() - await mower.coordinator.store.async_flush_mower_data() - await mower.coordinator.api.mammotion.stop() 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 diff --git a/homeassistant/components/mammotion/coordinator.py b/homeassistant/components/mammotion/coordinator.py index 970f6e28a90cf..e406a151ba600 100644 --- a/homeassistant/components/mammotion/coordinator.py +++ b/homeassistant/components/mammotion/coordinator.py @@ -9,9 +9,11 @@ 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 @@ -118,6 +120,8 @@ async def _async_update_data(self) -> MowingDevice: 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}") diff --git a/tests/components/mammotion/test_init.py b/tests/components/mammotion/test_init.py index 6888fb94897f7..625fe078b9d26 100644 --- a/tests/components/mammotion/test_init.py +++ b/tests/components/mammotion/test_init.py @@ -7,11 +7,12 @@ 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 ConfigEntryState +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import EVENT_HOMEASSISTANT_FINAL_WRITE from homeassistant.core import HomeAssistant @@ -223,3 +224,41 @@ async def _login(*args: Any, **kwargs: Any) -> None: 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