Simplify switchbot config flow (#76272)

This commit is contained in:
J. Nick Koston 2022-08-10 09:02:08 -10:00 committed by GitHub
parent 54fc17e10d
commit b1497b0857
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 412 additions and 246 deletions

View file

@ -9,6 +9,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ( from homeassistant.const import (
CONF_ADDRESS, CONF_ADDRESS,
CONF_MAC, CONF_MAC,
CONF_NAME,
CONF_PASSWORD, CONF_PASSWORD,
CONF_SENSOR_TYPE, CONF_SENSOR_TYPE,
Platform, Platform,
@ -17,31 +18,26 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
from .const import ( from .const import CONF_RETRY_COUNT, DEFAULT_RETRY_COUNT, DOMAIN, SupportedModels
ATTR_BOT,
ATTR_CONTACT,
ATTR_CURTAIN,
ATTR_HYGROMETER,
ATTR_MOTION,
ATTR_PLUG,
CONF_RETRY_COUNT,
DEFAULT_RETRY_COUNT,
DOMAIN,
)
from .coordinator import SwitchbotDataUpdateCoordinator from .coordinator import SwitchbotDataUpdateCoordinator
PLATFORMS_BY_TYPE = { PLATFORMS_BY_TYPE = {
ATTR_BOT: [Platform.SWITCH, Platform.SENSOR], SupportedModels.BULB.value: [Platform.SENSOR],
ATTR_PLUG: [Platform.SWITCH, Platform.SENSOR], SupportedModels.BOT.value: [Platform.SWITCH, Platform.SENSOR],
ATTR_CURTAIN: [Platform.COVER, Platform.BINARY_SENSOR, Platform.SENSOR], SupportedModels.PLUG.value: [Platform.SWITCH, Platform.SENSOR],
ATTR_HYGROMETER: [Platform.SENSOR], SupportedModels.CURTAIN.value: [
ATTR_CONTACT: [Platform.BINARY_SENSOR, Platform.SENSOR], Platform.COVER,
ATTR_MOTION: [Platform.BINARY_SENSOR, Platform.SENSOR], Platform.BINARY_SENSOR,
Platform.SENSOR,
],
SupportedModels.HYGROMETER.value: [Platform.SENSOR],
SupportedModels.CONTACT.value: [Platform.BINARY_SENSOR, Platform.SENSOR],
SupportedModels.MOTION.value: [Platform.BINARY_SENSOR, Platform.SENSOR],
} }
CLASS_BY_DEVICE = { CLASS_BY_DEVICE = {
ATTR_CURTAIN: switchbot.SwitchbotCurtain, SupportedModels.CURTAIN.value: switchbot.SwitchbotCurtain,
ATTR_BOT: switchbot.Switchbot, SupportedModels.BOT.value: switchbot.Switchbot,
ATTR_PLUG: switchbot.SwitchbotPlugMini, SupportedModels.PLUG.value: switchbot.SwitchbotPlugMini,
} }
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -49,6 +45,7 @@ _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."""
assert entry.unique_id is not None
hass.data.setdefault(DOMAIN, {}) hass.data.setdefault(DOMAIN, {})
if CONF_ADDRESS not in entry.data and CONF_MAC in entry.data: if CONF_ADDRESS not in entry.data and CONF_MAC in entry.data:
# Bleak uses addresses not mac addresses which are are actually # Bleak uses addresses not mac addresses which are are actually
@ -81,7 +78,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
retry_count=entry.options[CONF_RETRY_COUNT], retry_count=entry.options[CONF_RETRY_COUNT],
) )
coordinator = hass.data[DOMAIN][entry.entry_id] = SwitchbotDataUpdateCoordinator( coordinator = hass.data[DOMAIN][entry.entry_id] = SwitchbotDataUpdateCoordinator(
hass, _LOGGER, ble_device, device hass,
_LOGGER,
ble_device,
device,
entry.unique_id,
entry.data.get(CONF_NAME, entry.title),
) )
entry.async_on_unload(coordinator.async_start()) entry.async_on_unload(coordinator.async_start())
if not await coordinator.async_wait_ready(): if not await coordinator.async_wait_ready():

View file

@ -7,7 +7,6 @@ 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_ADDRESS, CONF_NAME
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
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
@ -53,20 +52,10 @@ async def async_setup_entry(
) -> 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]
unique_id = entry.unique_id
assert unique_id is not None
async_add_entities( async_add_entities(
[ SwitchBotBinarySensor(coordinator, binary_sensor)
SwitchBotBinarySensor( for binary_sensor in coordinator.data["data"]
coordinator, if binary_sensor in BINARY_SENSOR_TYPES
unique_id,
binary_sensor,
entry.data[CONF_ADDRESS],
entry.data[CONF_NAME],
)
for binary_sensor in coordinator.data["data"]
if binary_sensor in BINARY_SENSOR_TYPES
]
) )
@ -78,15 +67,12 @@ class SwitchBotBinarySensor(SwitchbotEntity, BinarySensorEntity):
def __init__( def __init__(
self, self,
coordinator: SwitchbotDataUpdateCoordinator, coordinator: SwitchbotDataUpdateCoordinator,
unique_id: str,
binary_sensor: str, binary_sensor: str,
mac: str,
switchbot_name: str,
) -> None: ) -> None:
"""Initialize the Switchbot sensor.""" """Initialize the Switchbot sensor."""
super().__init__(coordinator, unique_id, mac, name=switchbot_name) super().__init__(coordinator)
self._sensor = binary_sensor self._sensor = binary_sensor
self._attr_unique_id = f"{unique_id}-{binary_sensor}" self._attr_unique_id = f"{coordinator.base_unique_id}-{binary_sensor}"
self.entity_description = BINARY_SENSOR_TYPES[binary_sensor] self.entity_description = BINARY_SENSOR_TYPES[binary_sensor]
self._attr_name = self.entity_description.name self._attr_name = self.entity_description.name

View file

@ -12,9 +12,9 @@ from homeassistant.components.bluetooth import (
async_discovered_service_info, 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_ADDRESS, CONF_NAME, CONF_PASSWORD, CONF_SENSOR_TYPE from homeassistant.const import CONF_ADDRESS, 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 AbortFlow, FlowResult
from .const import CONF_RETRY_COUNT, DEFAULT_RETRY_COUNT, DOMAIN, SUPPORTED_MODEL_TYPES from .const import CONF_RETRY_COUNT, DEFAULT_RETRY_COUNT, DOMAIN, SUPPORTED_MODEL_TYPES
@ -26,6 +26,17 @@ def format_unique_id(address: str) -> str:
return address.replace(":", "").lower() return address.replace(":", "").lower()
def short_address(address: str) -> str:
"""Convert a Bluetooth address to a short address."""
results = address.replace("-", ":").split(":")
return f"{results[-2].upper()}{results[-1].upper()}"[-4:]
def name_from_discovery(discovery: SwitchBotAdvertisement) -> str:
"""Get the name from a discovery."""
return f'{discovery.data["modelFriendlyName"]} {short_address(discovery.address)}'
class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN): class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Switchbot.""" """Handle a config flow for Switchbot."""
@ -59,62 +70,128 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN):
self._discovered_adv = parsed self._discovered_adv = parsed
data = parsed.data data = parsed.data
self.context["title_placeholders"] = { self.context["title_placeholders"] = {
"name": data["modelName"], "name": data["modelFriendlyName"],
"address": discovery_info.address, "address": short_address(discovery_info.address),
} }
return await self.async_step_user() if self._discovered_adv.data["isEncrypted"]:
return await self.async_step_password()
return await self.async_step_confirm()
async def _async_create_entry_from_discovery(
self, user_input: dict[str, Any]
) -> FlowResult:
"""Create an entry from a discovery."""
assert self._discovered_adv is not None
discovery = self._discovered_adv
name = name_from_discovery(discovery)
model_name = discovery.data["modelName"]
return self.async_create_entry(
title=name,
data={
**user_input,
CONF_ADDRESS: discovery.address,
CONF_SENSOR_TYPE: str(SUPPORTED_MODEL_TYPES[model_name]),
},
)
async def async_step_confirm(self, user_input: dict[str, Any] = None) -> FlowResult:
"""Confirm a single device."""
assert self._discovered_adv is not None
if user_input is not None:
return await self._async_create_entry_from_discovery(user_input)
self._set_confirm_only()
return self.async_show_form(
step_id="confirm",
data_schema=vol.Schema({}),
description_placeholders={
"name": name_from_discovery(self._discovered_adv)
},
)
async def async_step_password(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the password step."""
assert self._discovered_adv is not None
if user_input is not None:
# There is currently no api to validate the password
# that does not operate the device so we have
# to accept it as-is
return await self._async_create_entry_from_discovery(user_input)
return self.async_show_form(
step_id="password",
data_schema=vol.Schema({vol.Required(CONF_PASSWORD): str}),
description_placeholders={
"name": name_from_discovery(self._discovered_adv)
},
)
@callback
def _async_discover_devices(self) -> None:
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
if not self._discovered_advs:
raise AbortFlow("no_unconfigured_devices")
async def _async_set_device(self, discovery: SwitchBotAdvertisement) -> None:
"""Set the device to work with."""
self._discovered_adv = discovery
address = discovery.address
await self.async_set_unique_id(
format_unique_id(address), raise_on_progress=False
)
self._abort_if_unique_id_configured()
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 the user step to pick discovered device.""" """Handle the user step to pick discovered device."""
errors: dict[str, str] = {} errors: dict[str, str] = {}
device_adv: SwitchBotAdvertisement | None = None
if user_input is not None: if user_input is not None:
address = user_input[CONF_ADDRESS] device_adv = self._discovered_advs[user_input[CONF_ADDRESS]]
await self.async_set_unique_id( await self._async_set_device(device_adv)
format_unique_id(address), raise_on_progress=False if device_adv.data["isEncrypted"]:
) return await self.async_step_password()
self._abort_if_unique_id_configured() return await self._async_create_entry_from_discovery(user_input)
user_input[CONF_SENSOR_TYPE] = SUPPORTED_MODEL_TYPES[
self._discovered_advs[address].data["modelName"]
]
return self.async_create_entry(title=user_input[CONF_NAME], data=user_input)
if discovery := self._discovered_adv: self._async_discover_devices()
self._discovered_advs[discovery.address] = discovery if len(self._discovered_advs) == 1:
else: # If there is only one device we can ask for a password
current_addresses = self._async_current_ids() # or simply confirm it
for discovery_info in async_discovered_service_info(self.hass): device_adv = list(self._discovered_advs.values())[0]
address = discovery_info.address await self._async_set_device(device_adv)
if ( if device_adv.data["isEncrypted"]:
format_unique_id(address) in current_addresses return await self.async_step_password()
or address in self._discovered_advs return await self.async_step_confirm()
):
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
if not self._discovered_advs:
return self.async_abort(reason="no_unconfigured_devices")
data_schema = vol.Schema(
{
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.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=vol.Schema(
{
vol.Required(CONF_ADDRESS): vol.In(
{
address: name_from_discovery(parsed)
for address, parsed in self._discovered_advs.items()
}
),
}
),
errors=errors,
) )

View file

@ -1,25 +1,39 @@
"""Constants for the switchbot integration.""" """Constants for the switchbot integration."""
from switchbot import SwitchbotModel
from homeassistant.backports.enum import StrEnum
DOMAIN = "switchbot" DOMAIN = "switchbot"
MANUFACTURER = "switchbot" MANUFACTURER = "switchbot"
# Config Attributes # Config Attributes
ATTR_BOT = "bot"
ATTR_CURTAIN = "curtain"
ATTR_HYGROMETER = "hygrometer"
ATTR_CONTACT = "contact"
ATTR_PLUG = "plug"
ATTR_MOTION = "motion"
DEFAULT_NAME = "Switchbot" DEFAULT_NAME = "Switchbot"
class SupportedModels(StrEnum):
"""Supported Switchbot models."""
BOT = "bot"
BULB = "bulb"
CURTAIN = "curtain"
HYGROMETER = "hygrometer"
CONTACT = "contact"
PLUG = "plug"
MOTION = "motion"
SUPPORTED_MODEL_TYPES = { SUPPORTED_MODEL_TYPES = {
"WoHand": ATTR_BOT, SwitchbotModel.BOT: SupportedModels.BOT,
"WoCurtain": ATTR_CURTAIN, SwitchbotModel.CURTAIN: SupportedModels.CURTAIN,
"WoSensorTH": ATTR_HYGROMETER, SwitchbotModel.METER: SupportedModels.HYGROMETER,
"WoContact": ATTR_CONTACT, SwitchbotModel.CONTACT_SENSOR: SupportedModels.CONTACT,
"WoPlug": ATTR_PLUG, SwitchbotModel.PLUG_MINI: SupportedModels.PLUG,
"WoPresence": ATTR_MOTION, SwitchbotModel.MOTION_SENSOR: SupportedModels.MOTION,
SwitchbotModel.COLOR_BULB: SupportedModels.BULB,
} }
# Config Defaults # Config Defaults
DEFAULT_RETRY_COUNT = 3 DEFAULT_RETRY_COUNT = 3

View file

@ -37,6 +37,8 @@ class SwitchbotDataUpdateCoordinator(PassiveBluetoothDataUpdateCoordinator):
logger: logging.Logger, logger: logging.Logger,
ble_device: BLEDevice, ble_device: BLEDevice,
device: switchbot.SwitchbotDevice, device: switchbot.SwitchbotDevice,
base_unique_id: str,
device_name: str,
) -> None: ) -> None:
"""Initialize global switchbot data updater.""" """Initialize global switchbot data updater."""
super().__init__( super().__init__(
@ -45,6 +47,8 @@ class SwitchbotDataUpdateCoordinator(PassiveBluetoothDataUpdateCoordinator):
self.ble_device = ble_device self.ble_device = ble_device
self.device = device self.device = device
self.data: dict[str, Any] = {} self.data: dict[str, Any] = {}
self.device_name = device_name
self.base_unique_id = base_unique_id
self._ready_event = asyncio.Event() self._ready_event = asyncio.Event()
@callback @callback

View file

@ -4,8 +4,6 @@ from __future__ import annotations
import logging import logging
from typing import Any from typing import Any
from switchbot import SwitchbotCurtain
from homeassistant.components.cover import ( from homeassistant.components.cover import (
ATTR_CURRENT_POSITION, ATTR_CURRENT_POSITION,
ATTR_POSITION, ATTR_POSITION,
@ -14,7 +12,6 @@ from homeassistant.components.cover import (
CoverEntityFeature, CoverEntityFeature,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ADDRESS, CONF_NAME
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
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
@ -33,19 +30,7 @@ async def async_setup_entry(
) -> 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]
unique_id = entry.unique_id async_add_entities([SwitchBotCurtainEntity(coordinator)])
assert unique_id is not None
async_add_entities(
[
SwitchBotCurtainEntity(
coordinator,
unique_id,
entry.data[CONF_ADDRESS],
entry.data[CONF_NAME],
coordinator.device,
)
]
)
class SwitchBotCurtainEntity(SwitchbotEntity, CoverEntity, RestoreEntity): class SwitchBotCurtainEntity(SwitchbotEntity, CoverEntity, RestoreEntity):
@ -59,19 +44,10 @@ class SwitchBotCurtainEntity(SwitchbotEntity, CoverEntity, RestoreEntity):
| CoverEntityFeature.SET_POSITION | CoverEntityFeature.SET_POSITION
) )
def __init__( def __init__(self, coordinator: SwitchbotDataUpdateCoordinator) -> None:
self,
coordinator: SwitchbotDataUpdateCoordinator,
unique_id: str,
address: str,
name: str,
device: SwitchbotCurtain,
) -> None:
"""Initialize the Switchbot.""" """Initialize the Switchbot."""
super().__init__(coordinator, unique_id, address, name) super().__init__(coordinator)
self._attr_unique_id = unique_id
self._attr_is_closed = None self._attr_is_closed = None
self._device = device
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Run when entity about to be added.""" """Run when entity about to be added."""

View file

@ -20,24 +20,19 @@ class SwitchbotEntity(PassiveBluetoothCoordinatorEntity):
coordinator: SwitchbotDataUpdateCoordinator coordinator: SwitchbotDataUpdateCoordinator
def __init__( def __init__(self, coordinator: SwitchbotDataUpdateCoordinator) -> None:
self,
coordinator: SwitchbotDataUpdateCoordinator,
unique_id: str,
address: str,
name: str,
) -> None:
"""Initialize the entity.""" """Initialize the entity."""
super().__init__(coordinator) super().__init__(coordinator)
self._device = coordinator.device
self._last_run_success: bool | None = None self._last_run_success: bool | None = None
self._unique_id = unique_id self._address = coordinator.ble_device.address
self._address = address self._attr_unique_id = coordinator.base_unique_id
self._attr_name = name self._attr_name = coordinator.device_name
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
connections={(dr.CONNECTION_BLUETOOTH, self._address)}, connections={(dr.CONNECTION_BLUETOOTH, self._address)},
manufacturer=MANUFACTURER, manufacturer=MANUFACTURER,
model=self.data["modelName"], model=self.data["modelName"],
name=name, name=coordinator.device_name,
) )
if ":" not in self._address: if ":" not in self._address:
# MacOS Bluetooth addresses are not mac addresses # MacOS Bluetooth addresses are not mac addresses

View file

@ -9,8 +9,6 @@ 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_ADDRESS,
CONF_NAME,
PERCENTAGE, PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT, SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
TEMP_CELSIUS, TEMP_CELSIUS,
@ -73,20 +71,13 @@ async def async_setup_entry(
) -> 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]
unique_id = entry.unique_id
assert unique_id is not None
async_add_entities( async_add_entities(
[ SwitchBotSensor(
SwitchBotSensor( coordinator,
coordinator, sensor,
unique_id, )
sensor, for sensor in coordinator.data["data"]
entry.data[CONF_ADDRESS], if sensor in SENSOR_TYPES
entry.data[CONF_NAME],
)
for sensor in coordinator.data["data"]
if sensor in SENSOR_TYPES
]
) )
@ -96,16 +87,14 @@ class SwitchBotSensor(SwitchbotEntity, SensorEntity):
def __init__( def __init__(
self, self,
coordinator: SwitchbotDataUpdateCoordinator, coordinator: SwitchbotDataUpdateCoordinator,
unique_id: str,
sensor: str, sensor: str,
address: str,
switchbot_name: str,
) -> None: ) -> None:
"""Initialize the Switchbot sensor.""" """Initialize the Switchbot sensor."""
super().__init__(coordinator, unique_id, address, name=switchbot_name) super().__init__(coordinator)
self._sensor = sensor self._sensor = sensor
self._attr_unique_id = f"{unique_id}-{sensor}" self._attr_unique_id = f"{coordinator.base_unique_id}-{sensor}"
self._attr_name = f"{switchbot_name} {sensor.replace('_', ' ').title()}" name = coordinator.device_name
self._attr_name = f"{name} {sensor.replace('_', ' ').title()}"
self.entity_description = SENSOR_TYPES[sensor] self.entity_description = SENSOR_TYPES[sensor]
@property @property

View file

@ -3,10 +3,16 @@
"flow_title": "{name} ({address})", "flow_title": "{name} ({address})",
"step": { "step": {
"user": { "user": {
"title": "Setup Switchbot device",
"data": { "data": {
"address": "Device address", "address": "Device address"
"name": "[%key:common::config_flow::data::name%]", }
},
"confirm": {
"description": "Do you want to setup {name}?"
},
"password": {
"description": "The {name} device requires a password",
"data": {
"password": "[%key:common::config_flow::data::password%]" "password": "[%key:common::config_flow::data::password%]"
} }
} }

View file

@ -4,11 +4,9 @@ from __future__ import annotations
import logging import logging
from typing import Any from typing import Any
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_ADDRESS, CONF_NAME, STATE_ON from homeassistant.const import STATE_ON
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
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
@ -29,19 +27,7 @@ async def async_setup_entry(
) -> 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]
unique_id = entry.unique_id async_add_entities([SwitchBotSwitch(coordinator)])
assert unique_id is not None
async_add_entities(
[
SwitchBotSwitch(
coordinator,
unique_id,
entry.data[CONF_ADDRESS],
entry.data[CONF_NAME],
coordinator.device,
)
]
)
class SwitchBotSwitch(SwitchbotEntity, SwitchEntity, RestoreEntity): class SwitchBotSwitch(SwitchbotEntity, SwitchEntity, RestoreEntity):
@ -49,18 +35,9 @@ class SwitchBotSwitch(SwitchbotEntity, SwitchEntity, RestoreEntity):
_attr_device_class = SwitchDeviceClass.SWITCH _attr_device_class = SwitchDeviceClass.SWITCH
def __init__( def __init__(self, coordinator: SwitchbotDataUpdateCoordinator) -> None:
self,
coordinator: SwitchbotDataUpdateCoordinator,
unique_id: str,
address: str,
name: str,
device: Switchbot,
) -> None:
"""Initialize the Switchbot.""" """Initialize the Switchbot."""
super().__init__(coordinator, unique_id, address, name) super().__init__(coordinator)
self._attr_unique_id = unique_id
self._device = device
self._attr_is_on = False self._attr_is_on = False
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:

View file

@ -7,16 +7,22 @@
"switchbot_unsupported_type": "Unsupported Switchbot Type.", "switchbot_unsupported_type": "Unsupported Switchbot Type.",
"unknown": "Unexpected error" "unknown": "Unexpected error"
}, },
"error": {},
"flow_title": "{name} ({address})", "flow_title": "{name} ({address})",
"step": { "step": {
"user": { "confirm": {
"description": "Do you want to setup {name}?"
},
"password": {
"data": { "data": {
"address": "Device address",
"mac": "Device MAC address",
"name": "Name",
"password": "Password" "password": "Password"
}, },
"title": "Setup Switchbot device" "description": "The {name} device requires a password"
},
"user": {
"data": {
"address": "Device address"
}
} }
} }
}, },
@ -24,10 +30,7 @@
"step": { "step": {
"init": { "init": {
"data": { "data": {
"retry_count": "Retry count", "retry_count": "Retry count"
"retry_timeout": "Timeout between retries",
"scan_timeout": "How long to scan for advertisement data",
"update_time": "Time between updates (seconds)"
} }
} }
} }

View file

@ -5,7 +5,7 @@ from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData from bleak.backends.scanner import AdvertisementData
from homeassistant.components.bluetooth import BluetoothServiceInfoBleak from homeassistant.components.bluetooth import BluetoothServiceInfoBleak
from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_PASSWORD from homeassistant.const import CONF_ADDRESS
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
@ -13,38 +13,18 @@ from tests.common import MockConfigEntry
DOMAIN = "switchbot" DOMAIN = "switchbot"
ENTRY_CONFIG = { ENTRY_CONFIG = {
CONF_NAME: "test-name",
CONF_PASSWORD: "test-password",
CONF_ADDRESS: "e7:89:43:99:99:99", CONF_ADDRESS: "e7:89:43:99:99:99",
} }
USER_INPUT = { USER_INPUT = {
CONF_NAME: "test-name",
CONF_PASSWORD: "test-password",
CONF_ADDRESS: "aa:bb:cc:dd:ee:ff",
}
USER_INPUT_CURTAIN = {
CONF_NAME: "test-name",
CONF_PASSWORD: "test-password",
CONF_ADDRESS: "aa:bb:cc:dd:ee:ff",
}
USER_INPUT_SENSOR = {
CONF_NAME: "test-name",
CONF_PASSWORD: "test-password",
CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", CONF_ADDRESS: "aa:bb:cc:dd:ee:ff",
} }
USER_INPUT_UNSUPPORTED_DEVICE = { USER_INPUT_UNSUPPORTED_DEVICE = {
CONF_NAME: "test-name",
CONF_PASSWORD: "test-password",
CONF_ADDRESS: "test", CONF_ADDRESS: "test",
} }
USER_INPUT_INVALID = { USER_INPUT_INVALID = {
CONF_NAME: "test-name",
CONF_PASSWORD: "test-password",
CONF_ADDRESS: "invalid-mac", CONF_ADDRESS: "invalid-mac",
} }
@ -90,6 +70,42 @@ WOHAND_SERVICE_INFO = BluetoothServiceInfoBleak(
), ),
device=BLEDevice("aa:bb:cc:dd:ee:ff", "WoHand"), device=BLEDevice("aa:bb:cc:dd:ee:ff", "WoHand"),
) )
WOHAND_ENCRYPTED_SERVICE_INFO = BluetoothServiceInfoBleak(
name="WoHand",
manufacturer_data={89: b"\xd8.\xad\xcd\r\x85"},
service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"\xc8\x10\xcf"},
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
address="798A8547-2A3D-C609-55FF-73FA824B923B",
rssi=-60,
source="local",
advertisement=AdvertisementData(
local_name="WoHand",
manufacturer_data={89: b"\xd8.\xad\xcd\r\x85"},
service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"\xc8\x10\xcf"},
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
),
device=BLEDevice("798A8547-2A3D-C609-55FF-73FA824B923B", "WoHand"),
)
WOHAND_SERVICE_ALT_ADDRESS_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="cc:cc:cc:cc:cc:cc",
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( WOCURTAIN_SERVICE_INFO = BluetoothServiceInfoBleak(
name="WoCurtain", name="WoCurtain",
address="aa:bb:cc:dd:ee:ff", address="aa:bb:cc:dd:ee:ff",

View file

@ -10,9 +10,9 @@ from homeassistant.data_entry_flow import FlowResultType
from . import ( from . import (
NOT_SWITCHBOT_INFO, NOT_SWITCHBOT_INFO,
USER_INPUT, USER_INPUT,
USER_INPUT_CURTAIN,
USER_INPUT_SENSOR,
WOCURTAIN_SERVICE_INFO, WOCURTAIN_SERVICE_INFO,
WOHAND_ENCRYPTED_SERVICE_INFO,
WOHAND_SERVICE_ALT_ADDRESS_INFO,
WOHAND_SERVICE_INFO, WOHAND_SERVICE_INFO,
WOSENSORTH_SERVICE_INFO, WOSENSORTH_SERVICE_INFO,
init_integration, init_integration,
@ -32,27 +32,53 @@ async def test_bluetooth_discovery(hass):
data=WOHAND_SERVICE_INFO, data=WOHAND_SERVICE_INFO,
) )
assert result["type"] == FlowResultType.FORM assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user" assert result["step_id"] == "confirm"
with patch_async_setup_entry() as mock_setup_entry: with patch_async_setup_entry() as mock_setup_entry:
result = await hass.config_entries.flow.async_configure( result = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
USER_INPUT, {},
) )
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["title"] == "test-name" assert result["title"] == "Bot EEFF"
assert result["data"] == { assert result["data"] == {
CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", CONF_ADDRESS: "aa:bb:cc:dd:ee:ff",
CONF_NAME: "test-name",
CONF_PASSWORD: "test-password",
CONF_SENSOR_TYPE: "bot", CONF_SENSOR_TYPE: "bot",
} }
assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1
async def test_bluetooth_discovery_requires_password(hass):
"""Test discovery via bluetooth with a valid device that needs a password."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_BLUETOOTH},
data=WOHAND_ENCRYPTED_SERVICE_INFO,
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "password"
with patch_async_setup_entry() as mock_setup_entry:
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_PASSWORD: "abc123"},
)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["title"] == "Bot 923B"
assert result["data"] == {
CONF_ADDRESS: "798A8547-2A3D-C609-55FF-73FA824B923B",
CONF_SENSOR_TYPE: "bot",
CONF_PASSWORD: "abc123",
}
assert len(mock_setup_entry.mock_calls) == 1
async def test_bluetooth_discovery_already_setup(hass): async def test_bluetooth_discovery_already_setup(hass):
"""Test discovery via bluetooth with a valid device when already setup.""" """Test discovery via bluetooth with a valid device when already setup."""
entry = MockConfigEntry( entry = MockConfigEntry(
@ -97,22 +123,20 @@ async def test_user_setup_wohand(hass):
DOMAIN, context={"source": SOURCE_USER} DOMAIN, context={"source": SOURCE_USER}
) )
assert result["type"] == FlowResultType.FORM assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user" assert result["step_id"] == "confirm"
assert result["errors"] == {} assert result["errors"] is None
with patch_async_setup_entry() as mock_setup_entry: with patch_async_setup_entry() as mock_setup_entry:
result = await hass.config_entries.flow.async_configure( result = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
USER_INPUT, {},
) )
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["title"] == "test-name" assert result["title"] == "Bot EEFF"
assert result["data"] == { assert result["data"] == {
CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", CONF_ADDRESS: "aa:bb:cc:dd:ee:ff",
CONF_NAME: "test-name",
CONF_PASSWORD: "test-password",
CONF_SENSOR_TYPE: "bot", CONF_SENSOR_TYPE: "bot",
} }
@ -154,28 +178,129 @@ async def test_user_setup_wocurtain(hass):
DOMAIN, context={"source": SOURCE_USER} DOMAIN, context={"source": SOURCE_USER}
) )
assert result["type"] == FlowResultType.FORM assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "confirm"
assert result["errors"] is None
with patch_async_setup_entry() as mock_setup_entry:
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{},
)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["title"] == "Curtain EEFF"
assert result["data"] == {
CONF_ADDRESS: "aa:bb:cc:dd:ee:ff",
CONF_SENSOR_TYPE: "curtain",
}
assert len(mock_setup_entry.mock_calls) == 1
async def test_user_setup_wocurtain_or_bot(hass):
"""Test the user initiated form with valid address."""
with patch(
"homeassistant.components.switchbot.config_flow.async_discovered_service_info",
return_value=[WOCURTAIN_SERVICE_INFO, WOHAND_SERVICE_ALT_ADDRESS_INFO],
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user" assert result["step_id"] == "user"
assert result["errors"] == {} assert result["errors"] == {}
with patch_async_setup_entry() as mock_setup_entry: with patch_async_setup_entry() as mock_setup_entry:
result = await hass.config_entries.flow.async_configure( result = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
USER_INPUT_CURTAIN, USER_INPUT,
) )
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["title"] == "test-name" assert result["title"] == "Curtain EEFF"
assert result["data"] == { assert result["data"] == {
CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", CONF_ADDRESS: "aa:bb:cc:dd:ee:ff",
CONF_NAME: "test-name",
CONF_PASSWORD: "test-password",
CONF_SENSOR_TYPE: "curtain", CONF_SENSOR_TYPE: "curtain",
} }
assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1
async def test_user_setup_wocurtain_or_bot_with_password(hass):
"""Test the user initiated form and valid address and a bot with a password."""
with patch(
"homeassistant.components.switchbot.config_flow.async_discovered_service_info",
return_value=[WOCURTAIN_SERVICE_INFO, WOHAND_ENCRYPTED_SERVICE_INFO],
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {}
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_ADDRESS: "798A8547-2A3D-C609-55FF-73FA824B923B"},
)
assert result2["type"] == FlowResultType.FORM
assert result2["step_id"] == "password"
assert result2["errors"] is None
with patch_async_setup_entry() as mock_setup_entry:
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
{CONF_PASSWORD: "abc123"},
)
await hass.async_block_till_done()
assert result3["type"] == FlowResultType.CREATE_ENTRY
assert result3["title"] == "Bot 923B"
assert result3["data"] == {
CONF_ADDRESS: "798A8547-2A3D-C609-55FF-73FA824B923B",
CONF_PASSWORD: "abc123",
CONF_SENSOR_TYPE: "bot",
}
assert len(mock_setup_entry.mock_calls) == 1
async def test_user_setup_single_bot_with_password(hass):
"""Test the user initiated form for a bot with a password."""
with patch(
"homeassistant.components.switchbot.config_flow.async_discovered_service_info",
return_value=[WOHAND_ENCRYPTED_SERVICE_INFO],
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "password"
assert result["errors"] is None
with patch_async_setup_entry() as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_PASSWORD: "abc123"},
)
await hass.async_block_till_done()
assert result2["type"] == FlowResultType.CREATE_ENTRY
assert result2["title"] == "Bot 923B"
assert result2["data"] == {
CONF_ADDRESS: "798A8547-2A3D-C609-55FF-73FA824B923B",
CONF_PASSWORD: "abc123",
CONF_SENSOR_TYPE: "bot",
}
assert len(mock_setup_entry.mock_calls) == 1
async def test_user_setup_wosensor(hass): async def test_user_setup_wosensor(hass):
"""Test the user initiated form with password and valid mac.""" """Test the user initiated form with password and valid mac."""
with patch( with patch(
@ -186,22 +311,20 @@ async def test_user_setup_wosensor(hass):
DOMAIN, context={"source": SOURCE_USER} DOMAIN, context={"source": SOURCE_USER}
) )
assert result["type"] == FlowResultType.FORM assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user" assert result["step_id"] == "confirm"
assert result["errors"] == {} assert result["errors"] is None
with patch_async_setup_entry() as mock_setup_entry: with patch_async_setup_entry() as mock_setup_entry:
result = await hass.config_entries.flow.async_configure( result = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
USER_INPUT_SENSOR, {},
) )
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["title"] == "test-name" assert result["title"] == "Meter EEFF"
assert result["data"] == { assert result["data"] == {
CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", CONF_ADDRESS: "aa:bb:cc:dd:ee:ff",
CONF_NAME: "test-name",
CONF_PASSWORD: "test-password",
CONF_SENSOR_TYPE: "hygrometer", CONF_SENSOR_TYPE: "hygrometer",
} }
@ -229,7 +352,7 @@ async def test_async_step_user_takes_precedence_over_discovery(hass):
data=WOCURTAIN_SERVICE_INFO, data=WOCURTAIN_SERVICE_INFO,
) )
assert result["type"] == FlowResultType.FORM assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user" assert result["step_id"] == "confirm"
with patch( with patch(
"homeassistant.components.switchbot.config_flow.async_discovered_service_info", "homeassistant.components.switchbot.config_flow.async_discovered_service_info",
@ -244,15 +367,13 @@ async def test_async_step_user_takes_precedence_over_discovery(hass):
with patch_async_setup_entry() as mock_setup_entry: with patch_async_setup_entry() as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure( result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
user_input=USER_INPUT, user_input={},
) )
assert result2["type"] == FlowResultType.CREATE_ENTRY assert result2["type"] == FlowResultType.CREATE_ENTRY
assert result2["title"] == "test-name" assert result2["title"] == "Curtain EEFF"
assert result2["data"] == { assert result2["data"] == {
CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", CONF_ADDRESS: "aa:bb:cc:dd:ee:ff",
CONF_NAME: "test-name",
CONF_PASSWORD: "test-password",
CONF_SENSOR_TYPE: "curtain", CONF_SENSOR_TYPE: "curtain",
} }