diff --git a/.coveragerc b/.coveragerc index 320bdc78c5d..6d7363c3d04 100644 --- a/.coveragerc +++ b/.coveragerc @@ -992,8 +992,6 @@ omit = homeassistant/components/swiss_public_transport/sensor.py homeassistant/components/swisscom/device_tracker.py homeassistant/components/switchbot/switch.py - homeassistant/components/switcher_kis/sensor.py - homeassistant/components/switcher_kis/switch.py homeassistant/components/switchmate/switch.py homeassistant/components/syncthing/__init__.py homeassistant/components/syncthing/sensor.py diff --git a/.strict-typing b/.strict-typing index 1a12b1c5299..981e3872eb5 100644 --- a/.strict-typing +++ b/.strict-typing @@ -79,6 +79,7 @@ homeassistant.components.ssdp.* homeassistant.components.stream.* homeassistant.components.sun.* homeassistant.components.switch.* +homeassistant.components.switcher_kis.* homeassistant.components.synology_dsm.* homeassistant.components.systemmonitor.* homeassistant.components.tag.* diff --git a/homeassistant/components/switcher_kis/__init__.py b/homeassistant/components/switcher_kis/__init__.py index ef196220656..6c13067cd7f 100644 --- a/homeassistant/components/switcher_kis/__init__.py +++ b/homeassistant/components/switcher_kis/__init__.py @@ -1,85 +1,181 @@ """The Switcher integration.""" from __future__ import annotations -from asyncio import QueueEmpty, TimeoutError as Asyncio_TimeoutError, wait_for -from datetime import datetime, timedelta +from datetime import timedelta import logging -from aioswitcher.bridge import SwitcherV2Bridge +from aioswitcher.device import SwitcherBase import voluptuous as vol +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_DEVICE_ID, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.discovery import async_load_platform +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.helpers import ( + config_validation as cv, + device_registry, + update_coordinator, +) from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.typing import EventType from .const import ( CONF_DEVICE_PASSWORD, CONF_PHONE_ID, DATA_DEVICE, + DATA_DISCOVERY, DOMAIN, - SIGNAL_SWITCHER_DEVICE_UPDATE, + MAX_UPDATE_INTERVAL_SEC, + SIGNAL_DEVICE_ADD, ) +from .utils import async_start_bridge, async_stop_bridge + +PLATFORMS = ["switch", "sensor"] _LOGGER = logging.getLogger(__name__) -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_PHONE_ID): cv.string, - vol.Required(CONF_DEVICE_ID): cv.string, - vol.Required(CONF_DEVICE_PASSWORD): cv.string, - } - ) - }, +CCONFIG_SCHEMA = vol.Schema( + vol.All( + cv.deprecated(DOMAIN), + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_PHONE_ID): cv.string, + vol.Required(CONF_DEVICE_ID): cv.string, + vol.Required(CONF_DEVICE_PASSWORD): cv.string, + } + ) + }, + ), extra=vol.ALLOW_EXTRA, ) async def async_setup(hass: HomeAssistant, config: dict) -> bool: """Set up the switcher component.""" - phone_id = config[DOMAIN][CONF_PHONE_ID] - device_id = config[DOMAIN][CONF_DEVICE_ID] - device_password = config[DOMAIN][CONF_DEVICE_PASSWORD] + hass.data.setdefault(DOMAIN, {}) - v2bridge = SwitcherV2Bridge(hass.loop, phone_id, device_id, device_password) + if DOMAIN not in config: + return True - await v2bridge.start() + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data={} + ) + ) + return True - async def async_stop_bridge(event: EventType) -> None: - """On Home Assistant stop, gracefully stop the bridge if running.""" - await v2bridge.stop() - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop_bridge) - - try: - device_data = await wait_for(v2bridge.queue.get(), timeout=10.0) - except (Asyncio_TimeoutError, RuntimeError): - _LOGGER.exception("Failed to get response from device") - await v2bridge.stop() - return False - hass.data[DOMAIN] = {DATA_DEVICE: device_data} - - hass.async_create_task(async_load_platform(hass, "switch", DOMAIN, {}, config)) - hass.async_create_task(async_load_platform(hass, "sensor", DOMAIN, {}, config)) +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Switcher from a config entry.""" + hass.data[DOMAIN][DATA_DEVICE] = {} @callback - def device_updates(timestamp: datetime | None) -> None: - """Use for updating the device data from the queue.""" - if v2bridge.running: - try: - device_new_data = v2bridge.queue.get_nowait() - if device_new_data: - async_dispatcher_send( - hass, SIGNAL_SWITCHER_DEVICE_UPDATE, device_new_data - ) - except QueueEmpty: - pass + def on_device_data_callback(device: SwitcherBase) -> None: + """Use as a callback for device data.""" - async_track_time_interval(hass, device_updates, timedelta(seconds=4)) + # Existing device update device data + if device.device_id in hass.data[DOMAIN][DATA_DEVICE]: + wrapper: SwitcherDeviceWrapper = hass.data[DOMAIN][DATA_DEVICE][ + device.device_id + ] + wrapper.async_set_updated_data(device) + return + + # New device - create device + _LOGGER.info( + "Discovered Switcher device - id: %s, name: %s, type: %s (%s)", + device.device_id, + device.name, + device.device_type.value, + device.device_type.hex_rep, + ) + + wrapper = hass.data[DOMAIN][DATA_DEVICE][ + device.device_id + ] = SwitcherDeviceWrapper(hass, entry, device) + hass.async_create_task(wrapper.async_setup()) + + async def platforms_setup_task() -> None: + # Must be ready before dispatcher is called + for platform in PLATFORMS: + await hass.config_entries.async_forward_entry_setup(entry, platform) + + discovery_task = hass.data[DOMAIN].pop(DATA_DISCOVERY, None) + if discovery_task is not None: + discovered_devices = await discovery_task + for device in discovered_devices.values(): + on_device_data_callback(device) + + await async_start_bridge(hass, on_device_data_callback) + + hass.async_create_task(platforms_setup_task()) + + @callback + async def stop_bridge(event: Event) -> None: + await async_stop_bridge(hass) + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_bridge) return True + + +class SwitcherDeviceWrapper(update_coordinator.DataUpdateCoordinator): + """Wrapper for a Switcher device with Home Assistant specific functions.""" + + def __init__( + self, hass: HomeAssistant, entry: ConfigEntry, device: SwitcherBase + ) -> None: + """Initialize the Switcher device wrapper.""" + super().__init__( + hass, + _LOGGER, + name=device.name, + update_interval=timedelta(seconds=MAX_UPDATE_INTERVAL_SEC), + ) + self.hass = hass + self.entry = entry + self.data = device + + async def _async_update_data(self) -> None: + """Mark device offline if no data.""" + raise update_coordinator.UpdateFailed( + f"Device {self.name} did not send update for {MAX_UPDATE_INTERVAL_SEC} seconds" + ) + + @property + def model(self) -> str: + """Switcher device model.""" + return self.data.device_type.value # type: ignore[no-any-return] + + @property + def device_id(self) -> str: + """Switcher device id.""" + return self.data.device_id # type: ignore[no-any-return] + + @property + def mac_address(self) -> str: + """Switcher device mac address.""" + return self.data.mac_address # type: ignore[no-any-return] + + async def async_setup(self) -> None: + """Set up the wrapper.""" + dev_reg = await device_registry.async_get_registry(self.hass) + dev_reg.async_get_or_create( + config_entry_id=self.entry.entry_id, + connections={(device_registry.CONNECTION_NETWORK_MAC, self.mac_address)}, + identifiers={(DOMAIN, self.device_id)}, + manufacturer="Switcher", + name=self.name, + model=self.model, + ) + async_dispatcher_send(self.hass, SIGNAL_DEVICE_ADD, self) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + await async_stop_bridge(hass) + + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(DATA_DEVICE) + + return unload_ok diff --git a/homeassistant/components/switcher_kis/config_flow.py b/homeassistant/components/switcher_kis/config_flow.py new file mode 100644 index 00000000000..3c758715205 --- /dev/null +++ b/homeassistant/components/switcher_kis/config_flow.py @@ -0,0 +1,49 @@ +"""Config flow for Switcher integration.""" +from __future__ import annotations + +from typing import Any + +from homeassistant import config_entries +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.typing import ConfigType + +from .const import DATA_DISCOVERY, DOMAIN +from .utils import async_discover_devices + + +class SwitcherFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle Switcher config flow.""" + + async def async_step_import(self, import_config: ConfigType) -> FlowResult: + """Handle a flow initiated by import.""" + if self._async_current_entries(True): + return self.async_abort(reason="single_instance_allowed") + + return self.async_create_entry(title="Switcher", data={}) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the start of the config flow.""" + if self._async_current_entries(True): + return self.async_abort(reason="single_instance_allowed") + + self.hass.data.setdefault(DOMAIN, {}) + if DATA_DISCOVERY not in self.hass.data[DOMAIN]: + self.hass.data[DOMAIN][DATA_DISCOVERY] = self.hass.async_create_task( + async_discover_devices() + ) + + return self.async_show_form(step_id="confirm") + + async def async_step_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle user-confirmation of the config flow.""" + discovered_devices = await self.hass.data[DOMAIN][DATA_DISCOVERY] + + if len(discovered_devices) == 0: + self.hass.data[DOMAIN].pop(DATA_DISCOVERY) + return self.async_abort(reason="no_devices_found") + + return self.async_create_entry(title="Switcher", data={}) diff --git a/homeassistant/components/switcher_kis/const.py b/homeassistant/components/switcher_kis/const.py index acd6c070337..88b6e447446 100644 --- a/homeassistant/components/switcher_kis/const.py +++ b/homeassistant/components/switcher_kis/const.py @@ -1,20 +1,22 @@ """Constants for the Switcher integration.""" - DOMAIN = "switcher_kis" CONF_DEVICE_PASSWORD = "device_password" CONF_PHONE_ID = "phone_id" +DATA_BRIDGE = "bridge" DATA_DEVICE = "device" +DATA_DISCOVERY = "discovery" -SIGNAL_SWITCHER_DEVICE_UPDATE = "switcher_device_update" +DISCOVERY_TIME_SEC = 6 -ATTR_AUTO_OFF_SET = "auto_off_set" -ATTR_ELECTRIC_CURRENT = "electric_current" -ATTR_REMAINING_TIME = "remaining_time" +SIGNAL_DEVICE_ADD = "switcher_device_add" +# Services CONF_AUTO_OFF = "auto_off" CONF_TIMER_MINUTES = "timer_minutes" - SERVICE_SET_AUTO_OFF_NAME = "set_auto_off" SERVICE_TURN_ON_WITH_TIMER_NAME = "turn_on_with_timer" + +# Defines the maximum interval device must send an update before it marked unavailable +MAX_UPDATE_INTERVAL_SEC = 20 diff --git a/homeassistant/components/switcher_kis/manifest.json b/homeassistant/components/switcher_kis/manifest.json index 84527954a2d..e982855e497 100644 --- a/homeassistant/components/switcher_kis/manifest.json +++ b/homeassistant/components/switcher_kis/manifest.json @@ -3,6 +3,7 @@ "name": "Switcher", "documentation": "https://www.home-assistant.io/integrations/switcher_kis/", "codeowners": ["@tomerfi","@thecode"], - "requirements": ["aioswitcher==1.2.3"], - "iot_class": "local_push" + "requirements": ["aioswitcher==2.0.4"], + "iot_class": "local_push", + "config_flow": true } diff --git a/homeassistant/components/switcher_kis/sensor.py b/homeassistant/components/switcher_kis/sensor.py index 2325d382b56..58a32e69154 100644 --- a/homeassistant/components/switcher_kis/sensor.py +++ b/homeassistant/components/switcher_kis/sensor.py @@ -3,8 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from aioswitcher.consts import WAITING_TEXT -from aioswitcher.devices import SwitcherV2Device +from aioswitcher.device import DeviceCategory from homeassistant.components.sensor import ( DEVICE_CLASS_CURRENT, @@ -12,13 +11,17 @@ from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, SensorEntity, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ELECTRICAL_CURRENT_AMPERE, POWER_WATT from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import DiscoveryInfoType, StateType +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DATA_DEVICE, DOMAIN, SIGNAL_SWITCHER_DEVICE_UPDATE +from . import SwitcherDeviceWrapper +from .const import SIGNAL_DEVICE_ADD @dataclass @@ -31,7 +34,6 @@ class AttributeDescription: device_class: str | None = None state_class: str | None = None default_enabled: bool = True - default_value: float | int | str | None = None POWER_SENSORS = { @@ -40,14 +42,12 @@ POWER_SENSORS = { unit=POWER_WATT, device_class=DEVICE_CLASS_POWER, state_class=STATE_CLASS_MEASUREMENT, - default_value=0, ), "electric_current": AttributeDescription( name="Electric Current", unit=ELECTRICAL_CURRENT_AMPERE, device_class=DEVICE_CLASS_CURRENT, state_class=STATE_CLASS_MEASUREMENT, - default_value=0.0, ), } @@ -55,77 +55,73 @@ TIME_SENSORS = { "remaining_time": AttributeDescription( name="Remaining Time", icon="mdi:av-timer", - default_value="00:00:00", ), "auto_off_set": AttributeDescription( name="Auto Shutdown", icon="mdi:progress-clock", default_enabled=False, - default_value="00:00:00", ), } -SENSORS = {**POWER_SENSORS, **TIME_SENSORS} +POWER_PLUG_SENSORS = POWER_SENSORS +WATER_HEATER_SENSORS = {**POWER_SENSORS, **TIME_SENSORS} -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: dict, + config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType, ) -> None: """Set up Switcher sensor from config entry.""" - device_data = hass.data[DOMAIN][DATA_DEVICE] - async_add_entities( - SwitcherSensorEntity(device_data, attribute, sensor) - for attribute, sensor in SENSORS.items() + @callback + def async_add_sensors(wrapper: SwitcherDeviceWrapper) -> None: + """Add sensors from Switcher device.""" + if wrapper.data.device_type.category == DeviceCategory.POWER_PLUG: + async_add_entities( + SwitcherSensorEntity(wrapper, attribute, info) + for attribute, info in POWER_PLUG_SENSORS.items() + ) + elif wrapper.data.device_type.category == DeviceCategory.WATER_HEATER: + async_add_entities( + SwitcherSensorEntity(wrapper, attribute, info) + for attribute, info in WATER_HEATER_SENSORS.items() + ) + + config_entry.async_on_unload( + async_dispatcher_connect(hass, SIGNAL_DEVICE_ADD, async_add_sensors) ) -class SwitcherSensorEntity(SensorEntity): +class SwitcherSensorEntity(CoordinatorEntity, SensorEntity): """Representation of a Switcher sensor entity.""" def __init__( self, - device_data: SwitcherV2Device, + wrapper: SwitcherDeviceWrapper, attribute: str, description: AttributeDescription, ) -> None: """Initialize the entity.""" - self._device_data = device_data + super().__init__(wrapper) + self.wrapper = wrapper self.attribute = attribute - self.description = description # Entity class attributes - self._attr_name = f"{self._device_data.name} {self.description.name}" - self._attr_icon = self.description.icon - self._attr_unit_of_measurement = self.description.unit - self._attr_device_class = self.description.device_class - self._attr_entity_registry_enabled_default = self.description.default_enabled - self._attr_should_poll = False + self._attr_name = f"{wrapper.name} {description.name}" + self._attr_icon = description.icon + self._attr_unit_of_measurement = description.unit + self._attr_device_class = description.device_class + self._attr_entity_registry_enabled_default = description.default_enabled - self._attr_unique_id = f"{self._device_data.device_id}-{self._device_data.mac_addr}-{self.attribute}" + self._attr_unique_id = f"{wrapper.device_id}-{wrapper.mac_address}-{attribute}" + self._attr_device_info = { + "connections": { + (device_registry.CONNECTION_NETWORK_MAC, wrapper.mac_address) + } + } @property def state(self) -> StateType: """Return value of sensor.""" - value = getattr(self._device_data, self.attribute) - if value and value is not WAITING_TEXT: - return value - - return self.description.default_value - - async def async_added_to_hass(self) -> None: - """Run when entity about to be added to hass.""" - self.async_on_remove( - async_dispatcher_connect( - self.hass, SIGNAL_SWITCHER_DEVICE_UPDATE, self.async_update_data - ) - ) - - @callback - def async_update_data(self, device_data: SwitcherV2Device) -> None: - """Update the entity data.""" - self._device_data = device_data - self.async_write_ha_state() + return getattr(self.wrapper.data, self.attribute) # type: ignore[no-any-return] diff --git a/homeassistant/components/switcher_kis/services.yaml b/homeassistant/components/switcher_kis/services.yaml index b4b2728fc2e..752b7f3de4c 100644 --- a/homeassistant/components/switcher_kis/services.yaml +++ b/homeassistant/components/switcher_kis/services.yaml @@ -5,6 +5,7 @@ set_auto_off: entity: integration: switcher_kis domain: switch + device_class: switch fields: auto_off: name: Auto off @@ -21,6 +22,7 @@ turn_on_with_timer: entity: integration: switcher_kis domain: switch + device_class: switch fields: timer_minutes: name: Timer diff --git a/homeassistant/components/switcher_kis/strings.json b/homeassistant/components/switcher_kis/strings.json new file mode 100644 index 00000000000..9f3518bcf8d --- /dev/null +++ b/homeassistant/components/switcher_kis/strings.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "confirm": { + "description": "[%key:common::config_flow::description::confirm_setup%]" + } + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switcher_kis/switch.py b/homeassistant/components/switcher_kis/switch.py index 21ebcf54cc7..c36fd0c208e 100644 --- a/homeassistant/components/switcher_kis/switch.py +++ b/homeassistant/components/switcher_kis/switch.py @@ -1,33 +1,42 @@ -"""Home Assistant Switcher Component Switch platform.""" +"""Switcher integration Switch platform.""" from __future__ import annotations -from aioswitcher.api import SwitcherV2Api -from aioswitcher.api.messages import SwitcherV2ControlResponseMSG -from aioswitcher.consts import ( - COMMAND_OFF, - COMMAND_ON, - STATE_OFF as SWITCHER_STATE_OFF, - STATE_ON as SWITCHER_STATE_ON, -) -from aioswitcher.devices import SwitcherV2Device +import asyncio +from datetime import timedelta +import logging +from typing import Any + +from aioswitcher.api import Command, SwitcherApi, SwitcherBaseResponse +from aioswitcher.device import DeviceCategory, DeviceState import voluptuous as vol -from homeassistant.components.switch import SwitchEntity -from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import config_validation as cv, entity_platform +from homeassistant.components.switch import ( + DEVICE_CLASS_OUTLET, + DEVICE_CLASS_SWITCH, + SwitchEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import ( + config_validation as cv, + device_registry, + entity_platform, +) from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import SwitcherDeviceWrapper from .const import ( CONF_AUTO_OFF, CONF_TIMER_MINUTES, - DATA_DEVICE, - DOMAIN, SERVICE_SET_AUTO_OFF_NAME, SERVICE_TURN_ON_WITH_TIMER_NAME, - SIGNAL_SWITCHER_DEVICE_UPDATE, + SIGNAL_DEVICE_ADD, ) +_LOGGER = logging.getLogger(__name__) + SERVICE_SET_AUTO_OFF_SCHEMA = { vol.Required(CONF_AUTO_OFF): cv.time_period_str, } @@ -39,135 +48,142 @@ SERVICE_TURN_ON_WITH_TIMER_SCHEMA = { } -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: dict, + config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: dict, ) -> None: - """Set up the switcher platform for the switch component.""" - if discovery_info is None: - return - - async def async_set_auto_off_service(entity, service_call: ServiceCall) -> None: - """Use for handling setting device auto-off service calls.""" - async with SwitcherV2Api( - hass.loop, - device_data.ip_addr, - device_data.phone_id, - device_data.device_id, - device_data.device_password, - ) as swapi: - await swapi.set_auto_shutdown(service_call.data[CONF_AUTO_OFF]) - - async def async_turn_on_with_timer_service( - entity, service_call: ServiceCall - ) -> None: - """Use for handling turning device on with a timer service calls.""" - async with SwitcherV2Api( - hass.loop, - device_data.ip_addr, - device_data.phone_id, - device_data.device_id, - device_data.device_password, - ) as swapi: - await swapi.control_device( - COMMAND_ON, service_call.data[CONF_TIMER_MINUTES] - ) - - device_data = hass.data[DOMAIN][DATA_DEVICE] - async_add_entities([SwitcherControl(hass.data[DOMAIN][DATA_DEVICE])]) - + """Set up Switcher switch from config entry.""" platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( SERVICE_SET_AUTO_OFF_NAME, SERVICE_SET_AUTO_OFF_SCHEMA, - async_set_auto_off_service, + "async_set_auto_off_service", ) platform.async_register_entity_service( SERVICE_TURN_ON_WITH_TIMER_NAME, SERVICE_TURN_ON_WITH_TIMER_SCHEMA, - async_turn_on_with_timer_service, + "async_turn_on_with_timer_service", + ) + + @callback + def async_add_switch(wrapper: SwitcherDeviceWrapper) -> None: + """Add switch from Switcher device.""" + if wrapper.data.device_type.category == DeviceCategory.POWER_PLUG: + async_add_entities([SwitcherPowerPlugSwitchEntity(wrapper)]) + elif wrapper.data.device_type.category == DeviceCategory.WATER_HEATER: + async_add_entities([SwitcherWaterHeaterSwitchEntity(wrapper)]) + + config_entry.async_on_unload( + async_dispatcher_connect(hass, SIGNAL_DEVICE_ADD, async_add_switch) ) -class SwitcherControl(SwitchEntity): - """Home Assistant switch entity.""" +class SwitcherBaseSwitchEntity(CoordinatorEntity, SwitchEntity): + """Representation of a Switcher switch entity.""" - def __init__(self, device_data: SwitcherV2Device) -> None: + def __init__(self, wrapper: SwitcherDeviceWrapper) -> None: """Initialize the entity.""" - self._self_initiated = False - self._device_data = device_data - self._state = device_data.state + super().__init__(wrapper) + self.wrapper = wrapper + self.control_result: bool | None = None - @property - def name(self) -> str: - """Return the device's name.""" - return self._device_data.name + # Entity class attributes + self._attr_name = wrapper.name + self._attr_unique_id = f"{wrapper.device_id}-{wrapper.mac_address}" + self._attr_device_info = { + "connections": { + (device_registry.CONNECTION_NETWORK_MAC, wrapper.mac_address) + } + } - @property - def should_poll(self) -> bool: - """Return False, entity pushes its state to HA.""" - return False + @callback + def _handle_coordinator_update(self) -> None: + """When device updates, clear control result that overrides state.""" + self.control_result = None + self.async_write_ha_state() - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return f"{self._device_data.device_id}-{self._device_data.mac_addr}" + async def _async_call_api(self, api: str, *args: Any) -> None: + """Call Switcher API.""" + _LOGGER.debug("Calling api for %s, api: '%s', args: %s", self.name, api, args) + response: SwitcherBaseResponse = None + error = None + + try: + async with SwitcherApi( + self.wrapper.data.ip_address, self.wrapper.device_id + ) as swapi: + response = await getattr(swapi, api)(*args) + except (asyncio.TimeoutError, OSError, RuntimeError) as err: + error = repr(err) + + if error or not response or not response.successful: + _LOGGER.error( + "Call api for %s failed, api: '%s', args: %s, response/error: %s", + self.name, + api, + args, + response or error, + ) + self.wrapper.last_update_success = False @property def is_on(self) -> bool: """Return True if entity is on.""" - return self._state == SWITCHER_STATE_ON + if self.control_result is not None: + return self.control_result - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._state in [SWITCHER_STATE_ON, SWITCHER_STATE_OFF] - - async def async_added_to_hass(self) -> None: - """Run when entity about to be added to hass.""" - self.async_on_remove( - async_dispatcher_connect( - self.hass, SIGNAL_SWITCHER_DEVICE_UPDATE, self.async_update_data - ) - ) - - @callback - def async_update_data(self, device_data: SwitcherV2Device) -> None: - """Update the entity data.""" - if self._self_initiated: - self._self_initiated = False - else: - self._device_data = device_data - self._state = self._device_data.state - self.async_write_ha_state() + return bool(self.wrapper.data.device_state == DeviceState.ON) async def async_turn_on(self, **kwargs: dict) -> None: """Turn the entity on.""" - await self._control_device(True) + await self._async_call_api("control_device", Command.ON) + self.control_result = True + self.async_write_ha_state() async def async_turn_off(self, **kwargs: dict) -> None: """Turn the entity off.""" - await self._control_device(False) + await self._async_call_api("control_device", Command.OFF) + self.control_result = False + self.async_write_ha_state() - async def _control_device(self, send_on: bool) -> None: - """Turn the entity on or off.""" - response: SwitcherV2ControlResponseMSG = None - async with SwitcherV2Api( - self.hass.loop, - self._device_data.ip_addr, - self._device_data.phone_id, - self._device_data.device_id, - self._device_data.device_password, - ) as swapi: - response = await swapi.control_device( - COMMAND_ON if send_on else COMMAND_OFF - ) + async def async_set_auto_off_service(self, auto_off: timedelta) -> None: + """Use for handling setting device auto-off service calls.""" + _LOGGER.warning( + "Service '%s' is not supported by %s", + SERVICE_SET_AUTO_OFF_NAME, + self.name, + ) - if response and response.successful: - self._self_initiated = True - self._state = SWITCHER_STATE_ON if send_on else SWITCHER_STATE_OFF - self.async_write_ha_state() + async def async_turn_on_with_timer_service(self, timer_minutes: int) -> None: + """Use for turning device on with a timer service calls.""" + _LOGGER.warning( + "Service '%s' is not supported by %s", + SERVICE_TURN_ON_WITH_TIMER_NAME, + self.name, + ) + + +class SwitcherPowerPlugSwitchEntity(SwitcherBaseSwitchEntity): + """Representation of a Switcher power plug switch entity.""" + + _attr_device_class = DEVICE_CLASS_OUTLET + + +class SwitcherWaterHeaterSwitchEntity(SwitcherBaseSwitchEntity): + """Representation of a Switcher water heater switch entity.""" + + _attr_device_class = DEVICE_CLASS_SWITCH + + async def async_set_auto_off_service(self, auto_off: timedelta) -> None: + """Use for handling setting device auto-off service calls.""" + await self._async_call_api("set_auto_shutdown", auto_off) + self.async_write_ha_state() + + async def async_turn_on_with_timer_service(self, timer_minutes: int) -> None: + """Use for turning device on with a timer service calls.""" + await self._async_call_api("control_device", Command.ON, timer_minutes) + self.control_result = True + self.async_write_ha_state() diff --git a/homeassistant/components/switcher_kis/translations/en.json b/homeassistant/components/switcher_kis/translations/en.json new file mode 100644 index 00000000000..f05becffed3 --- /dev/null +++ b/homeassistant/components/switcher_kis/translations/en.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "No devices found on the network", + "single_instance_allowed": "Already configured. Only a single configuration possible." + }, + "step": { + "confirm": { + "description": "Do you want to start set up?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switcher_kis/utils.py b/homeassistant/components/switcher_kis/utils.py new file mode 100644 index 00000000000..b2cc45cf67c --- /dev/null +++ b/homeassistant/components/switcher_kis/utils.py @@ -0,0 +1,54 @@ +"""Switcher integration helpers functions.""" +from __future__ import annotations + +import asyncio +import logging +from typing import Any, Callable + +from aioswitcher.bridge import SwitcherBase, SwitcherBridge + +from homeassistant.core import HomeAssistant, callback + +from .const import DATA_BRIDGE, DISCOVERY_TIME_SEC, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_start_bridge( + hass: HomeAssistant, on_device_callback: Callable[[SwitcherBase], Any] +) -> None: + """Start switcher UDP bridge.""" + bridge = hass.data[DOMAIN][DATA_BRIDGE] = SwitcherBridge(on_device_callback) + _LOGGER.debug("Starting Switcher bridge") + await bridge.start() + + +async def async_stop_bridge(hass: HomeAssistant) -> None: + """Stop switcher UDP bridge.""" + bridge: SwitcherBridge = hass.data[DOMAIN].get(DATA_BRIDGE) + if bridge is not None: + _LOGGER.debug("Stopping Switcher bridge") + await bridge.stop() + hass.data[DOMAIN].pop(DATA_BRIDGE) + + +async def async_discover_devices() -> dict[str, SwitcherBase]: + """Discover Switcher devices.""" + _LOGGER.debug("Starting discovery") + discovered_devices = {} + + @callback + def on_device_data_callback(device: SwitcherBase) -> None: + """Use as a callback for device data.""" + if device.device_id in discovered_devices: + return + + discovered_devices[device.device_id] = device + + bridge = SwitcherBridge(on_device_data_callback) + await bridge.start() + await asyncio.sleep(DISCOVERY_TIME_SEC) + await bridge.stop() + + _LOGGER.debug("Finished discovery, discovered devices: %s", len(discovered_devices)) + return discovered_devices diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index e71503ce5fc..b04d4f50dd8 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -251,6 +251,7 @@ FLOWS = [ "srp_energy", "starline", "subaru", + "switcher_kis", "syncthing", "syncthru", "synology_dsm", diff --git a/mypy.ini b/mypy.ini index ac94b19c446..6245144f54f 100644 --- a/mypy.ini +++ b/mypy.ini @@ -880,6 +880,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.switcher_kis.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.synology_dsm.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1539,9 +1550,6 @@ ignore_errors = true [mypy-homeassistant.components.switchbot.*] ignore_errors = true -[mypy-homeassistant.components.switcher_kis.*] -ignore_errors = true - [mypy-homeassistant.components.synology_srm.*] ignore_errors = true diff --git a/requirements_all.txt b/requirements_all.txt index 3a591583911..516885a9bb0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -236,7 +236,7 @@ aiorecollect==1.0.5 aioshelly==0.6.4 # homeassistant.components.switcher_kis -aioswitcher==1.2.3 +aioswitcher==2.0.4 # homeassistant.components.syncthing aiosyncthing==0.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fd91ae7b4ae..b1ffcb18d5d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -158,7 +158,7 @@ aiorecollect==1.0.5 aioshelly==0.6.4 # homeassistant.components.switcher_kis -aioswitcher==1.2.3 +aioswitcher==2.0.4 # homeassistant.components.syncthing aiosyncthing==0.5.1 diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 88106d96c32..f0f7abe7b7e 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -175,7 +175,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.stt.*", "homeassistant.components.surepetcare.*", "homeassistant.components.switchbot.*", - "homeassistant.components.switcher_kis.*", "homeassistant.components.synology_srm.*", "homeassistant.components.system_health.*", "homeassistant.components.system_log.*", diff --git a/tests/components/switcher_kis/__init__.py b/tests/components/switcher_kis/__init__.py index 46fbe073ab0..671af5e11b9 100644 --- a/tests/components/switcher_kis/__init__.py +++ b/tests/components/switcher_kis/__init__.py @@ -1 +1,16 @@ """Test cases and object for the Switcher integration tests.""" +from homeassistant.components.switcher_kis.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def init_integration(hass: HomeAssistant) -> MockConfigEntry: + """Set up the Switcher integration in Home Assistant.""" + entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry diff --git a/tests/components/switcher_kis/conftest.py b/tests/components/switcher_kis/conftest.py index fda5f39922d..3578e3ac6c9 100644 --- a/tests/components/switcher_kis/conftest.py +++ b/tests/components/switcher_kis/conftest.py @@ -1,194 +1,61 @@ """Common fixtures and objects for the Switcher integration tests.""" -from __future__ import annotations +from unittest.mock import AsyncMock, Mock, patch -from asyncio import Queue -from datetime import datetime -from typing import Any, Generator -from unittest.mock import AsyncMock, patch - -from pytest import fixture - -from .consts import ( - DUMMY_AUTO_OFF_SET, - DUMMY_DEVICE_ID, - DUMMY_DEVICE_NAME, - DUMMY_DEVICE_PASSWORD, - DUMMY_DEVICE_STATE, - DUMMY_ELECTRIC_CURRENT, - DUMMY_IP_ADDRESS, - DUMMY_MAC_ADDRESS, - DUMMY_PHONE_ID, - DUMMY_POWER_CONSUMPTION, - DUMMY_REMAINING_TIME, -) +import pytest -@patch("aioswitcher.devices.SwitcherV2Device") -class MockSwitcherV2Device: - """Class for mocking the aioswitcher.devices.SwitcherV2Device object.""" +@pytest.fixture +def mock_bridge(request): + """Return a mocked SwitcherBridge.""" + with patch( + "homeassistant.components.switcher_kis.utils.SwitcherBridge", autospec=True + ) as bridge_mock: + bridge = bridge_mock.return_value - def __init__(self) -> None: - """Initialize the object.""" - self._last_state_change = datetime.now() + bridge.devices = [] + if hasattr(request, "param") and request.param: + bridge.devices = request.param - @property - def device_id(self) -> str: - """Return the device id.""" - return DUMMY_DEVICE_ID + async def start(): + bridge.is_running = True - @property - def ip_addr(self) -> str: - """Return the ip address.""" - return DUMMY_IP_ADDRESS + for device in bridge.devices: + bridge_mock.call_args[0][0](device) - @property - def mac_addr(self) -> str: - """Return the mac address.""" - return DUMMY_MAC_ADDRESS + def mock_callbacks(devices): + for device in devices: + bridge_mock.call_args[0][0](device) - @property - def name(self) -> str: - """Return the device name.""" - return DUMMY_DEVICE_NAME + async def stop(): + bridge.is_running = False - @property - def state(self) -> str: - """Return the device state.""" - return DUMMY_DEVICE_STATE + bridge.start = AsyncMock(side_effect=start) + bridge.mock_callbacks = Mock(side_effect=mock_callbacks) + bridge.stop = AsyncMock(side_effect=stop) - @property - def remaining_time(self) -> str | None: - """Return the time left to auto-off.""" - return DUMMY_REMAINING_TIME - - @property - def auto_off_set(self) -> str: - """Return the auto-off configuration value.""" - return DUMMY_AUTO_OFF_SET - - @property - def power_consumption(self) -> int: - """Return the power consumption in watts.""" - return DUMMY_POWER_CONSUMPTION - - @property - def electric_current(self) -> float: - """Return the power consumption in amps.""" - return DUMMY_ELECTRIC_CURRENT - - @property - def phone_id(self) -> str: - """Return the phone id.""" - return DUMMY_PHONE_ID - - @property - def device_password(self) -> str: - """Return the device password.""" - return DUMMY_DEVICE_PASSWORD - - @property - def last_data_update(self) -> datetime: - """Return the timestamp of the last update.""" - return datetime.now() - - @property - def last_state_change(self) -> datetime: - """Return the timestamp of the state change.""" - return self._last_state_change + yield bridge -@fixture(name="mock_bridge") -def mock_bridge_fixture() -> Generator[None, Any, None]: - """Fixture for mocking aioswitcher.bridge.SwitcherV2Bridge.""" - queue = Queue() - - async def mock_queue(): - """Mock asyncio's Queue.""" - await queue.put(MockSwitcherV2Device()) - return await queue.get() - - mock_bridge = AsyncMock() +@pytest.fixture +def mock_api(): + """Fixture for mocking aioswitcher.api.SwitcherApi.""" + api_mock = AsyncMock() patchers = [ patch( - "homeassistant.components.switcher_kis.SwitcherV2Bridge.start", - new=mock_bridge, + "homeassistant.components.switcher_kis.switch.SwitcherApi.connect", + new=api_mock, ), patch( - "homeassistant.components.switcher_kis.SwitcherV2Bridge.stop", - new=mock_bridge, - ), - patch( - "homeassistant.components.switcher_kis.SwitcherV2Bridge.queue", - get=mock_queue, - ), - patch( - "homeassistant.components.switcher_kis.SwitcherV2Bridge.running", - return_value=True, + "homeassistant.components.switcher_kis.switch.SwitcherApi.disconnect", + new=api_mock, ), ] for patcher in patchers: patcher.start() - yield - - for patcher in patchers: - patcher.stop() - - -@fixture(name="mock_failed_bridge") -def mock_failed_bridge_fixture() -> Generator[None, Any, None]: - """Fixture for mocking aioswitcher.bridge.SwitcherV2Bridge.""" - - async def mock_queue(): - """Mock asyncio's Queue.""" - raise RuntimeError - - patchers = [ - patch( - "homeassistant.components.switcher_kis.SwitcherV2Bridge.start", - return_value=None, - ), - patch( - "homeassistant.components.switcher_kis.SwitcherV2Bridge.stop", - return_value=None, - ), - patch( - "homeassistant.components.switcher_kis.SwitcherV2Bridge.queue", - get=mock_queue, - ), - ] - - for patcher in patchers: - patcher.start() - - yield - - for patcher in patchers: - patcher.stop() - - -@fixture(name="mock_api") -def mock_api_fixture() -> Generator[AsyncMock, Any, None]: - """Fixture for mocking aioswitcher.api.SwitcherV2Api.""" - mock_api = AsyncMock() - - patchers = [ - patch( - "homeassistant.components.switcher_kis.switch.SwitcherV2Api.connect", - new=mock_api, - ), - patch( - "homeassistant.components.switcher_kis.switch.SwitcherV2Api.disconnect", - new=mock_api, - ), - ] - - for patcher in patchers: - patcher.start() - - yield + yield api_mock for patcher in patchers: patcher.stop() diff --git a/tests/components/switcher_kis/consts.py b/tests/components/switcher_kis/consts.py index ab5951710f4..e200d92e026 100644 --- a/tests/components/switcher_kis/consts.py +++ b/tests/components/switcher_kis/consts.py @@ -1,5 +1,12 @@ """Constants for the Switcher integration tests.""" +from aioswitcher.device import ( + DeviceState, + DeviceType, + SwitcherPowerPlug, + SwitcherWaterHeater, +) + from homeassistant.components.switcher_kis import ( CONF_DEVICE_ID, CONF_DEVICE_PASSWORD, @@ -8,27 +15,54 @@ from homeassistant.components.switcher_kis import ( ) DUMMY_AUTO_OFF_SET = "01:30:00" -DUMMY_TIMER_MINUTES_SET = "90" -DUMMY_DEVICE_ID = "a123bc" -DUMMY_DEVICE_NAME = "Device Name" +DUMMY_AUTO_SHUT_DOWN = "02:00:00" +DUMMY_DEVICE_ID1 = "a123bc" +DUMMY_DEVICE_ID2 = "cafe12" +DUMMY_DEVICE_NAME1 = "Plug 23BC" +DUMMY_DEVICE_NAME2 = "Heater FE12" DUMMY_DEVICE_PASSWORD = "12345678" -DUMMY_DEVICE_STATE = "on" -DUMMY_ELECTRIC_CURRENT = 12.8 -DUMMY_ICON = "mdi:dummy-icon" -DUMMY_IP_ADDRESS = "192.168.100.157" -DUMMY_MAC_ADDRESS = "A1:B2:C3:45:67:D8" -DUMMY_NAME = "boiler" +DUMMY_ELECTRIC_CURRENT1 = 0.5 +DUMMY_ELECTRIC_CURRENT2 = 12.8 +DUMMY_IP_ADDRESS1 = "192.168.100.157" +DUMMY_IP_ADDRESS2 = "192.168.100.158" +DUMMY_MAC_ADDRESS1 = "A1:B2:C3:45:67:D8" +DUMMY_MAC_ADDRESS2 = "A1:B2:C3:45:67:D9" DUMMY_PHONE_ID = "1234" -DUMMY_POWER_CONSUMPTION = 2780 +DUMMY_POWER_CONSUMPTION1 = 100 +DUMMY_POWER_CONSUMPTION2 = 2780 DUMMY_REMAINING_TIME = "01:29:32" +DUMMY_TIMER_MINUTES_SET = "90" -# Adjust if any modification were made to DUMMY_DEVICE_NAME -SWITCH_ENTITY_ID = "switch.device_name" - -MANDATORY_CONFIGURATION = { +YAML_CONFIG = { DOMAIN: { CONF_PHONE_ID: DUMMY_PHONE_ID, - CONF_DEVICE_ID: DUMMY_DEVICE_ID, + CONF_DEVICE_ID: DUMMY_DEVICE_ID1, CONF_DEVICE_PASSWORD: DUMMY_DEVICE_PASSWORD, } } + +DUMMY_PLUG_DEVICE = SwitcherPowerPlug( + DeviceType.POWER_PLUG, + DeviceState.ON, + DUMMY_DEVICE_ID1, + DUMMY_IP_ADDRESS1, + DUMMY_MAC_ADDRESS1, + DUMMY_DEVICE_NAME1, + DUMMY_POWER_CONSUMPTION1, + DUMMY_ELECTRIC_CURRENT1, +) + +DUMMY_WATER_HEATER_DEVICE = SwitcherWaterHeater( + DeviceType.V4, + DeviceState.ON, + DUMMY_DEVICE_ID2, + DUMMY_IP_ADDRESS2, + DUMMY_MAC_ADDRESS2, + DUMMY_DEVICE_NAME2, + DUMMY_POWER_CONSUMPTION2, + DUMMY_ELECTRIC_CURRENT2, + DUMMY_REMAINING_TIME, + DUMMY_AUTO_SHUT_DOWN, +) + +DUMMY_SWITCHER_DEVICES = [DUMMY_PLUG_DEVICE, DUMMY_WATER_HEATER_DEVICE] diff --git a/tests/components/switcher_kis/test_config_flow.py b/tests/components/switcher_kis/test_config_flow.py new file mode 100644 index 00000000000..07a2396a0d9 --- /dev/null +++ b/tests/components/switcher_kis/test_config_flow.py @@ -0,0 +1,106 @@ +"""Test the Switcher config flow.""" +from unittest.mock import patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.switcher_kis.const import DATA_DISCOVERY, DOMAIN +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +from .consts import DUMMY_PLUG_DEVICE, DUMMY_WATER_HEATER_DEVICE + +from tests.common import MockConfigEntry + + +async def test_import(hass): + """Test import step.""" + with patch( + "homeassistant.components.switcher_kis.async_setup_entry", return_value=True + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT} + ) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Switcher" + assert result["data"] == {} + + +@pytest.mark.parametrize( + "mock_bridge", + [ + [ + DUMMY_PLUG_DEVICE, + DUMMY_WATER_HEATER_DEVICE, + # Make sure we don't detect the same device twice + DUMMY_WATER_HEATER_DEVICE, + ] + ], + indirect=True, +) +async def test_user_setup(hass, mock_bridge): + """Test we can finish a config flow.""" + with patch("homeassistant.components.switcher_kis.utils.asyncio.sleep"): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert mock_bridge.is_running is False + assert len(hass.data[DOMAIN][DATA_DISCOVERY].result()) == 2 + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "confirm" + assert result["errors"] is None + + with patch( + "homeassistant.components.switcher_kis.async_setup_entry", return_value=True + ): + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "Switcher" + assert result2["result"].data == {} + + +async def test_user_setup_abort_no_devices_found(hass, mock_bridge): + """Test we abort a config flow if no devices found.""" + with patch("homeassistant.components.switcher_kis.utils.asyncio.sleep"): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert mock_bridge.is_running is False + assert len(hass.data[DOMAIN][DATA_DISCOVERY].result()) == 0 + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "confirm" + assert result["errors"] is None + + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result2["type"] == RESULT_TYPE_ABORT + assert result2["reason"] == "no_devices_found" + + +@pytest.mark.parametrize( + "source", + [ + config_entries.SOURCE_IMPORT, + config_entries.SOURCE_USER, + ], +) +async def test_single_instance(hass, source): + """Test we only allow a single config flow.""" + MockConfigEntry(domain=DOMAIN).add_to_hass(hass) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": source} + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "single_instance_allowed" diff --git a/tests/components/switcher_kis/test_init.py b/tests/components/switcher_kis/test_init.py index 14eb2a1a16e..367d215862e 100644 --- a/tests/components/switcher_kis/test_init.py +++ b/tests/components/switcher_kis/test_init.py @@ -1,200 +1,111 @@ """Test cases for the switcher_kis component.""" from datetime import timedelta -from typing import Any, Generator from unittest.mock import patch -from aioswitcher.consts import COMMAND_ON -from aioswitcher.devices import SwitcherV2Device -from pytest import raises +import pytest -from homeassistant.components.switcher_kis import ( +from homeassistant import config_entries +from homeassistant.components.switcher_kis.const import ( DATA_DEVICE, DOMAIN, - SIGNAL_SWITCHER_DEVICE_UPDATE, + MAX_UPDATE_INTERVAL_SEC, ) -from homeassistant.components.switcher_kis.switch import ( - CONF_AUTO_OFF, - CONF_TIMER_MINUTES, - SERVICE_SET_AUTO_OFF_NAME, - SERVICE_TURN_ON_WITH_TIMER_NAME, -) -from homeassistant.const import CONF_ENTITY_ID -from homeassistant.core import Context, HomeAssistant, callback -from homeassistant.exceptions import UnknownUser -from homeassistant.helpers.config_validation import time_period_str -from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.setup import async_setup_component -from homeassistant.util import dt +from homeassistant.util import dt, slugify -from .consts import ( - DUMMY_AUTO_OFF_SET, - DUMMY_DEVICE_ID, - DUMMY_DEVICE_NAME, - DUMMY_DEVICE_STATE, - DUMMY_ELECTRIC_CURRENT, - DUMMY_IP_ADDRESS, - DUMMY_MAC_ADDRESS, - DUMMY_PHONE_ID, - DUMMY_POWER_CONSUMPTION, - DUMMY_REMAINING_TIME, - DUMMY_TIMER_MINUTES_SET, - MANDATORY_CONFIGURATION, - SWITCH_ENTITY_ID, -) +from . import init_integration +from .consts import DUMMY_SWITCHER_DEVICES, YAML_CONFIG -from tests.common import MockUser, async_fire_time_changed +from tests.common import async_fire_time_changed -async def test_failed_config( - hass: HomeAssistant, mock_failed_bridge: Generator[None, Any, None] -) -> None: - """Test failed configuration.""" - assert await async_setup_component(hass, DOMAIN, MANDATORY_CONFIGURATION) is False - - -async def test_minimal_config( - hass: HomeAssistant, mock_bridge: Generator[None, Any, None] -) -> None: - """Test setup with configuration minimal entries.""" - assert await async_setup_component(hass, DOMAIN, MANDATORY_CONFIGURATION) - - -async def test_discovery_data_bucket( - hass: HomeAssistant, mock_bridge: Generator[None, Any, None] -) -> None: - """Test the event send with the updated device.""" - assert await async_setup_component(hass, DOMAIN, MANDATORY_CONFIGURATION) - +@pytest.mark.parametrize("mock_bridge", [DUMMY_SWITCHER_DEVICES], indirect=True) +async def test_async_setup_yaml_config(hass, mock_bridge) -> None: + """Test setup started by configuration from YAML.""" + assert await async_setup_component(hass, DOMAIN, YAML_CONFIG) await hass.async_block_till_done() - device = hass.data[DOMAIN].get(DATA_DEVICE) - assert device.device_id == DUMMY_DEVICE_ID - assert device.ip_addr == DUMMY_IP_ADDRESS - assert device.mac_addr == DUMMY_MAC_ADDRESS - assert device.name == DUMMY_DEVICE_NAME - assert device.state == DUMMY_DEVICE_STATE - assert device.remaining_time == DUMMY_REMAINING_TIME - assert device.auto_off_set == DUMMY_AUTO_OFF_SET - assert device.power_consumption == DUMMY_POWER_CONSUMPTION - assert device.electric_current == DUMMY_ELECTRIC_CURRENT - assert device.phone_id == DUMMY_PHONE_ID + assert mock_bridge.is_running is True + assert len(hass.data[DOMAIN]) == 2 + assert len(hass.data[DOMAIN][DATA_DEVICE]) == 2 -async def test_set_auto_off_service( - hass: HomeAssistant, - mock_bridge: Generator[None, Any, None], - mock_api: Generator[None, Any, None], - hass_owner_user: MockUser, -) -> None: - """Test the set_auto_off service.""" - assert await async_setup_component(hass, DOMAIN, MANDATORY_CONFIGURATION) - +@pytest.mark.parametrize("mock_bridge", [DUMMY_SWITCHER_DEVICES], indirect=True) +async def test_async_setup_user_config_flow(hass, mock_bridge) -> None: + """Test setup started by user config flow.""" + with patch("homeassistant.components.switcher_kis.utils.asyncio.sleep"): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await hass.config_entries.flow.async_configure(result["flow_id"], {}) await hass.async_block_till_done() - assert hass.services.has_service(DOMAIN, SERVICE_SET_AUTO_OFF_NAME) + assert mock_bridge.is_running is True + assert len(hass.data[DOMAIN]) == 2 + assert len(hass.data[DOMAIN][DATA_DEVICE]) == 2 - await hass.services.async_call( - DOMAIN, - SERVICE_SET_AUTO_OFF_NAME, - {CONF_ENTITY_ID: SWITCH_ENTITY_ID, CONF_AUTO_OFF: DUMMY_AUTO_OFF_SET}, - blocking=True, - context=Context(user_id=hass_owner_user.id), + +async def test_update_fail(hass, mock_bridge, caplog): + """Test entities state unavailable when updates fail..""" + await init_integration(hass) + assert mock_bridge + + mock_bridge.mock_callbacks(DUMMY_SWITCHER_DEVICES) + await hass.async_block_till_done() + + assert mock_bridge.is_running is True + assert len(hass.data[DOMAIN]) == 2 + assert len(hass.data[DOMAIN][DATA_DEVICE]) == 2 + + async_fire_time_changed( + hass, dt.utcnow() + timedelta(seconds=MAX_UPDATE_INTERVAL_SEC + 1) + ) + await hass.async_block_till_done() + + for device in DUMMY_SWITCHER_DEVICES: + assert ( + f"Device {device.name} did not send update for {MAX_UPDATE_INTERVAL_SEC} seconds" + in caplog.text + ) + + entity_id = f"switch.{slugify(device.name)}" + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE + + entity_id = f"sensor.{slugify(device.name)}_power_consumption" + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE + + mock_bridge.mock_callbacks(DUMMY_SWITCHER_DEVICES) + await hass.async_block_till_done() + async_fire_time_changed( + hass, dt.utcnow() + timedelta(seconds=MAX_UPDATE_INTERVAL_SEC - 1) ) - with raises(UnknownUser) as unknown_user_exc: - await hass.services.async_call( - DOMAIN, - SERVICE_SET_AUTO_OFF_NAME, - {CONF_ENTITY_ID: SWITCH_ENTITY_ID, CONF_AUTO_OFF: DUMMY_AUTO_OFF_SET}, - blocking=True, - context=Context(user_id="not_real_user"), - ) + for device in DUMMY_SWITCHER_DEVICES: + entity_id = f"switch.{slugify(device.name)}" + state = hass.states.get(entity_id) + assert state.state != STATE_UNAVAILABLE - assert unknown_user_exc.type is UnknownUser - - with patch( - "homeassistant.components.switcher_kis.switch.SwitcherV2Api.set_auto_shutdown" - ) as mock_set_auto_shutdown: - await hass.services.async_call( - DOMAIN, - SERVICE_SET_AUTO_OFF_NAME, - {CONF_ENTITY_ID: SWITCH_ENTITY_ID, CONF_AUTO_OFF: DUMMY_AUTO_OFF_SET}, - ) - - await hass.async_block_till_done() - - mock_set_auto_shutdown.assert_called_once_with( - time_period_str(DUMMY_AUTO_OFF_SET) - ) + entity_id = f"sensor.{slugify(device.name)}_power_consumption" + state = hass.states.get(entity_id) + assert state.state != STATE_UNAVAILABLE -async def test_turn_on_with_timer_service( - hass: HomeAssistant, - mock_bridge: Generator[None, Any, None], - mock_api: Generator[None, Any, None], - hass_owner_user: MockUser, -) -> None: - """Test the set_auto_off service.""" - assert await async_setup_component(hass, DOMAIN, MANDATORY_CONFIGURATION) +async def test_entry_unload(hass, mock_bridge): + """Test entry unload.""" + entry = await init_integration(hass) + assert mock_bridge + assert entry.state is ConfigEntryState.LOADED + assert mock_bridge.is_running is True + assert len(hass.data[DOMAIN]) == 2 + + await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() - assert hass.services.has_service(DOMAIN, SERVICE_TURN_ON_WITH_TIMER_NAME) - - await hass.services.async_call( - DOMAIN, - SERVICE_TURN_ON_WITH_TIMER_NAME, - {CONF_ENTITY_ID: SWITCH_ENTITY_ID, CONF_TIMER_MINUTES: DUMMY_TIMER_MINUTES_SET}, - blocking=True, - context=Context(user_id=hass_owner_user.id), - ) - - with raises(UnknownUser) as unknown_user_exc: - await hass.services.async_call( - DOMAIN, - SERVICE_TURN_ON_WITH_TIMER_NAME, - { - CONF_ENTITY_ID: SWITCH_ENTITY_ID, - CONF_TIMER_MINUTES: DUMMY_TIMER_MINUTES_SET, - }, - blocking=True, - context=Context(user_id="not_real_user"), - ) - - assert unknown_user_exc.type is UnknownUser - - with patch( - "homeassistant.components.switcher_kis.switch.SwitcherV2Api.control_device" - ) as mock_control_device: - await hass.services.async_call( - DOMAIN, - SERVICE_TURN_ON_WITH_TIMER_NAME, - { - CONF_ENTITY_ID: SWITCH_ENTITY_ID, - CONF_TIMER_MINUTES: DUMMY_TIMER_MINUTES_SET, - }, - ) - - await hass.async_block_till_done() - - mock_control_device.assert_called_once_with( - COMMAND_ON, int(DUMMY_TIMER_MINUTES_SET) - ) - - -async def test_signal_dispatcher( - hass: HomeAssistant, mock_bridge: Generator[None, Any, None] -) -> None: - """Test signal dispatcher dispatching device updates every 4 seconds.""" - assert await async_setup_component(hass, DOMAIN, MANDATORY_CONFIGURATION) - - await hass.async_block_till_done() - - @callback - def verify_update_data(device: SwitcherV2Device) -> None: - """Use as callback for signal dispatcher.""" - pass - - async_dispatcher_connect(hass, SIGNAL_SWITCHER_DEVICE_UPDATE, verify_update_data) - - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=5)) + assert entry.state is ConfigEntryState.NOT_LOADED + assert mock_bridge.is_running is False + assert len(hass.data[DOMAIN]) == 0 diff --git a/tests/components/switcher_kis/test_sensor.py b/tests/components/switcher_kis/test_sensor.py new file mode 100644 index 00000000000..b6fc1f2a49e --- /dev/null +++ b/tests/components/switcher_kis/test_sensor.py @@ -0,0 +1,93 @@ +"""Test the Switcher Sensor Platform.""" +import pytest + +from homeassistant.components.switcher_kis.const import DATA_DEVICE, DOMAIN +from homeassistant.helpers import entity_registry as er +from homeassistant.util import slugify + +from . import init_integration +from .consts import DUMMY_PLUG_DEVICE, DUMMY_SWITCHER_DEVICES, DUMMY_WATER_HEATER_DEVICE + +DEVICE_SENSORS_TUPLE = ( + ( + DUMMY_PLUG_DEVICE, + [ + "power_consumption", + "electric_current", + ], + ), + ( + DUMMY_WATER_HEATER_DEVICE, + [ + "power_consumption", + "electric_current", + "remaining_time", + ], + ), +) + + +@pytest.mark.parametrize("mock_bridge", [DUMMY_SWITCHER_DEVICES], indirect=True) +async def test_sensor_platform(hass, mock_bridge): + """Test sensor platform.""" + await init_integration(hass) + assert mock_bridge + + assert mock_bridge.is_running is True + assert len(hass.data[DOMAIN]) == 2 + assert len(hass.data[DOMAIN][DATA_DEVICE]) == 2 + + for device, sensors in DEVICE_SENSORS_TUPLE: + for sensor in sensors: + entity_id = f"sensor.{slugify(device.name)}_{sensor}" + state = hass.states.get(entity_id) + assert state.state == str(getattr(device, sensor)) + + +async def test_sensor_disabled(hass, mock_bridge): + """Test sensor disabled by default.""" + await init_integration(hass) + assert mock_bridge + + mock_bridge.mock_callbacks([DUMMY_WATER_HEATER_DEVICE]) + await hass.async_block_till_done() + + registry = er.async_get(hass) + device = DUMMY_WATER_HEATER_DEVICE + unique_id = f"{device.device_id}-{device.mac_address}-auto_off_set" + entity_id = f"sensor.{slugify(device.name)}_auto_shutdown" + entry = registry.async_get(entity_id) + + assert entry + assert entry.unique_id == unique_id + assert entry.disabled is True + assert entry.disabled_by == er.DISABLED_INTEGRATION + + # Test enabling entity + updated_entry = registry.async_update_entity( + entry.entity_id, **{"disabled_by": None} + ) + + assert updated_entry != entry + assert updated_entry.disabled is False + + +@pytest.mark.parametrize("mock_bridge", [[DUMMY_WATER_HEATER_DEVICE]], indirect=True) +async def test_sensor_update(hass, mock_bridge, monkeypatch): + """Test sensor update.""" + await init_integration(hass) + assert mock_bridge + + device = DUMMY_WATER_HEATER_DEVICE + sensor = "power_consumption" + entity_id = f"sensor.{slugify(device.name)}_{sensor}" + + state = hass.states.get(entity_id) + assert state.state == str(getattr(device, sensor)) + + monkeypatch.setattr(device, sensor, 1431) + mock_bridge.mock_callbacks([DUMMY_WATER_HEATER_DEVICE]) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == "1431" diff --git a/tests/components/switcher_kis/test_services.py b/tests/components/switcher_kis/test_services.py new file mode 100644 index 00000000000..9b0fcee27df --- /dev/null +++ b/tests/components/switcher_kis/test_services.py @@ -0,0 +1,162 @@ +"""Test the services for the Switcher integration.""" +from unittest.mock import patch + +from aioswitcher.api import Command +from aioswitcher.device import DeviceState +import pytest + +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.components.switcher_kis.const import ( + CONF_AUTO_OFF, + CONF_TIMER_MINUTES, + DOMAIN, + SERVICE_SET_AUTO_OFF_NAME, + SERVICE_TURN_ON_WITH_TIMER_NAME, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNAVAILABLE +from homeassistant.helpers.config_validation import time_period_str +from homeassistant.util import slugify + +from . import init_integration +from .consts import ( + DUMMY_AUTO_OFF_SET, + DUMMY_PLUG_DEVICE, + DUMMY_TIMER_MINUTES_SET, + DUMMY_WATER_HEATER_DEVICE, +) + + +@pytest.mark.parametrize("mock_bridge", [[DUMMY_WATER_HEATER_DEVICE]], indirect=True) +async def test_turn_on_with_timer_service(hass, mock_bridge, mock_api, monkeypatch): + """Test the turn on with timer service.""" + await init_integration(hass) + assert mock_bridge + + device = DUMMY_WATER_HEATER_DEVICE + entity_id = f"{SWITCH_DOMAIN}.{slugify(device.name)}" + + # Test initial state - off + monkeypatch.setattr(device, "device_state", DeviceState.OFF) + mock_bridge.mock_callbacks([DUMMY_WATER_HEATER_DEVICE]) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + + with patch( + "homeassistant.components.switcher_kis.switch.SwitcherApi.control_device" + ) as mock_control_device: + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_ON_WITH_TIMER_NAME, + { + ATTR_ENTITY_ID: entity_id, + CONF_TIMER_MINUTES: DUMMY_TIMER_MINUTES_SET, + }, + blocking=True, + ) + + assert mock_api.call_count == 2 + mock_control_device.assert_called_once_with( + Command.ON, int(DUMMY_TIMER_MINUTES_SET) + ) + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + +@pytest.mark.parametrize("mock_bridge", [[DUMMY_WATER_HEATER_DEVICE]], indirect=True) +async def test_set_auto_off_service(hass, mock_bridge, mock_api): + """Test the set auto off service.""" + await init_integration(hass) + assert mock_bridge + + device = DUMMY_WATER_HEATER_DEVICE + entity_id = f"{SWITCH_DOMAIN}.{slugify(device.name)}" + + with patch( + "homeassistant.components.switcher_kis.switch.SwitcherApi.set_auto_shutdown" + ) as mock_set_auto_shutdown: + await hass.services.async_call( + DOMAIN, + SERVICE_SET_AUTO_OFF_NAME, + {ATTR_ENTITY_ID: entity_id, CONF_AUTO_OFF: DUMMY_AUTO_OFF_SET}, + blocking=True, + ) + + assert mock_api.call_count == 2 + mock_set_auto_shutdown.assert_called_once_with( + time_period_str(DUMMY_AUTO_OFF_SET) + ) + + +@pytest.mark.parametrize("mock_bridge", [[DUMMY_WATER_HEATER_DEVICE]], indirect=True) +async def test_set_auto_off_service_fail(hass, mock_bridge, mock_api, caplog): + """Test set auto off service failed.""" + await init_integration(hass) + assert mock_bridge + + device = DUMMY_WATER_HEATER_DEVICE + entity_id = f"{SWITCH_DOMAIN}.{slugify(device.name)}" + + with patch( + "homeassistant.components.switcher_kis.switch.SwitcherApi.set_auto_shutdown", + return_value=None, + ) as mock_set_auto_shutdown: + await hass.services.async_call( + DOMAIN, + SERVICE_SET_AUTO_OFF_NAME, + {ATTR_ENTITY_ID: entity_id, CONF_AUTO_OFF: DUMMY_AUTO_OFF_SET}, + blocking=True, + ) + + assert mock_api.call_count == 2 + mock_set_auto_shutdown.assert_called_once_with( + time_period_str(DUMMY_AUTO_OFF_SET) + ) + assert ( + f"Call api for {device.name} failed, api: 'set_auto_shutdown'" + in caplog.text + ) + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize("mock_bridge", [[DUMMY_PLUG_DEVICE]], indirect=True) +async def test_plug_unsupported_services(hass, mock_bridge, mock_api, caplog): + """Test plug device unsupported services.""" + await init_integration(hass) + assert mock_bridge + + device = DUMMY_PLUG_DEVICE + entity_id = f"{SWITCH_DOMAIN}.{slugify(device.name)}" + + # Turn on with timer + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_ON_WITH_TIMER_NAME, + { + ATTR_ENTITY_ID: entity_id, + CONF_TIMER_MINUTES: DUMMY_TIMER_MINUTES_SET, + }, + blocking=True, + ) + + assert mock_api.call_count == 0 + assert ( + f"Service '{SERVICE_TURN_ON_WITH_TIMER_NAME}' is not supported by {device.name}" + in caplog.text + ) + + # Auto off + await hass.services.async_call( + DOMAIN, + SERVICE_SET_AUTO_OFF_NAME, + {ATTR_ENTITY_ID: entity_id, CONF_AUTO_OFF: DUMMY_AUTO_OFF_SET}, + blocking=True, + ) + + assert mock_api.call_count == 0 + assert ( + f"Service '{SERVICE_SET_AUTO_OFF_NAME}' is not supported by {device.name}" + in caplog.text + ) diff --git a/tests/components/switcher_kis/test_switch.py b/tests/components/switcher_kis/test_switch.py new file mode 100644 index 00000000000..a44e0c79611 --- /dev/null +++ b/tests/components/switcher_kis/test_switch.py @@ -0,0 +1,127 @@ +"""Test the Switcher switch platform.""" +from unittest.mock import patch + +from aioswitcher.api import Command, SwitcherBaseResponse +from aioswitcher.device import DeviceState +import pytest + +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, +) +from homeassistant.util import slugify + +from . import init_integration +from .consts import DUMMY_PLUG_DEVICE, DUMMY_WATER_HEATER_DEVICE + + +@pytest.mark.parametrize("mock_bridge", [[DUMMY_WATER_HEATER_DEVICE]], indirect=True) +async def test_switch(hass, mock_bridge, mock_api, monkeypatch): + """Test the switch.""" + await init_integration(hass) + assert mock_bridge + + device = DUMMY_WATER_HEATER_DEVICE + entity_id = f"{SWITCH_DOMAIN}.{slugify(device.name)}" + + # Test initial state - on + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + # Test state change on --> off + monkeypatch.setattr(device, "device_state", DeviceState.OFF) + mock_bridge.mock_callbacks([DUMMY_WATER_HEATER_DEVICE]) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + + # Test turning on + with patch( + "homeassistant.components.switcher_kis.switch.SwitcherApi.control_device", + ) as mock_control_device: + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + assert mock_api.call_count == 2 + mock_control_device.assert_called_once_with(Command.ON) + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + # Test turning off + with patch( + "homeassistant.components.switcher_kis.switch.SwitcherApi.control_device" + ) as mock_control_device: + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + assert mock_api.call_count == 4 + mock_control_device.assert_called_once_with(Command.OFF) + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + + +@pytest.mark.parametrize("mock_bridge", [[DUMMY_PLUG_DEVICE]], indirect=True) +async def test_switch_control_fail(hass, mock_bridge, mock_api, monkeypatch, caplog): + """Test switch control fail.""" + await init_integration(hass) + assert mock_bridge + + device = DUMMY_PLUG_DEVICE + entity_id = f"{SWITCH_DOMAIN}.{slugify(device.name)}" + + # Test initial state - off + monkeypatch.setattr(device, "device_state", DeviceState.OFF) + mock_bridge.mock_callbacks([DUMMY_PLUG_DEVICE]) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + + # Test exception during turn on + with patch( + "homeassistant.components.switcher_kis.switch.SwitcherApi.control_device", + side_effect=RuntimeError("fake error"), + ) as mock_control_device: + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + assert mock_api.call_count == 2 + mock_control_device.assert_called_once_with(Command.ON) + assert ( + f"Call api for {device.name} failed, api: 'control_device'" in caplog.text + ) + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE + + # Make device available again + mock_bridge.mock_callbacks([DUMMY_PLUG_DEVICE]) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + + # Test error response during turn on + with patch( + "homeassistant.components.switcher_kis.switch.SwitcherApi.control_device", + return_value=SwitcherBaseResponse(None), + ) as mock_control_device: + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + assert mock_api.call_count == 4 + mock_control_device.assert_called_once_with(Command.ON) + assert ( + f"Call api for {device.name} failed, api: 'control_device'" in caplog.text + ) + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE