Update switchbot to be local push (#75645)

* Update switchbot to be local push

* fixes

* fixes

* fixes

* fixes

* adjust

* cover is not assumed anymore

* cleanups

* adjust

* adjust

* add missing cover

* import compat

* fixes

* uses lower

* uses lower

* bleak users upper case addresses

* fixes

* bump

* keep conf_mac and deprecated options for rollback

* reuse coordinator

* adjust

* move around

* move around

* move around

* move around

* refactor fixes

* compat with DataUpdateCoordinator

* fix available

* Update homeassistant/components/bluetooth/passive_update_processor.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/bluetooth/passive_update_coordinator.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/bluetooth/update_coordinator.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Split bluetooth coordinator into PassiveBluetoothDataUpdateCoordinator and PassiveBluetoothProcessorCoordinator

The PassiveBluetoothDataUpdateCoordinator is now used to replace instances
of DataUpdateCoordinator where the data is coming from bluetooth
advertisements, and the integration may also mix in active updates

The PassiveBluetoothProcessorCoordinator is used for integrations that
want to process each bluetooth advertisement with multiple processors
which can be dispatched to individual platforms or areas or the integration
as it chooes

* change connections

* reduce code churn to reduce review overhead

* reduce code churn to reduce review overhead

* Update homeassistant/components/bluetooth/passive_update_coordinator.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* add basic test

* add basic test

* complete coverage

* Update homeassistant/components/switchbot/coordinator.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/switchbot/coordinator.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/switchbot/__init__.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/switchbot/__init__.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* lint

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
J. Nick Koston 2022-07-24 11:38:45 -05:00 committed by GitHub
parent 79be87f9ce
commit 198167a2c8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 542 additions and 462 deletions

View File

@ -1,10 +1,24 @@
"""Support for Switchbot devices.""" """Support for Switchbot devices."""
from collections.abc import Mapping
import logging
from types import MappingProxyType
from typing import Any
import switchbot import switchbot
from homeassistant.components import bluetooth
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_SENSOR_TYPE, Platform from homeassistant.const import (
CONF_ADDRESS,
CONF_MAC,
CONF_PASSWORD,
CONF_SENSOR_TYPE,
Platform,
)
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from .const import ( from .const import (
ATTR_BOT, ATTR_BOT,
@ -13,13 +27,8 @@ from .const import (
COMMON_OPTIONS, COMMON_OPTIONS,
CONF_RETRY_COUNT, CONF_RETRY_COUNT,
CONF_RETRY_TIMEOUT, CONF_RETRY_TIMEOUT,
CONF_SCAN_TIMEOUT,
CONF_TIME_BETWEEN_UPDATE_COMMAND,
DATA_COORDINATOR,
DEFAULT_RETRY_COUNT, DEFAULT_RETRY_COUNT,
DEFAULT_RETRY_TIMEOUT, DEFAULT_RETRY_TIMEOUT,
DEFAULT_SCAN_TIMEOUT,
DEFAULT_TIME_BETWEEN_UPDATE_COMMAND,
DOMAIN, DOMAIN,
) )
from .coordinator import SwitchbotDataUpdateCoordinator from .coordinator import SwitchbotDataUpdateCoordinator
@ -29,57 +38,67 @@ PLATFORMS_BY_TYPE = {
ATTR_CURTAIN: [Platform.COVER, Platform.BINARY_SENSOR, Platform.SENSOR], ATTR_CURTAIN: [Platform.COVER, Platform.BINARY_SENSOR, Platform.SENSOR],
ATTR_HYGROMETER: [Platform.SENSOR], ATTR_HYGROMETER: [Platform.SENSOR],
} }
CLASS_BY_DEVICE = {
ATTR_CURTAIN: switchbot.SwitchbotCurtain,
ATTR_BOT: switchbot.Switchbot,
}
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Switchbot from a config entry.""" """Set up Switchbot from a config entry."""
hass.data.setdefault(DOMAIN, {}) hass.data.setdefault(DOMAIN, {})
domain_data = hass.data[DOMAIN]
if not entry.options: if CONF_ADDRESS not in entry.data and CONF_MAC in entry.data:
options = { # Bleak uses addresses not mac addresses which are are actually
CONF_TIME_BETWEEN_UPDATE_COMMAND: DEFAULT_TIME_BETWEEN_UPDATE_COMMAND, # UUIDs on some platforms (MacOS).
CONF_RETRY_COUNT: DEFAULT_RETRY_COUNT, mac = entry.data[CONF_MAC]
CONF_RETRY_TIMEOUT: DEFAULT_RETRY_TIMEOUT, if "-" not in mac:
CONF_SCAN_TIMEOUT: DEFAULT_SCAN_TIMEOUT, mac = dr.format_mac(mac)
} hass.config_entries.async_update_entry(
entry,
hass.config_entries.async_update_entry(entry, options=options) data={**entry.data, CONF_ADDRESS: mac},
# Use same coordinator instance for all entities.
# Uses BTLE advertisement data, all Switchbot devices in range is stored here.
if DATA_COORDINATOR not in hass.data[DOMAIN]:
if COMMON_OPTIONS not in hass.data[DOMAIN]:
hass.data[DOMAIN][COMMON_OPTIONS] = {**entry.options}
switchbot.DEFAULT_RETRY_TIMEOUT = hass.data[DOMAIN][COMMON_OPTIONS][
CONF_RETRY_TIMEOUT
]
# Store api in coordinator.
coordinator = SwitchbotDataUpdateCoordinator(
hass,
update_interval=hass.data[DOMAIN][COMMON_OPTIONS][
CONF_TIME_BETWEEN_UPDATE_COMMAND
],
api=switchbot,
retry_count=hass.data[DOMAIN][COMMON_OPTIONS][CONF_RETRY_COUNT],
scan_timeout=hass.data[DOMAIN][COMMON_OPTIONS][CONF_SCAN_TIMEOUT],
) )
hass.data[DOMAIN][DATA_COORDINATOR] = coordinator if not entry.options:
hass.config_entries.async_update_entry(
entry,
options={
CONF_RETRY_COUNT: DEFAULT_RETRY_COUNT,
CONF_RETRY_TIMEOUT: DEFAULT_RETRY_TIMEOUT,
},
)
else: sensor_type: str = entry.data[CONF_SENSOR_TYPE]
coordinator = hass.data[DOMAIN][DATA_COORDINATOR] address: str = entry.data[CONF_ADDRESS]
ble_device = bluetooth.async_ble_device_from_address(hass, address.upper())
if not ble_device:
raise ConfigEntryNotReady(
f"Could not find Switchbot {sensor_type} with address {address}"
)
await coordinator.async_config_entry_first_refresh() if COMMON_OPTIONS not in domain_data:
domain_data[COMMON_OPTIONS] = entry.options
common_options: Mapping[str, int] = domain_data[COMMON_OPTIONS]
switchbot.DEFAULT_RETRY_TIMEOUT = common_options[CONF_RETRY_TIMEOUT]
cls = CLASS_BY_DEVICE.get(sensor_type, switchbot.SwitchbotDevice)
device = cls(
device=ble_device,
password=entry.data.get(CONF_PASSWORD),
retry_count=entry.options[CONF_RETRY_COUNT],
)
coordinator = hass.data[DOMAIN][entry.entry_id] = SwitchbotDataUpdateCoordinator(
hass, _LOGGER, ble_device, device, common_options
)
entry.async_on_unload(coordinator.async_start())
if not await coordinator.async_wait_ready():
raise ConfigEntryNotReady(f"Switchbot {sensor_type} with {address} not ready")
entry.async_on_unload(entry.add_update_listener(_async_update_listener)) entry.async_on_unload(entry.add_update_listener(_async_update_listener))
hass.data[DOMAIN][entry.entry_id] = {DATA_COORDINATOR: coordinator}
sensor_type = entry.data[CONF_SENSOR_TYPE]
await hass.config_entries.async_forward_entry_setups( await hass.config_entries.async_forward_entry_setups(
entry, PLATFORMS_BY_TYPE[sensor_type] entry, PLATFORMS_BY_TYPE[sensor_type]
) )
@ -96,8 +115,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if unload_ok: if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id) hass.data[DOMAIN].pop(entry.entry_id)
if not hass.config_entries.async_entries(DOMAIN):
if len(hass.config_entries.async_entries(DOMAIN)) == 0:
hass.data.pop(DOMAIN) hass.data.pop(DOMAIN)
return unload_ok return unload_ok
@ -106,8 +124,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle options update.""" """Handle options update."""
# Update entity options stored in hass. # Update entity options stored in hass.
if {**entry.options} != hass.data[DOMAIN][COMMON_OPTIONS]: common_options: MappingProxyType[str, Any] = hass.data[DOMAIN][COMMON_OPTIONS]
hass.data[DOMAIN][COMMON_OPTIONS] = {**entry.options} if entry.options != common_options:
hass.data[DOMAIN].pop(DATA_COORDINATOR) await hass.config_entries.async_reload(entry.entry_id)
await hass.config_entries.async_reload(entry.entry_id)

View File

@ -6,13 +6,12 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntityDescription, BinarySensorEntityDescription,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_MAC, CONF_NAME from homeassistant.const import CONF_ADDRESS, CONF_NAME
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DATA_COORDINATOR, DOMAIN from .const import DOMAIN
from .coordinator import SwitchbotDataUpdateCoordinator from .coordinator import SwitchbotDataUpdateCoordinator
from .entity import SwitchbotEntity from .entity import SwitchbotEntity
@ -30,23 +29,19 @@ async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None: ) -> None:
"""Set up Switchbot curtain based on a config entry.""" """Set up Switchbot curtain based on a config entry."""
coordinator: SwitchbotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ coordinator: SwitchbotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
DATA_COORDINATOR unique_id = entry.unique_id
] assert unique_id is not None
if not coordinator.data.get(entry.unique_id):
raise PlatformNotReady
async_add_entities( async_add_entities(
[ [
SwitchBotBinarySensor( SwitchBotBinarySensor(
coordinator, coordinator,
entry.unique_id, unique_id,
binary_sensor, binary_sensor,
entry.data[CONF_MAC], entry.data[CONF_ADDRESS],
entry.data[CONF_NAME], entry.data[CONF_NAME],
) )
for binary_sensor in coordinator.data[entry.unique_id]["data"] for binary_sensor in coordinator.data["data"]
if binary_sensor in BINARY_SENSOR_TYPES if binary_sensor in BINARY_SENSOR_TYPES
] ]
) )
@ -58,15 +53,15 @@ class SwitchBotBinarySensor(SwitchbotEntity, BinarySensorEntity):
def __init__( def __init__(
self, self,
coordinator: SwitchbotDataUpdateCoordinator, coordinator: SwitchbotDataUpdateCoordinator,
idx: str | None, unique_id: str,
binary_sensor: str, binary_sensor: str,
mac: str, mac: str,
switchbot_name: str, switchbot_name: str,
) -> None: ) -> None:
"""Initialize the Switchbot sensor.""" """Initialize the Switchbot sensor."""
super().__init__(coordinator, idx, mac, name=switchbot_name) super().__init__(coordinator, unique_id, mac, name=switchbot_name)
self._sensor = binary_sensor self._sensor = binary_sensor
self._attr_unique_id = f"{idx}-{binary_sensor}" self._attr_unique_id = f"{unique_id}-{binary_sensor}"
self._attr_name = f"{switchbot_name} {binary_sensor.title()}" self._attr_name = f"{switchbot_name} {binary_sensor.title()}"
self.entity_description = BINARY_SENSOR_TYPES[binary_sensor] self.entity_description = BINARY_SENSOR_TYPES[binary_sensor]

View File

@ -2,25 +2,26 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import Any from typing import Any, cast
from switchbot import GetSwitchbotDevices from switchbot import SwitchBotAdvertisement, parse_advertisement_data
import voluptuous as vol import voluptuous as vol
from homeassistant.components.bluetooth import (
BluetoothServiceInfoBleak,
async_discovered_service_info,
)
from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow
from homeassistant.const import CONF_MAC, CONF_NAME, CONF_PASSWORD, CONF_SENSOR_TYPE from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_PASSWORD, CONF_SENSOR_TYPE
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo
from .const import ( from .const import (
CONF_RETRY_COUNT, CONF_RETRY_COUNT,
CONF_RETRY_TIMEOUT, CONF_RETRY_TIMEOUT,
CONF_SCAN_TIMEOUT,
CONF_TIME_BETWEEN_UPDATE_COMMAND,
DEFAULT_RETRY_COUNT, DEFAULT_RETRY_COUNT,
DEFAULT_RETRY_TIMEOUT, DEFAULT_RETRY_TIMEOUT,
DEFAULT_SCAN_TIMEOUT,
DEFAULT_TIME_BETWEEN_UPDATE_COMMAND,
DOMAIN, DOMAIN,
SUPPORTED_MODEL_TYPES, SUPPORTED_MODEL_TYPES,
) )
@ -28,15 +29,9 @@ from .const import (
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
async def _btle_connect() -> dict: def format_unique_id(address: str) -> str:
"""Scan for BTLE advertisement data.""" """Format the unique ID for a switchbot."""
return address.replace(":", "").lower()
switchbot_devices = await GetSwitchbotDevices().discover()
if not switchbot_devices:
raise NotConnectedError("Failed to discover switchbot")
return switchbot_devices
class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN): class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN):
@ -44,18 +39,6 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1 VERSION = 1
async def _get_switchbots(self) -> dict:
"""Try to discover nearby Switchbot devices."""
# asyncio.lock prevents btle adapter exceptions if there are multiple calls to this method.
# store asyncio.lock in hass data if not present.
if DOMAIN not in self.hass.data:
self.hass.data.setdefault(DOMAIN, {})
# Discover switchbots nearby.
_btle_adv_data = await _btle_connect()
return _btle_adv_data
@staticmethod @staticmethod
@callback @callback
def async_get_options_flow( def async_get_options_flow(
@ -66,62 +49,79 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN):
def __init__(self): def __init__(self):
"""Initialize the config flow.""" """Initialize the config flow."""
self._discovered_devices = {} self._discovered_adv: SwitchBotAdvertisement | None = None
self._discovered_advs: dict[str, SwitchBotAdvertisement] = {}
async def async_step_bluetooth(
self, discovery_info: BluetoothServiceInfo
) -> FlowResult:
"""Handle the bluetooth discovery step."""
_LOGGER.debug("Discovered bluetooth device: %s", discovery_info)
await self.async_set_unique_id(format_unique_id(discovery_info.address))
self._abort_if_unique_id_configured()
discovery_info_bleak = cast(BluetoothServiceInfoBleak, discovery_info)
parsed = parse_advertisement_data(
discovery_info_bleak.device, discovery_info_bleak.advertisement
)
if not parsed or parsed.data.get("modelName") not in SUPPORTED_MODEL_TYPES:
return self.async_abort(reason="not_supported")
self._discovered_adv = parsed
data = parsed.data
self.context["title_placeholders"] = {
"name": data["modelName"],
"address": discovery_info.address,
}
return await self.async_step_user()
async def async_step_user( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> FlowResult: ) -> FlowResult:
"""Handle a flow initiated by the user.""" """Handle the user step to pick discovered device."""
errors: dict[str, str] = {} errors: dict[str, str] = {}
if user_input is not None: if user_input is not None:
await self.async_set_unique_id(user_input[CONF_MAC].replace(":", "")) address = user_input[CONF_ADDRESS]
await self.async_set_unique_id(
format_unique_id(address), raise_on_progress=False
)
self._abort_if_unique_id_configured() self._abort_if_unique_id_configured()
user_input[CONF_SENSOR_TYPE] = SUPPORTED_MODEL_TYPES[ user_input[CONF_SENSOR_TYPE] = SUPPORTED_MODEL_TYPES[
self._discovered_devices[self.unique_id]["modelName"] self._discovered_advs[address].data["modelName"]
] ]
return self.async_create_entry(title=user_input[CONF_NAME], data=user_input) return self.async_create_entry(title=user_input[CONF_NAME], data=user_input)
try: if discovery := self._discovered_adv:
self._discovered_devices = await self._get_switchbots() self._discovered_advs[discovery.address] = discovery
for device in self._discovered_devices.values(): else:
_LOGGER.debug("Found %s", device) current_addresses = self._async_current_ids()
for discovery_info in async_discovered_service_info(self.hass):
address = discovery_info.address
if (
format_unique_id(address) in current_addresses
or address in self._discovered_advs
):
continue
parsed = parse_advertisement_data(
discovery_info.device, discovery_info.advertisement
)
if parsed and parsed.data.get("modelName") in SUPPORTED_MODEL_TYPES:
self._discovered_advs[address] = parsed
except NotConnectedError: if not self._discovered_advs:
return self.async_abort(reason="cannot_connect")
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
return self.async_abort(reason="unknown")
# Get devices already configured.
configured_devices = {
item.data[CONF_MAC]
for item in self._async_current_entries(include_ignore=False)
}
# Get supported devices not yet configured.
unconfigured_devices = {
device["mac_address"]: f"{device['mac_address']} {device['modelName']}"
for device in self._discovered_devices.values()
if device.get("modelName") in SUPPORTED_MODEL_TYPES
and device["mac_address"] not in configured_devices
}
if not unconfigured_devices:
return self.async_abort(reason="no_unconfigured_devices") return self.async_abort(reason="no_unconfigured_devices")
data_schema = vol.Schema( data_schema = vol.Schema(
{ {
vol.Required(CONF_MAC): vol.In(unconfigured_devices), vol.Required(CONF_ADDRESS): vol.In(
{
address: f"{parsed.data['modelName']} ({address})"
for address, parsed in self._discovered_advs.items()
}
),
vol.Required(CONF_NAME): str, vol.Required(CONF_NAME): str,
vol.Optional(CONF_PASSWORD): str, vol.Optional(CONF_PASSWORD): str,
} }
) )
return self.async_show_form( return self.async_show_form(
step_id="user", data_schema=data_schema, errors=errors step_id="user", data_schema=data_schema, errors=errors
) )
@ -148,13 +148,6 @@ class SwitchbotOptionsFlowHandler(OptionsFlow):
return self.async_create_entry(title="", data=user_input) return self.async_create_entry(title="", data=user_input)
options = { options = {
vol.Optional(
CONF_TIME_BETWEEN_UPDATE_COMMAND,
default=self.config_entry.options.get(
CONF_TIME_BETWEEN_UPDATE_COMMAND,
DEFAULT_TIME_BETWEEN_UPDATE_COMMAND,
),
): int,
vol.Optional( vol.Optional(
CONF_RETRY_COUNT, CONF_RETRY_COUNT,
default=self.config_entry.options.get( default=self.config_entry.options.get(
@ -167,16 +160,6 @@ class SwitchbotOptionsFlowHandler(OptionsFlow):
CONF_RETRY_TIMEOUT, DEFAULT_RETRY_TIMEOUT CONF_RETRY_TIMEOUT, DEFAULT_RETRY_TIMEOUT
), ),
): int, ): int,
vol.Optional(
CONF_SCAN_TIMEOUT,
default=self.config_entry.options.get(
CONF_SCAN_TIMEOUT, DEFAULT_SCAN_TIMEOUT
),
): int,
} }
return self.async_show_form(step_id="init", data_schema=vol.Schema(options)) return self.async_show_form(step_id="init", data_schema=vol.Schema(options))
class NotConnectedError(Exception):
"""Exception for unable to find device."""

View File

@ -16,15 +16,14 @@ SUPPORTED_MODEL_TYPES = {
# Config Defaults # Config Defaults
DEFAULT_RETRY_COUNT = 3 DEFAULT_RETRY_COUNT = 3
DEFAULT_RETRY_TIMEOUT = 5 DEFAULT_RETRY_TIMEOUT = 5
DEFAULT_TIME_BETWEEN_UPDATE_COMMAND = 60
DEFAULT_SCAN_TIMEOUT = 5
# Config Options # Config Options
CONF_TIME_BETWEEN_UPDATE_COMMAND = "update_time"
CONF_RETRY_COUNT = "retry_count" CONF_RETRY_COUNT = "retry_count"
CONF_RETRY_TIMEOUT = "retry_timeout"
CONF_SCAN_TIMEOUT = "scan_timeout" CONF_SCAN_TIMEOUT = "scan_timeout"
# Deprecated config Entry Options to be removed in 2023.4
CONF_TIME_BETWEEN_UPDATE_COMMAND = "update_time"
CONF_RETRY_TIMEOUT = "retry_timeout"
# Data # Data
DATA_COORDINATOR = "coordinator"
COMMON_OPTIONS = "common_options" COMMON_OPTIONS = "common_options"

View File

@ -1,15 +1,22 @@
"""Provides the switchbot DataUpdateCoordinator.""" """Provides the switchbot DataUpdateCoordinator."""
from __future__ import annotations from __future__ import annotations
from datetime import timedelta import asyncio
from collections.abc import Mapping
import logging import logging
from typing import Any, cast
from bleak.backends.device import BLEDevice
import switchbot import switchbot
from switchbot import parse_advertisement_data
from homeassistant.core import HomeAssistant from homeassistant.components import bluetooth
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.components.bluetooth.passive_update_coordinator import (
PassiveBluetoothDataUpdateCoordinator,
)
from homeassistant.core import HomeAssistant, callback
from .const import DOMAIN from .const import CONF_RETRY_COUNT
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -22,40 +29,53 @@ def flatten_sensors_data(sensor):
return sensor return sensor
class SwitchbotDataUpdateCoordinator(DataUpdateCoordinator): class SwitchbotDataUpdateCoordinator(PassiveBluetoothDataUpdateCoordinator):
"""Class to manage fetching switchbot data.""" """Class to manage fetching switchbot data."""
def __init__( def __init__(
self, self,
hass: HomeAssistant, hass: HomeAssistant,
*, logger: logging.Logger,
update_interval: int, ble_device: BLEDevice,
api: switchbot, device: switchbot.SwitchbotDevice,
retry_count: int, common_options: Mapping[str, int],
scan_timeout: int,
) -> None: ) -> None:
"""Initialize global switchbot data updater.""" """Initialize global switchbot data updater."""
self.switchbot_api = api super().__init__(hass, logger, ble_device.address)
self.switchbot_data = self.switchbot_api.GetSwitchbotDevices() self.ble_device = ble_device
self.retry_count = retry_count self.device = device
self.scan_timeout = scan_timeout self.common_options = common_options
self.update_interval = timedelta(seconds=update_interval) self.data: dict[str, Any] = {}
self._ready_event = asyncio.Event()
super().__init__( @property
hass, _LOGGER, name=DOMAIN, update_interval=self.update_interval def retry_count(self) -> int:
) """Return retry count."""
return self.common_options[CONF_RETRY_COUNT]
async def _async_update_data(self) -> dict | None: @callback
"""Fetch data from switchbot.""" def _async_handle_bluetooth_event(
self,
service_info: bluetooth.BluetoothServiceInfo,
change: bluetooth.BluetoothChange,
) -> None:
"""Handle a Bluetooth event."""
super()._async_handle_bluetooth_event(service_info, change)
discovery_info_bleak = cast(bluetooth.BluetoothServiceInfoBleak, service_info)
if adv := parse_advertisement_data(
discovery_info_bleak.device, discovery_info_bleak.advertisement
):
self.data = flatten_sensors_data(adv.data)
if "modelName" in self.data:
self._ready_event.set()
_LOGGER.debug("%s: Switchbot data: %s", self.ble_device.address, self.data)
self.device.update_from_advertisement(adv)
self.async_update_listeners()
switchbot_data = await self.switchbot_data.discover( async def async_wait_ready(self) -> bool:
retry=self.retry_count, scan_timeout=self.scan_timeout """Wait for the device to be ready."""
) try:
await asyncio.wait_for(self._ready_event.wait(), timeout=55)
if not switchbot_data: except asyncio.TimeoutError:
raise UpdateFailed("Unable to fetch switchbot services data") return False
return True
return {
identifier: flatten_sensors_data(sensor)
for identifier, sensor in switchbot_data.items()
}

View File

@ -14,13 +14,12 @@ from homeassistant.components.cover import (
CoverEntityFeature, CoverEntityFeature,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_MAC, CONF_NAME, CONF_PASSWORD from homeassistant.const import CONF_ADDRESS, CONF_NAME
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.restore_state import RestoreEntity
from .const import CONF_RETRY_COUNT, DATA_COORDINATOR, DOMAIN from .const import DOMAIN
from .coordinator import SwitchbotDataUpdateCoordinator from .coordinator import SwitchbotDataUpdateCoordinator
from .entity import SwitchbotEntity from .entity import SwitchbotEntity
@ -33,25 +32,17 @@ async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None: ) -> None:
"""Set up Switchbot curtain based on a config entry.""" """Set up Switchbot curtain based on a config entry."""
coordinator: SwitchbotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ coordinator: SwitchbotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
DATA_COORDINATOR unique_id = entry.unique_id
] assert unique_id is not None
if not coordinator.data.get(entry.unique_id):
raise PlatformNotReady
async_add_entities( async_add_entities(
[ [
SwitchBotCurtainEntity( SwitchBotCurtainEntity(
coordinator, coordinator,
entry.unique_id, unique_id,
entry.data[CONF_MAC], entry.data[CONF_ADDRESS],
entry.data[CONF_NAME], entry.data[CONF_NAME],
coordinator.switchbot_api.SwitchbotCurtain( coordinator.device,
mac=entry.data[CONF_MAC],
password=entry.data.get(CONF_PASSWORD),
retry_count=entry.options[CONF_RETRY_COUNT],
),
) )
] ]
) )
@ -67,19 +58,18 @@ class SwitchBotCurtainEntity(SwitchbotEntity, CoverEntity, RestoreEntity):
| CoverEntityFeature.STOP | CoverEntityFeature.STOP
| CoverEntityFeature.SET_POSITION | CoverEntityFeature.SET_POSITION
) )
_attr_assumed_state = True
def __init__( def __init__(
self, self,
coordinator: SwitchbotDataUpdateCoordinator, coordinator: SwitchbotDataUpdateCoordinator,
idx: str | None, unique_id: str,
mac: str, address: str,
name: str, name: str,
device: SwitchbotCurtain, device: SwitchbotCurtain,
) -> None: ) -> None:
"""Initialize the Switchbot.""" """Initialize the Switchbot."""
super().__init__(coordinator, idx, mac, name) super().__init__(coordinator, unique_id, address, name)
self._attr_unique_id = idx self._attr_unique_id = unique_id
self._attr_is_closed = None self._attr_is_closed = None
self._device = device self._device = device
@ -97,21 +87,21 @@ class SwitchBotCurtainEntity(SwitchbotEntity, CoverEntity, RestoreEntity):
async def async_open_cover(self, **kwargs: Any) -> None: async def async_open_cover(self, **kwargs: Any) -> None:
"""Open the curtain.""" """Open the curtain."""
_LOGGER.debug("Switchbot to open curtain %s", self._mac) _LOGGER.debug("Switchbot to open curtain %s", self._address)
self._last_run_success = bool(await self._device.open()) self._last_run_success = bool(await self._device.open())
self.async_write_ha_state() self.async_write_ha_state()
async def async_close_cover(self, **kwargs: Any) -> None: async def async_close_cover(self, **kwargs: Any) -> None:
"""Close the curtain.""" """Close the curtain."""
_LOGGER.debug("Switchbot to close the curtain %s", self._mac) _LOGGER.debug("Switchbot to close the curtain %s", self._address)
self._last_run_success = bool(await self._device.close()) self._last_run_success = bool(await self._device.close())
self.async_write_ha_state() self.async_write_ha_state()
async def async_stop_cover(self, **kwargs: Any) -> None: async def async_stop_cover(self, **kwargs: Any) -> None:
"""Stop the moving of this device.""" """Stop the moving of this device."""
_LOGGER.debug("Switchbot to stop %s", self._mac) _LOGGER.debug("Switchbot to stop %s", self._address)
self._last_run_success = bool(await self._device.stop()) self._last_run_success = bool(await self._device.stop())
self.async_write_ha_state() self.async_write_ha_state()
@ -119,7 +109,7 @@ class SwitchBotCurtainEntity(SwitchbotEntity, CoverEntity, RestoreEntity):
"""Move the cover shutter to a specific position.""" """Move the cover shutter to a specific position."""
position = kwargs.get(ATTR_POSITION) position = kwargs.get(ATTR_POSITION)
_LOGGER.debug("Switchbot to move at %d %s", position, self._mac) _LOGGER.debug("Switchbot to move at %d %s", position, self._address)
self._last_run_success = bool(await self._device.set_position(position)) self._last_run_success = bool(await self._device.set_position(position))
self.async_write_ha_state() self.async_write_ha_state()
@ -128,4 +118,5 @@ class SwitchBotCurtainEntity(SwitchbotEntity, CoverEntity, RestoreEntity):
"""Handle updated data from the coordinator.""" """Handle updated data from the coordinator."""
self._attr_current_cover_position = self.data["data"]["position"] self._attr_current_cover_position = self.data["data"]["position"]
self._attr_is_closed = self.data["data"]["position"] <= 20 self._attr_is_closed = self.data["data"]["position"] <= 20
self._attr_is_opening = self.data["data"]["inMotion"]
self.async_write_ha_state() self.async_write_ha_state()

View File

@ -4,43 +4,58 @@ from __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping
from typing import Any from typing import Any
from homeassistant.components.bluetooth.passive_update_coordinator import (
PassiveBluetoothCoordinatorEntity,
)
from homeassistant.const import ATTR_CONNECTIONS
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity import DeviceInfo, Entity from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import MANUFACTURER from .const import MANUFACTURER
from .coordinator import SwitchbotDataUpdateCoordinator from .coordinator import SwitchbotDataUpdateCoordinator
class SwitchbotEntity(CoordinatorEntity[SwitchbotDataUpdateCoordinator], Entity): class SwitchbotEntity(PassiveBluetoothCoordinatorEntity):
"""Generic entity encapsulating common features of Switchbot device.""" """Generic entity encapsulating common features of Switchbot device."""
coordinator: SwitchbotDataUpdateCoordinator
def __init__( def __init__(
self, self,
coordinator: SwitchbotDataUpdateCoordinator, coordinator: SwitchbotDataUpdateCoordinator,
idx: str | None, unique_id: str,
mac: str, address: str,
name: str, name: str,
) -> None: ) -> None:
"""Initialize the entity.""" """Initialize the entity."""
super().__init__(coordinator) super().__init__(coordinator)
self._last_run_success: bool | None = None self._last_run_success: bool | None = None
self._idx = idx self._unique_id = unique_id
self._mac = mac self._address = address
self._attr_name = name self._attr_name = name
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
connections={(dr.CONNECTION_NETWORK_MAC, self._mac)}, connections={(dr.CONNECTION_BLUETOOTH, self._address)},
manufacturer=MANUFACTURER, manufacturer=MANUFACTURER,
model=self.data["modelName"], model=self.data["modelName"],
name=name, name=name,
) )
if ":" not in self._address:
# MacOS Bluetooth addresses are not mac addresses
return
# If the bluetooth address is also a mac address,
# add this connection as well to prevent a new device
# entry from being created when upgrading from a previous
# version of the integration.
self._attr_device_info[ATTR_CONNECTIONS].add(
(dr.CONNECTION_NETWORK_MAC, self._address)
)
@property @property
def data(self) -> dict[str, Any]: def data(self) -> dict[str, Any]:
"""Return coordinator data for this entity.""" """Return coordinator data for this entity."""
return self.coordinator.data[self._idx] return self.coordinator.data
@property @property
def extra_state_attributes(self) -> Mapping[Any, Any]: def extra_state_attributes(self) -> Mapping[Any, Any]:
"""Return the state attributes.""" """Return the state attributes."""
return {"last_run_success": self._last_run_success, "mac_address": self._mac} return {"last_run_success": self._last_run_success}

View File

@ -2,10 +2,11 @@
"domain": "switchbot", "domain": "switchbot",
"name": "SwitchBot", "name": "SwitchBot",
"documentation": "https://www.home-assistant.io/integrations/switchbot", "documentation": "https://www.home-assistant.io/integrations/switchbot",
"requirements": ["PySwitchbot==0.14.1"], "requirements": ["PySwitchbot==0.15.0"],
"config_flow": true, "config_flow": true,
"dependencies": ["bluetooth"],
"codeowners": ["@danielhiversen", "@RenierM26", "@murtas"], "codeowners": ["@danielhiversen", "@RenierM26", "@murtas"],
"bluetooth": [{ "service_uuid": "cba20d00-224d-11e6-9fb8-0002a5d5c51b" }], "bluetooth": [{ "service_uuid": "cba20d00-224d-11e6-9fb8-0002a5d5c51b" }],
"iot_class": "local_polling", "iot_class": "local_push",
"loggers": ["switchbot"] "loggers": ["switchbot"]
} }

View File

@ -8,18 +8,17 @@ from homeassistant.components.sensor import (
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ( from homeassistant.const import (
CONF_MAC, CONF_ADDRESS,
CONF_NAME, CONF_NAME,
PERCENTAGE, PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT, SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
TEMP_CELSIUS, TEMP_CELSIUS,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DATA_COORDINATOR, DOMAIN from .const import DOMAIN
from .coordinator import SwitchbotDataUpdateCoordinator from .coordinator import SwitchbotDataUpdateCoordinator
from .entity import SwitchbotEntity from .entity import SwitchbotEntity
@ -61,23 +60,19 @@ async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None: ) -> None:
"""Set up Switchbot sensor based on a config entry.""" """Set up Switchbot sensor based on a config entry."""
coordinator: SwitchbotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ coordinator: SwitchbotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
DATA_COORDINATOR unique_id = entry.unique_id
] assert unique_id is not None
if not coordinator.data.get(entry.unique_id):
raise PlatformNotReady
async_add_entities( async_add_entities(
[ [
SwitchBotSensor( SwitchBotSensor(
coordinator, coordinator,
entry.unique_id, unique_id,
sensor, sensor,
entry.data[CONF_MAC], entry.data[CONF_ADDRESS],
entry.data[CONF_NAME], entry.data[CONF_NAME],
) )
for sensor in coordinator.data[entry.unique_id]["data"] for sensor in coordinator.data["data"]
if sensor in SENSOR_TYPES if sensor in SENSOR_TYPES
] ]
) )
@ -89,15 +84,15 @@ class SwitchBotSensor(SwitchbotEntity, SensorEntity):
def __init__( def __init__(
self, self,
coordinator: SwitchbotDataUpdateCoordinator, coordinator: SwitchbotDataUpdateCoordinator,
idx: str | None, unique_id: str,
sensor: str, sensor: str,
mac: str, address: str,
switchbot_name: str, switchbot_name: str,
) -> None: ) -> None:
"""Initialize the Switchbot sensor.""" """Initialize the Switchbot sensor."""
super().__init__(coordinator, idx, mac, name=switchbot_name) super().__init__(coordinator, unique_id, address, name=switchbot_name)
self._sensor = sensor self._sensor = sensor
self._attr_unique_id = f"{idx}-{sensor}" self._attr_unique_id = f"{unique_id}-{sensor}"
self._attr_name = f"{switchbot_name} {sensor.title()}" self._attr_name = f"{switchbot_name} {sensor.title()}"
self.entity_description = SENSOR_TYPES[sensor] self.entity_description = SENSOR_TYPES[sensor]

View File

@ -1,11 +1,11 @@
{ {
"config": { "config": {
"flow_title": "{name}", "flow_title": "{name} ({address})",
"step": { "step": {
"user": { "user": {
"title": "Setup Switchbot device", "title": "Setup Switchbot device",
"data": { "data": {
"mac": "Device MAC address", "address": "Device address",
"name": "[%key:common::config_flow::data::name%]", "name": "[%key:common::config_flow::data::name%]",
"password": "[%key:common::config_flow::data::password%]" "password": "[%key:common::config_flow::data::password%]"
} }
@ -24,10 +24,8 @@
"step": { "step": {
"init": { "init": {
"data": { "data": {
"update_time": "Time between updates (seconds)",
"retry_count": "Retry count", "retry_count": "Retry count",
"retry_timeout": "Timeout between retries", "retry_timeout": "Timeout between retries"
"scan_timeout": "How long to scan for advertisement data"
} }
} }
} }

View File

@ -8,13 +8,12 @@ from switchbot import Switchbot
from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_MAC, CONF_NAME, CONF_PASSWORD, STATE_ON from homeassistant.const import CONF_ADDRESS, CONF_NAME, STATE_ON
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers import entity_platform from homeassistant.helpers import entity_platform
from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.restore_state import RestoreEntity
from .const import CONF_RETRY_COUNT, DATA_COORDINATOR, DOMAIN from .const import DOMAIN
from .coordinator import SwitchbotDataUpdateCoordinator from .coordinator import SwitchbotDataUpdateCoordinator
from .entity import SwitchbotEntity from .entity import SwitchbotEntity
@ -29,25 +28,17 @@ async def async_setup_entry(
async_add_entities: entity_platform.AddEntitiesCallback, async_add_entities: entity_platform.AddEntitiesCallback,
) -> None: ) -> None:
"""Set up Switchbot based on a config entry.""" """Set up Switchbot based on a config entry."""
coordinator: SwitchbotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ coordinator: SwitchbotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
DATA_COORDINATOR unique_id = entry.unique_id
] assert unique_id is not None
if not coordinator.data.get(entry.unique_id):
raise PlatformNotReady
async_add_entities( async_add_entities(
[ [
SwitchBotBotEntity( SwitchBotBotEntity(
coordinator, coordinator,
entry.unique_id, unique_id,
entry.data[CONF_MAC], entry.data[CONF_ADDRESS],
entry.data[CONF_NAME], entry.data[CONF_NAME],
coordinator.switchbot_api.Switchbot( coordinator.device,
mac=entry.data[CONF_MAC],
password=entry.data.get(CONF_PASSWORD),
retry_count=entry.options[CONF_RETRY_COUNT],
),
) )
] ]
) )
@ -61,14 +52,14 @@ class SwitchBotBotEntity(SwitchbotEntity, SwitchEntity, RestoreEntity):
def __init__( def __init__(
self, self,
coordinator: SwitchbotDataUpdateCoordinator, coordinator: SwitchbotDataUpdateCoordinator,
idx: str | None, unique_id: str,
mac: str, address: str,
name: str, name: str,
device: Switchbot, device: Switchbot,
) -> None: ) -> None:
"""Initialize the Switchbot.""" """Initialize the Switchbot."""
super().__init__(coordinator, idx, mac, name) super().__init__(coordinator, unique_id, address, name)
self._attr_unique_id = idx self._attr_unique_id = unique_id
self._device = device self._device = device
self._attr_is_on = False self._attr_is_on = False
@ -82,7 +73,7 @@ class SwitchBotBotEntity(SwitchbotEntity, SwitchEntity, RestoreEntity):
async def async_turn_on(self, **kwargs: Any) -> None: async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn device on.""" """Turn device on."""
_LOGGER.info("Turn Switchbot bot on %s", self._mac) _LOGGER.info("Turn Switchbot bot on %s", self._address)
self._last_run_success = bool(await self._device.turn_on()) self._last_run_success = bool(await self._device.turn_on())
if self._last_run_success: if self._last_run_success:
@ -91,7 +82,7 @@ class SwitchBotBotEntity(SwitchbotEntity, SwitchEntity, RestoreEntity):
async def async_turn_off(self, **kwargs: Any) -> None: async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn device off.""" """Turn device off."""
_LOGGER.info("Turn Switchbot bot off %s", self._mac) _LOGGER.info("Turn Switchbot bot off %s", self._address)
self._last_run_success = bool(await self._device.turn_off()) self._last_run_success = bool(await self._device.turn_off())
if self._last_run_success: if self._last_run_success:

View File

@ -7,11 +7,12 @@
"switchbot_unsupported_type": "Unsupported Switchbot Type.", "switchbot_unsupported_type": "Unsupported Switchbot Type.",
"unknown": "Unexpected error" "unknown": "Unexpected error"
}, },
"flow_title": "{name}", "error": {},
"flow_title": "{name} ({address})",
"step": { "step": {
"user": { "user": {
"data": { "data": {
"mac": "Device MAC address", "address": "Device address",
"name": "Name", "name": "Name",
"password": "Password" "password": "Password"
}, },
@ -24,9 +25,7 @@
"init": { "init": {
"data": { "data": {
"retry_count": "Retry count", "retry_count": "Retry count",
"retry_timeout": "Timeout between retries", "retry_timeout": "Timeout between retries"
"scan_timeout": "How long to scan for advertisement data",
"update_time": "Time between updates (seconds)"
} }
} }
} }

View File

@ -37,7 +37,7 @@ PyRMVtransport==0.3.3
PySocks==1.7.1 PySocks==1.7.1
# homeassistant.components.switchbot # homeassistant.components.switchbot
PySwitchbot==0.14.1 PySwitchbot==0.15.0
# homeassistant.components.transport_nsw # homeassistant.components.transport_nsw
PyTransportNSW==0.1.1 PyTransportNSW==0.1.1

View File

@ -33,7 +33,7 @@ PyRMVtransport==0.3.3
PySocks==1.7.1 PySocks==1.7.1
# homeassistant.components.switchbot # homeassistant.components.switchbot
PySwitchbot==0.14.1 PySwitchbot==0.15.0
# homeassistant.components.transport_nsw # homeassistant.components.transport_nsw
PyTransportNSW==0.1.1 PyTransportNSW==0.1.1

View File

@ -1,7 +1,11 @@
"""Tests for the switchbot integration.""" """Tests for the switchbot integration."""
from unittest.mock import patch from unittest.mock import patch
from homeassistant.const import CONF_MAC, CONF_NAME, CONF_PASSWORD from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData
from homeassistant.components.bluetooth import BluetoothServiceInfoBleak
from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_PASSWORD
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
@ -11,37 +15,37 @@ DOMAIN = "switchbot"
ENTRY_CONFIG = { ENTRY_CONFIG = {
CONF_NAME: "test-name", CONF_NAME: "test-name",
CONF_PASSWORD: "test-password", CONF_PASSWORD: "test-password",
CONF_MAC: "e7:89:43:99:99:99", CONF_ADDRESS: "e7:89:43:99:99:99",
} }
USER_INPUT = { USER_INPUT = {
CONF_NAME: "test-name", CONF_NAME: "test-name",
CONF_PASSWORD: "test-password", CONF_PASSWORD: "test-password",
CONF_MAC: "e7:89:43:99:99:99", CONF_ADDRESS: "aa:bb:cc:dd:ee:ff",
} }
USER_INPUT_CURTAIN = { USER_INPUT_CURTAIN = {
CONF_NAME: "test-name", CONF_NAME: "test-name",
CONF_PASSWORD: "test-password", CONF_PASSWORD: "test-password",
CONF_MAC: "e7:89:43:90:90:90", CONF_ADDRESS: "aa:bb:cc:dd:ee:ff",
} }
USER_INPUT_SENSOR = { USER_INPUT_SENSOR = {
CONF_NAME: "test-name", CONF_NAME: "test-name",
CONF_PASSWORD: "test-password", CONF_PASSWORD: "test-password",
CONF_MAC: "c0:ce:b0:d4:26:be", CONF_ADDRESS: "aa:bb:cc:dd:ee:ff",
} }
USER_INPUT_UNSUPPORTED_DEVICE = { USER_INPUT_UNSUPPORTED_DEVICE = {
CONF_NAME: "test-name", CONF_NAME: "test-name",
CONF_PASSWORD: "test-password", CONF_PASSWORD: "test-password",
CONF_MAC: "test", CONF_ADDRESS: "test",
} }
USER_INPUT_INVALID = { USER_INPUT_INVALID = {
CONF_NAME: "test-name", CONF_NAME: "test-name",
CONF_PASSWORD: "test-password", CONF_PASSWORD: "test-password",
CONF_MAC: "invalid-mac", CONF_ADDRESS: "invalid-mac",
} }
@ -68,3 +72,67 @@ async def init_integration(
await hass.async_block_till_done() await hass.async_block_till_done()
return entry return entry
WOHAND_SERVICE_INFO = BluetoothServiceInfoBleak(
name="WoHand",
manufacturer_data={89: b"\xfd`0U\x92W"},
service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x90\xd9"},
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
address="aa:bb:cc:dd:ee:ff",
rssi=-60,
source="local",
advertisement=AdvertisementData(
local_name="WoHand",
manufacturer_data={89: b"\xfd`0U\x92W"},
service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x90\xd9"},
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
),
device=BLEDevice("aa:bb:cc:dd:ee:ff", "WoHand"),
)
WOCURTAIN_SERVICE_INFO = BluetoothServiceInfoBleak(
name="WoCurtain",
address="aa:bb:cc:dd:ee:ff",
manufacturer_data={89: b"\xc1\xc7'}U\xab"},
service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"c\xd0Y\x00\x11\x04"},
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
rssi=-60,
source="local",
advertisement=AdvertisementData(
local_name="WoCurtain",
manufacturer_data={89: b"\xc1\xc7'}U\xab"},
service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"c\xd0Y\x00\x11\x04"},
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
),
device=BLEDevice("aa:bb:cc:dd:ee:ff", "WoCurtain"),
)
WOSENSORTH_SERVICE_INFO = BluetoothServiceInfoBleak(
name="WoSensorTH",
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
address="aa:bb:cc:dd:ee:ff",
manufacturer_data={2409: b"\xda,\x1e\xb1\x86Au\x03\x00\x96\xac"},
service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"T\x00d\x00\x96\xac"},
rssi=-60,
source="local",
advertisement=AdvertisementData(
manufacturer_data={2409: b"\xda,\x1e\xb1\x86Au\x03\x00\x96\xac"},
service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"T\x00d\x00\x96\xac"},
),
device=BLEDevice("aa:bb:cc:dd:ee:ff", "WoSensorTH"),
)
NOT_SWITCHBOT_INFO = BluetoothServiceInfoBleak(
name="unknown",
service_uuids=[],
address="aa:bb:cc:dd:ee:ff",
manufacturer_data={},
service_data={},
rssi=-60,
source="local",
advertisement=AdvertisementData(
manufacturer_data={},
service_data={},
),
device=BLEDevice("aa:bb:cc:dd:ee:ff", "unknown"),
)

View File

@ -1,139 +1,8 @@
"""Define fixtures available for all tests.""" """Define fixtures available for all tests."""
import sys
from unittest.mock import MagicMock, patch
from pytest import fixture import pytest
class MocGetSwitchbotDevices: @pytest.fixture(autouse=True)
"""Scan for all Switchbot devices and return by type.""" def mock_bluetooth(enable_bluetooth):
"""Auto mock bluetooth."""
def __init__(self, interface=None) -> None:
"""Get switchbot devices class constructor."""
self._interface = interface
self._all_services_data = {
"e78943999999": {
"mac_address": "e7:89:43:99:99:99",
"isEncrypted": False,
"model": "H",
"data": {
"switchMode": "true",
"isOn": "true",
"battery": 91,
"rssi": -71,
},
"modelName": "WoHand",
},
"e78943909090": {
"mac_address": "e7:89:43:90:90:90",
"isEncrypted": False,
"model": "c",
"data": {
"calibration": True,
"battery": 74,
"inMotion": False,
"position": 100,
"lightLevel": 2,
"deviceChain": 1,
"rssi": -73,
},
"modelName": "WoCurtain",
},
"ffffff19ffff": {
"mac_address": "ff:ff:ff:19:ff:ff",
"isEncrypted": False,
"model": "m",
"rawAdvData": "000d6d00",
},
"c0ceb0d426be": {
"mac_address": "c0:ce:b0:d4:26:be",
"isEncrypted": False,
"data": {
"temp": {"c": 21.6, "f": 70.88},
"fahrenheit": False,
"humidity": 73,
"battery": 100,
"rssi": -58,
},
"model": "T",
"modelName": "WoSensorTH",
},
}
self._curtain_all_services_data = {
"mac_address": "e7:89:43:90:90:90",
"isEncrypted": False,
"model": "c",
"data": {
"calibration": True,
"battery": 74,
"position": 100,
"lightLevel": 2,
"rssi": -73,
},
"modelName": "WoCurtain",
}
self._sensor_data = {
"mac_address": "c0:ce:b0:d4:26:be",
"isEncrypted": False,
"data": {
"temp": {"c": 21.6, "f": 70.88},
"fahrenheit": False,
"humidity": 73,
"battery": 100,
"rssi": -58,
},
"model": "T",
"modelName": "WoSensorTH",
}
self._unsupported_device = {
"mac_address": "test",
"isEncrypted": False,
"model": "HoN",
"data": {
"switchMode": "true",
"isOn": "true",
"battery": 91,
"rssi": -71,
},
"modelName": "WoOther",
}
async def discover(self, retry=0, scan_timeout=0):
"""Mock discover."""
return self._all_services_data
async def get_device_data(self, mac=None):
"""Return data for specific device."""
if mac == "e7:89:43:99:99:99":
return self._all_services_data
if mac == "test":
return self._unsupported_device
if mac == "e7:89:43:90:90:90":
return self._curtain_all_services_data
if mac == "c0:ce:b0:d4:26:be":
return self._sensor_data
return None
class MocNotConnectedError(Exception):
"""Mock exception."""
module = type(sys)("switchbot")
module.GetSwitchbotDevices = MocGetSwitchbotDevices
module.NotConnectedError = MocNotConnectedError
sys.modules["switchbot"] = module
@fixture
def switchbot_config_flow(hass):
"""Mock the bluepy api for easier config flow testing."""
with patch.object(MocGetSwitchbotDevices, "discover", return_value=True), patch(
"homeassistant.components.switchbot.config_flow.GetSwitchbotDevices"
) as mock_switchbot:
instance = mock_switchbot.return_value
instance.discover = MagicMock(return_value=True)
yield mock_switchbot

View File

@ -1,33 +1,104 @@
"""Test the switchbot config flow.""" """Test the switchbot config flow."""
from homeassistant.components.switchbot.config_flow import NotConnectedError from unittest.mock import patch
from homeassistant.components.switchbot.const import ( from homeassistant.components.switchbot.const import (
CONF_RETRY_COUNT, CONF_RETRY_COUNT,
CONF_RETRY_TIMEOUT, CONF_RETRY_TIMEOUT,
CONF_SCAN_TIMEOUT,
CONF_TIME_BETWEEN_UPDATE_COMMAND,
) )
from homeassistant.config_entries import SOURCE_USER from homeassistant.config_entries import SOURCE_BLUETOOTH, SOURCE_USER
from homeassistant.const import CONF_MAC, CONF_NAME, CONF_PASSWORD, CONF_SENSOR_TYPE from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_PASSWORD, CONF_SENSOR_TYPE
from homeassistant.data_entry_flow import FlowResultType from homeassistant.data_entry_flow import FlowResultType
from . import ( from . import (
NOT_SWITCHBOT_INFO,
USER_INPUT, USER_INPUT,
USER_INPUT_CURTAIN, USER_INPUT_CURTAIN,
USER_INPUT_SENSOR, USER_INPUT_SENSOR,
WOCURTAIN_SERVICE_INFO,
WOHAND_SERVICE_INFO,
WOSENSORTH_SERVICE_INFO,
init_integration, init_integration,
patch_async_setup_entry, patch_async_setup_entry,
) )
from tests.common import MockConfigEntry
DOMAIN = "switchbot" DOMAIN = "switchbot"
async def test_user_form_valid_mac(hass): async def test_bluetooth_discovery(hass):
"""Test discovery via bluetooth with a valid device."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_BLUETOOTH},
data=WOHAND_SERVICE_INFO,
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
with patch_async_setup_entry() as mock_setup_entry:
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
USER_INPUT,
)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["title"] == "test-name"
assert result["data"] == {
CONF_ADDRESS: "aa:bb:cc:dd:ee:ff",
CONF_NAME: "test-name",
CONF_PASSWORD: "test-password",
CONF_SENSOR_TYPE: "bot",
}
assert len(mock_setup_entry.mock_calls) == 1
async def test_bluetooth_discovery_already_setup(hass):
"""Test discovery via bluetooth with a valid device when already setup."""
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_ADDRESS: "aa:bb:cc:dd:ee:ff",
CONF_NAME: "test-name",
CONF_PASSWORD: "test-password",
CONF_SENSOR_TYPE: "bot",
},
unique_id="aabbccddeeff",
)
entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_BLUETOOTH},
data=WOHAND_SERVICE_INFO,
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_async_step_bluetooth_not_switchbot(hass):
"""Test discovery via bluetooth not switchbot."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_BLUETOOTH},
data=NOT_SWITCHBOT_INFO,
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "not_supported"
async def test_user_setup_wohand(hass):
"""Test the user initiated form with password and valid mac.""" """Test the user initiated form with password and valid mac."""
result = await hass.config_entries.flow.async_init( with patch(
DOMAIN, context={"source": SOURCE_USER} "homeassistant.components.switchbot.config_flow.async_discovered_service_info",
) return_value=[WOHAND_SERVICE_INFO],
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user" assert result["step_id"] == "user"
assert result["errors"] == {} assert result["errors"] == {}
@ -42,7 +113,7 @@ async def test_user_form_valid_mac(hass):
assert result["type"] == FlowResultType.CREATE_ENTRY assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["title"] == "test-name" assert result["title"] == "test-name"
assert result["data"] == { assert result["data"] == {
CONF_MAC: "e7:89:43:99:99:99", CONF_ADDRESS: "aa:bb:cc:dd:ee:ff",
CONF_NAME: "test-name", CONF_NAME: "test-name",
CONF_PASSWORD: "test-password", CONF_PASSWORD: "test-password",
CONF_SENSOR_TYPE: "bot", CONF_SENSOR_TYPE: "bot",
@ -50,11 +121,41 @@ async def test_user_form_valid_mac(hass):
assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1
# test curtain device creation.
result = await hass.config_entries.flow.async_init( async def test_user_setup_wohand_already_configured(hass):
DOMAIN, context={"source": SOURCE_USER} """Test the user initiated form with password and valid mac."""
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_ADDRESS: "aa:bb:cc:dd:ee:ff",
CONF_NAME: "test-name",
CONF_PASSWORD: "test-password",
CONF_SENSOR_TYPE: "bot",
},
unique_id="aabbccddeeff",
) )
entry.add_to_hass(hass)
with patch(
"homeassistant.components.switchbot.config_flow.async_discovered_service_info",
return_value=[WOHAND_SERVICE_INFO],
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "no_unconfigured_devices"
async def test_user_setup_wocurtain(hass):
"""Test the user initiated form with password and valid mac."""
with patch(
"homeassistant.components.switchbot.config_flow.async_discovered_service_info",
return_value=[WOCURTAIN_SERVICE_INFO],
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user" assert result["step_id"] == "user"
assert result["errors"] == {} assert result["errors"] == {}
@ -69,7 +170,7 @@ async def test_user_form_valid_mac(hass):
assert result["type"] == FlowResultType.CREATE_ENTRY assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["title"] == "test-name" assert result["title"] == "test-name"
assert result["data"] == { assert result["data"] == {
CONF_MAC: "e7:89:43:90:90:90", CONF_ADDRESS: "aa:bb:cc:dd:ee:ff",
CONF_NAME: "test-name", CONF_NAME: "test-name",
CONF_PASSWORD: "test-password", CONF_PASSWORD: "test-password",
CONF_SENSOR_TYPE: "curtain", CONF_SENSOR_TYPE: "curtain",
@ -77,11 +178,16 @@ async def test_user_form_valid_mac(hass):
assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1
# test sensor device creation.
result = await hass.config_entries.flow.async_init( async def test_user_setup_wosensor(hass):
DOMAIN, context={"source": SOURCE_USER} """Test the user initiated form with password and valid mac."""
) with patch(
"homeassistant.components.switchbot.config_flow.async_discovered_service_info",
return_value=[WOSENSORTH_SERVICE_INFO],
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user" assert result["step_id"] == "user"
assert result["errors"] == {} assert result["errors"] == {}
@ -96,7 +202,7 @@ async def test_user_form_valid_mac(hass):
assert result["type"] == FlowResultType.CREATE_ENTRY assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["title"] == "test-name" assert result["title"] == "test-name"
assert result["data"] == { assert result["data"] == {
CONF_MAC: "c0:ce:b0:d4:26:be", CONF_ADDRESS: "aa:bb:cc:dd:ee:ff",
CONF_NAME: "test-name", CONF_NAME: "test-name",
CONF_PASSWORD: "test-password", CONF_PASSWORD: "test-password",
CONF_SENSOR_TYPE: "hygrometer", CONF_SENSOR_TYPE: "hygrometer",
@ -104,39 +210,78 @@ async def test_user_form_valid_mac(hass):
assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1
# tests abort if no unconfigured devices are found.
result = await hass.config_entries.flow.async_init( async def test_user_no_devices(hass):
DOMAIN, context={"source": SOURCE_USER} """Test the user initiated form with password and valid mac."""
) with patch(
"homeassistant.components.switchbot.config_flow.async_discovered_service_info",
return_value=[],
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] == FlowResultType.ABORT assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "no_unconfigured_devices" assert result["reason"] == "no_unconfigured_devices"
async def test_user_form_exception(hass, switchbot_config_flow): async def test_async_step_user_takes_precedence_over_discovery(hass):
"""Test we handle exception on user form.""" """Test manual setup takes precedence over discovery."""
switchbot_config_flow.side_effect = NotConnectedError
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER} DOMAIN,
context={"source": SOURCE_BLUETOOTH},
data=WOCURTAIN_SERVICE_INFO,
) )
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
assert result["type"] == FlowResultType.ABORT with patch(
assert result["reason"] == "cannot_connect" "homeassistant.components.switchbot.config_flow.async_discovered_service_info",
return_value=[WOCURTAIN_SERVICE_INFO],
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
assert result["type"] == FlowResultType.FORM
switchbot_config_flow.side_effect = Exception with patch_async_setup_entry() as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=USER_INPUT,
)
result = await hass.config_entries.flow.async_init( assert result2["type"] == FlowResultType.CREATE_ENTRY
DOMAIN, context={"source": SOURCE_USER} assert result2["title"] == "test-name"
) assert result2["data"] == {
CONF_ADDRESS: "aa:bb:cc:dd:ee:ff",
CONF_NAME: "test-name",
CONF_PASSWORD: "test-password",
CONF_SENSOR_TYPE: "curtain",
}
assert result["type"] == FlowResultType.ABORT assert len(mock_setup_entry.mock_calls) == 1
assert result["reason"] == "unknown" # Verify the original one was aborted
assert not hass.config_entries.flow.async_progress(DOMAIN)
async def test_options_flow(hass): async def test_options_flow(hass):
"""Test updating options.""" """Test updating options."""
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_ADDRESS: "aa:bb:cc:dd:ee:ff",
CONF_NAME: "test-name",
CONF_PASSWORD: "test-password",
CONF_SENSOR_TYPE: "bot",
},
options={
CONF_RETRY_COUNT: 10,
CONF_RETRY_TIMEOUT: 10,
},
unique_id="aabbccddeeff",
)
entry.add_to_hass(hass)
with patch_async_setup_entry() as mock_setup_entry: with patch_async_setup_entry() as mock_setup_entry:
entry = await init_integration(hass) entry = await init_integration(hass)
@ -148,21 +293,17 @@ async def test_options_flow(hass):
result = await hass.config_entries.options.async_configure( result = await hass.config_entries.options.async_configure(
result["flow_id"], result["flow_id"],
user_input={ user_input={
CONF_TIME_BETWEEN_UPDATE_COMMAND: 60,
CONF_RETRY_COUNT: 3, CONF_RETRY_COUNT: 3,
CONF_RETRY_TIMEOUT: 5, CONF_RETRY_TIMEOUT: 5,
CONF_SCAN_TIMEOUT: 5,
}, },
) )
await hass.async_block_till_done() await hass.async_block_till_done()
assert result["type"] == FlowResultType.CREATE_ENTRY assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["data"][CONF_TIME_BETWEEN_UPDATE_COMMAND] == 60
assert result["data"][CONF_RETRY_COUNT] == 3 assert result["data"][CONF_RETRY_COUNT] == 3
assert result["data"][CONF_RETRY_TIMEOUT] == 5 assert result["data"][CONF_RETRY_TIMEOUT] == 5
assert result["data"][CONF_SCAN_TIMEOUT] == 5
assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 2
# Test changing of entry options. # Test changing of entry options.
@ -177,18 +318,17 @@ async def test_options_flow(hass):
result = await hass.config_entries.options.async_configure( result = await hass.config_entries.options.async_configure(
result["flow_id"], result["flow_id"],
user_input={ user_input={
CONF_TIME_BETWEEN_UPDATE_COMMAND: 66,
CONF_RETRY_COUNT: 6, CONF_RETRY_COUNT: 6,
CONF_RETRY_TIMEOUT: 6, CONF_RETRY_TIMEOUT: 6,
CONF_SCAN_TIMEOUT: 6,
}, },
) )
await hass.async_block_till_done() await hass.async_block_till_done()
assert result["type"] == FlowResultType.CREATE_ENTRY assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["data"][CONF_TIME_BETWEEN_UPDATE_COMMAND] == 66
assert result["data"][CONF_RETRY_COUNT] == 6 assert result["data"][CONF_RETRY_COUNT] == 6
assert result["data"][CONF_RETRY_TIMEOUT] == 6 assert result["data"][CONF_RETRY_TIMEOUT] == 6
assert result["data"][CONF_SCAN_TIMEOUT] == 6
assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1
assert entry.options[CONF_RETRY_COUNT] == 6
assert entry.options[CONF_RETRY_TIMEOUT] == 6