This commit is contained in:
2026-01-30 23:31:00 -06:00
commit a39095b3de
2665 changed files with 263970 additions and 0 deletions

View File

@@ -0,0 +1,378 @@
"""The LocalTuya integration."""
import asyncio
import logging
import time
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 ConfigEntry
from homeassistant.const import (
CONF_CLIENT_ID,
CONF_CLIENT_SECRET,
CONF_DEVICE_ID,
CONF_DEVICES,
CONF_ENTITIES,
CONF_HOST,
CONF_ID,
CONF_PLATFORM,
CONF_REGION,
CONF_USERNAME,
EVENT_HOMEASSISTANT_STOP,
SERVICE_RELOAD,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.service import async_register_admin_service
from .cloud_api import TuyaCloudApi
from .common import TuyaDevice, async_config_entry_by_device_id
from .config_flow import ENTRIES_VERSION, config_schema
from .const import (
ATTR_UPDATED_AT,
CONF_NO_CLOUD,
CONF_PRODUCT_KEY,
CONF_USER_ID,
DATA_CLOUD,
DATA_DISCOVERY,
DOMAIN,
TUYA_DEVICES,
)
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,
}
)
async def async_setup(hass: HomeAssistant, config: dict):
"""Set up the LocalTuya integration component."""
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][TUYA_DEVICES] = {}
device_cache = {}
async def _handle_reload(service):
"""Handle reload service call."""
_LOGGER.info("Service %s.reload called: reloading integration", DOMAIN)
current_entries = hass.config_entries.async_entries(DOMAIN)
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."""
dev_id = event.data[CONF_DEVICE_ID]
if dev_id not in hass.data[DOMAIN][TUYA_DEVICES]:
raise HomeAssistantError("unknown device id")
device = hass.data[DOMAIN][TUYA_DEVICES][dev_id]
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
entry = async_config_entry_by_device_id(hass, device_id)
if entry is None:
return
if device_id not in device_cache:
if entry and device_id in entry.data[CONF_DEVICES]:
# Save address from config entry in cache to trigger
# potential update below
host_ip = entry.data[CONF_DEVICES][device_id][CONF_HOST]
device_cache[device_id] = host_ip
if device_id not in device_cache:
return
dev_entry = entry.data[CONF_DEVICES][device_id]
new_data = entry.data.copy()
updated = False
if device_cache[device_id] != device_ip:
updated = True
new_data[CONF_DEVICES][device_id][CONF_HOST] = device_ip
device_cache[device_id] = device_ip
if dev_entry.get(CONF_PRODUCT_KEY) != product_key:
updated = True
new_data[CONF_DEVICES][device_id][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 updated:
_LOGGER.debug(
"Updating keys for device %s: %s %s", device_id, device_ip, product_key
)
new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000))
hass.config_entries.async_update_entry(entry, data=new_data)
elif device_id in hass.data[DOMAIN][TUYA_DEVICES]:
_LOGGER.debug("Device %s found with IP %s", device_id, device_ip)
device = hass.data[DOMAIN][TUYA_DEVICES].get(device_id)
if not device:
_LOGGER.warning(f"Could not find device for device_id {device_id}")
elif not device.connected:
device.async_connect()
def _shutdown(event):
"""Clean up resources when shutting down."""
discovery.close()
async def _async_reconnect(now):
"""Try connecting to devices not already connected to."""
for device_id, device in hass.data[DOMAIN][TUYA_DEVICES].items():
if not device.connected:
device.async_connect()
async_track_time_interval(hass, _async_reconnect, RECONNECT_INTERVAL)
async_register_admin_service(
hass,
DOMAIN,
SERVICE_RELOAD,
_handle_reload,
)
hass.services.async_register(
DOMAIN, SERVICE_SET_DP, _handle_set_dp, schema=SERVICE_SET_DP_SCHEMA
)
discovery = TuyaDiscovery(_device_discovered)
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")
return True
async def async_migrate_entry(hass, config_entry: ConfigEntry):
"""Migrate old entries merging all of them in one."""
new_version = ENTRIES_VERSION
stored_entries = hass.config_entries.async_entries(DOMAIN)
if config_entry.version == 1:
_LOGGER.debug("Migrating config entry from version %s", config_entry.version)
if config_entry.entry_id == stored_entries[0].entry_id:
_LOGGER.debug(
"Migrating the first config entry (%s)", config_entry.entry_id
)
new_data = {}
new_data[CONF_REGION] = "eu"
new_data[CONF_CLIENT_ID] = ""
new_data[CONF_CLIENT_SECRET] = ""
new_data[CONF_USER_ID] = ""
new_data[CONF_USERNAME] = DOMAIN
new_data[CONF_NO_CLOUD] = True
new_data[CONF_DEVICES] = {
config_entry.data[CONF_DEVICE_ID]: config_entry.data.copy()
}
new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000))
config_entry.version = new_version
hass.config_entries.async_update_entry(
config_entry, title=DOMAIN, data=new_data
)
else:
_LOGGER.debug(
"Merging the config entry %s into the main one", config_entry.entry_id
)
new_data = stored_entries[0].data.copy()
new_data[CONF_DEVICES].update(
{config_entry.data[CONF_DEVICE_ID]: config_entry.data.copy()}
)
new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000))
hass.config_entries.async_update_entry(stored_entries[0], data=new_data)
await hass.config_entries.async_remove(config_entry.entry_id)
_LOGGER.info(
"Entry %s successfully migrated to version %s.",
config_entry.entry_id,
new_version,
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Set up LocalTuya integration from a config entry."""
if entry.version < ENTRIES_VERSION:
_LOGGER.debug(
"Skipping setup for entry %s since its version (%s) is old",
entry.entry_id,
entry.version,
)
return
region = entry.data[CONF_REGION]
client_id = entry.data[CONF_CLIENT_ID]
secret = entry.data[CONF_CLIENT_SECRET]
user_id = entry.data[CONF_USER_ID]
tuya_api = TuyaCloudApi(hass, region, client_id, secret, user_id)
no_cloud = True
if CONF_NO_CLOUD in entry.data:
no_cloud = entry.data.get(CONF_NO_CLOUD)
if no_cloud:
_LOGGER.info("Cloud API account not configured.")
# wait 1 second to make sure possible migration has finished
await asyncio.sleep(1)
else:
res = await tuya_api.async_get_access_token()
if res != "ok":
_LOGGER.error("Cloud API connection failed: %s", res)
else:
_LOGGER.info("Cloud API connection succeeded.")
res = await tuya_api.async_get_devices_list()
hass.data[DOMAIN][DATA_CLOUD] = tuya_api
platforms = set()
for dev_id in entry.data[CONF_DEVICES].keys():
entities = entry.data[CONF_DEVICES][dev_id][CONF_ENTITIES]
platforms = platforms.union(
set(entity[CONF_PLATFORM] for entity in entities)
)
hass.data[DOMAIN][TUYA_DEVICES][dev_id] = TuyaDevice(hass, entry, dev_id)
# Setup all platforms at once, letting HA handling each platform and avoiding
# potential integration restarts while elements are still initialising.
await hass.config_entries.async_forward_entry_setups(entry, platforms)
async def setup_entities(device_ids):
for dev_id in device_ids:
hass.data[DOMAIN][TUYA_DEVICES][dev_id].async_connect()
await async_remove_orphan_entities(hass, entry)
hass.async_create_task(setup_entities(entry.data[CONF_DEVICES].keys()))
unsub_listener = entry.add_update_listener(update_listener)
hass.data[DOMAIN][entry.entry_id] = {UNSUB_LISTENER: unsub_listener}
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Unload a config entry."""
platforms = {}
for dev_id, dev_entry in entry.data[CONF_DEVICES].items():
for entity in dev_entry[CONF_ENTITIES]:
platforms[entity[CONF_PLATFORM]] = True
unload_ok = all(
await asyncio.gather(
*[
hass.config_entries.async_forward_entry_unload(entry, component)
for component in platforms
]
)
)
hass.data[DOMAIN][entry.entry_id][UNSUB_LISTENER]()
for dev_id, device in hass.data[DOMAIN][TUYA_DEVICES].items():
if device.connected:
await device.close()
if unload_ok:
hass.data[DOMAIN][TUYA_DEVICES] = {}
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_config_entry_device(
hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry
) -> bool:
"""Remove a config entry from a device."""
dev_id = list(device_entry.identifiers)[0][1].split("_")[-1]
ent_reg = er.async_get(hass)
entities = {
ent.unique_id: ent.entity_id
for ent in er.async_entries_for_config_entry(ent_reg, config_entry.entry_id)
if dev_id in ent.unique_id
}
for entity_id in entities.values():
ent_reg.async_remove(entity_id)
if dev_id not in config_entry.data[CONF_DEVICES]:
_LOGGER.info(
"Device %s not found in config entry: finalizing device removal", dev_id
)
return True
await hass.data[DOMAIN][TUYA_DEVICES][dev_id].close()
new_data = config_entry.data.copy()
new_data[CONF_DEVICES].pop(dev_id)
new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000))
hass.config_entries.async_update_entry(
config_entry,
data=new_data,
)
_LOGGER.info("Device %s removed.", dev_id)
return True
async def async_remove_orphan_entities(hass, entry):
"""Remove entities associated with config entry that has been removed."""
return
ent_reg = er.async_get(hass)
entities = {
ent.unique_id: ent.entity_id
for ent in er.async_entries_for_config_entry(ent_reg, entry.entry_id)
}
_LOGGER.info("ENTITIES ORPHAN %s", entities)
return
for entity in entry.data[CONF_ENTITIES]:
if entity[CONF_ID] in entities:
del entities[entity[CONF_ID]]
for entity_id in entities.values():
ent_reg.async_remove(entity_id)

View File

@@ -0,0 +1,76 @@
"""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."""
super().status_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
)
# No need to restore state for a sensor
async def restore_state_when_connected(self):
"""Do nothing for a sensor."""
return
async_setup_entry = partial(
async_setup_entry, DOMAIN, LocaltuyaBinarySensor, flow_schema
)

View File

@@ -0,0 +1,522 @@
"""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 (
HVACAction,
HVACMode,
PRESET_AWAY,
PRESET_ECO,
PRESET_HOME,
PRESET_NONE,
ClimateEntityFeature,
FAN_AUTO,
FAN_LOW,
FAN_MEDIUM,
FAN_HIGH,
FAN_TOP,
SWING_ON,
SWING_OFF,
)
from homeassistant.const import (
ATTR_TEMPERATURE,
CONF_TEMPERATURE_UNIT,
PRECISION_HALVES,
PRECISION_TENTHS,
PRECISION_WHOLE,
UnitOfTemperature,
)
from .common import LocalTuyaEntity, async_setup_entry
from .const import (
CONF_CURRENT_TEMPERATURE_DP,
CONF_TEMP_MAX,
CONF_TEMP_MIN,
CONF_ECO_DP,
CONF_ECO_VALUE,
CONF_HEURISTIC_ACTION,
CONF_HVAC_ACTION_DP,
CONF_HVAC_ACTION_SET,
CONF_HVAC_MODE_DP,
CONF_HVAC_MODE_SET,
CONF_MAX_TEMP_DP,
CONF_MIN_TEMP_DP,
CONF_PRECISION,
CONF_PRESET_DP,
CONF_PRESET_SET,
CONF_TARGET_PRECISION,
CONF_TARGET_TEMPERATURE_DP,
CONF_TEMPERATURE_STEP,
CONF_HVAC_FAN_MODE_DP,
CONF_HVAC_FAN_MODE_SET,
CONF_HVAC_SWING_MODE_DP,
CONF_HVAC_SWING_MODE_SET,
)
_LOGGER = logging.getLogger(__name__)
HVAC_MODE_SETS = {
"manual/auto": {
HVACMode.HEAT: "manual",
HVACMode.AUTO: "auto",
},
"Manual/Auto": {
HVACMode.HEAT: "Manual",
HVACMode.AUTO: "Auto",
},
"MANUAL/AUTO": {
HVACMode.HEAT: "MANUAL",
HVACMode.AUTO: "AUTO",
},
"Manual/Program": {
HVACMode.HEAT: "Manual",
HVACMode.AUTO: "Program",
},
"m/p": {
HVACMode.HEAT: "m",
HVACMode.AUTO: "p",
},
"True/False": {
HVACMode.HEAT: True,
},
"Auto/Cold/Dry/Wind/Hot": {
HVACMode.HEAT: "hot",
HVACMode.FAN_ONLY: "wind",
HVACMode.DRY: "wet",
HVACMode.COOL: "cold",
HVACMode.AUTO: "auto",
},
"Cold/Dehumidify/Hot": {
HVACMode.HEAT: "hot",
HVACMode.DRY: "dehumidify",
HVACMode.COOL: "cold",
},
"1/0": {
HVACMode.HEAT: "1",
HVACMode.AUTO: "0",
},
}
HVAC_ACTION_SETS = {
"True/False": {
HVACAction.HEATING: True,
HVACAction.IDLE: False,
},
"open/close": {
HVACAction.HEATING: "open",
HVACAction.IDLE: "close",
},
"heating/no_heating": {
HVACAction.HEATING: "heating",
HVACAction.IDLE: "no_heating",
},
"Heat/Warming": {
HVACAction.HEATING: "Heat",
HVACAction.IDLE: "Warming",
},
"heating/warming": {
HVACAction.HEATING: "heating",
HVACAction.IDLE: "warming",
},
}
HVAC_FAN_MODE_SETS = {
"Auto/Low/Middle/High/Strong": {
FAN_AUTO: "auto",
FAN_LOW: "low",
FAN_MEDIUM: "middle",
FAN_HIGH: "high",
FAN_TOP: "strong",
}
}
HVAC_SWING_MODE_SETS = {
"True/False": {
SWING_ON: True,
SWING_OFF: False,
}
}
PRESET_SETS = {
"Manual/Holiday/Program": {
PRESET_AWAY: "Holiday",
PRESET_HOME: "Program",
PRESET_NONE: "Manual",
},
"smart/holiday/hold": {
PRESET_AWAY: "holiday",
PRESET_HOME: "smart",
PRESET_NONE: "hold",
},
}
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, default=PRECISION_WHOLE): vol.In(
[PRECISION_WHOLE, PRECISION_HALVES, PRECISION_TENTHS]
),
vol.Optional(CONF_TEMP_MIN, default=DEFAULT_MIN_TEMP): vol.Coerce(float),
vol.Optional(CONF_TEMP_MAX, default=DEFAULT_MAX_TEMP): vol.Coerce(float),
vol.Optional(CONF_MAX_TEMP_DP): vol.In(dps),
vol.Optional(CONF_MIN_TEMP_DP): vol.In(dps),
vol.Optional(CONF_PRECISION, default=PRECISION_WHOLE): 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_FAN_MODE_DP): vol.In(dps),
vol.Optional(CONF_HVAC_FAN_MODE_SET): vol.In(list(HVAC_FAN_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, default=PRECISION_WHOLE): 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._fan_mode = None
self._swing_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_hvac_fan_mode_dp = self._config.get(CONF_HVAC_FAN_MODE_DP)
self._conf_hvac_fan_mode_set = HVAC_FAN_MODE_SETS.get(
self._config.get(CONF_HVAC_FAN_MODE_SET), {}
)
self._conf_hvac_swing_mode_dp = self._config.get(CONF_HVAC_SWING_MODE_DP)
self._conf_hvac_swing_mode_set = HVAC_SWING_MODE_SETS.get(
self._config.get(CONF_HVAC_SWING_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
)
_LOGGER.debug("Initialized climate [%s]", self.name)
@property
def supported_features(self):
"""Flag supported features."""
supported_features = ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF
if self.has_config(CONF_TARGET_TEMPERATURE_DP):
supported_features = supported_features | ClimateEntityFeature.TARGET_TEMPERATURE
if self.has_config(CONF_MAX_TEMP_DP):
supported_features = supported_features | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
if self.has_config(CONF_PRESET_DP) or self.has_config(CONF_ECO_DP):
supported_features = supported_features | ClimateEntityFeature.PRESET_MODE
if self.has_config(CONF_HVAC_FAN_MODE_DP) and self.has_config(CONF_HVAC_FAN_MODE_SET):
supported_features = supported_features | ClimateEntityFeature.FAN_MODE
if self.has_config(CONF_HVAC_SWING_MODE_DP):
supported_features = supported_features | ClimateEntityFeature.SWING_MODE
return supported_features
@property
def precision(self):
"""Return the precision of the system."""
return self._precision
@property
def target_precision(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 UnitOfTemperature.FAHRENHEIT
return UnitOfTemperature.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) + [HVACMode.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 == HVACMode.HEAT:
if self._current_temperature < (
self._target_temperature - self._precision
):
self._hvac_action = HVACAction.HEATING
if self._current_temperature == (
self._target_temperature - self._precision
):
if self._hvac_action == HVACAction.HEATING:
self._hvac_action = HVACAction.HEATING
if self._hvac_action == HVACAction.IDLE:
self._hvac_action = HVACAction.IDLE
if (
self._current_temperature + self._precision
) > self._target_temperature:
self._hvac_action = HVACAction.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 self._fan_mode
@property
def fan_modes(self):
"""Return the list of available fan modes."""
if not self.has_config(CONF_HVAC_FAN_MODE_DP):
return None
return list(self._conf_hvac_fan_mode_set)
@property
def swing_mode(self):
"""Return the swing setting."""
return self._swing_mode
@property
def swing_modes(self):
"""Return the list of available swing modes."""
if not self.has_config(CONF_HVAC_SWING_MODE_DP):
return None
return list(self._conf_hvac_swing_mode_set)
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]
)
async def async_set_fan_mode(self, fan_mode):
"""Set new target fan mode."""
if self._conf_hvac_fan_mode_dp is None:
_LOGGER.error("Fan speed unsupported (no DP)")
return
if fan_mode not in self._conf_hvac_fan_mode_set:
_LOGGER.error("Unsupported fan_mode: %s" % fan_mode)
return
await self._device.set_dp(
self._conf_hvac_fan_mode_set[fan_mode], self._conf_hvac_fan_mode_dp
)
async def async_set_hvac_mode(self, hvac_mode):
"""Set new target operation mode."""
if hvac_mode == HVACMode.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_set_swing_mode(self, swing_mode):
"""Set new target swing operation."""
if self._conf_hvac_swing_mode_dp is None:
_LOGGER.error("Swing mode unsupported (no DP)")
return
if swing_mode not in self._conf_hvac_swing_mode_set:
_LOGGER.error("Unsupported swing_mode: %s" % swing_mode)
return
await self._device.set_dp(
self._conf_hvac_swing_mode_set[swing_mode], self._conf_hvac_swing_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 self._config[CONF_TEMP_MIN]
@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 self._config[CONF_TEMP_MAX]
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 = HVACMode.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 = HVACMode.AUTO
# Update the fan status
if self.has_config(CONF_HVAC_FAN_MODE_DP):
for mode, value in self._conf_hvac_fan_mode_set.items():
if self.dps_conf(CONF_HVAC_FAN_MODE_DP) == value:
self._fan_mode = mode
break
else:
# in case fan mode and preset share the same dp
_LOGGER.debug("Unknown fan mode %s" % self.dps_conf(CONF_HVAC_FAN_MODE_DP))
self._fan_mode = FAN_AUTO
# Update the swing status
if self.has_config(CONF_HVAC_SWING_MODE_DP):
for mode, value in self._conf_hvac_swing_mode_set.items():
if self.dps_conf(CONF_HVAC_SWING_MODE_DP) == value:
self._swing_mode = mode
break
else:
_LOGGER.debug("Unknown swing mode %s" % self.dps_conf(CONF_HVAC_SWING_MODE_DP))
self._swing_mode = SWING_OFF
# Update the current action
for action, value in self._conf_hvac_action_set.items():
if self.dps_conf(CONF_HVAC_ACTION_DP) == value:
self._hvac_action = action
async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaClimate, flow_schema)

View File

@@ -0,0 +1,139 @@
"""Class to perform requests to Tuya Cloud APIs."""
import functools
import hashlib
import hmac
import json
import logging
import time
import requests
_LOGGER = logging.getLogger(__name__)
# Signature algorithm.
def calc_sign(msg, key):
"""Calculate signature for request."""
sign = (
hmac.new(
msg=bytes(msg, "latin-1"),
key=bytes(key, "latin-1"),
digestmod=hashlib.sha256,
)
.hexdigest()
.upper()
)
return sign
class TuyaCloudApi:
"""Class to send API calls."""
def __init__(self, hass, region_code, client_id, secret, user_id):
"""Initialize the class."""
self._hass = hass
self._base_url = f"https://openapi.tuya{region_code}.com"
self._client_id = client_id
self._secret = secret
self._user_id = user_id
self._access_token = ""
self.device_list = {}
def generate_payload(self, method, timestamp, url, headers, body=None):
"""Generate signed payload for requests."""
payload = self._client_id + self._access_token + timestamp
payload += method + "\n"
# Content-SHA256
payload += hashlib.sha256(bytes((body or "").encode("utf-8"))).hexdigest()
payload += (
"\n"
+ "".join(
[
"%s:%s\n" % (key, headers[key]) # Headers
for key in headers.get("Signature-Headers", "").split(":")
if key in headers
]
)
+ "\n/"
+ url.split("//", 1)[-1].split("/", 1)[-1] # Url
)
# _LOGGER.debug("PAYLOAD: %s", payload)
return payload
async def async_make_request(self, method, url, body=None, headers={}):
"""Perform requests."""
timestamp = str(int(time.time() * 1000))
payload = self.generate_payload(method, timestamp, url, headers, body)
default_par = {
"client_id": self._client_id,
"access_token": self._access_token,
"sign": calc_sign(payload, self._secret),
"t": timestamp,
"sign_method": "HMAC-SHA256",
}
full_url = self._base_url + url
# _LOGGER.debug("\n" + method + ": [%s]", full_url)
if method == "GET":
func = functools.partial(
requests.get, full_url, headers=dict(default_par, **headers)
)
elif method == "POST":
func = functools.partial(
requests.post,
full_url,
headers=dict(default_par, **headers),
data=json.dumps(body),
)
# _LOGGER.debug("BODY: [%s]", body)
elif method == "PUT":
func = functools.partial(
requests.put,
full_url,
headers=dict(default_par, **headers),
data=json.dumps(body),
)
resp = await self._hass.async_add_executor_job(func)
# r = json.dumps(r.json(), indent=2, ensure_ascii=False) # Beautify the format
return resp
async def async_get_access_token(self):
"""Obtain a valid access token."""
try:
resp = await self.async_make_request("GET", "/v1.0/token?grant_type=1")
except requests.exceptions.ConnectionError:
return "Request failed, status ConnectionError"
if not resp.ok:
return "Request failed, status " + str(resp.status)
r_json = resp.json()
if not r_json["success"]:
return f"Error {r_json['code']}: {r_json['msg']}"
self._access_token = resp.json()["result"]["access_token"]
return "ok"
async def async_get_devices_list(self):
"""Obtain the list of devices associated to a user."""
resp = await self.async_make_request(
"GET", url=f"/v1.0/users/{self._user_id}/devices"
)
if not resp.ok:
return "Request failed, status " + str(resp.status)
r_json = resp.json()
if not r_json["success"]:
# _LOGGER.debug(
# "Request failed, reply is %s",
# json.dumps(r_json, indent=2, ensure_ascii=False)
# )
return f"Error {r_json['code']}: {r_json['msg']}"
self.device_list = {dev["id"]: dev for dev in r_json["result"]}
# _LOGGER.debug("DEV_LIST: %s", self.device_list)
return "ok"

View File

@@ -0,0 +1,607 @@
"""Code shared between all platforms."""
import asyncio
import json.decoder
import logging
import time
from datetime import timedelta
from homeassistant.const import (
CONF_DEVICE_ID,
CONF_DEVICES,
CONF_ENTITIES,
CONF_FRIENDLY_NAME,
CONF_HOST,
CONF_ID,
CONF_PLATFORM,
CONF_SCAN_INTERVAL,
STATE_UNKNOWN,
)
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.restore_state import RestoreEntity
from . import pytuya
from .const import (
ATTR_STATE,
ATTR_UPDATED_AT,
CONF_DEFAULT_VALUE,
CONF_ENABLE_DEBUG,
CONF_LOCAL_KEY,
CONF_MODEL,
CONF_PASSIVE_ENTITY,
CONF_PROTOCOL_VERSION,
CONF_RESET_DPIDS,
CONF_RESTORE_ON_RECONNECT,
DATA_CLOUD,
DOMAIN,
TUYA_DEVICES,
)
_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 = []
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.
"""
entities = []
for dev_id in config_entry.data[CONF_DEVICES]:
# entities_to_setup = prepare_setup_entities(
# hass, config_entry.data[dev_id], domain
# )
dev_entry = config_entry.data[CONF_DEVICES][dev_id]
entities_to_setup = [
entity
for entity in dev_entry[CONF_ENTITIES]
if entity[CONF_PLATFORM] == domain
]
if entities_to_setup:
tuyainterface = hass.data[DOMAIN][TUYA_DEVICES][dev_id]
dps_config_fields = list(get_dps_for_platform(flow_schema))
for entity_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 entity_config:
tuyainterface.dps_to_request[entity_config[dp_conf]] = None
entities.append(
entity_class(
tuyainterface,
dev_entry,
entity_config[CONF_ID],
)
)
# Once the entities have been created, add to the TuyaDevice instance
tuyainterface.add_entities(entities)
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[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 device_id in entry.data.get(CONF_DEVICES, []):
return entry
else:
_LOGGER.debug(f"Missing device configuration for device_id {device_id}")
return None
class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger):
"""Cache wrapper for pytuya.TuyaInterface."""
def __init__(self, hass, config_entry, dev_id):
"""Initialize the cache."""
super().__init__()
self._hass = hass
self._config_entry = config_entry
self._dev_config_entry = config_entry.data[CONF_DEVICES][dev_id].copy()
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._entities = []
self._local_key = self._dev_config_entry[CONF_LOCAL_KEY]
self._default_reset_dpids = None
if CONF_RESET_DPIDS in self._dev_config_entry:
reset_ids_str = self._dev_config_entry[CONF_RESET_DPIDS].split(",")
self._default_reset_dpids = []
for reset_id in reset_ids_str:
self._default_reset_dpids.append(int(reset_id.strip()))
self.set_logger(_LOGGER, self._dev_config_entry[CONF_DEVICE_ID])
# This has to be done in case the device type is type_0d
for entity in self._dev_config_entry[CONF_ENTITIES]:
self.dps_to_request[entity[CONF_ID]] = None
def add_entities(self, entities):
"""Set the entities associated with this device."""
self._entities.extend(entities)
@property
def is_connecting(self):
"""Return whether device is currently connecting."""
return self._connect_task is not 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."""
# self.info("async_connect: %d %r %r", self._is_closing, self._connect_task, self._interface)
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.info("Trying to connect to %s...", self._dev_config_entry[CONF_HOST])
try:
self._interface = await pytuya.connect(
self._dev_config_entry[CONF_HOST],
self._dev_config_entry[CONF_DEVICE_ID],
self._local_key,
float(self._dev_config_entry[CONF_PROTOCOL_VERSION]),
self._dev_config_entry.get(CONF_ENABLE_DEBUG, False),
self,
)
self._interface.add_dps_to_request(self.dps_to_request)
except Exception as ex: # pylint: disable=broad-except
self.warning(
f"Failed to connect to {self._dev_config_entry[CONF_HOST]}: %s", ex
)
if self._interface is not None:
await self._interface.close()
self._interface = None
if self._interface is not None:
try:
try:
self.debug("Retrieving initial state")
status = await self._interface.status()
if status is None:
raise Exception("Failed to retrieve status")
self._interface.start_heartbeat()
self.status_updated(status)
except Exception as ex:
if (self._default_reset_dpids is not None) and (
len(self._default_reset_dpids) > 0
):
self.debug(
"Initial state update failed, trying reset command "
+ "for DP IDs: %s",
self._default_reset_dpids,
)
await self._interface.reset(self._default_reset_dpids)
self.debug("Update completed, retrying initial state")
status = await self._interface.status()
if status is None or not status:
raise Exception("Failed to retrieve status") from ex
self._interface.start_heartbeat()
self.status_updated(status)
else:
self.error("Initial state update failed, giving up: %r", ex)
if self._interface is not None:
await self._interface.close()
self._interface = None
except (UnicodeDecodeError, json.decoder.JSONDecodeError) as ex:
self.warning("Initial state update failed (%s), trying key update", ex)
await self.update_local_key()
if self._interface is not None:
await self._interface.close()
self._interface = None
if self._interface is not None:
# Attempt to restore status for all entities that need to first set
# the DPS value before the device will respond with status.
for entity in self._entities:
await entity.restore_state_when_connected()
def _new_entity_handler(entity_id):
self.debug(
"New entity %s was added to %s",
entity_id,
self._dev_config_entry[CONF_HOST],
)
self._dispatch_status()
signal = f"localtuya_entity_{self._dev_config_entry[CONF_DEVICE_ID]}"
self._disconnect_task = async_dispatcher_connect(
self._hass, signal, _new_entity_handler
)
if (
CONF_SCAN_INTERVAL in self._dev_config_entry
and int(self._dev_config_entry[CONF_SCAN_INTERVAL]) > 0
):
self._unsub_interval = async_track_time_interval(
self._hass,
self._async_refresh,
timedelta(seconds=int(self._dev_config_entry[CONF_SCAN_INTERVAL])),
)
self.info(f"Successfully connected to {self._dev_config_entry[CONF_HOST]}")
self._connect_task = None
async def update_local_key(self):
"""Retrieve updated local_key from Cloud API and update the config_entry."""
dev_id = self._dev_config_entry[CONF_DEVICE_ID]
await self._hass.data[DOMAIN][DATA_CLOUD].async_get_devices_list()
cloud_devs = self._hass.data[DOMAIN][DATA_CLOUD].device_list
if dev_id in cloud_devs:
self._local_key = cloud_devs[dev_id].get(CONF_LOCAL_KEY)
new_data = self._config_entry.data.copy()
new_data[CONF_DEVICES][dev_id][CONF_LOCAL_KEY] = self._local_key
new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000))
self._hass.config_entries.async_update_entry(
self._config_entry,
data=new_data,
)
self.info("local_key updated for device %s.", dev_id)
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()
self.info(
"Closed connection with device %s.",
self._dev_config_entry[CONF_FRIENDLY_NAME],
)
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 %s", dp_index, str(state))
else:
self.error(
"Not connected to device %s", self._dev_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._dev_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._dev_config_entry[CONF_DEVICE_ID]}"
async_dispatcher_send(self._hass, signal, self._status)
@callback
def disconnected(self):
"""Device disconnected."""
signal = f"localtuya_{self._dev_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
if self._connect_task is not None:
self._connect_task.cancel()
self._connect_task = None
self.warning("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._dev_config_entry = config_entry
self._config = get_entity_config(config_entry, dp_id)
self._dp_id = dp_id
self._status = {}
self._state = None
self._last_state = None
# Default value is available to be provided by Platform entities if required
self._default_value = self._config.get(CONF_DEFAULT_VALUE)
# Determine whether is a passive entity
self._is_passive_entity = self._config.get(CONF_PASSIVE_ENTITY) or False
""" Restore on connect setting is available to be provided by Platform entities
if required"""
self._restore_on_reconnect = (
self._config.get(CONF_RESTORE_ON_RECONNECT) or False
)
self.set_logger(logger, self._dev_config_entry[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()
# Update HA
self.schedule_update_ha_state()
signal = f"localtuya_{self._dev_config_entry[CONF_DEVICE_ID]}"
self.async_on_remove(
async_dispatcher_connect(self.hass, signal, _update_handler)
)
signal = f"localtuya_entity_{self._dev_config_entry[CONF_DEVICE_ID]}"
async_dispatcher_send(self.hass, signal, self.entity_id)
@property
def extra_state_attributes(self):
"""Return entity specific state attributes to be saved.
These attributes are then available for restore when the
entity is restored at startup.
"""
attributes = {}
if self._state is not None:
attributes[ATTR_STATE] = self._state
elif self._last_state is not None:
attributes[ATTR_STATE] = self._last_state
self.debug("Entity %s - Additional attributes: %s", self.name, attributes)
return attributes
@property
def device_info(self):
"""Return device information for the device registry."""
model = self._dev_config_entry.get(CONF_MODEL, "Tuya generic")
return {
"identifiers": {
# Serial numbers are unique identifiers within a specific domain
(DOMAIN, f"local_{self._dev_config_entry[CONF_DEVICE_ID]}")
},
"name": self._dev_config_entry[CONF_FRIENDLY_NAME],
"manufacturer": "Tuya",
"model": f"{model} ({self._dev_config_entry[CONF_DEVICE_ID]})",
"sw_version": self._dev_config_entry[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._dev_config_entry[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.
"""
state = self.dps(self._dp_id)
self._state = state
# Keep record in last_state as long as not during connection/re-connection,
# as last state will be used to restore the previous state
if (state is not None) and (not self._device.is_connecting):
self._last_state = state
def status_restored(self, stored_state):
"""Device status was restored.
Override in subclasses and update entity specific state.
"""
raw_state = stored_state.attributes.get(ATTR_STATE)
if raw_state is not None:
self._last_state = raw_state
self.debug(
"Restoring state for entity: %s - state: %s",
self.name,
str(self._last_state),
)
def default_value(self):
"""Return default value of this entity.
Override in subclasses to specify the default value for the entity.
"""
# Check if default value has been set - if not, default to the entity defaults.
if self._default_value is None:
self._default_value = self.entity_default_value()
return self._default_value
def entity_default_value(self): # pylint: disable=no-self-use
"""Return default value of the entity type.
Override in subclasses to specify the default value for the entity.
"""
return 0
@property
def restore_on_reconnect(self):
"""Return whether the last state should be restored on a reconnect.
Useful where the device loses settings if powered off
"""
return self._restore_on_reconnect
async def restore_state_when_connected(self):
"""Restore if restore_on_reconnect is set, or if no status has been yet found.
Which indicates a DPS that needs to be set before it starts returning
status.
"""
if (not self.restore_on_reconnect) and (
(str(self._dp_id) in self._status) or (not self._is_passive_entity)
):
self.debug(
"Entity %s (DP %d) - Not restoring as restore on reconnect is "
+ "disabled for this entity and the entity has an initial status "
+ "or it is not a passive entity",
self.name,
self._dp_id,
)
return
self.debug("Attempting to restore state for entity: %s", self.name)
# Attempt to restore the current state - in case reset.
restore_state = self._state
# If no state stored in the entity currently, go from last saved state
if (restore_state == STATE_UNKNOWN) | (restore_state is None):
self.debug("No current state for entity")
restore_state = self._last_state
# If no current or saved state, then use the default value
if restore_state is None:
if self._is_passive_entity:
self.debug("No last restored state - using default")
restore_state = self.default_value()
else:
self.debug("Not a passive entity and no state found - aborting restore")
return
self.debug(
"Entity %s (DP %d) - Restoring state: %s",
self.name,
self._dp_id,
str(restore_state),
)
# Manually initialise
await self._device.set_dp(restore_state, self._dp_id)

View File

@@ -0,0 +1,819 @@
"""Config flow for LocalTuya integration integration."""
import errno
import logging
import time
from importlib import import_module
import homeassistant.helpers.config_validation as cv
import homeassistant.helpers.entity_registry as er
import voluptuous as vol
from homeassistant import config_entries, core, exceptions
from homeassistant.const import (
CONF_CLIENT_ID,
CONF_CLIENT_SECRET,
CONF_DEVICE_ID,
CONF_DEVICES,
CONF_ENTITIES,
CONF_FRIENDLY_NAME,
CONF_HOST,
CONF_ID,
CONF_NAME,
CONF_PLATFORM,
CONF_REGION,
CONF_SCAN_INTERVAL,
CONF_USERNAME,
)
from homeassistant.core import callback
from .cloud_api import TuyaCloudApi
from .common import pytuya
from .const import (
ATTR_UPDATED_AT,
CONF_ACTION,
CONF_ADD_DEVICE,
CONF_DPS_STRINGS,
CONF_EDIT_DEVICE,
CONF_ENABLE_DEBUG,
CONF_LOCAL_KEY,
CONF_MANUAL_DPS,
CONF_MODEL,
CONF_NO_CLOUD,
CONF_PRODUCT_NAME,
CONF_PROTOCOL_VERSION,
CONF_RESET_DPIDS,
CONF_SETUP_CLOUD,
CONF_USER_ID,
CONF_ENABLE_ADD_ENTITIES,
DATA_CLOUD,
DATA_DISCOVERY,
DOMAIN,
PLATFORMS,
)
from .discovery import discover
_LOGGER = logging.getLogger(__name__)
ENTRIES_VERSION = 2
PLATFORM_TO_ADD = "platform_to_add"
NO_ADDITIONAL_ENTITIES = "no_additional_entities"
SELECTED_DEVICE = "selected_device"
CUSTOM_DEVICE = "..."
CONF_ACTIONS = {
CONF_ADD_DEVICE: "Add a new device",
CONF_EDIT_DEVICE: "Edit a device",
CONF_SETUP_CLOUD: "Reconfigure Cloud API account",
}
CONFIGURE_SCHEMA = vol.Schema(
{
vol.Required(CONF_ACTION, default=CONF_ADD_DEVICE): vol.In(CONF_ACTIONS),
}
)
CLOUD_SETUP_SCHEMA = vol.Schema(
{
vol.Required(CONF_REGION, default="eu"): vol.In(["eu", "us", "cn", "in"]),
vol.Optional(CONF_CLIENT_ID): cv.string,
vol.Optional(CONF_CLIENT_SECRET): cv.string,
vol.Optional(CONF_USER_ID): cv.string,
vol.Optional(CONF_USERNAME, default=DOMAIN): cv.string,
vol.Required(CONF_NO_CLOUD, default=False): bool,
}
)
DEVICE_SCHEMA = vol.Schema(
{
vol.Required(CONF_FRIENDLY_NAME): cv.string,
vol.Required(CONF_HOST): cv.string,
vol.Required(CONF_DEVICE_ID): cv.string,
vol.Required(CONF_LOCAL_KEY): cv.string,
vol.Required(CONF_PROTOCOL_VERSION, default="3.3"): vol.In(
["3.1", "3.2", "3.3", "3.4"]
),
vol.Required(CONF_ENABLE_DEBUG, default=False): bool,
vol.Optional(CONF_SCAN_INTERVAL): int,
vol.Optional(CONF_MANUAL_DPS): cv.string,
vol.Optional(CONF_RESET_DPIDS): str,
}
)
PICK_ENTITY_SCHEMA = vol.Schema(
{vol.Required(PLATFORM_TO_ADD, default="switch"): vol.In(PLATFORMS)}
)
def devices_schema(discovered_devices, cloud_devices_list, add_custom_device=True):
"""Create schema for devices step."""
devices = {}
for dev_id, dev_host in discovered_devices.items():
dev_name = dev_id
if dev_id in cloud_devices_list.keys():
dev_name = cloud_devices_list[dev_id][CONF_NAME]
devices[dev_id] = f"{dev_name} ({dev_host})"
if add_custom_device:
devices.update({CUSTOM_DEVICE: CUSTOM_DEVICE})
# devices.update(
# {
# ent.data[CONF_DEVICE_ID]: ent.data[CONF_FRIENDLY_NAME]
# for ent in entries
# }
# )
return vol.Schema({vol.Required(SELECTED_DEVICE): vol.In(devices)})
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): cv.string,
vol.Required(CONF_HOST): cv.string,
vol.Required(CONF_LOCAL_KEY): cv.string,
vol.Required(CONF_PROTOCOL_VERSION, default="3.3"): vol.In(
["3.1", "3.2", "3.3", "3.4"]
),
vol.Required(CONF_ENABLE_DEBUG, default=False): bool,
vol.Optional(CONF_SCAN_INTERVAL): int,
vol.Optional(CONF_MANUAL_DPS): cv.string,
vol.Optional(CONF_RESET_DPIDS): cv.string,
vol.Required(
CONF_ENTITIES, description={"suggested_value": entity_names}
): cv.multi_select(entity_names),
vol.Required(CONF_ENABLE_ADD_ENTITIES, default=False): bool,
}
)
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
reset_ids = None
try:
interface = await pytuya.connect(
data[CONF_HOST],
data[CONF_DEVICE_ID],
data[CONF_LOCAL_KEY],
float(data[CONF_PROTOCOL_VERSION]),
data[CONF_ENABLE_DEBUG],
)
if CONF_RESET_DPIDS in data:
reset_ids_str = data[CONF_RESET_DPIDS].split(",")
reset_ids = []
for reset_id in reset_ids_str:
reset_ids.append(int(reset_id.strip()))
_LOGGER.debug(
"Reset DPIDs configured: %s (%s)",
data[CONF_RESET_DPIDS],
reset_ids,
)
try:
detected_dps = await interface.detect_available_dps()
except Exception as ex:
try:
_LOGGER.debug(
"Initial state update failed (%s), trying reset command", ex
)
if len(reset_ids) > 0:
await interface.reset(reset_ids)
detected_dps = await interface.detect_available_dps()
except Exception as ex:
_LOGGER.debug("No DPS able to be detected: %s", ex)
detected_dps = {}
# if manual DPs are set, merge these.
_LOGGER.debug("Detected DPS: %s", detected_dps)
if CONF_MANUAL_DPS in data:
manual_dps_list = [dps.strip() for dps in data[CONF_MANUAL_DPS].split(",")]
_LOGGER.debug(
"Manual DPS Setting: %s (%s)", data[CONF_MANUAL_DPS], manual_dps_list
)
# merge the lists
for new_dps in manual_dps_list + (reset_ids or []):
# If the DPS not in the detected dps list, then add with a
# default value indicating that it has been manually added
if str(new_dps) not in detected_dps:
detected_dps[new_dps] = -1
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
_LOGGER.debug("Total DPS: %s", detected_dps)
return dps_string_list(detected_dps)
async def attempt_cloud_connection(hass, user_input):
"""Create device."""
cloud_api = TuyaCloudApi(
hass,
user_input.get(CONF_REGION),
user_input.get(CONF_CLIENT_ID),
user_input.get(CONF_CLIENT_SECRET),
user_input.get(CONF_USER_ID),
)
res = await cloud_api.async_get_access_token()
if res != "ok":
_LOGGER.error("Cloud API connection failed: %s", res)
return cloud_api, {"reason": "authentication_failed", "msg": res}
res = await cloud_api.async_get_devices_list()
if res != "ok":
_LOGGER.error("Cloud API get_devices_list failed: %s", res)
return cloud_api, {"reason": "device_list_failed", "msg": res}
_LOGGER.info("Cloud API connection succeeded.")
return cloud_api, {}
class LocaltuyaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for LocalTuya integration."""
VERSION = ENTRIES_VERSION
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."""
async def async_step_user(self, user_input=None):
"""Handle the initial step."""
errors = {}
placeholders = {}
if user_input is not None:
if user_input.get(CONF_NO_CLOUD):
for i in [CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_USER_ID]:
user_input[i] = ""
return await self._create_entry(user_input)
cloud_api, res = await attempt_cloud_connection(self.hass, user_input)
if not res:
return await self._create_entry(user_input)
errors["base"] = res["reason"]
placeholders = {"msg": res["msg"]}
defaults = {}
defaults.update(user_input or {})
return self.async_show_form(
step_id="user",
data_schema=schema_defaults(CLOUD_SETUP_SCHEMA, **defaults),
errors=errors,
description_placeholders=placeholders,
)
async def _create_entry(self, user_input):
"""Register new entry."""
# if self._async_current_entries():
# return self.async_abort(reason="already_configured")
await self.async_set_unique_id(user_input.get(CONF_USER_ID))
user_input[CONF_DEVICES] = {}
return self.async_create_entry(
title=user_input.get(CONF_USERNAME),
data=user_input,
)
async def async_step_import(self, user_input):
"""Handle import from YAML."""
_LOGGER.error(
"Configuration via YAML file is no longer supported by this integration."
)
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.selected_device = None
self.editing_device = False
self.device_data = None
self.dps_strings = []
self.selected_platform = None
self.discovered_devices = {}
self.entities = []
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:
if user_input.get(CONF_ACTION) == CONF_SETUP_CLOUD:
return await self.async_step_cloud_setup()
if user_input.get(CONF_ACTION) == CONF_ADD_DEVICE:
return await self.async_step_add_device()
if user_input.get(CONF_ACTION) == CONF_EDIT_DEVICE:
return await self.async_step_edit_device()
return self.async_show_form(
step_id="init",
data_schema=CONFIGURE_SCHEMA,
)
async def async_step_cloud_setup(self, user_input=None):
"""Handle the initial step."""
errors = {}
placeholders = {}
if user_input is not None:
if user_input.get(CONF_NO_CLOUD):
new_data = self.config_entry.data.copy()
new_data.update(user_input)
for i in [CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_USER_ID]:
new_data[i] = ""
self.hass.config_entries.async_update_entry(
self.config_entry,
data=new_data,
)
return self.async_create_entry(
title=new_data.get(CONF_USERNAME), data={}
)
cloud_api, res = await attempt_cloud_connection(self.hass, user_input)
if not res:
new_data = self.config_entry.data.copy()
new_data.update(user_input)
cloud_devs = cloud_api.device_list
for dev_id, dev in new_data[CONF_DEVICES].items():
if CONF_MODEL not in dev and dev_id in cloud_devs:
model = cloud_devs[dev_id].get(CONF_PRODUCT_NAME)
new_data[CONF_DEVICES][dev_id][CONF_MODEL] = model
new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000))
self.hass.config_entries.async_update_entry(
self.config_entry,
data=new_data,
)
return self.async_create_entry(
title=new_data.get(CONF_USERNAME), data={}
)
errors["base"] = res["reason"]
placeholders = {"msg": res["msg"]}
defaults = self.config_entry.data.copy()
defaults.update(user_input or {})
defaults[CONF_NO_CLOUD] = False
return self.async_show_form(
step_id="cloud_setup",
data_schema=schema_defaults(CLOUD_SETUP_SCHEMA, **defaults),
errors=errors,
description_placeholders=placeholders,
)
async def async_step_add_device(self, user_input=None):
"""Handle adding a new device."""
# Use cache if available or fallback to manual discovery
self.editing_device = False
self.selected_device = None
errors = {}
if user_input is not None:
if user_input[SELECTED_DEVICE] != CUSTOM_DEVICE:
self.selected_device = user_input[SELECTED_DEVICE]
return await self.async_step_configure_device()
self.discovered_devices = {}
data = self.hass.data.get(DOMAIN)
if data and DATA_DISCOVERY in data:
self.discovered_devices = data[DATA_DISCOVERY].devices
else:
try:
self.discovered_devices = await discover()
except OSError as ex:
if ex.errno == errno.EADDRINUSE:
errors["base"] = "address_in_use"
else:
errors["base"] = "discovery_failed"
except Exception as ex:
_LOGGER.exception("discovery failed: %s", ex)
errors["base"] = "discovery_failed"
devices = {
dev_id: dev["ip"]
for dev_id, dev in self.discovered_devices.items()
if dev["gwId"] not in self.config_entry.data[CONF_DEVICES]
}
return self.async_show_form(
step_id="add_device",
data_schema=devices_schema(
devices, self.hass.data[DOMAIN][DATA_CLOUD].device_list
),
errors=errors,
)
async def async_step_edit_device(self, user_input=None):
"""Handle editing a device."""
self.editing_device = True
# Use cache if available or fallback to manual discovery
errors = {}
if user_input is not None:
self.selected_device = user_input[SELECTED_DEVICE]
dev_conf = self.config_entry.data[CONF_DEVICES][self.selected_device]
self.dps_strings = dev_conf.get(CONF_DPS_STRINGS, gen_dps_strings())
self.entities = dev_conf[CONF_ENTITIES]
return await self.async_step_configure_device()
devices = {}
for dev_id, configured_dev in self.config_entry.data[CONF_DEVICES].items():
devices[dev_id] = configured_dev[CONF_HOST]
return self.async_show_form(
step_id="edit_device",
data_schema=devices_schema(
devices, self.hass.data[DOMAIN][DATA_CLOUD].device_list, False
),
errors=errors,
)
async def async_step_configure_device(self, user_input=None):
"""Handle input of basic info."""
errors = {}
dev_id = self.selected_device
if user_input is not None:
try:
self.device_data = user_input.copy()
if dev_id is not None:
# self.device_data[CONF_PRODUCT_KEY] = self.devices[
# self.selected_device
# ]["productKey"]
cloud_devs = self.hass.data[DOMAIN][DATA_CLOUD].device_list
if dev_id in cloud_devs:
self.device_data[CONF_MODEL] = cloud_devs[dev_id].get(
CONF_PRODUCT_NAME
)
if self.editing_device:
if user_input[CONF_ENABLE_ADD_ENTITIES]:
self.editing_device = False
user_input[CONF_DEVICE_ID] = dev_id
self.device_data.update(
{
CONF_DEVICE_ID: dev_id,
CONF_DPS_STRINGS: self.dps_strings,
}
)
return await self.async_step_pick_entity_type()
self.device_data.update(
{
CONF_DEVICE_ID: dev_id,
CONF_DPS_STRINGS: self.dps_strings,
CONF_ENTITIES: [],
}
)
if len(user_input[CONF_ENTITIES]) == 0:
return self.async_abort(
reason="no_entities",
description_placeholders={},
)
if user_input[CONF_ENTITIES]:
entity_ids = [
int(entity.split(":")[0])
for entity in user_input[CONF_ENTITIES]
]
device_config = self.config_entry.data[CONF_DEVICES][dev_id]
self.entities = [
entity
for entity in device_config[CONF_ENTITIES]
if entity[CONF_ID] in entity_ids
]
return await self.async_step_configure_entity()
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 as ex:
_LOGGER.exception("Unexpected exception: %s", ex)
errors["base"] = "unknown"
defaults = {}
if self.editing_device:
# If selected device exists as a config entry, load config from it
defaults = self.config_entry.data[CONF_DEVICES][dev_id].copy()
cloud_devs = self.hass.data[DOMAIN][DATA_CLOUD].device_list
placeholders = {"for_device": f" for device `{dev_id}`"}
if dev_id in cloud_devs:
cloud_local_key = cloud_devs[dev_id].get(CONF_LOCAL_KEY)
if defaults[CONF_LOCAL_KEY] != cloud_local_key:
_LOGGER.info(
"New local_key detected: new %s vs old %s",
cloud_local_key,
defaults[CONF_LOCAL_KEY],
)
defaults[CONF_LOCAL_KEY] = cloud_devs[dev_id].get(CONF_LOCAL_KEY)
note = "\nNOTE: a new local_key has been retrieved using cloud API"
placeholders = {"for_device": f" for device `{dev_id}`.{note}"}
defaults[CONF_ENABLE_ADD_ENTITIES] = False
schema = schema_defaults(options_schema(self.entities), **defaults)
else:
defaults[CONF_PROTOCOL_VERSION] = "3.3"
defaults[CONF_HOST] = ""
defaults[CONF_DEVICE_ID] = ""
defaults[CONF_LOCAL_KEY] = ""
defaults[CONF_FRIENDLY_NAME] = ""
if dev_id is not None:
# Insert default values from discovery and cloud if present
device = self.discovered_devices[dev_id]
defaults[CONF_HOST] = device.get("ip")
defaults[CONF_DEVICE_ID] = device.get("gwId")
defaults[CONF_PROTOCOL_VERSION] = device.get("version")
cloud_devs = self.hass.data[DOMAIN][DATA_CLOUD].device_list
if dev_id in cloud_devs:
defaults[CONF_LOCAL_KEY] = cloud_devs[dev_id].get(CONF_LOCAL_KEY)
defaults[CONF_FRIENDLY_NAME] = cloud_devs[dev_id].get(CONF_NAME)
schema = schema_defaults(DEVICE_SCHEMA, **defaults)
placeholders = {"for_device": ""}
return self.async_show_form(
step_id="configure_device",
data_schema=schema,
errors=errors,
description_placeholders=placeholders,
)
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_ENTITIES):
config = {
**self.device_data,
CONF_DPS_STRINGS: self.dps_strings,
CONF_ENTITIES: self.entities,
}
dev_id = self.device_data.get(CONF_DEVICE_ID)
new_data = self.config_entry.data.copy()
new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000))
new_data[CONF_DEVICES].update({dev_id: config})
self.hass.config_entries.async_update_entry(
self.config_entry,
data=new_data,
)
return self.async_create_entry(title="", data={})
self.selected_platform = user_input[PLATFORM_TO_ADD]
return await self.async_step_configure_entity()
# Add a checkbox that allows bailing out from config flow if at least one
# entity has been added
schema = PICK_ENTITY_SCHEMA
if self.selected_platform is not None:
schema = schema.extend(
{vol.Required(NO_ADDITIONAL_ENTITIES, default=True): bool}
)
return self.async_show_form(step_id="pick_entity_type", data_schema=schema)
def available_dps_strings(self):
"""Return list of DPs use by the device's entities."""
available_dps = []
used_dps = [str(entity[CONF_ID]) for entity in self.entities]
for dp_string in self.dps_strings:
dp = dp_string.split(" ")[0]
if dp not in used_dps:
available_dps.append(dp_string)
return available_dps
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.device_data[CONF_ENTITIES].append(entity)
if len(self.entities) == len(self.device_data[CONF_ENTITIES]):
self.hass.config_entries.async_update_entry(
self.config_entry,
title=self.device_data[CONF_FRIENDLY_NAME],
data=self.device_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_configure_entity(self, user_input=None):
"""Manage entity settings."""
errors = {}
if user_input is not None:
if self.editing_device:
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.device_data[CONF_ENTITIES].append(entity)
if len(self.entities) == len(self.device_data[CONF_ENTITIES]):
# finished editing device. Let's store the new config entry....
dev_id = self.device_data[CONF_DEVICE_ID]
new_data = self.config_entry.data.copy()
entry_id = self.config_entry.entry_id
# removing entities from registry (they will be recreated)
ent_reg = er.async_get(self.hass)
reg_entities = {
ent.unique_id: ent.entity_id
for ent in er.async_entries_for_config_entry(ent_reg, entry_id)
if dev_id in ent.unique_id
}
for entity_id in reg_entities.values():
ent_reg.async_remove(entity_id)
new_data[CONF_DEVICES][dev_id] = self.device_data
new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000))
self.hass.config_entries.async_update_entry(
self.config_entry,
data=new_data,
)
return self.async_create_entry(title="", data={})
else:
user_input[CONF_PLATFORM] = self.selected_platform
self.entities.append(strip_dps_values(user_input, self.dps_strings))
# new entity added. Let's check if there are more left...
user_input = None
if len(self.available_dps_strings()) == 0:
user_input = {NO_ADDITIONAL_ENTITIES: True}
return await self.async_step_pick_entity_type(user_input)
if self.editing_device:
schema = platform_schema(
self.current_entity[CONF_PLATFORM], self.dps_strings, allow_id=False
)
schema = schema_defaults(schema, self.dps_strings, **self.current_entity)
placeholders = {
"entity": f"entity with DP {self.current_entity[CONF_ID]}",
"platform": self.current_entity[CONF_PLATFORM],
}
else:
available_dps = self.available_dps_strings()
schema = platform_schema(self.selected_platform, available_dps)
placeholders = {
"entity": "an entity",
"platform": self.selected_platform,
}
return self.async_show_form(
step_id="configure_entity",
data_schema=schema,
errors=errors,
description_placeholders=placeholders,
)
async def async_step_yaml_import(self, user_input=None):
"""Manage YAML imports."""
_LOGGER.error(
"Configuration via YAML file is no longer supported by this integration."
)
# 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.device_data[CONF_ENTITIES])]
class CannotConnect(exceptions.HomeAssistantError):
"""Error to indicate we cannot connect."""
class InvalidAuth(exceptions.HomeAssistantError):
"""Error to indicate there is invalid auth."""
class EmptyDpsList(exceptions.HomeAssistantError):
"""Error to indicate no datapoints found."""

View File

@@ -0,0 +1,143 @@
"""Constants for localtuya integration."""
DOMAIN = "localtuya"
DATA_DISCOVERY = "discovery"
DATA_CLOUD = "cloud_data"
# Platforms in this list must support config flows
PLATFORMS = [
"binary_sensor",
"climate",
"cover",
"fan",
"light",
"number",
"select",
"sensor",
"switch",
"vacuum",
]
TUYA_DEVICES = "tuya_devices"
ATTR_CURRENT = "current"
ATTR_CURRENT_CONSUMPTION = "current_consumption"
ATTR_VOLTAGE = "voltage"
ATTR_UPDATED_AT = "updated_at"
# config flow
CONF_LOCAL_KEY = "local_key"
CONF_ENABLE_DEBUG = "enable_debug"
CONF_PROTOCOL_VERSION = "protocol_version"
CONF_DPS_STRINGS = "dps_strings"
CONF_MODEL = "model"
CONF_PRODUCT_KEY = "product_key"
CONF_PRODUCT_NAME = "product_name"
CONF_USER_ID = "user_id"
CONF_ENABLE_ADD_ENTITIES = "add_entities"
CONF_ACTION = "action"
CONF_ADD_DEVICE = "add_device"
CONF_EDIT_DEVICE = "edit_device"
CONF_SETUP_CLOUD = "setup_cloud"
CONF_NO_CLOUD = "no_cloud"
CONF_MANUAL_DPS = "manual_dps_strings"
CONF_DEFAULT_VALUE = "dps_default_value"
CONF_RESET_DPIDS = "reset_dpids"
CONF_PASSIVE_ENTITY = "is_passive_entity"
# light
CONF_BRIGHTNESS_LOWER = "brightness_lower"
CONF_BRIGHTNESS_UPPER = "brightness_upper"
CONF_COLOR = "color"
CONF_COLOR_MODE = "color_mode"
CONF_COLOR_MODE_SET = "color_mode_set"
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"
CONF_FAN_DPS_TYPE = "fan_dps_type"
# 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_TEMP_MAX = "max_temperature_const"
CONF_TEMP_MIN = "min_temperature_const"
CONF_PRECISION = "precision"
CONF_TARGET_PRECISION = "target_precision"
CONF_HVAC_MODE_DP = "hvac_mode_dp"
CONF_HVAC_MODE_SET = "hvac_mode_set"
CONF_HVAC_FAN_MODE_DP = "hvac_fan_mode_dp"
CONF_HVAC_FAN_MODE_SET = "hvac_fan_mode_set"
CONF_HVAC_SWING_MODE_DP = "hvac_swing_mode_dp"
CONF_HVAC_SWING_MODE_SET = "hvac_swing_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"
# number
CONF_MIN_VALUE = "min_value"
CONF_MAX_VALUE = "max_value"
CONF_STEPSIZE_VALUE = "step_size"
# select
CONF_OPTIONS = "select_options"
CONF_OPTIONS_FRIENDLY = "select_options_friendly"
# States
ATTR_STATE = "raw_state"
CONF_RESTORE_ON_RECONNECT = "restore_on_reconnect"

View File

@@ -0,0 +1,233 @@
"""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,
CoverEntity, CoverEntityFeature,
)
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
_LOGGER.debug("Initialized cover [%s]", self.name)
@property
def supported_features(self):
"""Flag supported features."""
supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP
if self._config[CONF_POSITIONING_MODE] != COVER_MODE_NONE:
supported_features = supported_features | CoverEntityFeature.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 False
if self._current_cover_position == 0:
return True
if self._current_cover_position == 100:
return False
return False
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()
# Keep record in last_state as long as not during connection/re-connection,
# as last state will be used to restore the previous state
if (self._state is not None) and (not self._device.is_connecting):
self._last_state = self._state
async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaCover, flow_schema)

View File

@@ -0,0 +1,65 @@
"""Diagnostics support for LocalTuya."""
from __future__ import annotations
import copy
import logging
from typing import Any
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_DEVICES
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntry
from .const import CONF_LOCAL_KEY, CONF_USER_ID, DATA_CLOUD, DOMAIN
CLOUD_DEVICES = "cloud_devices"
DEVICE_CONFIG = "device_config"
DEVICE_CLOUD_INFO = "device_cloud_info"
_LOGGER = logging.getLogger(__name__)
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: ConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
data = {}
data = dict(entry.data)
tuya_api = hass.data[DOMAIN][DATA_CLOUD]
# censoring private information on integration diagnostic data
for field in [CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_USER_ID]:
data[field] = f"{data[field][0:3]}...{data[field][-3:]}"
data[CONF_DEVICES] = copy.deepcopy(entry.data[CONF_DEVICES])
for dev_id, dev in data[CONF_DEVICES].items():
local_key = dev[CONF_LOCAL_KEY]
local_key_obfuscated = f"{local_key[0:3]}...{local_key[-3:]}"
dev[CONF_LOCAL_KEY] = local_key_obfuscated
data[CLOUD_DEVICES] = tuya_api.device_list
for dev_id, dev in data[CLOUD_DEVICES].items():
local_key = data[CLOUD_DEVICES][dev_id][CONF_LOCAL_KEY]
local_key_obfuscated = f"{local_key[0:3]}...{local_key[-3:]}"
data[CLOUD_DEVICES][dev_id][CONF_LOCAL_KEY] = local_key_obfuscated
return data
async def async_get_device_diagnostics(
hass: HomeAssistant, entry: ConfigEntry, device: DeviceEntry
) -> dict[str, Any]:
"""Return diagnostics for a device entry."""
data = {}
dev_id = list(device.identifiers)[0][1].split("_")[-1]
data[DEVICE_CONFIG] = entry.data[CONF_DEVICES][dev_id].copy()
# NOT censoring private information on device diagnostic data
# local_key = data[DEVICE_CONFIG][CONF_LOCAL_KEY]
# data[DEVICE_CONFIG][CONF_LOCAL_KEY] = f"{local_key[0:3]}...{local_key[-3:]}"
tuya_api = hass.data[DOMAIN][DATA_CLOUD]
if dev_id in tuya_api.device_list:
data[DEVICE_CLOUD_INFO] = tuya_api.device_list[dev_id]
# NOT censoring private information on device diagnostic data
# local_key = data[DEVICE_CLOUD_INFO][CONF_LOCAL_KEY]
# local_key_obfuscated = "{local_key[0:3]}...{local_key[-3:]}"
# data[DEVICE_CLOUD_INFO][CONF_LOCAL_KEY] = local_key_obfuscated
# data["log"] = hass.data[DOMAIN][CONF_DEVICES][dev_id].logger.retrieve_log()
return data

View File

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

View File

@@ -0,0 +1,259 @@
"""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,
FanEntityFeature,
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_DPS_TYPE,
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,
vol.Optional(CONF_FAN_DPS_TYPE, default="str"): vol.In(["str", "int"]),
}
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
self._dps_type = int if self._config.get(CONF_FAN_DPS_TYPE) == "int" else str
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(
self._dps_type(
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(
self._dps_type(
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) -> FanEntityFeature:
"""Flag supported features."""
features = FanEntityFeature(0)
if self.has_config(CONF_FAN_OSCILLATING_CONTROL):
features |= FanEntityFeature.OSCILLATE
if self.has_config(CONF_FAN_SPEED_CONTROL):
features |= FanEntityFeature.SET_SPEED
if self.has_config(CONF_FAN_DIRECTION):
features |= FanEntityFeature.DIRECTION
features |= FanEntityFeature.TURN_OFF
features |= FanEntityFeature.TURN_ON
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, str(current_speed)
)
else:
_LOGGER.debug(
"Fan current_speed ranged_value_to_percentage: %s from %s",
current_speed,
self._speed_range,
)
if current_speed is not None:
self._percentage = ranged_value_to_percentage(
self._speed_range, int(current_speed)
)
_LOGGER.debug("Fan current_percentage: %s", self._percentage)
if self.has_config(CONF_FAN_OSCILLATING_CONTROL):
self._oscillating = self.dps_conf(CONF_FAN_OSCILLATING_CONTROL)
_LOGGER.debug("Fan current_oscillating : %s", self._oscillating)
if self.has_config(CONF_FAN_DIRECTION):
value = self.dps_conf(CONF_FAN_DIRECTION)
if value is not None:
if value == self._config.get(CONF_FAN_DIRECTION_FWD):
self._direction = DIRECTION_FORWARD
if value == self._config.get(CONF_FAN_DIRECTION_REV):
self._direction = DIRECTION_REVERSE
_LOGGER.debug("Fan current_direction : %s > %s", value, self._direction)
async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaFan, flow_schema)

View File

@@ -0,0 +1,506 @@
"""Platform to locally control Tuya-based light devices."""
import logging
import textwrap
from dataclasses import dataclass
from functools import partial
import homeassistant.util.color as color_util
import voluptuous as vol
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_EFFECT,
ATTR_HS_COLOR,
DOMAIN,
LightEntity,
LightEntityFeature,
ColorMode,
)
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, CONF_COLOR_MODE_SET,
)
_LOGGER = logging.getLogger(__name__)
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_MANUAL = "manual"
MODE_COLOR = "colour"
MODE_MUSIC = "music"
MODE_SCENE = "scene"
MODE_WHITE = "white"
SCENE_CUSTOM = "Custom"
SCENE_MUSIC = "Music"
MODES_SET = {"Colour, Music, Scene and White": 0, "Manual, Music, Scene and White": 1}
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",
}
@dataclass(frozen=True)
class Mode:
color: str = MODE_COLOR
music: str = MODE_MUSIC
scene: str = MODE_SCENE
white: str = MODE_WHITE
def as_list(self) -> list:
return [self.color, self.music, self.scene, self.white]
def as_dict(self) -> dict[str, str]:
default = {"Default": self.white}
return {**default, "Mode Color": self.color, "Mode Scene": self.scene}
MAP_MODE_SET = {0: Mode(), 1: Mode(color=MODE_MANUAL)}
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 = color_util.color_temperature_kelvin_to_mired(
self._config.get(CONF_COLOR_TEMP_MIN_KELVIN, DEFAULT_MIN_KELVIN)
)
self._min_mired = color_util.color_temperature_kelvin_to_mired(
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._modes = MAP_MODE_SET[int(self._config.get(CONF_COLOR_MODE_SET, 0))]
self._hs = None
self._effect = None
self._effect_list = []
self._scenes = {}
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 (
ColorMode.HS in self.supported_color_modes
and not ColorMode.COLOR_TEMP in self.supported_color_modes
):
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."""
if self.is_scene_mode or self.is_music_mode:
return self._effect
elif (color_mode := self.__get_color_mode()) in self._scenes.values():
return self.__find_scene_by_scene_data(color_mode)
return None
@property
def supported_color_modes(self) -> set[ColorMode] | set[str] | None:
"""Flag supported color modes."""
color_modes: set[ColorMode] = set()
if self.has_config(CONF_COLOR_TEMP):
color_modes.add(ColorMode.COLOR_TEMP)
if self.has_config(CONF_COLOR):
color_modes.add(ColorMode.HS)
if not color_modes and self.has_config(CONF_BRIGHTNESS):
return {ColorMode.BRIGHTNESS}
if not color_modes:
return {ColorMode.ONOFF}
return color_modes
@property
def supported_features(self) -> LightEntityFeature:
"""Flag supported features."""
supports = LightEntityFeature(0)
if self.has_config(CONF_SCENE) or self.has_config(CONF_MUSIC_MODE):
supports |= LightEntityFeature.EFFECT
return supports
@property
def color_mode(self) -> ColorMode:
"""Return the color_mode of the light."""
if len(self.supported_color_modes) == 1:
return next(iter(self.supported_color_modes))
if self.is_color_mode:
return ColorMode.HS
if self.is_white_mode:
return ColorMode.COLOR_TEMP
if self._brightness:
return ColorMode.BRIGHTNESS
return ColorMode.ONOFF
@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 == self._modes.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 == self._modes.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(self._modes.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 == self._modes.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 self._modes.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 & LightEntityFeature.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 (
ColorMode.BRIGHTNESS in self.supported_color_modes
or self.has_config(CONF_BRIGHTNESS)
or self.has_config(CONF_COLOR)
):
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 ColorMode.HS in self.supported_color_modes:
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 ColorMode.COLOR_TEMP in kwargs and ColorMode.COLOR_TEMP in self.supported_color_modes:
if brightness is None:
brightness = self._brightness
mired = int(kwargs[ColorMode.COLOR_TEMP])
if self._color_temp_reverse:
mired = self._max_mired - (mired - self._min_mired)
if mired < self._min_mired:
mired = self._min_mired
elif mired > self._max_mired:
mired = self._max_mired
color_temp = int(
self._upper_color_temp
- (self._upper_color_temp / (self._max_mired - self._min_mired))
* (mired - self._min_mired)
)
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 (ColorMode.BRIGHTNESS in self.supported_color_modes
or self.has_config(CONF_BRIGHTNESS)
or self.has_config(CONF_COLOR)
):
self._brightness = self.dps_conf(CONF_BRIGHTNESS)
if ColorMode.HS in self.supported_color_modes:
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 ColorMode.COLOR_TEMP in self.supported_color_modes:
self._color_temp = self.dps_conf(CONF_COLOR_TEMP)
if self.is_scene_mode and supported & LightEntityFeature.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 & LightEntityFeature.EFFECT:
self._effect = SCENE_MUSIC
async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaLight, flow_schema)

View File

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

View File

@@ -0,0 +1,113 @@
"""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
from .const import (
CONF_DEFAULT_VALUE,
CONF_MAX_VALUE,
CONF_MIN_VALUE,
CONF_PASSIVE_ENTITY,
CONF_RESTORE_ON_RECONNECT,
CONF_STEPSIZE_VALUE,
)
_LOGGER = logging.getLogger(__name__)
DEFAULT_MIN = 0
DEFAULT_MAX = 100000
DEFAULT_STEP = 1.0
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),
),
vol.Required(CONF_STEPSIZE_VALUE, default=DEFAULT_STEP): vol.All(
vol.Coerce(float),
vol.Range(min=0.0, max=1000000.0),
),
vol.Required(CONF_RESTORE_ON_RECONNECT): bool,
vol.Required(CONF_PASSIVE_ENTITY): bool,
vol.Optional(CONF_DEFAULT_VALUE): str,
}
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 = DEFAULT_MAX
if CONF_MAX_VALUE in self._config:
self._max_value = self._config.get(CONF_MAX_VALUE)
self._step_size = DEFAULT_STEP
if CONF_STEPSIZE_VALUE in self._config:
self._step_size = self._config.get(CONF_STEPSIZE_VALUE)
# Override standard default value handling to cast to a float
default_value = self._config.get(CONF_DEFAULT_VALUE)
if default_value is not None:
self._default_value = float(default_value)
@property
def native_value(self) -> float:
"""Return sensor state."""
return self._state
@property
def native_min_value(self) -> float:
"""Return the minimum value."""
return self._min_value
@property
def native_max_value(self) -> float:
"""Return the maximum value."""
return self._max_value
@property
def native_step(self) -> float:
"""Return the maximum value."""
return self._step_size
@property
def device_class(self):
"""Return the class of this device."""
return self._config.get(CONF_DEVICE_CLASS)
async def async_set_native_value(self, value: float) -> None:
"""Update the current value."""
await self._device.set_dp(value, self._dp_id)
# Default value is the minimum value
def entity_default_value(self):
"""Return the minimum value as the default for this entity type."""
return self._min_value
async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaNumber, flow_schema)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,123 @@
"""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
from .const import (
CONF_DEFAULT_VALUE,
CONF_OPTIONS,
CONF_OPTIONS_FRIENDLY,
CONF_PASSIVE_ENTITY,
CONF_RESTORE_ON_RECONNECT,
)
def flow_schema(dps):
"""Return schema used in config flow."""
return {
vol.Required(CONF_OPTIONS): str,
vol.Optional(CONF_OPTIONS_FRIENDLY): str,
vol.Required(CONF_RESTORE_ON_RECONNECT): bool,
vol.Required(CONF_PASSIVE_ENTITY): bool,
vol.Optional(CONF_DEFAULT_VALUE): str,
}
_LOGGER = logging.getLogger(__name__)
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."""
super().status_updated()
state = self.dps(self._dp_id)
# Check that received status update for this entity.
if state is not None:
try:
self._state_friendly = self._display_options[
self._valid_options.index(state)
]
except Exception: # pylint: disable=broad-except
# Friendly value couldn't be mapped
self._state_friendly = state
# Default value is the first option
def entity_default_value(self):
"""Return the first option as the default value for this entity type."""
return self._valid_options[0]
async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaSelect, flow_schema)

View File

@@ -0,0 +1,75 @@
"""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
# No need to restore state for a sensor
async def restore_state_when_connected(self):
"""Do nothing for a sensor."""
return
async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaSensor, flow_schema)

View File

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

View File

@@ -0,0 +1,139 @@
{
"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": "Main Configuration",
"description": "Input the credentials for Tuya Cloud API.",
"data": {
"region": "API server region",
"client_id": "Client ID",
"client_secret": "Secret",
"user_id": "User ID"
}
},
"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"
}
}
}
},
"options": {
"step": {
"init": {
"title": "LocalTuya Configuration",
"description": "Please select the desired actionSSSS.",
"data": {
"add_device": "Add a new device",
"edit_device": "Edit a device",
"delete_device": "Delete a device",
"setup_cloud": "Reconfigure Cloud API account"
}
},
"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",
"fan_dps_type": "DP value type",
"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)",
"dps_default_value": "Default value when un-initialised (optional)",
"restore_on_reconnect": "Restore the last set value in HomeAssistant after a lost connection",
"min_value": "Minimum Value",
"max_value": "Maximum Value",
"step_size": "Minimum increment between numbers"
}
},
"yaml_import": {
"title": "Not Supported",
"description": "Options cannot be edited when configured via YAML."
}
}
},
"title": "LocalTuya"
}

View File

@@ -0,0 +1,91 @@
"""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_STATE,
ATTR_VOLTAGE,
CONF_CURRENT,
CONF_CURRENT_CONSUMPTION,
CONF_DEFAULT_VALUE,
CONF_PASSIVE_ENTITY,
CONF_RESTORE_ON_RECONNECT,
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),
vol.Required(CONF_RESTORE_ON_RECONNECT): bool,
vol.Required(CONF_PASSIVE_ENTITY): bool,
vol.Optional(CONF_DEFAULT_VALUE): str,
}
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
_LOGGER.debug("Initialized switch [%s]", 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
# Store the state
if self._state is not None:
attrs[ATTR_STATE] = self._state
elif self._last_state is not None:
attrs[ATTR_STATE] = self._last_state
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)
# Default value is the "OFF" state
def entity_default_value(self):
"""Return False as the default value for this entity type."""
return False
async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaSwitch, flow_schema)

View File

@@ -0,0 +1,238 @@
{
"config": {
"abort": {
"already_configured": "Device has already been configured.",
"device_updated": "Device configuration has been updated!"
},
"error": {
"authentication_failed": "Failed to authenticate.\n{msg}",
"cannot_connect": "Cannot connect to device. Verify that address is correct and try again.",
"device_list_failed": "Failed to retrieve device list.\n{msg}",
"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": "Cloud API account configuration",
"description": "Input the credentials for Tuya Cloud API.",
"data": {
"region": "API server region",
"client_id": "Client ID",
"client_secret": "Secret",
"user_id": "User ID",
"user_name": "Username",
"no_cloud": "Do not configure a Cloud API account"
}
}
}
},
"options": {
"abort": {
"already_configured": "Device has already been configured.",
"device_success": "Device {dev_name} successfully {action}.",
"no_entities": "Cannot remove all entities from a device.\nIf you want to delete a device, enter it in the Devices menu, click the 3 dots in the 'Device info' frame, and press the Delete button."
},
"error": {
"authentication_failed": "Failed to authenticate.\n{msg}",
"cannot_connect": "Cannot connect to device. Verify that address is correct and try again.",
"device_list_failed": "Failed to retrieve device list.\n{msg}",
"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": {
"yaml_import": {
"title": "Not Supported",
"description": "Options cannot be edited when configured via YAML."
},
"init": {
"title": "LocalTuya Configuration",
"description": "Please select the desired action.",
"data": {
"add_device": "Add a new device",
"edit_device": "Edit a device",
"setup_cloud": "Reconfigure Cloud API account"
}
},
"add_device": {
"title": "Add a new device",
"description": "Pick one of the automatically discovered devices or `...` to manually to add a device.",
"data": {
"selected_device": "Discovered Devices"
}
},
"edit_device": {
"title": "Edit a new device",
"description": "Pick the configured device you wish to edit.",
"data": {
"selected_device": "Configured Devices",
"max_temperature_const": "Max Temperature Constant (optional)",
"min_temperature_const": "Min Temperature Constant (optional)",
"hvac_fan_mode_dp": "HVAC Fan Mode DP (optional)",
"hvac_fan_mode_set": "HVAC Fan Mode Set (optional)",
"hvac_swing_mode_dp": "HVAC Swing Mode DP (optional)",
"hvac_swing_mode_set": "HVAC Swing Mode Set (optional)"
}
},
"cloud_setup": {
"title": "Cloud API account configuration",
"description": "Input the credentials for Tuya Cloud API.",
"data": {
"region": "API server region",
"client_id": "Client ID",
"client_secret": "Secret",
"user_id": "User ID",
"user_name": "Username",
"no_cloud": "Do not configure Cloud API account"
}
},
"configure_device": {
"title": "Configure Tuya device",
"description": "Fill in the device details{for_device}.",
"data": {
"friendly_name": "Name",
"host": "Host",
"device_id": "Device ID",
"local_key": "Local key",
"protocol_version": "Protocol Version",
"enable_debug": "Enable debugging for this device (debug must be enabled also in configuration.yaml)",
"scan_interval": "Scan interval (seconds, only when not updating automatically)",
"entities": "Entities (uncheck an entity to remove it)",
"add_entities": "Add more entities in 'edit device' mode",
"manual_dps_strings": "Manual DPS to add (separated by commas ',') - used when detection is not working (optional)",
"reset_dpids": "DPIDs to send in RESET command (separated by commas ',')- Used when device does not respond to status requests after turning on (optional)"
}
},
"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_entities": "Do not add any more entities"
}
},
"configure_entity": {
"title": "Configure entity",
"description": "Please fill out the details for {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",
"select_options": "Valid entries, separate entries by a ;",
"select_options_friendly": "User Friendly options, separate entries by a ;",
"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",
"fan_dps_type": "DP value type",
"current_temperature_dp": "Current Temperature",
"target_temperature_dp": "Target Temperature",
"temperature_step": "Temperature Step (optional)",
"max_temperature_dp": "Max Temperature DP (optional)",
"min_temperature_dp": "Min Temperature DP (optional)",
"max_temperature_const": "Max Temperature Constant (optional)",
"min_temperature_const": "Min Temperature Constant (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_fan_mode_dp": "HVAC Fan Mode DP (optional)",
"hvac_fan_mode_set": "HVAC Fan Mode Set (optional)",
"hvac_swing_mode_dp": "HVAC Swing Mode DP (optional)",
"hvac_swing_mode_set": "HVAC Swing 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)",
"dps_default_value": "Default value when un-initialised (optional)",
"restore_on_reconnect": "Restore the last set value in HomeAssistant after a lost connection",
"min_value": "Minimum Value",
"max_value": "Maximum Value",
"step_size": "Minimum increment between numbers",
"is_passive_entity": "Passive entity - requires integration to send initialisation value"
}
}
}
},
"services": {
"reload": {
"name": "Reload",
"description": "Reload localtuya and reconnect to all devices."
},
"set_dp": {
"name": "Set datapoint",
"description": "Change the value of a datapoint (DP)",
"fields": {
"device_id": {
"name": "Device ID",
"description": "Device ID of device to change datapoint value for"
},
"dp": {
"name": "DP",
"description": "Datapoint index"
},
"value": {
"name": "Value",
"description": "New value to set"
}
}
}
},
"title": "LocalTuya"
}

View File

@@ -0,0 +1,216 @@
{
"config": {
"abort": {
"already_configured": "Il dispositivo è già stato configurato.",
"device_updated": "La configurazione del dispositivo è stata aggiornata."
},
"error": {
"authentication_failed": "Autenticazione fallita. Errore:\n{msg}",
"cannot_connect": "Impossibile connettersi al dispositivo. Verifica che l'indirizzo sia corretto e riprova.",
"device_list_failed": "Impossibile recuperare l'elenco dei dispositivi.\n{msg}",
"invalid_auth": "Impossibile autenticarsi con il dispositivo. Verificare che device_id e local_key siano corretti.",
"unknown": "Si è verificato un errore sconosciuto. Vedere registro per i dettagli.",
"entity_already_configured": "L'entity con questo ID è già stata configurata.",
"address_in_use": "L'indirizzo utilizzato per il discovery è già in uso. Assicurarsi che nessun'altra applicazione lo stia utilizzando (porta TCP 6668).",
"discovery_failed": "Qualcosa è fallito nella discovery dei dispositivi. Vedi registro per i dettagli.",
"empty_dps": "La connessione al dispositivo è riuscita ma non sono stati trovati i datapoint, riprova. Crea un nuovo Issue e includi i log di debug se il problema persiste."
},
"step": {
"user": {
"title": "Configurazione dell'account Cloud API",
"description": "Inserisci le credenziali per l'account Cloud API Tuya.",
"data": {
"region": "Regione del server API",
"client_id": "Client ID",
"client_secret": "Secret",
"user_id": "User ID",
"user_name": "Username",
"no_cloud": "Non configurare un account Cloud API"
}
}
}
},
"options": {
"abort": {
"already_configured": "Il dispositivo è già stato configurato.",
"device_success": "Dispositivo {dev_name} {action} con successo.",
"no_entities": "Non si possono rimuovere tutte le entities da un device.\nPer rimuovere un device, entrarci nel menu Devices, premere sui 3 punti nel riquadro 'Device info', e premere il pulsante Delete."
},
"error": {
"authentication_failed": "Autenticazione fallita. Errore:\n{msg}",
"cannot_connect": "Impossibile connettersi al dispositivo. Verifica che l'indirizzo sia corretto e riprova.",
"device_list_failed": "Impossibile recuperare l'elenco dei dispositivi.\n{msg}",
"invalid_auth": "Impossibile autenticarsi con il dispositivo. Verificare che device_id e local_key siano corretti.",
"unknown": "Si è verificato un errore sconosciuto. Vedere registro per i dettagli.",
"entity_already_configured": "L'entity con questo ID è già stata configurata.",
"address_in_use": "L'indirizzo utilizzato per il discovery è già in uso. Assicurarsi che nessun'altra applicazione lo stia utilizzando (porta TCP 6668).",
"discovery_failed": "Qualcosa è fallito nella discovery dei dispositivi. Vedi registro per i dettagli.",
"empty_dps": "La connessione al dispositivo è riuscita ma non sono stati trovati i datapoint, riprova. Crea un nuovo Issue e includi i log di debug se il problema persiste."
},
"step": {
"yaml_import": {
"title": "Non supportato",
"description": "Le impostazioni non possono essere configurate tramite file YAML."
},
"init": {
"title": "Configurazione LocalTuya",
"description": "Seleziona l'azione desiderata.",
"data": {
"add_device": "Aggiungi un nuovo dispositivo",
"edit_device": "Modifica un dispositivo",
"setup_cloud": "Riconfigurare l'account Cloud API"
}
},
"add_device": {
"title": "Aggiungi un nuovo dispositivo",
"description": "Scegli uno dei dispositivi trovati automaticamente o `...` per aggiungere manualmente un dispositivo.",
"data": {
"selected_device": "Dispositivi trovati"
}
},
"edit_device": {
"title": "Modifica un dispositivo",
"description": "Scegli il dispositivo configurato che si desidera modificare.",
"data": {
"selected_device": "Dispositivi configurati"
}
},
"cloud_setup": {
"title": "Configurazione dell'account Cloud API",
"description": "Inserisci le credenziali per l'account Cloud API Tuya.",
"data": {
"region": "Regione del server API",
"client_id": "Client ID",
"client_secret": "Secret",
"user_id": "User ID",
"user_name": "Username",
"no_cloud": "Non configurare l'account Cloud API"
}
},
"configure_device": {
"title": "Configura il dispositivo",
"description": "Compila i dettagli del dispositivo {for_device}.",
"data": {
"friendly_name": "Nome",
"host": "Host",
"device_id": "ID del dispositivo",
"local_key": "Chiave locale",
"protocol_version": "Versione del protocollo",
"enable_debug": "Abilita il debugging per questo device (il debug va abilitato anche in configuration.yaml)",
"scan_interval": "Intervallo di scansione (secondi, solo quando non si aggiorna automaticamente)",
"entities": "Entities (deseleziona un'entity per rimuoverla)"
}
},
"pick_entity_type": {
"title": "Selezione del tipo di entity",
"description": "Scegli il tipo di entity che desideri aggiungere.",
"data": {
"platform_to_add": "piattaforma",
"no_additional_entities": "Non aggiungere altre entity"
}
},
"configure_entity": {
"title": "Configurare entity",
"description": "Compila i dettagli per {entity} con tipo `{platform}`.Tutte le impostazioni ad eccezione di `id` possono essere modificate dalla pagina delle opzioni in seguito.",
"data": {
"id": "ID",
"friendly_name": "Nome amichevole",
"current": "Corrente",
"current_consumption": "Potenza",
"voltage": "Tensione",
"commands_set": "Set di comandi Aperto_Chiuso_Stop",
"positioning_mode": "Modalità di posizionamento",
"current_position_dp": "Posizione attuale (solo per la modalità *posizione*)",
"set_position_dp": "Imposta posizione (solo per modalità *posizione*)",
"position_inverted": "Inverti posizione 0-100 (solo per modalità *posizione*)",
"span_time": "Tempo di apertura totale, in sec. (solo per modalità *a tempo*)",
"unit_of_measurement": "Unità di misura",
"device_class": "Classe del dispositivo",
"scaling": "Fattore di scala",
"state_on": "Valore di ON",
"state_off": "Valore di OFF",
"powergo_dp": "Potenza DP (di solito 25 o 2)",
"idle_status_value": "Stato di inattività (separato da virgole)",
"returning_status_value": "Stato di ritorno alla base",
"docked_status_value": "Stato di tornato alla base (separato da virgole)",
"fault_dp": "DP di guasto (di solito 11)",
"battery_dp": "DP di stato batteria (di solito 14)",
"mode_dp": "DP di modalità (di solito 27)",
"modes": "Elenco delle modalità",
"return_mode": "Ritorno in modalità home",
"fan_speed_dp": "DP di velocità del ventilatore (di solito 30)",
"fan_speeds": "DP di elenco delle velocità del ventilatore (separato da virgola)",
"clean_time_dp": "DP di tempo di pulizia (di solito 33)",
"clean_area_dp": "DP di area pulita (di solito 32)",
"clean_record_dp": "DP di record delle pulizie (di solito 34)",
"locate_dp": "DP di individuazione (di solito 31)",
"paused_state": "Stato di pausa (pausa, pausa, ecc.)",
"stop_status": "Stato di stop",
"brightness": "Luminosità (solo per il colore bianco)",
"brightness_lower": "Limite inferiore per la luminosità",
"brightness_upper": "Limite superiore per la luminosità",
"color_temp": "Temperatura di colore",
"color_temp_reverse": "Temperatura di colore invertita",
"color": "Colore",
"color_mode": "Modalità colore",
"color_temp_min_kelvin": "Minima temperatura di colore in K",
"color_temp_max_kelvin": "Massima temperatura di colore in k",
"music_mode": "Modalità musicale disponibile",
"scene": "Scena",
"select_options": "Opzioni valide, voci separate da una vigola (;)",
"select_options_friendly": "Opzioni intuitive, voci separate da una virgola",
"fan_speed_control": "DP di controllo di velocità del ventilatore",
"fan_oscillating_control": "DP di controllo dell'oscillazione del ventilatore",
"fan_speed_min": "Velocità del ventilatore minima",
"fan_speed_max": "Velocità del ventilatore massima",
"fan_speed_ordered_list": "Elenco delle modalità di velocità del ventilatore (sovrascrive velocità min/max)",
"fan_direction":"DP di direzione del ventilatore",
"fan_direction_forward": "Stringa del DP per avanti",
"fan_direction_reverse": "Stringa del DP per indietro",
"current_temperature_dp": "Temperatura attuale",
"target_temperature_dp": "Temperatura target",
"temperature_step": "Intervalli di temperatura (facoltativo)",
"max_temperature_dp": "Temperatura massima (opzionale)",
"min_temperature_dp": "Temperatura minima (opzionale)",
"precision": "Precisione (opzionale, per valori DP)",
"target_precision": "Precisione del target (opzionale, per valori DP)",
"temperature_unit": "Unità di temperatura (opzionale)",
"hvac_mode_dp": "Modalità HVAC attuale (opzionale)",
"hvac_mode_set": "Impostazione modalità HVAC (opzionale)",
"hvac_action_dp": "Azione HVAC attuale (opzionale)",
"hvac_action_set": "Impostazione azione HVAC (opzionale)",
"preset_dp": "Preset DP (opzionale)",
"preset_set": "Set di preset (opzionale)",
"eco_dp": "DP per Eco (opzionale)",
"eco_value": "Valore Eco (opzionale)",
"heuristic_action": "Abilita azione euristica (opzionale)"
}
}
}
},
"services": {
"reload": {
"name": "Reload",
"description": "Reload localtuya and reconnect to all devices."
},
"set_dp": {
"name": "Set datapoint",
"description": "Change the value of a datapoint (DP)",
"fields": {
"device_id": {
"name": "Device ID",
"description": "Device ID of device to change datapoint value for"
},
"dp": {
"name": "DP",
"description": "Datapoint index"
},
"value": {
"name": "Value",
"description": "New value to set"
}
}
}
},
"title": "LocalTuya"
}

View File

@@ -0,0 +1,216 @@
{
"config": {
"abort": {
"already_configured": "O dispositivo já foi configurado.",
"device_updated": "A configuração do dispositivo foi atualizada!"
},
"error": {
"authentication_failed": "Falha ao autenticar.\n{msg}",
"cannot_connect": "Não é possível se conectar ao dispositivo. Verifique se o endereço está correto e tente novamente",
"device_list_failed": "Falha ao recuperar a lista de dispositivos.\n{msg}",
"invalid_auth": "Falha ao autenticar com o dispositivo. Verifique se o ID do dispositivo e a chave local estão corretos.",
"unknown": "Ocorreu um erro desconhecido. Consulte o registro para obter detalhes.",
"entity_already_configured": "A entidade com este ID já foi configurada.",
"address_in_use": "AddresO endereço usado para descoberta já está em uso. Certifique-se de que nenhum outro aplicativo o esteja usando (porta TCP 6668).s used for discovery is already in use. Make sure no other application is using it (TCP port 6668).",
"discovery_failed": "Algo falhou ao descobrir dispositivos. Consulte o registro para obter detalhes.",
"empty_dps": "A conexão com o dispositivo foi bem-sucedida, mas nenhum ponto de dados foi encontrado. Tente novamente. Crie um novo issue e inclua os logs de depuração se o problema persistir."
},
"step": {
"user": {
"title": "Configuração da conta da API do Cloud",
"description": "Insira as credenciais para a API Tuya Cloud.",
"data": {
"region": "Região do servidor de API",
"client_id": "ID do cliente",
"client_secret": "Secret",
"user_id": "ID de usuário",
"user_name": "Nome de usuário",
"no_cloud": "Não configure uma conta de API da Cloud"
}
}
}
},
"options": {
"abort": {
"already_configured": "O dispositivo já foi configurado.",
"device_success": "Dispositivo {dev_name} {action} com sucesso.",
"no_entities": "Não é possível remover todas as entidades de um dispositivo.\nSe você deseja excluir um dispositivo, insira-o no menu Dispositivos, clique nos 3 pontos no quadro 'Informações do dispositivo' e pressione o botão Excluir."
},
"error": {
"authentication_failed": "Falha ao autenticar.\n{msg}",
"cannot_connect": "Não é possível se conectar ao dispositivo. Verifique se o endereço está correto e tente novamente",
"device_list_failed": "Falha ao recuperar a lista de dispositivos.\n{msg}",
"invalid_auth": "Falha ao autenticar com o dispositivo. Verifique se o ID do dispositivo e a chave local estão corretos.",
"unknown": "Ocorreu um erro desconhecido. Consulte o registro para obter detalhes.",
"entity_already_configured": "A entidade com este ID já foi configurada.",
"address_in_use": "O endereço usado para descoberta já está em uso. Certifique-se de que nenhum outro aplicativo o esteja usando (porta TCP 6668).",
"discovery_failed": "Algo falhou ao descobrir dispositivos. Consulte o registro para obter detalhes.",
"empty_dps": "A conexão com o dispositivo foi bem-sucedida, mas nenhum ponto de dados foi encontrado. Tente novamente. Crie um novo issue e inclua os logs de depuração se o problema persistir."
},
"step": {
"yaml_import": {
"title": "Não suportado",
"description": "As opções não podem ser editadas quando configuradas via YAML."
},
"init": {
"title": "Configuração LocalTuya",
"description": "Selecione a ação desejada.",
"data": {
"add_device": "Adicionar um novo dispositivo",
"edit_device": "Editar um dispositivo",
"setup_cloud": "Reconfigurar a conta da API da Cloud"
}
},
"add_device": {
"title": "Adicionar um novo dispositivo",
"description": "Escolha um dos dispositivos descobertos automaticamente ou `...` para adicionar um dispositivo manualmente.",
"data": {
"selected_device": "Dispositivos descobertos"
}
},
"edit_device": {
"title": "Editar um novo dispositivo",
"description": "Escolha o dispositivo configurado que você deseja editar.",
"data": {
"selected_device": "Dispositivos configurados"
}
},
"cloud_setup": {
"title": "Configuração da conta da API da Cloud",
"description": "Insira as credenciais para a API Tuya Cloud.",
"data": {
"region": "Região do servidor de API",
"client_id": "ID do Cliente",
"client_secret": "Secret",
"user_id": "ID do usuário",
"user_name": "Nome de usuário",
"no_cloud": "Não configure a conta da API da Cloud"
}
},
"configure_device": {
"title": "Configurar dispositivo Tuya",
"description": "Preencha os detalhes do dispositivo {for_device}.",
"data": {
"friendly_name": "Nome",
"host": "Host",
"device_id": "ID do dispositivo",
"local_key": "Local key",
"protocol_version": "Versão do protocolo",
"enable_debug": "Ative a depuração para este dispositivo (a depuração também deve ser ativada em configuration.yaml)",
"scan_interval": "Intervalo de escaneamento (segundos, somente quando não estiver atualizando automaticamente)",
"entities": "Entidades (desmarque uma entidade para removê-la)"
}
},
"pick_entity_type": {
"title": "Seleção do tipo de entidade",
"description": "Escolha o tipo de entidade que deseja adicionar.",
"data": {
"platform_to_add": "Plataforma",
"no_additional_entities": "Não adicione mais entidades"
}
},
"configure_entity": {
"title": "Configurar entidade",
"description": "Por favor, preencha os detalhes de {entity} com o tipo `{platform}`. Todas as configurações, exceto `ID`, podem ser alteradas na página Opções posteriormente.",
"data": {
"id": "ID",
"friendly_name": "Nome fantasia",
"current": "Atual",
"current_consumption": "Consumo atual",
"voltage": "Voltagem",
"commands_set": "Conjunto de comandos Abrir_Fechar_Parar",
"positioning_mode": "Modo de posicionamento",
"current_position_dp": "Posição atual (somente para o modo *posição*)",
"set_position_dp": "Definir posição (somente para o modo *posição*)",
"position_inverted": "Inverter 0-100 posição (somente para o modo *posição*)",
"span_time": "Tempo de abertura completo, em segundos. (somente para o modo *temporizado*)",
"unit_of_measurement": "Unidade de medida",
"device_class": "Classe do dispositivo",
"scaling": "Fator de escala",
"state_on": "Valor ligado",
"state_off": "Valor desligado",
"powergo_dp": "Potência DP (Geralmente 25 ou 2)",
"idle_status_value": "Status ocioso (separado por vírgula)",
"returning_status_value": "Status de retorno",
"docked_status_value": "Status encaixado (separado por vírgula)",
"fault_dp": "Falha DP (Geralmente 11)",
"battery_dp": "Status da bateria DP (normalmente 14)",
"mode_dp": "Modo DP (Geralmente 27)",
"modes": "Lista de modos",
"return_mode": "Modo de retorno para casa",
"fan_speed_dp": "Velocidades do ventilador DP (normalmente 30)",
"fan_speeds": "Lista de velocidades do ventilador (separadas por vírgulas)",
"clean_time_dp": "Tempo Limpo DP (Geralmente 33)",
"clean_area_dp": "Área Limpa DP (Geralmente 32)",
"clean_record_dp": "Limpar Registro DP (Geralmente 34)",
"locate_dp": "Localize DP (Geralmente 31)",
"paused_state": "Estado de pausa (pausa, pausado, etc)",
"stop_status": "Status de parada",
"brightness": "Brilho (somente para cor branca)",
"brightness_lower": "Valor mais baixo de brilho",
"brightness_upper": "Valor superior de brilho",
"color_temp": "Temperatura da cor",
"color_temp_reverse": "Temperatura da cor reversa",
"color": "Cor",
"color_mode": "Modo de cor",
"color_temp_min_kelvin": "Temperatura de cor mínima em K",
"color_temp_max_kelvin": "Temperatura máxima de cor em K",
"music_mode": "Modo de música disponível",
"scene": "Cena",
"select_options": "Entradas válidas, entradas separadas por um ;",
"select_options_friendly": "Opções fantasia ao usuário, entradas separadas por um ;",
"fan_speed_control": "Dps de controle de velocidade do ventilador",
"fan_oscillating_control": "Dps de controle oscilante do ventilador",
"fan_speed_min": "Velocidade mínima do ventilador inteiro",
"fan_speed_max": "Velocidade máxima do ventilador inteiro",
"fan_speed_ordered_list": "Lista de modos de velocidade do ventilador (substitui a velocidade min/max)",
"fan_direction":"Direção do ventilador dps",
"fan_direction_forward": "Seqüência de dps para frente",
"fan_direction_reverse": "String dps reversa",
"current_temperature_dp": "Temperatura atual",
"target_temperature_dp": "Temperatura alvo",
"temperature_step": "Etapa de temperatura (opcional)",
"max_temperature_dp": "Temperatura máxima (opcional)",
"min_temperature_dp": "Temperatura mínima (opcional)",
"precision": "Precisão (opcional, para valores de DPs)",
"target_precision": "Precisão do alvo (opcional, para valores de DPs)",
"temperature_unit": "Unidade de Temperatura (opcional)",
"hvac_mode_dp": "Modo HVAC DP (opcional)",
"hvac_mode_set": "Conjunto de modo HVAC (opcional)",
"hvac_action_dp": "Ação atual de HVAC DP (opcional)",
"hvac_action_set": "Conjunto de ação atual HVAC (opcional)",
"preset_dp": "Predefinições DP (opcional)",
"preset_set": "Conjunto de predefinições (opcional)",
"eco_dp": "Eco DP (opcional)",
"eco_value": "Valor eco (opcional)",
"heuristic_action": "Ativar ação heurística (opcional)"
}
}
}
},
"services": {
"reload": {
"name": "Reload",
"description": "Reload localtuya and reconnect to all devices."
},
"set_dp": {
"name": "Set datapoint",
"description": "Change the value of a datapoint (DP)",
"fields": {
"device_id": {
"name": "Device ID",
"description": "Device ID of device to change datapoint value for"
},
"dp": {
"name": "DP",
"description": "Datapoint index"
},
"value": {
"name": "Value",
"description": "New value to set"
}
}
}
},
"title": "LocalTuya"
}

View File

@@ -0,0 +1,241 @@
"""Platform to locally control Tuya-based vacuum devices."""
import logging
from functools import partial
import voluptuous as vol
from homeassistant.components.vacuum import (
DOMAIN,
StateVacuumEntity, VacuumActivity, VacuumEntityFeature,
)
from .common import LocalTuyaEntity, async_setup_entry
from .const import (
CONF_BATTERY_DP,
CONF_CLEAN_AREA_DP,
CONF_CLEAN_RECORD_DP,
CONF_CLEAN_TIME_DP,
CONF_DOCKED_STATUS_VALUE,
CONF_FAN_SPEED_DP,
CONF_FAN_SPEEDS,
CONF_FAULT_DP,
CONF_IDLE_STATUS_VALUE,
CONF_LOCATE_DP,
CONF_MODE_DP,
CONF_MODES,
CONF_PAUSED_STATE,
CONF_POWERGO_DP,
CONF_RETURN_MODE,
CONF_RETURNING_STATUS_VALUE,
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 = ""
_LOGGER.debug("Initialized vacuum [%s]", self.name)
@property
def supported_features(self):
"""Flag supported features."""
supported_features = (
VacuumEntityFeature.START
| VacuumEntityFeature.PAUSE
| VacuumEntityFeature.STOP
| VacuumEntityFeature.STATUS
| VacuumEntityFeature.STATE
)
if self.has_config(CONF_RETURN_MODE):
supported_features = supported_features | VacuumEntityFeature.RETURN_HOME
if self.has_config(CONF_FAN_SPEED_DP):
supported_features = supported_features | VacuumEntityFeature.FAN_SPEED
if self.has_config(CONF_BATTERY_DP):
supported_features = supported_features | VacuumEntityFeature.BATTERY
if self.has_config(CONF_LOCATE_DP):
supported_features = supported_features | VacuumEntityFeature.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 = VacuumActivity.IDLE
elif state_value in self._docked_status_list:
self._state = VacuumActivity.DOCKED
elif state_value == self._config[CONF_RETURNING_STATUS_VALUE]:
self._state = VacuumActivity.RETURNING
elif state_value == self._config[CONF_PAUSED_STATE]:
self._state = VacuumActivity.PAUSED
else:
self._state = VacuumActivity.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 = VacuumActivity.ERROR
async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaVacuum, flow_schema)