Add husqvarna automower ble integration (#108326)

Co-authored-by: Joostlek <joostlek@outlook.com>
This commit is contained in:
Alistair Francis 2024-10-26 00:54:02 +10:00 committed by GitHub
parent 759fe54132
commit b3cb2ac3ee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1127 additions and 5 deletions

View File

@ -659,6 +659,8 @@ build.json @home-assistant/supervisor
/tests/components/hunterdouglas_powerview/ @bdraco @kingy444 @trullock
/homeassistant/components/husqvarna_automower/ @Thomas55555
/tests/components/husqvarna_automower/ @Thomas55555
/homeassistant/components/husqvarna_automower_ble/ @alistair23
/tests/components/husqvarna_automower_ble/ @alistair23
/homeassistant/components/huum/ @frwickst
/tests/components/huum/ @frwickst
/homeassistant/components/hvv_departures/ @vigonotion

View File

@ -0,0 +1,5 @@
{
"domain": "husqvarna",
"name": "Husqvarna",
"integrations": ["husqvarna_automower", "husqvarna_automower_ble"]
}

View File

@ -0,0 +1,63 @@
"""The Husqvarna Autoconnect Bluetooth integration."""
from __future__ import annotations
from automower_ble.mower import Mower
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.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .const import LOGGER
from .coordinator import HusqvarnaCoordinator
PLATFORMS = [
Platform.LAWN_MOWER,
]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Husqvarna Autoconnect Bluetooth from a config entry."""
address = entry.data[CONF_ADDRESS]
channel_id = entry.data[CONF_CLIENT_ID]
mower = Mower(channel_id, address)
await close_stale_connections_by_address(address)
LOGGER.debug("connecting to %s with channel ID %s", address, str(channel_id))
try:
device = bluetooth.async_ble_device_from_address(
hass, address, connectable=True
) or await get_device(address)
if not await mower.connect(device):
raise ConfigEntryNotReady
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()
LOGGER.debug("Connected to Automower: %s", model)
coordinator = HusqvarnaCoordinator(hass, mower, address, channel_id, model)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
coordinator: HusqvarnaCoordinator = entry.runtime_data
await coordinator.async_shutdown()
return unload_ok

View File

@ -0,0 +1,121 @@
"""Config flow for Husqvarna Bluetooth integration."""
from __future__ import annotations
import random
from typing import Any
from automower_ble.mower import Mower
from bleak import BleakError
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 .const import DOMAIN, LOGGER
def _is_supported(discovery_info: BluetoothServiceInfo):
"""Check if device is supported."""
LOGGER.debug(
"%s manufacturer data: %s",
discovery_info.address,
discovery_info.manufacturer_data,
)
manufacturer = any(key == 1062 for key in discovery_info.manufacturer_data)
service_husqvarna = any(
service == "98bd0001-0b0e-421a-84e5-ddbf75dc6de4"
for service in discovery_info.service_uuids
)
service_generic = any(
service == "00001800-0000-1000-8000-00805f9b34fb"
for service in discovery_info.service_uuids
)
return manufacturer and service_husqvarna and service_generic
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
async def async_step_bluetooth(
self, discovery_info: BluetoothServiceInfo
) -> ConfigFlowResult:
"""Handle the bluetooth discovery step."""
LOGGER.debug("Discovered device: %s", discovery_info)
if not _is_supported(discovery_info):
return self.async_abort(reason="no_devices_found")
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()
async def async_step_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm discovery."""
assert self.address
device = bluetooth.async_ble_device_from_address(
self.hass, self.address, connectable=True
)
channel_id = random.randint(1, 0xFFFFFFFF)
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")
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},
)
self.context["title_placeholders"] = {
"name": title,
}
self._set_confirm_only()
return self.async_show_form(
step_id="confirm",
description_placeholders=self.context["title_placeholders"],
)
async def async_step_user(
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()
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_ADDRESS): str,
},
),
)

View File

@ -0,0 +1,8 @@
"""Constants for the Husqvarna Automower Bluetooth integration."""
import logging
DOMAIN = "husqvarna_automower_ble"
MANUFACTURER = "Husqvarna"
LOGGER = logging.getLogger(__package__)

View File

@ -0,0 +1,100 @@
"""Provides the DataUpdateCoordinator."""
from __future__ import annotations
from datetime import timedelta
from automower_ble.mower import Mower
from bleak import BleakError
from bleak_retry_connector import close_stale_connections_by_address
from homeassistant.components import bluetooth
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, LOGGER
SCAN_INTERVAL = timedelta(seconds=60)
class HusqvarnaCoordinator(DataUpdateCoordinator[dict[str, bytes]]):
"""Class to manage fetching data."""
def __init__(
self,
hass: HomeAssistant,
mower: Mower,
address: str,
channel_id: str,
model: str,
) -> None:
"""Initialize global data updater."""
super().__init__(
hass=hass,
logger=LOGGER,
name=DOMAIN,
update_interval=SCAN_INTERVAL,
)
self.address = address
self.channel_id = channel_id
self.model = model
self.mower = mower
async def async_shutdown(self) -> None:
"""Shutdown coordinator and any connection."""
LOGGER.debug("Shutdown")
await super().async_shutdown()
if self.mower.is_connected():
await self.mower.disconnect()
async def _async_find_device(self):
LOGGER.debug("Trying to reconnect")
await close_stale_connections_by_address(self.address)
device = bluetooth.async_ble_device_from_address(
self.hass, self.address, connectable=True
)
try:
if not await self.mower.connect(device):
raise UpdateFailed("Failed to connect")
except BleakError as err:
raise UpdateFailed("Failed to connect") from err
async def _async_update_data(self) -> dict[str, bytes]:
"""Poll the device."""
LOGGER.debug("Polling device")
data: dict[str, bytes] = {}
try:
if not self.mower.is_connected():
await self._async_find_device()
except BleakError as err:
raise UpdateFailed("Failed to connect") from err
try:
data["battery_level"] = await self.mower.battery_level()
LOGGER.debug(data["battery_level"])
if data["battery_level"] is None:
await self._async_find_device()
raise UpdateFailed("Error getting data from device")
data["activity"] = await self.mower.mower_activity()
LOGGER.debug(data["activity"])
if data["activity"] is None:
await self._async_find_device()
raise UpdateFailed("Error getting data from device")
data["state"] = await self.mower.mower_state()
LOGGER.debug(data["state"])
if data["state"] is None:
await self._async_find_device()
raise UpdateFailed("Error getting data from device")
except BleakError as err:
LOGGER.error("Error getting data from device")
await self._async_find_device()
raise UpdateFailed("Error getting data from device") from err
return data

View File

@ -0,0 +1,30 @@
"""Provides the HusqvarnaAutomowerBleEntity."""
from __future__ import annotations
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER
from .coordinator import HusqvarnaCoordinator
class HusqvarnaAutomowerBleEntity(CoordinatorEntity[HusqvarnaCoordinator]):
"""HusqvarnaCoordinator entity for Husqvarna Automower Bluetooth."""
_attr_has_entity_name = True
def __init__(self, coordinator: HusqvarnaCoordinator) -> None:
"""Initialize coordinator entity."""
super().__init__(coordinator)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{coordinator.address}_{coordinator.channel_id}")},
manufacturer=MANUFACTURER,
model_id=coordinator.model,
)
@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self.coordinator.mower.is_connected()

View File

@ -0,0 +1,149 @@
"""The Husqvarna Autoconnect Bluetooth lawn mower platform."""
from __future__ import annotations
from homeassistant.components import bluetooth
from homeassistant.components.lawn_mower import (
LawnMowerActivity,
LawnMowerEntity,
LawnMowerEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import LOGGER
from .coordinator import HusqvarnaCoordinator
from .entity import HusqvarnaAutomowerBleEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up AutomowerLawnMower integration from a config entry."""
coordinator: HusqvarnaCoordinator = config_entry.runtime_data
address = coordinator.address
async_add_entities(
[
AutomowerLawnMower(
coordinator,
address,
),
]
)
class AutomowerLawnMower(HusqvarnaAutomowerBleEntity, LawnMowerEntity):
"""Husqvarna Automower."""
_attr_name = None
_attr_supported_features = (
LawnMowerEntityFeature.PAUSE
| LawnMowerEntityFeature.START_MOWING
| LawnMowerEntityFeature.DOCK
)
def __init__(
self,
coordinator: HusqvarnaCoordinator,
address: str,
) -> None:
"""Initialize the lawn mower."""
super().__init__(coordinator)
self._attr_unique_id = str(address)
def _get_activity(self) -> LawnMowerActivity | None:
"""Return the current lawn mower activity."""
if self.coordinator.data is None:
return None
state = str(self.coordinator.data["state"])
activity = str(self.coordinator.data["activity"])
if state is None or activity is None:
return None
if state == "paused":
return LawnMowerActivity.PAUSED
if state in ("stopped", "off", "waitForSafetyPin"):
# This is actually stopped, but that isn't an option
return LawnMowerActivity.ERROR
if state in (
"restricted",
"inOperation",
"unknown",
"checkSafety",
"pendingStart",
):
if activity in ("charging", "parked", "none"):
return LawnMowerActivity.DOCKED
if activity in ("goingOut", "mowing"):
return LawnMowerActivity.MOWING
if activity in ("goingHome"):
return LawnMowerActivity.RETURNING
return LawnMowerActivity.ERROR
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
LOGGER.debug("AutomowerLawnMower: _handle_coordinator_update")
self._attr_activity = self._get_activity()
self._attr_available = self._attr_activity is not None
super()._handle_coordinator_update()
async def async_start_mowing(self) -> None:
"""Start mowing."""
LOGGER.debug("Starting mower")
if not self.coordinator.mower.is_connected():
device = bluetooth.async_ble_device_from_address(
self.coordinator.hass, self.coordinator.address, connectable=True
)
if not await self.coordinator.mower.connect(device):
return
await self.coordinator.mower.mower_resume()
if self._attr_activity is LawnMowerActivity.DOCKED:
await self.coordinator.mower.mower_override()
await self.coordinator.async_request_refresh()
self._attr_activity = self._get_activity()
self.async_write_ha_state()
async def async_dock(self) -> None:
"""Start docking."""
LOGGER.debug("Start docking")
if not self.coordinator.mower.is_connected():
device = bluetooth.async_ble_device_from_address(
self.coordinator.hass, self.coordinator.address, connectable=True
)
if not await self.coordinator.mower.connect(device):
return
await self.coordinator.mower.mower_park()
await self.coordinator.async_request_refresh()
self._attr_activity = self._get_activity()
self.async_write_ha_state()
async def async_pause(self) -> None:
"""Pause mower."""
LOGGER.debug("Pausing mower")
if not self.coordinator.mower.is_connected():
device = bluetooth.async_ble_device_from_address(
self.coordinator.hass, self.coordinator.address, connectable=True
)
if not await self.coordinator.mower.connect(device):
return
await self.coordinator.mower.mower_pause()
await self.coordinator.async_request_refresh()
self._attr_activity = self._get_activity()
self.async_write_ha_state()

View File

@ -0,0 +1,16 @@
{
"domain": "husqvarna_automower_ble",
"name": "Husqvarna Automower BLE",
"bluetooth": [
{
"service_uuid": "98bd0001-0b0e-421a-84e5-ddbf75dc6de4",
"connectable": true
}
],
"codeowners": ["@alistair23"],
"config_flow": true,
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/???",
"iot_class": "local_polling",
"requirements": ["automower-ble==0.1.35"]
}

View File

@ -0,0 +1,21 @@
{
"config": {
"flow_title": "{name} ({address})",
"step": {
"user": {
"data": {
"address": "Device BLE address"
}
},
"confirm": {
"description": "Do you want to set up {name}? Make sure the mower is in pairing mode"
}
},
"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%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
}
}
}

View File

@ -279,6 +279,11 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [
],
"manufacturer_id": 76,
},
{
"connectable": True,
"domain": "husqvarna_automower_ble",
"service_uuid": "98bd0001-0b0e-421a-84e5-ddbf75dc6de4",
},
{
"domain": "ibeacon",
"manufacturer_data_start": [

View File

@ -264,6 +264,7 @@ FLOWS = {
"huisbaasje",
"hunterdouglas_powerview",
"husqvarna_automower",
"husqvarna_automower_ble",
"huum",
"hvv_departures",
"hydrawise",

View File

@ -2678,11 +2678,22 @@
"integration_type": "virtual",
"supported_by": "motion_blinds"
},
"husqvarna_automower": {
"name": "Husqvarna Automower",
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_push"
"husqvarna": {
"name": "Husqvarna",
"integrations": {
"husqvarna_automower": {
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_push",
"name": "Husqvarna Automower"
},
"husqvarna_automower_ble": {
"integration_type": "hub",
"config_flow": true,
"iot_class": "local_polling",
"name": "Husqvarna Automower BLE"
}
}
},
"huum": {
"name": "Huum",

View File

@ -523,6 +523,9 @@ aurorapy==0.2.7
# homeassistant.components.autarco
autarco==3.0.0
# homeassistant.components.husqvarna_automower_ble
automower-ble==0.1.35
# homeassistant.components.avea
# avea==1.5.1

View File

@ -478,6 +478,9 @@ aurorapy==0.2.7
# homeassistant.components.autarco
autarco==3.0.0
# homeassistant.components.husqvarna_automower_ble
automower-ble==0.1.35
# homeassistant.components.axis
axis==63

View File

@ -0,0 +1,74 @@
"""Tests for the Husqvarna Automower Bluetooth integration."""
from unittest.mock import patch
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo
from tests.common import MockConfigEntry
from tests.components.bluetooth import inject_bluetooth_service_info
AUTOMOWER_SERVICE_INFO = BluetoothServiceInfo(
name="305",
address="00000000-0000-0000-0000-000000000003",
rssi=-63,
service_data={},
manufacturer_data={1062: b"\x05\x04\xbf\xcf\xbb\r"},
service_uuids=[
"98bd0001-0b0e-421a-84e5-ddbf75dc6de4",
"00001800-0000-1000-8000-00805f9b34fb",
],
source="local",
)
AUTOMOWER_UNNAMED_SERVICE_INFO = BluetoothServiceInfo(
name=None,
address="00000000-0000-0000-0000-000000000004",
rssi=-63,
service_data={},
manufacturer_data={1062: b"\x05\x04\xbf\xcf\xbb\r"},
service_uuids=[
"98bd0001-0b0e-421a-84e5-ddbf75dc6de4",
"00001800-0000-1000-8000-00805f9b34fb",
],
source="local",
)
AUTOMOWER_MISSING_MANUFACTURER_DATA_SERVICE_INFO = BluetoothServiceInfo(
name="Missing Manufacturer Data",
address="00000000-0000-0000-0002-000000000001",
rssi=-63,
service_data={},
manufacturer_data={},
service_uuids=[
"98bd0001-0b0e-421a-84e5-ddbf75dc6de4",
"00001800-0000-1000-8000-00805f9b34fb",
],
source="local",
)
AUTOMOWER_UNSUPPORTED_GROUP_SERVICE_INFO = BluetoothServiceInfo(
name="Unsupported Group",
address="00000000-0000-0000-0002-000000000002",
rssi=-63,
service_data={},
manufacturer_data={1062: b"\x05\x04\xbf\xcf\xbb\r"},
service_uuids=[
"98bd0001-0b0e-421a-84e5-ddbf75dc6de4",
],
source="local",
)
async def setup_entry(
hass: HomeAssistant, mock_entry: MockConfigEntry, platforms: list[Platform]
) -> None:
"""Make sure the device is available."""
inject_bluetooth_service_info(hass, AUTOMOWER_SERVICE_INFO)
with patch("homeassistant.components.husqvarna_automower_ble.PLATFORMS", platforms):
mock_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()

View File

@ -0,0 +1,82 @@
"""Common fixtures for the Husqvarna Automower Bluetooth tests."""
from collections.abc import Awaitable, Callable, Generator
from unittest.mock import AsyncMock, patch
from freezegun.api import FrozenDateTimeFactory
import pytest
from homeassistant.components.husqvarna_automower_ble.const import DOMAIN
from homeassistant.components.husqvarna_automower_ble.coordinator import SCAN_INTERVAL
from homeassistant.const import CONF_ADDRESS, CONF_CLIENT_ID
from homeassistant.core import HomeAssistant
from . import AUTOMOWER_SERVICE_INFO
from tests.common import MockConfigEntry, async_fire_time_changed
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.husqvarna_automower_ble.async_setup_entry",
return_value=True,
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture
async def scan_step(
hass: HomeAssistant, freezer: FrozenDateTimeFactory
) -> Generator[None, None, Callable[[], Awaitable[None]]]:
"""Step system time forward."""
freezer.move_to("2023-01-01T01:00:00Z")
async def delay() -> None:
"""Trigger delay in system."""
freezer.tick(delta=SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
return delay
@pytest.fixture(autouse=True)
def mock_automower_client(enable_bluetooth: None, scan_step) -> Generator[AsyncMock]:
"""Mock a BleakClient client."""
with (
patch(
"homeassistant.components.husqvarna_automower_ble.Mower",
autospec=True,
) as mock_client,
patch(
"homeassistant.components.husqvarna_automower_ble.config_flow.Mower",
new=mock_client,
),
):
client = mock_client.return_value
client.connect.return_value = True
client.is_connected.return_value = True
client.get_model.return_value = "305"
client.battery_level.return_value = 100
client.mower_state.return_value = "pendingStart"
client.mower_activity.return_value = "charging"
client.probe_gatts.return_value = ("Husqvarna", "Automower", "305")
yield client
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Mock a config entry."""
return MockConfigEntry(
domain=DOMAIN,
title="Husqvarna AutoMower",
data={
CONF_ADDRESS: AUTOMOWER_SERVICE_INFO.address,
CONF_CLIENT_ID: 1197489078,
},
unique_id=AUTOMOWER_SERVICE_INFO.address,
)

View File

@ -0,0 +1,33 @@
# serializer version: 1
# name: test_setup
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'husqvarna_automower_ble',
'00000000-0000-0000-0000-000000000003_1197489078',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'Husqvarna',
'model': None,
'model_id': '305',
'name': 'Husqvarna AutoMower',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'suggested_area': None,
'sw_version': None,
'via_device_id': None,
})
# ---

View File

@ -0,0 +1,198 @@
"""Test the Husqvarna Bluetooth config flow."""
from unittest.mock import Mock, patch
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.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from . import (
AUTOMOWER_SERVICE_INFO,
AUTOMOWER_UNNAMED_SERVICE_INFO,
AUTOMOWER_UNSUPPORTED_GROUP_SERVICE_INFO,
)
from tests.common import MockConfigEntry
from tests.components.bluetooth import inject_bluetooth_service_info
pytestmark = pytest.mark.usefixtures("mock_setup_entry")
@pytest.fixture(autouse=True)
def mock_random() -> Mock:
"""Mock random to generate predictable client id."""
with patch(
"homeassistant.components.husqvarna_automower_ble.config_flow.random"
) as mock_random:
mock_random.randint.return_value = 1197489078
yield mock_random
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(
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"},
)
assert result["type"] is FlowResultType.FORM
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["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,
}
async def test_bluetooth(hass: HomeAssistant) -> None:
"""Test bluetooth device discovery."""
inject_bluetooth_service_info(hass, AUTOMOWER_SERVICE_INFO)
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"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={},
)
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,
}
async def test_bluetooth_invalid(hass: HomeAssistant) -> None:
"""Test bluetooth device discovery with invalid data."""
inject_bluetooth_service_info(hass, AUTOMOWER_UNSUPPORTED_GROUP_SERVICE_INFO)
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_UNSUPPORTED_GROUP_SERVICE_INFO,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "no_devices_found"
async def test_failed_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
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"},
)
assert result["type"] is FlowResultType.FORM
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["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,
}
async def test_duplicate_entry(
hass: HomeAssistant,
mock_automower_client: Mock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test we can select a device."""
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
assert len(hass.config_entries.flow.async_progress_by_handler(DOMAIN)) == 0
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-000000000003"},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_exception_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.probe_gatts.side_effect = BleakError
result = hass.config_entries.flow.async_progress_by_handler(DOMAIN)[0]
assert result["step_id"] == "confirm"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "cannot_connect"

View File

@ -0,0 +1,71 @@
"""Test the Husqvarna Automower Bluetooth setup."""
from unittest.mock import Mock
from bleak import BleakError
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.husqvarna_automower_ble.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from . import AUTOMOWER_SERVICE_INFO
from tests.common import MockConfigEntry
pytestmark = pytest.mark.usefixtures("mock_automower_client")
async def test_setup(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
) -> None:
"""Test setup creates expected devices."""
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.LOADED
device_entry = device_registry.async_get_device(
identifiers={(DOMAIN, f"{AUTOMOWER_SERVICE_INFO.address}_1197489078")}
)
assert device_entry == snapshot
async def test_setup_retry_connect(
hass: HomeAssistant,
mock_automower_client: Mock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test setup creates expected devices."""
mock_automower_client.connect.return_value = False
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_RETRY
async def test_setup_failed_connect(
hass: HomeAssistant,
mock_automower_client: Mock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test setup creates expected devices."""
mock_automower_client.connect.side_effect = BleakError
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_RETRY

View File

@ -0,0 +1,126 @@
"""Test the Husqvarna Automower Bluetooth setup."""
from datetime import timedelta
from unittest.mock import Mock
from bleak import BleakError
from freezegun.api import FrozenDateTimeFactory
import pytest
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry, async_fire_time_changed
pytestmark = pytest.mark.usefixtures("mock_automower_client")
@pytest.mark.parametrize(
(
"is_connected_side_effect",
"is_connected_return_value",
"connect_side_effect",
"connect_return_value",
),
[
(None, False, None, False),
(None, False, BleakError, False),
(None, False, None, True),
(BleakError, False, None, True),
],
)
async def test_setup_disconnect(
hass: HomeAssistant,
mock_automower_client: Mock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
is_connected_side_effect: Exception,
is_connected_return_value: bool,
connect_side_effect: Exception,
connect_return_value: bool,
) -> None:
"""Test disconnected device."""
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.LOADED
assert hass.states.get("lawn_mower.husqvarna_automower").state != STATE_UNAVAILABLE
mock_automower_client.is_connected.side_effect = is_connected_side_effect
mock_automower_client.is_connected.return_value = is_connected_return_value
mock_automower_client.connect.side_effect = connect_side_effect
mock_automower_client.connect.return_value = connect_return_value
freezer.tick(timedelta(seconds=60))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert hass.states.get("lawn_mower.husqvarna_automower").state == STATE_UNAVAILABLE
@pytest.mark.parametrize(
("attribute"),
[
"mower_activity",
"mower_state",
"battery_level",
],
)
async def test_invalid_data_received(
hass: HomeAssistant,
mock_automower_client: Mock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
attribute: str,
) -> None:
"""Test invalid data received."""
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.LOADED
getattr(mock_automower_client, attribute).return_value = None
freezer.tick(timedelta(seconds=60))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert hass.states.get("lawn_mower.husqvarna_automower").state == STATE_UNAVAILABLE
@pytest.mark.parametrize(
("attribute"),
[
"mower_activity",
"mower_state",
"battery_level",
],
)
async def test_bleak_error_data_update(
hass: HomeAssistant,
mock_automower_client: Mock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
attribute: str,
) -> None:
"""Test BleakError during data update."""
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.LOADED
getattr(mock_automower_client, attribute).side_effect = BleakError
freezer.tick(timedelta(seconds=60))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert hass.states.get("lawn_mower.husqvarna_automower").state == STATE_UNAVAILABLE