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.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}"

View File

@@ -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
self.context["title_placeholders"] = {
"name": title,
}
async def connect_mower(self, device) -> tuple[int, Mower]:
"""Connect to the Mower."""
assert self.address
assert self.pin is not None
self._set_confirm_only()
return self.async_show_form(
step_id="confirm",
description_placeholders=self.context["title_placeholders"],
)
channel_id = random.randint(1, 0xFFFFFFFF)
mower = Mower(channel_id, self.address, int(self.pin))
async def async_step_user(
self, user_input: dict[str, Any] | None = None
return (channel_id, mower)
async def check_mower(
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()
"""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=vol.Schema(
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": 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": {
"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}"
}
}
}

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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