-
-
Notifications
You must be signed in to change notification settings - Fork 34.5k
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
base: dev
Are you sure you want to change the base?
Changes from all commits
e44d8c0
11bfbd5
d3f8d03
9fd5bd5
9b79811
404a9b3
45dcf02
5b1f6f4
f32003b
4cc3efd
986542d
31dcff4
c0f8dec
45b3237
f4abcac
4f966a9
d410be9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
|
||
|
@@ -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 | ||
|
@@ -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( | ||
alistair23 marked this conversation as resolved.
Show resolved
Hide resolved
alistair23 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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( | ||
F438 td> | step_id="user", | |
data_schema=vol.Schema( | ||
{ | ||
vol.Required(CONF_ADDRESS): str, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
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) | ||
alistair23 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
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") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
|
||
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: | ||
F987 | """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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
|
||
|
||
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, | ||
) |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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?