2021-09-18 19:25:05 +00:00
|
|
|
"""Config flow for Switchbot."""
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
import logging
|
2022-07-30 00:53:33 +00:00
|
|
|
from typing import Any
|
2021-09-18 19:25:05 +00:00
|
|
|
|
2022-12-28 04:16:00 +00:00
|
|
|
from switchbot import (
|
2022-12-31 04:05:25 +00:00
|
|
|
SwitchbotAccountConnectionError,
|
2022-12-28 04:16:00 +00:00
|
|
|
SwitchBotAdvertisement,
|
2022-12-31 04:05:25 +00:00
|
|
|
SwitchbotAuthenticationError,
|
2022-12-28 04:16:00 +00:00
|
|
|
SwitchbotLock,
|
|
|
|
SwitchbotModel,
|
|
|
|
parse_advertisement_data,
|
|
|
|
)
|
2021-09-18 19:25:05 +00:00
|
|
|
import voluptuous as vol
|
|
|
|
|
2022-07-24 16:38:45 +00:00
|
|
|
from homeassistant.components.bluetooth import (
|
|
|
|
BluetoothServiceInfoBleak,
|
|
|
|
async_discovered_service_info,
|
|
|
|
)
|
2021-09-18 19:25:05 +00:00
|
|
|
from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow
|
2022-12-30 07:48:39 +00:00
|
|
|
from homeassistant.const import (
|
|
|
|
CONF_ADDRESS,
|
|
|
|
CONF_PASSWORD,
|
|
|
|
CONF_SENSOR_TYPE,
|
|
|
|
CONF_USERNAME,
|
|
|
|
)
|
2021-09-18 19:25:05 +00:00
|
|
|
from homeassistant.core import callback
|
2022-08-10 19:02:08 +00:00
|
|
|
from homeassistant.data_entry_flow import AbortFlow, FlowResult
|
2021-09-18 19:25:05 +00:00
|
|
|
|
2022-08-22 18:02:26 +00:00
|
|
|
from .const import (
|
2022-12-28 04:16:00 +00:00
|
|
|
CONF_ENCRYPTION_KEY,
|
|
|
|
CONF_KEY_ID,
|
2022-08-22 18:02:26 +00:00
|
|
|
CONF_RETRY_COUNT,
|
|
|
|
CONNECTABLE_SUPPORTED_MODEL_TYPES,
|
|
|
|
DEFAULT_RETRY_COUNT,
|
|
|
|
DOMAIN,
|
|
|
|
NON_CONNECTABLE_SUPPORTED_MODEL_TYPES,
|
|
|
|
SUPPORTED_MODEL_TYPES,
|
|
|
|
)
|
2021-09-18 19:25:05 +00:00
|
|
|
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
2022-07-24 16:38:45 +00:00
|
|
|
def format_unique_id(address: str) -> str:
|
|
|
|
"""Format the unique ID for a switchbot."""
|
|
|
|
return address.replace(":", "").lower()
|
2021-09-18 19:25:05 +00:00
|
|
|
|
|
|
|
|
2022-08-10 19:02:08 +00:00
|
|
|
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)}'
|
|
|
|
|
|
|
|
|
2021-09-18 19:25:05 +00:00
|
|
|
class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN):
|
|
|
|
"""Handle a config flow for Switchbot."""
|
|
|
|
|
|
|
|
VERSION = 1
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
@callback
|
|
|
|
def async_get_options_flow(
|
|
|
|
config_entry: ConfigEntry,
|
|
|
|
) -> SwitchbotOptionsFlowHandler:
|
|
|
|
"""Get the options flow for this handler."""
|
|
|
|
return SwitchbotOptionsFlowHandler(config_entry)
|
|
|
|
|
2023-02-07 14:55:14 +00:00
|
|
|
def __init__(self) -> None:
|
2021-09-27 20:12:40 +00:00
|
|
|
"""Initialize the config flow."""
|
2022-07-24 16:38:45 +00:00
|
|
|
self._discovered_adv: SwitchBotAdvertisement | None = None
|
|
|
|
self._discovered_advs: dict[str, SwitchBotAdvertisement] = {}
|
|
|
|
|
|
|
|
async def async_step_bluetooth(
|
2022-07-30 00:53:33 +00:00
|
|
|
self, discovery_info: BluetoothServiceInfoBleak
|
2022-07-24 16:38:45 +00:00
|
|
|
) -> FlowResult:
|
|
|
|
"""Handle the bluetooth discovery step."""
|
2022-12-10 18:56:57 +00:00
|
|
|
_LOGGER.debug("Discovered bluetooth device: %s", discovery_info.as_dict())
|
2022-07-24 16:38:45 +00:00
|
|
|
await self.async_set_unique_id(format_unique_id(discovery_info.address))
|
|
|
|
self._abort_if_unique_id_configured()
|
|
|
|
parsed = parse_advertisement_data(
|
2022-07-30 00:53:33 +00:00
|
|
|
discovery_info.device, discovery_info.advertisement
|
2022-07-24 16:38:45 +00:00
|
|
|
)
|
|
|
|
if not parsed or parsed.data.get("modelName") not in SUPPORTED_MODEL_TYPES:
|
|
|
|
return self.async_abort(reason="not_supported")
|
2022-08-22 18:02:26 +00:00
|
|
|
model_name = parsed.data.get("modelName")
|
|
|
|
if (
|
|
|
|
not discovery_info.connectable
|
|
|
|
and model_name in CONNECTABLE_SUPPORTED_MODEL_TYPES
|
|
|
|
):
|
|
|
|
# Source is not connectable but the model is connectable
|
|
|
|
return self.async_abort(reason="not_supported")
|
2022-07-24 16:38:45 +00:00
|
|
|
self._discovered_adv = parsed
|
|
|
|
data = parsed.data
|
|
|
|
self.context["title_placeholders"] = {
|
2022-08-10 19:02:08 +00:00
|
|
|
"name": data["modelFriendlyName"],
|
|
|
|
"address": short_address(discovery_info.address),
|
2022-07-24 16:38:45 +00:00
|
|
|
}
|
2022-12-30 07:48:39 +00:00
|
|
|
if model_name == SwitchbotModel.LOCK:
|
2022-12-31 04:05:25 +00:00
|
|
|
return await self.async_step_lock_choose_method()
|
2022-08-10 19:02:08 +00:00
|
|
|
if self._discovered_adv.data["isEncrypted"]:
|
|
|
|
return await self.async_step_password()
|
|
|
|
return await self.async_step_confirm()
|
2021-09-27 20:12:40 +00:00
|
|
|
|
2022-08-10 19:02:08 +00:00
|
|
|
async def _async_create_entry_from_discovery(
|
|
|
|
self, user_input: dict[str, Any]
|
2021-09-18 19:25:05 +00:00
|
|
|
) -> FlowResult:
|
2022-08-10 19:02:08 +00:00
|
|
|
"""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]),
|
|
|
|
},
|
|
|
|
)
|
|
|
|
|
2022-08-13 16:46:49 +00:00
|
|
|
async def async_step_confirm(
|
|
|
|
self, user_input: dict[str, Any] | None = None
|
|
|
|
) -> FlowResult:
|
2022-08-10 19:02:08 +00:00
|
|
|
"""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)
|
|
|
|
},
|
|
|
|
)
|
2021-09-18 19:25:05 +00:00
|
|
|
|
2022-08-10 19:02:08 +00:00
|
|
|
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
|
2021-09-18 19:25:05 +00:00
|
|
|
if user_input is not None:
|
2022-08-10 19:02:08 +00:00
|
|
|
# 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)
|
|
|
|
},
|
|
|
|
)
|
|
|
|
|
2022-12-30 07:48:39 +00:00
|
|
|
async def async_step_lock_auth(
|
|
|
|
self, user_input: dict[str, Any] | None = None
|
|
|
|
) -> FlowResult:
|
|
|
|
"""Handle the SwitchBot API auth step."""
|
|
|
|
errors = {}
|
|
|
|
assert self._discovered_adv is not None
|
2023-01-05 20:29:13 +00:00
|
|
|
description_placeholders = {}
|
2022-12-30 07:48:39 +00:00
|
|
|
if user_input is not None:
|
|
|
|
try:
|
|
|
|
key_details = await self.hass.async_add_executor_job(
|
|
|
|
SwitchbotLock.retrieve_encryption_key,
|
|
|
|
self._discovered_adv.address,
|
|
|
|
user_input[CONF_USERNAME],
|
|
|
|
user_input[CONF_PASSWORD],
|
|
|
|
)
|
2022-12-31 04:05:25 +00:00
|
|
|
except SwitchbotAccountConnectionError as ex:
|
|
|
|
raise AbortFlow("cannot_connect") from ex
|
2023-01-05 20:29:13 +00:00
|
|
|
except SwitchbotAuthenticationError as ex:
|
|
|
|
_LOGGER.debug("Authentication failed: %s", ex, exc_info=True)
|
2022-12-31 04:05:25 +00:00
|
|
|
errors = {"base": "auth_failed"}
|
2023-01-05 20:29:13 +00:00
|
|
|
description_placeholders = {"error_detail": str(ex)}
|
2022-12-31 04:05:25 +00:00
|
|
|
else:
|
2022-12-30 07:48:39 +00:00
|
|
|
return await self.async_step_lock_key(key_details)
|
|
|
|
|
|
|
|
user_input = user_input or {}
|
|
|
|
return self.async_show_form(
|
|
|
|
step_id="lock_auth",
|
|
|
|
errors=errors,
|
|
|
|
data_schema=vol.Schema(
|
|
|
|
{
|
|
|
|
vol.Required(
|
|
|
|
CONF_USERNAME, default=user_input.get(CONF_USERNAME)
|
|
|
|
): str,
|
|
|
|
vol.Required(CONF_PASSWORD): str,
|
|
|
|
}
|
|
|
|
),
|
|
|
|
description_placeholders={
|
|
|
|
"name": name_from_discovery(self._discovered_adv),
|
2023-01-05 20:29:13 +00:00
|
|
|
**description_placeholders,
|
2022-12-30 07:48:39 +00:00
|
|
|
},
|
|
|
|
)
|
|
|
|
|
2022-12-31 04:05:25 +00:00
|
|
|
async def async_step_lock_choose_method(
|
2022-12-30 07:48:39 +00:00
|
|
|
self, user_input: dict[str, Any] | None = None
|
|
|
|
) -> FlowResult:
|
|
|
|
"""Handle the SwitchBot API chose method step."""
|
|
|
|
assert self._discovered_adv is not None
|
|
|
|
|
|
|
|
return self.async_show_menu(
|
2022-12-31 04:05:25 +00:00
|
|
|
step_id="lock_choose_method",
|
2022-12-30 07:48:39 +00:00
|
|
|
menu_options=["lock_auth", "lock_key"],
|
|
|
|
description_placeholders={
|
|
|
|
"name": name_from_discovery(self._discovered_adv),
|
|
|
|
},
|
|
|
|
)
|
|
|
|
|
2022-12-28 04:16:00 +00:00
|
|
|
async def async_step_lock_key(
|
|
|
|
self, user_input: dict[str, Any] | None = None
|
|
|
|
) -> FlowResult:
|
|
|
|
"""Handle the encryption key step."""
|
|
|
|
errors = {}
|
|
|
|
assert self._discovered_adv is not None
|
|
|
|
if user_input is not None:
|
|
|
|
if not await SwitchbotLock.verify_encryption_key(
|
|
|
|
self._discovered_adv.device,
|
2022-12-30 07:48:39 +00:00
|
|
|
user_input[CONF_KEY_ID],
|
|
|
|
user_input[CONF_ENCRYPTION_KEY],
|
2022-12-28 04:16:00 +00:00
|
|
|
):
|
|
|
|
errors = {
|
2022-12-30 07:48:39 +00:00
|
|
|
"base": "encryption_key_invalid",
|
2022-12-28 04:16:00 +00:00
|
|
|
}
|
|
|
|
else:
|
|
|
|
return await self._async_create_entry_from_discovery(user_input)
|
|
|
|
|
|
|
|
return self.async_show_form(
|
|
|
|
step_id="lock_key",
|
|
|
|
errors=errors,
|
|
|
|
data_schema=vol.Schema(
|
|
|
|
{
|
|
|
|
vol.Required(CONF_KEY_ID): str,
|
|
|
|
vol.Required(CONF_ENCRYPTION_KEY): str,
|
|
|
|
}
|
|
|
|
),
|
|
|
|
description_placeholders={
|
|
|
|
"name": name_from_discovery(self._discovered_adv),
|
|
|
|
},
|
|
|
|
)
|
|
|
|
|
2022-08-10 19:02:08 +00:00
|
|
|
@callback
|
|
|
|
def _async_discover_devices(self) -> None:
|
|
|
|
current_addresses = self._async_current_ids()
|
2022-08-22 18:02:26 +00:00
|
|
|
for connectable in (True, False):
|
|
|
|
for discovery_info in async_discovered_service_info(self.hass, connectable):
|
|
|
|
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 not parsed:
|
|
|
|
continue
|
|
|
|
model_name = parsed.data.get("modelName")
|
|
|
|
if (
|
|
|
|
discovery_info.connectable
|
|
|
|
and model_name in CONNECTABLE_SUPPORTED_MODEL_TYPES
|
|
|
|
) or model_name in NON_CONNECTABLE_SUPPORTED_MODEL_TYPES:
|
|
|
|
self._discovered_advs[address] = parsed
|
2022-07-24 16:38:45 +00:00
|
|
|
|
|
|
|
if not self._discovered_advs:
|
2023-02-16 21:51:27 +00:00
|
|
|
raise AbortFlow("no_devices_found")
|
2022-08-10 19:02:08 +00:00
|
|
|
|
|
|
|
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
|
2021-09-18 19:25:05 +00:00
|
|
|
)
|
2022-08-10 19:02:08 +00:00
|
|
|
self._abort_if_unique_id_configured()
|
|
|
|
|
|
|
|
async def async_step_user(
|
|
|
|
self, user_input: dict[str, Any] | None = None
|
|
|
|
) -> FlowResult:
|
|
|
|
"""Handle the user step to pick discovered device."""
|
|
|
|
errors: dict[str, str] = {}
|
|
|
|
device_adv: SwitchBotAdvertisement | None = None
|
|
|
|
if user_input is not None:
|
|
|
|
device_adv = self._discovered_advs[user_input[CONF_ADDRESS]]
|
|
|
|
await self._async_set_device(device_adv)
|
2022-12-28 04:16:00 +00:00
|
|
|
if device_adv.data.get("modelName") == SwitchbotModel.LOCK:
|
2022-12-31 04:05:25 +00:00
|
|
|
return await self.async_step_lock_choose_method()
|
2022-08-10 19:02:08 +00:00
|
|
|
if device_adv.data["isEncrypted"]:
|
|
|
|
return await self.async_step_password()
|
|
|
|
return await self._async_create_entry_from_discovery(user_input)
|
|
|
|
|
|
|
|
self._async_discover_devices()
|
|
|
|
if len(self._discovered_advs) == 1:
|
|
|
|
# If there is only one device we can ask for a password
|
|
|
|
# or simply confirm it
|
|
|
|
device_adv = list(self._discovered_advs.values())[0]
|
|
|
|
await self._async_set_device(device_adv)
|
2022-12-28 04:16:00 +00:00
|
|
|
if device_adv.data.get("modelName") == SwitchbotModel.LOCK:
|
2022-12-31 04:05:25 +00:00
|
|
|
return await self.async_step_lock_choose_method()
|
2022-08-10 19:02:08 +00:00
|
|
|
if device_adv.data["isEncrypted"]:
|
|
|
|
return await self.async_step_password()
|
|
|
|
return await self.async_step_confirm()
|
|
|
|
|
2021-09-18 19:25:05 +00:00
|
|
|
return self.async_show_form(
|
2022-08-10 19:02:08 +00:00
|
|
|
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,
|
2021-09-18 19:25:05 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
class SwitchbotOptionsFlowHandler(OptionsFlow):
|
|
|
|
"""Handle Switchbot options."""
|
|
|
|
|
|
|
|
def __init__(self, config_entry: ConfigEntry) -> None:
|
|
|
|
"""Initialize options flow."""
|
|
|
|
self.config_entry = config_entry
|
|
|
|
|
|
|
|
async def async_step_init(
|
|
|
|
self, user_input: dict[str, Any] | None = None
|
|
|
|
) -> FlowResult:
|
|
|
|
"""Manage Switchbot options."""
|
|
|
|
if user_input is not None:
|
|
|
|
# Update common entity options for all other entities.
|
|
|
|
return self.async_create_entry(title="", data=user_input)
|
|
|
|
|
|
|
|
options = {
|
|
|
|
vol.Optional(
|
|
|
|
CONF_RETRY_COUNT,
|
|
|
|
default=self.config_entry.options.get(
|
|
|
|
CONF_RETRY_COUNT, DEFAULT_RETRY_COUNT
|
|
|
|
),
|
2022-07-24 21:38:07 +00:00
|
|
|
): int
|
2021-09-18 19:25:05 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return self.async_show_form(step_id="init", data_schema=vol.Schema(options))
|