From a8ec826ef737463d92184531dd4ae93c64aa6c9d Mon Sep 17 00:00:00 2001 From: Joe Trabulsy Date: Tue, 23 Jul 2019 17:40:55 -0400 Subject: [PATCH] Add Support for VeSync Devices - Outlets and Switches (#24953) * Change dependency to pyvesync-v2 for vesync switch * async vesync component * FInish data_entry_flow * Update config flow * strings.json * Minor fix * Syntax fix * Minor Fixs * UI Fix * Minor Correct * Debug lines * fix device dictionaries * Light switch fix * Cleanup * pylint fixes * Hassfest and setup scripts * Flake8 fixes * Add vesync light platform * Fix typo * Update Devices Service * Fix update devices service * Add initial test * Add Config Flow Tests * Remove Extra Platforms * Fix requirements * Update pypi package * Add login to config_flow Avoid setting up component if login credentials are invalid * Fix variable import * Update config_flow.py * Update config_flow.py * Put VS object into hass.data instead of config entry * Update __init__.py * Handle Login Error * Fix invalid login error * Fix typo * Remove line * PEP fixes * Fix change requests * Fix typo * Update __init__.py * Update switch.py * Flake8 fix * Update test requirements * Fix permission * Address change requests * Address change requests * Fix device discovery indent, add MockConfigEntry * Fix vesynclightswitch classs * Remove active time attribute * Remove time_zone, grammar check --- .coveragerc | 3 + CODEOWNERS | 1 + .../components/vesync/.translations/en.json | 20 +++ homeassistant/components/vesync/__init__.py | 115 ++++++++++++++- homeassistant/components/vesync/common.py | 70 +++++++++ .../components/vesync/config_flow.py | 70 +++++++++ homeassistant/components/vesync/const.py | 9 ++ homeassistant/components/vesync/manifest.json | 9 +- homeassistant/components/vesync/services.yaml | 2 + homeassistant/components/vesync/strings.json | 20 +++ homeassistant/components/vesync/switch.py | 136 +++++++++--------- homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 2 +- requirements_test_all.txt | 3 + script/gen_requirements_all.py | 1 + tests/components/vesync/__init__.py | 1 + tests/components/vesync/test_config_flow.py | 57 ++++++++ 17 files changed, 444 insertions(+), 76 deletions(-) create mode 100644 homeassistant/components/vesync/.translations/en.json create mode 100644 homeassistant/components/vesync/common.py create mode 100644 homeassistant/components/vesync/config_flow.py create mode 100644 homeassistant/components/vesync/const.py create mode 100644 homeassistant/components/vesync/services.yaml create mode 100644 homeassistant/components/vesync/strings.json create mode 100644 tests/components/vesync/__init__.py create mode 100644 tests/components/vesync/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index b103a327af0..7cc5b80d017 100644 --- a/.coveragerc +++ b/.coveragerc @@ -673,6 +673,9 @@ omit = homeassistant/components/venstar/climate.py homeassistant/components/vera/* homeassistant/components/verisure/* + homeassistant/components/vesync/__init__.py + homeassistant/components/vesync/common.py + homeassistant/components/vesync/const.py homeassistant/components/vesync/switch.py homeassistant/components/viaggiatreno/sensor.py homeassistant/components/vizio/media_player.py diff --git a/CODEOWNERS b/CODEOWNERS index 14e25c2e4e0..0724a1a7e58 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -285,6 +285,7 @@ homeassistant/components/uptimerobot/* @ludeeus homeassistant/components/utility_meter/* @dgomes homeassistant/components/velux/* @Julius2342 homeassistant/components/version/* @fabaff +homeassistant/components/vesync/* @markperdue @webdjoe homeassistant/components/vizio/* @raman325 homeassistant/components/vlc_telnet/* @rodripf homeassistant/components/waqi/* @andrey-git diff --git a/homeassistant/components/vesync/.translations/en.json b/homeassistant/components/vesync/.translations/en.json new file mode 100644 index 00000000000..ec0bc728cdb --- /dev/null +++ b/homeassistant/components/vesync/.translations/en.json @@ -0,0 +1,20 @@ +{ + "config": { + "title": "VeSync", + "step": { + "user": { + "title": "Enter Username and Password", + "data": { + "username": "Email Address", + "password": "Password" + } + } + }, + "error": { + "invalid_login": "Invalid username or password" + }, + "abort": { + "already_setup": "Only one Vesync instance is allow" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/vesync/__init__.py b/homeassistant/components/vesync/__init__.py index 73b28a3d008..3a4c7048d68 100644 --- a/homeassistant/components/vesync/__init__.py +++ b/homeassistant/components/vesync/__init__.py @@ -1 +1,114 @@ -"""The vesync component.""" +"""Etekcity VeSync integration.""" +import logging +import voluptuous as vol +from pyvesync import VeSync +from homeassistant.const import CONF_USERNAME, CONF_PASSWORD +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.config_entries import SOURCE_IMPORT +from .common import async_process_devices +from .config_flow import configured_instances +from .const import (DOMAIN, VS_DISPATCHERS, VS_DISCOVERY, VS_SWITCHES, + SERVICE_UPDATE_DEVS, VS_MANAGER) + +_LOGGER = logging.getLogger(__name__) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + }), +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Set up the VeSync component.""" + conf = config.get(DOMAIN) + + if conf is None: + return True + + if not configured_instances(hass): + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={'source': SOURCE_IMPORT}, + data={ + CONF_USERNAME: conf[CONF_USERNAME], + CONF_PASSWORD: conf[CONF_PASSWORD] + })) + + return True + + +async def async_setup_entry(hass, config_entry): + """Set up Vesync as config entry.""" + username = config_entry.data[CONF_USERNAME] + password = config_entry.data[CONF_PASSWORD] + + time_zone = str(hass.config.time_zone) + + manager = VeSync(username, password, time_zone) + + login = await hass.async_add_executor_job(manager.login) + + if not login: + _LOGGER.error("Unable to login to the VeSync server") + return False + + device_dict = await async_process_devices(hass, manager) + + forward_setup = hass.config_entries.async_forward_entry_setup + + hass.data[DOMAIN] = {} + hass.data[DOMAIN][VS_MANAGER] = manager + + switches = hass.data[DOMAIN][VS_SWITCHES] = [] + + hass.data[DOMAIN][VS_DISPATCHERS] = [] + + if device_dict[VS_SWITCHES]: + switches.extend(device_dict[VS_SWITCHES]) + hass.async_create_task(forward_setup(config_entry, 'switch')) + + async def async_new_device_discovery(service): + """Discover if new devices should be added.""" + manager = hass.data[DOMAIN][VS_MANAGER] + switches = hass.data[DOMAIN][VS_SWITCHES] + + dev_dict = await async_process_devices(hass, manager) + switch_devs = dev_dict.get(VS_SWITCHES, []) + + switch_set = set(switch_devs) + new_switches = list(switch_set.difference(switches)) + if new_switches and switches: + switches.extend(new_switches) + async_dispatcher_send(hass, + VS_DISCOVERY.format(VS_SWITCHES), + new_switches) + return + if new_switches and not switches: + switches.extend(new_switches) + hass.async_create_task(forward_setup(config_entry, 'switch')) + + hass.services.async_register(DOMAIN, + SERVICE_UPDATE_DEVS, + async_new_device_discovery + ) + + return True + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + forward_unload = hass.config_entries.async_forward_entry_unload + remove_switches = False + if hass.data[DOMAIN][VS_SWITCHES]: + remove_switches = await forward_unload(entry, 'switch') + + if remove_switches: + hass.services.async_remove(DOMAIN, SERVICE_UPDATE_DEVS) + del hass.data[DOMAIN] + return True + + return False diff --git a/homeassistant/components/vesync/common.py b/homeassistant/components/vesync/common.py new file mode 100644 index 00000000000..d705df1e350 --- /dev/null +++ b/homeassistant/components/vesync/common.py @@ -0,0 +1,70 @@ +"""Common utilities for VeSync Component.""" +import logging +from homeassistant.helpers.entity import ToggleEntity +from .const import VS_SWITCHES + +_LOGGER = logging.getLogger(__name__) + + +async def async_process_devices(hass, manager): + """Assign devices to proper component.""" + devices = {} + devices[VS_SWITCHES] = [] + + await hass.async_add_executor_job(manager.update) + + if manager.outlets: + devices[VS_SWITCHES].extend(manager.outlets) + _LOGGER.info("%d VeSync outlets found", len(manager.outlets)) + + if manager.switches: + for switch in manager.switches: + if not switch.is_dimmable(): + devices[VS_SWITCHES].append(switch) + _LOGGER.info( + "%d VeSync standard switches found", len(manager.switches)) + + return devices + + +class VeSyncDevice(ToggleEntity): + """Base class for VeSync Device Representations.""" + + def __init__(self, device): + """Initialize the VeSync device.""" + self.device = device + + @property + def unique_id(self): + """Return the ID of this device.""" + if isinstance(self.device.sub_device_no, int): + return ('{}{}'.format( + self.device.cid, str(self.device.sub_device_no))) + return self.device.cid + + @property + def name(self): + """Return the name of the device.""" + return self.device.device_name + + @property + def is_on(self): + """Return True if switch is on.""" + return self.device.device_status == "on" + + @property + def available(self) -> bool: + """Return True if device is available.""" + return self.device.connection_status == "online" + + def turn_on(self, **kwargs): + """Turn the device on.""" + self.device.turn_on() + + def turn_off(self, **kwargs): + """Turn the device off.""" + self.device.turn_off() + + def update(self): + """Update vesync device.""" + self.device.update() diff --git a/homeassistant/components/vesync/config_flow.py b/homeassistant/components/vesync/config_flow.py new file mode 100644 index 00000000000..006a248a6ad --- /dev/null +++ b/homeassistant/components/vesync/config_flow.py @@ -0,0 +1,70 @@ +"""Config flow utilities.""" +import logging +from collections import OrderedDict +import voluptuous as vol +from pyvesync import VeSync +from homeassistant import config_entries +from homeassistant.core import callback +from homeassistant.const import CONF_USERNAME, CONF_PASSWORD +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +@callback +def configured_instances(hass): + """Return already configured instances.""" + return hass.config_entries.async_entries(DOMAIN) + + +@config_entries.HANDLERS.register(DOMAIN) +class VeSyncFlowHandler(config_entries.ConfigFlow): + """Handle a config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self): + """Instantiate config flow.""" + self._username = None + self._password = None + self.data_schema = OrderedDict() + self.data_schema[vol.Required(CONF_USERNAME)] = str + self.data_schema[vol.Required(CONF_PASSWORD)] = str + + @callback + def _show_form(self, errors=None): + """Show form to the user.""" + return self.async_show_form( + step_id='user', + data_schema=vol.Schema(self.data_schema), + errors=errors if errors else {}, + ) + + async def async_step_import(self, import_config): + """Handle external yaml configuration.""" + return await self.async_step_user(import_config) + + async def async_step_user(self, user_input=None): + """Handle a flow start.""" + if configured_instances(self.hass): + return self.async_abort(reason='already_setup') + + if not user_input: + return self._show_form() + + self._username = user_input[CONF_USERNAME] + self._password = user_input[CONF_PASSWORD] + + manager = VeSync(self._username, self._password) + login = await self.hass.async_add_executor_job(manager.login) + if not login: + return self._show_form(errors={'base': 'invalid_login'}) + + return self.async_create_entry( + title=self._username, + data={ + CONF_USERNAME: self._username, + CONF_PASSWORD: self._password, + }, + ) diff --git a/homeassistant/components/vesync/const.py b/homeassistant/components/vesync/const.py new file mode 100644 index 00000000000..be31c11c3ed --- /dev/null +++ b/homeassistant/components/vesync/const.py @@ -0,0 +1,9 @@ +"""Constants for VeSync Component.""" + +DOMAIN = 'vesync' +VS_DISPATCHERS = 'vesync_dispatchers' +VS_DISCOVERY = 'vesync_discovery_{}' +SERVICE_UPDATE_DEVS = 'update_devices' + +VS_SWITCHES = 'switches' +VS_MANAGER = 'manager' diff --git a/homeassistant/components/vesync/manifest.json b/homeassistant/components/vesync/manifest.json index 9bd0678c904..53cc96be388 100644 --- a/homeassistant/components/vesync/manifest.json +++ b/homeassistant/components/vesync/manifest.json @@ -1,10 +1,9 @@ { "domain": "vesync", - "name": "Vesync", + "name": "VeSync", "documentation": "https://www.home-assistant.io/components/vesync", - "requirements": [ - "pyvesync_v2==0.9.7" - ], "dependencies": [], - "codeowners": [] + "codeowners": ["@markperdue", "@webdjoe"], + "requirements": ["pyvesync==1.1.0"], + "config_flow": true } diff --git a/homeassistant/components/vesync/services.yaml b/homeassistant/components/vesync/services.yaml new file mode 100644 index 00000000000..2174c974e16 --- /dev/null +++ b/homeassistant/components/vesync/services.yaml @@ -0,0 +1,2 @@ +update_devices: + description: Add new VeSync devices to Home Assistant \ No newline at end of file diff --git a/homeassistant/components/vesync/strings.json b/homeassistant/components/vesync/strings.json new file mode 100644 index 00000000000..f28d7d0d0c2 --- /dev/null +++ b/homeassistant/components/vesync/strings.json @@ -0,0 +1,20 @@ +{ + "config": { + "title": "VeSync", + "step": { + "user": { + "title": "Enter Username and Password", + "data": { + "username": "Email Address", + "password": "Password" + } + } + }, + "error": { + "invalid_login": "Invalid username or password" + }, + "abort": { + "already_setup": "Only one Vesync instance is allowed" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/vesync/switch.py b/homeassistant/components/vesync/switch.py index d8fa3d317ff..516e33deaaa 100644 --- a/homeassistant/components/vesync/switch.py +++ b/homeassistant/components/vesync/switch.py @@ -1,102 +1,100 @@ """Support for Etekcity VeSync switches.""" import logging -import voluptuous as vol -from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) -from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD) -import homeassistant.helpers.config_validation as cv - +from homeassistant.core import callback +from homeassistant.components.switch import SwitchDevice +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from .const import VS_DISCOVERY, VS_DISPATCHERS, VS_SWITCHES, DOMAIN +from .common import VeSyncDevice _LOGGER = logging.getLogger(__name__) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, -}) +DEV_TYPE_TO_HA = { + 'wifi-switch-1.3': 'outlet', + 'ESW03-USA': 'outlet', + 'ESW01-EU': 'outlet', + 'ESW15-USA': 'outlet', + 'ESWL01': 'switch', + 'ESWL03': 'switch', + 'ESO15-TB': 'outlet' +} -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the VeSync switch platform.""" - from pyvesync_v2.vesync import VeSync +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up switches.""" + async def async_discover(devices): + """Add new devices to platform.""" + _async_setup_entities(devices, async_add_entities) - switches = [] + disp = async_dispatcher_connect( + hass, VS_DISCOVERY.format(VS_SWITCHES), async_discover) + hass.data[DOMAIN][VS_DISPATCHERS].append(disp) - manager = VeSync(config.get(CONF_USERNAME), config.get(CONF_PASSWORD)) + _async_setup_entities( + hass.data[DOMAIN][VS_SWITCHES], + async_add_entities + ) + return True - if not manager.login(): - _LOGGER.error("Unable to login to VeSync") - return - manager.update() - - if manager.devices is not None and manager.devices: - if len(manager.devices) == 1: - count_string = 'switch' +@callback +def _async_setup_entities(devices, async_add_entities): + """Check if device is online and add entity.""" + dev_list = [] + for dev in devices: + if DEV_TYPE_TO_HA.get(dev.device_type) == 'outlet': + dev_list.append(VeSyncSwitchHA(dev)) + elif DEV_TYPE_TO_HA.get(dev.device_type) == 'switch': + dev_list.append(VeSyncLightSwitch(dev)) else: - count_string = 'switches' + _LOGGER.warning("%s - Unkown device type - %s", + dev.device_name, dev.device_type) + continue - _LOGGER.info("Discovered %d VeSync %s", - len(manager.devices), count_string) - - for switch in manager.devices: - switches.append(VeSyncSwitchHA(switch)) - _LOGGER.info("Added a VeSync switch named '%s'", - switch.device_name) - else: - _LOGGER.info("No VeSync devices found") - - add_entities(switches) + async_add_entities( + dev_list, + update_before_add=True + ) -class VeSyncSwitchHA(SwitchDevice): +class VeSyncSwitchHA(VeSyncDevice, SwitchDevice): """Representation of a VeSync switch.""" def __init__(self, plug): """Initialize the VeSync switch device.""" + super().__init__(plug) self.smartplug = plug - self._current_power_w = None - self._today_energy_kwh = None @property - def unique_id(self): - """Return the ID of this switch.""" - return self.smartplug.cid - - @property - def name(self): - """Return the name of the switch.""" - return self.smartplug.device_name + def device_state_attributes(self): + """Return the state attributes of the device.""" + attr = {} + if hasattr(self.smartplug, 'weekly_energy_total'): + attr['voltage'] = self.smartplug.voltage + attr['weekly_energy_total'] = self.smartplug.weekly_energy_total + attr['monthly_energy_total'] = self.smartplug.monthly_energy_total + attr['yearly_energy_total'] = self.smartplug.yearly_energy_total + return attr @property def current_power_w(self): """Return the current power usage in W.""" - return self._current_power_w + return self.smartplug.power @property def today_energy_kwh(self): """Return the today total energy usage in kWh.""" - return self._today_energy_kwh - - @property - def available(self) -> bool: - """Return True if switch is available.""" - return self.smartplug.connection_status == "online" - - @property - def is_on(self): - """Return True if switch is on.""" - return self.smartplug.device_status == "on" - - def turn_on(self, **kwargs): - """Turn the switch on.""" - self.smartplug.turn_on() - - def turn_off(self, **kwargs): - """Turn the switch off.""" - self.smartplug.turn_off() + return self.smartplug.energy_today def update(self): - """Handle data changes for node values.""" + """Update outlet details and energy usage.""" self.smartplug.update() - if self.smartplug.devtype == 'outlet': - self._current_power_w = self.smartplug.get_power() - self._today_energy_kwh = self.smartplug.get_kwh_today() + self.smartplug.update_energy() + + +class VeSyncLightSwitch(VeSyncDevice, SwitchDevice): + """Handle representation of VeSync Light Switch.""" + + def __init__(self, switch): + """Initialize Light Switch device class.""" + super().__init__(switch) + self.switch = switch diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index c8d8f262a86..1ea7befb0e1 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -56,6 +56,7 @@ FLOWS = [ "twilio", "unifi", "upnp", + "vesync", "wemo", "wwlln", "zha", diff --git a/requirements_all.txt b/requirements_all.txt index d7f64453b33..6a12721ef34 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1559,7 +1559,7 @@ pyuptimerobot==0.0.5 pyvera==0.3.2 # homeassistant.components.vesync -pyvesync_v2==0.9.7 +pyvesync==1.1.0 # homeassistant.components.vizio pyvizio==0.0.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a1e9c1b29c1..660a1b9e203 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -315,6 +315,9 @@ python_awair==0.0.4 # homeassistant.components.tradfri pytradfri[async]==6.0.1 +# homeassistant.components.vesync +pyvesync==1.1.0 + # homeassistant.components.html5 pywebpush==1.9.2 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index ae4c3591164..1cf3965b61d 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -132,6 +132,7 @@ TEST_REQUIREMENTS = ( 'pytradfri[async]', 'pyunifi', 'pyupnp-async', + 'pyvesync', 'pywebpush', 'pyHS100', 'PyNaCl', diff --git a/tests/components/vesync/__init__.py b/tests/components/vesync/__init__.py new file mode 100644 index 00000000000..f6b53bf156f --- /dev/null +++ b/tests/components/vesync/__init__.py @@ -0,0 +1 @@ +"""Tests for VeSync Component.""" diff --git a/tests/components/vesync/test_config_flow.py b/tests/components/vesync/test_config_flow.py new file mode 100644 index 00000000000..8455ab22817 --- /dev/null +++ b/tests/components/vesync/test_config_flow.py @@ -0,0 +1,57 @@ +"""Test for vesync config flow.""" +from unittest.mock import patch +from homeassistant import data_entry_flow +from homeassistant.components.vesync import config_flow, DOMAIN +from homeassistant.const import CONF_USERNAME, CONF_PASSWORD +from tests.common import MockConfigEntry + + +async def test_abort_already_setup(hass): + """Test if we abort because component is already setup.""" + flow = config_flow.VeSyncFlowHandler() + flow.hass = hass + MockConfigEntry( + domain=DOMAIN, title='user', data={'user': 'pass'} + ).add_to_hass(hass) + result = await flow.async_step_user() + + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'already_setup' + + +async def test_invalid_login_error(hass): + """Test if we return error for invalid username and password.""" + test_dict = {CONF_USERNAME: 'user', CONF_PASSWORD: 'pass'} + flow = config_flow.VeSyncFlowHandler() + flow.hass = hass + with patch('pyvesync.vesync.VeSync.login', return_value=False): + result = await flow.async_step_user(user_input=test_dict) + + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['errors'] == {'base': 'invalid_login'} + + +async def test_config_flow_configuration_yaml(hass): + """Test config flow with configuration.yaml user input.""" + test_dict = {CONF_USERNAME: 'user', CONF_PASSWORD: 'pass'} + flow = config_flow.VeSyncFlowHandler() + flow.hass = hass + with patch('pyvesync.vesync.VeSync.login', return_value=True): + result = await flow.async_step_import(test_dict) + + assert result['data'].get(CONF_USERNAME) == test_dict[CONF_USERNAME] + assert result['data'].get(CONF_PASSWORD) == test_dict[CONF_PASSWORD] + + +async def test_config_flow_user_input(hass): + """Test config flow with user input.""" + flow = config_flow.VeSyncFlowHandler() + flow.hass = hass + result = await flow.async_step_user() + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + with patch('pyvesync.vesync.VeSync.login', return_value=True): + result = await flow.async_step_user( + {CONF_USERNAME: 'user', CONF_PASSWORD: 'pass'}) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['data'][CONF_USERNAME] == 'user' + assert result['data'][CONF_PASSWORD] == 'pass'