init
This commit is contained in:
378
custom_components/localtuya/__init__.py
Normal file
378
custom_components/localtuya/__init__.py
Normal 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)
|
||||
76
custom_components/localtuya/binary_sensor.py
Normal file
76
custom_components/localtuya/binary_sensor.py
Normal 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
|
||||
)
|
||||
522
custom_components/localtuya/climate.py
Normal file
522
custom_components/localtuya/climate.py
Normal 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)
|
||||
139
custom_components/localtuya/cloud_api.py
Normal file
139
custom_components/localtuya/cloud_api.py
Normal 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"
|
||||
607
custom_components/localtuya/common.py
Normal file
607
custom_components/localtuya/common.py
Normal 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)
|
||||
819
custom_components/localtuya/config_flow.py
Normal file
819
custom_components/localtuya/config_flow.py
Normal 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."""
|
||||
143
custom_components/localtuya/const.py
Normal file
143
custom_components/localtuya/const.py
Normal 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"
|
||||
233
custom_components/localtuya/cover.py
Normal file
233
custom_components/localtuya/cover.py
Normal 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)
|
||||
65
custom_components/localtuya/diagnostics.py
Normal file
65
custom_components/localtuya/diagnostics.py
Normal 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
|
||||
90
custom_components/localtuya/discovery.py
Normal file
90
custom_components/localtuya/discovery.py
Normal 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
|
||||
259
custom_components/localtuya/fan.py
Normal file
259
custom_components/localtuya/fan.py
Normal 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)
|
||||
506
custom_components/localtuya/light.py
Normal file
506
custom_components/localtuya/light.py
Normal 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)
|
||||
14
custom_components/localtuya/manifest.json
Normal file
14
custom_components/localtuya/manifest.json
Normal 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"
|
||||
}
|
||||
113
custom_components/localtuya/number.py
Normal file
113
custom_components/localtuya/number.py
Normal 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)
|
||||
1196
custom_components/localtuya/pytuya/__init__.py
Normal file
1196
custom_components/localtuya/pytuya/__init__.py
Normal file
File diff suppressed because it is too large
Load Diff
123
custom_components/localtuya/select.py
Normal file
123
custom_components/localtuya/select.py
Normal 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)
|
||||
75
custom_components/localtuya/sensor.py
Normal file
75
custom_components/localtuya/sensor.py
Normal 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)
|
||||
15
custom_components/localtuya/services.yaml
Normal file
15
custom_components/localtuya/services.yaml
Normal 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
|
||||
139
custom_components/localtuya/strings.json
Normal file
139
custom_components/localtuya/strings.json
Normal 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"
|
||||
}
|
||||
91
custom_components/localtuya/switch.py
Normal file
91
custom_components/localtuya/switch.py
Normal 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)
|
||||
238
custom_components/localtuya/translations/en.json
Normal file
238
custom_components/localtuya/translations/en.json
Normal 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"
|
||||
}
|
||||
216
custom_components/localtuya/translations/it.json
Normal file
216
custom_components/localtuya/translations/it.json
Normal 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"
|
||||
}
|
||||
216
custom_components/localtuya/translations/pt-BR.json
Normal file
216
custom_components/localtuya/translations/pt-BR.json
Normal 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"
|
||||
}
|
||||
241
custom_components/localtuya/vacuum.py
Normal file
241
custom_components/localtuya/vacuum.py
Normal 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)
|
||||
Reference in New Issue
Block a user