Added component named switcher_kis switcher water heater integration. (#22325)

* Added component named switcher_kis switcher water heater integration.

* Fixed conflicts.

* Updated requirements.

* Added manifest.json file and updated CODEOWNERS.

* Fixed requirements_all.txt.

* Better component tests.

* Removed unnecessary parameter from fixture function.

* Removed tests section from mypy.ini.

* Remove unused ENTITY_ID_FORMAT.

* Stop udp bridge when failed to setup the component.

* Replace DISCOVERY_ constants prefix with DATA_.

* Various change requests.

* Fixed constant name change remifications.

* Added explicit name to fixture.

* Various change requests.

* More various change requests.

* Added EventType for homeassistant.core.Event.

* Switched from event driven data distribution to dispatcher type plus clean-ups.

* Removed name and icon keys from the component configuration.

* Various change requests.

* Various change reqeusts and clean-ups.

* Removed unnecessary DEPENDENCIES constant from swith platform.

* Replaced configuration data guard with assert.

* Removed unused constants.

* Removed confusing type casting for mypy sake.

* Refactor property device_name to name.

* Removed None guard effecting mypy only.

* Removed unnecessary function from switch entity.

* Removed None guard in use by mypy only.

* Removed unused constant.

* Removed unnecessary context manager.

* Stopped messing around with mypy.ini.

* Referring to typing.TYPE_CHECKING for non-runtime imports.

* Added test requierment correctyly.

* Replaced queue.get() with queue.get_nowait() to avoid backing up intervals requests.

* Revert changes in mypy.ini.

* Changed attributes content to device properties instead of entity properties.

* Fixed typo in constant name.

* Remove unnecessary async keyword from callable.

* Waiting for tasks on event loop to end.

* Added callback decorator to callable.
This commit is contained in:
Tomer Figenblat 2019-04-20 00:54:48 +03:00 committed by Andrew Sayre
parent 31e514ec15
commit 9d8d8afa82
13 changed files with 443 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -48,6 +48,7 @@ TEST_REQUIREMENTS = (
'aiohttp_cors',
'aiohue',
'aiounifi',
'aioswitcher',
'apns2',
'av',
'axis',

View File

@ -0,0 +1 @@
"""Test cases and object for the Switcher integration tests."""

View File

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

View File

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

View File

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