diff --git a/homeassistant/components/husqvarna_automower_ble/__init__.py b/homeassistant/components/husqvarna_automower_ble/__init__.py index 4537dec0e28..89de3336440 100644 --- a/homeassistant/components/husqvarna_automower_ble/__init__.py +++ b/homeassistant/components/husqvarna_automower_ble/__init__.py @@ -9,11 +9,11 @@ from bleak_retry_connector import close_stale_connections_by_address, get_device from homeassistant.components import bluetooth from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ADDRESS, CONF_CLIENT_ID, Platform +from homeassistant.const import CONF_ADDRESS, CONF_CLIENT_ID, CONF_PIN, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from .const import LOGGER +from .const import DOMAIN, LOGGER from .coordinator import HusqvarnaCoordinator type HusqvarnaConfigEntry = ConfigEntry[HusqvarnaCoordinator] @@ -26,10 +26,18 @@ PLATFORMS = [ async def async_setup_entry(hass: HomeAssistant, entry: HusqvarnaConfigEntry) -> bool: """Set up Husqvarna Autoconnect Bluetooth from a config entry.""" + if CONF_PIN not in entry.data: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="pin_required", + translation_placeholders={"domain_name": "Husqvarna Automower BLE"}, + ) + address = entry.data[CONF_ADDRESS] + pin = int(entry.data[CONF_PIN]) channel_id = entry.data[CONF_CLIENT_ID] - mower = Mower(channel_id, address) + mower = Mower(channel_id, address, pin) await close_stale_connections_by_address(address) @@ -39,6 +47,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: HusqvarnaConfigEntry) -> hass, address, connectable=True ) or await get_device(address) response_result = await mower.connect(device) + if response_result == ResponseResult.INVALID_PIN: + raise ConfigEntryAuthFailed( + f"Unable to connect to device {address} due to wrong PIN" + ) if response_result != ResponseResult.OK: raise ConfigEntryNotReady( f"Unable to connect to device {address}, mower returned {response_result}" diff --git a/homeassistant/components/husqvarna_automower_ble/config_flow.py b/homeassistant/components/husqvarna_automower_ble/config_flow.py index 72835c22334..15de6bde708 100644 --- a/homeassistant/components/husqvarna_automower_ble/config_flow.py +++ b/homeassistant/components/husqvarna_automower_ble/config_flow.py @@ -2,17 +2,20 @@ from __future__ import annotations +from collections.abc import Mapping import random from typing import Any from automower_ble.mower import Mower +from automower_ble.protocol import ResponseResult from bleak import BleakError +from bleak_retry_connector import get_device import voluptuous as vol from homeassistant.components import bluetooth from homeassistant.components.bluetooth import BluetoothServiceInfo -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_ADDRESS, CONF_CLIENT_ID +from homeassistant.config_entries import SOURCE_BLUETOOTH, ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_ADDRESS, CONF_CLIENT_ID, CONF_PIN from .const import DOMAIN, LOGGER @@ -39,14 +42,23 @@ def _is_supported(discovery_info: BluetoothServiceInfo): return manufacturer and service_husqvarna and service_generic +def _pin_valid(pin: str) -> bool: + """Check if the pin is valid.""" + try: + int(pin) + except (TypeError, ValueError): + return False + return True + + class HusqvarnaAutomowerBleConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Husqvarna Bluetooth.""" VERSION = 1 - def __init__(self) -> None: - """Initialize the config flow.""" - self.address: str | None + address: str | None = None + mower_name: str = "" + pin: str | None = None async def async_step_bluetooth( self, discovery_info: BluetoothServiceInfo @@ -60,62 +72,244 @@ class HusqvarnaAutomowerBleConfigFlow(ConfigFlow, domain=DOMAIN): self.address = discovery_info.address await self.async_set_unique_id(self.address) self._abort_if_unique_id_configured() - return await self.async_step_confirm() + return await self.async_step_bluetooth_confirm() - async def async_step_confirm( + async def async_step_bluetooth_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Confirm discovery.""" + """Confirm Bluetooth discovery.""" assert self.address + errors: dict[str, str] = {} - device = bluetooth.async_ble_device_from_address( - self.hass, self.address, connectable=True + if user_input is not None: + if not _pin_valid(user_input[CONF_PIN]): + errors["base"] = "invalid_pin" + else: + self.pin = user_input[CONF_PIN] + return await self.check_mower(user_input) + + return self.async_show_form( + step_id="bluetooth_confirm", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_PIN): str, + }, + ), + user_input, + ), + description_placeholders={"name": self.mower_name or self.address}, + errors=errors, ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial manual step.""" + errors: dict[str, str] = {} + + if user_input is not None: + if not _pin_valid(user_input[CONF_PIN]): + errors["base"] = "invalid_pin" + else: + self.address = user_input[CONF_ADDRESS] + self.pin = user_input[CONF_PIN] + await self.async_set_unique_id(self.address, raise_on_progress=False) + self._abort_if_unique_id_configured() + return await self.check_mower(user_input) + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_ADDRESS): str, + vol.Required(CONF_PIN): str, + }, + ), + user_input, + ), + errors=errors, + ) + + async def probe_mower(self, device) -> str | None: + """Probe the mower to see if it exists.""" channel_id = random.randint(1, 0xFFFFFFFF) + assert self.address + try: (manufacturer, device_type, model) = await Mower( channel_id, self.address ).probe_gatts(device) except (BleakError, TimeoutError) as exception: - LOGGER.exception("Failed to connect to device: %s", exception) - return self.async_abort(reason="cannot_connect") + LOGGER.exception("Failed to probe device (%s): %s", self.address, exception) + return None title = manufacturer + " " + device_type LOGGER.debug("Found device: %s", title) - if user_input is not None: - return self.async_create_entry( - title=title, - data={CONF_ADDRESS: self.address, CONF_CLIENT_ID: channel_id}, - ) + return title + + async def connect_mower(self, device) -> tuple[int, Mower]: + """Connect to the Mower.""" + assert self.address + assert self.pin is not None + + channel_id = random.randint(1, 0xFFFFFFFF) + mower = Mower(channel_id, self.address, int(self.pin)) + + return (channel_id, mower) + + async def check_mower( + self, + user_input: dict[str, Any] | None = None, + ) -> ConfigFlowResult: + """Check that the mower exists and is setup.""" + assert self.address + + device = bluetooth.async_ble_device_from_address( + self.hass, self.address, connectable=True + ) + + title = await self.probe_mower(device) + if title is None: + return self.async_abort(reason="cannot_connect") + self.mower_name = title + + try: + errors: dict[str, str] = {} + + (channel_id, mower) = await self.connect_mower(device) + + response_result = await mower.connect(device) + await mower.disconnect() + + if response_result is not ResponseResult.OK: + LOGGER.debug("cannot connect, response: %s", response_result) + + if ( + response_result is ResponseResult.INVALID_PIN + or response_result is ResponseResult.NOT_ALLOWED + ): + errors["base"] = "invalid_auth" + else: + errors["base"] = "cannot_connect" + + if self.source == SOURCE_BLUETOOTH: + return self.async_show_form( + step_id="bluetooth_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_PIN): str, + }, + ), + description_placeholders={ + "name": self.mower_name or self.address + }, + errors=errors, + ) + + suggested_values = {} + + if self.address: + suggested_values[CONF_ADDRESS] = self.address + if self.pin: + suggested_values[CONF_PIN] = self.pin + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_ADDRESS): str, + vol.Required(CONF_PIN): str, + }, + ), + suggested_values, + ), + errors=errors, + ) + except (TimeoutError, BleakError): + return self.async_abort(reason="cannot_connect") + + return self.async_create_entry( + title=title, + data={ + CONF_ADDRESS: self.address, + CONF_CLIENT_ID: channel_id, + CONF_PIN: self.pin, + }, + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauthentication upon an API authentication error.""" + reauth_entry = self._get_reauth_entry() + self.address = reauth_entry.data[CONF_ADDRESS] + self.mower_name = reauth_entry.title + self.pin = reauth_entry.data.get(CONF_PIN, "") self.context["title_placeholders"] = { - "name": title, + "name": self.mower_name, + "address": self.address, } + return await self.async_step_reauth_confirm() - self._set_confirm_only() - return self.async_show_form( - step_id="confirm", - description_placeholders=self.context["title_placeholders"], - ) - - async def async_step_user( + async def async_step_reauth_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle the initial step.""" - if user_input is not None: - self.address = user_input[CONF_ADDRESS] - await self.async_set_unique_id(self.address, raise_on_progress=False) - self._abort_if_unique_id_configured() - return await self.async_step_confirm() + """Confirm reauthentication dialog.""" + errors: dict[str, str] = {} + + if user_input is not None and not _pin_valid(user_input[CONF_PIN]): + errors["base"] = "invalid_pin" + elif user_input is not None: + reauth_entry = self._get_reauth_entry() + self.pin = user_input[CONF_PIN] + + try: + assert self.address + device = bluetooth.async_ble_device_from_address( + self.hass, self.address, connectable=True + ) or await get_device(self.address) + + mower = Mower( + reauth_entry.data[CONF_CLIENT_ID], self.address, int(self.pin) + ) + + response_result = await mower.connect(device) + await mower.disconnect() + if ( + response_result is ResponseResult.INVALID_PIN + or response_result is ResponseResult.NOT_ALLOWED + ): + errors["base"] = "invalid_auth" + elif response_result is not ResponseResult.OK: + errors["base"] = "cannot_connect" + else: + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data=reauth_entry.data | {CONF_PIN: self.pin}, + ) + + except (TimeoutError, BleakError): + # We don't want to abort a reauth flow when we can't connect, so + # we just show the form again with an error. + errors["base"] = "cannot_connect" return self.async_show_form( - step_id="user", - data_schema=vol.Schema( - { - vol.Required(CONF_ADDRESS): str, - }, + step_id="reauth_confirm", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_PIN): str, + }, + ), + {CONF_PIN: self.pin}, ), + description_placeholders={"name": self.mower_name}, + errors=errors, ) diff --git a/homeassistant/components/husqvarna_automower_ble/strings.json b/homeassistant/components/husqvarna_automower_ble/strings.json index de0a140933a..64ae632330c 100644 --- a/homeassistant/components/husqvarna_automower_ble/strings.json +++ b/homeassistant/components/husqvarna_automower_ble/strings.json @@ -4,18 +4,49 @@ "step": { "user": { "data": { - "address": "Device BLE address" + "address": "Device BLE address", + "pin": "Mower PIN" + }, + "data_description": { + "pin": "The PIN used to secure the mower" } }, - "confirm": { - "description": "Do you want to set up {name}? Make sure the mower is in pairing mode" + "bluetooth_confirm": { + "description": "Do you want to set up {name}?\nMake sure the mower is in pairing mode.", + "data": { + "pin": "[%key:component::husqvarna_automower_ble::config::step::user::data::pin%]" + }, + "data_description": { + "pin": "[%key:component::husqvarna_automower_ble::config::step::user::data_description::pin%]" + } + }, + "reauth_confirm": { + "description": "Please confirm the PIN for {name}.", + "data": { + "pin": "[%key:component::husqvarna_automower_ble::config::step::user::data::pin%]" + }, + "data_description": { + "pin": "[%key:component::husqvarna_automower_ble::config::step::user::data_description::pin%]" + } } }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "no_devices_found": "Ensure the mower is in pairing mode and try again. It can take a few attempts.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "not_allowed": "Unable to read data from the mower, this usually means it is not paired", "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "Unable to pair with device, ensure the PIN is correct and the mower is in pairing mode", + "invalid_pin": "The PIN must be a number" + } + }, + "exceptions": { + "pin_required": { + "message": "PIN is required for {domain_name}" } } } diff --git a/tests/components/husqvarna_automower_ble/conftest.py b/tests/components/husqvarna_automower_ble/conftest.py index 1081db014e3..820edb29059 100644 --- a/tests/components/husqvarna_automower_ble/conftest.py +++ b/tests/components/husqvarna_automower_ble/conftest.py @@ -7,7 +7,7 @@ from automower_ble.protocol import ResponseResult import pytest from homeassistant.components.husqvarna_automower_ble.const import DOMAIN -from homeassistant.const import CONF_ADDRESS, CONF_CLIENT_ID +from homeassistant.const import CONF_ADDRESS, CONF_CLIENT_ID, CONF_PIN from . import AUTOMOWER_SERVICE_INFO @@ -58,6 +58,7 @@ def mock_config_entry() -> MockConfigEntry: data={ CONF_ADDRESS: AUTOMOWER_SERVICE_INFO.address, CONF_CLIENT_ID: 1197489078, + CONF_PIN: "1234", }, unique_id=AUTOMOWER_SERVICE_INFO.address, ) diff --git a/tests/components/husqvarna_automower_ble/test_config_flow.py b/tests/components/husqvarna_automower_ble/test_config_flow.py index e053a28b7dd..41dfdffae73 100644 --- a/tests/components/husqvarna_automower_ble/test_config_flow.py +++ b/tests/components/husqvarna_automower_ble/test_config_flow.py @@ -2,12 +2,13 @@ from unittest.mock import Mock, patch +from automower_ble.protocol import ResponseResult from bleak import BleakError import pytest from homeassistant.components.husqvarna_automower_ble.const import DOMAIN from homeassistant.config_entries import SOURCE_BLUETOOTH, SOURCE_USER -from homeassistant.const import CONF_ADDRESS, CONF_CLIENT_ID +from homeassistant.const import CONF_ADDRESS, CONF_CLIENT_ID, CONF_PIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -36,8 +37,6 @@ def mock_random() -> Mock: async def test_user_selection(hass: HomeAssistant) -> None: """Test we can select a device.""" - inject_bluetooth_service_info(hass, AUTOMOWER_SERVICE_INFO) - inject_bluetooth_service_info(hass, AUTOMOWER_UNNAMED_SERVICE_INFO) await hass.async_block_till_done(wait_background_tasks=True) result = await hass.config_entries.flow.async_init( @@ -48,14 +47,10 @@ async def test_user_selection(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={CONF_ADDRESS: "00000000-0000-0000-0000-000000000001"}, - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={}, + user_input={ + CONF_ADDRESS: "00000000-0000-0000-0000-000000000001", + CONF_PIN: "1234", + }, ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Husqvarna Automower" @@ -64,6 +59,65 @@ async def test_user_selection(hass: HomeAssistant) -> None: assert result["data"] == { CONF_ADDRESS: "00000000-0000-0000-0000-000000000001", CONF_CLIENT_ID: 1197489078, + CONF_PIN: "1234", + } + + +async def test_user_selection_incorrect_pin( + hass: HomeAssistant, + mock_automower_client: Mock, +) -> None: + """Test we can select a device.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + # Try non numeric pin + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_ADDRESS: "00000000-0000-0000-0000-000000000001", + CONF_PIN: "ABCD", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_pin"} + + # Try wrong PIN + mock_automower_client.connect.return_value = ResponseResult.INVALID_PIN + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_ADDRESS: "00000000-0000-0000-0000-000000000001", + CONF_PIN: "1234", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} + + mock_automower_client.connect.return_value = ResponseResult.OK + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_ADDRESS: "00000000-0000-0000-0000-000000000001", + CONF_PIN: "1234", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + assert result["data"] == { + CONF_ADDRESS: "00000000-0000-0000-0000-000000000001", + CONF_CLIENT_ID: 1197489078, + CONF_PIN: "1234", } @@ -74,13 +128,13 @@ async def test_bluetooth(hass: HomeAssistant) -> None: await hass.async_block_till_done(wait_background_tasks=True) result = hass.config_entries.flow.async_progress_by_handler(DOMAIN)[0] - assert result["step_id"] == "confirm" - assert result["context"]["unique_id"] == "00000000-0000-0000-0000-000000000003" + assert result["step_id"] == "bluetooth_confirm" result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={}, + user_input={CONF_PIN: "1234"}, ) + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Husqvarna Automower" assert result["result"].unique_id == "00000000-0000-0000-0000-000000000003" @@ -88,6 +142,135 @@ async def test_bluetooth(hass: HomeAssistant) -> None: assert result["data"] == { CONF_ADDRESS: "00000000-0000-0000-0000-000000000003", CONF_CLIENT_ID: 1197489078, + CONF_PIN: "1234", + } + + +async def test_bluetooth_incorrect_pin( + hass: HomeAssistant, + mock_automower_client: Mock, +) -> None: + """Test we can select a device.""" + + await hass.async_block_till_done(wait_background_tasks=True) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_BLUETOOTH}, + data=AUTOMOWER_SERVICE_INFO, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + # Try non numeric pin + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PIN: "ABCD", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["errors"] == {"base": "invalid_pin"} + + # Try wrong PIN + mock_automower_client.connect.return_value = ResponseResult.INVALID_PIN + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PIN: "5678"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["errors"] == {"base": "invalid_auth"} + + mock_automower_client.connect.return_value = ResponseResult.OK + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PIN: "1234"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Husqvarna Automower" + assert result["result"].unique_id == "00000000-0000-0000-0000-000000000003" + + assert result["data"] == { + CONF_ADDRESS: "00000000-0000-0000-0000-000000000003", + CONF_CLIENT_ID: 1197489078, + CONF_PIN: "1234", + } + + +async def test_bluetooth_unknown_error( + hass: HomeAssistant, + mock_automower_client: Mock, +) -> None: + """Test we can select a device.""" + + await hass.async_block_till_done(wait_background_tasks=True) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_BLUETOOTH}, + data=AUTOMOWER_SERVICE_INFO, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + mock_automower_client.connect.return_value = ResponseResult.UNKNOWN_ERROR + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PIN: "5678"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + +async def test_bluetooth_not_paired( + hass: HomeAssistant, + mock_automower_client: Mock, +) -> None: + """Test we can select a device.""" + + await hass.async_block_till_done(wait_background_tasks=True) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_BLUETOOTH}, + data=AUTOMOWER_SERVICE_INFO, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + mock_automower_client.connect.return_value = ResponseResult.NOT_ALLOWED + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PIN: "5678"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + mock_automower_client.connect.return_value = ResponseResult.OK + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PIN: "1234"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Husqvarna Automower" + assert result["result"].unique_id == "00000000-0000-0000-0000-000000000003" + + assert result["data"] == { + CONF_ADDRESS: "00000000-0000-0000-0000-000000000003", + CONF_CLIENT_ID: 1197489078, + CONF_PIN: "1234", } @@ -106,17 +289,90 @@ async def test_bluetooth_invalid(hass: HomeAssistant) -> None: assert result["reason"] == "no_devices_found" -async def test_failed_connect( +async def test_successful_reauth( + hass: HomeAssistant, + mock_automower_client: Mock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we can select a device.""" + + mock_config_entry.add_to_hass(hass) + + await hass.async_block_till_done(wait_background_tasks=True) + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + # Try non numeric pin + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PIN: "ABCD", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "invalid_pin"} + + # Try connection error + mock_automower_client.connect.return_value = ResponseResult.UNKNOWN_ERROR + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PIN: "5678", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "cannot_connect"} + + # Try wrong PIN + mock_automower_client.connect.return_value = ResponseResult.INVALID_PIN + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PIN: "5678", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "invalid_auth"} + + mock_automower_client.connect.return_value = ResponseResult.OK + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PIN: "1234", + }, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert len(hass.config_entries.async_entries("husqvarna_automower_ble")) == 1 + + assert ( + mock_config_entry.data[CONF_ADDRESS] == "00000000-0000-0000-0000-000000000003" + ) + assert mock_config_entry.data[CONF_CLIENT_ID] == 1197489078 + assert mock_config_entry.data[CONF_PIN] == "1234" + + +async def test_user_unable_to_connect( hass: HomeAssistant, mock_automower_client: Mock, ) -> None: """Test we can select a device.""" - - inject_bluetooth_service_info(hass, AUTOMOWER_SERVICE_INFO) - inject_bluetooth_service_info(hass, AUTOMOWER_UNNAMED_SERVICE_INFO) await hass.async_block_till_done(wait_background_tasks=True) - mock_automower_client.connect.side_effect = False + mock_automower_client.connect.side_effect = BleakError result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -126,23 +382,41 @@ async def test_failed_connect( result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={CONF_ADDRESS: "00000000-0000-0000-0000-000000000001"}, + user_input={ + CONF_ADDRESS: "00000000-0000-0000-0000-000000000001", + CONF_PIN: "1234", + }, ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_failed_reauth( + hass: HomeAssistant, + mock_automower_client: Mock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we can select a device.""" + + mock_config_entry.add_to_hass(hass) + + await hass.async_block_till_done(wait_background_tasks=True) + + mock_automower_client.connect.side_effect = BleakError + + result = await mock_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm" + assert result["step_id"] == "reauth_confirm" result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={}, + user_input={ + CONF_PIN: "5678", + }, ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "Husqvarna Automower" - assert result["result"].unique_id == "00000000-0000-0000-0000-000000000001" - - assert result["data"] == { - CONF_ADDRESS: "00000000-0000-0000-0000-000000000001", - CONF_CLIENT_ID: 1197489078, - } + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "cannot_connect"} async def test_duplicate_entry( @@ -154,8 +428,6 @@ async def test_duplicate_entry( mock_config_entry.add_to_hass(hass) - inject_bluetooth_service_info(hass, AUTOMOWER_SERVICE_INFO) - await hass.async_block_till_done(wait_background_tasks=True) # Test we should not discover the already configured device @@ -169,30 +441,63 @@ async def test_duplicate_entry( result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={CONF_ADDRESS: "00000000-0000-0000-0000-000000000003"}, + user_input={ + CONF_ADDRESS: "00000000-0000-0000-0000-000000000003", + CONF_PIN: "1234", + }, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" -async def test_exception_connect( +async def test_exception_probe( hass: HomeAssistant, mock_automower_client: Mock, ) -> None: """Test we can select a device.""" - inject_bluetooth_service_info(hass, AUTOMOWER_SERVICE_INFO) inject_bluetooth_service_info(hass, AUTOMOWER_UNNAMED_SERVICE_INFO) await hass.async_block_till_done(wait_background_tasks=True) mock_automower_client.probe_gatts.side_effect = BleakError result = hass.config_entries.flow.async_progress_by_handler(DOMAIN)[0] - assert result["step_id"] == "confirm" + assert result["step_id"] == "bluetooth_confirm" result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={}, + user_input={CONF_PIN: "1234"}, ) + assert result["type"] is FlowResultType.ABORT assert result["reason"] == "cannot_connect" + + +async def test_exception_connect( + hass: HomeAssistant, + mock_automower_client: Mock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we can select a device.""" + + mock_config_entry.add_to_hass(hass) + + await hass.async_block_till_done(wait_background_tasks=True) + + mock_automower_client.connect.side_effect = BleakError + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_ADDRESS: "00000000-0000-0000-0000-000000000001", + CONF_PIN: "1234", + }, + ) + + assert result["type"] is FlowResultType.ABORT diff --git a/tests/components/husqvarna_automower_ble/test_init.py b/tests/components/husqvarna_automower_ble/test_init.py index 95a0a1f2037..341cc3c282f 100644 --- a/tests/components/husqvarna_automower_ble/test_init.py +++ b/tests/components/husqvarna_automower_ble/test_init.py @@ -8,6 +8,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.husqvarna_automower_ble.const import DOMAIN from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_ADDRESS, CONF_CLIENT_ID, CONF_PIN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -39,7 +40,38 @@ async def test_setup( assert device_entry == snapshot -async def test_setup_retry_connect( +async def test_setup_missing_pin( + hass: HomeAssistant, + mock_automower_client: Mock, +) -> None: + """Test a setup that was created before PIN support.""" + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + title="My home", + unique_id="397678e5-9995-4a39-9d9f-ae6ba310236c", + data={ + CONF_ADDRESS: "00000000-0000-0000-0000-000000000003", + CONF_CLIENT_ID: "1197489078", + }, + ) + + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + hass.config_entries.async_update_entry( + mock_config_entry, + data={**mock_config_entry.data, CONF_PIN: 1234}, + ) + + assert len(hass.config_entries.flow.async_progress()) == 1 + await hass.async_block_till_done() + + +async def test_setup_failed_connect( hass: HomeAssistant, mock_automower_client: Mock, mock_config_entry: MockConfigEntry, @@ -68,3 +100,18 @@ async def test_setup_unknown_error( await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_invalid_pin( + hass: HomeAssistant, + mock_automower_client: Mock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unable to connect due to incorrect PIN.""" + mock_automower_client.connect.return_value = ResponseResult.INVALID_PIN + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR