Add Switcher config flow discovery support (#52316)

This commit is contained in:
Shay Levy 2021-07-19 16:28:40 +03:00 committed by GitHub
parent 51d16202ab
commit ea6e325762
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 1146 additions and 582 deletions

View File

@ -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

View File

@ -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.*

View File

@ -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

View File

@ -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={})

View File

@ -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

View File

@ -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
}

View File

@ -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]

View File

@ -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

View File

@ -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%]"
}
}
}

View File

@ -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()

View File

@ -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?"
}
}
}
}

View File

@ -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

View File

@ -251,6 +251,7 @@ FLOWS = [
"srp_energy",
"starline",
"subaru",
"switcher_kis",
"syncthing",
"syncthru",
"synology_dsm",

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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.*",

View File

@ -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

View File

@ -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()

View File

@ -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]

View File

@ -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"

View File

@ -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

View File

@ -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"

View File

@ -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
)

View File

@ -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