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
This commit is contained in:
Joe Trabulsy 2019-07-23 17:40:55 -04:00 committed by Martin Hjelmare
parent 738d00fb05
commit a8ec826ef7
17 changed files with 444 additions and 76 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,2 @@
update_devices:
description: Add new VeSync devices to Home Assistant

View File

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

View File

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

View File

@ -56,6 +56,7 @@ FLOWS = [
"twilio",
"unifi",
"upnp",
"vesync",
"wemo",
"wwlln",
"zha",

View File

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

View File

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

View File

@ -132,6 +132,7 @@ TEST_REQUIREMENTS = (
'pytradfri[async]',
'pyunifi',
'pyupnp-async',
'pyvesync',
'pywebpush',
'pyHS100',
'PyNaCl',

View File

@ -0,0 +1 @@
"""Tests for VeSync Component."""

View File

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