Ask for PIN in Husqvarna Automower BLE integration (#135440)

Signed-off-by: Alistair Francis <alistair@alistair23.me>
Co-authored-by: Erik Montnemery <erik@montnemery.com>
Co-authored-by: Christopher Fenner <9592452+CFenner@users.noreply.github.com>
This commit is contained in:
Alistair Francis
2025-08-27 17:49:05 +10:00
committed by GitHub
parent 50a2eba66e
commit 6e45713d3a
6 changed files with 672 additions and 82 deletions

View File

@@ -9,11 +9,11 @@ from bleak_retry_connector import close_stale_connections_by_address, get_device
from homeassistant.components import bluetooth from homeassistant.components import bluetooth
from homeassistant.config_entries import ConfigEntry 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.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 from .coordinator import HusqvarnaCoordinator
type HusqvarnaConfigEntry = ConfigEntry[HusqvarnaCoordinator] type HusqvarnaConfigEntry = ConfigEntry[HusqvarnaCoordinator]
@@ -26,10 +26,18 @@ PLATFORMS = [
async def async_setup_entry(hass: HomeAssistant, entry: HusqvarnaConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: HusqvarnaConfigEntry) -> bool:
"""Set up Husqvarna Autoconnect Bluetooth from a config entry.""" """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] address = entry.data[CONF_ADDRESS]
pin = int(entry.data[CONF_PIN])
channel_id = entry.data[CONF_CLIENT_ID] 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) await close_stale_connections_by_address(address)
@@ -39,6 +47,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: HusqvarnaConfigEntry) ->
hass, address, connectable=True hass, address, connectable=True
) or await get_device(address) ) or await get_device(address)
response_result = await mower.connect(device) 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: if response_result != ResponseResult.OK:
raise ConfigEntryNotReady( raise ConfigEntryNotReady(
f"Unable to connect to device {address}, mower returned {response_result}" f"Unable to connect to device {address}, mower returned {response_result}"

View File

@@ -2,17 +2,20 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping
import random import random
from typing import Any from typing import Any
from automower_ble.mower import Mower from automower_ble.mower import Mower
from automower_ble.protocol import ResponseResult
from bleak import BleakError from bleak import BleakError
from bleak_retry_connector import get_device
import voluptuous as vol import voluptuous as vol
from homeassistant.components import bluetooth from homeassistant.components import bluetooth
from homeassistant.components.bluetooth import BluetoothServiceInfo from homeassistant.components.bluetooth import BluetoothServiceInfo
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.config_entries import SOURCE_BLUETOOTH, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ADDRESS, CONF_CLIENT_ID from homeassistant.const import CONF_ADDRESS, CONF_CLIENT_ID, CONF_PIN
from .const import DOMAIN, LOGGER from .const import DOMAIN, LOGGER
@@ -39,14 +42,23 @@ def _is_supported(discovery_info: BluetoothServiceInfo):
return manufacturer and service_husqvarna and service_generic 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): class HusqvarnaAutomowerBleConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Husqvarna Bluetooth.""" """Handle a config flow for Husqvarna Bluetooth."""
VERSION = 1 VERSION = 1
def __init__(self) -> None: address: str | None = None
"""Initialize the config flow.""" mower_name: str = ""
self.address: str | None pin: str | None = None
async def async_step_bluetooth( async def async_step_bluetooth(
self, discovery_info: BluetoothServiceInfo self, discovery_info: BluetoothServiceInfo
@@ -60,62 +72,244 @@ class HusqvarnaAutomowerBleConfigFlow(ConfigFlow, domain=DOMAIN):
self.address = discovery_info.address self.address = discovery_info.address
await self.async_set_unique_id(self.address) await self.async_set_unique_id(self.address)
self._abort_if_unique_id_configured() 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 self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Confirm discovery.""" """Confirm Bluetooth discovery."""
assert self.address assert self.address
errors: dict[str, str] = {}
device = bluetooth.async_ble_device_from_address( if user_input is not None:
self.hass, self.address, connectable=True 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) channel_id = random.randint(1, 0xFFFFFFFF)
assert self.address
try: try:
(manufacturer, device_type, model) = await Mower( (manufacturer, device_type, model) = await Mower(
channel_id, self.address channel_id, self.address
).probe_gatts(device) ).probe_gatts(device)
except (BleakError, TimeoutError) as exception: except (BleakError, TimeoutError) as exception:
LOGGER.exception("Failed to connect to device: %s", exception) LOGGER.exception("Failed to probe device (%s): %s", self.address, exception)
return self.async_abort(reason="cannot_connect") return None
title = manufacturer + " " + device_type title = manufacturer + " " + device_type
LOGGER.debug("Found device: %s", title) LOGGER.debug("Found device: %s", title)
if user_input is not None: return title
return self.async_create_entry(
title=title,
data={CONF_ADDRESS: self.address, CONF_CLIENT_ID: channel_id},
)
self.context["title_placeholders"] = { async def connect_mower(self, device) -> tuple[int, Mower]:
"name": title, """Connect to the Mower."""
} assert self.address
assert self.pin is not None
self._set_confirm_only() channel_id = random.randint(1, 0xFFFFFFFF)
return self.async_show_form( mower = Mower(channel_id, self.address, int(self.pin))
step_id="confirm",
description_placeholders=self.context["title_placeholders"],
)
async def async_step_user( return (channel_id, mower)
self, user_input: dict[str, Any] | None = None
async def check_mower(
self,
user_input: dict[str, Any] | None = None,
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle the initial step.""" """Check that the mower exists and is setup."""
if user_input is not None: assert self.address
self.address = user_input[CONF_ADDRESS]
await self.async_set_unique_id(self.address, raise_on_progress=False) device = bluetooth.async_ble_device_from_address(
self._abort_if_unique_id_configured() self.hass, self.address, connectable=True
return await self.async_step_confirm() )
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( return self.async_show_form(
step_id="user", step_id="user",
data_schema=vol.Schema( data_schema=self.add_suggested_values_to_schema(
vol.Schema(
{ {
vol.Required(CONF_ADDRESS): str, 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": self.mower_name,
"address": self.address,
}
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""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="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,
) )

View File

@@ -4,18 +4,49 @@
"step": { "step": {
"user": { "user": {
"data": { "data": {
"address": "Device BLE address" "address": "Device BLE address",
"pin": "Mower PIN"
},
"data_description": {
"pin": "The PIN used to secure the mower"
} }
}, },
"confirm": { "bluetooth_confirm": {
"description": "Do you want to set up {name}? Make sure the mower is in pairing mode" "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": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "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.", "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%]", "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%]" "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}"
} }
} }
} }

View File

@@ -7,7 +7,7 @@ from automower_ble.protocol import ResponseResult
import pytest import pytest
from homeassistant.components.husqvarna_automower_ble.const import DOMAIN 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 from . import AUTOMOWER_SERVICE_INFO
@@ -58,6 +58,7 @@ def mock_config_entry() -> MockConfigEntry:
data={ data={
CONF_ADDRESS: AUTOMOWER_SERVICE_INFO.address, CONF_ADDRESS: AUTOMOWER_SERVICE_INFO.address,
CONF_CLIENT_ID: 1197489078, CONF_CLIENT_ID: 1197489078,
CONF_PIN: "1234",
}, },
unique_id=AUTOMOWER_SERVICE_INFO.address, unique_id=AUTOMOWER_SERVICE_INFO.address,
) )

View File

@@ -2,12 +2,13 @@
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
from automower_ble.protocol import ResponseResult
from bleak import BleakError from bleak import BleakError
import pytest import pytest
from homeassistant.components.husqvarna_automower_ble.const import DOMAIN from homeassistant.components.husqvarna_automower_ble.const import DOMAIN
from homeassistant.config_entries import SOURCE_BLUETOOTH, SOURCE_USER 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.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType from homeassistant.data_entry_flow import FlowResultType
@@ -36,8 +37,6 @@ def mock_random() -> Mock:
async def test_user_selection(hass: HomeAssistant) -> None: async def test_user_selection(hass: HomeAssistant) -> None:
"""Test we can select a device.""" """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) await hass.async_block_till_done(wait_background_tasks=True)
result = await hass.config_entries.flow.async_init( 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 = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
user_input={CONF_ADDRESS: "00000000-0000-0000-0000-000000000001"}, user_input={
) CONF_ADDRESS: "00000000-0000-0000-0000-000000000001",
assert result["type"] is FlowResultType.FORM CONF_PIN: "1234",
assert result["step_id"] == "confirm" },
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={},
) )
assert result["type"] is FlowResultType.CREATE_ENTRY assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Husqvarna Automower" assert result["title"] == "Husqvarna Automower"
@@ -64,6 +59,65 @@ async def test_user_selection(hass: HomeAssistant) -> None:
assert result["data"] == { assert result["data"] == {
CONF_ADDRESS: "00000000-0000-0000-0000-000000000001", CONF_ADDRESS: "00000000-0000-0000-0000-000000000001",
CONF_CLIENT_ID: 1197489078, 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) await hass.async_block_till_done(wait_background_tasks=True)
result = hass.config_entries.flow.async_progress_by_handler(DOMAIN)[0] result = hass.config_entries.flow.async_progress_by_handler(DOMAIN)[0]
assert result["step_id"] == "confirm" assert result["step_id"] == "bluetooth_confirm"
assert result["context"]["unique_id"] == "00000000-0000-0000-0000-000000000003"
result = await hass.config_entries.flow.async_configure( result = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
user_input={}, user_input={CONF_PIN: "1234"},
) )
assert result["type"] is FlowResultType.CREATE_ENTRY assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Husqvarna Automower" assert result["title"] == "Husqvarna Automower"
assert result["result"].unique_id == "00000000-0000-0000-0000-000000000003" assert result["result"].unique_id == "00000000-0000-0000-0000-000000000003"
@@ -88,6 +142,135 @@ async def test_bluetooth(hass: HomeAssistant) -> None:
assert result["data"] == { assert result["data"] == {
CONF_ADDRESS: "00000000-0000-0000-0000-000000000003", CONF_ADDRESS: "00000000-0000-0000-0000-000000000003",
CONF_CLIENT_ID: 1197489078, 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" 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, hass: HomeAssistant,
mock_automower_client: Mock, mock_automower_client: Mock,
) -> None: ) -> None:
"""Test we can select a device.""" """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) 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( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER} DOMAIN, context={"source": SOURCE_USER}
@@ -126,23 +382,41 @@ async def test_failed_connect(
result = await hass.config_entries.flow.async_configure( result = await hass.config_entries.flow.async_configure(
result["flow_id"], 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["type"] is FlowResultType.FORM
assert result["step_id"] == "confirm" assert result["step_id"] == "reauth_confirm"
result = await hass.config_entries.flow.async_configure( result = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
user_input={}, user_input={
CONF_PIN: "5678",
},
) )
assert result["type"] is FlowResultType.CREATE_ENTRY assert result["type"] is FlowResultType.FORM
assert result["title"] == "Husqvarna Automower" assert result["step_id"] == "reauth_confirm"
assert result["result"].unique_id == "00000000-0000-0000-0000-000000000001" assert result["errors"] == {"base": "cannot_connect"}
assert result["data"] == {
CONF_ADDRESS: "00000000-0000-0000-0000-000000000001",
CONF_CLIENT_ID: 1197489078,
}
async def test_duplicate_entry( async def test_duplicate_entry(
@@ -154,8 +428,6 @@ async def test_duplicate_entry(
mock_config_entry.add_to_hass(hass) 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) await hass.async_block_till_done(wait_background_tasks=True)
# Test we should not discover the already configured device # 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 = await hass.config_entries.flow.async_configure(
result["flow_id"], 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["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured" assert result["reason"] == "already_configured"
async def test_exception_connect( async def test_exception_probe(
hass: HomeAssistant, hass: HomeAssistant,
mock_automower_client: Mock, mock_automower_client: Mock,
) -> None: ) -> None:
"""Test we can select a device.""" """Test we can select a device."""
inject_bluetooth_service_info(hass, AUTOMOWER_SERVICE_INFO)
inject_bluetooth_service_info(hass, AUTOMOWER_UNNAMED_SERVICE_INFO) inject_bluetooth_service_info(hass, AUTOMOWER_UNNAMED_SERVICE_INFO)
await hass.async_block_till_done(wait_background_tasks=True) await hass.async_block_till_done(wait_background_tasks=True)
mock_automower_client.probe_gatts.side_effect = BleakError mock_automower_client.probe_gatts.side_effect = BleakError
result = hass.config_entries.flow.async_progress_by_handler(DOMAIN)[0] 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 = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
user_input={}, user_input={CONF_PIN: "1234"},
) )
assert result["type"] is FlowResultType.ABORT assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "cannot_connect" 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

View File

@@ -8,6 +8,7 @@ from syrupy.assertion import SnapshotAssertion
from homeassistant.components.husqvarna_automower_ble.const import DOMAIN from homeassistant.components.husqvarna_automower_ble.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_ADDRESS, CONF_CLIENT_ID, CONF_PIN
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
@@ -39,7 +40,38 @@ async def test_setup(
assert device_entry == snapshot 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, hass: HomeAssistant,
mock_automower_client: Mock, mock_automower_client: Mock,
mock_config_entry: MockConfigEntry, mock_config_entry: MockConfigEntry,
@@ -68,3 +100,18 @@ async def test_setup_unknown_error(
await hass.async_block_till_done() await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY 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