Add config flow for Tuya (#35422)

* Added Tuya config flow

* Added test config_flow

* Fixed log error message

* Add test requirements

* Lint Fix

* Fix Black formatting

* Added pylint directive

Added pylint:disable=unused-import in config_flow.py

* Implementation requested changes

* Update CodeOwners

* Removed device registry cleanup

* Force checks

* Force checks

* Fix implemetation

- Set config schema "deprecated"
- Removed async from update_callback

* Updating test

* Fix formatting

* Config Flow test fix

- mock out async_setup and async_setup_entry

* Fix formatting
This commit is contained in:
ollo69 2020-05-11 05:01:00 +02:00 committed by GitHub
parent c69d4943a5
commit b3ee54b124
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 757 additions and 210 deletions

View File

@ -417,6 +417,7 @@ homeassistant/components/tradfri/* @ggravlingen
homeassistant/components/trafikverket_train/* @endor-force
homeassistant/components/transmission/* @engrbm87 @JPHutchins
homeassistant/components/tts/* @pvizeli
homeassistant/components/tuya/* @ollo69
homeassistant/components/twentemilieu/* @frenck
homeassistant/components/twilio_call/* @robbiet480
homeassistant/components/twilio_sms/* @robbiet480

View File

@ -1,4 +1,5 @@
"""Support for Tuya Smart devices."""
import asyncio
from datetime import timedelta
import logging
@ -6,32 +7,38 @@ from tuyaha import TuyaApi
from tuyaha.tuyaapi import TuyaAPIException, TuyaNetException, TuyaServerException
import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import CONF_PASSWORD, CONF_PLATFORM, CONF_USERNAME
from homeassistant.core import callback
from homeassistant.helpers import discovery
from homeassistant.exceptions import ConfigEntryNotReady
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.event import call_later, track_time_interval
from homeassistant.helpers.event import async_track_time_interval
from .const import (
CONF_COUNTRYCODE,
DOMAIN,
TUYA_DATA,
TUYA_DISCOVERY_NEW,
TUYA_PLATFORMS,
)
_LOGGER = logging.getLogger(__name__)
CONF_COUNTRYCODE = "country_code"
ENTRY_IS_SETUP = "tuya_entry_is_setup"
PARALLEL_UPDATES = 0
DOMAIN = "tuya"
DATA_TUYA = "data_tuya"
FIRST_RETRY_TIME = 60
MAX_RETRY_TIME = 900
SERVICE_FORCE_UPDATE = "force_update"
SERVICE_PULL_DEVICES = "pull_devices"
SIGNAL_DELETE_ENTITY = "tuya_delete"
SIGNAL_UPDATE_ENTITY = "tuya_update"
SERVICE_FORCE_UPDATE = "force_update"
SERVICE_PULL_DEVICES = "pull_devices"
TUYA_TYPE_TO_HA = {
"climate": "climate",
"cover": "cover",
@ -41,48 +48,55 @@ TUYA_TYPE_TO_HA = {
"switch": "switch",
}
TUYA_TRACKER = "tuya_tracker"
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
{
vol.Required(CONF_PASSWORD): cv.string,
vol.Required(CONF_USERNAME): cv.string,
vol.Required(CONF_COUNTRYCODE): cv.string,
vol.Optional(CONF_PLATFORM, default="tuya"): cv.string,
}
)
},
vol.All(
cv.deprecated(DOMAIN),
{
DOMAIN: vol.Schema(
{
vol.Required(CONF_PASSWORD): cv.string,
vol.Required(CONF_USERNAME): cv.string,
vol.Required(CONF_COUNTRYCODE): cv.string,
vol.Optional(CONF_PLATFORM, default="tuya"): cv.string,
}
)
},
),
extra=vol.ALLOW_EXTRA,
)
def setup(hass, config, retry_delay=FIRST_RETRY_TIME):
"""Set up Tuya Component."""
async def async_setup(hass, config):
"""Set up the Tuya integration."""
_LOGGER.debug("Setting up integration")
tuya = TuyaApi()
username = config[DOMAIN][CONF_USERNAME]
password = config[DOMAIN][CONF_PASSWORD]
country_code = config[DOMAIN][CONF_COUNTRYCODE]
platform = config[DOMAIN][CONF_PLATFORM]
try:
tuya.init(username, password, country_code, platform)
except (TuyaNetException, TuyaServerException):
_LOGGER.warning(
"Connection error during integration setup. Will retry in %s seconds",
retry_delay,
conf = config.get(DOMAIN)
if conf is not None:
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=conf
)
)
def retry_setup(now):
"""Retry setup if a error happens on tuya API."""
setup(hass, config, retry_delay=min(2 * retry_delay, MAX_RETRY_TIME))
return True
call_later(hass, retry_delay, retry_setup)
return True
async def async_setup_entry(hass, entry):
"""Set up Tuya platform."""
tuya = TuyaApi()
username = entry.data[CONF_USERNAME]
password = entry.data[CONF_PASSWORD]
country_code = entry.data[CONF_COUNTRYCODE]
platform = entry.data[CONF_PLATFORM]
try:
await hass.async_add_executor_job(
tuya.init, username, password, country_code, platform
)
except (TuyaNetException, TuyaServerException):
raise ConfigEntryNotReady()
except TuyaAPIException as exc:
_LOGGER.error(
@ -90,10 +104,15 @@ def setup(hass, config, retry_delay=FIRST_RETRY_TIME):
)
return False
hass.data[DATA_TUYA] = tuya
hass.data[DOMAIN] = {"entities": {}}
hass.data[DOMAIN] = {
TUYA_DATA: tuya,
TUYA_TRACKER: None,
ENTRY_IS_SETUP: set(),
"entities": {},
"pending": {},
}
def load_devices(device_list):
async def async_load_devices(device_list):
"""Load new devices by device_list."""
device_type_list = {}
for device in device_list:
@ -107,51 +126,92 @@ def setup(hass, config, retry_delay=FIRST_RETRY_TIME):
device_type_list[ha_type] = []
device_type_list[ha_type].append(device.object_id())
hass.data[DOMAIN]["entities"][device.object_id()] = None
for ha_type, dev_ids in device_type_list.items():
discovery.load_platform(hass, ha_type, DOMAIN, {"dev_ids": dev_ids}, config)
config_entries_key = f"{ha_type}.tuya"
if config_entries_key not in hass.data[DOMAIN][ENTRY_IS_SETUP]:
hass.data[DOMAIN]["pending"][ha_type] = dev_ids
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, ha_type)
)
hass.data[DOMAIN][ENTRY_IS_SETUP].add(config_entries_key)
else:
async_dispatcher_send(hass, TUYA_DISCOVERY_NEW.format(ha_type), dev_ids)
device_list = tuya.get_all_devices()
load_devices(device_list)
device_list = await hass.async_add_executor_job(tuya.get_all_devices)
await async_load_devices(device_list)
def poll_devices_update(event_time):
def _get_updated_devices():
tuya.poll_devices_update()
return tuya.get_all_devices()
async def async_poll_devices_update(event_time):
"""Check if accesstoken is expired and pull device list from server."""
_LOGGER.debug("Pull devices from Tuya.")
tuya.poll_devices_update()
# Add new discover device.
device_list = tuya.get_all_devices()
load_devices(device_list)
device_list = await hass.async_add_executor_job(_get_updated_devices)
await async_load_devices(device_list)
# Delete not exist device.
newlist_ids = []
for device in device_list:
newlist_ids.append(device.object_id())
for dev_id in list(hass.data[DOMAIN]["entities"]):
if dev_id not in newlist_ids:
dispatcher_send(hass, SIGNAL_DELETE_ENTITY, dev_id)
async_dispatcher_send(hass, SIGNAL_DELETE_ENTITY, dev_id)
hass.data[DOMAIN]["entities"].pop(dev_id)
track_time_interval(hass, poll_devices_update, timedelta(minutes=5))
hass.data[DOMAIN][TUYA_TRACKER] = async_track_time_interval(
hass, async_poll_devices_update, timedelta(minutes=5)
)
hass.services.register(DOMAIN, SERVICE_PULL_DEVICES, poll_devices_update)
hass.services.async_register(
DOMAIN, SERVICE_PULL_DEVICES, async_poll_devices_update
)
def force_update(call):
async def async_force_update(call):
"""Force all devices to pull data."""
dispatcher_send(hass, SIGNAL_UPDATE_ENTITY)
async_dispatcher_send(hass, SIGNAL_UPDATE_ENTITY)
hass.services.register(DOMAIN, SERVICE_FORCE_UPDATE, force_update)
hass.services.async_register(DOMAIN, SERVICE_FORCE_UPDATE, async_force_update)
return True
async def async_unload_entry(hass, entry):
"""Unloading the Tuya platforms."""
unload_ok = all(
await asyncio.gather(
*[
hass.config_entries.async_forward_entry_unload(
entry, component.split(".", 1)[0]
)
for component in hass.data[DOMAIN][ENTRY_IS_SETUP]
]
)
)
if unload_ok:
hass.data[DOMAIN][ENTRY_IS_SETUP] = set()
hass.data[DOMAIN][TUYA_TRACKER]()
hass.data[DOMAIN][TUYA_TRACKER] = None
hass.data[DOMAIN][TUYA_DATA] = None
hass.services.async_remove(DOMAIN, SERVICE_FORCE_UPDATE)
hass.services.async_remove(DOMAIN, SERVICE_PULL_DEVICES)
hass.data.pop(DOMAIN)
return unload_ok
class TuyaDevice(Entity):
"""Tuya base device."""
def __init__(self, tuya):
def __init__(self, tuya, platform):
"""Init Tuya devices."""
self.tuya = tuya
self._tuya = tuya
self._platform = platform
async def async_added_to_hass(self):
"""Call when entity is added to hass."""
dev_id = self.tuya.object_id()
dev_id = self._tuya.object_id()
self.hass.data[DOMAIN]["entities"][dev_id] = self.entity_id
async_dispatcher_connect(self.hass, SIGNAL_DELETE_ENTITY, self._delete_callback)
async_dispatcher_connect(self.hass, SIGNAL_UPDATE_ENTITY, self._update_callback)
@ -159,32 +219,49 @@ class TuyaDevice(Entity):
@property
def object_id(self):
"""Return Tuya device id."""
return self.tuya.object_id()
return self._tuya.object_id()
@property
def unique_id(self):
"""Return a unique ID."""
return f"tuya.{self.tuya.object_id()}"
return f"tuya.{self._tuya.object_id()}"
@property
def name(self):
"""Return Tuya device name."""
return self.tuya.name()
return self._tuya.name()
@property
def available(self):
"""Return if the device is available."""
return self.tuya.available()
return self._tuya.available()
@property
def device_info(self):
"""Return a device description for device registry."""
_device_info = {
"identifiers": {(DOMAIN, f"{self.unique_id}")},
"manufacturer": TUYA_PLATFORMS.get(self._platform, self._platform),
"name": self.name,
"model": self._tuya.object_type(),
}
return _device_info
def update(self):
"""Refresh Tuya device data."""
self.tuya.update()
self._tuya.update()
@callback
def _delete_callback(self, dev_id):
async def _delete_callback(self, dev_id):
"""Remove this entity."""
if dev_id == self.object_id:
self.hass.async_create_task(self.async_remove())
entity_registry = (
await self.hass.helpers.entity_registry.async_get_registry()
)
if entity_registry.async_is_registered(self.entity_id):
entity_registry.async_remove(self.entity_id)
else:
await self.async_remove()
@callback
def _update_callback(self):

View File

@ -1,5 +1,9 @@
"""Support for the Tuya climate devices."""
from homeassistant.components.climate import ENTITY_ID_FORMAT, ClimateEntity
from homeassistant.components.climate import (
DOMAIN as SENSOR_DOMAIN,
ENTITY_ID_FORMAT,
ClimateEntity,
)
from homeassistant.components.climate.const import (
FAN_HIGH,
FAN_LOW,
@ -14,12 +18,15 @@ from homeassistant.components.climate.const import (
)
from homeassistant.const import (
ATTR_TEMPERATURE,
CONF_PLATFORM,
PRECISION_WHOLE,
TEMP_CELSIUS,
TEMP_FAHRENHEIT,
)
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from . import DATA_TUYA, TuyaDevice
from . import TuyaDevice
from .const import DOMAIN, TUYA_DATA, TUYA_DISCOVERY_NEW
DEVICE_TYPE = "climate"
@ -37,34 +44,53 @@ TUYA_STATE_TO_HA = {value: key for key, value in HA_STATE_TO_TUYA.items()}
FAN_MODES = {FAN_LOW, FAN_MEDIUM, FAN_HIGH}
def setup_platform(hass, config, add_entities, discovery_info=None):
"""Set up Tuya Climate devices."""
if discovery_info is None:
return
tuya = hass.data[DATA_TUYA]
dev_ids = discovery_info.get("dev_ids")
devices = []
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up tuya sensors dynamically through tuya discovery."""
platform = config_entry.data[CONF_PLATFORM]
async def async_discover_sensor(dev_ids):
"""Discover and add a discovered tuya sensor."""
if not dev_ids:
return
entities = await hass.async_add_executor_job(
_setup_entities, hass, dev_ids, platform,
)
async_add_entities(entities)
async_dispatcher_connect(
hass, TUYA_DISCOVERY_NEW.format(SENSOR_DOMAIN), async_discover_sensor
)
devices_ids = hass.data[DOMAIN]["pending"].pop(SENSOR_DOMAIN)
await async_discover_sensor(devices_ids)
def _setup_entities(hass, dev_ids, platform):
"""Set up Tuya Climate device."""
tuya = hass.data[DOMAIN][TUYA_DATA]
entities = []
for dev_id in dev_ids:
device = tuya.get_device_by_id(dev_id)
if device is None:
entity = tuya.get_device_by_id(dev_id)
if entity is None:
continue
devices.append(TuyaClimateEntity(device))
add_entities(devices)
entities.append(TuyaClimateEntity(entity, platform))
return entities
class TuyaClimateEntity(TuyaDevice, ClimateEntity):
"""Tuya climate devices,include air conditioner,heater."""
def __init__(self, tuya):
def __init__(self, tuya, platform):
"""Init climate device."""
super().__init__(tuya)
super().__init__(tuya, platform)
self.entity_id = ENTITY_ID_FORMAT.format(tuya.object_id())
self.operations = [HVAC_MODE_OFF]
async def async_added_to_hass(self):
"""Create operation list when add to hass."""
await super().async_added_to_hass()
modes = self.tuya.operation_list()
modes = self._tuya.operation_list()
if modes is None:
return
@ -80,7 +106,7 @@ class TuyaClimateEntity(TuyaDevice, ClimateEntity):
@property
def temperature_unit(self):
"""Return the unit of measurement used by the platform."""
unit = self.tuya.temperature_unit()
unit = self._tuya.temperature_unit()
if unit == "FAHRENHEIT":
return TEMP_FAHRENHEIT
return TEMP_CELSIUS
@ -88,10 +114,10 @@ class TuyaClimateEntity(TuyaDevice, ClimateEntity):
@property
def hvac_mode(self):
"""Return current operation ie. heat, cool, idle."""
if not self.tuya.state():
if not self._tuya.state():
return HVAC_MODE_OFF
mode = self.tuya.current_operation()
mode = self._tuya.current_operation()
if mode is None:
return None
return TUYA_STATE_TO_HA.get(mode)
@ -104,63 +130,63 @@ class TuyaClimateEntity(TuyaDevice, ClimateEntity):
@property
def current_temperature(self):
"""Return the current temperature."""
return self.tuya.current_temperature()
return self._tuya.current_temperature()
@property
def target_temperature(self):
"""Return the temperature we try to reach."""
return self.tuya.target_temperature()
return self._tuya.target_temperature()
@property
def target_temperature_step(self):
"""Return the supported step of target temperature."""
return self.tuya.target_temperature_step()
return self._tuya.target_temperature_step()
@property
def fan_mode(self):
"""Return the fan setting."""
return self.tuya.current_fan_mode()
return self._tuya.current_fan_mode()
@property
def fan_modes(self):
"""Return the list of available fan modes."""
return self.tuya.fan_list()
return self._tuya.fan_list()
def set_temperature(self, **kwargs):
"""Set new target temperature."""
if ATTR_TEMPERATURE in kwargs:
self.tuya.set_temperature(kwargs[ATTR_TEMPERATURE])
self._tuya.set_temperature(kwargs[ATTR_TEMPERATURE])
def set_fan_mode(self, fan_mode):
"""Set new target fan mode."""
self.tuya.set_fan_mode(fan_mode)
self._tuya.set_fan_mode(fan_mode)
def set_hvac_mode(self, hvac_mode):
"""Set new target operation mode."""
if hvac_mode == HVAC_MODE_OFF:
self.tuya.turn_off()
self._tuya.turn_off()
if not self.tuya.state():
self.tuya.turn_on()
if not self._tuya.state():
self._tuya.turn_on()
self.tuya.set_operation_mode(HA_STATE_TO_TUYA.get(hvac_mode))
self._tuya.set_operation_mode(HA_STATE_TO_TUYA.get(hvac_mode))
@property
def supported_features(self):
"""Return the list of supported features."""
supports = 0
if self.tuya.support_target_temperature():
if self._tuya.support_target_temperature():
supports = supports | SUPPORT_TARGET_TEMPERATURE
if self.tuya.support_wind_speed():
if self._tuya.support_wind_speed():
supports = supports | SUPPORT_FAN_MODE
return supports
@property
def min_temp(self):
"""Return the minimum temperature."""
return self.tuya.min_temp()
return self._tuya.min_temp()
@property
def max_temp(self):
"""Return the maximum temperature."""
return self.tuya.max_temp()
return self._tuya.max_temp()

View File

@ -0,0 +1,108 @@
"""Config flow for Tuya."""
import logging
from tuyaha import TuyaApi
from tuyaha.tuyaapi import TuyaAPIException, TuyaNetException, TuyaServerException
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_PASSWORD, CONF_PLATFORM, CONF_USERNAME
# pylint:disable=unused-import
from .const import CONF_COUNTRYCODE, DOMAIN, TUYA_PLATFORMS
_LOGGER = logging.getLogger(__name__)
DATA_SCHEMA_USER = vol.Schema(
{
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
vol.Required(CONF_COUNTRYCODE): vol.Coerce(int),
vol.Required(CONF_PLATFORM): vol.In(TUYA_PLATFORMS),
}
)
RESULT_AUTH_FAILED = "auth_failed"
RESULT_CONN_ERROR = "conn_error"
RESULT_SUCCESS = "success"
RESULT_LOG_MESSAGE = {
RESULT_AUTH_FAILED: "Invalid credential",
RESULT_CONN_ERROR: "Connection error",
}
class TuyaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a tuya config flow."""
VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
def __init__(self):
"""Initialize flow."""
self._country_code = None
self._password = None
self._platform = None
self._username = None
self._is_import = False
def _get_entry(self):
return self.async_create_entry(
title=self._username,
data={
CONF_COUNTRYCODE: self._country_code,
CONF_PASSWORD: self._password,
CONF_PLATFORM: self._platform,
CONF_USERNAME: self._username,
},
)
def _try_connect(self):
"""Try to connect and check auth."""
tuya = TuyaApi()
try:
tuya.init(
self._username, self._password, self._country_code, self._platform
)
except (TuyaNetException, TuyaServerException):
return RESULT_CONN_ERROR
except TuyaAPIException:
return RESULT_AUTH_FAILED
return RESULT_SUCCESS
async def async_step_import(self, user_input=None):
"""Handle configuration by yaml file."""
self._is_import = True
return await self.async_step_user(user_input)
async def async_step_user(self, user_input=None):
"""Handle a flow initialized by the user."""
if self._async_current_entries():
return self.async_abort(reason="single_instance_allowed")
errors = {}
if user_input is not None:
self._country_code = str(user_input[CONF_COUNTRYCODE])
self._password = user_input[CONF_PASSWORD]
self._platform = user_input[CONF_PLATFORM]
self._username = user_input[CONF_USERNAME]
result = await self.hass.async_add_executor_job(self._try_connect)
if result == RESULT_SUCCESS:
return self._get_entry()
if result != RESULT_AUTH_FAILED or self._is_import:
if self._is_import:
_LOGGER.error(
"Error importing from configuration.yaml: %s",
RESULT_LOG_MESSAGE.get(result, "Generic Error"),
)
return self.async_abort(reason=result)
errors["base"] = result
return self.async_show_form(
step_id="user", data_schema=DATA_SCHEMA_USER, errors=errors
)

View File

@ -0,0 +1,14 @@
"""Constants for the Tuya integration."""
CONF_COUNTRYCODE = "country_code"
DOMAIN = "tuya"
TUYA_DATA = "tuya_data"
TUYA_DISCOVERY_NEW = "tuya_discovery_new_{}"
TUYA_PLATFORMS = {
"tuya": "Tuya",
"smart_life": "Smart Life",
"jinvoo_smart": "Jinvoo Smart",
}

View File

@ -1,52 +1,75 @@
"""Support for Tuya covers."""
from homeassistant.components.cover import (
DOMAIN as SENSOR_DOMAIN,
ENTITY_ID_FORMAT,
SUPPORT_CLOSE,
SUPPORT_OPEN,
SUPPORT_STOP,
CoverEntity,
)
from homeassistant.const import CONF_PLATFORM
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from . import DATA_TUYA, TuyaDevice
from . import TuyaDevice
from .const import DOMAIN, TUYA_DATA, TUYA_DISCOVERY_NEW
PARALLEL_UPDATES = 0
def setup_platform(hass, config, add_entities, discovery_info=None):
"""Set up Tuya cover devices."""
if discovery_info is None:
return
tuya = hass.data[DATA_TUYA]
dev_ids = discovery_info.get("dev_ids")
devices = []
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up tuya sensors dynamically through tuya discovery."""
platform = config_entry.data[CONF_PLATFORM]
async def async_discover_sensor(dev_ids):
"""Discover and add a discovered tuya sensor."""
if not dev_ids:
return
entities = await hass.async_add_executor_job(
_setup_entities, hass, dev_ids, platform,
)
async_add_entities(entities)
async_dispatcher_connect(
hass, TUYA_DISCOVERY_NEW.format(SENSOR_DOMAIN), async_discover_sensor
)
devices_ids = hass.data[DOMAIN]["pending"].pop(SENSOR_DOMAIN)
await async_discover_sensor(devices_ids)
def _setup_entities(hass, dev_ids, platform):
"""Set up Tuya Cover device."""
tuya = hass.data[DOMAIN][TUYA_DATA]
entities = []
for dev_id in dev_ids:
device = tuya.get_device_by_id(dev_id)
if device is None:
entity = tuya.get_device_by_id(dev_id)
if entity is None:
continue
devices.append(TuyaCover(device))
add_entities(devices)
entities.append(TuyaCover(entity, platform))
return entities
class TuyaCover(TuyaDevice, CoverEntity):
"""Tuya cover devices."""
def __init__(self, tuya):
def __init__(self, tuya, platform):
"""Init tuya cover device."""
super().__init__(tuya)
super().__init__(tuya, platform)
self.entity_id = ENTITY_ID_FORMAT.format(tuya.object_id())
@property
def supported_features(self):
"""Flag supported features."""
supported_features = SUPPORT_OPEN | SUPPORT_CLOSE
if self.tuya.support_stop():
if self._tuya.support_stop():
supported_features |= SUPPORT_STOP
return supported_features
@property
def is_closed(self):
"""Return if the cover is closed or not."""
state = self.tuya.state()
state = self._tuya.state()
if state == 1:
return False
if state == 2:
@ -55,12 +78,12 @@ class TuyaCover(TuyaDevice, CoverEntity):
def open_cover(self, **kwargs):
"""Open the cover."""
self.tuya.open_cover()
self._tuya.open_cover()
def close_cover(self, **kwargs):
"""Close cover."""
self.tuya.close_cover()
self._tuya.close_cover()
def stop_cover(self, **kwargs):
"""Stop the cover."""
self.tuya.stop_cover()
self._tuya.stop_cover()

View File

@ -1,67 +1,89 @@
"""Support for Tuya fans."""
from homeassistant.components.fan import (
DOMAIN as SENSOR_DOMAIN,
ENTITY_ID_FORMAT,
SUPPORT_OSCILLATE,
SUPPORT_SET_SPEED,
FanEntity,
)
from homeassistant.const import STATE_OFF
from homeassistant.const import CONF_PLATFORM, STATE_OFF
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from . import DATA_TUYA, TuyaDevice
from . import TuyaDevice
from .const import DOMAIN, TUYA_DATA, TUYA_DISCOVERY_NEW
PARALLEL_UPDATES = 0
def setup_platform(hass, config, add_entities, discovery_info=None):
"""Set up Tuya fan platform."""
if discovery_info is None:
return
tuya = hass.data[DATA_TUYA]
dev_ids = discovery_info.get("dev_ids")
devices = []
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up tuya sensors dynamically through tuya discovery."""
platform = config_entry.data[CONF_PLATFORM]
async def async_discover_sensor(dev_ids):
"""Discover and add a discovered tuya sensor."""
if not dev_ids:
return
entities = await hass.async_add_executor_job(
_setup_entities, hass, dev_ids, platform,
)
async_add_entities(entities)
async_dispatcher_connect(
hass, TUYA_DISCOVERY_NEW.format(SENSOR_DOMAIN), async_discover_sensor
)
devices_ids = hass.data[DOMAIN]["pending"].pop(SENSOR_DOMAIN)
await async_discover_sensor(devices_ids)
def _setup_entities(hass, dev_ids, platform):
"""Set up Tuya Fan device."""
tuya = hass.data[DOMAIN][TUYA_DATA]
entities = []
for dev_id in dev_ids:
device = tuya.get_device_by_id(dev_id)
if device is None:
entity = tuya.get_device_by_id(dev_id)
if entity is None:
continue
devices.append(TuyaFanDevice(device))
add_entities(devices)
entities.append(TuyaFanDevice(entity, platform))
return entities
class TuyaFanDevice(TuyaDevice, FanEntity):
"""Tuya fan devices."""
def __init__(self, tuya):
def __init__(self, tuya, platform):
"""Init Tuya fan device."""
super().__init__(tuya)
super().__init__(tuya, platform)
self.entity_id = ENTITY_ID_FORMAT.format(tuya.object_id())
self.speeds = [STATE_OFF]
async def async_added_to_hass(self):
"""Create fan list when add to hass."""
await super().async_added_to_hass()
self.speeds.extend(self.tuya.speed_list())
self.speeds.extend(self._tuya.speed_list())
def set_speed(self, speed: str) -> None:
"""Set the speed of the fan."""
if speed == STATE_OFF:
self.turn_off()
else:
self.tuya.set_speed(speed)
self._tuya.set_speed(speed)
def turn_on(self, speed: str = None, **kwargs) -> None:
"""Turn on the fan."""
if speed is not None:
self.set_speed(speed)
else:
self.tuya.turn_on()
self._tuya.turn_on()
def turn_off(self, **kwargs) -> None:
"""Turn the entity off."""
self.tuya.turn_off()
self._tuya.turn_off()
def oscillate(self, oscillating) -> None:
"""Oscillate the fan."""
self.tuya.oscillate(oscillating)
self._tuya.oscillate(oscillating)
@property
def oscillating(self):
@ -70,18 +92,18 @@ class TuyaFanDevice(TuyaDevice, FanEntity):
return None
if self.speed == STATE_OFF:
return False
return self.tuya.oscillating()
return self._tuya.oscillating()
@property
def is_on(self):
"""Return true if the entity is on."""
return self.tuya.state()
return self._tuya.state()
@property
def speed(self) -> str:
"""Return the current speed."""
if self.is_on:
return self.tuya.speed()
return self._tuya.speed()
return STATE_OFF
@property
@ -93,6 +115,6 @@ class TuyaFanDevice(TuyaDevice, FanEntity):
def supported_features(self) -> int:
"""Flag supported features."""
supports = SUPPORT_SET_SPEED
if self.tuya.support_oscillate():
if self._tuya.support_oscillate():
supports = supports | SUPPORT_OSCILLATE
return supports

View File

@ -3,58 +3,81 @@ from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP,
ATTR_HS_COLOR,
DOMAIN as SENSOR_DOMAIN,
ENTITY_ID_FORMAT,
SUPPORT_BRIGHTNESS,
SUPPORT_COLOR,
SUPPORT_COLOR_TEMP,
LightEntity,
)
from homeassistant.const import CONF_PLATFORM
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.util import color as colorutil
from . import DATA_TUYA, TuyaDevice
from . import TuyaDevice
from .const import DOMAIN, TUYA_DATA, TUYA_DISCOVERY_NEW
PARALLEL_UPDATES = 0
def setup_platform(hass, config, add_entities, discovery_info=None):
"""Set up Tuya light platform."""
if discovery_info is None:
return
tuya = hass.data[DATA_TUYA]
dev_ids = discovery_info.get("dev_ids")
devices = []
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up tuya sensors dynamically through tuya discovery."""
platform = config_entry.data[CONF_PLATFORM]
async def async_discover_sensor(dev_ids):
"""Discover and add a discovered tuya sensor."""
if not dev_ids:
return
entities = await hass.async_add_executor_job(
_setup_entities, hass, dev_ids, platform,
)
async_add_entities(entities)
async_dispatcher_connect(
hass, TUYA_DISCOVERY_NEW.format(SENSOR_DOMAIN), async_discover_sensor
)
devices_ids = hass.data[DOMAIN]["pending"].pop(SENSOR_DOMAIN)
await async_discover_sensor(devices_ids)
def _setup_entities(hass, dev_ids, platform):
"""Set up Tuya Light device."""
tuya = hass.data[DOMAIN][TUYA_DATA]
entities = []
for dev_id in dev_ids:
device = tuya.get_device_by_id(dev_id)
if device is None:
entity = tuya.get_device_by_id(dev_id)
if entity is None:
continue
devices.append(TuyaLight(device))
add_entities(devices)
entities.append(TuyaLight(entity, platform))
return entities
class TuyaLight(TuyaDevice, LightEntity):
"""Tuya light device."""
def __init__(self, tuya):
def __init__(self, tuya, platform):
"""Init Tuya light device."""
super().__init__(tuya)
super().__init__(tuya, platform)
self.entity_id = ENTITY_ID_FORMAT.format(tuya.object_id())
@property
def brightness(self):
"""Return the brightness of the light."""
if self.tuya.brightness() is None:
if self._tuya.brightness() is None:
return None
return int(self.tuya.brightness())
return int(self._tuya.brightness())
@property
def hs_color(self):
"""Return the hs_color of the light."""
return tuple(map(int, self.tuya.hs_color()))
return tuple(map(int, self._tuya.hs_color()))
@property
def color_temp(self):
"""Return the color_temp of the light."""
color_temp = int(self.tuya.color_temp())
color_temp = int(self._tuya.color_temp())
if color_temp is None:
return None
return colorutil.color_temperature_kelvin_to_mired(color_temp)
@ -62,17 +85,17 @@ class TuyaLight(TuyaDevice, LightEntity):
@property
def is_on(self):
"""Return true if light is on."""
return self.tuya.state()
return self._tuya.state()
@property
def min_mireds(self):
"""Return color temperature min mireds."""
return colorutil.color_temperature_kelvin_to_mired(self.tuya.min_color_temp())
return colorutil.color_temperature_kelvin_to_mired(self._tuya.min_color_temp())
@property
def max_mireds(self):
"""Return color temperature max mireds."""
return colorutil.color_temperature_kelvin_to_mired(self.tuya.max_color_temp())
return colorutil.color_temperature_kelvin_to_mired(self._tuya.max_color_temp())
def turn_on(self, **kwargs):
"""Turn on or control the light."""
@ -81,27 +104,27 @@ class TuyaLight(TuyaDevice, LightEntity):
and ATTR_HS_COLOR not in kwargs
and ATTR_COLOR_TEMP not in kwargs
):
self.tuya.turn_on()
self._tuya.turn_on()
if ATTR_BRIGHTNESS in kwargs:
self.tuya.set_brightness(kwargs[ATTR_BRIGHTNESS])
self._tuya.set_brightness(kwargs[ATTR_BRIGHTNESS])
if ATTR_HS_COLOR in kwargs:
self.tuya.set_color(kwargs[ATTR_HS_COLOR])
self._tuya.set_color(kwargs[ATTR_HS_COLOR])
if ATTR_COLOR_TEMP in kwargs:
color_temp = colorutil.color_temperature_mired_to_kelvin(
kwargs[ATTR_COLOR_TEMP]
)
self.tuya.set_color_temp(color_temp)
self._tuya.set_color_temp(color_temp)
def turn_off(self, **kwargs):
"""Instruct the light to turn off."""
self.tuya.turn_off()
self._tuya.turn_off()
@property
def supported_features(self):
"""Flag supported features."""
supports = SUPPORT_BRIGHTNESS
if self.tuya.support_color():
if self._tuya.support_color():
supports = supports | SUPPORT_COLOR
if self.tuya.support_color_temp():
if self._tuya.support_color_temp():
supports = supports | SUPPORT_COLOR_TEMP
return supports

View File

@ -3,5 +3,6 @@
"name": "Tuya",
"documentation": "https://www.home-assistant.io/integrations/tuya",
"requirements": ["tuyaha==0.0.6"],
"codeowners": []
"codeowners": ["@ollo69"],
"config_flow": true
}

View File

@ -1,38 +1,60 @@
"""Support for the Tuya scenes."""
from typing import Any
from homeassistant.components.scene import DOMAIN, Scene
from homeassistant.components.scene import DOMAIN as SENSOR_DOMAIN, Scene
from homeassistant.const import CONF_PLATFORM
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from . import DATA_TUYA, TuyaDevice
from . import TuyaDevice
from .const import DOMAIN, TUYA_DATA, TUYA_DISCOVERY_NEW
ENTITY_ID_FORMAT = DOMAIN + ".{}"
ENTITY_ID_FORMAT = SENSOR_DOMAIN + ".{}"
PARALLEL_UPDATES = 0
def setup_platform(hass, config, add_entities, discovery_info=None):
"""Set up Tuya scenes."""
if discovery_info is None:
return
tuya = hass.data[DATA_TUYA]
dev_ids = discovery_info.get("dev_ids")
devices = []
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up tuya sensors dynamically through tuya discovery."""
platform = config_entry.data[CONF_PLATFORM]
async def async_discover_sensor(dev_ids):
"""Discover and add a discovered tuya sensor."""
if not dev_ids:
return
entities = await hass.async_add_executor_job(
_setup_entities, hass, dev_ids, platform,
)
async_add_entities(entities)
async_dispatcher_connect(
hass, TUYA_DISCOVERY_NEW.format(SENSOR_DOMAIN), async_discover_sensor
)
devices_ids = hass.data[DOMAIN]["pending"].pop(SENSOR_DOMAIN)
await async_discover_sensor(devices_ids)
def _setup_entities(hass, dev_ids, platform):
"""Set up Tuya Scene."""
tuya = hass.data[DOMAIN][TUYA_DATA]
entities = []
for dev_id in dev_ids:
device = tuya.get_device_by_id(dev_id)
if device is None:
entity = tuya.get_device_by_id(dev_id)
if entity is None:
continue
devices.append(TuyaScene(device))
add_entities(devices)
entities.append(TuyaScene(entity, platform))
return entities
class TuyaScene(TuyaDevice, Scene):
"""Tuya Scene."""
def __init__(self, tuya):
def __init__(self, tuya, platform):
"""Init Tuya scene."""
super().__init__(tuya)
super().__init__(tuya, platform)
self.entity_id = ENTITY_ID_FORMAT.format(tuya.object_id())
def activate(self, **kwargs: Any) -> None:
"""Activate the scene."""
self.tuya.activate()
self._tuya.activate()

View File

@ -0,0 +1,26 @@
{
"config": {
"flow_title": "Tuya configuration",
"step": {
"user": {
"title": "Tuya",
"description": "Enter your Tuya credential.",
"data": {
"country_code": "Your account country code (e.g., 1 for USA or 86 for China)",
"password": "[%key:common::config_flow::data::password%]",
"platform": "The app where your account register",
"username": "[%key:common::config_flow::data::username%]"
}
}
},
"abort": {
"already_in_progress": "Tuya configuration is already in progress.",
"auth_failed": "[%key:common::config_flow::error::invalid_auth%]",
"conn_error": "[%key:common::config_flow::error::cannot_connect%]",
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]"
},
"error": {
"auth_failed": "[%key:common::config_flow::error::invalid_auth%]"
}
}
}

View File

@ -1,43 +1,69 @@
"""Support for Tuya switches."""
from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchEntity
from homeassistant.components.switch import (
DOMAIN as SENSOR_DOMAIN,
ENTITY_ID_FORMAT,
SwitchEntity,
)
from homeassistant.const import CONF_PLATFORM
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from . import DATA_TUYA, TuyaDevice
from . import TuyaDevice
from .const import DOMAIN, TUYA_DATA, TUYA_DISCOVERY_NEW
PARALLEL_UPDATES = 0
def setup_platform(hass, config, add_entities, discovery_info=None):
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up tuya sensors dynamically through tuya discovery."""
platform = config_entry.data[CONF_PLATFORM]
async def async_discover_sensor(dev_ids):
"""Discover and add a discovered tuya sensor."""
if not dev_ids:
return
entities = await hass.async_add_executor_job(
_setup_entities, hass, dev_ids, platform,
)
async_add_entities(entities)
async_dispatcher_connect(
hass, TUYA_DISCOVERY_NEW.format(SENSOR_DOMAIN), async_discover_sensor
)
devices_ids = hass.data[DOMAIN]["pending"].pop(SENSOR_DOMAIN)
await async_discover_sensor(devices_ids)
def _setup_entities(hass, dev_ids, platform):
"""Set up Tuya Switch device."""
if discovery_info is None:
return
tuya = hass.data[DATA_TUYA]
dev_ids = discovery_info.get("dev_ids")
devices = []
tuya = hass.data[DOMAIN][TUYA_DATA]
entities = []
for dev_id in dev_ids:
device = tuya.get_device_by_id(dev_id)
if device is None:
entity = tuya.get_device_by_id(dev_id)
if entity is None:
continue
devices.append(TuyaSwitch(device))
add_entities(devices)
entities.append(TuyaSwitch(entity, platform))
return entities
class TuyaSwitch(TuyaDevice, SwitchEntity):
"""Tuya Switch Device."""
def __init__(self, tuya):
def __init__(self, tuya, platform):
"""Init Tuya switch device."""
super().__init__(tuya)
super().__init__(tuya, platform)
self.entity_id = ENTITY_ID_FORMAT.format(tuya.object_id())
@property
def is_on(self):
"""Return true if switch is on."""
return self.tuya.state()
return self._tuya.state()
def turn_on(self, **kwargs):
"""Turn the switch on."""
self.tuya.turn_on()
self._tuya.turn_on()
def turn_off(self, **kwargs):
"""Turn the device off."""
self.tuya.turn_off()
self._tuya.turn_off()

View File

@ -0,0 +1,26 @@
{
"config": {
"abort": {
"already_in_progress": "Tuya configuration is already in progress.",
"auth_failed": "Configured Tuya credential are incorrect.",
"conn_error": "Connection to Tuya failed.",
"single_instance_allowed": "Only a single configuration of Tuya is allowed."
},
"error": {
"auth_failed": "Provided credential are incorrect."
},
"flow_title": "Tuya configuration",
"step": {
"user": {
"data": {
"country_code": "Your account country code (e.g., 1 for USA or 86 for China)",
"password": "Your password to log in to Tuya",
"platform": "The app where your account register",
"username": "Your username to log in to Tuya"
},
"description": "Enter your Tuya credential.",
"title": "Tuya"
}
}
}
}

View File

@ -141,6 +141,7 @@ FLOWS = [
"traccar",
"tradfri",
"transmission",
"tuya",
"twentemilieu",
"twilio",
"unifi",

View File

@ -828,6 +828,9 @@ total_connect_client==0.54.1
# homeassistant.components.transmission
transmissionrpc==0.11
# homeassistant.components.tuya
tuyaha==0.0.6
# homeassistant.components.twentemilieu
twentemilieu==0.3.0

View File

@ -0,0 +1 @@
"""Tests for the Tuya component."""

View File

@ -0,0 +1,147 @@
"""Tests for the Tuya config flow."""
import pytest
from tuyaha.tuyaapi import TuyaAPIException, TuyaNetException
from homeassistant import config_entries, data_entry_flow, setup
from homeassistant.components.tuya.const import CONF_COUNTRYCODE, DOMAIN
from homeassistant.const import CONF_PASSWORD, CONF_PLATFORM, CONF_USERNAME
from tests.async_mock import Mock, patch
from tests.common import MockConfigEntry
USERNAME = "myUsername"
PASSWORD = "myPassword"
COUNTRY_CODE = "1"
TUYA_PLATFORM = "tuya"
TUYA_USER_DATA = {
CONF_USERNAME: USERNAME,
CONF_PASSWORD: PASSWORD,
CONF_COUNTRYCODE: COUNTRY_CODE,
CONF_PLATFORM: TUYA_PLATFORM,
}
@pytest.fixture(name="tuya")
def tuya_fixture() -> Mock:
"""Patch libraries."""
with patch("homeassistant.components.tuya.config_flow.TuyaApi") as tuya:
yield tuya
async def test_user(hass, tuya):
"""Test user config."""
await setup.async_setup_component(hass, "persistent_notification", {})
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
with patch(
"homeassistant.components.tuya.async_setup", return_value=True
) as mock_setup, patch(
"homeassistant.components.tuya.async_setup_entry", return_value=True
) as mock_setup_entry:
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=TUYA_USER_DATA
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == USERNAME
assert result["data"][CONF_USERNAME] == USERNAME
assert result["data"][CONF_PASSWORD] == PASSWORD
assert result["data"][CONF_COUNTRYCODE] == COUNTRY_CODE
assert result["data"][CONF_PLATFORM] == TUYA_PLATFORM
assert not result["result"].unique_id
await hass.async_block_till_done()
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
async def test_import(hass, tuya):
"""Test import step."""
await setup.async_setup_component(hass, "persistent_notification", {})
with patch(
"homeassistant.components.tuya.async_setup", return_value=True,
) as mock_setup, patch(
"homeassistant.components.tuya.async_setup_entry", return_value=True,
) as mock_setup_entry:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data=TUYA_USER_DATA,
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == USERNAME
assert result["data"][CONF_USERNAME] == USERNAME
assert result["data"][CONF_PASSWORD] == PASSWORD
assert result["data"][CONF_COUNTRYCODE] == COUNTRY_CODE
assert result["data"][CONF_PLATFORM] == TUYA_PLATFORM
assert not result["result"].unique_id
await hass.async_block_till_done()
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
async def test_abort_if_already_setup(hass, tuya):
"""Test we abort if Tuya is already setup."""
MockConfigEntry(domain=DOMAIN, data=TUYA_USER_DATA).add_to_hass(hass)
# Should fail, config exist (import)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}, data=TUYA_USER_DATA
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "single_instance_allowed"
# Should fail, config exist (flow)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=TUYA_USER_DATA
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "single_instance_allowed"
async def test_abort_on_invalid_credentials(hass, tuya):
"""Test when we have invalid credentials."""
tuya().init.side_effect = TuyaAPIException("Boom")
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}, data=TUYA_USER_DATA
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["errors"] == {"base": "auth_failed"}
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=TUYA_USER_DATA
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "auth_failed"
async def test_abort_on_connection_error(hass, tuya):
"""Test when we have a network error."""
tuya().init.side_effect = TuyaNetException("Boom")
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}, data=TUYA_USER_DATA
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "conn_error"
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=TUYA_USER_DATA
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "conn_error"