From 21743843e72c95fa867ebc384a63fe0e9bde3573 Mon Sep 17 00:00:00 2001 From: Nikolai Rahimi Date: Sun, 30 Aug 2026 14:41:00 -0400 Subject: [PATCH] Resolve missing local addresses for Mitsubishi Comfort devices (#173270) Co-authored-by: Joost Lekkerkerker Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: TheJulianJES --- .../components/mitsubishi_comfort/__init__.py | 116 +++- .../mitsubishi_comfort/config_flow.py | 41 +- .../components/mitsubishi_comfort/const.py | 11 +- .../components/mitsubishi_comfort/helpers.py | 78 +++ .../mitsubishi_comfort/manifest.json | 1 + .../mitsubishi_comfort/quality_scale.yaml | 2 +- .../components/mitsubishi_comfort/repairs.py | 213 ++++++ .../mitsubishi_comfort/strings.json | 22 + .../mitsubishi_comfort/test_config_flow.py | 282 +++++++- .../mitsubishi_comfort/test_init.py | 455 +++++++++++- .../mitsubishi_comfort/test_repairs.py | 655 ++++++++++++++++++ 11 files changed, 1844 insertions(+), 32 deletions(-) create mode 100644 homeassistant/components/mitsubishi_comfort/helpers.py create mode 100644 homeassistant/components/mitsubishi_comfort/repairs.py create mode 100644 tests/components/mitsubishi_comfort/test_repairs.py diff --git a/homeassistant/components/mitsubishi_comfort/__init__.py b/homeassistant/components/mitsubishi_comfort/__init__.py index d43a494a8dd3..75a8c4e3ae26 100644 --- a/homeassistant/components/mitsubishi_comfort/__init__.py +++ b/homeassistant/components/mitsubishi_comfort/__init__.py @@ -2,6 +2,7 @@ import asyncio import logging +from typing import Any from mitsubishi_comfort import ( DeviceInfo, @@ -11,20 +12,28 @@ from mitsubishi_comfort import ( ) from mitsubishi_comfort.exceptions import AuthenticationError, DeviceConnectionError +from homeassistant.components.dhcp import async_discovered_service_info from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, issue_registry as ir from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ( CONF_ADDRESSES, + CONF_CREDENTIALS, DEFAULT_CONNECT_TIMEOUT, DEFAULT_RESPONSE_TIMEOUT, DOMAIN, PLATFORMS, ) from .coordinator import MitsubishiComfortConfigEntry, MitsubishiComfortCoordinator +from .helpers import ( + async_create_missing_address_issue, + async_reconcile_missing_address_issue, + build_credentials, + is_fully_credentialed, +) _LOGGER = logging.getLogger(__name__) @@ -58,9 +67,21 @@ async def async_setup_entry( entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], session=session ) + # Replay cached per-device credentials so discover_devices() can skip the + # slow, rate-limited Socket.IO password fetch. The config flow seeds + # these; without them a second Socket.IO call right after the flow's own + # is throttled to empty, leaving devices unconfigurable. + cached_credentials: dict[str, dict[str, str]] = entry.data.get(CONF_CREDENTIALS, {}) + + # The issue is not persistent and unload deletes it: if the cloud is + # unreachable below, addressless devices would retry with no fix flow + # offered. Reconcile from the stored data now; the fresh device list + # refines it further down. + async_reconcile_missing_address_issue(hass, entry) + try: await account.login() - devices = await account.discover_devices() + devices = await account.discover_devices(cached_credentials=cached_credentials) except AuthenticationError as err: raise ConfigEntryError("Mitsubishi cloud authentication failed") from err except DeviceConnectionError as err: @@ -76,39 +97,96 @@ async def async_setup_entry( # device with its MAC so the manifest's "registered_devices" DHCP matcher # tracks it; DHCP discovery then supplies the IP via async_step_dhcp. device_registry = dr.async_get(hass) - owned_macs = {dr.format_mac(info.mac) for info in devices.values()} + owned_macs = {dr.format_mac(info.mac) for info in devices.values() if info.mac} for serial, info in devices.items(): device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, serial)}, - connections={(dr.CONNECTION_NETWORK_MAC, info.mac)}, + # Connections are globally indexed: registering an empty MAC would + # merge every MAC-less device into one registry entry. + connections=( + {(dr.CONNECTION_NETWORK_MAC, info.mac)} if info.mac else set() + ), manufacturer="Mitsubishi", name=info.label, serial_number=serial, ) - # Resolved IPs are stored keyed by MAC. Drop any for devices that are no - # longer on the account. + # Cache the freshly discovered credentials (password, cryptoSerial, MAC) so + # later setups replay them; also drops entries for devices no longer present. + credentials = build_credentials(devices) + + # Stored IPs are keyed by MAC; drop any for devices no longer on the account. + # Addresses come from DHCP discovery (async_step_dhcp), the sighting cache + # below, and the repair flow — the cloud never returns a device's LAN IP. stored: dict[str, str] = entry.data.get(CONF_ADDRESSES, {}) addresses = {mac: ip for mac, ip in stored.items() if mac in owned_macs} + + # The dhcp component caches every sighting, including devices seen before + # they were registered here — those never re-fire registered_devices + # discovery, so look the cache up instead of waiting for a new sighting. + # Stored addresses win: live discovery handles genuine IP changes. + discovered = { + dr.format_mac(info.macaddress): info.ip + for info in async_discovered_service_info(hass) + } + addresses |= { + mac: ip + for mac, ip in discovered.items() + if mac in owned_macs and mac not in addresses + } + + data_updates: dict[str, Any] = {} + if credentials != cached_credentials: + data_updates[CONF_CREDENTIALS] = credentials if addresses != stored: + data_updates[CONF_ADDRESSES] = addresses + if data_updates: hass.config_entries.async_update_entry( - entry, data={**entry.data, CONF_ADDRESSES: addresses} + entry, data={**entry.data, **data_updates} ) coordinators: dict[str, MitsubishiComfortCoordinator] = {} + no_address: list[str] = [] + incomplete: list[str] = [] for serial, info in devices.items(): - address = addresses.get(dr.format_mac(info.mac)) - if not address or not info.password or not info.crypto_serial: - # No LAN address yet: the device is registered, so DHCP discovery - # supplies its IP and reloads the entry to add it. - _LOGGER.debug("Device %s has no known LAN address yet", info.label) + if not is_fully_credentialed(info): + incomplete.append(info.label) continue + address = addresses.get(dr.format_mac(info.mac)) + if not address: + no_address.append(info.label) + continue + _LOGGER.debug("Setting up %s at %s", info.label, address) device = _make_device(info, serial, address, session) coordinators[serial] = MitsubishiComfortCoordinator( hass, entry, device, info.mac ) + if incomplete: + _LOGGER.debug( + "The cloud returned incomplete local connection data for %d device(s): %s", + len(incomplete), + ", ".join(sorted(incomplete)), + ) + # A device the cloud cannot locate stays unaddressable across restarts + # until DHCP discovery reaches it or the user enters an IP in the repair + # flow; raise a fixable repair issue while any device lacks an address and + # clear it once they all have one. + if no_address: + async_create_missing_address_issue(hass, entry.entry_id) + else: + ir.async_delete_issue(hass, DOMAIN, f"missing_address_{entry.entry_id}") + # The three buckets reconcile: set up + awaiting address + incomplete local + # data equals the number of devices on the account. + _LOGGER.debug( + "Set up %d of %d device(s); %d awaiting a LAN address, %d with incomplete local data", + len(coordinators), + len(devices), + len(no_address), + len(incomplete), + ) + await asyncio.gather( *(c.async_config_entry_first_refresh() for c in coordinators.values()) ) @@ -123,8 +201,22 @@ async def async_unload_entry( ) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + # Only after a successful unload: a failed unload leaves the entry + # active, so its addressless devices still need the repair. + ir.async_delete_issue(hass, DOMAIN, f"missing_address_{entry.entry_id}") await asyncio.gather( *(c.device.close() for c in entry.runtime_data.values()), return_exceptions=True, ) return unload_ok + + +async def async_remove_entry( + hass: HomeAssistant, entry: MitsubishiComfortConfigEntry +) -> None: + """Remove a config entry's leftovers. + + Removal never calls async_unload_entry for an entry that failed setup, so + the repair issue such a setup left behind is deleted here. + """ + ir.async_delete_issue(hass, DOMAIN, f"missing_address_{entry.entry_id}") diff --git a/homeassistant/components/mitsubishi_comfort/config_flow.py b/homeassistant/components/mitsubishi_comfort/config_flow.py index 84581f611b5f..989d5e1c62a8 100644 --- a/homeassistant/components/mitsubishi_comfort/config_flow.py +++ b/homeassistant/components/mitsubishi_comfort/config_flow.py @@ -13,7 +13,8 @@ from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from .const import CONF_ADDRESSES, DOMAIN +from .const import CONF_ADDRESSES, CONF_CREDENTIALS, DOMAIN +from .helpers import build_credentials, is_fully_credentialed _LOGGER = logging.getLogger(__name__) @@ -30,6 +31,15 @@ class MitsubishiComfortConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 + def __init__(self) -> None: + """Initialize the flow.""" + # Fields recovered by earlier attempts in this flow, replayed on retry: + # the rate-limited Socket.IO password fetch may succeed on one attempt + # and return nothing on the next, so no single attempt has to recover + # everything. + self._cached_credentials: dict[str, dict[str, str]] = {} + self._cached_username: str | None = None + @override async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -38,6 +48,11 @@ class MitsubishiComfortConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input is not None: + # The recovered fields belong to the account entered. + if user_input[CONF_USERNAME] != self._cached_username: + self._cached_username = user_input[CONF_USERNAME] + self._cached_credentials = {} + account = MitsubishiCloudAccount( user_input[CONF_USERNAME], user_input[CONF_PASSWORD], @@ -47,27 +62,46 @@ class MitsubishiComfortConfigFlow(ConfigFlow, domain=DOMAIN): devices: dict = {} try: await account.login() - devices = await account.discover_devices() + devices = await account.discover_devices( + cached_credentials=self._cached_credentials + ) except AuthenticationError: errors["base"] = "invalid_auth" except DeviceConnectionError: errors["base"] = "cannot_connect" except Exception: - _LOGGER.exception("Unexpected error during setup") + _LOGGER.exception( + "Unexpected error discovering Mitsubishi Comfort devices" + ) errors["base"] = "unknown" + else: + _LOGGER.debug("Discovered %d device(s)", len(devices)) if not errors: await self.async_set_unique_id(account.user_id) self._abort_if_unique_id_configured() + # Persist the fields discovered here for async_setup_entry to + # replay via discover_devices(cached_credentials=...): the + # slow, rate-limited Socket.IO call then runs only for + # passwords still missing. + credentials = build_credentials(devices) + if credentials: + self._cached_credentials = credentials if not devices: errors["base"] = "no_devices" + elif not any(is_fully_credentialed(info) for info in devices.values()): + # The cache may hold partial (MAC-less) records setup cannot + # use; creating the entry with nothing settable-up would + # load zero devices without raising any repair. + errors["base"] = "no_usable_devices" else: return self.async_create_entry( title=f"Mitsubishi Comfort ({user_input[CONF_USERNAME]})", data={ CONF_USERNAME: user_input[CONF_USERNAME], CONF_PASSWORD: user_input[CONF_PASSWORD], + CONF_CREDENTIALS: credentials, }, ) @@ -105,6 +139,7 @@ class MitsubishiComfortConfigFlow(ConfigFlow, domain=DOMAIN): addresses = entry.data.get(CONF_ADDRESSES, {}) if addresses.get(mac) != discovery_info.ip: + _LOGGER.debug("DHCP discovery resolved %s to %s", mac, discovery_info.ip) self.hass.config_entries.async_update_entry( entry, data={ diff --git a/homeassistant/components/mitsubishi_comfort/const.py b/homeassistant/components/mitsubishi_comfort/const.py index 5d5760da33d3..ae3907b9cc80 100644 --- a/homeassistant/components/mitsubishi_comfort/const.py +++ b/homeassistant/components/mitsubishi_comfort/const.py @@ -10,10 +10,17 @@ PLATFORMS: Final = [Platform.CLIMATE] # Config entry data key holding the per-device LAN address cache, keyed by the # device's formatted MAC. The cloud API only returns each device's MAC, never -# its LAN IP, so addresses are resolved from DHCP discovery and persisted here -# to survive restarts without re-discovery. +# its LAN IP, so addresses come from DHCP discovery (live and cached sightings) +# and from manual entry in the repair flow, then persisted here to survive +# restarts. CONF_ADDRESSES: Final = "addresses" +# Config entry data key holding per-device discovery fields (the Socket.IO-fetched +# password, plus the cryptoSerial and MAC read from the device status endpoint), +# keyed by serial and replayed via discover_devices(cached_credentials=...) so +# later setup attempts can reuse every field already recovered. +CONF_CREDENTIALS: Final = "credentials" + DEFAULT_SCAN_INTERVAL = timedelta(seconds=60) DEFAULT_CONNECT_TIMEOUT: Final = 1.2 DEFAULT_RESPONSE_TIMEOUT: Final = 8.0 diff --git a/homeassistant/components/mitsubishi_comfort/helpers.py b/homeassistant/components/mitsubishi_comfort/helpers.py new file mode 100644 index 000000000000..6daa1b024c16 --- /dev/null +++ b/homeassistant/components/mitsubishi_comfort/helpers.py @@ -0,0 +1,78 @@ +"""Helpers shared across the Mitsubishi Comfort integration.""" + +from mitsubishi_comfort import DeviceInfo + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, issue_registry as ir + +from .const import CONF_ADDRESSES, CONF_CREDENTIALS, DOMAIN + + +def is_fully_credentialed(info: DeviceInfo) -> bool: + """Return whether the device can be set up: local secrets plus its MAC. + + Without a password and cryptoSerial the device cannot be authenticated + against the local API, and the MAC keys the address cache, so without it + the device cannot be set up or offered in the address repair flow yet. + """ + return bool(info.password and info.crypto_serial and info.mac) + + +def has_full_credentials(cred: dict[str, str]) -> bool: + """Return whether a cached record holds the secrets plus MAC setup needs.""" + return bool(cred["password"] and cred["crypto_serial"] and cred["mac"]) + + +def async_create_missing_address_issue(hass: HomeAssistant, entry_id: str) -> None: + """Raise the fixable repair offering manual entry of missing LAN addresses.""" + ir.async_create_issue( + hass, + DOMAIN, + f"missing_address_{entry_id}", + is_fixable=True, + severity=ir.IssueSeverity.ERROR, + translation_key="missing_address", + data={"entry_id": entry_id}, + ) + + +def async_reconcile_missing_address_issue( + hass: HomeAssistant, entry: ConfigEntry +) -> None: + """Create or clear the repair from the entry's stored data alone. + + The issue is not persistent and unload deletes it, so paths that cannot + consult the cloud's fresh device list — setup before the cloud is + reached, a reload whose unload failed — reconcile it from the cached + credentials and stored addresses instead. + """ + addresses: dict[str, str] = entry.data.get(CONF_ADDRESSES, {}) + credentials: dict[str, dict[str, str]] = entry.data.get(CONF_CREDENTIALS, {}) + if any( + dr.format_mac(cred["mac"]) not in addresses + for cred in credentials.values() + if has_full_credentials(cred) + ): + async_create_missing_address_issue(hass, entry.entry_id) + else: + ir.async_delete_issue(hass, DOMAIN, f"missing_address_{entry.entry_id}") + + +def build_credentials(devices: dict[str, DeviceInfo]) -> dict[str, dict[str, str]]: + """Build the per-device credential cache, keyed by serial. + + discover_devices() consumes the password, cryptoSerial, and MAC + independently, so any recovered field is worth caching — above all the + password, which the throttled Socket.IO fetch may never return again. + All-empty records carry nothing worth replaying and are dropped. + """ + return { + serial: { + "password": info.password, + "crypto_serial": info.crypto_serial, + "mac": info.mac, + } + for serial, info in devices.items() + if info.password or info.crypto_serial or info.mac + } diff --git a/homeassistant/components/mitsubishi_comfort/manifest.json b/homeassistant/components/mitsubishi_comfort/manifest.json index 6f139e85a319..4218b0a806de 100644 --- a/homeassistant/components/mitsubishi_comfort/manifest.json +++ b/homeassistant/components/mitsubishi_comfort/manifest.json @@ -3,6 +3,7 @@ "name": "Mitsubishi Comfort", "codeowners": ["@nikolairahimi"], "config_flow": true, + "dependencies": ["dhcp"], "dhcp": [{ "registered_devices": true }], "documentation": "https://www.home-assistant.io/integrations/mitsubishi_comfort", "integration_type": "hub", diff --git a/homeassistant/components/mitsubishi_comfort/quality_scale.yaml b/homeassistant/components/mitsubishi_comfort/quality_scale.yaml index 8ab6f27d0467..accfe1ca04a6 100644 --- a/homeassistant/components/mitsubishi_comfort/quality_scale.yaml +++ b/homeassistant/components/mitsubishi_comfort/quality_scale.yaml @@ -63,7 +63,7 @@ rules: reconfiguration-flow: todo dynamic-devices: todo discovery-update-info: done - repair-issues: todo + repair-issues: done docs-use-cases: done docs-supported-devices: done docs-supported-functions: done diff --git a/homeassistant/components/mitsubishi_comfort/repairs.py b/homeassistant/components/mitsubishi_comfort/repairs.py new file mode 100644 index 000000000000..905ebf9bf1e2 --- /dev/null +++ b/homeassistant/components/mitsubishi_comfort/repairs.py @@ -0,0 +1,213 @@ +"""Repairs for the Mitsubishi Comfort integration.""" + +import asyncio +from ipaddress import IPv4Address +from typing import cast + +from aiohttp import ClientSession +from mitsubishi_comfort import DeviceInfo, probe_candidate_ips +import voluptuous as vol + +from homeassistant.components.dhcp import async_discovered_service_info +from homeassistant.components.repairs import ( + ConfirmRepairFlow, + RepairsFlow, + RepairsFlowResult, +) +from homeassistant.config_entries import ConfigEntry, OperationNotAllowed, UnknownEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_ADDRESSES, CONF_CREDENTIALS +from .helpers import async_reconcile_missing_address_issue, has_full_credentials + + +async def _async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Reload the entry, restoring the repair issue if the reload fails. + + The repairs framework deletes the issue when the fix flow finishes, and + normally the reload's setup re-creates it while devices remain + addressless — but a failed unload ends the reload before setup runs, + which would leave the still-loaded entry without its fix flow. + """ + try: + if await hass.config_entries.async_reload(entry.entry_id): + return + except UnknownEntry: + # Removed while this task was pending; nothing left to repair. + return + except OperationNotAllowed: + # A FAILED_UNLOAD entry cannot reload; treat it as a failed reload so + # a repeat repair attempt on the wedged entry keeps its issue. + pass + async_reconcile_missing_address_issue(hass, entry) + + +async def _async_probe( + serial: str, cred: dict[str, str], address: str, session: ClientSession +) -> bool: + """Return whether the device answers an authenticated probe at address.""" + info = DeviceInfo( + serial=serial, + label=serial, + address="", + mac=cred["mac"], + unit_type="", + password=cred["password"], + crypto_serial=cred["crypto_serial"], + ) + return bool(await probe_candidate_ips({serial: info}, [address], session=session)) + + +class MissingAddressRepairFlow(RepairsFlow): + """Collect LAN IPs for devices DHCP discovery has not resolved. + + The cloud never returns a device's LAN IP. DHCP discovery supplies it for + devices Home Assistant can see, but not for devices on another subnet or + VLAN — for those the user enters the IP here. + """ + + def __init__(self, entry: ConfigEntry) -> None: + """Initialize the flow for the entry that raised the issue.""" + self.entry = entry + super().__init__() + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> RepairsFlowResult: + """Handle the first step of the fix flow. + + The repairs manager passes the flow init data ({"issue_id": ...}) as + user_input here, so redirect to a named step that sees real form + input only. + """ + return await self.async_step_addresses() + + async def async_step_addresses( + self, user_input: dict[str, str] | None = None + ) -> RepairsFlowResult: + """Ask for the LAN IP of each device that has none.""" + stored: dict[str, str] = dict(self.entry.data.get(CONF_ADDRESSES, {})) + credentials: dict[str, dict[str, str]] = self.entry.data.get( + CONF_CREDENTIALS, {} + ) + # The freshly pruned credential cache reflects the account's current, + # usable devices, so it decides which fields to offer — the registry + # may be empty (no discovery succeeded yet) or hold removed devices. + # Only fully-credentialed devices (secrets plus MAC): a partial record + # cannot pass the authenticated probe, and setup counts its device as + # incomplete rather than addressless. + macs: dict[str, str] = { + formatted: serial + for serial, cred in credentials.items() + if has_full_credentials(cred) + and (formatted := dr.format_mac(cred["mac"])) not in stored + } + # The registry supplies friendly names; a device never registered + # keeps its serial as the label. + device_registry = dr.async_get(self.hass) + for device in dr.async_entries_for_config_entry( + device_registry, self.entry.entry_id + ): + mac = next( + ( + conn_id + for conn_type, conn_id in device.connections + if conn_type == dr.CONNECTION_NETWORK_MAC + ), + None, + ) + if mac is not None and (formatted := dr.format_mac(mac)) in macs: + macs[formatted] = device.name_by_user or device.name or formatted + + errors: dict[str, str] = {} + if user_input is not None: + entered: dict[str, str] = {} + for mac in macs: + value = user_input.get(mac, "").strip() + if not value: + continue + try: + # IPv4 only: the local API URL is built without IPv6 + # brackets, so an IPv6 literal can never work. + IPv4Address(value) + except ValueError: + errors[mac] = "invalid_ip" + else: + entered[mac] = value + if not errors and entered: + # Each address must answer an authenticated probe for its own + # device: a stored wrong address would suppress this repair + # while leaving the entry stuck in setup retries. + by_mac = { + dr.format_mac(cred["mac"]): (serial, cred) + for serial, cred in credentials.items() + if has_full_credentials(cred) + } + session = async_get_clientsession(self.hass) + reachable = await asyncio.gather( + *( + _async_probe(*by_mac[mac], address, session) + for mac, address in entered.items() + ) + ) + errors |= { + mac: "cannot_connect" + for mac, ok in zip(entered, reachable, strict=True) + if not ok + } + if not errors: + # Re-read the cache: DHCP discovery may have stored addresses + # while the probes above were awaited. On overlap the stored + # lease wins — live discovery saw the device after the user + # typed the address. + current: dict[str, str] = self.entry.data.get(CONF_ADDRESSES, {}) + self.hass.config_entries.async_update_entry( + self.entry, + data={**self.entry.data, CONF_ADDRESSES: {**entered, **current}}, + ) + # The repairs framework deletes the issue after this step + # returns; run the reload non-eagerly so it happens after that + # deletion and setup can re-create the issue if devices are + # still addressless. + self.hass.async_create_task( + _async_reload_entry(self.hass, self.entry), + f"mitsubishi_comfort repair reload {self.entry.entry_id}", + eager_start=False, + ) + return self.async_create_entry(data={}) + + # Pre-fill with the submitted values on a validation error so the user + # does not lose what they typed; otherwise suggest any IP the DHCP + # sighting cache has picked up since setup. + if user_input is None: + user_input = { + formatted: info.ip + for info in async_discovered_service_info(self.hass) + if (formatted := dr.format_mac(info.macaddress)) in macs + } + schema = vol.Schema({vol.Optional(mac): str for mac in macs}) + return self.async_show_form( + step_id="addresses", + data_schema=self.add_suggested_values_to_schema(schema, user_input), + errors=errors, + # The fields are keyed (and labeled) by raw MAC, so pair each name + # with its MAC here or the user cannot tell which field is which. + description_placeholders={ + "devices": ", ".join(f"{name} ({mac})" for mac, name in macs.items()) + }, + ) + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, str | int | float | None] | None, +) -> RepairsFlow: + """Create a fix flow for a missing-address issue.""" + if data is not None and ( + entry := hass.config_entries.async_get_entry(cast(str, data["entry_id"])) + ): + return MissingAddressRepairFlow(entry) + return ConfirmRepairFlow() diff --git a/homeassistant/components/mitsubishi_comfort/strings.json b/homeassistant/components/mitsubishi_comfort/strings.json index 61b4c33a5bb3..26c0cf8cd381 100644 --- a/homeassistant/components/mitsubishi_comfort/strings.json +++ b/homeassistant/components/mitsubishi_comfort/strings.json @@ -7,6 +7,7 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "no_devices": "No devices were found on this account", + "no_usable_devices": "The cloud did not return complete local-control information for any device on this account. Wait a few minutes and try again.", "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { @@ -44,5 +45,26 @@ "update_failed": { "message": "{device_name} returned no data" } + }, + "issues": { + "missing_address": { + "fix_flow": { + "error": { + "cannot_connect": "One or more devices did not respond at the entered address.", + "invalid_ip": "One or more entries are not valid IPv4 addresses." + }, + "step": { + "addresses": { + "description": "These devices have no known local IP address, so they have no entities yet. Enter the local IPv4 address for each device below; each field is labeled with the device's MAC address. Leave a field blank to keep waiting for DHCP discovery (which only works when the device is on the same network as Home Assistant). After you submit, the integration reloads and sets up every device that has an address. Devices: {devices}", + "title": "Device IP addresses" + }, + "confirm": { + "description": "The account this issue was created for no longer exists, so there is nothing left to fix. Confirm to dismiss the issue.", + "title": "Issue is no longer relevant" + } + } + }, + "title": "Mitsubishi Comfort devices have no local IP address" + } } } diff --git a/tests/components/mitsubishi_comfort/test_config_flow.py b/tests/components/mitsubishi_comfort/test_config_flow.py index b603feb5e435..7a0e404b1d6f 100644 --- a/tests/components/mitsubishi_comfort/test_config_flow.py +++ b/tests/components/mitsubishi_comfort/test_config_flow.py @@ -3,24 +3,26 @@ from collections.abc import Generator from unittest.mock import AsyncMock, patch +from mitsubishi_comfort import DeviceInfo from mitsubishi_comfort.exceptions import AuthenticationError, DeviceConnectionError import pytest from homeassistant import config_entries -from homeassistant.components.mitsubishi_comfort.const import CONF_ADDRESSES, DOMAIN +from homeassistant.components.mitsubishi_comfort.const import ( + CONF_ADDRESSES, + CONF_CREDENTIALS, + DOMAIN, +) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from .conftest import MOCK_MAC, MOCK_SERIAL +from .conftest import MOCK_MAC, MOCK_PASSWORD, MOCK_SERIAL, MOCK_USERNAME from tests.common import MockConfigEntry -MOCK_USERNAME = "test@test.com" -MOCK_PASSWORD = "testpass" - @pytest.fixture(autouse=True) def mock_setup_entry() -> Generator[AsyncMock]: @@ -58,13 +60,215 @@ async def test_user_step_success( ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == f"Mitsubishi Comfort ({MOCK_USERNAME})" + # Per-device credentials from discovery are persisted so setup can skip the + # rate-limited Socket.IO fetch. assert result["data"] == { CONF_USERNAME: MOCK_USERNAME, CONF_PASSWORD: MOCK_PASSWORD, + CONF_CREDENTIALS: { + MOCK_SERIAL: { + "password": "dGVzdHBhc3M=", + "crypto_serial": "0102030405060708090a", + "mac": MOCK_MAC, + } + }, } mock_setup_entry.assert_called_once() +async def test_user_step_persists_partial_records( + hass: HomeAssistant, + mock_cloud_account: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test partially discovered devices keep their recovered fields. + + discover_devices() consumes the password, cryptoSerial, and MAC + independently, so whatever discovery recovered is seeded for replay, + matching async_setup_entry's caching. + """ + mock_cloud_account.discover_devices.return_value = { + MOCK_SERIAL: DeviceInfo( + serial=MOCK_SERIAL, + label="Living Room", + address="", + mac=MOCK_MAC, + unit_type="ductless", + password="dGVzdHBhc3M=", + crypto_serial="0102030405060708090a", + ), + "SERIAL002": DeviceInfo( + serial="SERIAL002", + label="Bedroom", + address="", + mac="11:22:33:44:55:66", + unit_type="ductless", + password="", + crypto_serial="", + ), + } + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: MOCK_USERNAME, CONF_PASSWORD: MOCK_PASSWORD}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_CREDENTIALS] == { + MOCK_SERIAL: { + "password": "dGVzdHBhc3M=", + "crypto_serial": "0102030405060708090a", + "mac": MOCK_MAC, + }, + "SERIAL002": { + "password": "", + "crypto_serial": "", + "mac": "11:22:33:44:55:66", + }, + } + + +def _partial_device_info() -> DeviceInfo: + """Build a device with local secrets but no MAC: recoverable, not usable.""" + return DeviceInfo( + serial=MOCK_SERIAL, + label="Living Room", + address="", + mac="", + unit_type="ductless", + password="dGVzdHBhc3M=", + crypto_serial="0102030405060708090a", + ) + + +async def test_user_step_retry_replays_partial_credentials( + hass: HomeAssistant, + mock_cloud_account: AsyncMock, +) -> None: + """Test a retry replays the fields recovered by a failed earlier attempt. + + The Socket.IO password fetch is rate limited: one attempt can recover the + passwords yet miss the MACs, and its retry the reverse. Replaying the + recovered fields means no single attempt has to return everything. + """ + mock_cloud_account.discover_devices.return_value = { + MOCK_SERIAL: _partial_device_info() + } + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: MOCK_USERNAME, CONF_PASSWORD: MOCK_PASSWORD}, + ) + assert result["errors"] == {"base": "no_usable_devices"} + assert ( + mock_cloud_account.discover_devices.call_args.kwargs["cached_credentials"] == {} + ) + + mock_cloud_account.discover_devices.return_value = { + MOCK_SERIAL: DeviceInfo( + serial=MOCK_SERIAL, + label="Living Room", + address="", + mac=MOCK_MAC, + unit_type="ductless", + password="dGVzdHBhc3M=", + crypto_serial="0102030405060708090a", + ) + } + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: MOCK_USERNAME, CONF_PASSWORD: MOCK_PASSWORD}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert mock_cloud_account.discover_devices.call_args.kwargs[ + "cached_credentials" + ] == { + MOCK_SERIAL: { + "password": "dGVzdHBhc3M=", + "crypto_serial": "0102030405060708090a", + "mac": "", + } + } + + +async def test_user_step_empty_account_response_keeps_cached_credentials( + hass: HomeAssistant, + mock_cloud_account: AsyncMock, +) -> None: + """Test a transient empty device list does not wipe recovered fields. + + The cached password may be unrecoverable, so only a discovery that + returned devices may replace the cache. + """ + mock_cloud_account.discover_devices.return_value = { + MOCK_SERIAL: _partial_device_info() + } + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: MOCK_USERNAME, CONF_PASSWORD: MOCK_PASSWORD}, + ) + assert result["errors"] == {"base": "no_usable_devices"} + + mock_cloud_account.discover_devices.return_value = {} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: MOCK_USERNAME, CONF_PASSWORD: MOCK_PASSWORD}, + ) + assert result["errors"] == {"base": "no_devices"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: MOCK_USERNAME, CONF_PASSWORD: MOCK_PASSWORD}, + ) + assert mock_cloud_account.discover_devices.call_args.kwargs[ + "cached_credentials" + ] == { + MOCK_SERIAL: { + "password": "dGVzdHBhc3M=", + "crypto_serial": "0102030405060708090a", + "mac": "", + } + } + + +async def test_user_step_username_change_drops_cached_credentials( + hass: HomeAssistant, + mock_cloud_account: AsyncMock, +) -> None: + """Test fields recovered for one account are not replayed for another.""" + mock_cloud_account.discover_devices.return_value = { + MOCK_SERIAL: _partial_device_info() + } + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: MOCK_USERNAME, CONF_PASSWORD: MOCK_PASSWORD}, + ) + assert result["errors"] == {"base": "no_usable_devices"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "other@example.com", CONF_PASSWORD: MOCK_PASSWORD}, + ) + assert result["errors"] == {"base": "no_usable_devices"} + assert ( + mock_cloud_account.discover_devices.call_args.kwargs["cached_credentials"] == {} + ) + + @pytest.mark.parametrize( ("side_effect", "discover_return", "expected_error"), [ @@ -72,8 +276,45 @@ async def test_user_step_success( (DeviceConnectionError("nope"), None, "cannot_connect"), (RuntimeError("Unexpected"), None, "unknown"), (None, {}, "no_devices"), + ( + None, + { + "SERIAL002": DeviceInfo( + serial="SERIAL002", + label="Bedroom", + address="", + mac="11:22:33:44:55:66", + unit_type="ductless", + password="", + crypto_serial="", + ) + }, + "no_usable_devices", + ), + ( + None, + { + "SERIAL002": DeviceInfo( + serial="SERIAL002", + label="Bedroom", + address="", + mac="", + unit_type="ductless", + password="dGVzdHBhc3M=", + crypto_serial="0102030405060708090a", + ) + }, + "no_usable_devices", + ), + ], + ids=[ + "invalid_auth", + "cannot_connect", + "unknown_error", + "no_devices", + "no_usable_devices", + "no_usable_devices_mac_less", ], - ids=["invalid_auth", "cannot_connect", "unknown_error", "no_devices"], ) async def test_user_step_errors( hass: HomeAssistant, @@ -210,6 +451,35 @@ async def test_dhcp_unregistered_device_ignored( mock_reload.assert_not_called() +async def test_dhcp_device_without_current_entry_aborts( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, +) -> None: + """Test DHCP aborts when the registered device has no current owning entry. + + The device exists in the registry but belongs only to an ignored entry, so + there is nothing to update. + """ + ignored_entry = MockConfigEntry( + domain=DOMAIN, source=config_entries.SOURCE_IGNORE, unique_id="ignored" + ) + ignored_entry.add_to_hass(hass) + _register_device(device_registry, ignored_entry) + + with patch( + "homeassistant.config_entries.ConfigEntries.async_schedule_reload" + ) as mock_reload: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=_dhcp_info("192.168.1.253"), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + mock_reload.assert_not_called() + + async def test_dhcp_no_account_aborts(hass: HomeAssistant) -> None: """Test DHCP discovery with no configured account aborts without a flow.""" result = await hass.config_entries.flow.async_init( diff --git a/tests/components/mitsubishi_comfort/test_init.py b/tests/components/mitsubishi_comfort/test_init.py index 26263b7d88ee..084466dab181 100644 --- a/tests/components/mitsubishi_comfort/test_init.py +++ b/tests/components/mitsubishi_comfort/test_init.py @@ -1,22 +1,40 @@ """Tests for the Mitsubishi Comfort integration setup.""" -from unittest.mock import AsyncMock, MagicMock +import logging +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch from mitsubishi_comfort import DeviceInfo from mitsubishi_comfort.exceptions import AuthenticationError, DeviceConnectionError import pytest -from homeassistant.components.mitsubishi_comfort.const import CONF_ADDRESSES, DOMAIN +from homeassistant.components.mitsubishi_comfort.const import ( + CONF_ADDRESSES, + CONF_CREDENTIALS, + DOMAIN, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from .conftest import MOCK_ADDRESS, MOCK_MAC, MOCK_PASSWORD, MOCK_USERNAME +from .conftest import MOCK_ADDRESS, MOCK_MAC, MOCK_PASSWORD, MOCK_SERIAL, MOCK_USERNAME from tests.common import MockConfigEntry +def _cache_entry(ip: str, mac: str = MOCK_MAC) -> DhcpServiceInfo: + """Build a DHCP cache entry (the cache stores MACs without separators).""" + return DhcpServiceInfo( + ip=ip, hostname="kumo", macaddress=mac.replace(":", "").lower() + ) + + async def test_setup_entry_success( hass: HomeAssistant, entity_registry: er.EntityRegistry, @@ -76,6 +94,7 @@ async def test_setup_entry_no_address_loads_and_registers( hass: HomeAssistant, entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, + issue_registry: ir.IssueRegistry, mock_cloud_account: AsyncMock, ) -> None: """Test setup with no known LAN address loads and registers the device. @@ -84,7 +103,8 @@ async def test_setup_entry_no_address_loads_and_registers( resolved address the device cannot be polled, so it creates no entity — but it is registered with its MAC so "registered_devices" DHCP discovery can supply the IP and reload the entry. Setup must not retry (which would hammer - the cloud API) since retrying can never resolve the address. + the cloud API) since retrying can never resolve the address. The missing + address is surfaced as a repair issue rather than failing silently. """ entry = MockConfigEntry( domain=DOMAIN, @@ -101,11 +121,114 @@ async def test_setup_entry_no_address_loads_and_registers( assert device_registry.async_get_device_by_connection( (dr.CONNECTION_NETWORK_MAC, dr.format_mac(MOCK_MAC)), entry.entry_id ) + issue = issue_registry.async_get_issue(DOMAIN, f"missing_address_{entry.entry_id}") + assert issue + assert issue.is_fixable + assert issue.severity is ir.IssueSeverity.ERROR + assert issue.data == {"entry_id": entry.entry_id} + + +@pytest.mark.parametrize( + ("entry_data", "cache", "expected_addresses", "expect_issue"), + [ + pytest.param( + {CONF_USERNAME: MOCK_USERNAME, CONF_PASSWORD: MOCK_PASSWORD}, + [_cache_entry(MOCK_ADDRESS)], + {dr.format_mac(MOCK_MAC): MOCK_ADDRESS}, + False, + id="seeds_missing_address", + ), + pytest.param( + { + CONF_USERNAME: MOCK_USERNAME, + CONF_PASSWORD: MOCK_PASSWORD, + CONF_ADDRESSES: {dr.format_mac(MOCK_MAC): MOCK_ADDRESS}, + }, + [_cache_entry("192.168.1.222")], + {dr.format_mac(MOCK_MAC): MOCK_ADDRESS}, + False, + id="stored_address_wins", + ), + pytest.param( + {CONF_USERNAME: MOCK_USERNAME, CONF_PASSWORD: MOCK_PASSWORD}, + [_cache_entry("192.168.1.60", mac="99:99:99:99:99:99")], + {}, + True, + id="ignores_unowned_mac", + ), + ], +) +@pytest.mark.usefixtures("mock_setup_integration") +async def test_setup_entry_dhcp_cache_seeding( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + entry_data: dict[str, Any], + cache: list[DhcpServiceInfo], + expected_addresses: dict[str, str], + expect_issue: bool, +) -> None: + """Test setup consults the DHCP discovery cache for missing addresses. + + A device sighted before it was registered never re-fires + registered_devices discovery, so setup looks the sighting cache up instead + of waiting for a new sighting. Stored addresses are never overwritten by + the cache (live discovery handles genuine IP changes), and sightings of + MACs the account does not own are ignored. + """ + entry = MockConfigEntry(domain=DOMAIN, data=entry_data, unique_id="user-12345") + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.mitsubishi_comfort.async_discovered_service_info", + return_value=cache, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + assert entry.data.get(CONF_ADDRESSES, {}) == expected_addresses + issue = issue_registry.async_get_issue(DOMAIN, f"missing_address_{entry.entry_id}") + assert (issue is not None) is expect_issue + + +async def test_setup_entry_caches_and_replays_credentials( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_integration: tuple[AsyncMock, MagicMock], +) -> None: + """Test credentials are persisted on the entry and replayed to discovery.""" + mock_account, _ = mock_setup_integration + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # The first setup has nothing to replay and persists the discovered + # credentials for the next one. + assert mock_account.discover_devices.call_args.kwargs["cached_credentials"] == {} + credentials = { + MOCK_SERIAL: { + "password": "dGVzdHBhc3M=", + "crypto_serial": "0102030405060708090a", + "mac": MOCK_MAC, + } + } + assert mock_config_entry.data[CONF_CREDENTIALS] == credentials + + # A reload replays the persisted credentials so discovery can skip the + # rate-limited Socket.IO fetch. + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_account.discover_devices.call_args.kwargs["cached_credentials"] == ( + credentials + ) async def test_setup_entry_resolves_address_from_entry( hass: HomeAssistant, entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, mock_config_entry: MockConfigEntry, mock_setup_integration: tuple[AsyncMock, MagicMock], ) -> None: @@ -124,16 +247,56 @@ async def test_setup_entry_resolves_address_from_entry( assert mock_config_entry.data[CONF_ADDRESSES][dr.format_mac(MOCK_MAC)] == ( MOCK_ADDRESS ) + assert not issue_registry.async_get_issue( + DOMAIN, f"missing_address_{mock_config_entry.entry_id}" + ) + + +async def test_setup_entry_prunes_stale_addresses( + hass: HomeAssistant, + mock_setup_integration: tuple[AsyncMock, MagicMock], +) -> None: + """Test a stored address for a device no longer on the account is dropped.""" + stale_mac = dr.format_mac("99:99:99:99:99:99") + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: MOCK_USERNAME, + CONF_PASSWORD: MOCK_PASSWORD, + CONF_ADDRESSES: { + dr.format_mac(MOCK_MAC): MOCK_ADDRESS, + stale_mac: "192.168.1.99", + }, + }, + unique_id="user-12345", + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + assert entry.data[CONF_ADDRESSES] == {dr.format_mac(MOCK_MAC): MOCK_ADDRESS} async def test_setup_entry_skips_incomplete_devices( hass: HomeAssistant, entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, mock_config_entry: MockConfigEntry, mock_device_info: DeviceInfo, mock_setup_integration: tuple[AsyncMock, MagicMock], + caplog: pytest.LogCaptureFixture, ) -> None: - """Test setup skips incomplete devices and creates complete ones.""" + """Test setup skips devices the cloud returned incomplete data for. + + Without a password and cryptoSerial the local API cannot be authenticated, + and without a MAC the device cannot be keyed in the address cache, so the + device is skipped (no coordinator, no entity) and the gap is logged. Any + recovered field is still cached — discover_devices() consumes them + independently, and the password in particular may never be returned by + the throttled Socket.IO fetch again. + """ incomplete_info = DeviceInfo( serial="SERIAL002", label="Bedroom", @@ -143,19 +306,59 @@ async def test_setup_entry_skips_incomplete_devices( password="", crypto_serial="", ) + no_mac_info = DeviceInfo( + serial="SERIAL003", + label="Attic", + address="", + mac="", + unit_type="ductless", + password="dGVzdHBhc3M=", + crypto_serial="0102030405060708090a", + ) mock_account, _ = mock_setup_integration mock_account.discover_devices.return_value = { "SERIAL001": mock_device_info, "SERIAL002": incomplete_info, + "SERIAL003": no_mac_info, } mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + with caplog.at_level( + logging.DEBUG, logger="homeassistant.components.mitsubishi_comfort" + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.LOADED assert entity_registry.async_get_entity_id("climate", DOMAIN, "SERIAL001") assert entity_registry.async_get_entity_id("climate", DOMAIN, "SERIAL002") is None + assert entity_registry.async_get_entity_id("climate", DOMAIN, "SERIAL003") is None + assert ( + "The cloud returned incomplete local connection data for 2 device(s):" + " Attic, Bedroom" in caplog.text + ) + assert mock_config_entry.data[CONF_CREDENTIALS] == { + "SERIAL001": { + "password": "dGVzdHBhc3M=", + "crypto_serial": "0102030405060708090a", + "mac": MOCK_MAC, + }, + "SERIAL002": { + "password": "", + "crypto_serial": "", + "mac": "11:22:33:44:55:66", + }, + "SERIAL003": { + "password": "dGVzdHBhc3M=", + "crypto_serial": "0102030405060708090a", + "mac": "", + }, + } + # Incomplete devices are not addressless: an issue for them would open a + # fix flow with zero fields. + assert not issue_registry.async_get_issue( + DOMAIN, f"missing_address_{mock_config_entry.entry_id}" + ) async def test_unload_entry( @@ -174,3 +377,239 @@ async def test_unload_entry( await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_setup_entry_registers_mac_less_devices_separately( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_setup_integration: tuple[AsyncMock, MagicMock], +) -> None: + """Test MAC-less devices get their own registry entries, sans connection. + + Connections are globally indexed, so registering an empty MAC would merge + every MAC-less device into the first one's registry entry. + """ + mock_account, _ = mock_setup_integration + mock_account.discover_devices.return_value = { + "SERIAL002": DeviceInfo( + serial="SERIAL002", + label="Bedroom", + address="", + mac="", + unit_type="ductless", + password="dGVzdHBhc3M=", + crypto_serial="0102030405060708090a", + ), + "SERIAL003": DeviceInfo( + serial="SERIAL003", + label="Attic", + address="", + mac="", + unit_type="ductless", + password="dGVzdHBhc3M=", + crypto_serial="0102030405060708090a", + ), + } + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_USERNAME: MOCK_USERNAME, CONF_PASSWORD: MOCK_PASSWORD}, + unique_id="user-12345", + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + bedroom = device_registry.async_get_device_by_identifier( + (DOMAIN, "SERIAL002"), entry.entry_id + ) + attic = device_registry.async_get_device_by_identifier( + (DOMAIN, "SERIAL003"), entry.entry_id + ) + assert bedroom is not None + assert attic is not None + assert bedroom.id != attic.id + assert not bedroom.connections + assert not attic.connections + + +async def test_failed_unload_keeps_missing_address_issue( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_cloud_account: AsyncMock, +) -> None: + """Test a failed platform unload keeps the actionable repair issue. + + A failed unload leaves the entry active with its addressless devices, so + deleting the issue first would strip the only UI path to fix them. + """ + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_USERNAME: MOCK_USERNAME, CONF_PASSWORD: MOCK_PASSWORD}, + unique_id="user-12345", + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + issue_id = f"missing_address_{entry.entry_id}" + assert issue_registry.async_get_issue(DOMAIN, issue_id) + + with patch( + "homeassistant.config_entries.ConfigEntries.async_unload_platforms", + return_value=False, + ): + assert not await hass.config_entries.async_unload(entry.entry_id) + + assert entry.state is ConfigEntryState.FAILED_UNLOAD + assert issue_registry.async_get_issue(DOMAIN, issue_id) + + +async def test_setup_retry_raises_issue_from_cached_credentials( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_cloud_account: AsyncMock, +) -> None: + """Test a cloud-down setup still offers the fix flow from stored data. + + The issue is not persistent and unload deletes it, so a restart or reload + that cannot reach the cloud must reconcile it from the entry data alone. + """ + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: MOCK_USERNAME, + CONF_PASSWORD: MOCK_PASSWORD, + CONF_CREDENTIALS: { + MOCK_SERIAL: { + "password": "dGVzdHBhc3M=", + "crypto_serial": "0102030405060708090a", + "mac": MOCK_MAC, + } + }, + }, + unique_id="user-12345", + ) + entry.add_to_hass(hass) + mock_cloud_account.login.side_effect = DeviceConnectionError("cloud down") + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_RETRY + assert issue_registry.async_get_issue(DOMAIN, f"missing_address_{entry.entry_id}") + + +async def test_setup_retry_clears_stale_issue_when_all_addressed( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_cloud_account: AsyncMock, +) -> None: + """Test a cloud-down setup clears an issue whose devices got addresses.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: MOCK_USERNAME, + CONF_PASSWORD: MOCK_PASSWORD, + CONF_ADDRESSES: {dr.format_mac(MOCK_MAC): MOCK_ADDRESS}, + CONF_CREDENTIALS: { + MOCK_SERIAL: { + "password": "dGVzdHBhc3M=", + "crypto_serial": "0102030405060708090a", + "mac": MOCK_MAC, + }, + # A MAC-only record cannot be probed, so it must not count + # as addressless. + "SERIAL002": { + "password": "", + "crypto_serial": "", + "mac": "11:22:33:44:55:66", + }, + }, + }, + unique_id="user-12345", + ) + entry.add_to_hass(hass) + issue_id = f"missing_address_{entry.entry_id}" + ir.async_create_issue( + hass, + DOMAIN, + issue_id, + is_fixable=True, + severity=ir.IssueSeverity.ERROR, + translation_key="missing_address", + data={"entry_id": entry.entry_id}, + ) + mock_cloud_account.login.side_effect = DeviceConnectionError("cloud down") + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_RETRY + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + + +async def test_remove_never_loaded_entry_clears_missing_address_issue( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_setup_integration: tuple[AsyncMock, MagicMock], + mock_device_info: DeviceInfo, + mock_config_entry: MockConfigEntry, +) -> None: + """Test removing an entry stuck in setup retry clears the repair issue. + + Removal never calls async_unload_entry for an entry that failed setup, so + without the remove hook the issue would outlive the entry. + """ + mock_account, mock_device = mock_setup_integration + mock_account.discover_devices.return_value = { + MOCK_SERIAL: mock_device_info, + "SERIAL002": DeviceInfo( + serial="SERIAL002", + label="Bedroom", + address="", + mac="11:22:33:44:55:66", + unit_type="ductless", + password="dGVzdHBhc3M=", + crypto_serial="0102030405060708090a", + ), + } + mock_device.update_status.side_effect = DeviceConnectionError("boom") + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + issue_id = f"missing_address_{mock_config_entry.entry_id}" + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert issue_registry.async_get_issue(DOMAIN, issue_id) + + await hass.config_entries.async_remove(mock_config_entry.entry_id) + + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + + +async def test_unload_entry_clears_missing_address_issue( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_cloud_account: AsyncMock, +) -> None: + """Test unloading clears the missing-address repair issue. + + Without the cleanup, removing the integration would leave a stale issue for + a device that never had a resolved LAN address. + """ + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_USERNAME: MOCK_USERNAME, CONF_PASSWORD: MOCK_PASSWORD}, + unique_id="user-12345", + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + issue_id = f"missing_address_{entry.entry_id}" + assert issue_registry.async_get_issue(DOMAIN, issue_id) + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert not issue_registry.async_get_issue(DOMAIN, issue_id) diff --git a/tests/components/mitsubishi_comfort/test_repairs.py b/tests/components/mitsubishi_comfort/test_repairs.py new file mode 100644 index 000000000000..e5b4b9b3bf0c --- /dev/null +++ b/tests/components/mitsubishi_comfort/test_repairs.py @@ -0,0 +1,655 @@ +"""Tests for the Mitsubishi Comfort repairs flow.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +from mitsubishi_comfort import DeviceInfo +from mitsubishi_comfort.exceptions import DeviceConnectionError +import pytest + +from homeassistant.components.mitsubishi_comfort.const import ( + CONF_ADDRESSES, + CONF_CREDENTIALS, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, issue_registry as ir +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from homeassistant.setup import async_setup_component + +from .conftest import MOCK_MAC, MOCK_PASSWORD, MOCK_SERIAL, MOCK_USERNAME + +from tests.common import MockConfigEntry +from tests.components.repairs import process_repair_fix_flow, start_repair_fix_flow +from tests.typing import ClientSessionGenerator + +pytestmark = pytest.mark.usefixtures("mock_setup_integration") + +# The per-device IP fields are keyed by formatted MAC (dynamic), so they have +# no static label in strings.json; ignore that in the translation check. +IGNORE_FORM_TRANSLATIONS = [ + "component.mitsubishi_comfort.issues.missing_address.fix_flow.step.addresses.data.", + "component.mitsubishi_comfort.issues.missing_address.fix_flow.step.addresses.data_description.", +] + + +def _second_device_info() -> DeviceInfo: + """Build a second fully-credentialed device without a LAN address.""" + return DeviceInfo( + serial="SERIAL002", + label="Bedroom", + address="", + mac="11:22:33:44:55:66", + unit_type="ductless", + password="dGVzdHBhc3M=", + crypto_serial="0102030405060708090a", + ) + + +async def _setup_addressless_entry(hass: HomeAssistant) -> MockConfigEntry: + """Set up an entry whose device has no LAN address, raising the issue.""" + assert await async_setup_component(hass, "repairs", {}) + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_USERNAME: MOCK_USERNAME, CONF_PASSWORD: MOCK_PASSWORD}, + unique_id="user-12345", + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + return entry + + +@pytest.mark.parametrize("ignore_missing_translations", [IGNORE_FORM_TRANSLATIONS]) +@pytest.mark.parametrize( + "invalid_value", + [ + pytest.param("not-an-ip", id="not_an_ip"), + pytest.param("2001:db8::1", id="ipv6"), + ], +) +async def test_fix_flow_sets_missing_address( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, + invalid_value: str, +) -> None: + """Test the fix flow records a manually entered IP and resolves the issue. + + Non-IPv4 input is rejected: the local API URL is built without IPv6 + brackets, so an IPv6 literal can never work. + """ + entry = await _setup_addressless_entry(hass) + issue_id = f"missing_address_{entry.entry_id}" + assert issue_registry.async_get_issue(DOMAIN, issue_id) + + client = await hass_client() + data = await start_repair_fix_flow(client, DOMAIN, issue_id) + flow_id = data["flow_id"] + assert data["step_id"] == "addresses" + # The fields are labeled by raw MAC, so the description must pair each MAC + # with its device name for the user to tell the fields apart. + assert dr.format_mac(MOCK_MAC) in data["description_placeholders"]["devices"] + + data = await process_repair_fix_flow( + client, flow_id, json={dr.format_mac(MOCK_MAC): invalid_value} + ) + assert data["errors"] == {dr.format_mac(MOCK_MAC): "invalid_ip"} + + with patch( + "homeassistant.components.mitsubishi_comfort.repairs.probe_candidate_ips", + return_value={MOCK_SERIAL: "192.168.1.50"}, + ) as mock_probe: + data = await process_repair_fix_flow( + client, flow_id, json={dr.format_mac(MOCK_MAC): "192.168.1.50"} + ) + assert data["type"] == "create_entry" + assert mock_probe.call_args.kwargs["session"] is async_get_clientsession(hass) + # The probe must authenticate with the cached local secrets; without them + # every correct IP would be rejected as cannot_connect. + probed = mock_probe.call_args.args[0] + assert probed[MOCK_SERIAL].password == "dGVzdHBhc3M=" + assert probed[MOCK_SERIAL].crypto_serial == "0102030405060708090a" + await hass.async_block_till_done() + + assert entry.data[CONF_ADDRESSES][dr.format_mac(MOCK_MAC)] == "192.168.1.50" + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + + +@pytest.mark.parametrize("ignore_missing_translations", [IGNORE_FORM_TRANSLATIONS]) +async def test_fix_flow_blank_field_keeps_issue( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, +) -> None: + """Test leaving a field blank keeps the device addressless. + + The repairs framework deletes the issue when the flow completes; the + reload the flow schedules re-creates it while any device still lacks an + address. + """ + entry = await _setup_addressless_entry(hass) + issue_id = f"missing_address_{entry.entry_id}" + + client = await hass_client() + data = await start_repair_fix_flow(client, DOMAIN, issue_id) + data = await process_repair_fix_flow(client, data["flow_id"], json={}) + assert data["type"] == "create_entry" + await hass.async_block_till_done() + + assert not entry.data.get(CONF_ADDRESSES) + assert issue_registry.async_get_issue(DOMAIN, issue_id) + + +@pytest.mark.parametrize("ignore_missing_translations", [IGNORE_FORM_TRANSLATIONS]) +@pytest.mark.parametrize( + ("submission", "issue_expected"), + [ + pytest.param({}, True, id="still_addressless"), + pytest.param( + {dr.format_mac(MOCK_MAC): "192.168.1.50"}, False, id="fully_addressed" + ), + ], +) +async def test_fix_flow_failed_reload_restores_issue( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, + submission: dict[str, str], + issue_expected: bool, +) -> None: + """Test a reload whose unload fails restores the missing-address issue. + + The repairs framework deletes the issue when the flow finishes, and a + failed unload stops the reload before setup can re-create it — so the + reload task restores the issue itself, but only while devices actually + remain addressless. + """ + entry = await _setup_addressless_entry(hass) + issue_id = f"missing_address_{entry.entry_id}" + + client = await hass_client() + data = await start_repair_fix_flow(client, DOMAIN, issue_id) + with ( + patch( + "homeassistant.components.mitsubishi_comfort.repairs.probe_candidate_ips", + return_value={MOCK_SERIAL: "192.168.1.50"}, + ), + patch( + "homeassistant.components.mitsubishi_comfort.async_unload_entry", + return_value=False, + ), + ): + data = await process_repair_fix_flow(client, data["flow_id"], json=submission) + assert data["type"] == "create_entry" + await hass.async_block_till_done() + + assert bool(issue_registry.async_get_issue(DOMAIN, issue_id)) is issue_expected + + +@pytest.mark.parametrize("ignore_missing_translations", [IGNORE_FORM_TRANSLATIONS]) +async def test_fix_flow_retry_on_wedged_entry_keeps_issue( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, +) -> None: + """Test a repeat repair attempt on a FAILED_UNLOAD entry keeps the issue. + + The first failed reload leaves the entry non-recoverable, so the second + attempt's reload raises OperationNotAllowed instead of returning False; + the issue must survive that path too or the still-addressless entry loses + its only fix-flow path until restart. + """ + entry = await _setup_addressless_entry(hass) + issue_id = f"missing_address_{entry.entry_id}" + + client = await hass_client() + data = await start_repair_fix_flow(client, DOMAIN, issue_id) + with patch( + "homeassistant.components.mitsubishi_comfort.async_unload_entry", + return_value=False, + ): + data = await process_repair_fix_flow(client, data["flow_id"], json={}) + await hass.async_block_till_done() + assert entry.state is ConfigEntryState.FAILED_UNLOAD + assert issue_registry.async_get_issue(DOMAIN, issue_id) + + data = await start_repair_fix_flow(client, DOMAIN, issue_id) + data = await process_repair_fix_flow(client, data["flow_id"], json={}) + assert data["type"] == "create_entry" + await hass.async_block_till_done() + + assert issue_registry.async_get_issue(DOMAIN, issue_id) + + +@pytest.mark.parametrize("ignore_missing_translations", [IGNORE_FORM_TRANSLATIONS]) +async def test_fix_flow_failed_reload_ignores_partial_records( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, + mock_setup_integration: tuple[AsyncMock, MagicMock], + mock_device_info: DeviceInfo, +) -> None: + """Test a partial credential record cannot trigger issue restoration. + + A MAC-less record never gets an address, but it also cannot be offered in + the fix flow, so restoring the issue for it would create an unfixable + repair. + """ + mock_account, _ = mock_setup_integration + mock_account.discover_devices.return_value = { + MOCK_SERIAL: mock_device_info, + "SERIAL002": DeviceInfo( + serial="SERIAL002", + label="Bedroom", + address="", + mac="", + unit_type="ductless", + password="dGVzdHBhc3M=", + crypto_serial="0102030405060708090a", + ), + } + entry = await _setup_addressless_entry(hass) + issue_id = f"missing_address_{entry.entry_id}" + mac = dr.format_mac(MOCK_MAC) + + client = await hass_client() + data = await start_repair_fix_flow(client, DOMAIN, issue_id) + with ( + patch( + "homeassistant.components.mitsubishi_comfort.repairs.probe_candidate_ips", + return_value={MOCK_SERIAL: "192.168.1.50"}, + ), + patch( + "homeassistant.components.mitsubishi_comfort.async_unload_entry", + return_value=False, + ), + ): + data = await process_repair_fix_flow( + client, data["flow_id"], json={mac: "192.168.1.50"} + ) + assert data["type"] == "create_entry" + await hass.async_block_till_done() + + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + + +@pytest.mark.parametrize("ignore_missing_translations", [IGNORE_FORM_TRANSLATIONS]) +async def test_fix_flow_lists_only_addressless_devices( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_setup_integration: tuple[AsyncMock, MagicMock], + mock_device_info: DeviceInfo, +) -> None: + """Test the form omits devices that already have a stored address.""" + mock_account, _ = mock_setup_integration + mock_account.discover_devices.return_value = { + MOCK_SERIAL: mock_device_info, + "SERIAL002": _second_device_info(), + } + assert await async_setup_component(hass, "repairs", {}) + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: MOCK_USERNAME, + CONF_PASSWORD: MOCK_PASSWORD, + CONF_ADDRESSES: {dr.format_mac(MOCK_MAC): "192.168.1.100"}, + }, + unique_id="user-12345", + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + client = await hass_client() + data = await start_repair_fix_flow( + client, DOMAIN, f"missing_address_{entry.entry_id}" + ) + + second_mac = dr.format_mac("11:22:33:44:55:66") + assert [field["name"] for field in data["data_schema"]] == [second_mac] + assert data["description_placeholders"]["devices"] == f"Bedroom ({second_mac})" + + +@pytest.mark.parametrize("ignore_missing_translations", [IGNORE_FORM_TRANSLATIONS]) +async def test_fix_flow_offers_cached_devices_before_first_discovery( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, + mock_setup_integration: tuple[AsyncMock, MagicMock], +) -> None: + """Test the form offers cached devices when no discovery ever succeeded. + + The registry is empty then, so the fields fall back to the credential + cache with the serial as the label; a registry-only form would render + zero fields and make the repair a dead-end loop while the cloud is down. + """ + assert await async_setup_component(hass, "repairs", {}) + mock_account, _ = mock_setup_integration + mock_account.login.side_effect = DeviceConnectionError("cloud down") + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: MOCK_USERNAME, + CONF_PASSWORD: MOCK_PASSWORD, + CONF_CREDENTIALS: { + MOCK_SERIAL: { + "password": "dGVzdHBhc3M=", + "crypto_serial": "0102030405060708090a", + "mac": MOCK_MAC, + } + }, + }, + unique_id="user-12345", + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + issue_id = f"missing_address_{entry.entry_id}" + assert issue_registry.async_get_issue(DOMAIN, issue_id) + + mac = dr.format_mac(MOCK_MAC) + client = await hass_client() + data = await start_repair_fix_flow(client, DOMAIN, issue_id) + assert [field["name"] for field in data["data_schema"]] == [mac] + assert data["description_placeholders"]["devices"] == f"{MOCK_SERIAL} ({mac})" + + with patch( + "homeassistant.components.mitsubishi_comfort.repairs.probe_candidate_ips", + return_value={MOCK_SERIAL: "192.168.1.50"}, + ): + data = await process_repair_fix_flow( + client, data["flow_id"], json={mac: "192.168.1.50"} + ) + assert data["type"] == "create_entry" + await hass.async_block_till_done() + + assert entry.data[CONF_ADDRESSES][mac] == "192.168.1.50" + # Every cached device is addressed now, so the failed reload (the cloud + # is still down) must not resurrect the issue. + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + + +@pytest.mark.parametrize("ignore_missing_translations", [IGNORE_FORM_TRANSLATIONS]) +async def test_fix_flow_suggests_cached_ip( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, +) -> None: + """Test the form pre-fills an IP the DHCP cache saw after setup.""" + entry = await _setup_addressless_entry(hass) + issue_id = f"missing_address_{entry.entry_id}" + + client = await hass_client() + with patch( + "homeassistant.components.mitsubishi_comfort.repairs.async_discovered_service_info", + return_value=[ + DhcpServiceInfo( + ip="10.0.0.5", + hostname="kumo", + macaddress=MOCK_MAC.replace(":", "").lower(), + ) + ], + ): + data = await start_repair_fix_flow(client, DOMAIN, issue_id) + + assert data["step_id"] == "addresses" + assert data["data_schema"][0]["description"] == {"suggested_value": "10.0.0.5"} + + +@pytest.mark.parametrize("ignore_missing_translations", [IGNORE_FORM_TRANSLATIONS]) +async def test_fix_flow_keeps_address_discovered_during_probe( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_setup_integration: tuple[AsyncMock, MagicMock], + mock_device_info: DeviceInfo, +) -> None: + """Test an address stored by DHCP during the probe await is not erased.""" + second = _second_device_info() + mock_account, _ = mock_setup_integration + mock_account.discover_devices.return_value = { + "SERIAL001": mock_device_info, + "SERIAL002": second, + } + entry = await _setup_addressless_entry(hass) + second_mac = dr.format_mac(second.mac) + + async def _probe_with_concurrent_discovery( + *args: object, **kwargs: object + ) -> dict[str, str]: + hass.config_entries.async_update_entry( + entry, + data={**entry.data, CONF_ADDRESSES: {second_mac: "192.168.1.60"}}, + ) + return {MOCK_SERIAL: "192.168.1.50"} + + client = await hass_client() + data = await start_repair_fix_flow( + client, DOMAIN, f"missing_address_{entry.entry_id}" + ) + with patch( + "homeassistant.components.mitsubishi_comfort.repairs.probe_candidate_ips", + side_effect=_probe_with_concurrent_discovery, + ): + data = await process_repair_fix_flow( + client, data["flow_id"], json={dr.format_mac(MOCK_MAC): "192.168.1.50"} + ) + assert data["type"] == "create_entry" + await hass.async_block_till_done() + + assert entry.data[CONF_ADDRESSES] == { + second_mac: "192.168.1.60", + dr.format_mac(MOCK_MAC): "192.168.1.50", + } + + +@pytest.mark.parametrize("ignore_missing_translations", [IGNORE_FORM_TRANSLATIONS]) +async def test_fix_flow_rejects_unreachable_address( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, +) -> None: + """Test an address whose device fails the authenticated probe is rejected. + + Storing an unverified address would suppress this repair while the entry + is stuck retrying its first refresh, with no UI path left to correct it. + """ + entry = await _setup_addressless_entry(hass) + + client = await hass_client() + data = await start_repair_fix_flow( + client, DOMAIN, f"missing_address_{entry.entry_id}" + ) + with patch( + "homeassistant.components.mitsubishi_comfort.repairs.probe_candidate_ips", + return_value={}, + ) as mock_probe: + data = await process_repair_fix_flow( + client, data["flow_id"], json={dr.format_mac(MOCK_MAC): "192.168.1.77"} + ) + + assert data["errors"] == {dr.format_mac(MOCK_MAC): "cannot_connect"} + assert mock_probe.call_args.args[1] == ["192.168.1.77"] + # The re-rendered form keeps what the user typed. + assert data["data_schema"][0]["description"] == {"suggested_value": "192.168.1.77"} + assert not entry.data.get(CONF_ADDRESSES) + + +@pytest.mark.parametrize("ignore_missing_translations", [IGNORE_FORM_TRANSLATIONS]) +async def test_fix_flow_flags_each_unreachable_field( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_setup_integration: tuple[AsyncMock, MagicMock], + mock_device_info: DeviceInfo, +) -> None: + """Test only the fields whose probes fail carry the connection error. + + With several devices submitted, a base error would not tell the user + which address is wrong. + """ + second = _second_device_info() + mock_account, _ = mock_setup_integration + mock_account.discover_devices.return_value = { + MOCK_SERIAL: mock_device_info, + "SERIAL002": second, + } + entry = await _setup_addressless_entry(hass) + second_mac = dr.format_mac(second.mac) + + async def _probe_first_only( + devices: dict[str, DeviceInfo], ips: list[str], **kwargs: object + ) -> dict[str, str]: + serial = next(iter(devices)) + return {serial: ips[0]} if serial == MOCK_SERIAL else {} + + client = await hass_client() + data = await start_repair_fix_flow( + client, DOMAIN, f"missing_address_{entry.entry_id}" + ) + with patch( + "homeassistant.components.mitsubishi_comfort.repairs.probe_candidate_ips", + side_effect=_probe_first_only, + ): + data = await process_repair_fix_flow( + client, + data["flow_id"], + json={ + dr.format_mac(MOCK_MAC): "192.168.1.50", + second_mac: "192.168.1.60", + }, + ) + + assert data["errors"] == {second_mac: "cannot_connect"} + assert not entry.data.get(CONF_ADDRESSES) + + +@pytest.mark.parametrize("ignore_missing_translations", [IGNORE_FORM_TRANSLATIONS]) +async def test_fix_flow_concurrent_lease_wins_over_entry( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, +) -> None: + """Test a lease DHCP stored during the probe beats the form entry. + + Live discovery saw the device after the user typed the address, so the + stored lease is the fresher fact for the same MAC. + """ + entry = await _setup_addressless_entry(hass) + mac = dr.format_mac(MOCK_MAC) + + async def _probe_with_concurrent_lease( + *args: object, **kwargs: object + ) -> dict[str, str]: + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_ADDRESSES: {mac: "192.168.1.99"}} + ) + return {MOCK_SERIAL: "192.168.1.50"} + + client = await hass_client() + data = await start_repair_fix_flow( + client, DOMAIN, f"missing_address_{entry.entry_id}" + ) + with patch( + "homeassistant.components.mitsubishi_comfort.repairs.probe_candidate_ips", + side_effect=_probe_with_concurrent_lease, + ): + data = await process_repair_fix_flow( + client, data["flow_id"], json={mac: "192.168.1.50"} + ) + assert data["type"] == "create_entry" + await hass.async_block_till_done() + + assert entry.data[CONF_ADDRESSES][mac] == "192.168.1.99" + + +@pytest.mark.parametrize("ignore_missing_translations", [IGNORE_FORM_TRANSLATIONS]) +async def test_fix_flow_omits_devices_without_secrets( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_setup_integration: tuple[AsyncMock, MagicMock], + mock_device_info: DeviceInfo, +) -> None: + """Test the form skips devices whose local secrets are missing. + + A partial record can hold a MAC with no password or cryptoSerial; any + address entered for it is guaranteed to fail the authenticated probe, + and setup counts that device as incomplete rather than addressless. + """ + secretless = DeviceInfo( + serial="SERIAL002", + label="Bedroom", + address="", + mac="11:22:33:44:55:66", + unit_type="ductless", + password="", + crypto_serial="", + ) + mock_account, _ = mock_setup_integration + mock_account.discover_devices.return_value = { + "SERIAL001": mock_device_info, + "SERIAL002": secretless, + } + entry = await _setup_addressless_entry(hass) + + client = await hass_client() + data = await start_repair_fix_flow( + client, DOMAIN, f"missing_address_{entry.entry_id}" + ) + + assert data["step_id"] == "addresses" + assert [field["name"] for field in data["data_schema"]] == [dr.format_mac(MOCK_MAC)] + + +@pytest.mark.parametrize("ignore_missing_translations", [IGNORE_FORM_TRANSLATIONS]) +async def test_fix_flow_omits_devices_no_longer_on_account( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + device_registry: dr.DeviceRegistry, +) -> None: + """Test the form skips registry devices the account no longer has. + + Setup prunes the credential cache but leaves old device registry entries, + so the form intersects with the cache to ask only for current devices. + """ + entry = await _setup_addressless_entry(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, "REMOVED01")}, + connections={(dr.CONNECTION_NETWORK_MAC, "99:99:99:99:99:99")}, + ) + + client = await hass_client() + data = await start_repair_fix_flow( + client, DOMAIN, f"missing_address_{entry.entry_id}" + ) + + assert data["step_id"] == "addresses" + assert [field["name"] for field in data["data_schema"]] == [dr.format_mac(MOCK_MAC)] + assert "99:99:99:99:99:99" not in data["description_placeholders"]["devices"] + + +async def test_fix_flow_without_entry_falls_back_to_confirm( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, +) -> None: + """Test an issue whose entry no longer exists gets a confirm flow.""" + assert await async_setup_component(hass, "repairs", {}) + ir.async_create_issue( + hass, + DOMAIN, + "missing_address_gone", + is_fixable=True, + severity=ir.IssueSeverity.WARNING, + translation_key="missing_address", + data={"entry_id": "nonexistent"}, + ) + + client = await hass_client() + data = await start_repair_fix_flow(client, DOMAIN, "missing_address_gone") + assert data["step_id"] == "confirm" + + data = await process_repair_fix_flow(client, data["flow_id"], json={}) + assert data["type"] == "create_entry" + await hass.async_block_till_done() + + assert not issue_registry.async_get_issue(DOMAIN, "missing_address_gone")