diff --git a/.coveragerc b/.coveragerc index cb0c50f72fe..ac674b9fada 100644 --- a/.coveragerc +++ b/.coveragerc @@ -562,6 +562,7 @@ omit = homeassistant/components/swiss_public_transport/sensor.py homeassistant/components/swisscom/device_tracker.py homeassistant/components/switchbot/switch.py + homeassistant/components/switcher_kis/switch.py homeassistant/components/switchmate/switch.py homeassistant/components/syncthru/sensor.py homeassistant/components/synology/camera.py diff --git a/CODEOWNERS b/CODEOWNERS index a6dd61e4ffb..c2cd1f4553a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -206,6 +206,7 @@ homeassistant/components/supla/* @mwegrzynek homeassistant/components/swiss_hydrological_data/* @fabaff homeassistant/components/swiss_public_transport/* @fabaff homeassistant/components/switchbot/* @danielhiversen +homeassistant/components/switcher_kis/* @tomerfi homeassistant/components/switchmate/* @danielhiversen homeassistant/components/synology_srm/* @aerialls homeassistant/components/syslog/* @fabaff diff --git a/homeassistant/components/switcher_kis/__init__.py b/homeassistant/components/switcher_kis/__init__.py new file mode 100644 index 00000000000..43ca0abc2a0 --- /dev/null +++ b/homeassistant/components/switcher_kis/__init__.py @@ -0,0 +1,93 @@ +"""Home Assistant Switcher Component.""" + +from asyncio import QueueEmpty, TimeoutError as Asyncio_TimeoutError, wait_for +from datetime import datetime, timedelta +from logging import getLogger +from typing import Dict, Optional + +import voluptuous as vol + +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.discovery import async_load_platform +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.typing import EventType, HomeAssistantType + +_LOGGER = getLogger(__name__) + +DOMAIN = 'switcher_kis' + +CONF_DEVICE_ID = 'device_id' +CONF_DEVICE_PASSWORD = 'device_password' +CONF_PHONE_ID = 'phone_id' + +DATA_DEVICE = 'device' + +SIGNAL_SWITCHER_DEVICE_UPDATE = 'switcher_device_update' + +ATTR_AUTO_OFF_SET = 'auto_off_set' +ATTR_ELECTRIC_CURRENT = 'electric_current' +ATTR_REMAINING_TIME = 'remaining_time' + +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 + }) +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass: HomeAssistantType, config: Dict) -> bool: + """Set up the switcher component.""" + from aioswitcher.bridge import SwitcherV2Bridge + + phone_id = config[DOMAIN][CONF_PHONE_ID] + device_id = config[DOMAIN][CONF_DEVICE_ID] + device_password = config[DOMAIN][CONF_DEVICE_PASSWORD] + + v2bridge = SwitcherV2Bridge( + hass.loop, phone_id, device_id, device_password) + + await v2bridge.start() + + async def async_stop_bridge(event: EventType) -> None: + """On homeassistant stop, gracefully stop the bridge if running.""" + await v2bridge.stop() + + hass.async_add_job(hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, async_stop_bridge)) + + try: + device_data = await wait_for( + v2bridge.queue.get(), timeout=5.0, loop=hass.loop) + 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, DOMAIN, None, config)) + + @callback + def device_updates(timestamp: Optional[datetime]) -> 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 + + async_track_time_interval(hass, device_updates, timedelta(seconds=4)) + + return True diff --git a/homeassistant/components/switcher_kis/manifest.json b/homeassistant/components/switcher_kis/manifest.json new file mode 100644 index 00000000000..140caf51936 --- /dev/null +++ b/homeassistant/components/switcher_kis/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "switcher_kis", + "name": "Switcher", + "documentation": "https://www.home-assistant.io/components/switcher_kis/", + "codeowners": [ + "@tomerfi" + ], + "requirements": [ + "aioswitcher==2019.3.21" + ], + "dependencies": [] +} diff --git a/homeassistant/components/switcher_kis/switch.py b/homeassistant/components/switcher_kis/switch.py new file mode 100644 index 00000000000..c66c6b52e0c --- /dev/null +++ b/homeassistant/components/switcher_kis/switch.py @@ -0,0 +1,142 @@ +"""Home Assistant Switcher Component Switch platform.""" + +from logging import getLogger +from typing import Callable, Dict, TYPE_CHECKING + +from homeassistant.components.switch import ATTR_CURRENT_POWER_W, SwitchDevice +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.typing import HomeAssistantType + +from . import ( + ATTR_AUTO_OFF_SET, ATTR_ELECTRIC_CURRENT, ATTR_REMAINING_TIME, + DATA_DEVICE, DOMAIN, SIGNAL_SWITCHER_DEVICE_UPDATE) + +if TYPE_CHECKING: + from aioswitcher.devices import SwitcherV2Device + from aioswitcher.api.messages import SwitcherV2ControlResponseMSG + + +_LOGGER = getLogger(__name__) + +DEVICE_PROPERTIES_TO_HA_ATTRIBUTES = { + 'power_consumption': ATTR_CURRENT_POWER_W, + 'electric_current': ATTR_ELECTRIC_CURRENT, + 'remaining_time': ATTR_REMAINING_TIME, + 'auto_off_set': ATTR_AUTO_OFF_SET +} + + +async def async_setup_platform(hass: HomeAssistantType, config: Dict, + async_add_entities: Callable, + discovery_info: Dict) -> None: + """Set up the switcher platform for the switch component.""" + assert DOMAIN in hass.data + async_add_entities([SwitcherControl(hass.data[DOMAIN][DATA_DEVICE])]) + + +class SwitcherControl(SwitchDevice): + """Home Assistant switch entity.""" + + def __init__(self, device_data: 'SwitcherV2Device') -> None: + """Initialize the entity.""" + self._self_initiated = False + self._device_data = device_data + self._state = device_data.state + + @property + def name(self) -> str: + """Return the device's name.""" + return self._device_data.name + + @property + def should_poll(self) -> bool: + """Return False, entity pushes its state to HA.""" + return False + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return "{}-{}".format( + self._device_data.device_id, self._device_data.mac_addr) + + @property + def is_on(self) -> bool: + """Return True if entity is on.""" + from aioswitcher.consts import STATE_ON as SWITCHER_STATE_ON + return self._state == SWITCHER_STATE_ON + + @property + def current_power_w(self) -> int: + """Return the current power usage in W.""" + return self._device_data.power_consumption + + @property + def device_state_attributes(self) -> Dict: + """Return the optional state attributes.""" + from aioswitcher.consts import WAITING_TEXT + + attribs = {} + + for prop, attr in DEVICE_PROPERTIES_TO_HA_ATTRIBUTES.items(): + value = getattr(self._device_data, prop) + if value and value is not WAITING_TEXT: + attribs[attr] = value + + return attribs + + @property + def available(self) -> bool: + """Return True if entity is available.""" + from aioswitcher.consts import (STATE_OFF as SWITCHER_STATE_OFF, + STATE_ON as SWITCHER_STATE_ON) + 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.""" + async_dispatcher_connect( + self.hass, SIGNAL_SWITCHER_DEVICE_UPDATE, self.async_update_data) + + async def async_update_data(self, device_data: 'SwitcherV2Device') -> None: + """Update the entity data.""" + if device_data: + if self._self_initiated: + self._self_initiated = False + else: + self._device_data = device_data + self._state = self._device_data.state + self.async_schedule_update_ha_state() + + async def async_turn_on(self, **kwargs: Dict) -> None: + """Turn the entity on. + + This method must be run in the event loop and returns a coroutine. + """ + await self._control_device(True) + + async def async_turn_off(self, **kwargs: Dict) -> None: + """Turn the entity off. + + This method must be run in the event loop and returns a coroutine. + """ + await self._control_device(False) + + async def _control_device(self, send_on: bool) -> None: + """Turn the entity on or off.""" + from aioswitcher.api import SwitcherV2Api + from aioswitcher.consts import (COMMAND_OFF, COMMAND_ON, + STATE_OFF as SWITCHER_STATE_OFF, + STATE_ON as SWITCHER_STATE_ON) + + response = None # type: SwitcherV2ControlResponseMSG + 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) + + if response and response.successful: + self._self_initiated = True + self._state = \ + SWITCHER_STATE_ON if send_on else SWITCHER_STATE_OFF + self.async_schedule_update_ha_state() diff --git a/homeassistant/helpers/typing.py b/homeassistant/helpers/typing.py index 91b49283be8..e9a8d0749b0 100644 --- a/homeassistant/helpers/typing.py +++ b/homeassistant/helpers/typing.py @@ -7,6 +7,7 @@ import homeassistant.core GPSType = Tuple[float, float] ConfigType = Dict[str, Any] +EventType = homeassistant.core.Event HomeAssistantType = homeassistant.core.HomeAssistant ServiceDataType = Dict[str, Any] TemplateVarsType = Optional[Dict[str, Any]] diff --git a/requirements_all.txt b/requirements_all.txt index c1e381e760b..5fa04616a8e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -145,6 +145,9 @@ aiolifx_effects==0.2.1 # homeassistant.components.hunterdouglas_powerview aiopvapi==1.6.14 +# homeassistant.components.switcher_kis +aioswitcher==2019.3.21 + # homeassistant.components.unifi aiounifi==4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2e5012d76e0..383aec75958 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -51,6 +51,9 @@ aiohttp_cors==0.7.0 # homeassistant.components.hue aiohue==1.9.1 +# homeassistant.components.switcher_kis +aioswitcher==2019.3.21 + # homeassistant.components.unifi aiounifi==4 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index f71b8944d7c..63b0ef737e2 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -48,6 +48,7 @@ TEST_REQUIREMENTS = ( 'aiohttp_cors', 'aiohue', 'aiounifi', + 'aioswitcher', 'apns2', 'av', 'axis', diff --git a/tests/components/switcher_kis/__init__.py b/tests/components/switcher_kis/__init__.py new file mode 100644 index 00000000000..46fbe073ab0 --- /dev/null +++ b/tests/components/switcher_kis/__init__.py @@ -0,0 +1 @@ +"""Test cases and object for the Switcher integration tests.""" diff --git a/tests/components/switcher_kis/conftest.py b/tests/components/switcher_kis/conftest.py new file mode 100644 index 00000000000..d0398d448e9 --- /dev/null +++ b/tests/components/switcher_kis/conftest.py @@ -0,0 +1,110 @@ +"""Common fixtures and objects for the Switcher integration tests.""" + +from asyncio import Queue +from datetime import datetime +from typing import Any, Generator, Optional + +from asynctest import CoroutineMock, patch +from pytest import fixture + +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) + + +@patch('aioswitcher.devices.SwitcherV2Device') +class MockSwitcherV2Device: + """Class for mocking the aioswitcher.devices.SwitcherV2Device object.""" + + def __init__(self) -> None: + """Initialize the object.""" + self._last_state_change = datetime.now() + + @property + def device_id(self) -> str: + """Return the device id.""" + return DUMMY_DEVICE_ID + + @property + def ip_addr(self) -> str: + """Return the ip address.""" + return DUMMY_IP_ADDRESS + + @property + def mac_addr(self) -> str: + """Return the mac address.""" + return DUMMY_MAC_ADDRESS + + @property + def name(self) -> str: + """Return the device name.""" + return DUMMY_DEVICE_NAME + + @property + def state(self) -> str: + """Return the device state.""" + return DUMMY_DEVICE_STATE + + @property + def remaining_time(self) -> Optional[str]: + """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 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 + + +@fixture(name='mock_bridge') +def mock_bridge_fixture() -> Generator[None, Any, None]: + """Fixture for mocking aioswitcher.bridge.SwitcherV2Bridge.""" + queue = Queue() # type: Queue + + async def mock_queue(): + """Mock asyncio's Queue.""" + await queue.put(MockSwitcherV2Device()) + return await queue.get() + + mock_bridge = CoroutineMock() + + patchers = [ + patch('aioswitcher.bridge.SwitcherV2Bridge.start', new=mock_bridge), + patch('aioswitcher.bridge.SwitcherV2Bridge.stop', new=mock_bridge), + patch('aioswitcher.bridge.SwitcherV2Bridge.queue', get=mock_queue) + ] + + for patcher in patchers: + patcher.start() + + yield + + for patcher in patchers: + patcher.stop() diff --git a/tests/components/switcher_kis/consts.py b/tests/components/switcher_kis/consts.py new file mode 100644 index 00000000000..47efe8d03c9 --- /dev/null +++ b/tests/components/switcher_kis/consts.py @@ -0,0 +1,26 @@ +"""Constants for the Switcher integration tests.""" + +from homeassistant.components.switcher_kis import ( + CONF_DEVICE_ID, CONF_DEVICE_PASSWORD, CONF_PHONE_ID, DOMAIN) + +DUMMY_AUTO_OFF_SET = '01:30:00' +DUMMY_DEVICE_ID = 'a123bc' +DUMMY_DEVICE_NAME = "Device Name" +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_PHONE_ID = '1234' +DUMMY_POWER_CONSUMPTION = 2780 +DUMMY_REMAINING_TIME = '01:29:32' + +MANDATORY_CONFIGURATION = { + DOMAIN: { + CONF_PHONE_ID: DUMMY_PHONE_ID, + CONF_DEVICE_ID: DUMMY_DEVICE_ID, + CONF_DEVICE_PASSWORD: DUMMY_DEVICE_PASSWORD + } +} diff --git a/tests/components/switcher_kis/test_init.py b/tests/components/switcher_kis/test_init.py new file mode 100644 index 00000000000..0defb113747 --- /dev/null +++ b/tests/components/switcher_kis/test_init.py @@ -0,0 +1,49 @@ +"""Test cases for the switcher_kis component.""" + +from typing import Any, Generator + +from homeassistant.components.switcher_kis import (DOMAIN, DATA_DEVICE) +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.setup import async_setup_component + +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, MANDATORY_CONFIGURATION) + + +async def test_failed_config(hass: HomeAssistantType) -> None: + """Test failed configuration.""" + assert await async_setup_component( + hass, DOMAIN, MANDATORY_CONFIGURATION) is False + + +async def test_minimal_config(hass: HomeAssistantType, + 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: HomeAssistantType, + mock_bridge: Generator[None, Any, None] + ) -> None: + """Test the event send with the updated device.""" + assert await async_setup_component( + hass, DOMAIN, MANDATORY_CONFIGURATION) + + 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