Add config flow to venstar (#58152)

This commit is contained in:
Tim Rightnour 2021-10-25 14:40:36 -07:00 committed by GitHub
parent 2d6fa5c453
commit dad5d19a35
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 578 additions and 53 deletions

View File

@ -1168,6 +1168,7 @@ omit =
homeassistant/components/velbus/sensor.py
homeassistant/components/velbus/switch.py
homeassistant/components/velux/*
homeassistant/components/venstar/__init__.py
homeassistant/components/venstar/climate.py
homeassistant/components/verisure/__init__.py
homeassistant/components/verisure/alarm_control_panel.py

View File

@ -563,6 +563,7 @@ homeassistant/components/utility_meter/* @dgomes
homeassistant/components/vallox/* @andre-richter
homeassistant/components/velbus/* @Cereal2nd @brefra
homeassistant/components/velux/* @Julius2342
homeassistant/components/venstar/* @garbled1
homeassistant/components/vera/* @pavoni
homeassistant/components/verisure/* @frenck
homeassistant/components/versasense/* @flamm3blemuff1n

View File

@ -1 +1,109 @@
"""The venstar component."""
import asyncio
from requests import RequestException
from venstarcolortouch import VenstarColorTouch
from homeassistant.const import (
CONF_HOST,
CONF_PASSWORD,
CONF_PIN,
CONF_SSL,
CONF_USERNAME,
)
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.entity import Entity
from .const import _LOGGER, DOMAIN, VENSTAR_TIMEOUT
PLATFORMS = ["climate"]
async def async_setup_entry(hass, config):
"""Set up the Venstar thermostat."""
username = config.data.get(CONF_USERNAME)
password = config.data.get(CONF_PASSWORD)
pin = config.data.get(CONF_PIN)
host = config.data[CONF_HOST]
timeout = VENSTAR_TIMEOUT
protocol = "https" if config.data[CONF_SSL] else "http"
client = VenstarColorTouch(
addr=host,
timeout=timeout,
user=username,
password=password,
pin=pin,
proto=protocol,
)
try:
await hass.async_add_executor_job(client.update_info)
except (OSError, RequestException) as ex:
raise ConfigEntryNotReady(f"Unable to connect to the thermostat: {ex}") from ex
hass.data.setdefault(DOMAIN, {})[config.entry_id] = client
hass.config_entries.async_setup_platforms(config, PLATFORMS)
return True
async def async_unload_entry(hass, config):
"""Unload the config config and platforms."""
unload_ok = await hass.config_entries.async_unload_platforms(config, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(config.entry_id)
return unload_ok
class VenstarEntity(Entity):
"""Get the latest data and update."""
def __init__(self, config, client):
"""Initialize the data object."""
self._config = config
self._client = client
async def async_update(self):
"""Update the state."""
try:
info_success = await self.hass.async_add_executor_job(
self._client.update_info
)
except (OSError, RequestException) as ex:
_LOGGER.error("Exception during info update: %s", ex)
# older venstars sometimes cannot handle rapid sequential connections
await asyncio.sleep(3)
try:
sensor_success = await self.hass.async_add_executor_job(
self._client.update_sensors
)
except (OSError, RequestException) as ex:
_LOGGER.error("Exception during sensor update: %s", ex)
if not info_success or not sensor_success:
_LOGGER.error("Failed to update data")
@property
def name(self):
"""Return the name of the thermostat."""
return self._client.name
@property
def unique_id(self):
"""Set unique_id for this entity."""
return f"{self._config.entry_id}"
@property
def device_info(self):
"""Return the device information for this entity."""
return {
"identifiers": {(DOMAIN, self._config.entry_id)},
"name": self._client.name,
"manufacturer": "Venstar",
# pylint: disable=protected-access
"model": f"{self._client.model}-{self._client._type}",
# pylint: disable=protected-access
"sw_version": self._client._api_ver,
}

View File

@ -1,7 +1,4 @@
"""Support for Venstar WiFi Thermostats."""
import logging
from venstarcolortouch import VenstarColorTouch
import voluptuous as vol
from homeassistant.components.climate import PLATFORM_SCHEMA, ClimateEntity
@ -27,6 +24,7 @@ from homeassistant.components.climate.const import (
SUPPORT_TARGET_TEMPERATURE,
SUPPORT_TARGET_TEMPERATURE_RANGE,
)
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import (
ATTR_TEMPERATURE,
CONF_HOST,
@ -42,20 +40,18 @@ from homeassistant.const import (
)
import homeassistant.helpers.config_validation as cv
_LOGGER = logging.getLogger(__name__)
ATTR_FAN_STATE = "fan_state"
ATTR_HVAC_STATE = "hvac_mode"
CONF_HUMIDIFIER = "humidifier"
DEFAULT_SSL = False
VALID_FAN_STATES = [STATE_ON, HVAC_MODE_AUTO]
VALID_THERMOSTAT_MODES = [HVAC_MODE_HEAT, HVAC_MODE_COOL, HVAC_MODE_OFF, HVAC_MODE_AUTO]
HOLD_MODE_OFF = "off"
HOLD_MODE_TEMPERATURE = "temperature"
from . import VenstarEntity
from .const import (
_LOGGER,
ATTR_FAN_STATE,
ATTR_HVAC_STATE,
CONF_HUMIDIFIER,
DEFAULT_SSL,
DOMAIN,
HOLD_MODE_TEMPERATURE,
VALID_FAN_STATES,
VALID_THERMOSTAT_MODES,
)
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
@ -72,50 +68,42 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
)
def setup_platform(hass, config, add_entities, discovery_info=None):
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the Venstar thermostat."""
client = hass.data[DOMAIN][config_entry.entry_id]
async_add_entities([VenstarThermostat(config_entry, client)], True)
username = config.get(CONF_USERNAME)
password = config.get(CONF_PASSWORD)
pin = config.get(CONF_PIN)
host = config.get(CONF_HOST)
timeout = config.get(CONF_TIMEOUT)
humidifier = config.get(CONF_HUMIDIFIER)
protocol = "https" if config[CONF_SSL] else "http"
async def async_setup_platform(hass, config, add_entities, discovery_info=None):
"""Set up the Venstar thermostat platform.
client = VenstarColorTouch(
addr=host,
timeout=timeout,
user=username,
password=password,
pin=pin,
proto=protocol,
Venstar uses config flow for configuration now. If an entry exists in
configuration.yaml, the import flow will attempt to import it and create
a config entry.
"""
_LOGGER.warning(
"Loading venstar via platform config is deprecated; The configuration"
" has been migrated to a config entry and can be safely removed"
)
add_entities([VenstarThermostat(client, humidifier)], True)
# No config entry exists and configuration.yaml config exists, trigger the import flow.
if not hass.config_entries.async_entries(DOMAIN):
await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=config
)
class VenstarThermostat(ClimateEntity):
class VenstarThermostat(VenstarEntity, ClimateEntity):
"""Representation of a Venstar thermostat."""
def __init__(self, client, humidifier):
def __init__(self, config, client):
"""Initialize the thermostat."""
self._client = client
self._humidifier = humidifier
super().__init__(config, client)
self._mode_map = {
HVAC_MODE_HEAT: self._client.MODE_HEAT,
HVAC_MODE_COOL: self._client.MODE_COOL,
HVAC_MODE_AUTO: self._client.MODE_AUTO,
}
def update(self):
"""Update the data from the thermostat."""
info_success = self._client.update_info()
sensor_success = self._client.update_sensors()
if not info_success or not sensor_success:
_LOGGER.error("Failed to update data")
@property
def supported_features(self):
"""Return the list of supported features."""
@ -124,16 +112,11 @@ class VenstarThermostat(ClimateEntity):
if self._client.mode == self._client.MODE_AUTO:
features |= SUPPORT_TARGET_TEMPERATURE_RANGE
if self._humidifier and self._client.hum_setpoint is not None:
if self._client.hum_setpoint is not None:
features |= SUPPORT_TARGET_HUMIDITY
return features
@property
def name(self):
"""Return the name of the thermostat."""
return self._client.name
@property
def precision(self):
"""Return the precision of the system.

View File

@ -0,0 +1,96 @@
"""Config flow to configure the Venstar integration."""
from venstarcolortouch import VenstarColorTouch
import voluptuous as vol
from homeassistant import config_entries, core, exceptions
from homeassistant.const import (
CONF_HOST,
CONF_PASSWORD,
CONF_PIN,
CONF_SSL,
CONF_USERNAME,
)
from .const import _LOGGER, DOMAIN, VENSTAR_TIMEOUT
DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Optional(CONF_USERNAME): str,
vol.Optional(CONF_PASSWORD): str,
vol.Optional(CONF_PIN): str,
vol.Optional(CONF_SSL, default=False): bool,
}
)
async def validate_input(hass: core.HomeAssistant, data):
"""Validate the user input allows us to connect."""
username = data.get(CONF_USERNAME)
password = data.get(CONF_PASSWORD)
pin = data.get(CONF_PIN)
host = data[CONF_HOST]
timeout = VENSTAR_TIMEOUT
protocol = "https" if data[CONF_SSL] else "http"
client = VenstarColorTouch(
addr=host,
timeout=timeout,
user=username,
password=password,
pin=pin,
proto=protocol,
)
# perform a full info pull, because this calls login also.
info_success = await hass.async_add_executor_job(client.update_info)
if not info_success:
raise CannotConnect
return {"title": client.name}
class VenstarConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a venstar config flow."""
VERSION = 1
async def async_step_user(self, user_input=None):
"""Create config entry. Show the setup form to the user."""
errors = {}
info = {}
if user_input is not None:
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
try:
info = await validate_input(self.hass, user_input)
except CannotConnect:
errors["base"] = "cannot_connect"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_create_entry(title=info["title"], data=user_input)
return self.async_show_form(
step_id="user", data_schema=DATA_SCHEMA, errors=errors
)
async def async_step_import(self, import_data):
"""Import entry from configuration.yaml."""
self._async_abort_entries_match({CONF_HOST: import_data[CONF_HOST]})
return await self.async_step_user(
{
CONF_HOST: import_data[CONF_HOST],
CONF_USERNAME: import_data.get(CONF_USERNAME),
CONF_PASSWORD: import_data.get(CONF_PASSWORD),
CONF_PIN: import_data.get(CONF_PIN),
CONF_SSL: import_data[CONF_SSL],
}
)
class CannotConnect(exceptions.HomeAssistantError):
"""Error to indicate we cannot connect."""

View File

@ -0,0 +1,29 @@
"""The venstar component."""
import logging
from homeassistant.components.climate.const import (
HVAC_MODE_AUTO,
HVAC_MODE_COOL,
HVAC_MODE_HEAT,
HVAC_MODE_OFF,
)
from homeassistant.const import STATE_ON
DOMAIN = "venstar"
ATTR_FAN_STATE = "fan_state"
ATTR_HVAC_STATE = "hvac_mode"
CONF_HUMIDIFIER = "humidifier"
DEFAULT_SSL = False
VALID_FAN_STATES = [STATE_ON, HVAC_MODE_AUTO]
VALID_THERMOSTAT_MODES = [HVAC_MODE_HEAT, HVAC_MODE_COOL, HVAC_MODE_OFF, HVAC_MODE_AUTO]
HOLD_MODE_OFF = "off"
HOLD_MODE_TEMPERATURE = "temperature"
VENSTAR_TIMEOUT = 5
_LOGGER = logging.getLogger(__name__)

View File

@ -1,8 +1,11 @@
{
"domain": "venstar",
"name": "Venstar",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/venstar",
"requirements": ["venstarcolortouch==0.14"],
"codeowners": [],
"requirements": [
"venstarcolortouch==0.14"
],
"codeowners": ["@garbled1"],
"iot_class": "local_polling"
}

View File

@ -0,0 +1,23 @@
{
"config": {
"step": {
"user": {
"title": "Connect to the Venstar Thermostat",
"data": {
"host": "[%key:common::config_flow::data::host%]",
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]",
"pin": "[%key:common::config_flow::data::pin%]",
"ssl": "[%key:common::config_flow::data::ssl%]"
}
}
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
}
}
}

View File

@ -0,0 +1,20 @@
{
"config": {
"step": {
"user": {
"title": "Connect to the Venstar Thermostat",
"data": {
"host": "Hostname or IP",
"username": "Username for thermostat (optional)",
"password": "Password for thermostat (optional)",
"pin": "Pin for Lockscreen (required if lock screen enabled)",
"ssl": "Whether to use SSL or not when communicating"
}
}
},
"error": {
"cannot_connect": "Unable to connect to thermostat, please validate username/password if supplied, hostname/ip, and that LOCAL API is enabled on the thermostat.",
"unknown": "An unknown error has occurred."
}
}
}

View File

@ -306,6 +306,7 @@ FLOWS = [
"upnp",
"uptimerobot",
"velbus",
"venstar",
"vera",
"verisure",
"vesync",

View File

@ -1 +1,62 @@
"""Tests for the venstar integration."""
from requests import RequestException
class VenstarColorTouchMock:
"""Mock Venstar Library."""
def __init__(
self,
addr,
timeout,
user=None,
password=None,
pin=None,
proto="http",
SSLCert=False,
):
"""Initialize the Venstar library."""
self.status = {}
self.model = "COLORTOUCH"
self._api_ver = 5
self.name = "TestVenstar"
self._info = {}
self._sensors = {}
self.alerts = {}
self.MODE_OFF = 0
self.MODE_HEAT = 1
self.MODE_COOL = 2
self.MODE_AUTO = 3
self._type = "residential"
def login(self):
"""Mock login."""
return True
def _request(self, path, data=None):
"""Mock request."""
self.status = {}
def update(self):
"""Mock update."""
return True
def update_info(self):
"""Mock update_info."""
return True
def broken_update_info(self):
"""Mock a update_info that raises Exception."""
raise RequestException
def update_sensors(self):
"""Mock update_sensors."""
return True
def update_runtimes(self):
"""Mock update_runtimes."""
return True
def update_alerts(self):
"""Mock update_alerts."""
return True

View File

@ -0,0 +1,128 @@
"""Test the Venstar config flow."""
import logging
from unittest.mock import patch
from homeassistant import config_entries
from homeassistant.components.venstar.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import (
CONF_HOST,
CONF_PASSWORD,
CONF_PIN,
CONF_SSL,
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import (
RESULT_TYPE_ABORT,
RESULT_TYPE_CREATE_ENTRY,
RESULT_TYPE_FORM,
)
from . import VenstarColorTouchMock
from tests.common import MockConfigEntry
_LOGGER = logging.getLogger(__name__)
TEST_DATA = {
CONF_HOST: "1.1.1.1",
CONF_USERNAME: "test-username",
CONF_PASSWORD: "test-password",
CONF_PIN: "test-pin",
CONF_SSL: False,
}
TEST_ID = "VenstarUniqueID"
async def test_form(hass: HomeAssistant) -> None:
"""Test we get the form."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == RESULT_TYPE_FORM
assert result["errors"] == {}
with patch(
"homeassistant.components.venstar.config_flow.VenstarColorTouch.update_info",
new=VenstarColorTouchMock.update_info,
), patch(
"homeassistant.components.venstar.async_setup_entry",
return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
TEST_DATA,
)
await hass.async_block_till_done()
assert result2["type"] == RESULT_TYPE_CREATE_ENTRY
assert result2["data"] == TEST_DATA
assert len(mock_setup_entry.mock_calls) == 1
async def test_form_cannot_connect(hass: HomeAssistant) -> None:
"""Test we handle cannot connect error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"homeassistant.components.venstar.config_flow.VenstarColorTouch.update_info",
return_value=False,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
TEST_DATA,
)
assert result2["type"] == RESULT_TYPE_FORM
assert result2["errors"] == {"base": "cannot_connect"}
async def test_unknown_error(hass: HomeAssistant) -> None:
"""Test we handle unknown error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"homeassistant.components.venstar.config_flow.VenstarColorTouch.update_info",
side_effect=Exception,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
TEST_DATA,
)
assert result2["type"] == RESULT_TYPE_FORM
assert result2["errors"] == {"base": "unknown"}
async def test_already_configured(hass: HomeAssistant) -> None:
"""Test when provided credentials are already configured."""
MockConfigEntry(domain=DOMAIN, data=TEST_DATA, unique_id=TEST_ID).add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == RESULT_TYPE_FORM
assert result["step_id"] == SOURCE_USER
with patch(
"homeassistant.components.venstar.VenstarColorTouch.update_info",
new=VenstarColorTouchMock.update_info,
), patch(
"homeassistant.components.venstar.async_setup_entry",
return_value=True,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
TEST_DATA,
)
await hass.async_block_till_done()
assert result2["type"] == RESULT_TYPE_ABORT
assert result2["reason"] == "already_configured"

View File

@ -0,0 +1,71 @@
"""Tests of the initialization of the venstar integration."""
from unittest.mock import patch
from homeassistant.components.venstar.const import DOMAIN as VENSTAR_DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_HOST, CONF_SSL
from homeassistant.core import HomeAssistant
from . import VenstarColorTouchMock
from tests.common import MockConfigEntry
TEST_HOST = "venstartest.localdomain"
async def test_setup_entry(hass: HomeAssistant):
"""Validate that setup entry also configure the client."""
config_entry = MockConfigEntry(
domain=VENSTAR_DOMAIN,
data={
CONF_HOST: TEST_HOST,
CONF_SSL: False,
},
)
config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.venstar.VenstarColorTouch._request",
new=VenstarColorTouchMock._request,
), patch(
"homeassistant.components.venstar.VenstarColorTouch.update_sensors",
new=VenstarColorTouchMock.update_sensors,
), patch(
"homeassistant.components.venstar.VenstarColorTouch.update_info",
new=VenstarColorTouchMock.update_info,
):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state == ConfigEntryState.LOADED
await hass.config_entries.async_unload(config_entry.entry_id)
assert config_entry.state == ConfigEntryState.NOT_LOADED
async def test_setup_entry_exception(hass: HomeAssistant):
"""Validate that setup entry also configure the client."""
config_entry = MockConfigEntry(
domain=VENSTAR_DOMAIN,
data={
CONF_HOST: TEST_HOST,
CONF_SSL: False,
},
)
config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.venstar.VenstarColorTouch._request",
new=VenstarColorTouchMock._request,
), patch(
"homeassistant.components.venstar.VenstarColorTouch.update_sensors",
new=VenstarColorTouchMock.update_sensors,
), patch(
"homeassistant.components.venstar.VenstarColorTouch.update_info",
new=VenstarColorTouchMock.broken_update_info,
):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state == ConfigEntryState.SETUP_RETRY