Adding localtuya custom component
This commit is contained in:
parent
7500294633
commit
07db300fbe
|
@ -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)
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -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
|
||||||
|
)
|
|
@ -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)
|
|
@ -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.
|
||||||
|
"""
|
|
@ -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."""
|
|
@ -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"
|
|
@ -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)
|
|
@ -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
|
|
@ -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)
|
|
@ -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)
|
|
@ -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"
|
||||||
|
}
|
|
@ -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)
|
|
@ -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
|
Binary file not shown.
|
@ -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)
|
|
@ -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)
|
|
@ -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
|
|
@ -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"
|
||||||
|
}
|
|
@ -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)
|
|
@ -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"
|
||||||
|
}
|
|
@ -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)
|
Loading…
Reference in New Issue