"""Config flow for SMLIGHT Zigbee integration.""" from collections.abc import Mapping from typing import Any, override from pysmlight import Api2 from pysmlight.const import Devices from pysmlight.exceptions import SmlightAuthError, SmlightConnectionError import voluptuous as vol from homeassistant.config_entries import ( SOURCE_RECONFIGURE, SOURCE_USER, ConfigFlow, ConfigFlowResult, OptionsFlowWithReload, ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.selector import ( SelectSelector, SelectSelectorConfig, SelectSelectorMode, ) from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .bluetooth import get_ble_scanner_mode from .const import CONF_BLE_SCANNER_MODE, DOMAIN, BLEScannerMode from .coordinator import SmConfigEntry STEP_USER_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_HOST): str, } ) STEP_AUTH_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, } ) BLE_SCANNER_OPTIONS = [ BLEScannerMode.DISABLED, BLEScannerMode.AUTO, BLEScannerMode.ACTIVE, BLEScannerMode.PASSIVE, ] BLE_SCANNER_SCHEMA = vol.Schema( { vol.Required(CONF_BLE_SCANNER_MODE): SelectSelector( SelectSelectorConfig( options=BLE_SCANNER_OPTIONS, translation_key=CONF_BLE_SCANNER_MODE, mode=SelectSelectorMode.DROPDOWN, ) ) } ) class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for SMLIGHT Zigbee.""" _host: str _device_name: str client: Api2 @override async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" errors: dict[str, str] = {} if user_input is not None: self._host = user_input[CONF_HOST] self.client = Api2(self._host, session=async_get_clientsession(self.hass)) try: if not await self._async_check_auth_required(user_input): info = await self.client.get_info() self._device_name = str(info.hostname) if info.model not in Devices: return self.async_abort(reason="unsupported_device") return await self._async_complete_entry(user_input) except SmlightConnectionError: errors["base"] = "cannot_connect" except SmlightAuthError: return await self.async_step_auth() return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) async def async_step_auth( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle authentication to SLZB-06 device.""" errors: dict[str, str] = {} if user_input is not None: try: if not await self._async_check_auth_required(user_input): info = await self.client.get_info() self._device_name = str(info.hostname) if info.model not in Devices: return self.async_abort(reason="unsupported_device") if self.source == SOURCE_RECONFIGURE: await self.async_set_unique_id(format_mac(info.MAC)) self._abort_if_unique_id_mismatch() return self.async_update_reload_and_abort( self._get_reconfigure_entry(), data_updates={ CONF_HOST: self._host, **user_input, }, ) return await self._async_complete_entry(user_input) except SmlightConnectionError: return self.async_abort(reason="cannot_connect") except SmlightAuthError: errors["base"] = "invalid_auth" return self.async_show_form( step_id="auth", data_schema=STEP_AUTH_DATA_SCHEMA, errors=errors ) @override async def async_step_zeroconf( self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a discovered Lan coordinator.""" mac: str | None = discovery_info.properties.get("mac") self._device_name = discovery_info.hostname.removesuffix(".local.") self._host = discovery_info.host self.context["title_placeholders"] = {CONF_NAME: self._device_name} self.client = Api2(self._host, session=async_get_clientsession(self.hass)) # fallback for legacy firmware older than v2.3.x if mac is None: try: info = await self.client.get_info() except SmlightConnectionError: # User is likely running unsupported ESPHome firmware return self.async_abort(reason="cannot_connect") mac = info.MAC await self.async_set_unique_id(format_mac(mac)) self._abort_if_unique_id_configured(updates={CONF_HOST: self._host}) return await self.async_step_confirm_discovery() async def async_step_confirm_discovery( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle discovery confirm.""" errors: dict[str, str] = {} if user_input is not None: try: if not await self._async_check_auth_required(user_input): info = await self.client.get_info() if info.model not in Devices: return self.async_abort(reason="unsupported_device") return await self._async_complete_entry(user_input) except SmlightConnectionError: return self.async_abort(reason="cannot_connect") except SmlightAuthError: return await self.async_step_auth() self._set_confirm_only() return self.async_show_form( step_id="confirm_discovery", description_placeholders={"host": self._device_name}, errors=errors, ) async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: """Handle reauth when API Authentication failed.""" self._host = entry_data[CONF_HOST] self.client = Api2(self._host, session=async_get_clientsession(self.hass)) return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle re-authentication of an existing config entry.""" errors = {} if user_input is not None: try: await self.client.authenticate( user_input[CONF_USERNAME], user_input[CONF_PASSWORD] ) except SmlightAuthError: errors["base"] = "invalid_auth" except SmlightConnectionError: return self.async_abort(reason="cannot_connect") else: return self.async_update_reload_and_abort( self._get_reauth_entry(), data_updates=user_input ) return self.async_show_form( step_id="reauth_confirm", data_schema=STEP_AUTH_DATA_SCHEMA, description_placeholders=self.context["title_placeholders"], errors=errors, ) async def async_step_reconfigure( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle reconfiguration of SMLIGHT device.""" errors: dict[str, str] = {} entry = self._get_reconfigure_entry() if user_input is not None: self._host = user_input[CONF_HOST] self.client = Api2(self._host, session=async_get_clientsession(self.hass)) check_input = {**entry.data, **user_input} try: await self._async_check_auth_required(check_input) info = await self.client.get_info() except SmlightConnectionError: errors["base"] = "cannot_connect" except SmlightAuthError: return await self.async_step_auth() else: if info.model not in Devices: return self.async_abort(reason="unsupported_device") await self.async_set_unique_id(format_mac(info.MAC)) self._abort_if_unique_id_mismatch() return self.async_update_reload_and_abort( entry, data_updates=user_input, ) return self.async_show_form( step_id="reconfigure", data_schema=self.add_suggested_values_to_schema( STEP_USER_DATA_SCHEMA, user_input or entry.data ), errors=errors, ) @override async def async_step_dhcp( self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle DHCP discovery.""" await self.async_set_unique_id(format_mac(discovery_info.macaddress)) self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip}) # This should never happen since we only listen to DHCP requests # for configured devices. return self.async_abort(reason="already_configured") async def _async_check_auth_required(self, user_input: dict[str, Any]) -> bool: """Check if auth required and attempt to authenticate.""" if await self.client.check_auth_needed(): if user_input.get(CONF_USERNAME) and user_input.get(CONF_PASSWORD): return not await self.client.authenticate( user_input[CONF_USERNAME], user_input[CONF_PASSWORD] ) raise SmlightAuthError return False async def _async_complete_entry( self, user_input: dict[str, Any] ) -> ConfigFlowResult: info = await self.client.get_info() await self.async_set_unique_id( format_mac(info.MAC), raise_on_progress=self.source != SOURCE_USER ) self._abort_if_unique_id_configured(updates={CONF_HOST: self._host}) user_input[CONF_HOST] = self._host assert info.model is not None title = self._device_name or info.model return self.async_create_entry(title=title, data=user_input) @staticmethod @callback @override def async_get_options_flow( config_entry: SmConfigEntry, ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler() class OptionsFlowHandler(OptionsFlowWithReload): """Handle options flow for SMLIGHT.""" async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle options flow.""" errors: dict[str, str] = {} if not hasattr(self.config_entry, "runtime_data"): errors["base"] = "cannot_connect" return self.async_show_form( step_id="init", data_schema=self.add_suggested_values_to_schema( BLE_SCANNER_SCHEMA, user_input or {CONF_BLE_SCANNER_MODE: BLEScannerMode.DISABLED}, ), errors=errors, ) coordinator = self.config_entry.runtime_data.data info = coordinator.data.info if info.ble is None: return await self.async_step_no_settings() if user_input is not None: scanner_mode = BLEScannerMode(user_input[CONF_BLE_SCANNER_MODE]) user_input[CONF_BLE_SCANNER_MODE] = scanner_mode current_mode = get_ble_scanner_mode(self.config_entry, info) if (scanner_mode == BLEScannerMode.DISABLED) != ( current_mode == BLEScannerMode.DISABLED ): try: await coordinator.client.set_ble_proxy( scanner_mode != BLEScannerMode.DISABLED ) except SmlightConnectionError: errors["base"] = "cannot_connect" except SmlightAuthError: errors["base"] = "invalid_auth" self.config_entry.async_start_reauth(self.hass) if not errors: return self.async_create_entry(title="", data=user_input) suggested_values = { CONF_BLE_SCANNER_MODE: get_ble_scanner_mode(self.config_entry, info) } return self.async_show_form( step_id="init", data_schema=self.add_suggested_values_to_schema( BLE_SCANNER_SCHEMA, user_input or suggested_values ), errors=errors, ) async def async_step_no_settings( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle options for devices without settings.""" if user_input is not None: return self.async_create_entry(title="", data={}) coordinator = self.config_entry.runtime_data.data return self.async_show_form( step_id="no_settings", data_schema=vol.Schema({}), description_placeholders={"model": coordinator.data.info.model}, )