Adding localtuya custom component

This commit is contained in:
Jeffrey Stone 2022-04-05 11:22:07 -04:00
parent 7500294633
commit 07db300fbe
36 changed files with 4325 additions and 0 deletions

View File

@ -0,0 +1,326 @@
"""The LocalTuya integration integration.
Sample YAML config with all supported entity types (default values
are pre-filled for optional fields):
localtuya:
- host: 192.168.1.x
device_id: xxxxx
local_key: xxxxx
friendly_name: Tuya Device
protocol_version: "3.3"
entities:
- platform: binary_sensor
friendly_name: Plug Status
id: 1
device_class: power
state_on: "true" # Optional
state_off: "false" # Optional
- platform: cover
friendly_name: Device Cover
id: 2
commands_set: # Optional, default: "on_off_stop"
["on_off_stop","open_close_stop","fz_zz_stop","1_2_3"]
positioning_mode: ["none","position","timed"] # Optional, default: "none"
currpos_dp: 3 # Optional, required only for "position" mode
setpos_dp: 4 # Optional, required only for "position" mode
position_inverted: [True,False] # Optional, default: False
span_time: 25 # Full movement time: Optional, required only for "timed" mode
- platform: fan
friendly_name: Device Fan
id: 3
- platform: light
friendly_name: Device Light
id: 4
brightness: 20
brightness_lower: 29 # Optional
brightness_upper: 1000 # Optional
color_temp: 21
- platform: sensor
friendly_name: Plug Voltage
id: 20
scaling: 0.1 # Optional
device_class: voltage # Optional
unit_of_measurement: "V" # Optional
- platform: switch
friendly_name: Plug
id: 1
current: 18 # Optional
current_consumption: 19 # Optional
voltage: 20 # Optional
- platform: vacuum
friendly_name: Vacuum
id: 28
idle_status_value: "standby,sleep"
returning_status_value: "docking"
docked_status_value: "charging,chargecompleted"
battery_dp: 14
mode_dp: 27
modes: "smart,standby,chargego,wall_follow,spiral,single"
fan_speed_dp: 30
fan_speeds: "low,normal,high"
clean_time_dp: 33
clean_area_dp: 32
"""
import asyncio
import logging
from datetime import timedelta
import homeassistant.helpers.config_validation as cv
import homeassistant.helpers.entity_registry as er
import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import (
CONF_DEVICE_ID,
CONF_ENTITIES,
CONF_HOST,
CONF_ID,
CONF_PLATFORM,
EVENT_HOMEASSISTANT_STOP,
SERVICE_RELOAD,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.reload import async_integration_yaml_config
from .common import TuyaDevice, async_config_entry_by_device_id
from .config_flow import config_schema
from .const import CONF_PRODUCT_KEY, DATA_DISCOVERY, DOMAIN, TUYA_DEVICE
from .discovery import TuyaDiscovery
_LOGGER = logging.getLogger(__name__)
UNSUB_LISTENER = "unsub_listener"
RECONNECT_INTERVAL = timedelta(seconds=60)
CONFIG_SCHEMA = config_schema()
CONF_DP = "dp"
CONF_VALUE = "value"
SERVICE_SET_DP = "set_dp"
SERVICE_SET_DP_SCHEMA = vol.Schema(
{
vol.Required(CONF_DEVICE_ID): cv.string,
vol.Required(CONF_DP): int,
vol.Required(CONF_VALUE): object,
}
)
@callback
def _async_update_config_entry_if_from_yaml(hass, entries_by_id, conf):
"""Update a config entry with the latest yaml."""
device_id = conf[CONF_DEVICE_ID]
if device_id in entries_by_id and entries_by_id[device_id].source == SOURCE_IMPORT:
entry = entries_by_id[device_id]
hass.config_entries.async_update_entry(entry, data=conf.copy())
async def async_setup(hass: HomeAssistant, config: dict):
"""Set up the LocalTuya integration component."""
hass.data.setdefault(DOMAIN, {})
device_cache = {}
async def _handle_reload(service):
"""Handle reload service call."""
config = await async_integration_yaml_config(hass, DOMAIN)
if not config or DOMAIN not in config:
return
current_entries = hass.config_entries.async_entries(DOMAIN)
entries_by_id = {entry.data[CONF_DEVICE_ID]: entry for entry in current_entries}
for conf in config[DOMAIN]:
_async_update_config_entry_if_from_yaml(hass, entries_by_id, conf)
reload_tasks = [
hass.config_entries.async_reload(entry.entry_id)
for entry in current_entries
]
await asyncio.gather(*reload_tasks)
async def _handle_set_dp(event):
"""Handle set_dp service call."""
entry = async_config_entry_by_device_id(hass, event.data[CONF_DEVICE_ID])
if not entry:
raise HomeAssistantError("unknown device id")
if entry.entry_id not in hass.data[DOMAIN]:
raise HomeAssistantError("device has not been discovered")
device = hass.data[DOMAIN][entry.entry_id][TUYA_DEVICE]
if not device.connected:
raise HomeAssistantError("not connected to device")
await device.set_dp(event.data[CONF_VALUE], event.data[CONF_DP])
def _device_discovered(device):
"""Update address of device if it has changed."""
device_ip = device["ip"]
device_id = device["gwId"]
product_key = device["productKey"]
# If device is not in cache, check if a config entry exists
if device_id not in device_cache:
entry = async_config_entry_by_device_id(hass, device_id)
if entry:
# Save address from config entry in cache to trigger
# potential update below
device_cache[device_id] = entry.data[CONF_HOST]
if device_id not in device_cache:
return
entry = async_config_entry_by_device_id(hass, device_id)
if entry is None:
return
updates = {}
if device_cache[device_id] != device_ip:
updates[CONF_HOST] = device_ip
device_cache[device_id] = device_ip
if entry.data.get(CONF_PRODUCT_KEY) != product_key:
updates[CONF_PRODUCT_KEY] = product_key
# Update settings if something changed, otherwise try to connect. Updating
# settings triggers a reload of the config entry, which tears down the device
# so no need to connect in that case.
if updates:
_LOGGER.debug("Update keys for device %s: %s", device_id, updates)
hass.config_entries.async_update_entry(
entry, data={**entry.data, **updates}
)
elif entry.entry_id in hass.data[DOMAIN]:
_LOGGER.debug("Device %s found with IP %s", device_id, device_ip)
device = hass.data[DOMAIN][entry.entry_id][TUYA_DEVICE]
device.async_connect()
discovery = TuyaDiscovery(_device_discovered)
def _shutdown(event):
"""Clean up resources when shutting down."""
discovery.close()
try:
await discovery.start()
hass.data[DOMAIN][DATA_DISCOVERY] = discovery
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown)
except Exception: # pylint: disable=broad-except
_LOGGER.exception("failed to set up discovery")
async def _async_reconnect(now):
"""Try connecting to devices not already connected to."""
for entry_id, value in hass.data[DOMAIN].items():
if entry_id == DATA_DISCOVERY:
continue
device = value[TUYA_DEVICE]
if not device.connected:
device.async_connect()
async_track_time_interval(hass, _async_reconnect, RECONNECT_INTERVAL)
hass.helpers.service.async_register_admin_service(
DOMAIN,
SERVICE_RELOAD,
_handle_reload,
)
hass.helpers.service.async_register_admin_service(
DOMAIN, SERVICE_SET_DP, _handle_set_dp, schema=SERVICE_SET_DP_SCHEMA
)
for host_config in config.get(DOMAIN, []):
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=host_config
)
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Set up LocalTuya integration from a config entry."""
unsub_listener = entry.add_update_listener(update_listener)
device = TuyaDevice(hass, entry.data)
hass.data[DOMAIN][entry.entry_id] = {
UNSUB_LISTENER: unsub_listener,
TUYA_DEVICE: device,
}
async def setup_entities():
platforms = set(entity[CONF_PLATFORM] for entity in entry.data[CONF_ENTITIES])
await asyncio.gather(
*[
hass.config_entries.async_forward_entry_setup(entry, platform)
for platform in platforms
]
)
device.async_connect()
await async_remove_orphan_entities(hass, entry)
hass.async_create_task(setup_entities())
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Unload a config entry."""
unload_ok = all(
await asyncio.gather(
*[
hass.config_entries.async_forward_entry_unload(entry, component)
for component in set(
entity[CONF_PLATFORM] for entity in entry.data[CONF_ENTITIES]
)
]
)
)
hass.data[DOMAIN][entry.entry_id][UNSUB_LISTENER]()
await hass.data[DOMAIN][entry.entry_id][TUYA_DEVICE].close()
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
return True
async def update_listener(hass, config_entry):
"""Update listener."""
await hass.config_entries.async_reload(config_entry.entry_id)
async def async_remove_orphan_entities(hass, entry):
"""Remove entities associated with config entry that has been removed."""
ent_reg = await er.async_get_registry(hass)
entities = {
int(ent.unique_id.split("_")[-1]): ent.entity_id
for ent in er.async_entries_for_config_entry(ent_reg, entry.entry_id)
}
for entity in entry.data[CONF_ENTITIES]:
if entity[CONF_ID] in entities:
del entities[entity[CONF_ID]]
for entity_id in entities.values():
ent_reg.async_remove(entity_id)

View File

@ -0,0 +1,69 @@
"""Platform to present any Tuya DP as a binary sensor."""
import logging
from functools import partial
import voluptuous as vol
from homeassistant.components.binary_sensor import (
DEVICE_CLASSES_SCHEMA,
DOMAIN,
BinarySensorEntity,
)
from homeassistant.const import CONF_DEVICE_CLASS
from .common import LocalTuyaEntity, async_setup_entry
_LOGGER = logging.getLogger(__name__)
CONF_STATE_ON = "state_on"
CONF_STATE_OFF = "state_off"
def flow_schema(dps):
"""Return schema used in config flow."""
return {
vol.Required(CONF_STATE_ON, default="True"): str,
vol.Required(CONF_STATE_OFF, default="False"): str,
vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
}
class LocaltuyaBinarySensor(LocalTuyaEntity, BinarySensorEntity):
"""Representation of a Tuya binary sensor."""
def __init__(
self,
device,
config_entry,
sensorid,
**kwargs,
):
"""Initialize the Tuya binary sensor."""
super().__init__(device, config_entry, sensorid, _LOGGER, **kwargs)
self._is_on = False
@property
def is_on(self):
"""Return sensor state."""
return self._is_on
@property
def device_class(self):
"""Return the class of this device."""
return self._config.get(CONF_DEVICE_CLASS)
def status_updated(self):
"""Device status was updated."""
state = str(self.dps(self._dp_id)).lower()
if state == self._config[CONF_STATE_ON].lower():
self._is_on = True
elif state == self._config[CONF_STATE_OFF].lower():
self._is_on = False
else:
self.warning(
"State for entity %s did not match state patterns", self.entity_id
)
async_setup_entry = partial(
async_setup_entry, DOMAIN, LocaltuyaBinarySensor, flow_schema
)

View File

@ -0,0 +1,394 @@
"""Platform to locally control Tuya-based climate devices."""
import asyncio
import logging
from functools import partial
import voluptuous as vol
from homeassistant.components.climate import (
DEFAULT_MAX_TEMP,
DEFAULT_MIN_TEMP,
DOMAIN,
ClimateEntity,
)
from homeassistant.components.climate.const import (
HVAC_MODE_AUTO,
HVAC_MODE_HEAT,
HVAC_MODE_OFF,
SUPPORT_PRESET_MODE,
SUPPORT_TARGET_TEMPERATURE,
SUPPORT_TARGET_TEMPERATURE_RANGE,
CURRENT_HVAC_IDLE,
CURRENT_HVAC_HEAT,
PRESET_NONE,
PRESET_ECO,
PRESET_AWAY,
PRESET_HOME,
)
from homeassistant.const import (
ATTR_TEMPERATURE,
CONF_TEMPERATURE_UNIT,
PRECISION_HALVES,
PRECISION_TENTHS,
PRECISION_WHOLE,
TEMP_CELSIUS,
TEMP_FAHRENHEIT,
)
from .common import LocalTuyaEntity, async_setup_entry
from .const import (
CONF_CURRENT_TEMPERATURE_DP,
CONF_MAX_TEMP_DP,
CONF_MIN_TEMP_DP,
CONF_PRECISION,
CONF_TARGET_PRECISION,
CONF_TARGET_TEMPERATURE_DP,
CONF_TEMPERATURE_STEP,
CONF_HVAC_MODE_DP,
CONF_HVAC_MODE_SET,
CONF_HEURISTIC_ACTION,
CONF_HVAC_ACTION_DP,
CONF_HVAC_ACTION_SET,
CONF_ECO_DP,
CONF_ECO_VALUE,
CONF_PRESET_DP,
CONF_PRESET_SET,
)
_LOGGER = logging.getLogger(__name__)
HVAC_MODE_SETS = {
"manual/auto": {
HVAC_MODE_HEAT: "manual",
HVAC_MODE_AUTO: "auto",
},
"Manual/Auto": {
HVAC_MODE_HEAT: "Manual",
HVAC_MODE_AUTO: "Auto",
},
"Manual/Program": {
HVAC_MODE_HEAT: "Manual",
HVAC_MODE_AUTO: "Program",
},
"True/False": {
HVAC_MODE_HEAT: True,
},
}
HVAC_ACTION_SETS = {
"True/False": {
CURRENT_HVAC_HEAT: True,
CURRENT_HVAC_IDLE: False,
},
"open/close": {
CURRENT_HVAC_HEAT: "open",
CURRENT_HVAC_IDLE: "close",
},
"heating/no_heating": {
CURRENT_HVAC_HEAT: "heating",
CURRENT_HVAC_IDLE: "no_heating",
},
"Heat/Warming": {
CURRENT_HVAC_HEAT: "Heat",
CURRENT_HVAC_IDLE: "Warming",
},
}
PRESET_SETS = {
"Manual/Holiday/Program": {
PRESET_AWAY: "Holiday",
PRESET_HOME: "Program",
PRESET_NONE: "Manual",
},
}
TEMPERATURE_CELSIUS = "celsius"
TEMPERATURE_FAHRENHEIT = "fahrenheit"
DEFAULT_TEMPERATURE_UNIT = TEMPERATURE_CELSIUS
DEFAULT_PRECISION = PRECISION_TENTHS
DEFAULT_TEMPERATURE_STEP = PRECISION_HALVES
# Empirically tested to work for AVATTO thermostat
MODE_WAIT = 0.1
def flow_schema(dps):
"""Return schema used in config flow."""
return {
vol.Optional(CONF_TARGET_TEMPERATURE_DP): vol.In(dps),
vol.Optional(CONF_CURRENT_TEMPERATURE_DP): vol.In(dps),
vol.Optional(CONF_TEMPERATURE_STEP): vol.In(
[PRECISION_WHOLE, PRECISION_HALVES, PRECISION_TENTHS]
),
vol.Optional(CONF_MAX_TEMP_DP): vol.In(dps),
vol.Optional(CONF_MIN_TEMP_DP): vol.In(dps),
vol.Optional(CONF_PRECISION): vol.In(
[PRECISION_WHOLE, PRECISION_HALVES, PRECISION_TENTHS]
),
vol.Optional(CONF_HVAC_MODE_DP): vol.In(dps),
vol.Optional(CONF_HVAC_MODE_SET): vol.In(list(HVAC_MODE_SETS.keys())),
vol.Optional(CONF_HVAC_ACTION_DP): vol.In(dps),
vol.Optional(CONF_HVAC_ACTION_SET): vol.In(list(HVAC_ACTION_SETS.keys())),
vol.Optional(CONF_ECO_DP): vol.In(dps),
vol.Optional(CONF_ECO_VALUE): str,
vol.Optional(CONF_PRESET_DP): vol.In(dps),
vol.Optional(CONF_PRESET_SET): vol.In(list(PRESET_SETS.keys())),
vol.Optional(CONF_TEMPERATURE_UNIT): vol.In(
[TEMPERATURE_CELSIUS, TEMPERATURE_FAHRENHEIT]
),
vol.Optional(CONF_TARGET_PRECISION): vol.In(
[PRECISION_WHOLE, PRECISION_HALVES, PRECISION_TENTHS]
),
vol.Optional(CONF_HEURISTIC_ACTION): bool,
}
class LocaltuyaClimate(LocalTuyaEntity, ClimateEntity):
"""Tuya climate device."""
def __init__(
self,
device,
config_entry,
switchid,
**kwargs,
):
"""Initialize a new LocaltuyaClimate."""
super().__init__(device, config_entry, switchid, _LOGGER, **kwargs)
self._state = None
self._target_temperature = None
self._current_temperature = None
self._hvac_mode = None
self._preset_mode = None
self._hvac_action = None
self._precision = self._config.get(CONF_PRECISION, DEFAULT_PRECISION)
self._target_precision = self._config.get(
CONF_TARGET_PRECISION, self._precision
)
self._conf_hvac_mode_dp = self._config.get(CONF_HVAC_MODE_DP)
self._conf_hvac_mode_set = HVAC_MODE_SETS.get(
self._config.get(CONF_HVAC_MODE_SET), {}
)
self._conf_preset_dp = self._config.get(CONF_PRESET_DP)
self._conf_preset_set = PRESET_SETS.get(self._config.get(CONF_PRESET_SET), {})
self._conf_hvac_action_dp = self._config.get(CONF_HVAC_ACTION_DP)
self._conf_hvac_action_set = HVAC_ACTION_SETS.get(
self._config.get(CONF_HVAC_ACTION_SET), {}
)
self._conf_eco_dp = self._config.get(CONF_ECO_DP)
self._conf_eco_value = self._config.get(CONF_ECO_VALUE, "ECO")
self._has_presets = self.has_config(CONF_ECO_DP) or self.has_config(
CONF_PRESET_DP
)
print("Initialized climate [{}]".format(self.name))
@property
def supported_features(self):
"""Flag supported features."""
supported_features = 0
if self.has_config(CONF_TARGET_TEMPERATURE_DP):
supported_features = supported_features | SUPPORT_TARGET_TEMPERATURE
if self.has_config(CONF_MAX_TEMP_DP):
supported_features = supported_features | SUPPORT_TARGET_TEMPERATURE_RANGE
if self.has_config(CONF_PRESET_DP) or self.has_config(CONF_ECO_DP):
supported_features = supported_features | SUPPORT_PRESET_MODE
return supported_features
@property
def precision(self):
"""Return the precision of the system."""
return self._precision
@property
def target_recision(self):
"""Return the precision of the target."""
return self._target_precision
@property
def temperature_unit(self):
"""Return the unit of measurement used by the platform."""
if (
self._config.get(CONF_TEMPERATURE_UNIT, DEFAULT_TEMPERATURE_UNIT)
== TEMPERATURE_FAHRENHEIT
):
return TEMP_FAHRENHEIT
return TEMP_CELSIUS
@property
def hvac_mode(self):
"""Return current operation ie. heat, cool, idle."""
return self._hvac_mode
@property
def hvac_modes(self):
"""Return the list of available operation modes."""
if not self.has_config(CONF_HVAC_MODE_DP):
return None
return list(self._conf_hvac_mode_set) + [HVAC_MODE_OFF]
@property
def hvac_action(self):
"""Return the current running hvac operation if supported.
Need to be one of CURRENT_HVAC_*.
"""
if self._config.get(CONF_HEURISTIC_ACTION, False):
if self._hvac_mode == HVAC_MODE_HEAT:
if self._current_temperature < (
self._target_temperature - self._precision
):
self._hvac_action = CURRENT_HVAC_HEAT
if self._current_temperature == (
self._target_temperature - self._precision
):
if self._hvac_action == CURRENT_HVAC_HEAT:
self._hvac_action = CURRENT_HVAC_HEAT
if self._hvac_action == CURRENT_HVAC_IDLE:
self._hvac_action = CURRENT_HVAC_IDLE
if (
self._current_temperature + self._precision
) > self._target_temperature:
self._hvac_action = CURRENT_HVAC_IDLE
return self._hvac_action
return self._hvac_action
@property
def preset_mode(self):
"""Return current preset."""
return self._preset_mode
@property
def preset_modes(self):
"""Return the list of available presets modes."""
if not self._has_presets:
return None
presets = list(self._conf_preset_set)
if self._conf_eco_dp:
presets.append(PRESET_ECO)
return presets
@property
def current_temperature(self):
"""Return the current temperature."""
return self._current_temperature
@property
def target_temperature(self):
"""Return the temperature we try to reach."""
return self._target_temperature
@property
def target_temperature_step(self):
"""Return the supported step of target temperature."""
return self._config.get(CONF_TEMPERATURE_STEP, DEFAULT_TEMPERATURE_STEP)
@property
def fan_mode(self):
"""Return the fan setting."""
return NotImplementedError()
@property
def fan_modes(self):
"""Return the list of available fan modes."""
return NotImplementedError()
async def async_set_temperature(self, **kwargs):
"""Set new target temperature."""
if ATTR_TEMPERATURE in kwargs and self.has_config(CONF_TARGET_TEMPERATURE_DP):
temperature = round(kwargs[ATTR_TEMPERATURE] / self._target_precision)
await self._device.set_dp(
temperature, self._config[CONF_TARGET_TEMPERATURE_DP]
)
def set_fan_mode(self, fan_mode):
"""Set new target fan mode."""
return NotImplementedError()
async def async_set_hvac_mode(self, hvac_mode):
"""Set new target operation mode."""
if hvac_mode == HVAC_MODE_OFF:
await self._device.set_dp(False, self._dp_id)
return
if not self._state and self._conf_hvac_mode_dp != self._dp_id:
await self._device.set_dp(True, self._dp_id)
# Some thermostats need a small wait before sending another update
await asyncio.sleep(MODE_WAIT)
await self._device.set_dp(
self._conf_hvac_mode_set[hvac_mode], self._conf_hvac_mode_dp
)
async def async_turn_on(self) -> None:
"""Turn the entity on."""
await self._device.set_dp(True, self._dp_id)
async def async_turn_off(self) -> None:
"""Turn the entity off."""
await self._device.set_dp(False, self._dp_id)
async def async_set_preset_mode(self, preset_mode):
"""Set new target preset mode."""
if preset_mode == PRESET_ECO:
await self._device.set_dp(self._conf_eco_value, self._conf_eco_dp)
return
await self._device.set_dp(
self._conf_preset_set[preset_mode], self._conf_preset_dp
)
@property
def min_temp(self):
"""Return the minimum temperature."""
if self.has_config(CONF_MIN_TEMP_DP):
return self.dps_conf(CONF_MIN_TEMP_DP)
return DEFAULT_MIN_TEMP
@property
def max_temp(self):
"""Return the maximum temperature."""
if self.has_config(CONF_MAX_TEMP_DP):
return self.dps_conf(CONF_MAX_TEMP_DP)
return DEFAULT_MAX_TEMP
def status_updated(self):
"""Device status was updated."""
self._state = self.dps(self._dp_id)
if self.has_config(CONF_TARGET_TEMPERATURE_DP):
self._target_temperature = (
self.dps_conf(CONF_TARGET_TEMPERATURE_DP) * self._target_precision
)
if self.has_config(CONF_CURRENT_TEMPERATURE_DP):
self._current_temperature = (
self.dps_conf(CONF_CURRENT_TEMPERATURE_DP) * self._precision
)
if self._has_presets:
if (
self.has_config(CONF_ECO_DP)
and self.dps_conf(CONF_ECO_DP) == self._conf_eco_value
):
self._preset_mode = PRESET_ECO
else:
for preset, value in self._conf_preset_set.items(): # todo remove
if self.dps_conf(CONF_PRESET_DP) == value:
self._preset_mode = preset
break
else:
self._preset_mode = PRESET_NONE
# Update the HVAC status
if self.has_config(CONF_HVAC_MODE_DP):
if not self._state:
self._hvac_mode = HVAC_MODE_OFF
else:
for mode, value in self._conf_hvac_mode_set.items():
if self.dps_conf(CONF_HVAC_MODE_DP) == value:
self._hvac_mode = mode
break
else:
# in case hvac mode and preset share the same dp
self._hvac_mode = HVAC_MODE_AUTO
# Update the current action
for action, value in self._conf_hvac_action_set.items():
if self.dps_conf(CONF_HVAC_ACTION_DP) == value:
self._hvac_action = action
async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaClimate, flow_schema)

View File

@ -0,0 +1,369 @@
"""Code shared between all platforms."""
import asyncio
import logging
from datetime import timedelta
from homeassistant.const import (
CONF_DEVICE_ID,
CONF_ENTITIES,
CONF_FRIENDLY_NAME,
CONF_HOST,
CONF_ID,
CONF_PLATFORM,
CONF_SCAN_INTERVAL,
)
from homeassistant.core import callback
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.restore_state import RestoreEntity
from . import pytuya
from .const import (
CONF_LOCAL_KEY,
CONF_PRODUCT_KEY,
CONF_PROTOCOL_VERSION,
DOMAIN,
TUYA_DEVICE,
)
_LOGGER = logging.getLogger(__name__)
def prepare_setup_entities(hass, config_entry, platform):
"""Prepare ro setup entities for a platform."""
entities_to_setup = [
entity
for entity in config_entry.data[CONF_ENTITIES]
if entity[CONF_PLATFORM] == platform
]
if not entities_to_setup:
return None, None
tuyainterface = hass.data[DOMAIN][config_entry.entry_id][TUYA_DEVICE]
return tuyainterface, entities_to_setup
async def async_setup_entry(
domain, entity_class, flow_schema, hass, config_entry, async_add_entities
):
"""Set up a Tuya platform based on a config entry.
This is a generic method and each platform should lock domain and
entity_class with functools.partial.
"""
tuyainterface, entities_to_setup = prepare_setup_entities(
hass, config_entry, domain
)
if not entities_to_setup:
return
dps_config_fields = list(get_dps_for_platform(flow_schema))
entities = []
for device_config in entities_to_setup:
# Add DPS used by this platform to the request list
for dp_conf in dps_config_fields:
if dp_conf in device_config:
tuyainterface.dps_to_request[device_config[dp_conf]] = None
entities.append(
entity_class(
tuyainterface,
config_entry,
device_config[CONF_ID],
)
)
async_add_entities(entities)
def get_dps_for_platform(flow_schema):
"""Return config keys for all platform keys that depends on a datapoint."""
for key, value in flow_schema(None).items():
if hasattr(value, "container") and value.container is None:
yield key.schema
def get_entity_config(config_entry, dp_id):
"""Return entity config for a given DPS id."""
for entity in config_entry.data[CONF_ENTITIES]:
if entity[CONF_ID] == dp_id:
return entity
raise Exception(f"missing entity config for id {dp_id}")
@callback
def async_config_entry_by_device_id(hass, device_id):
"""Look up config entry by device id."""
current_entries = hass.config_entries.async_entries(DOMAIN)
for entry in current_entries:
if entry.data[CONF_DEVICE_ID] == device_id:
return entry
return None
class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger):
"""Cache wrapper for pytuya.TuyaInterface."""
def __init__(self, hass, config_entry):
"""Initialize the cache."""
super().__init__()
self._hass = hass
self._config_entry = config_entry
self._interface = None
self._status = {}
self.dps_to_request = {}
self._is_closing = False
self._connect_task = None
self._disconnect_task = None
self._unsub_interval = None
self.set_logger(_LOGGER, config_entry[CONF_DEVICE_ID])
# This has to be done in case the device type is type_0d
for entity in config_entry[CONF_ENTITIES]:
self.dps_to_request[entity[CONF_ID]] = None
@property
def connected(self):
"""Return if connected to device."""
return self._interface is not None
def async_connect(self):
"""Connect to device if not already connected."""
if not self._is_closing and self._connect_task is None and not self._interface:
self._connect_task = asyncio.create_task(self._make_connection())
async def _make_connection(self):
"""Subscribe localtuya entity events."""
self.debug("Connecting to %s", self._config_entry[CONF_HOST])
try:
self._interface = await pytuya.connect(
self._config_entry[CONF_HOST],
self._config_entry[CONF_DEVICE_ID],
self._config_entry[CONF_LOCAL_KEY],
float(self._config_entry[CONF_PROTOCOL_VERSION]),
self,
)
self._interface.add_dps_to_request(self.dps_to_request)
self.debug("Retrieving initial state")
status = await self._interface.status()
if status is None:
raise Exception("Failed to retrieve status")
self.status_updated(status)
def _new_entity_handler(entity_id):
self.debug(
"New entity %s was added to %s",
entity_id,
self._config_entry[CONF_HOST],
)
self._dispatch_status()
signal = f"localtuya_entity_{self._config_entry[CONF_DEVICE_ID]}"
self._disconnect_task = async_dispatcher_connect(
self._hass, signal, _new_entity_handler
)
if (
CONF_SCAN_INTERVAL in self._config_entry
and self._config_entry[CONF_SCAN_INTERVAL] > 0
):
self._unsub_interval = async_track_time_interval(
self._hass,
self._async_refresh,
timedelta(seconds=self._config_entry[CONF_SCAN_INTERVAL]),
)
except Exception: # pylint: disable=broad-except
self.exception(f"Connect to {self._config_entry[CONF_HOST]} failed")
if self._interface is not None:
await self._interface.close()
self._interface = None
self._connect_task = None
async def _async_refresh(self, _now):
if self._interface is not None:
await self._interface.update_dps()
async def close(self):
"""Close connection and stop re-connect loop."""
self._is_closing = True
if self._connect_task is not None:
self._connect_task.cancel()
await self._connect_task
if self._interface is not None:
await self._interface.close()
if self._disconnect_task is not None:
self._disconnect_task()
async def set_dp(self, state, dp_index):
"""Change value of a DP of the Tuya device."""
if self._interface is not None:
try:
await self._interface.set_dp(state, dp_index)
except Exception: # pylint: disable=broad-except
self.exception("Failed to set DP %d to %d", dp_index, state)
else:
self.error(
"Not connected to device %s", self._config_entry[CONF_FRIENDLY_NAME]
)
async def set_dps(self, states):
"""Change value of a DPs of the Tuya device."""
if self._interface is not None:
try:
await self._interface.set_dps(states)
except Exception: # pylint: disable=broad-except
self.exception("Failed to set DPs %r", states)
else:
self.error(
"Not connected to device %s", self._config_entry[CONF_FRIENDLY_NAME]
)
@callback
def status_updated(self, status):
"""Device updated status."""
self._status.update(status)
self._dispatch_status()
def _dispatch_status(self):
signal = f"localtuya_{self._config_entry[CONF_DEVICE_ID]}"
async_dispatcher_send(self._hass, signal, self._status)
@callback
def disconnected(self):
"""Device disconnected."""
signal = f"localtuya_{self._config_entry[CONF_DEVICE_ID]}"
async_dispatcher_send(self._hass, signal, None)
if self._unsub_interval is not None:
self._unsub_interval()
self._unsub_interval = None
self._interface = None
self.debug("Disconnected - waiting for discovery broadcast")
class LocalTuyaEntity(RestoreEntity, pytuya.ContextualLogger):
"""Representation of a Tuya entity."""
def __init__(self, device, config_entry, dp_id, logger, **kwargs):
"""Initialize the Tuya entity."""
super().__init__()
self._device = device
self._config_entry = config_entry
self._config = get_entity_config(config_entry, dp_id)
self._dp_id = dp_id
self._status = {}
self.set_logger(logger, self._config_entry.data[CONF_DEVICE_ID])
async def async_added_to_hass(self):
"""Subscribe localtuya events."""
await super().async_added_to_hass()
self.debug("Adding %s with configuration: %s", self.entity_id, self._config)
state = await self.async_get_last_state()
if state:
self.status_restored(state)
def _update_handler(status):
"""Update entity state when status was updated."""
if status is None:
status = {}
if self._status != status:
self._status = status.copy()
if status:
self.status_updated()
self.schedule_update_ha_state()
signal = f"localtuya_{self._config_entry.data[CONF_DEVICE_ID]}"
self.async_on_remove(
async_dispatcher_connect(self.hass, signal, _update_handler)
)
signal = f"localtuya_entity_{self._config_entry.data[CONF_DEVICE_ID]}"
async_dispatcher_send(self.hass, signal, self.entity_id)
@property
def device_info(self):
"""Return device information for the device registry."""
return {
"identifiers": {
# Serial numbers are unique identifiers within a specific domain
(DOMAIN, f"local_{self._config_entry.data[CONF_DEVICE_ID]}")
},
"name": self._config_entry.data[CONF_FRIENDLY_NAME],
"manufacturer": "Unknown",
"model": self._config_entry.data.get(CONF_PRODUCT_KEY, "Tuya generic"),
"sw_version": self._config_entry.data[CONF_PROTOCOL_VERSION],
}
@property
def name(self):
"""Get name of Tuya entity."""
return self._config[CONF_FRIENDLY_NAME]
@property
def should_poll(self):
"""Return if platform should poll for updates."""
return False
@property
def unique_id(self):
"""Return unique device identifier."""
return f"local_{self._config_entry.data[CONF_DEVICE_ID]}_{self._dp_id}"
def has_config(self, attr):
"""Return if a config parameter has a valid value."""
value = self._config.get(attr, "-1")
return value is not None and value != "-1"
@property
def available(self):
"""Return if device is available or not."""
return str(self._dp_id) in self._status
def dps(self, dp_index):
"""Return cached value for DPS index."""
value = self._status.get(str(dp_index))
if value is None:
self.warning(
"Entity %s is requesting unknown DPS index %s",
self.entity_id,
dp_index,
)
return value
def dps_conf(self, conf_item):
"""Return value of datapoint for user specified config item.
This method looks up which DP a certain config item uses based on
user configuration and returns its value.
"""
dp_index = self._config.get(conf_item)
if dp_index is None:
self.warning(
"Entity %s is requesting unset index for option %s",
self.entity_id,
conf_item,
)
return self.dps(dp_index)
def status_updated(self):
"""Device status was updated.
Override in subclasses and update entity specific state.
"""
def status_restored(self, stored_state):
"""Device status was restored.
Override in subclasses and update entity specific state.
"""

View File

@ -0,0 +1,478 @@
"""Config flow for LocalTuya integration integration."""
import errno
import logging
from importlib import import_module
import homeassistant.helpers.config_validation as cv
import voluptuous as vol
from homeassistant import config_entries, core, exceptions
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import (
CONF_DEVICE_ID,
CONF_ENTITIES,
CONF_FRIENDLY_NAME,
CONF_HOST,
CONF_ID,
CONF_PLATFORM,
CONF_SCAN_INTERVAL,
)
from homeassistant.core import callback
from .common import async_config_entry_by_device_id, pytuya
from .const import CONF_DPS_STRINGS # pylint: disable=unused-import
from .const import (
CONF_LOCAL_KEY,
CONF_PRODUCT_KEY,
CONF_PROTOCOL_VERSION,
DATA_DISCOVERY,
DOMAIN,
PLATFORMS,
)
from .discovery import discover
_LOGGER = logging.getLogger(__name__)
PLATFORM_TO_ADD = "platform_to_add"
NO_ADDITIONAL_PLATFORMS = "no_additional_platforms"
DISCOVERED_DEVICE = "discovered_device"
CUSTOM_DEVICE = "..."
BASIC_INFO_SCHEMA = vol.Schema(
{
vol.Required(CONF_FRIENDLY_NAME): str,
vol.Required(CONF_LOCAL_KEY): str,
vol.Required(CONF_HOST): str,
vol.Required(CONF_DEVICE_ID): str,
vol.Required(CONF_PROTOCOL_VERSION, default="3.3"): vol.In(["3.1", "3.3"]),
vol.Optional(CONF_SCAN_INTERVAL): int,
}
)
DEVICE_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): cv.string,
vol.Required(CONF_DEVICE_ID): cv.string,
vol.Required(CONF_LOCAL_KEY): cv.string,
vol.Required(CONF_FRIENDLY_NAME): cv.string,
vol.Required(CONF_PROTOCOL_VERSION, default="3.3"): vol.In(["3.1", "3.3"]),
vol.Optional(CONF_SCAN_INTERVAL): int,
}
)
PICK_ENTITY_SCHEMA = vol.Schema(
{vol.Required(PLATFORM_TO_ADD, default=PLATFORMS[0]): vol.In(PLATFORMS)}
)
def user_schema(devices, entries):
"""Create schema for user step."""
devices = {dev_id: dev["ip"] for dev_id, dev in devices.items()}
devices.update(
{
ent.data[CONF_DEVICE_ID]: ent.data[CONF_FRIENDLY_NAME]
for ent in entries
if ent.source != SOURCE_IMPORT
}
)
device_list = [f"{key} ({value})" for key, value in devices.items()]
return vol.Schema(
{vol.Required(DISCOVERED_DEVICE): vol.In(device_list + [CUSTOM_DEVICE])}
)
def options_schema(entities):
"""Create schema for options."""
entity_names = [
f"{entity[CONF_ID]} {entity[CONF_FRIENDLY_NAME]}" for entity in entities
]
return vol.Schema(
{
vol.Required(CONF_FRIENDLY_NAME): str,
vol.Required(CONF_HOST): str,
vol.Required(CONF_LOCAL_KEY): str,
vol.Required(CONF_PROTOCOL_VERSION, default="3.3"): vol.In(["3.1", "3.3"]),
vol.Optional(CONF_SCAN_INTERVAL): int,
vol.Required(
CONF_ENTITIES, description={"suggested_value": entity_names}
): cv.multi_select(entity_names),
}
)
def schema_defaults(schema, dps_list=None, **defaults):
"""Create a new schema with default values filled in."""
copy = schema.extend({})
for field, field_type in copy.schema.items():
if isinstance(field_type, vol.In):
value = None
for dps in dps_list or []:
if dps.startswith(f"{defaults.get(field)} "):
value = dps
break
if value in field_type.container:
field.default = vol.default_factory(value)
continue
if field.schema in defaults:
field.default = vol.default_factory(defaults[field])
return copy
def dps_string_list(dps_data):
"""Return list of friendly DPS values."""
return [f"{id} (value: {value})" for id, value in dps_data.items()]
def gen_dps_strings():
"""Generate list of DPS values."""
return [f"{dp} (value: ?)" for dp in range(1, 256)]
def platform_schema(platform, dps_strings, allow_id=True, yaml=False):
"""Generate input validation schema for a platform."""
schema = {}
if yaml:
# In YAML mode we force the specified platform to match flow schema
schema[vol.Required(CONF_PLATFORM)] = vol.In([platform])
if allow_id:
schema[vol.Required(CONF_ID)] = vol.In(dps_strings)
schema[vol.Required(CONF_FRIENDLY_NAME)] = str
return vol.Schema(schema).extend(flow_schema(platform, dps_strings))
def flow_schema(platform, dps_strings):
"""Return flow schema for a specific platform."""
integration_module = ".".join(__name__.split(".")[:-1])
return import_module("." + platform, integration_module).flow_schema(dps_strings)
def strip_dps_values(user_input, dps_strings):
"""Remove values and keep only index for DPS config items."""
stripped = {}
for field, value in user_input.items():
if value in dps_strings:
stripped[field] = int(user_input[field].split(" ")[0])
else:
stripped[field] = user_input[field]
return stripped
def config_schema():
"""Build schema used for setting up component."""
entity_schemas = [
platform_schema(platform, range(1, 256), yaml=True) for platform in PLATFORMS
]
return vol.Schema(
{
DOMAIN: vol.All(
cv.ensure_list,
[
DEVICE_SCHEMA.extend(
{vol.Required(CONF_ENTITIES): [vol.Any(*entity_schemas)]}
)
],
)
},
extra=vol.ALLOW_EXTRA,
)
async def validate_input(hass: core.HomeAssistant, data):
"""Validate the user input allows us to connect."""
detected_dps = {}
interface = None
try:
interface = await pytuya.connect(
data[CONF_HOST],
data[CONF_DEVICE_ID],
data[CONF_LOCAL_KEY],
float(data[CONF_PROTOCOL_VERSION]),
)
detected_dps = await interface.detect_available_dps()
except (ConnectionRefusedError, ConnectionResetError) as ex:
raise CannotConnect from ex
except ValueError as ex:
raise InvalidAuth from ex
finally:
if interface:
await interface.close()
# Indicate an error if no datapoints found as the rest of the flow
# won't work in this case
if not detected_dps:
raise EmptyDpsList
return dps_string_list(detected_dps)
class LocaltuyaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for LocalTuya integration."""
VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL
@staticmethod
@callback
def async_get_options_flow(config_entry):
"""Get options flow for this handler."""
return LocalTuyaOptionsFlowHandler(config_entry)
def __init__(self):
"""Initialize a new LocaltuyaConfigFlow."""
self.basic_info = None
self.dps_strings = []
self.platform = None
self.devices = {}
self.selected_device = None
self.entities = []
async def async_step_user(self, user_input=None):
"""Handle the initial step."""
errors = {}
if user_input is not None:
if user_input[DISCOVERED_DEVICE] != CUSTOM_DEVICE:
self.selected_device = user_input[DISCOVERED_DEVICE].split(" ")[0]
return await self.async_step_basic_info()
# Use cache if available or fallback to manual discovery
devices = {}
data = self.hass.data.get(DOMAIN)
if data and DATA_DISCOVERY in data:
devices = data[DATA_DISCOVERY].devices
else:
try:
devices = await discover()
except OSError as ex:
if ex.errno == errno.EADDRINUSE:
errors["base"] = "address_in_use"
else:
errors["base"] = "discovery_failed"
except Exception: # pylint: disable= broad-except
_LOGGER.exception("discovery failed")
errors["base"] = "discovery_failed"
self.devices = {
ip: dev
for ip, dev in devices.items()
if dev["gwId"] not in self._async_current_ids()
}
return self.async_show_form(
step_id="user",
errors=errors,
data_schema=user_schema(self.devices, self._async_current_entries()),
)
async def async_step_basic_info(self, user_input=None):
"""Handle input of basic info."""
errors = {}
if user_input is not None:
await self.async_set_unique_id(user_input[CONF_DEVICE_ID])
try:
self.basic_info = user_input
if self.selected_device is not None:
self.basic_info[CONF_PRODUCT_KEY] = self.devices[
self.selected_device
]["productKey"]
self.dps_strings = await validate_input(self.hass, user_input)
return await self.async_step_pick_entity_type()
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except EmptyDpsList:
errors["base"] = "empty_dps"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
# If selected device exists as a config entry, load config from it
if self.selected_device in self._async_current_ids():
entry = async_config_entry_by_device_id(self.hass, self.selected_device)
await self.async_set_unique_id(entry.data[CONF_DEVICE_ID])
self.basic_info = entry.data.copy()
self.dps_strings = self.basic_info.pop(CONF_DPS_STRINGS).copy()
self.entities = self.basic_info.pop(CONF_ENTITIES).copy()
return await self.async_step_pick_entity_type()
# Insert default values from discovery if present
defaults = {}
defaults.update(user_input or {})
if self.selected_device is not None:
device = self.devices[self.selected_device]
defaults[CONF_HOST] = device.get("ip")
defaults[CONF_DEVICE_ID] = device.get("gwId")
defaults[CONF_PROTOCOL_VERSION] = device.get("version")
return self.async_show_form(
step_id="basic_info",
data_schema=schema_defaults(BASIC_INFO_SCHEMA, **defaults),
errors=errors,
)
async def async_step_pick_entity_type(self, user_input=None):
"""Handle asking if user wants to add another entity."""
if user_input is not None:
if user_input.get(NO_ADDITIONAL_PLATFORMS):
config = {
**self.basic_info,
CONF_DPS_STRINGS: self.dps_strings,
CONF_ENTITIES: self.entities,
}
entry = async_config_entry_by_device_id(self.hass, self.unique_id)
if entry:
self.hass.config_entries.async_update_entry(entry, data=config)
return self.async_abort(reason="device_updated")
return self.async_create_entry(
title=config[CONF_FRIENDLY_NAME], data=config
)
self.platform = user_input[PLATFORM_TO_ADD]
return await self.async_step_add_entity()
# Add a checkbox that allows bailing out from config flow iff at least one
# entity has been added
schema = PICK_ENTITY_SCHEMA
if self.platform is not None:
schema = schema.extend(
{vol.Required(NO_ADDITIONAL_PLATFORMS, default=True): bool}
)
return self.async_show_form(step_id="pick_entity_type", data_schema=schema)
async def async_step_add_entity(self, user_input=None):
"""Handle adding a new entity."""
errors = {}
if user_input is not None:
already_configured = any(
switch[CONF_ID] == int(user_input[CONF_ID].split(" ")[0])
for switch in self.entities
)
if not already_configured:
user_input[CONF_PLATFORM] = self.platform
self.entities.append(strip_dps_values(user_input, self.dps_strings))
return await self.async_step_pick_entity_type()
errors["base"] = "entity_already_configured"
return self.async_show_form(
step_id="add_entity",
data_schema=platform_schema(self.platform, self.dps_strings),
errors=errors,
description_placeholders={"platform": self.platform},
)
async def async_step_import(self, user_input):
"""Handle import from YAML."""
await self.async_set_unique_id(user_input[CONF_DEVICE_ID])
self._abort_if_unique_id_configured(updates=user_input)
return self.async_create_entry(
title=f"{user_input[CONF_FRIENDLY_NAME]} (YAML)", data=user_input
)
class LocalTuyaOptionsFlowHandler(config_entries.OptionsFlow):
"""Handle options flow for LocalTuya integration."""
def __init__(self, config_entry):
"""Initialize localtuya options flow."""
self.config_entry = config_entry
self.dps_strings = config_entry.data.get(CONF_DPS_STRINGS, gen_dps_strings())
self.entities = config_entry.data[CONF_ENTITIES]
self.data = None
async def async_step_init(self, user_input=None):
"""Manage basic options."""
device_id = self.config_entry.data[CONF_DEVICE_ID]
if user_input is not None:
self.data = user_input.copy()
self.data.update(
{
CONF_DEVICE_ID: device_id,
CONF_DPS_STRINGS: self.dps_strings,
CONF_ENTITIES: [],
}
)
if len(user_input[CONF_ENTITIES]) > 0:
entity_ids = [
int(entity.split(" ")[0]) for entity in user_input[CONF_ENTITIES]
]
self.entities = [
entity
for entity in self.config_entry.data[CONF_ENTITIES]
if entity[CONF_ID] in entity_ids
]
return await self.async_step_entity()
# Not supported for YAML imports
if self.config_entry.source == config_entries.SOURCE_IMPORT:
return await self.async_step_yaml_import()
return self.async_show_form(
step_id="init",
data_schema=schema_defaults(
options_schema(self.entities), **self.config_entry.data
),
description_placeholders={"device_id": device_id},
)
async def async_step_entity(self, user_input=None):
"""Manage entity settings."""
errors = {}
if user_input is not None:
entity = strip_dps_values(user_input, self.dps_strings)
entity[CONF_ID] = self.current_entity[CONF_ID]
entity[CONF_PLATFORM] = self.current_entity[CONF_PLATFORM]
self.data[CONF_ENTITIES].append(entity)
if len(self.entities) == len(self.data[CONF_ENTITIES]):
self.hass.config_entries.async_update_entry(
self.config_entry,
title=self.data[CONF_FRIENDLY_NAME],
data=self.data,
)
return self.async_create_entry(title="", data={})
schema = platform_schema(
self.current_entity[CONF_PLATFORM], self.dps_strings, allow_id=False
)
return self.async_show_form(
step_id="entity",
errors=errors,
data_schema=schema_defaults(
schema, self.dps_strings, **self.current_entity
),
description_placeholders={
"id": self.current_entity[CONF_ID],
"platform": self.current_entity[CONF_PLATFORM],
},
)
async def async_step_yaml_import(self, user_input=None):
"""Manage YAML imports."""
if user_input is not None:
return self.async_create_entry(title="", data={})
return self.async_show_form(step_id="yaml_import")
@property
def current_entity(self):
"""Existing configuration for entity currently being edited."""
return self.entities[len(self.data[CONF_ENTITIES])]
class CannotConnect(exceptions.HomeAssistantError):
"""Error to indicate we cannot connect."""
class InvalidAuth(exceptions.HomeAssistantError):
"""Error to indicate there is invalid auth."""
class EmptyDpsList(exceptions.HomeAssistantError):
"""Error to indicate no datapoints found."""

View File

@ -0,0 +1,103 @@
"""Constants for localtuya integration."""
ATTR_CURRENT = "current"
ATTR_CURRENT_CONSUMPTION = "current_consumption"
ATTR_VOLTAGE = "voltage"
CONF_LOCAL_KEY = "local_key"
CONF_PROTOCOL_VERSION = "protocol_version"
CONF_DPS_STRINGS = "dps_strings"
CONF_PRODUCT_KEY = "product_key"
# light
CONF_BRIGHTNESS_LOWER = "brightness_lower"
CONF_BRIGHTNESS_UPPER = "brightness_upper"
CONF_COLOR = "color"
CONF_COLOR_MODE = "color_mode"
CONF_COLOR_TEMP_MIN_KELVIN = "color_temp_min_kelvin"
CONF_COLOR_TEMP_MAX_KELVIN = "color_temp_max_kelvin"
CONF_COLOR_TEMP_REVERSE = "color_temp_reverse"
CONF_MUSIC_MODE = "music_mode"
# switch
CONF_CURRENT = "current"
CONF_CURRENT_CONSUMPTION = "current_consumption"
CONF_VOLTAGE = "voltage"
# cover
CONF_COMMANDS_SET = "commands_set"
CONF_POSITIONING_MODE = "positioning_mode"
CONF_CURRENT_POSITION_DP = "current_position_dp"
CONF_SET_POSITION_DP = "set_position_dp"
CONF_POSITION_INVERTED = "position_inverted"
CONF_SPAN_TIME = "span_time"
# fan
CONF_FAN_SPEED_CONTROL = "fan_speed_control"
CONF_FAN_OSCILLATING_CONTROL = "fan_oscillating_control"
CONF_FAN_SPEED_MIN = "fan_speed_min"
CONF_FAN_SPEED_MAX = "fan_speed_max"
CONF_FAN_ORDERED_LIST = "fan_speed_ordered_list"
CONF_FAN_DIRECTION = "fan_direction"
CONF_FAN_DIRECTION_FWD = "fan_direction_forward"
CONF_FAN_DIRECTION_REV = "fan_direction_reverse"
# sensor
CONF_SCALING = "scaling"
# climate
CONF_TARGET_TEMPERATURE_DP = "target_temperature_dp"
CONF_CURRENT_TEMPERATURE_DP = "current_temperature_dp"
CONF_TEMPERATURE_STEP = "temperature_step"
CONF_MAX_TEMP_DP = "max_temperature_dp"
CONF_MIN_TEMP_DP = "min_temperature_dp"
CONF_PRECISION = "precision"
CONF_TARGET_PRECISION = "target_precision"
CONF_HVAC_MODE_DP = "hvac_mode_dp"
CONF_HVAC_MODE_SET = "hvac_mode_set"
CONF_PRESET_DP = "preset_dp"
CONF_PRESET_SET = "preset_set"
CONF_HEURISTIC_ACTION = "heuristic_action"
CONF_HVAC_ACTION_DP = "hvac_action_dp"
CONF_HVAC_ACTION_SET = "hvac_action_set"
CONF_ECO_DP = "eco_dp"
CONF_ECO_VALUE = "eco_value"
# vacuum
CONF_POWERGO_DP = "powergo_dp"
CONF_IDLE_STATUS_VALUE = "idle_status_value"
CONF_RETURNING_STATUS_VALUE = "returning_status_value"
CONF_DOCKED_STATUS_VALUE = "docked_status_value"
CONF_BATTERY_DP = "battery_dp"
CONF_MODE_DP = "mode_dp"
CONF_MODES = "modes"
CONF_FAN_SPEED_DP = "fan_speed_dp"
CONF_FAN_SPEEDS = "fan_speeds"
CONF_CLEAN_TIME_DP = "clean_time_dp"
CONF_CLEAN_AREA_DP = "clean_area_dp"
CONF_CLEAN_RECORD_DP = "clean_record_dp"
CONF_LOCATE_DP = "locate_dp"
CONF_FAULT_DP = "fault_dp"
CONF_PAUSED_STATE = "paused_state"
CONF_RETURN_MODE = "return_mode"
CONF_STOP_STATUS = "stop_status"
DATA_DISCOVERY = "discovery"
DOMAIN = "localtuya"
# Platforms in this list must support config flows
PLATFORMS = [
"binary_sensor",
"climate",
"cover",
"fan",
"light",
"number",
"select",
"sensor",
"switch",
"vacuum",
]
TUYA_DEVICE = "tuya_device"

View File

@ -0,0 +1,232 @@
"""Platform to locally control Tuya-based cover devices."""
import asyncio
import logging
import time
from functools import partial
import voluptuous as vol
from homeassistant.components.cover import (
ATTR_POSITION,
DOMAIN,
SUPPORT_CLOSE,
SUPPORT_OPEN,
SUPPORT_SET_POSITION,
SUPPORT_STOP,
CoverEntity,
)
from .common import LocalTuyaEntity, async_setup_entry
from .const import (
CONF_COMMANDS_SET,
CONF_CURRENT_POSITION_DP,
CONF_POSITION_INVERTED,
CONF_POSITIONING_MODE,
CONF_SET_POSITION_DP,
CONF_SPAN_TIME,
)
_LOGGER = logging.getLogger(__name__)
COVER_ONOFF_CMDS = "on_off_stop"
COVER_OPENCLOSE_CMDS = "open_close_stop"
COVER_FZZZ_CMDS = "fz_zz_stop"
COVER_12_CMDS = "1_2_3"
COVER_MODE_NONE = "none"
COVER_MODE_POSITION = "position"
COVER_MODE_TIMED = "timed"
COVER_TIMEOUT_TOLERANCE = 3.0
DEFAULT_COMMANDS_SET = COVER_ONOFF_CMDS
DEFAULT_POSITIONING_MODE = COVER_MODE_NONE
DEFAULT_SPAN_TIME = 25.0
def flow_schema(dps):
"""Return schema used in config flow."""
return {
vol.Optional(CONF_COMMANDS_SET): vol.In(
[COVER_ONOFF_CMDS, COVER_OPENCLOSE_CMDS, COVER_FZZZ_CMDS, COVER_12_CMDS]
),
vol.Optional(CONF_POSITIONING_MODE, default=DEFAULT_POSITIONING_MODE): vol.In(
[COVER_MODE_NONE, COVER_MODE_POSITION, COVER_MODE_TIMED]
),
vol.Optional(CONF_CURRENT_POSITION_DP): vol.In(dps),
vol.Optional(CONF_SET_POSITION_DP): vol.In(dps),
vol.Optional(CONF_POSITION_INVERTED, default=False): bool,
vol.Optional(CONF_SPAN_TIME, default=DEFAULT_SPAN_TIME): vol.All(
vol.Coerce(float), vol.Range(min=1.0, max=300.0)
),
}
class LocaltuyaCover(LocalTuyaEntity, CoverEntity):
"""Tuya cover device."""
def __init__(self, device, config_entry, switchid, **kwargs):
"""Initialize a new LocaltuyaCover."""
super().__init__(device, config_entry, switchid, _LOGGER, **kwargs)
commands_set = DEFAULT_COMMANDS_SET
if self.has_config(CONF_COMMANDS_SET):
commands_set = self._config[CONF_COMMANDS_SET]
self._open_cmd = commands_set.split("_")[0]
self._close_cmd = commands_set.split("_")[1]
self._stop_cmd = commands_set.split("_")[2]
self._timer_start = time.time()
self._state = self._stop_cmd
self._previous_state = self._state
self._current_cover_position = 0
print("Initialized cover [{}]".format(self.name))
@property
def supported_features(self):
"""Flag supported features."""
supported_features = SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_STOP
if self._config[CONF_POSITIONING_MODE] != COVER_MODE_NONE:
supported_features = supported_features | SUPPORT_SET_POSITION
return supported_features
@property
def current_cover_position(self):
"""Return current cover position in percent."""
if self._config[CONF_POSITIONING_MODE] == COVER_MODE_NONE:
return None
return self._current_cover_position
@property
def is_opening(self):
"""Return if cover is opening."""
state = self._state
return state == self._open_cmd
@property
def is_closing(self):
"""Return if cover is closing."""
state = self._state
return state == self._close_cmd
@property
def is_closed(self):
"""Return if the cover is closed or not."""
if self._config[CONF_POSITIONING_MODE] == COVER_MODE_NONE:
return None
if self._current_cover_position == 0:
return True
if self._current_cover_position == 100:
return False
return None
async def async_set_cover_position(self, **kwargs):
"""Move the cover to a specific position."""
self.debug("Setting cover position: %r", kwargs[ATTR_POSITION])
if self._config[CONF_POSITIONING_MODE] == COVER_MODE_TIMED:
newpos = float(kwargs[ATTR_POSITION])
currpos = self.current_cover_position
posdiff = abs(newpos - currpos)
mydelay = posdiff / 100.0 * self._config[CONF_SPAN_TIME]
if newpos > currpos:
self.debug("Opening to %f: delay %f", newpos, mydelay)
await self.async_open_cover()
else:
self.debug("Closing to %f: delay %f", newpos, mydelay)
await self.async_close_cover()
self.hass.async_create_task(self.async_stop_after_timeout(mydelay))
self.debug("Done")
elif self._config[CONF_POSITIONING_MODE] == COVER_MODE_POSITION:
converted_position = int(kwargs[ATTR_POSITION])
if self._config[CONF_POSITION_INVERTED]:
converted_position = 100 - converted_position
if 0 <= converted_position <= 100 and self.has_config(CONF_SET_POSITION_DP):
await self._device.set_dp(
converted_position, self._config[CONF_SET_POSITION_DP]
)
async def async_stop_after_timeout(self, delay_sec):
"""Stop the cover if timeout (max movement span) occurred."""
await asyncio.sleep(delay_sec)
await self.async_stop_cover()
async def async_open_cover(self, **kwargs):
"""Open the cover."""
self.debug("Launching command %s to cover ", self._open_cmd)
await self._device.set_dp(self._open_cmd, self._dp_id)
if self._config[CONF_POSITIONING_MODE] == COVER_MODE_TIMED:
# for timed positioning, stop the cover after a full opening timespan
# instead of waiting the internal timeout
self.hass.async_create_task(
self.async_stop_after_timeout(
self._config[CONF_SPAN_TIME] + COVER_TIMEOUT_TOLERANCE
)
)
async def async_close_cover(self, **kwargs):
"""Close cover."""
self.debug("Launching command %s to cover ", self._close_cmd)
await self._device.set_dp(self._close_cmd, self._dp_id)
if self._config[CONF_POSITIONING_MODE] == COVER_MODE_TIMED:
# for timed positioning, stop the cover after a full opening timespan
# instead of waiting the internal timeout
self.hass.async_create_task(
self.async_stop_after_timeout(
self._config[CONF_SPAN_TIME] + COVER_TIMEOUT_TOLERANCE
)
)
async def async_stop_cover(self, **kwargs):
"""Stop the cover."""
self.debug("Launching command %s to cover ", self._stop_cmd)
await self._device.set_dp(self._stop_cmd, self._dp_id)
def status_restored(self, stored_state):
"""Restore the last stored cover status."""
if self._config[CONF_POSITIONING_MODE] == COVER_MODE_TIMED:
stored_pos = stored_state.attributes.get("current_position")
if stored_pos is not None:
self._current_cover_position = stored_pos
self.debug("Restored cover position %s", self._current_cover_position)
def status_updated(self):
"""Device status was updated."""
self._previous_state = self._state
self._state = self.dps(self._dp_id)
if self._state.isupper():
self._open_cmd = self._open_cmd.upper()
self._close_cmd = self._close_cmd.upper()
self._stop_cmd = self._stop_cmd.upper()
if self.has_config(CONF_CURRENT_POSITION_DP):
curr_pos = self.dps_conf(CONF_CURRENT_POSITION_DP)
if self._config[CONF_POSITION_INVERTED]:
self._current_cover_position = 100 - curr_pos
else:
self._current_cover_position = curr_pos
if (
self._config[CONF_POSITIONING_MODE] == COVER_MODE_TIMED
and self._state != self._previous_state
):
if self._previous_state != self._stop_cmd:
# the state has changed, and the cover was moving
time_diff = time.time() - self._timer_start
pos_diff = round(time_diff / self._config[CONF_SPAN_TIME] * 100.0)
if self._previous_state == self._close_cmd:
pos_diff = -pos_diff
self._current_cover_position = min(
100, max(0, self._current_cover_position + pos_diff)
)
change = "stopped" if self._state == self._stop_cmd else "inverted"
self.debug(
"Movement %s after %s sec., position difference %s",
change,
time_diff,
pos_diff,
)
# store the time of the last movement change
self._timer_start = time.time()
async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaCover, flow_schema)

View File

@ -0,0 +1,90 @@
"""Discovery module for Tuya devices.
Entirely based on tuya-convert.py from tuya-convert:
https://github.com/ct-Open-Source/tuya-convert/blob/master/scripts/tuya-discovery.py
"""
import asyncio
import json
import logging
from hashlib import md5
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
_LOGGER = logging.getLogger(__name__)
UDP_KEY = md5(b"yGAdlopoPVldABfn").digest()
DEFAULT_TIMEOUT = 6.0
def decrypt_udp(message):
"""Decrypt encrypted UDP broadcasts."""
def _unpad(data):
return data[: -ord(data[len(data) - 1 :])]
cipher = Cipher(algorithms.AES(UDP_KEY), modes.ECB(), default_backend())
decryptor = cipher.decryptor()
return _unpad(decryptor.update(message) + decryptor.finalize()).decode()
class TuyaDiscovery(asyncio.DatagramProtocol):
"""Datagram handler listening for Tuya broadcast messages."""
def __init__(self, callback=None):
"""Initialize a new BaseDiscovery."""
self.devices = {}
self._listeners = []
self._callback = callback
async def start(self):
"""Start discovery by listening to broadcasts."""
loop = asyncio.get_running_loop()
listener = loop.create_datagram_endpoint(
lambda: self, local_addr=("0.0.0.0", 6666)
)
encrypted_listener = loop.create_datagram_endpoint(
lambda: self, local_addr=("0.0.0.0", 6667)
)
self._listeners = await asyncio.gather(listener, encrypted_listener)
_LOGGER.debug("Listening to broadcasts on UDP port 6666 and 6667")
def close(self):
"""Stop discovery."""
self._callback = None
for transport, _ in self._listeners:
transport.close()
def datagram_received(self, data, addr):
"""Handle received broadcast message."""
data = data[20:-8]
try:
data = decrypt_udp(data)
except Exception: # pylint: disable=broad-except
data = data.decode()
decoded = json.loads(data)
self.device_found(decoded)
def device_found(self, device):
"""Discover a new device."""
if device.get("ip") not in self.devices:
self.devices[device.get("gwId")] = device
_LOGGER.debug("Discovered device: %s", device)
if self._callback:
self._callback(device)
async def discover():
"""Discover and return devices on local network."""
discovery = TuyaDiscovery()
try:
await discovery.start()
await asyncio.sleep(DEFAULT_TIMEOUT)
finally:
discovery.close()
return discovery.devices

View File

@ -0,0 +1,255 @@
"""Platform to locally control Tuya-based fan devices."""
import logging
import math
from functools import partial
import homeassistant.helpers.config_validation as cv
import voluptuous as vol
from homeassistant.components.fan import (
DIRECTION_FORWARD,
DIRECTION_REVERSE,
DOMAIN,
SUPPORT_DIRECTION,
SUPPORT_OSCILLATE,
SUPPORT_SET_SPEED,
FanEntity,
)
from homeassistant.util.percentage import (
int_states_in_range,
ordered_list_item_to_percentage,
percentage_to_ordered_list_item,
percentage_to_ranged_value,
ranged_value_to_percentage,
)
from .common import LocalTuyaEntity, async_setup_entry
from .const import (
CONF_FAN_DIRECTION,
CONF_FAN_DIRECTION_FWD,
CONF_FAN_DIRECTION_REV,
CONF_FAN_ORDERED_LIST,
CONF_FAN_OSCILLATING_CONTROL,
CONF_FAN_SPEED_CONTROL,
CONF_FAN_SPEED_MAX,
CONF_FAN_SPEED_MIN,
)
_LOGGER = logging.getLogger(__name__)
def flow_schema(dps):
"""Return schema used in config flow."""
return {
vol.Optional(CONF_FAN_SPEED_CONTROL): vol.In(dps),
vol.Optional(CONF_FAN_OSCILLATING_CONTROL): vol.In(dps),
vol.Optional(CONF_FAN_DIRECTION): vol.In(dps),
vol.Optional(CONF_FAN_DIRECTION_FWD, default="forward"): cv.string,
vol.Optional(CONF_FAN_DIRECTION_REV, default="reverse"): cv.string,
vol.Optional(CONF_FAN_SPEED_MIN, default=1): cv.positive_int,
vol.Optional(CONF_FAN_SPEED_MAX, default=9): cv.positive_int,
vol.Optional(CONF_FAN_ORDERED_LIST, default="disabled"): cv.string,
}
class LocaltuyaFan(LocalTuyaEntity, FanEntity):
"""Representation of a Tuya fan."""
def __init__(
self,
device,
config_entry,
fanid,
**kwargs,
):
"""Initialize the entity."""
super().__init__(device, config_entry, fanid, _LOGGER, **kwargs)
self._is_on = False
self._oscillating = None
self._direction = None
self._percentage = None
self._speed_range = (
self._config.get(CONF_FAN_SPEED_MIN),
self._config.get(CONF_FAN_SPEED_MAX),
)
self._ordered_list = self._config.get(CONF_FAN_ORDERED_LIST).split(",")
self._ordered_list_mode = None
if isinstance(self._ordered_list, list) and len(self._ordered_list) > 1:
self._use_ordered_list = True
_LOGGER.debug(
"Fan _use_ordered_list: %s > %s",
self._use_ordered_list,
self._ordered_list,
)
else:
self._use_ordered_list = False
_LOGGER.debug("Fan _use_ordered_list: %s", self._use_ordered_list)
@property
def oscillating(self):
"""Return current oscillating status."""
return self._oscillating
@property
def current_direction(self):
"""Return the current direction of the fan."""
return self._direction
@property
def is_on(self):
"""Check if Tuya fan is on."""
return self._is_on
@property
def percentage(self):
"""Return the current percentage."""
return self._percentage
async def async_turn_on(
self,
speed: str = None,
percentage: int = None,
preset_mode: str = None,
**kwargs,
) -> None:
"""Turn on the entity."""
_LOGGER.debug("Fan async_turn_on")
await self._device.set_dp(True, self._dp_id)
if percentage is not None:
await self.async_set_percentage(percentage)
else:
self.schedule_update_ha_state()
async def async_turn_off(self, **kwargs) -> None:
"""Turn off the entity."""
_LOGGER.debug("Fan async_turn_off")
await self._device.set_dp(False, self._dp_id)
self.schedule_update_ha_state()
async def async_set_percentage(self, percentage):
"""Set the speed of the fan."""
_LOGGER.debug("Fan async_set_percentage: %s", percentage)
if percentage is not None:
if percentage == 0:
return await self.async_turn_off()
if not self.is_on:
await self.async_turn_on()
if self._use_ordered_list:
await self._device.set_dp(
str(
percentage_to_ordered_list_item(self._ordered_list, percentage)
),
self._config.get(CONF_FAN_SPEED_CONTROL),
)
_LOGGER.debug(
"Fan async_set_percentage: %s > %s",
percentage,
percentage_to_ordered_list_item(self._ordered_list, percentage),
)
else:
await self._device.set_dp(
str(
math.ceil(
percentage_to_ranged_value(self._speed_range, percentage)
)
),
self._config.get(CONF_FAN_SPEED_CONTROL),
)
_LOGGER.debug(
"Fan async_set_percentage: %s > %s",
percentage,
percentage_to_ranged_value(self._speed_range, percentage),
)
self.schedule_update_ha_state()
async def async_oscillate(self, oscillating: bool) -> None:
"""Set oscillation."""
_LOGGER.debug("Fan async_oscillate: %s", oscillating)
await self._device.set_dp(
oscillating, self._config.get(CONF_FAN_OSCILLATING_CONTROL)
)
self.schedule_update_ha_state()
async def async_set_direction(self, direction):
"""Set the direction of the fan."""
_LOGGER.debug("Fan async_set_direction: %s", direction)
if direction == DIRECTION_FORWARD:
value = self._config.get(CONF_FAN_DIRECTION_FWD)
if direction == DIRECTION_REVERSE:
value = self._config.get(CONF_FAN_DIRECTION_REV)
await self._device.set_dp(value, self._config.get(CONF_FAN_DIRECTION))
self.schedule_update_ha_state()
@property
def supported_features(self) -> int:
"""Flag supported features."""
features = 0
if self.has_config(CONF_FAN_OSCILLATING_CONTROL):
features |= SUPPORT_OSCILLATE
if self.has_config(CONF_FAN_SPEED_CONTROL):
features |= SUPPORT_SET_SPEED
if self.has_config(CONF_FAN_DIRECTION):
features |= SUPPORT_DIRECTION
return features
@property
def speed_count(self) -> int:
"""Speed count for the fan."""
speed_count = int_states_in_range(self._speed_range)
_LOGGER.debug("Fan speed_count: %s", speed_count)
return speed_count
def status_updated(self):
"""Get state of Tuya fan."""
self._is_on = self.dps(self._dp_id)
current_speed = self.dps_conf(CONF_FAN_SPEED_CONTROL)
if self._use_ordered_list:
_LOGGER.debug(
"Fan current_speed ordered_list_item_to_percentage: %s from %s",
current_speed,
self._ordered_list,
)
if current_speed is not None:
self._percentage = ordered_list_item_to_percentage(
self._ordered_list, current_speed
)
else:
_LOGGER.debug(
"Fan current_speed ranged_value_to_percentage: %s from %s",
current_speed,
self._speed_range,
)
if current_speed is not None:
self._percentage = ranged_value_to_percentage(
self._speed_range, int(current_speed)
)
_LOGGER.debug("Fan current_percentage: %s", self._percentage)
if self.has_config(CONF_FAN_OSCILLATING_CONTROL):
self._oscillating = self.dps_conf(CONF_FAN_OSCILLATING_CONTROL)
_LOGGER.debug("Fan current_oscillating : %s", self._oscillating)
if self.has_config(CONF_FAN_DIRECTION):
value = self.dps_conf(CONF_FAN_DIRECTION)
if value is not None:
if value == self._config.get(CONF_FAN_DIRECTION_FWD):
self._direction = DIRECTION_FORWARD
if value == self._config.get(CONF_FAN_DIRECTION_REV):
self._direction = DIRECTION_REVERSE
_LOGGER.debug("Fan current_direction : %s > %s", value, self._direction)
async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaFan, flow_schema)

View File

@ -0,0 +1,449 @@
"""Platform to locally control Tuya-based light devices."""
import logging
import textwrap
from functools import partial
import homeassistant.util.color as color_util
import voluptuous as vol
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP,
ATTR_EFFECT,
ATTR_HS_COLOR,
DOMAIN,
SUPPORT_BRIGHTNESS,
SUPPORT_COLOR,
SUPPORT_COLOR_TEMP,
SUPPORT_EFFECT,
LightEntity,
)
from homeassistant.const import CONF_BRIGHTNESS, CONF_COLOR_TEMP, CONF_SCENE
from .common import LocalTuyaEntity, async_setup_entry
from .const import (
CONF_BRIGHTNESS_LOWER,
CONF_BRIGHTNESS_UPPER,
CONF_COLOR,
CONF_COLOR_MODE,
CONF_COLOR_TEMP_MAX_KELVIN,
CONF_COLOR_TEMP_MIN_KELVIN,
CONF_COLOR_TEMP_REVERSE,
CONF_MUSIC_MODE,
)
_LOGGER = logging.getLogger(__name__)
MIRED_TO_KELVIN_CONST = 1000000
DEFAULT_MIN_KELVIN = 2700 # MIRED 370
DEFAULT_MAX_KELVIN = 6500 # MIRED 153
DEFAULT_COLOR_TEMP_REVERSE = False
DEFAULT_LOWER_BRIGHTNESS = 29
DEFAULT_UPPER_BRIGHTNESS = 1000
MODE_COLOR = "colour"
MODE_MUSIC = "music"
MODE_SCENE = "scene"
MODE_WHITE = "white"
SCENE_CUSTOM = "Custom"
SCENE_MUSIC = "Music"
SCENE_LIST_RGBW_1000 = {
"Night": "000e0d0000000000000000c80000",
"Read": "010e0d0000000000000003e801f4",
"Meeting": "020e0d0000000000000003e803e8",
"Leasure": "030e0d0000000000000001f401f4",
"Soft": "04464602007803e803e800000000464602007803e8000a00000000",
"Rainbow": "05464601000003e803e800000000464601007803e803e80000000046460100f003e803"
+ "e800000000",
"Shine": "06464601000003e803e800000000464601007803e803e80000000046460100f003e803e8"
+ "00000000",
"Beautiful": "07464602000003e803e800000000464602007803e803e80000000046460200f003e8"
+ "03e800000000464602003d03e803e80000000046460200ae03e803e800000000464602011303e80"
+ "3e800000000",
}
SCENE_LIST_RGBW_255 = {
"Night": "bd76000168ffff",
"Read": "fffcf70168ffff",
"Meeting": "cf38000168ffff",
"Leasure": "3855b40168ffff",
"Scenario 1": "scene_1",
"Scenario 2": "scene_2",
"Scenario 3": "scene_3",
"Scenario 4": "scene_4",
}
SCENE_LIST_RGB_1000 = {
"Night": "000e0d00002e03e802cc00000000",
"Read": "010e0d000084000003e800000000",
"Working": "020e0d00001403e803e800000000",
"Leisure": "030e0d0000e80383031c00000000",
"Soft": "04464602007803e803e800000000464602007803e8000a00000000",
"Colorful": "05464601000003e803e800000000464601007803e803e80000000046460100f003e80"
+ "3e800000000464601003d03e803e80000000046460100ae03e803e800000000464601011303e803"
+ "e800000000",
"Dazzling": "06464601000003e803e800000000464601007803e803e80000000046460100f003e80"
+ "3e800000000",
"Music": "07464602000003e803e800000000464602007803e803e80000000046460200f003e803e8"
+ "00000000464602003d03e803e80000000046460200ae03e803e800000000464602011303e803e80"
+ "0000000",
}
def map_range(value, from_lower, from_upper, to_lower, to_upper):
"""Map a value in one range to another."""
mapped = (value - from_lower) * (to_upper - to_lower) / (
from_upper - from_lower
) + to_lower
return round(min(max(mapped, to_lower), to_upper))
def flow_schema(dps):
"""Return schema used in config flow."""
return {
vol.Optional(CONF_BRIGHTNESS): vol.In(dps),
vol.Optional(CONF_COLOR_TEMP): vol.In(dps),
vol.Optional(CONF_BRIGHTNESS_LOWER, default=DEFAULT_LOWER_BRIGHTNESS): vol.All(
vol.Coerce(int), vol.Range(min=0, max=10000)
),
vol.Optional(CONF_BRIGHTNESS_UPPER, default=DEFAULT_UPPER_BRIGHTNESS): vol.All(
vol.Coerce(int), vol.Range(min=0, max=10000)
),
vol.Optional(CONF_COLOR_MODE): vol.In(dps),
vol.Optional(CONF_COLOR): vol.In(dps),
vol.Optional(CONF_COLOR_TEMP_MIN_KELVIN, default=DEFAULT_MIN_KELVIN): vol.All(
vol.Coerce(int), vol.Range(min=1500, max=8000)
),
vol.Optional(CONF_COLOR_TEMP_MAX_KELVIN, default=DEFAULT_MAX_KELVIN): vol.All(
vol.Coerce(int), vol.Range(min=1500, max=8000)
),
vol.Optional(
CONF_COLOR_TEMP_REVERSE,
default=DEFAULT_COLOR_TEMP_REVERSE,
description={"suggested_value": DEFAULT_COLOR_TEMP_REVERSE},
): bool,
vol.Optional(CONF_SCENE): vol.In(dps),
vol.Optional(
CONF_MUSIC_MODE, default=False, description={"suggested_value": False}
): bool,
}
class LocaltuyaLight(LocalTuyaEntity, LightEntity):
"""Representation of a Tuya light."""
def __init__(
self,
device,
config_entry,
lightid,
**kwargs,
):
"""Initialize the Tuya light."""
super().__init__(device, config_entry, lightid, _LOGGER, **kwargs)
self._state = False
self._brightness = None
self._color_temp = None
self._lower_brightness = self._config.get(
CONF_BRIGHTNESS_LOWER, DEFAULT_LOWER_BRIGHTNESS
)
self._upper_brightness = self._config.get(
CONF_BRIGHTNESS_UPPER, DEFAULT_UPPER_BRIGHTNESS
)
self._upper_color_temp = self._upper_brightness
self._max_mired = round(
MIRED_TO_KELVIN_CONST
/ self._config.get(CONF_COLOR_TEMP_MIN_KELVIN, DEFAULT_MIN_KELVIN)
)
self._min_mired = round(
MIRED_TO_KELVIN_CONST
/ self._config.get(CONF_COLOR_TEMP_MAX_KELVIN, DEFAULT_MAX_KELVIN)
)
self._color_temp_reverse = self._config.get(
CONF_COLOR_TEMP_REVERSE, DEFAULT_COLOR_TEMP_REVERSE
)
self._hs = None
self._effect = None
self._effect_list = []
self._scenes = None
if self.has_config(CONF_SCENE):
if self._config.get(CONF_SCENE) < 20:
self._scenes = SCENE_LIST_RGBW_255
elif self._config.get(CONF_BRIGHTNESS) is None:
self._scenes = SCENE_LIST_RGB_1000
else:
self._scenes = SCENE_LIST_RGBW_1000
self._effect_list = list(self._scenes.keys())
if self._config.get(CONF_MUSIC_MODE):
self._effect_list.append(SCENE_MUSIC)
@property
def is_on(self):
"""Check if Tuya light is on."""
return self._state
@property
def brightness(self):
"""Return the brightness of the light."""
if self.is_color_mode or self.is_white_mode:
return map_range(
self._brightness, self._lower_brightness, self._upper_brightness, 0, 255
)
return None
@property
def hs_color(self):
"""Return the hs color value."""
if self.is_color_mode:
return self._hs
if (
self.supported_features & SUPPORT_COLOR
and not self.supported_features & SUPPORT_COLOR_TEMP
):
return [0, 0]
return None
@property
def color_temp(self):
"""Return the color_temp of the light."""
if self.has_config(CONF_COLOR_TEMP) and self.is_white_mode:
color_temp_value = (
self._upper_color_temp - self._color_temp
if self._color_temp_reverse
else self._color_temp
)
return int(
self._max_mired
- (
((self._max_mired - self._min_mired) / self._upper_color_temp)
* color_temp_value
)
)
return None
@property
def min_mireds(self):
"""Return color temperature min mireds."""
return self._min_mired
@property
def max_mireds(self):
"""Return color temperature max mireds."""
return self._max_mired
@property
def effect(self):
"""Return the current effect for this light."""
if self.is_scene_mode or self.is_music_mode:
return self._effect
return None
@property
def effect_list(self):
"""Return the list of supported effects for this light."""
return self._effect_list
@property
def supported_features(self):
"""Flag supported features."""
supports = 0
if self.has_config(CONF_BRIGHTNESS):
supports |= SUPPORT_BRIGHTNESS
if self.has_config(CONF_COLOR_TEMP):
supports |= SUPPORT_COLOR_TEMP
if self.has_config(CONF_COLOR):
supports |= SUPPORT_COLOR | SUPPORT_BRIGHTNESS
if self.has_config(CONF_SCENE) or self.has_config(CONF_MUSIC_MODE):
supports |= SUPPORT_EFFECT
return supports
@property
def is_white_mode(self):
"""Return true if the light is in white mode."""
color_mode = self.__get_color_mode()
return color_mode is None or color_mode == MODE_WHITE
@property
def is_color_mode(self):
"""Return true if the light is in color mode."""
color_mode = self.__get_color_mode()
return color_mode is not None and color_mode == MODE_COLOR
@property
def is_scene_mode(self):
"""Return true if the light is in scene mode."""
color_mode = self.__get_color_mode()
return color_mode is not None and color_mode.startswith(MODE_SCENE)
@property
def is_music_mode(self):
"""Return true if the light is in music mode."""
color_mode = self.__get_color_mode()
return color_mode is not None and color_mode == MODE_MUSIC
def __is_color_rgb_encoded(self):
return len(self.dps_conf(CONF_COLOR)) > 12
def __find_scene_by_scene_data(self, data):
return next(
(item for item in self._effect_list if self._scenes.get(item) == data),
SCENE_CUSTOM,
)
def __get_color_mode(self):
return (
self.dps_conf(CONF_COLOR_MODE)
if self.has_config(CONF_COLOR_MODE)
else MODE_WHITE
)
async def async_turn_on(self, **kwargs):
"""Turn on or control the light."""
states = {}
if not self.is_on:
states[self._dp_id] = True
features = self.supported_features
brightness = None
if ATTR_EFFECT in kwargs and (features & SUPPORT_EFFECT):
scene = self._scenes.get(kwargs[ATTR_EFFECT])
if scene is not None:
if scene.startswith(MODE_SCENE):
states[self._config.get(CONF_COLOR_MODE)] = scene
else:
states[self._config.get(CONF_COLOR_MODE)] = MODE_SCENE
states[self._config.get(CONF_SCENE)] = scene
elif kwargs[ATTR_EFFECT] == SCENE_MUSIC:
states[self._config.get(CONF_COLOR_MODE)] = MODE_MUSIC
if ATTR_BRIGHTNESS in kwargs and (features & SUPPORT_BRIGHTNESS):
brightness = map_range(
int(kwargs[ATTR_BRIGHTNESS]),
0,
255,
self._lower_brightness,
self._upper_brightness,
)
if self.is_white_mode:
states[self._config.get(CONF_BRIGHTNESS)] = brightness
else:
if self.__is_color_rgb_encoded():
rgb = color_util.color_hsv_to_RGB(
self._hs[0],
self._hs[1],
int(brightness * 100 / self._upper_brightness),
)
color = "{:02x}{:02x}{:02x}{:04x}{:02x}{:02x}".format(
round(rgb[0]),
round(rgb[1]),
round(rgb[2]),
round(self._hs[0]),
round(self._hs[1] * 255 / 100),
brightness,
)
else:
color = "{:04x}{:04x}{:04x}".format(
round(self._hs[0]), round(self._hs[1] * 10.0), brightness
)
states[self._config.get(CONF_COLOR)] = color
states[self._config.get(CONF_COLOR_MODE)] = MODE_COLOR
if ATTR_HS_COLOR in kwargs and (features & SUPPORT_COLOR):
if brightness is None:
brightness = self._brightness
hs = kwargs[ATTR_HS_COLOR]
if hs[1] == 0 and self.has_config(CONF_BRIGHTNESS):
states[self._config.get(CONF_BRIGHTNESS)] = brightness
states[self._config.get(CONF_COLOR_MODE)] = MODE_WHITE
else:
if self.__is_color_rgb_encoded():
rgb = color_util.color_hsv_to_RGB(
hs[0], hs[1], int(brightness * 100 / self._upper_brightness)
)
color = "{:02x}{:02x}{:02x}{:04x}{:02x}{:02x}".format(
round(rgb[0]),
round(rgb[1]),
round(rgb[2]),
round(hs[0]),
round(hs[1] * 255 / 100),
brightness,
)
else:
color = "{:04x}{:04x}{:04x}".format(
round(hs[0]), round(hs[1] * 10.0), brightness
)
states[self._config.get(CONF_COLOR)] = color
states[self._config.get(CONF_COLOR_MODE)] = MODE_COLOR
if ATTR_COLOR_TEMP in kwargs and (features & SUPPORT_COLOR_TEMP):
if brightness is None:
brightness = self._brightness
color_temp_value = (
(self._max_mired - self._min_mired)
- (int(kwargs[ATTR_COLOR_TEMP]) - self._min_mired)
if self._color_temp_reverse
else (int(kwargs[ATTR_COLOR_TEMP]) - self._min_mired)
)
color_temp = int(
self._upper_color_temp
- (self._upper_color_temp / (self._max_mired - self._min_mired))
* color_temp_value
)
states[self._config.get(CONF_COLOR_MODE)] = MODE_WHITE
states[self._config.get(CONF_BRIGHTNESS)] = brightness
states[self._config.get(CONF_COLOR_TEMP)] = color_temp
await self._device.set_dps(states)
async def async_turn_off(self, **kwargs):
"""Turn Tuya light off."""
await self._device.set_dp(False, self._dp_id)
def status_updated(self):
"""Device status was updated."""
self._state = self.dps(self._dp_id)
supported = self.supported_features
self._effect = None
if supported & SUPPORT_BRIGHTNESS and self.has_config(CONF_BRIGHTNESS):
self._brightness = self.dps_conf(CONF_BRIGHTNESS)
if supported & SUPPORT_COLOR:
color = self.dps_conf(CONF_COLOR)
if color is not None and not self.is_white_mode:
if self.__is_color_rgb_encoded():
hue = int(color[6:10], 16)
sat = int(color[10:12], 16)
value = int(color[12:14], 16)
self._hs = [hue, (sat * 100 / 255)]
self._brightness = value
else:
hue, sat, value = [
int(value, 16) for value in textwrap.wrap(color, 4)
]
self._hs = [hue, sat / 10.0]
self._brightness = value
if supported & SUPPORT_COLOR_TEMP:
self._color_temp = self.dps_conf(CONF_COLOR_TEMP)
if self.is_scene_mode and supported & SUPPORT_EFFECT:
if self.dps_conf(CONF_COLOR_MODE) != MODE_SCENE:
self._effect = self.__find_scene_by_scene_data(
self.dps_conf(CONF_COLOR_MODE)
)
else:
self._effect = self.__find_scene_by_scene_data(
self.dps_conf(CONF_SCENE)
)
if self._effect == SCENE_CUSTOM:
if SCENE_CUSTOM not in self._effect_list:
self._effect_list.append(SCENE_CUSTOM)
elif SCENE_CUSTOM in self._effect_list:
self._effect_list.remove(SCENE_CUSTOM)
if self.is_music_mode and supported & SUPPORT_EFFECT:
self._effect = SCENE_MUSIC
async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaLight, flow_schema)

View File

@ -0,0 +1,14 @@
{
"domain": "localtuya",
"name": "LocalTuya integration",
"version": "3.2.1",
"documentation": "https://github.com/rospogrigio/localtuya/",
"dependencies": [],
"codeowners": [
"@rospogrigio", "@postlund"
],
"issue_tracker": "https://github.com/rospogrigio/localtuya/issues",
"requirements": [],
"config_flow": true,
"iot_class": "local_push"
}

View File

@ -0,0 +1,84 @@
"""Platform to present any Tuya DP as a number."""
import logging
from functools import partial
import voluptuous as vol
from homeassistant.components.number import DOMAIN, NumberEntity
from homeassistant.const import CONF_DEVICE_CLASS, STATE_UNKNOWN
from .common import LocalTuyaEntity, async_setup_entry
_LOGGER = logging.getLogger(__name__)
CONF_MIN_VALUE = "min_value"
CONF_MAX_VALUE = "max_value"
DEFAULT_MIN = 0
DEFAULT_MAX = 100000
def flow_schema(dps):
"""Return schema used in config flow."""
return {
vol.Optional(CONF_MIN_VALUE, default=DEFAULT_MIN): vol.All(
vol.Coerce(float),
vol.Range(min=-1000000.0, max=1000000.0),
),
vol.Required(CONF_MAX_VALUE, default=DEFAULT_MAX): vol.All(
vol.Coerce(float),
vol.Range(min=-1000000.0, max=1000000.0),
),
}
class LocaltuyaNumber(LocalTuyaEntity, NumberEntity):
"""Representation of a Tuya Number."""
def __init__(
self,
device,
config_entry,
sensorid,
**kwargs,
):
"""Initialize the Tuya sensor."""
super().__init__(device, config_entry, sensorid, _LOGGER, **kwargs)
self._state = STATE_UNKNOWN
self._min_value = DEFAULT_MIN
if CONF_MIN_VALUE in self._config:
self._min_value = self._config.get(CONF_MIN_VALUE)
self._max_value = self._config.get(CONF_MAX_VALUE)
@property
def value(self) -> float:
"""Return sensor state."""
return self._state
@property
def min_value(self) -> float:
"""Return the minimum value."""
return self._min_value
@property
def max_value(self) -> float:
"""Return the maximum value."""
return self._max_value
@property
def device_class(self):
"""Return the class of this device."""
return self._config.get(CONF_DEVICE_CLASS)
async def async_set_value(self, value: float) -> None:
"""Update the current value."""
await self._device.set_dp(value, self._dp_id)
def status_updated(self):
"""Device status was updated."""
state = self.dps(self._dp_id)
self._state = state
async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaNumber, flow_schema)

View File

@ -0,0 +1,682 @@
# PyTuya Module
# -*- coding: utf-8 -*-
"""
Python module to interface with Tuya WiFi smart devices.
Mostly derived from Shenzhen Xenon ESP8266MOD WiFi smart devices
E.g. https://wikidevi.com/wiki/Xenon_SM-PW701U
Author: clach04
Maintained by: postlund
For more information see https://github.com/clach04/python-tuya
Classes
TuyaInterface(dev_id, address, local_key=None)
dev_id (str): Device ID e.g. 01234567891234567890
address (str): Device Network IP Address e.g. 10.0.1.99
local_key (str, optional): The encryption key. Defaults to None.
Functions
json = status() # returns json payload
set_version(version) # 3.1 [default] or 3.3
detect_available_dps() # returns a list of available dps provided by the device
update_dps(dps) # sends update dps command
add_dps_to_request(dp_index) # adds dp_index to the list of dps used by the
# device (to be queried in the payload)
set_dp(on, dp_index) # Set value of any dps index.
Credits
* TuyaAPI https://github.com/codetheweb/tuyapi by codetheweb and blackrozes
For protocol reverse engineering
* PyTuya https://github.com/clach04/python-tuya by clach04
The origin of this python module (now abandoned)
* LocalTuya https://github.com/rospogrigio/localtuya-homeassistant by rospogrigio
Updated pytuya to support devices with Device IDs of 22 characters
"""
import asyncio
import base64
import binascii
import json
import logging
import struct
import time
import weakref
from abc import ABC, abstractmethod
from collections import namedtuple
from hashlib import md5
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
version_tuple = (9, 0, 0)
version = version_string = __version__ = "%d.%d.%d" % version_tuple
__author__ = "postlund"
_LOGGER = logging.getLogger(__name__)
TuyaMessage = namedtuple("TuyaMessage", "seqno cmd retcode payload crc")
SET = "set"
STATUS = "status"
HEARTBEAT = "heartbeat"
UPDATEDPS = "updatedps" # Request refresh of DPS
PROTOCOL_VERSION_BYTES_31 = b"3.1"
PROTOCOL_VERSION_BYTES_33 = b"3.3"
PROTOCOL_33_HEADER = PROTOCOL_VERSION_BYTES_33 + 12 * b"\x00"
MESSAGE_HEADER_FMT = ">4I" # 4*uint32: prefix, seqno, cmd, length
MESSAGE_RECV_HEADER_FMT = ">5I" # 4*uint32: prefix, seqno, cmd, length, retcode
MESSAGE_END_FMT = ">2I" # 2*uint32: crc, suffix
PREFIX_VALUE = 0x000055AA
SUFFIX_VALUE = 0x0000AA55
HEARTBEAT_INTERVAL = 10
# DPS that are known to be safe to use with update_dps (0x12) command
UPDATE_DPS_WHITELIST = [18, 19, 20] # Socket (Wi-Fi)
# This is intended to match requests.json payload at
# https://github.com/codetheweb/tuyapi :
# type_0a devices require the 0a command as the status request
# type_0d devices require the 0d command as the status request, and the list of
# dps used set to null in the request payload (see generate_payload method)
# prefix: # Next byte is command byte ("hexByte") some zero padding, then length
# of remaining payload, i.e. command + suffix (unclear if multiple bytes used for
# length, zero padding implies could be more than one byte)
PAYLOAD_DICT = {
"type_0a": {
STATUS: {"hexByte": 0x0A, "command": {"gwId": "", "devId": ""}},
SET: {"hexByte": 0x07, "command": {"devId": "", "uid": "", "t": ""}},
HEARTBEAT: {"hexByte": 0x09, "command": {}},
UPDATEDPS: {"hexByte": 0x12, "command": {"dpId": [18, 19, 20]}},
},
"type_0d": {
STATUS: {"hexByte": 0x0D, "command": {"devId": "", "uid": "", "t": ""}},
SET: {"hexByte": 0x07, "command": {"devId": "", "uid": "", "t": ""}},
HEARTBEAT: {"hexByte": 0x09, "command": {}},
UPDATEDPS: {"hexByte": 0x12, "command": {"dpId": [18, 19, 20]}},
},
}
class TuyaLoggingAdapter(logging.LoggerAdapter):
"""Adapter that adds device id to all log points."""
def process(self, msg, kwargs):
"""Process log point and return output."""
dev_id = self.extra["device_id"]
return f"[{dev_id[0:3]}...{dev_id[-3:]}] {msg}", kwargs
class ContextualLogger:
"""Contextual logger adding device id to log points."""
def __init__(self):
"""Initialize a new ContextualLogger."""
self._logger = None
def set_logger(self, logger, device_id):
"""Set base logger to use."""
self._logger = TuyaLoggingAdapter(logger, {"device_id": device_id})
def debug(self, msg, *args):
"""Debug level log."""
return self._logger.log(logging.DEBUG, msg, *args)
def info(self, msg, *args):
"""Info level log."""
return self._logger.log(logging.INFO, msg, *args)
def warning(self, msg, *args):
"""Warning method log."""
return self._logger.log(logging.WARNING, msg, *args)
def error(self, msg, *args):
"""Error level log."""
return self._logger.log(logging.ERROR, msg, *args)
def exception(self, msg, *args):
"""Exception level log."""
return self._logger.exception(msg, *args)
def pack_message(msg):
"""Pack a TuyaMessage into bytes."""
# Create full message excluding CRC and suffix
buffer = (
struct.pack(
MESSAGE_HEADER_FMT,
PREFIX_VALUE,
msg.seqno,
msg.cmd,
len(msg.payload) + struct.calcsize(MESSAGE_END_FMT),
)
+ msg.payload
)
# Calculate CRC, add it together with suffix
buffer += struct.pack(MESSAGE_END_FMT, binascii.crc32(buffer), SUFFIX_VALUE)
return buffer
def unpack_message(data):
"""Unpack bytes into a TuyaMessage."""
header_len = struct.calcsize(MESSAGE_RECV_HEADER_FMT)
end_len = struct.calcsize(MESSAGE_END_FMT)
_, seqno, cmd, _, retcode = struct.unpack(
MESSAGE_RECV_HEADER_FMT, data[:header_len]
)
payload = data[header_len:-end_len]
crc, _ = struct.unpack(MESSAGE_END_FMT, data[-end_len:])
return TuyaMessage(seqno, cmd, retcode, payload, crc)
class AESCipher:
"""Cipher module for Tuya communication."""
def __init__(self, key):
"""Initialize a new AESCipher."""
self.block_size = 16
self.cipher = Cipher(algorithms.AES(key), modes.ECB(), default_backend())
def encrypt(self, raw, use_base64=True):
"""Encrypt data to be sent to device."""
encryptor = self.cipher.encryptor()
crypted_text = encryptor.update(self._pad(raw)) + encryptor.finalize()
return base64.b64encode(crypted_text) if use_base64 else crypted_text
def decrypt(self, enc, use_base64=True):
"""Decrypt data from device."""
if use_base64:
enc = base64.b64decode(enc)
decryptor = self.cipher.decryptor()
return self._unpad(decryptor.update(enc) + decryptor.finalize()).decode()
def _pad(self, data):
padnum = self.block_size - len(data) % self.block_size
return data + padnum * chr(padnum).encode()
@staticmethod
def _unpad(data):
return data[: -ord(data[len(data) - 1 :])]
class MessageDispatcher(ContextualLogger):
"""Buffer and dispatcher for Tuya messages."""
# Heartbeats always respond with sequence number 0, so they can't be waited for like
# other messages. This is a hack to allow waiting for heartbeats.
HEARTBEAT_SEQNO = -100
def __init__(self, dev_id, listener):
"""Initialize a new MessageBuffer."""
super().__init__()
self.buffer = b""
self.listeners = {}
self.listener = listener
self.set_logger(_LOGGER, dev_id)
def abort(self):
"""Abort all waiting clients."""
for key in self.listeners:
sem = self.listeners[key]
self.listeners[key] = None
# TODO: Received data and semahore should be stored separately
if isinstance(sem, asyncio.Semaphore):
sem.release()
async def wait_for(self, seqno, timeout=5):
"""Wait for response to a sequence number to be received and return it."""
if seqno in self.listeners:
raise Exception(f"listener exists for {seqno}")
self.debug("Waiting for sequence number %d", seqno)
self.listeners[seqno] = asyncio.Semaphore(0)
try:
await asyncio.wait_for(self.listeners[seqno].acquire(), timeout=timeout)
except asyncio.TimeoutError:
del self.listeners[seqno]
raise
return self.listeners.pop(seqno)
def add_data(self, data):
"""Add new data to the buffer and try to parse messages."""
self.buffer += data
header_len = struct.calcsize(MESSAGE_RECV_HEADER_FMT)
while self.buffer:
# Check if enough data for measage header
if len(self.buffer) < header_len:
break
# Parse header and check if enough data according to length in header
_, seqno, cmd, length, retcode = struct.unpack_from(
MESSAGE_RECV_HEADER_FMT, self.buffer
)
if len(self.buffer[header_len - 4 :]) < length:
break
# length includes payload length, retcode, crc and suffix
if (retcode & 0xFFFFFF00) != 0:
payload_start = header_len - 4
payload_length = length - struct.calcsize(MESSAGE_END_FMT)
else:
payload_start = header_len
payload_length = length - 4 - struct.calcsize(MESSAGE_END_FMT)
payload = self.buffer[payload_start : payload_start + payload_length]
crc, _ = struct.unpack_from(
MESSAGE_END_FMT,
self.buffer[payload_start + payload_length : payload_start + length],
)
self.buffer = self.buffer[header_len - 4 + length :]
self._dispatch(TuyaMessage(seqno, cmd, retcode, payload, crc))
def _dispatch(self, msg):
"""Dispatch a message to someone that is listening."""
self.debug("Dispatching message %s", msg)
if msg.seqno in self.listeners:
self.debug("Dispatching sequence number %d", msg.seqno)
sem = self.listeners[msg.seqno]
self.listeners[msg.seqno] = msg
sem.release()
elif msg.cmd == 0x09:
self.debug("Got heartbeat response")
if self.HEARTBEAT_SEQNO in self.listeners:
sem = self.listeners[self.HEARTBEAT_SEQNO]
self.listeners[self.HEARTBEAT_SEQNO] = msg
sem.release()
elif msg.cmd == 0x12:
self.debug("Got normal updatedps response")
elif msg.cmd == 0x08:
self.debug("Got status update")
self.listener(msg)
else:
self.debug(
"Got message type %d for unknown listener %d: %s",
msg.cmd,
msg.seqno,
msg,
)
class TuyaListener(ABC):
"""Listener interface for Tuya device changes."""
@abstractmethod
def status_updated(self, status):
"""Device updated status."""
@abstractmethod
def disconnected(self):
"""Device disconnected."""
class EmptyListener(TuyaListener):
"""Listener doing nothing."""
def status_updated(self, status):
"""Device updated status."""
def disconnected(self):
"""Device disconnected."""
class TuyaProtocol(asyncio.Protocol, ContextualLogger):
"""Implementation of the Tuya protocol."""
def __init__(self, dev_id, local_key, protocol_version, on_connected, listener):
"""
Initialize a new TuyaInterface.
Args:
dev_id (str): The device id.
address (str): The network address.
local_key (str, optional): The encryption key. Defaults to None.
Attributes:
port (int): The port to connect to.
"""
super().__init__()
self.loop = asyncio.get_running_loop()
self.set_logger(_LOGGER, dev_id)
self.id = dev_id
self.local_key = local_key.encode("latin1")
self.version = protocol_version
self.dev_type = "type_0a"
self.dps_to_request = {}
self.cipher = AESCipher(self.local_key)
self.seqno = 0
self.transport = None
self.listener = weakref.ref(listener)
self.dispatcher = self._setup_dispatcher()
self.on_connected = on_connected
self.heartbeater = None
self.dps_cache = {}
def _setup_dispatcher(self):
def _status_update(msg):
decoded_message = self._decode_payload(msg.payload)
if "dps" in decoded_message:
self.dps_cache.update(decoded_message["dps"])
listener = self.listener and self.listener()
if listener is not None:
listener.status_updated(self.dps_cache)
return MessageDispatcher(self.id, _status_update)
def connection_made(self, transport):
"""Did connect to the device."""
async def heartbeat_loop():
"""Continuously send heart beat updates."""
self.debug("Started heartbeat loop")
while True:
try:
await self.heartbeat()
await asyncio.sleep(HEARTBEAT_INTERVAL)
except asyncio.CancelledError:
self.debug("Stopped heartbeat loop")
raise
except asyncio.TimeoutError:
self.debug("Heartbeat failed due to timeout, disconnecting")
break
except Exception as ex: # pylint: disable=broad-except
self.exception("Heartbeat failed (%s), disconnecting", ex)
break
transport = self.transport
self.transport = None
transport.close()
self.transport = transport
self.on_connected.set_result(True)
self.heartbeater = self.loop.create_task(heartbeat_loop())
def data_received(self, data):
"""Received data from device."""
self.dispatcher.add_data(data)
def connection_lost(self, exc):
"""Disconnected from device."""
self.debug("Connection lost: %s", exc)
try:
listener = self.listener and self.listener()
if listener is not None:
listener.disconnected()
except Exception: # pylint: disable=broad-except
self.exception("Failed to call disconnected callback")
async def close(self):
"""Close connection and abort all outstanding listeners."""
self.debug("Closing connection")
if self.heartbeater is not None:
self.heartbeater.cancel()
try:
await self.heartbeater
except asyncio.CancelledError:
pass
self.heartbeater = None
if self.dispatcher is not None:
self.dispatcher.abort()
self.dispatcher = None
if self.transport is not None:
transport = self.transport
self.transport = None
transport.close()
async def exchange(self, command, dps=None):
"""Send and receive a message, returning response from device."""
self.debug(
"Sending command %s (device type: %s)",
command,
self.dev_type,
)
payload = self._generate_payload(command, dps)
dev_type = self.dev_type
# Wait for special sequence number if heartbeat
seqno = (
MessageDispatcher.HEARTBEAT_SEQNO
if command == HEARTBEAT
else (self.seqno - 1)
)
self.transport.write(payload)
msg = await self.dispatcher.wait_for(seqno)
if msg is None:
self.debug("Wait was aborted for seqno %d", seqno)
return None
# TODO: Verify stuff, e.g. CRC sequence number?
payload = self._decode_payload(msg.payload)
# Perform a new exchange (once) if we switched device type
if dev_type != self.dev_type:
self.debug(
"Re-send %s due to device type change (%s -> %s)",
command,
dev_type,
self.dev_type,
)
return await self.exchange(command, dps)
return payload
async def status(self):
"""Return device status."""
status = await self.exchange(STATUS)
if status and "dps" in status:
self.dps_cache.update(status["dps"])
return self.dps_cache
async def heartbeat(self):
"""Send a heartbeat message."""
return await self.exchange(HEARTBEAT)
async def update_dps(self, dps=None):
"""
Request device to update index.
Args:
dps([int]): list of dps to update, default=detected&whitelisted
"""
if self.version == 3.3:
if dps is None:
if not self.dps_cache:
await self.detect_available_dps()
if self.dps_cache:
dps = [int(dp) for dp in self.dps_cache]
# filter non whitelisted dps
dps = list(set(dps).intersection(set(UPDATE_DPS_WHITELIST)))
self.debug("updatedps() entry (dps %s, dps_cache %s)", dps, self.dps_cache)
payload = self._generate_payload(UPDATEDPS, dps)
self.transport.write(payload)
return True
async def set_dp(self, value, dp_index):
"""
Set value (may be any type: bool, int or string) of any dps index.
Args:
dp_index(int): dps index to set
value: new value for the dps index
"""
return await self.exchange(SET, {str(dp_index): value})
async def set_dps(self, dps):
"""Set values for a set of datapoints."""
return await self.exchange(SET, dps)
async def detect_available_dps(self):
"""Return which datapoints are supported by the device."""
# type_0d devices need a sort of bruteforce querying in order to detect the
# list of available dps experience shows that the dps available are usually
# in the ranges [1-25] and [100-110] need to split the bruteforcing in
# different steps due to request payload limitation (max. length = 255)
self.dps_cache = {}
ranges = [(2, 11), (11, 21), (21, 31), (100, 111)]
for dps_range in ranges:
# dps 1 must always be sent, otherwise it might fail in case no dps is found
# in the requested range
self.dps_to_request = {"1": None}
self.add_dps_to_request(range(*dps_range))
try:
data = await self.status()
except Exception as ex:
self.exception("Failed to get status: %s", ex)
raise
if "dps" in data:
self.dps_cache.update(data["dps"])
if self.dev_type == "type_0a":
return self.dps_cache
self.debug("Detected dps: %s", self.dps_cache)
return self.dps_cache
def add_dps_to_request(self, dp_indicies):
"""Add a datapoint (DP) to be included in requests."""
if isinstance(dp_indicies, int):
self.dps_to_request[str(dp_indicies)] = None
else:
self.dps_to_request.update({str(index): None for index in dp_indicies})
def _decode_payload(self, payload):
if not payload:
payload = "{}"
elif payload.startswith(b"{"):
pass
elif payload.startswith(PROTOCOL_VERSION_BYTES_31):
payload = payload[len(PROTOCOL_VERSION_BYTES_31) :] # remove version header
# remove (what I'm guessing, but not confirmed is) 16-bytes of MD5
# hexdigest of payload
payload = self.cipher.decrypt(payload[16:])
elif self.version == 3.3:
if self.dev_type != "type_0a" or payload.startswith(
PROTOCOL_VERSION_BYTES_33
):
payload = payload[len(PROTOCOL_33_HEADER) :]
payload = self.cipher.decrypt(payload, False)
if "data unvalid" in payload:
self.dev_type = "type_0d"
self.debug(
"switching to dev_type %s",
self.dev_type,
)
return None
else:
raise Exception(f"Unexpected payload={payload}")
if not isinstance(payload, str):
payload = payload.decode()
self.debug("Decrypted payload: %s", payload)
return json.loads(payload)
def _generate_payload(self, command, data=None):
"""
Generate the payload to send.
Args:
command(str): The type of command.
This is one of the entries from payload_dict
data(dict, optional): The data to be send.
This is what will be passed via the 'dps' entry
"""
cmd_data = PAYLOAD_DICT[self.dev_type][command]
json_data = cmd_data["command"]
command_hb = cmd_data["hexByte"]
if "gwId" in json_data:
json_data["gwId"] = self.id
if "devId" in json_data:
json_data["devId"] = self.id
if "uid" in json_data:
json_data["uid"] = self.id # still use id, no separate uid
if "t" in json_data:
json_data["t"] = str(int(time.time()))
if data is not None:
if "dpId" in json_data:
json_data["dpId"] = data
else:
json_data["dps"] = data
elif command_hb == 0x0D:
json_data["dps"] = self.dps_to_request
payload = json.dumps(json_data).replace(" ", "").encode("utf-8")
self.debug("Send payload: %s", payload)
if self.version == 3.3:
payload = self.cipher.encrypt(payload, False)
if command_hb not in [0x0A, 0x12]:
# add the 3.3 header
payload = PROTOCOL_33_HEADER + payload
elif command == SET:
payload = self.cipher.encrypt(payload)
to_hash = (
b"data="
+ payload
+ b"||lpv="
+ PROTOCOL_VERSION_BYTES_31
+ b"||"
+ self.local_key
)
hasher = md5()
hasher.update(to_hash)
hexdigest = hasher.hexdigest()
payload = (
PROTOCOL_VERSION_BYTES_31
+ hexdigest[8:][:16].encode("latin1")
+ payload
)
msg = TuyaMessage(self.seqno, command_hb, 0, payload, 0)
self.seqno += 1
return pack_message(msg)
def __repr__(self):
"""Return internal string representation of object."""
return self.id
async def connect(
address,
device_id,
local_key,
protocol_version,
listener=None,
port=6668,
timeout=5,
):
"""Connect to a device."""
loop = asyncio.get_running_loop()
on_connected = loop.create_future()
_, protocol = await loop.create_connection(
lambda: TuyaProtocol(
device_id,
local_key,
protocol_version,
on_connected,
listener or EmptyListener(),
),
address,
port,
)
await asyncio.wait_for(on_connected, timeout=timeout)
return protocol

View File

@ -0,0 +1,100 @@
"""Platform to present any Tuya DP as an enumeration."""
import logging
from functools import partial
import voluptuous as vol
from homeassistant.components.select import DOMAIN, SelectEntity
from homeassistant.const import CONF_DEVICE_CLASS, STATE_UNKNOWN
from .common import LocalTuyaEntity, async_setup_entry
_LOGGER = logging.getLogger(__name__)
CONF_OPTIONS = "select_options"
CONF_OPTIONS_FRIENDLY = "select_options_friendly"
def flow_schema(dps):
"""Return schema used in config flow."""
return {
vol.Required(CONF_OPTIONS): str,
vol.Optional(CONF_OPTIONS_FRIENDLY): str,
}
class LocaltuyaSelect(LocalTuyaEntity, SelectEntity):
"""Representation of a Tuya Enumeration."""
def __init__(
self,
device,
config_entry,
sensorid,
**kwargs,
):
"""Initialize the Tuya sensor."""
super().__init__(device, config_entry, sensorid, _LOGGER, **kwargs)
self._state = STATE_UNKNOWN
self._state_friendly = ""
self._valid_options = self._config.get(CONF_OPTIONS).split(";")
# Set Display options
self._display_options = []
display_options_str = ""
if CONF_OPTIONS_FRIENDLY in self._config:
display_options_str = self._config.get(CONF_OPTIONS_FRIENDLY).strip()
_LOGGER.debug("Display Options Configured: %s", display_options_str)
if display_options_str.find(";") >= 0:
self._display_options = display_options_str.split(";")
elif len(display_options_str.strip()) > 0:
self._display_options.append(display_options_str)
else:
# Default display string to raw string
_LOGGER.debug("No Display options configured - defaulting to raw values")
self._display_options = self._valid_options
_LOGGER.debug(
"Total Raw Options: %s - Total Display Options: %s",
str(len(self._valid_options)),
str(len(self._display_options)),
)
if len(self._valid_options) > len(self._display_options):
# If list of display items smaller than list of valid items,
# then default remaining items to be the raw value
_LOGGER.debug(
"Valid options is larger than display options - \
filling up with raw values"
)
for i in range(len(self._display_options), len(self._valid_options)):
self._display_options.append(self._valid_options[i])
@property
def current_option(self) -> str:
"""Return the current value."""
return self._state_friendly
@property
def options(self) -> list:
"""Return the list of values."""
return self._display_options
@property
def device_class(self):
"""Return the class of this device."""
return self._config.get(CONF_DEVICE_CLASS)
async def async_select_option(self, option: str) -> None:
"""Update the current value."""
option_value = self._valid_options[self._display_options.index(option)]
_LOGGER.debug("Sending Option: " + option + " -> " + option_value)
await self._device.set_dp(option_value, self._dp_id)
def status_updated(self):
"""Device status was updated."""
state = self.dps(self._dp_id)
self._state_friendly = self._display_options[self._valid_options.index(state)]
self._state = state
async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaSelect, flow_schema)

View File

@ -0,0 +1,70 @@
"""Platform to present any Tuya DP as a sensor."""
import logging
from functools import partial
import voluptuous as vol
from homeassistant.components.sensor import DEVICE_CLASSES, DOMAIN
from homeassistant.const import (
CONF_DEVICE_CLASS,
CONF_UNIT_OF_MEASUREMENT,
STATE_UNKNOWN,
)
from .common import LocalTuyaEntity, async_setup_entry
from .const import CONF_SCALING
_LOGGER = logging.getLogger(__name__)
DEFAULT_PRECISION = 2
def flow_schema(dps):
"""Return schema used in config flow."""
return {
vol.Optional(CONF_UNIT_OF_MEASUREMENT): str,
vol.Optional(CONF_DEVICE_CLASS): vol.In(DEVICE_CLASSES),
vol.Optional(CONF_SCALING): vol.All(
vol.Coerce(float), vol.Range(min=-1000000.0, max=1000000.0)
),
}
class LocaltuyaSensor(LocalTuyaEntity):
"""Representation of a Tuya sensor."""
def __init__(
self,
device,
config_entry,
sensorid,
**kwargs,
):
"""Initialize the Tuya sensor."""
super().__init__(device, config_entry, sensorid, _LOGGER, **kwargs)
self._state = STATE_UNKNOWN
@property
def state(self):
"""Return sensor state."""
return self._state
@property
def device_class(self):
"""Return the class of this device."""
return self._config.get(CONF_DEVICE_CLASS)
@property
def unit_of_measurement(self):
"""Return the unit of measurement of this entity, if any."""
return self._config.get(CONF_UNIT_OF_MEASUREMENT)
def status_updated(self):
"""Device status was updated."""
state = self.dps(self._dp_id)
scale_factor = self._config.get(CONF_SCALING)
if scale_factor is not None and isinstance(state, (int, float)):
state = round(state * scale_factor, DEFAULT_PRECISION)
self._state = state
async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaSensor, flow_schema)

View File

@ -0,0 +1,15 @@
reload:
description: Reload localtuya and re-process yaml configuration.
set_dp:
description: Change the value of a datapoint (DP)
fields:
device_id:
description: Device ID of device to change datapoint value for
example: 11100118278aab4de001
dp:
description: Datapoint index
example: 1
value:
description: New value to set
example: False

View File

@ -0,0 +1,43 @@
{
"config": {
"abort": {
"already_configured": "Device has already been configured.",
"unsupported_device_type": "Unsupported device type!"
},
"error": {
"cannot_connect": "Cannot connect to device. Verify that address is correct.",
"invalid_auth": "Failed to authenticate with device. Verify that device id and local key are correct.",
"unknown": "An unknown error occurred. See log for details.",
"switch_already_configured": "Switch with this ID has already been configured."
},
"step": {
"user": {
"title": "Add Tuya device",
"description": "Fill in the basic details and pick device type. The name entered here will be used to identify the integration itself (as seen in the `Integrations` page). You will name each sub-device in the following steps.",
"data": {
"name": "Name",
"host": "Host",
"device_id": "Device ID",
"local_key": "Local key",
"protocol_version": "Protocol Version",
"scan_interval": "Scan interval (seconds, only when not updating automatically)",
"device_type": "Device type"
}
},
"power_outlet": {
"title": "Add subswitch",
"description": "You are about to add subswitch number `{number}`. If you want to add another, tick `Add another switch` before continuing.",
"data": {
"id": "ID",
"name": "Name",
"friendly_name": "Friendly name",
"current": "Current",
"current_consumption": "Current Consumption",
"voltage": "Voltage",
"add_another_switch": "Add another switch"
}
}
}
},
"title": "LocalTuya"
}

View File

@ -0,0 +1,77 @@
"""Platform to locally control Tuya-based switch devices."""
import logging
from functools import partial
import voluptuous as vol
from homeassistant.components.switch import DOMAIN, SwitchEntity
from .common import LocalTuyaEntity, async_setup_entry
from .const import (
ATTR_CURRENT,
ATTR_CURRENT_CONSUMPTION,
ATTR_VOLTAGE,
CONF_CURRENT,
CONF_CURRENT_CONSUMPTION,
CONF_VOLTAGE,
)
_LOGGER = logging.getLogger(__name__)
def flow_schema(dps):
"""Return schema used in config flow."""
return {
vol.Optional(CONF_CURRENT): vol.In(dps),
vol.Optional(CONF_CURRENT_CONSUMPTION): vol.In(dps),
vol.Optional(CONF_VOLTAGE): vol.In(dps),
}
class LocaltuyaSwitch(LocalTuyaEntity, SwitchEntity):
"""Representation of a Tuya switch."""
def __init__(
self,
device,
config_entry,
switchid,
**kwargs,
):
"""Initialize the Tuya switch."""
super().__init__(device, config_entry, switchid, _LOGGER, **kwargs)
self._state = None
print("Initialized switch [{}]".format(self.name))
@property
def is_on(self):
"""Check if Tuya switch is on."""
return self._state
@property
def extra_state_attributes(self):
"""Return device state attributes."""
attrs = {}
if self.has_config(CONF_CURRENT):
attrs[ATTR_CURRENT] = self.dps(self._config[CONF_CURRENT])
if self.has_config(CONF_CURRENT_CONSUMPTION):
attrs[ATTR_CURRENT_CONSUMPTION] = (
self.dps(self._config[CONF_CURRENT_CONSUMPTION]) / 10
)
if self.has_config(CONF_VOLTAGE):
attrs[ATTR_VOLTAGE] = self.dps(self._config[CONF_VOLTAGE]) / 10
return attrs
async def async_turn_on(self, **kwargs):
"""Turn Tuya switch on."""
await self._device.set_dp(True, self._dp_id)
async def async_turn_off(self, **kwargs):
"""Turn Tuya switch off."""
await self._device.set_dp(False, self._dp_id)
def status_updated(self):
"""Device status was updated."""
self._state = self.dps(self._dp_id)
async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaSwitch, flow_schema)

View File

@ -0,0 +1,217 @@
{
"config": {
"abort": {
"already_configured": "Device has already been configured.",
"device_updated": "Device configuration has been updated!"
},
"error": {
"cannot_connect": "Cannot connect to device. Verify that address is correct and try again.",
"invalid_auth": "Failed to authenticate with device. Verify that device id and local key are correct.",
"unknown": "An unknown error occurred. See log for details.",
"entity_already_configured": "Entity with this ID has already been configured.",
"address_in_use": "Address used for discovery is already in use. Make sure no other application is using it (TCP port 6668).",
"discovery_failed": "Something failed when discovering devices. See log for details.",
"empty_dps": "Connection to device succeeded but no datapoints found, please try again. Create a new issue and include debug logs if problem persists."
},
"step": {
"user": {
"title": "Device Discovery",
"description": "Pick one of the automatically discovered devices or `...` to manually to add a device.",
"data": {
"discovered_device": "Discovered Device"
}
},
"basic_info": {
"title": "Add Tuya device",
"description": "Fill in the basic device details. The name entered here will be used to identify the integration itself (as seen in the `Integrations` page). You will add entities and give them names in the following steps.",
"data": {
"friendly_name": "Name",
"host": "Host",
"device_id": "Device ID",
"local_key": "Local key",
"protocol_version": "Protocol Version",
"scan_interval": "Scan interval (seconds, only when not updating automatically)"
}
},
"pick_entity_type": {
"title": "Entity type selection",
"description": "Please pick the type of entity you want to add.",
"data": {
"platform_to_add": "Platform",
"no_additional_platforms": "Do not add any more entities"
}
},
"add_entity": {
"title": "Add new entity",
"description": "Please fill out the details for an entity with type `{platform}`. All settings except for `ID` can be changed from the Options page later.",
"data": {
"id": "ID",
"friendly_name": "Friendly name",
"current": "Current",
"current_consumption": "Current Consumption",
"voltage": "Voltage",
"commands_set": "Open_Close_Stop Commands Set",
"positioning_mode": "Positioning mode",
"current_position_dp": "Current Position (for *position* mode only)",
"set_position_dp": "Set Position (for *position* mode only)",
"position_inverted": "Invert 0-100 position (for *position* mode only)",
"span_time": "Full opening time, in secs. (for *timed* mode only)",
"unit_of_measurement": "Unit of Measurement",
"device_class": "Device Class",
"scaling": "Scaling Factor",
"state_on": "On Value",
"state_off": "Off Value",
"powergo_dp": "Power DP (Usually 25 or 2)",
"idle_status_value": "Idle Status (comma-separated)",
"returning_status_value": "Returning Status",
"docked_status_value": "Docked Status (comma-separated)",
"fault_dp": "Fault DP (Usually 11)",
"battery_dp": "Battery status DP (Usually 14)",
"mode_dp": "Mode DP (Usually 27)",
"modes": "Modes list",
"return_mode": "Return home mode",
"fan_speed_dp": "Fan speeds DP (Usually 30)",
"fan_speeds": "Fan speeds list (comma-separated)",
"clean_time_dp": "Clean Time DP (Usually 33)",
"clean_area_dp": "Clean Area DP (Usually 32)",
"clean_record_dp": "Clean Record DP (Usually 34)",
"locate_dp": "Locate DP (Usually 31)",
"paused_state": "Pause state (pause, paused, etc)",
"stop_status": "Stop status",
"brightness": "Brightness (only for white color)",
"brightness_lower": "Brightness Lower Value",
"brightness_upper": "Brightness Upper Value",
"color_temp": "Color Temperature",
"color_temp_reverse": "Color Temperature Reverse",
"color": "Color",
"color_mode": "Color Mode",
"color_temp_min_kelvin": "Minimum Color Temperature in K",
"color_temp_max_kelvin": "Maximum Color Temperature in K",
"music_mode": "Music mode available",
"scene": "Scene",
"fan_speed_control": "Fan Speed Control dps",
"fan_oscillating_control": "Fan Oscillating Control dps",
"fan_speed_min": "minimum fan speed integer",
"fan_speed_max": "maximum fan speed integer",
"fan_speed_ordered_list": "Fan speed modes list (overrides speed min/max)",
"fan_direction":"fan direction dps",
"fan_direction_forward": "forward dps string",
"fan_direction_reverse": "reverse dps string",
"current_temperature_dp": "Current Temperature",
"target_temperature_dp": "Target Temperature",
"temperature_step": "Temperature Step (optional)",
"max_temperature_dp": "Max Temperature (optional)",
"min_temperature_dp": "Min Temperature (optional)",
"precision": "Precision (optional, for DPs values)",
"target_precision": "Target Precision (optional, for DPs values)",
"temperature_unit": "Temperature Unit (optional)",
"hvac_mode_dp": "HVAC Mode DP (optional)",
"hvac_mode_set": "HVAC Mode Set (optional)",
"hvac_action_dp": "HVAC Current Action DP (optional)",
"hvac_action_set": "HVAC Current Action Set (optional)",
"preset_dp": "Presets DP (optional)",
"preset_set": "Presets Set (optional)",
"eco_dp": "Eco DP (optional)",
"eco_value": "Eco value (optional)",
"heuristic_action": "Enable heuristic action (optional)"
}
}
}
},
"options": {
"step": {
"init": {
"title": "Configure Tuya Device",
"description": "Basic configuration for device id `{device_id}`.",
"data": {
"friendly_name": "Friendly Name",
"host": "Host",
"local_key": "Local key",
"protocol_version": "Protocol Version",
"scan_interval": "Scan interval (seconds, only when not updating automatically)",
"entities": "Entities (uncheck an entity to remove it)"
}
},
"entity": {
"title": "Entity Configuration",
"description": "Editing entity with DPS `{id}` and platform `{platform}`.",
"data": {
"id": "ID",
"friendly_name": "Friendly name",
"current": "Current",
"current_consumption": "Current Consumption",
"voltage": "Voltage",
"commands_set": "Open_Close_Stop Commands Set",
"positioning_mode": "Positioning mode",
"current_position_dp": "Current Position (for *position* mode only)",
"set_position_dp": "Set Position (for *position* mode only)",
"position_inverted": "Invert 0-100 position (for *position* mode only)",
"span_time": "Full opening time, in secs. (for *timed* mode only)",
"unit_of_measurement": "Unit of Measurement",
"device_class": "Device Class",
"scaling": "Scaling Factor",
"state_on": "On Value",
"state_off": "Off Value",
"powergo_dp": "Power DP (Usually 25 or 2)",
"idle_status_value": "Idle Status (comma-separated)",
"returning_status_value": "Returning Status",
"docked_status_value": "Docked Status (comma-separated)",
"fault_dp": "Fault DP (Usually 11)",
"battery_dp": "Battery status DP (Usually 14)",
"mode_dp": "Mode DP (Usually 27)",
"modes": "Modes list",
"return_mode": "Return home mode",
"fan_speed_dp": "Fan speeds DP (Usually 30)",
"fan_speeds": "Fan speeds list (comma-separated)",
"clean_time_dp": "Clean Time DP (Usually 33)",
"clean_area_dp": "Clean Area DP (Usually 32)",
"clean_record_dp": "Clean Record DP (Usually 34)",
"locate_dp": "Locate DP (Usually 31)",
"paused_state": "Pause state (pause, paused, etc)",
"stop_status": "Stop status",
"brightness": "Brightness (only for white color)",
"brightness_lower": "Brightness Lower Value",
"brightness_upper": "Brightness Upper Value",
"color_temp": "Color Temperature",
"color_temp_reverse": "Color Temperature Reverse",
"color": "Color",
"color_mode": "Color Mode",
"color_temp_min_kelvin": "Minimum Color Temperature in K",
"color_temp_max_kelvin": "Maximum Color Temperature in K",
"music_mode": "Music mode available",
"scene": "Scene",
"fan_speed_control": "Fan Speed Control dps",
"fan_oscillating_control": "Fan Oscillating Control dps",
"fan_speed_min": "minimum fan speed integer",
"fan_speed_max": "maximum fan speed integer",
"fan_speed_ordered_list": "Fan speed modes list (overrides speed min/max)",
"fan_direction":"fan direction dps",
"fan_direction_forward": "forward dps string",
"fan_direction_reverse": "reverse dps string",
"current_temperature_dp": "Current Temperature",
"target_temperature_dp": "Target Temperature",
"temperature_step": "Temperature Step (optional)",
"max_temperature_dp": "Max Temperature (optional)",
"min_temperature_dp": "Min Temperature (optional)",
"precision": "Precision (optional, for DPs values)",
"target_precision": "Target Precision (optional, for DPs values)",
"temperature_unit": "Temperature Unit (optional)",
"hvac_mode_dp": "HVAC Mode DP (optional)",
"hvac_mode_set": "HVAC Mode Set (optional)",
"hvac_action_dp": "HVAC Current Action DP (optional)",
"hvac_action_set": "HVAC Current Action Set (optional)",
"preset_dp": "Presets DP (optional)",
"preset_set": "Presets Set (optional)",
"eco_dp": "Eco DP (optional)",
"eco_value": "Eco value (optional)",
"heuristic_action": "Enable heuristic action (optional)"
}
},
"yaml_import": {
"title": "Not Supported",
"description": "Options cannot be edited when configured via YAML."
}
}
},
"title": "LocalTuya"
}

View File

@ -0,0 +1,258 @@
"""Platform to locally control Tuya-based vacuum devices."""
import logging
from functools import partial
import voluptuous as vol
from homeassistant.components.vacuum import (
DOMAIN,
STATE_CLEANING,
STATE_DOCKED,
STATE_IDLE,
STATE_RETURNING,
STATE_PAUSED,
STATE_ERROR,
SUPPORT_BATTERY,
SUPPORT_FAN_SPEED,
SUPPORT_PAUSE,
SUPPORT_RETURN_HOME,
SUPPORT_START,
SUPPORT_STATE,
SUPPORT_STATUS,
SUPPORT_STOP,
SUPPORT_LOCATE,
StateVacuumEntity,
)
from .common import LocalTuyaEntity, async_setup_entry
from .const import (
CONF_POWERGO_DP,
CONF_IDLE_STATUS_VALUE,
CONF_RETURNING_STATUS_VALUE,
CONF_DOCKED_STATUS_VALUE,
CONF_BATTERY_DP,
CONF_MODE_DP,
CONF_MODES,
CONF_FAN_SPEED_DP,
CONF_FAN_SPEEDS,
CONF_CLEAN_TIME_DP,
CONF_CLEAN_AREA_DP,
CONF_CLEAN_RECORD_DP,
CONF_LOCATE_DP,
CONF_FAULT_DP,
CONF_PAUSED_STATE,
CONF_RETURN_MODE,
CONF_STOP_STATUS,
)
_LOGGER = logging.getLogger(__name__)
CLEAN_TIME = "clean_time"
CLEAN_AREA = "clean_area"
CLEAN_RECORD = "clean_record"
MODES_LIST = "cleaning_mode_list"
MODE = "cleaning_mode"
FAULT = "fault"
DEFAULT_IDLE_STATUS = "standby,sleep"
DEFAULT_RETURNING_STATUS = "docking"
DEFAULT_DOCKED_STATUS = "charging,chargecompleted"
DEFAULT_MODES = "smart,wall_follow,spiral,single"
DEFAULT_FAN_SPEEDS = "low,normal,high"
DEFAULT_PAUSED_STATE = "paused"
DEFAULT_RETURN_MODE = "chargego"
DEFAULT_STOP_STATUS = "standby"
def flow_schema(dps):
"""Return schema used in config flow."""
return {
vol.Required(CONF_IDLE_STATUS_VALUE, default=DEFAULT_IDLE_STATUS): str,
vol.Required(CONF_POWERGO_DP): vol.In(dps),
vol.Required(CONF_DOCKED_STATUS_VALUE, default=DEFAULT_DOCKED_STATUS): str,
vol.Optional(
CONF_RETURNING_STATUS_VALUE, default=DEFAULT_RETURNING_STATUS
): str,
vol.Optional(CONF_BATTERY_DP): vol.In(dps),
vol.Optional(CONF_MODE_DP): vol.In(dps),
vol.Optional(CONF_MODES, default=DEFAULT_MODES): str,
vol.Optional(CONF_RETURN_MODE, default=DEFAULT_RETURN_MODE): str,
vol.Optional(CONF_FAN_SPEED_DP): vol.In(dps),
vol.Optional(CONF_FAN_SPEEDS, default=DEFAULT_FAN_SPEEDS): str,
vol.Optional(CONF_CLEAN_TIME_DP): vol.In(dps),
vol.Optional(CONF_CLEAN_AREA_DP): vol.In(dps),
vol.Optional(CONF_CLEAN_RECORD_DP): vol.In(dps),
vol.Optional(CONF_LOCATE_DP): vol.In(dps),
vol.Optional(CONF_FAULT_DP): vol.In(dps),
vol.Optional(CONF_PAUSED_STATE, default=DEFAULT_PAUSED_STATE): str,
vol.Optional(CONF_STOP_STATUS, default=DEFAULT_STOP_STATUS): str,
}
class LocaltuyaVacuum(LocalTuyaEntity, StateVacuumEntity):
"""Tuya vacuum device."""
def __init__(self, device, config_entry, switchid, **kwargs):
"""Initialize a new LocaltuyaVacuum."""
super().__init__(device, config_entry, switchid, _LOGGER, **kwargs)
self._state = None
self._battery_level = None
self._attrs = {}
self._idle_status_list = []
if self.has_config(CONF_IDLE_STATUS_VALUE):
self._idle_status_list = self._config[CONF_IDLE_STATUS_VALUE].split(",")
self._modes_list = []
if self.has_config(CONF_MODES):
self._modes_list = self._config[CONF_MODES].split(",")
self._attrs[MODES_LIST] = self._modes_list
self._docked_status_list = []
if self.has_config(CONF_DOCKED_STATUS_VALUE):
self._docked_status_list = self._config[CONF_DOCKED_STATUS_VALUE].split(",")
self._fan_speed_list = []
if self.has_config(CONF_FAN_SPEEDS):
self._fan_speed_list = self._config[CONF_FAN_SPEEDS].split(",")
self._fan_speed = ""
self._cleaning_mode = ""
print("Initialized vacuum [{}]".format(self.name))
@property
def supported_features(self):
"""Flag supported features."""
supported_features = (
SUPPORT_START
| SUPPORT_PAUSE
| SUPPORT_STOP
| SUPPORT_STATUS
| SUPPORT_STATE
)
if self.has_config(CONF_RETURN_MODE):
supported_features = supported_features | SUPPORT_RETURN_HOME
if self.has_config(CONF_FAN_SPEED_DP):
supported_features = supported_features | SUPPORT_FAN_SPEED
if self.has_config(CONF_BATTERY_DP):
supported_features = supported_features | SUPPORT_BATTERY
if self.has_config(CONF_LOCATE_DP):
supported_features = supported_features | SUPPORT_LOCATE
return supported_features
@property
def state(self):
"""Return the vacuum state."""
return self._state
@property
def battery_level(self):
"""Return the current battery level."""
return self._battery_level
@property
def extra_state_attributes(self):
"""Return the specific state attributes of this vacuum cleaner."""
return self._attrs
@property
def fan_speed(self):
"""Return the current fan speed."""
return self._fan_speed
@property
def fan_speed_list(self) -> list:
"""Return the list of available fan speeds."""
return self._fan_speed_list
async def async_start(self, **kwargs):
"""Turn the vacuum on and start cleaning."""
await self._device.set_dp(True, self._config[CONF_POWERGO_DP])
async def async_pause(self, **kwargs):
"""Stop the vacuum cleaner, do not return to base."""
await self._device.set_dp(False, self._config[CONF_POWERGO_DP])
async def async_return_to_base(self, **kwargs):
"""Set the vacuum cleaner to return to the dock."""
if self.has_config(CONF_RETURN_MODE):
await self._device.set_dp(
self._config[CONF_RETURN_MODE], self._config[CONF_MODE_DP]
)
else:
_LOGGER.error("Missing command for return home in commands set.")
async def async_stop(self, **kwargs):
"""Turn the vacuum off stopping the cleaning."""
if self.has_config(CONF_STOP_STATUS):
await self._device.set_dp(
self._config[CONF_STOP_STATUS], self._config[CONF_MODE_DP]
)
else:
_LOGGER.error("Missing command for stop in commands set.")
async def async_clean_spot(self, **kwargs):
"""Perform a spot clean-up."""
return None
async def async_locate(self, **kwargs):
"""Locate the vacuum cleaner."""
if self.has_config(CONF_LOCATE_DP):
await self._device.set_dp("", self._config[CONF_LOCATE_DP])
async def async_set_fan_speed(self, fan_speed, **kwargs):
"""Set the fan speed."""
await self._device.set_dp(fan_speed, self._config[CONF_FAN_SPEED_DP])
async def async_send_command(self, command, params=None, **kwargs):
"""Send a command to a vacuum cleaner."""
if command == "set_mode" and "mode" in params:
mode = params["mode"]
await self._device.set_dp(mode, self._config[CONF_MODE_DP])
def status_updated(self):
"""Device status was updated."""
state_value = str(self.dps(self._dp_id))
if state_value in self._idle_status_list:
self._state = STATE_IDLE
elif state_value in self._docked_status_list:
self._state = STATE_DOCKED
elif state_value == self._config[CONF_RETURNING_STATUS_VALUE]:
self._state = STATE_RETURNING
elif state_value == self._config[CONF_PAUSED_STATE]:
self._state = STATE_PAUSED
else:
self._state = STATE_CLEANING
if self.has_config(CONF_BATTERY_DP):
self._battery_level = self.dps_conf(CONF_BATTERY_DP)
self._cleaning_mode = ""
if self.has_config(CONF_MODES):
self._cleaning_mode = self.dps_conf(CONF_MODE_DP)
self._attrs[MODE] = self._cleaning_mode
self._fan_speed = ""
if self.has_config(CONF_FAN_SPEEDS):
self._fan_speed = self.dps_conf(CONF_FAN_SPEED_DP)
if self.has_config(CONF_CLEAN_TIME_DP):
self._attrs[CLEAN_TIME] = self.dps_conf(CONF_CLEAN_TIME_DP)
if self.has_config(CONF_CLEAN_AREA_DP):
self._attrs[CLEAN_AREA] = self.dps_conf(CONF_CLEAN_AREA_DP)
if self.has_config(CONF_CLEAN_RECORD_DP):
self._attrs[CLEAN_RECORD] = self.dps_conf(CONF_CLEAN_RECORD_DP)
if self.has_config(CONF_FAULT_DP):
self._attrs[FAULT] = self.dps_conf(CONF_FAULT_DP)
if self._attrs[FAULT] != 0:
self._state = STATE_ERROR
async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaVacuum, flow_schema)