diff --git a/.coveragerc b/.coveragerc index 23531c159fd..6f4fa46864c 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1587,6 +1587,9 @@ omit = homeassistant/components/zwave_me/sensor.py homeassistant/components/zwave_me/siren.py homeassistant/components/zwave_me/switch.py + homeassistant/components/switchbee/__init__.py + homeassistant/components/switchbee/const.py + homeassistant/components/switchbee/switch.py [report] # Regexes for lines to exclude from consideration diff --git a/CODEOWNERS b/CODEOWNERS index 7203e4705f1..c8e4de87818 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1087,6 +1087,8 @@ build.json @home-assistant/supervisor /tests/components/switch/ @home-assistant/core /homeassistant/components/switch_as_x/ @home-assistant/core /tests/components/switch_as_x/ @home-assistant/core +/homeassistant/components/switchbee/ @jafar-atili +/tests/components/switchbee/ @jafar-atili /homeassistant/components/switchbot/ @bdraco @danielhiversen @RenierM26 @murtas @Eloston /tests/components/switchbot/ @bdraco @danielhiversen @RenierM26 @murtas @Eloston /homeassistant/components/switcher_kis/ @tomerfi @thecode diff --git a/homeassistant/components/switchbee/__init__.py b/homeassistant/components/switchbee/__init__.py new file mode 100644 index 00000000000..ca8d792111b --- /dev/null +++ b/homeassistant/components/switchbee/__init__.py @@ -0,0 +1,156 @@ +"""The SwitchBee Smart Home integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from switchbee.api import CentralUnitAPI, SwitchBeeError +from switchbee.device import DeviceType + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + CONF_DEFUALT_ALLOWED, + CONF_DEVICES, + CONF_SWITCHES_AS_LIGHTS, + DOMAIN, + SCAN_INTERVAL_SEC, +) + +_LOGGER = logging.getLogger(__name__) + + +PLATFORMS: list[Platform] = [Platform.SWITCH] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up SwitchBee Smart Home from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + central_unit = entry.data[CONF_HOST] + user = entry.data[CONF_USERNAME] + password = entry.data[CONF_PASSWORD] + devices_map: dict[str, DeviceType] = {s.display: s for s in DeviceType} + allowed_devices = [ + devices_map[device] + for device in entry.options.get(CONF_DEVICES, CONF_DEFUALT_ALLOWED) + ] + websession = async_get_clientsession(hass, verify_ssl=False) + api = CentralUnitAPI(central_unit, user, password, websession) + try: + await api.connect() + except SwitchBeeError: + return False + + coordinator = SwitchBeeCoordinator( + hass, + api, + SCAN_INTERVAL_SEC, + allowed_devices, + entry.data[CONF_SWITCHES_AS_LIGHTS], + ) + await coordinator.async_config_entry_first_refresh() + entry.async_on_unload(entry.add_update_listener(update_listener)) + hass.data[DOMAIN][entry.entry_id] = coordinator + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Update listener.""" + await hass.config_entries.async_reload(config_entry.entry_id) + + +class SwitchBeeCoordinator(DataUpdateCoordinator): + """Class to manage fetching Freedompro data API.""" + + def __init__( + self, + hass, + swb_api, + scan_interval, + devices: list[DeviceType], + switch_as_light: bool, + ): + """Initialize.""" + self._api: CentralUnitAPI = swb_api + self._reconnect_counts: int = 0 + self._devices_to_include: list[DeviceType] = devices + self._prev_devices_to_include_to_include: list[DeviceType] = [] + self._mac_addr_fmt: str = format_mac(swb_api.mac) + self._switch_as_light = switch_as_light + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=scan_interval), + ) + + @property + def api(self) -> CentralUnitAPI: + """Return SwitchBee API object.""" + return self._api + + @property + def mac_formated(self) -> str: + """Return formatted MAC address.""" + return self._mac_addr_fmt + + @property + def switch_as_light(self) -> bool: + """Return switch_as_ligh config.""" + return self._switch_as_light + + async def _async_update_data(self): + + if self._reconnect_counts != self._api.reconnect_count: + self._reconnect_counts = self._api.reconnect_count + _LOGGER.debug( + "Central Unit re-connected again due to invalid token, total %i", + self._reconnect_counts, + ) + + config_changed = False + + if set(self._prev_devices_to_include_to_include) != set( + self._devices_to_include + ): + self._prev_devices_to_include_to_include = self._devices_to_include + config_changed = True + + # The devices are loaded once during the config_entry + if not self._api.devices or config_changed: + # Try to load the devices from the CU for the first time + try: + await self._api.fetch_configuration(self._devices_to_include) + except SwitchBeeError as exp: + raise UpdateFailed( + f"Error communicating with API: {exp}" + ) from SwitchBeeError + else: + _LOGGER.debug("Loaded devices") + + # Get the state of the devices + try: + await self._api.fetch_states() + except SwitchBeeError as exp: + raise UpdateFailed( + f"Error communicating with API: {exp}" + ) from SwitchBeeError + else: + return self._api.devices diff --git a/homeassistant/components/switchbee/config_flow.py b/homeassistant/components/switchbee/config_flow.py new file mode 100644 index 00000000000..38d33bd4981 --- /dev/null +++ b/homeassistant/components/switchbee/config_flow.py @@ -0,0 +1,133 @@ +"""Config flow for SwitchBee Smart Home integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from switchbee.api import CentralUnitAPI, SwitchBeeError +from switchbee.device import DeviceType +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import format_mac + +from .const import CONF_DEFUALT_ALLOWED, CONF_DEVICES, CONF_SWITCHES_AS_LIGHTS, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_SWITCHES_AS_LIGHTS, default=False): cv.boolean, + } +) + + +async def validate_input(hass: HomeAssistant, data: dict[str, Any]): + """Validate the user input allows us to connect.""" + + websession = async_get_clientsession(hass, verify_ssl=False) + api = CentralUnitAPI( + data[CONF_HOST], data[CONF_USERNAME], data[CONF_PASSWORD], websession + ) + try: + await api.connect() + except SwitchBeeError as exp: + _LOGGER.error(exp) + if "LOGIN_FAILED" in str(exp): + raise InvalidAuth from SwitchBeeError + + raise CannotConnect from SwitchBeeError + + return format_mac(api.mac) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for SwitchBee Smart Home.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None) -> FlowResult: + """Show the setup form to the user.""" + errors: dict[str, str] = {} + + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + try: + mac_formated = await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + else: + await self.async_set_unique_id(mac_formated) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=user_input[CONF_HOST], data=user_input) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> OptionsFlowHandler: + """Get the options flow for this handler.""" + return OptionsFlowHandler(config_entry) + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Handle a option flow for AEMET.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None) -> FlowResult: + """Handle options flow.""" + + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + all_devices = [ + DeviceType.Switch, + DeviceType.TimedSwitch, + DeviceType.GroupSwitch, + DeviceType.TimedPowerSwitch, + ] + + data_schema = { + vol.Required( + CONF_DEVICES, + default=self.config_entry.options.get( + CONF_DEVICES, + CONF_DEFUALT_ALLOWED, + ), + ): cv.multi_select([device.display for device in all_devices]), + } + + return self.async_show_form(step_id="init", data_schema=vol.Schema(data_schema)) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/switchbee/const.py b/homeassistant/components/switchbee/const.py new file mode 100644 index 00000000000..be818346589 --- /dev/null +++ b/homeassistant/components/switchbee/const.py @@ -0,0 +1,14 @@ +"""Constants for the SwitchBee Smart Home integration.""" + +from switchbee.device import DeviceType + +DOMAIN = "switchbee" +SCAN_INTERVAL_SEC = 5 +CONF_SCAN_INTERVAL = "scan_interval" +CONF_SWITCHES_AS_LIGHTS = "switch_as_light" +CONF_DEVICES = "devices" +CONF_DEFUALT_ALLOWED = [ + DeviceType.Switch.display, + DeviceType.TimedPowerSwitch.display, + DeviceType.TimedSwitch.display, +] diff --git a/homeassistant/components/switchbee/manifest.json b/homeassistant/components/switchbee/manifest.json new file mode 100644 index 00000000000..ba0e4a454ce --- /dev/null +++ b/homeassistant/components/switchbee/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "switchbee", + "name": "SwitchBee", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/switchbee", + "requirements": ["pyswitchbee==1.4.7"], + "codeowners": ["@jafar-atili"], + "iot_class": "local_polling" +} diff --git a/homeassistant/components/switchbee/strings.json b/homeassistant/components/switchbee/strings.json new file mode 100644 index 00000000000..531e19fda00 --- /dev/null +++ b/homeassistant/components/switchbee/strings.json @@ -0,0 +1,32 @@ +{ + "config": { + "step": { + "user": { + "description": "Setup SwitchBee integration with Home Assistant.", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "switch_as_light": "Initialize switches as light entities" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "options": { + "step": { + "init": { + "data": { + "devices": "Devices to include" + } + } + } + } +} diff --git a/homeassistant/components/switchbee/switch.py b/homeassistant/components/switchbee/switch.py new file mode 100644 index 00000000000..de648d4232c --- /dev/null +++ b/homeassistant/components/switchbee/switch.py @@ -0,0 +1,141 @@ +"""Support for SwitchBee switch.""" +import logging +from typing import Any + +from switchbee import SWITCHBEE_BRAND +from switchbee.api import SwitchBeeDeviceOfflineError, SwitchBeeError +from switchbee.device import ApiStateCommand, DeviceType, SwitchBeeBaseDevice + +from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import SwitchBeeCoordinator +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Switchbee switch.""" + coordinator: SwitchBeeCoordinator = hass.data[DOMAIN][entry.entry_id] + device_types = ( + [DeviceType.TimedPowerSwitch] + if coordinator.switch_as_light + else [ + DeviceType.TimedPowerSwitch, + DeviceType.GroupSwitch, + DeviceType.Switch, + DeviceType.TimedSwitch, + DeviceType.TwoWay, + ] + ) + + async_add_entities( + Device(hass, device, coordinator) + for device in coordinator.data.values() + if device.type in device_types + ) + + +class Device(CoordinatorEntity, SwitchEntity): + """Representation of an Switchbee switch.""" + + def __init__(self, hass, device: SwitchBeeBaseDevice, coordinator): + """Initialize the Switchbee switch.""" + super().__init__(coordinator) + self._session = aiohttp_client.async_get_clientsession(hass) + self._attr_name = f"{device.name}" + self._device_id = device.id + self._attr_unique_id = f"{coordinator.mac_formated}-{device.id}" + self._attr_is_on = False + self._attr_available = True + self._attr_has_entity_name = True + self._device = device + self._attr_device_info = DeviceInfo( + name=f"SwitchBee_{str(device.unit_id)}", + identifiers={ + ( + DOMAIN, + f"{str(device.unit_id)}-{coordinator.mac_formated}", + ) + }, + manufacturer=SWITCHBEE_BRAND, + model=coordinator.api.module_display(device.unit_id), + suggested_area=device.zone, + via_device=( + DOMAIN, + f"{coordinator.api.name} ({coordinator.api.mac})", + ), + ) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + + async def async_refresh_state(): + + try: + await self.coordinator.api.set_state(self._device_id, "dummy") + except SwitchBeeDeviceOfflineError: + return + except SwitchBeeError: + return + + if self.coordinator.data[self._device_id].state == -1: + # This specific call will refresh the state of the device in the CU + self.hass.async_create_task(async_refresh_state()) + + if self.available: + _LOGGER.error( + "%s switch is not responding, check the status in the SwitchBee mobile app", + self.name, + ) + self._attr_available = False + self.async_write_ha_state() + return None + + if not self.available: + _LOGGER.info( + "%s switch is now responding", + self.name, + ) + self._attr_available = True + + # timed power switch state will represent a number of minutes until it goes off + # regulare switches state is ON/OFF + self._attr_is_on = ( + self.coordinator.data[self._device_id].state != ApiStateCommand.OFF + ) + + super()._handle_coordinator_update() + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self._handle_coordinator_update() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Async function to set on to switch.""" + return await self._async_set_state(ApiStateCommand.ON) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Async function to set off to switch.""" + return await self._async_set_state(ApiStateCommand.OFF) + + async def _async_set_state(self, state): + try: + await self.coordinator.api.set_state(self._device_id, state) + except (SwitchBeeError, SwitchBeeDeviceOfflineError) as exp: + _LOGGER.error( + "Failed to set %s state %s, error: %s", self._attr_name, state, exp + ) + self._async_write_ha_state() + else: + await self.coordinator.async_refresh() diff --git a/homeassistant/components/switchbee/translations/en.json b/homeassistant/components/switchbee/translations/en.json new file mode 100644 index 00000000000..8fee54f3fba --- /dev/null +++ b/homeassistant/components/switchbee/translations/en.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_configured_device": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Failed to Authenticate with the Central Unit", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "description": "Setup SwitchBee integration with Home Assistant.", + "data": { + "host": "Central Unit IP address", + "username": "User (e-mail)", + "password": "Password", + "switch_as_light": "Initialize switches as light entities" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "devices": "Devices to include" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 2b78c6888d4..22425231288 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -372,6 +372,7 @@ FLOWS = { "subaru", "sun", "surepetcare", + "switchbee", "switchbot", "switcher_kis", "syncthing", diff --git a/requirements_all.txt b/requirements_all.txt index 6c310e55e55..1c7129153d7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1908,6 +1908,9 @@ pystiebeleltron==0.0.1.dev2 # homeassistant.components.suez_water pysuez==0.1.19 +# homeassistant.components.switchbee +pyswitchbee==1.4.7 + # homeassistant.components.syncthru pysyncthru==0.7.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 769c5ffcaa9..ddac1964828 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1334,6 +1334,9 @@ pyspcwebgw==0.4.0 # homeassistant.components.squeezebox pysqueezebox==0.6.0 +# homeassistant.components.switchbee +pyswitchbee==1.4.7 + # homeassistant.components.syncthru pysyncthru==0.7.10 diff --git a/tests/components/switchbee/__init__.py b/tests/components/switchbee/__init__.py new file mode 100644 index 00000000000..5043a70c35c --- /dev/null +++ b/tests/components/switchbee/__init__.py @@ -0,0 +1,141 @@ +"""Tests for the SwitchBee Smart Home integration.""" + +MOCK_GET_CONFIGURATION = { + "status": "OK", + "data": { + "mac": "A8-21-08-E7-67-B6", + "name": "Residence", + "version": "1.4.4(4)", + "lastConfChange": 1661856874511, + "zones": [ + { + "name": "Sensor Setting", + "items": [ + { + "id": 200000, + "name": "home", + "hw": "VIRTUAL", + "type": "ALARM_SYSTEM", + }, + { + "id": 200010, + "name": "away", + "hw": "VIRTUAL", + "type": "ALARM_SYSTEM", + }, + ], + }, + { + "name": "General", + "items": [ + { + "operations": [113], + "id": 100080, + "name": "All Lights", + "hw": "VIRTUAL", + "type": "GROUP_SWITCH", + }, + { + "operations": [ + {"itemId": 21, "value": 100}, + {"itemId": 333, "value": 100}, + ], + "id": 100160, + "name": "Sunrise", + "hw": "VIRTUAL", + "type": "SCENARIO", + }, + ], + }, + { + "name": "Entrance", + "items": [ + { + "id": 113, + "name": "Staircase Lights", + "hw": "DIMMABLE_SWITCH", + "type": "TIMED_SWITCH", + }, + { + "id": 222, + "name": "Front Door", + "hw": "REGULAR_SWITCH", + "type": "TIMED_SWITCH", + }, + ], + }, + { + "name": "Kitchen", + "items": [ + {"id": 21, "name": "Shutter ", "hw": "SHUTTER", "type": "SHUTTER"}, + { + "operations": [593, 581, 171], + "id": 481, + "name": "Leds", + "hw": "DIMMABLE_SWITCH", + "type": "GROUP_SWITCH", + }, + { + "id": 12, + "name": "Walls", + "hw": "DIMMABLE_SWITCH", + "type": "DIMMER", + }, + ], + }, + { + "name": "Two Way Zone", + "items": [ + { + "operations": [113], + "id": 72, + "name": "Staircase Lights", + "hw": "DIMMABLE_SWITCH", + "type": "TWO_WAY", + } + ], + }, + { + "name": "Facilities ", + "items": [ + { + "id": 321, + "name": "Boiler", + "hw": "TIMED_POWER_SWITCH", + "type": "TIMED_POWER", + }, + { + "modes": ["COOL", "HEAT", "FAN"], + "temperatureUnits": "CELSIUS", + "id": 271, + "name": "HVAC", + "hw": "THERMOSTAT", + "type": "THERMOSTAT", + }, + { + "id": 571, + "name": "Repeater", + "hw": "REPEATER", + "type": "REPEATER", + }, + ], + }, + { + "name": "Alarm", + "items": [ + { + "operations": [{"itemId": 113, "value": 100}], + "id": 81, + "name": "Open Home", + "hw": "STIKER_SWITCH", + "type": "SCENARIO", + } + ], + }, + ], + }, +} +MOCK_FAILED_TO_LOGIN_MSG = ( + "Central Unit replied with failure: {'status': 'LOGIN_FAILED'}" +) +MOCK_INVALID_TOKEN_MGS = "Error fetching switchbee data: Error communicating with API: data Request failed due to INVALID_TOKEN, trying to re-login" diff --git a/tests/components/switchbee/test_config_flow.py b/tests/components/switchbee/test_config_flow.py new file mode 100644 index 00000000000..541259853a3 --- /dev/null +++ b/tests/components/switchbee/test_config_flow.py @@ -0,0 +1,198 @@ +"""Test the SwitchBee Smart Home config flow.""" +from unittest.mock import patch + +from homeassistant import config_entries +from homeassistant.components.switchbee.config_flow import DeviceType, SwitchBeeError +from homeassistant.components.switchbee.const import CONF_SWITCHES_AS_LIGHTS, DOMAIN +from homeassistant.const import CONF_DEVICES, CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import RESULT_TYPE_FORM, FlowResultType + +from . import MOCK_FAILED_TO_LOGIN_MSG, MOCK_GET_CONFIGURATION, MOCK_INVALID_TOKEN_MGS + +from tests.common import MockConfigEntry + + +async def test_form(hass): + """Test we get the form.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + with patch( + "switchbee.api.CentralUnitAPI.get_configuration", + return_value=MOCK_GET_CONFIGURATION, + ), patch( + "homeassistant.components.switchbee.async_setup_entry", + return_value=True, + ), patch( + "switchbee.api.CentralUnitAPI.fetch_states", return_value=None + ), patch( + "switchbee.api.CentralUnitAPI._login", return_value=None + ): + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_SWITCHES_AS_LIGHTS: False, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "1.1.1.1" + assert result2["data"] == { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_SWITCHES_AS_LIGHTS: False, + } + + +async def test_form_invalid_auth(hass: HomeAssistant) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "switchbee.api.CentralUnitAPI._login", + side_effect=SwitchBeeError(MOCK_FAILED_TO_LOGIN_MSG), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_SWITCHES_AS_LIGHTS: False, + }, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_cannot_connect(hass: HomeAssistant) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "switchbee.api.CentralUnitAPI._login", + side_effect=SwitchBeeError(MOCK_INVALID_TOKEN_MGS), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_SWITCHES_AS_LIGHTS: False, + }, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_unknown_error(hass): + """Test we handle an unknown error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "switchbee.api.CentralUnitAPI._login", + side_effect=Exception, + ): + form_result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_SWITCHES_AS_LIGHTS: False, + }, + ) + + assert form_result["type"] == RESULT_TYPE_FORM + assert form_result["errors"] == {"base": "unknown"} + + +async def test_form_entry_exists(hass): + """Test we handle an already existing entry.""" + MockConfigEntry( + unique_id="a8:21:08:e7:67:b6", + domain=DOMAIN, + data={ + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_SWITCHES_AS_LIGHTS: False, + }, + title="1.1.1.1", + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch("switchbee.api.CentralUnitAPI._login", return_value=None), patch( + "homeassistant.components.switchbee.async_setup_entry", + return_value=True, + ), patch( + "switchbee.api.CentralUnitAPI.get_configuration", + return_value=MOCK_GET_CONFIGURATION, + ), patch( + "switchbee.api.CentralUnitAPI.fetch_states", return_value=None + ): + form_result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.2.2.2", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_SWITCHES_AS_LIGHTS: False, + }, + ) + + assert form_result["type"] == FlowResultType.ABORT + assert form_result["reason"] == "already_configured" + + +async def test_option_flow(hass): + """Test config flow options.""" + entry = MockConfigEntry( + unique_id="a8:21:08:e7:67:b6", + domain=DOMAIN, + data={ + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_SWITCHES_AS_LIGHTS: False, + }, + title="1.1.1.1", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] == "form" + assert result["step_id"] == "init" + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_DEVICES: [DeviceType.Switch.display, DeviceType.GroupSwitch.display], + }, + ) + assert result["type"] == "create_entry" + assert result["data"] == { + CONF_DEVICES: [DeviceType.Switch.display, DeviceType.GroupSwitch.display], + }