mirror of
https://github.com/home-assistant/core.git
synced 2025-07-07 21:37:07 +00:00
Add husqvarna automower ble integration (#108326)
Co-authored-by: Joostlek <joostlek@outlook.com>
This commit is contained in:
parent
759fe54132
commit
b3cb2ac3ee
@ -659,6 +659,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/hunterdouglas_powerview/ @bdraco @kingy444 @trullock
|
/tests/components/hunterdouglas_powerview/ @bdraco @kingy444 @trullock
|
||||||
/homeassistant/components/husqvarna_automower/ @Thomas55555
|
/homeassistant/components/husqvarna_automower/ @Thomas55555
|
||||||
/tests/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
|
/homeassistant/components/huum/ @frwickst
|
||||||
/tests/components/huum/ @frwickst
|
/tests/components/huum/ @frwickst
|
||||||
/homeassistant/components/hvv_departures/ @vigonotion
|
/homeassistant/components/hvv_departures/ @vigonotion
|
||||||
|
5
homeassistant/brands/husqvarna.json
Normal file
5
homeassistant/brands/husqvarna.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"domain": "husqvarna",
|
||||||
|
"name": "Husqvarna",
|
||||||
|
"integrations": ["husqvarna_automower", "husqvarna_automower_ble"]
|
||||||
|
}
|
63
homeassistant/components/husqvarna_automower_ble/__init__.py
Normal file
63
homeassistant/components/husqvarna_automower_ble/__init__.py
Normal 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
|
121
homeassistant/components/husqvarna_automower_ble/config_flow.py
Normal file
121
homeassistant/components/husqvarna_automower_ble/config_flow.py
Normal 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,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
@ -0,0 +1,8 @@
|
|||||||
|
"""Constants for the Husqvarna Automower Bluetooth integration."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
DOMAIN = "husqvarna_automower_ble"
|
||||||
|
MANUFACTURER = "Husqvarna"
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger(__package__)
|
100
homeassistant/components/husqvarna_automower_ble/coordinator.py
Normal file
100
homeassistant/components/husqvarna_automower_ble/coordinator.py
Normal 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
|
30
homeassistant/components/husqvarna_automower_ble/entity.py
Normal file
30
homeassistant/components/husqvarna_automower_ble/entity.py
Normal 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()
|
149
homeassistant/components/husqvarna_automower_ble/lawn_mower.py
Normal file
149
homeassistant/components/husqvarna_automower_ble/lawn_mower.py
Normal 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()
|
@ -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"]
|
||||||
|
}
|
@ -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%]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -279,6 +279,11 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [
|
|||||||
],
|
],
|
||||||
"manufacturer_id": 76,
|
"manufacturer_id": 76,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"connectable": True,
|
||||||
|
"domain": "husqvarna_automower_ble",
|
||||||
|
"service_uuid": "98bd0001-0b0e-421a-84e5-ddbf75dc6de4",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"domain": "ibeacon",
|
"domain": "ibeacon",
|
||||||
"manufacturer_data_start": [
|
"manufacturer_data_start": [
|
||||||
|
@ -264,6 +264,7 @@ FLOWS = {
|
|||||||
"huisbaasje",
|
"huisbaasje",
|
||||||
"hunterdouglas_powerview",
|
"hunterdouglas_powerview",
|
||||||
"husqvarna_automower",
|
"husqvarna_automower",
|
||||||
|
"husqvarna_automower_ble",
|
||||||
"huum",
|
"huum",
|
||||||
"hvv_departures",
|
"hvv_departures",
|
||||||
"hydrawise",
|
"hydrawise",
|
||||||
|
@ -2678,11 +2678,22 @@
|
|||||||
"integration_type": "virtual",
|
"integration_type": "virtual",
|
||||||
"supported_by": "motion_blinds"
|
"supported_by": "motion_blinds"
|
||||||
},
|
},
|
||||||
"husqvarna_automower": {
|
"husqvarna": {
|
||||||
"name": "Husqvarna Automower",
|
"name": "Husqvarna",
|
||||||
"integration_type": "hub",
|
"integrations": {
|
||||||
"config_flow": true,
|
"husqvarna_automower": {
|
||||||
"iot_class": "cloud_push"
|
"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": {
|
"huum": {
|
||||||
"name": "Huum",
|
"name": "Huum",
|
||||||
|
@ -523,6 +523,9 @@ aurorapy==0.2.7
|
|||||||
# homeassistant.components.autarco
|
# homeassistant.components.autarco
|
||||||
autarco==3.0.0
|
autarco==3.0.0
|
||||||
|
|
||||||
|
# homeassistant.components.husqvarna_automower_ble
|
||||||
|
automower-ble==0.1.35
|
||||||
|
|
||||||
# homeassistant.components.avea
|
# homeassistant.components.avea
|
||||||
# avea==1.5.1
|
# avea==1.5.1
|
||||||
|
|
||||||
|
@ -478,6 +478,9 @@ aurorapy==0.2.7
|
|||||||
# homeassistant.components.autarco
|
# homeassistant.components.autarco
|
||||||
autarco==3.0.0
|
autarco==3.0.0
|
||||||
|
|
||||||
|
# homeassistant.components.husqvarna_automower_ble
|
||||||
|
automower-ble==0.1.35
|
||||||
|
|
||||||
# homeassistant.components.axis
|
# homeassistant.components.axis
|
||||||
axis==63
|
axis==63
|
||||||
|
|
||||||
|
74
tests/components/husqvarna_automower_ble/__init__.py
Normal file
74
tests/components/husqvarna_automower_ble/__init__.py
Normal 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()
|
82
tests/components/husqvarna_automower_ble/conftest.py
Normal file
82
tests/components/husqvarna_automower_ble/conftest.py
Normal 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,
|
||||||
|
)
|
@ -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,
|
||||||
|
})
|
||||||
|
# ---
|
198
tests/components/husqvarna_automower_ble/test_config_flow.py
Normal file
198
tests/components/husqvarna_automower_ble/test_config_flow.py
Normal 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"
|
71
tests/components/husqvarna_automower_ble/test_init.py
Normal file
71
tests/components/husqvarna_automower_ble/test_init.py
Normal 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
|
126
tests/components/husqvarna_automower_ble/test_lawn_mower.py
Normal file
126
tests/components/husqvarna_automower_ble/test_lawn_mower.py
Normal 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
|
Loading…
x
Reference in New Issue
Block a user