8000 Ask for PIN in Husqvarna Automower BLE integration by alistair23 · Pull Request #135440 · home-assistant/core · GitHub
[go: up one dir, main page]

Skip to content

Ask for PIN in Husqvarna Automower BLE integration #135440

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 17 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
e44d8c0
components/husqvarna_automower_ble: Support PIN
alistair23 Jan 12, 2025
11bfbd5
components/husqvarna_automower_ble: Don't set unique ID twice
alistair23 Mar 19, 2025
d3f8d03
components/husqvarna_automower_ble: Remove confirm form
alistair23 Mar 19, 2025
9fd5bd5
components/husqvarna_automower_ble: Raise ConfigEntryNotReady on init…
alistair23 Mar 19, 2025
9b79811
components/husqvarna_automower_ble: Improve config flow
alistair23 Mar 27, 2025
404a9b3
components/husqvarna_automower_ble: Improve reauth unique id flow
alistair23 Mar 31, 2025
45dcf02
components/husqvarna_automower_ble: Update strings
alistair23 Apr 7, 2025
5b1f6f4
husqvarna_automower_ble: config_flow: Consolidate connection logic
alistair23 May 14, 2025
f32003b
husqvarna_automower_ble: Simplify the config flow
alistair23 May 14, 2025
4cc3efd
husqvarna_automower_ble: Remove excess inject_bluetooth_service_info
alistair23 May 27, 2025
986542d
husqvarna_automower_ble: config_flow: A few fixes from user testing
alistair23 Jun 9, 2025
31dcff4
husqvarna_automower_ble: Use references in the manifest
alistair23 Jun 9, 2025
c0f8dec
husqvarna_automower_ble: Detect if the PIN is invalid
alistair23 Jul 1, 2025
45b3237
husqvarna_automower_ble: Support numeric PINs
alistair23 Jul 2, 2025
f4abcac
husqvarna_automower_ble: Handle authentication errors
alistair23 Jul 2, 2025
4f966a9
husqvarna_automower_ble: Bump automower-ble to 0.2.6
alistair23 Jul 21, 2025
d410be9
husqvarna_automower_ble: Convert PIN to an int
alistair23 Jul 22, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions homeassistant/components/husqvarna_automower_ble/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@
from __future__ import annotations

from automower_ble.mower import Mower
from automower_ble.protocol import ResponseResult
from bleak import BleakError
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 .coordinator import HusqvarnaCoordinator
Expand All @@ -24,10 +25,14 @@

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
Comment on lines +28 to +29
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This means every single user will be requested to input a PIN, even if it's not required, why do we do that? Can't we detect that a PIN is needed and initiate a reauth flow only then?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct. Everyone will be requested to input a PIN.

There is no reason not to request the PIN (now that it's supported). A future firmware update could require it for things that used to work or a mower setting change could also require it. So we collect it now and then we can use it when required

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, we don't really have an easy way to detect if it is or isn't required. Note that everyone who sets up a mower knows the PIN, so it isn't really secret

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the user accidentally inputs the wrong PIN, can we detect that or the integration will just not work? Is there nothing in the BLE protocol to indicate wrong PIN?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't have a way to detect a wrong PIN unfortunately. Everything is reverse engineered and detecting a wrong PIN isn't supported at the moment.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok! We can. I have updated this to detect an invalid PIN from the mower

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't ConfigEntryAuthFailed be raised only when a PIN is required, and there's no PIN in the config entry data or there's an invalid PIN in the config entry data?


address = entry.data[CONF_ADDRESS]
pin = 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)

Expand All @@ -36,12 +41,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: HusqvarnaConfigEntry) ->
device = bluetooth.async_ble_device_from_address(
hass, address, connectable=True
) or await get_device(address)
if not await mower.connect(device):
raise ConfigEntryNotReady
response_result = await mower.connect(device)
if response_result == ResponseResult.INVALID_PIN:
raise ConfigEntryAuthFailed(f"Unable to connect to device {address}")
if response_result != ResponseResult.OK:
raise ConfigEntryNotReady(
f"Unable to connect to device {address}, mower returned {response_result}"
)
except (TimeoutError, BleakError) as exception:
raise ConfigEntryNotReady(
f"Unable to connect to device {address} due to {exception}"
) from exception

LOGGER.debug("connected and paired")

model = await mower.get_model()
Expand Down
212 changes: 183 additions & 29 deletions homeassistant/components/husqvarna_automower_ble/config_flow.py
F987
Original file line number Diff line number Diff line change
Expand Up @@ -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.const import CONF_ADDRESS, CONF_CLIENT_ID, CONF_PIN

from .const import DOMAIN, LOGGER

Expand Down Expand Up @@ -46,7 +49,8 @@ class HusqvarnaAutomowerBleConfigFlow(ConfigFlow, domain=DOMAIN):

def __init__(self) -> None:
"""Initialize the config flow."""
self.address: str | None
self.address: str
self.pin: int | None

async def async_step_bluetooth(
self, discovery_info: BluetoothServiceInfo
Expand All @@ -60,62 +64,212 @@ async def async_step_bluetooth(
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."""
assert self.address
"""Confirm Bluetooth discovery."""

device = bluetooth.async_ble_device_from_address(
self.hass, self.address, connectable=True
if user_input is not None:
self.pin = self.validate_pin(user_input[CONF_PIN])
return await self.async_step_bluetooth_finalise()

return self.async_show_form(
step_id="bluetooth_confirm",
data_schema=vol.Schema(
{
vol.Required(CONF_PIN): int,
},
),
)

async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial manual step."""

if user_input is not None:
self.address = user_input[CONF_ADDRESS]
self.pin = self.validate_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.async_step_finalise()

return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_ADDRESS): str,
Copy link
Contributor
@emontnemery emontnemery Jul 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It will become very tedious to have to fill in the bluetooth address again in case the PIN was wrong. To fix that, use the the helper self.add_suggested_values_to_schema to populate the address and pin with the value from last round.

vol.Required(CONF_PIN): int,
},
),
)

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(f"Failed to probe device ({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, self.pin)

return (channel_id, mower)

def validate_pin(self, pin_str: str) -> int:
"""Validate the PIN."""
try:
return int(pin_str)
except ValueError:
raise vol.Invalid("PIN needs to be an integer") from None

async def check_mower(
self,
ble_flow: bool,
user_input: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""Check that the mower exists and is setup."""
device = bluetooth.async_ble_device_from_address(
self.hass, self.address, connectable=True
)

async def async_step_user(
title = await self.probe_mower(device)
if title is None:
return self.async_abort(reason="cannot_connect")

try:
errors: dict[str, str] = {}

(channel_id, mower) = await self.connect_mower(device)

response_result = await mower.connect(device)

if response_result is not ResponseResult.OK:
if (
response_result is ResponseResult.INVALID_PIN
or response_result is ResponseResult.NOT_ALLOWED
):
errors["base"] = "invalid_auth"
else:
errors["base"] = "cannot_connect"

if ble_flow:
return self.async_show_form(
step_id="bluetooth_confirm",
data_schema=vol.Schema(
{
vol.Required(CONF_PIN): int,
},
),
errors=errors,
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_ADDRESS): str,
vol.Required(CONF_PIN): int,
},
),
errors=errors,
)
except (TimeoutError, BleakError):
return self.async_abort(reason="cannot_connect")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this mean we abort if the address is wrong?


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_bluetooth_finalise(
self,
user_input: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""Finalise the Bluetooth setup."""
return await self.check_mower(True, user_input)

async def async_step_finalise(
self,
user_input: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""Finalise the Manual setup."""
return await self.check_mower(False, user_input)
Comment on lines +208 to +220
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we have these steps? the user and bluetooth confirm steps can just call check_mower?


async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauthentication upon an API authentication error."""
return await self.async_step_reauth_confirm()

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:
"""Confirm reauthentication dialog."""
errors: dict[str, str] = {}

if user_input:
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()
self.pin = self.validate_pin(user_input[CONF_PIN])

try:
device = bluetooth.async_ble_device_from_address(
self.hass, self.address, connectable=True
) or await get_device(self.address)

(channel_id, mower) = await self.connect_mower(device)

response_result = await mower.connect(device)
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:
data = {
CONF_ADDRESS: self.address,
CONF_PIN: self.pin,
}
Comment on lines +254 to +257
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now we forget the client ID, and after reauth setting up fails with:

2025-07-19 11:27:20.788 ERROR (MainThread) [homeassistant.config_entries] Error setting up entry Unknown Automower for husqvarna_automower_ble
Traceback (most recent call last):
  File "/home/erik/development/home-assistant_fork/homeassistant/config_entries.py", line 749, in __async_setup_with_context
    result = await component.async_setup_entry(hass, self)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/erik/development/home-assistant_fork/homeassistant/components/husqvarna_automower_ble/__init__.py", line 34, in async_setup_entry
    channel_id = entry.data[CONF_CLIENT_ID]
                 ~~~~~~~~~~^^^^^^^^^^^^^^^^
KeyError: 'client_id'


return self.async_update_reload_and_abort(
self._get_reauth_entry(), data=data
)

except (TimeoutError, BleakError):
return self.async_abort(reason="cannot_connect")

return self.async_show_form(
step_id="user",
step_id="reauth_confirm",
data_schema=vol.Schema(
{
vol.Required(CONF_ADDRESS): str,
vol.Required(CONF_PIN): int,
},
),
errors=errors,
)
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from typing import TYPE_CHECKING

from automower_ble.mower import Mower
from automower_ble.protocol import ResponseResult
from bleak import BleakError
from bleak_retry_connector import close_stale_connections_by_address

Expand Down Expand Up @@ -62,7 +63,7 @@ async def _async_find_device(self):
)

try:
if not await self.mower.connect(device):
if await self.mower.connect(device) is not ResponseResult.OK:
raise UpdateFailed("Failed to connect")
except BleakError as err:
raise UpdateFailed("Failed to connect") from err
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from __future__ import annotations

from automower_ble.protocol import MowerActivity, MowerState
from automower_ble.protocol import MowerActivity, MowerState, ResponseResult

from homeassistant.components import bluetooth
from homeassistant.components.lawn_mower import (
Expand Down Expand Up @@ -107,7 +107,7 @@ async def async_start_mowing(self) -> None:
device = bluetooth.async_ble_device_from_address(
self.coordinator.hass, self.coordinator.address, connectable=True
)
if not await self.coordinator.mower.connect(device):
if await self.coordinator.mower.connect(device) is not ResponseResult.OK:
return

await self.coordinator.mower.mower_resume()
Expand All @@ -126,7 +126,7 @@ async def async_dock(self) -> None:
device = bluetooth.async_ble_device_from_address(
self.coordinator.hass, self.coordinator.address, connectable=True
)
if not await self.coordinator.mower.connect(device):
if await self.coordinator.mower.connect(device) is not ResponseResult.OK:
return

await self.coordinator.mower.mower_park()
Expand All @@ -143,7 +143,7 @@ async def async_pause(self) -> None:
device = bluetooth.async_ble_device_from_address(
self.coordinator.hass, self.coordinator.address, connectable=True
)
if not await self.coordinator.mower.connect(device):
if await self.coordinator.mower.connect(device) is not ResponseResult.OK:
return

await self.coordinator.mower.mower_pause()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/husqvarna_automower_ble",
"iot_class": "local_polling",
"requirements": ["automower-ble==0.2.1"]
"requirements": ["automower-ble==0.2.6"]
}
Loading
0