diff --git a/.claude/skills/run-tests/SKILL.md b/.claude/skills/run-tests/SKILL.md index c05114f..373c760 100644 --- a/.claude/skills/run-tests/SKILL.md +++ b/.claude/skills/run-tests/SKILL.md @@ -98,20 +98,15 @@ for. Do not widen the shared config. ## 5. What the suite does not cover -The suite covers the protocol layer, `custom_components/gree_custom/aiogree/`. -It does not cover the Home Assistant layer: the entities, the coordinator and -the config flow. - -Those need +The suite covers the protocol layer, `custom_components/gree_custom/aiogree/`, +and the config flow, in `tests/config_flow/`. The config flow tests run a real +Home Assistant through [pytest-homeassistant-custom-component](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component), -which extracts Home Assistant's own test plugins for custom integrations. It -needs the `enable_custom_integrations` fixture and `asyncio_mode = auto`, which -`pytest.ini` already sets. It is not a dependency of this repository yet, -because it pulls in all of Home Assistant and CI does not need it for the -protocol tests. - -So a green run does not mean a change is safe. For anything that touches an -entity, a service or the config flow, also run Home Assistant itself against a +against a fake Gree Cloud, a fake MQTT broker and fake units on loopback. + +It does not cover the entities, the coordinator beyond setup and unload, or the +real Gree servers. So a green run does not mean a change is safe. For anything +that touches an entity or a service, also run Home Assistant itself against a device or a fake one, and say in the pull request which one you did. ## 6. Report diff --git a/AGENTS.md b/AGENTS.md index 4c0e91b..358ae6d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,8 +36,8 @@ Code: - Match the style of the code around you. Do not reformat lines you did not need to touch. - The version in `manifest.json` belongs to the maintainers. Do not change it in a normal PR unless asked. -- `tests/` holds a pytest suite for the protocol layer. Run it with `pytest`. A change to `aiogree/` is expected to come with a test. See [docs/development.md](docs/development.md#testing). -- The suite does not cover the Home Assistant entities, the config flow, the cloud or MQTT. For those, test against a real device or a fake one, and say in the PR which one you did. +- `tests/` holds a pytest suite for the protocol layer, and `tests/config_flow/` tests the config flow inside a real Home Assistant, with a fake cloud and a fake MQTT broker. Run both with `pytest`. A change to `aiogree/` or to the config flow is expected to come with a test. See [docs/development.md](docs/development.md#testing). +- The suite does not cover what the entities show, the services or the 4.x migration. For those, test against a real device or a fake one, and say in the PR which one you did. Writing, for everything you produce in this repo: diff --git a/custom_components/gree_custom/__init__.py b/custom_components/gree_custom/__init__.py index 595e2dc..e2d7970 100755 --- a/custom_components/gree_custom/__init__.py +++ b/custom_components/gree_custom/__init__.py @@ -1,6 +1,7 @@ """Gree climate integration init.""" # Standard library imports +from collections.abc import Mapping import json import logging from typing import Any @@ -13,10 +14,8 @@ CONF_HOST, CONF_NAME, CONF_PORT, - CONF_REGION, CONF_SCAN_INTERVAL, CONF_TIMEOUT, - CONF_TOKEN, Platform, ) from homeassistant.core import HomeAssistant @@ -29,7 +28,6 @@ from homeassistant.helpers.typing import ConfigType from .aiogree.cipher import EncryptionVersion -from .aiogree.cloud_api import GreeRegion from .aiogree.device import GreeDevice from .aiogree.errors import GreeConnectionError from .aiogree.transport_mqtt import GreeMqttTransport @@ -67,7 +65,15 @@ ENCRYPTION_VERSION_AUTO, ) from .coordinator import GreeConfigEntry, GreeCoordinator -from .helpers import get_vrf_controller_mac, reconcile_vrf_controllers, try_find_new_ip +from .helpers import ( + async_disconnect_transports, + async_refresh_cloud_token, + create_mqtt_transport, + get_vrf_controller_mac, + is_mqtt_auth_refusal, + reconcile_vrf_controllers, + try_find_new_ip, +) from .migration import ( async_migrate_legacy_registry, async_prepare_legacy_migration, @@ -131,6 +137,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: GreeConfigEntry) -> bool return True cloud_conf = conf.get(CONF_CLOUD) or {} + # The stored token is refreshed at most once per setup + token_refreshed = False device_configs: dict[str, Any] = conf[CONF_DEVICES] coordinators: dict[str, GreeCoordinator] = {} mqtt_transport: GreeMqttTransport | None = None @@ -194,16 +202,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: GreeConfigEntry) -> bool if mac_controller_cloud and cloud_conf and not mqtt_transport: _LOGGER.debug("Creating MQTT transport for %s", cloud_conf[CONF_EMAIL]) - userid: int = cloud_conf.get(CONF_UID, 0) - token: str = cloud_conf.get(CONF_TOKEN, "") - region: str = cloud_conf.get(CONF_REGION, "") - - if not userid or not token or not region: - raise ConfigEntryAuthFailed("no_account_info") - - mqtt_transport = GreeMqttTransport( - user_id=str(userid), token=token, region=GreeRegion(region) - ) + mqtt_transport = create_mqtt_transport(cloud_conf) + if mqtt_transport is None: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="no_account_info" + ) device = GreeDevice( name=name, @@ -212,18 +215,49 @@ async def async_setup_entry(hass: HomeAssistant, entry: GreeConfigEntry) -> bool user_id=uid, ) + local_transport = ( + None if prefer_cloud else local_transports.get(mac_controller_local) + ) + try: - await device.bind_with_transport( - preferred_local_version=preferred_local_version, - local_controller_mac=mac_controller_local, - local_transport=None - if prefer_cloud - else local_transports.get(mac_controller_local), - mqtt_controller_mac=mac_controller_cloud, - mqtt_transport=mqtt_transport, - ) + try: + await device.bind_with_transport( + preferred_local_version=preferred_local_version, + local_controller_mac=mac_controller_local, + local_transport=local_transport, + mqtt_controller_mac=mac_controller_cloud, + mqtt_transport=mqtt_transport, + ) + except MqttConnectError as err: + if token_refreshed or not is_mqtt_auth_refusal(err): + raise + + # The stored token is no longer valid, log in again once + token_refreshed = True + cloud_conf = await _async_refresh_entry_token(hass, entry, cloud_conf) + await async_disconnect_transports([mqtt_transport]) + mqtt_transport = create_mqtt_transport(cloud_conf) + + # The local transport failed already, so only MQTT is retried + await device.bind_with_transport( + preferred_local_version=preferred_local_version, + local_controller_mac=mac_controller_local, + local_transport=None, + mqtt_controller_mac=mac_controller_cloud, + mqtt_transport=mqtt_transport, + ) + except ConfigEntryAuthFailed: + raise except MqttConnectError as err: - raise ConfigEntryAuthFailed("bad_credentials") from err + # The broker's reason, so a user log shows why the login failed + _LOGGER.warning( + "Gree Cloud refused the MQTT connection for entry '%s': %s", + entry.title, + err, + ) + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="bad_credentials" + ) from err except GreeConnectionError: if not await try_find_new_ip(hass, device, entry): _LOGGER.exception("Error setting up device %s", mac) @@ -273,9 +307,57 @@ async def async_setup_entry(hass: HomeAssistant, entry: GreeConfigEntry) -> bool return True +async def _async_refresh_entry_token( + hass: HomeAssistant, entry: GreeConfigEntry, cloud_conf: Mapping[str, Any] +) -> dict[str, Any]: + """Log in with the stored account and store the new token in the entry. + + The token is stored right after the login, because the login made the + old token invalid. Raises ConfigEntryAuthFailed when the login fails. + """ + try: + new_cloud_conf = await async_refresh_cloud_token(cloud_conf, entry.title) + except Exception as err: + _LOGGER.warning( + "Gree Cloud login with the stored account failed for entry '%s': %s", + entry.title, + err, + ) + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="bad_credentials" + ) from err + + # This integration has no update listener, so this does not reload + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_CLOUD: new_cloud_conf} + ) + return new_cloud_conf + + async def async_unload_entry(hass: HomeAssistant, entry: GreeConfigEntry) -> bool: - """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + """Unload a config entry and close its connections. + + Every reload sets up new transports, so the old ones must close here. + The coordinators unbind their devices first. Home Assistant calls their + shutdown again after this, which then does nothing. + """ + if not await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + return False + + # An entry without devices sets no runtime data + coordinators: dict[str, GreeCoordinator] = getattr(entry, "runtime_data", {}) + transports = [c.device.transport for c in coordinators.values()] + + for coordinator in coordinators.values(): + try: + await coordinator.async_shutdown() + except Exception: + _LOGGER.exception( + "Error unbinding device %s", coordinator.device.mac_address + ) + + await async_disconnect_transports(transports) + return True async def async_remove_config_entry_device( diff --git a/custom_components/gree_custom/config_flow.py b/custom_components/gree_custom/config_flow.py index 52f491e..c0f54ce 100755 --- a/custom_components/gree_custom/config_flow.py +++ b/custom_components/gree_custom/config_flow.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any, override from aiomqtt import MqttError +from aiomqtt.exceptions import MqttConnectError if TYPE_CHECKING: import probatio @@ -21,6 +22,7 @@ SOURCE_REAUTH, SOURCE_RECONFIGURE, SOURCE_USER, + ConfigEntryState, ConfigFlow, ConfigFlowResult, ) @@ -29,11 +31,13 @@ CONF_DISCOVERY, CONF_EMAIL, CONF_HOST, + CONF_NAME, CONF_PASSWORD, CONF_PORT, CONF_REGION, CONF_TOKEN, ) +from homeassistant.core import callback from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.storage import Store @@ -91,6 +95,7 @@ CONF_UID, CONFENTRY_ID_LOCAL_ONLY, CURRENT_CONF_VERSION, + DATA_YAML_UNIQUE_IDS, DEFAULT_DEVICE_UID, DEFAULT_DISCOVERY_RETRIES, DEFAULT_DISCOVERY_TIMEOUT, @@ -102,11 +107,16 @@ ) from .coordinator import GreeConfigEntry from .helpers import ( + async_disconnect_transports, + async_refresh_cloud_token, create_discovered_from_config, + create_mqtt_transport, get_config_entries, get_configured_macs_in_entries, + get_device_by_identifier, get_discovery_addresses, get_entry_matching_mac, + is_mqtt_auth_refusal, ) from .migration import async_setup_from_flow @@ -126,6 +136,62 @@ def _matches_cloud_account( ) +# Local settings of a controller. Every device behind it (VRF) uses the same values. +CONTROLLER_LOCAL_KEYS = (CONF_HOST, CONF_PORT, CONF_ENCRYPTION_VERSION) + + +def _bind_settings(connection: Mapping[str, Any]) -> tuple: + """Return the connection settings that decide how a device binds. + + The poll interval, the availability check, the timeout and the retries + do not change the bind, so they are left out. + """ + local = connection.get(CONF_DEVICE_CONNECTION_LOCAL) or {} + cloud = connection.get(CONF_DEVICE_CONNECTION_CLOUD) or {} + + return ( + connection.get(CONF_ENCRYPTION_KEY), + connection.get(CONF_UID), + local.get(CONF_MAC_CONTROLLER_LOCAL), + local.get(CONF_HOST), + local.get(CONF_PORT), + local.get(CONF_ENCRYPTION_VERSION), + cloud.get(CONF_PREFER_CLOUD), + cloud.get(CONF_MAC_CONTROLLER_CLOUD), + ) + + +def _adds_connection(d: GreeDiscoveredDevice, dev_conf: Mapping[str, Any]) -> bool: + """Tell if discovery found a connection that a configured device lacks. + + That is a local host for a device without one, or a cloud controller for + a device without one. + """ + connection = dev_conf.get(CONF_DEVICE_CONNECTION, {}) + local = connection.get(CONF_DEVICE_CONNECTION_LOCAL) or {} + cloud = connection.get(CONF_DEVICE_CONNECTION_CLOUD) or {} + + return bool( + (d.host and not local.get(CONF_HOST)) + or (d.mac_controller_mqtt and not cloud.get(CONF_MAC_CONTROLLER_CLOUD)) + ) + + +def _apply_discovery_info(d: GreeDiscoveredDevice, connection: dict[str, Any]) -> None: + """Put the host and controller MACs that discovery found in a connection.""" + if d.host: + local = dict(connection.get(CONF_DEVICE_CONNECTION_LOCAL) or {}) + local[CONF_HOST] = d.host + if d.mac_controller_local: + local[CONF_MAC_CONTROLLER_LOCAL] = d.mac_controller_local + connection[CONF_DEVICE_CONNECTION_LOCAL] = local + + if d.mac_controller_mqtt: + cloud = dict(connection.get(CONF_DEVICE_CONNECTION_CLOUD) or {}) + cloud[CONF_MAC_CONTROLLER_CLOUD] = d.mac_controller_mqtt + connection[CONF_DEVICE_CONNECTION_CLOUD] = cloud + + class SetupConfigFlow(ConfigFlow, domain=DOMAIN): """Handle the config flow for the integration.""" @@ -152,6 +218,8 @@ def __init__(self) -> None: self._current_setup_device_index = 0 self._mqtt_transport: GreeMqttTransport | None = None + # True after a login in this flow. A fresh token is never refreshed. + self._cloud_token_fresh = False self._local_transports: dict[str, GreeUdpTransport] = {} self._devices: dict[str, GreeDevice] = {} @@ -159,6 +227,9 @@ def __init__(self) -> None: self._options_by_controller: dict[str, Any] = {} self._options_by_model: dict[str, Any] = {} + # Reconfigure: the device configs to write at the end, keyed by MAC + self._pending_devices: dict[str, Any] | None = None + async def async_step_import(self, import_config: dict) -> ConfigFlowResult: """Handle import from configuration.yaml.""" @@ -205,6 +276,10 @@ async def async_step_import(self, import_config: dict) -> ConfigFlowResult: CONF_UID: credentials.user_id, } + # Reconfigure refuses entries the YAML manages. Kept in memory only, + # so an entry is editable again after its YAML block is gone. + self.hass.data.setdefault(DATA_YAML_UNIQUE_IDS, set()).add(unique_id) + await self.async_set_unique_id(unique_id) self._drop_devices_of_other_entries(devices, unique_id) @@ -239,7 +314,9 @@ async def async_step_import(self, import_config: dict) -> ConfigFlowResult: device_registry = dr.async_get(self.hass) for mac in entry.data.get(CONF_DEVICES, {}): if mac not in devices: - dev = device_registry.async_get_device(identifiers={(DOMAIN, mac)}) + dev = get_device_by_identifier( + device_registry, (DOMAIN, mac), entry.entry_id + ) if dev: device_registry.async_remove_device(dev.id) @@ -429,27 +506,119 @@ async def async_step_reauth( async def async_step_reconfigure( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle reconfiguration of an existing entry.""" + """Show the reconfigure menu. Every reconfigure step returns here. + + The changes collect in `_pending_devices`. Only `finish` writes them + to the entry, with one reload. + """ reconfigure_entry = self._get_reconfigure_entry() - reconfigure_data = dict(reconfigure_entry.data) - _LOGGER.debug("Reconfiguring: %s", reconfigure_entry.title) - # If on the local-only entry, exit early and continue with the local method only - if reconfigure_entry.unique_id == CONFENTRY_ID_LOCAL_ONLY: + if self._pending_devices is None: + _LOGGER.debug("Reconfiguring: %s", reconfigure_entry.title) + + # The YAML puts its own values back at the next start + if reconfigure_entry.unique_id in self.hass.data.get( + DATA_YAML_UNIQUE_IDS, set() + ): + return self.async_abort(reason="yaml_managed") + + # Copy deep, so the edits do not change the live entry data + self._pending_devices = copy.deepcopy( + dict(reconfigure_entry.data.get(CONF_DEVICES, {})) + ) + # Keep the account and its token when no step logs in again + if cloud_conf := reconfigure_entry.data.get(CONF_CLOUD): + self._config_data[CONF_CLOUD] = dict(cloud_conf) + + if reconfigure_entry.unique_id == CONFENTRY_ID_LOCAL_ONLY: + await self.async_set_unique_id(CONFENTRY_ID_LOCAL_ONLY) + + menu_options = ["rediscover", "finish"] + if self._pending_devices: + menu_options = ["edit_device", "rediscover", "remove_devices", "finish"] + + return self.async_show_menu( + step_id="reconfigure", + menu_options=menu_options, + description_placeholders={"device_cnt": str(len(self._pending_devices))}, + ) + + def _pending_as_discovered(self) -> dict[str, GreeDiscoveredDevice]: + """Return the pending devices in the shape the device forms use.""" + pending: dict[str, Any] = self._pending_devices or {} + devices: dict[str, GreeDiscoveredDevice] = {} + + for mac, dev_conf in pending.items(): + devices[mac] = create_discovered_from_config(mac, dev_conf) + devices[mac].name = dev_conf.get(CONF_DEVICE_OPTIONS, {}).get(CONF_NAME, "") + + return devices + + async def async_step_edit_device( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Pick one configured device and run its connection and option forms.""" + devices = self._pending_as_discovered() + if not devices: + return await self.async_step_reconfigure() + + if user_input is not None: + self._selected_devices = [devices[user_input[CONF_DEVICES]]] + self._current_setup_device_index = 0 + return await self.async_step_connection_options() + + return self.async_show_form( + step_id="edit_device", + data_schema=setup_picker_schema( + next(iter(devices)), devices, multiple=False + ), + ) + + async def async_step_remove_devices( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Pick configured devices to remove from the entry.""" + devices = self._pending_as_discovered() + + if user_input is not None: + pending: dict[str, Any] = self._pending_devices or {} + for mac in user_input.get(CONF_DEVICES, []): + pending.pop(mac, None) + return await self.async_step_reconfigure() + + return self.async_show_form( + step_id="remove_devices", + data_schema=setup_picker_schema([], devices), + ) + + async def async_step_finish( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Write the reconfigured entry and reload it once.""" + return await self._async_finish() + + async def async_step_rediscover( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Run discovery again for the entry that is reconfigured.""" + self._discovered_devices_cloud = {} + self._discovered_devices_local = {} + self._current_setup_method_index = 0 + + # The local-only entry only has the local method + if self._get_reconfigure_entry().unique_id == CONFENTRY_ID_LOCAL_ONLY: self._selected_setup_methods = ["local"] - self._current_setup_method_index = 0 - await self.async_set_unique_id(CONFENTRY_ID_LOCAL_ONLY) return await self._setup_next_setup_method() # If entry has cloud, ask if user wants to add local # A user cannot remove cloud from an entry, because the entry is keyed by the cloud account # For that, remove the entry and add to the local-only entry + # Cloud discovery needs a login: the stored token only works for MQTT if user_input is not None: self._selected_setup_methods = ["cloud"] if user_input.get("include_local", False): self._selected_setup_methods.append("local") - self._current_setup_method_index = 0 return await self._setup_next_setup_method() # Pre-select the include local option if any devices have local configurations @@ -458,11 +627,11 @@ async def async_step_reconfigure( .get(CONF_DEVICE_CONNECTION_LOCAL, {}) .get(CONF_HOST) is not None - for d in reconfigure_data.get(CONF_DEVICES, {}).values() + for d in (self._pending_devices or {}).values() ) return self.async_show_form( - step_id="reconfigure", + step_id="rediscover", data_schema=probatio.Schema( { probatio.Required( @@ -473,6 +642,57 @@ async def async_step_reconfigure( ), ) + def _store_pending_devices(self, device_configs: dict[str, Any]) -> None: + """Put the configs from the device forms in the pending devices. + + When an edit changes the local settings of a controller, the other + devices behind that controller get the same values. + """ + if self._pending_devices is None: + return + + for mac, dev_conf in device_configs.items(): + previous = self._pending_devices.get(mac) + self._pending_devices[mac] = dev_conf + + if previous is None: + continue + + old_local = ( + previous.get(CONF_DEVICE_CONNECTION, {}).get( + CONF_DEVICE_CONNECTION_LOCAL + ) + or {} + ) + new_local = ( + dev_conf[CONF_DEVICE_CONNECTION].get(CONF_DEVICE_CONNECTION_LOCAL) or {} + ) + controller = new_local.get(CONF_MAC_CONTROLLER_LOCAL) + changed = { + key: new_local[key] + for key in CONTROLLER_LOCAL_KEYS + if key in new_local and new_local[key] != old_local.get(key) + } + if not controller or not changed: + continue + + for other_mac, other_conf in self._pending_devices.items(): + if other_mac == mac or other_mac in device_configs: + continue + + other_local = other_conf.get(CONF_DEVICE_CONNECTION, {}).get( + CONF_DEVICE_CONNECTION_LOCAL + ) + if other_local and ( + other_local.get(CONF_MAC_CONTROLLER_LOCAL) == controller + ): + _LOGGER.debug( + "Copying the controller settings of %s to %s", + mac, + other_mac, + ) + other_local.update(changed) + async def _setup_next_setup_method( self, user_input: dict | None = None ) -> ConfigFlowResult: @@ -512,6 +732,10 @@ async def async_step_cloud_add( ) try: credentials = await self._cloud_api.login() + self._cloud_token_fresh = True + + # Close the connection of an earlier attempt or edit first + await async_disconnect_transports([self._mqtt_transport]) # Also create the transport here so possible errors are shown self._mqtt_transport = GreeMqttTransport( @@ -714,7 +938,7 @@ async def async_step_local_add( async def async_step_device_picker( self, user_input: dict | None = None ) -> ConfigFlowResult: - """Let users pick devices to add, edit or remove.""" + """Let users pick the devices to set up.""" errors: dict[str, str] = {} if user_input is not None: @@ -724,42 +948,208 @@ async def async_step_device_picker( self._selected_devices = [ self._discovered_devices[key] for key in selected ] - + self._current_setup_device_index = 0 return await self.async_step_connection_options() - errors[CONF_DEVICES] = "no_devices_selected" + # Reconfigure: picking nothing adds nothing + if self.source == SOURCE_RECONFIGURE: + return await self.async_step_reconfigure() - selected = list(self._discovered_devices.keys()) + errors[CONF_DEVICES] = "no_devices_selected" - # Pre-fill for reconfigure + # Reconfigure only lists what adds something to the entry if self.source == SOURCE_RECONFIGURE: - configured_devices: dict[str, Any] = self._get_reconfigure_entry().data.get( - CONF_DEVICES, {} - ) - - # Don't select things by default that were not there before - selected = [m for m in selected if m in configured_devices] - - # Add back missing devices that weren't discovered but were in config - for mac, dev_conf in configured_devices.items(): - if mac not in self._discovered_devices: - self._discovered_devices[mac] = create_discovered_from_config( - mac, dev_conf - ) - selected.append(mac) + self._keep_new_rediscovered_devices() + if not self._discovered_devices: + return await self.async_step_no_new_devices() if not self._discovered_devices: return self.async_abort(reason="no_devices_to_add") return self.async_show_form( step_id="device_picker", - data_schema=setup_picker_schema(selected, self._discovered_devices), + data_schema=setup_picker_schema( + list(self._discovered_devices), self._discovered_devices + ), description_placeholders={ "devices_found": str(len(self._discovered_devices)) }, errors=errors, ) + def _keep_new_rediscovered_devices(self) -> None: + """Keep only the rediscovered devices that add something to the entry. + + A device is new when it is not in the pending devices, or when + discovery found a connection it lacks (see `_adds_connection`). The + other configured devices only get their host and controller MACs + refreshed, like the DHCP step, without a form and without a bind. + Rediscover never removes a device. + """ + pending: dict[str, Any] = self._pending_devices or {} + new_devices: dict[str, GreeDiscoveredDevice] = {} + + for mac, d in self._discovered_devices.items(): + dev_conf = pending.get(mac) + if dev_conf is None or _adds_connection(d, dev_conf): + new_devices[mac] = d + else: + _apply_discovery_info( + d, dev_conf.setdefault(CONF_DEVICE_CONNECTION, {}) + ) + + self._discovered_devices = new_devices + + async def async_step_no_new_devices( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Tell that rediscover found nothing new, then go back to the menu. + + An abort would drop the pending changes, and a menu cannot show an + error, so this is a form without fields. + """ + if user_input is not None: + return await self.async_step_reconfigure() + + return self.async_show_form(step_id="no_new_devices") + + def _get_running_device( + self, mac: str, user_input: Mapping[str, Any] + ) -> GreeDevice | None: + """Return the bound device of the loaded entry, when a new bind is not needed. + + Only during reconfigure, and only when the submitted settings bind the + same way as the stored ones. This saves a bind, and for a cloud + device it needs no cloud login. + """ + if self.source != SOURCE_RECONFIGURE: + return None + + entry = self._get_reconfigure_entry() + stored = ( + entry.data.get(CONF_DEVICES, {}).get(mac, {}).get(CONF_DEVICE_CONNECTION) + ) + if not stored or _bind_settings(stored) != _bind_settings(user_input): + return None + + if entry.state is not ConfigEntryState.LOADED: + return None + + coordinator = entry.runtime_data.get(mac) + if coordinator is None or not coordinator.device.is_bound: + return None + + _LOGGER.debug("Device %s is unchanged, using its running bind", mac) + return coordinator.device + + async def _bind_device( + self, + d: GreeDiscoveredDevice, + user_input: Mapping[str, Any], + mac_local_controller: str, + mac_mqtt_controller: str, + ) -> GreeDevice: + """Bind a device with the submitted connection settings.""" + local = user_input.get(CONF_DEVICE_CONNECTION_LOCAL, {}) + cloud = user_input.get(CONF_DEVICE_CONNECTION_CLOUD, {}) + device = GreeDevice( + name=d.name, + mac_addr=d.mac, + preferred_encryption_key=user_input.get(CONF_ENCRYPTION_KEY, d.key), + user_id=user_input.get(CONF_UID, d.user_id), + ) + + encryption_version_value = local.get( + CONF_ENCRYPTION_VERSION, DEFAULT_ENCRYPTION_VERSION + ) + + ip = local.get(CONF_HOST, "") + port = local.get(CONF_PORT, "") + + # Only a device with a local host and controller can bind locally, + # the same rule as the entry setup + local_transport: GreeUdpTransport | None = None + if ip and mac_local_controller: + local_transport = self._local_transports.get( + mac_local_controller + ) or GreeUdpTransport(ip_addr=ip, port=port) + + # Reconfigure without a cloud login uses the stored token + if ( + self.source == SOURCE_RECONFIGURE + and mac_mqtt_controller + and not self._mqtt_transport + ): + self._mqtt_transport = create_mqtt_transport( + self._config_data.get(CONF_CLOUD) or {} + ) + + preferred_local_version = ( + None + if encryption_version_value == ENCRYPTION_VERSION_AUTO + else EncryptionVersion(int(encryption_version_value)) + ) + + try: + await device.bind_with_transport( + preferred_local_version=preferred_local_version, + local_controller_mac=mac_local_controller, + local_transport=( + local_transport + if not cloud.get(CONF_PREFER_CLOUD, DEFAULT_PREFER_CLOUD) + else None + ), + mqtt_controller_mac=mac_mqtt_controller, + mqtt_transport=self._mqtt_transport, + ) + except MqttConnectError as err: + if ( + self.source != SOURCE_RECONFIGURE + or self._cloud_token_fresh + or not is_mqtt_auth_refusal(err) + ): + raise + + # The stored token is no longer valid, log in again once + try: + await self._async_refresh_stored_token() + except Exception as refresh_err: + _LOGGER.warning( + "Gree Cloud login with the stored account failed: %s", + refresh_err, + ) + raise err from refresh_err + + # The local transport failed already, so only MQTT is retried + await device.bind_with_transport( + preferred_local_version=preferred_local_version, + local_controller_mac=mac_local_controller, + local_transport=None, + mqtt_controller_mac=mac_mqtt_controller, + mqtt_transport=self._mqtt_transport, + ) + + if local_transport: + self._local_transports[mac_local_controller] = local_transport + + return device + + async def _async_refresh_stored_token(self) -> None: + """Log in with the stored account and use the new token from now on. + + The new token goes in the flow data, so finish writes it to the entry. + The flow MQTT transport is rebuilt with it. + """ + self._cloud_token_fresh = True + cloud_conf = await async_refresh_cloud_token( + self._config_data.get(CONF_CLOUD) or {}, + self._get_reconfigure_entry().title, + ) + self._config_data[CONF_CLOUD] = cloud_conf + + await async_disconnect_transports([self._mqtt_transport]) + self._mqtt_transport = create_mqtt_transport(cloud_conf) + async def async_step_connection_options( # noqa: C901 self, user_input: dict | None = None ) -> ConfigFlowResult: @@ -770,12 +1160,6 @@ async def async_step_connection_options( # noqa: C901 if user_input is not None: local = user_input.get(CONF_DEVICE_CONNECTION_LOCAL, {}) cloud = user_input.get(CONF_DEVICE_CONNECTION_CLOUD, {}) - device = GreeDevice( - name=d.name, - mac_addr=d.mac, - preferred_encryption_key=user_input.get(CONF_ENCRYPTION_KEY, d.key), - user_id=user_input.get(CONF_UID, d.user_id), - ) # Ensure the device can bind try: @@ -785,35 +1169,13 @@ async def async_step_connection_options( # noqa: C901 mac_mqtt_controller = cloud.get( CONF_MAC_CONTROLLER_CLOUD, d.mac_controller_mqtt ) - encryption_version_value = local.get( - CONF_ENCRYPTION_VERSION, DEFAULT_ENCRYPTION_VERSION - ) - - ip = local.get(CONF_HOST, "") - port = local.get(CONF_PORT, "") - - local_transport = self._local_transports.get( - mac_local_controller, GreeUdpTransport(ip_addr=ip, port=port) - ) - - await device.bind_with_transport( - preferred_local_version=( - None - if encryption_version_value == ENCRYPTION_VERSION_AUTO - else EncryptionVersion(int(encryption_version_value)) - ), - local_controller_mac=mac_local_controller, - local_transport=( - local_transport - if not cloud.get(CONF_PREFER_CLOUD, DEFAULT_PREFER_CLOUD) - else None - ), - mqtt_controller_mac=mac_mqtt_controller, - mqtt_transport=self._mqtt_transport, - ) - if mac_local_controller: - self._local_transports[mac_local_controller] = local_transport + # An unchanged device during reconfigure keeps its running bind + device = self._get_running_device(d.mac, user_input) + if device is None: + device = await self._bind_device( + d, user_input, mac_local_controller, mac_mqtt_controller + ) # Save the correct version if local succeeded if ( @@ -865,14 +1227,17 @@ async def async_step_connection_options( # noqa: C901 .get(CONF_DEVICE_CONNECTION, None) ) - # During reconfigure inject previous connection options + # During reconfigure inject previous connection options, plus what + # rediscover found, so the user only confirms a new connection if not default and self.source == SOURCE_RECONFIGURE: - default = ( - self._get_reconfigure_entry() - .data.get(CONF_DEVICES, {}) + pending_connection = ( + (self._pending_devices or {}) .get(d.mac, {}) .get(CONF_DEVICE_CONNECTION, None) ) + if pending_connection: + default = copy.deepcopy(pending_connection) + _apply_discovery_info(d, default) defaults = ( default @@ -911,6 +1276,12 @@ async def async_step_device_options( if self._current_setup_device_index >= len(self._selected_devices) - 1: self._current_setup_device_index = 0 + + # Reconfigure collects the changes and writes them on finish + if self.source == SOURCE_RECONFIGURE: + self._store_pending_devices(self._collect_device_configs()) + return await self.async_step_reconfigure() + return await self._async_finish() self._current_setup_device_index += 1 @@ -943,8 +1314,7 @@ async def async_step_device_options( # During reconfigure inject previous device options if not default and self.source == SOURCE_RECONFIGURE: default = ( - self._get_reconfigure_entry() - .data.get(CONF_DEVICES, {}) + (self._pending_devices or {}) .get(d.mac, {}) .get(CONF_DEVICE_OPTIONS, {}) ) @@ -969,9 +1339,26 @@ async def async_step_device_options( errors=errors, ) + def _collect_device_configs(self) -> dict[str, Any]: + """Return the configs of the devices that went through the device forms.""" + device_configs: dict[str, Any] = {} + for d in self._selected_devices: + mac = str(self._devices[d.mac].mac_address) + device_configs[mac] = { + CONF_DEVICE_CONNECTION: self._config_data[CONF_ALL_DEVICE_CONNECTIONS][ + d.mac + ], + CONF_DEVICE_OPTIONS: self._config_data[CONF_ALL_DEVICE_OPTIONS][d.mac], + } + + return device_configs + async def _async_finish(self) -> ConfigFlowResult: # noqa: C901 """Create or update the entry.""" + # Close the flow connections before the entry opens its own + await self._async_close_transports() + if self.source == SOURCE_REAUTH: cloud_conf: dict[str, str] = self._config_data.get(CONF_CLOUD, {}) return self.async_update_reload_and_abort( @@ -982,15 +1369,12 @@ async def _async_finish(self) -> ConfigFlowResult: # noqa: C901 device_registry = dr.async_get(self.hass) - device_configs: dict[str, Any] = {} - for d in self._selected_devices: - mac = str(self._devices[d.mac].mac_address) - device_configs[mac] = { - CONF_DEVICE_CONNECTION: self._config_data[CONF_ALL_DEVICE_CONNECTIONS][ - d.mac - ], - CONF_DEVICE_OPTIONS: self._config_data[CONF_ALL_DEVICE_OPTIONS][d.mac], - } + # Reconfigure writes the devices it collected in the menu steps + device_configs: dict[str, Any] = ( + dict(self._pending_devices) + if self.source == SOURCE_RECONFIGURE and self._pending_devices is not None + else self._collect_device_configs() + ) local_entry = next( iter( @@ -1013,8 +1397,8 @@ async def _async_finish(self) -> ConfigFlowResult: # noqa: C901 moved_any = True # remove from registry, it will be added by the new entry - dev = device_registry.async_get_device( - identifiers={(DOMAIN, mac)} + dev = get_device_by_identifier( + device_registry, (DOMAIN, mac), local_entry.entry_id ) if dev: device_registry.async_remove_device(dev.id) @@ -1084,7 +1468,9 @@ async def _async_finish(self) -> ConfigFlowResult: # noqa: C901 ) for m in previous_configured: if self.source == SOURCE_RECONFIGURE and m not in device_configs: - dev = device_registry.async_get_device(identifiers={(DOMAIN, m)}) + dev = get_device_by_identifier( + device_registry, (DOMAIN, m), update_entry.entry_id + ) if dev: device_registry.async_remove_device(dev.id) @@ -1109,3 +1495,27 @@ async def _async_finish(self) -> ConfigFlowResult: # noqa: C901 title=title, data=data, ) + + async def _async_close_transports(self) -> None: + """Disconnect the transports this flow opened. + + The devices bound in the flow are throwaway, so they are not unbound + first. A running device from `_get_running_device()` uses the entry + transports, which are never in these fields. + """ + transports = [self._mqtt_transport, *self._local_transports.values()] + self._mqtt_transport = None + self._local_transports = {} + + await async_disconnect_transports(transports) + + @callback + @override + def async_remove(self) -> None: + """Close the flow transports when the flow ends or is closed. + + Home Assistant calls this when a step creates an entry or aborts, + and when the user closes the flow. + """ + if self._mqtt_transport or self._local_transports: + self.hass.async_create_task(self._async_close_transports()) diff --git a/custom_components/gree_custom/config_schema.py b/custom_components/gree_custom/config_schema.py index eb36e25..1e94646 100644 --- a/custom_components/gree_custom/config_schema.py +++ b/custom_components/gree_custom/config_schema.py @@ -461,21 +461,25 @@ def setup_local_schema(default_values: dict | None = None) -> probatio.Schema: def setup_picker_schema( - default: list[str], options: dict[str, GreeDiscoveredDevice] + default: list[str] | str, + options: dict[str, GreeDiscoveredDevice], + multiple: bool = True, ) -> probatio.Schema: - """Build the form that lets the user pick which devices to set up.""" + """Build the form that lets the user pick devices. + + With `multiple` set to False it shows a radio list to pick one device. + """ + config = SelectSelectorConfig( + options=[ + SelectOptionDict(value=m, label=d.friendly_name) for m, d in options.items() + ], + multiple=multiple, + ) + if not multiple: + config["mode"] = SelectSelectorMode.LIST + return probatio.Schema( - { - probatio.Required(CONF_DEVICES, default=default): SelectSelector( - SelectSelectorConfig( - options=[ - SelectOptionDict(value=m, label=d.friendly_name) - for m, d in options.items() - ], - multiple=True, - ) - ) - } + {probatio.Required(CONF_DEVICES, default=default): SelectSelector(config)} ) diff --git a/custom_components/gree_custom/const.py b/custom_components/gree_custom/const.py index 7e3c089..a1c19cd 100755 --- a/custom_components/gree_custom/const.py +++ b/custom_components/gree_custom/const.py @@ -21,6 +21,9 @@ CURRENT_CONF_VERSION = 3 CONFENTRY_ID_LOCAL_ONLY = "local_only" + +# hass.data key: unique_ids of the entries that the YAML import handled since the start +DATA_YAML_UNIQUE_IDS = f"{DOMAIN}_yaml_unique_ids" CONF_EXTRA_SCAN_NETWORKS = "extra_scan_networks" CONF_EXTRA_SCAN_HOSTS = "extra_scan_hosts" CONF_DISCOVERY_PREFS_KEY = DOMAIN + "_discovery_prefs" diff --git a/custom_components/gree_custom/coordinator.py b/custom_components/gree_custom/coordinator.py index 524b781..79e480f 100755 --- a/custom_components/gree_custom/coordinator.py +++ b/custom_components/gree_custom/coordinator.py @@ -13,6 +13,7 @@ from .aiogree.api import OperationMode from .aiogree.device import GreeDevice from .aiogree.errors import GreeBindingError, GreeConnectionError +from .const import DOMAIN from .helpers import try_find_new_ip _LOGGER = logging.getLogger(__name__) @@ -58,6 +59,7 @@ def __init__( self._feature_auto_xfan: bool = False self._feature_auto_light: bool = False self._unsub_follow_up_refresh: CALLBACK_TYPE | None = None + self._is_shut_down: bool = False async def _setup(self) -> None: """Bind to the device before the first coordinator refresh. @@ -76,13 +78,23 @@ def _device_pushed_status(self, status: dict[str, str]) -> None: @override async def async_shutdown(self) -> None: - """Clean up the coordinator and Gree device resources.""" + """Clean up the coordinator and Gree device resources. + + Entry unload calls this before it closes the transports, and Home + Assistant calls it again from the entry's unload callbacks. Only the + first call does the work. + """ + if self._is_shut_down: + return + self._is_shut_down = True + self._cancel_follow_up_refresh() self.device.api_client.remove_status_listener(self._device_pushed_status) - await self.device.unbind_device() - - await super().async_shutdown() + try: + await self.device.unbind_device() + finally: + await super().async_shutdown() async def _update_data(self) -> None: """Update the device with he latest state. @@ -108,7 +120,11 @@ async def _update_data(self) -> None: except GreeBindingError as err: _LOGGER.exception("Failed to initiate Gree device") - raise ConfigEntryAuthFailed("Failed to initiate Gree device") from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="device_bind_failed", + translation_placeholders={"mac": self.device.mac_address}, + ) from err except Exception as err: _LOGGER.exception("Error getting state from device") diff --git a/custom_components/gree_custom/helpers.py b/custom_components/gree_custom/helpers.py index c18de13..7720c5a 100755 --- a/custom_components/gree_custom/helpers.py +++ b/custom_components/gree_custom/helpers.py @@ -1,20 +1,33 @@ """Helpers for the Gree integration.""" -from collections.abc import Mapping +from collections.abc import Iterable, Mapping from ipaddress import IPv4Address, IPv4Network, ip_address, ip_network import logging from typing import Any +from aiomqtt.exceptions import MqttConnectError + from homeassistant.components import network from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.const import ( + CONF_EMAIL, + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_REGION, + CONF_TOKEN, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import UNDEFINED, UndefinedType from .aiogree.api import GreeDiscoveredDevice, gree_discover_devices +from .aiogree.cloud_api import GreeCloudApi, GreeRegion from .aiogree.device import GreeDevice +from .aiogree.errors import GreeCloudLoginError +from .aiogree.transport import GreeBaseTransport +from .aiogree.transport_mqtt import GreeMqttTransport from .aiogree.transport_udp import GreeUdpTransport from .const import ( CONF_DEVICE_CONNECTION, @@ -40,6 +53,11 @@ _LOGGER = logging.getLogger(__name__) +# The broker refuses a token with "bad user name or password" or "not +# authorized". paho 2 turns the MQTT 3.1.1 return codes 4 and 5 into the +# MQTT 5 reason codes 134 and 135. An older aiomqtt passes the 3.1.1 code. +MQTT_AUTH_REFUSED_CODES = frozenset({4, 5, 134, 135}) + async def _get_hass_broadcast_addr(hass: HomeAssistant) -> list[str]: """Return the broadcast addresses from HA.""" @@ -220,6 +238,93 @@ async def try_find_new_ip( return True +def create_mqtt_transport( + cloud_conf: Mapping[str, Any], +) -> GreeMqttTransport | None: + """Build the MQTT transport from a stored cloud block, without a login. + + Returns None when the block misses the user id, the token or the region. + The transport connects on its first request. + """ + user_id: int = cloud_conf.get(CONF_UID, 0) + token: str = cloud_conf.get(CONF_TOKEN, "") + region: str = cloud_conf.get(CONF_REGION, "") + + if not user_id or not token or not region: + return None + + return GreeMqttTransport( + user_id=str(user_id), token=token, region=GreeRegion(region) + ) + + +def is_mqtt_auth_refusal(err: BaseException) -> bool: + """Tell if the broker refused the connection because of the token.""" + if not isinstance(err, MqttConnectError): + return False + + # A paho ReasonCode has its number in value, an older aiomqtt gives an int + code = getattr(err.rc, "value", err.rc) + return code in MQTT_AUTH_REFUSED_CODES + + +async def async_refresh_cloud_token( + cloud_conf: Mapping[str, Any], name: str +) -> dict[str, Any]: + """Log in again with the stored account and return the block with the new token. + + Used when the broker refuses the stored token. The login ends the other + sessions of the account, so the Gree app logs out. Raises the error of + the login, or GreeCloudLoginError when the login returns another account. + """ + cloud_api = GreeCloudApi( + region=GreeRegion(cloud_conf[CONF_REGION]), + username=cloud_conf[CONF_EMAIL], + password=cloud_conf[CONF_PASSWORD], + ) + try: + credentials = await cloud_api.login() + finally: + await cloud_api.close() + + stored_uid = cloud_conf.get(CONF_UID) + if stored_uid and str(stored_uid) != str(credentials.user_id): + raise GreeCloudLoginError("The login returned another Gree account") + + _LOGGER.warning( + "Gree Cloud refused the stored token of '%s'. The integration logged in " + "again and stored a new token. This logs out the Gree app if it uses " + "the same account", + name, + ) + + return { + **cloud_conf, + CONF_TOKEN: credentials.token, + CONF_UID: credentials.user_id, + } + + +async def async_disconnect_transports( + transports: Iterable[GreeBaseTransport | None], +) -> None: + """Disconnect every distinct transport once. + + An error is logged and does not stop the other transports from closing. + """ + closed: list[GreeBaseTransport] = [] + + for transport in transports: + if transport is None or transport in closed: + continue + + closed.append(transport) + try: + await transport.disconnect() + except Exception: + _LOGGER.exception("Error disconnecting %s", transport) + + def create_discovered_from_config(mac: str, conf: dict) -> GreeDiscoveredDevice: """Return a GreeDiscoveredDevice based on config data.""" conn = conf.get(CONF_DEVICE_CONNECTION, {}) @@ -365,6 +470,28 @@ def get_vrf_sub_units(device_configs: Mapping[str, Any]) -> dict[str, set[str]]: return sub_units +def get_device_by_identifier( + device_registry: dr.DeviceRegistry, + identifier: tuple[str, str], + config_entry_id: str, +) -> dr.DeviceEntry | None: + """Return the device with the identifier that the config entry owns. + + HA 2026.8 added `async_get_device_by_identifier`, and `async_get_device` + stops working in 2027.8. Older versions only have `async_get_device`, so + use it there and check the owner. + """ + if hasattr(device_registry, "async_get_device_by_identifier"): + return device_registry.async_get_device_by_identifier( + identifier, config_entry_id + ) + + device = device_registry.async_get_device(identifiers={identifier}) + if device is None or config_entry_id not in device.config_entries: + return None + return device + + def get_vrf_controller_mac(device_entry: dr.DeviceEntry) -> str | None: """Return the controller mac if the device is a VRF controller device.""" return next( diff --git a/custom_components/gree_custom/manifest.json b/custom_components/gree_custom/manifest.json index 7466a46..a31530d 100755 --- a/custom_components/gree_custom/manifest.json +++ b/custom_components/gree_custom/manifest.json @@ -32,5 +32,5 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/RobHofmann/HomeAssistant-GreeClimateComponent/issues", "requirements": ["asyncio_dgram", "aiomqtt", "pydantic"], - "version": "5.0.0-alpha.107" + "version": "5.0.0-alpha.108" } diff --git a/custom_components/gree_custom/migration.py b/custom_components/gree_custom/migration.py index 80c7406..a0ceae2 100644 --- a/custom_components/gree_custom/migration.py +++ b/custom_components/gree_custom/migration.py @@ -91,6 +91,7 @@ DEFAULT_TARGET_TEMP_STEP, DOMAIN, ) +from .helpers import get_device_by_identifier _LOGGER = logging.getLogger(__name__) @@ -891,7 +892,7 @@ async def async_migrate_legacy_registry( # moving a device removes the entities that still belong to the old # entry. legacy_device, target_device_id = _find_devices( - dev_reg, legacy_entry, mac, mac_controller + dev_reg, entry, legacy_entry, mac, mac_controller ) rows = _rows_to_move(hass, ent_reg, legacy_entry, mac, mac_controller) _async_record_move(hass, legacy_entry, rows, legacy_device) @@ -914,12 +915,13 @@ async def async_migrate_legacy_registry( def _find_devices( dev_reg: dr.DeviceRegistry, + entry: ConfigEntry, legacy_entry: ConfigEntry, mac: str, mac_controller: str, ) -> tuple[dr.DeviceEntry | None, str | None]: """Find the 4.x device row to move, and the device ID the entities link to.""" - if target := dev_reg.async_get_device(identifiers={(DOMAIN, mac)}): + if target := get_device_by_identifier(dev_reg, (DOMAIN, mac), entry.entry_id): return None, target.id # A VRF controller held the sub-devices under one row in 4.x. Leave it, and @@ -927,11 +929,10 @@ def _find_devices( if mac != mac_controller: return None, None - legacy_device = dev_reg.async_get_device(identifiers={(LEGACY_DOMAIN, mac)}) - if ( - legacy_device is None - or legacy_entry.entry_id not in legacy_device.config_entries - ): + legacy_device = get_device_by_identifier( + dev_reg, (LEGACY_DOMAIN, mac), legacy_entry.entry_id + ) + if legacy_device is None: return None, None return legacy_device, legacy_device.id diff --git a/custom_components/gree_custom/translations/en.json b/custom_components/gree_custom/translations/en.json index c172cf1..fc72095 100644 --- a/custom_components/gree_custom/translations/en.json +++ b/custom_components/gree_custom/translations/en.json @@ -11,7 +11,8 @@ "cloud_bad_login": "Login failed. Ensure the details are correct and try again.", "cloud_unknown": "Error during login. Please check the logs and contact the developers", "no_devices_selected": "Please select at least one device to continue", - "no_methods_selected": "Please select at least one discovery method" + "no_methods_selected": "Please select at least one discovery method", + "cannot_connect_mqtt": "Cannot connect to the Gree cloud (MQTT). Check the account details. If the error stays, check the logs for the reason the server gave." }, "abort": { "reconfigure_successful": "Devices reconfigured with success.", @@ -19,7 +20,10 @@ "unique_id_mismatch": "The entry being configured does not match the configuration intent.", "no_devices_to_add": "No new devices discovered. Adjust your discovery options and try again.", "import_failed": "The YAML configuration could not be imported. See the repair issue and the log for the reason.", - "legacy_migration_started": "Your Gree 4.x devices are being moved to this integration. They show up in a few seconds, with the same entity IDs." + "legacy_migration_started": "Your Gree 4.x devices are being moved to this integration. They show up in a few seconds, with the same entity IDs.", + "yaml_managed": "This entry is managed in configuration.yaml. Change the YAML and restart Home Assistant. Changes made here would be replaced at the next start.", + "reauth_successful": "The Gree account is logged in again.", + "already_in_progress": "A setup for this Gree account is already in progress." }, "step": { "user": { @@ -52,14 +56,14 @@ }, "device_picker": { "title": "Discovered Devices", - "description": "Found {devices_found} Gree device(s). Select the devices you want to configure.\n\nCaution: If you are reconfiguring devices, unselected devices will be removed.", + "description": "Found {devices_found} Gree device(s). Select the devices you want to configure.\n\nWhen you look for devices again, this list only shows new devices and devices with a new local or cloud connection. Devices you do not select stay as they are. To remove a device, use Remove devices in the menu.", "data": { "devices": "Devices" } }, "connection_options": { "title": "Connection Options {device_idx} of {device_cnt}", - "description": "Configure the connection options that will use for the device:\n\n{device_name}", + "description": "Configure the connection options that will use for the device:\n\n{device_name}\n\nDevices behind the same controller (VRF or zone controller) share the IP address, port and encryption version. When you change these during reconfigure, the other devices of that controller get the same values.", "data": { "disable_available_check": "Disable Available Check", "encryption_key": "Encryption Key", @@ -123,10 +127,38 @@ }, "reconfigure": { "title": "Reconfigure Devices", - "description": "You are reconfiguring a entry with a cloud account. Do you want to look for local devices that match your cloud devices?", + "description": "This entry has {device_cnt} device(s). Pick what to do. Your changes are saved when you choose Save and reload.", + "menu_options": { + "edit_device": "Edit a device", + "rediscover": "Look for devices again", + "remove_devices": "Remove devices", + "finish": "Save and reload" + } + }, + "rediscover": { + "title": "Look for Devices Again", + "description": "This logs in to your Gree account to find its devices. The login logs the Gree app out.\n\nYou are reconfiguring a entry with a cloud account. Do you want to look for local devices that match your cloud devices?", "data": { "include_local": "Include local devices" } + }, + "edit_device": { + "title": "Edit a Device", + "description": "Pick the device to edit. If you do not change how it connects, the integration uses the running connection and does not log in to the Gree account.", + "data": { + "devices": "Device" + } + }, + "no_new_devices": { + "title": "No New Devices", + "description": "No new devices or new connections were found. The IP addresses of the devices that were found are updated.\n\nSubmit to go back to the menu. Your changes are saved when you choose Save and reload." + }, + "remove_devices": { + "title": "Remove Devices", + "description": "Pick the devices to remove from this entry.", + "data": { + "devices": "Devices" + } } } }, @@ -371,6 +403,15 @@ }, "remove_vrf_controller": { "message": "This is a VRF controller. Delete its sub-units first, and the controller is deleted automatically." + }, + "bad_credentials": { + "message": "The Gree cloud refused the stored login, and a new login with the stored account details failed. Log in again." + }, + "no_account_info": { + "message": "The Gree account details of this entry are missing. Log in again." + }, + "device_bind_failed": { + "message": "Cannot bind to the Gree device {mac}. Check the connection and the encryption key." } }, "issues": { diff --git a/custom_components/gree_custom/translations/nl.json b/custom_components/gree_custom/translations/nl.json index 7b7262a..0df9f60 100644 --- a/custom_components/gree_custom/translations/nl.json +++ b/custom_components/gree_custom/translations/nl.json @@ -11,7 +11,8 @@ "cloud_bad_login": "Inloggen mislukt. Controleer de gegevens en probeer het opnieuw.", "cloud_unknown": "Fout tijdens het inloggen. Controleer de logs en neem contact op met de ontwikkelaars", "no_devices_selected": "Selecteer minimaal één apparaat om verder te gaan", - "no_methods_selected": "Selecteer minimaal één ontdekkingsmethode" + "no_methods_selected": "Selecteer minimaal één ontdekkingsmethode", + "cannot_connect_mqtt": "Kan geen verbinding maken met de Gree-cloud (MQTT). Controleer de accountgegevens. Blijft de fout, kijk dan in de logs voor de reden die de server gaf." }, "abort": { "reconfigure_successful": "Apparaten zijn opnieuw geconfigureerd.", @@ -19,7 +20,10 @@ "unique_id_mismatch": "De entry die wordt geconfigureerd komt niet overeen met de bedoelde configuratie.", "no_devices_to_add": "Geen nieuwe apparaten ontdekt. Pas de ontdekkingsopties aan en probeer het opnieuw.", "import_failed": "De YAML-configuratie kon niet worden geïmporteerd. Zie het herstelprobleem en de log voor de reden.", - "legacy_migration_started": "Je Gree 4.x-apparaten worden naar deze integratie verhuisd. Ze verschijnen over een paar seconden, met dezelfde entity-ID's." + "legacy_migration_started": "Je Gree 4.x-apparaten worden naar deze integratie verhuisd. Ze verschijnen over een paar seconden, met dezelfde entity-ID's.", + "yaml_managed": "Deze entry wordt beheerd in configuration.yaml. Pas de YAML aan en herstart Home Assistant. Wijzigingen die je hier maakt, worden bij de volgende start overschreven.", + "reauth_successful": "Het Gree-account is opnieuw ingelogd.", + "already_in_progress": "Er loopt al een configuratie voor dit Gree-account." }, "step": { "user": { @@ -52,14 +56,14 @@ }, "device_picker": { "title": "Ontdekte apparaten", - "description": "{devices_found} Gree-apparaat/apparaten gevonden. Selecteer de apparaten die je wilt configureren.\n\nLet op: als je apparaten opnieuw configureert, worden niet-geselecteerde apparaten verwijderd.", + "description": "{devices_found} Gree-apparaat/apparaten gevonden. Selecteer de apparaten die je wilt configureren.\n\nAls je opnieuw naar apparaten zoekt, toont deze lijst alleen nieuwe apparaten en apparaten met een nieuwe lokale of cloudverbinding. Apparaten die je niet selecteert, blijven zoals ze zijn. Een apparaat verwijder je met Apparaten verwijderen in het menu.", "data": { "devices": "Apparaten" } }, "connection_options": { "title": "Verbindingsopties {device_idx} van {device_cnt}", - "description": "Stel de verbindingsopties in voor het apparaat:\n\n{device_name}", + "description": "Stel de verbindingsopties in voor het apparaat:\n\n{device_name}\n\nApparaten achter dezelfde controller (VRF of zonecontroller) delen het IP-adres, de poort en de encryptieversie. Als je deze wijzigt tijdens het opnieuw configureren, krijgen de andere apparaten van die controller dezelfde waarden.", "data": { "disable_available_check": "Beschikbaarheidscontrole uitschakelen", "encryption_key": "Encryptiesleutel", @@ -123,10 +127,38 @@ }, "reconfigure": { "title": "Apparaten opnieuw configureren", - "description": "Je configureert een entry met een cloudaccount opnieuw. Wil je zoeken naar lokale apparaten die overeenkomen met je cloudapparaten?", + "description": "Deze entry heeft {device_cnt} apparaat/apparaten. Kies wat je wilt doen. Je wijzigingen worden opgeslagen als je Opslaan en herladen kiest.", + "menu_options": { + "edit_device": "Een apparaat bewerken", + "rediscover": "Opnieuw naar apparaten zoeken", + "remove_devices": "Apparaten verwijderen", + "finish": "Opslaan en herladen" + } + }, + "rediscover": { + "title": "Opnieuw naar apparaten zoeken", + "description": "Hiervoor logt de integratie in op je Gree-account om de apparaten te vinden. Door die login wordt de Gree-app uitgelogd.\n\nJe configureert een entry met een cloudaccount opnieuw. Wil je zoeken naar lokale apparaten die overeenkomen met je cloudapparaten?", "data": { "include_local": "Lokale apparaten meenemen" } + }, + "edit_device": { + "title": "Een apparaat bewerken", + "description": "Kies het apparaat dat je wilt bewerken. Als je niets aan de verbinding verandert, gebruikt de integratie de lopende verbinding en logt ze niet in op het Gree-account.", + "data": { + "devices": "Apparaat" + } + }, + "no_new_devices": { + "title": "Geen nieuwe apparaten", + "description": "Er zijn geen nieuwe apparaten of nieuwe verbindingen gevonden. De IP-adressen van de gevonden apparaten zijn bijgewerkt.\n\nVerzend om terug te gaan naar het menu. Je wijzigingen worden opgeslagen als je Opslaan en herladen kiest." + }, + "remove_devices": { + "title": "Apparaten verwijderen", + "description": "Kies de apparaten die je uit deze entry wilt verwijderen.", + "data": { + "devices": "Apparaten" + } } } }, @@ -371,6 +403,15 @@ }, "remove_vrf_controller": { "message": "Dit is een VRF-controller. Verwijder eerst de sub-units, dan wordt de controller automatisch verwijderd." + }, + "bad_credentials": { + "message": "De Gree-cloud weigerde de opgeslagen login, en een nieuwe login met de opgeslagen accountgegevens mislukte. Log opnieuw in." + }, + "no_account_info": { + "message": "De Gree-accountgegevens van deze entry ontbreken. Log opnieuw in." + }, + "device_bind_failed": { + "message": "Kan niet binden met het Gree-apparaat {mac}. Controleer de verbinding en de encryptiesleutel." } }, "issues": { diff --git a/custom_components/gree_custom/translations/pt.json b/custom_components/gree_custom/translations/pt.json index e52c956..d537219 100644 --- a/custom_components/gree_custom/translations/pt.json +++ b/custom_components/gree_custom/translations/pt.json @@ -11,7 +11,8 @@ "cloud_bad_login": "Falha no login. \nCertifique-se de que os detalhes estejam corretos e tente novamente.", "cloud_unknown": "Erro durante o login. \nVerifique os registos e entre em contato com os desenvolvedores", "no_devices_selected": "Selecione pelo menos um dispositivo para continuar", - "no_methods_selected": "Selecione pelo menos um método de procura" + "no_methods_selected": "Selecione pelo menos um método de procura", + "cannot_connect_mqtt": "Não foi possível ligar à cloud da Gree (MQTT). Verifique os dados da conta. Se o erro persistir, verifique nos registos o motivo indicado pelo servidor." }, "abort": { "reconfigure_successful": "Dispositivos reconfigurados com sucesso", @@ -19,7 +20,10 @@ "unique_id_mismatch": "A entrada a ser configurada não corresponde o tipo de configuração.", "no_devices_to_add": "Não foram encontrados novos dispositivos. Ajuste as opções de procura e tente de novo.", "import_failed": "A configuração YAML não pôde ser importada. Consulte as correções sugeridas e o log para saber o motivo.", - "legacy_migration_started": "Os seus dispositivos Gree da versão 4.x estão a ser migrados para a nova versão. Eles irão aparecer dentro de segundos, com os mesmos IDs de entidade." + "legacy_migration_started": "Os seus dispositivos Gree da versão 4.x estão a ser migrados para a nova versão. Eles irão aparecer dentro de segundos, com os mesmos IDs de entidade.", + "yaml_managed": "Esta entrada é gerida no configuration.yaml. Altere o YAML e reinicie o Home Assistant. As alterações feitas aqui seriam substituídas no próximo arranque.", + "reauth_successful": "A conta Gree tem sessão iniciada novamente.", + "already_in_progress": "Já está em curso uma configuração para esta conta Gree." }, "step": { "user": { @@ -52,14 +56,14 @@ }, "device_picker": { "title": "Dispositivos encontrados", - "description": "Foram encontrado(s) {devices_found} dispositivo(s) Gree.\nSelecione os dispositivos que deseja configurar.\n\nAtenção: Se estiver a reconfigurar dispositivos, os dispositivos não selecionados serão removidos.", + "description": "Foram encontrado(s) {devices_found} dispositivo(s) Gree.\nSelecione os dispositivos que deseja configurar.\n\nAo procurar dispositivos novamente, esta lista só mostra dispositivos novos e dispositivos com uma nova ligação local ou na nuvem. Os dispositivos que não selecionar ficam como estão. Para remover um dispositivo, use Remover dispositivos no menu.", "data": { "devices": "Dispositivos" } }, "connection_options": { "title": "Opções de Ligação {device_idx} de {device_cnt}", - "description": "Configure as opções de ligação que serão utilizadas para o dispositivo:\n\n{device_name}", + "description": "Configure as opções de ligação que serão utilizadas para o dispositivo:\n\n{device_name}\n\nOs dispositivos atrás do mesmo controlador (VRF ou controlador de zonas) partilham o endereço IP, a porta e a versão de encriptação. Se os alterar durante a reconfiguração, os outros dispositivos desse controlador recebem os mesmos valores.", "data": { "disable_available_check": "Desativar Verificação de Disponibilidade", "encryption_key": "Chave de Encriptação", @@ -123,10 +127,38 @@ }, "reconfigure": { "title": "Reconfigurar Dispositivos", - "description": "Está a reconfigurar uma entrada com uma conta Gree. \nDeseja procurar dispositivos locais que correspondam aos seus dispositivos na conta?", + "description": "Esta entrada tem {device_cnt} dispositivo(s). Escolha o que pretende fazer. As alterações são guardadas quando escolher Guardar e recarregar.", + "menu_options": { + "edit_device": "Editar um dispositivo", + "rediscover": "Procurar dispositivos novamente", + "remove_devices": "Remover dispositivos", + "finish": "Guardar e recarregar" + } + }, + "rediscover": { + "title": "Procurar Dispositivos Novamente", + "description": "Isto inicia sessão na sua conta Gree para encontrar os dispositivos. Esse início de sessão termina a sessão da aplicação Gree.\n\nEstá a reconfigurar uma entrada com uma conta Gree. \nDeseja procurar dispositivos locais que correspondam aos seus dispositivos na conta?", "data": { "include_local": "Incluir dispositivos locais" } + }, + "edit_device": { + "title": "Editar um Dispositivo", + "description": "Escolha o dispositivo a editar. Se não alterar a forma de ligação, a integração usa a ligação atual e não inicia sessão na conta Gree.", + "data": { + "devices": "Dispositivo" + } + }, + "no_new_devices": { + "title": "Sem Dispositivos Novos", + "description": "Não foram encontrados dispositivos novos nem ligações novas. Os endereços IP dos dispositivos encontrados foram atualizados.\n\nSubmeta para voltar ao menu. As alterações são guardadas quando escolher Guardar e recarregar." + }, + "remove_devices": { + "title": "Remover Dispositivos", + "description": "Escolha os dispositivos a remover desta entrada.", + "data": { + "devices": "Dispositivos" + } } } }, @@ -291,6 +323,15 @@ }, "remove_vrf_controller": { "message": "Este é um controlador VRF. Elimine primeiro as respetivas subunidades e o controlador será eliminado automaticamente." + }, + "bad_credentials": { + "message": "A cloud da Gree recusou o início de sessão guardado e um novo início de sessão com os dados da conta guardados falhou. Inicie sessão novamente." + }, + "no_account_info": { + "message": "Faltam os dados da conta Gree desta entrada. Inicie sessão novamente." + }, + "device_bind_failed": { + "message": "Não foi possível associar ao dispositivo Gree {mac}. Verifique a ligação e a chave de encriptação." } }, "selector": { diff --git a/docs/architecture.md b/docs/architecture.md index 7498585..b9d7c13 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -27,7 +27,7 @@ No Home Assistant imports here. |---|---| | `__init__.py` | Entry setup. Builds transports and devices, binds them, starts one `GreeCoordinator` per device. Creates and links the VRF controller devices, see [VRF controller device](#vrf-controller-device). | | `coordinator.py` | `GreeCoordinator`. Polls on `scan_interval` and listens for status pushed by the device. Polls again every 2 s after a command while the device has not confirmed it, until the hold ends. | -| `config_flow.py` | Setup, reconfigure, reauth and YAML import flows. Local discovery, cloud login, device picker, per device options. `async_step_import` turns one validated YAML item into a config entry. | +| `config_flow.py` | Setup, reconfigure, reauth and YAML import flows. Local discovery, cloud login, device picker, per device options. Reconfigure is a menu over the same steps, see [config-entry.md](config-entry.md#reconfigure). `async_step_import` turns one validated YAML item into a config entry. | | `config_schema.py` | `CONFIG_SCHEMA` for the `gree_custom:` block in `configuration.yaml`. Validates the YAML, normalizes the MAC addresses and fills the defaults. It also holds the form schemas that the config flow shows. | | `migration.py` | Moves a 4.x setup (domain `gree`) to this integration: config entries, the `gree:` YAML block, and the device and entity registry rows. See [config-entry.md](config-entry.md#migration-from-4x). | | `climate.py`, `switch.py`, `sensor.py`, `binary_sensor.py`, `number.py`, `select.py` | Entity platforms. | diff --git a/docs/config-entry.md b/docs/config-entry.md index fa03c50..6c10df8 100644 --- a/docs/config-entry.md +++ b/docs/config-entry.md @@ -63,6 +63,7 @@ An entry can also come from `configuration.yaml`. The parts: - `async_step_import` in `config_flow.py` resolves the target entry. Without a `cloud` block that is the local-only entry (`unique_id` `local_only`). With a `cloud` block it first looks for an entry that already stores the same email, region and password. If it finds one, it reuses that `unique_id` and the stored `uid` and `token`, so there is no cloud login on every restart. Only when there is no such entry does it log in and use the returned user id as `unique_id`. A login matters: Gree allows one session per account, so every login logs the Gree app out. It happens on the first import and after a change of email, region or password. A changed email still lands on the same entry, because the user id from the login is the `unique_id`. - The step then creates the entry, or updates the existing one with `async_update_reload_and_abort`. Unchanged YAML changes nothing and causes no reload. Changed YAML updates the entry and reloads it once, after startup. A device that is already in another entry is skipped, with an error in the log and a repair issue (`yaml_import_failed`). - Devices that are in the entry but not in the YAML are removed, together with their device registry rows. +- The step adds the `unique_id` to the set in `hass.data[DATA_YAML_UNIQUE_IDS]`, also when it aborts after the `unique_id` is known. Reconfigure refuses an entry in that set with `yaml_managed`. `entry.source` cannot tell this, because an import that updates a UI entry keeps the source `user`. The set lives in memory only, so after the YAML block is removed and Home Assistant restarts, the entry can be reconfigured again. The YAML wins at every start, so values that change after the import are not preserved: @@ -70,6 +71,38 @@ The YAML wins at every start, so values that change after the import are not pre - The import does not bind the device, so it stores the `encryption_key` and `encryption_version` as written. With a blank key and version `"0"`, entry setup fetches the key and detects the version on every start. That is the normal path. - Changes made in the UI to a YAML managed entry are replaced by the YAML values at the next start. +## Reconfigure + +`async_step_reconfigure` in `config_flow.py` shows a menu, and every reconfigure step returns to it. The flow copies the entry devices deep into `_pending_devices` (MAC to `connection` and `options`). The menu steps change that copy, and only `finish` writes it with `_async_finish`, which reloads the entry once. + +- `edit_device` runs the normal `connection_options` and `device_options` steps for one device. When the submitted settings that decide the bind (`_bind_settings()`: key, uid, controller MACs, host, port, encryption version, `prefer_cloud`) equal the stored ones, and the entry is loaded with that device bound, the flow uses the running `GreeDevice` from `entry.runtime_data` and does not bind. Otherwise it binds. A cloud bind uses a `GreeMqttTransport` built from the stored `token`, `uid` and `region` with `create_mqtt_transport()` in `helpers.py`, the same helper entry setup uses. There is no cloud login, unless the broker refuses the stored token. Then `_bind_device()` refreshes the token once, see [Token refresh](#token-refresh). +- `rediscover` runs `cloud_add` (with a login, because the cloud device list needs one), `local_add` and `device_picker`, as before. In reconfigure, `device_picker` first calls `_keep_new_rediscovered_devices()`. That keeps a device when it is not in `_pending_devices`, or when `_adds_connection()` is true: discovery found a host for a device without `local.host`, or a cloud controller for a device without `cloud.mac_controller_cloud`. The kept devices are all preselected. Every picked device goes through `connection_options` and `device_options`. For a configured device the forms start from the pending config, and `_apply_discovery_info()` fills in the found host and controller MACs. The other configured devices that discovery found get the found `local.host`, `local.mac_controller_local` and `cloud.mac_controller_cloud` from `_apply_discovery_info()` without a form, like the DHCP step. Rediscover never removes a device. When nothing is kept, the flow shows `no_new_devices`, a form without fields that returns to the menu, because an abort would drop the pending changes. Picking no device also returns to the menu. +- `remove_devices` removes the picked devices from the copy. +- When an edit changes `host`, `port` or `encryption_version` of a device with a `mac_controller_local`, the other devices with the same controller MAC get the same values. +- `_async_finish` removes the device registry rows of removed devices and moves local-only devices found in the cloud to the account entry, as before. + +Reauth does not use the menu. It runs `cloud_add` and updates only the `cloud` block. + +## Connection lifetime + +Entry setup creates its own `GreeMqttTransport` and one `GreeUdpTransport` per local controller. A transport is never shared between entries, or between an entry and a config flow. + +- `async_unload_entry` unloads the platforms, then shuts down every coordinator, which unbinds its device. Then it disconnects every distinct transport of the coordinators with `async_disconnect_transports()` in `helpers.py`. The last unbind on a transport normally disconnects it already. The explicit disconnect also covers an unbind that fails, for example after the broker dropped the connection. +- Home Assistant runs the coordinator shutdown again from the unload callbacks of the entry. `GreeCoordinator.async_shutdown()` only does its work on the first call. +- The config flow keeps its own transports in `_mqtt_transport` and `_local_transports`. `cloud_add` closes the earlier MQTT transport before it opens a new one. `_async_finish` closes all flow transports before it writes the entry. `async_remove()` closes them when the flow aborts or the user closes it. Home Assistant calls `async_remove()` for every flow that ends, see `_async_remove_flow_progress` in `data_entry_flow.py`. +- When the broker refuses the connection at entry setup (`MqttConnectError`), setup first tries one [token refresh](#token-refresh) if the reason is about the token. When that is not possible or does not help, setup logs the reason at warning level and raises `ConfigEntryAuthFailed`, which starts reauth. + +### Token refresh + +Sometimes the broker refuses a stored token with "Bad user name or password". Why Gree ends a token is not known. Seen so far: a token can stop working soon after the login that issued it, and the cloud may rate-limit logins. A cloud login ends the Gree app session, but an MQTT connection with an existing token does not. `is_mqtt_auth_refusal()` in `helpers.py` decides if an `MqttConnectError` is about the token. The transport uses MQTT 3.1.1, but paho 2 turns the 3.1.1 return codes 4 and 5 into the MQTT 5 reason codes 134 and 135 before aiomqtt puts them in `rc`. The helper accepts all four. Any other connect error keeps the old behaviour. + +`async_refresh_cloud_token()` in `helpers.py` logs in with the stored `email`, `password` and `region` through `GreeCloudApi.login()`, and returns the cloud block with the new `token` and `uid`. It refuses a login that returns another `uid`. It logs a warning, because the login logs the Gree app out. Two callers use it, each at most once: + +- `async_setup_entry` in `__init__.py`. On an auth refusal it calls `_async_refresh_entry_token()`, which stores the new block with `async_update_entry` right after the login. The login already made the old token invalid, so keeping it is never right. The integration has no update listener, and `async_update_entry` only saves and notifies (`_async_save_and_notify` in `config_entries.py`), so this does not reload. Setup then disconnects the old MQTT transport, builds a new one, and binds the device again over MQTT only, because its local transport already failed. Devices later in the setup use the new transport. A failed login, or a second refusal, raises `ConfigEntryAuthFailed` as before. +- `_bind_device()` in `config_flow.py`, during reconfigure, when this flow did not log in yet (`_cloud_token_fresh`). `_async_refresh_stored_token()` puts the new block in `_config_data[CONF_CLOUD]`, so `_async_finish` writes it, and replaces the flow MQTT transport. When the login fails, or the retry fails, the form shows `cannot_connect_mqtt`. + +A YAML import copies the stored cloud block of the matching entry, so a refreshed token survives the next start. + ## Migration from 4.x Releases 4.x used the domain `gree` and a flat entry: one device per entry, all fields at the top level. `async_migrate_entry` cannot help here, because it only works inside one domain. `migration.py` moves a 4.x setup over in two phases instead. diff --git a/docs/configuration.md b/docs/configuration.md index 355c6b1..9b58456 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -22,9 +22,13 @@ Shown when you picked the cloud method. | Email, Password | Your Gree account. | | Region | The region of your account. The integration uses it to pick the right Gree server. | -The login returns a session token. The integration stores the token in the config entry and uses it every time the entry loads. A restart or a reload does not log in again. Only when the stored token stops working does the entry ask for [reauthentication](#reauthentication), which logs in once more and stores the new token. +The login returns a session token. The integration stores the token in the config entry and uses it every time the entry loads. A restart or a reload does not log in again. -This matters because Gree allows one session per account. Every login ends the other sessions, so the Gree app on your phone logs out at that moment. A YAML entry works the same way: a matching entry keeps its token, and a changed email, region or password triggers a new login. +When the Gree server refuses the stored token, the integration logs in once with the stored email, password and region. It stores the new token and goes on. This happens when the entry loads, and when **Edit a device** binds a cloud device. The log then has a warning that starts with `Gree Cloud refused the stored token`. Only when that login fails too, or the server also refuses the new token, does the entry ask for [reauthentication](#reauthentication). + +This matters because a cloud login by the integration logs the Gree app on your phone out. Connecting with the stored token does not log the app out. Why Gree sometimes stops accepting a stored token is not known: it can happen soon after a login, and the cloud may also limit how often you can log in. Every time the integration has to log in again, the app logs out. To avoid this, use a separate Gree account for Home Assistant, if your devices can be shared with a second account. See [The Gree app logs me out](troubleshooting.md#common-situations). + +A YAML entry works the same way: a matching entry keeps its token, also a token that the integration refreshed, and a changed email, region or password triggers a new login. In [reconfigure](#reconfigure), only **Look for devices again** logs in, unless the server refuses the stored token. ### Local discovery @@ -45,8 +49,7 @@ The values you enter are remembered and offered again in the next setup flow. The list holds every device that was found and is not configured yet. Pick the ones to add. -> [!WARNING] -> When you are reconfiguring, the devices you do not pick are removed from the entry. +When you look for devices again during [reconfigure](#reconfigure), the list only holds new devices and devices with a new connection. All of them are selected. A device you do not select stays as it is. With a cloud account, the list holds the devices of the account. It shows whether each one was also found on the local network. @@ -100,13 +103,28 @@ The external sensors only change what the climate entity shows. The unit keeps u ## Reconfigure -Open the entry under **Settings** > **Devices & Services**, click the three dots and choose **Reconfigure**. The flow runs again with your current values filled in. Saving reloads the entry, so the change works right away without a restart. +Open the entry under **Settings** > **Devices & Services**, click the three dots and choose **Reconfigure**. A menu opens. You can use its items as often as you like, in any order. Nothing is saved until you choose **Save and reload**. That writes all changes at once and reloads the entry one time, so the changes work right away without a restart. If you close the flow before that, nothing changes. + +| Menu item | What it does | +|---|---| +| Edit a device | Pick one device. Its [connection options](#connection-options) and [device features](#device-features) open with the current values filled in. No discovery runs. | +| Look for devices again | Runs discovery again, as in the first setup. The list only shows what is new: devices that are not set up yet, and devices that discovery found on a new connection. An example is a cloud device that now also answers on the local network. The devices you pick go through the device pages, with the found IP address and controller MACs filled in. The other devices keep their settings, and only their IP address and controller MACs are updated. Nothing is removed. When nothing is new, a page tells you so and you go back to the menu. | +| Remove devices | Pick the devices to remove from the entry. | +| Save and reload | Writes the changes and reloads the entry. | + +**Edit a device** does not log in to the Gree account, so the Gree app stays logged in. The one exception is a stored token that the server refuses: then it logs in once, see [Gree account](#gree-account). When you keep the connection settings that decide how the device binds (key, user ID, IP address, port, encryption version, controller MACs and Prefer Cloud Connection), the integration uses the running connection of the device and does not bind again. When you change one of them, the integration binds again. For a cloud device it uses the stored session for that. + +**Look for devices again** on an account entry first asks whether to also look for local devices. Then it asks for the account details and logs in, because the cloud device list needs a fresh login. That login logs the Gree app out. On the **Local-only Devices** entry it starts at local discovery and does not log in. + +To give a cloud device a local connection, add its IP address under **Extra Hosts** and choose **Look for devices again** with local devices included. The device shows up in the list, and its connection page has the found IP address filled in. + +The flow closes the connections it opened when it ends, also when you close it. A reload closes the old connections of the entry before it opens new ones. + +The account itself cannot be removed from an account entry. To make a device local-only, remove it from the account entry and add it again with the local method. -- On the **Local-only Devices** entry the flow starts at local discovery. -- On an account entry the flow first asks whether to also look for local devices. The account itself cannot be removed from the entry. To make a device local-only, remove it from the account entry and add it again with the local method. +VRF units and zone controllers: you edit every indoor unit on its own. The IP address, port and encryption version belong to the controller, so all units behind one controller share them. When you change one of these for one unit, the other units of that controller get the same value. -> [!WARNING] -> Devices you do not pick in the device list are removed from the entry. +An entry that is set up in `configuration.yaml` cannot be reconfigured. The flow stops with a message, because the YAML would put its own values back at the next start. Change the YAML instead. After you remove the YAML block and restart, the entry can be reconfigured again. ## Removing a device @@ -123,7 +141,7 @@ Both need the device to be on a network that Home Assistant can reach with a bro ## Reauthentication -A cloud account entry asks for a new login when the stored session stops working. Click **Reconfigure** on the repair, or open the entry and choose **Reauthenticate**, and enter the account details again. +A cloud account entry asks for a new login when the stored session stops working and a login with the stored account details fails too, for example after a password change. Click **Reconfigure** on the repair, or open the entry and choose **Reauthenticate**, and enter the account details again. ## YAML configuration @@ -147,7 +165,7 @@ How the YAML is applied: - Home Assistant reads the YAML at every start. It creates the config entry when it is missing and updates it when the YAML changed. An unchanged YAML causes no reload. - Every item in the list is one config entry. An item with a `cloud` block is the entry for that Gree account. The one item without a `cloud` block is the entry for all local-only devices. - A device you remove from the YAML is removed from the entry, together with its device registry rows. -- Do not change a YAML managed entry in the UI. The next restart puts the YAML values back. That includes the IP address. An IP found by discovery is replaced by the `host` in the YAML at the next start. Keep the YAML current, or give the unit a fixed IP. +- Do not change a YAML managed entry in the UI. **Reconfigure** refuses it. The next restart puts the YAML values back. That includes the IP address. An IP found by discovery is replaced by the `host` in the YAML at the next start. Keep the YAML current, or give the unit a fixed IP. - A device that is already in another entry is skipped, with an error in the log and a repair issue. - A `cloud` block logs in to the Gree account only on the first import, and again when you change the email, region or password. An unchanged `cloud` block reuses the stored session. Every login logs the Gree app out. diff --git a/docs/development.md b/docs/development.md index 44e9d36..ab3f53e 100644 --- a/docs/development.md +++ b/docs/development.md @@ -17,18 +17,38 @@ Use the devcontainer. `CONTRIBUTING.md` has the steps. Inside it, the VS Code ta ## Testing -`tests/` holds a pytest suite for the protocol layer. Run it from the repo -root: +`tests/` holds a pytest suite for the protocol layer, and `tests/config_flow/` +holds tests for the config flow that run inside a real Home Assistant. One +`pytest` run does both. Run it from the repo root, on Linux: ```bash pip install -r requirements_dev.txt pytest ``` -It takes about 30 seconds. `pytest -n auto` needs `pytest-xdist` and cuts that -to about 8 seconds. There is also a `run-tests` skill for coding agents in -`.claude/skills/`, which runs the suite and the three linters and says what to -report. +It takes about 65 seconds: about 45 for the protocol tests and about 20 for +the config flow. The first install takes a few minutes, because +`requirements_dev.txt` pins `pytest-homeassistant-custom-component`, which +brings one exact Home Assistant release (0.13.371 is Home Assistant 2026.10.0). +That plugin also pins pytest, pytest-asyncio and pytest-timeout. Bump it on +purpose, in its own PR. + +Without a local Python 3.14, run it in a throwaway container: + +```bash +docker run --rm -v "$PWD:/src" -w /src python:3.14-slim sh -c \ + "python -m venv /venv && /venv/bin/pip install -q -r requirements_dev.txt && /venv/bin/pytest -q" +``` + +Only the config flow tests: + +```bash +pytest -q tests/config_flow +``` + +`pytest -n auto` needs `pytest-xdist`. There is also a `run-tests` skill for +coding agents in `.claude/skills/`, which runs the suite and the three linters +and says what to report. ### What it covers @@ -46,8 +66,14 @@ report. | `test_device_state.py` | Reads, pending values, held values and their TTL, what counts as supported, pruning | | `test_device_api_client.py` | Bind, the column probe, diagnostic sweeps and the canary, listeners, one bind shared by the sub-units of a controller | | `test_device.py` | The poll cycle and the rules between features | +| `config_flow/test_setup.py` | The user flow with cloud and LAN, closing it after the login, the YAML import and why it refuses reconfigure, reauth after the broker refuses the token | +| `config_flow/test_reconfigure.py` | The reconfigure menu: finish at once, rediscover with and without new units, edit with and without a new bind, remove, a local-only entry, a VRF host change, closing the flow after a login | +| `config_flow/test_registry_lookup.py` | `get_device_by_identifier` on a registry with and without `async_get_device_by_identifier` | -254 tests, about 94 percent of `aiogree/`. Check the number of the day with: +255 protocol tests, about 94 percent of `aiogree/`, and 16 config flow tests. +One config flow test is a strict `xfail`: it shows a known defect and starts to +fail as soon as the defect is fixed, so the marker gets removed with the fix. +Check the coverage of the day with: ```bash pytest --cov=aiogree --cov-report=term-missing @@ -57,17 +83,53 @@ That needs `pytest-cov`, which is not a dependency of the suite. ### What is not covered yet -The Home Assistant layer: the entities, the coordinator, the config flow and -the services. Those need -[pytest-homeassistant-custom-component](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component), -which extracts Home Assistant's own test plugins for custom integrations. It -needs the `enable_custom_integrations` fixture and `asyncio_mode = auto`, which -`pytest.ini` already sets. It is not a dependency yet because it pulls in all of -Home Assistant, which CI does not need for the protocol tests. The devcontainer -already has Home Assistant, so that is where this work starts. +The entities, the poll cycle of the coordinator, the services, the DHCP step +and the migration from 4.x. The config flow tests set entries up and unload +them, so the entities are created, but no test checks what they show. -So a green suite does not mean a change is safe. It means the protocol layer -still works. Say in the PR what else you tested. +So a green suite does not mean a change is safe. Say in the PR what else you +tested. + +### The config flow tests + +They use +[pytest-homeassistant-custom-component](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component), +which extracts Home Assistant's own test plugins: the `hass` fixture, the flow +manager, the config entries and the registries. The component is loaded from +`custom_components/` through the `enable_custom_integrations` fixture. +`tests/config_flow/conftest.py` says per piece what is real: + +- The Gree Cloud is `FakeGreeCloud` on real HTTP. Only the server address of + the region is changed, so the login and the device list run the real client. +- MQTT is `FakeMqttBroker`, in place of `aiomqtt.Client`. It hands each request + to a `FakeGreeDevice`, so a bind over the cloud runs the real protocol. It can + refuse the login, which is how the reauth test starts. +- LAN units are `FakeGreeDevice` and `FakeVrfGateway` on real UDP, on port 7000 + of their own address in `127.0.0.0/8`. The flow finds them through its extra + hosts field, which is the real unicast scan. +- The broadcast is switched off, because the test host has no LAN and a + broadcast would leave the machine. `helpers._get_hass_broadcast_addr` is the + only function of the component that is replaced. +- The flow forms are filled the way the frontend fills them: every field + starts at its default or suggested value, and a test names only what it + changes (`submit()` in the conftest). + +Every test checks resource hygiene. It counts the cloud logins, and it checks +that the only MQTT clients and UDP sockets still open belong to a loaded +entry. When a test ends, the `gree` fixture unloads every entry, and then +nothing may stay open. + +The plugin applies Home Assistant's test rules to the whole run, also to the +protocol tests: it blocks sockets, fails a test that leaves a task or a timer +behind, and runs the event loop in debug mode. The `loopback_sockets` fixture +in `tests/conftest.py` allows sockets on `127.0.0.0/8` again, and nowhere else, +so a test that tries to reach a real Gree server still fails. + +Code in a test file runs with Home Assistant's strictest deprecation rules, +because it is not an integration. A deprecated call such as +`device_registry.async_get_device` raises there, while the component itself +only logs it. Use calls that exist in both 2026.3 and the pinned release, for +example `dr.async_entries_for_config_entry`. ### How the fakes work @@ -87,7 +149,8 @@ none of that. `FakeGreeDevice` without a socket, for the paths UDP does not have. - `tests/fakes/mqtt.py` has `FakeMqttClient`. This is the one place where the wire is replaced, because MQTT needs a broker. Everything above the broker is - still the shipped code. + still the shipped code. `FakeMqttBroker` in the same file answers each + request with a `FakeGreeDevice`, and can refuse the login. A new failure mode is a new keyword on `FakeGreeDevice`, not a new class. The ones that exist are `answer_scan`, `answer_bind`, `scan_delay`, `reply_delay`, @@ -189,8 +252,10 @@ Ruff is pinned to one exact version, in `lint.yml` and in `requirements_dev.txt` `validate.yaml` runs three jobs on every push and PR: - **Hassfest** and **HACS** validation. Neither imports or compiles the Python. -- **Tests**: `pytest` on Python 3.14, on an Ubuntu runner. This one does import - the protocol layer, so a syntax error or a broken import fails the build. +- **Tests**: `pytest` on Python 3.14, on an Ubuntu runner. It runs the protocol + tests and the config flow tests, so a syntax error or a broken import in + the component fails the build. The install of Home Assistant takes a few + minutes of the job. CI does not run pylint or mypy. They need the Home Assistant core checkout in `.ha-core`, which only the devcontainer has. Run both yourself before you push. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 146c497..1cc8b71 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -41,7 +41,7 @@ The integration raises repair issues under **Settings** > **System** > **Repairs | Update your Gree configuration from 4.x | Devices still come from the 4.x `gree:` block, or from 4.x UI entries while YAML manages the local devices. They work. | Comment out the `gree:` block, paste the block from the issue into `configuration.yaml` and restart. See [Legacy gree: block](configuration.md#legacy-gree-block). | | Remove the old Gree 4.x files | The folder `custom_components/gree` from 4.x is still installed. The old 4.x entries are disabled. | Delete the folder and restart. The old entries stay disabled, so you can go back to 4.x. | | Remove the old Gree 4.x config entries | Your devices run on this integration, and the 4.x folder is gone. The old 4.x entries are only kept, disabled, so you can go back to 4.x without setting it up again. | Select **Submit** in the issue to remove them. After that, going back to 4.x means setting it up again. See [Going back to 4.x](installation.md#going-back-to-4x). | -| Reauthentication required | The device refused the key exchange, or the cloud session is no longer valid. | For a cloud account, log in again. For a local device, check the key. See [encryption-key.md](encryption-key.md). | +| Reauthentication required | The device refused the key exchange, or the cloud session is no longer valid and a login with the stored account details failed too. | For a cloud account, log in again. Check the password first, if you changed it. The log line `Gree Cloud refused the MQTT connection` shows the reason the broker gave. For a local device, check the key. See [encryption-key.md](encryption-key.md). | ## Messages in the setup flow @@ -63,13 +63,17 @@ The integration raises repair issues under **Settings** > **System** > **Repairs **The unit ignores Turbo or Quiet.** The unit does that while Power Save or Smart Heat 8°C is on. Turn those off first. -**A feature I expect is missing.** The device did not answer the property behind it at setup, or the feature is not enabled. Reconfigure the entry and check **Device Features and Modes**. Use the `get_prop_values` action to see what the device answers. See [actions.md](actions.md). If the device answers the property but the integration has no entity for it, open an issue. We may need to add it. +**A feature I expect is missing.** The device did not answer the property behind it at setup, or the feature is not enabled. Reconfigure the entry, choose **Edit a device**, and check **Device Features and Modes**. Use the `get_prop_values` action to see what the device answers. See [actions.md](actions.md). If the device answers the property but the integration has no entity for it, open an issue. We may need to add it. **The unit shows only default values, or every entity has the same value after every restart.** Some firmwares refuse a status request with too many columns. The integration measures the limit right after it binds and stays below it, so this should not happen. If it does, run `tools/probe_status_limit.py --host ` from a checkout of the repo. Attach the output to an issue. See [development.md](development.md#tools). **Changes made in the UI are undone after a restart.** The entry is managed by the `gree_custom:` block in `configuration.yaml`. The YAML wins at every start. Change the YAML instead, or remove the block to manage the entry in the UI. See [YAML configuration](configuration.md#yaml-configuration). -**The Gree app logs me out.** Gree allows one session per account, and every login by the integration ends the app session. The integration logs in only when it has no session token yet. That is at the first setup of an account, at a YAML import with new account details, and at reauthentication. Loading or reloading the entry uses the stored token. See [Gree account](configuration.md#gree-account). +**The Gree app logs me out.** Gree allows one session per account, and every login by the integration ends the app session. The integration logs in only when it has no valid session token. That is at the first setup of an account, at a YAML import with new account details, at reauthentication, and at **Look for devices again** in reconfigure. **Edit a device** in reconfigure does not log in. Loading or reloading the entry uses the stored token. + +Sometimes the Gree server stops accepting the stored token. Why is not known: it can happen soon after a login, and the cloud may also limit how often an account can log in. When the server refuses the stored token, the integration logs in again once with the stored account details and stores the new token. That happens when the entry loads, or when **Edit a device** binds a cloud device. That login logs the app out. The log then shows a warning that starts with `Gree Cloud refused the stored token`. + +If this happens often, use a separate Gree account for Home Assistant, if your devices can be shared with a second account. Then a login by the integration does not log out your app. See [Gree account](configuration.md#gree-account). **A setting jumps back after a few seconds.** After a change, the integration shows the new value for up to 8 seconds while the device still reports the old one. A VRF controller does this for a few seconds after every change. If the device still reports the old value after 8 seconds, it did not take the change, and the old value is shown again. Check that the setting is allowed in the current mode. diff --git a/requirements_dev.txt b/requirements_dev.txt index 823eed6..38b7e20 100755 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -10,6 +10,11 @@ pytest>=8.4 pytest-asyncio>=1.0 pytest-timeout>=2.3 +# The config flow tests run a real Home Assistant. This plugin pins one exact +# Home Assistant release, and pytest, pytest-asyncio and pytest-timeout with +# it. 0.13.371 is Home Assistant 2026.10.0. Bump it on purpose, in its own PR. +pytest-homeassistant-custom-component==0.13.371 + # Integration dependencies (from manifest) asyncio-dgram aiomqtt diff --git a/tests/config_flow/__init__.py b/tests/config_flow/__init__.py new file mode 100644 index 0000000..6a9ea13 --- /dev/null +++ b/tests/config_flow/__init__.py @@ -0,0 +1 @@ +"""Tests for the config flow, run inside a real Home Assistant.""" diff --git a/tests/config_flow/conftest.py b/tests/config_flow/conftest.py new file mode 100644 index 0000000..92f38f8 --- /dev/null +++ b/tests/config_flow/conftest.py @@ -0,0 +1,445 @@ +"""Fixtures for the config flow tests. + +These tests run the shipped config flow inside a real Home Assistant, from +pytest-homeassistant-custom-component: the flow manager, the config entries, +the device registry and the reload after a flow. What is real and what is +faked: + +- Gree Cloud: `FakeGreeCloud`, real HTTP on loopback. Only the server address + of the region changes, so the login and the device list run the real client. +- MQTT: `FakeMqttBroker`. It replaces `aiomqtt.Client`, and it hands every + request to a `FakeGreeDevice`, so a bind over the cloud runs the real + protocol. The broker can refuse a login, or accept only the newest token + (`GreeEnv.revoke_token`). +- Local devices: `FakeGreeDevice` and `FakeVrfGateway` on real UDP sockets, on + port 7000 of their own address in 127.0.0.0/8. Discovery reaches them through + the extra hosts field of the flow, which is the real unicast scan. +- Broadcast: switched off. The test host has no LAN, and a broadcast would + leave the machine. That is the one function of the component that is + replaced (`helpers._get_hass_broadcast_addr`). + +Every test also checks resource hygiene: how many cloud logins it made, and +that the only MQTT clients and UDP sockets still open belong to a loaded +config entry. When the test ends, every entry is unloaded and nothing may stay +open. +""" + +from collections.abc import AsyncIterator, Callable, Iterator, Mapping +from dataclasses import dataclass, field +import logging +from typing import Any + +import pytest + +import custom_components.gree_custom as gree_component +from custom_components.gree_custom import config_flow, helpers as gree_helpers +from custom_components.gree_custom.aiogree import ( + cloud_api, + transport_mqtt, + transport_udp, +) +from custom_components.gree_custom.aiogree.cloud_api import GreeRegion +from custom_components.gree_custom.aiogree.transport_mqtt import GreeMqttTransport +from custom_components.gree_custom.aiogree.transport_udp import GreeUdpTransport +from custom_components.gree_custom.const import ( + CONF_DEVICES, + CONF_EXTRA_SCAN_HOSTS, + DOMAIN, +) +from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, + SOURCE_USER, + ConfigEntry, + ConfigEntryState, + ConfigFlowResult, +) +from homeassistant.const import CONF_DISCOVERY, CONF_EMAIL, CONF_PASSWORD, CONF_REGION +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType, section +from homeassistant.helpers import device_registry as dr +from tests.conftest import RecordingHandler +from tests.fakes.cloud import USER_ID, FakeGreeCloud, cloud_device +from tests.fakes.device import FakeGreeDevice +from tests.fakes.mqtt import BrokerClient, FakeMqttBroker + +# The component logs under this name inside Home Assistant. +COMPONENT_LOGGER = "custom_components.gree_custom" + +DISCOVERY_PORT = 7000 + +# Three units on one cloud account. A is also reachable on the LAN, B and C +# only through the cloud, until a test starts their UDP side. +MAC_A = "c03937a10001" +MAC_B = "c03937b20002" +MAC_C = "c03937c30003" +NAMES = {MAC_A: "Woonkamer", MAC_B: "Slaapkamer", MAC_C: "Zolder"} +KEYS = {MAC_A: "KeyA4f9Qz7Lm2Wx1", MAC_B: "KeyB8d3Rt6Yn0Pv5", MAC_C: "KeyC2s7Hj4Gk9Bq3"} + +CLOUD_LOGIN = { + CONF_EMAIL: "rob@example.com", + CONF_PASSWORD: "hunter2", + CONF_REGION: GreeRegion.EU.value, +} + +LOGIN_PATH = "/App/UserLoginV2" + +# What Home Assistant 2026.10 logs when an integration calls the deprecated +# `device_registry.async_get_device`. +DEPRECATED_LOOKUP = "calls `device_registry.async_get_device`" + + +@dataclass +class GreeEnv: + """The fakes of one test, and the counters the tests assert on.""" + + hass: HomeAssistant + cloud: FakeGreeCloud + broker: FakeMqttBroker + reserve_ips: Callable[[int], list[str]] + devices: dict[str, FakeGreeDevice] = field(default_factory=dict) + udp_streams: list[Any] = field(default_factory=list) + setup_calls: int = 0 + + @property + def logins(self) -> int: + """Count the cloud logins since the last reset.""" + return sum(1 for path, _ in self.cloud.requests if path == LOGIN_PATH) + + def reset_counts(self) -> None: + """Start counting logins, setups and device requests from zero.""" + self.cloud.requests.clear() + self.setup_calls = 0 + for device in self.devices.values(): + device.reset_record() + + async def start_udp( + self, device: FakeGreeDevice, port: int = DISCOVERY_PORT + ) -> str: + """Put a device on the LAN, on its own loopback address. Returns the host.""" + host = self.reserve_ips(1)[0] + await device.start(host, port) + return host + + def revoke_token(self, new_token: str) -> None: + """Make the stored token invalid, as a login in the Gree app does. + + The broker accepts only `new_token` from now on, and the next cloud + login hands it out. + """ + self.cloud.login_response = {"uid": USER_ID, "token": new_token} + self.broker.accepted_tokens = {new_token} + + def open_mqtt(self) -> list[BrokerClient]: + """Return the MQTT clients that are connected.""" + return self.broker.open_clients() + + def open_udp(self) -> list[Any]: + """Return the UDP sockets of the component that are still open. + + asyncio_dgram has no public way to ask if a stream is closed. + """ + return [ + s + for s in self.udp_streams + if not s._transport.is_closing() # noqa: SLF001 + ] + + def entry_connections(self) -> tuple[list[Any], list[Any]]: + """Return the MQTT clients and UDP sockets of the loaded entries. + + A transport keeps its connection private, so this reads the private + fields. There is no public way to ask a transport for its socket. + """ + mqtt: list[Any] = [] + udp: list[Any] = [] + for entry in self.hass.config_entries.async_entries(DOMAIN): + if entry.state is not ConfigEntryState.LOADED: + continue + for coordinator in (getattr(entry, "runtime_data", None) or {}).values(): + transport = coordinator.device.transport + if isinstance(transport, GreeMqttTransport): + client = transport._client # noqa: SLF001 + if client: + mqtt.append(client) + if isinstance(transport, GreeUdpTransport): + stream = transport._stream # noqa: SLF001 + if stream: + udp.append(stream) + return mqtt, udp + + def assert_only_entry_connections(self) -> None: + """Check that every open connection belongs to a loaded entry.""" + entry_mqtt, entry_udp = self.entry_connections() + assert _ids(self.open_mqtt()) == _ids(entry_mqtt), ( + "an MQTT client is open that no loaded entry uses" + ) + assert _ids(self.open_udp()) == _ids(entry_udp), ( + "a UDP socket is open that no loaded entry uses" + ) + + +def _ids(objects: list[Any]) -> set[int]: + """Compare connections by identity.""" + return {id(o) for o in objects} + + +def binds(device: FakeGreeDevice) -> int: + """Count the bind requests a device got since its last reset.""" + return sum(1 for pack in device.packs if pack.get("t") == "bind") + + +@pytest.fixture(autouse=True) +def component_logs() -> Iterator[RecordingHandler]: + """Fail a test when the component writes a log line that cannot be formatted.""" + handler = RecordingHandler() + logger = logging.getLogger(COMPONENT_LOGGER) + logger.addHandler(handler) + try: + yield handler + finally: + logger.removeHandler(handler) + + assert not handler.format_errors, ( + f"log lines that could not be formatted: {handler.format_errors}" + ) + + +@pytest.fixture +async def gree( + hass: HomeAssistant, + enable_custom_integrations: None, + monkeypatch: pytest.MonkeyPatch, + loopback_ips: Callable[[int], list[str]], +) -> AsyncIterator[GreeEnv]: + """Start the fakes and point the component at them.""" + cloud = FakeGreeCloud( + devices_per_home={1: [cloud_device(m, KEYS[m], NAMES[m]) for m in NAMES]} + ) + await cloud.start() + monkeypatch.setitem(cloud_api.CLOUD_SERVERS, GreeRegion.EU, cloud.url) + + broker = FakeMqttBroker() + monkeypatch.setattr(transport_mqtt.aiomqtt, "Client", broker.client) + + env = GreeEnv(hass=hass, cloud=cloud, broker=broker, reserve_ips=loopback_ips) + + for mac, name in NAMES.items(): + env.devices[mac] = FakeGreeDevice(mac=mac, name=name, session_key=KEYS[mac]) + broker.add(env.devices[mac]) + + real_connect = transport_udp.asyncio_dgram.connect + + async def recording_connect(*args: Any, **kwargs: Any) -> Any: + stream = await real_connect(*args, **kwargs) + env.udp_streams.append(stream) + return stream + + monkeypatch.setattr(transport_udp.asyncio_dgram, "connect", recording_connect) + + async def no_broadcast(_hass: HomeAssistant) -> list[str]: + return [] + + monkeypatch.setattr(gree_helpers, "_get_hass_broadcast_addr", no_broadcast) + + # A scan always waits for its whole window, so keep it short. + monkeypatch.setattr(config_flow, "DEFAULT_DISCOVERY_TIMEOUT", 1) + + real_setup_entry = gree_component.async_setup_entry + + async def counting_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + env.setup_calls += 1 + return await real_setup_entry(hass, entry) + + monkeypatch.setattr(gree_component, "async_setup_entry", counting_setup_entry) + + try: + yield env + + # Close what a test left open, then check that nothing stays behind + for flow in hass.config_entries.flow.async_progress_by_handler(DOMAIN): + hass.config_entries.flow.async_abort(flow["flow_id"]) + for entry in hass.config_entries.async_entries(DOMAIN): + if entry.state is ConfigEntryState.LOADED: + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert not env.open_mqtt(), "an MQTT client is open after every unload" + assert not env.open_udp(), "a UDP socket is open after every unload" + finally: + for device in env.devices.values(): + device.close() + await cloud.close() + + +# Form helpers. They fill a form the way the frontend does: every field starts +# at its default or suggested value, and the test only names what it changes. + + +def _schema_defaults(schema: Any) -> dict[str, Any]: + """Return the values a form shows before the user changes anything.""" + values: dict[str, Any] = {} + for key, value in schema.schema.items(): + name = str(key.schema) + if isinstance(value, section): + values[name] = _schema_defaults(value.schema) + elif callable(key.default): + values[name] = key.default() + elif (key.description or {}).get("suggested_value"): + values[name] = key.description["suggested_value"] + return values + + +def form_input(result: ConfigFlowResult, **changes: Any) -> dict[str, Any]: + """Return the input of a form, with some fields changed. + + A change to a section is a dict, merged into what the section shows. + """ + assert result["type"] is FlowResultType.FORM, result + schema = result["data_schema"] + values = _schema_defaults(schema) if schema is not None else {} + for name, value in changes.items(): + if isinstance(value, Mapping) and isinstance(values.get(name), dict): + values[name] = {**values[name], **value} + else: + values[name] = value + return values + + +async def submit( + hass: HomeAssistant, result: ConfigFlowResult, **changes: Any +) -> ConfigFlowResult: + """Submit a form with its defaults and the given changes.""" + return await hass.config_entries.flow.async_configure( + result["flow_id"], form_input(result, **changes) + ) + + +async def choose( + hass: HomeAssistant, result: ConfigFlowResult, step: str +) -> ConfigFlowResult: + """Choose one option of a menu.""" + assert result["type"] is FlowResultType.MENU, result + assert step in result["menu_options"], result["menu_options"] + return await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": step} + ) + + +def picker_labels(result: ConfigFlowResult) -> dict[str, str]: + """Return the label of every MAC a device picker lists, in list order.""" + assert result["type"] is FlowResultType.FORM, result + selector = next( + value + for key, value in result["data_schema"].schema.items() + if str(key.schema) == CONF_DEVICES + ) + return {option["value"]: option["label"] for option in selector.config["options"]} + + +def picker_options(result: ConfigFlowResult) -> list[str]: + """Return the MACs a device picker lists.""" + return list(picker_labels(result)) + + +def form_mac(result: ConfigFlowResult, macs: list[str]) -> str: + """Tell which device a device form is about.""" + shown = result["description_placeholders"]["device_name"] + return next(mac for mac in macs if mac in shown) + + +async def submit_device_forms( + hass: HomeAssistant, + result: ConfigFlowResult, + macs: list[str], + connection: Mapping[str, Mapping[str, Any]] | None = None, + options: Mapping[str, Mapping[str, Any]] | None = None, +) -> ConfigFlowResult: + """Submit the connection and option forms of the picked devices. + + `connection` and `options` hold the changes per device MAC. A device + without changes gets the defaults. + """ + while result["type"] is FlowResultType.FORM and result["step_id"] in ( + "connection_options", + "device_options", + ): + mac = form_mac(result, macs) + changes = ( + connection if result["step_id"] == "connection_options" else options + ) or {} + result = await submit(hass, result, **changes.get(mac, {})) + return result + + +async def add_entry( + hass: HomeAssistant, + methods: list[str], + picks: list[str], + hosts: list[str] | None = None, +) -> ConfigEntry: + """Add a config entry through the user flow and wait until it is set up.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_DISCOVERY: methods} + ) + if "cloud" in methods: + assert result["step_id"] == "cloud_add" + result = await submit(hass, result, **CLOUD_LOGIN) + if "local" in methods: + assert result["step_id"] == "local_add" + result = await submit(hass, result, **{CONF_EXTRA_SCAN_HOSTS: hosts or []}) + + assert result["step_id"] == "device_picker", result + result = await submit(hass, result, **{CONF_DEVICES: picks}) + result = await submit_device_forms(hass, result, picks) + + assert result["type"] is FlowResultType.CREATE_ENTRY, result + await hass.async_block_till_done() + + entry: ConfigEntry = result["result"] + assert entry.state is ConfigEntryState.LOADED + return entry + + +async def start_reconfigure( + hass: HomeAssistant, entry: ConfigEntry +) -> ConfigFlowResult: + """Open the reconfigure flow of an entry.""" + return await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_RECONFIGURE, "entry_id": entry.entry_id} + ) + + +def registry_device( + hass: HomeAssistant, entry: ConfigEntry, mac: str +) -> dr.DeviceEntry | None: + """Return the device registry row of one unit of an entry. + + `async_get_device` and lookups on `devices` are deprecated in Home + Assistant 2026.10, and their replacements are not in 2026.3. + """ + return next( + ( + device + for device in dr.async_entries_for_config_entry( + dr.async_get(hass), entry.entry_id + ) + if (DOMAIN, mac) in device.identifiers + ), + None, + ) + + +@pytest.fixture +async def cloud_entry(hass: HomeAssistant, gree: GreeEnv) -> ConfigEntry: + """Set up a cloud entry through the user flow, as in the maintainer's test. + + A is picked from the LAN, B through the cloud only, C is left out. The + counters start at zero after the setup. + """ + host_a = await gree.start_udp(gree.devices[MAC_A]) + entry = await add_entry(hass, ["cloud", "local"], [MAC_A, MAC_B], [host_a]) + gree.reset_counts() + return entry diff --git a/tests/config_flow/test_reconfigure.py b/tests/config_flow/test_reconfigure.py new file mode 100644 index 0000000..9d1f373 --- /dev/null +++ b/tests/config_flow/test_reconfigure.py @@ -0,0 +1,627 @@ +"""Tests for the reconfigure menu of an entry. + +Most tests start from `cloud_entry`: a cloud entry made through the user flow, +with A on the LAN and B through the cloud only. C is on the account but not in +the entry. See `conftest.py` for what is real and what is faked. +""" + +# pylint: disable=redefined-outer-name +# A test takes a fixture as an argument with the same name. That is the +# pytest pattern, not shadowing. + +import copy + +import pytest + +from custom_components.gree_custom.aiogree.transport_mqtt import GreeMqttTransport +from custom_components.gree_custom.aiogree.transport_udp import GreeUdpTransport +from custom_components.gree_custom.const import ( + CONF_CLOUD, + CONF_DEVICE_CONNECTION, + CONF_DEVICE_CONNECTION_CLOUD, + CONF_DEVICE_CONNECTION_LOCAL, + CONF_DEVICE_OPTIONS, + CONF_DEVICES, + CONF_ENCRYPTION_VERSION, + CONF_EXTRA_SCAN_HOSTS, + CONF_MAC_CONTROLLER_LOCAL, + CONF_PREFER_CLOUD, + CONFENTRY_ID_LOCAL_ONLY, + DOMAIN, +) +from homeassistant.config_entries import ( + SOURCE_REAUTH, + SOURCE_USER, + ConfigEntry, + ConfigEntryState, +) +from homeassistant.const import CONF_DISCOVERY, CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from tests.config_flow.conftest import ( + CLOUD_LOGIN, + DEPRECATED_LOOKUP, + MAC_A, + MAC_B, + MAC_C, + GreeEnv, + add_entry, + binds, + choose, + form_input, + form_mac, + picker_labels, + picker_options, + registry_device, + start_reconfigure, + submit, + submit_device_forms, +) +from tests.fakes.cloud import TOKEN, USER_ID +from tests.fakes.vrf import GATEWAY_MAC, FakeVrfGateway, sub_device_macs + +NEW_TOKEN = "cloud-token-after-app-login" + +FULL_MENU = ["edit_device", "rediscover", "remove_devices", "finish"] + + +def connection(entry: ConfigEntry, mac: str) -> dict: + """Return the stored connection of one device.""" + return entry.data[CONF_DEVICES][mac][CONF_DEVICE_CONNECTION] + + +def coordinator_transport(entry: ConfigEntry, mac: str) -> object: + """Return the transport the loaded entry uses for one device.""" + return entry.runtime_data[mac].device.transport + + +def reauth_flows(hass: HomeAssistant) -> list: + """Return the reauth flows in progress.""" + return [ + flow + for flow in hass.config_entries.flow.async_progress_by_handler(DOMAIN) + if flow["context"]["source"] == SOURCE_REAUTH + ] + + +async def test_finish_at_once_reloads_without_a_login( + hass: HomeAssistant, gree: GreeEnv, cloud_entry: ConfigEntry +) -> None: + """Finish without changes: no login, one reload, the same token, no reauth.""" + data_before = copy.deepcopy(dict(cloud_entry.data)) + + result = await start_reconfigure(hass, cloud_entry) + assert result["type"] is FlowResultType.MENU + assert result["menu_options"] == FULL_MENU + + result = await choose(hass, result, "finish") + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + await hass.async_block_till_done() + + assert cloud_entry.state is ConfigEntryState.LOADED + assert dict(cloud_entry.data) == data_before + assert cloud_entry.data[CONF_CLOUD]["token"] == TOKEN + assert not reauth_flows(hass) + + assert gree.logins == 0 + assert gree.setup_calls == 1 + gree.assert_only_entry_connections() + + +async def test_rediscover_finds_a_cloud_only_unit_on_the_lan( + hass: HomeAssistant, gree: GreeEnv, cloud_entry: ConfigEntry +) -> None: + """B shows up on the LAN. Only units that add something are listed. + + Regression for two reports by the maintainer: + + - A6: rediscover with local included and an extra host that matches a + configured cloud-only device. The device stayed on MQTT. Now the picker + lists it, its form has the found host filled in, the entry runs it on + UDP after finish, and "Edit a device" labels it (Local). + - The picker showed a configured cloud and local device that had nothing + new. A already has both connections, so the picker leaves it out. + + C is listed because it is not in the entry. Nothing was removed. + """ + host_a = connection(cloud_entry, MAC_A)[CONF_DEVICE_CONNECTION_LOCAL][CONF_HOST] + host_b = await gree.start_udp(gree.devices[MAC_B]) + + result = await start_reconfigure(hass, cloud_entry) + result = await choose(hass, result, "rediscover") + assert result["step_id"] == "rediscover" + assert form_input(result)["include_local"] is True + + result = await submit(hass, result, include_local=True) + assert result["step_id"] == "cloud_add" + + result = await submit(hass, result) + assert result["step_id"] == "local_add" + # The hosts of the first setup are prefilled + assert form_input(result)[CONF_EXTRA_SCAN_HOSTS] == [host_a] + + result = await submit(hass, result, **{CONF_EXTRA_SCAN_HOSTS: [host_a, host_b]}) + assert result["step_id"] == "device_picker" + assert sorted(picker_options(result)) == [MAC_B, MAC_C] + + result = await submit(hass, result, **{CONF_DEVICES: [MAC_B]}) + assert result["step_id"] == "connection_options" + shown = form_input(result) + assert shown[CONF_DEVICE_CONNECTION_LOCAL][CONF_HOST] == host_b + assert shown[CONF_DEVICE_CONNECTION_LOCAL][CONF_MAC_CONTROLLER_LOCAL] == MAC_B + + result = await submit(hass, result) + assert result["step_id"] == "device_options" + result = await submit(hass, result) + assert result["type"] is FlowResultType.MENU + + result = await choose(hass, result, "finish") + assert result["reason"] == "reconfigure_successful" + await hass.async_block_till_done() + + assert cloud_entry.state is ConfigEntryState.LOADED + assert set(cloud_entry.data[CONF_DEVICES]) == {MAC_A, MAC_B} + assert connection(cloud_entry, MAC_B)[CONF_DEVICE_CONNECTION_LOCAL][CONF_HOST] == ( + host_b + ) + assert connection(cloud_entry, MAC_A)[CONF_DEVICE_CONNECTION_LOCAL][CONF_HOST] == ( + host_a + ) + assert registry_device(hass, cloud_entry, MAC_A) is not None + assert registry_device(hass, cloud_entry, MAC_B) is not None + assert isinstance(coordinator_transport(cloud_entry, MAC_B), GreeUdpTransport) + + # The edit list now shows B as a local device + result = await start_reconfigure(hass, cloud_entry) + result = await choose(hass, result, "edit_device") + labels = picker_labels(result) + assert labels[MAC_B].endswith("(Local)"), labels + hass.config_entries.flow.async_abort(result["flow_id"]) + await hass.async_block_till_done() + + assert gree.logins == 1 + gree.assert_only_entry_connections() + + +async def test_rediscover_runs_the_forms_of_every_picked_unit( + hass: HomeAssistant, gree: GreeEnv, cloud_entry: ConfigEntry +) -> None: + """Two picked units both go through the connection and device forms. + + Regression for the report "only the new device went through the forms + although two were picked". B is configured cloud-only and now found on + the LAN, C is new. Each form shows the units in the order they were + picked, and both end up in the entry. + """ + host_a = connection(cloud_entry, MAC_A)[CONF_DEVICE_CONNECTION_LOCAL][CONF_HOST] + host_b = await gree.start_udp(gree.devices[MAC_B]) + + result = await start_reconfigure(hass, cloud_entry) + result = await choose(hass, result, "rediscover") + result = await submit(hass, result, include_local=True) + result = await submit(hass, result) + result = await submit(hass, result, **{CONF_EXTRA_SCAN_HOSTS: [host_a, host_b]}) + assert result["step_id"] == "device_picker" + + result = await submit(hass, result, **{CONF_DEVICES: [MAC_B, MAC_C]}) + + steps: list[tuple[str, str]] = [] + while result["type"] is FlowResultType.FORM: + steps.append((result["step_id"], form_mac(result, [MAC_B, MAC_C]))) + result = await submit(hass, result) + + assert steps == [ + ("connection_options", MAC_B), + ("connection_options", MAC_C), + ("device_options", MAC_B), + ("device_options", MAC_C), + ] + assert result["type"] is FlowResultType.MENU + + result = await choose(hass, result, "finish") + await hass.async_block_till_done() + + assert cloud_entry.state is ConfigEntryState.LOADED + assert set(cloud_entry.data[CONF_DEVICES]) == {MAC_A, MAC_B, MAC_C} + assert isinstance(coordinator_transport(cloud_entry, MAC_B), GreeUdpTransport) + assert isinstance(coordinator_transport(cloud_entry, MAC_C), GreeMqttTransport) + + assert gree.logins == 1 + gree.assert_only_entry_connections() + + +async def test_rediscover_without_new_units_keeps_the_pending_edit( + hass: HomeAssistant, gree: GreeEnv, cloud_entry: ConfigEntry +) -> None: + """Nothing new: a step that says so, back to the menu, the edit survives.""" + # C left the account, so the cloud only lists the units of the entry + gree.cloud.devices_per_home[1] = [ + d for d in gree.cloud.devices_per_home[1] if d["mac"] != MAC_C + ] + + result = await start_reconfigure(hass, cloud_entry) + result = await choose(hass, result, "edit_device") + result = await submit(hass, result, **{CONF_DEVICES: MAC_B}) + result = await submit(hass, result) + result = await submit(hass, result, **{CONF_NAME: "Slaapkamer boven"}) + assert result["type"] is FlowResultType.MENU + + result = await choose(hass, result, "rediscover") + result = await submit(hass, result, include_local=False) + result = await submit(hass, result) + assert result["step_id"] == "no_new_devices" + + result = await submit(hass, result) + assert result["type"] is FlowResultType.MENU + + result = await choose(hass, result, "finish") + assert result["reason"] == "reconfigure_successful" + await hass.async_block_till_done() + + options_b = cloud_entry.data[CONF_DEVICES][MAC_B][CONF_DEVICE_OPTIONS] + assert options_b[CONF_NAME] == "Slaapkamer boven" + assert cloud_entry.state is ConfigEntryState.LOADED + + assert gree.logins == 1 + assert gree.setup_calls == 1 + gree.assert_only_entry_connections() + + +async def test_edit_without_a_new_connection_reuses_the_running_device( + hass: HomeAssistant, gree: GreeEnv, cloud_entry: ConfigEntry +) -> None: + """A new name only: no login, no bind in the flow, one reload.""" + clients_before = len(gree.broker.clients) + + result = await start_reconfigure(hass, cloud_entry) + result = await choose(hass, result, "edit_device") + result = await submit(hass, result, **{CONF_DEVICES: MAC_B}) + assert result["step_id"] == "connection_options" + + result = await submit(hass, result) + assert result["step_id"] == "device_options" + result = await submit(hass, result, **{CONF_NAME: "Slaapkamer 2"}) + assert result["type"] is FlowResultType.MENU + + # The flow used the bind of the running entry + assert binds(gree.devices[MAC_B]) == 0 + assert len(gree.broker.clients) == clients_before + + result = await choose(hass, result, "finish") + await hass.async_block_till_done() + + options_b = cloud_entry.data[CONF_DEVICES][MAC_B][CONF_DEVICE_OPTIONS] + assert options_b[CONF_NAME] == "Slaapkamer 2" + assert cloud_entry.state is ConfigEntryState.LOADED + + assert gree.logins == 0 + assert gree.setup_calls == 1 + gree.assert_only_entry_connections() + + +async def test_edit_with_a_new_bind_setting_binds_with_the_stored_token( + hass: HomeAssistant, gree: GreeEnv, cloud_entry: ConfigEntry +) -> None: + """Prefer cloud for A: a bind over MQTT with the stored token, no login. + + The flow opens its own MQTT client for the bind and closes it when the + flow ends. + """ + clients_before = len(gree.broker.clients) + + result = await start_reconfigure(hass, cloud_entry) + result = await choose(hass, result, "edit_device") + result = await submit(hass, result, **{CONF_DEVICES: MAC_A}) + result = await submit( + hass, result, **{CONF_DEVICE_CONNECTION_CLOUD: {CONF_PREFER_CLOUD: True}} + ) + assert result["step_id"] == "device_options" + + flow_client = gree.broker.clients[clients_before] + assert flow_client.options["password"] == TOKEN + assert flow_client.entered + assert not flow_client.exited + assert any(topic == f"request/{MAC_A}" for topic, _ in flow_client.published) + assert binds(gree.devices[MAC_A]) == 1 + assert gree.logins == 0 + + result = await submit(hass, result) + result = await choose(hass, result, "finish") + await hass.async_block_till_done() + + assert flow_client.exited + assert connection(cloud_entry, MAC_A)[CONF_DEVICE_CONNECTION_CLOUD][ + CONF_PREFER_CLOUD + ] + assert isinstance(coordinator_transport(cloud_entry, MAC_A), GreeMqttTransport) + + assert gree.logins == 0 + assert gree.setup_calls == 1 + gree.assert_only_entry_connections() + + +async def test_edit_with_prefer_cloud_refreshes_a_revoked_token( + hass: HomeAssistant, gree: GreeEnv, cloud_entry: ConfigEntry +) -> None: + """Prefer cloud for A after the Gree app made the stored token invalid. + + Regression for the maintainer's report A3: "Edit a device, tick Prefer + cloud connection" failed with cannot_connect_mqtt, `[code:134] Bad user + name or password`. The broker refuses the stored token. The flow logs in once with the + stored account, binds A with the new token, and finish writes that token. + """ + clients_before = len(gree.broker.clients) + gree.revoke_token(NEW_TOKEN) + + result = await start_reconfigure(hass, cloud_entry) + result = await choose(hass, result, "edit_device") + result = await submit(hass, result, **{CONF_DEVICES: MAC_A}) + result = await submit( + hass, result, **{CONF_DEVICE_CONNECTION_CLOUD: {CONF_PREFER_CLOUD: True}} + ) + assert result["step_id"] == "device_options" + assert gree.logins == 1 + assert binds(gree.devices[MAC_A]) == 1 + + # The client with the stored token was refused, the second one connected + refused, flow_client = gree.broker.clients[clients_before:] + assert refused.options["password"] == TOKEN + assert not refused.entered + assert flow_client.options["password"] == NEW_TOKEN + assert flow_client.entered + + result = await submit(hass, result) + result = await choose(hass, result, "finish") + await hass.async_block_till_done() + + assert flow_client.exited + assert cloud_entry.state is ConfigEntryState.LOADED + assert cloud_entry.data[CONF_CLOUD]["token"] == NEW_TOKEN + assert isinstance(coordinator_transport(cloud_entry, MAC_A), GreeMqttTransport) + assert not reauth_flows(hass) + + assert gree.logins == 1 + assert gree.setup_calls == 1 + assert {c.options["password"] for c in gree.open_mqtt()} == {NEW_TOKEN} + gree.assert_only_entry_connections() + + +async def test_edit_shows_cannot_connect_mqtt_when_the_new_login_fails( + hass: HomeAssistant, gree: GreeEnv, cloud_entry: ConfigEntry +) -> None: + """A revoked token and a failed login: one login, the form shows the error.""" + gree.revoke_token(NEW_TOKEN) + gree.cloud.login_response = {"r": 400, "msg": "Wrong password"} + + result = await start_reconfigure(hass, cloud_entry) + result = await choose(hass, result, "edit_device") + result = await submit(hass, result, **{CONF_DEVICES: MAC_A}) + result = await submit( + hass, result, **{CONF_DEVICE_CONNECTION_CLOUD: {CONF_PREFER_CLOUD: True}} + ) + assert result["step_id"] == "connection_options" + assert result["errors"] == {"base": "cannot_connect_mqtt"} + assert gree.logins == 1 + + hass.config_entries.flow.async_abort(result["flow_id"]) + await hass.async_block_till_done() + + assert cloud_entry.data[CONF_CLOUD]["token"] == TOKEN + assert gree.logins == 1 + gree.assert_only_entry_connections() + + +async def test_remove_devices( + hass: HomeAssistant, + gree: GreeEnv, + cloud_entry: ConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """A removed unit leaves the entry and the device registry. + + The lookup in the registry does not use the call that Home Assistant + 2026.10 reports as deprecated. + """ + assert registry_device(hass, cloud_entry, MAC_B) is not None + + result = await start_reconfigure(hass, cloud_entry) + result = await choose(hass, result, "remove_devices") + result = await submit(hass, result, **{CONF_DEVICES: [MAC_B]}) + assert result["type"] is FlowResultType.MENU + assert result["description_placeholders"]["device_cnt"] == "1" + + result = await choose(hass, result, "finish") + await hass.async_block_till_done() + + assert set(cloud_entry.data[CONF_DEVICES]) == {MAC_A} + assert registry_device(hass, cloud_entry, MAC_B) is None + assert registry_device(hass, cloud_entry, MAC_A) is not None + assert cloud_entry.state is ConfigEntryState.LOADED + assert DEPRECATED_LOOKUP not in caplog.text + + assert gree.logins == 0 + gree.assert_only_entry_connections() + + +async def test_local_only_entry_never_logs_in( + hass: HomeAssistant, gree: GreeEnv +) -> None: + """A local-only entry: the edit reuses the running device, no step logs in.""" + host_a = await gree.start_udp(gree.devices[MAC_A]) + entry = await add_entry(hass, ["local"], [MAC_A], [host_a]) + assert entry.unique_id == CONFENTRY_ID_LOCAL_ONLY + assert entry.data[CONF_CLOUD] is None + gree.reset_counts() + + result = await start_reconfigure(hass, entry) + assert result["menu_options"] == FULL_MENU + + result = await choose(hass, result, "edit_device") + result = await submit(hass, result, **{CONF_DEVICES: MAC_A}) + result = await submit(hass, result) + assert result["step_id"] == "device_options" + assert binds(gree.devices[MAC_A]) == 0 + + result = await submit(hass, result, **{CONF_NAME: "Woonkamer 2"}) + + # Rediscover goes straight to the LAN scan + result = await choose(hass, result, "rediscover") + assert result["step_id"] == "local_add" + result = await submit(hass, result) + assert result["step_id"] == "no_new_devices" + result = await submit(hass, result) + + result = await choose(hass, result, "finish") + await hass.async_block_till_done() + + assert entry.data[CONF_DEVICES][MAC_A][CONF_DEVICE_OPTIONS][CONF_NAME] == ( + "Woonkamer 2" + ) + assert entry.state is ConfigEntryState.LOADED + assert not gree.cloud.requests + assert not gree.broker.clients + assert gree.setup_calls == 1 + gree.assert_only_entry_connections() + + +async def test_vrf_host_change_moves_every_sub_unit( + hass: HomeAssistant, gree: GreeEnv +) -> None: + """A new host for one sub-unit is also written to the others behind it. + + The gateway moved to a new address and port. The user edits one sub-unit, + and its sibling gets the same values. A change of encryption version is + not covered: the fake answers a scan in V2 when it is a V2 unit, and the + component reads scan replies in V1 only. + """ + old_gateway = FakeVrfGateway(sub_count=2) + new_gateway = FakeVrfGateway(sub_count=2) + gree.devices["old_gateway"] = old_gateway + gree.devices["new_gateway"] = new_gateway + old_host = await gree.start_udp(old_gateway) + new_host = await gree.start_udp(new_gateway, port=0) + + sub_macs = sub_device_macs(GATEWAY_MAC, 2) + entry = await add_entry(hass, ["local"], sub_macs, [old_host]) + for mac in sub_macs: + local = connection(entry, mac)[CONF_DEVICE_CONNECTION_LOCAL] + assert local[CONF_MAC_CONTROLLER_LOCAL] == GATEWAY_MAC + assert local[CONF_HOST] == old_host + gree.reset_counts() + + result = await start_reconfigure(hass, entry) + result = await choose(hass, result, "edit_device") + result = await submit(hass, result, **{CONF_DEVICES: sub_macs[0]}) + result = await submit( + hass, + result, + **{ + CONF_DEVICE_CONNECTION_LOCAL: { + CONF_HOST: new_host, + CONF_PORT: new_gateway.port, + } + }, + ) + assert result["step_id"] == "device_options" + result = await submit(hass, result) + result = await choose(hass, result, "finish") + await hass.async_block_till_done() + + for mac in sub_macs: + local = connection(entry, mac)[CONF_DEVICE_CONNECTION_LOCAL] + assert local[CONF_HOST] == new_host, mac + assert local[CONF_PORT] == new_gateway.port, mac + assert local[CONF_ENCRYPTION_VERSION] == "1", mac + assert entry.runtime_data[mac].device.is_bound, mac + + assert entry.state is ConfigEntryState.LOADED + assert binds(new_gateway) >= 1 + assert binds(old_gateway) == 0 + assert gree.setup_calls == 1 + gree.assert_only_entry_connections() + + +async def test_flows_with_two_logins_and_an_edit_leave_no_client_open( + hass: HomeAssistant, gree: GreeEnv +) -> None: + """No flow leaves an MQTT client behind, and a reload keeps one client. + + Regression for the report "HA stop closed 8 MQTT loops". The user flow + logs in twice: a wrong password first, then the right one. Then a + reconfigure edit binds A over MQTT in the flow. After each flow only the + entry client is open, and after a reload there is still only one. + """ + host_a = await gree.start_udp(gree.devices[MAC_A]) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_DISCOVERY: ["cloud", "local"]} + ) + gree.cloud.login_response = {"r": 400, "msg": "Wrong password"} + result = await submit(hass, result, **CLOUD_LOGIN) + assert result["step_id"] == "cloud_add" + assert result["errors"] == {"base": "cloud_bad_login"} + + gree.cloud.login_response = {"uid": USER_ID, "token": TOKEN} + result = await submit(hass, result, **CLOUD_LOGIN) + assert result["step_id"] == "local_add" + result = await submit(hass, result, **{CONF_EXTRA_SCAN_HOSTS: [host_a]}) + result = await submit(hass, result, **{CONF_DEVICES: [MAC_A, MAC_B]}) + result = await submit_device_forms(hass, result, [MAC_A, MAC_B]) + assert result["type"] is FlowResultType.CREATE_ENTRY + await hass.async_block_till_done() + entry: ConfigEntry = result["result"] + + assert gree.logins == 2 + assert len(gree.open_mqtt()) == 1 + gree.assert_only_entry_connections() + + result = await start_reconfigure(hass, entry) + result = await choose(hass, result, "edit_device") + result = await submit(hass, result, **{CONF_DEVICES: MAC_A}) + result = await submit( + hass, result, **{CONF_DEVICE_CONNECTION_CLOUD: {CONF_PREFER_CLOUD: True}} + ) + result = await submit(hass, result) + result = await choose(hass, result, "finish") + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + assert len(gree.open_mqtt()) == 1 + gree.assert_only_entry_connections() + + assert await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + assert len(gree.open_mqtt()) == 1 + gree.assert_only_entry_connections() + assert gree.logins == 2 + + +async def test_closing_reconfigure_after_a_login_closes_its_connection( + hass: HomeAssistant, gree: GreeEnv, cloud_entry: ConfigEntry +) -> None: + """The user closes the flow at the picker: its MQTT client closes too.""" + clients_before = len(gree.broker.clients) + + result = await start_reconfigure(hass, cloud_entry) + result = await choose(hass, result, "rediscover") + result = await submit(hass, result, include_local=False) + result = await submit(hass, result) + assert result["step_id"] == "device_picker" + assert gree.logins == 1 + + flow_client = gree.broker.clients[clients_before] + assert flow_client.entered + assert not flow_client.exited + + hass.config_entries.flow.async_abort(result["flow_id"]) + await hass.async_block_till_done() + + assert flow_client.exited + assert cloud_entry.state is ConfigEntryState.LOADED + assert gree.setup_calls == 0 + gree.assert_only_entry_connections() diff --git a/tests/config_flow/test_registry_lookup.py b/tests/config_flow/test_registry_lookup.py new file mode 100644 index 0000000..fce661e --- /dev/null +++ b/tests/config_flow/test_registry_lookup.py @@ -0,0 +1,104 @@ +"""Tests for `get_device_by_identifier`, the device registry lookup by owner. + +Home Assistant 2026.8 added `async_get_device_by_identifier`. Older versions, +down to the 2026.3 floor of this repo, only have `async_get_device`. The +helper uses the new call when it is there, and otherwise the old call with an +owner check. The first branch runs against the real registry of the Home +Assistant in the test. The second needs an older registry, so a small stand-in +without the new method serves the rows of the real one. +""" + +# pylint: disable=redefined-outer-name +# A test takes a fixture as an argument with the same name. That is the +# pytest pattern, not shadowing. + +from typing import Any + +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.gree_custom.const import DOMAIN +from custom_components.gree_custom.helpers import get_device_by_identifier +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +MAC = "c03937a10001" +IDENTIFIER = (DOMAIN, MAC) + + +class OldRegistry: + """A device registry as Home Assistant before 2026.8 has it. + + It has `async_get_device` and no `async_get_device_by_identifier`. The + rows come from the real registry. + """ + + def __init__(self, registry: dr.DeviceRegistry, entry_ids: list[str]) -> None: + """Serve the devices of these entries.""" + self._registry = registry + self._entry_ids = entry_ids + + def async_get_device(self, identifiers: set[tuple[str, str]]) -> Any: + """Return the first device with one of the identifiers.""" + for entry_id in self._entry_ids: + for device in dr.async_entries_for_config_entry(self._registry, entry_id): + if identifiers & device.identifiers: + return device + return None + + +@pytest.fixture +def entries(hass: HomeAssistant) -> tuple[MockConfigEntry, MockConfigEntry]: + """Two entries. The first one owns the device.""" + owner = MockConfigEntry(domain=DOMAIN, unique_id="owner") + other = MockConfigEntry(domain=DOMAIN, unique_id="other") + owner.add_to_hass(hass) + other.add_to_hass(hass) + + dr.async_get(hass).async_get_or_create( + config_entry_id=owner.entry_id, identifiers={IDENTIFIER} + ) + return owner, other + + +async def test_the_new_lookup_finds_the_device_of_its_owner( + hass: HomeAssistant, entries: tuple[MockConfigEntry, MockConfigEntry] +) -> None: + """The Home Assistant of this test has the new call, so it is used.""" + owner, other = entries + registry = dr.async_get(hass) + assert hasattr(registry, "async_get_device_by_identifier") + + device = get_device_by_identifier(registry, IDENTIFIER, owner.entry_id) + assert device is not None + assert IDENTIFIER in device.identifiers + + assert get_device_by_identifier(registry, IDENTIFIER, other.entry_id) is None + assert ( + get_device_by_identifier(registry, (DOMAIN, "000000000000"), owner.entry_id) + is None + ) + + +async def test_the_old_lookup_checks_the_owner( + hass: HomeAssistant, entries: tuple[MockConfigEntry, MockConfigEntry] +) -> None: + """Without the new call, a device of another entry gives None.""" + owner, other = entries + registry = OldRegistry(dr.async_get(hass), [owner.entry_id, other.entry_id]) + assert not hasattr(registry, "async_get_device_by_identifier") + + device = get_device_by_identifier(registry, IDENTIFIER, owner.entry_id) # type: ignore[arg-type] + assert device is not None + assert device in dr.async_entries_for_config_entry( + dr.async_get(hass), owner.entry_id + ) + + assert ( + get_device_by_identifier(registry, IDENTIFIER, other.entry_id) # type: ignore[arg-type] + is None + ) + assert ( + get_device_by_identifier(registry, (DOMAIN, "000000000000"), owner.entry_id) # type: ignore[arg-type] + is None + ) diff --git a/tests/config_flow/test_setup.py b/tests/config_flow/test_setup.py new file mode 100644 index 0000000..782fca1 --- /dev/null +++ b/tests/config_flow/test_setup.py @@ -0,0 +1,406 @@ +"""Tests for adding an entry: the user flow, the YAML import and reauth. + +See `conftest.py` for what is real and what is faked. +""" + +# pylint: disable=redefined-outer-name +# A test takes a fixture as an argument with the same name. That is the +# pytest pattern, not shadowing. + +import copy +import logging + +from aiomqtt.exceptions import MqttConnectError, MqttError +import pytest + +from custom_components.gree_custom.config_schema import CONFIG_SCHEMA +from custom_components.gree_custom.const import ( + CONF_CLOUD, + CONF_DEVICE_CONNECTION, + CONF_DEVICE_CONNECTION_CLOUD, + CONF_DEVICE_CONNECTION_LOCAL, + CONF_DEVICE_OPTIONS, + CONF_DEVICES, + CONF_EXTRA_SCAN_HOSTS, + CONF_MAC_CONTROLLER_CLOUD, + CONFENTRY_ID_LOCAL_ONLY, + DOMAIN, +) +from custom_components.gree_custom.helpers import is_mqtt_auth_refusal +from homeassistant.config_entries import ( + SOURCE_IMPORT, + SOURCE_REAUTH, + SOURCE_USER, + ConfigEntry, + ConfigEntryState, +) +from homeassistant.const import CONF_DISCOVERY, CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.setup import async_setup_component +from tests.config_flow.conftest import ( + CLOUD_LOGIN, + DEPRECATED_LOOKUP, + MAC_A, + MAC_B, + MAC_C, + GreeEnv, + binds, + registry_device, + start_reconfigure, + submit, + submit_device_forms, +) +from tests.conftest import RecordingHandler +from tests.fakes.cloud import TOKEN, USER_ID +from tests.fakes.device import FakeGreeDevice +from tests.fakes.mqtt import connack_reason + +# A unit that only the YAML knows. +MAC_D = "c03937d40004" +MAC_E = "c03937e50005" +NEW_TOKEN = "cloud-token-after-reauth" + + +async def test_user_setup_with_cloud_and_local( + hass: HomeAssistant, gree: GreeEnv +) -> None: + """Cloud and local discovery, one LAN unit and one cloud-only unit. + + The cloud lists three units and the LAN scan finds one of them. The user + picks that one and a cloud-only one. The flow logs in once, binds A over + UDP and B over MQTT, and closes its own connections when the entry is made. + """ + host_a = await gree.start_udp(gree.devices[MAC_A]) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_DISCOVERY: ["cloud", "local"]} + ) + assert result["step_id"] == "cloud_add" + + result = await submit(hass, result, **CLOUD_LOGIN) + assert result["step_id"] == "local_add" + assert gree.logins == 1 + + result = await submit(hass, result, **{CONF_EXTRA_SCAN_HOSTS: [host_a]}) + assert result["step_id"] == "device_picker" + assert result["description_placeholders"]["devices_found"] == "3" + + result = await submit(hass, result, **{CONF_DEVICES: [MAC_A, MAC_B]}) + assert result["step_id"] == "connection_options" + + result = await submit_device_forms(hass, result, [MAC_A, MAC_B]) + assert result["type"] is FlowResultType.CREATE_ENTRY + await hass.async_block_till_done() + + entry = result["result"] + assert entry.state is ConfigEntryState.LOADED + assert entry.unique_id == str(USER_ID) + assert entry.data[CONF_CLOUD]["token"] == TOKEN + assert set(entry.data[CONF_DEVICES]) == {MAC_A, MAC_B} + + conn_a = entry.data[CONF_DEVICES][MAC_A][CONF_DEVICE_CONNECTION] + conn_b = entry.data[CONF_DEVICES][MAC_B][CONF_DEVICE_CONNECTION] + assert conn_a[CONF_DEVICE_CONNECTION_LOCAL][CONF_HOST] == host_a + assert conn_a[CONF_DEVICE_CONNECTION_CLOUD][CONF_MAC_CONTROLLER_CLOUD] == MAC_A + assert not conn_b[CONF_DEVICE_CONNECTION_LOCAL].get(CONF_HOST) + assert conn_b[CONF_DEVICE_CONNECTION_CLOUD][CONF_MAC_CONTROLLER_CLOUD] == MAC_B + + # One login in total, the entry setup uses the stored token + assert gree.logins == 1 + # The flow and the entry each bound A and B once + assert binds(gree.devices[MAC_A]) == 2 + assert binds(gree.devices[MAC_B]) == 2 + assert binds(gree.devices[MAC_C]) == 0 + + # The flow connected to the broker and closed it again. The entry has + # its own client, for B. + assert len(gree.broker.clients) == 2 + assert gree.broker.clients[0].exited + assert len(gree.open_mqtt()) == 1 + gree.assert_only_entry_connections() + + +async def test_closing_the_user_flow_after_login_closes_its_connection( + hass: HomeAssistant, gree: GreeEnv +) -> None: + """The user closes the flow at the LAN step, after the cloud login.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_DISCOVERY: ["cloud", "local"]} + ) + result = await submit(hass, result, **CLOUD_LOGIN) + assert result["step_id"] == "local_add" + assert len(gree.open_mqtt()) == 1 + + hass.config_entries.flow.async_abort(result["flow_id"]) + await hass.async_block_till_done() + + assert gree.logins == 1 + assert not gree.open_mqtt() + assert not hass.config_entries.async_entries(DOMAIN) + + +async def test_a_cloud_only_bind_logs_no_error( + hass: HomeAssistant, gree: GreeEnv, component_logs: RecordingHandler +) -> None: + """Binding a unit that has no host is normal and is not an error.""" + host_a = await gree.start_udp(gree.devices[MAC_A]) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_DISCOVERY: ["cloud", "local"]} + ) + result = await submit(hass, result, **CLOUD_LOGIN) + result = await submit(hass, result, **{CONF_EXTRA_SCAN_HOSTS: [host_a]}) + result = await submit(hass, result, **{CONF_DEVICES: [MAC_B]}) + result = await submit(hass, result) + assert result["step_id"] == "device_options" + + try: + assert "No controller MAC provided for local transport" not in ( + component_logs.messages(logging.ERROR) + ) + finally: + hass.config_entries.flow.async_abort(result["flow_id"]) + await hass.async_block_till_done() + + +def yaml_device(host: str, port: int, name: str) -> dict: + """One device as a user writes it in configuration.yaml.""" + return { + CONF_DEVICE_CONNECTION: { + CONF_DEVICE_CONNECTION_LOCAL: {CONF_HOST: host, CONF_PORT: port} + }, + CONF_DEVICE_OPTIONS: {CONF_NAME: name}, + } + + +async def test_yaml_managed_entry_cannot_be_reconfigured( + hass: HomeAssistant, gree: GreeEnv, caplog: pytest.LogCaptureFixture +) -> None: + """An entry from configuration.yaml refuses reconfigure. + + The YAML would put its own values back at the next start. When the YAML + drops a device, the import removes it from the device registry, without + the lookup that Home Assistant 2026.10 reports as deprecated. + """ + hosts: dict[str, tuple[str, int]] = {} + for mac, name in ((MAC_D, "Zolderkamer"), (MAC_E, "Washok")): + gree.devices[mac] = FakeGreeDevice(mac=mac, name=name) + host = await gree.start_udp(gree.devices[mac], port=0) + hosts[mac] = (host, gree.devices[mac].port) + + config = { + DOMAIN: [ + { + CONF_DEVICES: { + MAC_D: yaml_device(*hosts[MAC_D], "Zolderkamer"), + MAC_E: yaml_device(*hosts[MAC_E], "Washok"), + } + } + ] + } + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + assert entry.unique_id == CONFENTRY_ID_LOCAL_ONLY + assert entry.state is ConfigEntryState.LOADED + assert registry_device(hass, entry, MAC_E) is not None + + result = await start_reconfigure(hass, entry) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "yaml_managed" + + # The next start reads a YAML without E. async_setup runs once per test, + # so this hands the validated item to the import step, as it does. + item = CONFIG_SCHEMA( + {DOMAIN: [{CONF_DEVICES: {MAC_D: yaml_device(*hosts[MAC_D], "Zolderkamer")}}]} + )[DOMAIN][0] + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=item + ) + assert result["reason"] == "reconfigure_successful" + await hass.async_block_till_done() + + assert set(entry.data[CONF_DEVICES]) == {MAC_D} + assert registry_device(hass, entry, MAC_E) is None + assert registry_device(hass, entry, MAC_D) is not None + assert entry.state is ConfigEntryState.LOADED + assert DEPRECATED_LOOKUP not in caplog.text + + assert gree.logins == 0 + gree.assert_only_entry_connections() + + +def reauth_flows(hass: HomeAssistant) -> list: + """Return the reauth flows in progress.""" + return [ + flow + for flow in hass.config_entries.flow.async_progress_by_handler(DOMAIN) + if flow["context"]["source"] == SOURCE_REAUTH + ] + + +# What the fake cloud answers to a wrong password. +BAD_LOGIN = {"r": 400, "msg": "Wrong password"} + + +async def test_reauth_after_the_broker_refuses_the_token( + hass: HomeAssistant, gree: GreeEnv, cloud_entry: ConfigEntry +) -> None: + """A refused token and a failed login with the stored account start reauth. + + Setup tries one login with the stored account. When that fails too, it + starts reauth. Reauth logs in once and reloads. Only the cloud block of + the entry changes. The devices stay as they are. + """ + devices_before = copy.deepcopy(dict(cloud_entry.data[CONF_DEVICES])) + + gree.broker.refuse_connect = True + gree.cloud.login_response = BAD_LOGIN + await hass.config_entries.async_reload(cloud_entry.entry_id) + await hass.async_block_till_done() + + assert cloud_entry.state is ConfigEntryState.SETUP_ERROR + flows = reauth_flows(hass) + assert len(flows) == 1 + assert gree.logins == 1 + assert cloud_entry.data[CONF_CLOUD]["token"] == TOKEN + # A was bound over UDP before B failed. The failed setup closed it again. + assert not gree.open_udp() + assert not gree.open_mqtt() + + gree.broker.refuse_connect = False + gree.cloud.login_response = {"uid": USER_ID, "token": NEW_TOKEN} + + # Show the form of the step the flow waits in, then submit the stored account + result = await hass.config_entries.flow.async_configure(flows[0]["flow_id"]) + assert result["step_id"] == "cloud_add" + result = await submit(hass, result) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + await hass.async_block_till_done() + + assert cloud_entry.state is ConfigEntryState.LOADED + assert cloud_entry.data[CONF_CLOUD]["token"] == NEW_TOKEN + assert dict(cloud_entry.data[CONF_DEVICES]) == devices_before + assert not reauth_flows(hass) + + assert gree.logins == 2 + # The entry connects with the new token + assert [c.options["password"] for c in gree.open_mqtt()] == [NEW_TOKEN] + gree.assert_only_entry_connections() + + +async def test_setup_refreshes_a_revoked_token_once( + hass: HomeAssistant, + gree: GreeEnv, + cloud_entry: ConfigEntry, + component_logs: RecordingHandler, +) -> None: + """The Gree app logged in after Home Assistant, so the stored token is dead. + + Regression for the maintainer's report: "Save and reload" right after he + created a cloud entry raised reauth with bad_credentials, because the + broker refused the stored token. Setup now logs in once with the stored account, binds B with the new token + and stores it. Storing the token does not reload the entry again. + """ + gree.revoke_token(NEW_TOKEN) + await hass.config_entries.async_reload(cloud_entry.entry_id) + await hass.async_block_till_done() + + assert cloud_entry.state is ConfigEntryState.LOADED + assert cloud_entry.data[CONF_CLOUD]["token"] == NEW_TOKEN + assert cloud_entry.data[CONF_CLOUD]["uid"] == USER_ID + assert not reauth_flows(hass) + + assert gree.logins == 1 + assert gree.setup_calls == 1 + assert binds(gree.devices[MAC_B]) == 1 + assert any("refused the stored token" in line for line in component_logs.warnings()) + + assert [c.options["password"] for c in gree.open_mqtt()] == [NEW_TOKEN] + gree.assert_only_entry_connections() + + +async def test_setup_starts_reauth_when_the_new_login_fails( + hass: HomeAssistant, gree: GreeEnv, cloud_entry: ConfigEntry +) -> None: + """A revoked token and a changed password: one login, then reauth.""" + gree.revoke_token(NEW_TOKEN) + gree.cloud.login_response = BAD_LOGIN + await hass.config_entries.async_reload(cloud_entry.entry_id) + await hass.async_block_till_done() + + assert cloud_entry.state is ConfigEntryState.SETUP_ERROR + assert len(reauth_flows(hass)) == 1 + assert cloud_entry.data[CONF_CLOUD]["token"] == TOKEN + + assert gree.logins == 1 + assert not gree.open_udp() + assert not gree.open_mqtt() + + +async def test_setup_starts_reauth_when_the_new_token_is_refused_too( + hass: HomeAssistant, gree: GreeEnv, cloud_entry: ConfigEntry +) -> None: + """The login works, but the broker refuses the new token too: no loop. + + The login made the old token invalid, so the new one stays stored. + """ + gree.broker.refuse_connect = True + gree.cloud.login_response = {"uid": USER_ID, "token": NEW_TOKEN} + await hass.config_entries.async_reload(cloud_entry.entry_id) + await hass.async_block_till_done() + + assert cloud_entry.state is ConfigEntryState.SETUP_ERROR + assert len(reauth_flows(hass)) == 1 + assert cloud_entry.data[CONF_CLOUD]["token"] == NEW_TOKEN + + assert gree.logins == 1 + assert not gree.open_udp() + assert not gree.open_mqtt() + + +async def test_a_connect_error_that_is_not_about_the_token_does_not_log_in( + hass: HomeAssistant, gree: GreeEnv, cloud_entry: ConfigEntry +) -> None: + """A broker that is unavailable is no reason for a new login.""" + gree.broker.refuse_connect = True + gree.broker.refuse_reason = connack_reason("Server unavailable") + await hass.config_entries.async_reload(cloud_entry.entry_id) + await hass.async_block_till_done() + + assert cloud_entry.state is ConfigEntryState.SETUP_ERROR + assert cloud_entry.data[CONF_CLOUD]["token"] == TOKEN + assert gree.logins == 0 + assert not gree.open_mqtt() + + +@pytest.mark.parametrize( + ("rc", "refused"), + [ + (4, True), + (5, True), + (connack_reason("Bad user name or password"), True), + (connack_reason("Not authorized"), True), + (3, False), + (connack_reason("Server unavailable"), False), + ], +) +def test_which_connect_errors_are_about_the_token(rc: object, refused: bool) -> None: + """The MQTT 3.1.1 codes 4 and 5 and the MQTT 5 codes 134 and 135.""" + assert is_mqtt_auth_refusal(MqttConnectError(rc)) is refused + assert not is_mqtt_auth_refusal(MqttError("lost connection")) diff --git a/tests/config_flow/test_translations.py b/tests/config_flow/test_translations.py new file mode 100644 index 0000000..a277e8e --- /dev/null +++ b/tests/config_flow/test_translations.py @@ -0,0 +1,81 @@ +"""Every key the integration shows to a user has a text in every language. + +A config flow error, an abort reason, a form step, a menu option and an +exception with a translation key all reach the user through the files in +`translations/`. A key without a text shows up raw in the UI, such as +`cannot_connect_mqtt` in the connection form or `bad_credentials` on the +entry card. These tests read the keys from the source, so a new key without +a text fails here instead of in a user's UI. +""" + +import json +from pathlib import Path +import re + +import pytest + +COMPONENT = Path(__file__).parents[2] / "custom_components" / "gree_custom" +LANGUAGES = ("en", "nl", "pt") +CONFIG_FLOW = (COMPONENT / "config_flow.py").read_text(encoding="utf-8") +ALL_SOURCE = "".join( + path.read_text(encoding="utf-8") for path in sorted(COMPONENT.glob("*.py")) +) + + +def translations(language: str) -> dict: + """Return the translation file of one language.""" + path = COMPONENT / "translations" / f"{language}.json" + return json.loads(path.read_text(encoding="utf-8")) + + +def keys(pattern: str, source: str) -> set[str]: + """Return every key the pattern finds in the source.""" + return set(re.findall(pattern, source)) + + +ERRORS = keys(r'errors\[[^\]]+\]\s*=\s*"([^"]+)"', CONFIG_FLOW) +# Home Assistant aborts with these reasons itself: the reauth default of +# async_update_reload_and_abort, and the unique ID helpers of the flow. +IMPLICIT_ABORTS = { + "reauth_successful", + "already_configured", + "already_in_progress", + "unique_id_mismatch", +} +ABORTS = keys(r'reason="([^"]+)"', CONFIG_FLOW) | IMPLICIT_ABORTS +STEPS = keys(r'step_id="([^"]+)"', CONFIG_FLOW) +MENU_OPTIONS = { + option + for block in re.findall(r"menu_options = \[([^\]]+)\]", CONFIG_FLOW) + for option in re.findall(r'"([^"]+)"', block) +} +EXCEPTIONS = keys(r'translation_domain=DOMAIN,\s*translation_key="([^"]+)"', ALL_SOURCE) + + +def test_the_source_has_keys() -> None: + """The patterns still find keys, so the tests below check something.""" + assert {"cannot_connect_mqtt", "cloud_bad_login"} <= ERRORS + assert {"yaml_managed", "reconfigure_successful"} <= ABORTS + assert {"reconfigure", "edit_device", "no_new_devices"} <= STEPS + assert {"edit_device", "rediscover", "remove_devices", "finish"} <= MENU_OPTIONS + assert {"bad_credentials", "no_account_info", "device_bind_failed"} <= EXCEPTIONS + + +@pytest.mark.parametrize("language", LANGUAGES) +def test_every_config_flow_key_has_a_text(language: str) -> None: + """Errors, abort reasons, steps and menu options of the config flow.""" + config = translations(language)["config"] + + assert sorted(ERRORS - set(config["error"])) == [] + assert sorted(ABORTS - set(config["abort"])) == [] + assert sorted(STEPS - set(config["step"])) == [] + menu = config["step"]["reconfigure"]["menu_options"] + assert sorted(MENU_OPTIONS - set(menu)) == [] + + +@pytest.mark.parametrize("language", LANGUAGES) +def test_every_exception_key_has_a_text(language: str) -> None: + """Exceptions raised with a translation key, such as reauth reasons.""" + exceptions = translations(language)["exceptions"] + + assert sorted(EXCEPTIONS - set(exceptions)) == [] diff --git a/tests/conftest.py b/tests/conftest.py index 08639bf..5543592 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,6 +16,11 @@ from aiogree.const import DEFAULT_DEVICE_PORT import pytest +try: + import pytest_socket +except ImportError: # pragma: no cover - only without the Home Assistant plugin + pytest_socket = None + # Everything the protocol layer logs sits under this logger, because the tests # import the package as "aiogree". Inside Home Assistant the same records are # named "custom_components.gree_custom.aiogree.*". @@ -71,6 +76,25 @@ def warnings(self) -> list[str]: return self.messages(logging.WARNING) +@pytest.fixture(autouse=True) +def loopback_sockets() -> None: + """Allow sockets on loopback, and nowhere else. + + pytest-homeassistant-custom-component blocks every socket before each + test, and allows a connect only to 127.0.0.1. The fakes in this suite + listen on real sockets anywhere in 127.0.0.0/8, so this opens that range + again. A test that tries to reach a real Gree server or a real device + still fails. Without the plugin there is nothing to undo. + """ + if pytest_socket is None: + return + + # The allow list patches whatever class socket.socket is at that moment, + # so the real class has to be back first. + pytest_socket.enable_socket() + pytest_socket.socket_allow_hosts(["127.0.0.0/8"]) + + @pytest.fixture(autouse=True) def gree_logs() -> Iterator[RecordingHandler]: """Capture the component's log records for the length of one test. diff --git a/tests/fakes/mqtt.py b/tests/fakes/mqtt.py index c655daa..47df9e3 100644 --- a/tests/fakes/mqtt.py +++ b/tests/fakes/mqtt.py @@ -8,11 +8,37 @@ So the broker is faked and everything above it is real. The class below has the same surface as `aiomqtt.Client` for the parts the transport uses. + +`FakeMqttBroker` goes one step further, for the config flow tests. It hands +every request to a `FakeGreeDevice` and sends the reply back on the response +topic, so a bind over the cloud runs the real protocol end to end. It can also +refuse the connection, as the Gree broker does with a stale token, and it can +accept only some tokens, as the Gree broker does after a new login. """ import asyncio +import json from types import TracebackType -from typing import Any, Self +from typing import TYPE_CHECKING, Any, Self, override + +from aiomqtt.exceptions import MqttConnectError +from paho.mqtt.packettypes import PacketTypes +from paho.mqtt.reasoncodes import ReasonCode + +if TYPE_CHECKING: + from .device import FakeGreeDevice + +# The MQTT 3.1.1 return code for a refused user name or password. +CONNACK_NOT_AUTHORIZED = 5 + + +def connack_reason(name: str) -> ReasonCode: + """Return a CONNACK reason the way paho 2 reports it, also for MQTT 3.1.1. + + paho turns the 3.1.1 return code 4 into "Bad user name or password" + (code 134), so that is what aiomqtt puts in `MqttConnectError.rc`. + """ + return ReasonCode(PacketTypes.CONNACK, name) class FakeMqttMessage: @@ -92,3 +118,73 @@ def __aiter__(self) -> Self: async def __anext__(self) -> FakeMqttMessage: """Wait for the next message.""" return await self._queue.get() + + +class FakeMqttBroker: + """A broker that answers every request with a fake device. + + Install `client` in place of `aiomqtt.Client`. Each client the code under + test builds is kept in `clients`, so a test can tell which connections are + still open when a flow or an entry is done. + """ + + def __init__(self) -> None: + """Start with no devices and a broker that accepts every login.""" + self.devices: dict[str, FakeGreeDevice] = {} + # Refuse every connection with this reason + self.refuse_connect = False + self.refuse_reason: int | ReasonCode = CONNACK_NOT_AUTHORIZED + # When set, only these tokens may connect. The others get "Bad user + # name or password", as a token that a newer login replaced. + self.accepted_tokens: set[str] | None = None + self.clients: list[BrokerClient] = [] + + def add(self, device: FakeGreeDevice, mac_controller: str | None = None) -> None: + """Answer the requests for one controller MAC with this device.""" + self.devices[mac_controller or device.mac] = device + + def client(self, **kwargs: Any) -> BrokerClient: + """Build a client, the way `aiomqtt.Client(...)` does.""" + client = BrokerClient(self, **kwargs) + self.clients.append(client) + return client + + def open_clients(self) -> list[BrokerClient]: + """Return the clients that connected and did not disconnect.""" + return [c for c in self.clients if c.entered and not c.exited] + + +class BrokerClient(FakeMqttClient): + """A fake client whose requests reach a fake device.""" + + def __init__(self, broker: FakeMqttBroker, **kwargs: Any) -> None: + """Remember the broker this client talks to.""" + super().__init__(**kwargs) + self.broker = broker + + @override + async def __aenter__(self) -> Self: + """Open the connection, unless the broker refuses the login.""" + if self.broker.refuse_connect: + raise MqttConnectError(self.broker.refuse_reason) + accepted = self.broker.accepted_tokens + if accepted is not None and self.options.get("password") not in accepted: + raise MqttConnectError(connack_reason("Bad user name or password")) + return await super().__aenter__() + + @override + async def publish(self, topic: str, payload: str, qos: int = 0) -> None: + """Send one message, and deliver the reply of the device it is for.""" + await super().publish(topic, payload, qos) + + kind, _, mac = topic.partition("/") + device = self.broker.devices.get(mac) + if kind != "request" or device is None: + return + + envelope = json.loads(payload) + device.requests.append(envelope) + + reply = device.handle(envelope) + if reply is not None: + self.deliver(f"response/{mac}/app", json.dumps(reply))