From 3ac9b2c518d82d1caff8e11a39afeca90fdf9fd3 Mon Sep 17 00:00:00 2001 From: ltadeu6 Date: Sun, 17 May 2026 16:38:41 -0300 Subject: [PATCH] Control luz-sala directly via LocalTuya (xZetsubou fork) Drops the bridge approach. Copies the localtuya custom component (v2025.11.0) into the repo and symlinks it into /var/lib/hass via systemd-tmpfiles on rebuild. Reverts all bridge-related config. After rebuild, configure the integration via HA UI with the device credentials already in the repo. Co-Authored-By: Claude Sonnet 4.6 --- .../custom_components/localtuya/__init__.py | 541 +++++ .../localtuya/alarm_control_panel.py | 131 ++ .../localtuya/binary_sensor.py | 98 + .../custom_components/localtuya/button.py | 41 + .../custom_components/localtuya/climate.py | 579 +++++ .../custom_components/localtuya/cloud_api.py | 139 ++ .../custom_components/localtuya/common.py | 607 ++++++ .../localtuya/config_flow.py | 1333 ++++++++++++ .../custom_components/localtuya/const.py | 295 +++ .../localtuya/coordinator.py | 676 ++++++ .../localtuya/core/__init__.py | 1 + .../localtuya/core/cloud_api.py | 362 ++++ .../localtuya/core/ha_entities/__init__.py | 300 +++ .../core/ha_entities/alarm_control_panels.py | 48 + .../localtuya/core/ha_entities/base.py | 897 ++++++++ .../core/ha_entities/binary_sensors.py | 492 +++++ .../localtuya/core/ha_entities/buttons.py | 251 +++ .../localtuya/core/ha_entities/climates.py | 267 +++ .../localtuya/core/ha_entities/covers.py | 144 ++ .../localtuya/core/ha_entities/fans.py | 87 + .../localtuya/core/ha_entities/humidifiers.py | 84 + .../localtuya/core/ha_entities/lights.py | 431 ++++ .../localtuya/core/ha_entities/locks.py | 32 + .../localtuya/core/ha_entities/numbers.py | 1097 ++++++++++ .../localtuya/core/ha_entities/remotes.py | 31 + .../localtuya/core/ha_entities/selects.py | 1543 +++++++++++++ .../localtuya/core/ha_entities/sensors.py | 1907 +++++++++++++++++ .../localtuya/core/ha_entities/sirens.py | 34 + .../localtuya/core/ha_entities/switches.py | 1040 +++++++++ .../localtuya/core/ha_entities/vacuums.py | 95 + .../core/ha_entities/water_heaters.py | 83 + .../localtuya/core/helpers.py | 116 + .../localtuya/core/pytuya/__init__.py | 1339 ++++++++++++ .../localtuya/core/pytuya/cipher.py | 77 + .../localtuya/core/pytuya/const.py | 103 + .../localtuya/core/pytuya/parser.py | 219 ++ .../custom_components/localtuya/cover.py | 349 +++ .../localtuya/diagnostics.py | 89 + .../custom_components/localtuya/discovery.py | 132 ++ .../custom_components/localtuya/entity.py | 404 ++++ .../custom_components/localtuya/fan.py | 257 +++ .../custom_components/localtuya/humidifier.py | 152 ++ .../custom_components/localtuya/light.py | 669 ++++++ .../custom_components/localtuya/lock.py | 64 + .../custom_components/localtuya/manifest.json | 13 + .../custom_components/localtuya/number.py | 124 ++ .../localtuya/pytuya/__init__.py | 1196 +++++++++++ .../custom_components/localtuya/remote.py | 490 +++++ .../custom_components/localtuya/select.py | 98 + .../custom_components/localtuya/sensor.py | 167 ++ .../custom_components/localtuya/services.yaml | 78 + .../custom_components/localtuya/siren.py | 70 + .../custom_components/localtuya/strings.json | 141 ++ .../custom_components/localtuya/switch.py | 103 + .../localtuya/templates/__init__.py | 0 .../localtuya/templates/luzsala.yaml | 18 + .../localtuya/templates/sample_2g_switch.yaml | 43 + .../templates/sample_lights_bulb.yaml | 16 + .../localtuya/translations/ar.json | 146 ++ .../localtuya/translations/en.json | 273 +++ .../localtuya/translations/it.json | 256 +++ .../localtuya/translations/pl.json | 261 +++ .../localtuya/translations/pt-BR.json | 256 +++ .../localtuya/translations/tr.json | 266 +++ .../localtuya/translations/vi.json | 273 +++ .../localtuya/translations/zh-Hans.json | 273 +++ .../custom_components/localtuya/vacuum.py | 256 +++ .../localtuya/water_heater.py | 240 +++ configs/home-assistant/ui-lovelace.yaml | 27 +- configs/home-assistant/ui-overview.yaml | 27 +- hosts/Nixos/configuration.nix | 56 +- 71 files changed, 22715 insertions(+), 88 deletions(-) create mode 100644 configs/home-assistant/custom_components/localtuya/__init__.py create mode 100644 configs/home-assistant/custom_components/localtuya/alarm_control_panel.py create mode 100644 configs/home-assistant/custom_components/localtuya/binary_sensor.py create mode 100644 configs/home-assistant/custom_components/localtuya/button.py create mode 100644 configs/home-assistant/custom_components/localtuya/climate.py create mode 100644 configs/home-assistant/custom_components/localtuya/cloud_api.py create mode 100644 configs/home-assistant/custom_components/localtuya/common.py create mode 100644 configs/home-assistant/custom_components/localtuya/config_flow.py create mode 100644 configs/home-assistant/custom_components/localtuya/const.py create mode 100644 configs/home-assistant/custom_components/localtuya/coordinator.py create mode 100644 configs/home-assistant/custom_components/localtuya/core/__init__.py create mode 100644 configs/home-assistant/custom_components/localtuya/core/cloud_api.py create mode 100644 configs/home-assistant/custom_components/localtuya/core/ha_entities/__init__.py create mode 100644 configs/home-assistant/custom_components/localtuya/core/ha_entities/alarm_control_panels.py create mode 100644 configs/home-assistant/custom_components/localtuya/core/ha_entities/base.py create mode 100644 configs/home-assistant/custom_components/localtuya/core/ha_entities/binary_sensors.py create mode 100644 configs/home-assistant/custom_components/localtuya/core/ha_entities/buttons.py create mode 100644 configs/home-assistant/custom_components/localtuya/core/ha_entities/climates.py create mode 100644 configs/home-assistant/custom_components/localtuya/core/ha_entities/covers.py create mode 100644 configs/home-assistant/custom_components/localtuya/core/ha_entities/fans.py create mode 100644 configs/home-assistant/custom_components/localtuya/core/ha_entities/humidifiers.py create mode 100644 configs/home-assistant/custom_components/localtuya/core/ha_entities/lights.py create mode 100644 configs/home-assistant/custom_components/localtuya/core/ha_entities/locks.py create mode 100644 configs/home-assistant/custom_components/localtuya/core/ha_entities/numbers.py create mode 100644 configs/home-assistant/custom_components/localtuya/core/ha_entities/remotes.py create mode 100644 configs/home-assistant/custom_components/localtuya/core/ha_entities/selects.py create mode 100644 configs/home-assistant/custom_components/localtuya/core/ha_entities/sensors.py create mode 100644 configs/home-assistant/custom_components/localtuya/core/ha_entities/sirens.py create mode 100644 configs/home-assistant/custom_components/localtuya/core/ha_entities/switches.py create mode 100644 configs/home-assistant/custom_components/localtuya/core/ha_entities/vacuums.py create mode 100644 configs/home-assistant/custom_components/localtuya/core/ha_entities/water_heaters.py create mode 100644 configs/home-assistant/custom_components/localtuya/core/helpers.py create mode 100644 configs/home-assistant/custom_components/localtuya/core/pytuya/__init__.py create mode 100644 configs/home-assistant/custom_components/localtuya/core/pytuya/cipher.py create mode 100644 configs/home-assistant/custom_components/localtuya/core/pytuya/const.py create mode 100644 configs/home-assistant/custom_components/localtuya/core/pytuya/parser.py create mode 100644 configs/home-assistant/custom_components/localtuya/cover.py create mode 100644 configs/home-assistant/custom_components/localtuya/diagnostics.py create mode 100644 configs/home-assistant/custom_components/localtuya/discovery.py create mode 100644 configs/home-assistant/custom_components/localtuya/entity.py create mode 100644 configs/home-assistant/custom_components/localtuya/fan.py create mode 100644 configs/home-assistant/custom_components/localtuya/humidifier.py create mode 100644 configs/home-assistant/custom_components/localtuya/light.py create mode 100644 configs/home-assistant/custom_components/localtuya/lock.py create mode 100644 configs/home-assistant/custom_components/localtuya/manifest.json create mode 100644 configs/home-assistant/custom_components/localtuya/number.py create mode 100644 configs/home-assistant/custom_components/localtuya/pytuya/__init__.py create mode 100644 configs/home-assistant/custom_components/localtuya/remote.py create mode 100644 configs/home-assistant/custom_components/localtuya/select.py create mode 100644 configs/home-assistant/custom_components/localtuya/sensor.py create mode 100644 configs/home-assistant/custom_components/localtuya/services.yaml create mode 100644 configs/home-assistant/custom_components/localtuya/siren.py create mode 100644 configs/home-assistant/custom_components/localtuya/strings.json create mode 100644 configs/home-assistant/custom_components/localtuya/switch.py create mode 100644 configs/home-assistant/custom_components/localtuya/templates/__init__.py create mode 100644 configs/home-assistant/custom_components/localtuya/templates/luzsala.yaml create mode 100644 configs/home-assistant/custom_components/localtuya/templates/sample_2g_switch.yaml create mode 100644 configs/home-assistant/custom_components/localtuya/templates/sample_lights_bulb.yaml create mode 100644 configs/home-assistant/custom_components/localtuya/translations/ar.json create mode 100644 configs/home-assistant/custom_components/localtuya/translations/en.json create mode 100644 configs/home-assistant/custom_components/localtuya/translations/it.json create mode 100644 configs/home-assistant/custom_components/localtuya/translations/pl.json create mode 100644 configs/home-assistant/custom_components/localtuya/translations/pt-BR.json create mode 100644 configs/home-assistant/custom_components/localtuya/translations/tr.json create mode 100644 configs/home-assistant/custom_components/localtuya/translations/vi.json create mode 100644 configs/home-assistant/custom_components/localtuya/translations/zh-Hans.json create mode 100644 configs/home-assistant/custom_components/localtuya/vacuum.py create mode 100644 configs/home-assistant/custom_components/localtuya/water_heater.py diff --git a/configs/home-assistant/custom_components/localtuya/__init__.py b/configs/home-assistant/custom_components/localtuya/__init__.py new file mode 100644 index 0000000..285432b --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/__init__.py @@ -0,0 +1,541 @@ +"""The LocalTuya integration.""" + +import asyncio +from dataclasses import dataclass +import logging +import time +from datetime import timedelta +from typing import Any, NamedTuple + +import homeassistant.helpers.config_validation as cv +import homeassistant.helpers.device_registry as dr +import homeassistant.helpers.entity_registry as er +import voluptuous as vol +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import ( + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, + CONF_DEVICES, + CONF_DEVICE_ID, + CONF_ENTITIES, + CONF_HOST, + CONF_ID, + CONF_PLATFORM, + CONF_REGION, + EVENT_HOMEASSISTANT_STOP, + SERVICE_RELOAD, +) +from homeassistant.core import Event, HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.event import async_track_time_interval + +from .coordinator import TuyaDevice, HassLocalTuyaData, TuyaCloudApi +from .config_flow import ENTRIES_VERSION +from .const import ( + ATTR_UPDATED_AT, + CONF_GATEWAY_ID, + CONF_NODE_ID, + CONF_NO_CLOUD, + CONF_PRODUCT_KEY, + CONF_USER_ID, + DATA_DISCOVERY, + DOMAIN, + PLATFORMS, +) + +from .discovery import TuyaDiscovery + +_LOGGER = logging.getLogger(__name__) + +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.Optional(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, {}) + + current_entries = hass.config_entries.async_entries(DOMAIN) + device_cache = {} + + async def _handle_reload(service: ServiceCall): + """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: ServiceCall): + """Handle set_dp service call.""" + dev_id = event.data[CONF_DEVICE_ID] + entry: ConfigEntry = async_config_entry_by_device_id(hass, dev_id) + if not entry or not entry.entry_id: + raise HomeAssistantError("unknown device id") + + host = entry.data[CONF_DEVICES][dev_id].get(CONF_HOST) + if node_id := entry.data[CONF_DEVICES][dev_id].get(CONF_NODE_ID): + host = f"{host}_{node_id}" + device: TuyaDevice = hass.data[DOMAIN][entry.entry_id].devices[host] + if not device.connected: + raise HomeAssistantError("not connected to device") + value = event.data[CONF_VALUE] + if isinstance(value, dict): + await device.set_dps(value) + else: + await device.set_dp(value, event.data[CONF_DP]) + + def _device_discovered(device: dict): + """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: ConfigEntry = async_config_entry_by_device_id(hass, device_id) + + if entry is None: + return + + hass_data: HassLocalTuyaData = hass.data[DOMAIN][entry.entry_id] + + if device_id not in device_cache or device_id not in device_cache.get( + device_id, {} + ): + 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] = {device_id: host_ip} + + for subdev_id, dev_config in entry.data[CONF_DEVICES].items(): + if dev_config.get(CONF_NODE_ID): + if gateway_id := dev_config.get(CONF_GATEWAY_ID): + if entry and device_id == gateway_id: + device_cache[device_id] = device_cache.get(device_id, {}) + device_cache[device_id].update( + {subdev_id: dev_config.get(CONF_HOST)} + ) + + if device_id not in device_cache: + return + if not entry.state == ConfigEntryState.LOADED: + return + + if device := hass_data.devices.get(device_ip): + ... + + # hass.create_task(hass_data.cloud_data.async_get_devices_list()) + new_data = entry.data.copy() + updated = False + for dev_id, host in device_cache[device_id].items(): + if dev_id not in entry.data[CONF_DEVICES]: + continue + dev_entry = entry.data[CONF_DEVICES][dev_id] + if host != device_ip: + updated = True + new_data[CONF_DEVICES][dev_id][CONF_HOST] = device_ip + device_cache[device_id][dev_id] = device_ip + + if (p_key := dev_entry.get(CONF_PRODUCT_KEY)) and p_key != product_key: + updated = True + new_data[CONF_DEVICES][dev_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) + + def _shutdown(event): + """Clean up resources when shutting down.""" + discovery.close() + + hass.services.async_register(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: HomeAssistant, 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: + # This an old version of original integration no need to put it here. + pass + # Update to version 3 + if config_entry.version == 2: + # Switch config flow to selectors convert DP IDs from int to str require HA 2022.4. + _LOGGER.debug("Migrating config entry from version %s", config_entry.version) + new_data = config_entry.data.copy() + for device in new_data[CONF_DEVICES]: + i = 0 + for _ent in new_data[CONF_DEVICES][device][CONF_ENTITIES]: + ent_items = {} + for k, v in _ent.items(): + ent_items[k] = str(v) if type(v) is int else v + new_data[CONF_DEVICES][device][CONF_ENTITIES][i].update(ent_items) + i = i + 1 + hass.config_entries.async_update_entry(config_entry, data=new_data, version=3) + # Update to version 4 + if config_entry.version <= 3: + # Convert values and friendly name values to dict. + from .const import ( + Platform, + CONF_OPTIONS, + CONF_HVAC_MODE_SET, + CONF_HVAC_ACTION_SET, + CONF_PRESET_SET, + CONF_SCENE_VALUES, + # Deprecated + CONF_SCENE_VALUES_FRIENDLY, + CONF_OPTIONS_FRIENDLY, + CONF_HVAC_ADD_OFF, + ) + from .climate import ( + RENAME_HVAC_MODE_SETS, + RENAME_ACTION_SETS, + RENAME_PRESET_SETS, + HVAC_OFF, + ) + + def convert_str_to_dict(list1: str, list2: str = ""): + to_dict = {} + if not isinstance(list1, str): + return list1 + list1, list2 = list1.replace(";", ","), list2.replace(";", ",") + v, v_fn = list1.split(","), list2.split(",") + for k in range(len(v)): + to_dict[v[k]] = ( + v_fn[k] if k < len(v_fn) and v_fn[k] else v[k].capitalize() + ) + return to_dict + + new_data = config_entry.data.copy() + for device in new_data[CONF_DEVICES]: + current_entity = 0 + for entity in new_data[CONF_DEVICES][device][CONF_ENTITIES]: + new_entity_data = {} + if entity[CONF_PLATFORM] == Platform.SELECT: + # Merge 2 Lists Values and Values friendly names into dict. + v_fn = entity.get(CONF_OPTIONS_FRIENDLY, "") + if v := entity.get(CONF_OPTIONS): + new_entity_data[CONF_OPTIONS] = convert_str_to_dict(v, v_fn) + if entity[CONF_PLATFORM] == Platform.LIGHT: + v_fn = entity.get(CONF_SCENE_VALUES_FRIENDLY, "") + if v := entity.get(CONF_SCENE_VALUES): + new_entity_data[CONF_SCENE_VALUES] = convert_str_to_dict( + v, v_fn + ) + if entity[CONF_PLATFORM] == Platform.CLIMATE: + # Merge 2 Lists Values and Values friendly names into dict. + climate_to_dict = {} + for conf, new_values in ( + (CONF_HVAC_MODE_SET, RENAME_HVAC_MODE_SETS), + (CONF_HVAC_ACTION_SET, RENAME_ACTION_SETS), + (CONF_PRESET_SET, RENAME_PRESET_SETS), + ): + climate_to_dict[conf] = {} + if hvac_set := entity.get(conf, ""): + if entity.get(CONF_HVAC_ADD_OFF, False): + if conf == CONF_HVAC_MODE_SET: + climate_to_dict[conf].update(HVAC_OFF) + if not isinstance(conf, str): + continue + hvac_set = hvac_set.replace("/", ",") + for i in hvac_set.split(","): + for k, v in new_values.items(): + if i in k: + new_v = True if i == "True" else i + new_v = False if i == "False" else new_v + climate_to_dict[conf].update({v: new_v}) + new_entity_data = climate_to_dict + new_data[CONF_DEVICES][device][CONF_ENTITIES][current_entity].update( + new_entity_data + ) + current_entity += 1 + hass.config_entries.async_update_entry(config_entry, data=new_data, version=4) + + _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(region, client_id, secret, user_id) + no_cloud = entry.data.get(CONF_NO_CLOUD, True) + + if no_cloud: + _LOGGER.info(f"Cloud API account not configured.") + else: + entry.async_create_background_task( + hass, tuya_api.async_connect(), "localtuya-cloudAPI" + ) + + hass_localtuya = HassLocalTuyaData(tuya_api, {}) + hass.data[DOMAIN][entry.entry_id] = hass_localtuya + + def _setup_devices(entry_devices: dict): + """Setup Localtuya devices object.""" + devices = hass_localtuya.devices + connect_to_devices: list[TuyaDevice] = [] + + # Sort parent devices first then sub-devices. + sorted_devices = dict( + sorted( + entry_devices.items(), key=lambda k: 1 if k[1].get(CONF_NODE_ID) else 0 + ) + ) + + for dev_id, config in sorted_devices.items(): + if check_if_device_disabled(hass, entry, dev_id): + continue + + host = config.get(CONF_HOST) + + # Parent Devices. + if not (node_id := config.get(CONF_NODE_ID)): + devices[host] = (dev := TuyaDevice(hass, entry, config)) + connect_to_devices.append(dev) + continue + + # Sub-Devices + if not (gateway := devices.get(host)): + # Setup sub-device as fake gateway if there is no a gateway exist. + devices[host] = (gateway := TuyaDevice(hass, entry, config, True)) + connect_to_devices.append(gateway) + + devices[f"{host}_{node_id}"] = (sub_dev := TuyaDevice(hass, entry, config)) + sub_dev.gateway = gateway + gateway.sub_devices[node_id] = sub_dev + + return connect_to_devices + + connect_to_devices = _setup_devices(entry.data[CONF_DEVICES]) + + await async_remove_orphan_entities(hass, entry) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS.values()) + + # Note: entry.async_on_unload items are called in LIFO order! + + for dev in connect_to_devices: + entry.async_create_task(hass, dev.async_connect()) + entry.async_on_unload(dev.close) + + entry.async_on_unload(entry.add_update_listener(update_listener)) + + async def _shutdown(event): + """Clean up resources when shutting down.""" + await asyncio.gather(*[dev.close() for dev in connect_to_devices]) + _LOGGER.info(f"{entry.title}: Shutdown completed") + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown) + ) + + entry.async_on_unload(_run_async_listen(hass, entry)) + _LOGGER.info(f"{entry.title}: Setup completed") + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unloading the Tuya platforms.""" + # Unload the platforms. + await hass.config_entries.async_unload_platforms(entry, PLATFORMS.values()) + hass.data[DOMAIN].pop(entry.entry_id) + + _LOGGER.info("Unload completed") + return True + + +async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry): + """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: dr.DeviceEntry +) -> bool: + """Remove a config entry from a device.""" + dev_id = _device_id_by_identifiers(device_entry.identifiers) + + 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 + + # host = config_entry.data[CONF_DEVICES][dev_id][CONF_HOST] + # await hass.data[DOMAIN][config_entry.entry_id].devices[host].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) + + +def _run_async_listen(hass: HomeAssistant, entry: ConfigEntry): + """Start the listing events""" + + @callback + def _event_filter(data: dr.EventDeviceRegistryUpdatedData) -> bool: + device_reg = dr.async_get(hass).async_get(data["device_id"]) + is_entry = device_reg and entry.entry_id in device_reg.config_entries + return data["action"] == "update" and is_entry + + async def device_state_changed(event: Event[dr.EventDeviceRegistryUpdatedData]): + """Close connection if device disabled.""" + if not "disabled_by" in event.data["changes"]: + return + + device_registry = dr.async_get(hass).async_get(event.data["device_id"]) + + if not device_registry.disabled: + return + + hass_localtuya: HassLocalTuyaData = hass.data[DOMAIN][entry.entry_id] + + dev_id = _device_id_by_identifiers(device_registry.identifiers) + host_ip = entry.data[CONF_DEVICES][dev_id][CONF_HOST] + + if cid := entry.data[CONF_DEVICES][dev_id].get(CONF_NODE_ID): + host_ip = f"{host_ip}_{cid}" + + device = hass_localtuya.devices.get(host_ip) + + if device: + # If this is a gateway or fake gateway then reload entry to start using another device as GW. + if device.sub_devices or (device.gateway and device.gateway.id == dev_id): + await hass.config_entries.async_reload(entry.entry_id) + else: + await device.close() + + return hass.bus.async_listen( + dr.EVENT_DEVICE_REGISTRY_UPDATED, device_state_changed, _event_filter + ) + + +def _device_id_by_identifiers(identifiers: set[tuple[str, str]]): + """Return localtuya device ID by device registry identifiers.""" + return list(identifiers)[0][1].split("_")[-1] + + +@callback +def async_config_entry_by_device_id(hass: HomeAssistant, device_id: str): + """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[CONF_DEVICES]: + return entry + # Search for gateway_id + for dev_conf in entry.data[CONF_DEVICES].values(): + if (gw_id := dev_conf.get(CONF_GATEWAY_ID)) and gw_id == device_id: + return entry + return None + + +@callback +def async_device_id_by_entity_id(hass: HomeAssistant, entity_id: str): + """Look up config entry by device id.""" + ent_reg = er.async_get(hass) + dev_reg = dr.async_get(hass) + if device := dev_reg.async_get(ent_reg.async_get(entity_id).device_id): + return list(device.identifiers)[0][1].split("_")[-1] + + return None + + +@callback +def check_if_device_disabled(hass: HomeAssistant, entry: ConfigEntry, dev_id: str): + """Return whether if the device disabled or not.""" + ent_reg = er.async_get(hass) + entries = er.async_entries_for_config_entry(ent_reg, entry.entry_id) + ha_device_id: str = None + + for entity in entries: + if dev_id in entity.unique_id: + ha_device_id = entity.device_id + break + + if ha_device_id and (device := dr.async_get(hass).async_get(ha_device_id)): + return device.disabled diff --git a/configs/home-assistant/custom_components/localtuya/alarm_control_panel.py b/configs/home-assistant/custom_components/localtuya/alarm_control_panel.py new file mode 100644 index 0000000..1104d56 --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/alarm_control_panel.py @@ -0,0 +1,131 @@ +"""Platform to present any Tuya DP as a Alarm.""" + +from enum import StrEnum +import logging +from functools import partial +from .config_flow import col_to_select + +import voluptuous as vol +from homeassistant.helpers.selector import ObjectSelector +from homeassistant.components.alarm_control_panel import ( + DOMAIN, + AlarmControlPanelEntity, + CodeFormat, + AlarmControlPanelEntityFeature, + AlarmControlPanelState, +) + +from .entity import LocalTuyaEntity, async_setup_entry +from .const import CONF_ALARM_SUPPORTED_STATES, DictSelector + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_PRECISION = 2 + + +class TuyaMode(StrEnum): + DISARMED = "disarmed" + ARM = "arm" + HOME = "home" + SOS = "sos" + + +DEFAULT_SUPPORTED_MODES = { + AlarmControlPanelState.DISARMED: TuyaMode.DISARMED, + AlarmControlPanelState.ARMED_AWAY: TuyaMode.ARM, + AlarmControlPanelState.ARMED_HOME: TuyaMode.HOME, + AlarmControlPanelState.TRIGGERED: TuyaMode.SOS, +} + + +def flow_schema(dps): + """Return schema used in config flow.""" + return { + vol.Optional( + CONF_ALARM_SUPPORTED_STATES, default=DEFAULT_SUPPORTED_MODES + ): ObjectSelector(), + } + + +class LocalTuyaAlarmControlPanel(LocalTuyaEntity, AlarmControlPanelEntity): + """Representation of a Tuya Alarm.""" + + _supported_modes = {} + + def __init__( + self, + device, + config_entry, + dpid, + **kwargs, + ): + """Initialize the Tuya Alarm.""" + super().__init__(device, config_entry, dpid, _LOGGER, **kwargs) + self._state = None + self._changed_by = None + + # supported modes + if supported_modes := self._config.get(CONF_ALARM_SUPPORTED_STATES, {}): + # Key is HA state and value is Tuya State. + if AlarmControlPanelState.ARMED_AWAY in supported_modes: + self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_HOME + if AlarmControlPanelState.ARMED_HOME in supported_modes: + self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_AWAY + if AlarmControlPanelState.TRIGGERED in supported_modes: + self._attr_supported_features |= AlarmControlPanelEntityFeature.TRIGGER + + self._states = DictSelector(supported_modes, reverse=True) + + @property + def alarm_state(self) -> AlarmControlPanelState | None: + """Return the state of the device.""" + return self._states.to_ha(self._state, None) + + @property + def code_format(self) -> CodeFormat | None: + """Code format or None if no code is required.""" + return None # self._attr_code_format + + @property + def changed_by(self) -> str | None: + """Last change triggered by.""" + return None # self._attr_changed_by + + @property + def code_arm_required(self) -> bool: + """Whether the code is required for arm actions.""" + return True # self._attr_code_arm_required + + async def async_alarm_disarm(self, code: str | None = None) -> None: + """Send disarm command.""" + state = self._states.to_tuya(AlarmControlPanelState.DISARMED) + await self._device.set_dp(state, self._dp_id) + + async def async_alarm_arm_home(self, code: str | None = None) -> None: + """Send arm home command.""" + state = self._states.to_tuya(AlarmControlPanelState.ARMED_HOME) + await self._device.set_dp(state, self._dp_id) + + async def async_alarm_arm_away(self, code: str | None = None) -> None: + """Send arm away command.""" + state = self._states.to_tuya(AlarmControlPanelState.ARMED_AWAY) + await self._device.set_dp(state, self._dp_id) + + async def async_alarm_trigger(self, code: str | None = None) -> None: + """Send alarm trigger command.""" + state = self._states.to_tuya(AlarmControlPanelState.TRIGGERED) + await self._device.set_dp(state, self._dp_id) + + def status_updated(self): + """Device status was updated.""" + super().status_updated() + + # No need to restore state for a AlarmControlPanel + async def restore_state_when_connected(self): + """Do nothing for a AlarmControlPanel.""" + return + + +async_setup_entry = partial( + async_setup_entry, DOMAIN, LocalTuyaAlarmControlPanel, flow_schema +) diff --git a/configs/home-assistant/custom_components/localtuya/binary_sensor.py b/configs/home-assistant/custom_components/localtuya/binary_sensor.py new file mode 100644 index 0000000..7e307a9 --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/binary_sensor.py @@ -0,0 +1,98 @@ +"""Platform to present any Tuya DP as a binary sensor.""" + +import logging +import voluptuous as vol + +from functools import partial + +from homeassistant.helpers.selector import NumberSelector, NumberSelectorConfig +from homeassistant.helpers.event import async_call_later +from homeassistant.core import callback, CALLBACK_TYPE +from homeassistant.const import CONF_DEVICE_CLASS +from homeassistant.components.binary_sensor import ( + DEVICE_CLASSES_SCHEMA, + DOMAIN, + BinarySensorEntity, +) + +from .entity import LocalTuyaEntity, async_setup_entry +from .const import CONF_STATE_ON, CONF_RESET_TIMER + + +CONF_STATE_OFF = "state_off" + +_LOGGER = logging.getLogger(__name__) + + +def flow_schema(dps): + """Return schema used in config flow.""" + return { + vol.Required(CONF_STATE_ON, default="true,1,pir,on"): str, + # vol.Required(CONF_STATE_OFF, default="False"): str, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_RESET_TIMER, default=0): NumberSelector( + NumberSelectorConfig(min=0, unit_of_measurement="Seconds", mode="box") + ), + } + + +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 + + self._reset_timer: float = self._config.get(CONF_RESET_TIMER, 0) + self._reset_timer_interval: CALLBACK_TYPE | None = None + + @property + def is_on(self): + """Return sensor state.""" + return self._is_on + + def status_updated(self): + """Device status was updated.""" + super().status_updated() + + state = str(self.dp_value(self._dp_id)).lower() + # users may set wrong on states, But we assume that must devices use this on states. + if state in self._config[CONF_STATE_ON].lower().split(","): + self._is_on = True + else: + self._is_on = False + + if self._reset_timer and self._is_on: + if self._reset_timer_interval is not None: + self._reset_timer_interval() + self._reset_timer_interval = None + + @callback + def async_reset_state(now): + """Set the state of the entity to off.""" + # "_update_handler" logic, if status hasn't changed "status_updated" will not be called. + # Maybe we can find better solution then this workaround? + self._status[self._dp_id] = "reset_state_binary_sensor" + self._is_on = False + self.async_write_ha_state() + + self._reset_timer_interval = async_call_later( + self.hass, self._reset_timer, async_reset_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, LocalTuyaBinarySensor, flow_schema +) diff --git a/configs/home-assistant/custom_components/localtuya/button.py b/configs/home-assistant/custom_components/localtuya/button.py new file mode 100644 index 0000000..1a210c0 --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/button.py @@ -0,0 +1,41 @@ +"""Platform to locally control Tuya-based button devices.""" + +import logging +from functools import partial + +import voluptuous as vol +from homeassistant.components.button import DOMAIN, ButtonEntity + +from .entity import LocalTuyaEntity, async_setup_entry +from .const import CONF_PASSIVE_ENTITY + +_LOGGER = logging.getLogger(__name__) + + +def flow_schema(dps): + """Return schema used in config flow.""" + return { + # vol.Required(CONF_PASSIVE_ENTITY): bool, + } + + +class LocalTuyaButton(LocalTuyaEntity, ButtonEntity): + """Representation of a Tuya button.""" + + def __init__( + self, + device, + config_entry, + buttonid, + **kwargs, + ): + """Initialize the Tuya button.""" + super().__init__(device, config_entry, buttonid, _LOGGER, **kwargs) + self._state = None + + async def async_press(self): + """Press the button.""" + await self._device.set_dp(True, self._dp_id) + + +async_setup_entry = partial(async_setup_entry, DOMAIN, LocalTuyaButton, flow_schema) diff --git a/configs/home-assistant/custom_components/localtuya/climate.py b/configs/home-assistant/custom_components/localtuya/climate.py new file mode 100644 index 0000000..d699f98 --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/climate.py @@ -0,0 +1,579 @@ +"""Platform to locally control Tuya-based climate devices.""" + +import asyncio +from enum import StrEnum +import logging +from functools import partial +from .config_flow import col_to_select +from homeassistant.helpers.selector import ObjectSelector + +import voluptuous as vol +from homeassistant.components.climate import ( + DEFAULT_MAX_TEMP, + DEFAULT_MIN_TEMP, + DOMAIN, + ClimateEntity, +) +from homeassistant.components.climate.const import ( + HVACMode, + HVACAction, + PRESET_AWAY, + PRESET_ECO, + PRESET_HOME, + PRESET_NONE, + ClimateEntityFeature, +) +from homeassistant.const import ( + ATTR_TEMPERATURE, + CONF_TEMPERATURE_UNIT, + PRECISION_HALVES, + PRECISION_TENTHS, + PRECISION_WHOLE, + UnitOfTemperature, +) +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM +from .entity import LocalTuyaEntity, async_setup_entry +from .const import ( + CONF_CURRENT_TEMPERATURE_DP, + 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_PRECISION, + CONF_PRESET_DP, + CONF_PRESET_SET, + CONF_TARGET_PRECISION, + CONF_TARGET_TEMPERATURE_DP, + CONF_TEMPERATURE_STEP, + CONF_MIN_TEMP, + CONF_MAX_TEMP, + CONF_HVAC_ADD_OFF, + CONF_FAN_SPEED_DP, + CONF_FAN_SPEED_LIST, + CONF_SWING_MODE_DP, + CONF_SWING_MODES, + CONF_SWING_HORIZONTAL_DP, + CONF_SWING_HORIZONTAL_MODES, + DictSelector, +) + +_LOGGER = logging.getLogger(__name__) + + +HVAC_OFF = {HVACMode.OFF.value: "off"} # Migrate to 3 +RENAME_HVAC_MODE_SETS = { # Migrate to 3 + ("manual", "Manual", "hot", "m", "True"): HVACMode.HEAT.value, + ("auto", "0", "p", "Program"): HVACMode.AUTO.value, + ("freeze", "cold", "1"): HVACMode.COOL.value, + ("wet"): HVACMode.DRY.value, +} +RENAME_ACTION_SETS = { # Migrate to 3 + ("open", "opened", "heating", "Heat", "True"): HVACAction.HEATING.value, + ("closed", "close", "no_heating"): HVACAction.IDLE.value, + ("Warming", "warming", "False"): HVACAction.IDLE.value, + ("cooling"): HVACAction.COOLING.value, + ("off"): HVACAction.OFF.value, +} +RENAME_PRESET_SETS = { # Migrate to 3 + "Holiday": (PRESET_AWAY), + "Program": (PRESET_HOME), + "Manual": (PRESET_NONE, "manual"), + "Auto": "auto", + "Manual": "manual", + "Smart": "smart", + "Comfort": "comfortable", + "ECO": "eco", +} + + +HVAC_MODE_SETS = { + HVACMode.OFF: False, + HVACMode.AUTO: "auto", + HVACMode.COOL: "cold", + HVACMode.HEAT: "hot", + HVACMode.HEAT_COOL: "heat", + HVACMode.DRY: "wet", + HVACMode.FAN_ONLY: "wind", +} + +HVAC_ACTION_SETS = { + HVACAction.HEATING: "opened", + HVACAction.IDLE: "closed", +} + + +class SupportedTemps(StrEnum): + C = "celsius" + F = "fahrenheit" + C_F = f"celsius/fahrenheit" + F_C = f"fahrenheit/celsius" + + +SUPPORTED_TEMPERATURES = { + UnitOfTemperature.CELSIUS: SupportedTemps.C, + UnitOfTemperature.FAHRENHEIT: SupportedTemps.F, + f"Target Temperature: {UnitOfTemperature.CELSIUS} | Current Temperature {UnitOfTemperature.FAHRENHEIT}": SupportedTemps.C_F, + f"Current Temperature {UnitOfTemperature.CELSIUS} | Target Temperature: {UnitOfTemperature.FAHRENHEIT} ": SupportedTemps.F_C, +} +SUPPORTED_PRECISIONS = [0.01, PRECISION_TENTHS, PRECISION_HALVES, PRECISION_WHOLE] + +DEFAULT_TEMPERATURE_UNIT = SupportedTemps.C +DEFAULT_PRECISION = PRECISION_TENTHS +DEFAULT_TEMPERATURE_STEP = PRECISION_HALVES +# Empirically tested to work for AVATTO thermostat +MODE_WAIT = 0.1 + +FAN_SPEEDS_DEFAULT = {"1": "Low", "2": "Medium", "3": "High"} + + +def flow_schema(dps): + """Return schema used in config flow.""" + return { + vol.Optional(CONF_TARGET_TEMPERATURE_DP): col_to_select(dps, is_dps=True), + vol.Optional(CONF_CURRENT_TEMPERATURE_DP): col_to_select(dps, is_dps=True), + vol.Optional(CONF_TEMPERATURE_STEP): col_to_select( + [PRECISION_WHOLE, PRECISION_HALVES, PRECISION_TENTHS] + ), + vol.Optional(CONF_MIN_TEMP, default=DEFAULT_MIN_TEMP): vol.Coerce(float), + vol.Optional(CONF_MAX_TEMP, default=DEFAULT_MAX_TEMP): vol.Coerce(float), + vol.Optional(CONF_PRECISION, default=str(DEFAULT_PRECISION)): col_to_select( + SUPPORTED_PRECISIONS + ), + vol.Optional( + CONF_TARGET_PRECISION, default=str(DEFAULT_PRECISION) + ): col_to_select(SUPPORTED_PRECISIONS), + vol.Optional(CONF_HVAC_MODE_DP): col_to_select(dps, is_dps=True), + vol.Optional(CONF_HVAC_MODE_SET, default=HVAC_MODE_SETS): ObjectSelector(), + vol.Optional(CONF_HVAC_ACTION_DP): col_to_select(dps, is_dps=True), + vol.Optional(CONF_HVAC_ACTION_SET, default=HVAC_ACTION_SETS): ObjectSelector(), + vol.Optional(CONF_ECO_DP): col_to_select(dps, is_dps=True), + vol.Optional(CONF_ECO_VALUE): str, + vol.Optional(CONF_PRESET_DP): col_to_select(dps, is_dps=True), + vol.Optional(CONF_PRESET_SET, default={}): ObjectSelector(), + vol.Optional(CONF_SWING_MODE_DP): col_to_select(dps, is_dps=True), + vol.Optional(CONF_SWING_MODES, default={}): ObjectSelector(), + vol.Optional(CONF_SWING_HORIZONTAL_DP): col_to_select(dps, is_dps=True), + vol.Optional(CONF_SWING_HORIZONTAL_MODES, default={}): ObjectSelector(), + vol.Optional(CONF_FAN_SPEED_DP): col_to_select(dps, is_dps=True), + vol.Optional(CONF_FAN_SPEED_LIST, default=FAN_SPEEDS_DEFAULT): ObjectSelector(), + vol.Optional(CONF_TEMPERATURE_UNIT): col_to_select(SUPPORTED_TEMPERATURES), + vol.Optional(CONF_HEURISTIC_ACTION): bool, + } + + +# Converters +def f_to_c(num): + return (num - 32) * 5 / 9 if num else num + + +def c_to_f(num): + return (num * 1.8) + 32 if num else num + + +def config_unit(unit): + if unit == SupportedTemps.F: + return UnitOfTemperature.FAHRENHEIT + else: + return UnitOfTemperature.CELSIUS + + +class LocalTuyaClimate(LocalTuyaEntity, ClimateEntity): + """Tuya climate device.""" + + _enable_turn_on_off_backwards_compatibility = False + + def __init__( + self, + device, + config_entry, + switchid, + **kwargs, + ): + """Initialize a new LocalTuyaClimate.""" + super().__init__(device, config_entry, switchid, _LOGGER, **kwargs) + self._state = None + self._state_on, self._state_off = True, False + self._target_temperature = None + self._target_temp_forced_to_celsius = None + self._current_temperature = None + self._hvac_mode: HVACMode | None = None + self._hvac_action: HVACAction | None = None + self._preset_mode: str | None = None + self._swing_mode: str | None = None + self._swing_horizontal_mode: str | None = None + self._precision = float(self._config.get(CONF_PRECISION, DEFAULT_PRECISION)) + self._precision_target = float( + self._config.get(CONF_TARGET_PRECISION, DEFAULT_PRECISION) + ) + + # HVAC Modes + self._hvac_mode_dp = self._config.get(CONF_HVAC_MODE_DP) + if hvac_modes := self._config.get(CONF_HVAC_MODE_SET, {}): + # HA HVAC Modes are all lower case. + hvac_modes = {k.lower(): v for k, v in hvac_modes.copy().items()} + + self._preset_dp = self._config.get(CONF_PRESET_DP) + preset_set: dict = self._config.get(CONF_PRESET_SET, {}) + # Sort Modes If the HVAC isn't supported by HA then we add it as preset. + if self._preset_dp == self._hvac_mode_dp or not self._preset_dp: + for k in hvac_modes.copy().keys(): + if k not in HVACMode: + self._preset_dp = self._hvac_mode_dp + preset_set[k] = hvac_modes.pop(k) + + self._preset_set = DictSelector(preset_set) + self._hvac_mode_set = DictSelector(hvac_modes, reverse=True) + + # HVAC Actions + self._hvac_action_dp = self._config.get(CONF_HVAC_ACTION_DP) + if actions_set := self._config.get(CONF_HVAC_ACTION_SET, {}): + actions_set = {k.lower(): v for k, v in actions_set.copy().items()} + self._hvac_action_set = DictSelector(actions_set, reverse=True) + + # Fan + self._fan_speed_dp = self._config.get(CONF_FAN_SPEED_DP) + self._fan_speeds = DictSelector(self._config.get(CONF_FAN_SPEED_LIST, {})) + + # Swing configurations. + self._swing_v_mode_dp = self._config.get(CONF_SWING_MODE_DP) + self._swing_v_modes = DictSelector(self._config.get(CONF_SWING_MODES, {})) + self._swing_h_mode_dp = self._config.get(CONF_SWING_HORIZONTAL_DP) + self._swing_h_modes = DictSelector( + self._config.get(CONF_SWING_HORIZONTAL_MODES, {}) + ) + + # Eco!? + self._eco_dp = self._config.get(CONF_ECO_DP) + self._eco_value = self._config.get(CONF_ECO_VALUE, "ECO") + self._has_presets = self._eco_dp or (self._preset_dp and self._preset_set) + + self._min_temp = self._config.get(CONF_MIN_TEMP, DEFAULT_MIN_TEMP) + self._max_temp = self._config.get(CONF_MAX_TEMP, DEFAULT_MAX_TEMP) + + # Temperature unit + config_temp_unit = self._config.get(CONF_TEMPERATURE_UNIT, "") + target_unit, *current_unit = config_temp_unit.split("/") + set_temp_unit = UnitOfTemperature.CELSIUS + if current_unit: + self._target_temp_forced_to_celsius = target_unit == SupportedTemps.F + if self._target_temp_forced_to_celsius: + self._min_temp = f_to_c(self._min_temp) + self._max_temp = f_to_c(self._max_temp) + else: + set_temp_unit = config_unit(config_temp_unit) + self._temperature_unit = set_temp_unit + + @property + def _is_on(self): + """Return if the device is on.""" + return self._state and self._state != self._state_off + + @property + def supported_features(self): + """Flag supported features.""" + supported_features = ClimateEntityFeature(0) + if self.has_config(CONF_TARGET_TEMPERATURE_DP): + supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE + if self._has_presets: + supported_features |= ClimateEntityFeature.PRESET_MODE + if self._fan_speed_dp and self._fan_speeds: + supported_features |= ClimateEntityFeature.FAN_MODE + if self._swing_v_mode_dp and self._swing_v_modes: + supported_features |= ClimateEntityFeature.SWING_MODE + if self._swing_h_mode_dp and self._swing_h_modes: + supported_features |= ClimateEntityFeature.SWING_HORIZONTAL_MODE + + supported_features |= ClimateEntityFeature.TURN_OFF + supported_features |= ClimateEntityFeature.TURN_ON + + return supported_features + + @property + def precision(self): + """Return the precision of the system.""" + return self._precision + + @property + def temperature_unit(self): + """Return the unit of measurement used by the platform.""" + return self._temperature_unit + + @property + def min_temp(self): + """Return the minimum temperature.""" + # DEFAULT_MIN_TEMP is in C + return self._min_temp + + @property + def max_temp(self): + """Return the maximum temperature.""" + # DEFAULT_MAX_TEMP is in C + return self._max_temp + + @property + def hvac_mode(self): + """Return current operation ie. heat, cool, idle.""" + if not self._is_on: + return HVACMode.OFF + if not self._hvac_mode_dp: + return HVACMode.HEAT + + 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 [HVACMode.OFF] + + modes = self._hvac_mode_set.names + if self._config.get(CONF_HVAC_ADD_OFF, True) and HVACMode.OFF not in modes: + modes.append(HVACMode.OFF) + return modes + + @property + def hvac_action(self): + """Return the current running hvac operation if supported.""" + if not self._is_on: + return HVACAction.OFF + + hvac_action = self._hvac_action + hvac_mode = self._hvac_mode + + if ( + (self._config.get(CONF_HEURISTIC_ACTION) or not self._hvac_action_dp) + and (target_temperature := self._target_temperature) is not None + and (current_temperature := self._current_temperature) is not None + ): + # This function assumes that action changes based on target step different from current. + target_step = self.target_temperature_step + is_heating = current_temperature < (target_temperature - target_step) + is_cooling = current_temperature > (target_temperature + target_step) + + if hvac_mode == HVACMode.HEAT: + if is_heating: + hvac_action = HVACAction.HEATING + elif current_temperature >= target_temperature: + hvac_action = HVACAction.IDLE + elif hvac_mode == HVACMode.COOL: + if is_cooling: + hvac_action = HVACAction.COOLING + elif current_temperature <= target_temperature: + hvac_action = HVACAction.IDLE + elif hvac_mode == HVACMode.HEAT_COOL: + if is_heating: + hvac_action = HVACAction.HEATING + elif is_cooling: + hvac_action = HVACAction.COOLING + elif current_temperature == target_temperature: + hvac_action = HVACAction.IDLE + elif hvac_mode == HVACMode.DRY: + hvac_action = HVACAction.DRYING + elif hvac_mode == HVACMode.FAN_ONLY: + hvac_action = HVACAction.FAN + + self._hvac_action = hvac_action + + return hvac_action + + @property + def preset_mode(self): + """Return current preset.""" + mode = self.dp_value(CONF_HVAC_MODE_DP) + if self._preset_dp == self._hvac_mode_dp and ( + mode in self._hvac_mode_set.values + ): + return None + + return self._preset_mode + + @property + def preset_modes(self): + """Return the list of available presets modes.""" + if not self._has_presets: + return None + + presets = self._preset_set.names + if self._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.""" + target_step = self._config.get(CONF_TEMPERATURE_STEP, DEFAULT_TEMPERATURE_STEP) + return float(target_step) + + @property + def fan_mode(self): + """Return the fan setting.""" + if not (fan_value := self.dp_value(self._fan_speed_dp)): + return None + return self._fan_speeds.to_ha(fan_value) + + @property + def fan_modes(self) -> list: + """Return the list of available fan modes.""" + return self._fan_speeds.names + + @property + def swing_mode(self) -> str | None: + """Return the swing setting.""" + return self._swing_mode + + @property + def swing_modes(self) -> list[str] | None: + """Return the list of available swing modes.""" + return self._swing_v_modes.names + + @property + def swing_horizontal_mode(self) -> str | None: + """Return the horizontal swing setting.""" + return self._swing_horizontal_mode + + @property + def swing_horizontal_modes(self) -> list[str] | None: + """Return the list of available horizontal swing modes.""" + return self._swing_h_modes.names + + async def async_set_swing_mode(self, swing_mode): + """Set new target swing operation.""" + await self._device.set_dp( + self._swing_v_modes.to_tuya(swing_mode), self._swing_v_mode_dp + ) + + async def async_set_swing_horizontal_mode(self, swing_mode): + """Set new target horizontal swing operation.""" + await self._device.set_dp( + self._swing_h_modes.to_tuya(swing_mode), self._swing_h_mode_dp + ) + + 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 = kwargs[ATTR_TEMPERATURE] + + if self._target_temp_forced_to_celsius: + # Revert Temperature to Fahrenheit it was forced to celsius + temperature = round(c_to_f(temperature)) + + temperature = round(temperature / self._precision_target) + 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 not self._is_on: + await self._device.set_dp(self._state_on, self._dp_id) + + await self._device.set_dp( + self._fan_speeds.to_tuya(fan_mode), self._fan_speed_dp + ) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode): + """Set new target operation mode.""" + new_states = {} + if not self._is_on: + new_states[self._dp_id] = self._state_on + + if hvac_mode in self._hvac_mode_set.names: + new_states[self._hvac_mode_dp] = self._hvac_mode_set.to_tuya(hvac_mode) + elif hvac_mode == HVACMode.OFF: + new_states[self._dp_id] = self._state_off + + await self._device.set_dps(new_states) + + async def async_turn_on(self) -> None: + """Turn the entity on.""" + await self._device.set_dp(self._state_on, self._dp_id) + + async def async_turn_off(self) -> None: + """Turn the entity off.""" + await self._device.set_dp(self._state_off, 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._eco_value, self._eco_dp) + return + + preset_value = self._preset_set.to_tuya(preset_mode) + await self._device.set_dp(preset_value, self._preset_dp) + + def connection_made(self): + """The connection has made with the device and status retrieved. configure entity based on it.""" + super().connection_made() + + match self.dp_value(self._dp_id): + case str() as v if v.lower() in ("on", "off"): + self._state_on = "ON" if v.isupper() else "on" + self._state_off = "OFF" if v.isupper() else "off" + case int() as v if not isinstance(v, bool) and v in (0, 1): + self._state_on = 1 + self._state_off = 0 + + def status_updated(self): + """Device status was updated.""" + self._state = self.dp_value(self._dp_id) + + # Update target temperature + if self.has_config(CONF_TARGET_TEMPERATURE_DP) and ( + target_dp_value := self.dp_value(CONF_TARGET_TEMPERATURE_DP) + ): + self._target_temperature = target_dp_value * self._precision_target + + # Update current temperature + if self.has_config(CONF_CURRENT_TEMPERATURE_DP) and ( + current_dp_temp := self.dp_value(CONF_CURRENT_TEMPERATURE_DP) + ): + self._current_temperature = current_dp_temp * self._precision + + # Force the Current temperature and Target temperature to matching the unit. + if self._target_temp_forced_to_celsius: + self._target_temperature = f_to_c(self._target_temperature) + elif self._target_temp_forced_to_celsius is False: + self._current_temperature = f_to_c(self._current_temperature) + + # Update preset states + if self._has_presets: + if self.dp_value(CONF_ECO_DP) == self._eco_value: + self._preset_mode = PRESET_ECO + else: + self._preset_mode = self._preset_set.to_ha( + self.dp_value(CONF_PRESET_DP), PRESET_NONE + ) + + # Update swing actions + if (swing_v := self.dp_value(self._swing_v_mode_dp)) is not None: + self._swing_mode = self._swing_v_modes.to_ha(swing_v) + if (swing_h := self.dp_value(self._swing_h_mode_dp)) is not None: + self._swing_horizontal_mode = self._swing_h_modes.to_ha(swing_h) + + # If device is off there is no needs to check the states. + if not self._is_on: + return + + # Update the HVAC Mode + if (mode := self.dp_value(CONF_HVAC_MODE_DP)) is not None: + self._hvac_mode = self._hvac_mode_set.to_ha(mode) + + # Update the current action + if (action := self.dp_value(CONF_HVAC_ACTION_DP)) is not None: + self._hvac_action = self._hvac_action_set.to_ha(action) + + +async_setup_entry = partial(async_setup_entry, DOMAIN, LocalTuyaClimate, flow_schema) diff --git a/configs/home-assistant/custom_components/localtuya/cloud_api.py b/configs/home-assistant/custom_components/localtuya/cloud_api.py new file mode 100644 index 0000000..a0c5128 --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/cloud_api.py @@ -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" diff --git a/configs/home-assistant/custom_components/localtuya/common.py b/configs/home-assistant/custom_components/localtuya/common.py new file mode 100644 index 0000000..fdb3503 --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/common.py @@ -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) diff --git a/configs/home-assistant/custom_components/localtuya/config_flow.py b/configs/home-assistant/custom_components/localtuya/config_flow.py new file mode 100644 index 0000000..6547796 --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/config_flow.py @@ -0,0 +1,1333 @@ +"""Config flow for LocalTuya integration integration.""" + +import asyncio +import errno +import logging +import time +import copy +from importlib import import_module +from functools import partial +from collections.abc import Coroutine +from typing import Any + + +import homeassistant.helpers.config_validation as cv +import homeassistant.helpers.entity_registry as er +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, + SelectOptionDict, +) +import voluptuous as vol +from homeassistant import exceptions +from homeassistant.core import callback, HomeAssistant +from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow +from homeassistant.const import ( + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, + CONF_DEVICE_ID, + CONF_DEVICES, + CONF_ENTITIES, + CONF_FRIENDLY_NAME, + CONF_ENTITY_CATEGORY, + CONF_HOST, + CONF_ICON, + CONF_ID, + CONF_NAME, + CONF_PLATFORM, + CONF_REGION, + CONF_SCAN_INTERVAL, + CONF_USERNAME, + EntityCategory, +) + +from .coordinator import HassLocalTuyaData +from .core import pytuya +from .core.cloud_api import TUYA_ENDPOINTS, TuyaCloudApi +from .core.helpers import templates, get_gateway_by_deviceid, gen_localtuya_entities +from .const import ( + ATTR_UPDATED_AT, + CONF_ADD_DEVICE, + CONF_CONFIGURE_CLOUD, + CONF_DPS_STRINGS, + CONF_EDIT_DEVICE, + CONF_ENABLE_ADD_ENTITIES, + CONF_ENABLE_DEBUG, + CONF_GATEWAY_ID, + CONF_LOCAL_KEY, + CONF_MANUAL_DPS, + CONF_MODEL, + CONF_NODE_ID, + CONF_NO_CLOUD, + CONF_PRODUCT_KEY, + CONF_PRODUCT_NAME, + CONF_PROTOCOL_VERSION, + CONF_RESET_DPIDS, + CONF_TUYA_GWID, + CONF_TUYA_IP, + CONF_TUYA_VERSION, + CONF_USER_ID, + DATA_DISCOVERY, + DEFAULT_CATEGORIES, + DOMAIN, + ENTITY_CATEGORY, + PLATFORMS, + SUPPORTED_PROTOCOL_VERSIONS, + CONF_DEVICE_SLEEP_TIME, +) +from .discovery import discover + +_LOGGER = logging.getLogger(__name__) + +ENTRIES_VERSION = 4 + +PLATFORM_TO_ADD = "platform_to_add" +USE_TEMPLATE = "use_template" +TEMPLATES = "templates" +NO_ADDITIONAL_ENTITIES = "no_additional_entities" +SELECTED_DEVICE = "selected_device" +EXPORT_CONFIG = "export_config" + +TUYA_CATEGORY = "category" +DEVICE_CLOUD_DATA = "device_cloud_data" + +# Using list method so we can translate options. +CONFIGURE_MENU = [CONF_ADD_DEVICE, CONF_EDIT_DEVICE, CONF_CONFIGURE_CLOUD] + + +def col_to_select( + opt_list: dict | list, multi_select=False, is_dps=False, custom_value=False +) -> SelectSelector: + """Convert collections to SelectSelectorConfig.""" + if type(opt_list) == dict: + return SelectSelector( + SelectSelectorConfig( + options=[ + SelectOptionDict(value=str(v), label=k) for k, v in opt_list.items() + ], + mode=SelectSelectorMode.DROPDOWN, + custom_value=custom_value, + multiple=True if multi_select else False, + ) + ) + elif type(opt_list) == list: + # value used the same method as func available_dps_string, no spaces values. + return SelectSelector( + SelectSelectorConfig( + options=[ + SelectOptionDict( + value=str(kv).split(" ")[0] if is_dps else str(kv), + label=str(kv), + ) + for kv in opt_list + ], + mode=SelectSelectorMode.DROPDOWN, + custom_value=custom_value, + multiple=True if multi_select else False, + ) + ) + + +CLOUD_CONFIGURE_SCHEMA = vol.Schema( + { + vol.Required(CONF_REGION, default="eu"): col_to_select(TUYA_ENDPOINTS), + 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="auto"): col_to_select( + ["auto"] + sorted(SUPPORTED_PROTOCOL_VERSIONS) + ), + 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, + vol.Optional(CONF_DEVICE_SLEEP_TIME): int, + vol.Optional(CONF_NODE_ID, default=None): vol.Any(None, cv.string), + } +) + +PICK_ENTITY_SCHEMA = vol.Schema( + {vol.Required(PLATFORM_TO_ADD, default="switch"): col_to_select(PLATFORMS)} +) + + +CONF_MASS_CONFIGURE = "mass_configure" +MASS_CONFIGURE_SCHEMA = {vol.Optional(CONF_MASS_CONFIGURE, default=False): bool} +CUSTOM_DEVICE = {"Add Device Manually": "..."} + + +class LocaltuyaConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for LocalTuya integration.""" + + VERSION = ENTRIES_VERSION + + @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(user_input) + + if not res: + return await self._create_entry(user_input) + errors["base"] = res["reason"] + # 1004 = Secret, 1106 = USER ID, 2009 = Client ID + if "1106" in res["msg"]: + res["msg"] = f"{res['msg']} Check UserID or country code!" + if "1004" in res["msg"]: + res["msg"] = f"{res['msg']} Check Secret Key!" + placeholders = {"msg": res["msg"]} + + defaults = {} + defaults.update(user_input or {}) + + return self.async_show_form( + step_id="user", + data_schema=schema_suggested_values(CLOUD_CONFIGURE_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)) + self._abort_if_unique_id_configured() + + 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(OptionsFlow): + """Handle options flow for LocalTuya integration.""" + + def __init__(self, config_entry: ConfigEntry): + """Initialize localtuya options flow.""" + self._entry_id = config_entry.entry_id + + self.selected_device = None + self.nodeID = None + + self.editing_device: bool = False + self.device_data: dict = None + self.dps_strings = [] + self.selected_platform = None + self.discovered_devices = {} + self.entities = [] + self.use_template = False + self.template_device = None + + @property + def localtuya_data(self) -> HassLocalTuyaData: + return self.hass.data[DOMAIN][self._entry_id] + + @property + def cloud_data(self) -> TuyaCloudApi: + return self.localtuya_data.cloud_data + + async def async_step_init(self, user_input=None): + """Manage basic options.""" + configure_menu = CONFIGURE_MENU.copy() + # Remove Reconfigure existing device option if there is no existed devices. + if not self.config_entry.data[CONF_DEVICES]: + configure_menu.pop(configure_menu.index(CONF_EDIT_DEVICE)) + + if not self.config_entry.data.get(CONF_NO_CLOUD, True): + self.hass.async_create_task(self.cloud_data.async_get_devices_list()) + + return self.async_show_menu(step_id="init", menu_options=configure_menu) + + async def async_step_configure_cloud(self, user_input=None): + """Handle the initial step.""" + errors = {} + placeholders = {} + if user_input is not None: + username = user_input.get(CONF_USERNAME) + 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] = "" + + return self._update_entry(new_data, new_title=username) + + cloud_api, res = await attempt_cloud_connection(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 + + return self._update_entry(new_data, new_title=username) + + 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="configure_cloud", + data_schema=schema_suggested_values(CLOUD_CONFIGURE_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["Add Device Manually"]: + self.selected_device = user_input[SELECTED_DEVICE] + + if user_input.pop(CONF_MASS_CONFIGURE, False): + # Handle auto configure all recognized devices. + await self.cloud_data.async_get_devices_dps_query() + devices, fails = await setup_localtuya_devices( + self.hass, + self.localtuya_data, + self.discovered_devices, + self.cloud_data.device_list, + log_fails=True, + ) + if devices: + devices_sucessed, devices_fails = "", "" + for sucess_dev in devices.values(): + devices_sucessed += f"\n{sucess_dev[CONF_FRIENDLY_NAME]}" + for fail_dev in fails.values(): + devices_fails += f"\n{fail_dev['name']}: {fail_dev['reason']}" + + msg = f"Succeeded devices: ``{len(devices)}``\n ```{devices_sucessed}\n```" + if fails: + msg += f" \n Failed devices: ``{len(fails)}``\n ```{devices_fails}\n```" + msg += "\nClick on submit to add the devices" + + return await self.async_step_confirm( + msg=msg, + confirm_callback=lambda: self._update_entry( + devices, CONF_DEVICES + ), + ) + + 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: + self.discovered_devices, errors = await discover_devices() + + allDevices = mergeDevicesList( + self.discovered_devices, self.cloud_data.device_list + ) + + self.discovered_devices = allDevices + devices = {} + # To avoid duplicated entities we will get all devices in every hub. + entries = self.hass.config_entries.async_entries(DOMAIN) + configured_Devices = [] + for entry in entries: + for devID in entry.data[CONF_DEVICES].keys(): + configured_Devices.append(devID) + + for dev_id, dev in allDevices.items(): + if dev_id not in configured_Devices: + if dev.get(CONF_NODE_ID, None) is not None: + devices[dev_id] = "Sub Device" + else: + devices[dev_id] = dev.get(CONF_TUYA_IP, "") + + return self.async_show_form( + step_id="add_device", + data_schema=devices_schema(devices, self.cloud_data.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(): + if configured_dev.get(CONF_NODE_ID, None): + devices[dev_id] = "Sub Device" + else: + devices[dev_id] = configured_dev[CONF_HOST] + + return self.async_show_form( + step_id="edit_device", + data_schema=devices_schema( + devices, + self.cloud_data.device_list, + False, + self.config_entry.data[CONF_DEVICES], + ), + errors=errors, + ) + + async def async_step_device_setup_method(self, user_input=None): + """Manage basic options.""" + DEVICE_SETUP_METHOD = [ + "auto_configure_device", + "pick_entity_type", + "choose_template", + ] + return self.async_show_menu( + step_id="device_setup_method", + menu_options=DEVICE_SETUP_METHOD, + ) + + async def async_step_configure_device(self, user_input=None): + """Handle input of basic info.""" + errors = {} + placeholders = {} + dev_id = self.selected_device + cloud_devs = self.cloud_data.device_list + if user_input is not None: + try: + self.device_data = user_input.copy() + self.selected_device: str = dev_id or user_input.get(CONF_DEVICE_ID) + self.nodeID: str = self.nodeID or user_input.get(CONF_NODE_ID) + if dev_id is not None: + if dev_id in cloud_devs: + self.device_data[CONF_MODEL] = cloud_devs[dev_id].get( + CONF_PRODUCT_NAME + ) + # Pulls some of device data that aren't required from user in config_flow. + if device := self.discovered_devices.get(dev_id): + self.device_data[CONF_PRODUCT_KEY] = device.get("productKey") + if gateway_id := device.get(CONF_GATEWAY_ID): + self.device_data[CONF_GATEWAY_ID] = gateway_id + + # Handle Inputs on edit device mode. + if self.editing_device: + dev_config: dict = self.config_entry.data[CONF_DEVICES].get( + dev_id, {} + ) + if self.device_data.pop(EXPORT_CONFIG, False): + dev_config = self.config_entry.data[CONF_DEVICES][dev_id].copy() + await self.hass.async_add_executor_job( + templates.export_config, + dev_config, + self.device_data[CONF_FRIENDLY_NAME], + ) + return self.async_create_entry(title="", data={}) + + # We will restore device details if it's already existed! + for res_conf in [CONF_GATEWAY_ID, CONF_MODEL, CONF_PRODUCT_KEY]: + if dev_config.get(res_conf): + self.device_data[res_conf] = dev_config.get(res_conf) + + self.dps_strings = merge_dps_manual_strings( + self.device_data.get(CONF_MANUAL_DPS, ""), self.dps_strings + ) + if self.device_data.pop(CONF_ENABLE_ADD_ENTITIES, False): + self.editing_device = False + user_input[CONF_DEVICE_ID] = dev_id + self.device_data.update( + { + CONF_DEVICE_ID: dev_id, + CONF_NODE_ID: self.nodeID, + CONF_DPS_STRINGS: self.dps_strings, + } + ) + return await self.async_step_pick_entity_type() + + self.device_data.update( + { + CONF_DEVICE_ID: dev_id, + CONF_NODE_ID: self.nodeID, + CONF_DPS_STRINGS: self.dps_strings, + CONF_ENTITIES: [], + } + ) + + if len(user_input[CONF_ENTITIES]) == 0: + # If user unchecked all entities. + return self.async_abort(reason="no_entities") + + if user_input[CONF_ENTITIES]: + entity_ids = [ + int(e.split(":")[0]) for e in user_input[CONF_ENTITIES] + ] + if self.use_template: + device_config = self.template_device + else: + device_config = self.config_entry.data[CONF_DEVICES][dev_id] + self.entities = [ + entity + for entity in device_config[CONF_ENTITIES] + if int(entity[CONF_ID]) in entity_ids + ] + return await self.async_step_configure_entity() + + valid_data = await validate_input(self.localtuya_data, user_input) + self.dps_strings = valid_data[CONF_DPS_STRINGS] + # We will also get protocol version from valid date in case auto used. + self.device_data[CONF_PROTOCOL_VERSION] = valid_data[ + CONF_PROTOCOL_VERSION + ] + + return await self.async_step_device_setup_method() + # 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 (OSError, ValueError, pytuya.parser.DecodeError) as ex: + _LOGGER.debug("Unexpected exception: %s", ex) + placeholders["ex"] = str(ex) + errors["base"] = "unknown" + except Exception as ex: + _LOGGER.debug("Unexpected exception: %s", ex) + raise ex + + defaults = {} + if self.editing_device: + # If selected device exists as a config entry, load config from it + defaults = ( + self.device_data + if self.use_template + else self.config_entry.data[CONF_DEVICES][dev_id].copy() + ) + + self.nodeID = defaults.get(CONF_NODE_ID, None) + placeholders["for_device"] = f" for device `{dev_id}`" + if self.nodeID: + placeholders.update( + {"for_device": f"for Sub-Device `{dev_id}.NodeID {self.nodeID}`"} + ) + 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}"} + if self.nodeID: + placeholders = { + "for_device": f" for sub-device `{dev_id}.\nNodeID {self.nodeID}.{note}`" + } + schema = schema_suggested_values(options_schema(self.entities), **defaults) + else: + # user_in will restore input if an error occurred instead of clears all fields. + user_in = user_input or {} + defaults[CONF_PROTOCOL_VERSION] = user_in.get(CONF_PROTOCOL_VERSION, "auto") + defaults[CONF_HOST] = user_in.get(CONF_HOST, "") + defaults[CONF_DEVICE_ID] = user_in.get(CONF_DEVICE_ID, "") + defaults[CONF_LOCAL_KEY] = user_in.get(CONF_LOCAL_KEY, "") + defaults[CONF_FRIENDLY_NAME] = user_in.get(CONF_FRIENDLY_NAME, "") + defaults[CONF_NODE_ID] = user_in.get(CONF_NODE_ID, "") + + if defaults[CONF_DEVICE_ID] in [cloud_devs, self.selected_device]: + dev_id = defaults[CONF_DEVICE_ID] + + if dev_id is not None and dev_id in self.discovered_devices: + # Insert default values from discovery and cloud if present + device = self.discovered_devices.get(dev_id, {}) + defaults[CONF_HOST] = device.get(CONF_TUYA_IP) + defaults[CONF_DEVICE_ID] = device.get(CONF_TUYA_GWID) + defaults[CONF_PROTOCOL_VERSION] = device.get(CONF_TUYA_VERSION) + defaults[CONF_NODE_ID] = device.get(CONF_NODE_ID, None) + + 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_suggested_values(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_auto_configure_device(self, user_input=None): + """Handle asking which templates to use""" + + errors = {} + placeholders = {} + + # Gather the information + is_cloud = not self.config_entry.data.get(CONF_NO_CLOUD) + dev_id = self.selected_device + category = None + node_id = self.nodeID + device_data = self.cloud_data.device_list.get(dev_id) + if device_data: + category = self.cloud_data.device_list[dev_id].get(TUYA_CATEGORY, "") + + localtuya_data = { + DEVICE_CLOUD_DATA: device_data, + CONF_DPS_STRINGS: self.dps_strings, + CONF_FRIENDLY_NAME: self.device_data.get(CONF_FRIENDLY_NAME), + } + + dev_data = gen_localtuya_entities(localtuya_data, category) + + # Process to add the device to localtuya HA Config. + if dev_data: + self.entities = dev_data + return await self.async_step_pick_entity_type( + {NO_ADDITIONAL_ENTITIES: True} + ) + + if not is_cloud: + err_msg = f"This feature requires cloud API setup for now" + elif not device_data: + err_msg = f"Couldn't find your device in the cloud account you using" + elif not category: + err_msg = f"Your device category isn't supported" + elif not dev_data: + err_msg = f"Couldn't find the data for your device category: {category}." + + placeholders = {"err_msg": err_msg} + + return self.async_show_menu( + step_id="auto_configure_device", + menu_options=["device_setup_method"], + 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[CONF_DEVICES].update({dev_id: config}) + return self._update_entry(new_data) + + if user_input.get(USE_TEMPLATE): + return await self.async_step_choose_template() + + 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) + + async def async_step_choose_template(self, user_input=None): + """Handle asking which templates to use""" + if user_input is not None: + self.use_template = True + filename = user_input.get(TEMPLATES) + _config = await self.hass.async_add_executor_job( + templates.import_config, filename + ) + dev_conf = self.device_data + dev_conf[CONF_ENTITIES] = _config + dev_conf[CONF_DPS_STRINGS] = self.dps_strings + dev_conf[CONF_NODE_ID] = self.nodeID + self.device_data = dev_conf + + self.entities = dev_conf[CONF_ENTITIES] + self.template_device = self.device_data + self.editing_device = True + return await self.async_step_configure_device() + templates_list = await self.hass.async_add_executor_job( + templates.list_templates + ) + schema = vol.Schema( + {vol.Required(TEMPLATES): col_to_select(templates_list, custom_value=True)} + ) + return self.async_show_form(step_id="choose_template", data_schema=schema) + + 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]): + return self._update_entry(self.device_data) + + schema = await platform_schema( + self.hass, self.current_entity[CONF_PLATFORM], self.dps_strings, False + ) + return self.async_show_form( + step_id="entity", + errors=errors, + data_schema=schema_suggested_values(schema, **self.current_entity), + description_placeholders={ + "id": int(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] + entity[CONF_ICON] = self.current_entity.get(CONF_ICON, "") + 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 the unwanted entities. + entitesNames = [ + name.get(CONF_FRIENDLY_NAME) + for name in self.device_data[CONF_ENTITIES] + ] + 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 + and ent.original_name not in entitesNames + } + for entity_id in reg_entities.values(): + ent_reg.async_remove(entity_id) + + new_data[CONF_DEVICES][dev_id] = self.device_data + return self._update_entry(new_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 = await platform_schema( + self.hass, self.current_entity[CONF_PLATFORM], self.dps_strings, False + ) + schema = schema_suggested_values(schema, **self.current_entity) + placeholders = { + "entity": f"entity with DP {int(self.current_entity[CONF_ID])}", + "platform": self.current_entity[CONF_PLATFORM], + } + else: + available_dps = self.available_dps_strings() + schema = await platform_schema( + self.hass, 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_confirm(self, msg: str, confirm_callback: Coroutine = None): + """Create a confirmation config flow page. If submitted, the `confirm_callback` will be called.""" + if confirm_callback: + self._confirm_callback = confirm_callback + + placeholders = {} + placeholders["message"] = msg + + if not msg: + return self._confirm_callback() + + return self.async_show_form( + step_id="confirm", description_placeholders=placeholders + ) + + # menu = ["confirm", "init"] + # return self.async_show_menu( + # step_id="confirm", menu_options=menu, description_placeholders=placeholders + # ) + + @callback + def _update_entry(self, new_data, target_obj="", new_title=""): + """Update entry data and save etnry,""" + _data = copy.deepcopy(dict(self.config_entry.data)) + if target_obj: + _data[target_obj].update(new_data) + else: + _data.update(new_data) + _data[ATTR_UPDATED_AT] = str(int(time.time() * 1000)) + + self.hass.config_entries.async_update_entry( + self.config_entry, data=_data, title=new_title or self.config_entry.title + ) + return self.async_create_entry(title=new_title, data={}) + + 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 + + @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.""" + + +async def setup_localtuya_devices( + hass: HomeAssistant, + localtuya_data: HassLocalTuyaData, + discovered_devices: dict, + devices_cloud_data: dict, + log_fails=False, +): + """Return a dict of configured devices ready to import into devices data.""" + # Store devices data + devices_cfg = [] + devices = {} + fails = {} + + def update_fails(dev_id: str, reason: str, msg: str = None): + name = devices_cloud_data[dev_id].get(CONF_NAME, dev_id) + fails.update({dev_id: {"name": name, "reason": reason}}) + if log_fails: + msg = f"[ name: {name} — id: {dev_id} — reason: {reason or repr(reason)}]" + _LOGGER.warning(f"Failed to configure device: {msg}") + + # To avoid duplicated entities we will get all devices in every hub. + entries = hass.config_entries.async_entries(DOMAIN) + configured_Devices = [] + for entry in entries: + for devID in entry.data[CONF_DEVICES].keys(): + configured_Devices.append(devID) + + for dev_id, data in discovered_devices.items(): + # Skip configured devices. + if dev_id in configured_Devices: + continue + if dev_cloud_data := devices_cloud_data.get(dev_id): + # Create localtuya devices data and store them into devices_config. + device_data = { + CONF_FRIENDLY_NAME: dev_cloud_data.get(CONF_NAME, dev_id), + CONF_DEVICE_ID: dev_id, + CONF_HOST: data[CONF_TUYA_IP], + CONF_LOCAL_KEY: dev_cloud_data.get(CONF_LOCAL_KEY), + CONF_PROTOCOL_VERSION: data[CONF_TUYA_VERSION], + CONF_ENABLE_DEBUG: False, + CONF_NODE_ID: dev_cloud_data.get(CONF_NODE_ID), + CONF_MODEL: dev_cloud_data.get(CONF_MODEL), + CONF_PRODUCT_KEY: data.get("productKey"), + } + # If device is sub and has Gateway ID store gatewayID + if sub_gwid := data.get(CONF_GATEWAY_ID): + device_data.update({CONF_GATEWAY_ID: sub_gwid}) + + # Store device to device_data. + devices_cfg.append(device_data) + + # Connect to the devices to ensure the are usable. + validate_devices = [validate_input(localtuya_data, dev) for dev in devices_cfg] + results = await asyncio.gather(*validate_devices, return_exceptions=True) + + # Merge test results with devices config + for dev_cfg, result in zip(devices_cfg, results): + dev_id = dev_cfg.get(CONF_DEVICE_ID) + if not isinstance(result, dict): + update_fails(dev_id, result) + continue + devices.update({dev_id: {**dev_cfg, **result}}) + + # Configure entities. + for dev_id, dev_data in copy.deepcopy(devices).items(): + category = devices_cloud_data[dev_id].get("category") + dev_data[DEVICE_CLOUD_DATA] = devices_cloud_data[dev_id] + if category and (dps_strings := dev_data.get(CONF_DPS_STRINGS, False)): + dev_entites = gen_localtuya_entities(dev_data, category) + + # Configure entities fails + if not dev_entites: + devices.pop(dev_id) + update_fails(dev_id, f"no configured entities: {dev_entites} - {category}") + continue + + # Add configured entities + devices[dev_id].update({CONF_ENTITIES: dev_entites}) + + return devices, fails + + +async def discover_devices() -> tuple[dict[str, dict], dict[str, str]]: + """Start discovering Tuya devices within the network""" + errors = {} + discovered_devices = {} + try: + 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" + return discovered_devices, errors + + +def devices_schema( + discovered_devices, cloud_devices_list, add_custom_device=True, existed_devices={} +): + """Create schema for devices step.""" + known_devices = {} + devices = {} + for dev_id, dev_host in discovered_devices.items(): + dev_name = dev_id + # when editing devices get INFOS from stored!. + if not add_custom_device and dev_id in existed_devices.keys(): + dev_name = existed_devices[dev_id].get(CONF_FRIENDLY_NAME, dev_id) + elif dev_id in cloud_devices_list.keys(): + dev_name = cloud_devices_list[dev_id][CONF_NAME] + + known_devices[f"{dev_name} ({dev_host})"] = dev_id + continue + + devices[f"{dev_name} ({dev_host})"] = dev_id + + known_devices = dict(sorted(known_devices.items())) + devices = {**known_devices, **devices} + if add_custom_device: + devices.update(CUSTOM_DEVICE) + else: # Sort devices in edit mode. + devices = dict(sorted(devices.items())) + + schema = vol.Schema( + { + vol.Required(SELECTED_DEVICE): col_to_select(devices), + } + ) + + return schema.extend(MASS_CONFIGURE_SCHEMA) if known_devices else schema + + +def mergeDevicesList(localList: dict, cloudList: dict, addSubDevices=True) -> dict: + """Merge CloudDevices with Discovered LocalDevices (in specific ways)!""" + # try Get SubDevices. + newList = localList.copy() + for _devID, _devData in cloudList.items(): + try: + is_online = _devData.get("online", None) + sub_device = _devData.get(CONF_NODE_ID, False) + # We skip offline devices and already merged devices. + if not is_online or _devID in localList: + continue + # Make sure the device isn't already in localList. + if addSubDevices and sub_device: + # infrared are ir remote sub-devices + if _devData.get(TUYA_CATEGORY, "").startswith("infrared"): + continue + + gateway = get_gateway_by_deviceid(_devID, cloudList) + local_gw = localList.get(gateway.id) + if local_gw: + # Create a data for sub_device [cloud and local gateway] to merge it with discovered devices. + dev_data = { + _devID: { + CONF_TUYA_IP: local_gw.get(CONF_TUYA_IP), + CONF_TUYA_GWID: _devID, + CONF_TUYA_VERSION: local_gw.get(CONF_TUYA_VERSION, "auto"), + CONF_NODE_ID: _devData.get(CONF_NODE_ID, None), + CONF_GATEWAY_ID: local_gw.get(CONF_TUYA_GWID), + } + } + newList.update(dev_data) + except Exception as ex: + _LOGGER.debug(f"An error occurred while trying to pull sub-devices {ex}") + continue + return newList + + +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"): col_to_select( + sorted(SUPPORTED_PROTOCOL_VERSIONS) + ), + 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.Optional(CONF_DEVICE_SLEEP_TIME): int, + vol.Required( + CONF_ENTITIES, description={"suggested_value": entity_names} + ): cv.multi_select(entity_names), + # col_to_select(entity_names, multi_select=True) + vol.Required(CONF_ENABLE_ADD_ENTITIES, default=False): bool, + vol.Optional(EXPORT_CONFIG, default=False): bool, + } + ) + + +def schema_suggested_values(schema: vol.Schema, **defaults): + """Returns a copy of the schema with suggested values added to field descriptions.""" + new_schema = {} + for field, field_type in schema.schema.items(): + new_field = copy.copy(field) + + # We don't want to overwrite existing suggested values. + if field.schema in defaults and ( + not field.description or "suggested_value" not in field.description + ): + new_field.description = {"suggested_value": defaults[field]} + + new_schema[new_field] = field_type + return vol.Schema(new_schema) + + +def dps_string_list(dps_data: dict[str, dict], cloud_dp_codes: dict[str, dict]) -> list: + """Return list of friendly DPS values.""" + strs = [] + + # Merge DPs that found through cloud with local. + for dp, func in cloud_dp_codes.items(): + # Default Manual dp value is -1, we will replace it if it in cloud. + if dp not in dps_data or dps_data.get(dp) == -1: + value = func.get("value", "") + dps_data[dp] = f"{value}, cloud pull" + + for dp, value in dps_data.items(): + if (dp_data := cloud_dp_codes.get(dp)) and (code := dp_data.get("code")): + strs.append(f"{dp} ( code: {code} , value: {value} )") + else: + strs.append(f"{dp} ( value: {value} )") + + return sorted(strs, key=lambda i: int(i.split()[0])) + + +def gen_dps_strings(): + """Generate list of DPS values.""" + return [f"{dp} (value: ?)" for dp in range(1, 256)] + + +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 merge_dps_manual_strings(manual_dps: list, dps_strings: list): + """Split manual_dps by comma and assign -1 as default value. Return merged with dps string.""" + manual_list = [] + avaliable_dps = [dp.split(" ")[0] for dp in dps_strings] + + for dp in manual_dps.split(","): + dp = dp.strip() + if dp.isdigit() and dp not in avaliable_dps and dp != "0": + manual_list.append(f"{dp} ( value: -1 )") + + return sorted(dps_strings + manual_list, key=lambda i: int(i.split(" ")[0])) + + +async def platform_schema( + hass: HomeAssistant, platform, dps_strings, allow_id=True, yaml=False +): + """Generate input validation schema for a platform.""" + # decide default value of device by platform. + schema = {} + if yaml: + # In YAML mode we force the specified platform to match flow schema + schema[vol.Required(CONF_PLATFORM)] = col_to_select([platform]) + if allow_id: + schema[vol.Required(CONF_ID)] = col_to_select(dps_strings, is_dps=True) + schema[vol.Optional(CONF_FRIENDLY_NAME, default="")] = vol.Any(None, cv.string) + schema[ + vol.Required(CONF_ENTITY_CATEGORY, default=str(default_category(platform))) + ] = col_to_select(ENTITY_CATEGORY) + + plat_schema = await hass.async_add_import_executor_job( + flow_schema, platform, dps_strings + ) + + return vol.Schema(schema).extend(plat_schema) + + +def default_category(_platform): + """Auto Select default category depends on the platform.""" + if any(_platform in i for i in DEFAULT_CATEGORIES["CONTROL"]): + return None + elif any(_platform in i for i in DEFAULT_CATEGORIES["CONFIG"]): + return EntityCategory.CONFIG + elif any(_platform in i for i in DEFAULT_CATEGORIES["DIAGNOSTIC"]): + return EntityCategory.DIAGNOSTIC + else: + return None + + +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) + + +async def validate_input(entry_runtime: HassLocalTuyaData, data): + """Validate the user input allows us to connect.""" + logger = pytuya.ContextualLogger() + logger.set_logger(_LOGGER, data[CONF_DEVICE_ID], True, data[CONF_FRIENDLY_NAME]) + + detected_dps = {} + error = None + interface = None + reset_ids = None + close = True + bypass_connection = False # On users risk, only used for low-power power devices + bypass_handshake = False # In-case device is passive. + + cid = data.get(CONF_NODE_ID, None) + localtuya_devices = entry_runtime.devices + try: + conf_protocol = data[CONF_PROTOCOL_VERSION] + auto_protocol = conf_protocol == "auto" + # If sub device we will search if gateway is existed if not create new connection. + if ( + cid + and (existed_interface := localtuya_devices.get(data[CONF_HOST])) + and existed_interface.connected + and not existed_interface.is_connecting + ): + interface = existed_interface._interface + close = False + else: + # If 'auto' will be loop through supported protocols. + for ver in SUPPORTED_PROTOCOL_VERSIONS: + try: + version = ver if auto_protocol else conf_protocol + logger.info(f"Connecting with protocol version: {version}") + async with asyncio.timeout(5): + interface = await pytuya.connect( + data[CONF_HOST], + data[CONF_DEVICE_ID], + data[CONF_LOCAL_KEY], + float(version), + data[CONF_ENABLE_DEBUG], + ) + logger.info(f"Connected attempt to detect the device DPS") + detected_dps = await interface.detect_available_dps(cid=cid) + + # Break the loop if input isn't auto. + if not auto_protocol: + break + + # If Auto: using DPS detected we will assume this is the correct version if dps found. + if len(detected_dps) > 0: + # Set the conf_protocol to the worked version to return it and update self.device_data. + logger.info(f"Detected DPS: {detected_dps}") + conf_protocol = version + break + + # If connection to host is failed raise wrong address. + except (OSError, ValueError) as ex: + logger.error(f"Connection failed! {ex}") + error = ex + break + except: + continue + finally: + if not auto_protocol and data.get(CONF_DEVICE_SLEEP_TIME, 0) > 0: + logger.info("Low-power device configured — handshake skipped") + bypass_connection = True + if not error and not interface: + error = InvalidAuth + + if conf_reset_dpids := data.get(CONF_RESET_DPIDS): + reset_ids_str = conf_reset_dpids.split(",") + reset_ids = [int(reset_id.strip()) for reset_id in reset_ids_str] + logger.info("Reset DPIDs configured: %s (%s)", conf_reset_dpids, reset_ids) + try: + # If reset dpids set - then assume reset is needed before status. + if (reset_ids is not None) and (len(reset_ids) > 0): + logger.debug("Resetting command for DP IDs: %s", reset_ids) + # Assume we want to request status updated for the same set of DP_IDs as the reset ones. + interface.set_updatedps_list(reset_ids) + + # Reset the interface + await interface.reset(reset_ids, cid=cid) + + # Detect any other non-manual DPS strings + if not detected_dps: + detected_dps = await interface.detect_available_dps(cid=cid) + + except (ValueError, pytuya.parser.DecodeError) as ex: + error = ex + except Exception as ex: + logger.info(f"No DPS able to be detected {ex}") + detected_dps = {} + + # if manual DPs are set, merge these. + # detected_dps_device used to prevent user from bypass handshake manual dps. + detected_dps_device = detected_dps.copy() + 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) == "0": + bypass_handshake = True + continue + if str(new_dps) not in detected_dps: + detected_dps[new_dps] = -1 + + except (ConnectionRefusedError, ConnectionResetError) as ex: + raise CannotConnect from ex + except (OSError, ValueError, pytuya.parser.DecodeError) as ex: + error = ex + finally: + if interface and close: + await interface.close() + + # Get DP descriptions from the cloud, if the device is there. + cloud_dp_codes = {} + cloud_data = entry_runtime.cloud_data + if (dev_id := data.get(CONF_DEVICE_ID)) in cloud_data.device_list: + cloud_dp_codes = await cloud_data.async_get_device_functions(dev_id) + + # Indicate an error if no datapoints found as the rest of the flow + # won't work in this case + if not bypass_connection and error: + raise error + # If bypass handshake. otherwise raise failed to make handshake with device. + # --- Cloud: We will use the DPS found on cloud if exists. + # --- No cloud: user will have to input the DPS manually. + if not detected_dps_device and not ( + (cloud_dp_codes or detected_dps) and bypass_handshake + ): + raise EmptyDpsList + + logger.info("Total DPS: %s", detected_dps) + return { + CONF_DPS_STRINGS: dps_string_list(detected_dps, cloud_dp_codes), + CONF_PROTOCOL_VERSION: conf_protocol, + } + + +async def attempt_cloud_connection(user_input): + """Create device.""" + cloud_api = TuyaCloudApi( + user_input.get(CONF_REGION), + user_input.get(CONF_CLIENT_ID), + user_input.get(CONF_CLIENT_SECRET), + user_input.get(CONF_USER_ID), + ) + + msg, res = await cloud_api.async_connect() + + if res != "ok": + return cloud_api, {"reason": msg, "msg": res} + + return cloud_api, {} diff --git a/configs/home-assistant/custom_components/localtuya/const.py b/configs/home-assistant/custom_components/localtuya/const.py new file mode 100644 index 0000000..ff365ad --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/const.py @@ -0,0 +1,295 @@ +"""Constants for localtuya integration.""" + +from dataclasses import dataclass +from typing import Any +from homeassistant.const import ( + CONF_DEVICE_ID, + CONF_ENTITIES, + CONF_FRIENDLY_NAME, + CONF_HOST, + CONF_ID, + CONF_SCAN_INTERVAL, + EntityCategory, + Platform, +) + +DOMAIN = "localtuya" +DATA_DISCOVERY = "discovery" + +# Order on priority +SUPPORTED_PROTOCOL_VERSIONS = ["3.3", "3.1", "3.2", "3.4", "3.5"] + + +# Platforms in this list must support config flows +PLATFORMS = { + "Alarm Control Panel": Platform.ALARM_CONTROL_PANEL, + "Binary Sensor": Platform.BINARY_SENSOR, + "Button": Platform.BUTTON, + "Climate": Platform.CLIMATE, + "Cover": Platform.COVER, + "Fan": Platform.FAN, + "Humidifier": Platform.HUMIDIFIER, + "Light": Platform.LIGHT, + "Lock": Platform.LOCK, + "Number": Platform.NUMBER, + "Remote": Platform.REMOTE, + "Select": Platform.SELECT, + "Sensor": Platform.SENSOR, + "Siren": Platform.SIREN, + "Switch": Platform.SWITCH, + "Vacuum": Platform.VACUUM, + "Water Heater": Platform.WATER_HEATER, +} + +ATTR_CURRENT = "current" +ATTR_CURRENT_CONSUMPTION = "current_consumption" +ATTR_VOLTAGE = "voltage" +ATTR_UPDATED_AT = "updated_at" + +# Tuya Devices +CONF_TUYA_IP = "ip" +CONF_TUYA_GWID = "gwId" +CONF_TUYA_VERSION = "version" + +# Status Payloads. +RESTORE_STATES = {"0": "restore"} + + +# config flow +CONF_LOCAL_KEY = "local_key" +CONF_ENABLE_DEBUG = "enable_debug" +CONF_PROTOCOL_VERSION = "protocol_version" +CONF_NODE_ID = "node_id" +CONF_GATEWAY_ID = "gateway_id" +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_ADD_DEVICE = "add_device" +CONF_EDIT_DEVICE = "edit_device" +CONF_CONFIGURE_CLOUD = "configure_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" +CONF_DEVICE_SLEEP_TIME = "device_sleep_time" + +# ALARM +CONF_ALARM_SUPPORTED_STATES = "alarm_supported_states" + +# Binary_sensor, Siren +CONF_STATE_ON = "state_on" +CONF_RESET_TIMER = "reset_timer" + +# 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" +CONF_SCENE_VALUES = "scene_values" +CONF_SCENE_VALUES_FRIENDLY = "scene_values_friendly" + +# 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_STOP_SWITCH_DP = "stop_switch_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" +CONF_STATE_CLASS = "state_class" + +# climate +CONF_TARGET_TEMPERATURE_DP = "target_temperature_dp" +CONF_CURRENT_TEMPERATURE_DP = "current_temperature_dp" +CONF_TEMPERATURE_STEP = "temperature_step" +CONF_MIN_TEMP = "min_temperature" +CONF_MAX_TEMP = "max_temperature" +CONF_PRECISION = "precision" +CONF_TARGET_PRECISION = "target_precision" +CONF_HVAC_MODE_DP = "hvac_mode_dp" +CONF_HVAC_MODE_SET = "hvac_mode_set" +CONF_PRESET_DP = "preset_dp" +CONF_PRESET_SET = "preset_set" +CONF_HEURISTIC_ACTION = "heuristic_action" +CONF_HVAC_ACTION_DP = "hvac_action_dp" +CONF_HVAC_ACTION_SET = "hvac_action_set" +CONF_HVAC_ADD_OFF = "hvac_add_off" +CONF_ECO_DP = "eco_dp" +CONF_ECO_VALUE = "eco_value" +CONF_FAN_SPEED_LIST = "fan_speed_list" +CONF_SWING_MODE_DP = "swing_mode_dp" +CONF_SWING_MODES = "swing_modes" +CONF_SWING_HORIZONTAL_DP = "swing_horizontal_dp" +CONF_SWING_HORIZONTAL_MODES = "swing_horizontal_modes" + +# 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" +CONF_PAUSE_DP = "pause_dp" + +# number +CONF_MIN_VALUE = "min_value" +CONF_MAX_VALUE = "max_value" +CONF_STEPSIZE = "step_size" + +# select +CONF_OPTIONS = "select_options" +CONF_OPTIONS_FRIENDLY = "select_options_friendly" + +# Remote +CONF_RECEIVE_DP = "receive_dp" +CONF_KEY_STUDY_DP = "key_study_dp" + +# Lock +CONF_JAMMED_DP = "jammed_dp" +CONF_LOCK_STATE_DP = "lock_state_dp" + +# Humidifier +CONF_HUMIDIFIER_SET_HUMIDITY_DP = "humidifier_set_humidity_dp" +CONF_HUMIDIFIER_CURRENT_HUMIDITY_DP = "humidifier_current_humidity_dp" +CONF_HUMIDIFIER_MODE_DP = "humidifier_mode_dp" +CONF_HUMIDIFIER_AVAILABLE_MODES = "humidifier_available_modes" + +# Water Heater +CONF_TARGET_TEMPERATURE_LOW_DP = "target_temperature_low_dp" +CONF_TARGET_TEMPERATURE_HIGH_DP = "target_temperature_high_dp" + +# States +ATTR_STATE = "raw_state" +CONF_RESTORE_ON_RECONNECT = "restore_on_reconnect" + +# Categories +ENTITY_CATEGORY = { + "None": None, + "Configuration": EntityCategory.CONFIG, + "Diagnostic": EntityCategory.DIAGNOSTIC, +} + +# Default Categories +DEFAULT_CATEGORIES = { + "CONTROL": ["switch", "climate", "fan", "vacuum", "light"], + "CONFIG": ["select", "number", "button"], + "DIAGNOSTIC": ["sensor", "binary_sensor"], +} + + +@dataclass +class DictSelector: + """ + A class that manages the mapping between Tuya values and Home Assistant (HA) values. + If string is provided split bya comma, it will be converted to a dict. + + Attributes: + tuya_ha (dict): A dictionary mapping Tuya values (keys) to HA values (values). + reverse (bool): Swaps `tuya_ha` keys and values. + """ + + tuya_ha: dict[str, Any] + reverse: bool = False + + def __post_init__(self): + if isinstance(self.tuya_ha, str): + # Convert string into a dict with capitalized values. + self.tuya_ha = {v: v for v in self.tuya_ha.split(",")} + if self.reverse: + self.tuya_ha = {v: k for k, v in self.tuya_ha.items()} + + @property + def as_dict(self): + """Return options as dict.""" + return self.tuya_ha + + @property + def values(self) -> list: + """Return options Tuya keys.""" + return getattr(self, "_cached_keys__tuya_ha", list(self.tuya_ha.keys())) + + @property + def names(self) -> list: + """Return options HA values.""" + return getattr(self, "_cached_values_tuya_ha", list(self.tuya_ha.values())) + + def to_ha(self, value: str, default=None): + """Return the friendly name.""" + return self.tuya_ha.get(value, default) + + def to_tuya(self, name: str): + """Return the tuya value.""" + reversed_dict = getattr( + self, "_cached_reverse_tuya_ha", {v: k for k, v in self.tuya_ha.items()} + ) + return reversed_dict.get(name) + + def __repr__(self) -> str: + return "valid" if self.tuya_ha else "" + + +@dataclass +class DeviceConfig: + """Represent the main configuration for LocalTuya device.""" + + device_config: dict[str, Any] + + def __post_init__(self) -> None: + self.id: str = self.device_config[CONF_DEVICE_ID] + self.host: str = self.device_config[CONF_HOST] + self.local_key: str = self.device_config[CONF_LOCAL_KEY] + self.entities: list = self.device_config[CONF_ENTITIES] + self.protocol_version: str = self.device_config[CONF_PROTOCOL_VERSION] + self.sleep_time: int = self.device_config.get(CONF_DEVICE_SLEEP_TIME, 0) + self.scan_interval: int = self.device_config.get(CONF_SCAN_INTERVAL, 0) + self.enable_debug: bool = self.device_config.get(CONF_ENABLE_DEBUG, False) + self.name: str = self.device_config.get(CONF_FRIENDLY_NAME) + self.node_id: str | None = self.device_config.get(CONF_NODE_ID) + self.model: str = self.device_config.get(CONF_MODEL, "Tuya generic") + self.reset_dps: str = self.device_config.get(CONF_RESET_DPIDS, "") + self.manual_dps: str = self.device_config.get(CONF_MANUAL_DPS, "") + self.dps_strings: list = self.device_config.get(CONF_DPS_STRINGS, []) + + def as_dict(self): + return self.device_config diff --git a/configs/home-assistant/custom_components/localtuya/coordinator.py b/configs/home-assistant/custom_components/localtuya/coordinator.py new file mode 100644 index 0000000..467793d --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/coordinator.py @@ -0,0 +1,676 @@ +"""Tuya Device API""" + +from __future__ import annotations +import asyncio +import errno +import logging +import time +from datetime import timedelta +from typing import Any, NamedTuple + + +from homeassistant.core import HomeAssistant, CALLBACK_TYPE, callback, State +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ID, CONF_DEVICES, CONF_HOST, CONF_DEVICE_ID +from homeassistant.helpers.event import async_track_time_interval, async_call_later +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + dispatcher_send, +) + +from .core.cloud_api import TuyaCloudApi +from .core.pytuya import ( + ContextualLogger, + HEARTBEAT_INTERVAL, + TIMEOUT_CONNECT, + SubdeviceState, + TuyaListener, + TuyaProtocol, + connect as pytuya_connect, +) +from .core.pytuya.parser import DecodeError + +from .const import ( + ATTR_UPDATED_AT, + CONF_GATEWAY_ID, + CONF_LOCAL_KEY, + CONF_NODE_ID, + CONF_NO_CLOUD, + CONF_TUYA_IP, + DATA_DISCOVERY, + DOMAIN, + DeviceConfig, + RESTORE_STATES, +) + +_LOGGER = logging.getLogger(__name__) +RECONNECT_INTERVAL = timedelta(seconds=5) +# Subdevice: Offline events before disconnecting the device, around 5 minutes +MIN_OFFLINE_EVENTS = 5 * 60 // HEARTBEAT_INTERVAL + + +class HassLocalTuyaData(NamedTuple): + """LocalTuya data stored in homeassistant data object.""" + + cloud_data: TuyaCloudApi + devices: dict[str, TuyaDevice] + + +class TuyaDevice(TuyaListener, ContextualLogger): + """Cache wrapper for pytuya.TuyaInterface.""" + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry[Any], + device_config: dict, + fake_gateway=False, + ): + """Initialize the cache.""" + super().__init__() + self.hass = hass + + self._entry = entry + self._hass_entry: HassLocalTuyaData = hass.data[DOMAIN][entry.entry_id] + self._device_config = DeviceConfig(device_config.copy()) + self.id = self._device_config.id + self.local_key = self._device_config.local_key + + self._status = {} + self._interface: TuyaProtocol = None + + # For SubDevices + self.gateway: TuyaDevice = None + self.sub_devices: dict[str, TuyaDevice] = {} + self.subdevice_state = None + self._fake_gateway = fake_gateway + self._node_id: str = self._device_config.node_id + self._subdevice_off_count: int = 0 + + # last_update_time: Sleep timer, a device that reports the status every x seconds then goes into sleep. + self._last_update_time = time.monotonic() - 5 + self._pending_status: dict[str, dict[str, Any]] = {} + + self.is_closing = False + self._task_connect: asyncio.Task | None = None + self._task_reconnect: asyncio.Task | None = None + self._task_shutdown_entities: asyncio.Task | None = None + self._unsub_refresh: CALLBACK_TYPE | None = None + self._unsub_new_entity: CALLBACK_TYPE | None = None + + self._entities = [] + + self._default_reset_dpids: list | None = None + dev = self._device_config + if reset_dps := dev.reset_dps: + self._default_reset_dpids = [int(id.strip()) for id in reset_dps.split(",")] + + # This has to be done in case the device type is type_0d + self.dps_to_request = {} + for dp in dev.dps_strings: + self.dps_to_request[dp.split(" ")[0]] = None + + self.set_logger(_LOGGER, dev.id, dev.enable_debug, self.friendly_name) + + @property + def friendly_name(self): + """Name string for log prefixes.""" + name = self._device_config.name + return name if not self._fake_gateway else (name + "/G") + + @property + def connected(self): + """Return if connected to device.""" + return self._interface and self._interface.is_connected + + @property + def is_connecting(self): + """Return whether device is currently connecting.""" + return self._task_connect is not None + + @property + def is_subdevice(self): + """Return whether this is a subdevice or not.""" + return self._node_id and not self._fake_gateway + + @property + def is_sleep(self): + """Return whether the device is sleep or not.""" + if (device_sleep := self._device_config.sleep_time) > 0: + setattr(self, "low_power", True) + last_update = time.monotonic() - self._last_update_time + return last_update < device_sleep + + return False + + @property + def is_write_only(self): + """Return if this sub-device is BLE. We uses 0 in manual dps as mark for BLE devices. + + NOTE: this may not be the best way to detect if this device is BLE + """ + return self.is_subdevice and "0" in self._device_config.manual_dps.split(",") + + def add_entities(self, entities): + """Set the entities associated with this device.""" + self._entities.extend(entities) + + async def async_connect(self, _now=None) -> None: + """Connect to device if not already connected.""" + if self.is_closing or self.is_connecting: + return + + if self.connected: + return self._dispatch_status() + + self._task_connect = asyncio.create_task(self._make_connection()) + if not self.is_sleep: + await self._task_connect + + async def _connect_subdevices(self): + """Gateway: connect to sub-devices one by one.""" + if not self.sub_devices: + return + + for subdevice in self.sub_devices.values(): + if not self.connected or self.is_closing: + break + await subdevice.async_connect() + + async def _make_connection(self): + """Subscribe localtuya entity events.""" + if self.is_sleep and not self._status: + self.status_updated(RESTORE_STATES) + + name, host = self._device_config.name, self._device_config.host + retry = 0 + max_retries = 3 + update_localkey = False + + self.debug(f"Trying to connect to: {host}...", force=True) + # Connect to the device, interface should be connected for next steps. + while retry < max_retries and not self.is_closing: + retry += 1 + try: + if self.is_subdevice: + gateway = self._get_gateway() + if not gateway: + update_localkey = True + break + if not gateway.connected and gateway.is_connecting: + return await self.abort_connect() + self._interface = gateway._interface + if not self._interface: + break + if self._device_config.enable_debug: + self._interface.enable_debug(True, gateway.friendly_name) + else: + self._interface = await pytuya_connect( + self._device_config.host, + self._device_config.id, + self.local_key, + float(self._device_config.protocol_version), + self._device_config.enable_debug, + self, + ) + self._interface.enable_debug( + self._device_config.enable_debug, self.friendly_name + ) + self._interface.add_dps_to_request(self.dps_to_request) + break # Succeed break while loop + except asyncio.CancelledError: + await self.abort_connect() + self._task_connect = None + return + except OSError as e: + await self.abort_connect() + if ( + e.errno == errno.EHOSTUNREACH + and not self._status + and not self.is_sleep + ): + self.warning(f"Connection failed: {e}") + break + except Exception as ex: # pylint: disable=broad-except + await self.abort_connect() + if not self.is_sleep: + self.warning(f"Failed to connect to {host}: {str(ex)}") + if "key" in str(ex): + update_localkey = True + break + + # Get device status and configure DPS. + if self.connected and not self.is_closing: + try: + # If reset dpids set - then assume reset is needed before status. + reset_dpids = self._default_reset_dpids + if (reset_dpids is not None) and (len(reset_dpids) > 0): + self.debug(f"Resetting cmd for DP IDs: {reset_dpids}") + # Assume we want to request status updated for the same set of DP_IDs as the reset ones. + self._interface.set_updatedps_list(reset_dpids) + + # Reset the interface + await self._interface.reset(reset_dpids, cid=self._node_id) + + self.debug("Retrieving initial state") + status = await self._interface.status(cid=self._node_id) + if status is None: + raise Exception("Failed to retrieve status") + + self.status_updated(status) + except (UnicodeDecodeError, DecodeError) as e: + self.exception(f"Handshake with {host} failed: due to {type(e)}: {e}") + await self.abort_connect() + update_localkey = True + except asyncio.CancelledError as e: + await self.abort_connect() + self._task_connect = None + except Exception as e: + if not (self._fake_gateway and "Not found" in str(e)): + e = "Sub device is not connected" if self.is_subdevice else e + self.warning(f"Handshake with {host} failed due to: {e}") + await self.abort_connect() + if self.is_subdevice or "key" in str(e): + # TODO: Add exceptions for pytuya. + update_localkey = True + except: + if self._fake_gateway: + self.warning(f"Failed to use {name} as gateway.") + await self.abort_connect() + update_localkey = True + + # Connect and configure the entities, at this point the device should be ready to get commands. + if self.connected and not self.is_closing: + self.debug(f"Success: connected to: {host}", force=True) + # 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() + + if self._unsub_new_entity is None: + + def _new_entity_handler(entity_id): + self.debug(f"New entity {entity_id} was added to {host}") + self._dispatch_status() + + signal = f"localtuya_entity_{self._device_config.id}" + self._unsub_new_entity = async_dispatcher_connect( + self.hass, signal, _new_entity_handler + ) + + if (scan_inv := int(self._device_config.scan_interval)) > 0: + self._unsub_refresh = async_track_time_interval( + self.hass, self._async_refresh, timedelta(seconds=scan_inv) + ) + + self._task_connect = None + # Ensure the connected sub-device is in its gateway's sub_devices + # and reset offline/absent counters + if self.gateway: + self.gateway.sub_devices[self._node_id] = self + if self.is_subdevice: + self.subdevice_state_updated(SubdeviceState.ONLINE) + + if not self._status and "0" in self._device_config.manual_dps.split(","): + self.status_updated(RESTORE_STATES) + + if self._pending_status: + await self.set_status() + + if self.sub_devices: + asyncio.create_task(self._connect_subdevices()) + + self._interface.keep_alive(len(self.sub_devices) > 0) + + # If not connected try to handle the errors. + if not self.connected and not self.is_closing: + if update_localkey: + # Check if the cloud device info has changed! + await self._update_local_key() + if self._task_reconnect is None: + self._task_reconnect = asyncio.create_task(self._async_reconnect()) + + self._task_connect = None + + async def abort_connect(self): + """Abort the connect process to the interface[device]""" + if self.is_subdevice: + self._interface = None + self._task_connect = None + + if self._interface is not None: + await self._interface.close() + self._interface = None + + async def check_connection(self): + """Ensure that the device is not still connecting; if it is, wait for it.""" + if not self.connected and self._task_connect: + await self._task_connect + if not self.connected and self.gateway and self.gateway._task_connect: + await self.gateway._task_connect + if not self.connected: + self.error(f"Not connected to device {self._device_config.name}") + + async def close(self): + """Close connection and stop re-connect loop.""" + if self.is_closing: + return + + self.is_closing = True + + tasks = [self._task_shutdown_entities, self._task_reconnect, self._task_connect] + pending_tasks = [task for task in tasks if task and task.cancel()] + await asyncio.gather(*pending_tasks, return_exceptions=True) + + # Close subdevices first, to prevent them try to reconnect + # after gateway disconnected. + for subdevice in self.sub_devices.values(): + await subdevice.close() + + if self._unsub_new_entity: + self._unsub_new_entity() + self._unsub_new_entity = None + + if self._unsub_refresh: + self._unsub_refresh() + self._unsub_refresh = None + + await self.abort_connect() + + if self.gateway: + self.gateway.filter_subdevices() + self.debug("Closed connection", force=True) + + async def set_status(self): + """Send self._pending_status payload to device.""" + await self.check_connection() + if self._interface and self._pending_status: + payload, self._pending_status = self._pending_status.copy(), {} + try: + await self._interface.set_dps(payload, cid=self._node_id) + # bluetooth devices usually does not send updated status payload. + # NOTE: This will override the status if the BLE device fails to receive the signal. + if self.is_write_only: + self.status_updated(payload) + except (TimeoutError, Exception) as ex: + self.debug(f"Failed to set values {payload} --> {ex}", force=True) + elif not self.connected: + self.error(f"Device is not connected.") + + async def set_dp(self, state, dp_index): + """Change value of a DP of the Tuya device.""" + if self._interface is not None: + self._pending_status.update({dp_index: state}) + await asyncio.sleep(0.001) + await self.set_status() + else: + if self.is_sleep: + return self._pending_status.update({str(dp_index): state}) + + async def set_dps(self, states): + """Change value of a DPs of the Tuya device.""" + if self._interface is not None: + self._pending_status.update(states) + await asyncio.sleep(0.001) + await self.set_status() + else: + if self.is_sleep: + return self._pending_status.update(states) + + async def _async_refresh(self, _now): + if self.connected: + self.debug("Refreshing dps for device") + # This a workaround for >= 3.4 devices, since there is an issue on waiting for the correct seqno + try: + await self._interface.update_dps(cid=self._node_id) + except TimeoutError: + pass + + async def _async_reconnect(self): + """Task: continuously attempt to reconnect to the device.""" + attempts = 0 + while True: + try: + # for sub-devices, if it is reported as offline then no need for reconnect. + if ( + self.is_subdevice + and self._subdevice_off_count >= MIN_OFFLINE_EVENTS + ): + await asyncio.sleep(1) + continue + + # for sub-devices, if the gateway isn't connected then no need for reconnect. + if self.gateway and ( + not self.gateway.connected or self.gateway.is_connecting + ): + await asyncio.sleep(3) + continue + + if not self._task_connect: + await self.async_connect() + if self._task_connect: + await self._task_connect + + if self.connected: + if not self.is_sleep and attempts > 0: + self.info(f"Reconnect succeeded on attempt: {attempts}") + break + + if self.is_closing: + break + + attempts += 1 + scale = ( + 2 + if (self.subdevice_state == SubdeviceState.ABSENT) + or (attempts > MIN_OFFLINE_EVENTS) + else 1 + ) + await asyncio.sleep(scale * RECONNECT_INTERVAL.total_seconds()) + except asyncio.CancelledError as e: + self.debug(f"Reconnect task has been canceled: {e}", force=True) + break + + self._task_reconnect = None + + async def _shutdown_entities(self, exc=""): + """Shutdown device entities""" + # Delay shutdown. + if not self.is_closing: + try: + await asyncio.sleep(TIMEOUT_CONNECT + self._device_config.sleep_time) + except asyncio.CancelledError as e: + self.debug(f"Shutdown entities task has been canceled: {e}", force=True) + return + + if self.connected or self.is_sleep: + self._task_shutdown_entities = None + return + + signal = f"localtuya_{self._device_config.id}" + dispatcher_send(self.hass, signal, None) + + if self.is_closing: + return + + if self.is_subdevice: + self.info(f"Sub-device disconnected due to: {exc}") + elif hasattr(self, "low_power"): + m, s = divmod((int(time.monotonic() - self._last_update_time)), 60) + h, m = divmod(m, 60) + self.info(f"The device is still out of reach since: {h}h:{m}m:{s}s") + else: + self.info(f"Disconnected due to: {exc}") + + self._task_shutdown_entities = None + + async def _update_local_key(self): + """Retrieve updated local_key from Cloud API and update the config_entry.""" + if self._entry.data.get(CONF_NO_CLOUD, True): + return self.info("Ensure that localkey hasn't changed and it's correct") + + self.info(f"Trying to update local-key...") + dev_id = self._device_config.id + cloud_api = self._hass_entry.cloud_data + await cloud_api.async_get_devices_list(force_update=True) + + cloud_devs = cloud_api.device_list + if dev_id in cloud_devs: + cloud_localkey = cloud_devs[dev_id].get(CONF_LOCAL_KEY) + if not cloud_localkey or self.local_key == cloud_localkey: + return + + new_data = self._entry.data.copy() + self.local_key = cloud_localkey + + if self._node_id: + from .core.helpers import get_gateway_by_deviceid + + # Update Node ID. + if new_node_id := cloud_devs[dev_id].get(CONF_NODE_ID): + new_data[CONF_DEVICES][dev_id][CONF_NODE_ID] = new_node_id + + # Update Gateway ID and IP + if new_gw := get_gateway_by_deviceid(dev_id, cloud_devs): + self.info(f"Gateway ID has been updated to: {new_gw.id}") + new_data[CONF_DEVICES][dev_id][CONF_GATEWAY_ID] = new_gw.id + + discovery = self.hass.data[DOMAIN].get(DATA_DISCOVERY) + if discovery and (local_gw := discovery.devices.get(new_gw.id)): + new_ip = local_gw.get(CONF_TUYA_IP, self._device_config.host) + new_data[CONF_DEVICES][dev_id][CONF_HOST] = new_ip + self.info(f"IP has been updated to: {new_ip}") + + 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._entry, data=new_data) + self.info(f"Local-key has been updated") + + def filter_subdevices(self): + """Remove closed subdevices that are closed.""" + self.sub_devices = { + k: v for k, v in self.sub_devices.items() if not v.is_closing + } + + def _dispatch_status(self): + signal = f"localtuya_{self._device_config.id}" + dispatcher_send(self.hass, signal, self._status) + + def _handle_event(self, old_status: dict, new_status: dict): + """Handle events in HA when devices updated.""" + + def fire_event(event, data: dict): + """Fire events.""" + if f"localtuya_{event}" not in self.hass.bus.async_listeners(): + return + event_data = {CONF_DEVICE_ID: self.id, **data} + if len(event_data) > 1: + self.hass.bus.async_fire(f"localtuya_{event}", event_data) + + event_status_update = "status_update" + event_device_dp_triggered = "device_dp_triggered" + + if self._interface and old_status and new_status: + # A massive number of events that can be triggered when some devices update too quickly such as temp sensors, + # - We want only to update if status changed except for 1 DP trigger, for scene controls. + if len(self._interface.dispatched_dps) == 1: + dp, value = next(iter(self._interface.dispatched_dps.items())) + data = {"dp": dp, "value": value} + fire_event(event_device_dp_triggered, data) + if old_status != new_status: + data = {"old_status": old_status, "new_status": new_status} + fire_event(event_status_update, data) + + def _get_gateway(self): + """Return the gateway device of this sub device.""" + if not self._node_id or (gateway := self.gateway) is None: + return None # Should never happen + + # Ensure that sub-device still on the same gateway device. + if gateway.local_key != self.local_key: + if self.subdevice_state != SubdeviceState.ABSENT: + self.warning("Sub-device localkey doesn't match the gateway localkey") + # This will become ONLINE after successful connect + self.subdevice_state = SubdeviceState.ABSENT + return None + else: + return gateway + + @callback + def status_updated(self, status: dict): + """Device updated status.""" + if self._fake_gateway: + # Fake gateways are only used to pass commands no need to update status. + return + + self._last_update_time = time.monotonic() + self._handle_event(self._status, status) + self._status.update(status) + self._dispatch_status() + + @callback + def disconnected(self, exc=""): + """Device disconnected.""" + if not self._interface: + return + self._interface = None + + if self._unsub_refresh: + self._unsub_refresh() + self._unsub_refresh = None + + for subdevice in self.sub_devices.values(): + subdevice.disconnected("Gateway disconnected") + + if self._task_connect is not None: + self._task_connect.cancel() + self._task_connect = None + + # If it disconnects unexpectedly. + if self.is_closing: + return + + if self._task_reconnect is None: + self._task_reconnect = asyncio.create_task(self._async_reconnect()) + + if self._task_shutdown_entities is not None: + self._task_shutdown_entities.cancel() + self._task_shutdown_entities = asyncio.create_task( + self._shutdown_entities(exc=exc) + ) + + @callback + def subdevice_state_updated(self, state: SubdeviceState): + """Handle the reported states for Sub-Devices.""" + node_id = self._node_id + old_state = self.subdevice_state + self.subdevice_state = state + + # This will trigger if state is absent twice. + if state == SubdeviceState.ABSENT: + if old_state == state: + delay = time.monotonic() - self._last_update_time + if delay >= (HEARTBEAT_INTERVAL * 2): + self._subdevice_off_count = 0 + self.disconnected("Device is absent") + # Can be >2 subsequent payloads per one request + elif delay > HEARTBEAT_INTERVAL: + self.debug(f"Sub-device is absent for {delay:.03f}s") + return + elif old_state == SubdeviceState.ABSENT and not self.connected: + self.info(f"Sub-device is back {node_id}") + + is_online = state == SubdeviceState.ONLINE + off_count = self._subdevice_off_count + self._subdevice_off_count = 0 if is_online else off_count + 1 + # For sub-devices, the last time it is known as not absent + self._last_update_time = time.monotonic() + + if is_online: + return self.info(f"Sub-device is online {node_id}") if off_count else None + else: + off_count += 1 + if off_count == 1: + self.warning(f"Sub-device is offline {node_id}") + elif off_count == MIN_OFFLINE_EVENTS: + self.disconnected("Device is offline") diff --git a/configs/home-assistant/custom_components/localtuya/core/__init__.py b/configs/home-assistant/custom_components/localtuya/core/__init__.py new file mode 100644 index 0000000..1f58fad --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/core/__init__.py @@ -0,0 +1 @@ +"""The core of localtuya""" diff --git a/configs/home-assistant/custom_components/localtuya/core/cloud_api.py b/configs/home-assistant/custom_components/localtuya/core/cloud_api.py new file mode 100644 index 0000000..7a959e7 --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/core/cloud_api.py @@ -0,0 +1,362 @@ +"""Class to perform requests to Tuya Cloud APIs.""" + +import aiohttp +import asyncio +import hashlib +import hmac +import json +import logging +import time + + +DEVICES_UPDATE_INTERVAL = 300 +DEVICES_UPDATE_INTERVAL_FORCED = 10 + +TUYA_ENDPOINTS = { + # Regions code + "Central Europe Data Center": "eu", + "China Data Center": "cn", + "Eastern America Data Center": "ea", + "India Data Center": "in", + "Western America Data Center": "us", + "Western Europe Data Center": "we", + "Singapore Data Center": "sg", +} + + +# 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 CustomAdapter(logging.LoggerAdapter): + """Adapter logger for cloud api.""" + + def process(self, msg, kwargs): + return f"[{self.extra.get('prefix', '')}] {msg}", kwargs + + +class AioHttpSession: + """ + A class to manage a shared aiohttp.ClientSession. + Ensures that only one session is created, based on active usage counts. + """ + + _session: aiohttp.ClientSession = None + _lock = asyncio.Lock() + _active_requests = 0 + + async def __get_session(self): + """ + Create ClientSession if it doesn't exist yet. + Increases the active requests to keep track of current session uses. + + Returns: aiohttp.ClientSession + """ + if self._session is None: + self._session = aiohttp.ClientSession() + self._active_requests += 1 + return self._session + + async def __close_session(self): + """Close session only if this is the last used of session.""" + self._active_requests -= 1 + + async with self._lock: + if self._session and self._active_requests <= 0: + await self._session.close() + self._session = None + + async def __aenter__(self): + return await self.__get_session() + + async def __aexit__(self, exc_type, exc, tb): + await self.__close_session() + + +class TuyaCloudApi: + """Class to send API calls.""" + + def __init__(self, region_code, client_id, secret, user_id): + """Initialize the class.""" + self._logger = CustomAdapter( + logging.getLogger(__name__), {"prefix": user_id[:3] + "..." + user_id[-3:]} + ) + + self._session = AioHttpSession() + self._client_id = client_id + self._secret = secret + self._user_id = user_id + self._access_token = "" + self._token_expire_time: int = 0 + + if region_code == "ea": + self._base_url = "https://openapi-ueaz.tuyaus.com" + elif region_code == "we": + self._base_url = "https://openapi-weaz.tuyaeu.com" + elif region_code == "sg": + self._base_url = "https://openapi-sg.iotbing.com" + else: + self._base_url = f"https://openapi.tuya{region_code}.com" + + self.device_list = {} + self.cached_device_list = {} + + self._last_devices_update = int(time.time()) + + 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 + ) + # self._logger.debug("PAYLOAD: %s", payload) + return payload + + async def async_make_request(self, method, url, body=None, headers={}): + """Perform requests.""" + # obtain new token if expired. + if not self.token_validate and self._token_expire_time != -1: + if (res := await self.async_get_access_token()) and res != "ok": + return self._logger.debug(f"Refresh Token failed due to: {res}") + + 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 + + async with self._session as session: + try: + if method == "GET": + async with session.get( + full_url, headers=dict(default_par, **headers) + ) as resp: + return await resp.json() + + if method == "POST": + async with session.post( + full_url, + headers=dict(default_par, **headers), + data=json.dumps(body), + ) as resp: + return await resp.json() + + if method == "PUT": + async with session.put( + full_url, + headers=dict(default_par, **headers), + data=json.dumps(body), + ) as resp: + return await resp.json() + except (aiohttp.ClientConnectionError, TimeoutError) as ex: + self._logger.debug(f"Failed to send request to tuya cloud: {ex}") + return False + + async def async_get_access_token(self) -> str | None: + """Obtain a valid access token.""" + # Reset access token + self._token_expire_time = -1 + self._access_token = "" + + if not ( + resp := await self.async_make_request("GET", "/v1.0/token?grant_type=1") + ): + self._token_expire_time = 0 + return self._logger.debug(f"Failed to retrieve a valid token") + + if not resp["success"]: + self._token_expire_time = 0 + return f"Error {resp['code']}: {resp['msg']}" + + req_results = resp["result"] + + expire_time = int(req_results.get("expire_time", 3600)) + self._token_expire_time = int(time.time()) + expire_time + self._access_token = resp["result"]["access_token"] + return "ok" + + async def async_get_devices_list(self, force_update=False) -> str | None: + """Obtain the list of devices associated to a user. - force_update will ignore last update check.""" + interval = ( + DEVICES_UPDATE_INTERVAL_FORCED if force_update else DEVICES_UPDATE_INTERVAL + ) + if ( + self.device_list + and int(time.time()) - (self._last_devices_update + interval) < 0 + ): + return self._logger.debug(f"Devices has been updated a minutes ago.") + + if not ( + resp := await self.async_make_request( + "GET", url=f"/v1.0/users/{self._user_id}/devices" + ) + ): + return self._logger.debug(f"Failed to retrieve a devices list") + + if not resp["success"]: + return f"Error {resp['code']}: {resp['msg']}" + + self.device_list.update({dev["id"]: dev for dev in resp["result"]}) + + self._last_devices_update = int(time.time()) + return "ok" + + async def async_get_devices_dps_query(self): + """Update All the devices dps_data.""" + # Get Devices DPS Data. + await asyncio.wait( + asyncio.create_task(self.async_get_device_functions(devid)) + for devid in self.device_list + ) + return "ok" + + async def async_get_device_specifications(self, device_id) -> dict[str, dict]: + """Obtain the DP ID mappings for a device.""" + + if not ( + resp := await self.async_make_request( + "GET", url=f"/v1.1/devices/{device_id}/specifications" + ) + ): + return self._logger.debug(f"Failed to retrieve a device specifications") + + if not resp["success"]: + return {}, f"Error {resp['code']}: {resp['msg']}" + + return resp["result"], "ok" + + async def async_get_device_query_properties(self, device_id) -> dict[dict, str]: + """Obtain the DP ID mappings for a device correctly! Note: This won't works if the subscription expired.""" + + if not ( + resp := await self.async_make_request( + "GET", url=f"/v2.0/cloud/thing/{device_id}/shadow/properties" + ) + ): + return self._logger.debug(f"Failed to retrieve a device properties") + + if not resp["success"]: + return {}, f"Error {resp['code']}: {resp['msg']}" + + return resp["result"], "ok" + + async def async_get_device_query_things_data_model( + self, device_id + ) -> dict[str, dict]: + """Obtain the DP ID mappings for a device.""" + + if not ( + resp := await self.async_make_request( + "GET", url=f"/v2.0/cloud/thing/{device_id}/model" + ) + ): + return self._logger.debug(f"Failed to retrieve a device data model") + + if not resp["success"]: + return {}, f"Error {resp['code']}: {resp['msg']}" + + return resp["result"], "ok" + + async def async_get_device_functions(self, device_id) -> dict[str, dict]: + """Pull Devices Properties and Specifications to devices_list""" + cached = device_id in self.cached_device_list + if cached and (dps_data := self.cached_device_list[device_id].get("dps_data")): + self.device_list[device_id]["dps_data"] = dps_data + return dps_data + + device_data = {} + get_data = [ + self.async_get_device_specifications(device_id), + self.async_get_device_query_properties(device_id), + self.async_get_device_query_things_data_model(device_id), + ] + try: + specs, query_props, query_model = await asyncio.gather(*get_data) + except (Exception,) as ex: + self._logger.debug(f"Failed to get DPS functions for {device_id} - {ex}") + return + + if query_props[1] == "ok": + device_data = {str(p["dp_id"]): p for p in query_props[0].get("properties")} + if specs[1] == "ok": + for func in specs[0].get("functions", {}): + if str(func.get("dp_id")) in device_data: + device_data[str(func["dp_id"])].update(func) + elif dp_id := func.get("dp_id"): + device_data[str(dp_id)] = func + if query_model[1] == "ok": + model_data = json.loads(query_model[0]["model"]) + services = model_data.get("services", [{}])[0] + properties = services.get("properties") + for dp_data in properties if properties else {}: + refactored = { + "id": dp_data.get("abilityId"), + # "code": dp_data.get("code"), + "accessMode": dp_data.get("accessMode"), + # values: json.loads later + "values": str(dp_data.get("typeSpec")).replace("'", '"'), + } + if str(dp_data["abilityId"]) in device_data: + device_data[str(dp_data["abilityId"])].update(refactored) + else: + refactored["code"] = dp_data.get("code") + device_data[str(dp_data["abilityId"])] = refactored + + if "28841002" in str(query_props[1]): + # No permissions This affect auto configure feature. + self.device_list[device_id]["localtuya_note"] = str(query_props[1]) + + if device_data: + self.device_list[device_id]["dps_data"] = device_data + self.cached_device_list.update({device_id: self.device_list[device_id]}) + + return device_data + + async def async_connect(self): + """Connect to cloudAPI""" + if (res := await self.async_get_access_token()) and res != "ok": + self._logger.warning("Cloud API connection failed: %s", res) + return "authentication_failed", res + if res and (res := await self.async_get_devices_list()) and res != "ok": + self._logger.warning("Cloud API connection failed: %s", res) + return "device_list_failed", res + if res: + self._logger.info("Cloud API connection succeeded.") + return True, res + + @property + def token_validate(self): + """Return whether token is expired or not""" + cur_time = int(time.time()) + expire_time = self._token_expire_time - 30 + + return expire_time >= cur_time diff --git a/configs/home-assistant/custom_components/localtuya/core/ha_entities/__init__.py b/configs/home-assistant/custom_components/localtuya/core/ha_entities/__init__.py new file mode 100644 index 0000000..7d031ec --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/core/ha_entities/__init__.py @@ -0,0 +1,300 @@ +""" + Tuya Devices: https://xzetsubou.github.io/hass-localtuya/auto_configure/ + + This functionality is similar to HA Tuya, as it retrieves the category and searches for the corresponding categories. + The categories data has been improved & modified to work seamlessly with localtuya + + Device Data: You can obtain all the data for your device from Home Assistant by directly downloading the diagnostics or using entry diagnostics. + Alternative: Use Tuya IoT. + + Add a new device or modify an existing one: + 1. Make sure the device category doesn't already exist. If you are creating a new one, you can modify existing categories. + 2. In order to add a device, you need to specify the category of the device you want to add inside the entity type dictionary. + + Add entities to devices: + 1. Open the file with the name of the entity type on which you want to make changes [e.g. switches.py] and search for your device category. + 2. You can add entities inside the tuple value of the dictionary by including LocalTuyaEntity and passing the parameters for the entity configurations. + 3. These configurations include "id" (required), "icon" (optional), "device_class" (optional), "state_class" (optional), and "name" (optional) [Using COVERS as an example] + Example: "3 ( code: percent_state , value: 0 )" - Refer to the Device Data section above for more details. + current_state_dp = DPCode.PERCENT_STATE < This maps the "percent_state" code DP to the current_state_dp configuration. + + If the configuration is not DPS, it will be inserted through "custom_configs". This is used to inject any configuration into the entity configuration + Example: custom_configs={"positioning_mode": "position"}. I hope that clarifies the concept + + Check URL above for more details. +""" + +import json +from .base import LocalTuyaEntity, CONF_DPS_STRINGS, CLOUD_VALUE, DPType +from enum import Enum +from homeassistant.const import Platform, CONF_FRIENDLY_NAME, CONF_PLATFORM, CONF_ID + +import logging + +# Supported files +from .alarm_control_panels import ALARMS # not added yet +from .binary_sensors import BINARY_SENSORS +from .buttons import BUTTONS +from .climates import CLIMATES +from .covers import COVERS +from .fans import FANS +from .humidifiers import HUMIDIFIERS +from .lights import LIGHTS +from .numbers import NUMBERS +from .remotes import REMOTES +from .selects import SELECTS +from .sensors import SENSORS +from .sirens import SIRENS +from .switches import SWITCHES +from .vacuums import VACUUMS +from .locks import LOCKS +from .water_heaters import WATER_HEATERS + +# The supported PLATFORMS [ Platform: Data ] +DATA_PLATFORMS = { + Platform.ALARM_CONTROL_PANEL: ALARMS, + Platform.BINARY_SENSOR: BINARY_SENSORS, + Platform.BUTTON: BUTTONS, + Platform.CLIMATE: CLIMATES, + Platform.COVER: COVERS, + Platform.FAN: FANS, + Platform.HUMIDIFIER: HUMIDIFIERS, + Platform.LIGHT: LIGHTS, + Platform.LOCK: LOCKS, + Platform.NUMBER: NUMBERS, + Platform.REMOTE: REMOTES, + Platform.SELECT: SELECTS, + Platform.SENSOR: SENSORS, + Platform.SIREN: SIRENS, + Platform.SWITCH: SWITCHES, + Platform.VACUUM: VACUUMS, + Platform.WATER_HEATER: WATER_HEATERS, +} + +_LOGGER = logging.getLogger(__name__) + +TUYA_CATEGORY = "category" +DEVICE_CLOUD_DATA = "device_cloud_data" + + +def gen_localtuya_entities(localtuya_data: dict, tuya_category: str) -> list[dict]: + """Return localtuya entities using the data that provided from TUYA""" + detected_dps: list = localtuya_data.get(CONF_DPS_STRINGS) + + if not tuya_category or not detected_dps: + _LOGGER.debug(f"Missing category: {tuya_category} or DPS: {detected_dps}") + return + + device_name: str = localtuya_data.get(CONF_FRIENDLY_NAME).strip() + device_cloud_data: dict = localtuya_data.get(DEVICE_CLOUD_DATA, {}) + dps_data = device_cloud_data.get("dps_data", {}) + + entities = {} + + for platform, tuya_data in DATA_PLATFORMS.items(): + # TODO: Refactor needed here. + if cat_data := tuya_data.get(tuya_category): + for ent_data in cat_data: + main_confs = ent_data.data + localtuya_conf = ent_data.localtuya_conf + localtuya_entity_configs = ent_data.entity_configs + # Conditions + contains_any: list[str] = ent_data.contains_any + entity = {} + + # used_dp = 0 + for k, code in localtuya_conf.items(): + if type(code) == Enum: + code = code.value + + # If there's multi possible codes. + if isinstance(code, tuple): + for _code in code: + if any(_code in dp.lower().split() for dp in detected_dps): + code = parse_enum(_code) + break + else: + code = None + + for dp_data in detected_dps: + dp_data: str = dp_data.lower() + # Same method we use in config_flow to get dp. + dp_id = dp_data.split(" ")[0] + + if k in entity: + # if the k already configured break the loop!. + _LOGGER.debug(f"{k} Already configured with: {entity[k]}.") + break + + if contains_any is not None: + if not any(cond in dp_data for cond in contains_any): + continue + + if code and code.lower() in dp_data.split(): + entity[k] = dp_id + + # Pull dp values from cloud. still unsure to apply this to all. + # This is due to the fact that some local values may not same with the values provided from cloud. + # For now, this is applied only to numbers values. + for k, v in localtuya_entity_configs.items(): + if isinstance(v, CLOUD_VALUE): + config_dp = entity.get(v.dp_config) + dp_values = get_dp_values(config_dp, dps_data, v) or {} + + # special case for lights + # if v.value_key in dp_values and "kelvin" in k: + # value = dp_values.get(v.value_key) + # dp_values[v.value_key] = convert_to_kelvin(value) + + entity[k] = dp_values.get(v.value_key, v.default_value) + else: + entity[k] = v + + if entity: + # Entity most contains ID + if not entity.get(CONF_ID): + continue + # Workaround to Prevent duplicated id. + if entity[CONF_ID] in entities: + _LOGGER.debug(f"{device_name}: Duplicated ID: {entity}") + continue + + entity.update(main_confs) + entity[CONF_PLATFORM] = platform + entities[entity.get(CONF_ID)] = entity + _LOGGER.debug(f"{device_name}: Entity configured: {entity}") + + # sort entities by id + sorted_ids = sorted(entities, key=int) + + # convert to list of configs + list_entities = [entities.get(id) for id in sorted_ids] + + _LOGGER.debug(f"{device_name}: Configured entities: {list_entities}") + # return [] + return list_entities + + +def parse_enum(dp_code: Enum) -> str: + """Get enum value if code type is enum""" + try: + parsed_dp_code = dp_code.value + except: + parsed_dp_code = dp_code + + return parsed_dp_code + + +def get_dp_values(dp: str, dps_data: dict, req_info: CLOUD_VALUE = None) -> dict: + """Get DP Values""" + if not dp or not dps_data: + return + + dp_data = dps_data.get(dp, {}) + dp_values = dp_data.get("values") + dp_type = dp_data.get("type", "").capitalize() + + if not dp_values or not (dp_values := json.loads(dp_values)): + return + + # Some DPS doesn't have the type, in high level data. + if not dp_type and (_type := dp_values.get("type")): + dp_type = _type.capitalize() + # Fix type names. + dp_type = DPType.INTEGER if dp_type == "Value" else dp_type + + # Integer values: min, max, scale, step + if dp_values and dp_type == DPType.INTEGER: + # We only need the scaling factor, other values will be scaled from via later on. + # dp_values["min"] = scale(dp_values.get("min"), val_scale) + valid_type = req_info.prefer_type and req_info.prefer_type in (str, float, int) + pref_type = req_info.prefer_type if valid_type else int + val_scale = dp_values.get("scale", 1) + dp_values["min"] = pref_type(dp_values.get("min")) + dp_values["max"] = pref_type(dp_values.get("max")) + dp_values["step"] = pref_type(dp_values.get("step")) + + pref_type = req_info.prefer_type if valid_type else float + dp_values["scale"] = pref_type(scale(1, val_scale, float)) + + # Scale if requested. + if req_info.scale: + for v in ("min", "max", "step"): + value = dp_values[v] + dp_values[v] = pref_type(scale(value, val_scale)) + + return dp_values + + # ENUM Values: range: list of values. + if dp_values and dp_type == DPType.ENUM: + range_values = dp_values.get("range", []) + + dp_values["min"] = range_values[0] if range_values else 0 # first value + dp_values["max"] = range_values[-1] if range_values else 0 # Last value + dp_values["range"] = convert_list(range_values, req_info) + return dp_values + + # Sensors don't have type + if dp_values and not dp_type: + # we need scaling factor for sensors. + if "scale" in dp_values: + dp_values["scale"] = scale(1, dp_values["scale"], float) + return dp_values + + +def scale(value: int, scale: int, _type: type = int) -> float: + """Return scaled value.""" + value = _type(value) / (10**scale) + if value.is_integer(): + value = int(value) + return value + + +def convert_list(_list: list, req_info: CLOUD_VALUE): + """Return list to dict values.""" + if not _list: + return [] + + prefer_type = req_info.prefer_type + + if prefer_type == str: + # Return str "value1,value2,value3" + to_str = ",".join(str(v) for v in _list) + return to_str + + if prefer_type == dict: + # Return dict {value_1: Value 1, value_2: Value 2, value_3: Value 3} + to_dict = {} + for k in _list: + if k.lower() in req_info.remap_values: + k_name = req_info.remap_values.get(k.lower()) + else: + # k_name = k.replace("_", " ").capitalize() # Default name + k_name = k # Default name + if isinstance(req_info.default_value, dict): + k_name = req_info.default_value.get(k, k_name) + + if req_info.reverse_dict: + to_dict.update({k_name: k}) + else: + to_dict.update({k: k_name}) + return to_dict + + # otherwise return prefer type list + return _list + + +def convert_to_kelvin(value): + """Convert Tuya color temperature to kelvin""" + # Given data points + v0, k0 = 0, 2700 # (0, 2700) + v1, k1 = 1000, 6500 # (1000, 6500) + + # Calculate slope (m) and y-intercept (b) using the given points + m = (k1 - k0) / (v1 - v0) + b = k0 - m * v0 + + # Use the linear equation to calculate the color temperature (K) + kelvin = m * value + b + + return kelvin diff --git a/configs/home-assistant/custom_components/localtuya/core/ha_entities/alarm_control_panels.py b/configs/home-assistant/custom_components/localtuya/core/ha_entities/alarm_control_panels.py new file mode 100644 index 0000000..93625f7 --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/core/ha_entities/alarm_control_panels.py @@ -0,0 +1,48 @@ +""" + This a file contains available tuya data + https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq + Credits: official HA Tuya integration. + Modified by: xZetsubou +""" + +from .base import DPCode, LocalTuyaEntity, CLOUD_VALUE +from ...const import CONF_ALARM_SUPPORTED_STATES +from homeassistant.components.alarm_control_panel import AlarmControlPanelState + +MAP_ALARM_STATES = { + "disarmed": AlarmControlPanelState.DISARMED, + "arm": AlarmControlPanelState.ARMED_AWAY, + "home": AlarmControlPanelState.ARMED_HOME, + "sos": AlarmControlPanelState.TRIGGERED, +} + + +def localtuya_alarm(states: dict): + """Generate localtuya alarm configs""" + data = { + CONF_ALARM_SUPPORTED_STATES: CLOUD_VALUE( + states, "id", "range", dict, MAP_ALARM_STATES, True + ), + } + return data + + +# All descriptions can be found here: +# https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq +ALARMS: dict[str, tuple[LocalTuyaEntity, ...]] = { + # Alarm Host + # https://developer.tuya.com/en/docs/iot/categorymal?id=Kaiuz33clqxaf + "mal": ( + LocalTuyaEntity( + id=DPCode.MASTER_MODE, + custom_configs=localtuya_alarm( + { + AlarmControlPanelState.DISARMED: "disarmed", + AlarmControlPanelState.ARMED_AWAY: "arm", + AlarmControlPanelState.ARMED_HOME: "home", + AlarmControlPanelState.TRIGGERED: "sos", + } + ), + ), + ), +} diff --git a/configs/home-assistant/custom_components/localtuya/core/ha_entities/base.py b/configs/home-assistant/custom_components/localtuya/core/ha_entities/base.py new file mode 100644 index 0000000..dd3a48a --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/core/ha_entities/base.py @@ -0,0 +1,897 @@ +from enum import StrEnum +from dataclasses import dataclass, field +from typing import Any + +from homeassistant.const import ( + CONF_FRIENDLY_NAME, + CONF_ICON, + CONF_ENTITY_CATEGORY, + CONF_DEVICE_CLASS, + Platform, + EntityCategory, +) +from ...const import CONF_CLEAN_AREA_DP, CONF_DPS_STRINGS, CONF_STATE_CLASS + + +# Obtain values from cloud data. +@dataclass +class CLOUD_VALUE: + """Retrieve a value from stored cloud data + + `default_value`: The value that will be used if it fails to retrieve from the cloud.\n + `dp_config(str)`: The dp config key that will be used to look for the values into it.\n + `value_key(str)`: The "key" name of the targeted value.\n + `prefer_type`: Convert values + Integer: Type(value) ( int, float or str ).\n + Enums: convert the values to [dict or str split by comma, default is list].\n + `remap_values(dict)`: Used to remap dict values, if prefer_type is dict.\n + `reverse_dict(bool)`: Reverse dict keys, value, if prefer_type is dict.\n + `scale(bool)`: For integers, scale final value.\n + """ + + default_value: Any + dp_config: str + value_key: str + prefer_type: type = None + remap_values: dict[str, Any] = field(default_factory=dict) + reverse_dict: bool = False + scale: bool = False + + +class LocalTuyaEntity: + """ + Localtuya entity config. + Each platform has unique custom_configs to give the required data to validate entity setups. + e.g. Switch req( Friendly_Name and DP(Code) ) + """ + + def __init__( + self, + name: str = "", + icon: str = "", + entity_category="None", + device_class=None, + state_class=None, + custom_configs: dict[str, Any | tuple[Any, CLOUD_VALUE]] = {}, + condition_contains_any: list = None, + **kwargs, + ): + # platform, name, icon, entity_category, device_class, *key + # self.platform = platform + self.name = name + self.data = { + CONF_FRIENDLY_NAME: name, + CONF_ICON: icon, + CONF_ENTITY_CATEGORY: entity_category, + } + + # Optional + if device_class: + self.data[CONF_DEVICE_CLASS] = device_class + + # Optional + if state_class: + self.data[CONF_STATE_CLASS] = state_class + + self.entity_configs = custom_configs + + self.contains_any = condition_contains_any + + # Replace key with id if needed + if kwargs.get("key", False): + kwargs["id"] = kwargs.pop("key") + # e.g.e CONF_ID etc.. + + self.localtuya_conf = kwargs + + +class DPType(StrEnum): + """Data point types.""" + + BOOLEAN = "Boolean" + ENUM = "Enum" + INTEGER = "Integer" + JSON = "Json" + RAW = "Raw" + STRING = "String" + + +class DPCode(StrEnum): + """Data Point Codes used by Tuya. + + https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq + """ + + AC_CURRENT = "ac_current" + AC_VOLT = "ac_volt" + ADD_ELE = "add_ele" + ADD_ELE1 = "add_ele1" + ADD_ELE2 = "add_ele2" + AIR_QUALITY = "air_quality" + AIR_RETURN = "air_return" + ALARMPERIOD = "AlarmPeriod" + ALARMSWITCH = "AlarmSwitch" + ALARMTYPE = "Alarmtype" + ALARM_DELAY_TIME = "alarm_delay_time" + ALARM_LOCK = "alarm_lock" + ALARM_MESSAGE = "alarm_message" + ALARM_RINGTONE = "alarm_ringtone" + ALARM_SETTING = "alarm_setting" + ALARM_SET_1 = "alarm_set_1" + ALARM_SET_2 = "alarm_set_2" + ALARM_STATE = "alarm_state" + ALARM_SWITCH = "alarm_switch" # Alarm switch + ALARM_TIME = "alarm_time" # Alarm time + ALARM_VOLUME = "alarm_volume" # Alarm volume + ALL_ENERGY = "all_energy" + AMBIEN = "ambien" # codespell:ignore + ANGLE_HORIZONTAL = "angle_horizontal" + ANGLE_VERTICAL = "angle_vertical" + ANION = "anion" # Ionizer unit + ANTILOCK_STATUS = "antilock_status" + APN = "apn" + APN_USER_NAME = "apn_user_name" + APN_USER_PASSWORD = "apn_user_password" + APPOINTMENT_TIME = "appointment_time" + ARMING_SWITCH = "arming_switch" + ARM_DOWN_PERCENT = "arm_down_percent" + ARM_UP_PERCENT = "arm_up_percent" + AUTH_PASSWORD = "auth_password" + AUTOMATIC_LOCK = "automatic_lock" + AUTO_CLEAN = "auto_clean" + AUTO_LOCK_TIME = "auto_lock_time" + A_CURRENT = "A_Current" + A_VOLTAGE = "A_Voltage" + BACKLIGHT_SWITCH = "backlight_switch" + BALANCE_ENERGY = "balance_energy" + BASIC_ANTI_FLICKER = "basic_anti_flicker" + BASIC_DEVICE_VOLUME = "basic_device_volume" + BASIC_FLIP = "basic_flip" + BASIC_INDICATOR = "basic_indicator" + BASIC_NIGHTVISION = "basic_nightvision" + BASIC_OSD = "basic_osd" + BASIC_PRIVATE = "basic_private" + BASIC_WDR = "basic_wdr" + BASS_CONTROL = "bass_control" + BATTERY = "battery" + BATTERYSTATUS = "BatteryStatus" + BATTERY_PERCENTAGE = "battery_percentage" # Battery percentage + BATTERY_STATE = "battery_state" # Battery state + BATTERY_VALUE = "battery_value" # Battery value + BEEP = "beep" + BKLIGHT_SETTING = "bklight_setting" + BOOLRESERVED01 = "boolreserved01" + BOOLRESERVED02 = "boolreserved02" + BOOLRESERVED03 = "boolreserved03" + BREAK_CLEAN = "break_clean" + BRIGHTNESS_MAX_1 = "brightness_max_1" + BRIGHTNESS_MAX_2 = "brightness_max_2" + BRIGHTNESS_MAX_3 = "brightness_max_3" + BRIGHTNESS_MIN_1 = "brightness_min_1" + BRIGHTNESS_MIN_2 = "brightness_min_2" + BRIGHTNESS_MIN_3 = "brightness_min_3" + BRIGHT_CONTROLLER = "bright_controller" + BRIGHT_STATE = "bright_state" # Brightness status + BRIGHT_VALUE = "bright_value" # Brightness + BRIGHT_VALUE_1 = "bright_value_1" + BRIGHT_VALUE_2 = "bright_value_2" + BRIGHT_VALUE_3 = "bright_value_3" + BRIGHT_VALUE_4 = "bright_value_4" + BRIGHT_VALUE_V2 = "bright_value_v2" + B_CURRENT = "B_Current" + B_VOLTAGE = "B_Voltage" + CALLPHONE = "callphone" + CARD_BALANCE = "card_balance" + CAT_WEIGHT = "cat_weight" + CH2O_STATE = "ch2o_state" + CH2O_VALUE = "ch2o_value" + CH4_SENSOR_STATE = "ch4_sensor_state" + CH4_SENSOR_VALUE = "ch4_sensor_value" + CHARGE_CARD_NO1 = "charge_card_no1" + CHARGE_CARD_NO2 = "charge_card_no2" + CHARGE_ELECTRIC_QUANTITY = "charge_electric_quantity" + CHARGE_ENERGY_ONCE = "charge_energy_once" + CHARGE_MONEY = "charge_money" + CHARGE_PATTERN = "charge_pattern" + CHARGE_POWER1 = "charge_power1" + CHARGE_POWER2 = "charge_power2" + CHARGE_STATE = "charge_state" + CHARGINGOPERATION = "ChargingOperation" + CHARGING_STATE = "charging_state" + CHILDLOCK = "childlock" + CHILD_LOCK = "child_lock" # Child lock + CISTERN = "cistern" + CLEAN = "clean" + CLEANING = "cleaning" + CLEANING_NUM = "cleaning_num" + CLEAN_AREA = "clean_area" + CLEAN_RECORD = "clean_record" + CLEAN_TIME = "clean_time" + CLEARAPPOINTMENT = "ClearAppointment" + CLEAR_ENERGY = "clear_energy" + CLICK_SUSTAIN_TIME = "click_sustain_time" + CLOCK_SET = "clock_set" + CLOSED_OPENED = "closed_opened" + CLOSED_OPENED_KIT = "closed_opened_kit" + CLOUD_RECIPE_NUMBER = "cloud_recipe_number" + CO2_STATE = "co2_state" + CO2_VALUE = "co2_value" # CO2 concentration + COEF_B_RESET = "coef_b_reset" + COIL_OUT = "coil_out" + COLD_TEMP_CURRENT = "cold_temp_current" + COLLECTION_MODE = "collection_mode" + COLOR_DATA_V2 = "color_data_v2" + COLOUR_DATA = "colour_data" # Colored light mode + COLOUR_DATA_HSV = "colour_data_hsv" # Colored light mode + COLOUR_DATA_RAW = "colour_data_raw" # Colored light mode for BLE + COLOUR_DATA_V2 = "colour_data_v2" # Colored light mode + COMPRESSOR_COMMAND = "compressor_command" + CONCENTRATION_SET = "concentration_set" # Concentration setting + CONTROL = "control" + CONTROL_2 = "control_2" + CONTROL_3 = "control_3" + CONTROL_4 = "control_4" + CONTROL_BACK = "control_back" + CONTROL_BACK_MODE = "control_back_mode" + COOK_TEMPERATURE = "cook_temperature" + COOK_TIME = "cook_time" + COUNTDOWN = "countdown" # Countdown + COUNTDOWN_1 = "countdown_1" # Countdown 1 + COUNTDOWN_2 = "countdown_2" # Countdown 2 + COUNTDOWN_3 = "countdown_3" # Countdown 3 + COUNTDOWN_4 = "countdown_4" # Countdown 4 + COUNTDOWN_5 = "countdown_5" # Countdown 5 + COUNTDOWN_6 = "countdown_6" # Countdown 6 + COUNTDOWN_LEFT = "countdown_left" + COUNTDOWN_SET = "countdown_set" # Countdown setting + COUNTDOWN_USB = "countdown" # Countdown + COUNTDOWN_USB1 = "countdown_usb1" # Countdown USBS 1 + COUNTDOWN_USB2 = "countdown_usb2" # Countdown USBS 2 + COUNTDOWN_USB3 = "countdown_usb3" # Countdown USBS 3 + COUNTDOWN_USB4 = "countdown_usb4" # Countdown USBS 4 + COUNTDOWN_USB5 = "countdown_usb5" # Countdown USBS 5 + COUNTDOWN_USB6 = "countdown_usb6" # Countdown USBS 6 + CO_STATE = "co_state" + CO_STATUS = "co_status" + CO_VALUE = "co_value" + CP = "cp" + CRUISE_MODE = "cruise_mode" + CRY_DETECTION_SWITCH = "cry_detection_switch" + CTIME = "Ctime" + CUP_NUMBER = "cup_number" # NUmber of cups + CURRENT_A = "current_a" + CURRENT_A_CALIBRATION = "current_a_calibration" + CURRENT_B = "current_b" + CURRENT_B_CALIBRATION = "current_b_calibration" + CURRENT_C = "current_c" + CURRENT_C_CALIBRATION = "current_c_calibration" + CUR_CURRENT = "cur_current" # Actual current + CUR_CURRENT1 = "cur_current1" + CUR_CURRENT2 = "cur_current2" + CUR_POWER = "cur_power" # Actual power + CUR_POWER1 = "cur_power1" + CUR_POWER2 = "cur_power2" + CUR_VOLTAGE = "cur_voltage" # Actual voltage + CUR_VOLTAGE1 = "cur_voltage1" + CUR_VOLTAGE2 = "cur_voltage2" + C_CURRENT = "C_Current" + C_F = "c_f" # Temperature unit switching + C_VOLTAGE = "C_Voltage" + DATA = "data" + DATA_IDENTIFICATION = "data_identification" + DATA_OVERFLOW = "data_overflow" + DAY_ENERGY = "day_energy" + DECIBEL_SENSITIVITY = "decibel_sensitivity" + DECIBEL_SWITCH = "decibel_switch" + DEFINEDIS = "definedis" + DEFROST = "defrost" + DEHUMIDITY_SET_ENUM = "dehumidify_set_enum" + DEHUMIDITY_SET_VALUE = "dehumidify_set_value" + DELAYDIS = "DelayDis" + DELAY_CLEAN_TIME = "delay_clean_time" + DELAY_SET = "delay_set" + DEODORIZATION_NUM = "deodorization_num" + DEVICEKW = "DeviceKw" + DEVICEKWH = "DeviceKwh" + DEVICEMAXSETA = "DeviceMaxSetA" + DEVICESTATE = "DeviceState" + DEVICETEMP = "DeviceTemp" + DEVICETEMP2 = "DeviceTemp2" + DEVICE_NUMBER = "device_number" + DEVICE_STATE1 = "device_state1" + DEVICE_STATE2 = "device_state2" + DIRECTION_A = "direction_a" + DIRECTION_B = "direction_b" + DIRECTION_C = "direction_c" + DIRECTION_CONTROL = "direction_control" + DISINFECTION = "disinfection" + DIS_CURRENT = "dis_current" + DM = "DM" + DOORBELL = "doorbell" + DOORBELL_SONG = "doorbell_song" + DOORCONTACT_STATE = "doorcontact_state" # Status of door window sensor + DOORCONTACT_STATE_2 = "doorcontact_state_2" + DOORCONTACT_STATE_3 = "doorcontact_state_3" + DOOR_UNCLOSED = "door_unclosed" + DOOR_UNCLOSED_TRIGGER = "door_unclosed_trigger" + DOWN_CONFIRM = "down_confirm" # cover reset. + DO_NOT_DISTURB = "do_not_disturb" + DUSTER_CLOTH = "duster_cloth" + EARTH_TEST = "earth_test" + ECO = "eco" + ECO2 = "eco2" + EDGE_BRUSH = "edge_brush" + ELECTRICITY_LEFT = "electricity_left" + ELECTRICITY_PHASE_A = "electricity_phase_a" + ELECTRICITY_PHASE_B = "electricity_phase_b" + ELECTRICITY_PHASE_C = "electricity_phase_c" + ELECTRICITY_TOTAL = "electricity_total" + EMISSION = "emission" + EMPTY = "empty" + ENERGY = "energy" + ENERGY_A_CALIBRATION_FWD = "energy_a_calibration_fwd" + ENERGY_A_CALIBRATION_REV = "energy_a_calibration_rev" + ENERGY_B_CALIBRATION_FWD = "energy_b_calibration_fwd" + ENERGY_B_CALIBRATION_REV = "energy_b_calibration_rev" + ENERGY_C_CALIBRATION_FWD = "energy_c_calibration_fwd" + ENERGY_C_CALIBRATION_REV = "energy_c_calibration_rev" + ENERGY_FORWORD_A = "energy_forword_a" + ENERGY_FORWORD_B = "energy_forword_b" + ENERGY_FORWORD_C = "energy_forword_c" + ENERGY_RESERSE_A = "energy_reserse_A" + ENERGY_RESERSE_B = "energy_reserse_b" + ENERGY_RESERSE_C = "energy_reserse_c" + ENERGY_REVERSE_A = "energy_reverse_a" + ENERGY_REVERSE_B = "energy_reverse_b" + ENERGY_REVERSE_C = "energy_reverse_c" + ENUMRESERVED01 = "enumreserved01" + ENUMRESERVED02 = "enumreserved02" + ENUMRESERVED03 = "enumreserved03" + EQUIPMENT_TIME = "equipment_time" + ERRO = "erro" # codespell:ignore + EXCRETION_TIMES_DAY = "excretion_times_day" + EXCRETION_TIME_DAY = "excretion_time_day" + FACTORY_RESET = "factory_reset" + FAN_BEEP = "fan_beep" # Sound + FAN_COOL = "fan_cool" # Cool wind + FAN_COUNTDOWN = "fan_countdown" + FAN_COUNTDOWN_2 = "fan_countdown_2" + FAN_COUNTDOWN_3 = "fan_countdown_3" + FAN_COUNTDOWN_4 = "fan_countdown_4" + FAN_DIRECTION = "fan_direction" # Fan direction + FAN_HORIZONTAL = "fan_horizontal" # Horizontal swing flap angle + FAN_MODE = "fan_mode" + FAN_SPEED = "fan_speed" + FAN_SPEED_ENUM = "fan_speed_enum" # Speed mode + FAN_SPEED_PERCENT = "fan_speed_percent" # Stepless speed + FAN_SWITCH = "fan_switch" + FAN_VERTICAL = "fan_vertical" # Vertical swing flap angle + FAR_DETECTION = "far_detection" + FAULT = "fault" + FAULTRESERVED01 = "faultreserved01" + FEED_REPORT = "feed_report" + FEED_STATE = "feed_state" + FILTER = "filter" + FILTER_LIFE = "filter" + FILTER_RESET = "filter_reset" # Filter (cartridge) reset + FLIGHT_BRIGHT_MODE = "flight_bright_mode" + FLOODLIGHT_LIGHTNESS = "floodlight_lightness" + FLOODLIGHT_SWITCH = "floodlight_switch" + FLOW_SET = "flow_set" + FORWARD_ENERGY_TOTAL = "forward_energy_total" + FOUT_WAY_VALVE = "fout_way_valve" + FREQ_CALIBRATION = "freq_calibration" + GAS_SENSOR_STATE = "gas_sensor_state" + GAS_SENSOR_STATUS = "gas_sensor_status" + GAS_SENSOR_VALUE = "gas_sensor_value" + HEAT_WD = "heat_wd" + HIGHTPROTECTVALUE = "hightprotectvalue" + HIJACK = "hijack" + HISTORY = "History" + HUMIDIFIER = "humidifier" # Humidification + HUMIDITY = "humidity" # Humidity + HUMIDITY_CURRENT = "humidity_current" # Current humidity + HUMIDITY_INDOOR = "humidity_indoor" # Indoor humidity + HUMIDITY_OUTDOOR_1 = "humidity_outdoor_1" + HUMIDITY_OUTDOOR_2 = "humidity_outdoor_2" + HUMIDITY_OUTDOOR_3 = "humidity_outdoor_3" + HUMIDITY_SET = "humidity_set" # Humidity setting + HUMIDITY_VALUE = "humidity_value" # Humidity + HUMI_STATUS = "humi_status" + HUM_ALARM = "hum_alarm" + HUM_PERIODIC_REPORT = "hum_periodic_report" + HUM_SENSITIVITY = "hum_sensitivity" + IDU_ERROR = "idu_error" + ILLUMINANCE_VALUE = "illuminance_value" + INDICATOR_LIGHT = "indicator_light" + INNERDRY = "innerdry" + INSTALLATION_HEIGHT = "installation_height" + INTERVAL_TIME = "interval_time" + IPC_WORK_MODE = "ipc_work_mode" + IR_SEND = "ir_send" + IR_STUDY_CODE = "ir_study_code" + IS_LOGIN = "is_login" + KEY_STUDY = "key_study" + KNOB_SWITCH_MODE_1 = "knob_switch_mode_1" + LCD_ONOF = "lcd_onof" + LEDLIGHT = "ledlight" + LED_TYPE_1 = "led_type_1" + LED_TYPE_2 = "led_type_2" + LED_TYPE_3 = "led_type_3" + LEVEL = "level" + LEVEL_CURRENT = "level_current" + LIGHT = "light" # Light + LIGHT_MODE = "light_mode" + LIQUID_DEPTH = "liquid_depth" + LIQUID_DEPTH_MAX = "liquid_depth_max" + LIQUID_LEVEL_PERCENT = "liquid_level_percent" + LIQUID_STATE = "liquid_state" + LOADSTATUS = "loadstatus" + LOAD_BALANCING_CURRENT = "load_balancing_current" + LOAD_BALANCING_STATE = "load_balancing_state" + LOCK = "lock" # Lock / Child lock + LOCK_MOTOR_STATE = "lock_motor_state" + LOWER_TEMP = "lower_temp" + LOWER_TEMP_F = "lower_temp_f" + LOWPROTECTVALUE = "lowprotectvalue" + LOW_POWER_THRESHOLD = "low_power_threshold" + LUX = "lux" # Ikuu SXSEN003PIR IP65 Motion Detector (Wi-Fi) + MACHINEAPPOINTMENT = "MachineAppointment" + MACHINECONTROLCMD = "MachineControlCmd" + MACHINECOVER = "MachineCover" + MACHINEERROR = "MachineError" + MACHINEERRORLOG = "MachineErrorLog" + MACHINEPARTITION = "MachinePartition" + MACHINEPASSWORD = "MachinePassword" + MACHINERAINMODE = "MachineRainMode" + MACHINESTATUS = "MachineStatus" + MACHINEWARNING = "MachineWarning" + MACHINEWORKLOG = "MachineWorkLog" + MACHINEWORKTIME = "MachineWorktime" + MACH_OPERATE = "mach_operate" + MAGNETNUM = "magnetNum" + MANUAL_CLEAN = "manual_clean" + MANUAL_FEED = "manual_feed" + MASTER_MODE = "master_mode" # alarm mode + MASTER_STATE = "master_state" # alarm mode + MATERIAL = "material" # Material + MATERIAL_TYPE = "material_type" + MAXHUM_SET = "maxhum_set" + MAXTEMP_SET = "maxtemp_set" + MAX_HUMI = "max_humi" + MAX_SET = "max_set" + MEAL_PLAN = "meal_plan" + MEASUREMENT_MODEL = "measurement_model" + MIDDLE_CONFIRM = "middle_confirm" # cover reset. + MINIHUM_SET = "minihum_set" + MINITEMP_SET = "minitemp_set" + MINI_SET = "mini_set" + MIN_HUMI = "min_humi" + MOD = "mod" # Ikuu SXSEN003PIR IP65 Motion Detector (Wi-Fi) + MODE = "mode" # Working mode / Mode + MODE_1 = "mode_1" # Working mode / Mode + MODE_2 = "mode_2" # Working mode / Mode + MODE_3 = "mode_3" # Working mode / Mode + MODE_4 = "mode_4" # Working mode / Mode + MODE_5 = "mode_5" # Working mode / Mode + MODE_6 = "mode_6" # Working mode / Mode + MOD_ON_TMR = "mod_on_tmr" # Ikuu SXSEN003PIR IP65 Motion Detector (Wi-Fi) + MOD_ON_TMR_CD = "mod_on_tmr_cd" # Ikuu SXSEN003PIR IP65 Motion Detector (Wi-Fi) + MOODLIGHTING = "moodlighting" # Mood light + MOTION_INTERVAL = "motion_interval" + MOTION_RECORD = "motion_record" + MOTION_SENSITIVITY = "motion_sensitivity" + MOTION_SWITCH = "motion_switch" # Motion switch + MOTION_TRACKING = "motion_tracking" + MOTOR_MODE = "motor_mode" + MOVEMENT_DETECT_PIC = "movement_detect_pic" + MUFFLING = "muffling" # Muffling + MUTE = "mute" + M_ADC_NUM = "M_ADC_NUM" + NEAR_DETECTION = "near_detection" + NETWORK_MODEL = "network_model" + NET_STATE = "net_state" + NORMAL_OPEN_SWITCH = "normal_open_switch" + NOTIFICATION_STATUS = "notification_status" + OCPP_TLS = "ocpp_tls" + OCPP_URL = "ocpp_url" + ODU_FAN_SPEED = "odu_fan_speed" + ONLINE_STATE = "online_state" + OPEN_CLOSE = "open_close" + OPPOSITE = "opposite" + OPTIMUMSTART = "optimumstart" + OTHEREVENT = "OtherEvent" + OUT_POWER = "out_power" + OVERCHARGE_SWITCH = "overcharge_switch" + OXYGEN = "oxygen" # Oxygen bar + PAUSE = "pause" + PEDAL_ANGLE = "pedal_angle" + PEN_PROTECT = "pen_protect" + PERCENT_CONTROL = "percent_control" + PERCENT_CONTROL_2 = "percent_control_2" + PERCENT_CONTROL_3 = "percent_control_3" + PERCENT_CONTROL_4 = "percent_control_4" + PERCENT_STATE = "percent_state" + PERCENT_STATE_2 = "percent_state_2" + PERCENT_STATE_3 = "percent_state_3" + PERCENT_STATE_4 = "percent_state_4" + PHASEFLAG = "PhaseFlag" + PHASE_A = "phase_a" + PHASE_B = "phase_b" + PHASE_C = "phase_c" + PHOTO_MODE = "photo_mode" + PILE_NUMBER = "pile_number" + PIR = "pir" # Motion sensor + PIR_RADAR = "PIR_RADAR" + PIR_SENSITIVITY = "pir_sensitivity" + PIR_STATE = "pir_state" + PIR_TIME = "pir_time" + PLANT = "plant" + PLAY_INFO = "play_info" + PLAY_MODE = "play_mode" + PLAY_TIME = "play_time" + PM1 = "pm1" + PM10 = "pm10" + PM100_STATE = "pm100_state" + PM100_VALUE = "pm100_value" + PM10_STATE = "pm10_state" + PM10_VALUE = "pm10_value" + PM25 = "pm25" + PM25_STATE = "pm25_state" + PM25_VALUE = "pm25_value" + POSITION = "position" + POWDER_SET = "powder_set" # Powder + POWER = "power" + POWEREVENT = "PowerEvent" + POWER_A = "power_a" + POWER_ADJUSTMENT = "power_adjustmen" + POWER_A_CALIBRATION = "power_a_calibration" + POWER_B = "power_b" + POWER_B_CALIBRATION = "power_b_calibration" + POWER_C = "power_c" + POWER_C_CALIBRATION = "power_c_calibration" + POWER_FACTOR = "power_factor" + POWER_FACTOR_A = "power_factor_a" + POWER_FACTOR_B = "power_factor_b" + POWER_FACTOR_C = "power_factor_c" + POWER_GO = "power_go" + POWER_TYPE = "power_type" + POWER_TYPE1 = "power_type1" + POWER_TYPE2 = "power_type2" + PRESENCE_STATE = "presence_state" + PRESSURE_STATE = "pressure_state" + PRESSURE_UNIT_CONVERT = "pressure_unit_convert" + PRESSURE_VALUE = "pressure_value" + PRM_CONTENT = "prm_content" + PRM_TEMPERATURE = "prm_temperature" + PTZ_CONTROL = "ptz_control" + PTZ_STOP = "ptz_stop" + PUMP_RESET = "pump_reset" # Water pump reset + PUMP_TIME = "pump_time" + PVRPM = "pvrpm" + PV_CURRENT = "pv_current" + PV_POWER = "pv_power" + PV_VOLT = "pv_volt" + QR_CODE_PREFIX = "qr_code_prefix" + QUERYAPPOINTMENT = "QueryAppointment" + QUERYPARTITION = "QueryPartition" + QUICK_FEED = "quick_feed" + QUIET_TIME_END = "quiet_time_end" + QUIET_TIME_START = "quiet_time_start" + QUIET_TIMING_ON = "quiet_timing_on" + RATED_CURRENT = "rated_current" + RAWRESERVED01 = "rawreserved01" + RAWRESERVED02 = "rawreserved02" + RAWRESERVED03 = "rawreserved03" + REBOOT = "reboot" + RECORD_MODE = "record_mode" + RECORD_SWITCH = "record_switch" # Recording switch + RELAY_STATUS = "relay_status" + RELAY_STATUS_1 = "relay_status_1" # Scene Switch cjkg + RELAY_STATUS_2 = "relay_status_2" # Scene Switch cjkg + RELAY_STATUS_3 = "relay_status_3" # Scene Switch cjkg + RELAY_STATUS_4 = "relay_status_4" # Scene Switch cjkg + RELAY_STATUS_5 = "relay_status_5" # Scene Switch cjkg + RELAY_STATUS_6 = "relay_status_6" # Scene Switch cjkg + RELAY_STATUS_7 = "relay_status_7" # Scene Switch cjkg + RELAY_STATUS_8 = "relay_status_8" # Scene Switch cjkg + REMAIN_TIME = "remain_time" + REMOTE_REGISTER = "remote_register" + REMOTE_UNLOCK_SWITCH = "remote_unlock_switch" + REPORT_PERIOD_SET = "report_period_set" + REPORT_RATE_CONTROL = "report_rate_control" + RESET_DUSTER_CLOTH = "reset_duster_cloth" + RESET_EDGE_BRUSH = "reset_edge_brush" + RESET_FILTER = "reset_filter" + RESET_LIMIT = "reset_limit" + RESET_MAP = "reset_map" + RESET_ROLL_BRUSH = "reset_roll_brush" + RESIDUAL_ELECTRICITY = "residual_electricity" + REVERSE_ENERGY_TOTAL = "reverse_energy_total" + RFID = "RFID" + ROLL_BRUSH = "roll_brush" + RUNNING_FAN_SPEED = "running_fan_speed" + SCENE_1 = "scene_1" + SCENE_10 = "scene_10" + SCENE_11 = "scene_11" + SCENE_12 = "scene_12" + SCENE_13 = "scene_13" + SCENE_14 = "scene_14" + SCENE_15 = "scene_15" + SCENE_16 = "scene_16" + SCENE_17 = "scene_17" + SCENE_18 = "scene_18" + SCENE_19 = "scene_19" + SCENE_2 = "scene_2" + SCENE_20 = "scene_20" + SCENE_3 = "scene_3" + SCENE_4 = "scene_4" + SCENE_5 = "scene_5" + SCENE_6 = "scene_6" + SCENE_7 = "scene_7" + SCENE_8 = "scene_8" + SCENE_9 = "scene_9" + SCENE_DATA = "scene_data" # Colored light mode + SCENE_DATA_RAW = "scene_data_raw" # Colored light mode for BLE + SCENE_DATA_V2 = "scene_data_v2" # Colored light mode + SEEK = "seek" + SENS = "sens" # Ikuu SXSEN003PIR IP65 Motion Detector (Wi-Fi) + SENSITIVITY = "sensitivity" # Sensitivity + SENSORTYPE = "sensortype" + SENSOR_HUMIDITY = "sensor_humidity" + SENSOR_LINE = "sensor_line" + SENSOR_TEMPERATURE = "sensor_temperature" + SET16A = "Set16A" + SET32A = "Set32A" + SET40A = "Set40A" + SET50A = "Set50A" + SET60A = "set60a" + SET80A = "set80a" + SETDEFINETIME = "SetDefineTime" + SETDELAYTIME = "SetDelayTime" + SETTING = "setting" + SHAKE = "shake" # Oscillating + SHOCK_STATE = "shock_state" # Vibration status + SIREN_SWITCH = "siren_switch" + SITUATION_SET = "situation_set" + SLEEP = "sleep" # Sleep function + SLEEPING = "sleeping" + SLOW_FEED = "slow_feed" + SMART_WEATHER = "smart_weather" + SMOKE_SENSOR_STATE = "smoke_sensor_state" + SMOKE_SENSOR_STATUS = "smoke_sensor_status" + SMOKE_SENSOR_VALUE = "smoke_sensor_value" + SOS = "sos" # Emergency State + SOS_STATE = "sos_state" # Emergency mode + SOUND_EFFECTS = "sound_effects" + SOUND_MODE = "sound_mode" + SOURCE = "source" + SPEED = "speed" # Speed level + SPEEK = "speek" + SPRAY_MODE = "spray_mode" # Spraying mode + SPRAY_VOLUME = "spray_volume" # Dehumidifier + STA = "sta" # Ikuu SXSEN003PIR IP65 Motion Detector (Wi-Fi) + START = "start" # Start + STATUS = "status" + STERILIZATION = "sterilization" # Sterilization + STRIP_DIRECTION = "strip_direction" + STRIP_INPUT_POS = "strip_input_pos" + STRRESERVED01 = "strreserved01" + STRRESERVED02 = "strreserved02" + STRRESERVED03 = "strreserved03" + STUDY_CODE = "study_code" + SUB_CLASS = "sub_class" + SUB_STATE = "sub_state" + SUB_TYPE = "sub_type" + SUCTION = "suction" + SWING = "swing" # Swing mode + SWITCH = "switch" # Switch + SWITCH1 = "switch1" # Switch 1 no underscore + SWITCH1_VALUE = "switch1_value" # scene switch "wxkg" + SWITCH2 = "switch2" # Switch 2 no underscore + SWITCH2_VALUE = "switch2_value" # scene switch "wxkg" + SWITCH3 = "switch3" # Switch 3 no underscore + SWITCH3_VALUE = "switch3_value" # scene switch "wxkg" + SWITCH4 = "switch4" # Switch 4 no underscore + SWITCH4_VALUE = "switch4_value" # scene switch "wxkg" + SWITCH5 = "switch5" # Switch 5 no underscore + SWITCH5_VALUE = "switch5_value" # scene switch "wxkg" + SWITCH6 = "switch6" # Switch 6 no underscore + SWITCH6_VALUE = "switch6_value" # scene switch "wxkg" + SWITCH7 = "switch7" # Switch 7 no underscore + SWITCH8 = "switch8" # Switch 8 no underscore + SWITCH_1 = "switch_1" # Switch 1 + SWITCH_2 = "switch_2" # Switch 2 + SWITCH_3 = "switch_3" # Switch 3 + SWITCH_4 = "switch_4" # Switch 4 + SWITCH_5 = "switch_5" # Switch 5 + SWITCH_6 = "switch_6" # Switch 6 + SWITCH_7 = "switch_7" # Switch 7 + SWITCH_8 = "switch_8" # Switch 8 + SWITCH_ALARM_CALL = "switch_alarm_call" + SWITCH_ALARM_LIGHT = "switch_alarm_light" + SWITCH_ALARM_PROPEL = "switch_alarm_propel" + SWITCH_ALARM_SMS = "switch_alarm_sms" + SWITCH_ALARM_SOUND = "switch_alarm_sound" + SWITCH_BACKLIGHT = "switch_backlight" # Backlight switch + SWITCH_CHARGE = "switch_charge" + SWITCH_COLD = "switch_cold" + SWITCH_CONTROLLER = "switch_controller" + SWITCH_DISTURB = "switch_disturb" + SWITCH_FAN = "switch_fan" + SWITCH_HORIZONTAL = "switch_horizontal" # Horizontal swing flap switch + SWITCH_KB_LIGHT = "switch_kb_light" + SWITCH_KB_SOUND = "switch_kb_sound" + SWITCH_LED = "switch_led" # Switch + SWITCH_LED_1 = "switch_led_1" + SWITCH_LED_2 = "switch_led_2" + SWITCH_LED_3 = "switch_led_3" + SWITCH_LED_4 = "switch_led_4" + SWITCH_NIGHT_LIGHT = "switch_night_light" + SWITCH_SAVE_ENERGY = "switch_save_energy" + SWITCH_SOUND = "switch_sound" # Voice switch + SWITCH_SPRAY = "switch_spray" # Spraying switch + SWITCH_STOP = "switch_stop" + SWITCH_TYPE_1 = "switch_type_1" + SWITCH_TYPE_2 = "switch_type_2" + SWITCH_TYPE_3 = "switch_type_3" + SWITCH_TYPE_4 = "switch_type_4" + SWITCH_TYPE_5 = "switch_type_5" + SWITCH_USB1 = "switch_usb1" # USB 1 + SWITCH_USB2 = "switch_usb2" # USB 2 + SWITCH_USB3 = "switch_usb3" # USB 3 + SWITCH_USB4 = "switch_usb4" # USB 4 + SWITCH_USB5 = "switch_usb5" # USB 5 + SWITCH_USB6 = "switch_usb6" # USB 6 + SWITCH_VERTICAL = "switch_vertical" # Vertical swing flap switch + SWITCH_VOICE = "switch_voice" # Voice switch + SWITCH_WEATHER = "switch_weather" + SWITCH_WELCOME = "switch_welcome" + SYNC_REQUEST = "sync_request" + SYNC_RESPONSE = "sync_response" + SYSTEMMODE = "systemmode" + SYSTEM_VERSION = "system_version" + TBD = "tbd" + TEMP = "temp" # Temperature setting + TEMPACTIVATE = "tempactivate" + TEMPCOMP = "tempcomp" + TEMPCURRENT = "tempcurrent" # Current temperature in °C + TEMPERATURE = "temperature" + TEMPER_ALARM = "temper_alarm" # Tamper alarm + TEMPFLOOR = "TempFloor" + TEMPPROGRAM = "tempprogram" + TEMP_ADC = "temp_adc" + TEMP_ALARM = "temp_alarm" + TEMP_BOILING_C = "temp_boiling_c" + TEMP_BOILING_F = "temp_boiling_f" + TEMP_CONTROLLER = "temp_controller" + TEMP_CURRENT = "temp_current" # Current temperature in °C + TEMP_CURRENT_EXTERNAL_1 = "temp_current_external_1" + TEMP_CURRENT_EXTERNAL_2 = "temp_current_external_2" + TEMP_CURRENT_EXTERNAL_3 = "temp_current_external_3" + TEMP_CURRENT_F = "temp_current_f" # Current temperature in °F + TEMP_INDOOR = "temp_indoor" # Indoor temperature in °C + TEMP_LOW = "temp_low" + TEMP_PERIODIC_REPORT = "temp_periodic_report" + TEMP_SENSITIVITY = "temp_sensitivity" + TEMP_SET = "temp_set" # Set the temperature in °C + TEMP_SET_F = "temp_set_f" # Set the temperature in °F + TEMP_STATUS = "temp_status" + TEMP_UNIT_CONVERT = "temp_unit_convert" # Temperature unit switching + TEMP_UP = "temp_up" + TEMP_VALUE = "temp_value" # Color temperature + TEMP_VALUE_V2 = "temp_value_v2" + TEST = "test" + TIM = "tim" # Ikuu SXSEN003PIR IP65 Motion Detector (Wi-Fi) + TIMER = "timer" + TIME_FORMAT = "Time_Format" + TIME_TOTAL = "time_total" + TIME_USE = "time_use" + TODAY_ACC_ENERGY = "today_acc_energy" + TODAY_ACC_ENERGY1 = "today_acc_energy1" + TODAY_ACC_ENERGY2 = "today_acc_energy2" + TODAY_ENERGY_ADD = "today_energy_add" + TODAY_ENERGY_ADD1 = "today_energy_add1" + TODAY_ENERGY_ADD2 = "today_energy_add2" + TOTAL_CLEAN_AREA = "total_clean_area" + TOTAL_CLEAN_COUNT = "total_clean_count" + TOTAL_CLEAN_TIME = "total_clean_time" + TOTAL_ENERGY = "total_energy" + TOTAL_ENERGY1 = "total_energy1" + TOTAL_ENERGY2 = "total_energy2" + TOTAL_FORWARD_ENERGY = "total_forward_energy" + TOTAL_PM = "total_pm" + TOTAL_POWER = "total_power" + TOTAL_TIME = "total_time" + TOUCH_WARNING = "touch_warning" + TRANSACTION_ENERGY = "transaction_energy" + TRANSACTION_MONRY = "transaction_monry" + TRANSACTION_STATUS = "transaction_status" + TRANSACTION_TIME = "transaction_time" + TRASH_STATUS = "trash_status" + TREBLE_CONTROL = "treble_control" + TVOC = "tvoc" + TV_SIZE = "tv_size" + UID = "UID" + UNLOCK_APP = "unlock_app" + UNLOCK_BLE = "unlock_ble" + UNLOCK_CARD = "unlock_card" + UNLOCK_DOUBLE = "unlock_double" + UNLOCK_DYNAMIC = "unlock_dynamic" + UNLOCK_EYE = "unlock_eye" + UNLOCK_FACE = "unlock_face" + UNLOCK_FINGERPRINT = "unlock_fingerprint" + UNLOCK_FINGER_VEIN = "unlock_finger_vein" + UNLOCK_HAND = "unlock_hand" + UNLOCK_IDENTITY_CARD = "unlock_identity_card" + UNLOCK_KEY = "unlock_key" + UNLOCK_PASSWORD = "unlock_password" + UNLOCK_PHONE_REMOTE = "unlock_phone_remote" + UNLOCK_REMOTE = "unlock_remote" + UNLOCK_REQUEST = "unlock_request" + UNLOCK_SPECIAL = "unlock_special" + UNLOCK_SWITCH = "unlock_switch" + UNLOCK_TEMPORARY = "unlock_temporary" + UNLOCK_VOICE_REMOTE = "unlock_voice_remote" + UPDATE_PASSWORD = "update_password" + UPPER_TEMP = "upper_temp" + UPPER_TEMP_F = "upper_temp_f" + UP_CONFIRM = "up_confirm" # cover reset. + USB_BZ = "usb_bz" + USE_TIME = "use_time" + USE_TIME_ONE = "use_time_one" + UV = "uv" # UV sterilization + VALUERESERVED01 = "valuereserved01" + VALUERESERVED02 = "valuereserved02" + VALUERESERVED03 = "valuereserved03" + VA_BATTERY = "va_battery" + VA_HUMIDITY = "va_humidity" + VA_TEMPERATURE = "va_temperature" + VERSION_NUMBER = "version_number" + VIDEO_INTENSITY = "video_intensity" + VIDEO_MODE = "video_mode" + VIDEO_SCENE = "video_scene" + VOC_STATE = "voc_state" + VOC_VALUE = "voc_value" + VOICE_BT_PLAY = "voice_bt_play" + VOICE_LANGUAGE = "voice_language" + VOICE_MIC = "voice_mic" + VOICE_PLAY = "voice_play" + VOICE_SWITCH = "voice_switch" + VOICE_TIMES = "voice_times" + VOICE_VOL = "voice_vol" + VOLTAGE_A = "voltage_a" + VOLTAGE_COEF = "voltage_coef" + VOLTAGE_CURRENT = "voltage_current" + VOLTAGE_PHASE_A = "voltage_phase_a" + VOLTAGE_PHASE_B = "voltage_phase_b" + VOLTAGE_PHASE_C = "voltage_phase_c" + VOLUME_SET = "volume_set" + WARM = "warm" # Heat preservation + WARM_TIME = "warm_time" # Heat preservation time + WARN_POWER = "warn_power" + WARN_POWER1 = "warn_power1" + WARN_POWER2 = "warn_power2" + WATER = "water" + WATERSENSOR_STATE = "watersensor_state" + WATER_RESET = "water_reset" # Resetting of water usage days + WATER_SET = "water_set" # Water level + WATER_TEMP = "water_temp" + WATER_USE_DATA = "water_use_data" + WEATHER_DELAY = "weather_delay" + WET = "wet" # Humidification + WINDOWDETECT = "windowdetect" + WINDOW_CHECK = "window_check" + WINDOW_STATE = "window_state" + WINDSPEED = "windspeed" + WINDSPEED_UNIT_CONVERT = "windspeed_unit_convert" + WIRELESS_BATTERYLOCK = "wireless_batterylock" + WIRELESS_ELECTRICITY = "wireless_electricity" + WORK_MODE = "work_mode" # Working mode + WORK_POWER = "work_power" + WORK_STAT = "work_stat" + WORK_STATE = "work_state" + WORK_STATUS = "work_status" + Y_MOP = "y_mop" + ZONE_ATTRIBUTE = "zone_attribute" + ZONE_NUMBER = "zone_number" diff --git a/configs/home-assistant/custom_components/localtuya/core/ha_entities/binary_sensors.py b/configs/home-assistant/custom_components/localtuya/core/ha_entities/binary_sensors.py new file mode 100644 index 0000000..cc9878a --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/core/ha_entities/binary_sensors.py @@ -0,0 +1,492 @@ +""" + This a file contains available tuya data + https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq + + Credits: official HA Tuya integration. + Modified by: xZetsubou +""" + +from .base import DPCode, LocalTuyaEntity, CONF_DEVICE_CLASS, EntityCategory +from homeassistant.components.binary_sensor import BinarySensorDeviceClass + +CONF_STATE_ON = "state_on" + +ALARM_ON = {CONF_STATE_ON: "alarm"} +STATE_TRUE = {CONF_STATE_ON: "true"} +ON_1 = {CONF_STATE_ON: "1"} +ON_FEEDING = {CONF_STATE_ON: "feeding"} +ON_PRESENCE = {CONF_STATE_ON: "presence"} + +ON_OPEN = {CONF_STATE_ON: "open"} +ON_OPENED = {CONF_STATE_ON: "opened"} + +ON_AQAB = {CONF_STATE_ON: "AQAB"} + + +def localtuya_binarySensor(state_on="1"): + """Define localtuya binary_sensor configs""" + data = {CONF_STATE_ON: state_on} + return data + + +# Commonly used sensors +TAMPER_BINARY_SENSOR = LocalTuyaEntity( + key=DPCode.TEMPER_ALARM, + name="Tamper", + device_class=BinarySensorDeviceClass.TAMPER, + entity_category=EntityCategory.DIAGNOSTIC, + custom_configs=STATE_TRUE, +) + +# Fault +FAULT_SENSOR = ( + LocalTuyaEntity( + id=DPCode.FAULT, + name="Fault", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + custom_configs=ON_1, + ), + LocalTuyaEntity( + id=DPCode.MACHINEERROR, + name="Fault", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + custom_configs=ON_1, + ), + LocalTuyaEntity( + id=DPCode.IDU_ERROR, + name="IDU Error", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + custom_configs=ON_1, + ), + # CZ - Energy monitor? + LocalTuyaEntity( + id=DPCode.POWER_TYPE, + name="Power State", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + custom_configs=localtuya_binarySensor("warn"), + ), + LocalTuyaEntity( + id=DPCode.POWER_TYPE1, + name="Power 1 State", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + custom_configs=localtuya_binarySensor("warn"), + ), + LocalTuyaEntity( + id=DPCode.POWER_TYPE2, + name="Power 2 State", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + custom_configs=localtuya_binarySensor("warn"), + ), +) + + +BINARY_SENSORS: dict[str, tuple[LocalTuyaEntity, ...]] = { + # Fan + # https://developer.tuya.com/en/docs/iot/categoryfs?id=Kaiuz1xweel1c + "fs": ( + LocalTuyaEntity( + id=DPCode.ERRO, # codespell:ignore + name="Error", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + custom_configs=ON_1, + ), + LocalTuyaEntity( + id=DPCode.USB_BZ, + name="USB", + device_class=BinarySensorDeviceClass.PLUG, + entity_category=EntityCategory.DIAGNOSTIC, + custom_configs=STATE_TRUE, + ), + ), + # Multi-functional Sensor + # https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3 + "dgnbj": ( + LocalTuyaEntity( + id=DPCode.GAS_SENSOR_STATE, + name="Gas detection", + icon="mdi:gas-cylinder", + device_class=BinarySensorDeviceClass.GAS, + custom_configs=ALARM_ON, + ), + LocalTuyaEntity( + id=DPCode.CH4_SENSOR_STATE, + name="Methane detection", + device_class=BinarySensorDeviceClass.GAS, + custom_configs=ALARM_ON, + ), + LocalTuyaEntity( + id=DPCode.VOC_STATE, + name="VOC detection", + device_class=BinarySensorDeviceClass.SAFETY, + custom_configs=ALARM_ON, + ), + LocalTuyaEntity( + id=DPCode.PM10_STATE, + name="PM1.0 detection", + device_class=BinarySensorDeviceClass.SAFETY, + custom_configs=ALARM_ON, + ), + LocalTuyaEntity( + id=DPCode.PM25_STATE, + name="PM2.5 detection", + device_class=BinarySensorDeviceClass.SAFETY, + custom_configs=ALARM_ON, + ), + LocalTuyaEntity( + id=DPCode.PM100_STATE, + name="PM10 detection", + device_class=BinarySensorDeviceClass.SAFETY, + custom_configs=ALARM_ON, + ), + LocalTuyaEntity( + id=DPCode.CO_STATE, + name="CO detection", + icon="mdi:molecule-co", + device_class=BinarySensorDeviceClass.SAFETY, + custom_configs=ALARM_ON, + ), + LocalTuyaEntity( + id=DPCode.CO2_STATE, + name="CO2 detection", + icon="mdi:molecule-co2", + device_class=BinarySensorDeviceClass.SAFETY, + custom_configs=ALARM_ON, + ), + LocalTuyaEntity( + id=DPCode.CH2O_STATE, + name="Formaldehyde detection", + device_class=BinarySensorDeviceClass.SAFETY, + custom_configs=ALARM_ON, + ), + LocalTuyaEntity( + id=DPCode.DOORCONTACT_STATE, + name="Door", + device_class=BinarySensorDeviceClass.DOOR, + custom_configs=STATE_TRUE, + ), + LocalTuyaEntity( + id=DPCode.WATERSENSOR_STATE, + device_class=BinarySensorDeviceClass.MOISTURE, + custom_configs=ALARM_ON, + ), + LocalTuyaEntity( + id=DPCode.PRESSURE_STATE, + name="Pressure", + custom_configs=ALARM_ON, + ), + LocalTuyaEntity( + id=DPCode.SMOKE_SENSOR_STATE, + name="Smoke detection", + icon="mdi:smoke-detector", + device_class=BinarySensorDeviceClass.SMOKE, + custom_configs=ALARM_ON, + ), + TAMPER_BINARY_SENSOR, + ), + # CO2 Detector + # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy + "co2bj": ( + LocalTuyaEntity( + id=DPCode.CO2_STATE, + icon="mdi:molecule-co2", + device_class=BinarySensorDeviceClass.SAFETY, + custom_configs=ALARM_ON, + ), + TAMPER_BINARY_SENSOR, + ), + # CO Detector + # https://developer.tuya.com/en/docs/iot/categorycobj?id=Kaiuz3u1j6q1v + "cobj": ( + LocalTuyaEntity( + id=DPCode.CO_STATE, + icon="mdi:molecule-co", + device_class=BinarySensorDeviceClass.SAFETY, + custom_configs=ON_1, + ), + LocalTuyaEntity( + id=DPCode.CO_STATUS, + icon="mdi:molecule-co", + device_class=BinarySensorDeviceClass.SAFETY, + custom_configs=ALARM_ON, + ), + TAMPER_BINARY_SENSOR, + ), + # Smart Pet Feeder + # https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld + "cwwsq": ( + LocalTuyaEntity( + id=DPCode.FEED_STATE, + icon="mdi:information", + custom_configs=ON_FEEDING, + ), + *FAULT_SENSOR, + ), + # Human Presence Sensor + # https://developer.tuya.com/en/docs/iot/categoryhps?id=Kaiuz42yhn1hs + "hps": ( + LocalTuyaEntity( + id=DPCode.PRESENCE_STATE, + device_class=BinarySensorDeviceClass.MOTION, + custom_configs=ON_PRESENCE, + ), + ), + # Formaldehyde Detector + # Note: Not documented + "jqbj": ( + LocalTuyaEntity( + id=DPCode.CH2O_STATE, + device_class=BinarySensorDeviceClass.SAFETY, + custom_configs=ALARM_ON, + ), + TAMPER_BINARY_SENSOR, + ), + # Methane Detector + # https://developer.tuya.com/en/docs/iot/categoryjwbj?id=Kaiuz40u98lkm + "jwbj": ( + LocalTuyaEntity( + id=DPCode.CH4_SENSOR_STATE, + device_class=BinarySensorDeviceClass.GAS, + custom_configs=ALARM_ON, + ), + TAMPER_BINARY_SENSOR, + ), + # Door and Window Controller + # https://developer.tuya.com/en/docs/iot/s?id=K9gf48r5zjsy9 + "mc": ( + LocalTuyaEntity( + id=DPCode.STATUS, + device_class=BinarySensorDeviceClass.DOOR, + custom_configs=ON_OPENED, + ), + LocalTuyaEntity( + id=DPCode.DOOR_UNCLOSED, + device_class=BinarySensorDeviceClass.DOOR, + custom_configs=STATE_TRUE, + ), + ), + # Cat litter box + # https://developer.tuya.com/en/docs/iot/f?id=Kakg309qkmuit + "msp": ( + LocalTuyaEntity( + id=DPCode.CLEANING_NUM, + name="Cleaning", + custom_configs=STATE_TRUE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.NOTIFICATION_STATUS, + name="Notification", + custom_configs=STATE_TRUE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.TRASH_STATUS, + name="Trash", + custom_configs=STATE_TRUE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.POWER, + name="Power", + device_class=BinarySensorDeviceClass.POWER, + custom_configs=STATE_TRUE, + ), + *FAULT_SENSOR, + ), + # Door Window Sensor + # https://developer.tuya.com/en/docs/iot/s?id=K9gf48hm02l8m + "mcs": ( + LocalTuyaEntity( + id=DPCode.DOORCONTACT_STATE, + device_class=BinarySensorDeviceClass.DOOR, + custom_configs=STATE_TRUE, + ), + TAMPER_BINARY_SENSOR, + ), + # Access Control + # https://developer.tuya.com/en/docs/iot/s?id=Kb0o2xhlkxbet + "mk": ( + LocalTuyaEntity( + id=DPCode.CLOSED_OPENED_KIT, + device_class=BinarySensorDeviceClass.LOCK, + custom_configs=ON_AQAB, + ), + ), + # Access Control + # https://developer.tuya.com/en/docs/iot/s?id=Kb0o2xhlkxbet + "mk": ( + LocalTuyaEntity( + id=DPCode.CLOSED_OPENED_KIT, + device_class=BinarySensorDeviceClass.LOCK, + custom_configs=ON_AQAB, + ), + ), + # Luminance Sensor + # https://developer.tuya.com/en/docs/iot/categoryldcg?id=Kaiuz3n7u69l8 + "ldcg": ( + LocalTuyaEntity( + id=DPCode.TEMPER_ALARM, + device_class=BinarySensorDeviceClass.TAMPER, + entity_category=EntityCategory.DIAGNOSTIC, + custom_configs=STATE_TRUE, + ), + TAMPER_BINARY_SENSOR, + ), + # PIR Detector + # https://developer.tuya.com/en/docs/iot/categorypir?id=Kaiuz3ss11b80 + "pir": ( + LocalTuyaEntity( + id=(DPCode.PIR, DPCode.PIR_STATE), + device_class=BinarySensorDeviceClass.MOTION, + custom_configs={CONF_STATE_ON: "pir"}, + ), + LocalTuyaEntity( + id=DPCode.STA, + device_class=BinarySensorDeviceClass.MOTION, + custom_configs={CONF_STATE_ON: "true"}, + ), + TAMPER_BINARY_SENSOR, + ), + # PM2.5 Sensor + # https://developer.tuya.com/en/docs/iot/categorypm25?id=Kaiuz3qof3yfu + "pm2.5": ( + LocalTuyaEntity( + id=DPCode.PM25_STATE, + device_class=BinarySensorDeviceClass.SAFETY, + custom_configs=ALARM_ON, + ), + TAMPER_BINARY_SENSOR, + ), + # Gas Detector + # https://developer.tuya.com/en/docs/iot/categoryrqbj?id=Kaiuz3d162ubw + "rqbj": ( + LocalTuyaEntity( + id=DPCode.GAS_SENSOR_STATUS, + device_class=BinarySensorDeviceClass.GAS, + custom_configs=ALARM_ON, + ), + LocalTuyaEntity( + id=DPCode.GAS_SENSOR_STATE, + device_class=BinarySensorDeviceClass.GAS, + custom_configs=ON_1, + ), + TAMPER_BINARY_SENSOR, + ), + # Water Detector + # https://developer.tuya.com/en/docs/iot/categorysj?id=Kaiuz3iub2sli + "sj": ( + LocalTuyaEntity( + id=DPCode.WATERSENSOR_STATE, + device_class=BinarySensorDeviceClass.MOISTURE, + custom_configs=ALARM_ON, + ), + TAMPER_BINARY_SENSOR, + ), + # Emergency Button + # https://developer.tuya.com/en/docs/iot/categorysos?id=Kaiuz3oi6agjy + "sos": ( + LocalTuyaEntity( + id=DPCode.SOS_STATE, + device_class=BinarySensorDeviceClass.SAFETY, + custom_configs=STATE_TRUE, + ), + TAMPER_BINARY_SENSOR, + ), + # Volatile Organic Compound Sensor + # Note: Undocumented in cloud API docs, based on test device + "voc": ( + LocalTuyaEntity( + id=DPCode.VOC_STATE, + device_class=BinarySensorDeviceClass.SAFETY, + custom_configs=ALARM_ON, + ), + TAMPER_BINARY_SENSOR, + ), + # Thermostatic Radiator Valve + # Not documented + "wkf": ( + LocalTuyaEntity( + id=DPCode.WINDOW_STATE, + device_class=BinarySensorDeviceClass.WINDOW, + custom_configs=ON_OPENED, + ), + ), + # Temperature and Humidity Sensor + # https://developer.tuya.com/en/docs/iot/categorywsdcg?id=Kaiuz3hinij34 + "wsdcg": (TAMPER_BINARY_SENSOR,), + # Pressure Sensor + # https://developer.tuya.com/en/docs/iot/categoryylcg?id=Kaiuz3kc2e4gm + "ylcg": ( + LocalTuyaEntity( + id=DPCode.PRESSURE_STATE, + custom_configs=ALARM_ON, + ), + TAMPER_BINARY_SENSOR, + ), + # Smoke Detector + # https://developer.tuya.com/en/docs/iot/categoryywbj?id=Kaiuz3f6sf952 + "ywbj": ( + LocalTuyaEntity( + id=DPCode.SMOKE_SENSOR_STATUS, + device_class=BinarySensorDeviceClass.SMOKE, + custom_configs=ALARM_ON, + ), + LocalTuyaEntity( + id=DPCode.SMOKE_SENSOR_STATE, + device_class=BinarySensorDeviceClass.SMOKE, + custom_configs=ALARM_ON, + condition_contains_any=["alarm"], + ), + LocalTuyaEntity( + id=DPCode.SMOKE_SENSOR_STATE, + device_class=BinarySensorDeviceClass.SMOKE, + custom_configs=ON_1, + ), + TAMPER_BINARY_SENSOR, + ), + # Vibration Sensor + # https://developer.tuya.com/en/docs/iot/categoryzd?id=Kaiuz3a5vrzno + "zd": ( + LocalTuyaEntity( + id=(DPCode.SHOCK_STATE, f"{DPCode.SHOCK_STATE}_vibration"), + device_class=BinarySensorDeviceClass.VIBRATION, + custom_configs={CONF_STATE_ON: "vibration"}, + condition_contains_any=["tilt", "true"], + ), + LocalTuyaEntity( + id=(DPCode.SHOCK_STATE, f"{DPCode.SHOCK_STATE}_drop"), + icon="mdi:icon=package-down", + custom_configs={CONF_STATE_ON: "drop"}, + condition_contains_any=["tilt", "true"], + ), + LocalTuyaEntity( + id=(DPCode.SHOCK_STATE, f"{DPCode.SHOCK_STATE}_tilt"), + name="Tilt", + icon="mdi:spirit-level", + custom_configs={CONF_STATE_ON: "tilt"}, + condition_contains_any=["tilt", "true"], + ), + ), + # EV Charcher + # https://developer.tuya.com/en/docs/iot/categoryqn?id=Kaiuz18kih0sm + "qccdz": (*FAULT_SENSOR,), +} + +BINARY_SENSORS["gcj"] = FAULT_SENSOR +BINARY_SENSORS["cl"] = FAULT_SENSOR +BINARY_SENSORS["wk"] = FAULT_SENSOR +BINARY_SENSORS["kg"] = FAULT_SENSOR +BINARY_SENSORS["pc"] = FAULT_SENSOR +BINARY_SENSORS["cz"] = FAULT_SENSOR +BINARY_SENSORS["cs"] = FAULT_SENSOR +BINARY_SENSORS["jsq"] = FAULT_SENSOR +BINARY_SENSORS["kt"] = FAULT_SENSOR +BINARY_SENSORS["sd"] = FAULT_SENSOR +BINARY_SENSORS["sfkzq"] = FAULT_SENSOR diff --git a/configs/home-assistant/custom_components/localtuya/core/ha_entities/buttons.py b/configs/home-assistant/custom_components/localtuya/core/ha_entities/buttons.py new file mode 100644 index 0000000..39530a8 --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/core/ha_entities/buttons.py @@ -0,0 +1,251 @@ +""" + This a file contains available tuya data + https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq + + Credits: official HA Tuya integration. + Modified by: xZetsubou +""" + +from .base import DPCode, LocalTuyaEntity, CONF_DEVICE_CLASS, EntityCategory + +BUTTONS: dict[str, tuple[LocalTuyaEntity, ...]] = { + # Scene Switch + # https://developer.tuya.com/en/docs/iot/f?id=K9gf7nx6jelo8 + "cjkg": ( + LocalTuyaEntity( + id=DPCode.SCENE_1, + name="Scene 1", + icon="mdi:palette", + ), + LocalTuyaEntity( + id=DPCode.SCENE_2, + name="Scene 2", + icon="mdi:palette", + ), + LocalTuyaEntity( + id=DPCode.SCENE_3, + name="Scene 3", + icon="mdi:palette", + ), + LocalTuyaEntity( + id=DPCode.SCENE_4, + name="Scene 4", + icon="mdi:palette", + ), + LocalTuyaEntity( + id=DPCode.SCENE_5, + name="Scene 5", + icon="mdi:palette", + ), + LocalTuyaEntity( + id=DPCode.SCENE_6, + name="Scene 6", + icon="mdi:palette", + ), + LocalTuyaEntity( + id=DPCode.SCENE_7, + name="Scene 7", + icon="mdi:palette", + ), + LocalTuyaEntity( + id=DPCode.SCENE_8, + name="Scene 8", + icon="mdi:palette", + ), + LocalTuyaEntity( + id=DPCode.SCENE_9, + name="Scene 9", + icon="mdi:palette", + ), + LocalTuyaEntity( + id=DPCode.SCENE_10, + name="Scene 10", + icon="mdi:palette", + ), + LocalTuyaEntity( + id=DPCode.SCENE_11, + name="Scene 11", + icon="mdi:palette", + ), + LocalTuyaEntity( + id=DPCode.SCENE_12, + name="Scene 12", + icon="mdi:palette", + ), + LocalTuyaEntity( + id=DPCode.SCENE_13, + name="Scene 13", + icon="mdi:palette", + ), + LocalTuyaEntity( + id=DPCode.SCENE_14, + name="Scene 14", + icon="mdi:palette", + ), + LocalTuyaEntity( + id=DPCode.SCENE_15, + name="Scene 15", + icon="mdi:palette", + ), + LocalTuyaEntity( + id=DPCode.SCENE_16, + name="Scene 16", + icon="mdi:palette", + ), + LocalTuyaEntity( + id=DPCode.SCENE_17, + name="Scene 17", + icon="mdi:palette", + ), + LocalTuyaEntity( + id=DPCode.SCENE_18, + name="Scene 18", + icon="mdi:palette", + ), + LocalTuyaEntity( + id=DPCode.SCENE_18, + name="Scene 18", + icon="mdi:palette", + ), + LocalTuyaEntity( + id=DPCode.SCENE_19, + name="Scene 19", + icon="mdi:palette", + ), + LocalTuyaEntity( + id=DPCode.SCENE_20, + name="Scene 20", + icon="mdi:palette", + ), + ), + # Curtain + # Note: Multiple curtains isn't documented + # https://developer.tuya.com/en/docs/iot/categorycl?id=Kaiuz1hnpo7df + "cl": ( + LocalTuyaEntity( + id=DPCode.REMOTE_REGISTER, + name="Pair Remote", + icon="mdi:remote", + entity_category=EntityCategory.CONFIG, + ), + ), + # Smart Pet Feeder + # https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld + "cwwsq": ( + LocalTuyaEntity( + id=DPCode.FACTORY_RESET, + name="Factory Reset", + icon="mdi:cog-counterclockwise", + entity_category=EntityCategory.CONFIG, + ), + ), + # Smart Pet Feeder + # https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld + "cwwsq": ( + LocalTuyaEntity( + id=DPCode.FACTORY_RESET, + name="Factory Reset", + icon="mdi:cog-counterclockwise", + entity_category=EntityCategory.CONFIG, + ), + ), + # Cat litter box + # https://developer.tuya.com/en/docs/iot/f?id=Kakg309qkmuit + "msp": ( + LocalTuyaEntity( + id=DPCode.FACTORY_RESET, + name="Factory Reset", + icon="mdi:restore", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.REBOOT, + name="Reboot", + icon="mdi:restart", + entity_category=EntityCategory.CONFIG, + ), + ), + # Robot Vacuum + # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo + "sd": ( + LocalTuyaEntity( + id=DPCode.RESET_DUSTER_CLOTH, + name="Reset Duster Cloth", + icon="mdi:restart", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.RESET_EDGE_BRUSH, + name="Reset Edge Brush", + icon="mdi:restart", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.RESET_FILTER, + name="Reset Filter", + icon="mdi:air-filter", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.RESET_MAP, + name="Reset Map", + icon="mdi:map-marker-remove", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.RESET_ROLL_BRUSH, + name="Reset Roll Brush", + icon="mdi:restart", + entity_category=EntityCategory.CONFIG, + ), + ), + # Wake Up Light II + # Not documented + "hxd": ( + LocalTuyaEntity( + id=DPCode.SWITCH_USB6, + name="Snooze", + icon="mdi:sleep", + ), + ), + "cz": ( + LocalTuyaEntity( + id=DPCode.CLEAR_ENERGY, + name="Clear Energy", + icon="mdi:lightning-bolt-circle", + entity_category=EntityCategory.CONFIG, + ), + ), + # EV Charcher + # https://developer.tuya.com/en/docs/iot/categoryqn?id=Kaiuz18kih0sm + "qccdz": ( + LocalTuyaEntity( + id=DPCode.CLEAR_ENERGY, + name="Clear Energy", + icon="mdi:lightning-bolt-circle", + entity_category=EntityCategory.CONFIG, + ), + ), + # Lawn mower + "gcj": ( + LocalTuyaEntity( + id=DPCode.CLEARAPPOINTMENT, + name="Clear schedule", + icon="mdi:calendar-remove-outline", + ), + LocalTuyaEntity( + id=DPCode.QUERYAPPOINTMENT, + name="Query schedule", + icon="mdi:calendar-search-outline", + ), + LocalTuyaEntity( + id=DPCode.QUERYPARTITION, + name="Query zones", + icon="mdi:map-search-outline", + ), + ), +} + +# Wireless Switch # also can come as knob switch. +# https://developer.tuya.com/en/docs/iot/wxkg?id=Kbeo9t3ryuqm5 +BUTTONS["wxkg"] = BUTTONS["cjkg"] diff --git a/configs/home-assistant/custom_components/localtuya/core/ha_entities/climates.py b/configs/home-assistant/custom_components/localtuya/core/ha_entities/climates.py new file mode 100644 index 0000000..349fc96 --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/core/ha_entities/climates.py @@ -0,0 +1,267 @@ +""" + This a file contains available tuya data + https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq + + Credits: official HA Tuya integration. + Modified by: xZetsubou +""" + +from homeassistant.components.climate import ( + HVACMode, + HVACAction, + DEFAULT_MAX_TEMP, + DEFAULT_MIN_TEMP, + ATTR_MAX_TEMP, + ATTR_MIN_TEMP, +) +from homeassistant.const import CONF_TEMPERATURE_UNIT + +from .base import DPCode, LocalTuyaEntity, CLOUD_VALUE +from ...const import ( + CONF_ECO_VALUE, + CONF_HVAC_ACTION_SET, + CONF_HVAC_MODE_SET, + CONF_PRECISION, + CONF_PRESET_SET, + CONF_TARGET_PRECISION, + CONF_TEMPERATURE_STEP, + CONF_HVAC_ACTION_DP, + CONF_HVAC_MODE_DP, + CONF_CURRENT_TEMPERATURE_DP, + CONF_MAX_TEMP, + CONF_MIN_TEMP, + CONF_FAN_SPEED_LIST, + CONF_FAN_SPEED_DP, + CONF_TARGET_TEMPERATURE_DP, + CONF_PRESET_DP, +) + + +UNIT_C = "celsius" +UNIT_F = "fahrenheit" + +FAN_SPEEDS_DEFAULT = "auto,low,middle,high" + + +def localtuya_climate( + hvac_mode_set=None, + temp_step=1, + actions_set=None, + echo_value=None, + preset_set=None, + fans_speeds=FAN_SPEEDS_DEFAULT, + unit=None, + min_temperature=7, + max_temperature=35, + values_precsion=0.1, + target_precision=1, +) -> dict: + """Create localtuya climate configs""" + data = {} + for key, conf in { + CONF_HVAC_MODE_SET: CLOUD_VALUE( + hvac_mode_set, CONF_HVAC_MODE_DP, "range", dict, MAP_CLIMATE_MODES, True + ), + CONF_MIN_TEMP: CLOUD_VALUE( + min_temperature, CONF_TARGET_TEMPERATURE_DP, "min", scale=True + ), + CONF_MAX_TEMP: CLOUD_VALUE( + max_temperature, CONF_TARGET_TEMPERATURE_DP, "max", scale=True + ), + CONF_TEMPERATURE_STEP: CLOUD_VALUE( + str(temp_step), CONF_TARGET_TEMPERATURE_DP, "step", str, scale=True + ), + CONF_HVAC_ACTION_SET: CLOUD_VALUE( + actions_set, CONF_HVAC_ACTION_DP, "range", dict, MAP_CLIMATE_ACTIONS, True + ), + CONF_FAN_SPEED_LIST: CLOUD_VALUE(fans_speeds, CONF_FAN_SPEED_DP, "range", str), + CONF_ECO_VALUE: echo_value, + CONF_PRESET_SET: CLOUD_VALUE(preset_set, CONF_PRESET_DP, "range", dict), + CONF_TEMPERATURE_UNIT: unit, + CONF_PRECISION: CLOUD_VALUE( + str(values_precsion), CONF_CURRENT_TEMPERATURE_DP, "scale", str + ), + CONF_TARGET_PRECISION: CLOUD_VALUE( + str(target_precision), CONF_TARGET_TEMPERATURE_DP, "scale", str + ), + }.items(): + if conf: + data.update({key: conf}) + + return data + + +# Map used for cloud value obtain. +MAP_CLIMATE_MODES = { + "off": HVACMode.OFF, + "auto": HVACMode.AUTO, + "cold": HVACMode.COOL, + "freeze": HVACMode.COOL, + "cooling": HVACMode.COOL, + "hot": HVACMode.HEAT, + "heating": HVACMode.HEAT, + "manual": HVACMode.HEAT_COOL, + "wet": HVACMode.DRY, + "dehum": HVACMode.DRY, + "wind": HVACMode.FAN_ONLY, + "fan": HVACMode.FAN_ONLY, + "off": HVACMode.OFF, + "0": HVACMode.COOL, + "1": HVACMode.HEAT, + "2": HVACMode.FAN_ONLY, +} +MAP_CLIMATE_ACTIONS = { + "heating": HVACAction.HEATING, + "cooling": HVACAction.COOLING, + "warming": HVACAction.IDLE, + "opened": HVACAction.HEATING, + "closed": HVACAction.IDLE, +} + +CLIMATES: dict[str, tuple[LocalTuyaEntity, ...]] = { + # Air conditioner + # https://developer.tuya.com/en/docs/iot/categorykt?id=Kaiuz0z71ov2n + "kt": ( + LocalTuyaEntity( + id=DPCode.SWITCH, + target_temperature_dp=(DPCode.TEMP_SET, DPCode.TEMP_SET_F), + current_temperature_dp=( + DPCode.TEMP_CURRENT, + DPCode.TEMP_CURRENT_F, + DPCode.TEMPCURRENT, + ), + hvac_mode_dp=(DPCode.SYSTEMMODE, DPCode.MODE), + hvac_action_dp=(DPCode.WORK_MODE, DPCode.WORK_STATUS, DPCode.WORK_STATE), + preset_dp=DPCode.MODE, + fan_speed_dp=(DPCode.FAN_SPEED_ENUM, DPCode.WINDSPEED), + custom_configs=localtuya_climate( + hvac_mode_set={ + HVACMode.AUTO: "auto", + HVACMode.COOL: "cold", + HVACMode.HEAT: "hot", + HVACMode.DRY: "wet", + }, + preset_set={}, + temp_step=1, + actions_set={ + HVACAction.HEATING: "heating", + HVACAction.COOLING: "cooling", + }, + unit=UNIT_C, + values_precsion=0.1, + target_precision=0.1, + ), + ), + ), + # Heater + # https://developer.tuya.com/en/docs/iot/f?id=K9gf46epy4j82 + "qn": ( + LocalTuyaEntity( + id=DPCode.SWITCH, + target_temperature_dp=(DPCode.TEMP_SET, DPCode.TEMP_SET_F), + current_temperature_dp=(DPCode.TEMP_CURRENT, DPCode.TEMP_CURRENT_F), + hvac_mode_dp=DPCode.SWITCH, + hvac_action_dp=(DPCode.WORK_STATE, DPCode.WORK_MODE, DPCode.WORK_STATUS), + preset_dp=DPCode.MODE, + fan_speed_dp=(DPCode.FAN_SPEED_ENUM, DPCode.WINDSPEED), + custom_configs=localtuya_climate( + hvac_mode_set={ + HVACMode.OFF: False, + HVACMode.HEAT: True, + }, + temp_step=1, + actions_set={ + HVACAction.HEATING: True, + HVACAction.IDLE: False, + }, + values_precsion=0.1, + target_precision=0.1, + preset_set={}, + ), + ), + ), + # Heater + # https://developer.tuya.com/en/docs/iot/categoryrs?id=Kaiuz0nfferyx + ## Converted to Water Heaters + # "rs": ( + # LocalTuyaEntity( + # id=DPCode.SWITCH, + # target_temperature_dp=(DPCode.TEMP_SET, DPCode.TEMP_SET_F), + # current_temperature_dp=(DPCode.TEMP_CURRENT, DPCode.TEMP_CURRENT_F), + # hvac_action_dp=(DPCode.WORK_STATE, DPCode.WORK_MODE, DPCode.WORK_STATUS), + # preset_dp=DPCode.MODE, + # fan_speed_dp=(DPCode.FAN_SPEED_ENUM, DPCode.WINDSPEED), + # custom_configs=localtuya_climate( + # hvac_mode_set={ + # HVACMode.OFF: "off", + # HVACMode.HEAT: "hot", + # }, + # temp_step=1, + # actions_set={ + # HVACAction.HEATING: "heating", + # HVACAction.IDLE: "warming", + # }, + # unit=UNIT_C, + # values_precsion=0.1, + # target_precision=0.1, + # preset_set={}, + # ), + # ), + # ), + # Thermostat + # https://developer.tuya.com/en/docs/iot/f?id=K9gf45ld5l0t9 + "wk": ( + LocalTuyaEntity( + id=(DPCode.SWITCH, DPCode.MODE), + target_temperature_dp=(DPCode.TEMP_SET, DPCode.TEMP_SET_F), + current_temperature_dp=( + DPCode.TEMP_CURRENT, + DPCode.TEMP_CURRENT_F, + DPCode.TEMPCURRENT, + ), + hvac_mode_dp=(DPCode.SYSTEMMODE, DPCode.SWITCH, DPCode.MODE), + hvac_action_dp=(DPCode.WORK_STATE, DPCode.WORK_MODE, DPCode.WORK_STATUS), + preset_dp=DPCode.MODE, + fan_speed_dp=(DPCode.FAN_SPEED_ENUM, DPCode.WINDSPEED, DPCode.SPEED), + custom_configs=localtuya_climate( + hvac_mode_set={HVACMode.HEAT: True, HVACMode.OFF: False}, + temp_step=1, + actions_set={ + HVACAction.HEATING: True, + HVACAction.IDLE: False, + }, + unit=UNIT_C, + values_precsion=0.1, + target_precision=0.1, + ), + ), + ), + # Thermostatic Radiator Valve + # Not documented + "wkf": ( + LocalTuyaEntity( + id=(DPCode.SWITCH, DPCode.MODE), + target_temperature_dp=(DPCode.TEMP_SET, DPCode.TEMP_SET_F), + current_temperature_dp=( + DPCode.TEMP_CURRENT, + DPCode.TEMP_CURRENT_F, + DPCode.TEMPCURRENT, + ), + hvac_mode_dp=(DPCode.SYSTEMMODE, DPCode.MODE), + hvac_action_dp=(DPCode.WORK_STATE, DPCode.WORK_MODE, DPCode.WORK_STATUS), + preset_dp=DPCode.MODE, + fan_speed_dp=(DPCode.FAN_SPEED_ENUM, DPCode.WINDSPEED, DPCode.SPEED), + custom_configs=localtuya_climate( + hvac_mode_set={ + HVACMode.HEAT: "manual", + HVACMode.AUTO: "auto", + }, + temp_step=1, + actions_set={HVACAction.HEATING: "opened", HVACAction.IDLE: "closed"}, + unit=UNIT_C, + values_precsion=0.1, + target_precision=0.1, + ), + ), + ), +} diff --git a/configs/home-assistant/custom_components/localtuya/core/ha_entities/covers.py b/configs/home-assistant/custom_components/localtuya/core/ha_entities/covers.py new file mode 100644 index 0000000..8f7fc14 --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/core/ha_entities/covers.py @@ -0,0 +1,144 @@ +""" + This a file contains available tuya data + https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq + Credits: official HA Tuya integration. + Modified by: xZetsubou +""" + +from .base import DPCode, LocalTuyaEntity, CONF_DEVICE_CLASS, EntityCategory +from homeassistant.components.cover import CoverDeviceClass + +# from const.py this is temporarily. +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" + + +def localtuya_cover(cmd_set, position_mode=None, inverted=False, timed=25): + """Define localtuya cover configs""" + data = { + CONF_COMMANDS_SET: cmd_set, + CONF_POSITIONING_MODE: position_mode, + CONF_POSITION_INVERTED: inverted, + CONF_SPAN_TIME: timed, + } + return data + + +COVERS: dict[str, tuple[LocalTuyaEntity, ...]] = { + # Curtain + # Note: Multiple curtains isn't documented + # https://developer.tuya.com/en/docs/iot/categorycl?id=Kaiuz1hnpo7df + "cl": ( + LocalTuyaEntity( + id=DPCode.CONTROL, + name="Curtain", + custom_configs=localtuya_cover("open_close_stop", "position"), + current_state=DPCode.SITUATION_SET, + current_position_dp=(DPCode.PERCENT_STATE, DPCode.PERCENT_CONTROL), + set_position_dp=DPCode.PERCENT_CONTROL, + ), + LocalTuyaEntity( + id=DPCode.CONTROL_2, + name="Curtain 2", + custom_configs=localtuya_cover("open_close_stop", "position"), + current_position_dp=(DPCode.PERCENT_STATE_2, DPCode.PERCENT_CONTROL_2), + set_position_dp=DPCode.PERCENT_CONTROL_2, + device_class=CoverDeviceClass.CURTAIN, + ), + LocalTuyaEntity( + id=DPCode.CONTROL_3, + name="Curtain 3", + custom_configs=localtuya_cover("open_close_stop", "position"), + current_position_dp=(DPCode.PERCENT_STATE_3, DPCode.PERCENT_CONTROL_3), + set_position_dp=DPCode.PERCENT_CONTROL_3, + device_class=CoverDeviceClass.CURTAIN, + ), + LocalTuyaEntity( + id=DPCode.CONTROL_4, + name="Curtain 4", + custom_configs=localtuya_cover("open_close_stop", "position"), + current_position_dp=(DPCode.PERCENT_STATE_4, DPCode.PERCENT_CONTROL_4), + set_position_dp=DPCode.PERCENT_CONTROL_4, + device_class=CoverDeviceClass.CURTAIN, + ), + LocalTuyaEntity( + id=DPCode.MACH_OPERATE, + name="Curtain", + custom_configs=localtuya_cover("fz_zz_stop", "position"), + current_position_dp=DPCode.POSITION, + set_position_dp=DPCode.POSITION, + device_class=CoverDeviceClass.CURTAIN, + ), + # switch_1 is an undocumented code that behaves identically to control + # It is used by the Kogan Smart Blinds Driver + LocalTuyaEntity( + id=DPCode.SWITCH_1, + name="Blind", + custom_configs=localtuya_cover("open_close_stop", "position"), + current_position_dp=DPCode.PERCENT_CONTROL, + set_position_dp=DPCode.PERCENT_CONTROL, + device_class=CoverDeviceClass.BLIND, + ), + ), + # Garage Door Opener + # https://developer.tuya.com/en/docs/iot/categoryckmkzq?id=Kaiuz0ipcboee + "ckmkzq": ( + LocalTuyaEntity( + id=DPCode.SWITCH_1, + name="Door", + custom_configs=localtuya_cover("open_close_stop", "none", True), + current_position_dp=DPCode.DOORCONTACT_STATE, + device_class=CoverDeviceClass.GARAGE, + ), + LocalTuyaEntity( + id=DPCode.SWITCH_2, + name="Door 2", + custom_configs=localtuya_cover("open_close_stop", "none", True), + current_position_dp=DPCode.DOORCONTACT_STATE_2, + device_class=CoverDeviceClass.GARAGE, + ), + LocalTuyaEntity( + id=DPCode.SWITCH_3, + name="Door 3", + custom_configs=localtuya_cover("open_close_stop", "none", True), + current_position_dp=DPCode.DOORCONTACT_STATE_3, + device_class=CoverDeviceClass.GARAGE, + ), + ), + # Curtain Switch + # https://developer.tuya.com/en/docs/iot/category-clkg?id=Kaiuz0gitil39 + "clkg": ( + LocalTuyaEntity( + id=DPCode.CONTROL, + name="Curtain", + custom_configs=localtuya_cover("open_close_stop", "position"), + current_position_dp=DPCode.PERCENT_CONTROL, + set_position_dp=DPCode.PERCENT_CONTROL, + device_class=CoverDeviceClass.CURTAIN, + ), + LocalTuyaEntity( + id=DPCode.CONTROL_2, + name="Curtain 2", + custom_configs=localtuya_cover("open_close_stop", "position"), + current_position_dp=DPCode.PERCENT_CONTROL_2, + set_position_dp=DPCode.PERCENT_CONTROL_2, + device_class=CoverDeviceClass.CURTAIN, + ), + ), + # Curtain Robot + # Note: Not documented + "jdcljqr": ( + LocalTuyaEntity( + id=DPCode.CONTROL, + name="Curtain", + custom_configs=localtuya_cover("open_close_stop", "position"), + current_position_dp=DPCode.PERCENT_STATE, + set_position_dp=DPCode.PERCENT_CONTROL, + device_class=CoverDeviceClass.CURTAIN, + ), + ), +} diff --git a/configs/home-assistant/custom_components/localtuya/core/ha_entities/fans.py b/configs/home-assistant/custom_components/localtuya/core/ha_entities/fans.py new file mode 100644 index 0000000..7ae8885 --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/core/ha_entities/fans.py @@ -0,0 +1,87 @@ +""" + This a file contains available tuya data + https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq + Credits: official HA Tuya integration. + Modified by: xZetsubou +""" + +from .base import ( + DPCode, + LocalTuyaEntity, + CONF_DEVICE_CLASS, + EntityCategory, + CLOUD_VALUE, +) +from homeassistant.components.fan import DIRECTION_FORWARD, DIRECTION_REVERSE + +# from const.py this is temporarily +CONF_FAN_SPEED_CONTROL = "fan_speed_control" +CONF_FAN_OSCILLATING_CONTROL = "fan_oscillating_control" +CONF_FAN_DIRECTION = "fan_direction" + +CONF_FAN_SPEED_MIN = "fan_speed_min" +CONF_FAN_SPEED_MAX = "fan_speed_max" +CONF_FAN_DIRECTION_FWD = "fan_direction_forward" +CONF_FAN_DIRECTION_REV = "fan_direction_reverse" +CONF_FAN_DPS_TYPE = "fan_dps_type" +CONF_FAN_ORDERED_LIST = "fan_speed_ordered_list" + +FAN_SPEED_DP = ( + DPCode.FAN_SPEED_PERCENT, + DPCode.FAN_SPEED, + DPCode.SPEED, + DPCode.FAN_SPEED_ENUM, +) + +FANS_OSCILLATING = (DPCode.SWITCH_HORIZONTAL, DPCode.SWITCH_VERTICAL) + + +def localtuya_fan(fwd, rev, min_speed, max_speed, order, dp_type): + """Define localtuya fan configs""" + data = { + CONF_FAN_DIRECTION_FWD: fwd, + CONF_FAN_DIRECTION_REV: rev, + CONF_FAN_SPEED_MIN: CLOUD_VALUE(min_speed, CONF_FAN_SPEED_CONTROL, "min"), + CONF_FAN_SPEED_MAX: CLOUD_VALUE(max_speed, CONF_FAN_SPEED_CONTROL, "max"), + CONF_FAN_ORDERED_LIST: CLOUD_VALUE(order, CONF_FAN_SPEED_CONTROL, "range", str), + CONF_FAN_DPS_TYPE: dp_type, + } + return data + + +FANS: dict[str, tuple[LocalTuyaEntity, ...]] = { + # Fan + "fs": ( + LocalTuyaEntity( + id=(DPCode.SWITCH_FAN, DPCode.FAN_SWITCH, DPCode.SWITCH), + name="Fan", + icon="mdi:fan", + fan_speed_control=FAN_SPEED_DP, + fan_direction=DPCode.FAN_DIRECTION, + fan_oscillating_control=FANS_OSCILLATING, + custom_configs=localtuya_fan( + DIRECTION_FORWARD, DIRECTION_REVERSE, 1, 100, "disabled", "int" + ), + ), + ), + # Normal switch with fan controller. + "tdq": ( + LocalTuyaEntity( + id=(DPCode.SWITCH_FAN, DPCode.FAN_SWITCH), + name="Fan", + icon="mdi:fan", + fan_speed_control=FAN_SPEED_DP, + fan_direction=DPCode.FAN_DIRECTION, + fan_oscillating_control=FANS_OSCILLATING, + custom_configs=localtuya_fan( + DIRECTION_FORWARD, DIRECTION_REVERSE, 1, 100, "disabled", "int" + ), + ), + ), +} +# Fan with Light +FANS["fsd"] = FANS["fs"] +# Fan wall switch +FANS["fskg"] = FANS["fs"] +# Air Purifier +FANS["kj"] = FANS["fs"] diff --git a/configs/home-assistant/custom_components/localtuya/core/ha_entities/humidifiers.py b/configs/home-assistant/custom_components/localtuya/core/ha_entities/humidifiers.py new file mode 100644 index 0000000..293980b --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/core/ha_entities/humidifiers.py @@ -0,0 +1,84 @@ +""" + This a file contains available tuya data + https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq + + Credits: official HA Tuya integration. + Modified by: xZetsubou +""" + +from .base import ( + DPCode, + LocalTuyaEntity, + CONF_DEVICE_CLASS, + EntityCategory, + CLOUD_VALUE, +) +from homeassistant.components.humidifier import ( + HumidifierDeviceClass, + ATTR_MAX_HUMIDITY, + ATTR_MIN_HUMIDITY, + DEFAULT_MAX_HUMIDITY, + DEFAULT_MIN_HUMIDITY, +) + +CONF_HUMIDIFIER_SET_HUMIDITY_DP = "humidifier_set_humidity_dp" +CONF_HUMIDIFIER_CURRENT_HUMIDITY_DP = "humidifier_current_humidity_dp" +CONF_HUMIDIFIER_MODE_DP = "humidifier_mode_dp" +CONF_HUMIDIFIER_AVAILABLE_MODES = "humidifier_available_modes" + + +def localtuya_humidifier(modes): + """Define localtuya fan configs""" + + data = { + CONF_HUMIDIFIER_AVAILABLE_MODES: CLOUD_VALUE( + modes, CONF_HUMIDIFIER_MODE_DP, "range", dict + ), + ATTR_MIN_HUMIDITY: CLOUD_VALUE( + DEFAULT_MIN_HUMIDITY, CONF_HUMIDIFIER_SET_HUMIDITY_DP, "min" + ), + ATTR_MAX_HUMIDITY: CLOUD_VALUE( + DEFAULT_MAX_HUMIDITY, CONF_HUMIDIFIER_SET_HUMIDITY_DP, "max" + ), + } + return data + + +HUMIDIFIERS: dict[str, tuple[LocalTuyaEntity, ...]] = { + # Dehumidifier + # https://developer.tuya.com/en/docs/iot/categorycs?id=Kaiuz1vcz4dha + "cs": ( + LocalTuyaEntity( + id=DPCode.SWITCH, + humidifier_current_humidity_dp=DPCode.HUMIDITY_INDOOR, + humidifier_set_humidity_dp=DPCode.DEHUMIDITY_SET_VALUE, + humidifier_mode_dp=(DPCode.MODE, DPCode.WORK_MODE), + custom_configs=localtuya_humidifier( + { + "dehumidify": "Dehumidify", + "drying": "Drying", + "continuous": "Continuous", + } + ), + device_class=HumidifierDeviceClass.DEHUMIDIFIER, + ), + ), + # Humidifier + # https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b + "jsq": ( + LocalTuyaEntity( + id=DPCode.SWITCH, + humidifier_current_humidity_dp=DPCode.HUMIDITY_CURRENT, + humidifier_set_humidity_dp=DPCode.HUMIDITY_SET, + humidifier_mode_dp=(DPCode.MODE, DPCode.WORK_MODE), + custom_configs=localtuya_humidifier( + { + "large": "Large", + "middle": "Middle", + "small": "Small", + } + ), + device_class=HumidifierDeviceClass.HUMIDIFIER, + ), + ), +} diff --git a/configs/home-assistant/custom_components/localtuya/core/ha_entities/lights.py b/configs/home-assistant/custom_components/localtuya/core/ha_entities/lights.py new file mode 100644 index 0000000..2761058 --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/core/ha_entities/lights.py @@ -0,0 +1,431 @@ +""" + This a file contains available tuya data + https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq + + Credits: official HA Tuya integration. + Modified by: xZetsubou +""" + +from typing import Any +from .base import DPCode, LocalTuyaEntity, EntityCategory, CLOUD_VALUE +from homeassistant.const import CONF_BRIGHTNESS, CONF_COLOR_TEMP, CONF_SCENE + +from ...const import ( + CONF_BRIGHTNESS_LOWER, + CONF_BRIGHTNESS_UPPER, + CONF_COLOR_TEMP_MIN_KELVIN, + CONF_COLOR_TEMP_MAX_KELVIN, + CONF_COLOR_TEMP_REVERSE, + CONF_MUSIC_MODE, +) + + +def localtuya_light( + lower=29, upper=1000, min_kv=2700, max_kv=6500, temp_reverse=False, music_mode=False +) -> dict[str, Any | CLOUD_VALUE]: + """Define localtuya light configs""" + data = { + CONF_BRIGHTNESS_LOWER: CLOUD_VALUE(lower, CONF_BRIGHTNESS, "min"), + CONF_BRIGHTNESS_UPPER: CLOUD_VALUE(upper, CONF_BRIGHTNESS, "max"), + CONF_COLOR_TEMP_MIN_KELVIN: min_kv, # CLOUD_VALUE(min_kv, CONF_COLOR_TEMP, "min") + CONF_COLOR_TEMP_MAX_KELVIN: max_kv, # CLOUD_VALUE(max_kv, CONF_COLOR_TEMP, "max") + CONF_COLOR_TEMP_REVERSE: temp_reverse, + CONF_MUSIC_MODE: music_mode, + } + + return data + + +LIGHTS: dict[str, tuple[LocalTuyaEntity, ...]] = { + # Curtain Switch + # https://developer.tuya.com/en/docs/iot/category-clkg?id=Kaiuz0gitil39 + "clkg": ( + LocalTuyaEntity( + id=DPCode.SWITCH_BACKLIGHT, + name="State light", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + ), + # Smart Pet Feeder + # https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld + "cwwsq": ( + LocalTuyaEntity( + id=DPCode.LIGHT, + name="Light", + ), + ), + # String Lights + # https://developer.tuya.com/en/docs/iot/dc?id=Kaof7taxmvadu + "dc": ( + LocalTuyaEntity( + id=DPCode.SWITCH_LED, + name=None, + color_mode=DPCode.WORK_MODE, + brightness=DPCode.BRIGHT_VALUE, + color_temp=DPCode.TEMP_VALUE, + color=DPCode.COLOUR_DATA, + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + ), + # Strip Lights + # https://developer.tuya.com/en/docs/iot/dd?id=Kaof804aibg2l + "dd": ( + LocalTuyaEntity( + id=DPCode.SWITCH_LED, + name=None, + color_mode=DPCode.WORK_MODE, + brightness=(DPCode.BRIGHT_VALUE_V2, DPCode.BRIGHT_VALUE), + color_temp=(DPCode.TEMP_VALUE_V2, DPCode.TEMP_VALUE), + color=(DPCode.COLOUR_DATA_V2, DPCode.COLOUR_DATA), + scene=(DPCode.SCENE_DATA_V2, DPCode.SCENE_DATA), + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + # default_color_type=DEFAULT_COLOR_TYPE_DATA_V2, + ), + ), + # Light + # https://developer.tuya.com/en/docs/iot/categorydj?id=Kaiuyzy3eheyy + "dj": ( + LocalTuyaEntity( + id=DPCode.SWITCH_LED, + name=None, + color_mode=DPCode.WORK_MODE, + brightness=(DPCode.BRIGHT_VALUE_V2, DPCode.BRIGHT_VALUE), + color_temp=(DPCode.TEMP_VALUE_V2, DPCode.TEMP_VALUE), + color=(DPCode.COLOUR_DATA_V2, DPCode.COLOUR_DATA, DPCode.COLOUR_DATA_RAW), + scene=(DPCode.SCENE_DATA_V2, DPCode.SCENE_DATA, DPCode.SCENE_DATA_RAW), + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, True), + ), + # Not documented + # Based on multiple reports: manufacturer customized Dimmer 2 switches + LocalTuyaEntity( + id=DPCode.SWITCH_1, + name="light", + brightness=DPCode.BRIGHT_VALUE_1, + ), + ), + # Ceiling Fan Light + # https://developer.tuya.com/en/docs/iot/fsd?id=Kaof8eiei4c2v + "fsd": ( + LocalTuyaEntity( + id=DPCode.SWITCH_LED, + name=None, + color_mode=DPCode.WORK_MODE, + brightness=DPCode.BRIGHT_VALUE, + color_temp=DPCode.TEMP_VALUE, + color=DPCode.COLOUR_DATA, + scene=(DPCode.SCENE_DATA, DPCode.SCENE_DATA_V2), + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + # Some ceiling fan lights use LIGHT for DPCode instead of SWITCH_LED + LocalTuyaEntity( + id=DPCode.LIGHT, + name=None, + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + ), + # Fan Switch + "fskg": ( + LocalTuyaEntity( + id=DPCode.SWITCH_LED, + name="Light", + color_mode=DPCode.WORK_MODE, + brightness=DPCode.BRIGHT_VALUE, + color_temp=DPCode.TEMP_VALUE, + color=DPCode.COLOUR_DATA, + scene=(DPCode.SCENE_DATA, DPCode.SCENE_DATA_V2), + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + # Some ceiling fan lights use LIGHT for DPCode instead of SWITCH_LED + LocalTuyaEntity( + id=DPCode.LIGHT, + name=None, + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + ), + # Ambient Light + # https://developer.tuya.com/en/docs/iot/ambient-light?id=Kaiuz06amhe6g + "fwd": ( + LocalTuyaEntity( + id=DPCode.SWITCH_LED, + name=None, + color_mode=DPCode.WORK_MODE, + brightness=DPCode.BRIGHT_VALUE, + color_temp=DPCode.TEMP_VALUE, + color=DPCode.COLOUR_DATA, + scene=(DPCode.SCENE_DATA, DPCode.SCENE_DATA_V2), + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + ), + # Motion Sensor Light + # https://developer.tuya.com/en/docs/iot/gyd?id=Kaof8a8hycfmy + "gyd": ( + LocalTuyaEntity( + id=DPCode.SWITCH_LED, + name=None, + color_mode=DPCode.WORK_MODE, + brightness=DPCode.BRIGHT_VALUE, + color_temp=DPCode.TEMP_VALUE, + color=DPCode.COLOUR_DATA, + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + ), + # Humidifier Light + # https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b + "jsq": ( + LocalTuyaEntity( + id=DPCode.SWITCH_LED, + name=None, + color_mode=DPCode.WORK_MODE, + brightness=DPCode.BRIGHT_VALUE, + color=DPCode.COLOUR_DATA_HSV, + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + ), + # Switch + # https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s + "kg": ( + LocalTuyaEntity( + id=DPCode.SWITCH_BACKLIGHT, + name="State light", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + ), + # Air Purifier + # https://developer.tuya.com/en/docs/iot/f?id=K9gf46h2s6dzm + "kj": ( + LocalTuyaEntity( + id=DPCode.LIGHT, + name="State light", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + ), + # Air conditioner + # https://developer.tuya.com/en/docs/iot/categorykt?id=Kaiuz0z71ov2n + "kt": ( + LocalTuyaEntity( + id=DPCode.LIGHT, + name="State light", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + ), + # Unknown light product + # Found as VECINO RGBW as provided by diagnostics + # Not documented + "mbd": ( + LocalTuyaEntity( + id=DPCode.SWITCH_LED, + name=None, + color_mode=DPCode.WORK_MODE, + brightness=DPCode.BRIGHT_VALUE, + color=DPCode.COLOUR_DATA, + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + ), + # Unknown product with light capabilities + # Fond in some diffusers, plugs and PIR flood lights + # Not documented + "qjdcz": ( + LocalTuyaEntity( + id=DPCode.SWITCH_LED, + name=None, + color_mode=DPCode.WORK_MODE, + brightness=DPCode.BRIGHT_VALUE, + color=DPCode.COLOUR_DATA, + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + ), + # Heater + # https://developer.tuya.com/en/docs/iot/categoryqn?id=Kaiuz18kih0sm + "qn": ( + LocalTuyaEntity( + id=DPCode.LIGHT, + name="State light", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + ), + # Smart Camera + # https://developer.tuya.com/en/docs/iot/categorysp?id=Kaiuz35leyo12 + "sp": ( + LocalTuyaEntity( + id=DPCode.FLOODLIGHT_SWITCH, + brightness=DPCode.FLOODLIGHT_LIGHTNESS, + name="Floodlight", + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + LocalTuyaEntity( + id=DPCode.BASIC_INDICATOR, + name="Indicator light", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + ), + # Dimmer Switch + # https://developer.tuya.com/en/docs/iot/categorytgkg?id=Kaiuz0ktx7m0o + "tgkg": ( + LocalTuyaEntity( + id=DPCode.SWITCH_LED_1, + brightness=DPCode.BRIGHT_VALUE_1, + brightness_upper=DPCode.BRIGHTNESS_MAX_1, + brightness_lower=DPCode.BRIGHTNESS_MIN_1, + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + LocalTuyaEntity( + id=DPCode.SWITCH_LED_2, + name="Light 2", + brightness=DPCode.BRIGHT_VALUE_2, + brightness_upper=DPCode.BRIGHTNESS_MAX_2, + brightness_lower=DPCode.BRIGHTNESS_MIN_2, + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + LocalTuyaEntity( + id=DPCode.SWITCH_LED_3, + name="Light 3", + brightness=DPCode.BRIGHT_VALUE_3, + brightness_upper=DPCode.BRIGHTNESS_MAX_3, + brightness_lower=DPCode.BRIGHTNESS_MIN_3, + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + ), + # Dimmer + # https://developer.tuya.com/en/docs/iot/tgq?id=Kaof8ke9il4k4 + "tgq": ( + LocalTuyaEntity( + id=DPCode.SWITCH_LED, + brightness=(DPCode.BRIGHT_VALUE_V2, DPCode.BRIGHT_VALUE), + brightness_upper=DPCode.BRIGHTNESS_MAX_1, + brightness_lower=DPCode.BRIGHTNESS_MIN_1, + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + LocalTuyaEntity( + id=DPCode.SWITCH_LED_1, + name="Light 1", + brightness=DPCode.BRIGHT_VALUE_1, + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + LocalTuyaEntity( + id=DPCode.SWITCH_LED_2, + name="Light 2", + brightness=DPCode.BRIGHT_VALUE_2, + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + LocalTuyaEntity( + id=DPCode.SWITCH_LED_3, + name="Light 3", + brightness=DPCode.BRIGHT_VALUE_3, + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + LocalTuyaEntity( + id=DPCode.SWITCH_LED_4, + name="Light 4", + brightness=DPCode.BRIGHT_VALUE_4, + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + ), + # Wake Up Light II + # Not documented + "hxd": ( + LocalTuyaEntity( + id=DPCode.SWITCH_LED, + name="light", + brightness=(DPCode.BRIGHT_VALUE_V2, DPCode.BRIGHT_VALUE), + brightness_upper=DPCode.BRIGHTNESS_MAX_1, + brightness_lower=DPCode.BRIGHTNESS_MIN_1, + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + ), + # Solar Light + # https://developer.tuya.com/en/docs/iot/tynd?id=Kaof8j02e1t98 + "tyndj": ( + LocalTuyaEntity( + id=DPCode.SWITCH_LED, + name=None, + color_mode=DPCode.WORK_MODE, + brightness=DPCode.BRIGHT_VALUE, + color_temp=DPCode.TEMP_VALUE, + color=DPCode.COLOUR_DATA, + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + ), + # Ceiling Light + # https://developer.tuya.com/en/docs/iot/ceiling-light?id=Kaiuz03xxfc4r + "xdd": ( + LocalTuyaEntity( + id=DPCode.SWITCH_LED, + name=None, + color_mode=DPCode.WORK_MODE, + brightness=DPCode.BRIGHT_VALUE, + color_temp=DPCode.TEMP_VALUE, + color=DPCode.COLOUR_DATA, + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + LocalTuyaEntity( + id=DPCode.SWITCH_NIGHT_LIGHT, + name="night_light", + ), + ), + # Remote Control + # https://developer.tuya.com/en/docs/iot/ykq?id=Kaof8ljn81aov + "ykq": ( + LocalTuyaEntity( + id=DPCode.SWITCH_CONTROLLER, + name=None, + color_mode=DPCode.WORK_MODE, + brightness=DPCode.BRIGHT_CONTROLLER, + color_temp=DPCode.TEMP_CONTROLLER, + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + ), + # Fan + # https://developer.tuya.com/en/docs/iot/categoryfs?id=Kaiuz1xweel1c + "fs": ( + LocalTuyaEntity( + id=DPCode.LIGHT, + name=None, + color_mode=DPCode.WORK_MODE, + brightness=DPCode.BRIGHT_VALUE, + color_temp=DPCode.TEMP_VALUE, + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + LocalTuyaEntity( + id=DPCode.SWITCH_LED, + name="light_2", + brightness=DPCode.BRIGHT_VALUE_1, + custom_configs=localtuya_light(29, 1000, 2700, 6500, False, False), + ), + ), +} + +# HDMI Sync Box A1 +LIGHTS["hdmipmtbq"] = ( + *LIGHTS["tgkg"], + *LIGHTS["dj"], +) + +# Dimmer +LIGHTS["tdq"] = LIGHTS["tgkg"] + +# Scene Switch +# https://developer.tuya.com/en/docs/iot/f?id=K9gf7nx6jelo8 +LIGHTS["cjkg"] = LIGHTS["tgkg"] + +# Wireless Switch # also can come as knob switch. +# https://developer.tuya.com/en/docs/iot/wxkg?id=Kbeo9t3ryuqm5 +LIGHTS["wxkg"] = LIGHTS["tgkg"] + + +# Socket (duplicate of `kg`) +# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s +LIGHTS["cz"] = LIGHTS["kg"] + +# Power Socket (duplicate of `kg`) +# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s +LIGHTS["pc"] = LIGHTS["kg"] + +# Dehumidifier +# https://developer.tuya.com/en/docs/iot/categorycs?id=Kaiuz1vcz4dha +LIGHTS["cs"] = LIGHTS["jsq"] diff --git a/configs/home-assistant/custom_components/localtuya/core/ha_entities/locks.py b/configs/home-assistant/custom_components/localtuya/core/ha_entities/locks.py new file mode 100644 index 0000000..ce2490a --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/core/ha_entities/locks.py @@ -0,0 +1,32 @@ +""" + This a file contains available tuya data + https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq + Credits: official HA Tuya integration. + Modified by: xZetsubou +""" + +from .base import ( + DPCode, + LocalTuyaEntity, +) + + +def localtuya_lock(): + """Define localtuya lock configs""" + data = {} + return data + + +LOCKS: dict[str, tuple[LocalTuyaEntity, ...]] = { + # Locks + "ms": ( + LocalTuyaEntity( + id=(DPCode.REMOTE_UNLOCK_SWITCH, DPCode.SWITCH), + jammed_dp=DPCode.HIJACK, + lock_state_dp=(DPCode.CLOSED_OPENED, DPCode.OPEN_CLOSE), + ), + ), +} + +LOCKS["jtmspro"] = LOCKS["ms"] +LOCKS["jtmsbh"] = LOCKS["ms"] diff --git a/configs/home-assistant/custom_components/localtuya/core/ha_entities/numbers.py b/configs/home-assistant/custom_components/localtuya/core/ha_entities/numbers.py new file mode 100644 index 0000000..4beb6e3 --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/core/ha_entities/numbers.py @@ -0,0 +1,1097 @@ +""" + This a file contains available tuya data + https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq + + Credits: official HA Tuya integration. + Modified by: xZetsubou +""" + +from homeassistant.components.number import NumberDeviceClass +from homeassistant.const import ( + PERCENTAGE, + UnitOfTime, + UnitOfPower, + UnitOfTemperature, + CONF_UNIT_OF_MEASUREMENT, + UnitOfLength, + UnitOfElectricCurrent, +) + +from .base import DPCode, LocalTuyaEntity, EntityCategory, CLOUD_VALUE +from ...const import CONF_MIN_VALUE, CONF_MAX_VALUE, CONF_STEPSIZE, CONF_SCALING + + +def localtuya_numbers(_min, _max, _step=1, _scale=1, unit=None) -> dict: + """Will return dict with CONF MIN AND CONF MAX, scale 1 is default, 1=1""" + data = { + CONF_MIN_VALUE: CLOUD_VALUE(_min, "id", "min"), + CONF_MAX_VALUE: CLOUD_VALUE(_max, "id", "max"), + CONF_STEPSIZE: CLOUD_VALUE(_step, "id", "step"), + CONF_SCALING: CLOUD_VALUE(_scale, "id", "scale"), + } + + if unit: + data.update({CONF_UNIT_OF_MEASUREMENT: unit}) + + return data + + +NUMBERS: dict[str, tuple[LocalTuyaEntity, ...]] = { + # Smart panel with switches and zigbee hub ? + # Not documented + "dgnzk": ( + LocalTuyaEntity( + id=DPCode.VOICE_VOL, + name="Volume", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(0, 100), + icon="mdi:volume-equal", + ), + LocalTuyaEntity( + id=DPCode.PLAY_TIME, + name="Play time", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(0, 7200, unit=UnitOfTime.SECONDS), + icon="mdi:motion-play-outline", + ), + LocalTuyaEntity( + id=DPCode.BASS_CONTROL, + name="Bass", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(0, 15), + icon="mdi:speaker", + ), + LocalTuyaEntity( + id=DPCode.TREBLE_CONTROL, + name="Treble", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(0, 15), + icon="mdi:music-clef-treble", + ), + ), + # Multi-functional Sensor + # https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3 + "dgnbj": ( + LocalTuyaEntity( + id=DPCode.ALARM_TIME, + name="Time", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(0, 60), + ), + ), + # Smart Kettle + # https://developer.tuya.com/en/docs/iot/fbh?id=K9gf484m21yq7 + "bh": ( + LocalTuyaEntity( + id=DPCode.TEMP_SET, + name="Temperature", + device_class=NumberDeviceClass.TEMPERATURE, + icon="mdi:thermometer", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(0, 100), + ), + LocalTuyaEntity( + id=DPCode.TEMP_SET_F, + name="Temperature", + device_class=NumberDeviceClass.TEMPERATURE, + icon="mdi:thermometer", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(32, 212), + ), + LocalTuyaEntity( + id=DPCode.TEMP_BOILING_C, + name="Temperature After Boiling", + device_class=NumberDeviceClass.TEMPERATURE, + icon="mdi:thermometer", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(0, 100), + ), + LocalTuyaEntity( + id=DPCode.TEMP_BOILING_F, + name="Temperature After Boiling", + device_class=NumberDeviceClass.TEMPERATURE, + icon="mdi:thermometer", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(32, 212), + ), + LocalTuyaEntity( + id=DPCode.WARM_TIME, + name="Heat preservation time", + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(0, 360), + ), + ), + # Smart Pet Feeder + # https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld + "cwwsq": ( + LocalTuyaEntity( + id=DPCode.MANUAL_FEED, + name="Feed", + icon="mdi:bowl", + custom_configs=localtuya_numbers(1, 12), + ), + LocalTuyaEntity( + id=DPCode.VOICE_TIMES, + name="Voice prompt", + icon="mdi:microphone", + custom_configs=localtuya_numbers(0, 10), + ), + ), + # Pet Water Feeder + # https://developer.tuya.com/en/docs/iot/f?id=K9gf46aewxem5 + "cwysj": ( + LocalTuyaEntity( + id=DPCode.PUMP_TIME, + name="Cleaning Time", + custom_configs=localtuya_numbers(0, 31, unit="d"), + ), + ), + # Light + # https://developer.tuya.com/en/docs/iot/categorydj?id=Kaiuyzy3eheyy + "dj": ( + LocalTuyaEntity( + id=DPCode.COUNTDOWN_1, + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + name="Light 1 Timer", + custom_configs=localtuya_numbers(0, 86400, 1, 1, UnitOfTime.SECONDS), + ), + LocalTuyaEntity( + id=DPCode.COUNTDOWN_2, + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + name="Light 2 Timer", + custom_configs=localtuya_numbers(0, 86400, 1, 1, UnitOfTime.SECONDS), + ), + LocalTuyaEntity( + id=DPCode.COUNTDOWN_3, + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + name="Light 3 Timer", + custom_configs=localtuya_numbers(0, 86400, 1, 1, UnitOfTime.SECONDS), + ), + LocalTuyaEntity( + id=DPCode.COUNTDOWN_4, + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + name="Light 4 Timer", + custom_configs=localtuya_numbers(0, 86400, 1, 1, UnitOfTime.SECONDS), + ), + LocalTuyaEntity( + id=DPCode.COUNTDOWN, + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + name="Timer", + custom_configs=localtuya_numbers(0, 86400, 1, 1, UnitOfTime.SECONDS), + ), + ), + # Human Presence Sensor + # https://developer.tuya.com/en/docs/iot/categoryhps?id=Kaiuz42yhn1hs + "hps": ( + LocalTuyaEntity( + id=DPCode.SENSITIVITY, + name="sensitivity", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(0, 9), + ), + LocalTuyaEntity( + id=DPCode.NEAR_DETECTION, + name="Near Detection CM", + icon="mdi:signal-distance-variant", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(0, 1000), + ), + LocalTuyaEntity( + id=DPCode.FAR_DETECTION, + name="Far Detection CM", + icon="mdi:signal-distance-variant", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(0, 1000), + ), + ), + # Coffee maker + # https://developer.tuya.com/en/docs/iot/categorykfj?id=Kaiuz2p12pc7f + "kfj": ( + LocalTuyaEntity( + id=DPCode.WATER_SET, + name="Water Level", + icon="mdi:cup-water", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(0, 500), + ), + LocalTuyaEntity( + id=DPCode.TEMP_SET, + name="Temperature", + device_class=NumberDeviceClass.TEMPERATURE, + icon="mdi:thermometer", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(0, 100), + ), + LocalTuyaEntity( + id=DPCode.WARM_TIME, + name="Heat preservation time", + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(0, 1440), + ), + LocalTuyaEntity( + id=DPCode.POWDER_SET, + name="Powder", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(0, 24), + ), + ), + # Switch + # https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s + "kg": ( + LocalTuyaEntity( + id=DPCode.COUNTDOWN_1, + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + name="Switch 1 Timer", + custom_configs=localtuya_numbers(0, 86400, 1, 1, UnitOfTime.SECONDS), + ), + LocalTuyaEntity( + id=DPCode.COUNTDOWN_2, + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + name="Switch 2 Timer", + custom_configs=localtuya_numbers(0, 86400, 1, 1, UnitOfTime.SECONDS), + ), + LocalTuyaEntity( + id=DPCode.COUNTDOWN_3, + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + name="Switch 3 Timer", + custom_configs=localtuya_numbers(0, 86400, 1, 1, UnitOfTime.SECONDS), + ), + LocalTuyaEntity( + id=DPCode.COUNTDOWN_4, + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + name="Switch 4 Timer", + custom_configs=localtuya_numbers(0, 86400, 1, 1, UnitOfTime.SECONDS), + ), + LocalTuyaEntity( + id=DPCode.COUNTDOWN_5, + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + name="Switch 5 Timer", + custom_configs=localtuya_numbers(0, 86400, 1, 1, UnitOfTime.SECONDS), + ), + LocalTuyaEntity( + id=DPCode.COUNTDOWN_6, + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + name="Switch 6 Timer", + custom_configs=localtuya_numbers(0, 86400, 1, 1, UnitOfTime.SECONDS), + ), + LocalTuyaEntity( + id=DPCode.COUNTDOWN_USB1, + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + name="USB1 Timer", + custom_configs=localtuya_numbers(0, 86400, 1, 1, UnitOfTime.SECONDS), + ), + LocalTuyaEntity( + id=DPCode.COUNTDOWN_USB2, + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + name="USB2 Timer", + custom_configs=localtuya_numbers(0, 86400, 1, 1, UnitOfTime.SECONDS), + ), + LocalTuyaEntity( + id=DPCode.COUNTDOWN_USB3, + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + name="USB3 Timer", + custom_configs=localtuya_numbers(0, 86400, 1, 1, UnitOfTime.SECONDS), + ), + LocalTuyaEntity( + id=DPCode.COUNTDOWN_USB4, + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + name="USB4 Timer", + custom_configs=localtuya_numbers(0, 86400, 1, 1, UnitOfTime.SECONDS), + ), + LocalTuyaEntity( + id=DPCode.COUNTDOWN_USB5, + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + name="USB5 Timer", + custom_configs=localtuya_numbers(0, 86400, 1, 1, UnitOfTime.SECONDS), + ), + LocalTuyaEntity( + id=DPCode.COUNTDOWN_USB6, + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + name="USB6 Timer", + custom_configs=localtuya_numbers(0, 86400, 1, 1, UnitOfTime.SECONDS), + ), + LocalTuyaEntity( + id=DPCode.COUNTDOWN, + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + name="Switch Timer", + custom_configs=localtuya_numbers(0, 86400, 1, 1, UnitOfTime.SECONDS), + ), + LocalTuyaEntity( + id=DPCode.COUNTDOWN_USB, + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + name="Switch Timer", + custom_configs=localtuya_numbers(0, 86400, 1, 1, UnitOfTime.SECONDS), + ), + # CZ - Energy monitor? + LocalTuyaEntity( + id=DPCode.WARN_POWER, + icon="mdi:alert-outline", + entity_category=EntityCategory.CONFIG, + name="Power Wanring Limit", + custom_configs=localtuya_numbers(0, 50000, 1, 1, UnitOfPower.WATT), + ), + LocalTuyaEntity( + id=DPCode.WARN_POWER1, + icon="mdi:alert-outline", + entity_category=EntityCategory.CONFIG, + name="Power 1 Wanring Limit", + custom_configs=localtuya_numbers(0, 50000, 1, 1, UnitOfPower.WATT), + ), + LocalTuyaEntity( + id=DPCode.WARN_POWER2, + icon="mdi:alert-outline", + entity_category=EntityCategory.CONFIG, + name="Power 2 Wanring Limit", + custom_configs=localtuya_numbers(0, 50000, 1, 1, UnitOfPower.WATT), + ), + LocalTuyaEntity( + id=DPCode.POWER_ADJUSTMENT, + icon="mdi:generator-mobile", + entity_category=EntityCategory.CONFIG, + name="Power Adjustment", + custom_configs=localtuya_numbers(20, 100, 1, 1, PERCENTAGE), + ), + # Fan "tdq" + LocalTuyaEntity( + id=DPCode.FAN_COUNTDOWN, + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + name="Fan Timer", + custom_configs=localtuya_numbers(0, 86400, 1, 1, UnitOfTime.SECONDS), + ), + LocalTuyaEntity( + id=DPCode.FAN_COUNTDOWN_2, + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + name="Fan 2 Timer", + custom_configs=localtuya_numbers(0, 86400, 1, 1, UnitOfTime.SECONDS), + ), + LocalTuyaEntity( + id=DPCode.FAN_COUNTDOWN_3, + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + name="Fan 3 Timer", + custom_configs=localtuya_numbers(0, 86400, 1, 1, UnitOfTime.SECONDS), + ), + LocalTuyaEntity( + id=DPCode.FAN_COUNTDOWN_4, + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + name="Fan 4 Timer", + custom_configs=localtuya_numbers(0, 86400, 1, 1, UnitOfTime.SECONDS), + ), + ), + # Smart Lock + # https://developer.tuya.com/en/docs/iot/s?id=Kb0o2xhlkxbet + "mc": ( + LocalTuyaEntity( + id=( + DPCode.UNLOCK_APP, + DPCode.UNLOCK_FINGERPRINT, + DPCode.UNLOCK_CARD, + DPCode.UNLOCK_DYNAMIC, + DPCode.UNLOCK_TEMPORARY, + ), + name="Temporary Unlock", + icon="mdi:lock-open", + custom_configs=localtuya_numbers(0, 999, 1, 1, UnitOfTime.SECONDS), + ), + ), + # Cat litter box + # https://developer.tuya.com/en/docs/iot/f?id=Kakg309qkmuit + "msp": ( + LocalTuyaEntity( + id=DPCode.DELAY_CLEAN_TIME, + name="Delay Clean Time", + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(1, 60, 1, 1, UnitOfTime.MINUTES), + ), + LocalTuyaEntity( + id=DPCode.QUIET_TIME_START, + name="Quiet Time Start", + icon="mdi:timer-play-outline", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(1, 1439, 1, 1, UnitOfTime.MINUTES), + ), + LocalTuyaEntity( + id=DPCode.QUIET_TIME_END, + name="Quiet Time End", + icon="mdi:timer-pause-outline", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(1, 1439, 1, 1, UnitOfTime.MINUTES), + ), + LocalTuyaEntity( + id=DPCode.DIS_CURRENT, + name="DIS CURRENT", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(0, 50, 1, 1), + ), + LocalTuyaEntity( + id=DPCode.FLOW_SET, + name="Flow", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(0, 255, 1, 1), + ), + ), + # Sous Vide Cooker + # https://developer.tuya.com/en/docs/iot/categorymzj?id=Kaiuz2vy130ux + "mzj": ( + LocalTuyaEntity( + id=DPCode.COOK_TEMPERATURE, + name="Cooking temperature", + icon="mdi:thermometer", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(0, 500), + ), + LocalTuyaEntity( + id=DPCode.COOK_TIME, + name="Cooking time", + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(0, 360, 1, 1, UnitOfTime.MINUTES), + ), + LocalTuyaEntity( + id=DPCode.CLOUD_RECIPE_NUMBER, + name="Cloud Recipes", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(0, 999999), + ), + LocalTuyaEntity( + id=DPCode.APPOINTMENT_TIME, + name="Appointment time", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(0, 360), + ), + ), + # PIR Detector + # https://developer.tuya.com/en/docs/iot/categorypir?id=Kaiuz3ss11b80 + "pir": ( + LocalTuyaEntity( + id=DPCode.SENS, + icon="mdi:signal-distance-variant", + entity_category=EntityCategory.CONFIG, + name="Sensitivity", + custom_configs=localtuya_numbers(0, 4), + ), + LocalTuyaEntity( + id=DPCode.TIM, + icon="mdi:timer-10", + entity_category=EntityCategory.CONFIG, + name="Timer Duration", + custom_configs=localtuya_numbers(10, 900, 1, 1, UnitOfTime.SECONDS), + ), + LocalTuyaEntity( + id=DPCode.LUX, + icon="mdi:brightness-6", + entity_category=EntityCategory.CONFIG, + name="Light level", + custom_configs=localtuya_numbers(0, 981, 1, 1, "lx"), + ), + LocalTuyaEntity( + id=DPCode.INTERVAL_TIME, + icon="mdi:timer-sand-complete", + entity_category=EntityCategory.CONFIG, + name="Interval", + custom_configs=localtuya_numbers(1, 720, 1, 1, UnitOfTime.MINUTES), + ), + ), + # Robot Vacuum + # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo + "sd": ( + LocalTuyaEntity( + id=DPCode.VOLUME_SET, + name="volume", + icon="mdi:volume-high", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(0, 100), + ), + ), + # Siren Alarm + # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu + "sgbj": ( + LocalTuyaEntity( + id=(DPCode.ALARM_TIME, DPCode.ALARMPERIOD), + name="Alarm duration", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(1, 60), + ), + ), + # Smart Camera + # https://developer.tuya.com/en/docs/iot/categorysp?id=Kaiuz35leyo12 + "sp": ( + LocalTuyaEntity( + id=DPCode.BASIC_DEVICE_VOLUME, + name="volume", + icon="mdi:volume-high", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(1, 10), + ), + LocalTuyaEntity( + id=DPCode.FLOODLIGHT_LIGHTNESS, + name="Floodlight brightness", + icon="mdi:brightness-6", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(1, 100), + ), + ), + # Dimmer Switch + # https://developer.tuya.com/en/docs/iot/categorytgkg?id=Kaiuz0ktx7m0o + "tgkg": ( + LocalTuyaEntity( + id=DPCode.BRIGHTNESS_MIN_1, + name="minimum_brightness", + icon="mdi:lightbulb-outline", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(10, 1000), + ), + LocalTuyaEntity( + id=DPCode.BRIGHTNESS_MAX_1, + name="maximum_brightness", + icon="mdi:lightbulb-on-outline", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(10, 1000), + ), + LocalTuyaEntity( + id=DPCode.BRIGHTNESS_MIN_2, + name="minimum_brightness_2", + icon="mdi:lightbulb-outline", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(10, 1000), + ), + LocalTuyaEntity( + id=DPCode.BRIGHTNESS_MAX_2, + name="maximum_brightness_2", + icon="mdi:lightbulb-on-outline", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(10, 1000), + ), + LocalTuyaEntity( + id=DPCode.BRIGHTNESS_MIN_3, + name="minimum_brightness_3", + icon="mdi:lightbulb-outline", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(10, 1000), + ), + LocalTuyaEntity( + id=DPCode.BRIGHTNESS_MAX_3, + name="maximum_brightness_3", + icon="mdi:lightbulb-on-outline", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(10, 1000), + ), + ), + # Dimmer Switch + # https://developer.tuya.com/en/docs/iot/categorytgkg?id=Kaiuz0ktx7m0o + "tgq": ( + LocalTuyaEntity( + id=DPCode.BRIGHTNESS_MIN_1, + name="minimum_brightness", + icon="mdi:lightbulb-outline", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(10, 1000), + ), + LocalTuyaEntity( + id=DPCode.BRIGHTNESS_MAX_1, + name="maximum_brightness", + icon="mdi:lightbulb-on-outline", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(10, 1000), + ), + LocalTuyaEntity( + id=DPCode.BRIGHTNESS_MIN_2, + name="minimum_brightness_2", + icon="mdi:lightbulb-outline", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(10, 1000), + ), + LocalTuyaEntity( + id=DPCode.BRIGHTNESS_MAX_2, + name="maximum_brightness_2", + icon="mdi:lightbulb-on-outline", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(10, 1000), + ), + ), + # Vibration Sensor + # https://developer.tuya.com/en/docs/iot/categoryzd?id=Kaiuz3a5vrzno + "zd": ( + LocalTuyaEntity( + id=DPCode.SENSITIVITY, + name="Sensitivity", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(0, 9), + ), + ), + # Fingerbot + # arm_down_percent: "{\"min\":50,\"max\":100,\"scale\":0,\"step\":1}" + # arm_up_percent: "{\"min\":0,\"max\":50,\"scale\":0,\"step\":1}" + # click_sustain_time: "values": "{\"unit\":\"s\",\"min\":2,\"max\":10,\"scale\":0,\"step\":1}" + "szjqr": ( + LocalTuyaEntity( + id=DPCode.ARM_DOWN_PERCENT, + name="Move Down", + icon="mdi:arrow-down-bold", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(50, 100, 1, 1, PERCENTAGE), + ), + LocalTuyaEntity( + id=DPCode.ARM_UP_PERCENT, + name="Move UP", + icon="mdi:arrow-up-bold", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(0, 50, 1, 1, PERCENTAGE), + ), + LocalTuyaEntity( + id=DPCode.CLICK_SUSTAIN_TIME, + name="Down Delay", + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(2, 10), + ), + ), + # Fan + # https://developer.tuya.com/en/docs/iot/categoryfs?id=Kaiuz1xweel1c + "fs": ( + LocalTuyaEntity( + id=DPCode.TEMP, + name="Temperature", + device_class=NumberDeviceClass.TEMPERATURE, + icon="mdi:thermometer-lines", + custom_configs=localtuya_numbers(1, 10, unit=UnitOfTemperature.CELSIUS), + ), + LocalTuyaEntity( + id=(DPCode.TEMP_SET, DPCode.TEMP_SET_F), + name="Temperature", + entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.TEMPERATURE, + custom_configs=localtuya_numbers(40, 70, unit=UnitOfTemperature.CELSIUS), + ), + LocalTuyaEntity( + id=DPCode.COUNTDOWN, + icon="mdi:timer", + entity_category=EntityCategory.CONFIG, + name="Timer", + custom_configs=localtuya_numbers(0, 86400, 1, 1, UnitOfTime.SECONDS), + ), + ), + # Humidifier + # https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b + "jsq": ( + LocalTuyaEntity( + id=DPCode.TEMP_SET, + name="Temperature", + device_class=NumberDeviceClass.TEMPERATURE, + icon="mdi:thermometer-lines", + custom_configs=localtuya_numbers(0, 50), + ), + LocalTuyaEntity( + id=DPCode.TEMP_SET_F, + name="Temperature", + device_class=NumberDeviceClass.TEMPERATURE, + icon="mdi:thermometer-lines", + custom_configs=localtuya_numbers(32, 212, 1), + ), + ), + # Thermostat + "wk": ( + LocalTuyaEntity( + id=DPCode.TEMPCOMP, + name="Calibration offset", + custom_configs=localtuya_numbers(-9, 9), + ), + LocalTuyaEntity( + id=DPCode.TEMPACTIVATE, + name="Calibration swing", + custom_configs=localtuya_numbers(1, 9), + ), + ), + # Temperature and Humidity Sensor + # https://developer.tuya.com/en/docs/iot/categorywsdcg?id=Kaiuz3hinij34 + "wsdcg": ( + LocalTuyaEntity( + id=(DPCode.MAXTEMP_SET, DPCode.UPPER_TEMP, DPCode.UPPER_TEMP_F), + name="Max Temperature", + icon="mdi:thermometer-high", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(-200, 600, unit=UnitOfTemperature.CELSIUS), + ), + LocalTuyaEntity( + id=(DPCode.MINITEMP_SET, DPCode.LOWER_TEMP, DPCode.LOWER_TEMP_F), + name="Min Temperature", + icon="mdi:thermometer-low", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(-200, 600, unit=UnitOfTemperature.CELSIUS), + ), + LocalTuyaEntity( + id=(DPCode.MAXHUM_SET, DPCode.MAX_HUMI), + name="Max Humidity", + icon="mdi:water-percent", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(0, 100, unit=PERCENTAGE), + ), + LocalTuyaEntity( + id=(DPCode.MINIHUM_SET, DPCode.MIN_HUMI), + name="Min Humidity", + icon="mdi:water-percent", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(0, 100, unit=PERCENTAGE), + ), + LocalTuyaEntity( + id=DPCode.TEMP_PERIODIC_REPORT, + name="Report Temperature Period", + icon="mdi:timer-sand", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(1, 120, unit=UnitOfTime.MINUTES), + ), + LocalTuyaEntity( + id=DPCode.HUM_PERIODIC_REPORT, + name="Report Humidity Period", + icon="mdi:timer-sand", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(1, 120, unit=UnitOfTime.MINUTES), + ), + LocalTuyaEntity( + id=DPCode.TEMP_SENSITIVITY, + name="Temperature Sensitivity", + icon="mdi:thermometer-lines", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(3, 20, unit=UnitOfTemperature.CELSIUS), + ), + LocalTuyaEntity( + id=DPCode.HUM_SENSITIVITY, + name="Humidity Sensitivity", + icon="mdi:water-opacity", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_numbers(3, 20, unit=PERCENTAGE), + ), + ), + # Alarm Host + # https://developer.tuya.com/en/docs/iot/categorymal?id=Kaiuz33clqxaf + "mal": ( + LocalTuyaEntity( + id=DPCode.DELAY_SET, + name="Delay Setting", + custom_configs=localtuya_numbers(0, 65535), + icon="mdi:clock-outline", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.ALARM_TIME, + name="Duration", + custom_configs=localtuya_numbers(0, 65535), + icon="mdi:alarm", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.ALARM_DELAY_TIME, + name="Delay Alarm", + custom_configs=localtuya_numbers(0, 65535), + icon="mdi:history", + entity_category=EntityCategory.CONFIG, + ), + ), + # Air conditioner + # https://developer.tuya.com/en/docs/iot/categorykt?id=Kaiuz0z71ov2n + "kt": ( + LocalTuyaEntity( + id=DPCode.TIMER, + name="Timer", + custom_configs=localtuya_numbers(0, 24, unit=UnitOfTime.HOURS), + icon="mdi:timer-outline", + entity_category=EntityCategory.CONFIG, + ), + ), + # EV Charcher + # https://developer.tuya.com/en/docs/iot/categoryqn?id=Kaiuz18kih0sm + "qccdz": ( + LocalTuyaEntity( + id=DPCode.SETDELAYTIME, + name="Set Delay time", + custom_configs=localtuya_numbers(0, 15, unit=UnitOfTime.HOURS), + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.SETDEFINETIME, + name="Set Define time", + custom_configs=localtuya_numbers(0, 15, unit=UnitOfTime.HOURS), + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.SET16A, + name="Set 16A", + custom_configs=localtuya_numbers(8, 16, unit=UnitOfElectricCurrent.AMPERE), + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.SET32A, + name="Set 32A", + custom_configs=localtuya_numbers(8, 32, unit=UnitOfElectricCurrent.AMPERE), + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.SET40A, + name="Set 400A", + custom_configs=localtuya_numbers(12, 40, unit=UnitOfElectricCurrent.AMPERE), + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.SET50A, + name="Set 50A", + custom_configs=localtuya_numbers(12, 50, unit=UnitOfElectricCurrent.AMPERE), + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.SET60A, + name="Set 60A", + custom_configs=localtuya_numbers(6, 80, unit=UnitOfElectricCurrent.AMPERE), + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.SET80A, + name="Set 80A", + custom_configs=localtuya_numbers(24, 80, unit=UnitOfElectricCurrent.AMPERE), + entity_category=EntityCategory.CONFIG, + ), + ), + # Generic products, EV Charger + # https://support.tuya.com/en/help/_detail/K9g77zfmlnwal + "qt": ( + LocalTuyaEntity( + id=DPCode.RATED_CURRENT, + name="Rated Current", + custom_configs=localtuya_numbers( + 0, 20000, unit=UnitOfElectricCurrent.AMPERE, _scale=0.01 + ), + icon="mdi:sine-wave", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.LOAD_BALANCING_CURRENT, + name="Load Balancing Current", + custom_configs=localtuya_numbers( + 0, 20000, unit=UnitOfElectricCurrent.AMPERE, _scale=0.01 + ), + icon="mdi:wave-undercurrent", + entity_category=EntityCategory.CONFIG, + ), + ), + # Smart Electricity Meter + # https://developer.tuya.com/en/docs/iot/smart-meter?id=Kaiuz4gv6ack7 + "zndb": ( + LocalTuyaEntity( + id=DPCode.ENERGY_A_CALIBRATION_FWD, + name="Energy A Calibrations", + custom_configs=localtuya_numbers(800, 1200), + icon="mdi:lightning-bolt-outline", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.ENERGY_B_CALIBRATION_FWD, + name="Energy A Calibrations", + custom_configs=localtuya_numbers(800, 1200), + icon="mdi:lightning-bolt-outline", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.ENERGY_C_CALIBRATION_FWD, + name="Energy A Calibrations", + custom_configs=localtuya_numbers(800, 1200), + icon="mdi:lightning-bolt-outline", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.ENERGY_A_CALIBRATION_REV, + name="Reverse Energy A Calibrations", + custom_configs=localtuya_numbers(800, 1200), + icon="mdi:lightning-bolt-outline", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.ENERGY_B_CALIBRATION_REV, + name="Reverse Energy B Calibrations", + custom_configs=localtuya_numbers(800, 1200), + icon="mdi:lightning-bolt-outline", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.ENERGY_C_CALIBRATION_REV, + name="Reverse Energy C Calibrations", + custom_configs=localtuya_numbers(800, 1200), + icon="mdi:lightning-bolt-outline", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.CURRENT_A_CALIBRATION, + name="Current A Calibrations", + custom_configs=localtuya_numbers(800, 1200), + icon="mdi:power-cycle", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.CURRENT_B_CALIBRATION, + name="Current B Calibrations", + custom_configs=localtuya_numbers(800, 1200), + icon="mdi:power-cycle", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.CURRENT_C_CALIBRATION, + name="Current C Calibrations", + custom_configs=localtuya_numbers(800, 1200), + icon="mdi:power-cycle", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.POWER_A_CALIBRATION, + name="Power A Calibrations", + custom_configs=localtuya_numbers(800, 1200), + icon="mdi:power-cycle", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.POWER_B_CALIBRATION, + name="Power B Calibrations", + custom_configs=localtuya_numbers(800, 1200), + icon="mdi:power-cycle", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.POWER_C_CALIBRATION, + name="Power C Calibrations", + custom_configs=localtuya_numbers(800, 1200), + icon="mdi:power-cycle", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.FREQ_CALIBRATION, + name="Frequency Calibrations", + custom_configs=localtuya_numbers(800, 1200), + icon="mdi:sine-wave", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.VOLTAGE_COEF, + name="Voltage Calibrations", + custom_configs=localtuya_numbers(800, 1200), + icon="mdi:flash-triangle-outline", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.REPORT_RATE_CONTROL, + name="Report Period", + custom_configs=localtuya_numbers(3, 60, unit=UnitOfTime.SECONDS), + icon="mdi:timer-sand", + entity_category=EntityCategory.CONFIG, + ), + ), + # Ultrasonic level sensor + "ywcgq": ( + LocalTuyaEntity( + id=DPCode.MAX_SET, + name="Maximum", + custom_configs=localtuya_numbers(0, 100, unit=PERCENTAGE), + icon="mdi:pan-top-right", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.MINI_SET, + name="Minimum", + custom_configs=localtuya_numbers(0, 100, unit=PERCENTAGE), + icon="mdi:pan-bottom-left", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.LIQUID_DEPTH_MAX, + name="Depth Maximum", + custom_configs=localtuya_numbers(100, 2400, unit=UnitOfLength.METERS), + icon="mdi:arrow-collapse-down", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.INSTALLATION_HEIGHT, + name="Installation Height", + custom_configs=localtuya_numbers( + 200, 2500, _scale=0.001, unit=UnitOfLength.METERS + ), + icon="mdi:table-row-height", + entity_category=EntityCategory.CONFIG, + ), + ), + # Lawn mower + "gcj": ( + LocalTuyaEntity( + id=DPCode.MACHINEWORKTIME, + name="Running time", + custom_configs=localtuya_numbers(1, 99, unit=UnitOfTime.MINUTES), + icon="mdi:timer-outline", + entity_category=EntityCategory.CONFIG, + ), + ), +} + +# Wireless Switch # also can come as knob switch. +# https://developer.tuya.com/en/docs/iot/wxkg?id=Kbeo9t3ryuqm5 +NUMBERS["wxkg"] = ( + LocalTuyaEntity( + id=DPCode.TEMP_VALUE, + name="Temperature", + icon="mdi:thermometer", + custom_configs=localtuya_numbers(0, 1000), + ), + *NUMBERS["kg"], +) + +# Water Valve +NUMBERS["sfkzq"] = NUMBERS["kg"] + +# Water Detector +# https://developer.tuya.com/en/docs/iot/categorysj?id=Kaiuz3iub2sli +NUMBERS["sj"] = NUMBERS["wsdcg"] + +# Circuit Breaker +# https://developer.tuya.com/en/docs/iot/dlq?id=Kb0kidk9enyh8 +NUMBERS["dlq"] = NUMBERS["zndb"] + +# HDMI Sync Box A1 +NUMBERS["hdmipmtbq"] = NUMBERS["dj"] + +# Scene Switch +# https://developer.tuya.com/en/docs/iot/f?id=K9gf7nx6jelo8 +NUMBERS["cjkg"] = NUMBERS["kg"] + +NUMBERS["cz"] = NUMBERS["kg"] +NUMBERS["tdq"] = NUMBERS["kg"] +NUMBERS["pc"] = NUMBERS["kg"] + +# Locker +NUMBERS["bxx"] = NUMBERS["mc"] +NUMBERS["gyms"] = NUMBERS["mc"] +NUMBERS["jtmspro"] = NUMBERS["mc"] +NUMBERS["hotelms"] = NUMBERS["mc"] +NUMBERS["ms_category"] = NUMBERS["mc"] +NUMBERS["jtmsbh"] = NUMBERS["mc"] +NUMBERS["mk"] = NUMBERS["mc"] +NUMBERS["videolock"] = NUMBERS["mc"] +NUMBERS["photolock"] = NUMBERS["mc"] diff --git a/configs/home-assistant/custom_components/localtuya/core/ha_entities/remotes.py b/configs/home-assistant/custom_components/localtuya/core/ha_entities/remotes.py new file mode 100644 index 0000000..546ffed --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/core/ha_entities/remotes.py @@ -0,0 +1,31 @@ +""" + This a file contains available tuya data + https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq + + Credits: official HA Tuya integration. + Modified by: xZetsubou +""" + +from .base import DPCode, LocalTuyaEntity + + +CONF_RECEIVE_DP = "receive_dp" + + +# def localtuya_remote(_): +# """Define localtuya fan configs""" +# data = {} +# return data + + +REMOTES: dict[str, tuple[LocalTuyaEntity, ...]] = { + # IR Remote + # not documented + "wnykq": ( + LocalTuyaEntity( + id=(DPCode.IR_SEND, DPCode.CONTROL), + receive_dp=(DPCode.IR_STUDY_CODE, DPCode.STUDY_CODE), + key_study_dp=DPCode.KEY_STUDY, + ), + ), +} diff --git a/configs/home-assistant/custom_components/localtuya/core/ha_entities/selects.py b/configs/home-assistant/custom_components/localtuya/core/ha_entities/selects.py new file mode 100644 index 0000000..43e9c3f --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/core/ha_entities/selects.py @@ -0,0 +1,1543 @@ +""" + This a file contains available tuya data + https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq + + Credits: official HA Tuya integration. + Modified by: xZetsubou +""" + +from .base import ( + DPCode, + LocalTuyaEntity, + CONF_DEVICE_CLASS, + EntityCategory, + CLOUD_VALUE, +) + +# from const.py this is temporarily. + +from ...select import CONF_OPTIONS as OPS_VALS + + +def localtuya_selector(options): + """Generate localtuya select configs""" + data = {OPS_VALS: CLOUD_VALUE(options, "id", "range", dict)} + return data + + +COUNT_DOWN = { + "cancel": "Disable", + "1": "1 Hour", + "2": "2 Hours", + "3": "3 Hours", + "4": "4 Hours", + "5": "5 Hours", + "6": "6 Hours", +} +COUNT_DOWN_HOURS = { + "off": "Disable", + "1h": "1 Hour", + "2h": "2 Hours", + "3h": "3 Hours", + "4h": "4 Hours", + "5h": "5 Hours", + "6h": "6 Hours", +} + +SELECTS: dict[str, tuple[LocalTuyaEntity, ...]] = { + # Smart panel with switches and zigbee hub ? + # Not documented + "dgnzk": ( + LocalTuyaEntity( + id=DPCode.SOURCE, + name="Source", + icon="mdi:volume-source", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector( + { + "cloud": "Cloud", + "local": "Local", + "aux": "Aux", + "bluetooth": "Bluetooth", + } + ), + ), + LocalTuyaEntity( + id=DPCode.PLAY_MODE, + name="Mode", + icon="mdi:cog-outline", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector( + { + "order": "Order", + "repeat_all": "Repeat ALL", + "repeat_one": "Repeat one", + "random": "Random", + } + ), + ), + LocalTuyaEntity( + id=DPCode.SOUND_EFFECTS, + name="Sound Effects", + icon="mdi:sine-wave", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector( + { + "normal": "Normal", + "pop": "Pop", + "opera": "Opera", + "classical": "Classical", + "jazz": "Jazz", + "rock": "Rock", + "folk": "Folk", + "heavy_metal": "Metal", + "hip_hop": "HipHop", + "wave": "Wave", + } + ), + ), + ), + # Multi-functional Sensor + # https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3 + "dgnbj": ( + LocalTuyaEntity( + id=DPCode.ALARM_VOLUME, + name="volume", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector( + { + "low": "Low", + "middle": "Middle", + "high": "High", + "mute": "Mute", + } + ), + ), + LocalTuyaEntity( + id=DPCode.ALARM_RINGTONE, + name="Ringtone", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector( + { + "1": "1", + "2": "2", + "3": "3", + "4": "4", + "5": "5", + } + ), + ), + ), + # Heater + "kt": ( + LocalTuyaEntity( + id=(DPCode.C_F, DPCode.TEMP_UNIT_CONVERT), + name="Temperature Unit", + custom_configs=localtuya_selector({"c": "Celsius", "f": "Fahrenheit"}), + ), + ), + # Heater + "rs": ( + LocalTuyaEntity( + id=(DPCode.C_F, DPCode.TEMP_UNIT_CONVERT), + name="Temperature Unit", + custom_configs=localtuya_selector({"c": "Celsius", "f": "Fahrenheit"}), + ), + LocalTuyaEntity( + id=DPCode.CRUISE_MODE, + name="Cruise mode", + custom_configs=localtuya_selector( + {"all_day": "Always", "water_control": "Water", "single_cruise": "Once"} + ), + ), + ), + # Coffee maker + # https://developer.tuya.com/en/docs/iot/categorykfj?id=Kaiuz2p12pc7f + "kfj": ( + LocalTuyaEntity( + id=DPCode.CUP_NUMBER, + name="Cups", + icon="mdi:numeric", + custom_configs=localtuya_selector( + { + "1": "1", + "2": "2", + "3": "3", + "4": "4", + "5": "5", + "6": "6", + "7": "7", + "8": "8", + "9": "9", + "10": "10", + "11": "11", + "12": "12", + } + ), + ), + LocalTuyaEntity( + id=DPCode.CONCENTRATION_SET, + name="Concentration", + icon="mdi:altimeter", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector( + {"regular": "REGULAR", "middle": "MIDDLE", "bold": "BOLD"} + ), + ), + LocalTuyaEntity( + id=DPCode.MATERIAL, + name="Material", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector({"bean": "BEAN", "powder": "POWDER"}), + ), + LocalTuyaEntity( + id=DPCode.MODE, + name="Mode", + icon="mdi:coffee", + custom_configs=localtuya_selector( + { + "espresso": "Espresso", + "americano": "Americano", + "machiatto": "Machiatto", + "caffe_latte": "Latte", + "caffe_mocha": "Mocha", + "cappuccino": "Cappuccino", + } + ), + ), + ), + # Switch + # https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s + "kg": ( + LocalTuyaEntity( + id=DPCode.RELAY_STATUS, + icon="mdi:circle-double", + entity_category=EntityCategory.CONFIG, + name="Power-on behavior", + custom_configs=localtuya_selector( + {"power_on": "ON", "power_off": "OFF", "last": "Last State"} + ), + condition_contains_any=["power_on", "power_off", "last"], + ), + LocalTuyaEntity( + id=DPCode.RELAY_STATUS, + icon="mdi:circle-double", + entity_category=EntityCategory.CONFIG, + name="Power-on behavior", + custom_configs=localtuya_selector( + {"on": "ON", "off": "OFF", "memory": "Last State"} + ), + condition_contains_any=["on", "off", "memory"], + ), + LocalTuyaEntity( + id=DPCode.RELAY_STATUS, + icon="mdi:circle-double", + entity_category=EntityCategory.CONFIG, + name="Power-on behavior", + custom_configs=localtuya_selector( + {"0": "ON", "1": "OFF", "2": "Last State"} + ), + ), + LocalTuyaEntity( + id=DPCode.RELAY_STATUS_1, + icon="mdi:circle-double", + entity_category=EntityCategory.CONFIG, + name="Power-on behavior 1", + custom_configs=localtuya_selector( + {"power_on": "ON", "power_off": "OFF", "last": "Last State"} + ), + condition_contains_any=["power_on", "power_off", "last"], + ), + LocalTuyaEntity( + id=DPCode.RELAY_STATUS_1, + icon="mdi:circle-double", + entity_category=EntityCategory.CONFIG, + name="Power-on behavior 1", + custom_configs=localtuya_selector( + {"on": "ON", "off": "OFF", "memory": "Last State"} + ), + condition_contains_any=["on", "off", "memory"], + ), + LocalTuyaEntity( + id=DPCode.RELAY_STATUS_1, + icon="mdi:circle-double", + entity_category=EntityCategory.CONFIG, + name="Power-on behavior 1", + custom_configs=localtuya_selector( + {"0": "ON", "1": "OFF", "2": "Last State"} + ), + ), + LocalTuyaEntity( + id=DPCode.RELAY_STATUS_2, + icon="mdi:circle-double", + entity_category=EntityCategory.CONFIG, + name="Power-on behavior 2", + custom_configs=localtuya_selector( + {"power_on": "ON", "power_off": "OFF", "last": "Last State"} + ), + condition_contains_any=["power_on", "power_off", "last"], + ), + LocalTuyaEntity( + id=DPCode.RELAY_STATUS_2, + icon="mdi:circle-double", + entity_category=EntityCategory.CONFIG, + name="Power-on behavior 2", + custom_configs=localtuya_selector( + {"on": "ON", "off": "OFF", "memory": "Last State"} + ), + condition_contains_any=["on", "off", "memory"], + ), + LocalTuyaEntity( + id=DPCode.RELAY_STATUS_2, + icon="mdi:circle-double", + entity_category=EntityCategory.CONFIG, + name="Power-on behavior 2", + custom_configs=localtuya_selector( + {"0": "ON", "1": "OFF", "2": "Last State"} + ), + ), + LocalTuyaEntity( + id=DPCode.RELAY_STATUS_3, + icon="mdi:circle-double", + entity_category=EntityCategory.CONFIG, + name="Power-on behavior 3", + custom_configs=localtuya_selector( + {"power_on": "ON", "power_off": "OFF", "last": "Last State"} + ), + condition_contains_any=["power_on", "power_off", "last"], + ), + LocalTuyaEntity( + id=DPCode.RELAY_STATUS_3, + icon="mdi:circle-double", + entity_category=EntityCategory.CONFIG, + name="Power-on behavior 3", + custom_configs=localtuya_selector( + {"on": "ON", "off": "OFF", "memory": "Last State"} + ), + condition_contains_any=["on", "off", "memory"], + ), + LocalTuyaEntity( + id=DPCode.RELAY_STATUS_3, + icon="mdi:circle-double", + entity_category=EntityCategory.CONFIG, + name="Power-on behavior 3", + custom_configs=localtuya_selector( + {"0": "ON", "1": "OFF", "2": "Last State"} + ), + ), + LocalTuyaEntity( + id=DPCode.RELAY_STATUS_4, + icon="mdi:circle-double", + entity_category=EntityCategory.CONFIG, + name="Power-on behavior 4", + custom_configs=localtuya_selector( + {"power_on": "ON", "power_off": "OFF", "last": "Last State"} + ), + condition_contains_any=["power_on", "power_off", "last"], + ), + LocalTuyaEntity( + id=DPCode.RELAY_STATUS_4, + icon="mdi:circle-double", + entity_category=EntityCategory.CONFIG, + name="Power-on behavior 4", + custom_configs=localtuya_selector( + {"on": "ON", "off": "OFF", "memory": "Last State"} + ), + condition_contains_any=["on", "off", "memory"], + ), + LocalTuyaEntity( + id=DPCode.RELAY_STATUS_4, + icon="mdi:circle-double", + entity_category=EntityCategory.CONFIG, + name="Power-on behavior 4", + custom_configs=localtuya_selector( + {"0": "ON", "1": "OFF", "2": "Last State"} + ), + ), + LocalTuyaEntity( + id=DPCode.RELAY_STATUS_5, + icon="mdi:circle-double", + entity_category=EntityCategory.CONFIG, + name="Power-on behavior 5", + custom_configs=localtuya_selector( + {"power_on": "ON", "power_off": "OFF", "last": "Last State"} + ), + condition_contains_any=["power_on", "power_off", "last"], + ), + LocalTuyaEntity( + id=DPCode.RELAY_STATUS_5, + icon="mdi:circle-double", + entity_category=EntityCategory.CONFIG, + name="Power-on behavior 5", + custom_configs=localtuya_selector( + {"on": "ON", "off": "OFF", "memory": "Last State"} + ), + condition_contains_any=["on", "off", "memory"], + ), + LocalTuyaEntity( + id=DPCode.RELAY_STATUS_5, + icon="mdi:circle-double", + entity_category=EntityCategory.CONFIG, + name="Power-on behavior 5", + custom_configs=localtuya_selector( + {"0": "ON", "1": "OFF", "2": "Last State"} + ), + ), + LocalTuyaEntity( + id=DPCode.RELAY_STATUS_6, + icon="mdi:circle-double", + entity_category=EntityCategory.CONFIG, + name="Power-on behavior 6", + custom_configs=localtuya_selector( + {"power_on": "ON", "power_off": "OFF", "last": "Last State"} + ), + condition_contains_any=["power_on", "power_off", "last"], + ), + LocalTuyaEntity( + id=DPCode.RELAY_STATUS_6, + icon="mdi:circle-double", + entity_category=EntityCategory.CONFIG, + name="Power-on behavior 6", + custom_configs=localtuya_selector( + {"on": "ON", "off": "OFF", "memory": "Last State"} + ), + condition_contains_any=["on", "off", "memory"], + ), + LocalTuyaEntity( + id=DPCode.RELAY_STATUS_6, + icon="mdi:circle-double", + entity_category=EntityCategory.CONFIG, + name="Power-on behavior 6", + custom_configs=localtuya_selector( + {"0": "ON", "1": "OFF", "2": "Last State"} + ), + ), + LocalTuyaEntity( + id=DPCode.LIGHT_MODE, + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector( + {"relay": "State", "pos": "Position", "none": "OFF"} + ), + name="Light Mode", + ), + ), + # Cat litter box + # https://developer.tuya.com/en/docs/iot/f?id=Kakg309qkmuit + "msp": ( + LocalTuyaEntity( + id=DPCode.LEVEL, + name="Doorbell song", + icon="mdi:thermometer-lines", + custom_configs=localtuya_selector( + { + "red": "Red", + "greed": "Green", + "blue": "Blue", + "yellow": "Yellow", + "purple": "Purple", + "white": "White", + } + ), + ), + ), + # EV Charcher + # https://developer.tuya.com/en/docs/iot/categoryqn?id=Kaiuz18kih0sm + "qccdz": ( + LocalTuyaEntity( + id=DPCode.WORK_MODE, + name="Mode", + icon="mdi:cog", + custom_configs=localtuya_selector( + { + "charge_now": "NOW", + "charge_pct": "PCT", + "charge_energy": "Energy", + "charge_schedule": "Schedule", + } + ), + ), + LocalTuyaEntity( + id=DPCode.ONLINE_STATE, + name="Online state", + icon="mdi:cog", + custom_configs=localtuya_selector( + {"online": "online", "offline": "offline"} + ), + ), + LocalTuyaEntity( + id=DPCode.CHARGINGOPERATION, + name="Charge State", + icon="mdi:cog", + custom_configs=localtuya_selector( + { + "OpenCharging": "Open charging", + "CloseCharging": "Close charging", + "WaitOperation": "Wait for operation", + } + ), + entity_category=EntityCategory.CONFIG, + ), + ), + # Heater + # https://developer.tuya.com/en/docs/iot/categoryqn?id=Kaiuz18kih0sm + "qn": ( + LocalTuyaEntity( + id=DPCode.LEVEL, + name="Temperature Level", + icon="mdi:thermometer-lines", + custom_configs=localtuya_selector( + {"1": "Level 1", "2": " Levell 2", "3": " Level 3"} + ), + ), + LocalTuyaEntity( + id=DPCode.COUNTDOWN, + name="Set Countdown", + icon="mdi:timer-cog-outline", + custom_configs=localtuya_selector(COUNT_DOWN), + ), + LocalTuyaEntity( + id=DPCode.COUNTDOWN_SET, + name="Set Countdown", + icon="mdi:timer-cog-outline", + custom_configs=localtuya_selector(COUNT_DOWN_HOURS), + ), + ), + # Generic products, EV Charger + # https://support.tuya.com/en/help/_detail/K9g77zfmlnwal + "qt": ( + LocalTuyaEntity( + id=DPCode.CHARGE_PATTERN, + name="Charge Pattern", + icon="mdi:car-shift-pattern", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector( + { + "netversion": "Netversion", + "standalone": "Standalone", + "standalone_reserved": "Standalone Reserved", + "plug_and_charge": "Plug and Charge", + } + ), + ), + LocalTuyaEntity( + id=DPCode.MEASUREMENT_MODEL, + name="Measurement Model", + icon="mdi:call-merge", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector( + {"internal_meter": "Internal", "external_meter": "External"} + ), + ), + LocalTuyaEntity( + id=DPCode.EARTH_TEST, + name="Earth Test", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector( + {"enabled_energy": "Enable", "forbidden_energy": "Disable"} + ), + ), + LocalTuyaEntity( + id=DPCode.PEN_PROTECT, + name="Pen Protect", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector( + {"enabled_energy": "Enable", "forbidden_energy": "Disable"} + ), + ), + LocalTuyaEntity( + id=DPCode.NETWORK_MODEL, + name="Network", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector({"LAN": "LAN", "4G": "4G"}), + ), + ), + # Weather Station + "qxj": ( + LocalTuyaEntity( + id=DPCode.TEMP_UNIT_CONVERT, + name="Temperature unit", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector({"c": "c", "f": "f"}), + ), + LocalTuyaEntity( + id=DPCode.WINDSPEED_UNIT_CONVERT, + name="Windspeed unit", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector( + {"kmph": "kmph", "mph": "mph", "mps": "mps", "knots": "knots"} + ), + ), + LocalTuyaEntity( + id=DPCode.PRESSURE_UNIT_CONVERT, + name="Pressure unit", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector( + {"hpa": "hpa", "inhg": "inhg", "mmhg": "mmhg"} + ), + ), + LocalTuyaEntity( + id=DPCode.TIME_FORMAT, + name="Time Format", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector({"12Hr": "12Hr", "24Hr": "24Hr"}), + ), + LocalTuyaEntity( + id=DPCode.DM, + name="DM", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector({"D_M": "D_M", "M_D": "M_D"}), + ), + ), + # Siren Alarm + # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu + "sgbj": ( + LocalTuyaEntity( + id=DPCode.ALARM_VOLUME, + name="Volume", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector( + {"low": "LOW", "middle": "MIDDLE", "high": "HIGH", "mute": "MUTE"} + ), + ), + LocalTuyaEntity( + id=DPCode.ALARM_STATE, + name="State", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector( + { + "alarm_sound": "Sound", + "alarm_light": "Light", + "alarm_sound_light": "Sound and Light", + "normal": "NNORMAL", + } + ), + ), + LocalTuyaEntity( + id=DPCode.BRIGHT_STATE, + name="Brightness", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector( + {"low": "LOW", "middle": "MIDDLE", "high": "HIGH", "strong": "MAX"} + ), + ), + LocalTuyaEntity( + id=DPCode.ALARM_SETTING, + name="Alarm Setting", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector( + {"0": "Setting 1", "0": "Setting 2", "2": "Setting 3", "3": "Setting 4"} + ), + ), + LocalTuyaEntity( + id=DPCode.ALARMTYPE, + name="Alarm Setting", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector( + { + "1": "1", + "2": "2", + "3": "3", + "4": "4", + "5": "5", + "6": "6", + "7": "7", + "8": "8", + "9": "9", + "10": "10", + "11": "11", + "12": "12", + } + ), + ), + ), + # Smart Camera + # https://developer.tuya.com/en/docs/iot/categorysp?id=Kaiuz35leyo12 + "sp": ( + LocalTuyaEntity( + id=DPCode.IPC_WORK_MODE, + entity_category=EntityCategory.CONFIG, + name="Working mode", + custom_configs=localtuya_selector({"0": "Low Power", "1": "Continuous"}), + ), + LocalTuyaEntity( + id=DPCode.DECIBEL_SENSITIVITY, + icon="mdi:volume-vibrate", + entity_category=EntityCategory.CONFIG, + name="Decibel Sensitivity", + custom_configs=localtuya_selector( + {"0": "Low Sensitivity", "1": "High Sensitivity"} + ), + ), + LocalTuyaEntity( + id=DPCode.RECORD_MODE, + icon="mdi:record-rec", + entity_category=EntityCategory.CONFIG, + name="Record Mode", + custom_configs=localtuya_selector( + {"1": "Record Events Only", "2": "Always Record"} + ), + ), + LocalTuyaEntity( + id=DPCode.BASIC_NIGHTVISION, + icon="mdi:theme-light-dark", + entity_category=EntityCategory.CONFIG, + name="IR Night Vision", + custom_configs=localtuya_selector({"0": "Auto", "1": "OFF", "2": "ON"}), + ), + LocalTuyaEntity( + id=DPCode.BASIC_ANTI_FLICKER, + icon="mdi:image-outline", + entity_category=EntityCategory.CONFIG, + name="Anti-Flicker", + custom_configs=localtuya_selector( + {"0": "Disable", "1": "50 Hz", "2": "60 Hz"} + ), + ), + LocalTuyaEntity( + id=DPCode.MOTION_SENSITIVITY, + icon="mdi:motion-sensor", + entity_category=EntityCategory.CONFIG, + name="Motion Sensitivity", + custom_configs=localtuya_selector({"0": "Low", "1": "Medium", "2": "High"}), + ), + LocalTuyaEntity( + id=DPCode.PTZ_CONTROL, + icon="mdi:image-filter-tilt-shift", + entity_category=EntityCategory.CONFIG, + name="PTZ control", + custom_configs=localtuya_selector( + { + "0": "UP", + "1": "Upper Right", + "2": "Right", + "3": "Bottom Right", + "4": "Down", + "5": "Bottom Left", + "6": "Left", + "7": "Upper Left", + } + ), + ), + LocalTuyaEntity( + id=DPCode.FLIGHT_BRIGHT_MODE, + entity_category=EntityCategory.CONFIG, + name="Brightness mode", + custom_configs=localtuya_selector({"0": "Manual", "1": "Auto"}), + ), + LocalTuyaEntity( + id=DPCode.PIR_SENSITIVITY, + icon="mdi:ray-start-arrow", + entity_category=EntityCategory.CONFIG, + name="PIR Sensitivity", + custom_configs=localtuya_selector({"0": "Low", "1": "Medium", "2": "High"}), + ), + ), + # Dimmer Switch + # https://developer.tuya.com/en/docs/iot/categorytgkg?id=Kaiuz0ktx7m0o + "tgkg": ( + LocalTuyaEntity( + id=DPCode.RELAY_STATUS, + icon="mdi:circle-double", + entity_category=EntityCategory.CONFIG, + name="Power-on behavior", + custom_configs=localtuya_selector( + {"on": "ON", "off": "OFF", "memory": "Last State"} + ), + condition_contains_any=["on", "off", "memory"], + ), + LocalTuyaEntity( + id=DPCode.RELAY_STATUS, + icon="mdi:circle-double", + entity_category=EntityCategory.CONFIG, + name="Power-on behavior", + custom_configs=localtuya_selector( + {"0": "ON", "1": "OFF", "2": "Last State"} + ), + ), + LocalTuyaEntity( + id=DPCode.LIGHT_MODE, + entity_category=EntityCategory.CONFIG, + name="Light Mode", + custom_configs=localtuya_selector( + {"relay": "State", "pos": "Position", "none": "OFF"} + ), + ), + LocalTuyaEntity( + id=DPCode.LED_TYPE_1, + entity_category=EntityCategory.CONFIG, + name="Led Type 1", + custom_configs=localtuya_selector( + {"led": "Led", "incandescent": "Incandescent", "halogen": "Halogen"} + ), + ), + LocalTuyaEntity( + id=DPCode.LED_TYPE_2, + entity_category=EntityCategory.CONFIG, + name="Led Type 2", + custom_configs=localtuya_selector( + {"led": "Led", "incandescent": "Incandescent", "halogen": "Halogen"} + ), + ), + LocalTuyaEntity( + id=DPCode.LED_TYPE_3, + entity_category=EntityCategory.CONFIG, + name="Led Type 3", + custom_configs=localtuya_selector( + {"led": "Led", "incandescent": "Incandescent", "halogen": "Halogen"} + ), + ), + ), + # Dimmer + # https://developer.tuya.com/en/docs/iot/tgq?id=Kaof8ke9il4k4 + "tgq": ( + LocalTuyaEntity( + id=DPCode.LED_TYPE_1, + entity_category=EntityCategory.CONFIG, + name="Led Type 1", + custom_configs=localtuya_selector( + {"led": "Led", "incandescent": "Incandescent", "halogen": "Halogen"} + ), + ), + LocalTuyaEntity( + id=DPCode.LED_TYPE_2, + entity_category=EntityCategory.CONFIG, + name="Led Type 2", + custom_configs=localtuya_selector( + {"led": "Led", "incandescent": "Incandescent", "halogen": "Halogen"} + ), + ), + ), + # Fingerbot + "szjqr": ( + LocalTuyaEntity( + id=DPCode.MODE, + entity_category=EntityCategory.CONFIG, + name="Fingerbot Mode", + custom_configs=localtuya_selector( + {"click": "Click", "switch": "Switch", "toggle": "Toggle"} + ), + ), + ), + # Robot Vacuum + # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo + "sd": ( + LocalTuyaEntity( + id=DPCode.CISTERN, + entity_category=EntityCategory.CONFIG, + icon="mdi:water-opacity", + name="Water Tank Adjustment", + custom_configs=localtuya_selector( + {"low": "Low", "middle": "Middle", "high": "High", "closed": "Closed"} + ), + ), + LocalTuyaEntity( + id=DPCode.COLLECTION_MODE, + entity_category=EntityCategory.CONFIG, + icon="mdi:air-filter", + name="Dust Collection Mode", + custom_configs=localtuya_selector( + {"small": "Small", "middle": "Middle", "large": "Large"} + ), + ), + LocalTuyaEntity( + id=DPCode.VOICE_LANGUAGE, + entity_category=EntityCategory.CONFIG, + icon="mdi:air-filter", + name="Dust Collection Mode", + custom_configs=localtuya_selector({"cn": "Chinese", "en": "English"}), + ), + LocalTuyaEntity( + id=DPCode.DIRECTION_CONTROL, + entity_category=EntityCategory.CONFIG, + icon="mdi:arrow-all", + name="Direction", + custom_configs=localtuya_selector( + { + "forward": "Forward", + "backward": "Backward", + "turn_left": "Left", + "turn_right": "Right", + "stop": "Stop", + } + ), + ), + LocalTuyaEntity( + id=DPCode.MODE, + entity_category=EntityCategory.CONFIG, + icon="mdi:layers-outline", + name="Mode", + custom_configs=localtuya_selector( + { + "standby": "StandBy", + "random": "Random", + "smart": "Smart", + "wallfollow": "Follow Wall", + "mop": "Mop", + "spiral": "Spiral", + "left_spiral": "Spiral Left", + "right_spiral": "Spiral Right", + "right_bow": "Bow Right", + "left_bow": "Bow Left", + "partial_bow": "Bow Partial", + "chargego": "Charge", + } + ), + ), + ), + # Fan + # https://developer.tuya.com/en/docs/iot/f?id=K9gf45vs7vkge + "fs": ( + LocalTuyaEntity( + id=DPCode.MODE, + entity_category=EntityCategory.CONFIG, + icon="mdi:cog", + name="Mode", + custom_configs=localtuya_selector( + {"sleep": "Sleep", "normal": "Normal", "nature": "Nature"} + ), + ), + LocalTuyaEntity( + id=DPCode.FAN_VERTICAL, + entity_category=EntityCategory.CONFIG, + icon="mdi:format-vertical-align-center", + name="Vertical swing", + custom_configs=localtuya_selector( + {"30": "30 Deg", "60": "60 Deg", "90": "90 Deg"} + ), + ), + LocalTuyaEntity( + id=DPCode.FAN_HORIZONTAL, + entity_category=EntityCategory.CONFIG, + icon="mdi:format-horizontal-align-center", + name="Horizontal swing", + custom_configs=localtuya_selector( + {"30": "30 Deg", "60": "60 Deg", "90": "90 Deg"} + ), + ), + LocalTuyaEntity( + id=DPCode.WORK_MODE, + entity_category=EntityCategory.CONFIG, + icon="mdi:ceiling-fan-light", + name="Light mode", + custom_configs=localtuya_selector( + {"white": "White", "colour": "Colour", "colourful": "Colourful"} + ), + ), + LocalTuyaEntity( + id=DPCode.COUNTDOWN, + entity_category=EntityCategory.CONFIG, + icon="mdi:timer-cog-outline", + name="Countdown", + custom_configs=localtuya_selector(COUNT_DOWN), + ), + LocalTuyaEntity( + id=DPCode.COUNTDOWN_SET, + entity_category=EntityCategory.CONFIG, + icon="mdi:timer-cog-outline", + name="Countdown", + custom_configs=localtuya_selector(COUNT_DOWN_HOURS), + ), + # Gratkit dryer v2 https://github.com/xZetsubou/hass-localtuya/issues/501 + LocalTuyaEntity( + id=DPCode.LEDLIGHT, + entity_category=EntityCategory.CONFIG, + icon="mdi:led-strip", + name="Light", + custom_configs=localtuya_selector( + { + "0": "OFF", + "1": "Red", + "2": "Green", + "3": "Blue", + "4": "White", + "5": "Yellow", + "6": "Cyan", + "7": "Purple", + "8": "Orange", + "9": "Pink", + "10": "Rainbow Fade", + "11": "Rainbow Blink", + "12": "Rainbow Smooth", + "13": "13", + "14": "14", + "15": "15", + "16": "16", + "17": "17", + "18": "18", + "19": "19", + "20": "20", + } + ), + ), + LocalTuyaEntity( + id=DPCode.MATERIAL_TYPE, + entity_category=EntityCategory.CONFIG, + icon="mdi:kite-outline", + name="Material Type", + custom_configs=localtuya_selector( + { + "PETG": "PETG", + "PLA_J": "PLA_J", + "PC": "PC", + "TPU": "TPU", + "ABS": "ABS", + "DIY2": "DIY2", + "PLA": "PLA", + "DIY1": "DIY1", + "Nylon": "Nylon", + "HIPS": "HIPS", + } + ), + ), + ), + # Curtain + # https://developer.tuya.com/en/docs/iot/f?id=K9gf46o5mtfyc + "cl": ( + LocalTuyaEntity( + id=(DPCode.CONTROL_BACK_MODE, DPCode.CONTROL_BACK), + name="Motor Direction", + entity_category=EntityCategory.CONFIG, + icon="mdi:swap-vertical", + custom_configs=localtuya_selector({"forward": "Forward", "back": "Back"}), + ), + LocalTuyaEntity( + id=DPCode.MOTOR_MODE, + name="Motor Mode", + entity_category=EntityCategory.CONFIG, + icon="mdi:cog-transfer", + custom_configs=localtuya_selector( + {"contiuation": "Auto", "point": "Manual"} + ), + ), + LocalTuyaEntity( + id=DPCode.MODE, + entity_category=EntityCategory.CONFIG, + name="Cover Mode", + custom_configs=localtuya_selector({"morning": "Morning", "night": "Night"}), + ), + ), + # Humidifier + # https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b + "jsq": ( + LocalTuyaEntity( + id=DPCode.SPRAY_MODE, + entity_category=EntityCategory.CONFIG, + icon="mdi:spray", + name="Spraying mode", + custom_configs=localtuya_selector( + { + "auto": "AUTO", + "health": "Health", + "baby": "BABY", + "sleep": "SLEEP", + "humidity": "HUMIDITY", + "work": "WORK", + } + ), + ), + LocalTuyaEntity( + id=DPCode.LEVEL, + entity_category=EntityCategory.CONFIG, + icon="mdi:spray", + name="Spraying level", + custom_configs=localtuya_selector( + { + "level_1": "LEVEL 1", + "level_2": "LEVEL 2", + "level_3": "LEVEL 3", + "level_4": "LEVEL 4", + "level_5": "LEVEL 5", + "level_6": "LEVEL 6", + "level_7": "LEVEL 7", + "level_8": "LEVEL 8", + "level_9": "LEVEL 9", + "level_10": "LEVEL 10", + } + ), + ), + LocalTuyaEntity( + id=DPCode.MOODLIGHTING, + entity_category=EntityCategory.CONFIG, + icon="mdi:lightbulb-multiple", + name="Mood light", + custom_configs=localtuya_selector( + {"1": "1", "2": "2", "3": "3", "4": "4", "5": "5"} + ), + ), + LocalTuyaEntity( + id=DPCode.COUNTDOWN, + entity_category=EntityCategory.CONFIG, + icon="mdi:timer-cog-outline", + name="Countdown", + custom_configs=localtuya_selector(COUNT_DOWN), + ), + LocalTuyaEntity( + id=DPCode.COUNTDOWN_SET, + entity_category=EntityCategory.CONFIG, + icon="mdi:timer-cog-outline", + name="Countdown", + custom_configs=localtuya_selector(COUNT_DOWN_HOURS), + ), + ), + # Air Purifier + # https://developer.tuya.com/en/docs/iot/f?id=K9gf46h2s6dzm + "kj": ( + LocalTuyaEntity( + id=DPCode.COUNTDOWN, + entity_category=EntityCategory.CONFIG, + icon="mdi:timer-cog-outline", + name="Countdown", + custom_configs=localtuya_selector(COUNT_DOWN), + ), + LocalTuyaEntity( + id=DPCode.COUNTDOWN_SET, + entity_category=EntityCategory.CONFIG, + icon="mdi:timer-cog-outline", + name="Countdown", + custom_configs=localtuya_selector(COUNT_DOWN_HOURS), + ), + ), + # Dehumidifier + # https://developer.tuya.com/en/docs/iot/categorycs?id=Kaiuz1vcz4dha + "cs": ( + LocalTuyaEntity( + id=DPCode.COUNTDOWN_SET, + entity_category=EntityCategory.CONFIG, + icon="mdi:timer-cog-outline", + name="Countdown", + custom_configs=localtuya_selector( + {"cancel": "Disable", "2h": "2 Hours", "4h": "4 Hours", "8h": "8 Hours"} + ), + ), + LocalTuyaEntity( + id=DPCode.DEHUMIDITY_SET_ENUM, + name="Target Humidity", + entity_category=EntityCategory.CONFIG, + icon="mdi:water-percent", + custom_configs=localtuya_selector( + {"10": "10", "20": "20", "30": "30", "40": "40", "50": "50", "60": "60"} + ), + ), + LocalTuyaEntity( + id=DPCode.SPRAY_VOLUME, + name="Intensity", + entity_category=EntityCategory.CONFIG, + icon="mdi:volume-source", + custom_configs=localtuya_selector( + {"small": "Low", "middle": "Medium", "large": "High"} + ), + ), + LocalTuyaEntity( + id=DPCode.FAN_SPEED_ENUM, + name="Fan Speed", + entity_category=EntityCategory.CONFIG, + icon="mdi:fan", + custom_configs=localtuya_selector({"low": "Low", "high": "High"}), + ), + ), + # Water Detector + # https://developer.tuya.com/en/docs/iot/categorysj?id=Kaiuz3iub2sli + "sj": ( + LocalTuyaEntity( + id=(DPCode.C_F, DPCode.TEMP_UNIT_CONVERT), + name="Temperature Unit", + icon="mdi:cog", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector({"c": "Celsius", "f": "Fahrenheit"}), + ), + ), + # Water Valve + "sfkzq": ( + LocalTuyaEntity( + id=DPCode.SMART_WEATHER, + name="Smart Weather Mode", + icon="mdi:cog", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector( + {"cloudy": "Cloudy", "rainy": "Rainy", "snowy": "Snowy"} + ), + ), + ), + # sous vide cookers + # https://developer.tuya.com/en/docs/iot/f?id=K9r2v9hgmyk3h + "mzj": ( + LocalTuyaEntity( + id=DPCode.MODE, + entity_category=EntityCategory.CONFIG, + name="Cooking Mode", + custom_configs=localtuya_selector( + { + "vegetables": "Vegetables", + "meat": "Meat", + "shrimp": "Shrimp", + "fish": "Fish", + "chicken": "Chicken", + "drumsticks": "Drumsticks", + "beef": "Beef", + "rice": "Rice", + } + ), + ), + ), + # PIR Detector + # https://developer.tuya.com/en/docs/iot/categorypir?id=Kaiuz3ss11b80 + "pir": ( + LocalTuyaEntity( + id=DPCode.MOD, + icon="mdi:cog", + entity_category=EntityCategory.CONFIG, + name="Mode", + custom_configs=localtuya_selector( + {"mode_auto": "AUTO", "mode_on": "ON", "mode_off": "OFF"} + ), + ), + LocalTuyaEntity( + id=DPCode.PIR_SENSITIVITY, + icon="mdi:ray-start-arrow", + entity_category=EntityCategory.CONFIG, + name="PIR Sensitivity", + custom_configs=localtuya_selector( + {"low": "Low", "middle": "Middle", "high": "High"} + ), + ), + LocalTuyaEntity( + id=DPCode.PIR_TIME, + icon="mdi:timer-sand", + entity_category=EntityCategory.CONFIG, + name="Reset Time", + custom_configs=localtuya_selector( + {"30s": "30 Seconds", "60s": "60 Seconds", "120s": "120 Seconds"} + ), + ), + ), + # Thermostat + # https://developer.tuya.com/en/docs/iot/f?id=K9gf45ld5l0t9 + "wk": ( + LocalTuyaEntity( + id=DPCode.SENSORTYPE, + entity_category=EntityCategory.CONFIG, + name="Temperature sensor", + custom_configs=localtuya_selector( + {"0": "Internal", "1": "External", "2": "Both"} + ), + ), + ), + # Temperature and Humidity Sensor + # https://developer.tuya.com/en/docs/iot/categorywsdcg?id=Kaiuz3hinij34 + "wsdcg": ( + LocalTuyaEntity( + id=(DPCode.C_F, DPCode.TEMP_UNIT_CONVERT), + name="Temperature Unit", + icon="mdi:cog", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector({"c": "Celsius", "f": "Fahrenheit"}), + ), + # LocalTuyaEntity( + # id=DPCode.TEMP_ALARM, + # name="Temperature Alarm", + # entity_category=EntityCategory.CONFIG, + # icon="mdi:bell-alert", + # custom_configs=localtuya_selector( + # {"loweralarm": "Low", "upperalarm": "High", "cancel": "Cancel"} + # ), + # ), + # LocalTuyaEntity( + # id=DPCode.HUM_ALARM, + # name="Humidity Alarm", + # icon="mdi:bell-alert", + # entity_category=EntityCategory.CONFIG, + # custom_configs=localtuya_selector( + # {"loweralarm": "Low", "upperalarm": "High", "cancel": "Cancel"} + # ), + # ), + ), + # Alarm Host + # https://developer.tuya.com/en/docs/iot/categorymal?id=Kaiuz33clqxaf + "mal": ( + LocalTuyaEntity( + id=DPCode.ZONE_ATTRIBUTE, + entity_category=EntityCategory.CONFIG, + name="Zone Attribute", + custom_configs=localtuya_selector( + { + "MODE_HOME_ARM": "Home Arm", + "MODE_ARM": "Arm", + "MODE_24": "24H", + "MODE_DOORBELL": "Doorbell", + "MODE_24_SILENT": "Silent", + "HOME_ARM_NO_DELAY": "Home, Arm No delay", + "ARM_NO_DELAY": "Arm No delay", + } + ), + ), + LocalTuyaEntity( + id=DPCode.MASTER_STATE, + entity_category=EntityCategory.CONFIG, + name="Host Status", + custom_configs=localtuya_selector({"normal": "Normal", "alarm": "Alarm"}), + ), + LocalTuyaEntity( + id=DPCode.SUB_CLASS, + entity_category=EntityCategory.CONFIG, + name="Sub-device category", + custom_configs=localtuya_selector( + { + "remote_controller": "Remote Controller", + "detector": "Detector", + "socket": "Socket", + } + ), + ), + LocalTuyaEntity( + id=DPCode.SUB_TYPE, + entity_category=EntityCategory.CONFIG, + name="Sub-device type", + custom_configs=localtuya_selector( + { + "OTHER": "Other", + "DOOR": "Door", + "PIR": "Pir", + "SOS": "SoS", + "ROOM": "Room", + "WINDOW": "Window", + "BALCONY": "Balcony", + "FENCE": "Fence", + "SMOKE": "Smoke", + "GAS": "Gas", + "CO": "CO", + "WATER": "Water", + } + ), + ), + ), + # Smart Water Meter + # https://developer.tuya.com/en/docs/iot/f?id=Ka8n052xu7w4c + "znsb": ( + LocalTuyaEntity( + id=DPCode.REPORT_PERIOD_SET, + entity_category=EntityCategory.CONFIG, + name="Report Period", + custom_configs=localtuya_selector( + { + "1h": "1 Hours", + "2h": "2 Hours", + "3h": "3 Hours", + "4h": "4 Hours", + "6h": "6 Hours", + "8h": "8 Hours", + "12h": "12 Hours", + "24h": "24 Hours", + "48h": "48 Hours", + "72h": "72 Hours", + } + ), + icon="mdi:file-chart-outline", + ), + ), + # HDMI Sync Box A1 + "hdmipmtbq": ( + LocalTuyaEntity( + id=DPCode.VIDEO_SCENE, + entity_category=EntityCategory.CONFIG, + name="Video Type", + icon="mdi:camera-burst", + custom_configs=localtuya_selector({"game": "Gaming", "movie": "Movies"}), + ), + LocalTuyaEntity( + id=DPCode.VIDEO_MODE, + entity_category=EntityCategory.CONFIG, + name="Video Mode", + icon="mdi:format-wrap-square", + custom_configs=localtuya_selector( + { + "nor_closed": "Nor Closed", + "multiple_colour": "Multi Colors", + "single_colour": "Single Color", + } + ), + ), + LocalTuyaEntity( + id=DPCode.VIDEO_INTENSITY, + entity_category=EntityCategory.CONFIG, + name="Intensity", + icon="mdi:television-ambient-light", + custom_configs=localtuya_selector( + { + "low": "Low", + "middle": "Middle", + "high": "High", + "music": "Music", + } + ), + ), + LocalTuyaEntity( + id=DPCode.STRIP_INPUT_POS, + entity_category=EntityCategory.CONFIG, + name="Start Position", + icon="mdi:vector-square-minus", + custom_configs=localtuya_selector( + {"low_right": "Low Right", "low_left": "Low Left"} + ), + ), + LocalTuyaEntity( + id=DPCode.STRIP_DIRECTION, + entity_category=EntityCategory.CONFIG, + name="Strip Direction", + icon="mdi:subdirectory-arrow-right", + custom_configs=localtuya_selector( + {"clockwise": "Clockwise", "anti_clockwise": "Counter-Clockwise"} + ), + ), + LocalTuyaEntity( + id=DPCode.TV_SIZE, + entity_category=EntityCategory.CONFIG, + name="TV Size", + icon="mdi:move-resize", + custom_configs=localtuya_selector( + { + "55_to_64_inch": "55 - 64 Inches", + "65_to_74_inch": "65 - 74 Inches", + "above_75_inch": "75 Inches or Above", + } + ), + ), + ), + # Lawn mower + "gcj": ( + LocalTuyaEntity( + id=DPCode.MACHINECONTROLCMD, + name="Control", + custom_configs=localtuya_selector( + { + "PauseWork": "PauseWork", + "CancelWork": "CancelWork", + "ContinueWork": "ContinueWork", + "StartMowing": "StartMowing", + "StartFixedMowing": "StartFixedMowing", + "StartReturnStation": "StartReturnStation", + } + ), + ), + LocalTuyaEntity( + id=DPCode.MACHINEPASSWORD, + name="Password", + entity_category=EntityCategory.DIAGNOSTIC, + icon="mdi:lock-question-outline", + ), + ), +} +# Wireless Switch # also can come as knob switch. # and scene switch. +# https://developer.tuya.com/en/docs/iot/wxkg?id=Kbeo9t3ryuqm5 +SELECTS["wxkg"] = ( + LocalTuyaEntity( + id=DPCode.WORK_MODE, + name="Display mode", + icon="mdi:square-outline", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector( + {"brightness": "Brightness", "temperature": "Temperature"} + ), + ), + LocalTuyaEntity( + id=(DPCode.SWITCH1_VALUE, DPCode.SWITCH_TYPE_1), + name="Switch 1", + icon="mdi:square-outline", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector( + { + "single_click": "Single click", + "double_click": "Double click", + "long_press": "Long Press", + } + ), + condition_contains_any=["single_click", "double_click", "long_press"], + ), + LocalTuyaEntity( + id=(DPCode.SWITCH2_VALUE, DPCode.SWITCH_TYPE_2), + name="Switch 2", + icon="mdi:palette-outline", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector( + { + "single_click": "Single click", + "double_click": "Double click", + "long_press": "Long Press", + } + ), + condition_contains_any=["single_click", "double_click", "long_press"], + ), + LocalTuyaEntity( + id=(DPCode.SWITCH3_VALUE, DPCode.SWITCH_TYPE_3), + name="Switch 3", + icon="mdi:palette-outline", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector( + { + "single_click": "Single click", + "double_click": "Double click", + "long_press": "Long Press", + } + ), + condition_contains_any=["single_click", "double_click", "long_press"], + ), + LocalTuyaEntity( + id=(DPCode.SWITCH4_VALUE, DPCode.SWITCH_TYPE_4), + name="Switch 4", + icon="mdi:palette-outline", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector( + { + "single_click": "Single click", + "double_click": "Double click", + "long_press": "Long Press", + } + ), + condition_contains_any=["single_click", "double_click", "long_press"], + ), + LocalTuyaEntity( + id=(DPCode.SWITCH5_VALUE, DPCode.SWITCH_TYPE_5), + name="Switch 5", + icon="mdi:palette-outline", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector( + { + "single_click": "Single click", + "double_click": "Double click", + "long_press": "Long Press", + } + ), + condition_contains_any=["single_click", "double_click", "long_press"], + ), + LocalTuyaEntity( + id=DPCode.MODE, + name="Mode", + icon="mdi:cog", + entity_category=EntityCategory.CONFIG, + custom_configs=localtuya_selector( + {"remote_control": "Remote", "wireless_switch": "Wireless"} + ), + condition_contains_any=["remote_control", "wireless_switch"], + ), + *SELECTS["kg"], +) + +# Scene Switch +# https://developer.tuya.com/en/docs/iot/f?id=K9gf7nx6jelo8 +SELECTS["cjkg"] = SELECTS["kg"] + +# Fan wall switch +# For Power-on behavior +SELECTS["fskg"] = SELECTS["kg"] + +# Socket (duplicate of `kg`) +# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s +SELECTS["cz"] = SELECTS["kg"] + +# Power Socket (duplicate of `kg`) +# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s +SELECTS["pc"] = SELECTS["kg"] + +SELECTS["tdq"] = SELECTS["kg"] + +# Heater +SELECTS["rs"] = SELECTS["kt"] diff --git a/configs/home-assistant/custom_components/localtuya/core/ha_entities/sensors.py b/configs/home-assistant/custom_components/localtuya/core/ha_entities/sensors.py new file mode 100644 index 0000000..ef9609b --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/core/ha_entities/sensors.py @@ -0,0 +1,1907 @@ +""" + This a file contains available tuya data + https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq + + Credits: official HA Tuya integration. + Modified by: xZetsubou +""" + +from homeassistant.components.sensor import SensorStateClass, SensorDeviceClass +from homeassistant.const import ( + PERCENTAGE, + UnitOfTime, + UnitOfPower, + PERCENTAGE, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfTime, + CONF_UNIT_OF_MEASUREMENT, + UnitOfTemperature, + UnitOfEnergy, + UnitOfVolume, + UnitOfElectricPotential, + UnitOfMass, + DEGREE, + LIGHT_LUX, + UnitOfLength, +) + +from .base import ( + DPCode, + LocalTuyaEntity, + EntityCategory, + CLOUD_VALUE, +) +from ...const import CONF_SCALING as SCALE_FACTOR + + +def localtuya_sensor(unit_of_measurement=None, scale_factor: float = 1) -> dict: + """Define LocalTuya Configs for Sensor.""" + data = {CONF_UNIT_OF_MEASUREMENT: unit_of_measurement} + data.update({SCALE_FACTOR: CLOUD_VALUE(scale_factor, "id", "scale")}) + + return data + + +# Commonly used battery sensors, that are reused in the sensors down below. +BATTERY_SENSORS = ( + LocalTuyaEntity( + id=DPCode.BATTERY_PERCENTAGE, + name="Battery", + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + custom_configs=localtuya_sensor(PERCENTAGE), + ), + LocalTuyaEntity( + id=(DPCode.BATTERY_STATE, DPCode.BATTERYSTATUS), + name="Battery Level", + # name="battery_state", + icon="mdi:battery", + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.BATTERY_VALUE, + name="Battery", + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(PERCENTAGE), + ), + LocalTuyaEntity( + id=DPCode.VA_BATTERY, + name="Battery", + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(PERCENTAGE), + ), + LocalTuyaEntity( + id=DPCode.BATTERY, + name="Battery", + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(PERCENTAGE), + ), +) + +# All descriptions can be found here. Mostly the Integer data types in the +# default status set of each category (that don't have a set instruction) +# end up being a sensor. +# https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq +SENSORS: dict[str, tuple[LocalTuyaEntity, ...]] = { + # Wireless Switch # also can come as knob switch. + # https://developer.tuya.com/en/docs/iot/wxkg?id=Kbeo9t3ryuqm5 + "wxkg": ( + LocalTuyaEntity( + id=DPCode.MODE_1, + name="Switch 1 Mode", + icon="mdi:information-slab-circle-outline", + ), + LocalTuyaEntity( + id=DPCode.MODE_2, + name="Switch 2 Mode", + icon="mdi:information-slab-circle-outline", + ), + LocalTuyaEntity( + id=DPCode.KNOB_SWITCH_MODE_1, + name="Knob Mode", + icon="mdi:knob", + entity_category=EntityCategory.DIAGNOSTIC, + ), + *BATTERY_SENSORS, + ), + # Smart panel with switches and zigbee hub ? + # Not documented + "dgnzk": ( + LocalTuyaEntity( + id=DPCode.PLAY_INFO, + name="Playing", + icon="mdi:playlist-play", + ), + ), + # Multi-functional Sensor + # https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3 + "dgnbj": ( + LocalTuyaEntity( + id=DPCode.GAS_SENSOR_VALUE, + # name="gas", + icon="mdi:gas-cylinder", + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.CH4_SENSOR_VALUE, + # name="gas", + name="Methane", + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.VOC_VALUE, + # name="voc", + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.PM25_VALUE, + # name="pm25", + device_class=SensorDeviceClass.PM25, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.CO_VALUE, + # name="carbon_monoxide", + icon="mdi:molecule-co", + device_class=SensorDeviceClass.CO, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.CO2_VALUE, + # name="carbon_dioxide", + icon="mdi:molecule-co2", + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.CH2O_VALUE, + # name="formaldehyde", + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.BRIGHT_STATE, + # name="luminosity", + icon="mdi:brightness-6", + ), + LocalTuyaEntity( + id=DPCode.BRIGHT_VALUE, + # name="illuminance", + icon="mdi:brightness-6", + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.TEMP_CURRENT, + # name="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.HUMIDITY_VALUE, + # name="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.SMOKE_SENSOR_VALUE, + # name="smoke_amount", + icon="mdi:smoke-detector", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), + # Smart Kettle + # https://developer.tuya.com/en/docs/iot/fbh?id=K9gf484m21yq7 + "bh": ( + LocalTuyaEntity( + id=DPCode.TEMP_CURRENT, + # name="current_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.TEMP_CURRENT_F, + # name="current_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.STATUS, + # name="status", + ), + ), + # CO2 Detector + # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy + "co2bj": ( + LocalTuyaEntity( + id=DPCode.HUMIDITY_VALUE, + # name="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.TEMP_CURRENT, + # name="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.CO2_VALUE, + # name="carbon_dioxide", + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), + # Two-way temperature and humidity switch + # "MOES Temperature and Humidity Smart Switch Module MS-103" + # Documentation not found + "wkcz": ( + LocalTuyaEntity( + id=DPCode.HUMIDITY_VALUE, + name="Humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.TEMP_CURRENT, + name="Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + ), + # CO Detector + # https://developer.tuya.com/en/docs/iot/categorycobj?id=Kaiuz3u1j6q1v + "cobj": ( + LocalTuyaEntity( + id=DPCode.CO_VALUE, + # name="carbon_monoxide", + device_class=SensorDeviceClass.CO, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), + # Smart Pet Feeder + # https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld + "cwwsq": ( + LocalTuyaEntity( + id=DPCode.FEED_STATE, + icon="mdi:list-status", + ), + LocalTuyaEntity( + id=DPCode.CHARGE_STATE, + name="Charge state", + icon="mdi:power-plug-battery-outline", + ), + LocalTuyaEntity( + id=DPCode.FEED_REPORT, + # name="last_amount", + icon="mdi:counter", + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), + # Air Quality Monitor + # No specification on Tuya portal + "hjjcy": ( + LocalTuyaEntity( + id=DPCode.TEMP_CURRENT, + # name="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.HUMIDITY_VALUE, + # name="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.CO2_VALUE, + # name="carbon_dioxide", + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.CH2O_VALUE, + # name="formaldehyde", + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.VOC_VALUE, + # name="voc", + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.PM25_VALUE, + # name="pm25", + device_class=SensorDeviceClass.PM25, + state_class=SensorStateClass.MEASUREMENT, + ), + ), + # Formaldehyde Detector + # Note: Not documented + "jqbj": ( + LocalTuyaEntity( + id=DPCode.CO2_VALUE, + # name="carbon_dioxide", + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.VOC_VALUE, + # name="voc", + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.PM25_VALUE, + # name="pm25", + device_class=SensorDeviceClass.PM25, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.VA_HUMIDITY, + # name="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.VA_TEMPERATURE, + # name="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.CH2O_VALUE, + # name="formaldehyde", + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), + # Methane Detector + # https://developer.tuya.com/en/docs/iot/categoryjwbj?id=Kaiuz40u98lkm + "jwbj": ( + LocalTuyaEntity( + id=DPCode.CH4_SENSOR_VALUE, + # name="methane", + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), + # Switch + # https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s + "kg": ( + LocalTuyaEntity( + id=DPCode.CUR_CURRENT, + name="Current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfElectricCurrent.MILLIAMPERE), + ), + LocalTuyaEntity( + id=DPCode.CUR_POWER, + name="Power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfPower.WATT, 0.1), + ), + LocalTuyaEntity( + id=DPCode.CUR_VOLTAGE, + name="Voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfElectricPotential.VOLT, 0.1), + ), + LocalTuyaEntity( + id=DPCode.ADD_ELE, + name="Electricity", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + custom_configs=localtuya_sensor(UnitOfEnergy.KILO_WATT_HOUR, 0.001), + ), + # CZ - Energy monitor? + LocalTuyaEntity( + id=DPCode.CUR_CURRENT1, + name="Current 1", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfElectricCurrent.MILLIAMPERE), + ), + LocalTuyaEntity( + id=DPCode.CUR_CURRENT2, + name="Current 2", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfElectricCurrent.MILLIAMPERE), + ), + LocalTuyaEntity( + id=DPCode.CUR_POWER1, + name="Power 1", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfPower.WATT, 0.1), + ), + LocalTuyaEntity( + id=DPCode.CUR_POWER2, + name="Power 2", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfPower.WATT, 0.1), + ), + LocalTuyaEntity( + id=DPCode.CUR_VOLTAGE1, + name="Voltage 1", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfElectricPotential.VOLT, 0.1), + ), + LocalTuyaEntity( + id=DPCode.CUR_VOLTAGE2, + name="Voltage 2", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfElectricPotential.VOLT, 0.1), + ), + LocalTuyaEntity( + id=DPCode.ADD_ELE1, + name="Electricity 1", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + custom_configs=localtuya_sensor(UnitOfEnergy.KILO_WATT_HOUR, 0.001), + ), + LocalTuyaEntity( + id=DPCode.ADD_ELE2, + name="Electricity 2", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + custom_configs=localtuya_sensor(UnitOfEnergy.KILO_WATT_HOUR, 0.001), + ), + LocalTuyaEntity( + id=DPCode.TOTAL_ENERGY, + name="Total Energy", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + custom_configs=localtuya_sensor(UnitOfEnergy.KILO_WATT_HOUR, 0.001), + ), + LocalTuyaEntity( + id=DPCode.TOTAL_ENERGY1, + name="Total Energy 1", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + custom_configs=localtuya_sensor(UnitOfEnergy.KILO_WATT_HOUR, 0.001), + ), + LocalTuyaEntity( + id=DPCode.TOTAL_ENERGY2, + name="Total Energy 2", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + custom_configs=localtuya_sensor(UnitOfEnergy.KILO_WATT_HOUR, 0.001), + ), + LocalTuyaEntity( + id=DPCode.TODAY_ACC_ENERGY, + name="Today Energy", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + custom_configs=localtuya_sensor(UnitOfEnergy.KILO_WATT_HOUR, 0.001), + ), + LocalTuyaEntity( + id=DPCode.TODAY_ACC_ENERGY1, + name="Today Energy 1", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + custom_configs=localtuya_sensor(UnitOfEnergy.KILO_WATT_HOUR, 0.001), + ), + LocalTuyaEntity( + id=DPCode.TODAY_ACC_ENERGY2, + name="Today Energy 2", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + custom_configs=localtuya_sensor(UnitOfEnergy.KILO_WATT_HOUR, 0.001), + ), + LocalTuyaEntity( + id=DPCode.TODAY_ENERGY_ADD, + name="Today Energy Increase", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + custom_configs=localtuya_sensor(UnitOfEnergy.KILO_WATT_HOUR, 0.001), + ), + LocalTuyaEntity( + id=DPCode.TODAY_ENERGY_ADD1, + name="Today Energy 1 Increase", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + custom_configs=localtuya_sensor(UnitOfEnergy.KILO_WATT_HOUR, 0.001), + ), + LocalTuyaEntity( + id=DPCode.TODAY_ENERGY_ADD2, + name="Today Energy 2 Increase", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + custom_configs=localtuya_sensor(UnitOfEnergy.KILO_WATT_HOUR, 0.001), + ), + LocalTuyaEntity( + id=DPCode.SYNC_REQUEST, + name="Sync Request", + ), + LocalTuyaEntity( + id=DPCode.DEVICE_STATE1, + name="Device 1 State", + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.DEVICE_STATE2, + name="Device 2 State", + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.NET_STATE, + name="Connection state", + entity_category=EntityCategory.DIAGNOSTIC, + icon="mdi:network", + ), + ), + # IoT Switch + # Note: Undocumented + "tdq": ( + LocalTuyaEntity( + id=DPCode.CUR_CURRENT, + name="Current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfElectricCurrent.MILLIAMPERE), + # entity_registry_enabled_default=False, + ), + LocalTuyaEntity( + id=DPCode.CUR_POWER, + name="Power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfPower.WATT, 0.1), + # entity_registry_enabled_default=False, + ), + LocalTuyaEntity( + id=DPCode.CUR_VOLTAGE, + name="Voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfElectricPotential.VOLT, 0.1), + # entity_registry_enabled_default=False, + ), + LocalTuyaEntity( + id=DPCode.ADD_ELE, + name="Electricity", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + custom_configs=localtuya_sensor(UnitOfEnergy.KILO_WATT_HOUR, 0.001), + ), + ), + # Luminance Sensor + # https://developer.tuya.com/en/docs/iot/categoryldcg?id=Kaiuz3n7u69l8 + "ldcg": ( + LocalTuyaEntity( + id=DPCode.BRIGHT_STATE, + # name="luminosity", + icon="mdi:brightness-6", + ), + LocalTuyaEntity( + id=DPCode.BRIGHT_VALUE, + # name="illuminance", + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.TEMP_CURRENT, + # name="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.HUMIDITY_VALUE, + # name="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.CO2_VALUE, + # name="carbon_dioxide", + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), + # Door and Window Controller + # https://developer.tuya.com/en/docs/iot/s?id=K9gf48r5zjsy9 + "mc": BATTERY_SENSORS, + # Door Window Sensor + # https://developer.tuya.com/en/docs/iot/s?id=K9gf48hm02l8m + "mcs": BATTERY_SENSORS, + # Sous Vide Cooker + # https://developer.tuya.com/en/docs/iot/categorymzj?id=Kaiuz2vy130ux + "mzj": ( + LocalTuyaEntity( + id=DPCode.TEMP_CURRENT, + # name="current_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.STATUS, + # name="sous_vide_status", + ), + LocalTuyaEntity( + id=DPCode.REMAIN_TIME, + name="Timer Remaining", + custom_configs=localtuya_sensor(UnitOfTime.MINUTES), + icon="mdi:timer", + entity_category=EntityCategory.DIAGNOSTIC, + ), + ), + # PIR Detector + # https://developer.tuya.com/en/docs/iot/categorypir?id=Kaiuz3ss11b80 + "pir": ( + LocalTuyaEntity( + id=DPCode.PM25_VALUE, + # name="pm25", + device_class=SensorDeviceClass.PM25, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.MOD_ON_TMR_CD, + icon="mdi:timer-edit-outline", + name="Timer left", + entity_category=EntityCategory.DIAGNOSTIC, + custom_configs=localtuya_sensor("s"), + ), + LocalTuyaEntity( + id=DPCode.ILLUMINANCE_VALUE, + name="Illuminance", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(LIGHT_LUX), + ), + *BATTERY_SENSORS, + ), + # PM2.5 Sensor + # https://developer.tuya.com/en/docs/iot/categorypm25?id=Kaiuz3qof3yfu + "pm2.5": ( + LocalTuyaEntity( + id=DPCode.PM25_VALUE, + # name="pm25", + device_class=SensorDeviceClass.PM25, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.CH2O_VALUE, + # name="formaldehyde", + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.VOC_VALUE, + # name="voc", + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.TEMP_CURRENT, + # name="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.CO2_VALUE, + # name="carbon_dioxide", + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.HUMIDITY_VALUE, + # name="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.PM1, + # name="pm1", + device_class=SensorDeviceClass.PM1, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.PM10, + # name="pm10", + device_class=SensorDeviceClass.PM10, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), + # EV Charcher + # https://developer.tuya.com/en/docs/iot/categoryqn?id=Kaiuz18kih0sm + "qccdz": ( + LocalTuyaEntity( + id=DPCode.WORK_STATE, + name="Work state", + ), + LocalTuyaEntity( + id=DPCode.DEVICESTATE, + name="Device state", + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.PHASEFLAG, + name="Phase Flag", + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.DEVICEMAXSETA, + name="Max Set Ampere", + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.DEVICEMAXSETA, + name="Max Set Ampere", + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.DEVICETEMP, + name="Device Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfTemperature.CELSIUS, 0.1), + ), + LocalTuyaEntity( + id=DPCode.BALANCE_ENERGY, + name="Energy Balance", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + custom_configs=localtuya_sensor(UnitOfEnergy.KILO_WATT_HOUR, 0.001), + ), + LocalTuyaEntity( + id=DPCode.CHARGE_ENERGY_ONCE, + name="Energy charge", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + custom_configs=localtuya_sensor(UnitOfEnergy.KILO_WATT_HOUR, 0.01), + ), + LocalTuyaEntity( + id=DPCode.DEVICEKW, + name="Device kW", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfPower.KILO_WATT, 0.1), + ), + LocalTuyaEntity( + id=DPCode.DEVICEKW, + name="Device kWh", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + custom_configs=localtuya_sensor(UnitOfEnergy.KILO_WATT_HOUR, 0.01), + ), + LocalTuyaEntity( + id=DPCode.A_VOLTAGE, + name="Voltage A", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfElectricPotential.VOLT, 0.1), + ), + LocalTuyaEntity( + id=DPCode.B_VOLTAGE, + name="Voltage B", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfElectricPotential.VOLT, 0.1), + ), + LocalTuyaEntity( + id=DPCode.C_VOLTAGE, + name="Voltage C", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfElectricPotential.VOLT, 0.1), + ), + LocalTuyaEntity( + id=DPCode.A_CURRENT, + name="Current A", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfElectricPotential.VOLT, 0.01), + ), + LocalTuyaEntity( + id=DPCode.B_CURRENT, + name="Current B", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfElectricPotential.VOLT, 0.01), + ), + LocalTuyaEntity( + id=DPCode.C_CURRENT, + name="Current C", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfElectricPotential.VOLT, 0.01), + ), + ), + # Heater + # https://developer.tuya.com/en/docs/iot/categoryqn?id=Kaiuz18kih0sm + "qn": ( + LocalTuyaEntity( + id=DPCode.WORK_POWER, + name="Power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfPower.WATT, 0.1), + ), + ), + # Generic products, EV Charger + # https://support.tuya.com/en/help/_detail/K9g77zfmlnwal + "qt": ( + LocalTuyaEntity( + id=DPCode.IS_LOGIN, + name="Is login", + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.VOLTAGE_PHASE_A, + name="Voltage Phase A", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfElectricPotential.VOLT, 0.01), + ), + LocalTuyaEntity( + id=DPCode.VOLTAGE_PHASE_B, + name="Voltage Phase B", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfElectricPotential.VOLT, 0.01), + ), + LocalTuyaEntity( + id=DPCode.VOLTAGE_PHASE_C, + name="Voltage Phase C", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfElectricPotential.VOLT, 0.01), + ), + LocalTuyaEntity( + id=DPCode.ELECTRICITY_PHASE_A, + name="Electricity Phase A", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfElectricCurrent.AMPERE, 0.01), + ), + LocalTuyaEntity( + id=DPCode.ELECTRICITY_PHASE_B, + name="Electricity Phase B", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfElectricCurrent.AMPERE, 0.01), + ), + LocalTuyaEntity( + id=DPCode.ELECTRICITY_PHASE_C, + name="Electricity Phase C", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfElectricCurrent.AMPERE, 0.01), + ), + LocalTuyaEntity( + id=DPCode.ELECTRICITY_TOTAL, + name="Electricity Total", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfElectricCurrent.AMPERE, 0.01), + ), + LocalTuyaEntity( + id=DPCode.CHARGE_ELECTRIC_QUANTITY, + name="Charge Electric Quantity", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + custom_configs=localtuya_sensor(UnitOfEnergy.KILO_WATT_HOUR, 0.01), + ), + LocalTuyaEntity( + id=DPCode.CHARGE_MONEY, + name="Charge Money", + ), + LocalTuyaEntity( + id=DPCode.CARD_BALANCE, + name="Card Balance", + ), + LocalTuyaEntity( + id=DPCode.LOAD_BALANCING_STATE, + name="Load Balancing State", + ), + LocalTuyaEntity( + id=DPCode.VERSION_NUMBER, + name="Version", + entity_category=EntityCategory.DIAGNOSTIC, + ), + ), + # Weather Station + "qxj": ( + LocalTuyaEntity( + id=DPCode.TEMP_CURRENT, + name="Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfTemperature.CELSIUS), + ), + LocalTuyaEntity( + id=DPCode.TEMP_CURRENT_EXTERNAL_1, + name="Temperature External 1", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfTemperature.CELSIUS), + ), + LocalTuyaEntity( + id=DPCode.TEMP_CURRENT_EXTERNAL_2, + name="Temperature External 2", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfTemperature.CELSIUS), + ), + LocalTuyaEntity( + id=DPCode.TEMP_CURRENT_EXTERNAL_3, + name="Temperature External 3", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfTemperature.CELSIUS), + ), + LocalTuyaEntity( + id=DPCode.HUMIDITY_VALUE, + name="Humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.HUMIDITY_OUTDOOR_1, + name="Humidity Outdoor 1", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.HUMIDITY_OUTDOOR_2, + name="Humidity Outdoor 2", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.HUMIDITY_OUTDOOR_3, + name="Humidity Outdoor 3", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), + # Gas Detector + # https://developer.tuya.com/en/docs/iot/categoryrqbj?id=Kaiuz3d162ubw + "rqbj": ( + LocalTuyaEntity( + id=DPCode.GAS_SENSOR_VALUE, + icon="mdi:gas-cylinder", + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), + # Water Detector + # https://developer.tuya.com/en/docs/iot/categorysj?id=Kaiuz3iub2sli + "sj": ( + LocalTuyaEntity( + id=DPCode.WATERSENSOR_STATE, + icon="mdi:water", + ), + LocalTuyaEntity( + id=DPCode.TEMP_STATUS, + name="Temperature Status", + icon="mdi:thermometer-check", + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.HUMI_STATUS, + name="Humidity Status", + icon="mdi:water-percent-alert", + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.POWER, + icon="mdi:power", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.HUMIDITY_VALUE, + name="Humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(PERCENTAGE, 0.01), + ), + LocalTuyaEntity( + id=(DPCode.TEMP_CURRENT, DPCode.TEMP_CURRENT_F), + name="Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), + # Emergency Button + # https://developer.tuya.com/en/docs/iot/categorysos?id=Kaiuz3oi6agjy + "sos": BATTERY_SENSORS, + # Smart Camera + # https://developer.tuya.com/en/docs/iot/categorysp?id=Kaiuz35leyo12 + "sp": ( + LocalTuyaEntity( + id=DPCode.SENSOR_TEMPERATURE, + # name="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.SENSOR_HUMIDITY, + # name="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.WIRELESS_ELECTRICITY, + name="Battery", + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), + ), + # Water Valve + "sfkzq": ( + LocalTuyaEntity( + id=DPCode.WORK_STATE, + name="State", + icon="mdi:state-machine", + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.USE_TIME_ONE, + name="Single Usage Time", + icon="mdi:chart-arc", + entity_category=EntityCategory.DIAGNOSTIC, + custom_configs=localtuya_sensor(unit_of_measurement=UnitOfTime.SECONDS), + ), + LocalTuyaEntity( + id=(DPCode.TIME_USE, DPCode.USE_TIME), + name="Usage Time", + icon="mdi:chart-arc", + entity_category=EntityCategory.DIAGNOSTIC, + custom_configs=localtuya_sensor(unit_of_measurement=UnitOfTime.SECONDS), + ), + *BATTERY_SENSORS, + ), + # Fingerbot + "szjqr": BATTERY_SENSORS, + # Solar Light + # https://developer.tuya.com/en/docs/iot/tynd?id=Kaof8j02e1t98 + "tyndj": BATTERY_SENSORS, + # Volatile Organic Compound Sensor + # Note: Undocumented in cloud API docs, based on test device + "voc": ( + LocalTuyaEntity( + id=DPCode.CO2_VALUE, + # name="carbon_dioxide", + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.PM25_VALUE, + # name="pm25", + device_class=SensorDeviceClass.PM25, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.CH2O_VALUE, + # name="formaldehyde", + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.HUMIDITY_VALUE, + # name="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.TEMP_CURRENT, + # name="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.VOC_VALUE, + # name="voc", + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), + # Thermostat + # https://developer.tuya.com/en/docs/iot/f?id=K9gf45ld5l0t9 + "wk": { + LocalTuyaEntity( + id=(DPCode.TEMP_CURRENT, DPCode.TEMPFLOOR), + name="External temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + }, + # Thermostatic Radiator Valve + # Not documented + "wkf": BATTERY_SENSORS, + # Temperature and Humidity Sensor + # https://developer.tuya.com/en/docs/iot/categorywsdcg?id=Kaiuz3hinij34 + "wsdcg": ( + LocalTuyaEntity( + id=DPCode.VA_TEMPERATURE, + name="Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=(DPCode.TEMP_CURRENT, DPCode.PRM_CONTENT), + name="Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfTemperature.CELSIUS, 0.01), + ), + LocalTuyaEntity( + id=(DPCode.HUMIDITY_VALUE, DPCode.PRM_CONTENT, DPCode.VA_HUMIDITY), + name="Humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(PERCENTAGE, 0.01), + ), + LocalTuyaEntity( + id=DPCode.BRIGHT_VALUE, + name="Illuminance", + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(LIGHT_LUX), + ), + *BATTERY_SENSORS, + ), + # Pressure Sensor + # https://developer.tuya.com/en/docs/iot/categoryylcg?id=Kaiuz3kc2e4gm + "ylcg": ( + LocalTuyaEntity( + id=DPCode.PRESSURE_VALUE, + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), + # Smoke Detector + # https://developer.tuya.com/en/docs/iot/categoryywbj?id=Kaiuz3f6sf952 + "ywbj": ( + LocalTuyaEntity( + id=DPCode.SMOKE_SENSOR_VALUE, + # name="smoke_amount", + icon="mdi:smoke-detector", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), + # Vibration Sensor + # https://developer.tuya.com/en/docs/iot/categoryzd?id=Kaiuz3a5vrzno + "zd": BATTERY_SENSORS, + # Smart Electricity Meter + # https://developer.tuya.com/en/docs/iot/smart-meter?id=Kaiuz4gv6ack7 + "zndb": ( + LocalTuyaEntity( + id=DPCode.FORWARD_ENERGY_TOTAL, + # name="total_energy", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + custom_configs=localtuya_sensor(UnitOfEnergy.KILO_WATT_HOUR, 0.01), + ), + LocalTuyaEntity( + id=DPCode.REVERSE_ENERGY_TOTAL, + name="Total Reverse Energy", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + custom_configs=localtuya_sensor(UnitOfEnergy.KILO_WATT_HOUR, 0.01), + ), + ## PHASE X Are probably encrypted values. since it duplicated it probably raw dict data. + LocalTuyaEntity( + id=DPCode.PHASE_A, + name="Phase C Current", + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.PHASE_B, + name="Phase B", + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.PHASE_C, + name="Phase C", + entity_category=EntityCategory.DIAGNOSTIC, + ), + ## PHASE X Are probably encrypted values. since it duplicated it probably raw dict data. + LocalTuyaEntity( + id=DPCode.POWER_A, + name="Power A", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfPower.WATT, 0.1), + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.POWER_B, + name="Power B", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfPower.WATT, 0.1), + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.POWER_C, + name="Power C", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfPower.WATT, 0.1), + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.ENERGY_FORWORD_A, + name="Energy A", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + custom_configs=localtuya_sensor(UnitOfEnergy.KILO_WATT_HOUR, 0.1), + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.ENERGY_FORWORD_B, + name="Energy B", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + custom_configs=localtuya_sensor(UnitOfEnergy.KILO_WATT_HOUR, 0.1), + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.ENERGY_FORWORD_C, + name="Energy C", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + custom_configs=localtuya_sensor(UnitOfEnergy.KILO_WATT_HOUR, 0.1), + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=(DPCode.ENERGY_REVERSE_A, DPCode.ENERGY_RESERSE_A), + name="Reverse Energy A", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + custom_configs=localtuya_sensor(UnitOfEnergy.KILO_WATT_HOUR, 0.1), + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=(DPCode.ENERGY_REVERSE_B, DPCode.ENERGY_RESERSE_B), + name="Reverse Energy B", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + custom_configs=localtuya_sensor(UnitOfEnergy.KILO_WATT_HOUR, 0.1), + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=(DPCode.ENERGY_REVERSE_C, DPCode.ENERGY_RESERSE_C), + name="Reverse Energy C", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + custom_configs=localtuya_sensor(UnitOfEnergy.KILO_WATT_HOUR, 0.1), + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=(DPCode.POWER_FACTOR, DPCode.POWER_FACTOR_A), + name="Power Factor A", + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.POWER_FACTOR_B, + name="Power Factor B", + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.POWER_FACTOR_C, + name="Power Factor C", + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.DIRECTION_A, + name="Direction A", + icon="mdi:arrow-up-down", + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.DIRECTION_B, + name="Direction B", + icon="mdi:arrow-up-down", + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.DIRECTION_C, + name="Direction C", + icon="mdi:arrow-up-down", + entity_category=EntityCategory.DIAGNOSTIC, + ), + ), + # Robot Vacuum + # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo + "sd": ( + LocalTuyaEntity( + id=DPCode.CLEAN_AREA, + # name="cleaning_area", + icon="mdi:texture-box", + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.CLEAN_TIME, + # name="cleaning_time", + icon="mdi:progress-clock", + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.TOTAL_CLEAN_AREA, + # name="total_cleaning_area", + icon="mdi:texture-box", + state_class=SensorStateClass.TOTAL_INCREASING, + ), + LocalTuyaEntity( + id=DPCode.TOTAL_CLEAN_TIME, + # name="total_cleaning_time", + icon="mdi:history", + state_class=SensorStateClass.TOTAL_INCREASING, + ), + LocalTuyaEntity( + id=DPCode.TOTAL_CLEAN_COUNT, + # name="total_cleaning_times", + icon="mdi:counter", + state_class=SensorStateClass.TOTAL_INCREASING, + ), + LocalTuyaEntity( + id=DPCode.DUSTER_CLOTH, + # name="duster_cloth_life", + icon="mdi:ticket-percent-outline", + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.EDGE_BRUSH, + # name="side_brush_life", + icon="mdi:ticket-percent-outline", + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.FILTER_LIFE, + # name="filter_life", + icon="mdi:ticket-percent-outline", + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.ROLL_BRUSH, + # name="rolling_brush_life", + icon="mdi:ticket-percent-outline", + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=(DPCode.ELECTRICITY_LEFT, DPCode.RESIDUAL_ELECTRICITY), + name="Battery", + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), + # Curtain + # https://developer.tuya.com/en/docs/iot/s?id=K9gf48qy7wkre + "cl": ( + LocalTuyaEntity( + id=DPCode.TIME_TOTAL, + # name="last_operation_duration", + entity_category=EntityCategory.DIAGNOSTIC, + icon="mdi:progress-clock", + ), + ), + # Pet Water Feeder + # https://developer.tuya.com/en/docs/iot/f?id=K9gf46aewxem5 + "cwysj": ( + LocalTuyaEntity( + id=DPCode.FILTER_LIFE, + # name="filter_life", + icon="mdi:ticket-percent-outline", + state_class=SensorStateClass.MEASUREMENT, + ), + ), + # Humidifier + # https://developer.tuya.com/en/docs/iot/s?id=K9gf48qwjz0i3 + "jsq": ( + LocalTuyaEntity( + id=DPCode.HUMIDITY_CURRENT, + name="Humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.TEMP_CURRENT, + name="Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.TEMP_CURRENT_F, + name="Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.LEVEL_CURRENT, + name="Water Level", + entity_category=EntityCategory.DIAGNOSTIC, + icon="mdi:waves-arrow-up", + ), + ), + # Air Purifier + # https://developer.tuya.com/en/docs/iot/s?id=K9gf48r41mn81 + "kj": ( + LocalTuyaEntity( + id=DPCode.FILTER, + # name="filter_utilization", + entity_category=EntityCategory.DIAGNOSTIC, + icon="mdi:ticket-percent-outline", + ), + LocalTuyaEntity( + id=DPCode.PM25, + # name="pm25", + device_class=SensorDeviceClass.PM25, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:molecule", + ), + LocalTuyaEntity( + id=DPCode.TEMP, + # name="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.HUMIDITY, + # name="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.TVOC, + # name="total_volatile_organic_compound", + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.ECO2, + # name="concentration_carbon_dioxide", + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.TOTAL_TIME, + # name="total_operating_time", + icon="mdi:history", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.TOTAL_PM, + # name="total_absorption_particles", + icon="mdi:texture-box", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.AIR_QUALITY, + # name="air_quality", + icon="mdi:air-filter", + ), + ), + # Fan + # https://developer.tuya.com/en/docs/iot/s?id=K9gf48quojr54 + "fs": ( + LocalTuyaEntity( + id=DPCode.TEMP_CURRENT, + name="Current Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfTemperature.CELSIUS), + ), + LocalTuyaEntity( + id=DPCode.HUMIDITY, + name="Current Humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(PERCENTAGE), + ), + LocalTuyaEntity( + id=DPCode.HEAT_WD, + name="Heating Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfTemperature.CELSIUS), + ), + LocalTuyaEntity( + id=DPCode.PVRPM, + name="Fan Speed", + icon="mdi:fan", + custom_configs=localtuya_sensor("rpm"), + ), + ), + # eMylo Smart WiFi IR Remote + # Air Conditioner Mate (Smart IR Socket) + "wnykq": ( + LocalTuyaEntity( + id=(DPCode.VA_TEMPERATURE, DPCode.TEMP_CURRENT, DPCode.TEMP_CURRENT_F), + name="Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=(DPCode.VA_HUMIDITY, DPCode.HUMIDITY_VALUE), + name="Humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.CUR_CURRENT, + name="Current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + custom_configs=localtuya_sensor(UnitOfElectricCurrent.MILLIAMPERE), + # entity_registry_enabled_default=False, + ), + LocalTuyaEntity( + id=DPCode.CUR_POWER, + name="Power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + custom_configs=localtuya_sensor(UnitOfPower.WATT, 0.1), + # entity_registry_enabled_default=False, + ), + LocalTuyaEntity( + id=DPCode.CUR_VOLTAGE, + name="Voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + custom_configs=localtuya_sensor(UnitOfElectricPotential.VOLT, 0.1), + # entity_registry_enabled_default=False, + ), + LocalTuyaEntity( + id=DPCode.ADD_ELE, + name="Electricity", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + custom_configs=localtuya_sensor(UnitOfEnergy.KILO_WATT_HOUR, 0.001), + ), + ), + # Dehumidifier + # https://developer.tuya.com/en/docs/iot/s?id=K9gf48r6jke8e + "cs": ( + LocalTuyaEntity( + id=DPCode.TEMP_INDOOR, + name="Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfTemperature.CELSIUS), + ), + LocalTuyaEntity( + id=DPCode.HUMIDITY_INDOOR, + name="Humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(PERCENTAGE), + ), + LocalTuyaEntity( + id=DPCode.COUNTDOWN_LEFT, + name="Timer Remaining", + custom_configs=localtuya_sensor(UnitOfTime.MINUTES), + icon="mdi:timer", + entity_category=EntityCategory.DIAGNOSTIC, + ), + # Sensors 'Micro Inverter' ? + LocalTuyaEntity( + id=DPCode.PV_POWER, + name="PV Power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfPower.WATT), + ), + LocalTuyaEntity( + id=DPCode.EMISSION, + name="Emission", + device_class=SensorDeviceClass.WEIGHT, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfMass.KILOGRAMS), + ), + LocalTuyaEntity( + id=DPCode.PV_VOLT, + name="PV Voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfElectricPotential.VOLT), + ), + LocalTuyaEntity( + id=DPCode.TEMPERATURE, + name="Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfTemperature.CELSIUS), + ), + LocalTuyaEntity( + id=DPCode.AC_CURRENT, + name="AC Current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfElectricCurrent.AMPERE), + ), + LocalTuyaEntity( + id=DPCode.PV_CURRENT, + name="PV Current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfElectricCurrent.AMPERE), + ), + LocalTuyaEntity( + id=DPCode.AC_VOLT, + name="AC Voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfElectricPotential.VOLT), + ), + LocalTuyaEntity( + id=DPCode.DAY_ENERGY, + name="Daily Consumption", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + custom_configs=localtuya_sensor(UnitOfEnergy.KILO_WATT_HOUR), + ), + LocalTuyaEntity( + id=DPCode.ENERGY, + name="Energy", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + custom_configs=localtuya_sensor(UnitOfEnergy.KILO_WATT_HOUR), + ), + LocalTuyaEntity( + id=DPCode.OUT_POWER, + name="Out Power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfPower.WATT), + ), + LocalTuyaEntity( + id=DPCode.PLANT, + name="Plant", + custom_configs=localtuya_sensor("pcs"), + ), + ), + # Soil sensor (Plant monitor) + "zwjcy": ( + LocalTuyaEntity( + id=DPCode.TEMP_CURRENT, + # name="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + LocalTuyaEntity( + id=DPCode.HUMIDITY, + # name="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), + # Alarm Host + # https://developer.tuya.com/en/docs/iot/categorymal?id=Kaiuz33clqxaf + "mal": ( + LocalTuyaEntity( + id=DPCode.SUB_STATE, + name="Sub-Device State", + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.POWEREVENT, + name="Power Event", + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.ZONE_NUMBER, + name="Zone Number", + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.OTHEREVENT, + name="Other Event", + entity_category=EntityCategory.DIAGNOSTIC, + ), + ), + # Lock + "ms": ( + LocalTuyaEntity( + id=DPCode.LOCK_MOTOR_STATE, + name="Motor State", + entity_category=EntityCategory.DIAGNOSTIC, + ), + ), + # Cat litter box + # https://developer.tuya.com/en/docs/iot/f?id=Kakg309qkmuit + "msp": ( + LocalTuyaEntity( + id=DPCode.TEMPERATURE, + name="Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfTemperature.CELSIUS, 0.1), + ), + LocalTuyaEntity( + id=(DPCode.HUMIDITY, DPCode.HUMIDITY_CURRENT), + name="Humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(PERCENTAGE, 0.1), + ), + LocalTuyaEntity( + id=DPCode.CAT_WEIGHT, + name="Cat Weight", + device_class=SensorDeviceClass.WEIGHT, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfMass.KILOGRAMS, 0.1), + ), + LocalTuyaEntity( + id=DPCode.EXCRETION_TIMES_DAY, + name="Excretion times", + custom_configs=localtuya_sensor("times"), + ), + LocalTuyaEntity( + id=DPCode.EXCRETION_TIME_DAY, + name="Excretion duration", + ), + LocalTuyaEntity( + id=DPCode.COLD_TEMP_CURRENT, + name="Cold Temp Current", + custom_configs=localtuya_sensor(scale_factor=0.1), + ), + LocalTuyaEntity( + id=DPCode.DATA_IDENTIFICATION, + ), + ), + # Smart Water Meter + # https://developer.tuya.com/en/docs/iot/f?id=Ka8n052xu7w4c + "znsb": ( + LocalTuyaEntity( + id=DPCode.WATER_USE_DATA, + name="Total Water Consumption", + icon="mdi:water-outline", + device_class=SensorDeviceClass.WATER, + state_class=SensorStateClass.TOTAL_INCREASING, + custom_configs=localtuya_sensor(UnitOfVolume.LITERS, 1), + ), + LocalTuyaEntity( + id=DPCode.WATER_TEMP, + name="Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + custom_configs=localtuya_sensor(UnitOfTemperature.CELSIUS, 0.01), + ), + LocalTuyaEntity( + id=DPCode.VOLTAGE_CURRENT, + name="Battery", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + custom_configs=localtuya_sensor(UnitOfElectricPotential.VOLT, 0.01), + ), + ), + # Air conditioner + # https://developer.tuya.com/en/docs/iot/categorykt?id=Kaiuz0z71ov2n + "kt": ( + LocalTuyaEntity( + id=DPCode.AIR_RETURN, + name="AIR Return", + icon="mdi:air-filter", + custom_configs=localtuya_sensor(DEGREE, 0.1), + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.COIL_OUT, + name="Coil Out", + icon="mdi:heating-coil", + custom_configs=localtuya_sensor(DEGREE, 0.1), + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.DEFROST, + name="Defrosting", + icon="mdi:snowflake-melt", + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.COUNTDOWN, + name="Timer State", + icon="mdi:timer-sand", + custom_configs=localtuya_sensor(UnitOfTime.MINUTES, 1), + entity_category=EntityCategory.DIAGNOSTIC, + ), + LocalTuyaEntity( + id=DPCode.COMPRESSOR_COMMAND, + name="Compressor", + ), + LocalTuyaEntity( + id=DPCode.FOUT_WAY_VALVE, + name="Fout Way Valve", + ), + LocalTuyaEntity( + id=DPCode.ODU_FAN_SPEED, + name="ODU Fan Speed", + icon="mdi:fan", + ), + ), + # Ultrasonic level sensor + "ywcgq": ( + LocalTuyaEntity( + id=DPCode.LIQUID_STATE, + name="State", + ), + LocalTuyaEntity( + id=DPCode.LIQUID_DEPTH, + name="Depth", + icon="mdi:altimeter", + custom_configs=localtuya_sensor(UnitOfLength.METERS, 1), + ), + LocalTuyaEntity( + id=DPCode.LIQUID_LEVEL_PERCENT, + name="Level", + icon="mdi:altimeter", + custom_configs=localtuya_sensor(PERCENTAGE, 1), + ), + ), + # Lawn mower + "gcj": ( + LocalTuyaEntity( + id=DPCode.MACHINESTATUS, + name="State", + ), + LocalTuyaEntity( + id=DPCode.MACHINEPASSWORD, + name="Password", + entity_category=EntityCategory.DIAGNOSTIC, + icon="mdi:lock-question-outline", + ), + LocalTuyaEntity( + id=DPCode.MACHINECOVER, + name="Cover", + entity_category=EntityCategory.DIAGNOSTIC, + icon="mdi:shield-lock-outline", + ), + *BATTERY_SENSORS, + ), +} + + +# Circuit Breaker +# https://developer.tuya.com/en/docs/iot/dlq?id=Kb0kidk9enyh8 +SENSORS["dlq"] = SENSORS["zndb"] + +# Socket (duplicate of `kg`) +# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s +SENSORS["cz"] = SENSORS["kg"] + +# Power Socket (duplicate of `kg`) +# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s +SENSORS["pc"] = SENSORS["kg"] diff --git a/configs/home-assistant/custom_components/localtuya/core/ha_entities/sirens.py b/configs/home-assistant/custom_components/localtuya/core/ha_entities/sirens.py new file mode 100644 index 0000000..42b65a3 --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/core/ha_entities/sirens.py @@ -0,0 +1,34 @@ +""" + This a file contains available tuya data + https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq + Credits: official HA Tuya integration. + Modified by: xZetsubou +""" + +from .base import DPCode, LocalTuyaEntity, CONF_DEVICE_CLASS, EntityCategory + +# All descriptions can be found here: +# https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq +SIRENS: dict[str, tuple[LocalTuyaEntity, ...]] = { + # Multi-functional Sensor + # https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3 + "dgnbj": ( + LocalTuyaEntity( + id=(DPCode.ALARM_SWITCH, DPCode.ALARMSWITCH), + ), + ), + # Siren Alarm + # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu + "sgbj": ( + LocalTuyaEntity( + id=(DPCode.ALARM_SWITCH, DPCode.ALARMSWITCH), + ), + ), + # Smart Camera + # https://developer.tuya.com/en/docs/iot/categorysp?id=Kaiuz35leyo12 + "sp": ( + LocalTuyaEntity( + id=DPCode.SIREN_SWITCH, + ), + ), +} diff --git a/configs/home-assistant/custom_components/localtuya/core/ha_entities/switches.py b/configs/home-assistant/custom_components/localtuya/core/ha_entities/switches.py new file mode 100644 index 0000000..dc9f4a2 --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/core/ha_entities/switches.py @@ -0,0 +1,1040 @@ +""" + This a file contains available tuya data + https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq + + Credits: official HA Tuya integration. + Modified by: xZetsubou +""" + +from .base import DPCode, LocalTuyaEntity, CONF_DEVICE_CLASS, EntityCategory +from homeassistant.components.switch import SwitchDeviceClass + +CHILD_LOCK = ( + LocalTuyaEntity( + id=DPCode.CHILD_LOCK, + name="Child Lock", + icon="mdi:account-lock", + entity_category=EntityCategory.CONFIG, + ), +) +SWITCHES: dict[str, tuple[LocalTuyaEntity, ...]] = { + # Smart Kettle + # https://developer.tuya.com/en/docs/iot/fbh?id=K9gf484m21yq7 + "bh": ( + LocalTuyaEntity( + id=DPCode.START, + name="Start", + icon="mdi:kettle-steam", + ), + LocalTuyaEntity( + id=DPCode.WARM, + name="Warm", + entity_category=EntityCategory.CONFIG, + ), + ), + # EasyBaby + # Undocumented, might have a wider use + "cn": ( + LocalTuyaEntity( + id=DPCode.DISINFECTION, + name="Disinfection", + icon="mdi:bacteria", + ), + LocalTuyaEntity( + id=DPCode.WATER, + name="Water", + icon="mdi:water", + ), + ), + # Smart Pet Feeder + # https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld + "cwwsq": ( + LocalTuyaEntity( + id=DPCode.SLOW_FEED, + name="Slow Feed", + icon="mdi:speedometer-slow", + entity_category=EntityCategory.CONFIG, + ), + ), + # Pet Water Feeder + # https://developer.tuya.com/en/docs/iot/f?id=K9gf46aewxem5 + "cwysj": ( + LocalTuyaEntity( + id=DPCode.FILTER_RESET, + name="Reset Filter", + icon="mdi:filter", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.PUMP_RESET, + name="Reset Water Pump", + icon="mdi:pump", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.SWITCH, + name="Power", + ), + LocalTuyaEntity( + id=DPCode.WATER_RESET, + name="Reset Water", + icon="mdi:water-sync", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.UV, + name="UV Sterilization", + icon="mdi:lightbulb", + entity_category=EntityCategory.CONFIG, + ), + ), + # Light + # https://developer.tuya.com/en/docs/iot/f?id=K9i5ql3v98hn3 + "dj": ( + # There are sockets available with an RGB light + # that advertise as `dj`, but provide an additional + # switch to control the plug. + LocalTuyaEntity( + id=DPCode.SWITCH, + name="Plug", + ), + ), + # Circuit Breaker + "dlq": ( + LocalTuyaEntity( + id=DPCode.CHILD_LOCK, + name="Child Lock", + icon="mdi:account-lock", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.SWITCH, + name="Switch", + ), + ), + # Wake Up Light II + # Not documented + "hxd": ( + LocalTuyaEntity( + id=DPCode.SWITCH_1, + name="Radio", + icon="mdi:radio", + ), + LocalTuyaEntity( + id=DPCode.SWITCH_2, + name="Alarm 2", + icon="mdi:alarm", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.SWITCH_3, + name="Alarm 3", + icon="mdi:alarm", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.SWITCH_4, + name="Alarm 4", + icon="mdi:alarm", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.SWITCH_5, + name="Alarm 5", + icon="mdi:alarm", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.SWITCH_6, + name="Alarm 6", + icon="mdi:power-sleep", + ), + ), + # Two-way temperature and humidity switch + # "MOES Temperature and Humidity Smart Switch Module MS-103" + # Documentation not found + "wkcz": ( + LocalTuyaEntity( + id=DPCode.SWITCH_1, + name="Switch 1", + device_class=SwitchDeviceClass.OUTLET, + ), + LocalTuyaEntity( + id=DPCode.SWITCH_2, + name="Switch 2", + device_class=SwitchDeviceClass.OUTLET, + ), + ), + # Switch + # https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s + "kg": ( + LocalTuyaEntity( + id=DPCode.CHILD_LOCK, + name="Child lock", + icon="mdi:account-lock", + ), + LocalTuyaEntity( + id=DPCode.SWITCH, + name="Switch", + ), + LocalTuyaEntity( + id=DPCode.SWITCH_1, + name="Switch 1", + ), + LocalTuyaEntity( + id=DPCode.SWITCH_2, + name="Switch 2", + ), + LocalTuyaEntity( + id=DPCode.SWITCH_3, + name="Switch 3", + ), + LocalTuyaEntity( + id=DPCode.SWITCH_4, + name="Switch 4", + ), + LocalTuyaEntity( + id=DPCode.SWITCH_5, + name="Switch 5", + ), + LocalTuyaEntity( + id=DPCode.SWITCH_6, + name="Switch 6", + ), + LocalTuyaEntity( + id=DPCode.SWITCH_7, + name="Switch 7", + ), + LocalTuyaEntity( + id=DPCode.SWITCH_8, + name="Switch 8", + ), + LocalTuyaEntity( + id=DPCode.SWITCH_USB1, + name="USB", + ), + LocalTuyaEntity( + id=DPCode.SWITCH_USB2, + name="USB 2", + ), + LocalTuyaEntity( + id=DPCode.SWITCH_USB3, + name="USB 3", + ), + LocalTuyaEntity( + id=DPCode.SWITCH_USB4, + name="USB 4", + ), + LocalTuyaEntity( + id=DPCode.SWITCH_USB5, + name="USB 5", + ), + LocalTuyaEntity( + id=DPCode.SWITCH_USB6, + name="USB 6", + device_class=SwitchDeviceClass.OUTLET, + ), + ), + # Air Purifier + # https://developer.tuya.com/en/docs/iot/f?id=K9gf46h2s6dzm + "kj": ( + LocalTuyaEntity( + id=DPCode.ANION, + name="Ionizer", + icon="mdi:minus-circle-outline", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.FILTER_RESET, + name="Reset Filter Cartridge_", + icon="mdi:filter", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.LOCK, + name="Child Lock", + icon="mdi:account-lock", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.SWITCH, + name="Power", + ), + LocalTuyaEntity( + id=DPCode.WET, + name="Humidification", + icon="mdi:water-percent", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.UV, + name="UV Sterilization", + icon="mdi:minus-circle-outline", + entity_category=EntityCategory.CONFIG, + ), + ), + # Air conditioner + # https://developer.tuya.com/en/docs/iot/categorykt?id=Kaiuz0z71ov2n + "kt": ( + LocalTuyaEntity( + id=DPCode.ANION, + name="Ionizer", + icon="mdi:minus-circle-outline", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.LOCK, + name="Child Lock", + icon="mdi:account-lock", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.SLEEP, + name="Sleep", + icon="mdi:sleep", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.SHAKE, + name="Shake", + # icon="mdi:vibrate", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.INNERDRY, + name="Inner Dry", + icon="mdi:water-outline", + entity_category=EntityCategory.CONFIG, + ), + ), + # Cat litter box + # https://developer.tuya.com/en/docs/iot/f?id=Kakg309qkmuit + "msp": ( + LocalTuyaEntity( + id=DPCode.SWITCH, + ), + LocalTuyaEntity( + id=DPCode.MANUAL_CLEAN, + name="Manual Cleaning", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.CLEANING, + name="Cleaning", + icon="mdi:power", + ), + LocalTuyaEntity( + id=DPCode.SLEEPING, + name="Sleep", + icon="mdi:sleep", + ), + LocalTuyaEntity( + id=DPCode.BEEP, + name="Beep", + icon="mdi:volume-high", + ), + LocalTuyaEntity( + id=DPCode.INDICATOR_LIGHT, + name="Light Indicator", + icon="mdi:wall-sconce-flat-variant", + ), + LocalTuyaEntity( + id=DPCode.QUIET_TIMING_ON, + name="Enable Quiet Timing", + icon="mdi:timer-settings-outline", + entity_category=EntityCategory.CONFIG, + ), + ), + # Po + # Sous Vide Cooker + # https://developer.tuya.com/en/docs/iot/categorymzj?id=Kaiuz2vy130ux + "mzj": ( + LocalTuyaEntity( + id=DPCode.SWITCH, + name="Switch", + icon="mdi:power", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.START, + name="Start", + icon="mdi:pot-steam", + entity_category=EntityCategory.CONFIG, + ), + ), + # Power Socket + # https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s + "pc": ( + LocalTuyaEntity( + id=DPCode.CHILD_LOCK, + name="Child Lock", + icon="mdi:account-lock", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.OVERCHARGE_SWITCH, + name="Overcharge", + icon="mdi:flash-alert", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.SWITCH_1, + name="Switch 1", + device_class=SwitchDeviceClass.OUTLET, + ), + LocalTuyaEntity( + id=DPCode.SWITCH_2, + name="Switch 2", + device_class=SwitchDeviceClass.OUTLET, + ), + LocalTuyaEntity( + id=DPCode.SWITCH_3, + name="Switch 3", + device_class=SwitchDeviceClass.OUTLET, + ), + LocalTuyaEntity( + id=DPCode.SWITCH_4, + name="Switch 4", + device_class=SwitchDeviceClass.OUTLET, + ), + LocalTuyaEntity( + id=DPCode.SWITCH_5, + name="Switch 5", + device_class=SwitchDeviceClass.OUTLET, + ), + LocalTuyaEntity( + id=DPCode.SWITCH_6, + name="Switch 6", + device_class=SwitchDeviceClass.OUTLET, + ), + LocalTuyaEntity( + id=DPCode.SWITCH_USB1, + name="USB 1", + ), + LocalTuyaEntity( + id=DPCode.SWITCH_USB2, + name="USB 2", + ), + LocalTuyaEntity( + id=DPCode.SWITCH_USB3, + name="USB 3", + ), + LocalTuyaEntity( + id=DPCode.SWITCH_USB4, + name="USB 4", + ), + LocalTuyaEntity( + id=DPCode.SWITCH_USB5, + name="USB 5", + ), + LocalTuyaEntity( + id=DPCode.SWITCH_USB6, + name="USB 6", + ), + LocalTuyaEntity( + id=DPCode.SWITCH, + name="Socket", + device_class=SwitchDeviceClass.OUTLET, + ), + ), + # Smart panel with switches and zigbee hub ? + # Not documented + "dgnzk": ( + LocalTuyaEntity( + id=DPCode.SWITCH, + name="Switch", + ), + LocalTuyaEntity( + id=(DPCode.SWITCH_1, DPCode.SWITCH1), + name="Switch 1", + ), + LocalTuyaEntity( + id=(DPCode.SWITCH_2, DPCode.SWITCH2), + name="Switch 2", + ), + LocalTuyaEntity( + id=(DPCode.SWITCH_3, DPCode.SWITCH3), + name="Switch 3", + ), + LocalTuyaEntity( + id=(DPCode.SWITCH_4, DPCode.SWITCH4), + name="Switch 4", + ), + LocalTuyaEntity( + id=(DPCode.SWITCH_5, DPCode.SWITCH5), + name="Switch 5", + ), + LocalTuyaEntity( + id=(DPCode.SWITCH_6, DPCode.SWITCH6), + name="Switch 6", + ), + LocalTuyaEntity( + id=DPCode.VOICE_PLAY, + name="Voice", + icon="mdi:play", + ), + LocalTuyaEntity( + id=DPCode.VOICE_BT_PLAY, + name="BT Voice", + icon="mdi:play", + ), + LocalTuyaEntity( + id=DPCode.MUTE, + name="Mute", + icon="mdi:volume-off", + ), + LocalTuyaEntity( + id=DPCode.VOICE_MIC, + name="Microphone", + icon="mdi:microphone-off", + ), + LocalTuyaEntity( + id=DPCode.SWITCH_WELCOME, + name="Welcome", + icon="mdi:human-greeting", + ), + ), + # EV Charcher + # https://developer.tuya.com/en/docs/iot/categoryqn?id=Kaiuz18kih0sm + "qccdz": ( + LocalTuyaEntity( + id=DPCode.SWITCH, + ), + ), + "gcj": ( + LocalTuyaEntity( + id=DPCode.MACHINERAINMODE, + name="Rain Mode", + icon="mdi:weather-rainy", + ), + ), + # Unknown product with switch capabilities + # Fond in some diffusers, plugs and PIR flood lights + # Not documented + "qjdcz": ( + LocalTuyaEntity( + id=DPCode.SWITCH_1, + name="Switch", + ), + ), + # Heater + # https://developer.tuya.com/en/docs/iot/categoryqn?id=Kaiuz18kih0sm + "qn": ( + LocalTuyaEntity( + id=DPCode.ANION, + name="Ionizer", + icon="mdi:minus-circle-outline", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.LOCK, + name="Child Lock", + icon="mdi:account-lock", + entity_category=EntityCategory.CONFIG, + ), + ), + # Generic products, EV Charger + # https://support.tuya.com/en/help/_detail/K9g77zfmlnwal + "qt": ( + LocalTuyaEntity( + id=DPCode.CHARGING_STATE, + icon="mdi:ev-plug-tesla", + name="Charge", + ), + ), + # Robot Vacuum + # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo + "sd": ( + LocalTuyaEntity( + id=DPCode.SWITCH_DISTURB, + name="Do Not Disturb", + icon="mdi:minus-circle", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.VOICE_SWITCH, + name="Mute Voice", + icon="mdi:account-voice", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.RESET_MAP, + name="Map Resetting", + icon="mdi:backup-restore", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.BREAK_CLEAN, + name="Resumable Cleaning", + icon="mdi:cog-play-outline", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.Y_MOP, + name="Mop Y", + icon="mdi:dots-vertical", + entity_category=EntityCategory.CONFIG, + ), + ), + # Water Valve + "sfkzq": ( + LocalTuyaEntity( + id=DPCode.SWITCH, + icon="mdi:valve", + ), + LocalTuyaEntity( + id=DPCode.SWITCH_WEATHER, + name="Smart Weather", + icon="mdi:auto-mode", + entity_category=EntityCategory.CONFIG, + ), + ), + # Siren Alarm + # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu + "sgbj": ( + LocalTuyaEntity( + id=DPCode.MUFFLING, + name="Mute", + entity_category=EntityCategory.CONFIG, + ), + ), + # Smart Camera + # https://developer.tuya.com/en/docs/iot/categorysp?id=Kaiuz35leyo12 + "sp": ( + LocalTuyaEntity( + id=DPCode.WIRELESS_BATTERYLOCK, + name="Battery Lock", + icon="mdi:battery-lock", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.CRY_DETECTION_SWITCH, + name="Cry Detection", + icon="mdi:emoticon-cry", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.DECIBEL_SWITCH, + name="Sound Detection", + icon="mdi:microphone-outline", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.RECORD_SWITCH, + name="Video Recording", + icon="mdi:record-rec", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.MOTION_RECORD, + name="Motion Recording", + icon="mdi:record-rec", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.BASIC_PRIVATE, + name="Privacy Mode", + icon="mdi:eye-off", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.BASIC_FLIP, + name="Flip", + icon="mdi:flip-horizontal", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.BASIC_OSD, + name="Time Watermark", + icon="mdi:watermark", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.BASIC_WDR, + name="Wide Dynamic Range", + icon="mdi:watermark", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.MOTION_TRACKING, + name="Motion Tracking", + icon="mdi:motion-sensor", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.MOTION_SWITCH, + name="Motion Alarm", + icon="mdi:motion-sensor", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.PTZ_STOP, + name="PTZ Stop", + icon="mdi:stop-circle", + entity_category=EntityCategory.CONFIG, + ), + ), + # Fingerbot + "szjqr": ( + LocalTuyaEntity( + id=DPCode.SWITCH, + name="Switch", + icon="mdi:cursor-pointer", + ), + ), + # IoT Switch? + # Note: Undocumented + "tdq": ( + LocalTuyaEntity( + id=DPCode.SWITCH_1, + name="Switch 1", + device_class=SwitchDeviceClass.OUTLET, + ), + LocalTuyaEntity( + id=DPCode.SWITCH_2, + name="Switch 2", + device_class=SwitchDeviceClass.OUTLET, + ), + LocalTuyaEntity( + id=DPCode.SWITCH_3, + name="Switch 3", + device_class=SwitchDeviceClass.OUTLET, + ), + LocalTuyaEntity( + id=DPCode.SWITCH_4, + name="Switch 4", + device_class=SwitchDeviceClass.OUTLET, + ), + LocalTuyaEntity( + id=DPCode.CHILD_LOCK, + name="Child Lock", + icon="mdi:account-lock", + entity_category=EntityCategory.CONFIG, + ), + ), + # Solar Light + # https://developer.tuya.com/en/docs/iot/tynd?id=Kaof8j02e1t98 + "tyndj": ( + LocalTuyaEntity( + id=DPCode.SWITCH_SAVE_ENERGY, + name="Energy Saving", + icon="mdi:leaf", + entity_category=EntityCategory.CONFIG, + ), + ), + # PIR Detector + # https://developer.tuya.com/en/docs/iot/categorypir?id=Kaiuz3ss11b80 + "pir": ( + LocalTuyaEntity( + id=DPCode.MOD_ON_TMR, + icon="mdi:timer-play", + entity_category=EntityCategory.CONFIG, + name="Timer", + ), + ), + # Thermostatic Radiator Valve + # Not documented + "wkf": ( + LocalTuyaEntity( + id=DPCode.CHILD_LOCK, + name="Child Lock", + icon="mdi:account-lock", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=(DPCode.WINDOW_CHECK, DPCode.WINDOW_STATE), + name="Open Window Detection", + icon="mdi:window-open", + entity_category=EntityCategory.CONFIG, + ), + ), + # Air Conditioner Mate (Smart IR Socket) + "wnykq": ( + LocalTuyaEntity( + id=DPCode.SWITCH, + name="Switch", + ), + ), + # Zigbee Gateway (dunno if it's useful) + # "wg2": ( + # LocalTuyaEntity( + # id=DPCode.SWITCH_ALARM_SOUND, + # name="Switch", + # ), + # ), + # SIREN: Siren (switch) with Temperature and humidity sensor + # https://developer.tuya.com/en/docs/iot/f?id=Kavck4sr3o5ek + "wsdcg": ( + LocalTuyaEntity( + id=DPCode.SWITCH, + name="Switch", + device_class=SwitchDeviceClass.OUTLET, + ), + ), + # Ceiling Light + # https://developer.tuya.com/en/docs/iot/ceiling-light?id=Kaiuz03xxfc4r + "xdd": ( + LocalTuyaEntity( + id=DPCode.DO_NOT_DISTURB, + name="Do Not Disturb", + icon="mdi:minus-circle-outline", + entity_category=EntityCategory.CONFIG, + ), + ), + # Diffuser + # https://developer.tuya.com/en/docs/iot/categoryxxj?id=Kaiuz1f9mo6bl + "xxj": ( + LocalTuyaEntity( + id=DPCode.SWITCH, + name="Power", + ), + LocalTuyaEntity( + id=DPCode.SWITCH_SPRAY, + name="Spray", + icon="mdi:spray", + ), + LocalTuyaEntity( + id=DPCode.SWITCH_VOICE, + name="Voice", + icon="mdi:account-voice", + entity_category=EntityCategory.CONFIG, + ), + ), + # Smart Electricity Meter + # https://developer.tuya.com/en/docs/iot/smart-meter?id=Kaiuz4gv6ack7 + "zndb": ( + LocalTuyaEntity( + id=DPCode.SWITCH, + name="Switch", + ), + ), + # Fan + # https://developer.tuya.com/en/docs/iot/categoryfs?id=Kaiuz1xweel1c + "fs": ( + LocalTuyaEntity( + id=DPCode.ANION, + name="Anion", + icon="mdi:atom", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.HUMIDIFIER, + name="Humidification", + icon="mdi:air-humidifier", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.OXYGEN, + name="Oxygen Bar", + icon="mdi:molecule", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.FAN_COOL, + name="Natural Wind", + icon="mdi:weather-windy", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.FAN_BEEP, + name="Sound", + icon="mdi:minus-circle", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.CHILD_LOCK, + name="Child Lock", + icon="mdi:account-lock", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.LCD_ONOF, + name="LCD", + icon="mdi:television", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.SPEEK, + name="Sound", + icon="mdi:volume-medium", + entity_category=EntityCategory.CONFIG, + ), + ), + # Fan switch + "fskg": ( + LocalTuyaEntity( + id=DPCode.BACKLIGHT_SWITCH, + name="LED Switch", + icon="mdi:led-outline", + entity_category=EntityCategory.CONFIG, + ), + ), + # Curtain + # https://developer.tuya.com/en/docs/iot/f?id=K9gf46o5mtfyc + "cl": ( + LocalTuyaEntity( + id=DPCode.CONTROL_BACK, + name="Reverse", + icon="mdi:swap-horizontal", + entity_category=EntityCategory.CONFIG, + condition_contains_any=["true", "false"], + ), + LocalTuyaEntity( + id=DPCode.OPPOSITE, + name="Reverse", + icon="mdi:swap-horizontal", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.UP_CONFIRM, + name="Set Upper Limit", + icon="mdi:arrow-collapse-up", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.MIDDLE_CONFIRM, + name="Set Middle Limit", + icon="mdi:format-vertical-align-center", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.DOWN_CONFIRM, + name="Set Down Limit", + icon="mdi:arrow-collapse-down", + entity_category=EntityCategory.CONFIG, + ), + ), + # Humidifier + # https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b + "jsq": ( + LocalTuyaEntity( + id=DPCode.SWITCH_SOUND, + name="Voice", + icon="mdi:account-voice", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.SLEEP, + name="Sleep", + icon="mdi:power-sleep", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.STERILIZATION, + name="Sterilization", + icon="mdi:minus-circle-outline", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.SWITCH_SPRAY, + name="Spray", + icon="mdi:spray", + entity_category=EntityCategory.CONFIG, + ), + ), + # Alarm Host + # https://developer.tuya.com/en/docs/iot/categorymal?id=Kaiuz33clqxaf + "mal": ( + LocalTuyaEntity( + id=DPCode.SWITCH_ALARM_SOUND, + name="Sound", + icon="mdi:volume-source", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.SWITCH_ALARM_LIGHT, + name="Light", + icon="mdi:alarm-light-outline", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.SWITCH_KB_SOUND, + name="Key Tone Sound", + icon="mdi:volume-source", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.SWITCH_KB_LIGHT, + name="Keypad Light", + icon="mdi:alarm-light-outline", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.SWITCH_ALARM_CALL, + name="Call", + icon="mdi:phone", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.SWITCH_ALARM_SMS, + name="SMS", + icon="mdi:message", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.SWITCH_ALARM_PROPEL, + name="Push Notification", + icon="mdi:bell-badge-outline", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.MUFFLING, + name="Mute", + icon="mdi:volume-mute", + entity_category=EntityCategory.CONFIG, + ), + ), + # Smart Water Meter + # https://developer.tuya.com/en/docs/iot/f?id=Ka8n052xu7w4c + "znsb": ( + LocalTuyaEntity( + id=DPCode.SWITCH_COLD, + name="Valve", + icon="mdi:Valve", + ), + LocalTuyaEntity( + id=DPCode.AUTO_CLEAN, + name="Auto Clean", + icon="mdi:auto-fix", + entity_category=EntityCategory.CONFIG, + ), + ), + # Thermostat + # https://developer.tuya.com/en/docs/iot/f?id=K9gf45ld5l0t9 + "wk": ( + LocalTuyaEntity( + id=DPCode.CHILD_LOCK, + name="Child Lock", + icon="mdi:account-lock", + entity_category=EntityCategory.CONFIG, + ), + LocalTuyaEntity( + id=DPCode.ECO, + name="ECO", + icon="mdi:sprout", + entity_category=EntityCategory.CONFIG, + ), + ), +} + +# Scene Switch +# https://developer.tuya.com/en/docs/iot/f?id=K9gf7nx6jelo8 +SWITCHES["cjkg"] = SWITCHES["kg"] + +# Wireless Switch # also can come as knob switch. +# https://developer.tuya.com/en/docs/iot/wxkg?id=Kbeo9t3ryuqm5 +SWITCHES["wxkg"] = SWITCHES["kg"] + +# Socket (duplicate of `pc`) +# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s +SWITCHES["cz"] = SWITCHES["pc"] + +# Climates / heaters +SWITCHES["wkf"] = SWITCHES["wk"] +SWITCHES["rs"] = SWITCHES["wk"] +SWITCHES["qn"] = SWITCHES["wk"] +SWITCHES["kt"] = SWITCHES["wk"] + +# Dehumidifier +# https://developer.tuya.com/en/docs/iot/categorycs?id=Kaiuz1vcz4dha +SWITCHES["cs"] = SWITCHES["jsq"] diff --git a/configs/home-assistant/custom_components/localtuya/core/ha_entities/vacuums.py b/configs/home-assistant/custom_components/localtuya/core/ha_entities/vacuums.py new file mode 100644 index 0000000..692b6bd --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/core/ha_entities/vacuums.py @@ -0,0 +1,95 @@ +""" + This a file contains available tuya data + https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq + Credits: official HA Tuya integration. + Modified by: xZetsubou +""" + +from .base import DPCode, LocalTuyaEntity, CLOUD_VALUE + +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" + +DEFAULT_IDLE_STATUS = "standby,sleep" +DEFAULT_RETURNING_STATUS = "docking,to_charge,goto_charge" +DEFAULT_DOCKED_STATUS = "charging,chargecompleted,charge_done" +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 localtuya_vaccuums( + modes: str = None, + returning_status_value: str = None, + return_mode: str = None, + fan_speeds: str = None, + paused_state: str = None, + stop_status: str = None, + idle_status_value: str = None, + docked_status_value: str = None, +) -> dict: + """Will return dict with the vacuum localtuya entity configs""" + data = { + CONF_MODES: CLOUD_VALUE(modes, CONF_MODE_DP, "range", str), + CONF_IDLE_STATUS_VALUE: idle_status_value or DEFAULT_IDLE_STATUS, + CONF_STOP_STATUS: stop_status or DEFAULT_STOP_STATUS, + CONF_PAUSED_STATE: paused_state or DEFAULT_PAUSED_STATE, + CONF_FAN_SPEEDS: CLOUD_VALUE(fan_speeds, CONF_FAN_SPEED_DP, "range", str), + CONF_RETURN_MODE: return_mode or DEFAULT_RETURN_MODE, + CONF_RETURNING_STATUS_VALUE: returning_status_value or DEFAULT_RETURNING_STATUS, + CONF_DOCKED_STATUS_VALUE: docked_status_value or CONF_DOCKED_STATUS_VALUE, + } + + return data + + +VACUUMS: dict[str, tuple[LocalTuyaEntity, ...]] = { + # Robot Vacuum + # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo + "sd": ( + LocalTuyaEntity( + id=DPCode.STATUS, + icon="mdi:robot-vacuum", + powergo_dp=(DPCode.POWER_GO, DPCode.POWER, DPCode.SWITCH), + mode_dp=DPCode.MODE, + fan_speed_dp=DPCode.SUCTION, + pause_dp=DPCode.PAUSE, + locate_dp=DPCode.SEEK, + clean_time_dp=( + DPCode.CLEAN_TIME, + DPCode.TOTAL_CLEAN_AREA, + DPCode.TOTAL_CLEAN_TIME, + ), + clean_area_dp=DPCode.CLEAN_AREA, + clean_record_dp=DPCode.CLEAN_RECORD, + fault_dp=DPCode.FAULT, + custom_configs=localtuya_vaccuums( + modes=DEFAULT_MODES, + returning_status_value=DEFAULT_RETURNING_STATUS, + return_mode=DEFAULT_RETURN_MODE, + fan_speeds=DEFAULT_FAN_SPEEDS, + paused_state=DEFAULT_PAUSED_STATE, + stop_status=DEFAULT_STOP_STATUS, + idle_status_value=DEFAULT_IDLE_STATUS, + docked_status_value=DEFAULT_DOCKED_STATUS, + ), + ), + ), +} diff --git a/configs/home-assistant/custom_components/localtuya/core/ha_entities/water_heaters.py b/configs/home-assistant/custom_components/localtuya/core/ha_entities/water_heaters.py new file mode 100644 index 0000000..810f365 --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/core/ha_entities/water_heaters.py @@ -0,0 +1,83 @@ +""" + This a file contains available tuya data + https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq + + Credits: official HA Tuya integration. + Modified by: xZetsubou +""" + +from homeassistant.components.water_heater import ( + DEFAULT_MAX_TEMP, + DEFAULT_MIN_TEMP, +) +from homeassistant.const import CONF_TEMPERATURE_UNIT + +from .base import DPCode, LocalTuyaEntity, CLOUD_VALUE +from ...const import ( + CONF_TARGET_TEMPERATURE_LOW_DP, + CONF_TARGET_TEMPERATURE_HIGH_DP, + CONF_PRECISION, + CONF_TARGET_PRECISION, + CONF_CURRENT_TEMPERATURE_DP, + CONF_MAX_TEMP, + CONF_MIN_TEMP, + CONF_TARGET_TEMPERATURE_DP, + CONF_MODES, + CONF_MODE_DP, +) + + +UNIT_C = "celsius" +UNIT_F = "fahrenheit" + + +def localtuya_water_heater( + modes={}, + unit=None, + min_temperature=DEFAULT_MIN_TEMP, + max_temperature=DEFAULT_MAX_TEMP, + current_precsion=0.1, + target_precision=1, +) -> dict: + """Create localtuya climate configs""" + data = {} + for key, conf in { + CONF_MODES: CLOUD_VALUE(modes, CONF_MODE_DP, "range", dict), + CONF_MIN_TEMP: CLOUD_VALUE( + min_temperature, CONF_TARGET_TEMPERATURE_DP, "min", scale=True + ), + CONF_MAX_TEMP: CLOUD_VALUE( + max_temperature, CONF_TARGET_TEMPERATURE_DP, "max", scale=True + ), + CONF_TEMPERATURE_UNIT: unit, + CONF_PRECISION: CLOUD_VALUE( + str(current_precsion), CONF_CURRENT_TEMPERATURE_DP, "scale", str + ), + CONF_TARGET_PRECISION: CLOUD_VALUE( + str(target_precision), CONF_TARGET_TEMPERATURE_DP, "scale", str + ), + }.items(): + if conf is not None: + data.update({key: conf}) + + return data + + +WATER_HEATERS: dict[str, tuple[LocalTuyaEntity, ...]] = { + # Water Heater + # https://developer.tuya.com/en/docs/iot/categoryrs?id=Kaiuz0nfferyx + "rs": ( + LocalTuyaEntity( + id=DPCode.SWITCH, + target_temperature_dp=(DPCode.TEMP_SET, DPCode.TEMP_SET_F), + current_temperature_dp=(DPCode.TEMP_CURRENT, DPCode.TEMP_CURRENT_F), + target_temperature_low_dp=(DPCode.TEMP_LOW, DPCode.LOWER_TEMP), + target_temperature_high_dp=(DPCode.TEMP_UP, DPCode.UPPER_TEMP), + mode_dp=DPCode.MODE, + fan_speed_dp=(DPCode.FAN_SPEED_ENUM, DPCode.WINDSPEED), + custom_configs=localtuya_water_heater( + current_precsion=0.1, target_precision=0.1 + ), + ), + ), +} diff --git a/configs/home-assistant/custom_components/localtuya/core/helpers.py b/configs/home-assistant/custom_components/localtuya/core/helpers.py new file mode 100644 index 0000000..254e688 --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/core/helpers.py @@ -0,0 +1,116 @@ +""" +Helpers functions for HASS-LocalTuya. +""" + +import asyncio +import logging +import os.path +from enum import Enum +from fnmatch import fnmatch +from typing import NamedTuple + +from homeassistant.util.yaml import load_yaml, dump +from homeassistant.const import CONF_PLATFORM, CONF_ENTITIES + + +import custom_components.localtuya.templates as templates_dir + +JSON_TYPE = list | dict | str + +_LOGGER = logging.getLogger(__name__) + + +############################### +# Templates # +############################### +class templates: + + def yaml_dump(config, fname: str | None = None) -> JSON_TYPE: + """Save yaml config.""" + try: + with open(fname, "w", encoding="utf-8") as conf_file: + return conf_file.write(dump(config)) + except UnicodeDecodeError as exc: + _LOGGER.error("Unable to save file %s: %s", fname, exc) + + def list_templates(): + """Return the available templates files.""" + dir = os.path.dirname(templates_dir.__file__) + files = {} + for e in sorted(os.scandir(dir), key=lambda e: e.name): + file: str = e.name.lower() + if e.is_file() and (fnmatch(file, "*yaml") or fnmatch(file, "*yml")): + # fn = str(file).replace(".yaml", "").replace("_", " ") + files[e.name] = e.name + return files + + def import_config(filename): + """Create a data that can be used as config in localtuya.""" + template_dir = os.path.dirname(templates_dir.__file__) + template_file = os.path.join(template_dir, filename) + _config = load_yaml(template_file) + entities = [] + for cfg in _config: + ent = {} + for plat, values in cfg.items(): + for key, value in values.items(): + ent[str(key)] = ( + str(value) + if not isinstance(value, (bool, float, dict, list)) + else value + ) + ent[CONF_PLATFORM] = plat + entities.append(ent) + if not entities: + raise ValueError("No entities found the can be used for localtuya") + return entities + + @classmethod + def export_config(cls, config: dict, config_name: str): + """Create a yaml config file for localtuya.""" + export_config = [] + for cfg in config[CONF_ENTITIES]: + # Special case device_classes + for k, v in cfg.items(): + if not type(v) is str and isinstance(v, Enum): + cfg[k] = v.value + + ents = {cfg[CONF_PLATFORM]: cfg} + export_config.append(ents) + fname = ( + config_name + ".yaml" if not config_name.endswith(".yaml") else config_name + ) + fname = fname.replace(" ", "_") + template_dir = os.path.dirname(templates_dir.__file__) + template_file = os.path.join(template_dir, fname) + + cls.yaml_dump(export_config, template_file) + + +################################ +## config flows ## +################################ + +from ..const import CONF_LOCAL_KEY, CONF_NODE_ID + +GATEWAY = NamedTuple("Gateway", [("id", str), ("data", dict)]) + + +def get_gateway_by_deviceid(device_id: str, cloud_data: dict) -> GATEWAY: + """Return the gateway (id, data) of the sub-deviceID if existed in cloud_data.""" + + if sub_device := cloud_data.get(device_id): + for dev_id, dev_data in cloud_data.items(): + # Get gateway Assuming the LocalKey is the same gateway LocalKey! + if ( + dev_id != device_id + and not dev_data.get(CONF_NODE_ID) + and dev_data.get(CONF_LOCAL_KEY) == sub_device.get(CONF_LOCAL_KEY) + ): + return GATEWAY(dev_id, dev_data) + + +############################### +# Auto configure device # +############################### +from .ha_entities import gen_localtuya_entities diff --git a/configs/home-assistant/custom_components/localtuya/core/pytuya/__init__.py b/configs/home-assistant/custom_components/localtuya/core/pytuya/__init__.py new file mode 100644 index 0000000..0819509 --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/core/pytuya/__init__.py @@ -0,0 +1,1339 @@ +# PyTuya Module +# -*- coding: utf-8 -*- +""" +Python module to interface with Tuya WiFi smart devices. + +Author: clach04, postlund +Maintained by: rospogrigio, xZetsubou + +For more information see https://github.com/clach04/python-tuya + +Classes + TuyaInterface(dev_id, address, local_key=None) + dev_id (str): Device ID e.g. 01234567891234567890 + address (str): Device Network IP Address e.g. 10.0.1.99 + local_key (str, optional): The encryption key. Defaults to None. + +Functions + json = status() # returns json payload + set_version(version) # 3.1 [default], 3.2, 3.3, 3.4 or 3.5 + detect_available_dps() # returns a list of available dps provided by the device + update_dps(dps) # sends update dps command + add_dps_to_request(dp_index) # adds dp_index to the list of dps used by the + # device (to be queried in the payload) + set_dp(on, dp_index) # Set value of any dps index. + + +Credits + * TuyaAPI https://github.com/codetheweb/tuyapi by codetheweb and blackrozes + For protocol reverse engineering + * PyTuya https://github.com/clach04/python-tuya by clach04 + The origin of this python module (now abandoned) + * Tuya Protocol 3.4 and 3.5 Support by uzlonewolf + Enhancement to TuyaMessage logic for multi-payload messages and Tuya Protocol 3.4 support + * TinyTuya https://github.com/jasonacox/tinytuya by jasonacox, uzlonewolf + Several CLI tools and code for Tuya devices +""" + +import os +import asyncio +import errno +import base64 +import binascii +import hmac +import json +import logging +import struct +import time +import weakref +from abc import ABC, abstractmethod +from typing import Self +from hashlib import md5, sha256 +from .cipher import AESCipher + + +from . import parser +from .const import ( + CMDType, + SubdeviceState, + Affix, + TuyaHeader, + TuyaMessage, + MessagePayload, + MessagesFormat, +) + +version_tuple = (2025, 7, 0) +version = version_string = __version__ = "%d.%d.%d" % version_tuple +__author__ = "rospogrigio, xZetsubou" + +_LOGGER = logging.getLogger(__name__) + + +# TinyTuya Error Response Codes +ERR_JSON = 900 +ERR_CONNECT = 901 +ERR_TIMEOUT = 902 +ERR_RANGE = 903 +ERR_PAYLOAD = 904 +ERR_OFFLINE = 905 +ERR_STATE = 906 +ERR_FUNCTION = 907 +ERR_DEVTYPE = 908 +ERR_CLOUDKEY = 909 +ERR_CLOUDRESP = 910 +ERR_CLOUDTOKEN = 911 +ERR_PARAMS = 912 +ERR_CLOUD = 913 + +error_codes = { + ERR_JSON: "Invalid JSON Response from Device", + ERR_CONNECT: "Network Error: Unable to Connect", + ERR_TIMEOUT: "Timeout Waiting for Device", + ERR_RANGE: "Specified Value Out of Range", + ERR_PAYLOAD: "Unexpected Payload from Device", + ERR_OFFLINE: "Network Error: Device Unreachable", + ERR_STATE: "Device in Unknown State", + ERR_FUNCTION: "Function Not Supported by Device", + ERR_DEVTYPE: "Device22 Detected: Retry Command", + ERR_CLOUDKEY: "Missing Tuya Cloud Key and Secret", + ERR_CLOUDRESP: "Invalid JSON Response from Cloud", + ERR_CLOUDTOKEN: "Unable to Get Cloud Token", + ERR_PARAMS: "Missing Function Parameters", + ERR_CLOUD: "Error Response from Tuya Cloud", + None: "Unknown Error", +} + + +UPDATE_DPS_LIST = [3.2, 3.3, 3.4, 3.5] # 3.2 behaves like 3.3 with type_0d + +PROTOCOL_VERSION_BYTES_31 = b"3.1" +PROTOCOL_VERSION_BYTES_33 = b"3.3" +PROTOCOL_VERSION_BYTES_34 = b"3.4" +PROTOCOL_VERSION_BYTES_35 = b"3.5" + +PROTOCOL_3x_HEADER = 12 * b"\x00" +PROTOCOL_33_HEADER = PROTOCOL_VERSION_BYTES_33 + PROTOCOL_3x_HEADER +PROTOCOL_34_HEADER = PROTOCOL_VERSION_BYTES_34 + PROTOCOL_3x_HEADER +PROTOCOL_35_HEADER = PROTOCOL_VERSION_BYTES_35 + PROTOCOL_3x_HEADER + + +NO_PROTOCOL_HEADER_CMDS = [ + CMDType.DP_QUERY, + CMDType.DP_QUERY_NEW, + CMDType.UPDATEDPS, + CMDType.HEART_BEAT, + CMDType.SESS_KEY_NEG_START, + CMDType.SESS_KEY_NEG_RESP, + CMDType.SESS_KEY_NEG_FINISH, + CMDType.LAN_EXT_STREAM, +] + +HEARTBEAT_INTERVAL = 8.3 +TIMEOUT_CONNECT = 5 +TIMEOUT_REPLY = 5 + +# DPS that are known to be safe to use with update_dps (0x12) command +UPDATE_DPS_WHITELIST = [18, 19, 20] # Socket (Wi-Fi) + +# Tuya Device Dictionary - Command and Payload Overrides +# This is intended to match requests.json payload at +# https://github.com/codetheweb/tuyapi : +# 'type_0a' devices require the 0a command for the DP_QUERY request +# 'type_0d' devices require the 0d command for the DP_QUERY request and a list of +# dps used set to Null in the request payload +# prefix: # Next byte is command byte ("hexByte") some zero padding, then length +# of remaining payload, i.e. command + suffix (unclear if multiple bytes used for +# length, zero padding implies could be more than one byte) + +payload_dict = { + # Default Device + "type_0a": { + CMDType.AP_CONFIG: { # [BETA] Set Control Values on Device + "command": {"gwId": "", "devId": "", "uid": "", "t": "", "cid": ""}, + }, + CMDType.CONTROL: { # Set Control Values on Device + "command": {"devId": "", "uid": "", "t": "", "cid": ""}, + }, + CMDType.STATUS: { # Get Status from Device + "command": {"gwId": "", "devId": "", "cid": ""}, + }, + CMDType.HEART_BEAT: {"command": {"gwId": "", "devId": ""}}, + CMDType.DP_QUERY: { # Get Data Points from Device + "command": {"gwId": "", "devId": "", "uid": "", "t": "", "cid": ""}, + }, + CMDType.CONTROL_NEW: {"command": {"devId": "", "uid": "", "t": "", "cid": ""}}, + CMDType.DP_QUERY_NEW: {"command": {"devId": "", "uid": "", "t": "", "cid": ""}}, + CMDType.UPDATEDPS: {"command": {"dpId": [18, 19, 20], "cid": ""}}, + CMDType.LAN_EXT_STREAM: {"command": {"reqType": "", "data": {}}}, + }, + # Special Case Device "0d" - Some of these devices + # Require the 0d command as the DP_QUERY status request and the list of + # dps requested payload + "type_0d": { + CMDType.DP_QUERY: { # Get Data Points from Device + "command_override": CMDType.CONTROL_NEW, # Uses CONTROL_NEW command for some reason + "command": {"devId": "", "uid": "", "t": "", "cid": ""}, + }, + }, + "v3.4": { + CMDType.CONTROL: { + "command_override": CMDType.CONTROL_NEW, # Uses CONTROL_NEW command + "command": {"protocol": 5, "t": "int", "data": {"cid": ""}}, + }, + CMDType.DP_QUERY: {"command_override": CMDType.DP_QUERY_NEW}, + }, + "v3.5": { + CMDType.CONTROL: { + "command_override": CMDType.CONTROL_NEW, # Uses CONTROL_NEW command + "command": {"protocol": 5, "t": "int", "data": {"cid": ""}}, + }, + CMDType.DP_QUERY: {"command_override": CMDType.DP_QUERY_NEW}, + }, +} + + +class TuyaLoggingAdapter(logging.LoggerAdapter): + """Adapter that adds device id to all log points.""" + + def process(self, msg, kwargs): + """Process log point and return output.""" + dev_id = self.extra["device_id"] + name = self.extra.get("name") + prefix = f"{dev_id[0:3]}...{dev_id[-3:]}" + if name: + return f"[{prefix} - {name}] {msg}", kwargs + + return f"[{prefix}] {msg}", kwargs + + +class ContextualLogger: + """Contextual logger adding device id to log points.""" + + def __init__(self): + """Initialize a new ContextualLogger.""" + self._logger = None + self._enable_debug = False + + self._last_warning = "" + + def set_logger(self, logger, device_id, enable_debug=False, name=None): + """Set base logger to use.""" + self._enable_debug = enable_debug + self._logger = TuyaLoggingAdapter( + logger, {"device_id": device_id, "name": name} + ) + return self + + def debug(self, msg, *args, force=False): + """Debug level log for device. force will ignore device debug check.""" + if not self._enable_debug and not force: + return + return self._logger.log(logging.DEBUG, msg, *args) + + def info(self, msg, *args, clear_warning=False): + """Info level log. clear_warning to re-enable warnings msgs if duplicated""" + if clear_warning: + self._last_warning = "" + + return self._logger.log(logging.INFO, msg, *args) + + def warning(self, msg, *args): + """Warning method log.""" + if msg != self._last_warning: + self._last_warning = msg + return self._logger.log(logging.WARNING, msg, *args) + # else: + # self.info(msg) + + def error(self, msg, *args): + """Error level log.""" + return self._logger.log(logging.ERROR, msg, *args) + + def exception(self, msg, *args): + """Exception level log.""" + return self._logger.exception(msg, *args) + + +class MessageDispatcher(ContextualLogger): + """Buffer and dispatcher for Tuya messages.""" + + # Heartbeats on protocols < 3.3 respond with sequence number 0, + # so they can't be waited for like other messages. + # This is a hack to allow waiting for heartbeats. + HEARTBEAT_SEQNO = -100 + RESET_SEQNO = -101 + SESS_KEY_SEQNO = -102 + SUB_DEVICE_QUERY_SEQNO = -103 + + def __init__(self, dev_id, callback_status_update, protocol_version, local_key): + """Initialize a new MessageBuffer.""" + super().__init__() + self.buffer = b"" + self.listeners: dict[str, asyncio.Future] = {} + self.callback_status_update = callback_status_update + self.version = protocol_version + self.local_key = local_key + + def abort(self): + """Abort all waiting clients.""" + for feat in self.listeners.copy(): + feature = self.listeners.pop(feat) + feature.cancel("aborted") + + async def wait_for(self, seqno, cmd, timeout=TIMEOUT_REPLY): + """Wait for response to a sequence number to be received and return it.""" + if seqno in self.listeners: + self.debug(f"listener exists for {seqno}") + if seqno == self.HEARTBEAT_SEQNO: + raise Exception(f"listener exists for {seqno}") + + self.debug("Command %d waiting for seq. number %d", cmd, seqno) + future = asyncio.Future() + self.listeners[seqno] = future + try: + response = await asyncio.wait_for(future, timeout=timeout) + return response + except asyncio.TimeoutError: + self.abort() + raise TimeoutError( + f"Command {cmd} timed out waiting for sequence number {seqno}" + ) + finally: + self.listeners.pop(seqno, True) + + def _release_listener(self, seqno, msg): + if seqno not in self.listeners: + return + + future = self.listeners[seqno] + if isinstance(future, asyncio.Future) and not future.done(): + future.set_result(msg) + else: + self.debug(f"{seqno} - Got additional message without request: skip {msg}") + + def add_data(self, data: bytes): + """Add new data to the buffer and try to parse messages.""" + self.buffer += data + prefixes_bin = {prefix.bin for prefix in Affix.prefixes} + suffixes_bin = {suffix.bin for suffix in Affix.suffixes} + header_len = struct.calcsize(MessagesFormat.END_55AA) + while self.buffer: + + # Check if enough data for measage header + if len(self.buffer) < header_len: + break + + if (prefix_index := self.buffer.find(Affix.prefix_55aa.bin)) == -1: + prefix_index = self.buffer.find(Affix.prefix_6699.bin) + + if prefix_index == -1: + self.debug(f"Invalid prefix: {self.buffer!r}") + self.buffer = b"" + elif self.buffer[:4] not in (prefixes_bin) and prefix_index > 0: + self.debug(f"prefix is not at the end: {self.buffer}") + self.buffer = self.buffer[prefix_index:] + + if (suffix_index := self.buffer.find(Affix.suffix_55aa.bin)) == -1: + suffix_index = self.buffer.find(Affix.suffix_6699.bin) + + # if not any (self.buffer.endswith(suffix) for suffix in suffixes) + if suffix_index == -1: + self.debug(f"Invalid suffix: {self.buffer!r}") + self.buffer = b"" + if not any(self.buffer.endswith(suffix_bin) for suffix_bin in suffixes_bin): + self.debug(f"suffix is not at the end: {self.buffer!r}") + break + # self.buffer = self.buffer[: suffix_index + 4] + + header = parser.parse_header(self.buffer, logger=self) + if len(self.buffer) < header.total_length: + self.debug(f"Not enough data to parse: {self.buffer}") + break # not enough data. + + hmac_key = self.local_key if self.version >= 3.4 else None + no_retcode = False + msg = parser.unpack_message( + self.buffer, + header=header, + hmac_key=hmac_key, + no_retcode=no_retcode, + logger=self, + ) + self.buffer = self.buffer[header.total_length :] + self._dispatch(msg) + + def _dispatch(self, msg: TuyaMessage): + """Dispatch a message to someone that is listening.""" + + self.debug("Dispatching message CMD %r %s", msg.cmd, msg) + + if msg.seqno in self.listeners: + self.debug("Dispatching sequence number %d", msg.seqno) + self._release_listener(msg.seqno, msg) + + if msg.cmd == CMDType.HEART_BEAT: + self.debug("Got heartbeat response") + self._release_listener(self.HEARTBEAT_SEQNO, msg) + elif msg.cmd == CMDType.UPDATEDPS: + self.debug("Got normal updatedps response") + self._release_listener(self.RESET_SEQNO, msg) + elif msg.cmd == CMDType.SESS_KEY_NEG_RESP: + self.debug("Got key negotiation response") + self._release_listener(self.SESS_KEY_SEQNO, msg) + elif msg.cmd == CMDType.STATUS: + if self.RESET_SEQNO in self.listeners: + self.debug("Got reset status update") + self._release_listener(self.RESET_SEQNO, msg) + else: + self.debug("Got status update") + self.callback_status_update(msg) + elif msg.cmd == CMDType.LAN_EXT_STREAM: + self._release_listener(self.SUB_DEVICE_QUERY_SEQNO, msg) + if msg.payload: + self.debug(f"Got Sub-devices status update") + self.callback_status_update(msg) + else: + if msg.cmd == CMDType.CONTROL_NEW or not msg.payload: + self.debug( + "Got ACK message for command %d: ignoring it %s", msg.cmd, msg.seqno + ) + self.callback_status_update(msg, ack=True) + elif msg.seqno not in self.listeners: + self.debug( + "Got message type %d for unknown listener %d: %s", + msg.cmd, + msg.seqno, + msg, + ) + + +class TuyaListener(ABC): + """Listener interface for Tuya device changes.""" + + sub_devices: dict[str, Self] + + @abstractmethod + def status_updated(self, status): + """Device updated status.""" + + @abstractmethod + def disconnected(self, exc=""): + """Device disconnected.""" + + @abstractmethod + def subdevice_state_updated(self, state: SubdeviceState): + """Device is offline or online.""" + + +class EmptyListener(TuyaListener): + """Listener doing nothing.""" + + def status_updated(self, status): + """Device updated status.""" + + def disconnected(self, exc=""): + """Device disconnected.""" + + def subdevice_state_updated(self, state: SubdeviceState): + """Device is offline or online.""" + + +class TuyaProtocol(asyncio.Protocol, ContextualLogger): + """Implementation of the Tuya protocol.""" + + def __init__( + self, + dev_id: str, + local_key: str, + protocol_version: float, + enable_debug: bool, + listener: TuyaListener, + ): + """ + Initialize a new TuyaInterface. + + Args: + dev_id (str): The device id. + address (str): The network address. + local_key (str, optional): The encryption key. Defaults to None. + + Attributes: + port (int): The port to connect to. + """ + if len(local_key) != 16: + raise ValueError("The localkey is incorrect") + + super().__init__() + self.loop = asyncio.get_running_loop() + self.id = dev_id + self.local_key = local_key.encode("latin1") + self.real_local_key = self.local_key + self.dev_type = "type_0a" + self.dps_to_request = {} + + if protocol_version: + self.set_version(float(protocol_version)) + else: + # make sure we call our set_version() and not a subclass since some of + # them (such as BulbDevice) make connections when called + TuyaProtocol.set_version(self, 3.1) + + self.cipher = AESCipher(self.local_key) + self.seqno = 1 + self.transport = None + self.listener = weakref.ref(listener) + self.dispatcher = self._setup_dispatcher() + self.heartbeater: asyncio.Task | None = None + self._sub_devs_query_task: asyncio.Task | None = None + self.dps_cache = {} + self.local_nonce = b"0123456789abcdef" # not-so-random random key + self.remote_nonce = b"" + self.dps_whitelist = UPDATE_DPS_WHITELIST + self.dispatched_dps = {} # Store payload so we can trigger an event in HA. + self._last_command_sent = 1 # The time last command was sent + self._write_lock = asyncio.Lock() # To serialize writes + self.enable_debug(enable_debug) + + def set_version(self, protocol_version): + """Set the device version and eventually start available DPs detection.""" + self.version = protocol_version + self.version_bytes = str(protocol_version).encode("latin1") + self.version_header = self.version_bytes + PROTOCOL_3x_HEADER + if protocol_version == 3.2: # 3.2 behaves like 3.3 with type_0d + # self.version = 3.3 + self.dev_type = "type_0d" + elif protocol_version == 3.4: + self.dev_type = "v3.4" + elif protocol_version == 3.5: + self.dev_type = "v3.5" + + def error_json(self, number=None, payload=None): + """Return error details in JSON.""" + try: + spayload = json.dumps(payload) + # spayload = payload.replace('\"','').replace('\'','') + except Exception: # pylint: disable=broad-except + spayload = '""' + + vals = (error_codes[number], str(number), spayload) + self.debug("ERROR %s - %s - payload: %s", *vals) + + return json.loads('{ "Error":"%s", "Err":"%s", "Payload":%s }' % vals) + + def _msg_subdevs_query(self, decoded_message): + """ + Handle the sub-devices query message. + Message: {"online": [cids, ...], "offline": [cids, ...], "nearby": [cids, ...]} + """ + + async def _action(on_devs, off_devs): + try: + self.debug(f"Sub-Devices States Update: {on_devs=} {off_devs=}") + listener = self.listener and self.listener() + if listener is None: + return + for cid, device in listener.sub_devices.items(): + if cid in on_devs: + device.subdevice_state_updated(SubdeviceState.ONLINE) + elif cid in off_devs: + device.subdevice_state_updated(SubdeviceState.OFFLINE) + else: + # ABSENT detection is weak, because, with many sub-devices, + # the gateway provides them all in more than 1 reply. This + # should be taken into account in device.subdevice_state_updated() + device.subdevice_state_updated(SubdeviceState.ABSENT) + except asyncio.CancelledError: + pass + finally: + self._sub_devs_query_task = None + + if (data := decoded_message.get("data")) and isinstance(data, dict): + on_devs, off_devs = data.get("online", []), data.get("offline", []) + self._sub_devs_query_task = self.loop.create_task( + _action(on_devs, off_devs) + ) + + def _setup_dispatcher(self) -> MessageDispatcher: + def _status_update(msg, ack=False): + if msg.seqno > 0: + if msg.seqno >= self.seqno: + self.seqno = msg.seqno + 1 + if ack: + self.debug( + f"Got update ack message update seqno only. msg.seqno={msg.seqno} self.seqno={self.seqno}" + ) + return + + decoded_message: dict = self._decode_payload(msg.payload) + cid = None + + # Sub-devices query message. + if msg.cmd == CMDType.LAN_EXT_STREAM: + return self._msg_subdevs_query(decoded_message) + + if "dps" not in decoded_message: + return + + if dps_payload := decoded_message.get("dps"): + if cid := decoded_message.get("cid"): + self.dps_cache.setdefault(cid, {}) + self.dps_cache[cid].update(dps_payload) + else: + self.dps_cache.setdefault("parent", {}) + self.dps_cache["parent"].update(dps_payload) + + listener = self.listener and self.listener() + if listener is not None: + if cid: + # Don't pass sub-device's payload to the (fake)gateway! + if not (listener := listener.sub_devices.get(cid, None)): + return self.debug( + f'Payload for missing sub-device discarded: "{decoded_message}"' + ) + status = self.dps_cache.get(cid, {}) + else: + status = self.dps_cache.get("parent", {}) + + listener.status_updated(status) + + return MessageDispatcher(self.id, _status_update, self.version, self.local_key) + + def connection_made(self, transport): + """Did connect to the device.""" + self.transport = transport + + def keep_alive(self, is_gateway: bool = False): + """ + Start the heartbeat transmissions with the device. + is_gateway: will use subdevices_query as heartbeat. + """ + + async def keep_alive_loop(action): + """Continuously send heart beat updates.""" + self.debug("Started keep alive loop.") + fail_attempt = 0 + delta = 0 + while True: + start = time.monotonic() + try: + await asyncio.sleep(HEARTBEAT_INTERVAL - delta) + await action() + fail_attempt = 0 + except asyncio.CancelledError: + self.debug("Stopped heartbeat loop") + break + except asyncio.TimeoutError: + fail_attempt += 1 + if fail_attempt >= 2: + self.debug("Heartbeat failed due to timeout, disconnecting") + break + except Exception as ex: # pylint: disable=broad-except + self.exception("Heartbeat failed (%s), disconnecting", ex) + break + # Adjusts sleep time to maintain the heartbeat interval + delta = (time.monotonic() - start) - HEARTBEAT_INTERVAL + delta = max(0, min(delta, HEARTBEAT_INTERVAL)) + + self.heartbeater = None + if self.transport is not None: + self.clean_up_session() + + self.debug("Stopped heartbeat loop") + + if self.heartbeater is None: + # Prevent duplicates heartbeat task + self.heartbeater = self.loop.create_task( + keep_alive_loop( + # Ver. 3.3 gateways don't respond to subdevice query + self.subdevices_query + if is_gateway and self.version >= 3.4 + else self.heartbeat + ) + ) + + def data_received(self, data): + """Received data from device.""" + # self.debug("received data=%r", binascii.hexlify(data), force=True) + self.dispatcher.add_data(data) + + def connection_lost(self, exc): + """Disconnected from device.""" + self.debug("Connection lost: %s", exc, force=True) + + listener = self.listener and self.listener() + self.clean_up_session() + + try: + if listener is not None: + listener.disconnected(exc or "Connection lost") + except Exception: # pylint: disable=broad-except + self.exception("Failed to call disconnected callback") + + async def transport_write(self, data): + """Write data on transport, ensure that no massive requests happen all at once.""" + async with self._write_lock: + while self.last_command_sent < 0.050: + await asyncio.sleep(0.010) + + self._last_command_sent = time.monotonic() + self.transport.write(data) + + async def close(self): + """Close connection and abort all outstanding listeners.""" + self.debug("Closing connection") + self.clean_up_session() + + if self.heartbeater: + await self.heartbeater + + if self._sub_devs_query_task: + await self._sub_devs_query_task + + def clean_up_session(self): + """Clean up session.""" + self.debug(f"Cleaning up session.") + self.real_local_key = self.local_key + + if self.heartbeater: + self.heartbeater.cancel() + + if self._sub_devs_query_task: + self._sub_devs_query_task.cancel() + + if self.is_connected: + self.transport.close() + + if self.dispatcher: + self.dispatcher.abort() + + async def exchange_quick(self, payload, recv_retries): + """Similar to exchange() but never retries sending and does not decode the response.""" + if not self.is_connected: + self.debug("send quick failed, could not get socket: %s", payload) + return None + enc_payload = ( + self._encode_message(payload) + if isinstance(payload, MessagePayload) + else payload + ) + # self.debug("Quick-dispatching message %s, seqno %s", binascii.hexlify(enc_payload), self.seqno) + + try: + await self.transport_write(enc_payload) + except (Exception, TimeoutError) as ee: # pylint: disable=broad-except + return self.clean_up_session() + + while recv_retries: + try: + seqno = MessageDispatcher.SESS_KEY_SEQNO + msg = await self.dispatcher.wait_for(seqno, payload.cmd) + # for 3.4 devices, we get the starting seqno with the SESS_KEY_NEG_RESP message + self.seqno = msg.seqno + except Exception: # pylint: disable=broad-except + msg = None + if msg and len(msg.payload) != 0: + return msg + recv_retries -= 1 + if recv_retries == 0: + self.debug( + "received null payload (%r) but out of recv retries, giving up", msg + ) + else: + self.debug( + "received null payload (%r), fetch new one - %s retries remaining", + msg, + recv_retries, + ) + return None + + async def exchange( + self, + command: CMDType, + dps: dict = None, + nodeID: str = None, + payload: dict = None, + ): + """Send and receive a message, returning response from device.""" + if not self.is_connected: + return None + + if self.version >= 3.4 and self.real_local_key == self.local_key: + self.debug("3.4 or 3.5 device: negotiating a new session key") + if not await self._negotiate_session_key(): + return self.clean_up_session() + + self.debug( + "Sending command %s (device type: %s) DPS: %s", command, self.dev_type, dps + ) + payload = payload or self._generate_payload(command, dps, nodeId=nodeID) + real_cmd = payload.cmd + dev_type = self.dev_type + + # Wait for special sequence number + seqno = self.seqno + + if payload.cmd == CMDType.HEART_BEAT: + seqno = MessageDispatcher.HEARTBEAT_SEQNO + elif payload.cmd == CMDType.UPDATEDPS: + seqno = MessageDispatcher.RESET_SEQNO + elif payload.cmd == CMDType.LAN_EXT_STREAM: + seqno = MessageDispatcher.SUB_DEVICE_QUERY_SEQNO + + enc_payload = self._encode_message(payload) + + try: + await self.transport_write(enc_payload) + except Exception: # pylint: disable=broad-except + return self.clean_up_session() + msg = await self.dispatcher.wait_for(seqno, payload.cmd) + if msg is None: + self.debug("Wait was aborted for seqno %d", seqno) + return None + + # TODO: Verify stuff, e.g. CRC sequence number? + if ( + real_cmd in [CMDType.HEART_BEAT, CMDType.CONTROL, CMDType.CONTROL_NEW] + and len(msg.payload) == 0 + ): + # device may send messages with empty payload in response + # to a HEART_BEAT or CONTROL or CONTROL_NEW command: consider them an ACK + self.debug(f"ACK received for command {real_cmd}: ignoring: {msg.seqno}") + return None + payload = self._decode_payload(msg.payload) + + # Perform a new exchange (once) if we switched device type + if dev_type != self.dev_type: + self.debug( + "Re-send %s due to device type change (%s -> %s)", + command, + dev_type, + self.dev_type, + ) + return await self.exchange(command, dps, nodeID=nodeID) + return payload + + async def status(self, cid=None): + """Return device status.""" + status: dict = await self.exchange(command=CMDType.DP_QUERY, nodeID=cid) + + self.dps_cache.setdefault("parent", {}) + if status and "dps" in status: + if "cid" in status: + self.dps_cache.update({status["cid"]: status["dps"]}) + else: + self.dps_cache["parent"].update(status["dps"]) + + return self.dps_cache.get(cid or "parent", {}) + + async def heartbeat(self): + """Send a heartbeat message.""" + return await self.exchange(CMDType.HEART_BEAT) + + async def reset(self, dpIds=None, cid=None): + """Send a reset message (3.3 only).""" + if self.version == 3.3: + self.dev_type = "type_0a" + self.debug("reset switching to dev_type %s", self.dev_type) + return await self.exchange(CMDType.UPDATEDPS, dpIds, nodeID=cid) + + return True + + def set_updatedps_list(self, update_list): + """Set the DPS to be requested with the update command.""" + self.dps_whitelist = update_list + + async def update_dps(self, dps=None, cid=None): + """ + Request device to update index. + + Args: + dps([int]): list of dps to update, default=detected&whitelisted + """ + if self.version in UPDATE_DPS_LIST and self.is_connected: + if dps is None: + if not self.dps_cache: + await self.detect_available_dps(cid=cid) + if self.dps_cache: + if cid and cid in self.dps_cache: + dps = [int(dp) for dp in self.dps_cache[cid]] + else: + dps = [int(dp) for dp in self.dps_cache["parent"]] + # filter non whitelisted dps + dps = list(set(dps).intersection(set(self.dps_whitelist))) + payload = self._generate_payload(CMDType.UPDATEDPS, dps, nodeId=cid) + enc_payload = self._encode_message(payload) + await self.transport_write(enc_payload) + return True + + async def set_dp(self, value, dp_index, cid=None): + """ + Set value (may be any type: bool, int or string) of any dps index. + + Args: + dp_index(int): dps index to set + value: new value for the dps index + """ + return await self.exchange(CMDType.CONTROL, {str(dp_index): value}, nodeID=cid) + + async def set_dps(self, dps, cid=None): + """Set values for a set of datapoints.""" + return await self.exchange(CMDType.CONTROL, dps, nodeID=cid) + + async def subdevices_query(self): + """Request a list of sub-devices and their status.""" + # Return payload: {"online": [cid1, ...], "offline": [cid2, ...]} + # "nearby": [cids, ...] can come in payload. + payload = self._generate_payload( + CMDType.LAN_EXT_STREAM, + rawData={"cids": []}, + reqType="subdev_online_stat_query", + ) + + return await self.exchange(command=CMDType.LAN_EXT_STREAM, payload=payload) + + async def detect_available_dps(self, cid=None): + """Return which datapoints are supported by the device.""" + # type_0d devices need a sort of bruteforce querying in order to detect the + # list of available dps experience shows that the dps available are usually + # in the ranges [1-25] and [100-110] need to split the bruteforcing in + # different steps due to request payload limitation (max. length = 255) + + ranges = [(2, 11), (11, 21), (21, 31), (100, 111)] + + for dps_range in ranges: + # dps 1 must always be sent, otherwise it might fail in case no dps is found + # in the requested range + self.dps_to_request = {"1": None} + self.add_dps_to_request(range(*dps_range)) + data = await self.status(cid=cid) + if cid and cid in data: + self.dps_cache.update({cid: data[cid]}) + elif not cid and "parent" in data: + self.dps_cache.update({"parent": data["parent"]}) + + if self.dev_type == "type_0a" and not cid: + return self.dps_cache.get("parent", {}) + + return self.dps_cache.get(cid or "parent", {}) + + def add_dps_to_request(self, dp_indicies): + """Add a datapoint (DP) to be included in requests.""" + if isinstance(dp_indicies, int): + self.dps_to_request[str(dp_indicies)] = None + else: + self.dps_to_request.update({str(index): None for index in dp_indicies}) + + def _decode_payload(self, payload): + cipher = AESCipher(self.local_key) + + if self.version == 3.4: + # 3.4 devices encrypt the version header in addition to the payload + try: + # self.debug("decrypting=%r", payload) + payload = cipher.decrypt(payload, False, decode_text=False) + except Exception as ex: + self.debug( + "incomplete payload=%r with len:%d (%s)", payload, len(payload), ex + ) + return self.error_json(ERR_PAYLOAD) + + # self.debug("decrypted 3.x payload=%r", payload) + + if payload.startswith(PROTOCOL_VERSION_BYTES_31): + # Received an encrypted payload + # Remove version header + payload = payload[len(PROTOCOL_VERSION_BYTES_31) :] + # Decrypt payload + # Remove 16-bytes of MD5 hexdigest of payload + payload = cipher.decrypt(payload[16:]) + elif self.version >= 3.2: # 3.2 or 3.3 or 3.4 + # Trim header for non-default device type + if payload.startswith(self.version_bytes): + payload = payload[len(self.version_header) :] + # self.debug("removing 3.x=%r", payload) + elif self.dev_type == "type_0d" and (len(payload) & 0x0F) != 0: + payload = payload[len(self.version_header) :] + # self.debug("removing type_0d 3.x header=%r", payload) + + if self.version < 3.4: + try: + # self.debug("decrypting=%r", payload) + payload = cipher.decrypt(payload, False) + except Exception as ex: + self.debug( + "incomplete payload=%r with len:%d (%s)", + payload, + len(payload), + ex, + ) + return self.error_json(ERR_PAYLOAD) + + # self.debug("decrypted 3.x payload=%r", payload) + # Try to detect if type_0d found + + if not isinstance(payload, str): + try: + payload = payload.decode() + except Exception as ex: + self.debug("payload was not string type and decoding failed") + return self.error_json(ERR_JSON, payload) + + if "data unvalid" in payload: # codespell:ignore + if self.version == 3.3: + self.dev_type = "type_0d" + self.debug( + "'data unvalid' error detected: switching to dev_type %r", + self.dev_type, + ) + return None + elif not payload.startswith(b"{"): + self.debug("Unexpected payload=%r", payload) + return self.error_json(ERR_PAYLOAD, payload) + + if not isinstance(payload, str): + payload = payload.decode() + self.debug("Deciphered data = %r", payload) + try: + json_payload = json.loads(payload) + except Exception as ex: + json_payload = self.error_json(ERR_JSON, payload) + + if "devid not" in payload: # DeviceID Not found. + raise ValueError(f"DeviceID [{self.id}] Not found") + # else: + # raise DecodeError( + # f"[{self.id}]: could not decrypt data: wrong local_key? (exception: {ex}, payload: {payload})" + # ) + # json_payload = self.error_json(ERR_JSON, payload) + + # v3.4 stuffs it into {"data":{"dps":{"1":true}}, ...} + if ( + "dps" not in json_payload + and "data" in json_payload + and "dps" in json_payload["data"] + ): + json_payload["dps"] = json_payload["data"]["dps"] + + if "cid" in json_payload["data"]: + json_payload["cid"] = json_payload["data"]["cid"] + + # We will store the payload to trigger an event in HA. + if "dps" in json_payload: + self.dispatched_dps = json_payload["dps"] + return json_payload + + async def _negotiate_session_key(self): + self.remote_nonce = b"" + self.local_key = self.real_local_key + + try: + rkey = await self.exchange_quick( + MessagePayload(CMDType.SESS_KEY_NEG_START, self.local_nonce), 2 + ) + except: + # Device may instantly disconnect if we sent send wrong localkey. + if not self.is_connected: + raise ConnectionAbortedError("Session key negotiation failed on step 1") + + if not rkey or not isinstance(rkey, TuyaMessage) or len(rkey.payload) < 48: + # error + self.debug("session key negotiation failed on step 1") + + return False + + if rkey.cmd != CMDType.SESS_KEY_NEG_RESP: + self.debug( + "session key negotiation step 2 returned wrong command: %d", rkey.cmd + ) + return False + + payload = rkey.payload + if self.version == 3.4: + try: + # self.debug("decrypting %r using %r", payload, self.real_local_key) + cipher = AESCipher(self.real_local_key) + payload = cipher.decrypt(payload, False, decode_text=False) + except Exception as ex: + self.debug( + "session key step 2 decrypt failed, payload=%r with len:%d (%s)", + payload, + len(payload), + ex, + ) + return False + + self.debug("decrypted session key negotiation step 2: payload=%r", payload) + + if len(payload) < 48: + self.debug("session key negotiation step 2 failed, too short response") + return False + + self.remote_nonce = payload[:16] + hmac_check = hmac.new(self.local_key, self.local_nonce, sha256).digest() + + if hmac_check != payload[16:48]: + self.debug( + "session key negotiation step 2 failed HMAC check! wanted=%r but got=%r", + binascii.hexlify(hmac_check), + binascii.hexlify(payload[16:48]), + ) + + # self.debug("session local nonce: %r remote nonce: %r", self.local_nonce, self.remote_nonce) + rkey_hmac = hmac.new(self.local_key, self.remote_nonce, sha256).digest() + await self.exchange_quick( + MessagePayload(CMDType.SESS_KEY_NEG_FINISH, rkey_hmac), None + ) + + self.local_key = bytes( + [a ^ b for (a, b) in zip(self.local_nonce, self.remote_nonce)] + ) + # self.debug("Session nonce XOR'd: %r" % self.local_key) + + cipher = AESCipher(self.real_local_key) + if self.version == 3.4: + self.local_key = self.dispatcher.local_key = cipher.encrypt( + self.local_key, False, pad=False + ) + else: + iv = self.local_nonce[:12] + self.debug("Session IV: %r", iv) + self.local_key = self.dispatcher.local_key = cipher.encrypt( + self.local_key, use_base64=False, pad=False, iv=iv + )[12:28] + + self.debug("Session key negotiate success! session key: %r", self.local_key) + return True + + # adds protocol header (if needed) and encrypts + def _encode_message(self, msg: MessagePayload): + hmac_key = None + iv = None + payload = msg.payload + self.cipher = AESCipher(self.local_key) + + if self.version >= 3.4: + hmac_key = self.local_key + if msg.cmd not in NO_PROTOCOL_HEADER_CMDS: + # add the 3.x header + payload = self.version_header + payload + self.debug("final payload for cmd %r: %r", msg.cmd, payload) + + if self.version >= 3.5: + iv = True + # seqno cmd retcode payload crc crc_good, prefix, iv + msg = TuyaMessage( + self.seqno, + msg.cmd, + None, + payload, + 0, + True, + Affix.prefix_6699.value, + True, + ) + self.seqno += 1 # increase message sequence number + data = parser.pack_message(msg, hmac_key=self.local_key) + self.debug("payload encrypted=%r", binascii.hexlify(data)) + return data + + payload = self.cipher.encrypt(payload, False) + elif self.version >= 3.2: + # expect to connect and then disconnect to set new + payload = self.cipher.encrypt(payload, False) + if msg.cmd not in NO_PROTOCOL_HEADER_CMDS: + # add the 3.x header + payload = self.version_header + payload + elif msg.cmd == CMDType.CONTROL: + # need to encrypt + payload = self.cipher.encrypt(payload) + preMd5String = ( + b"data=" + + payload + + b"||lpv=" + + PROTOCOL_VERSION_BYTES_31 + + b"||" + + self.local_key + ) + m = md5() + m.update(preMd5String) + hexdigest = m.hexdigest() + # some tuya libraries strip 8: to :24 + payload = ( + PROTOCOL_VERSION_BYTES_31 + + hexdigest[8:][:16].encode("latin1") + + payload + ) + + self.cipher = None + msg = TuyaMessage( + self.seqno, msg.cmd, 0, payload, 0, True, Affix.prefix_55aa.value, False + ) + self.seqno += 1 # increase message sequence number + buffer = parser.pack_message(msg, hmac_key=hmac_key) + # self.debug("payload encrypted with key %r => %r", self.local_key, binascii.hexlify(buffer)) + return buffer + + def _generate_payload( + self, + command: CMDType, + data: dict = None, + gwId: str = None, + devId: str = None, + uid: str = None, + nodeId: str = None, + rawData: dict = None, + reqType: str = None, + ): + """ + Generate the payload to be sent. + + Args: + command (str): The type of command (must be a key from `payload_dict`). + data (dict, optional): Payload data, typically assigned to the 'dps' field. + gwId (str, optional): Gateway ID. + devId (str, optional): Device ID. + uid (str, optional): User ID. + nodeId (str, optional): Sub-device node ID. + rawData (str, optional): Overrides the 'data' field in the payload. + reqType (str, optional): Request type, used for gateway-level commands. + """ + json_data = command_override = None + + # Create a deep copy of payload_dict. otherwise, the original references will be overwritten + def deepcopy_dict(_dict: dict): + output = _dict.copy() + for key, value in output.items(): + output[key] = deepcopy_dict(value) if isinstance(value, dict) else value + return output + + payloads = deepcopy_dict(payload_dict) + + if command in payloads[self.dev_type]: + if "command" in payloads[self.dev_type][command]: + json_data = payloads[self.dev_type][command]["command"].copy() + if "command_override" in payloads[self.dev_type][command]: + command_override = payloads[self.dev_type][command]["command_override"] + + if self.dev_type != "type_0a": + if ( + json_data is None + and command in payloads["type_0a"] + and "command" in payloads["type_0a"][command] + ): + json_data = payloads["type_0a"][command]["command"].copy() + if ( + command_override is None + and command in payloads["type_0a"] + and "command_override" in payloads["type_0a"][command] + ): + command_override = payloads["type_0a"][command]["command_override"] + + if command_override is None: + command_override = command + if json_data is None: + # I have yet to see a device complain about included but unneeded attribs, but they *will* + # complain about missing attribs, so just include them all unless otherwise specified + json_data = {"gwId": "", "devId": "", "uid": "", "t": "", "cid": ""} + + if "gwId" in json_data: + json_data["gwId"] = gwId if gwId is not None else self.id + if "devId" in json_data: + json_data["devId"] = devId if devId is not None else self.id + if "uid" in json_data: + json_data["uid"] = uid if uid is not None else self.id + if "cid" in json_data: + if nodeId: + json_data["cid"] = nodeId + # for <= 3.3 we don't need `gwID`, `devID` and `uid` in payload. + if command in (CMDType.CONTROL, CMDType.DP_QUERY): + for k in ("gwId", "devId", "uid"): + json_data.pop(k, None) + else: + del json_data["cid"] + if "data" in json_data and "cid" in json_data["data"]: + # "cid" is inside "data" For 3.4 and 3.5 versions. + if nodeId: + json_data["data"]["cid"] = nodeId + else: + del json_data["data"]["cid"] + if rawData is not None and "data" in json_data: + json_data["data"] = rawData + elif data is not None: + if "dpId" in json_data: + json_data["dpId"] = data + elif "data" in json_data: + json_data["data"]["dps"] = data # We don't want to remove CID + else: + json_data["dps"] = data + elif self.dev_type == "type_0d" and command == CMDType.DP_QUERY: + json_data["dps"] = self.dps_to_request + if reqType and "reqType" in json_data: + json_data["reqType"] = reqType + if "t" in json_data: + t = time.time() + json_data["uid"] = int(t) if json_data["t"] == "int" else str(int(t)) + + payload = json.dumps(json_data, separators=(",", ":")) if json_data else "" + + self.debug("Sending payload: %s", payload) + return MessagePayload(command_override, payload.encode()) + + def enable_debug(self, enable=False, friendly_name=None): + """Enable the debug logs for the device.""" + self.set_logger(_LOGGER, self.id, enable, friendly_name) + self.dispatcher.set_logger(_LOGGER, self.id, enable, friendly_name) + + @property + def is_connected(self): + return self.transport and not self.transport.is_closing() + + @property + def last_command_sent(self): + """Return last command sent by seconds""" + return time.monotonic() - self._last_command_sent + + def __repr__(self): + """Return internal string representation of object.""" + return self.id + + +async def connect( + address: str, + device_id: str, + local_key: str, + protocol_version: float, + enable_debug: bool, + listener=None, + port=6668, + timeout=TIMEOUT_CONNECT, +): + """Connect to a device.""" + loop = asyncio.get_running_loop() + try: + async with asyncio.timeout(timeout): + _, protocol = await loop.create_connection( + lambda: TuyaProtocol( + device_id, + local_key, + protocol_version, + enable_debug, + listener or EmptyListener(), + ), + address, + port, + ) + # Assuming the connect timed out then then the host isn't reachable. + except (OSError, TimeoutError) as ex: + if ex.errno == errno.EHOSTUNREACH or isinstance(ex, TimeoutError): + raise OSError( + errno.EHOSTUNREACH, + os.strerror(errno.EHOSTUNREACH) + f" ('{address}', '{port}')", + ) + raise ex + except (Exception, asyncio.CancelledError) as ex: + raise ex + except: + raise Exception(f"The host refused to connect") + + return protocol diff --git a/configs/home-assistant/custom_components/localtuya/core/pytuya/cipher.py b/configs/home-assistant/custom_components/localtuya/core/pytuya/cipher.py new file mode 100644 index 0000000..6daa29a --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/core/pytuya/cipher.py @@ -0,0 +1,77 @@ +"""""" + +import logging +import base64 +import time +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + +_LOGGER = logging.getLogger(__name__) + + +class AESCipher: + """Cipher module for Tuya communication.""" + + def __init__(self, key): + """Initialize a new AESCipher.""" + self.block_size = 16 + self.key = key + self.cipher = Cipher(algorithms.AES(key), modes.ECB(), default_backend()) + + def encrypt(self, raw, use_base64=True, pad=True, iv=False, header=None): + """Encrypt data to be sent to device.""" + if iv: + if iv is True: + if _LOGGER.isEnabledFor(logging.DEBUG): + iv = b"0123456789ab" + else: + iv = str(time.time() * 10)[:12].encode("utf8") + encryptor = Cipher(algorithms.AES(self.key), modes.GCM(iv)).encryptor() + if header: + encryptor.authenticate_additional_data(header) + crypted_text = encryptor.update(raw) + encryptor.finalize() + crypted_text = iv + crypted_text + encryptor.tag + else: + encryptor = self.cipher.encryptor() + if pad: + raw = self._pad(raw) + crypted_text = encryptor.update(raw) + encryptor.finalize() + return base64.b64encode(crypted_text) if use_base64 else crypted_text + + def decrypt( + self, enc, use_base64=True, decode_text=True, iv=False, header=None, tag=None + ): + """Decrypt data from device.""" + if not iv: + if use_base64: + enc = base64.b64decode(enc) + + if iv: + if iv is True: + iv = enc[:12] + enc = enc[12:] + if tag is None: + decryptor = Cipher( + algorithms.AES(self.key), modes.CTR(iv + b"\x00\x00\x00\x02") + ).decryptor() + else: + decryptor = Cipher( + algorithms.AES(self.key), modes.GCM(iv, tag) + ).decryptor() + if header and (tag is not None): + decryptor.authenticate_additional_data(header) + raw = decryptor.update(enc) + decryptor.finalize() + else: + decryptor = self.cipher.decryptor() + raw = decryptor.update(enc) + decryptor.finalize() + raw = self._unpad(raw) + + return raw.decode("utf-8") if decode_text else raw + + def _pad(self, data): + padnum = self.block_size - len(data) % self.block_size + return data + padnum * chr(padnum).encode() + + @staticmethod + def _unpad(data): + return data[: -ord(data[len(data) - 1 :])] diff --git a/configs/home-assistant/custom_components/localtuya/core/pytuya/const.py b/configs/home-assistant/custom_components/localtuya/core/pytuya/const.py new file mode 100644 index 0000000..caac434 --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/core/pytuya/const.py @@ -0,0 +1,103 @@ +"""Constants for pytuya.""" + +from enum import IntEnum, Enum +from dataclasses import dataclass +from enum import Enum + + +class MessagesFormat: + """Formats definitions for Tuya Devices messages.""" + + RECV_HEADER = ">5I" # Received message header: prefix, seqno, cmd, length, retcode + HEADER = ">4I" # Standard header: prefix, seqno, cmd, length + HEADER_55AA = HEADER # Same as HEADER, used for 0x55AA + HEADER_6699 = ">IHIII" # Header for 0x6699: prefix, unknown, seqno, cmd, length + RETCODE = ">I" # Return code: 1 uint32 + END = ">2I" # End of message: crc, suffix + END_55AA = END # Same as END, used for 0x55AA + END_HMAC = ">32sI" # End with HMAC: 32-byte hmac, suffix + END_6699 = ">16sI" # End for 0x6699: 16-byte tag, suffix + + +@dataclass +class AffixData: + value: int + bin: bytes + + +class Affix: + # 55aa common protocols. + prefix_55aa = AffixData(0x000055AA, b"\x00\x00U\xaa") + suffix_55aa = AffixData(0x0000AA55, b"\x00\x00\xaaU") + # 6699 new protocols. + prefix_6699 = AffixData(0x00006699, b"\x00\x00\x66\x99") + suffix_6699 = AffixData(0x00009966, b"\x00\x00\x99\x66") + + prefixes = (prefix_55aa, prefix_6699) + suffixes = (suffix_55aa, suffix_6699) + + +# Tuya Packet Format +# TuyaHeader = namedtuple("TuyaHeader", "prefix seqno cmd length total_length") +@dataclass +class TuyaHeader: + prefix: int + seqno: int + cmd: int + length: int + total_length: int + + +@dataclass +class MessagePayload: + # MessagePayload = namedtuple("MessagePayload", "cmd payload") + cmd: int + payload: bytes + + +@dataclass +class TuyaMessage: + # MessagePayload = namedtuple("MessagePayload", "cmd payload") + seqno: int + cmd: int + retcode: int + payload: bytes + crc: int + crc_good: bool = True + prefix: int = 0x55AA + iv: bool = None + + +class SubdeviceState(IntEnum): + ONLINE = 1 + OFFLINE = 2 + ABSENT = 3 + + +# Tuya Command Types +# REF: https://github.com/tuya/tuya-iotos-embeded-sdk-wifi-ble-bk7231n/blob/master/sdk/include/lan_protocol.h +class CMDType(IntEnum): + """Tuya commands type.""" + + AP_CONFIG = 1 # FRM_TP_CFG_WF # only used for ap 3.0 network config + ACTIVE = 2 # FRM_TP_ACTV (discard) # WORK_MODE_CMD + SESS_KEY_NEG_START = 3 # FRM_SECURITY_TYPE3 # negotiate session key + SESS_KEY_NEG_RESP = 4 # FRM_SECURITY_TYPE4 # negotiate session key response + SESS_KEY_NEG_FINISH = 5 # FRM_SECURITY_TYPE5 # finalize session key negotiation + UNBIND = 6 # FRM_TP_UNBIND_DEV # DATA_QUERT_CMD - issue command + CONTROL = 7 # FRM_TP_CMD # STATE_UPLOAD_CMD + STATUS = 8 # FRM_TP_STAT_REPORT # STATE_QUERY_CMD + HEART_BEAT = 9 # FRM_TP_HB + DP_QUERY = 10 # FRM_QUERY_STAT # UPDATE_START_CMD - get data points + QUERY_WIFI = 11 # FRM_SSID_QUERY (discard) # UPDATE_TRANS_CMD + TOKEN_BIND = 12 # FRM_USER_BIND_REQ # GET_ONLINE_TIME_CMD - system time (GMT) + CONTROL_NEW = 13 # FRM_TP_NEW_CMD # FACTORY_MODE_CMD + ENABLE_WIFI = 14 # FRM_ADD_SUB_DEV_CMD # WIFI_TEST_CMD + WIFI_INFO = 15 # FRM_CFG_WIFI_INFO + DP_QUERY_NEW = 16 # FRM_QUERY_STAT_NEW + SCENE_EXECUTE = 17 # FRM_SCENE_EXEC + UPDATEDPS = 18 # FRM_LAN_QUERY_DP # Request refresh of DPS + UDP_NEW = 19 # FR_TYPE_ENCRYPTION + AP_CONFIG_NEW = 20 # FRM_AP_CFG_WF_V40 + BOARDCAST_LPV34 = 35 # FR_TYPE_BOARDCAST_LPV34 + LAN_EXT_STREAM = 64 # FRM_LAN_EXT_STREAM diff --git a/configs/home-assistant/custom_components/localtuya/core/pytuya/parser.py b/configs/home-assistant/custom_components/localtuya/core/pytuya/parser.py new file mode 100644 index 0000000..7fcbaeb --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/core/pytuya/parser.py @@ -0,0 +1,219 @@ +"""Tuya messages parser.""" + +import logging +import struct +import hmac +import binascii +from hashlib import md5, sha256 +from .const import Affix, MessagesFormat, TuyaHeader, TuyaMessage +from .cipher import AESCipher + +_LOGGER = logging.getLogger(__name__) + + +def pack_message(msg: TuyaMessage, hmac_key: bytes = None): + """Pack a TuyaMessage into bytes.""" + if msg.prefix == Affix.prefix_55aa.value: + header_fmt = MessagesFormat.HEADER_55AA + end_fmt = MessagesFormat.END_HMAC if hmac_key else MessagesFormat.END_55AA + msg_len = len(msg.payload) + struct.calcsize(end_fmt) + header_data = (msg.prefix, msg.seqno, msg.cmd, msg_len) + elif msg.prefix == Affix.prefix_6699.value: + if not hmac_key: + raise TypeError("key must be provided to pack 6699-format messages") + header_fmt = MessagesFormat.HEADER_6699 + end_fmt = MessagesFormat.END_6699 + msg_len = len(msg.payload) + (struct.calcsize(end_fmt) - 4) + 12 + if type(msg.retcode) == int: + msg_len += struct.calcsize(MessagesFormat.RETCODE) + header_data = (msg.prefix, 0, msg.seqno, msg.cmd, msg_len) + else: + raise ValueError( + "pack_message() cannot handle message format %08X" % msg.prefix + ) + + # Create full message excluding CRC and suffix + data = struct.pack(header_fmt, *header_data) + + if msg.prefix == Affix.prefix_6699.value: + cipher = AESCipher(hmac_key) + if type(msg.retcode) == int: + raw = struct.pack(MessagesFormat.RETCODE, msg.retcode) + msg.payload + else: + raw = msg.payload + data2 = cipher.encrypt( + raw, + use_base64=False, + pad=False, + iv=True if not msg.iv else msg.iv, + header=data[4:], + ) + data += data2 + Affix.suffix_6699.bin + else: + data += msg.payload + if hmac_key: + crc = hmac.new(hmac_key, data, sha256).digest() + else: + crc = binascii.crc32(data) & 0xFFFFFFFF + # Calculate CRC, add it together with suffix + data += struct.pack(end_fmt, crc, Affix.suffix_55aa.value) + + return data + + +def unpack_message( + data: bytes, hmac_key=None, header=None, no_retcode=False, logger=_LOGGER +): + """Unpack bytes into a TuyaMessage.""" + if header is None: + header = parse_header(data) + + if header.prefix == Affix.prefix_55aa.value: + # 4-word header plus return code + header_len = struct.calcsize(MessagesFormat.HEADER_55AA) + end_fmt = MessagesFormat.END_HMAC if hmac_key else MessagesFormat.END_55AA + retcode_len = 0 if no_retcode else struct.calcsize(MessagesFormat.RETCODE) + msg_len = header_len + header.length + elif header.prefix == Affix.prefix_6699.value: + if not hmac_key: + raise TypeError("key must be provided to unpack 6699-format messages") + header_len = struct.calcsize(MessagesFormat.HEADER_6699) + end_fmt = MessagesFormat.END_6699 + retcode_len = 0 + msg_len = header_len + header.length + 4 + else: + raise ValueError( + "unpack_message() cannot handle message format %08X" % header.prefix + ) + + if len(data) < msg_len: + logger.debug( + "unpack_message(): not enough data to unpack payload! need %d but only have %d", + header_len + header.length, + len(data), + ) + raise DecodeError(f"Not enough data to unpack payload: {data}") + + end_len = struct.calcsize(end_fmt) + # the retcode is technically part of the payload, but strip it as we do not want it here + retcode = ( + 0 + if not retcode_len + else struct.unpack( + MessagesFormat.RETCODE, data[header_len : header_len + retcode_len] + )[0] + ) + payload = data[header_len + retcode_len : msg_len] + crc, suffix = struct.unpack(end_fmt, payload[-end_len:]) + payload = payload[:-end_len] + + if header.prefix == Affix.prefix_55aa.value: + if hmac_key: + have_crc = hmac.new( + hmac_key, data[: (header_len + header.length) - end_len], sha256 + ).digest() + else: + have_crc = ( + binascii.crc32(data[: (header_len + header.length) - end_len]) + & 0xFFFFFFFF + ) + + if suffix != Affix.suffix_55aa.value: + logger.debug( + "Suffix prefix wrong! %08X != %08X", suffix, Affix.suffix_55aa.value + ) + + if crc != have_crc: + if hmac_key: + logger.debug( + "HMAC checksum wrong! %r != %r", + binascii.hexlify(have_crc), + binascii.hexlify(crc), + ) + else: + logger.debug("CRC wrong! %08X != %08X", have_crc, crc) + crc_good = crc == have_crc + iv = None + elif header.prefix == Affix.prefix_6699.value: + iv = payload[:12] + payload = payload[12:] + try: + cipher = AESCipher(hmac_key) + payload = cipher.decrypt( + payload, + use_base64=False, + decode_text=False, + iv=iv, + header=data[4:header_len], + tag=crc, + ) + crc_good = True + except: + crc_good = False + + retcode_len = struct.calcsize(MessagesFormat.RETCODE) + if no_retcode is False: + pass + elif ( + no_retcode is None + and payload[0:1] != b"{" + and payload[retcode_len : retcode_len + 1] == b"{" + ): + retcode_len = struct.calcsize(MessagesFormat.RETCODE) + else: + retcode_len = 0 + if retcode_len: + retcode = struct.unpack(MessagesFormat.RETCODE, payload[:retcode_len])[0] + payload = payload[retcode_len:] + + return TuyaMessage( + header.seqno, header.cmd, retcode, payload, crc, crc_good, header.prefix, iv + ) + + +def parse_header(data: bytes, logger=_LOGGER): + """Unpack bytes into a TuyaHeader.""" + if data[:4] == Affix.prefix_6699.bin: + fmt = MessagesFormat.HEADER_6699 + elif data[:4] == Affix.prefix_55aa.bin: + fmt = MessagesFormat.HEADER_55AA + else: + err = f"Prefix Does not match! {prefix} known {set(p for p in Affix.prefixes)}" + logger.error(err) + raise DecodeError(err) + + header_len = struct.calcsize(fmt) + + if len(data) < header_len: + err = "Not enough data to unpack header" + logger.error(err) + raise DecodeError(err) + + unpacked = struct.unpack(fmt, data[:header_len]) + prefix = unpacked[0] + + if prefix == Affix.prefix_55aa.value: + prefix, seqno, cmd, payload_len = unpacked + total_length = payload_len + header_len + elif prefix == Affix.prefix_6699.value: + prefix, unknown, seqno, cmd, payload_len = unpacked + # seqno |= unknown << 32 + total_length = payload_len + header_len + len(Affix.suffix_6699.bin) + else: + err = f"Prefix Does not match! {prefix} known {set(p for p in Affix.prefixes)}" + logger.error(err) + raise DecodeError(err) + + # sanity check. currently the max payload length is somewhere around 300 bytes + if payload_len > 2000: + err = f"Header claims the packet size is over 2000 bytes! It is most likely corrupt. Claimed size: {payload_len} bytes. fmt: {fmt} unpacked: {unpacked}" + logger.error(err) + raise DecodeError(err) + + return TuyaHeader(prefix, seqno, cmd, payload_len, total_length) + + +class DecodeError(Exception): + """Specific Exception caused by decoding error.""" + + pass diff --git a/configs/home-assistant/custom_components/localtuya/cover.py b/configs/home-assistant/custom_components/localtuya/cover.py new file mode 100644 index 0000000..0c7cac9 --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/cover.py @@ -0,0 +1,349 @@ +"""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, + CoverEntityFeature, + CoverEntity, + DEVICE_CLASSES_SCHEMA, +) +from homeassistant.const import CONF_DEVICE_CLASS +from .config_flow import col_to_select +from .entity 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, + CONF_STOP_SWITCH_DP, +) + + +# cover states. +STATE_OPENING = "opening" +STATE_CLOSING = "closing" +STATE_STOPPED = "stopped" +STATE_SET_CMD = "moving" +STATE_SET_OPENING = "set_opeing" +STATE_SET_CLOSING = "set_closing" + +_LOGGER = logging.getLogger(__name__) + + +COVER_COMMANDS = { + "Open, Close and Stop": "open_close_stop", + "Open, Close and Continue": "open_close_continue", + "ON, OFF and Stop": "on_off_stop", + "fz, zz and Stop": "fz_zz_stop", + "zz, fz and Stop": "zz_fz_stop", + "1, 2 and 3": "1_2_3", + "0, 1 and 2": "0_1_2", +} + +MODE_NONE = "none" +MODE_SET_POSITION = "position" +MODE_TIME_BASED = "timed" +COVER_MODES = { + "Neither": MODE_NONE, + "Set Position": MODE_SET_POSITION, + "Time Based": MODE_TIME_BASED, +} + +COVER_TIMEOUT_TOLERANCE = 3.0 + +DEF_CMD_SET = list(COVER_COMMANDS.values())[0] +DEF_POS_MODE = list(COVER_MODES.values())[0] +DEFAULT_SPAN_TIME = 25.0 + + +def flow_schema(dps): + """Return schema used in config flow.""" + return { + vol.Optional(CONF_COMMANDS_SET, default=DEF_CMD_SET): col_to_select( + COVER_COMMANDS + ), + vol.Optional(CONF_POSITIONING_MODE, default=DEF_POS_MODE): col_to_select( + COVER_MODES + ), + vol.Optional(CONF_CURRENT_POSITION_DP): col_to_select(dps, is_dps=True), + vol.Optional(CONF_SET_POSITION_DP): col_to_select(dps, is_dps=True), + 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) + ), + vol.Optional(CONF_STOP_SWITCH_DP): col_to_select(dps, is_dps=True), + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + } + + +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 = DEF_CMD_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 = None + self._previous_state = None + self._current_cover_position = 0 + self._current_state_action = STATE_STOPPED # Default. + self._set_new_position = int | None + self._stop_switch = self._config.get(CONF_STOP_SWITCH_DP) + self._position_inverted = self._config.get(CONF_POSITION_INVERTED) + self._current_task = None + + @property + def supported_features(self): + """Flag supported features.""" + supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + if not isinstance(self._open_cmd, bool): + supported_features |= CoverEntityFeature.STOP + if self._config[CONF_POSITIONING_MODE] != MODE_NONE: + supported_features |= CoverEntityFeature.SET_POSITION + return supported_features + + @property + def _current_state(self) -> str: + """Return the current state of the cover.""" + state = self._current_state_action + curr_pos = self._current_cover_position + + # Reset STATE when cover is fully closed or fully opened. + if (state == STATE_CLOSING and curr_pos == 0) or ( + state == STATE_OPENING and curr_pos == 100 + ): + self._current_state_action = STATE_STOPPED + if state in (STATE_SET_CLOSING, STATE_SET_OPENING): + set_pos = self._set_new_position + # Reset state when cover reached the position. + if curr_pos - set_pos < 5 and curr_pos - set_pos >= -5: + self._current_state_action = STATE_STOPPED + + return self._current_state_action + + @property + def current_cover_position(self): + """Return current cover position in percent.""" + if self._config[CONF_POSITIONING_MODE] == MODE_NONE: + return None + return self._current_cover_position + + @property + def is_opening(self): + """Return if cover is opening.""" + return self._current_state in (STATE_OPENING, STATE_SET_OPENING) + + @property + def is_closing(self): + """Return if cover is closing.""" + return self._current_state in (STATE_CLOSING, STATE_SET_CLOSING) + + @property + def is_closed(self): + """Return if the cover is closed or not.""" + if isinstance(self._open_cmd, bool): + return self._current_cover_position == 0 + if self._config[CONF_POSITIONING_MODE] == MODE_NONE: + return None + return self.current_cover_position == 0 and self._current_state == STATE_STOPPED + + async def async_set_cover_position(self, **kwargs): + """Move the cover to a specific position.""" + # Update device values IF the device is moving at the moment. + if self._current_state != STATE_STOPPED: + await self.async_stop_cover() + + self.debug("Setting cover position: %r", kwargs[ATTR_POSITION]) + if self._config[CONF_POSITIONING_MODE] == MODE_TIME_BASED: + 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(delay=mydelay) + self.update_state(STATE_OPENING) + else: + self.debug("Closing to %f: delay %f", newpos, mydelay) + await self.async_close_cover(delay=mydelay) + self.update_state(STATE_CLOSING) + self.debug("Done") + + elif self._config[CONF_POSITIONING_MODE] == MODE_SET_POSITION: + converted_position = int(kwargs[ATTR_POSITION]) + if self._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] + ) + # Give it a moment, to make sure hass updated current pos. + await asyncio.sleep(0.1) + self.update_state(STATE_SET_CMD, int(kwargs[ATTR_POSITION])) + + async def async_stop_after_timeout(self, delay_sec): + """Stop the cover if timeout (max movement span) occurred.""" + try: + await asyncio.sleep(delay_sec) + self._current_task = None + await self.async_stop_cover() + except asyncio.CancelledError: + self._current_task = None + + 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] == MODE_TIME_BASED: + if self._current_task is not None: + self._current_task.cancel() + # for timed positioning, stop the cover after a full opening timespan + # instead of waiting the internal timeout + self._current_task = self.hass.async_create_task( + self.async_stop_after_timeout( + kwargs.get( + "delay", self._config[CONF_SPAN_TIME] + COVER_TIMEOUT_TOLERANCE + ) + ) + ) + self.update_state(STATE_OPENING) + + 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] == MODE_TIME_BASED: + if self._current_task is not None: + self._current_task.cancel() + # for timed positioning, stop the cover after a full opening timespan + # instead of waiting the internal timeout + self._current_task = self.hass.async_create_task( + self.async_stop_after_timeout( + kwargs.get( + "delay", self._config[CONF_SPAN_TIME] + COVER_TIMEOUT_TOLERANCE + ) + ) + ) + self.update_state(STATE_CLOSING) + + async def async_stop_cover(self, **kwargs): + """Stop the cover.""" + if self._current_task is not None: + self._current_task.cancel() + self.debug("Launching command %s to cover ", self._stop_cmd) + command = {self._dp_id: self._stop_cmd} + if self._stop_switch is not None: + command[self._stop_switch] = True + await self._device.set_dps(command) + self.update_state(STATE_STOPPED) + + def status_restored(self, stored_state): + """Restore the last stored cover status.""" + if self._config[CONF_POSITIONING_MODE] == MODE_TIME_BASED: + 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 connection_made(self): + super().connection_made() + + match self.dp_value(self._dp_id): + case str() as i if i.isupper(): + self._open_cmd = self._open_cmd.upper() + self._close_cmd = self._close_cmd.upper() + self._stop_cmd = self._stop_cmd.upper() + case bool(): + self._open_cmd = True + self._close_cmd = False + + def status_updated(self): + """Device status was updated.""" + self._previous_state = self._state + self._state = self.dp_value(self._dp_id) + + if self.has_config(CONF_CURRENT_POSITION_DP): + curr_pos = self.dp_value(CONF_CURRENT_POSITION_DP) + if isinstance(curr_pos, (bool, str)): + closed = curr_pos in (True, "fully_close") + stopped = ( + self._previous_state is None or self._previous_state == self._state + ) + curr_pos = 0 if stopped and closed else (100 if stopped else 50) + + if self._position_inverted: + curr_pos = 100 - curr_pos + + self._current_cover_position = curr_pos + + if ( + self._config[CONF_POSITIONING_MODE] == MODE_TIME_BASED + 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 + + def update_state(self, action, position=None): + """Update cover current states.""" + if (state := self._current_state_action) == action: + return + + # using Commands. + if position is None: + self._current_state_action = action + # Set position cmd, check if target position weither close or open + if action == STATE_SET_CMD and position is not None: + curr_pos = self.current_cover_position + self._set_new_position = position + pos_diff = position - curr_pos + # Prevent stuck state when interrupted on middle of cmd + if state == STATE_STOPPED: + if pos_diff > 0: + self._current_state_action = STATE_SET_OPENING + elif pos_diff < 0: + self._current_state_action = STATE_SET_CLOSING + else: + self._current_state_action = STATE_STOPPED + # Write state data. + self.schedule_update_ha_state() + + +async_setup_entry = partial(async_setup_entry, DOMAIN, LocalTuyaCover, flow_schema) diff --git a/configs/home-assistant/custom_components/localtuya/diagnostics.py b/configs/home-assistant/custom_components/localtuya/diagnostics.py new file mode 100644 index 0000000..1581e35 --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/diagnostics.py @@ -0,0 +1,89 @@ +"""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 . import HassLocalTuyaData +from .const import CONF_LOCAL_KEY, CONF_USER_ID, DOMAIN, CONF_NO_CLOUD, DATA_DISCOVERY + +CLOUD_DEVICES = "cloud_devices" +DEVICE_CONFIG = "device_config" +DEVICE_CLOUD_INFO = "device_cloud_info" + +_LOGGER = logging.getLogger(__name__) + +DATA_OBFUSCATE = {"ip": 1, "uid": 3, CONF_LOCAL_KEY: 3, "lat": 0, "lon": 0} + + +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) + hass_localtuya: HassLocalTuyaData = hass.data[DOMAIN][entry.entry_id] + tuya_api = hass_localtuya.cloud_data + if data.get(CONF_NO_CLOUD, True) is not True: + await hass.async_create_task(tuya_api.async_get_devices_dps_query()) + # censoring private information on integration diagnostic data + for field in [CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_USER_ID]: + data[field] = obfuscate(data[field]) + 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 = obfuscate(local_key) + dev[CONF_LOCAL_KEY] = local_key_obfuscated + data[CLOUD_DEVICES] = copy.deepcopy(tuya_api.device_list) + for dev_id, dev in data[CLOUD_DEVICES].items(): + for obf, obf_len in DATA_OBFUSCATE.items(): + if ob := data[CLOUD_DEVICES][dev_id].get(obf): + data[CLOUD_DEVICES][dev_id][obf] = obfuscate(ob, obf_len, obf_len) + if discovery := hass.data[DOMAIN].get(DATA_DISCOVERY): + data["Discovered_Devices"] = discovery.devices + 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:]}" + + hass_localtuya: HassLocalTuyaData = hass.data[DOMAIN][entry.entry_id] + tuya_api = hass_localtuya.cloud_data + if dev_id in tuya_api.device_list: + await tuya_api.async_get_device_functions(dev_id) + data[DEVICE_CLOUD_INFO] = copy.deepcopy(tuya_api.device_list[dev_id]) + for obf, obf_len in DATA_OBFUSCATE.items(): + if ob := data[DEVICE_CLOUD_INFO].get(obf): + data[DEVICE_CLOUD_INFO][obf] = obfuscate(ob, obf_len, obf_len) + # 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() + if discovery := hass.data[DOMAIN].get(DATA_DISCOVERY): + data["Discovered_Devices"] = discovery.devices.get(dev_id) + return data + + +def obfuscate(key, start_characters=3, end_characters=3) -> str: + """Return obfuscated text by removing characters between [start_characters and end_characters]""" + if start_characters <= 0 and end_characters <= 0: + return "" + + return f"{key[0:start_characters]}...{key[-end_characters:]}" diff --git a/configs/home-assistant/custom_components/localtuya/discovery.py b/configs/home-assistant/custom_components/localtuya/discovery.py new file mode 100644 index 0000000..16de39f --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/discovery.py @@ -0,0 +1,132 @@ +"""Discovery module for Tuya devices. + +based on tuya-convert.py from tuya-convert: + https://github.com/ct-Open-Source/tuya-convert/blob/master/scripts/tuya-discovery.py + +Maintained by @xZetsubou +""" + +import os +import asyncio +import json +import logging +from hashlib import md5 +from socket import inet_aton + +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + +from .core.pytuya import parser + +_LOGGER = logging.getLogger(__name__) + +UDP_KEY = md5(b"yGAdlopoPVldABfn").digest() + +PREFIX_55AA_BIN = b"\x00\x00U\xaa" +PREFIX_6699_BIN = b"\x00\x00\x66\x99" +UDP_COMMAND = b"\x00\x00\x00\x00" + +DEFAULT_TIMEOUT = 6.0 + + +def decrypt(msg, key): + def _unpad(data): + return data[: -ord(data[len(data) - 1 :])] + + cipher = Cipher(algorithms.AES(key), modes.ECB(), default_backend()) + decryptor = cipher.decryptor() + return _unpad(decryptor.update(msg) + decryptor.finalize()).decode() + + +def decrypt_udp(message): + """Decrypt encrypted UDP broadcasts.""" + if message[:4] == PREFIX_55AA_BIN: + payload = message[20:-8] + if message[8:12] == UDP_COMMAND: + return payload + return decrypt(payload, UDP_KEY) + if message[:4] == PREFIX_6699_BIN: + unpacked = parser.unpack_message(message, hmac_key=UDP_KEY, no_retcode=None) + payload = unpacked.payload.decode() + # app sometimes has extra bytes at the end + while payload[-1] == chr(0): + payload = payload[:-1] + return payload + return decrypt(message, UDP_KEY) + + +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() + op_reuse_port = {"reuse_port": True} if os.name != "nt" else {} + listener = loop.create_datagram_endpoint( + lambda: self, local_addr=("0.0.0.0", 6666), **op_reuse_port + ) + encrypted_listener = loop.create_datagram_endpoint( + lambda: self, local_addr=("0.0.0.0", 6667), **op_reuse_port + ) + # tuyaApp_encrypted_listener = loop.create_datagram_endpoint( + # lambda: self, local_addr=("0.0.0.0", 7000), **op_reuse_port + # ) + self._listeners = await asyncio.gather(listener, encrypted_listener) + _LOGGER.debug("Listening to broadcasts on UDP port 6666, 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.""" + try: + try: + data = decrypt_udp(data) + except Exception as ex: # pylint: disable=broad-except + data = data.decode() + decoded = json.loads(data) + self.device_found(decoded) + except (json.JSONDecodeError, Exception) as ex: + # _LOGGER.debug("Bordcast from app from ip: %s", addr[0]) + _LOGGER.debug( + "Failed to decode broadcast from %r: %r [%s]", addr[0], data, ex + ) + + def device_found(self, device): + """Discover a new device.""" + gwid, ip = device.get("gwId"), device.get("ip") + # If device found but the ip changed. + if gwid in self.devices and (self.devices[gwid].get("ip") != ip): + self.devices.pop(gwid) + + if gwid not in self.devices: + self.devices[gwid] = device + # Sort devices by ip. + sort_devices = sorted( + self.devices.items(), key=lambda i: inet_aton(i[1].get("ip", "0")) + ) + self.devices = dict(sort_devices) + + _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 diff --git a/configs/home-assistant/custom_components/localtuya/entity.py b/configs/home-assistant/custom_components/localtuya/entity.py new file mode 100644 index 0000000..f9f52a4 --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/entity.py @@ -0,0 +1,404 @@ +"""Code shared between all platforms.""" + +import logging +from typing import Any, Coroutine, Callable + +from homeassistant.core import HomeAssistant, State +from homeassistant.config_entries import ConfigEntry + +from homeassistant.const import ( + CONF_DEVICES, + CONF_DEVICE_CLASS, + CONF_ENTITIES, + CONF_ENTITY_CATEGORY, + CONF_FRIENDLY_NAME, + CONF_HOST, + CONF_ICON, + CONF_ID, + CONF_PLATFORM, + EntityCategory, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ATTR_VIA_DEVICE, +) +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) + +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .core import pytuya +from .coordinator import HassLocalTuyaData, TuyaDevice +from .const import ( + ATTR_STATE, + CONF_DEFAULT_VALUE, + CONF_ID, + CONF_NODE_ID, + CONF_PASSIVE_ENTITY, + CONF_RESTORE_ON_RECONNECT, + CONF_SCALING, + DOMAIN, + RESTORE_STATES, + DeviceConfig, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + domain: str, + entity_class: Any, + flow_schema: Callable, + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + async_setup_services: Coroutine[HomeAssistant, list, None] = None, +): + """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 = [] + hass_entry_data: HassLocalTuyaData = hass.data[DOMAIN][config_entry.entry_id] + + for dev_id in config_entry.data[CONF_DEVICES]: + dev_entry: dict = config_entry.data[CONF_DEVICES][dev_id] + + host = dev_entry.get(CONF_HOST) + node_id = dev_entry.get(CONF_NODE_ID) + device_key = f"{host}_{node_id}" if node_id else host + + if device_key not in hass_entry_data.devices: + continue + + entities_to_setup = [ + entity + for entity in dev_entry[CONF_ENTITIES] + if entity[CONF_PLATFORM] == domain + ] + + if entities_to_setup: + device: TuyaDevice = hass_entry_data.devices[device_key] + 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: + device.dps_to_request[entity_config[dp_conf]] = None + + entities.append( + entity_class( + device, + dev_entry, + entity_config[CONF_ID], + # we need add_entites_callback in-case we want to add sub-entites, such as electric sensor "phase_a" + add_entites_callback=async_add_entities, + ) + ) + # Once the entities have been created, add to the TuyaDevice instance + if entities: + device.add_entities(entities) + async_add_entities(entities) + + if async_setup_services: + await async_setup_services(hass, 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) -> dict: + """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}") + + +class LocalTuyaEntity(RestoreEntity, pytuya.ContextualLogger): + """Representation of a Tuya entity.""" + + _attr_device_class = None + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__( + self, device: TuyaDevice, device_config: dict, dp_id: str, logger, **kwargs + ): + """Initialize the Tuya entity.""" + super().__init__() + self._device = device + self._device_config = DeviceConfig(device_config) + self._config = get_entity_config(device_config, dp_id) + self._dp_id = dp_id + self._status = {} + self._state = None + self._last_state = None + self._stored_states: State | None = None + self.hass = device.hass + self.componet_add_entities: AddEntitiesCallback = kwargs.get( + "add_entites_callback" + ) + self._loaded = False + + # Default value is available to be provided by Platform entities if required + self._default_value = self._config.get(CONF_DEFAULT_VALUE) + + """ Restore on connect setting is available to be provided by Platform entities + if required""" + dev = self._device_config + self.set_logger(logger, dev.id, dev.enable_debug, dev.name) + self.debug(f"Initialized {self._config.get(CONF_PLATFORM)} [{self.name}]") + + async def async_added_to_hass(self): + """Subscribe localtuya events.""" + await super().async_added_to_hass() + + self.debug(f"Adding {self.entity_id} with configuration: {self._config}") + + stored_data = await self.async_get_last_state() + if stored_data: + self._stored_states = stored_data + self.status_restored(stored_data) + + def _update_handler(status: dict | None): + """Update entity state when status was updated.""" + last_status = self._status.copy() + + self._status = {} if status is None else {**self._status, **status} + + if not self._loaded: + self._loaded = True + self.connection_made() + + if status != last_status: + if status: + self.status_updated() + + self.schedule_update_ha_state() + + signal = f"localtuya_{self._device_config.id}" + + self.async_on_remove( + async_dispatcher_connect(self.hass, signal, _update_handler) + ) + + signal = f"localtuya_entity_{self._device_config.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(f"Entity {self.name} - Additional attributes: {attributes}") + return attributes + + @property + def device_info(self): + """Return device information for the device registry.""" + device_config = self._device_config + device_info = DeviceInfo( + # Serial numbers are unique identifiers within a specific domain + identifiers={(DOMAIN, f"local_{device_config.id}")}, + name=device_config.name, + manufacturer="Tuya", + model=f"{device_config.model} ({device_config.id})", + sw_version=device_config.protocol_version, + ) + if self._device.is_subdevice and self._device.id != self._device.gateway.id: + device_info[ATTR_VIA_DEVICE] = (DOMAIN, f"local_{self._device.gateway.id}") + return device_info + + @property + def name(self) -> str: + """Get name of Tuya entity.""" + return getattr(self, "_attr_name", self._config.get(CONF_FRIENDLY_NAME)) + + @property + def icon(self) -> str | None: + """Icon of the entity.""" + return self._config.get(CONF_ICON, None) + + @property + def unique_id(self) -> str: + """Return unique device identifier.""" + if getattr(self, "_attr_unique_id") is not None: + return self._attr_unique_id + + return f"local_{self._device_config.id}_{self._dp_id}" + + @property + def available(self) -> bool: + """Return if device is available or not.""" + return (len(self._status) > 0) or self._device.connected + + @property + def entity_category(self) -> str: + """Return the category of the entity.""" + if category := self._config.get(CONF_ENTITY_CATEGORY): + return EntityCategory(category) if category != "None" else None + else: + # Set Default values for unconfigured devices. + if platform := self._config.get(CONF_PLATFORM): + # Call default_category from config_flow to set default values! + # This will be removed after a while, this is only made to convert who came from main integration. + # new users will be forced to choose category from config_flow. + from .config_flow import default_category + + return default_category(platform) + return None + + @property + def device_class(self): + """Return the class of this device.""" + attr_device_class = getattr(self, "_attr_device_class") + return attr_device_class or self._config.get(CONF_DEVICE_CLASS) + + def has_config(self, attr) -> bool: + """Return if a config parameter has a valid value.""" + value = self._config.get(attr, "-1") + return value is not None and value != "-1" + + def dp_value(self, key, default=None) -> Any | None: + """Return cached value for DPS index or Entity Config Key. else default None""" + requested_dp = str(key) + # If requested_dp in DP ID, get cached value. + if (value := self._status.get(requested_dp)) or value is not None: + return value + + # If requested_dp is an config key get config dp then get cached value. + if (conf_key := self._config.get(requested_dp)) or conf_key is not None: + if (value := self._status.get(conf_key)) or value is not None: + return value + + if value is None: + value = default + # self.debug(f"{self.name}: is requesting unknown DP Value {key}", force=True) + + return value + + def status_updated(self) -> None: + """Device status was updated. + + Override in subclasses and update entity specific state. + """ + state = self.dp_value(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: State) -> None: + """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( + f"Restoring state for entity: {self.name} - state: {str(self._last_state)}" + ) + + def connection_made(self): + """The connection has made with the device and status retrieved. configure entity based on it. + + Override in subclasses and update entity initialization based on detected DPS. + """ + stored_data = self._stored_states + if self._status == RESTORE_STATES and stored_data: + self._status.pop("0", True) + if self._dp_id in self._status: + return + if stored_data.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN): + self.debug(f"{self.name}: Restore state: {stored_data.state}") + self._status[self._dp_id] = stored_data.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 + + def scale(self, value): + """Return the scaled factor of the value, else same value.""" + scale_factor = self._config.get(CONF_SCALING) + if scale_factor is not None and isinstance(value, (int, float)): + value = round(value * scale_factor, 2) + + return value + + async def restore_state_when_connected(self) -> None: + """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. + """ + restore_on_reconnect = self._config.get(CONF_RESTORE_ON_RECONNECT, False) + passive_entity = self._config.get(CONF_PASSIVE_ENTITY, False) + dp_id = str(self._dp_id) + + if not restore_on_reconnect and (dp_id in self._status or not passive_entity): + self.debug( + f"Entity {self.name} (DP {self._dp_id}) - 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" + ) + return + + self.debug(f"Attempting to restore state for entity: {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 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( + f"Entity {self.name} (DP {self._dp_id}) - Restoring state: {str(restore_state)}" + ) + + # Manually initialise + await self._device.set_dp(restore_state, self._dp_id) diff --git a/configs/home-assistant/custom_components/localtuya/fan.py b/configs/home-assistant/custom_components/localtuya/fan.py new file mode 100644 index 0000000..386b6b2 --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/fan.py @@ -0,0 +1,257 @@ +"""Platform to locally control Tuya-based fan devices.""" + +import logging +import math +from functools import partial +from .config_flow import col_to_select + +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 .entity 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): col_to_select(dps, is_dps=True), + vol.Optional(CONF_FAN_OSCILLATING_CONTROL): col_to_select(dps, is_dps=True), + vol.Optional(CONF_FAN_DIRECTION): col_to_select(dps, is_dps=True), + 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 = ( + int(self._config.get(CONF_FAN_SPEED_MIN, 1)), + int(self._config.get(CONF_FAN_SPEED_MAX, 9)), + ) + self._ordered_list = self._config.get(CONF_FAN_ORDERED_LIST).split(",") + + if isinstance(self._ordered_list, list) and len(self._ordered_list) > 1: + self._use_ordered_list = True + else: + self._use_ordered_list = False + + @property + def oscillating(self): + """Return current oscillating status.""" + return self._oscillating + + @property + def current_direction(self): + """Return the current direction of the fan.""" + return self._direction + + @property + def is_on(self): + """Check if Tuya fan is on.""" + return self._is_on + + @property + def percentage(self): + """Return the current percentage.""" + return self._percentage + + async def async_turn_on( + self, + speed: str = None, + percentage: int = None, + preset_mode: str = None, + **kwargs, + ) -> None: + """Turn on the entity.""" + _LOGGER.debug("Fan async_turn_on") + await self._device.set_dp(True, self._dp_id) + if percentage is not None: + await self.async_set_percentage(percentage) + else: + self.schedule_update_ha_state() + + async def async_turn_off(self, **kwargs) -> None: + """Turn off the entity.""" + _LOGGER.debug("Fan async_turn_off") + + await self._device.set_dp(False, self._dp_id) + self.schedule_update_ha_state() + + async def async_set_percentage(self, percentage): + """Set the speed of the fan.""" + _LOGGER.debug("Fan async_set_percentage: %s", percentage) + + if percentage is not None: + if percentage == 0: + return await self.async_turn_off() + if not self.is_on: + await self.async_turn_on() + + if self._use_ordered_list: + await self._device.set_dp( + str( + percentage_to_ordered_list_item(self._ordered_list, percentage) + ), + self._config.get(CONF_FAN_SPEED_CONTROL), + ) + _LOGGER.debug( + "Fan async_set_percentage: %s > %s", + percentage, + percentage_to_ordered_list_item(self._ordered_list, percentage), + ) + else: + await self._device.set_dp( + int( + 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.""" + if self._use_ordered_list: + return len(self._ordered_list) + 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.dp_value(self._dp_id) + + current_speed = self.dp_value(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: + if str(current_speed) not in self._ordered_list: + self._percentage = None + else: + 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.dp_value(CONF_FAN_OSCILLATING_CONTROL) + _LOGGER.debug("Fan current_oscillating : %s", self._oscillating) + + if self.has_config(CONF_FAN_DIRECTION): + value = self.dp_value(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) diff --git a/configs/home-assistant/custom_components/localtuya/humidifier.py b/configs/home-assistant/custom_components/localtuya/humidifier.py new file mode 100644 index 0000000..70f03f4 --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/humidifier.py @@ -0,0 +1,152 @@ +"""Platform to locally control Tuya-based button devices.""" + +import logging +from functools import partial +from .config_flow import col_to_select +from homeassistant.helpers.selector import ObjectSelector + +import voluptuous as vol +from homeassistant.const import CONF_DEVICE_CLASS +from homeassistant.components.humidifier import ( + DOMAIN, + HumidifierDeviceClass, + DEVICE_CLASSES_SCHEMA, + HumidifierEntity, + HumidifierEntityDescription, + HumidifierEntityFeature, +) +from homeassistant.components.humidifier.const import ( + ATTR_MAX_HUMIDITY, + ATTR_MIN_HUMIDITY, + DEFAULT_MAX_HUMIDITY, + DEFAULT_MIN_HUMIDITY, +) + +from .const import ( + CONF_HUMIDIFIER_SET_HUMIDITY_DP, + CONF_HUMIDIFIER_CURRENT_HUMIDITY_DP, + CONF_HUMIDIFIER_MODE_DP, + CONF_HUMIDIFIER_AVAILABLE_MODES, + DictSelector, +) + +from .entity import LocalTuyaEntity, async_setup_entry + + +_LOGGER = logging.getLogger(__name__) + + +def flow_schema(dps): + """Return schema used in config flow.""" + return { + vol.Optional(CONF_HUMIDIFIER_SET_HUMIDITY_DP): col_to_select(dps, is_dps=True), + vol.Optional(CONF_HUMIDIFIER_CURRENT_HUMIDITY_DP): col_to_select( + dps, is_dps=True + ), + vol.Optional(CONF_HUMIDIFIER_MODE_DP): col_to_select(dps, is_dps=True), + vol.Required(ATTR_MIN_HUMIDITY, default=DEFAULT_MIN_HUMIDITY): int, + vol.Required(ATTR_MAX_HUMIDITY, default=DEFAULT_MAX_HUMIDITY): int, + vol.Optional(CONF_HUMIDIFIER_AVAILABLE_MODES, default={}): ObjectSelector(), + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + } + + +class LocalTuyaHumidifier(LocalTuyaEntity, HumidifierEntity): + """Representation of a Localtuya Humidifier.""" + + _dp_mode = CONF_HUMIDIFIER_MODE_DP + _available_modes = CONF_HUMIDIFIER_AVAILABLE_MODES + _dp_current_humidity = CONF_HUMIDIFIER_CURRENT_HUMIDITY_DP + _dp_set_humidity = CONF_HUMIDIFIER_SET_HUMIDITY_DP + _mode_name_to_value = {} + + def __init__( + self, + device, + config_entry, + humidifierID, + **kwargs, + ): + """Initialize the Tuya button.""" + super().__init__(device, config_entry, humidifierID, _LOGGER, **kwargs) + self._state = None + self._current_mode = None + + if (modes := self._config.get(self._available_modes, {})) and ( + self._config.get(self._dp_mode) + ): + self._attr_supported_features |= HumidifierEntityFeature.MODES + modes = { + k: v if k else v.replace("_", " ").capitalize() + for k, v in modes.copy().items() + } + self._available_modes = DictSelector(modes) + + self._attr_min_humidity = self._config.get( + ATTR_MIN_HUMIDITY, DEFAULT_MIN_HUMIDITY + ) + self._attr_max_humidity = self._config.get( + ATTR_MAX_HUMIDITY, DEFAULT_MAX_HUMIDITY + ) + + @property + def is_on(self) -> bool: + """Return the device is on or off.""" + return self._state + + @property + def mode(self) -> str | None: + """Return the current mode.""" + return self._current_mode + + @property + def target_humidity(self) -> int | None: + """Return the humidity we try to reach.""" + target_dp = self._config.get(self._dp_set_humidity, None) + return self.dp_value(target_dp) if target_dp else None + + @property + def current_humidity(self) -> int | None: + """Return the current humidity.""" + curr_humidity = self._config.get(self._dp_current_humidity) + + return self.dp_value(self._dp_current_humidity) if curr_humidity else None + + async def async_turn_on(self, **kwargs): + """Turn the device on.""" + await self._device.set_dp(True, self._dp_id) + + async def async_turn_off(self, **kwargs): + """Turn the device off.""" + await self._device.set_dp(False, self._dp_id) + + async def async_set_humidity(self, humidity: int) -> None: + """Set new target humidity.""" + set_humidity_dp = self._config.get(self._dp_set_humidity, None) + if set_humidity_dp is None: + return None + + await self._device.set_dp(humidity, set_humidity_dp) + + @property + def available_modes(self): + """Return the list of presets that this device supports.""" + return self._available_modes.names + + async def async_set_mode(self, mode): + """Set new target preset mode.""" + set_mode_dp = self._config.get(self._dp_mode, None) + if set_mode_dp is None: + return None + + await self._device.set_dp(self._available_modes.to_tuya(mode), set_mode_dp) + + def status_updated(self): + """Device status was updated.""" + super().status_updated() + current_mode = self.dp_value(self._dp_mode) + + self._current_mode = self._available_modes.to_ha(current_mode, "unknown") + + +async_setup_entry = partial(async_setup_entry, DOMAIN, LocalTuyaHumidifier, flow_schema) diff --git a/configs/home-assistant/custom_components/localtuya/light.py b/configs/home-assistant/custom_components/localtuya/light.py new file mode 100644 index 0000000..f608821 --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/light.py @@ -0,0 +1,669 @@ +"""Platform to locally control Tuya-based light devices.""" + +import base64 +import logging +import textwrap +import homeassistant.util.color as color_util +import voluptuous as vol + +from dataclasses import dataclass +from functools import partial +from homeassistant.helpers import selector +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP_KELVIN, + ATTR_EFFECT, + ATTR_HS_COLOR, + ATTR_WHITE, + ColorMode, + DOMAIN, + LightEntity, + LightEntityFeature, +) +from homeassistant.const import CONF_BRIGHTNESS, CONF_COLOR_TEMP, CONF_SCENE + +from .config_flow import col_to_select +from .entity import LocalTuyaEntity, async_setup_entry +from .const import ( + CONF_BRIGHTNESS_LOWER, + CONF_BRIGHTNESS_UPPER, + CONF_COLOR, + CONF_COLOR_MODE, + CONF_COLOR_MODE_SET, + CONF_COLOR_TEMP_MAX_KELVIN, + CONF_COLOR_TEMP_MIN_KELVIN, + CONF_COLOR_TEMP_REVERSE, + CONF_MUSIC_MODE, + CONF_SCENE_VALUES, + DictSelector, +) + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_MIN_KELVIN = 2700 # MIRED 370 +DEFAULT_MAX_KELVIN = 6500 # MIRED 153 + +DEFAULT_COLOR_TEMP_REVERSE = False + +DEFAULT_LOWER_BRIGHTNESS = 10 +DEFAULT_UPPER_BRIGHTNESS = 1000 + +MODE_MANUAL = "manual" +MODE_COLOR = "colour" +MODE_MUSIC = "music" +MODE_SCENE = "scene" +MODE_WHITE = "white" + +SCENE_MUSIC = "Music" + +MODES_SET = {"Colour, Music, Scene and White": 0, "Manual, Music, Scene and White": 1} + +# https://developer.tuya.com/en/docs/iot/dj?id=K9i5ql3v98hn3#title-10-scene_data +SCENE_LIST_RGBW_255 = { + "Night": "bd76000168ffff", + "Read": "fffcf70168ffff", + "Meeting": "cf38000168ffff", + "Leisure": "3855b40168ffff", + "Scenario 1": "scene_1", + "Scenario 2": "scene_2", + "Scenario 3": "scene_3", + "Scenario 4": "scene_4", +} + +# https://developer.tuya.com/en/docs/iot/dj?id=K9i5ql3v98hn3#title-11-scene_data_v2 +SCENE_LIST_RGBW_1000 = { + "Night 1": "000e0d0000000000000000c80000", + "Night 2": "000e0d00002e03e802cc00000000", + "Read 1": "010e0d0000000000000003e801f4", + "Read 2": "010e0d000084000003e800000000", + "Meeting": "020e0d0000000000000003e803e8", + "Working": "020e0d00001403e803e800000000", + "Leisure 1": "030e0d0000000000000001f401f4", + "Leisure 2": "030e0d0000e80383031c00000000", + "Soft": "04464602007803e803e800000000464602007803e8000a00000000", + "Rainbow": "05464601000003e803e800000000464601007803e803e80000000046460100f003e803" + + "e800000000", + "Colorful": "06464601000003e803e800000000464601007803e803e80000000046460100f003e80" + + "3e800000000464601003d03e803e80000000046460100ae03e803e800000000464601011303e803" + + "e800000000", + "Beautiful": "07464602000003e803e800000000464602007803e803e80000000046460200f003e8" + + "03e800000000464602003d03e803e80000000046460200ae03e803e800000000464602011303e80" + + "3e800000000", + "Forest": "19464601007803e803e800000000464602006e0320025800000000464602005a038403e8" + + "00000000", + "Dream": "1c4646020104032003e800000000464602011802bc03e800000000464602011303e803e80" + + "0000000", + "F Style": "1e323201015e01f403e800000000323202003201f403e80000000032320200a001f403e" + + "800000000", + "A Style": "1f46460100dc02bc03e800000000464602006e03200258000000004646020014038403e" + + "800000000464601012703e802ee0000000046460100000384028a00000000", + "Halloween": "28464601011303e803e800000000464601001e03e803e800000000", + "Christmas": "225a5a0100f003e803e8000000005a5a01003d03e803e800000000464601000003e80" + + "3e8000000005a5a0100ae03e803e8000000005a5a01011303e803e800000000464601007803e803e" + + "800000000", + "Birthday": "20646401003d03e803e800000000646401007803e803e8000000005a5a01011303e803" + + "e8000000005a5a0100ae03e803e800000000646401003201f403e800000000646401000003e803e8" + + "00000000", + "Wedding Anniversary": "21323202015e01f403e800000000323202011303e803e800000000", +} + +# Same format as SCENE_LIST_RGBW_1000 +SCENE_LIST_RGB_1000 = { + "Night": "000e0d00002e03e802cc00000000", + "Read": "010e0d000084000003e800000000", + "Working": "020e0d00001403e803e800000000", + "Leisure": "030e0d0000e80383031c00000000", + "Soft": "04464602007803e803e800000000464602007803e8000a00000000", + "Colorful": "05464601000003e803e800000000464601007803e803e80000000046460100f003e80" + + "3e800000000464601003d03e803e80000000046460100ae03e803e800000000464601011303e803" + + "e800000000", + "Dazzling": "06464601000003e803e800000000464601007803e803e80000000046460100f003e80" + + "3e800000000", + "Gorgeous": "07464602000003e803e800000000464602007803e803e80000000046460200f003e803e8" + + "00000000464602003d03e803e80000000046460200ae03e803e800000000464602011303e803e80" + + "0000000", +} + +SCENE_LIST_RGBW_BLE = { + "Good Night": "AAAAAGQKQA==", + "Reading": "AQAAAGRkCA==", + "Work": "AgAAAGRkAQ==", + "Leisure": "AwAAAGQ8BA==", + "White Breath": "BgACADxkAYA=", + "White Flashing": "BwABADJkAYA=", + "Warm Breath": "BwABADJkAYA=", + "Warm Flashing": "CQABADJkQIA=", + "Rainbow": "CgACAUtkAQIEECAI", + "Blue & Green Gradient": "CwACAUtkAgQ=", + "Red & Green Gradient": "DAACAUtkAQQ=", + "Red & Blue Gradient": "DQACAUtkAQI=", + "Red & Blue & Green Gradient": "DgACATxkAYACgASA", + "Red Breath": "DwACATxkAYA=", + "Flash": "FAABATJkAQIEECAI", +} + + +@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: int, from_min: int, from_max: int, to_min=0, to_max=255, reverse=False +): + """Maps a value from one range to another.""" + + if reverse: + value = from_max - (value - from_min) + + scale = (to_max - to_min) / (from_max - from_min) + mapped_value = to_min + (value - from_min) * scale + + return min(max(round(mapped_value), to_min), to_max) + + +def flow_schema(dps): + """Return schema used in config flow.""" + return { + vol.Optional(CONF_BRIGHTNESS): col_to_select(dps, is_dps=True), + vol.Optional(CONF_COLOR_TEMP): col_to_select(dps, is_dps=True), + 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): col_to_select(dps, is_dps=True), + vol.Required(CONF_COLOR_MODE_SET, default="0"): col_to_select(MODES_SET), + vol.Optional(CONF_COLOR): col_to_select(dps, is_dps=True), + 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): bool, + vol.Optional(CONF_SCENE): col_to_select(dps, is_dps=True), + vol.Optional(CONF_SCENE_VALUES, default={}): selector.ObjectSelector(), + vol.Optional(CONF_MUSIC_MODE, default=False): selector.BooleanSelector(), + } + + +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) + # Light is an active device (mains powered). It should be able + # to respond at any time. But Tuya BLE bulbs are write-only. + self._write_only = self._device.is_write_only + + self._state = None + self._color_temp = None + self._lower_brightness = int( + self._config.get(CONF_BRIGHTNESS_LOWER, DEFAULT_LOWER_BRIGHTNESS) + ) + self._upper_brightness = int( + self._config.get(CONF_BRIGHTNESS_UPPER, DEFAULT_UPPER_BRIGHTNESS) + ) + self._brightness = None if not self._write_only else self._upper_brightness + self._upper_color_temp = self._upper_brightness + + self._color_temp_reverse = self._config.get(CONF_COLOR_TEMP_REVERSE, False) + 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 = DictSelector({}) + self._cached_status = {} + + if self._config.get(CONF_MUSIC_MODE): + self._effect_list.append(SCENE_MUSIC) + + self._attr_min_color_temp_kelvin = int( + self._config.get(CONF_COLOR_TEMP_MIN_KELVIN, DEFAULT_MIN_KELVIN) + ) + self._attr_max_color_temp_kelvin = int( + self._config.get(CONF_COLOR_TEMP_MAX_KELVIN, DEFAULT_MAX_KELVIN) + ) + + self.__to_color = self.__to_color_common + self.__from_color = self.__from_color_common + + def connection_made(self): + """The connection has made with the device and status retrieved, Configure the entity based on its reserved status.""" + super().connection_made() + is_write_only = self._write_only + + if self.has_config(CONF_SCENE): + if (cf_scenes := self._config.get(CONF_SCENE_VALUES)) and len(cf_scenes): + scenes = {v: k for k, v in cf_scenes.items()} + else: + scene_value = self.dp_value(CONF_SCENE) + if is_write_only and not scene_value: + scenes = SCENE_LIST_RGBW_BLE + elif scene_value and len(scene_value) <= 20: + scenes = SCENE_LIST_RGBW_255 + elif self._config.get(CONF_BRIGHTNESS) is None: + scenes = SCENE_LIST_RGB_1000 + else: + scenes = SCENE_LIST_RGBW_1000 + scenes = {**self._modes.as_dict(), **scenes} + self._scenes = DictSelector(scenes, reverse=True) + + self._effect_list = list(scenes.keys()) + self._effect_list + + if self.has_config(CONF_COLOR): + color_data = self.dp_value(CONF_COLOR) + if is_write_only and not color_data: + self.__to_color = self.__to_color_raw + self.__from_color = self.__from_color_raw + else: + self.__to_color = self.__to_color_common + self.__from_color = self.__from_color_common + + if is_write_only and self._cached_status: + self._status.update(self._cached_status) + + @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 = super().extra_state_attributes + + extra_attrs = (CONF_COLOR_MODE, CONF_COLOR, CONF_BRIGHTNESS, CONF_COLOR_TEMP) + for attr in extra_attrs: + dp = self._config.get(attr) + if dp is not None and (state := self._status.get(dp)) is not None: + attributes[f"raw_{attr}"] = state + + return attributes + + @property + def is_on(self): + """Check if Tuya light is on.""" + return self._state + + @property + def brightness(self): + """Return the brightness of the light.""" + brightness = self._brightness + if brightness is not None and (self.is_color_mode or self.is_white_mode): + return map_range(brightness, self._lower_brightness, self._upper_brightness) + 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_kelvin(self): + """Return the color_temp of the light.""" + if self._color_temp is not None: + return map_range( + self._color_temp, + self._lower_brightness, + self._upper_brightness, + self.min_color_temp_kelvin, + self.max_color_temp_kelvin, + self._color_temp_reverse, + ) + + @property + def effect(self): + """Return the current effect 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 effect_list(self): + """Return the list of supported effects for this light.""" + if len(self._effect_list) > 0: + return self._effect_list + 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) + elif self.has_config(CONF_BRIGHTNESS): + color_modes.add(ColorMode.WHITE) + if self.has_config(CONF_COLOR): + color_modes.add(ColorMode.HS) + + if self.has_config(CONF_COLOR): + color_modes.add(ColorMode.HS) + + if color_modes == {ColorMode.WHITE}: + 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 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 + + @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: + if self.has_config(CONF_COLOR_TEMP): + return ColorMode.COLOR_TEMP + else: + return ColorMode.WHITE + if self._brightness: + return ColorMode.BRIGHTNESS + + return ColorMode.ONOFF + + def __is_color_rgb_encoded(self): + # for now we will prefer non encoded if color is none "added by manual or cloud pull dp" + color = self.dp_value(CONF_COLOR) + return False if color is None else len(color) > 12 + + def __find_scene_by_scene_data(self, data): + return ( + next( + (i for i in self._effect_list if self._scenes.to_tuya(i) == data), + None, + ) + if data is not None + else None + ) + + def __get_color_mode(self): + return ( + self.dp_value(CONF_COLOR_MODE) + if self.has_config(CONF_COLOR_MODE) + else self._modes.white + ) + + def __to_color_raw(self, hs, brightness): + return base64.b64encode( + # BASE64-encoded 4-byte value: HHSL + bytes( + [ + round(hs[0]) // 256, + round(hs[0]) % 256, + round(hs[1]), + round(brightness * 100 / self._upper_brightness), + ] + ) + ).decode("ascii") + + def __to_color_(self, hs, brightness): + # https://developer.tuya.com/en/docs/iot/dj?id=K9i5ql3v98hn3#title-8-colour_data + return "{:04x}{:02x}{:02x}".format( + round(hs[0]), + round(hs[1] * 255 / 100), + round(brightness * 255 / self._upper_brightness), + ) + + def __to_color_v2(self, hs, brightness): + # https://developer.tuya.com/en/docs/iot/dj?id=K9i5ql3v98hn3#title-9-colour_data_v2 + return "{:04x}{:04x}{:04x}".format( + round(hs[0]), round(hs[1] * 10.0), brightness + ) + + def __to_color_common(self, hs, brightness): + """Converts HSB values to a string.""" + if self.__is_color_rgb_encoded(): + # Not documented format + rgb = color_util.color_hsv_to_RGB( + hs[0], hs[1], int(brightness * 100 / self._upper_brightness) + ) + return "{: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: + return self.__to_color_v2(hs, brightness) + + def __from_color_raw(self, color): + # BASE64-encoded 4-byte value: HHSL + hsl = int.from_bytes(base64.b64decode(color), byteorder="big", signed=False) + hue = hsl // 65536 + sat = (hsl // 256) % 256 + value = (hsl % 256) * self._upper_brightness / 100 + self._hs = [hue, sat] + self._brightness = value + + def __from_color_(self, color): + # https://developer.tuya.com/en/docs/iot/dj?id=K9i5ql3v98hn3#title-8-colour_data + hue, sat, value = [int(value, 16) for value in textwrap.wrap(color, 4)] + self._hs = [hue, sat * 100 / 255] + self._brightness = value * self._upper_brightness / 100 + + def __from_color_v2(self, color): + # https://developer.tuya.com/en/docs/iot/dj?id=K9i5ql3v98hn3#title-9-colour_data_v2 + hue, sat, value = [int(value, 16) for value in textwrap.wrap(color, 4)] + self._hs = [hue, sat / 10.0] + self._brightness = value + + def __from_color_common(self, color: str): + """Convert a string to HSL values.""" + 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: + self.__from_color_v2(color) + + async def async_turn_on(self, **kwargs): + """Turn on or control the light.""" + states = {} + if not self.is_on or self._write_only: + states[self._dp_id] = True + features = self.supported_features + color_modes = self.supported_color_modes + brightness = None + color_mode = None + if ATTR_EFFECT in kwargs and (features & LightEntityFeature.EFFECT): + effect = kwargs[ATTR_EFFECT] + scene = self._scenes.to_tuya(effect) + if scene is not None: + if scene.startswith(self._modes.scene) or scene in ( + self._modes.white, + self._modes.color, + ): + color_mode = scene + else: + color_mode = self._modes.scene + states[self._config.get(CONF_SCENE)] = scene + elif effect in self._modes.as_list(): + color_mode = effect + elif effect == self._modes.music: + color_mode = self._modes.music + + if ATTR_BRIGHTNESS in kwargs and ( + ColorMode.BRIGHTNESS in 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, + ) + brightness = max(brightness, self._lower_brightness) + + if self.is_color_mode and self._hs is not None: + states[self._config.get(CONF_COLOR)] = self.__to_color( + self._hs, brightness + ) + color_mode = self._modes.color + else: + states[self._config.get(CONF_BRIGHTNESS)] = brightness + color_mode = self._modes.white + + if ATTR_HS_COLOR in kwargs and ColorMode.HS in 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 + color_mode = self._modes.white + else: + states[self._config.get(CONF_COLOR)] = self.__to_color(hs, brightness) + color_mode = self._modes.color + + if ATTR_COLOR_TEMP_KELVIN in kwargs and ColorMode.COLOR_TEMP in color_modes: + if brightness is None: + brightness = self._brightness + + color_temp = map_range( + int(kwargs[ATTR_COLOR_TEMP_KELVIN]), + self.min_color_temp_kelvin, + self.max_color_temp_kelvin, + self._lower_brightness, + self._upper_color_temp, + self._color_temp_reverse, + ) + + color_mode = self._modes.white + states[self._config.get(CONF_BRIGHTNESS)] = brightness + states[self._config.get(CONF_COLOR_TEMP)] = color_temp + + if ATTR_WHITE in kwargs and ColorMode.WHITE in color_modes: + if brightness is None: + brightness = self._brightness + color_mode = self._modes.white + states[self._config.get(CONF_BRIGHTNESS)] = brightness + + if color_mode is not None: + states[self._config.get(CONF_COLOR_MODE)] = color_mode + + 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.dp_value(self._dp_id) + supported = self.supported_features + self._effect = None + + if (brightness_dp_value := self.dp_value(CONF_BRIGHTNESS)) is not None: + self._brightness = brightness_dp_value + + if ColorMode.HS in self.supported_color_modes: + color = self.dp_value(CONF_COLOR) + if color is not None and not self.is_white_mode: + self.__from_color(color) + elif self._brightness is None: + self._brightness = self._upper_brightness + + if ColorMode.COLOR_TEMP in self.supported_color_modes: + self._color_temp = self.dp_value(CONF_COLOR_TEMP) + + if self.is_scene_mode and supported & LightEntityFeature.EFFECT: + color_mode = self.dp_value(CONF_COLOR_MODE) + if color_mode != self._modes.scene: + self._effect = self.__find_scene_by_scene_data(color_mode) + else: + self._effect = self.__find_scene_by_scene_data( + self.dp_value(CONF_SCENE) + ) + if self._effect is None: + self._effect = self.__find_scene_by_scene_data(color_mode) + + if self.is_music_mode and supported & LightEntityFeature.EFFECT: + self._effect = SCENE_MUSIC + + def status_restored(self, stored_state) -> None: + """Device status was restored.""" + restore_attrs = (CONF_COLOR_MODE, CONF_COLOR, CONF_BRIGHTNESS, CONF_COLOR_TEMP) + if self._write_only: + for attr in restore_attrs: + dp = self._config.get(attr) + restored_value = stored_state.attributes.get(f"raw_{attr}") + if None in (dp, restored_value): + continue + self._cached_status[dp] = restored_value + self._state = self._last_state + + +async_setup_entry = partial(async_setup_entry, DOMAIN, LocalTuyaLight, flow_schema) diff --git a/configs/home-assistant/custom_components/localtuya/lock.py b/configs/home-assistant/custom_components/localtuya/lock.py new file mode 100644 index 0000000..ee8272a --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/lock.py @@ -0,0 +1,64 @@ +"""Platform to present any Tuya DP as a Lock.""" + +import logging +from functools import partial +from typing import Any +from .config_flow import col_to_select + +import voluptuous as vol +from homeassistant.components.lock import DOMAIN, LockEntity +from .entity import LocalTuyaEntity, async_setup_entry + +from .const import CONF_JAMMED_DP, CONF_LOCK_STATE_DP + +_LOGGER = logging.getLogger(__name__) + + +def flow_schema(dps): + """Return schema used in config flow.""" + return { + vol.Optional(CONF_LOCK_STATE_DP): col_to_select(dps, is_dps=True), + vol.Optional(CONF_JAMMED_DP): col_to_select(dps, is_dps=True), + } + + +class LocalTuyaLock(LocalTuyaEntity, LockEntity): + """Representation of a Tuya Lock.""" + + def __init__( + self, + device, + config_entry, + Lockid, + **kwargs, + ): + """Initialize the Tuya Lock.""" + super().__init__(device, config_entry, Lockid, _LOGGER, **kwargs) + self._state = None + + async def async_lock(self, **kwargs: Any) -> None: + """Lock the lock.""" + await self._device.set_dp(True, self._dp_id) + + async def async_unlock(self, **kwargs: Any) -> None: + """Unlock the lock.""" + await self._device.set_dp(False, self._dp_id) + + def status_updated(self): + """Device status was updated.""" + state = self.dp_value(self._dp_id) + if (lock_state := self.dp_value(CONF_LOCK_STATE_DP)) or lock_state is not None: + state = lock_state + + self._attr_is_locked = state in (False, "closed", "close", None) + + if jammed := self.dp_value(CONF_JAMMED_DP, False): + self._attr_is_jammed = jammed + + # No need to restore state for a Lock + async def restore_state_when_connected(self): + """Do nothing for a Lock.""" + return + + +async_setup_entry = partial(async_setup_entry, DOMAIN, LocalTuyaLock, flow_schema) diff --git a/configs/home-assistant/custom_components/localtuya/manifest.json b/configs/home-assistant/custom_components/localtuya/manifest.json new file mode 100644 index 0000000..d8a0145 --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "localtuya", + "name": "Local Tuya", + "codeowners": [], + "config_flow": true, + "dependencies": [], + "documentation": "https://github.com/xZetsubou/hass-localtuya/", + "integration_type": "hub", + "iot_class": "local_push", + "issue_tracker": "https://github.com/xZetsubou/hass-localtuya/issues", + "requirements": [], + "version": "2025.11.0" +} diff --git a/configs/home-assistant/custom_components/localtuya/number.py b/configs/home-assistant/custom_components/localtuya/number.py new file mode 100644 index 0000000..e49768c --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/number.py @@ -0,0 +1,124 @@ +"""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, DEVICE_CLASSES_SCHEMA +from homeassistant.const import ( + CONF_DEVICE_CLASS, + STATE_UNKNOWN, + CONF_UNIT_OF_MEASUREMENT, +) + +from .entity 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_SCALING, + CONF_STEPSIZE, +) + +_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, default=DEFAULT_STEP): vol.All( + vol.Coerce(float), vol.Range(min=0.0, max=1000000.0) + ), + vol.Optional(CONF_RESTORE_ON_RECONNECT, default=False): bool, + vol.Optional(CONF_PASSIVE_ENTITY, default=False): bool, + vol.Optional(CONF_DEFAULT_VALUE): str, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): vol.Any(None, str), + vol.Optional(CONF_SCALING): vol.All( + vol.Coerce(float), vol.Range(min=-1000000.0, max=1000000.0) + ), + } + + +class LocalTuyaNumber(LocalTuyaEntity, NumberEntity): + """Representation of a Tuya Number.""" + + def __init__( + self, + device, + config_entry, + sensorid, + **kwargs, + ): + """Initialize the Tuya sensor.""" + super().__init__(device, config_entry, sensorid, _LOGGER, **kwargs) + self._state = STATE_UNKNOWN + + self._min_value = self.scale(self._config.get(CONF_MIN_VALUE, DEFAULT_MIN)) + self._max_value = self.scale(self._config.get(CONF_MAX_VALUE, DEFAULT_MAX)) + self._step_size = self.scale(self._config.get(CONF_STEPSIZE, DEFAULT_STEP)) + + # 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.""" + self._state = self.scale(self._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 native_unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._config.get(CONF_UNIT_OF_MEASUREMENT) + + @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.""" + if scale_factor := self._config.get(CONF_SCALING): + value = value / float(scale_factor) + + await self._device.set_dp(int(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) diff --git a/configs/home-assistant/custom_components/localtuya/pytuya/__init__.py b/configs/home-assistant/custom_components/localtuya/pytuya/__init__.py new file mode 100644 index 0000000..67aabb2 --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/pytuya/__init__.py @@ -0,0 +1,1196 @@ +# PyTuya Module +# -*- coding: utf-8 -*- +""" +Python module to interface with Tuya WiFi smart devices. + +Author: clach04, postlund +Maintained by: rospogrigio + +For more information see https://github.com/clach04/python-tuya + +Classes + TuyaInterface(dev_id, address, local_key=None) + dev_id (str): Device ID e.g. 01234567891234567890 + address (str): Device Network IP Address e.g. 10.0.1.99 + local_key (str, optional): The encryption key. Defaults to None. + +Functions + json = status() # returns json payload + set_version(version) # 3.1 [default], 3.2, 3.3 or 3.4 + detect_available_dps() # returns a list of available dps provided by the device + update_dps(dps) # sends update dps command + add_dps_to_request(dp_index) # adds dp_index to the list of dps used by the + # device (to be queried in the payload) + set_dp(on, dp_index) # Set value of any dps index. + + + Credits + * TuyaAPI https://github.com/codetheweb/tuyapi by codetheweb and blackrozes + For protocol reverse engineering + * PyTuya https://github.com/clach04/python-tuya by clach04 + The origin of this python module (now abandoned) + * Tuya Protocol 3.4 Support by uzlonewolf + Enhancement to TuyaMessage logic for multi-payload messages and Tuya Protocol 3.4 support + * TinyTuya https://github.com/jasonacox/tinytuya by jasonacox + Several CLI tools and code for Tuya devices +""" + +import asyncio +import base64 +import binascii +import hmac +import json +import logging +import struct +import time +import weakref +from abc import ABC, abstractmethod +from collections import namedtuple +from hashlib import md5, sha256 + +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + +version_tuple = (10, 0, 0) +version = version_string = __version__ = "%d.%d.%d" % version_tuple +__author__ = "rospogrigio" + +_LOGGER = logging.getLogger(__name__) + +# Tuya Packet Format +TuyaHeader = namedtuple("TuyaHeader", "prefix seqno cmd length") +MessagePayload = namedtuple("MessagePayload", "cmd payload") +try: + TuyaMessage = namedtuple( + "TuyaMessage", "seqno cmd retcode payload crc crc_good", defaults=(True,) + ) +except Exception: + TuyaMessage = namedtuple("TuyaMessage", "seqno cmd retcode payload crc crc_good") + +# TinyTuya Error Response Codes +ERR_JSON = 900 +ERR_CONNECT = 901 +ERR_TIMEOUT = 902 +ERR_RANGE = 903 +ERR_PAYLOAD = 904 +ERR_OFFLINE = 905 +ERR_STATE = 906 +ERR_FUNCTION = 907 +ERR_DEVTYPE = 908 +ERR_CLOUDKEY = 909 +ERR_CLOUDRESP = 910 +ERR_CLOUDTOKEN = 911 +ERR_PARAMS = 912 +ERR_CLOUD = 913 + +error_codes = { + ERR_JSON: "Invalid JSON Response from Device", + ERR_CONNECT: "Network Error: Unable to Connect", + ERR_TIMEOUT: "Timeout Waiting for Device", + ERR_RANGE: "Specified Value Out of Range", + ERR_PAYLOAD: "Unexpected Payload from Device", + ERR_OFFLINE: "Network Error: Device Unreachable", + ERR_STATE: "Device in Unknown State", + ERR_FUNCTION: "Function Not Supported by Device", + ERR_DEVTYPE: "Device22 Detected: Retry Command", + ERR_CLOUDKEY: "Missing Tuya Cloud Key and Secret", + ERR_CLOUDRESP: "Invalid JSON Response from Cloud", + ERR_CLOUDTOKEN: "Unable to Get Cloud Token", + ERR_PARAMS: "Missing Function Parameters", + ERR_CLOUD: "Error Response from Tuya Cloud", + None: "Unknown Error", +} + + +class DecodeError(Exception): + """Specific Exception caused by decoding error.""" + + pass + + +# Tuya Command Types +# Reference: +# https://github.com/tuya/tuya-iotos-embeded-sdk-wifi-ble-bk7231n/blob/master/sdk/include/lan_protocol.h +AP_CONFIG = 0x01 # FRM_TP_CFG_WF # only used for ap 3.0 network config +ACTIVE = 0x02 # FRM_TP_ACTV (discard) # WORK_MODE_CMD +SESS_KEY_NEG_START = 0x03 # FRM_SECURITY_TYPE3 # negotiate session key +SESS_KEY_NEG_RESP = 0x04 # FRM_SECURITY_TYPE4 # negotiate session key response +SESS_KEY_NEG_FINISH = 0x05 # FRM_SECURITY_TYPE5 # finalize session key negotiation +UNBIND = 0x06 # FRM_TP_UNBIND_DEV # DATA_QUERT_CMD - issue command +CONTROL = 0x07 # FRM_TP_CMD # STATE_UPLOAD_CMD +STATUS = 0x08 # FRM_TP_STAT_REPORT # STATE_QUERY_CMD +HEART_BEAT = 0x09 # FRM_TP_HB +DP_QUERY = 0x0A # 10 # FRM_QUERY_STAT # UPDATE_START_CMD - get data points +QUERY_WIFI = 0x0B # 11 # FRM_SSID_QUERY (discard) # UPDATE_TRANS_CMD +TOKEN_BIND = 0x0C # 12 # FRM_USER_BIND_REQ # GET_ONLINE_TIME_CMD - system time (GMT) +CONTROL_NEW = 0x0D # 13 # FRM_TP_NEW_CMD # FACTORY_MODE_CMD +ENABLE_WIFI = 0x0E # 14 # FRM_ADD_SUB_DEV_CMD # WIFI_TEST_CMD +WIFI_INFO = 0x0F # 15 # FRM_CFG_WIFI_INFO +DP_QUERY_NEW = 0x10 # 16 # FRM_QUERY_STAT_NEW +SCENE_EXECUTE = 0x11 # 17 # FRM_SCENE_EXEC +UPDATEDPS = 0x12 # 18 # FRM_LAN_QUERY_DP # Request refresh of DPS +UDP_NEW = 0x13 # 19 # FR_TYPE_ENCRYPTION +AP_CONFIG_NEW = 0x14 # 20 # FRM_AP_CFG_WF_V40 +BOARDCAST_LPV34 = 0x23 # 35 # FR_TYPE_BOARDCAST_LPV34 +LAN_EXT_STREAM = 0x40 # 64 # FRM_LAN_EXT_STREAM + + +PROTOCOL_VERSION_BYTES_31 = b"3.1" +PROTOCOL_VERSION_BYTES_33 = b"3.3" +PROTOCOL_VERSION_BYTES_34 = b"3.4" + +PROTOCOL_3x_HEADER = 12 * b"\x00" +PROTOCOL_33_HEADER = PROTOCOL_VERSION_BYTES_33 + PROTOCOL_3x_HEADER +PROTOCOL_34_HEADER = PROTOCOL_VERSION_BYTES_34 + PROTOCOL_3x_HEADER +MESSAGE_HEADER_FMT = ">4I" # 4*uint32: prefix, seqno, cmd, length [, retcode] +MESSAGE_RECV_HEADER_FMT = ">5I" # 4*uint32: prefix, seqno, cmd, length, retcode +MESSAGE_RETCODE_FMT = ">I" # retcode for received messages +MESSAGE_END_FMT = ">2I" # 2*uint32: crc, suffix +MESSAGE_END_FMT_HMAC = ">32sI" # 32s:hmac, uint32:suffix +PREFIX_VALUE = 0x000055AA +PREFIX_BIN = b"\x00\x00U\xaa" +SUFFIX_VALUE = 0x0000AA55 +SUFFIX_BIN = b"\x00\x00\xaaU" +NO_PROTOCOL_HEADER_CMDS = [ + DP_QUERY, + DP_QUERY_NEW, + UPDATEDPS, + HEART_BEAT, + SESS_KEY_NEG_START, + SESS_KEY_NEG_RESP, + SESS_KEY_NEG_FINISH, +] + +HEARTBEAT_INTERVAL = 10 + +# DPS that are known to be safe to use with update_dps (0x12) command +UPDATE_DPS_WHITELIST = [18, 19, 20] # Socket (Wi-Fi) + +# Tuya Device Dictionary - Command and Payload Overrides +# This is intended to match requests.json payload at +# https://github.com/codetheweb/tuyapi : +# 'type_0a' devices require the 0a command for the DP_QUERY request +# 'type_0d' devices require the 0d command for the DP_QUERY request and a list of +# dps used set to Null in the request payload +# prefix: # Next byte is command byte ("hexByte") some zero padding, then length +# of remaining payload, i.e. command + suffix (unclear if multiple bytes used for +# length, zero padding implies could be more than one byte) + +# Any command not defined in payload_dict will be sent as-is with a +# payload of {"gwId": "", "devId": "", "uid": "", "t": ""} + +payload_dict = { + # Default Device + "type_0a": { + AP_CONFIG: { # [BETA] Set Control Values on Device + "command": {"gwId": "", "devId": "", "uid": "", "t": ""}, + }, + CONTROL: { # Set Control Values on Device + "command": {"devId": "", "uid": "", "t": ""}, + }, + STATUS: { # Get Status from Device + "command": {"gwId": "", "devId": ""}, + }, + HEART_BEAT: {"command": {"gwId": "", "devId": ""}}, + DP_QUERY: { # Get Data Points from Device + "command": {"gwId": "", "devId": "", "uid": "", "t": ""}, + }, + CONTROL_NEW: {"command": {"devId": "", "uid": "", "t": ""}}, + DP_QUERY_NEW: {"command": {"devId": "", "uid": "", "t": ""}}, + UPDATEDPS: {"command": {"dpId": [18, 19, 20]}}, + }, + # Special Case Device "0d" - Some of these devices + # Require the 0d command as the DP_QUERY status request and the list of + # dps requested payload + "type_0d": { + DP_QUERY: { # Get Data Points from Device + "command_override": CONTROL_NEW, # Uses CONTROL_NEW command for some reason + "command": {"devId": "", "uid": "", "t": ""}, + }, + }, + "v3.4": { + CONTROL: { + "command_override": CONTROL_NEW, # Uses CONTROL_NEW command + "command": {"protocol": 5, "t": "int", "data": ""}, + }, + DP_QUERY: {"command_override": DP_QUERY_NEW}, + }, +} + + +class TuyaLoggingAdapter(logging.LoggerAdapter): + """Adapter that adds device id to all log points.""" + + def process(self, msg, kwargs): + """Process log point and return output.""" + dev_id = self.extra["device_id"] + return f"[{dev_id[0:3]}...{dev_id[-3:]}] {msg}", kwargs + + +class ContextualLogger: + """Contextual logger adding device id to log points.""" + + def __init__(self): + """Initialize a new ContextualLogger.""" + self._logger = None + self._enable_debug = False + + def set_logger(self, logger, device_id, enable_debug=False): + """Set base logger to use.""" + self._enable_debug = enable_debug + self._logger = TuyaLoggingAdapter(logger, {"device_id": device_id}) + + def debug(self, msg, *args): + """Debug level log.""" + if not self._enable_debug: + return + return self._logger.log(logging.DEBUG, msg, *args) + + def info(self, msg, *args): + """Info level log.""" + return self._logger.log(logging.INFO, msg, *args) + + def warning(self, msg, *args): + """Warning method log.""" + return self._logger.log(logging.WARNING, msg, *args) + + def error(self, msg, *args): + """Error level log.""" + return self._logger.log(logging.ERROR, msg, *args) + + def exception(self, msg, *args): + """Exception level log.""" + return self._logger.exception(msg, *args) + + +def pack_message(msg, hmac_key=None): + """Pack a TuyaMessage into bytes.""" + end_fmt = MESSAGE_END_FMT_HMAC if hmac_key else MESSAGE_END_FMT + # Create full message excluding CRC and suffix + buffer = ( + struct.pack( + MESSAGE_HEADER_FMT, + PREFIX_VALUE, + msg.seqno, + msg.cmd, + len(msg.payload) + struct.calcsize(end_fmt), + ) + + msg.payload + ) + if hmac_key: + crc = hmac.new(hmac_key, buffer, sha256).digest() + else: + crc = binascii.crc32(buffer) & 0xFFFFFFFF + # Calculate CRC, add it together with suffix + buffer += struct.pack(end_fmt, crc, SUFFIX_VALUE) + return buffer + + +def unpack_message(data, hmac_key=None, header=None, no_retcode=False, logger=None): + """Unpack bytes into a TuyaMessage.""" + end_fmt = MESSAGE_END_FMT_HMAC if hmac_key else MESSAGE_END_FMT + # 4-word header plus return code + header_len = struct.calcsize(MESSAGE_HEADER_FMT) + retcode_len = 0 if no_retcode else struct.calcsize(MESSAGE_RETCODE_FMT) + end_len = struct.calcsize(end_fmt) + headret_len = header_len + retcode_len + + if len(data) < headret_len + end_len: + logger.debug( + "unpack_message(): not enough data to unpack header! need %d but only have %d", + headret_len + end_len, + len(data), + ) + raise DecodeError("Not enough data to unpack header") + + if header is None: + header = parse_header(data) + + if len(data) < header_len + header.length: + logger.debug( + "unpack_message(): not enough data to unpack payload! need %d but only have %d", + header_len + header.length, + len(data), + ) + raise DecodeError("Not enough data to unpack payload") + + retcode = ( + 0 + if no_retcode + else struct.unpack(MESSAGE_RETCODE_FMT, data[header_len:headret_len])[0] + ) + # the retcode is technically part of the payload, but strip it as we do not want it here + payload = data[header_len + retcode_len : header_len + header.length] + crc, suffix = struct.unpack(end_fmt, payload[-end_len:]) + + if hmac_key: + have_crc = hmac.new( + hmac_key, data[: (header_len + header.length) - end_len], sha256 + ).digest() + else: + have_crc = ( + binascii.crc32(data[: (header_len + header.length) - end_len]) & 0xFFFFFFFF + ) + + if suffix != SUFFIX_VALUE: + logger.debug("Suffix prefix wrong! %08X != %08X", suffix, SUFFIX_VALUE) + + if crc != have_crc: + if hmac_key: + logger.debug( + "HMAC checksum wrong! %r != %r", + binascii.hexlify(have_crc), + binascii.hexlify(crc), + ) + else: + logger.debug("CRC wrong! %08X != %08X", have_crc, crc) + + return TuyaMessage( + header.seqno, header.cmd, retcode, payload[:-end_len], crc, crc == have_crc + ) + + +def parse_header(data): + """Unpack bytes into a TuyaHeader.""" + header_len = struct.calcsize(MESSAGE_HEADER_FMT) + + if len(data) < header_len: + raise DecodeError("Not enough data to unpack header") + + prefix, seqno, cmd, payload_len = struct.unpack( + MESSAGE_HEADER_FMT, data[:header_len] + ) + + if prefix != PREFIX_VALUE: + # self.debug('Header prefix wrong! %08X != %08X', prefix, PREFIX_VALUE) + raise DecodeError("Header prefix wrong! %08X != %08X" % (prefix, PREFIX_VALUE)) + + # sanity check. currently the max payload length is somewhere around 300 bytes + if payload_len > 1000: + raise DecodeError( + "Header claims the packet size is over 1000 bytes! It is most likely corrupt. Claimed size: %d bytes" + % payload_len + ) + + return TuyaHeader(prefix, seqno, cmd, payload_len) + + +class AESCipher: + """Cipher module for Tuya communication.""" + + def __init__(self, key): + """Initialize a new AESCipher.""" + self.block_size = 16 + self.cipher = Cipher(algorithms.AES(key), modes.ECB(), default_backend()) + + def encrypt(self, raw, use_base64=True, pad=True): + """Encrypt data to be sent to device.""" + encryptor = self.cipher.encryptor() + if pad: + raw = self._pad(raw) + crypted_text = encryptor.update(raw) + encryptor.finalize() + return base64.b64encode(crypted_text) if use_base64 else crypted_text + + def decrypt(self, enc, use_base64=True, decode_text=True): + """Decrypt data from device.""" + if use_base64: + enc = base64.b64decode(enc) + + decryptor = self.cipher.decryptor() + raw = self._unpad(decryptor.update(enc) + decryptor.finalize()) + return raw.decode("utf-8") if decode_text else raw + + def _pad(self, data): + padnum = self.block_size - len(data) % self.block_size + return data + padnum * chr(padnum).encode() + + @staticmethod + def _unpad(data): + return data[: -ord(data[len(data) - 1 :])] + + +class MessageDispatcher(ContextualLogger): + """Buffer and dispatcher for Tuya messages.""" + + # Heartbeats on protocols < 3.3 respond with sequence number 0, + # so they can't be waited for like other messages. + # This is a hack to allow waiting for heartbeats. + HEARTBEAT_SEQNO = -100 + RESET_SEQNO = -101 + SESS_KEY_SEQNO = -102 + + def __init__(self, dev_id, listener, protocol_version, local_key, enable_debug): + """Initialize a new MessageBuffer.""" + super().__init__() + self.buffer = b"" + self.listeners = {} + self.listener = listener + self.version = protocol_version + self.local_key = local_key + self.set_logger(_LOGGER, dev_id, enable_debug) + + def abort(self): + """Abort all waiting clients.""" + for key in self.listeners: + sem = self.listeners[key] + self.listeners[key] = None + + # TODO: Received data and semahore should be stored separately + if isinstance(sem, asyncio.Semaphore): + sem.release() + + async def wait_for(self, seqno, cmd, timeout=5): + """Wait for response to a sequence number to be received and return it.""" + if seqno in self.listeners: + raise Exception(f"listener exists for {seqno}") + + self.debug("Command %d waiting for seq. number %d", cmd, seqno) + self.listeners[seqno] = asyncio.Semaphore(0) + try: + await asyncio.wait_for(self.listeners[seqno].acquire(), timeout=timeout) + except asyncio.TimeoutError: + self.debug( + "Command %d timed out waiting for sequence number %d", cmd, seqno + ) + del self.listeners[seqno] + raise + + return self.listeners.pop(seqno) + + def add_data(self, data): + """Add new data to the buffer and try to parse messages.""" + self.buffer += data + header_len = struct.calcsize(MESSAGE_RECV_HEADER_FMT) + + while self.buffer: + # Check if enough data for measage header + if len(self.buffer) < header_len: + break + + header = parse_header(self.buffer) + hmac_key = self.local_key if self.version == 3.4 else None + msg = unpack_message( + self.buffer, header=header, hmac_key=hmac_key, logger=self + ) + self.buffer = self.buffer[header_len - 4 + header.length :] + self._dispatch(msg) + + def _dispatch(self, msg): + """Dispatch a message to someone that is listening.""" + self.debug("Dispatching message CMD %r %s", msg.cmd, msg) + if msg.seqno in self.listeners: + # self.debug("Dispatching sequence number %d", msg.seqno) + sem = self.listeners[msg.seqno] + if isinstance(sem, asyncio.Semaphore): + self.listeners[msg.seqno] = msg + sem.release() + else: + self.debug("Got additional message without request - skipping: %s", sem) + elif msg.cmd == HEART_BEAT: + self.debug("Got heartbeat response") + if self.HEARTBEAT_SEQNO in self.listeners: + sem = self.listeners[self.HEARTBEAT_SEQNO] + self.listeners[self.HEARTBEAT_SEQNO] = msg + sem.release() + elif msg.cmd == UPDATEDPS: + self.debug("Got normal updatedps response") + if self.RESET_SEQNO in self.listeners: + sem = self.listeners[self.RESET_SEQNO] + self.listeners[self.RESET_SEQNO] = msg + sem.release() + elif msg.cmd == SESS_KEY_NEG_RESP: + self.debug("Got key negotiation response") + if self.SESS_KEY_SEQNO in self.listeners: + sem = self.listeners[self.SESS_KEY_SEQNO] + self.listeners[self.SESS_KEY_SEQNO] = msg + sem.release() + elif msg.cmd == STATUS: + if self.RESET_SEQNO in self.listeners: + self.debug("Got reset status update") + sem = self.listeners[self.RESET_SEQNO] + self.listeners[self.RESET_SEQNO] = msg + sem.release() + else: + self.debug("Got status update") + self.listener(msg) + else: + if msg.cmd == CONTROL_NEW: + self.debug("Got ACK message for command %d: will ignore it", msg.cmd) + else: + self.debug( + "Got message type %d for unknown listener %d: %s", + msg.cmd, + msg.seqno, + msg, + ) + + +class TuyaListener(ABC): + """Listener interface for Tuya device changes.""" + + @abstractmethod + def status_updated(self, status): + """Device updated status.""" + + @abstractmethod + def disconnected(self): + """Device disconnected.""" + + +class EmptyListener(TuyaListener): + """Listener doing nothing.""" + + def status_updated(self, status): + """Device updated status.""" + + def disconnected(self): + """Device disconnected.""" + + +class TuyaProtocol(asyncio.Protocol, ContextualLogger): + """Implementation of the Tuya protocol.""" + + def __init__( + self, dev_id, local_key, protocol_version, enable_debug, on_connected, listener + ): + """ + Initialize a new TuyaInterface. + + Args: + dev_id (str): The device id. + address (str): The network address. + local_key (str, optional): The encryption key. Defaults to None. + + Attributes: + port (int): The port to connect to. + """ + super().__init__() + self.loop = asyncio.get_running_loop() + self.set_logger(_LOGGER, dev_id, enable_debug) + self.id = dev_id + self.local_key = local_key.encode("latin1") + self.real_local_key = self.local_key + self.dev_type = "type_0a" + self.dps_to_request = {} + + if protocol_version: + self.set_version(float(protocol_version)) + else: + # make sure we call our set_version() and not a subclass since some of + # them (such as BulbDevice) make connections when called + TuyaProtocol.set_version(self, 3.1) + + self.cipher = AESCipher(self.local_key) + self.seqno = 1 + self.transport = None + self.listener = weakref.ref(listener) + self.dispatcher = self._setup_dispatcher(enable_debug) + self.on_connected = on_connected + self.heartbeater = None + self.dps_cache = {} + self.local_nonce = b"0123456789abcdef" # not-so-random random key + self.remote_nonce = b"" + + def set_version(self, protocol_version): + """Set the device version and eventually start available DPs detection.""" + self.version = protocol_version + self.version_bytes = str(protocol_version).encode("latin1") + self.version_header = self.version_bytes + PROTOCOL_3x_HEADER + if protocol_version == 3.2: # 3.2 behaves like 3.3 with type_0d + # self.version = 3.3 + self.dev_type = "type_0d" + elif protocol_version == 3.4: + self.dev_type = "v3.4" + + def error_json(self, number=None, payload=None): + """Return error details in JSON.""" + try: + spayload = json.dumps(payload) + # spayload = payload.replace('\"','').replace('\'','') + except Exception: + spayload = '""' + + vals = (error_codes[number], str(number), spayload) + self.debug("ERROR %s - %s - payload: %s", *vals) + + return json.loads('{ "Error":"%s", "Err":"%s", "Payload":%s }' % vals) + + def _setup_dispatcher(self, enable_debug): + def _status_update(msg): + if msg.seqno > 0: + self.seqno = msg.seqno + 1 + decoded_message = self._decode_payload(msg.payload) + if "dps" in decoded_message: + self.dps_cache.update(decoded_message["dps"]) + + listener = self.listener and self.listener() + if listener is not None: + listener.status_updated(self.dps_cache) + + return MessageDispatcher( + self.id, _status_update, self.version, self.local_key, enable_debug + ) + + def connection_made(self, transport): + """Did connect to the device.""" + self.transport = transport + self.on_connected.set_result(True) + + def start_heartbeat(self): + """Start the heartbeat transmissions with the device.""" + + async def heartbeat_loop(): + """Continuously send heart beat updates.""" + self.debug("Started heartbeat loop") + while True: + try: + await self.heartbeat() + await asyncio.sleep(HEARTBEAT_INTERVAL) + except asyncio.CancelledError: + self.debug("Stopped heartbeat loop") + raise + except asyncio.TimeoutError: + self.debug("Heartbeat failed due to timeout, disconnecting") + break + except Exception as ex: # pylint: disable=broad-except + self.exception("Heartbeat failed (%s), disconnecting", ex) + break + + transport = self.transport + self.transport = None + transport.close() + + self.heartbeater = self.loop.create_task(heartbeat_loop()) + + def data_received(self, data): + """Received data from device.""" + # self.debug("received data=%r", binascii.hexlify(data)) + self.dispatcher.add_data(data) + + def connection_lost(self, exc): + """Disconnected from device.""" + self.debug("Connection lost: %s", exc) + self.real_local_key = self.local_key + try: + listener = self.listener and self.listener() + if listener is not None: + listener.disconnected() + except Exception: # pylint: disable=broad-except + self.exception("Failed to call disconnected callback") + + async def close(self): + """Close connection and abort all outstanding listeners.""" + self.debug("Closing connection") + self.real_local_key = self.local_key + if self.heartbeater is not None: + self.heartbeater.cancel() + try: + await self.heartbeater + except asyncio.CancelledError: + pass + self.heartbeater = None + if self.dispatcher is not None: + self.dispatcher.abort() + self.dispatcher = None + if self.transport is not None: + transport = self.transport + self.transport = None + transport.close() + + async def exchange_quick(self, payload, recv_retries): + """Similar to exchange() but never retries sending and does not decode the response.""" + if not self.transport: + self.debug( + "[" + self.id + "] send quick failed, could not get socket: %s", payload + ) + return None + enc_payload = ( + self._encode_message(payload) + if isinstance(payload, MessagePayload) + else payload + ) + # self.debug("Quick-dispatching message %s, seqno %s", binascii.hexlify(enc_payload), self.seqno) + + try: + self.transport.write(enc_payload) + except Exception: + # self._check_socket_close(True) + self.close() + return None + while recv_retries: + try: + seqno = MessageDispatcher.SESS_KEY_SEQNO + msg = await self.dispatcher.wait_for(seqno, payload.cmd) + # for 3.4 devices, we get the starting seqno with the SESS_KEY_NEG_RESP message + self.seqno = msg.seqno + except Exception: + msg = None + if msg and len(msg.payload) != 0: + return msg + recv_retries -= 1 + if recv_retries == 0: + self.debug( + "received null payload (%r) but out of recv retries, giving up", msg + ) + else: + self.debug( + "received null payload (%r), fetch new one - %s retries remaining", + msg, + recv_retries, + ) + return None + + async def exchange(self, command, dps=None): + """Send and receive a message, returning response from device.""" + if self.version == 3.4 and self.real_local_key == self.local_key: + self.debug("3.4 device: negotiating a new session key") + await self._negotiate_session_key() + + self.debug( + "Sending command %s (device type: %s)", + command, + self.dev_type, + ) + payload = self._generate_payload(command, dps) + real_cmd = payload.cmd + dev_type = self.dev_type + # self.debug("Exchange: payload %r %r", payload.cmd, payload.payload) + + # Wait for special sequence number if heartbeat or reset + seqno = self.seqno + + if payload.cmd == HEART_BEAT: + seqno = MessageDispatcher.HEARTBEAT_SEQNO + elif payload.cmd == UPDATEDPS: + seqno = MessageDispatcher.RESET_SEQNO + + enc_payload = self._encode_message(payload) + self.transport.write(enc_payload) + msg = await self.dispatcher.wait_for(seqno, payload.cmd) + if msg is None: + self.debug("Wait was aborted for seqno %d", seqno) + return None + + # TODO: Verify stuff, e.g. CRC sequence number? + if real_cmd in [HEART_BEAT, CONTROL, CONTROL_NEW] and len(msg.payload) == 0: + # device may send messages with empty payload in response + # to a HEART_BEAT or CONTROL or CONTROL_NEW command: consider them an ACK + self.debug("ACK received for command %d: ignoring it", real_cmd) + return None + payload = self._decode_payload(msg.payload) + + # Perform a new exchange (once) if we switched device type + if dev_type != self.dev_type: + self.debug( + "Re-send %s due to device type change (%s -> %s)", + command, + dev_type, + self.dev_type, + ) + return await self.exchange(command, dps) + return payload + + async def status(self): + """Return device status.""" + status = await self.exchange(DP_QUERY) + if status and "dps" in status: + self.dps_cache.update(status["dps"]) + return self.dps_cache + + async def heartbeat(self): + """Send a heartbeat message.""" + return await self.exchange(HEART_BEAT) + + async def reset(self, dpIds=None): + """Send a reset message (3.3 only).""" + if self.version == 3.3: + self.dev_type = "type_0a" + self.debug("reset switching to dev_type %s", self.dev_type) + return await self.exchange(UPDATEDPS, dpIds) + + return True + + async def update_dps(self, dps=None): + """ + Request device to update index. + + Args: + dps([int]): list of dps to update, default=detected&whitelisted + """ + if self.version in [3.2, 3.3, 3.4]: # 3.2 behaves like 3.3 with type_0d + if dps is None: + if not self.dps_cache: + await self.detect_available_dps() + if self.dps_cache: + dps = [int(dp) for dp in self.dps_cache] + # filter non whitelisted dps + dps = list(set(dps).intersection(set(UPDATE_DPS_WHITELIST))) + self.debug("updatedps() entry (dps %s, dps_cache %s)", dps, self.dps_cache) + payload = self._generate_payload(UPDATEDPS, dps) + enc_payload = self._encode_message(payload) + self.transport.write(enc_payload) + return True + + async def set_dp(self, value, dp_index): + """ + Set value (may be any type: bool, int or string) of any dps index. + + Args: + dp_index(int): dps index to set + value: new value for the dps index + """ + return await self.exchange(CONTROL, {str(dp_index): value}) + + async def set_dps(self, dps): + """Set values for a set of datapoints.""" + return await self.exchange(CONTROL, dps) + + async def detect_available_dps(self): + """Return which datapoints are supported by the device.""" + # type_0d devices need a sort of bruteforce querying in order to detect the + # list of available dps experience shows that the dps available are usually + # in the ranges [1-25] and [100-110] need to split the bruteforcing in + # different steps due to request payload limitation (max. length = 255) + self.dps_cache = {} + ranges = [(2, 11), (11, 21), (21, 31), (100, 111)] + + for dps_range in ranges: + # dps 1 must always be sent, otherwise it might fail in case no dps is found + # in the requested range + self.dps_to_request = {"1": None} + self.add_dps_to_request(range(*dps_range)) + try: + data = await self.status() + except Exception as ex: + self.exception("Failed to get status: %s", ex) + raise + if "dps" in data: + self.dps_cache.update(data["dps"]) + + if self.dev_type == "type_0a": + return self.dps_cache + self.debug("Detected dps: %s", self.dps_cache) + return self.dps_cache + + def add_dps_to_request(self, dp_indicies): + """Add a datapoint (DP) to be included in requests.""" + if isinstance(dp_indicies, int): + self.dps_to_request[str(dp_indicies)] = None + else: + self.dps_to_request.update({str(index): None for index in dp_indicies}) + + def _decode_payload(self, payload): + cipher = AESCipher(self.local_key) + + if self.version == 3.4: + # 3.4 devices encrypt the version header in addition to the payload + try: + # self.debug("decrypting=%r", payload) + payload = cipher.decrypt(payload, False, decode_text=False) + except Exception as ex: + self.debug( + "incomplete payload=%r with len:%d (%s)", payload, len(payload), ex + ) + return self.error_json(ERR_PAYLOAD) + + # self.debug("decrypted 3.x payload=%r", payload) + + if payload.startswith(PROTOCOL_VERSION_BYTES_31): + # Received an encrypted payload + # Remove version header + payload = payload[len(PROTOCOL_VERSION_BYTES_31) :] + # Decrypt payload + # Remove 16-bytes of MD5 hexdigest of payload + payload = cipher.decrypt(payload[16:]) + elif self.version >= 3.2: # 3.2 or 3.3 or 3.4 + # Trim header for non-default device type + if payload.startswith(self.version_bytes): + payload = payload[len(self.version_header) :] + # self.debug("removing 3.x=%r", payload) + elif self.dev_type == "type_0d" and (len(payload) & 0x0F) != 0: + payload = payload[len(self.version_header) :] + # self.debug("removing type_0d 3.x header=%r", payload) + + if self.version != 3.4: + try: + # self.debug("decrypting=%r", payload) + payload = cipher.decrypt(payload, False) + except Exception as ex: + self.debug( + "incomplete payload=%r with len:%d (%s)", + payload, + len(payload), + ex, + ) + return self.error_json(ERR_PAYLOAD) + + # self.debug("decrypted 3.x payload=%r", payload) + # Try to detect if type_0d found + + if not isinstance(payload, str): + try: + payload = payload.decode() + except Exception as ex: + self.debug("payload was not string type and decoding failed") + raise DecodeError("payload was not a string: %s" % ex) + # return self.error_json(ERR_JSON, payload) + + if "data unvalid" in payload: + self.dev_type = "type_0d" + self.debug( + "'data unvalid' error detected: switching to dev_type %r", + self.dev_type, + ) + return None + elif not payload.startswith(b"{"): + self.debug("Unexpected payload=%r", payload) + return self.error_json(ERR_PAYLOAD, payload) + + if not isinstance(payload, str): + payload = payload.decode() + self.debug("Deciphered data = %r", payload) + try: + json_payload = json.loads(payload) + except Exception as ex: + raise DecodeError( + "could not decrypt data: wrong local_key? (exception: %s)" % ex + ) + # json_payload = self.error_json(ERR_JSON, payload) + + # v3.4 stuffs it into {"data":{"dps":{"1":true}}, ...} + if ( + "dps" not in json_payload + and "data" in json_payload + and "dps" in json_payload["data"] + ): + json_payload["dps"] = json_payload["data"]["dps"] + + return json_payload + + async def _negotiate_session_key(self): + self.local_key = self.real_local_key + + rkey = await self.exchange_quick( + MessagePayload(SESS_KEY_NEG_START, self.local_nonce), 2 + ) + if not rkey or not isinstance(rkey, TuyaMessage) or len(rkey.payload) < 48: + # error + self.debug("session key negotiation failed on step 1") + return False + + if rkey.cmd != SESS_KEY_NEG_RESP: + self.debug( + "session key negotiation step 2 returned wrong command: %d", rkey.cmd + ) + return False + + payload = rkey.payload + try: + # self.debug("decrypting %r using %r", payload, self.real_local_key) + cipher = AESCipher(self.real_local_key) + payload = cipher.decrypt(payload, False, decode_text=False) + except Exception as ex: + self.debug( + "session key step 2 decrypt failed, payload=%r with len:%d (%s)", + payload, + len(payload), + ex, + ) + return False + + self.debug("decrypted session key negotiation step 2: payload=%r", payload) + + if len(payload) < 48: + self.debug("session key negotiation step 2 failed, too short response") + return False + + self.remote_nonce = payload[:16] + hmac_check = hmac.new(self.local_key, self.local_nonce, sha256).digest() + + if hmac_check != payload[16:48]: + self.debug( + "session key negotiation step 2 failed HMAC check! wanted=%r but got=%r", + binascii.hexlify(hmac_check), + binascii.hexlify(payload[16:48]), + ) + + # self.debug("session local nonce: %r remote nonce: %r", self.local_nonce, self.remote_nonce) + rkey_hmac = hmac.new(self.local_key, self.remote_nonce, sha256).digest() + await self.exchange_quick(MessagePayload(SESS_KEY_NEG_FINISH, rkey_hmac), None) + + self.local_key = bytes( + [a ^ b for (a, b) in zip(self.local_nonce, self.remote_nonce)] + ) + # self.debug("Session nonce XOR'd: %r" % self.local_key) + + cipher = AESCipher(self.real_local_key) + self.local_key = self.dispatcher.local_key = cipher.encrypt( + self.local_key, False, pad=False + ) + self.debug("Session key negotiate success! session key: %r", self.local_key) + return True + + # adds protocol header (if needed) and encrypts + def _encode_message(self, msg): + hmac_key = None + payload = msg.payload + self.cipher = AESCipher(self.local_key) + if self.version == 3.4: + hmac_key = self.local_key + if msg.cmd not in NO_PROTOCOL_HEADER_CMDS: + # add the 3.x header + payload = self.version_header + payload + self.debug("final payload for cmd %r: %r", msg.cmd, payload) + payload = self.cipher.encrypt(payload, False) + elif self.version >= 3.2: + # expect to connect and then disconnect to set new + payload = self.cipher.encrypt(payload, False) + if msg.cmd not in NO_PROTOCOL_HEADER_CMDS: + # add the 3.x header + payload = self.version_header + payload + elif msg.cmd == CONTROL: + # need to encrypt + payload = self.cipher.encrypt(payload) + preMd5String = ( + b"data=" + + payload + + b"||lpv=" + + PROTOCOL_VERSION_BYTES_31 + + b"||" + + self.local_key + ) + m = md5() + m.update(preMd5String) + hexdigest = m.hexdigest() + # some tuya libraries strip 8: to :24 + payload = ( + PROTOCOL_VERSION_BYTES_31 + + hexdigest[8:][:16].encode("latin1") + + payload + ) + + self.cipher = None + msg = TuyaMessage(self.seqno, msg.cmd, 0, payload, 0, True) + self.seqno += 1 # increase message sequence number + buffer = pack_message(msg, hmac_key=hmac_key) + # self.debug("payload encrypted with key %r => %r", self.local_key, binascii.hexlify(buffer)) + return buffer + + def _generate_payload(self, command, data=None, gwId=None, devId=None, uid=None): + """ + Generate the payload to send. + + Args: + command(str): The type of command. + This is one of the entries from payload_dict + data(dict, optional): The data to be send. + This is what will be passed via the 'dps' entry + gwId(str, optional): Will be used for gwId + devId(str, optional): Will be used for devId + uid(str, optional): Will be used for uid + """ + json_data = command_override = None + + if command in payload_dict[self.dev_type]: + if "command" in payload_dict[self.dev_type][command]: + json_data = payload_dict[self.dev_type][command]["command"] + if "command_override" in payload_dict[self.dev_type][command]: + command_override = payload_dict[self.dev_type][command][ + "command_override" + ] + + if self.dev_type != "type_0a": + if ( + json_data is None + and command in payload_dict["type_0a"] + and "command" in payload_dict["type_0a"][command] + ): + json_data = payload_dict["type_0a"][command]["command"] + if ( + command_override is None + and command in payload_dict["type_0a"] + and "command_override" in payload_dict["type_0a"][command] + ): + command_override = payload_dict["type_0a"][command]["command_override"] + + if command_override is None: + command_override = command + if json_data is None: + # I have yet to see a device complain about included but unneeded attribs, but they *will* + # complain about missing attribs, so just include them all unless otherwise specified + json_data = {"gwId": "", "devId": "", "uid": "", "t": ""} + + if "gwId" in json_data: + if gwId is not None: + json_data["gwId"] = gwId + else: + json_data["gwId"] = self.id + if "devId" in json_data: + if devId is not None: + json_data["devId"] = devId + else: + json_data["devId"] = self.id + if "uid" in json_data: + if uid is not None: + json_data["uid"] = uid + else: + json_data["uid"] = self.id + if "t" in json_data: + if json_data["t"] == "int": + json_data["t"] = int(time.time()) + else: + json_data["t"] = str(int(time.time())) + + if data is not None: + if "dpId" in json_data: + json_data["dpId"] = data + elif "data" in json_data: + json_data["data"] = {"dps": data} + else: + json_data["dps"] = data + elif self.dev_type == "type_0d" and command == DP_QUERY: + json_data["dps"] = self.dps_to_request + + if json_data == "": + payload = "" + else: + payload = json.dumps(json_data) + # if spaces are not removed device does not respond! + payload = payload.replace(" ", "").encode("utf-8") + self.debug("Sending payload: %s", payload) + + return MessagePayload(command_override, payload) + + def __repr__(self): + """Return internal string representation of object.""" + return self.id + + +async def connect( + address, + device_id, + local_key, + protocol_version, + enable_debug, + listener=None, + port=6668, + timeout=5, +): + """Connect to a device.""" + loop = asyncio.get_running_loop() + on_connected = loop.create_future() + _, protocol = await loop.create_connection( + lambda: TuyaProtocol( + device_id, + local_key, + protocol_version, + enable_debug, + on_connected, + listener or EmptyListener(), + ), + address, + port, + ) + + await asyncio.wait_for(on_connected, timeout=timeout) + return protocol diff --git a/configs/home-assistant/custom_components/localtuya/remote.py b/configs/home-assistant/custom_components/localtuya/remote.py new file mode 100644 index 0000000..fada9d8 --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/remote.py @@ -0,0 +1,490 @@ +"""Platform to present any Tuya DP as a remote.""" + +import asyncio +import json +import base64 +import logging +from functools import partial +from enum import StrEnum +from typing import Any, Iterable +from .config_flow import col_to_select + +import voluptuous as vol +from homeassistant.components.remote import ( + ATTR_ACTIVITY, + ATTR_COMMAND, + ATTR_COMMAND_TYPE, + ATTR_NUM_REPEATS, + ATTR_DELAY_SECS, + ATTR_DEVICE, + ATTR_TIMEOUT, + DOMAIN, + RemoteEntity, + RemoteEntityFeature, +) +from homeassistant.components import persistent_notification +from homeassistant.const import STATE_OFF +from homeassistant.core import ServiceCall, State, callback, HomeAssistant +from homeassistant.exceptions import ServiceValidationError, NoEntitySpecifiedError +from homeassistant.helpers.storage import Store + +from .entity import LocalTuyaEntity, async_setup_entry +from .const import CONF_RECEIVE_DP, CONF_KEY_STUDY_DP + +NSDP_CONTROL = "control" # The control commands +NSDP_TYPE = "type" # The identifier of an IR library +NSDP_HEAD = "head" # Actually used but not documented +NSDP_KEY1 = "key1" # Actually used but not documented + +_LOGGER = logging.getLogger(__name__) + + +class ControlType(StrEnum): + ENUM = "Enum" + JSON = "Json" + + +class ControlMode(StrEnum): + SEND_IR = "send_ir" + STUDY = "study" + STUDY_EXIT = "study_exit" + STUDY_KEY = "study_key" + + +class RemoteDP(StrEnum): + DP_SEND = "201" + DP_RECIEVE = "202" + + +MODE_IR_TO_RF = { + ControlMode.SEND_IR: "rfstudy_send", + ControlMode.STUDY: "rf_study", + ControlMode.STUDY_EXIT: "rfstudy_exit", + ControlMode.STUDY_KEY: "rf_study", +} + +MODE_RF_TO_SHORT = { + MODE_IR_TO_RF[ControlMode.STUDY]: "rf_shortstudy", + MODE_IR_TO_RF[ControlMode.STUDY_EXIT]: "rfstudy_exit", +} +ATTR_FEQ = "feq" +ATTR_VER = "ver" +ATTR_RF_TYPE = "rf_type" +ATTR_TIMES = "times" +ATTR_DELAY = "delay" +ATTR_INTERVALS = "intervals" +ATTR_STUDY_FREQ = "study_feq" + +RF_DEFAULTS = ( + (ATTR_RF_TYPE, "sub_2g"), + (ATTR_STUDY_FREQ, "433.92"), + (ATTR_VER, "2"), + ("feq", "0"), + ("rate", "0"), + ("mode", "0"), +) +SEND_DEFAULTS = ( + (ATTR_TIMES, "6"), + (ATTR_DELAY, "0"), + (ATTR_INTERVALS, "0"), +) + +CODE_STORAGE_VERSION = 1 +SOTRAGE_KEY = "localtuya_remotes_codes" + + +def flow_schema(dps): + """Return schema used in config flow.""" + return { + vol.Optional(CONF_RECEIVE_DP, default=RemoteDP.DP_RECIEVE.value): col_to_select( + dps, is_dps=True + ), + vol.Optional(CONF_KEY_STUDY_DP): col_to_select(dps, is_dps=True), + } + + +def rf_decode_button(base64_code): + """Decode base64 RF command.""" + try: + jstr = base64.b64decode(base64_code) + jdata: dict = json.loads(jstr) + return jdata + except: + return {} + + +def parse_head_key(head_key: str): + """Head and key should looks similar to :HEAD:000:KEY:000. return head, key""" + head_key = head_key.split(":HEAD:")[-1] + head = head_key.split(":KEY:")[0] + key = head_key.split(":KEY:")[1] + return head, key + + +class LocalTuyaRemote(LocalTuyaEntity, RemoteEntity): + """Representation of a Tuya remote.""" + + def __init__( + self, + device, + config_entry, + remoteid, + **kwargs, + ): + """Initialize the Tuya remote.""" + super().__init__(device, config_entry, remoteid, _LOGGER, **kwargs) + + self._dp_send = str(self._config.get(self._dp_id, RemoteDP.DP_SEND)) + self._dp_recieve = str(self._config.get(CONF_RECEIVE_DP, RemoteDP.DP_RECIEVE)) + self._dp_key_study = self._config.get(CONF_KEY_STUDY_DP) + + self._device_id = self._device_config.id + self._lock = asyncio.Lock() + self._event = asyncio.Event() + + # self._attr_activity_list: list = [] + # self._attr_current_activity: str | None = None + + self._last_code = None + + self._codes = {} # Contains only device commands. + self._global_codes = {} # contains all devices commands. + + self._codes_storage = Store(self.hass, CODE_STORAGE_VERSION, SOTRAGE_KEY) + + self._storage_loaded = False + + self._attr_supported_features = ( + RemoteEntityFeature.LEARN_COMMAND | RemoteEntityFeature.DELETE_COMMAND + ) + + @property + def _ir_control_type(self): + if self.has_config(CONF_KEY_STUDY_DP): + return ControlType.ENUM + else: + return ControlType.JSON + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the remote.""" + self._attr_is_on = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the remote.""" + self._attr_is_on = False + self.async_write_ha_state() + + async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None: + """Send commands to a device.""" + if not self._attr_is_on: + raise ServiceValidationError(f"Remote {self.entity_id} is turned off") + + commands = command + device = kwargs.get(ATTR_DEVICE) + + repeats: int = kwargs.get(ATTR_NUM_REPEATS) + repeats_delay: float = kwargs.get(ATTR_DELAY_SECS) + + for req in [device, commands]: + if not req: + raise ServiceValidationError("Missing required fields") + + if not self._storage_loaded: + await self._async_load_storage() + + for cmd in commands: + # If device is "raw_b64", treat cmd as raw base64 code + code = cmd if device == "raw_b64" else self._get_code(device, cmd) + base64_code = code + if repeats: + current_repeat = 0 + while current_repeat < repeats: + await self.send_signal(ControlMode.SEND_IR, base64_code) + if repeats_delay: + await asyncio.sleep(repeats_delay) + current_repeat += 1 + continue + + await self.send_signal(ControlMode.SEND_IR, base64_code) + + async def async_learn_command(self, **kwargs: Any) -> None: + """Learn a command from a device.""" + if not self._attr_is_on: + raise ServiceValidationError(f"Remote {self.entity_id} is turned off") + + now, timeout = 0, kwargs.get(ATTR_TIMEOUT, 30) + + device = kwargs.get(ATTR_DEVICE) + commands = kwargs.get(ATTR_COMMAND) + + is_rf = kwargs.get(ATTR_COMMAND_TYPE) == "rf" + # command_type = kwargs.get(ATTR_COMMAND_TYPE) + for req in [device, commands]: + if not req: + raise ServiceValidationError("Missing required fields") + + if not self._storage_loaded: + await self._async_load_storage() + + if self._lock.locked(): + return self.debug("The device is already in learning mode.") + + async with self._lock: + for command in commands: + await self.send_signal(ControlMode.STUDY, rf=is_rf) + persistent_notification.async_create( + self.hass, + f"Press the '{command}' button.", + title="Learn command", + notification_id="learn_command", + ) + + try: + self.debug(f"Waiting for code from DP: {self._dp_recieve}") + await asyncio.wait_for(self._event.wait(), timeout) + await self.save_new_command(device, command, self._last_code) + except TimeoutError: + raise ServiceValidationError(f"Timeout: Failed to learn: {command}") + finally: + self._event.clear() + await self.send_signal(ControlMode.STUDY_EXIT, rf=is_rf) + persistent_notification.async_dismiss( + self.hass, notification_id="learn_command" + ) + + # code retrieve success and it's stored in self._last_code + # we will store the codes. + + if command != commands[-1]: + await asyncio.sleep(1) + + async def async_delete_command(self, **kwargs: Any) -> None: + """Delete commands from the database.""" + device = kwargs.get(ATTR_DEVICE) + commands = kwargs.get(ATTR_COMMAND) + + for req in [device, commands]: + if not req: + raise ServiceValidationError("Missing required fields") + + if not self._storage_loaded: + await self._async_load_storage() + + for command in commands: + await self._delete_command(device, command) + + async def send_signal(self, control, base64_code=None, rf=False): + """Send command to the remote device.""" + rf_data = rf_decode_button(base64_code) + is_rf = rf_data or rf + + @callback + def async_handle_enum_type(): + """Handle enum type IR.""" + commands = {self._dp_id: control, "13": 0} + if control == ControlMode.SEND_IR: + if all(i in base64_code for i in (":HEAD:", ":KEY:")): + head, key = parse_head_key(base64_code) + commands["3"] = head + commands["4"] = key + else: + commands[self._dp_id] = ControlMode.STUDY_KEY.value + commands[self._dp_key_study] = base64_code + return commands + + @callback + def async_handle_json_type(): + """Handle json type IR.""" + commands = {NSDP_CONTROL: control} + if control == ControlMode.SEND_IR: + commands[NSDP_TYPE] = 0 + if all(i in base64_code for i in (":HEAD:", ":KEY:")): + head, key = parse_head_key(base64_code) + commands[NSDP_HEAD] = head + commands[NSDP_KEY1] = key + else: + commands[NSDP_HEAD] = "" + commands[NSDP_KEY1] = "1" + base64_code + return commands + + @callback + def async_handle_rf_json_type(): + """Handle json type RF.""" + commands = {NSDP_CONTROL: MODE_IR_TO_RF[control]} + if freq := rf_data.get(ATTR_STUDY_FREQ): + commands[ATTR_STUDY_FREQ] = freq + if ver := rf_data.get(ATTR_VER): + commands[ATTR_VER] = ver + + for attr, default_value in RF_DEFAULTS: + if attr not in commands: + commands[attr] = default_value + + if control == ControlMode.SEND_IR: + commands[NSDP_KEY1] = {"code": base64_code} + for attr, default_value in SEND_DEFAULTS: + if attr not in commands[NSDP_KEY1]: + commands[NSDP_KEY1][attr] = default_value + return commands + + if self._ir_control_type == ControlType.ENUM: + commands = async_handle_enum_type() + else: + if is_rf: + commands = async_handle_rf_json_type() + else: + commands = async_handle_json_type() + commands = {self._dp_id: json.dumps(commands)} + + self.debug(f"Sending Command: {commands}") + if rf_data: + self.debug(f"Decoded RF Button: {rf_data}") + + await self._device.set_dps(commands) + + async def _delete_command(self, device, command) -> None: + """Store new code into stoarge.""" + codes_data = self._codes + ir_controller = self._device_id + devices_data = self._global_codes + + if ir_controller in codes_data and device in codes_data[ir_controller]: + devices_data = codes_data[ir_controller] + + if device not in devices_data: + raise ServiceValidationError( + f"Couldn't find the device: {device} available devices is on this IR Remote is: {list(devices_data)}." + ) + + commands = devices_data[device] + if command not in commands: + commands.pop("rf", False) + raise ServiceValidationError( + f"Couldn't find the command {command} for in {device} device. the available commands for this device is: {list(commands)}" + ) + + # For now this only works if the command is in the list of commands of this device. + devices_data[device].pop(command) + if device in self._global_codes: + self._global_codes.pop(device) + await self._codes_storage.async_save(codes_data) + + async def save_new_command(self, device, command, code) -> None: + """Store new code into stoarge.""" + if not self._storage_loaded: + await self._async_load_storage() + + device_unqiue_id = self._device_id + codes = self._codes + + if device_unqiue_id not in codes: + codes[device_unqiue_id] = {} + + # device_data = {command: {ATTR_COMMAND: code, ATTR_COMMAND_TYPE: command_type}} + device_data = {command: code} + + if device in codes[device_unqiue_id]: + codes[device_unqiue_id][device].update(device_data) + else: + codes[device_unqiue_id][device] = device_data + + self._global_codes[device] = device_data + await self._codes_storage.async_save(codes) + + async def _async_load_storage(self): + """Load code and flag storage from disk.""" + # Exception is intentionally not trapped to + # provide feedback if something fails. + # await self._codes_storage._async_migrate_func(1, 1, self._codes) + self._codes.update(await self._codes_storage.async_load() or {}) + + if self._codes: + for dev in self._codes.keys(): + self._global_codes.update(self._codes[dev]) + + self._storage_loaded = True + + # No need to restore state for a remote + async def restore_state_when_connected(self): + """Do nothing for a remote.""" + return + + def _get_code(self, device, command): + """Get the code of command from database.""" + codes_data = self._codes + ir_controller = self._device_id + devices_data = self._global_codes + + if ir_controller in codes_data and device in codes_data[ir_controller]: + devices_data = codes_data[ir_controller] + + if device not in devices_data: + raise ServiceValidationError( + f"Couldn't find the device: {device} available devices is on this IR Remote is: {list(devices_data)}." + ) + + commands = devices_data[device] + if command not in commands: + commands.pop("rf", False) + raise ServiceValidationError( + f"Couldn't find the command {command} for in {device} device. the available commands for this device is: {list(commands)}" + ) + + command = devices_data[device][command] + + return command + + async def _async_migrate_func(self, old_major_version, old_minor_version, old_data): + """Migrate to the new version.""" + raise NotImplementedError + + def status_updated(self): + """Device status was updated.""" + state = self.dp_value(self._dp_id) + if (dp_recv := self.dp_value(self._dp_recieve)) != self._last_code: + self._last_code = dp_recv + self._event.set() + + def status_restored(self, stored_state: State) -> None: + """Device status was restored..""" + state = stored_state + self._attr_is_on = state is None or state.state != STATE_OFF + + +async def async_setup_services(hass: HomeAssistant, entities: list[LocalTuyaRemote]): + """Setup remote services.""" + + async def _handle_add_key(call: ServiceCall): + """Handle add remote key service's action.""" + entity = None + for ent in entities: + if call.data.get("target") == ent.device_entry.id: + entity = ent + if not entity: + raise NoEntitySpecifiedError("The targeted device could not be found") + + if base65code := call.data.get("base64"): + await entity.save_new_command( + call.data["device_name"], call.data["command_name"], base65code + ) + elif (head := call.data.get("head")) and (key := call.data.get("key")): + base65code = f":HEAD:{head}:KEY:{key}" + await entity.save_new_command( + call.data["device_name"], call.data["command_name"], base65code + ) + else: + raise ServiceValidationError( + "Ensure that the fields for Raw Base64 code or header/key are valid" + ) + + hass.services.async_register("localtuya", "remote_add_code", _handle_add_key) + + +async_setup_entry = partial( + async_setup_entry, + DOMAIN, + LocalTuyaRemote, + flow_schema, + async_setup_services=async_setup_services, +) diff --git a/configs/home-assistant/custom_components/localtuya/select.py b/configs/home-assistant/custom_components/localtuya/select.py new file mode 100644 index 0000000..00d99c6 --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/select.py @@ -0,0 +1,98 @@ +"""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 homeassistant.helpers import selector + +from .entity import LocalTuyaEntity, async_setup_entry +from .const import ( + CONF_DEFAULT_VALUE, + CONF_OPTIONS, + CONF_PASSIVE_ENTITY, + CONF_RESTORE_ON_RECONNECT, + DictSelector, +) + + +def flow_schema(dps): + """Return schema used in config flow.""" + return { + vol.Required(CONF_OPTIONS, default={}): selector.ObjectSelector(), + 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 = "" + + # Set Display options + options = {} + config_options: dict = self._config.get(CONF_OPTIONS, {}) + if not isinstance(config_options, dict): + self.warning( + f"{self.name} DPiD: {self._dp_id}: Options configured incorrectly!" + + "It must be in the format of key-value pairs," + + "where each line follows the structure [device_value: friendly name]" + ) + config_options = {} + for k, v in config_options.items(): + options[k] = str(v) if v else k.replace("_", "").capitalize() + + self._options = DictSelector(options) + + @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._options.names + + @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._options.to_tuya(option) + self.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() + + if (state := self.dp_value(self._dp_id)) is not None: + self._state_friendly = self._options.to_ha(state, 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._options.names[0] + + +async_setup_entry = partial(async_setup_entry, DOMAIN, LocalTuyaSelect, flow_schema) diff --git a/configs/home-assistant/custom_components/localtuya/sensor.py b/configs/home-assistant/custom_components/localtuya/sensor.py new file mode 100644 index 0000000..4a73c7d --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/sensor.py @@ -0,0 +1,167 @@ +"""Platform to present any Tuya DP as a sensor.""" + +import logging +import base64 +from functools import partial +from .config_flow import col_to_select + +import voluptuous as vol +from homeassistant.components.sensor import ( + DEVICE_CLASSES_SCHEMA, + DOMAIN, + STATE_CLASSES_SCHEMA, + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) +from homeassistant.const import ( + CONF_DEVICE_CLASS, + CONF_UNIT_OF_MEASUREMENT, + Platform, + STATE_UNKNOWN, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfPower, +) +from homeassistant.helpers import entity_registry as er + +from .entity import LocalTuyaEntity, async_setup_entry +from .const import CONF_SCALING, CONF_STATE_CLASS + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_PRECISION = 2 + +ATTR_POWER = "power" +ATTR_VOLTAGE = "voltage" +ATTR_CURRENT = "current" +MAP_UOM = { + ATTR_CURRENT: UnitOfElectricCurrent.AMPERE, + ATTR_VOLTAGE: UnitOfElectricPotential.VOLT, + ATTR_POWER: UnitOfPower.KILO_WATT, +} + + +def flow_schema(dps): + """Return schema used in config flow.""" + return { + vol.Optional(CONF_UNIT_OF_MEASUREMENT): str, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_STATE_CLASS): col_to_select( + [sc.value for sc in SensorStateClass] + ), + vol.Optional(CONF_SCALING): vol.All( + vol.Coerce(float), vol.Range(min=-1000000.0, max=1000000.0) + ), + } + + +class LocalTuyaSensor(LocalTuyaEntity, SensorEntity): + """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 = None + + self._has_sub_entities = False + self._attr_device_class = self._config.get(CONF_DEVICE_CLASS) + + @property + def native_value(self): + """Return sensor state.""" + return self._state + + @property + def state_class(self) -> str | None: + """Return state class.""" + return getattr(self, "_attr_state_class", self._config.get(CONF_STATE_CLASS)) + + @property + def native_unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return getattr( + self, + "_attr_native_unit_of_measurement", + self._config.get(CONF_UNIT_OF_MEASUREMENT), + ) + + def status_updated(self): + """Device status was updated.""" + + state = self.dp_value(self._dp_id) + + if self.is_base64(state): + if not self._has_sub_entities: + self.hass.add_job(self.__create_sub_sensors()) + + if None not in ( + sub_sensor := getattr(self, "_attr_sub_sensor", None), + sub_sensor_state := self.decode_base64(state).get(sub_sensor), + ): + self._state = sub_sensor_state + else: + self._state = state + else: + self._state = self.scale(state) + + def status_restored(self, stored_state) -> None: + super().status_restored(stored_state) + + if (last_state := self._last_state) and self.is_base64(last_state): + self._status.update({self._dp_id: last_state}) + + # No need to restore state for a sensor + async def restore_state_when_connected(self): + """Do nothing for a sensor.""" + return + + def is_base64(self, data): + """Return if the data is valid Tuya raw Base64 encoded data.""" + return ( + (data and isinstance(data, str)) + and len(data) >= 12 + and len(data) % 2 == 0 + and data.endswith("=") + ) + + def decode_base64(self, data): + """Decode data base64 such as DPS phase_a.""" + buf = base64.b64decode(data) + voltage = (buf[1] | buf[0] << 8) / 10 + current = (buf[4] | buf[3] << 8) / 1000 + power = (buf[7] | buf[6] << 8) / 1000 + return {ATTR_VOLTAGE: voltage, ATTR_CURRENT: current, ATTR_POWER: power} + + async def __create_sub_sensors(self): + """Create sub entities for voltage, current and power and hide this parent sensor.""" + sub_entities = [] + + for sensor in (ATTR_CURRENT, ATTR_POWER, ATTR_VOLTAGE): + sub_entity = LocalTuyaSensor( + self._device, self._device_config.as_dict(), self._dp_id + ) + setattr(sub_entity, "_attr_sub_sensor", sensor) + setattr(sub_entity, "_attr_unique_id", f"{self.unique_id}_{sensor}") + setattr(sub_entity, "_attr_name", f"{self.name} {sensor.capitalize()}") + setattr(sub_entity, "_attr_device_class", SensorDeviceClass(sensor)) + setattr(sub_entity, "_attr_state_class", SensorStateClass.MEASUREMENT) + setattr(sub_entity, "_attr_native_unit_of_measurement", MAP_UOM[sensor]) + sub_entities.append(sub_entity) + + # Sub entities shouldn't have add entities attr. + if sub_entities and self.componet_add_entities: + self._has_sub_entities = True + self.componet_add_entities(sub_entities) + er.async_get(self.hass).async_update_entity( + self.entity_id, hidden_by=er.RegistryEntryHider.INTEGRATION + ) + + +async_setup_entry = partial(async_setup_entry, DOMAIN, LocalTuyaSensor, flow_schema) diff --git a/configs/home-assistant/custom_components/localtuya/services.yaml b/configs/home-assistant/custom_components/localtuya/services.yaml new file mode 100644 index 0000000..78fe20d --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/services.yaml @@ -0,0 +1,78 @@ +reload: + name: "Reload" + description: Reload localtuya and reconnect to all devices. + +set_dp: + name: "Set DP Value" + description: Change the value of a datapoint (DP) + fields: + device_id: + name: "Device ID" + description: The device ID of the device where the datapoint value needs to be changed + required: true + example: 11100118278aab4de001 + selector: + text: + dp: + name: "DP" + description: Target DP, Datapoint index + required: false + example: 1 + selector: + number: + mode: box + value: + name: "Value" + description: "A new value to set or a list of DP-value pairs. If a list is provided, the target DP will be ignored" + required: true + example: '{ "1": True, "2": True }' + selector: + object: + +remote_add_code: + name: "Add Remote Code" + description: Add the remote code to the device's remote storage. + fields: + target: + name: "Choose remote device" + description: "Select the remote to store the code on it" + required: true + selector: + device: + multiple: false + entity: + domain: "remote" + filter: + integration: "localtuya" + device_name: + name: "Device Name" + description: The name of the device to store the code in + required: true + example: TV + selector: + text: + command_name: + name: "Command Name" + description: The command name to use when calling it + required: true + example: volume_up + selector: + text: + base64: + name: "Base64 Code" + description: The Base64 code (this will override the head/key values) + required: false + selector: + text: + head: + name: "Head" + description: "The header can be found in the Tuya IoT device debug logs, Key's required" + required: false + selector: + text: + key: + name: "Key" + description: "The key can be found in the Tuya IoT device debug logs, Head's required" + required: false + selector: + text: diff --git a/configs/home-assistant/custom_components/localtuya/siren.py b/configs/home-assistant/custom_components/localtuya/siren.py new file mode 100644 index 0000000..d96d0b5 --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/siren.py @@ -0,0 +1,70 @@ +"""Platform to present any Tuya DP as a siren.""" + +import logging +from functools import partial + +import voluptuous as vol +from homeassistant.components.siren import DOMAIN, SirenEntity, SirenEntityFeature + +from .entity import LocalTuyaEntity, async_setup_entry +from .const import CONF_STATE_ON + +_LOGGER = logging.getLogger(__name__) + +# CONF_STATE_MAP = ["True and False", "ON and 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, + } + + +class LocalTuyaSiren(LocalTuyaEntity, SirenEntity): + """Representation of a Tuya siren.""" + + _attr_supported_features = SirenEntityFeature.TURN_ON | SirenEntityFeature.TURN_OFF + + def __init__( + self, + device, + config_entry, + sirenid, + **kwargs, + ): + """Initialize the Tuya siren.""" + super().__init__(device, config_entry, sirenid, _LOGGER, **kwargs) + self._is_on = False + + @property + def is_on(self): + """Return siren state.""" + return self._is_on + + async def async_turn_on(self, **kwargs): + """Turn Tuya siren on.""" + await self._device.set_dp(True, self._dp_id) + + async def async_turn_off(self, **kwargs): + """Turn Tuya siren off.""" + await self._device.set_dp(False, self._dp_id) + + # No need to restore state for a siren + async def restore_state_when_connected(self): + """Do nothing for a siren.""" + return + + def status_updated(self): + """Device status was updated.""" + super().status_updated() + + state = str(self.dp_value(self._dp_id)).lower() + if state == self._config[CONF_STATE_ON].lower() or state == "true": + self._is_on = True + else: + self._is_on = False + + +async_setup_entry = partial(async_setup_entry, DOMAIN, LocalTuyaSiren, flow_schema) diff --git a/configs/home-assistant/custom_components/localtuya/strings.json b/configs/home-assistant/custom_components/localtuya/strings.json new file mode 100644 index 0000000..db2e356 --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/strings.json @@ -0,0 +1,141 @@ +{ + "config": { + "abort": { + "already_configured": "This Account ID 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", + "scene_values": "Scene values, separate entries by a ;", + "scene_values_friendly": "User friendly scene values, 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 (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" +} diff --git a/configs/home-assistant/custom_components/localtuya/switch.py b/configs/home-assistant/custom_components/localtuya/switch.py new file mode 100644 index 0000000..7cc7ad3 --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/switch.py @@ -0,0 +1,103 @@ +"""Platform to locally control Tuya-based switch devices.""" + +import logging +from functools import partial +from .config_flow import col_to_select + +import voluptuous as vol +from homeassistant.components.switch import ( + DOMAIN, + SwitchEntity, + DEVICE_CLASSES_SCHEMA, + SwitchDeviceClass, +) +from homeassistant.const import CONF_DEVICE_CLASS + +from .entity 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): col_to_select(dps, is_dps=True), + vol.Optional(CONF_CURRENT_CONSUMPTION): col_to_select(dps, is_dps=True), + vol.Optional(CONF_VOLTAGE): col_to_select(dps, is_dps=True), + vol.Required(CONF_RESTORE_ON_RECONNECT): bool, + vol.Required(CONF_PASSIVE_ENTITY): bool, + vol.Optional(CONF_DEFAULT_VALUE): str, + vol.Optional(CONF_DEVICE_CLASS): col_to_select( + [sc.value for sc in SwitchDeviceClass] + ), + } + + +class LocalTuyaSwitch(LocalTuyaEntity, SwitchEntity): + """Representation of a Tuya switch.""" + + _attr_device_class = SwitchDeviceClass.SWITCH + + def __init__( + self, + device, + config_entry, + switchid, + **kwargs, + ): + """Initialize the Tuya switch.""" + super().__init__(device, config_entry, switchid, _LOGGER, **kwargs) + self._state = None + + @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.dp_value(self._config[CONF_CURRENT]) + if self.has_config(CONF_CURRENT_CONSUMPTION): + val_cc = self.dp_value(self._config[CONF_CURRENT_CONSUMPTION]) + attrs[ATTR_CURRENT_CONSUMPTION] = None if val_cc is None else val_cc / 10 + if self.has_config(CONF_VOLTAGE): + val_vol = self.dp_value(self._config[CONF_VOLTAGE]) + attrs[ATTR_VOLTAGE] = None if val_vol is None else val_vol / 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) diff --git a/configs/home-assistant/custom_components/localtuya/templates/__init__.py b/configs/home-assistant/custom_components/localtuya/templates/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/configs/home-assistant/custom_components/localtuya/templates/luzsala.yaml b/configs/home-assistant/custom_components/localtuya/templates/luzsala.yaml new file mode 100644 index 0000000..a35377e --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/templates/luzsala.yaml @@ -0,0 +1,18 @@ +- light: + brightness: '22' + brightness_lower: 10 + brightness_upper: 1000 + color: '24' + color_mode: '21' + color_mode_set: '0' + color_temp: '23' + color_temp_max_kelvin: 6500 + color_temp_min_kelvin: 2700 + color_temp_reverse: false + entity_category: None + friendly_name: luz_sala + icon: '' + id: '20' + music_mode: false + platform: light + scene_values: {} diff --git a/configs/home-assistant/custom_components/localtuya/templates/sample_2g_switch.yaml b/configs/home-assistant/custom_components/localtuya/templates/sample_2g_switch.yaml new file mode 100644 index 0000000..cfe6819 --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/templates/sample_2g_switch.yaml @@ -0,0 +1,43 @@ +# Simple 2 Switches config example +- switch: + id: "1" + friendly_name: "2G Local Switch 1" + entity_category: None + restore_on_reconnect: false + is_passive_entity: false + platform: "switch" + +- switch: + id: "2" + friendly_name: "2G Local Switch 2" + entity_category: None + is_passive_entity: false + platform: "switch" +#################################################### +#---# Templates Guide #---# +#################################################### +# Templates: +# The template is basically ready to go configs can be imported instead of choosing configs DPs names etc... + +# IMPORTANT: +# there is now valid check atm config so make sure you're importing correct configs. + +# the configs depends on the platform and what input does platform support read bottom. + +# THERE Is 2 ways to make template: +# - 1st is write the yaml ur self: +# --[ Keep in mind there is no valid check atm ] + +# - 2nd is to export ur device file from config flow. [ Recommended ]: +# -- in HA Dashboard go to [ Devices -> localtuya -> Configure -> Edit Device * choose the device u want to export +# --- Export the device config then submit] + +# Templates DIR: +# the configs will be exported in [custom_components/localtuya/templates] + +# How to import: +# -- When u add new device when the form [ Pick Entity type selection ] +# --- Import template Form will show up showing available templates in templates folder. + +# -- templates in [custom_components/localtuya/templates] +# -- Templates files will load up with HA so adding files will require restarting HA to show up. diff --git a/configs/home-assistant/custom_components/localtuya/templates/sample_lights_bulb.yaml b/configs/home-assistant/custom_components/localtuya/templates/sample_lights_bulb.yaml new file mode 100644 index 0000000..16f289c --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/templates/sample_lights_bulb.yaml @@ -0,0 +1,16 @@ +- light: + brightness: '22' + brightness_lower: 29 + brightness_upper: 1000 + color: '24' + color_mode: '21' + color_temp: '23' + color_temp_max_kelvin: 6500 + color_temp_min_kelvin: 2700 + color_temp_reverse: false + entity_category: None + friendly_name: test_light_35 + id: '20' + music_mode: true + platform: light + scene: '25' diff --git a/configs/home-assistant/custom_components/localtuya/translations/ar.json b/configs/home-assistant/custom_components/localtuya/translations/ar.json new file mode 100644 index 0000000..7b4f93a --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/translations/ar.json @@ -0,0 +1,146 @@ +{ + "config": { + "abort": { + "already_configured": "تم تكوين هذا الحساب بالفعل.", + "device_updated": "تم تحديث تكوين الجهاز." + }, + "error": { + "authentication_failed": "فشلت عملية المصادقة.\n{msg}", + "cannot_connect": "لا يمكن الاتصال بالجهاز. تحقق من صحة عنوان IP ثم حاول مرة أخرى.", + "device_list_failed": "فشل استرجاع قائمة الأجهزة.\n{msg}", + "invalid_auth": "فشلت عملية المصادقة مع الجهاز. تأكد من صحة معرّف الجهاز والمفتاح المحلي.", + "unknown": "حدث خطأ غير معروف.\n{ex}.", + "entity_already_configured": "تم تكوين هذه الكيان بالفعل.", + "address_in_use": "منفذ TCP 6668 (المستخدم للاكتشاف) قيد الاستخدام بالفعل. تحقق من عدم استخدام أي تكامل آخر له.", + "discovery_failed": "حدث خطأ عند اكتشاف الأجهزة. انظر إلى السجل للتفاصيل. إذا استمرت المشكلة، قم بإنشاء مشكلة جديدة (بما في ذلك السجلات التصحيحية).", + "empty_dps": "نجح الاتصال بالجهاز ولكن لم يتم العثور على نقاط البيانات. يُرجى المحاولة مرة أخرى. إذا استمرت المشكلة، قم بإنشاء مشكلة جديدة (بما في ذلك السجلات التصحيحية)." + }, + "step": { + "user": { + "title": "تكوين حساب Cloud API", + "description": "قم بتكوين بيانات الاعتماد المستخدمة للاتصال بـ Tuya Cloud API.", + "data": { + "region": "منطقة مركز البيانات", + "client_id": "معرف العميل (Client ID)", + "client_secret": "المعرف السري العميل (Client Secret)", + "user_id": "معرف المستخدم (UID)", + "username": "اسم المستخدم", + "no_cloud": "هل تريد تعطيل Cloud API؟" + } + } + } + }, + "options": { + "abort": { + "already_configured": "تم تكوين هذا الحساب بالفعل.", + "device_success": "تم {action} الجهاز {dev_name} بنجاح.", + "no_entities": "لا يمكن حذف كل الكيانات من الجهاز.\nإذا كنت ترغب في حذف الجهاز: انتقل إلى القائمة 'الأجهزة والخدمات'، ابحث عن جهازك في علامة التبويب 'الأجهزة'، انقر على 3 نقاط في الإطار 'معلومات الجهاز'، واضغط على زر 'حذف'." + }, + "error": { + "authentication_failed": "فشلت عملية المصادقة.\n{msg}", + "cannot_connect": "لا يمكن الاتصال بالجهاز. تحقق من صحة عنوان IP ثم حاول مرة أخرى.", + "device_list_failed": "فشل استرجاع قائمة الأجهزة.\n{msg}", + "invalid_auth": "فشلت عملية المصادقة مع الجهاز. تأكد من صحة معرّف الجهاز والمفتاح المحلي.", + "unknown": "حدث خطأ غير معروف. \n{ex}.", + "entity_already_configured": "تم تكوين هذه الكيان بالفعل.", + "address_in_use": "منفذ TCP 6668 (المستخدم للاكتشاف) قيد الاستخدام بالفعل. تحقق من عدم استخدام أي تكامل آخر له.", + "discovery_failed": "حدث خطأ عند اكتشاف الأجهزة. انظر إلى السجل للتفاصيل. إذا استمرت المشكلة، قم بإنشاء مشكلة جديدة (بما في ذلك السجلات التصحيحية).", + "empty_dps": "نجح الاتصال بالجهاز ولكن لم يتم العثور على نقاط البيانات. يُرجى المحاولة مرة أخرى. إذا استمرت المشكلة، قم بإنشاء مشكلة جديدة (بما في ذلك السجلات التصحيحية)." + }, + "step": { + "yaml_import": { + "title": "غير معتمد", + "description": "الأجهزة المكونة باستخدام `YAML` لا يمكن تكوينها في واجهة المستخدم. احذف جهازك من `YAML` وأعِد إنشاؤه في واجهة المستخدم أو قم بتعديل تكوين `YAML` الخاص بك." + }, + "init": { + "title": "التكوين", + "description": "حدد خيارًا للمتابعة.", + "menu_options": { + "add_device": "إضافة جهاز جديد", + "edit_device": "إعادة تكوين الجهاز موجود", + "configure_cloud": "إدارة حساب Cloud API" + } + }, + "add_device": { + "title": "اختيار الجهاز للتكوين", + "description": "يتم اكتشاف الأجهزة المتوافقة مع Tuya على شبكتك المحلية تلقائيًا بمجرد إعدادها في تطبيق Tuya. إذا لم تر الجهاز الذي تتوقعه، اختر `إضافة الجهاز يدويًا` من القائمة المنسدلة.", + "data": { + "selected_device": "الأجهزة المكتشفة", + "mass_configure": "ضبط جميع الأجهزة المتعرف عليها تلقائيًا" + } + }, + "edit_device": { + "title": "إعادة تكوين الجهاز الموجود", + "description": "حدد الجهاز الذي ترغب في إعادة تكوينه.", + "data": { + "selected_device": "الأجهزة المكونة" + } + }, + "configure_cloud": { + "title": "إدارة حساب Cloud API", + "description": "قم بتكوين بيانات الاعتماد المستخدمة للاتصال بـ Tuya Cloud API.", + "data": { + "region": "منطقة مركز البيانات", + "client_id": "معرف العميل (Client ID)", + "client_secret": "المعرف السري العميل (Client Secret)", + "user_id": "معرف المستخدم (UID)", + "username": "اسم للحساب", + "no_cloud": "هل تريد تعطيل Cloud API؟" + } + }, + "configure_device": { + "title": "تكوين اتصال الجهاز", + "description": "قم بتكوين أي تفاصيل جهاز {for_device} فارغة (إن وجدت) للسماح لـ LocalTuya بالاتصال بالجهاز.", + "data": { + "friendly_name": "اسم الجهاز", + "host": "عنوان IP", + "device_id": "معرف الجهاز", + "local_key": "المفتاح المحلي (Local Key)", + "node_id": "(اختياري) معرف الأجهزة الفرعية", + "protocol_version": "إصدار البروتوكول", + "enable_debug": "تمكين التصحيح (يجب تمكينه يدويًا في `configuration.yaml` أيضًا)", + "scan_interval": "(اختياري) الفاصل الزمني للمسح بالثواني، إذا كان الجهاز لا يمسح تلقائيًا", + "entities": "الكيانات التي تم تكوينها (قم بإلغاء التحديد للحذف)", + "add_entities": "إضافة كيان (كيانات) جديدة", + "manual_dps_strings": "(اختياري) دليل DPS، إذا لم يتم اكتشافه تلقائيًا (مفصولاً بفواصل)", + "reset_dpids": "(اختياري) معرفات DPID لإرسالها في أمر RESET، إذا لم يستجب الجهاز لطلبات الحالة بعد التشغيل (مفصولة بفواصل)", + "device_sleep_time": "(اختياري) وقت سبات الجهاز بالثواني: في حالة أن الجهاز يقوم بإرسال الحالة ثم يدخل في وضع السكون", + "export_config": "احفظ تكوين الكيان كقالب" + } + }, + "device_setup_method": { + "title": "تكوين كيانات الجهاز", + "description": "سيحاول LocalTuya اكتشاف بقية التكوين تلقائيًا. ", + "menu_options": { + "auto_configure_device": "اكتشف كيانات الجهاز تلقائيًا", + "pick_entity_type": "قم بتكوين كيانات الجهاز يدويًا", + "choose_template": "استخدم القالب المحفوظ" + } + }, + "auto_configure_device": { + "title": "التكوين التلقائي", + "description": "حدث خطأ: {err_msg}. ", + "menu_options": { + "device_setup_method": "العودة إلى طريقة الإعداد" + } + }, + "pick_entity_type": { + "title": "اختيار نوع الكيان", + "description": "اختر نوع الكيان الذي تريد إضافته.", + "data": { + "platform_to_add": "اختر الكيان", + "no_additional_entities": "الانتهاء من تكوين الكيانات", + "use_template": "استيراد ملف القالب" + } + }, + "choose_template": { + "title": "استيراد ملف القالب", + "description": "توجد ملفات القالب في المجلد `templates` ([لمعلومات أكثر](https://github.com/xZetsubou/hass-localtuya/discussions/13)).", + "data": { + "templates": "اختيار القالب" + } + } +} +}, +"title": "LocalTuya" +} \ No newline at end of file diff --git a/configs/home-assistant/custom_components/localtuya/translations/en.json b/configs/home-assistant/custom_components/localtuya/translations/en.json new file mode 100644 index 0000000..b25e288 --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/translations/en.json @@ -0,0 +1,273 @@ +{ + "config": { + "abort": { + "already_configured": "This account 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. Confirm the IP Address is correct then try again.", + "device_list_failed": "Failed to retrieve device list.\n{msg}", + "invalid_auth": "Failed to authenticate with device. Confirm the Device Id and Local Key are correct.", + "unknown": "An unknown error occurred.\n{ex}.", + "entity_already_configured": "This entity has already been configured.", + "address_in_use": "TCP port 6668 (used for discovery) is already in use. Check no other integration is using it.", + "discovery_failed": "Something failed when discovering devices. See log for details. If problem persists, create a new issue (including debug logs).", + "empty_dps": "Connection to device succeeded but no datapoints could be found. Please try set-up again. If problem persists, create a new issue (including debug logs)." + }, + "step": { + "user": { + "title": "Cloud API account configuration", + "description": "Configure the credentials used to connect to the Tuya Cloud API.", + "data": { + "region": "Data Center Region", + "client_id": "Client ID", + "client_secret": "Client Secret", + "user_id": "User ID", + "username": "Username", + "no_cloud": "Disable Cloud API?" + } + } + } + }, + "options": { + "abort": { + "already_configured": "This account 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: Browse to `Devices & services` menu, search for your device in `Devices` tab, 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. Confirm the IP Address is correct then try again.", + "device_list_failed": "Failed to retrieve device list.\n{msg}", + "invalid_auth": "Failed to authenticate with device. Confirm the Device Id and Local Key are correct.", + "unknown": "An unknown error occurred. \n{ex}.", + "entity_already_configured": "This entity has already been configured.", + "address_in_use": "TCP port 6668 (used for discovery) is already in use. Check no other integration is using it.", + "discovery_failed": "Something failed when discovering devices. See log for details. If problem persists, create a new issue (including debug logs).", + "empty_dps": "Connection to device succeeded but no datapoints could be found. Please try set-up again. If problem persists, create a new issue (including debug logs)." + }, + "step": { + "yaml_import": { + "title": "Not supported", + "description": "Devices configured using `YAML` cannot be configured in the UI. Delete your device from `YAML` and re-create it in the UI or modify your `YAML` configuration." + }, + "init": { + "title": "Configuration", + "description": "Select an option to proceed.", + "menu_options": { + "add_device": "Add new device", + "edit_device": "Reconfigure existing device", + "configure_cloud": "Manage Cloud API account" + } + }, + "add_device": { + "title": "Choose device to configure", + "description": "Compatible Tuya devices on your local network are discovered automatically once they have been set-up in the Tuya app. If you can't see the device you expected, choose `Add device manually` from the dropdown.", + "data": { + "selected_device": "Discovered devices", + "mass_configure": "Configure all recognized devices automatically" + } + }, + "edit_device": { + "title": "Reconfigure existing device", + "description": "Select the device you wish to re-configure.", + "data": { + "selected_device": "Configured devices" + } + }, + "configure_cloud": { + "title": "Manage Cloud API account", + "description": "Configure the credentials used to connect to the Tuya Cloud API.", + "data": { + "region": "Data Center Region", + "client_id": "Client ID", + "client_secret": "Client Secret", + "user_id": "User ID", + "username": "Username", + "no_cloud": "Disable Cloud API?" + } + }, + "confirm": { + "title": "Confirmation", + "description": "{message}" + }, + "configure_device": { + "title": "Configure device connectivity", + "description": "Configure any device details{for_device} that are empty (if any) to allow LocalTuya to connect to the device.", + "data": { + "friendly_name": "Device Name", + "host": "IP Address", + "device_id": "Device ID", + "local_key": "Local Key", + "node_id": "(Optional) Sub-devices Node Id", + "protocol_version": "Protocol Version", + "enable_debug": "Enable debug (must be manually enabled in `configuration.yaml` too)", + "scan_interval": "(Optional) Scan interval in seconds, if not scanning automatically", + "entities": "Configured entities (uncheck to delete)", + "add_entities": "Add new entity(s)", + "manual_dps_strings": "(Optional) Manual DPS's, if not detected automatically (separated by commas)", + "reset_dpids": "(Optional) DPIDs to send in RESET command, if device does not respond to status requests after turning on (separated by commas)", + "device_sleep_time": "(Optional) Device sleep time in seconds: If the device reports its state, then it goes into sleep", + "export_config": "Save entity configuration as template" + } + }, + "device_setup_method": { + "title": "Configure device entities", + "description": "LocalTuya will try to discover the rest of the configuration automatically. However, if this does not work for your device or you would like to tweak settings, choose the `manual` option.", + "menu_options": { + "auto_configure_device":"Discover device entities automatically", + "pick_entity_type": "Configure device entities manually", + "choose_template":"Use saved template" + } + }, + "auto_configure_device": { + "title": "Auto configure", + "description": "An error occurred: {err_msg}. If reason isn't showing, check logs.", + "menu_options": { + "device_setup_method":"Return to Setup method" + } + }, + "pick_entity_type": { + "title": "Entity type selection", + "description": "Choose the type of entity you want to add.", + "data": { + "platform_to_add": "Choose entity", + "no_additional_entities": "Finish configuring entities", + "use_template" : "Import template file" + } + }, + "choose_template":{ + "title": "Import template file", + "description": "Template files are located in the `templates` directory ([More Info](https://github.com/xZetsubou/hass-localtuya/discussions/13)).", + "data": { + "templates": "Choose template" + } + }, + "configure_entity": { + "title": "Configure entity", + "description": "Please fill out the details for {entity} with type {platform}. All settings (except for `Type` and `ID`) can be changed from the `Configure` page later.", + "data": { + "id": "DP ID", + "friendly_name": "Friendly name for Entity", + "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)", + "stop_switch_dp": "(Optional) Stop switch (if the cover has continue command?)", + "position_inverted": "Invert 0-100 position (for *position* mode only)", + "span_time": "Full opening time, in secs. (for *timed* mode only)", + "unit_of_measurement": "(Optional) Unit of Measurement", + "device_class": "(Optional) Device Class", + "state_class": "(Optional) State Class", + "scaling": "(Optional) Scaling Factor", + "state_on": "On Values (optionally comma-separated)", + "state_off": "Off Value", + "powergo_dp": "Power DP (usually 25 or 2)", + "idle_status_value": "Idle Status (comma-separated)", + "returning_status_value": "Returning Status (comma-separated)", + "docked_status_value": "Docked Status (comma-separated)", + "fault_dp": "Fault DP (usually 11)", + "battery_dp": "Battery status DP (usually 14)", + "mode_dp": "Mode DP", + "modes": "Modes list", + "return_mode": "Return home mode", + "fan_speed_dp": "(Optional) Fan speeds DP", + "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)", + "pause_dp":"Pause DP", + "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": "Reverse Color Temperature?", + "color": "Color", + "color_mode": "Color Mode aka Work 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", + "scene_values": "(Optional) Scene values", + "select_options": "Select options values", + "fan_speed_control": "Fan Speed Control DP", + "fan_oscillating_control": "Fan Oscillating Control DP", + "fan_speed_min": "minimum fan speed integer", + "fan_speed_max": "maximum fan speed integer", + "fan_speed_ordered_list": "Fan speed list (overrides speed min/max), separate entries by comma ','", + "fan_direction":"Fan Direction DP", + "fan_direction_forward": "Forward DP string", + "fan_direction_reverse": "Reverse DP string", + "fan_dps_type": "DP value type", + "current_temperature_dp": "Current Temperature", + "target_temperature_dp": "Target Temperature", + "temperature_step": "(Optional) Temperature Step", + "min_temperature": "Min Temperature", + "max_temperature": "Max Temperature", + "precision": "Precision (optional, for DPs values)", + "target_precision": "Target Precision (optional, for DP values)", + "temperature_unit": "(Optional) Temperature Unit", + "hvac_mode_dp": "(Optional) HVAC Mode DP", + "hvac_mode_set": "(Optional) HVAC Modes", + "hvac_add_off": "(Optional) Include `OFF` in HVAC Modes", + "hvac_action_dp": "(Optional) HVAC Current Action DP", + "hvac_action_set": "(Optional) HVAC Actions", + "preset_dp": "(Optional) Presets DP", + "preset_set": "(Optional) Presets", + "fan_speed_list": "(Optional) Fan supported speeds", + "eco_dp": "(Optional) Eco DP", + "eco_value": "(Optional) Eco value", + "heuristic_action": "(Optional) Enable heuristic action", + "dps_default_value": "(Optional) Default value when un-initialised", + "restore_on_reconnect": "Restore the last value set in Home Assistant after 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)", + "entity_category": "Show the entity in this category", + "humidifier_available_modes": "(Optional) Available modes in the device", + "humidifier_current_humidity_dp": "(Optional) Current Humidity DP", + "humidifier_mode_dp": "(Optional) Set mode DP", + "humidifier_set_humidity_dp": "(Optional) Set Humidity DP", + "min_humidity": "Set the minimum supported humidity", + "max_humidity": "Set the maximum supported humidity", + "alarm_supported_states": "States supported by the device", + "receive_dp":"Receiving signals DP. (default is 202)", + "key_study_dp":"(Optional) Key Study DP (usually 7)", + "lock_state_dp":"(Optional) Lock state DP", + "jammed_dp":"(Optional) Jam DP", + "target_temperature_high_dp":"(Optional) Target Temperature High DP", + "target_temperature_low_dp":"(Optional) Target Temperature Low DP", + "color_mode_set":"Supported modes set (Leave as default if you aren't sure)", + "reset_timer": "(Optional) Interval timer to reset state to off", + "swing_mode_dp": "(Optional) Vertical swing DP", + "swing_modes": "(Optional) Available Vertical swing options", + "swing_horizontal_dp": "(Optional) Horizontal swing DP", + "swing_horizontal_modes": "(Optional) Available horizontal swing options" + }, + "data_description": { + "hvac_mode_set":"Each line represents [ hvac_mode: device_value ] [Supported HVAC Modes](https://developers.home-assistant.io/docs/core/entity/climate/#hvac-modes)", + "hvac_action_set":"Each line represents [ hvac_action: device_value ] [Supported HVAC Actions](https://developers.home-assistant.io/docs/core/entity/climate/#hvac-action)", + "preset_set":"Each line represents [ device_value: friendly name ]", + "scene_values":"Each line represents [ device_value: friendly name ]", + "select_options":"Each line represents [ device_value: friendly name ]", + "swing_modes":"Each line represents [ device_value: friendly name ]", + "swing_horizontal_modes":"Each line represents [ device_value: friendly name ]", + "alarm_supported_states":"Each line represents [ supported state: device value ] [Supported States](https://developers.home-assistant.io/docs/core/entity/alarm-control-panel/#states)", + "humidifier_available_modes":"Each line represents [ device_value: friendly name ]", + "fan_speed_list":"Each line represents [ device_value: friendly name ]", + "device_class": "Find out more about [Device Classes](https://www.home-assistant.io/integrations/homeassistant/#device-class)", + "state_class": "Find out more about [State Classes](https://developers.home-assistant.io/docs/core/entity/sensor/#available-state-classes)" + } + } + } + }, + "title": "LocalTuya" +} \ No newline at end of file diff --git a/configs/home-assistant/custom_components/localtuya/translations/it.json b/configs/home-assistant/custom_components/localtuya/translations/it.json new file mode 100644 index 0000000..a029243 --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/translations/it.json @@ -0,0 +1,256 @@ +{ + "config": { + "abort": { + "already_configured": "Questo account è già stato configurato.", + "device_updated": "La configurazione del dispositivo è stata aggiornata." + }, + "error": { + "authentication_failed": "Autenticazione fallita.\n{msg}", + "cannot_connect": "Impossibile connettersi al dispositivo. Conferma che l'indirizzo IP sia corretto e riprova.", + "device_list_failed": "Recupero elenco dispositivi fallito.\n{msg}", + "invalid_auth": "Autenticazione fallita con il dispositivo. Conferma che l'ID dispositivo e la chiave locale siano corretti.", + "unknown": "Si è verificato un errore sconosciuto.\n{ex}.", + "entity_already_configured": "Questa entità è già stata configurata.", + "address_in_use": "La porta TCP 6668 (usata per la scoperta) è già in uso. Controlla che nessun'altra integrazione la stia utilizzando.", + "discovery_failed": "Qualcosa è andato storto durante la scoperta dei dispositivi. Consulta il registro per i dettagli. Se il problema persiste, crea una nuova segnalazione (includendo i log di debug).", + "empty_dps": "Connessione al dispositivo riuscita ma non è stato possibile trovare datapoint. Si prega di riprovare la configurazione. Se il problema persiste, crea una nuova segnalazione (includendo i log di debug)." + }, + "step": { + "user": { + "title": "Configurazione dell'account dell'API cloud", + "description": "Configura le credenziali utilizzate per connettersi all'API Cloud di Tuya.", + "data": { + "region": "Regione del Data Center", + "client_id": "ID client", + "client_secret": "Segreto client", + "user_id": "ID utente", + "username": "Nome utente", + "no_cloud": "Disabilitare l'API Cloud?" + } + } + } + }, + "options": { + "abort": { + "already_configured": "Questo account è già stato configurato.", + "device_success": "Dispositivo {dev_name} configurato con successo {action}.", + "no_entities": "Impossibile rimuovere tutte le entità da un dispositivo.\nSe desideri eliminare un dispositivo: Vai al menu 'Dispositivi e servizi', cerca il tuo dispositivo nella scheda 'Dispositivi', fai clic sui 3 puntini nel riquadro 'Informazioni sul dispositivo' e premi il pulsante 'Elimina'." + }, + "error": { + "authentication_failed": "Autenticazione fallita.\n{msg}", + "cannot_connect": "Impossibile connettersi al dispositivo. Conferma che l'indirizzo IP sia corretto e riprova.", + "device_list_failed": "Recupero elenco dispositivi fallito.\n{msg}", + "invalid_auth": "Autenticazione fallita con il dispositivo. Conferma che l'ID dispositivo e la chiave locale siano corretti.", + "unknown": "Si è verificato un errore sconosciuto. \n{ex}.", + "entity_already_configured": "Questa entità è già stata configurata.", + "address_in_use": "La porta TCP 6668 (usata per la scoperta) è già in uso. Controlla che nessun'altra integrazione la stia utilizzando.", + "discovery_failed": "Qualcosa è andato storto durante la scoperta dei dispositivi. Consulta il registro per i dettagli. Se il problema persiste, crea una nuova segnalazione (includendo i log di debug).", + "empty_dps": "Connessione al dispositivo riuscita ma non è stato possibile trovare datapoint. Si prega di riprovare la configurazione. Se il problema persiste, crea una nuova segnalazione (includendo i log di debug)." + }, + "step": { + "yaml_import": { + "title": "Non supportato", + "description": "I dispositivi configurati utilizzando `YAML` non possono essere configurati nell'interfaccia utente. Elimina il dispositivo da `YAML` e ricrealo nell'interfaccia utente o modifica la tua configurazione `YAML`." + }, + "init": { + "title": "Configurazione", + "description": "Seleziona un'opzione per procedere.", + "menu_options": { + "add_device": "Aggiungi nuovo dispositivo", + "edit_device": "Riconfigura dispositivo esistente", + "configure_cloud": "Gestisci account dell'API Cloud" + } + }, + "add_device": { + "title": "Scegli il dispositivo da configurare", + "description": "I dispositivi Tuya compatibili nella tua rete locale vengono scoperti automaticamente una volta configurati nell'app Tuya. Se non vedi il dispositivo previsto, scegli `Add Device Manually` dal menu a discesa.", + "data": { + "selected_device": "Dispositivi trovati" + } + }, + "edit_device": { + "title": "Riconfigura dispositivo esistente", + "description": "Seleziona il dispositivo che desideri riconfigurare.", + "data": { + "selected_device": "Dispositivi configurati" + } + }, + "configure_cloud": { + "title": "Gestisci account dell'API Cloud", + "description": "Configura le credenziali utilizzate per connettersi all'API Cloud di Tuya.", + "data": { + "region": "Regione del Data Center", + "client_id": "ID client", + "client_secret": "Segreto client", + "user_id": "ID utente", + "username": "Nome utente", + "no_cloud": "Disabilitare l'API Cloud?" + } + }, + "configure_device": { + "title": "Configura la connettività del dispositivo", + "description": "Configura eventuali dettagli del dispositivo {for_device} vuoti (se presenti) per consentire a LocalTuya di connettersi al dispositivo.", + "data": { + "friendly_name": "Nome dispositivo", + "host": "Indirizzo IP", + "device_id": "ID dispositivo", + "local_key": "Chiave locale", + "node_id": "(Opzionale) ID nodo sottodispositivi", + "protocol_version": "Versione del protocollo", + "enable_debug": "Abilita debug (deve essere abilitato manualmente anche in `configuration.yaml`)", + "scan_interval": "(Opzionale) Intervallo di scansione in secondi, se la scansione automatica non è attiva", + "entities": "Entità configurate (deseleziona per eliminare)", + "add_entities": "Aggiungi nuove entità", + "manual_dps_strings": "(Opzionale) DPS manuali, se non rilevati automaticamente (separati da virgole)", + "reset_dpids": "(Opzionale) DPID da inviare nel comando RESET, se il dispositivo non risponde alle richieste di stato dopo l'accensione (separati da virgole)", + "device_sleep_time": "(Optional) Device sleep time in seconds: If the device reports its state, then it goes into sleep", + "export_config": "Salva configurazione entità come modello" + } + }, + "device_setup_method": { + "title": "Configura entità del dispositivo", + "description": "LocalTuya cercherà di scoprire automaticamente il resto della configurazione. Tuttavia, se ciò non funziona per il tuo dispositivo o se desideri regolare le impostazioni, scegli l'opzione `manuale`.", + "menu_options": { + "auto_configure_device": "Scopri entità del dispositivo automaticamente", + "pick_entity_type": "Configura manualmente entità del dispositivo", + "choose_template": "Usa modello salvato" + } + }, + "auto_configure_device": { + "title": "Configurazione automatica", + "description": "Si è verificato un errore: {err_msg}. Se il motivo non viene mostrato, controlla i log.", + "menu_options": { + "device_setup_method": "Torna al metodo di configurazione" + } + }, + "pick_entity_type": { + "title": "Selezione tipo di entità", + "description": "Scegli il tipo di entità che desideri aggiungere.", + "data": { + "platform_to_add": "Scegli entità", + "no_additional_entities": "Termina configurazione entità", + "use_template": "Importa file modello" + } + }, + "choose_template": { + "title": "Importa file modello", + "description": "I file modello si trovano nella directory `templates` ([Maggiori informazioni](https://github.com/xZetsubou/hass-localtuya/discussions/13)).", + "data": { + "templates": "Scegli modello" + } + }, + "configure_entity": { + "title": "Configure entity", + "description": "Please fill out the details for {entity} with type {platform}. All settings (except for `Type` and `ID`) can be changed from the `Configure` page later.", + "data": { + "id": "DP ID", + "friendly_name": "Friendly name for Entity", + "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)", + "stop_switch_dp": "(Optional) Stop switch (if the cover has continue command?)", + "position_inverted": "Invert 0-100 position (for *position* mode only)", + "span_time": "Full opening time, in secs. (for *timed* mode only)", + "unit_of_measurement": "(Optional) Unit of Measurement", + "device_class": "(Optional) Device Class", + "state_class": "(Optional) State Class", + "scaling": "(Optional) Scaling Factor", + "state_on": "On Value (optionally comma-separated)", + "state_off": "Off Value", + "powergo_dp": "Power DP (usually 25 or 2)", + "idle_status_value": "Idle Status (comma-separated)", + "returning_status_value": "Returning Status (comma-separated)", + "docked_status_value": "Docked Status (comma-separated)", + "fault_dp": "Fault DP (usually 11)", + "battery_dp": "Battery status DP (usually 14)", + "mode_dp": "Mode DP", + "modes": "Modes list", + "return_mode": "Return home mode", + "fan_speed_dp": "(Optional) Fan speeds DP", + "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)", + "pause_dp":"Pause DP", + "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": "Reverse Color Temperature?", + "color": "Color", + "color_mode": "Color Mode aka Work 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", + "scene_values": "(Optional) Scene values", + "select_options": "Select options values", + "fan_speed_control": "Fan Speed Control DP", + "fan_oscillating_control": "Fan Oscillating Control DP", + "fan_speed_min": "minimum fan speed integer", + "fan_speed_max": "maximum fan speed integer", + "fan_speed_ordered_list": "Fan speed list (overrides speed min/max), separate entries by comma ','", + "fan_direction":"Fan Direction DP", + "fan_direction_forward": "Forward DP string", + "fan_direction_reverse": "Reverse DP string", + "fan_dps_type": "DP value type", + "current_temperature_dp": "Current Temperature", + "target_temperature_dp": "Target Temperature", + "temperature_step": "(Optional) Temperature Step", + "min_temperature": "Min Temperature", + "max_temperature": "Max Temperature", + "precision": "Precision (optional, for DPs values)", + "target_precision": "Target Precision (optional, for DP values)", + "temperature_unit": "(Optional) Temperature Unit", + "hvac_mode_dp": "(Optional) HVAC Mode DP", + "hvac_mode_set": "(Optional) HVAC Modes", + "hvac_add_off": "(Optional) Include `OFF` in HVAC Modes", + "hvac_action_dp": "(Optional) HVAC Current Action DP", + "hvac_action_set": "(Optional) HVAC Actions", + "preset_dp": "(Optional) Presets DP", + "preset_set": "(Optional) Presets", + "fan_speed_list": "(Optional) Fan supported speeds", + "eco_dp": "(Optional) Eco DP", + "eco_value": "(Optional) Eco value", + "heuristic_action": "(Optional) Enable heuristic action", + "dps_default_value": "(Optional) Default value when un-initialised", + "restore_on_reconnect": "Restore the last value set in Home Assistant after 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)", + "entity_category": "Show the entity in this category", + "humidifier_available_modes": "(Optional) Available modes in the device", + "humidifier_current_humidity_dp": "(Optional) Current Humidity DP", + "humidifier_mode_dp": "(Optional) Set mode DP", + "humidifier_set_humidity_dp": "(Optional) Set Humidity DP", + "min_humidity": "Set the minimum supported humidity", + "max_humidity": "Set the maximum supported humidity", + "alarm_supported_states": "States supported by the device", + "receive_dp":"Receiving signals DP. (default is 202)", + "key_study_dp":"(Optional) Key Study DP (usually 7)" + }, + "data_description": { + "hvac_mode_set":"Each line represents [ hvac_mode: device_value ] [Supported HVAC Modes](https://developers.home-assistant.io/docs/core/entity/climate/#hvac-modes)", + "hvac_action_set":"Each line represents [ hvac_action: device_value ] [Supported HVAC Actions](https://developers.home-assistant.io/docs/core/entity/climate/#hvac-action)", + "preset_set":"Each line represents [ device_value: friendly name ]", + "scene_values":"Each line represents [ device_value: friendly name ]", + "select_options":"Each line represents [ device_value: friendly name ]", + "alarm_supported_states":"Each line represents [ supported state: device value ] [Supported States](https://developers.home-assistant.io/docs/core/entity/alarm-control-panel/#states)", + "humidifier_available_modes":"Each line represents [ device_value: friendly name ]", + "fan_speed_list":"Each line represents [ device_value: friendly name ]", + "device_class": "Find out more about [Device Classes](https://www.home-assistant.io/integrations/homeassistant/#device-class)", + "state_class": "Find out more about [State Classes](https://developers.home-assistant.io/docs/core/entity/sensor/#available-state-classes)" + } + } + } + }, + "title": "LocalTuya" +} \ No newline at end of file diff --git a/configs/home-assistant/custom_components/localtuya/translations/pl.json b/configs/home-assistant/custom_components/localtuya/translations/pl.json new file mode 100644 index 0000000..7c1beec --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/translations/pl.json @@ -0,0 +1,261 @@ +{ + "config": { + "abort": { + "already_configured": "To konto zostało już skonfigurowane.", + "device_updated": "Konfiguracja urządzenia została zaktualizowana." + }, + "error": { + "authentication_failed": "Nie udało się uwierzytelnić.\n{msg}", + "cannot_connect": "Nie można połączyć się z urządzeniem. Potwierdź, że adres IP jest poprawny, a następnie spróbuj ponownie.", + "device_list_failed": "Nie udało się pobrać listy urządzeń.\n{msg}", + "invalid_auth": "Nie udało się uwierzytelnić z urządzeniem. Upewnij się, że identyfikator urządzenia i klucz lokalny są prawidłowe.", + "unknown": "Wystąpił nieznany błąd.\n{ex}.", + "entity_already_configured": "Ta encja została już skonfigurowana.", + "address_in_use": "Port TCP 6668 (używany do wykrywania) jest już używany. Sprawdź, czy nie używa go żadna inna integracja.", + "discovery_failed": "Coś nie powiodło się podczas wykrywania urządzeń. Szczegóły znajdziesz w logu. Jeśli problem będzie się powtarzał, utwórz nowy problem (w tym dzienniki debugowania).", + "empty_dps": "Połączenie z urządzeniem powiodło się, ale nie znaleziono żadnych punktów danych. Spróbuj ponownie dokonać konfiguracji. Jeśli problem będzie się powtarzał, utwórz nowy problem (w tym dzienniki debugowania)." + }, + "step": { + "user": { + "title": "Konfiguracja konta Cloud API", + "description": "Skonfiguruj dane uwierzytelniające używane do łączenia się z API Tuya Cloud.", + "data": { + "region": "Region centrum danych", + "client_id": "ID Klienta", + "client_secret": "Hasło klienta", + "user_id": "ID Użytkownika", + "username": "Nazwa użytkownika", + "no_cloud": "Wyłączyć interfejs Cloud API?" + } + } + } + }, + "options": { + "abort": { + "already_configured": "To konto zostało już skonfigurowane.", + "device_success": "Urządzenie {dev_name} pomyślnie wykonało {action}.", + "no_entities": "Nie można usunąć wszystkich elementów z urządzenia.\nJeśli chcesz usunąć urządzenie: Przejdź do menu „Urządzenia oraz usługi”, wyszukaj swoje urządzenie w zakładce „Urządzenia”, kliknij trzy kropki w ramce „Informacje o urządzeniu” i naciśnij przycisk „Usuń”." + }, + "error": { + "authentication_failed": "Nie udało się uwierzytelnić.\n{msg}", + "cannot_connect": "Nie można połączyć się z urządzeniem. Potwierdź, że adres IP jest poprawny, a następnie spróbuj ponownie.", + "device_list_failed": "Nie udało się pobrać listy urządzeń.\n{msg}", + "invalid_auth": "Nie udało się uwierzytelnić z urządzeniem. Upewnij się, że identyfikator urządzenia i klucz lokalny są prawidłowe.", + "unknown": "Wystąpił nieznany błąd. \n{ex}.", + "entity_already_configured": "Ta encja została już skonfigurowana.", + "address_in_use": "Port TCP 6668 (używany do wykrywania) jest już używany. Sprawdź, czy nie używa go żadna inna integracja.", + "discovery_failed": "Coś nie powiodło się podczas wykrywania urządzeń. Szczegóły znajdziesz w logu. Jeśli problem będzie się powtarzał, utwórz nowy problem (w tym dzienniki debugowania)..", + "empty_dps": "Połączenie z urządzeniem powiodło się, ale nie znaleziono żadnych punktów danych. Spróbuj ponownie dokonać konfiguracji. Jeśli problem będzie się powtarzał, utwórz nowy problem (w tym dzienniki debugowania)." + }, + "step": { + "yaml_import": { + "title": "Nieobsługiwany", + "description": "Urządzenia skonfigurowane przy użyciu konfiguracji `YAML` nie mogą być konfigurowane w interfejsie użytkownika. Usuń swoje urządzenie z konfiguracji `YAML` i utwórz je ponownie w interfejsie użytkownika lub zmodyfikuj konfigurację `YAML`." + }, + "init": { + "title": "Konfiguracja", + "description": "Wybierz opcję, aby kontynuować.", + "menu_options": { + "add_device": "Dodaj nowe urządzenie", + "edit_device": "Skonfiguruj ponownie istniejące urządzenie", + "configure_cloud": "Zarządzaj kontem Cloud API" + } + }, + "add_device": { + "title": "Wybierz urządzenie do skonfigurowania", + "description": "Kompatybilne urządzenia Tuya w Twojej sieci lokalnej są wykrywane automatycznie po skonfigurowaniu ich w aplikacji Tuya. Jeśli nie widzisz oczekiwanego urządzenia, wybierz z menu rozwijanego opcję „Dodaj urządzenie ręcznie”.", + "data": { + "selected_device": "Znalezione urządzenia", + "mass_configure": "Skonfiguruj automatycznie wszystkie rozpoznane urządzenia" + } + }, + "edit_device": { + "title": "Skonfiguruj ponownie istniejące urządzenie", + "description": "Wybierz urządzenie, które chcesz ponownie skonfigurować.", + "data": { + "selected_device": "Skonfigurowane urządzenia" + } + }, + "configure_cloud": { + "title": "Zarządzaj kontem Cloud API", + "description": "Skonfiguruj dane uwierzytelniające używane do łączenia się z API Tuya Cloud.", + "data": { + "region": "Region centrum danych", + "client_id": "ID Klienta", + "client_secret": "Hasło klienta", + "user_id": "ID Użytkownika", + "username": "Nazwa użytkownika", + "no_cloud": "Wyłączyć interfejs Cloud API?" + } + }, + "confirm": { + "title": "Confirmation", + "description": "{message}" + }, + "configure_device": { + "title": "Skonfiguruj łączność urządzenia", + "description": "Skonfiguruj wszystkie szczegóły urządzenia{for_device}, które są puste (jeśli istnieją), aby umożliwić LocalTuya połączenie się z urządzeniem.", + "data": { + "friendly_name": "Nazwa Urządzenia", + "host": "Adres IP", + "device_id": "ID Urządzenia", + "local_key": "Klucz Lokalny", + "node_id": "(Opcjonalnie) Id Node urządzenia podrzędnego", + "protocol_version": "Wersja Protokołu", + "enable_debug": "Włącz debugowanie (należy włączyć również ręcznie w pliku `configuration.yaml`)", + "scan_interval": "(Opcjonalnie) Interwał skanowania w sekundach, jeśli nie skanuje się automatycznie", + "entities": "Skonfigurowane encje (odznacz, aby usunąć)", + "add_entities": "Dodaj nowe encje", + "manual_dps_strings": "(Opcjonalnie) Ręczne DPS, jeśli nie zostaną wykryte automatycznie (oddzielone przecinkami)", + "reset_dpids": "(Opcjonalnie) Identyfikatory DPID do wysłania polecenia RESET, jeśli urządzenie nie odpowiada na żądania statusu po włączeniu (oddzielone przecinkami)", + "device_sleep_time": "(Optional) Device sleep time in seconds: If the device reports its state, then it goes into sleep", + "export_config": "Zapisz konfigurację encji jako szablon" + } + }, + "device_setup_method": { + "title": "Skonfiguruj encje urządzenia", + "description": "LocalTuya spróbuje automatycznie znaleść resztę konfiguracji. Jeśli jednak to nie zadziała na Twoim urządzeniu lub chcesz dostosować ustawienia, wybierz opcję „ręczną”.", + "menu_options": { + "auto_configure_device":"Automatycznie wykrywaj encje urządzenia", + "pick_entity_type": "Skonfiguruj ręcznie enje urządzenia", + "choose_template":"Użyj zapisanego szablonu" + } + }, + "auto_configure_device": { + "title": "Automatyczna konfiguracja", + "description": "Wystąpił błąd: {err_msg}. Jeśli przyczyna nie jest widoczna, sprawdź dzienniki.", + "menu_options": { + "device_setup_method":"Wróć do metody konfiguracji" + } + }, + "pick_entity_type": { + "title": "Wybór typu encji", + "description": "Wybierz typ encji, którą chcesz dodać.", + "data": { + "platform_to_add": "Wybierz encję", + "no_additional_entities": "Zakończ konfigurowanie encji", + "use_template" : "Importuj plik szablonu" + } + }, + "choose_template":{ + "title": "Importuj plik szablonu", + "description": "Pliki szablonów znajdują się w katalogu `templates` ([Więcej informacji](https://github.com/xZetsubou/hass-localtuya/discussions/13)).", + "data": { + "templates": "Wybierz szablon" + } + }, + "configure_entity": { + "title": "Skonfiguruj encje", + "description": "Podaj szczegóły dotyczące elementu {entity}, wpisując typ {platform}. Wszystkie ustawienia (z wyjątkiem „Typu” i „ID”) można później zmienić na stronie „Konfiguruj”.", + "data": { + "id": "DP ID", + "friendly_name": "Przyjazna nazwa dla encji", + "current": "Prąd", + "current_consumption": "Obecne zużycie", + "voltage": "Napięcie", + "commands_set": "Zestaw poleceń Otwórz_Zamknij_zatrzymaj", + "positioning_mode": "Tryb pozycjonowania", + "current_position_dp": "Bieżąca pozycja (tylko dla trybu *pozycjonowania*)", + "set_position_dp": "Ustaw pozycję (tylko dla trybu *pozycjonowania*)", + "stop_switch_dp": "(Optional) Stop switch (if the cover has continue command?)", + "position_inverted": "Odwróć pozycję 0-100 (tylko dla trybu *pozycjonowania*)", + "span_time": "Pełny czas otwarcia, w sekundach. (tylko dla trybu *czasowego*)", + "unit_of_measurement": "Jednostka miary (opcjonalnie)", + "device_class": "(Opcjonalnie) Klasa urządzenia", + "state_class": "(Opcjonalnie) Klasa stanu", + "scaling": "Współczynnik skalowania", + "state_on": "Wartość włączenia (oddzielone przecinkami)", + "state_off": "Wartość wyłączenia", + "powergo_dp": "DP mocy (zazwyczaj 25 or 2)", + "idle_status_value": "Stan bezczynności (oddzielone przecinkami)", + "returning_status_value": "Stan powrotu (oddzielone przecinkami)", + "docked_status_value": "Stan zadokowania (oddzielone przecinkami)", + "fault_dp": "DP błędu (zazwyczaj 11)", + "battery_dp": "DP statusu baterii (zazwyczaj 14)", + "mode_dp": "DP trybu", + "modes": "Lista trybów", + "return_mode": "Tryb powrotu do domu", + "fan_speed_dp": "(Opcjonalnie) DP prędkości wentylatora", + "fan_speeds": "Lista prędkości wentylatorów (oddzielone przecinkami)", + "clean_time_dp": "DP czasu czyszczenia(zazwyczaj 33)", + "clean_area_dp": "DP obszaru czyszczenia (zazwyczaj 32)", + "clean_record_dp": "DP zapisu czyszczenia (zazwyczaj 34)", + "locate_dp": "DP lokalizacji (zazwyczaj 31)", + "pause_dp":"Pause DP", + "paused_state": "Stan pauzy (pauza, itp)", + "stop_status": "Status zatrzymania", + "brightness": "Jasność (tylko dla koloru białego)", + "brightness_lower": "Najniższa wartość jasności", + "brightness_upper": "Najwyższa wartość jasności", + "color_temp": "Temperatura barwy", + "color_temp_reverse": "Odwrócona temperatura barwy", + "color": "Kolor", + "color_mode": "Tryb koloru, czyli tryb pracy", + "color_temp_min_kelvin": "Minimalna temperatura barwowa w K", + "color_temp_max_kelvin": "Maksymalna temperatura barwowa w K", + "music_mode": "Dostępny tryb muzyczny", + "scene": "Scena", + "scene_values": "Wartości scen", + "select_options": "Prawidłowe wpisy", + "fan_speed_control": "DP Kontroli prędkości wentylatora", + "fan_oscillating_control": "DP Sterowania oscylacyjnego wentylatora", + "fan_speed_min": "minimalna prędkość wentylatora", + "fan_speed_max": "maksymalna prędkośc wentylatora", + "fan_speed_ordered_list": "Lista trybów prędkości wentylatora (zastępuje prędkość min./maks.), wpisy oddzielaj przecinkami ','", + "fan_direction":"DP kierunku wentylatora", + "fan_direction_forward": "DP do przodu", + "fan_direction_reverse": "DP do tyłu", + "fan_dps_type": "DP typu", + "current_temperature_dp": "Obecna temperatura", + "target_temperature_dp": "Docelowa temperatura", + "temperature_step": "Krok temperatury (opcjonalnie)", + "max_temp": "Maksymalna temperatura (liczba)", + "min_temp": "Minimalna temperatura (liczba)", + "precision": "Precyzja (opcjonalnie, dla wartości DPs)", + "target_precision": "Dokładność docelowa (opcjonalnie, dla wartości DP)", + "temperature_unit": "(opcjonalnie) Jednostka temperatury", + "hvac_mode_dp": "(opcjonalnie) DP trybu HVAC", + "hvac_mode_set": "(opcjonalnie) Ustawiony tryb HVAC", + "hvac_add_off": "(Opcjonalnie) Dodaj `OFF` do trybów HVAC", + "hvac_action_dp": "(opcjonalnie) Bieżące działanie HVAC DP", + "hvac_action_set": "(opcjonalnie) Zestaw bieżących działań HVAC", + "preset_dp": "(opcjonalnie) DP ustawień wstępnych", + "preset_set": "(opcjonalnie) Zestaw ustawień wstępnych", + "fan_speed_list": "(Optional) Fan supported speeds", + "eco_dp": "(opcjonalnie) DP trybu eco", + "eco_value": "(opcjonalnie) Tryb eco", + "heuristic_action": "(opcjonalnie) Włącz działanie heurystyczne", + "dps_default_value": "(opcjonalnie) Wartość domyślna w przypadku niezainicjowania", + "restore_on_reconnect": "Przywróć ostatnią wartość ustawioną w Home Assistant po utracie połączenia", + "min_value": "Minimalna wartość", + "max_value": "Maksymalna wartość", + "step_size": "Minimalny odstęp między liczbami", + "is_passive_entity": "Jednostka pasywna? (wymaga integracji w celu przesłania wartości inicjalizacyjnej)", + "entity_category": "Pokaż encje w tej kategorii", + "humidifier_available_modes": "(opcjonalnie) Dostępne tryby w urządzeniu", + "humidifier_current_humidity_dp": "(opcjonalnie) DP aktualnej wilgotności", + "humidifier_mode_dp": "(opcjonalnie) DP ustawienia trybu", + "humidifier_set_humidity_dp": "(opcjonalnie) DP ustawienia wilgotności", + "min_humidity": "Ustaw minimalną obsługiwaną wilgotność", + "max_humidity": "Ustaw maksymalną obsługiwaną wilgotność", + "alarm_supported_states": "States supported by the device", + "receive_dp":"Receiving signals DP. (default is 202)", + "key_study_dp":"(Optional) Key Study DP (usually 7)" + }, + "data_description": { + "hvac_mode_set":"Każda linia reprezentuje [ hvac_mode: device_value ] [Obsługiwane tryby HVAC](https://developers.home-assistant.io/docs/core/entity/climate/#hvac-modes)", + "hvac_action_set":"Każda linia reprezentuje [ hvac_action: device_value ] [Obsługiwane działania HVAC](https://developers.home-assistant.io/docs/core/entity/climate/#hvac-action)", + "preset_set":"Każda linia reprezentuje [ device_value: friendly name ]", + "scene_values":"Każda linia reprezentuje [ device_value: friendly name ]", + "select_options":"Każda linia reprezentuje [ device_value: friendly name ]", + "alarm_supported_states":"Each line represents [ supported state: device value ] [Supported States](https://developers.home-assistant.io/docs/core/entity/alarm-control-panel/#states)", + "humidifier_available_modes":"Każda linia reprezentuje [ device_value: friendly name ]", + "fan_speed_list":"Każda linia reprezentuje [ device_value: friendly name ]", + "device_class": "Dowiedz się więcej o [Klasach urządzeń](https://www.home-assistant.io/integrations/homeassistant/#device-class)", + "state_class": "Dowiedz się więcej o [Klasach stanów](https://developers.home-assistant.io/docs/core/entity/sensor/#available-state-classes)" + } + } + } + }, + "title": "LocalTuya" +} \ No newline at end of file diff --git a/configs/home-assistant/custom_components/localtuya/translations/pt-BR.json b/configs/home-assistant/custom_components/localtuya/translations/pt-BR.json new file mode 100644 index 0000000..7660d24 --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/translations/pt-BR.json @@ -0,0 +1,256 @@ +{ + "config": { + "abort": { + "already_configured": "Esta conta já foi configurada.", + "device_updated": "A configuração do dispositivo foi atualizada." + }, + "error": { + "authentication_failed": "Falha na autenticação.\n{msg}", + "cannot_connect": "Não é possível se conectar ao dispositivo. Confirme se o Endereço IP está correto e tente novamente.", + "device_list_failed": "Falha ao recuperar a lista de dispositivos.\n{msg}", + "invalid_auth": "Falha na autenticação do dispositivo. Confirme se o ID do Dispositivo e a Chave Local estão corretos.", + "unknown": "Ocorreu um erro desconhecido.\n{ex}.", + "entity_already_configured": "Esta entidade já foi configurada.", + "address_in_use": "A porta TCP 6668 (usada para descoberta) já está em uso. Verifique se nenhuma outra integração a está utilizando.", + "discovery_failed": "Algo falhou ao descobrir dispositivos. Consulte o registro para detalhes. Se o problema persistir, crie um novo problema (incluindo registros de depuração).", + "empty_dps": "A conexão com o dispositivo foi bem-sucedida, mas nenhum ponto de dados pôde ser encontrado. Tente configurar novamente. Se o problema persistir, crie um novo problema (incluindo registros de depuração)." + }, + "step": { + "user": { + "title": "Configuração da conta da API de Nuvem", + "description": "Configure as credenciais usadas para se conectar à API de Nuvem Tuya.", + "data": { + "region": "Região do Centro de Dados", + "client_id": "ID do Cliente", + "client_secret": "Segredo do Cliente", + "user_id": "ID do Usuário", + "username": "Nome de Usuário", + "no_cloud": "Desativar a API de Nuvem?" + } + } + } + }, + "options": { + "abort": { + "already_configured": "Esta conta já foi configurada.", + "device_success": "Dispositivo {dev_name} configurado com sucesso {action}.", + "no_entities": "Não é possível remover todas as entidades de um dispositivo.\nSe deseja excluir um dispositivo: Acesse o menu 'Dispositivos e serviços', procure seu dispositivo na guia 'Dispositivos', clique nos 3 pontos no quadro 'Informações do Dispositivo' e pressione o botão 'Excluir'." + }, + "error": { + "authentication_failed": "Falha na autenticação.\n{msg}", + "cannot_connect": "Não é possível se conectar ao dispositivo. Confirme se o Endereço IP está correto e tente novamente.", + "device_list_failed": "Falha ao recuperar a lista de dispositivos.\n{msg}", + "invalid_auth": "Falha na autenticação do dispositivo. Confirme se o ID do Dispositivo e a Chave Local estão corretos.", + "unknown": "Ocorreu um erro desconhecido.\n{ex}.", + "entity_already_configured": "Esta entidade já foi configurada.", + "address_in_use": "A porta TCP 6668 (usada para descoberta) já está em uso. Verifique se nenhuma outra integração a está utilizando.", + "discovery_failed": "Algo falhou ao descobrir dispositivos. Consulte o registro para detalhes. Se o problema persistir, crie um novo problema (incluindo registros de depuração).", + "empty_dps": "A conexão com o dispositivo foi bem-sucedida, mas nenhum ponto de dados pôde ser encontrado. Tente configurar novamente. Se o problema persistir, crie um novo problema (incluindo registros de depuração)." + }, + "step": { + "yaml_import": { + "title": "Não suportado", + "description": "Dispositivos configurados usando `YAML` não podem ser configurados na interface do usuário. Exclua seu dispositivo do `YAML` e recrie-o na interface do usuário ou modifique sua configuração `YAML`." + }, + "init": { + "title": "Configuração", + "description": "Selecione uma opção para prosseguir.", + "menu_options": { + "add_device": "Adicionar novo dispositivo", + "edit_device": "Reconfigurar dispositivo existente", + "configure_cloud": "Gerenciar conta da API de Nuvem" + } + }, + "add_device": { + "title": "Escolha o dispositivo para configurar", + "description": "Os dispositivos Tuya compatíveis na sua rede local são descobertos automaticamente depois de configurados no aplicativo Tuya. Se você não conseguir ver o dispositivo esperado, escolha `Add Device Manually` no menu suspenso.", + "data": { + "selected_device": "Dispositivos descobertos" + } + }, + "edit_device": { + "title": "Reconfigurar dispositivo existente", + "description": "Selecione o dispositivo que deseja reconfigurar.", + "data": { + "selected_device": "Dispositivos configurados" + } + }, + "configure_cloud": { + "title": "Gerenciar conta da API de Nuvem", + "description": "Configure as credenciais usadas para se conectar à API de Nuvem Tuya.", + "data": { + "region": "Região do Centro de Dados", + "client_id": "ID do Cliente", + "client_secret": "Segredo do Cliente", + "user_id": "ID do Usuário", + "username": "Nome de Usuário", + "no_cloud": "Desativar a API de Nuvem?" + } + }, + "configure_device": { + "title": "Configurar conectividade do dispositivo", + "description": "Configure quaisquer detalhes do dispositivo{for_device} que estejam vazios (se houver) para permitir que o LocalTuya se conecte ao dispositivo.", + "data": { + "friendly_name": "Nome do Dispositivo", + "host": "Endereço IP", + "device_id": "ID do Dispositivo (device id)", + "local_key": "Chave Local (local key)", + "node_id": "(Opcional) ID do nó de subdispositivos", + "protocol_version": "Versão do Protocolo", + "enable_debug": "Habilitar depuração (deve ser habilitado manualmente em `configuration.yaml` também)", + "scan_interval": "(Opcional) Intervalo de varredura em segundos, se não estiver escaneando automaticamente", + "entities": "Entidades configuradas (desmarque para excluir)", + "add_entities": "Adicionar nova(s) entidade(s)", + "manual_dps_strings": "(Opcional) DPS's Manuais, se não detectados automaticamente (separados por vírgulas)", + "reset_dpids": "(Opcional) DPIDs a serem enviados no comando RESET, se o dispositivo não responder a solicitações de status após ligar (separados por vírgulas)", + "device_sleep_time": "(Optional) Device sleep time in seconds: If the device reports its state, then it goes into sleep", + "export_config": "Salvar configuração de entidade como modelo" + } + }, + "device_setup_method": { + "title": "Configurar entidades do dispositivo", + "description": "O LocalTuya tentará descobrir o restante da configuração automaticamente. No entanto, se isso não funcionar para o seu dispositivo ou se desejar ajustar configurações, escolha a opção `manual`.", + "menu_options": { + "auto_configure_device":"Descobrir entidades do dispositivo automaticamente", + "pick_entity_type": "Configurar entidades do dispositivo manualmente", + "choose_template":"Usar modelo salvo" + } + }, + "auto_configure_device": { + "title": "Configuração automática", + "description": "Ocorreu um erro: {err_msg}. Se o motivo não estiver sendo exibido, verifique os registros.", + "menu_options": { + "device_setup_method":"Voltar ao método de configuração" + } + }, + "pick_entity_type": { + "title": "Seleção do tipo de entidade", + "description": "Escolha o tipo de entidade que deseja adicionar.", + "data": { + "platform_to_add": "Escolher entidade", + "no_additional_entities": "Terminar de configurar as entidades", + "use_template" : "Importar arquivo de modelo" + } + }, + "choose_template":{ + "title": "Importar arquivo de modelo", + "description": "Os arquivos de modelo estão localizados no diretório 'templates' ([Mais Informações](https://github.com/xZetsubou/hass-localtuya/discussions/13)).", + "data": { + "templates": "Escolher modelo" + } + }, + "configure_entity": { + "title": "Configure entity", + "description": "Please fill out the details for {entity} with type {platform}. All settings (except for `Type` and `ID`) can be changed from the `Configure` page later.", + "data": { + "id": "DP ID", + "friendly_name": "Friendly name for Entity", + "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)", + "stop_switch_dp": "(Optional) Stop switch (if the cover has continue command?)", + "position_inverted": "Invert 0-100 position (for *position* mode only)", + "span_time": "Full opening time, in secs. (for *timed* mode only)", + "unit_of_measurement": "(Optional) Unit of Measurement", + "device_class": "(Optional) Device Class", + "state_class": "(Optional) State Class", + "scaling": "(Optional) Scaling Factor", + "state_on": "On Value (oddzielone przecinkami)", + "state_off": "Off Value", + "powergo_dp": "Power DP (usually 25 or 2)", + "idle_status_value": "Idle Status (comma-separated)", + "returning_status_value": "Returning Status (comma-separated)", + "docked_status_value": "Docked Status (comma-separated)", + "fault_dp": "Fault DP (usually 11)", + "battery_dp": "Battery status DP (usually 14)", + "mode_dp": "Mode DP ", + "modes": "Modes list", + "return_mode": "Return home mode", + "fan_speed_dp": "(Optional) Fan speeds DP", + "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)", + "pause_dp":"Pause DP", + "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": "Reverse Color Temperature?", + "color": "Color", + "color_mode": "Color Mode aka Work 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", + "scene_values": "(Optional) Scene values", + "select_options": "Select options values", + "fan_speed_control": "Fan Speed Control DP", + "fan_oscillating_control": "Fan Oscillating Control DP", + "fan_speed_min": "minimum fan speed integer", + "fan_speed_max": "maximum fan speed integer", + "fan_speed_ordered_list": "Fan speed list (overrides speed min/max), separate entries by comma ','", + "fan_direction":"Fan Direction DP", + "fan_direction_forward": "Forward DP string", + "fan_direction_reverse": "Reverse DP string", + "fan_dps_type": "DP value type", + "current_temperature_dp": "Current Temperature", + "target_temperature_dp": "Target Temperature", + "temperature_step": "(Optional) Temperature Step", + "min_temperature": "Min Temperature", + "max_temperature": "Max Temperature", + "precision": "Precision (optional, for DPs values)", + "target_precision": "Target Precision (optional, for DP values)", + "temperature_unit": "(Optional) Temperature Unit", + "hvac_mode_dp": "(Optional) HVAC Mode DP", + "hvac_mode_set": "(Optional) HVAC Modes", + "hvac_add_off": "(Optional) Include `OFF` in HVAC Modes", + "hvac_action_dp": "(Optional) HVAC Current Action DP", + "hvac_action_set": "(Optional) HVAC Actions", + "preset_dp": "(Optional) Presets DP", + "preset_set": "(Optional) Presets", + "fan_speed_list": "(Optional) Fan supported speeds", + "eco_dp": "(Optional) Eco DP", + "eco_value": "(Optional) Eco value", + "heuristic_action": "(Optional) Enable heuristic action", + "dps_default_value": "(Optional) Default value when un-initialised", + "restore_on_reconnect": "Restore the last value set in Home Assistant after 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)", + "entity_category": "Show the entity in this category", + "humidifier_available_modes": "(Optional) Available modes in the device", + "humidifier_current_humidity_dp": "(Optional) Current Humidity DP", + "humidifier_mode_dp": "(Optional) Set mode DP", + "humidifier_set_humidity_dp": "(Optional) Set Humidity DP", + "min_humidity": "Set the minimum supported humidity", + "max_humidity": "Set the maximum supported humidity", + "alarm_supported_states": "States supported by the device", + "receive_dp":"Receiving signals DP. (default is 202)", + "key_study_dp":"(Optional) Key Study DP (usually 7)" + }, + "data_description": { + "hvac_mode_set":"Each line represents [ hvac_mode: device_value ] [Supported HVAC Modes](https://developers.home-assistant.io/docs/core/entity/climate/#hvac-modes)", + "hvac_action_set":"Each line represents [ hvac_action: device_value ] [Supported HVAC Actions](https://developers.home-assistant.io/docs/core/entity/climate/#hvac-action)", + "preset_set":"Each line represents [ device_value: friendly name ]", + "scene_values":"Each line represents [ device_value: friendly name ]", + "select_options":"Each line represents [ device_value: friendly name ]", + "alarm_supported_states":"Each line represents [ supported state: device value ] [Supported States](https://developers.home-assistant.io/docs/core/entity/alarm-control-panel/#states)", + "humidifier_available_modes":"Each line represents [ device_value: friendly name ]", + "fan_speed_list":"Each line represents [ device_value: friendly name ]", + "device_class": "Find out more about [Device Classes](https://www.home-assistant.io/integrations/homeassistant/#device-class)", + "state_class": "Find out more about [State Classes](https://developers.home-assistant.io/docs/core/entity/sensor/#available-state-classes)" + } + } + } + }, + "title": "LocalTuya" +} \ No newline at end of file diff --git a/configs/home-assistant/custom_components/localtuya/translations/tr.json b/configs/home-assistant/custom_components/localtuya/translations/tr.json new file mode 100644 index 0000000..1ea910e --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/translations/tr.json @@ -0,0 +1,266 @@ +{ + "config": { + "abort": { + "already_configured": "Bu hesap zaten yapılandırılmış.", + "device_updated": "Cihaz yapılandırması güncellendi." + }, + "error": { + "authentication_failed": "Kimlik doğrulama başarısız oldu.\n{msg}", + "cannot_connect": "Cihaza bağlanılamıyor. IP adresinin doğru olduğundan emin olun ve tekrar deneyin.", + "device_list_failed": "Cihaz listesi alınamadı.\n{msg}", + "invalid_auth": "Cihazla kimlik doğrulama başarısız oldu. Cihaz Kimliği ve Yerel Anahtarın doğru olduğundan emin olun.", + "unknown": "Bilinmeyen bir hata oluştu.\n{ex}.", + "entity_already_configured": "Bu varlık zaten yapılandırılmış.", + "address_in_use": "Keşif için kullanılan TCP portu 6668 zaten kullanılıyor. Başka bir entegrasyonun bunu kullanmadığından emin olun.", + "discovery_failed": "Cihazlar keşfedilirken bir hata oluştu. Ayrıntılar için günlüğe bakın. Sorun devam ederse, bir hata bildirimi oluşturun (hata ayıklama günlüklerini dahil ederek).", + "empty_dps": "Cihaza bağlantı başarılı oldu ancak veri noktası bulunamadı. Lütfen kurulumu tekrar deneyin. Sorun devam ederse, bir hata bildirimi oluşturun (hata ayıklama günlüklerini dahil ederek)." + }, + "step": { + "user": { + "title": "Bulut API hesabı yapılandırması", + "description": "Tuya Bulut API'sine bağlanmak için kullanılan kimlik bilgilerini yapılandırın.", + "data": { + "region": "Veri Merkezi Bölgesi", + "client_id": "Müşteri Kimliği", + "client_secret": "Müşteri Sırrı", + "user_id": "Kullanıcı Kimliği", + "username": "Kullanıcı Adı", + "no_cloud": "Bulut API'sini devre dışı bırak?" + } + } + } + }, + "options": { + "abort": { + "already_configured": "Bu hesap zaten yapılandırılmış.", + "device_success": "{dev_name} cihazı başarıyla {action}.", + "no_entities": "Bir cihazdan tüm varlıklar kaldırılamaz.\nBir cihazı silmek istiyorsanız: `Cihazlar ve hizmetler` menüsüne gidin, `Cihazlar` sekmesinde cihazınızı arayın, `Cihaz bilgisi` çerçevesindeki 3 noktaya tıklayın ve `Sil` düğmesine basın." + }, + "error": { + "authentication_failed": "Kimlik doğrulama başarısız oldu.\n{msg}", + "cannot_connect": "Cihaza bağlanılamıyor. IP adresinin doğru olduğundan emin olun ve tekrar deneyin.", + "device_list_failed": "Cihaz listesi alınamadı.\n{msg}", + "invalid_auth": "Cihazla kimlik doğrulama başarısız oldu. Cihaz Kimliği ve Yerel Anahtarın doğru olduğundan emin olun.", + "unknown": "Bilinmeyen bir hata oluştu. \n{ex}.", + "entity_already_configured": "Bu varlık zaten yapılandırılmış.", + "address_in_use": "Keşif için kullanılan TCP portu 6668 zaten kullanılıyor. Başka bir entegrasyonun bunu kullanmadığından emin olun.", + "discovery_failed": "Cihazlar keşfedilirken bir hata oluştu. Ayrıntılar için günlüğe bakın. Sorun devam ederse, bir hata bildirimi oluşturun (hata ayıklama günlüklerini dahil ederek).", + "empty_dps": "Cihaza bağlantı başarılı oldu ancak veri noktası bulunamadı. Lütfen kurulumu tekrar deneyin. Sorun devam ederse, bir hata bildirimi oluşturun (hata ayıklama günlüklerini dahil ederek)." + }, + "step": { + "yaml_import": { + "title": "Desteklenmiyor", + "description": "`YAML` kullanılarak yapılandırılmış cihazlar, kullanıcı arayüzünde yapılandırılamaz. Cihazınızı `YAML`den silin ve kullanıcı arayüzünde yeniden oluşturun ya da `YAML` yapılandırmanızı değiştirin." + }, + "init": { + "title": "Yapılandırma", + "description": "Devam etmek için bir seçenek seçin.", + "menu_options": { + "add_device": "Yeni cihaz ekle", + "edit_device": "Mevcut cihazı yeniden yapılandır", + "configure_cloud": "Bulut API hesabını yönet" + } + }, + "add_device": { + "title": "Yapılandırılacak cihazı seçin", + "description": "Yerel ağınızdaki uyumlu Tuya cihazları, Tuya uygulamasında kurulumları yapıldıktan sonra otomatik olarak keşfedilir. Beklediğiniz cihazı göremiyorsanız, açılır menüden `Cihazı manuel ekle` seçeneğini belirleyin.", + "data": { + "selected_device": "Keşfedilen cihazlar", + "mass_configure": "Tüm tanınan cihazları otomatik olarak yapılandır" + } + }, + "edit_device": { + "title": "Mevcut cihazı yeniden yapılandır", + "description": "Yeniden yapılandırmak istediğiniz cihazı seçin.", + "data": { + "selected_device": "Yapılandırılmış cihazlar" + } + }, + "configure_cloud": { + "title": "Bulut API hesabını yönet", + "description": "Tuya Bulut API'sine bağlanmak için kullanılan kimlik bilgilerini yapılandırın.", + "data": { + "region": "Veri Merkezi Bölgesi", + "client_id": "Müşteri Kimliği", + "client_secret": "Müşteri Sırrı", + "user_id": "Kullanıcı Kimliği", + "username": "Kullanıcı Adı", + "no_cloud": "Bulut API'sini devre dışı bırak?" + } + }, + "confirm": { + "title": "Onay", + "description": "{message}" + }, + "configure_device": { + "title": "Cihaz bağlantısını yapılandır", + "description": "LocalTuya'nın cihaza bağlanabilmesi için cihazla ilgili tüm eksik bilgileri doldurun {for_device}.", + "data": { + "friendly_name": "Cihaz Adı", + "host": "IP Adresi", + "device_id": "Cihaz Kimliği", + "local_key": "Yerel Anahtar", + "node_id": "(Opsiyonel) Alt cihazların Düğüm Kimliği", + "protocol_version": "Protokol Sürümü", + "enable_debug": "Hata ayıklamayı etkinleştir (manuel olarak `configuration.yaml` içinde de etkinleştirilmelidir)", + "scan_interval": "(Opsiyonel) Otomatik tarama yapılmıyorsa, saniye cinsinden tarama aralığı", + "entities": "Yapılandırılmış varlıklar (kaldırmak için işareti kaldırın)", + "add_entities": "Yeni varlık ekle", + "manual_dps_strings": "(Opsiyonel) Otomatik algılanamıyorsa, manuel DPS'ler (virgülle ayrılmış)", + "reset_dpids": "(Opsiyonel) Cihaz açıldıktan sonra durum isteklerine yanıt vermezse RESET komutunda gönderilecek DPIDs (virgülle ayrılmış)", + "device_sleep_time": "(Opsiyonel) Cihazın durumunu rapor ettikten sonra uykuya geçme süresi (saniye cinsinden)", + "export_config": "Varlık yapılandırmasını şablon olarak kaydet" + } + }, + "device_setup_method": { + "title": "Cihaz varlıklarını yapılandır", + "description": "LocalTuya, yapılandırmanın geri kalanını otomatik olarak keşfetmeye çalışacaktır. Ancak, bu cihazınız için işe yaramazsa veya ayarları ince ayar yapmak istiyorsanız, `manuel` seçeneğini belirleyin.", + "menu_options": { + "auto_configure_device": "Cihaz varlıklarını otomatik olarak keşfet", + "pick_entity_type": "Cihaz varlıklarını manuel yapılandır", + "choose_template": "Kaydedilmiş şablonu kullan" + } + }, + "auto_configure_device": { + "title": "Otomatik yapılandır", + "description": "Bir hata oluştu: {err_msg}. Sebep görünmüyorsa, günlükleri kontrol edin.", + "menu_options": { + "device_setup_method": "Kurulum yöntemine geri dön" + } + }, + "pick_entity_type": { + "title": "Varlık türü seçimi", + "description": "Eklemek istediğiniz varlık türünü seçin.", + "data": { + "platform_to_add": "Varlık türünü seçin", + "no_additional_entities": "Varlık yapılandırmasını tamamla", + "use_template": "Şablon dosyasını içe aktar" + } + }, + "choose_template": { + "title": "Şablon dosyasını içe aktar", + "description": "Şablon dosyaları `templates` dizininde bulunur ([Daha Fazla Bilgi](https://github.com/xZetsubou/hass-localtuya/discussions/13)).", + "data": { + "templates": "Şablonu seçin" + } + }, + "configure_entity": { + "title": "Varlığı yapılandır", + "description": "Lütfen {entity} için {platform} türüne sahip ayrıntıları doldurun. Tüm ayarlar (`Tür` ve `Kimlik` haricinde) daha sonra `Yapılandır` sayfasından değiştirilebilir.", + "data": { + "id": "DP Kimliği", + "friendly_name": "Varlık için Dostane Ad", + "current": "Akım", + "current_consumption": "Anlık Tüketim", + "voltage": "Voltaj", + "commands_set": "Aç_Kapat_Durdur Komut Seti", + "positioning_mode": "Konumlandırma modu", + "current_position_dp": "Mevcut Konum (yalnızca *konum* modu için)", + "set_position_dp": "Konumu Ayarla (yalnızca *konum* modu için)", + "stop_switch_dp": "(Opsiyonel) Durdurma anahtarı (kapak devam komutuna sahip mi?)", + "position_inverted": "0-100 konumunu ters çevir (yalnızca *konum* modu için)", + "span_time": "Tam açılma süresi, saniye cinsinden. (yalnızca *zamanlanmış* mod için)", + "unit_of_measurement": "(Opsiyonel) Ölçüm Birimi", + "device_class": "(Opsiyonel) Cihaz Sınıfı", + "state_class": "(Opsiyonel) Durum Sınıfı", + "scaling": "(Opsiyonel) Ölçeklendirme Faktörü", + "state_on": "Açık Değer (virgülle ayrılmış)", + "state_off": "Kapalı Değer", + "powergo_dp": "Güç DP'si (genellikle 25 veya 2)", + "idle_status_value": "Boşta Durum (virgülle ayrılmış)", + "returning_status_value": "Geri Dönüş Durumu (virgülle ayrılmış)", + "docked_status_value": "Bağlantı Durumu (virgülle ayrılmış)", + "fault_dp": "Hata DP'si (genellikle 11)", + "battery_dp": "Batarya durumu DP'si (genellikle 14)", + "mode_dp": "Mod DP", + "modes": "Modlar listesi", + "return_mode": "Eve dönüş modu", + "fan_speed_dp": "(Opsiyonel) Fan hızları DP", + "fan_speeds": "Fan hızları listesi (virgülle ayrılmış)", + "clean_time_dp": "Temizleme Süresi DP (genellikle 33)", + "clean_area_dp": "Temizleme Alanı DP (genellikle 32)", + "clean_record_dp": "Temizleme Kaydı DP (genellikle 34)", + "locate_dp": "Konumlandırma DP (genellikle 31)", + "pause_dp": "Duraklatma DP", + "paused_state": "Duraklatma durumu (pause, paused, vb.)", + "stop_status": "Durdurma durumu", + "brightness": "Parlaklık (sadece beyaz renk için)", + "brightness_lower": "Alt Parlaklık Değeri", + "brightness_upper": "Üst Parlaklık Değeri", + "color_temp": "Renk Sıcaklığı", + "color_temp_reverse": "Renk Sıcaklığı Ters Çevrilsin mi?", + "color": "Renk", + "color_mode": "Renk Modu ya da Çalışma Modu", + "color_temp_min_kelvin": "Minimum Renk Sıcaklığı (K)", + "color_temp_max_kelvin": "Maksimum Renk Sıcaklığı (K)", + "music_mode": "Müzik modu mevcut mu?", + "scene": "Sahne", + "scene_values": "(Opsiyonel) Sahne değerleri", + "select_options": "Seçenek değerlerini seçin", + "fan_speed_control": "Fan Hızı Kontrol DP", + "fan_oscillating_control": "Fan Salınım Kontrol DP", + "fan_speed_min": "Minimum fan hızı (tam sayı)", + "fan_speed_max": "Maksimum fan hızı (tam sayı)", + "fan_speed_ordered_list": "Fan hız listesi (min/maks hızı geçersiz kılar, her girdiyi virgülle ',' ayırın)", + "fan_direction": "Fan Yönü DP", + "fan_direction_forward": "İleri DP değeri", + "fan_direction_reverse": "Geri DP değeri", + "fan_dps_type": "DP değer türü", + "current_temperature_dp": "Mevcut Sıcaklık", + "target_temperature_dp": "Hedef Sıcaklık", + "temperature_step": "(Opsiyonel) Sıcaklık Adımı", + "min_temperature": "Minimum Sıcaklık", + "max_temperature": "Maksimum Sıcaklık", + "precision": "Hassasiyet (opsiyonel, DP değerleri için)", + "target_precision": "Hedef Hassasiyet (opsiyonel, DP değerleri için)", + "temperature_unit": "(Opsiyonel) Sıcaklık Birimi", + "hvac_mode_dp": "(Opsiyonel) HVAC Modu DP", + "hvac_mode_set": "(Opsiyonel) HVAC Modları", + "hvac_add_off": "(Opsiyonel) HVAC Modlarına `OFF` ekle", + "hvac_action_dp": "(Opsiyonel) HVAC Geçerli Eylem DP", + "hvac_action_set": "(Opsiyonel) HVAC Eylemleri", + "preset_dp": "(Opsiyonel) Önayarlar DP", + "preset_set": "(Opsiyonel) Önayarlar", + "fan_speed_list": "(Opsiyonel) Desteklenen fan hızları", + "eco_dp": "(Opsiyonel) Eko DP", + "eco_value": "(Opsiyonel) Eko değeri", + "heuristic_action": "(Opsiyonel) Heuristik eylemi etkinleştir", + "dps_default_value": "(Opsiyonel) Başlatılmamışken varsayılan değer", + "restore_on_reconnect": "Bağlantı kaybından sonra Home Assistant'da ayarlanan son değeri geri yükle?", + "min_value": "Minimum Değer", + "max_value": "Maksimum Değer", + "step_size": "Sayılar arasındaki minimum artış", + "is_passive_entity": "Pasif varlık mı? (başlangıç değerini göndermek için entegrasyon gerekir)", + "entity_category": "Varlığı bu kategoride göster", + "humidifier_available_modes": "(Opsiyonel) Cihazda mevcut modlar", + "humidifier_current_humidity_dp": "(Opsiyonel) Mevcut Nem DP", + "humidifier_mode_dp": "(Opsiyonel) Mod Ayarı DP", + "humidifier_set_humidity_dp": "(Opsiyonel) Nem Ayarı DP", + "min_humidity": "Desteklenen minimum nemi ayarla", + "max_humidity": "Desteklenen maksimum nemi ayarla", + "alarm_supported_states": "Cihaz tarafından desteklenen durumlar", + "receive_dp": "Sinyal alma DP'si (varsayılan 202)", + "key_study_dp": "(Opsiyonel) Anahtar Çalışması DP (genellikle 7)", + "lock_state_dp": "(Opsiyonel) Kilit durumu DP", + "jammed_dp": "(Opsiyonel) Sıkışma DP", + "target_temperature_high_dp": "(Opsiyonel) Hedef Yüksek Sıcaklık DP", + "target_temperature_low_dp": "(Opsiyonel) Hedef Düşük Sıcaklık DP", + "color_mode_set": "Desteklenen modlar seti (emin değilseniz varsayılanı bırakın)" + }, + "data_description": { + "hvac_mode_set": "Her satır [ hvac_modu: cihaz_değeri ] temsil eder [Desteklenen HVAC Modları](https://developers.home-assistant.io/docs/core/entity/climate/#hvac-modes)", + "hvac_action_set": "Her satır [ hvac_eylemi: cihaz_değeri ] temsil eder [Desteklenen HVAC Eylemleri](https://developers.home-assistant.io/docs/core/entity/climate/#hvac-action)", + "preset_set": "Her satır [ cihaz_değeri: dostane ad ] temsil eder", + "scene_values": "Her satır [ cihaz_değeri: dostane ad ] temsil eder", + "select_options": "Her satır [ cihaz_değeri: dostane ad ] temsil eder", + "alarm_supported_states": "Her satır [ desteklenen durum: cihaz değeri ] temsil eder [Desteklenen Durumlar](https://developers.home-assistant.io/docs/core/entity/alarm-control-panel/#states)", + "humidifier_available_modes": "Her satır [ cihaz_değeri: dostane ad ] temsil eder", + "fan_speed_list": "Her satır [ cihaz_değeri: dostane ad ] temsil eder", + "device_class": "[Cihaz Sınıfları](https://www.home-assistant.io/integrations/homeassistant/#device-class) hakkında daha fazla bilgi edinin", + "state_class": "[Durum Sınıfları](https://developers.home-assistant.io/docs/core/entity/sensor/#available-state-classes) hakkında daha fazla bilgi edinin" + } + } + } + }, + "title": "LocalTuya" +} \ No newline at end of file diff --git a/configs/home-assistant/custom_components/localtuya/translations/vi.json b/configs/home-assistant/custom_components/localtuya/translations/vi.json new file mode 100644 index 0000000..096487c --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/translations/vi.json @@ -0,0 +1,273 @@ +{ + "title": "LocalTuya", + "config": { + "abort": { + "already_configured": "Tài khoản này đã được cấu hình.", + "device_updated": "Cấu hình thiết bị đã được cập nhật." + }, + "error": { + "authentication_failed": "Xác thực thất bại.\n{msg}", + "cannot_connect": "Không thể kết nối tới thiết bị. Hãy kiểm tra lại địa chỉ IP và thử lại.", + "device_list_failed": "Không lấy được danh sách thiết bị.\n{msg}", + "invalid_auth": "Xác thực với thiết bị thất bại. Hãy kiểm tra Device Id và Local Key.", + "unknown": "Đã xảy ra lỗi không xác định.\n{ex}.", + "entity_already_configured": "Thực thể này đã được cấu hình.", + "address_in_use": "Cổng TCP 6668 (dùng cho khám phá thiết bị) đang được sử dụng. Kiểm tra xem có tích hợp nào khác đang dùng không.", + "discovery_failed": "Có lỗi khi khám phá thiết bị. Xem log để biết chi tiết. Nếu vẫn lỗi, hãy tạo issue mới (kèm debug log).", + "empty_dps": "Kết nối thiết bị thành công nhưng không tìm thấy datapoint nào. Hãy thử lại. Nếu vẫn lỗi, hãy tạo issue mới (kèm debug log)." + }, + "step": { + "user": { + "title": "Cấu hình tài khoản Cloud API", + "description": "Cấu hình thông tin đăng nhập dùng để kết nối Tuya Cloud API.", + "data": { + "region": "Khu vực trung tâm dữ liệu", + "client_id": "Client ID", + "client_secret": "Client Secret", + "user_id": "User ID", + "username": "Tên đăng nhập", + "no_cloud": "Tắt Cloud API?" + } + } + } + }, + "options": { + "abort": { + "already_configured": "Tài khoản này đã được cấu hình.", + "device_success": "Thiết bị {dev_name} đã được {action} thành công.", + "no_entities": "Không thể xóa hết thực thể khỏi thiết bị.\nNếu muốn xóa thiết bị: Vào menu `Thiết bị & dịch vụ`, tìm thiết bị trong tab `Thiết bị`, bấm 3 chấm ở khung `Thông tin thiết bị`, rồi nhấn `Xóa`." + }, + "error": { + "authentication_failed": "Xác thực thất bại.\n{msg}", + "cannot_connect": "Không thể kết nối tới thiết bị. Hãy kiểm tra lại địa chỉ IP và thử lại.", + "device_list_failed": "Không lấy được danh sách thiết bị.\n{msg}", + "invalid_auth": "Xác thực với thiết bị thất bại. Hãy kiểm tra Device Id và Local Key.", + "unknown": "Đã xảy ra lỗi không xác định.\n{ex}.", + "entity_already_configured": "Thực thể này đã được cấu hình.", + "address_in_use": "Cổng TCP 6668 (dùng cho khám phá thiết bị) đang được sử dụng. Kiểm tra xem có tích hợp nào khác đang dùng không.", + "discovery_failed": "Có lỗi khi khám phá thiết bị. Xem log để biết chi tiết. Nếu vẫn lỗi, hãy tạo issue mới (kèm debug log).", + "empty_dps": "Kết nối thiết bị thành công nhưng không tìm thấy datapoint nào. Hãy thử lại. Nếu vẫn lỗi, hãy tạo issue mới (kèm debug log)." + }, + "step": { + "yaml_import": { + "title": "Không hỗ trợ", + "description": "Thiết bị cấu hình bằng `YAML` không thể cấu hình trong giao diện. Hãy xóa thiết bị khỏi `YAML` và tạo lại trong giao diện hoặc chỉnh sửa file cấu hình." + }, + "init": { + "title": "Cấu hình", + "description": "Chọn một tùy chọn để tiếp tục.", + "menu_options": { + "add_device": "Thêm thiết bị mới", + "edit_device": "Cấu hình lại thiết bị", + "configure_cloud": "Quản lý tài khoản Cloud API" + } + }, + "add_device": { + "title": "Chọn thiết bị để cấu hình", + "description": "Các thiết bị Tuya tương thích trong mạng nội bộ sẽ được tự động phát hiện sau khi đã cài đặt trong app Tuya. Nếu không thấy thiết bị mong muốn, chọn `Thêm thiết bị thủ công` trong danh sách.", + "data": { + "selected_device": "Thiết bị đã phát hiện", + "mass_configure": "Tự động cấu hình tất cả thiết bị nhận diện được" + } + }, + "edit_device": { + "title": "Cấu hình lại thiết bị", + "description": "Chọn thiết bị bạn muốn cấu hình lại.", + "data": { + "selected_device": "Thiết bị đã cấu hình" + } + }, + "configure_cloud": { + "title": "Quản lý tài khoản Cloud API", + "description": "Cấu hình thông tin đăng nhập dùng để kết nối Tuya Cloud API.", + "data": { + "region": "Khu vực trung tâm dữ liệu", + "client_id": "Client ID", + "client_secret": "Client Secret", + "user_id": "User ID", + "username": "Tên đăng nhập", + "no_cloud": "Tắt Cloud API?" + } + }, + "confirm": { + "title": "Xác nhận", + "description": "{message}" + }, + "configure_device": { + "title": "Cấu hình kết nối thiết bị", + "description": "Cấu hình các thông tin thiết bị{for_device} còn thiếu (nếu có) để LocalTuya có thể kết nối.", + "data": { + "friendly_name": "Tên thiết bị", + "host": "Địa chỉ IP", + "device_id": "Device ID", + "local_key": "Local Key", + "node_id": "(Tùy chọn) Node Id cho sub-device", + "protocol_version": "Phiên bản giao thức", + "enable_debug": "Bật debug (cần bật thủ công trong `configuration.yaml`)", + "scan_interval": "(Tùy chọn) Khoảng thời gian quét (giây), nếu không tự động quét", + "entities": "Các thực thể đã cấu hình (bỏ chọn để xóa)", + "add_entities": "Thêm thực thể mới", + "manual_dps_strings": "(Tùy chọn) DPS thủ công, nếu không tự phát hiện (cách nhau bằng dấu phẩy)", + "reset_dpids": "(Tùy chọn) DPIDs gửi trong lệnh RESET, nếu thiết bị không phản hồi sau khi bật (cách nhau bằng dấu phẩy)", + "device_sleep_time": "(Tùy chọn) Thời gian ngủ của thiết bị (giây): Nếu thiết bị báo trạng thái xong sẽ ngủ", + "export_config": "Lưu cấu hình thực thể thành template" + } + }, + "device_setup_method": { + "title": "Cấu hình thực thể thiết bị", + "description": "LocalTuya sẽ cố gắng tự động phát hiện cấu hình còn lại. Nếu không hoạt động hoặc muốn tùy chỉnh, chọn `thủ công`.", + "menu_options": { + "auto_configure_device":"Tự động phát hiện thực thể", + "pick_entity_type": "Cấu hình thực thể thủ công", + "choose_template":"Dùng template đã lưu" + } + }, + "auto_configure_device": { + "title": "Tự động cấu hình", + "description": "Có lỗi: {err_msg}. Nếu không rõ lý do, hãy kiểm tra log.", + "menu_options": { + "device_setup_method":"Quay lại chọn phương thức cấu hình" + } + }, + "pick_entity_type": { + "title": "Chọn loại thực thể", + "description": "Chọn loại thực thể bạn muốn thêm.", + "data": { + "platform_to_add": "Chọn thực thể", + "no_additional_entities": "Kết thúc cấu hình", + "use_template" : "Nhập file template" + } + }, + "choose_template":{ + "title": "Nhập file template", + "description": "File template nằm trong thư mục `templates` ([Xem thêm](https://github.com/xZetsubou/hass-localtuya/discussions/13)).", + "data": { + "templates": "Chọn template" + } + }, + "configure_entity": { + "title": "Cấu hình thực thể", + "description": "Điền thông tin cho {entity} loại {platform}. Tất cả tùy chọn (trừ `Type` và `ID`) có thể thay đổi sau ở trang cấu hình.", + "data": { + "id": "DP ID", + "friendly_name": "Tên thân thiện cho thực thể", + "current": "Dòng điện hiện tại", + "current_consumption": "Công suất tiêu thụ hiện tại", + "voltage": "Điện áp", + "commands_set": "Bộ lệnh Open_Close_Stop", + "positioning_mode": "Chế độ định vị", + "current_position_dp": "Vị trí hiện tại (chỉ cho chế độ *position*)", + "set_position_dp": "Đặt vị trí (chỉ cho chế độ *position*)", + "stop_switch_dp": "(Tùy chọn) DP dừng (nếu cover có lệnh tiếp tục?)", + "position_inverted": "Đảo ngược vị trí 0-100 (chỉ cho chế độ *position*)", + "span_time": "Thời gian mở hoàn toàn (giây, chỉ cho *timed*)", + "unit_of_measurement": "(Tùy chọn) Đơn vị đo lường", + "device_class": "(Tùy chọn) Device Class", + "state_class": "(Tùy chọn) State Class", + "scaling": "(Tùy chọn) Hệ số scale", + "state_on": "Giá trị Bật (cách nhau bằng dấu phẩy)", + "state_off": "Giá trị Tắt", + "powergo_dp": "DP công suất (thường là 25 hoặc 2)", + "idle_status_value": "Trạng thái chờ (cách nhau bằng dấu phẩy)", + "returning_status_value": "Trạng thái đang về (cách nhau bằng dấu phẩy)", + "docked_status_value": "Trạng thái đã về dock (cách nhau bằng dấu phẩy)", + "fault_dp": "DP lỗi (thường là 11)", + "battery_dp": "DP pin (thường là 14)", + "mode_dp": "DP chế độ", + "modes": "Danh sách chế độ", + "return_mode": "Chế độ về nhà", + "fan_speed_dp": "(Tùy chọn) DP tốc độ quạt", + "fan_speeds": "Danh sách tốc độ quạt (cách nhau bằng dấu phẩy)", + "clean_time_dp": "DP thời gian làm sạch (thường là 33)", + "clean_area_dp": "DP diện tích làm sạch (thường là 32)", + "clean_record_dp": "DP lịch sử làm sạch (thường là 34)", + "locate_dp": "DP định vị (thường là 31)", + "pause_dp":"DP tạm dừng", + "paused_state": "Trạng thái tạm dừng (pause, paused, v.v.)", + "stop_status": "Trạng thái dừng", + "brightness": "Độ sáng (chỉ cho màu trắng)", + "brightness_lower": "Giá trị độ sáng thấp nhất", + "brightness_upper": "Giá trị độ sáng cao nhất", + "color_temp": "Nhiệt độ màu", + "color_temp_reverse": "Đảo ngược nhiệt độ màu?", + "color": "Màu sắc", + "color_mode": "Chế độ màu (Work Mode)", + "color_temp_min_kelvin": "Nhiệt độ màu tối thiểu (K)", + "color_temp_max_kelvin": "Nhiệt độ màu tối đa (K)", + "music_mode": "Có chế độ nhạc không?", + "scene": "Cảnh", + "scene_values": "(Tùy chọn) Giá trị cảnh", + "select_options": "Giá trị các tùy chọn", + "fan_speed_control": "DP điều khiển tốc độ quạt", + "fan_oscillating_control": "DP điều khiển quay quạt", + "fan_speed_min": "Tốc độ quạt nhỏ nhất (số nguyên)", + "fan_speed_max": "Tốc độ quạt lớn nhất (số nguyên)", + "fan_speed_ordered_list": "Danh sách tốc độ quạt (ghi đè min/max), cách nhau bằng dấu phẩy ','", + "fan_direction":"DP hướng quạt", + "fan_direction_forward": "Chuỗi DP tiến", + "fan_direction_reverse": "Chuỗi DP lùi", + "fan_dps_type": "Kiểu giá trị DP", + "current_temperature_dp": "Nhiệt độ hiện tại", + "target_temperature_dp": "Nhiệt độ mục tiêu", + "temperature_step": "(Tùy chọn) Bước nhiệt độ", + "min_temperature": "Nhiệt độ tối thiểu", + "max_temperature": "Nhiệt độ tối đa", + "precision": "Độ chính xác (tùy chọn, cho giá trị DP)", + "target_precision": "Độ chính xác mục tiêu (tùy chọn, cho giá trị DP)", + "temperature_unit": "(Tùy chọn) Đơn vị nhiệt độ", + "hvac_mode_dp": "(Tùy chọn) DP chế độ HVAC", + "hvac_mode_set": "(Tùy chọn) Các chế độ HVAC", + "hvac_add_off": "(Tùy chọn) Thêm `OFF` vào chế độ HVAC", + "hvac_action_dp": "(Tùy chọn) DP trạng thái hiện tại HVAC", + "hvac_action_set": "(Tùy chọn) Các trạng thái HVAC", + "preset_dp": "(Tùy chọn) DP preset", + "preset_set": "(Tùy chọn) Preset", + "fan_speed_list": "(Tùy chọn) Danh sách tốc độ quạt", + "eco_dp": "(Tùy chọn) DP Eco", + "eco_value": "(Tùy chọn) Giá trị Eco", + "heuristic_action": "(Tùy chọn) Bật heuristic action", + "dps_default_value": "(Tùy chọn) Giá trị mặc định khi chưa khởi tạo", + "restore_on_reconnect": "Khôi phục giá trị cuối cùng đã đặt trong Home Assistant sau khi mất kết nối?", + "min_value": "Giá trị nhỏ nhất", + "max_value": "Giá trị lớn nhất", + "step_size": "Bước tăng nhỏ nhất", + "is_passive_entity": "Thực thể thụ động? (cần tích hợp gửi giá trị khởi tạo)", + "entity_category": "Hiển thị thực thể trong nhóm này", + "humidifier_available_modes": "(Tùy chọn) Các chế độ khả dụng trên thiết bị", + "humidifier_current_humidity_dp": "(Tùy chọn) DP độ ẩm hiện tại", + "humidifier_mode_dp": "(Tùy chọn) DP đặt chế độ", + "humidifier_set_humidity_dp": "(Tùy chọn) DP đặt độ ẩm", + "min_humidity": "Đặt độ ẩm tối thiểu hỗ trợ", + "max_humidity": "Đặt độ ẩm tối đa hỗ trợ", + "alarm_supported_states": "Các trạng thái thiết bị hỗ trợ", + "receive_dp":"DP nhận tín hiệu (mặc định là 202)", + "key_study_dp":"(Tùy chọn) DP học phím (thường là 7)", + "lock_state_dp":"(Tùy chọn) DP trạng thái khóa", + "jammed_dp":"(Tùy chọn) DP kẹt", + "target_temperature_high_dp":"(Tùy chọn) DP nhiệt độ cao mục tiêu", + "target_temperature_low_dp":"(Tùy chọn) DP nhiệt độ thấp mục tiêu", + "color_mode_set":"Bộ chế độ màu hỗ trợ (để mặc định nếu không chắc)", + "reset_timer": "(Tùy chọn) Hẹn giờ đặt lại trạng thái về tắt", + "swing_mode_dp": "(Tùy chọn) DP quay dọc", + "swing_modes": "(Tùy chọn) Các chế độ quay dọc", + "swing_horizontal_dp": "(Tùy chọn) DP quay ngang", + "swing_horizontal_modes": "(Tùy chọn) Các chế độ quay ngang" + }, + "data_description": { + "hvac_mode_set":"Mỗi dòng là [ hvac_mode: device_value ] [Các chế độ HVAC hỗ trợ](https://developers.home-assistant.io/docs/core/entity/climate/#hvac-modes)", + "hvac_action_set":"Mỗi dòng là [ hvac_action: device_value ] [Các trạng thái HVAC hỗ trợ](https://developers.home-assistant.io/docs/core/entity/climate/#hvac-action)", + "preset_set":"Mỗi dòng là [ device_value: tên thân thiện ]", + "scene_values":"Mỗi dòng là [ device_value: tên thân thiện ]", + "select_options":"Mỗi dòng là [ device_value: tên thân thiện ]", + "swing_modes":"Mỗi dòng là [ device_value: tên thân thiện ]", + "swing_horizontal_modes":"Mỗi dòng là [ device_value: tên thân thiện ]", + "alarm_supported_states":"Mỗi dòng là [ trạng thái hỗ trợ: device value ] [Các trạng thái hỗ trợ](https://developers.home-assistant.io/docs/core/entity/alarm-control-panel/#states)", + "humidifier_available_modes":"Mỗi dòng là [ device_value: tên thân thiện ]", + "fan_speed_list":"Mỗi dòng là [ device_value: tên thân thiện ]", + "device_class": "Tìm hiểu thêm về [Device Classes](https://www.home-assistant.io/integrations/homeassistant/#device-class)", + "state_class": "Tìm hiểu thêm về [State Classes](https://developers.home-assistant.io/docs/core/entity/sensor/#available-state-classes)" + } + } + } + } +} diff --git a/configs/home-assistant/custom_components/localtuya/translations/zh-Hans.json b/configs/home-assistant/custom_components/localtuya/translations/zh-Hans.json new file mode 100644 index 0000000..9311614 --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/translations/zh-Hans.json @@ -0,0 +1,273 @@ +{ + "config": { + "abort": { + "already_configured": "该账户已被配置。", + "device_updated": "设备配置信息已更新。" + }, + "error": { + "authentication_failed": "认证失败:\n{msg}", + "cannot_connect": "无法连接到设备,请确认IP地址是否正确后重试", + "device_list_failed": "无法获取设备列表:\n{msg}", + "invalid_auth": "与设备认证失败。请确认 设备ID(did) 和 Local Key 是否正确。", + "unknown": "发生了未知错误:\n{ex}.", + "entity_already_configured": "该实体已被配置。", + "address_in_use": "TCP端口6668(用于发现设备)已被占用. 请检查并确保没有其他集成正在使用该端口。", + "discovery_failed": "发现设备时发生错误。请查看日志获取详细信息。如问题持续存在,欢迎在GitHub创建新issue一起讨论(请记得调试日志)。", + "empty_dps": "成功连接到设备,但未找到数据点。请重新设置。如果问题持续存在,欢迎在GitHub创建新issue一起讨论(请记得调试日志)。" + }, + "step": { + "user": { + "title": "云服务API账户配置", + "description": "配置用于连接Tuya云服务API的账户信息。", + "data": { + "region": "数据中心所属区域", + "client_id": "Client ID(可在Tuya云服务平台项目概况页面的授权密钥中查看)", + "client_secret": "Client Secret(可在Tuya云服务平台项目概况页面的授权密钥中查看)", + "user_id": "用户ID(在Tuya云服务平台项目设备页面的关联APP账号中的UID)", + "username": "中枢名", + "no_cloud": "是否禁用云服务API?" + } + } + } + }, + "options": { + "abort": { + "already_configured": "该账号已配置。", + "device_success": "设备 {dev_name} 已成功 {action}。", + "no_entities": "无法从设备中删除所有实体。\n如需删除设备,请前往`设置`->`设备与服务`页面,在`设备`标签页中找到该设备,点击 `设备信息` 卡片右下角三点按钮,选择 `删除设备`。" + }, + "error": { + "authentication_failed": "认证失败:\n{msg}", + "cannot_connect": "无法连接到设备。请确认设备IP地址是否正确后重试。", + "device_list_failed": "获取设备列表失败:\n{msg}", + "invalid_auth": "与设备认证失败。请确认设备ID(did)和Local Key是否正确。", + "unknown": "发生了未知错误:\n{ex}.", + "entity_already_configured": "该实体已配置。", + "address_in_use": "TCP端口6668(用于发现设备)已被占用. 请检查并确保没有其他集成正在使用该端口。", + "discovery_failed": "发现设备时发生错误。请查看日志获取详细信息。如问题持续存在,欢迎在GitHub创建新issue一起讨论(请记得调试日志)。", + "empty_dps": "成功连接到设备,但未找到数据点位。请重新设置。如果问题持续存在,欢迎在GitHub创建新issue一起讨论(请记得调试日志)。" + }, + "step": { + "yaml_import": { + "title": "不支持", + "description": "通过`YAML`配置的设备无法在可视化界面中配置。请从`YAML`中删除设备并在可视化界面中重新创建该设备,或修改你的`YAML`配置。" + }, + "init": { + "title": "配置", + "description": "请选择操作方式继续下一步。", + "menu_options": { + "add_device": "添加新设备", + "edit_device": "重新配置已添加设备", + "configure_cloud": "管理云服务API账户" + } + }, + "add_device": { + "title": "选择要配置的设备", + "description": "在Tuya手机应用中配置兼容Tuya的设备后,该设备将自动在本地网络中被发现。如未看到你想要添加的设备,请从下拉菜单中选择`手动添加设备`。", + "data": { + "selected_device": "已发现的设备", + "mass_configure": "自动配置所有已识别设备" + } + }, + "edit_device": { + "title": "重新配置已添加的设备", + "description": "请选择你想要重新配置的设备。", + "data": { + "selected_device": "已添加的设备" + } + }, + "configure_cloud": { + "title": "管理云服务API账户", + "description": "配置用于连接Tuya云服务API的账户信息。", + "data": { + "region": "数据中心所属区域", + "client_id": "Client ID(可在Tuya云服务平台项目概况页面的授权密钥中查看)", + "client_secret": "Client Secret(可在Tuya云服务平台项目概况页面的授权密钥中查看)", + "user_id": "用户ID(在Tuya云服务平台项目设备页面的关联APP账号中的UID)", + "username": "中枢名", + "no_cloud": "是否禁用云服务API?" + } + }, + "confirm": { + "title": "信息确认", + "description": "{message}" + }, + "configure_device": { + "title": "配置设备连接信息", + "description": "请填写缺失的设备信息{for_device}以便LocalTuya连接到设备。", + "data": { + "friendly_name": "设备名称", + "host": "设备IP地址", + "device_id": "设备ID(Device ID)", + "local_key": "Local Key", + "node_id": "(非必填)子设备Node ID", + "protocol_version": "协议版本", + "enable_debug": "启用调试模式(还需在`configuration.yaml`中手动启用)", + "scan_interval": "(非必填)如未自动扫描,扫描间隔时间(秒)", + "entities": "已配置实体(取消勾选可删除实体)", + "add_entities": "添加新实体", + "manual_dps_strings": "(非必填)手动指定未自动检测的数据点位(用英文逗号分隔)", + "reset_dpids": "(非必填)发送 RESET 命令时使用的数据点位ID(用英文逗号分隔)", + "device_sleep_time": "(非必填) 设备休眠时间(秒):设备报告状态后将休眠", + "export_config": "将实体配置保存为模板" + } + }, + "device_setup_method": { + "title": "配置设备实体", + "description": "LocalTuya将尝试自动发现其余配置内容。如果此方法无法适用于你的设备,或你希望自行调整设置,请选择`手动配置设备实体`选项。", + "menu_options": { + "auto_configure_device":"自动发现设备实体", + "pick_entity_type": "手动配置设备实体", + "choose_template":"使用已保存的模板" + } + }, + "auto_configure_device": { + "title": "自动配置", + "description": "发生错误:{err_msg}。如果未显示具体原因,请检查系统日志。", + "menu_options": { + "device_setup_method":"回到配置设备实体界面" + } + }, + "pick_entity_type": { + "title": "选择实体类型", + "description": "请选择你想要添加的实体类型。", + "data": { + "platform_to_add": "选择实体", + "no_additional_entities": "完成实体配置", + "use_template" : "导入模板文件" + } + }, + "choose_template":{ + "title": "导入模板文件", + "description": "模板文件位于`templates`目录下([更多信息](https://github.com/xZetsubou/hass-localtuya/discussions/13))。", + "data": { + "templates": "选择模板" + } + }, + "configure_entity": { + "title": "配置实体", + "description": "请填写 {entity}(类型为 {platform})的相关信息。所有设置(除了`类型`和`数据点位ID`)均可在之后的`配置`页面中修改。", + "data": { + "id": "数据点位ID(DPID)", + "friendly_name": "实体显示名称", + "current": "电流", + "current_consumption": "当前功耗", + "voltage": "电压", + "commands_set": "开_关_停 命令集(用英文逗号分隔)", + "positioning_mode": "定位模式", + "current_position_dp": "当前位置(仅适用于*position*模式)", + "set_position_dp": "设定位置(仅适用于*position*模式)", + "stop_switch_dp": "(可选项)停止开关(如设备有持续指令)", + "position_inverted": "反转0-100位置(仅适用于*position*模式)", + "span_time": "完全打开所需时间(秒,仅用于*timed*模式)", + "unit_of_measurement": "(非必填)计量单位", + "device_class": "(非必填)设备类型", + "state_class": "(非必填)状态分类", + "scaling": "(非必填)缩放系数", + "state_on": "打开时的状态值(用英文逗号分隔)", + "state_off": "关闭时的状态值", + "powergo_dp": "电源数据点位(通常为25或2)", + "idle_status_value": "空闲状态值(用英文逗号分隔)", + "returning_status_value": "返回状态值(用英文逗号分隔)", + "docked_status_value": "基站对接状态值(用英文逗号分隔)", + "fault_dp": "故障状态数据点位(通常为11)", + "battery_dp": "电池状态数据点位(通常为14)", + "mode_dp": "模式数据点位", + "modes": "模式列表(用英文逗号分隔)", + "return_mode": "返回基座模式", + "fan_speed_dp": "(非必填)风速数据点位", + "fan_speeds": "风速列表(用英文逗号分隔)", + "clean_time_dp": "清洁时间数据点位(通常为33)", + "clean_area_dp": "清洁面积数据点位(通常为32)", + "clean_record_dp": "清洁记录数据点位(通常为34)", + "locate_dp": "定位数据点位(通常为31)", + "pause_dp":"暂停数据点位", + "paused_state": "暂停状态(如pause、paused等)", + "stop_status": "停止状态", + "brightness": "亮度(仅适用于白光)", + "brightness_lower": "亮度下限值", + "brightness_upper": "亮度上限值", + "color_temp": "色温", + "color_temp_reverse": "是否反转色温?", + "color": "颜色", + "color_mode": "色彩模式(或工作模式)", + "color_temp_min_kelvin": "最小色温(开尔文)", + "color_temp_max_kelvin": "最大色温(开尔文)", + "music_mode": "是否支持音乐模式?", + "scene": "场景", + "scene_values": "(非必填)场景值(用英文逗号分隔)", + "select_options": "选项值列表(用英文逗号分隔)", + "fan_speed_control": "风速控制数据点位", + "fan_oscillating_control": "风扇摇头控制数据点位", + "fan_speed_min": "最小风速(整数)", + "fan_speed_max": "最大风速(整数)", + "fan_speed_ordered_list": "风速列表(包含最小和最大值,使用英文逗号分隔)", + "fan_direction":"风向数据点位", + "fan_direction_forward": "正向出风时风向值", + "fan_direction_reverse": "反向出风时风向值", + "fan_dps_type": "该数据点位的数据类型", + "current_temperature_dp": "当前温度", + "target_temperature_dp": "目标温度", + "temperature_step": "(非必填)温度调控间隔", + "min_temperature": "最小温度", + "max_temperature": "最大温度", + "precision": "精度(非必填,应用于数据点位的值)", + "target_precision": "目标精度(非必填,应用于数据点位的值)", + "temperature_unit": "(非必填)温度单位", + "hvac_mode_dp": "(非必填)HVAC 模式数据点位", + "hvac_mode_set": "(非必填)HVAC 模式列表(用英文逗号分隔)", + "hvac_add_off": "(非必填)是否包含 `关闭` 模式", + "hvac_action_dp": "(非必填)当前HVAC动作数据点位", + "hvac_action_set": "(非必填)HVAC动作列表(用英文逗号分隔)", + "preset_dp": "(非必填)预设数据点位", + "preset_set": "(非必填)预设值列表(用英文逗号分隔)", + "fan_speed_list": "(非必填)支持的风速值", + "eco_dp": "(非必填)节能数据点位", + "eco_value": "(非必填)节能值", + "heuristic_action": "(非必填)启用启发式动作", + "dps_default_value": "(非必填)未初始化时的默认值", + "restore_on_reconnect": "是否在重连后恢复Home Assistant中的上次设置值?", + "min_value": "最小值", + "max_value": "最大值", + "step_size": "最小间隔值", + "is_passive_entity": "是否属于被动实体?(需集成发送初始化值)", + "entity_category": "将实体显示在此类别下", + "humidifier_available_modes": "(非必填)设备支持的模式", + "humidifier_current_humidity_dp": "(非必填)当前湿度数据点位", + "humidifier_mode_dp": "(非必填)模式设置数据点位", + "humidifier_set_humidity_dp": "(非必填)湿度设置数据点位", + "min_humidity": "可支持的最低湿度", + "max_humidity": "可支持的最高湿度", + "alarm_supported_states": "设备支持的警报状态(用英文逗号分隔)", + "receive_dp":"接收信号数据点位(默认为202)", + "key_study_dp":"(Optional) Key Study DP (usually 7)", + "lock_state_dp":"(非必填)锁定状态数据点位", + "jammed_dp":"(非必填)卡顿数据点位", + "target_temperature_high_dp":"(非必填)目标高温数据点位", + "target_temperature_low_dp":"(非必填)目标低温数据点位", + "color_mode_set":"支持的模式集合(用英文逗号分隔,如不确定请保留默认)", + "reset_timer": "(非必填)自动重置为关闭状态的定时器", + "swing_mode_dp": "(非必填)垂直摆风数据点位", + "swing_modes": "(非必填)垂直摆风选项(用英文逗号分隔)", + "swing_horizontal_dp": "(非必填)水平摆风数据点位", + "swing_horizontal_modes": "(非必填)水平摆风选项(用英文逗号分隔)" + }, + "data_description": { + "hvac_mode_set": "每一行格式为 [ HVAC模式: 设备值 ] [HVAC模式参考](https://developers.home-assistant.io/docs/core/entity/climate/#hvac-modes)", + "hvac_action_set": "每一行格式为 [ HVAC动作: 设备值 ] [HVAC动作参考](https://developers.home-assistant.io/docs/core/entity/climate/#hvac-action)", + "preset_set": "每一行格式为 [ 设备值: 显示名称 ]", + "scene_values": "每一行格式为 [ 设备值: 显示名称 ]", + "select_options": "每一行格式为 [ 设备值: 显示名称 ]", + "swing_modes": "每一行格式为 [ 设备值: 显示名称 ]", + "swing_horizontal_modes": "每一行格式为 [ 设备值: 显示名称 ]", + "alarm_supported_states": "每一行格式为 [ 支持的状态: 设备值 ] [参考状态](https://developers.home-assistant.io/docs/core/entity/alarm-control-panel/#states)", + "humidifier_available_modes": "每一行格式为 [ 设备值: 显示名称 ]", + "fan_speed_list": "每一行格式为 [ 设备值: 显示名称 ]", + "device_class": "查看更多 [设备类型](https://www.home-assistant.io/integrations/homeassistant/#device-class)", + "state_class": "查看更多 [状态分类](https://developers.home-assistant.io/docs/core/entity/sensor/#available-state-classes)" + } + } + } + }, + "title": "LocalTuya" +} diff --git a/configs/home-assistant/custom_components/localtuya/vacuum.py b/configs/home-assistant/custom_components/localtuya/vacuum.py new file mode 100644 index 0000000..ef59bb3 --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/vacuum.py @@ -0,0 +1,256 @@ +"""Platform to locally control Tuya-based vacuum devices.""" + +import logging +from functools import partial +from .config_flow import col_to_select + +import voluptuous as vol +from homeassistant.components.vacuum import ( + DOMAIN, + StateVacuumEntity, + VacuumActivity, + VacuumEntityFeature, +) + +from .entity import LocalTuyaEntity, async_setup_entry +from .const import ( + 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, + CONF_PAUSE_DP, +) + +_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,to_charge,goto_charge" +DEFAULT_DOCKED_STATUS = "charging,chargecompleted,charge_done,charging_dock" +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_POWERGO_DP): col_to_select(dps, is_dps=True), + vol.Required(CONF_IDLE_STATUS_VALUE, default=DEFAULT_IDLE_STATUS): str, + 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_PAUSED_STATE, default=DEFAULT_PAUSED_STATE): str, + vol.Optional(CONF_STOP_STATUS, default=DEFAULT_STOP_STATUS): str, + vol.Optional(CONF_PAUSE_DP): col_to_select(dps, is_dps=True), + vol.Optional(CONF_MODE_DP): col_to_select(dps, is_dps=True), + vol.Optional(CONF_MODES, default=DEFAULT_MODES): str, + vol.Optional(CONF_RETURN_MODE, default=DEFAULT_RETURN_MODE): str, + vol.Optional(CONF_FAN_SPEED_DP): col_to_select(dps, is_dps=True), + vol.Optional(CONF_FAN_SPEEDS, default=DEFAULT_FAN_SPEEDS): str, + vol.Optional(CONF_CLEAN_TIME_DP): col_to_select(dps, is_dps=True), + vol.Optional(CONF_CLEAN_AREA_DP): col_to_select(dps, is_dps=True), + vol.Optional(CONF_CLEAN_RECORD_DP): col_to_select(dps, is_dps=True), + vol.Optional(CONF_LOCATE_DP): col_to_select(dps, is_dps=True), + vol.Optional(CONF_FAULT_DP): col_to_select(dps, is_dps=True), + } + + +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._attrs = {} + + self._idle_status_list = [] + if self.has_config(CONF_IDLE_STATUS_VALUE): + status = self._config[CONF_IDLE_STATUS_VALUE].split(",") + self._idle_status_list = [state.lstrip() for state in status] + + self._modes_list = [] + if self.has_config(CONF_MODES): + modes_list = self._config[CONF_MODES].split(",") + self._modes_list = [mode.lstrip() for mode in modes_list] + self._attrs[MODES_LIST] = self._modes_list + + self._returning_status_list = [] + if self.has_config(CONF_RETURNING_STATUS_VALUE): + returning_status = self._config[CONF_RETURNING_STATUS_VALUE].split(",") + self._returning_status_list = [state.lstrip() for state in returning_status] + + self._docked_status_list = [] + if self.has_config(CONF_DOCKED_STATUS_VALUE): + docked_status = self._config[CONF_DOCKED_STATUS_VALUE].split(",") + self._docked_status_list = [state.lstrip() for state in docked_status] + + self._fan_speed_list = [] + if self.has_config(CONF_FAN_SPEEDS): + fan_speeds = self._config[CONF_FAN_SPEEDS].split(",") + self._fan_speed_list = [speed.lstrip() for speed in fan_speeds] + + self._fan_speed = "" + self._cleaning_mode = "" + + @property + def supported_features(self) -> VacuumEntityFeature: + """Flag supported features.""" + supported_features = ( + VacuumEntityFeature.START + | VacuumEntityFeature.PAUSE + | VacuumEntityFeature.STOP + | VacuumEntityFeature.STATUS + | VacuumEntityFeature.STATE + ) + + if ( + self.has_config(CONF_RETURN_MODE) + and self._config[CONF_RETURN_MODE] in self._modes_list + ): + supported_features |= VacuumEntityFeature.RETURN_HOME + if self.has_config(CONF_FAN_SPEED_DP): + supported_features |= VacuumEntityFeature.FAN_SPEED + if self.has_config(CONF_LOCATE_DP): + supported_features |= VacuumEntityFeature.LOCATE + + return supported_features + + @property + def activity(self) -> VacuumActivity | None: + """Return the vacuum state.""" + return self._state + + @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_stop(self, **kwargs): + """Turn the vacuum off stopping the cleaning.""" + if ( + self.has_config(CONF_STOP_STATUS) + and self._config[CONF_STOP_STATUS] in self._modes_list + ): + await self._device.set_dp( + self._config[CONF_STOP_STATUS], self._config[CONF_MODE_DP] + ) + else: + await self._device.set_dp(False, self._config[CONF_POWERGO_DP]) + # _LOGGER.error("Missing command for stop in commands set.") + + async def async_pause(self, **kwargs): + """Stop the vacuum cleaner, do not return to base.""" + if self.has_config(CONF_PAUSE_DP): + return await self._device.set_dp(True, self._config[CONF_PAUSE_DP]) + + await self.async_stop() + + 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_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(True, 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 = self.dp_value(self._dp_id) + + if state_value is None: + self._state = None + elif 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 in self._returning_status_list: + self._state = VacuumActivity.RETURNING + elif state_value in [self._config[CONF_PAUSED_STATE], "pause"] or ( + self.dp_value(CONF_PAUSE_DP) is True + ): + self._state = VacuumActivity.PAUSED + else: + self._state = VacuumActivity.CLEANING + + self._cleaning_mode = "" + if self.has_config(CONF_MODES): + self._cleaning_mode = self.dp_value(CONF_MODE_DP) + self._attrs[MODE] = self._cleaning_mode + + self._fan_speed = "" + if self.has_config(CONF_FAN_SPEEDS): + self._fan_speed = self.dp_value(CONF_FAN_SPEED_DP) + + if self.has_config(CONF_CLEAN_TIME_DP): + self._attrs[CLEAN_TIME] = self.dp_value(CONF_CLEAN_TIME_DP) + + if self.has_config(CONF_CLEAN_AREA_DP): + self._attrs[CLEAN_AREA] = self.dp_value(CONF_CLEAN_AREA_DP) + + if self.has_config(CONF_CLEAN_RECORD_DP): + self._attrs[CLEAN_RECORD] = self.dp_value(CONF_CLEAN_RECORD_DP) + + if self.has_config(CONF_FAULT_DP): + self._attrs[FAULT] = self.dp_value(CONF_FAULT_DP) + if self._attrs[FAULT] != 0: + self._state = VacuumActivity.ERROR + + +async_setup_entry = partial(async_setup_entry, DOMAIN, LocalTuyaVacuum, flow_schema) diff --git a/configs/home-assistant/custom_components/localtuya/water_heater.py b/configs/home-assistant/custom_components/localtuya/water_heater.py new file mode 100644 index 0000000..6450ccf --- /dev/null +++ b/configs/home-assistant/custom_components/localtuya/water_heater.py @@ -0,0 +1,240 @@ +"""Platform to locally control Tuya-based WaterHeater devices.""" + +import logging +from functools import partial +from .config_flow import col_to_select +from homeassistant.helpers.selector import ObjectSelector + +import voluptuous as vol +from homeassistant.components.water_heater import ( + DEFAULT_MIN_TEMP, + DEFAULT_MAX_TEMP, + DOMAIN, + WaterHeaterEntity, + WaterHeaterEntityFeature, +) +from homeassistant.components.water_heater.const import ( + STATE_ECO, + STATE_ELECTRIC, + STATE_PERFORMANCE, + STATE_HIGH_DEMAND, + STATE_HEAT_PUMP, + STATE_GAS, +) +from homeassistant.const import ( + ATTR_TEMPERATURE, + CONF_TEMPERATURE_UNIT, + PRECISION_HALVES, + PRECISION_TENTHS, + PRECISION_WHOLE, + UnitOfTemperature, +) +from .entity import LocalTuyaEntity, async_setup_entry +from .const import ( + CONF_TARGET_TEMPERATURE_DP, + CONF_CURRENT_TEMPERATURE_DP, + CONF_MIN_TEMP, + CONF_MAX_TEMP, + CONF_PRECISION, + CONF_TARGET_PRECISION, + CONF_MODE_DP, + CONF_MODES, + CONF_TARGET_TEMPERATURE_LOW_DP, + CONF_TARGET_TEMPERATURE_HIGH_DP, + DictSelector, +) + +_LOGGER = logging.getLogger(__name__) + + +TEMPERATURE_CELSIUS = "celsius" +TEMPERATURE_FAHRENHEIT = "fahrenheit" + +DEFAULT_TEMPERATURE_UNIT = TEMPERATURE_CELSIUS +DEFAULT_PRECISION = PRECISION_TENTHS +DEFAULT_TEMPERATURE_STEP = PRECISION_HALVES +PERCISION_SET = [PRECISION_WHOLE, PRECISION_HALVES, PRECISION_TENTHS] + +OFF_MODE = "Off" + + +def flow_schema(dps): + """Return schema used in config flow.""" + return { + vol.Optional(CONF_TARGET_TEMPERATURE_DP): col_to_select(dps, is_dps=True), + vol.Optional(CONF_TARGET_TEMPERATURE_LOW_DP): col_to_select(dps, is_dps=True), + vol.Optional(CONF_TARGET_TEMPERATURE_HIGH_DP): col_to_select(dps, is_dps=True), + vol.Optional(CONF_CURRENT_TEMPERATURE_DP): col_to_select(dps, is_dps=True), + vol.Optional(CONF_MIN_TEMP, default=DEFAULT_MIN_TEMP): vol.Coerce(float), + vol.Optional(CONF_MAX_TEMP, default=DEFAULT_MAX_TEMP): vol.Coerce(float), + vol.Optional(CONF_PRECISION, default=str(DEFAULT_PRECISION)): col_to_select( + PERCISION_SET + ), + vol.Optional( + CONF_TARGET_PRECISION, default=str(DEFAULT_PRECISION) + ): col_to_select(PERCISION_SET), + vol.Optional(CONF_MODE_DP): col_to_select(dps, is_dps=True), + vol.Optional(CONF_MODES, default={}): ObjectSelector(), + vol.Optional(CONF_TEMPERATURE_UNIT): col_to_select( + [TEMPERATURE_CELSIUS, TEMPERATURE_FAHRENHEIT] + ), + } + + +def config_unit(unit): + if unit == TEMPERATURE_FAHRENHEIT: + return UnitOfTemperature.FAHRENHEIT + else: + return UnitOfTemperature.CELSIUS + + +class LocalTuyaWaterHeater(LocalTuyaEntity, WaterHeaterEntity): + """Tuya WaterHeater device.""" + + _enable_turn_on_off_backwards_compatibility = False + _attr_current_operation = False + + def __init__( + self, + device, + config_entry, + switchid, + **kwargs, + ): + """Initialize a new LocalTuyaWaterHeater.""" + super().__init__(device, config_entry, switchid, _LOGGER, **kwargs) + self._state = None + self._target_temperature = None + self._current_temperature = None + self._dp_mode = self._config.get(CONF_MODE_DP, None) + + self._available_modes = DictSelector(self._config.get(CONF_MODES, {})) + + self._precision = float(self._config.get(CONF_PRECISION, DEFAULT_PRECISION)) + self._precision_target = float( + self._config.get(CONF_TARGET_PRECISION, DEFAULT_PRECISION) + ) + + @property + def supported_features(self): + """Flag supported features.""" + supported_features = WaterHeaterEntityFeature(0) + if self.has_config(CONF_TARGET_TEMPERATURE_DP): + supported_features |= WaterHeaterEntityFeature.TARGET_TEMPERATURE + if self.has_config(CONF_MODE_DP): + supported_features |= WaterHeaterEntityFeature.OPERATION_MODE + + supported_features |= WaterHeaterEntityFeature.ON_OFF + + return supported_features + + @property + def precision(self): + """Return the precision of the system.""" + return self._precision + + @property + def temperature_unit(self): + """Return the unit of measurement used by the platform.""" + return config_unit(self._config.get(CONF_TEMPERATURE_UNIT)) + + @property + def min_temp(self): + """Return the minimum temperature.""" + return self._config.get(CONF_MIN_TEMP, DEFAULT_MIN_TEMP) + + @property + def max_temp(self): + """Return the maximum temperature.""" + return self._config.get(CONF_MAX_TEMP, DEFAULT_MAX_TEMP) + + @property + def operation_list(self) -> list[str] | None: + """Return the list of available operation modes.""" + return self._available_modes.names + [OFF_MODE] + + @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_high(self) -> float | None: + """Return the highbound target temperature we try to reach.""" + return self._attr_target_temperature_high + + @property + def target_temperature_low(self) -> float | None: + """Return the lowbound target temperature we try to reach.""" + return self._attr_target_temperature_low + + 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 = kwargs[ATTR_TEMPERATURE] + + temperature = round(temperature / self._precision_target) + await self._device.set_dp( + temperature, self._config[CONF_TARGET_TEMPERATURE_DP] + ) + + async def async_set_operation_mode(self, operation_mode: str) -> None: + """Set new target operation mode.""" + status = {} + if operation_mode == OFF_MODE: + return await self.async_turn_off() + elif not self._state: + status[self._dp_id] = True + + status[self._dp_mode] = self._available_modes.to_tuya(operation_mode) + await self._device.set_dps(status) + + 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) + + def status_updated(self): + """Device status was updated.""" + self._state = self.dp_value(self._dp_id) + + # Update target temperature + if self.has_config(CONF_TARGET_TEMPERATURE_DP): + self._target_temperature = ( + self.dp_value(CONF_TARGET_TEMPERATURE_DP) * self._precision_target + ) + + # Update current temperature + if self.has_config(CONF_CURRENT_TEMPERATURE_DP): + self._current_temperature = ( + self.dp_value(CONF_CURRENT_TEMPERATURE_DP) * self._precision + ) + + # Update modes states + if not self._state: + self._attr_current_operation = OFF_MODE + elif self._dp_mode is not None and (mode := self.dp_value(CONF_MODE_DP)): + self._attr_current_operation = self._available_modes.to_ha(mode) + + if ( + target_high := self.dp_value(CONF_TARGET_TEMPERATURE_HIGH_DP) + ) or target_high is not None: + self._attr_target_temperature_high = target_high + + if ( + target_low := self.dp_value(CONF_TARGET_TEMPERATURE_LOW_DP) + ) or target_low is not None: + self._attr_target_temperature_low = target_low + + +async_setup_entry = partial( + async_setup_entry, DOMAIN, LocalTuyaWaterHeater, flow_schema +) diff --git a/configs/home-assistant/ui-lovelace.yaml b/configs/home-assistant/ui-lovelace.yaml index c7ae319..9d9d158 100644 --- a/configs/home-assistant/ui-lovelace.yaml +++ b/configs/home-assistant/ui-lovelace.yaml @@ -5,9 +5,7 @@ views: type: masonry cards: - type: custom:button-card - entity: switch.luz_sala - triggers_update: - - sensor.luz_sala_remota + entity: light.luzsala icon: mdi:lightbulb name: Luz Sala show_icon: true @@ -15,19 +13,15 @@ views: show_state: false show_label: false tap_action: - action: call-service - service: switch.toggle - service_data: - entity_id: switch.luz_sala + action: toggle hold_action: - action: none + action: more-info custom_fields: status: > [[[ - const raw = states['sensor.luz_sala_remota']?.state; - if (raw === 'on') return 'Ligada'; - if (raw === 'off') return 'Desligada'; - return raw || 'Indisponível'; + if (entity.state === 'on') return 'Ligada'; + if (entity.state === 'off') return 'Desligada'; + return entity.state || 'Indisponível'; ]]] styles: card: @@ -35,8 +29,7 @@ views: - border-radius: 22px - background: | [[[ - const s = states['sensor.luz_sala_remota']?.state; - return s === 'on' + return entity.state === 'on' ? 'linear-gradient(135deg, rgba(255,215,0,0.22), rgba(255,184,108,0.10))' : 'var(--ha-card-background, var(--card-background-color, #1c1c1c))'; ]]] @@ -57,8 +50,7 @@ views: icon: - color: | [[[ - const s = states['sensor.luz_sala_remota']?.state; - return s === 'on' ? '#ffd700' : 'rgba(255,255,255,0.4)'; + return entity.state === 'on' ? '#ffd700' : 'rgba(255,255,255,0.4)'; ]]] - width: 24px - height: 24px @@ -73,8 +65,7 @@ views: - justify-self: start - color: | [[[ - const s = states['sensor.luz_sala_remota']?.state; - return s === 'on' ? '#ffd700' : 'rgba(248,250,252,0.82)'; + return entity.state === 'on' ? '#ffd700' : 'rgba(248,250,252,0.82)'; ]]] - font-size: 15px - font-weight: 600 diff --git a/configs/home-assistant/ui-overview.yaml b/configs/home-assistant/ui-overview.yaml index b43d9c0..4873fae 100644 --- a/configs/home-assistant/ui-overview.yaml +++ b/configs/home-assistant/ui-overview.yaml @@ -5,9 +5,7 @@ views: type: masonry cards: - type: custom:button-card - entity: switch.luz_sala - triggers_update: - - sensor.luz_sala_remota + entity: light.luzsala icon: mdi:lightbulb name: Luz Sala show_icon: true @@ -15,19 +13,15 @@ views: show_state: false show_label: false tap_action: - action: call-service - service: switch.toggle - service_data: - entity_id: switch.luz_sala + action: toggle hold_action: - action: none + action: more-info custom_fields: status: > [[[ - const raw = states['sensor.luz_sala_remota']?.state; - if (raw === 'on') return 'Ligada'; - if (raw === 'off') return 'Desligada'; - return raw || 'Indisponível'; + if (entity.state === 'on') return 'Ligada'; + if (entity.state === 'off') return 'Desligada'; + return entity.state || 'Indisponível'; ]]] styles: card: @@ -35,8 +29,7 @@ views: - border-radius: 22px - background: | [[[ - const s = states['sensor.luz_sala_remota']?.state; - return s === 'on' + return entity.state === 'on' ? 'linear-gradient(135deg, rgba(255,215,0,0.22), rgba(255,184,108,0.10))' : 'var(--ha-card-background, var(--card-background-color, #1c1c1c))'; ]]] @@ -57,8 +50,7 @@ views: icon: - color: | [[[ - const s = states['sensor.luz_sala_remota']?.state; - return s === 'on' ? '#ffd700' : 'rgba(255,255,255,0.4)'; + return entity.state === 'on' ? '#ffd700' : 'rgba(255,255,255,0.4)'; ]]] - width: 24px - height: 24px @@ -73,8 +65,7 @@ views: - justify-self: start - color: | [[[ - const s = states['sensor.luz_sala_remota']?.state; - return s === 'on' ? '#ffd700' : 'rgba(248,250,252,0.82)'; + return entity.state === 'on' ? '#ffd700' : 'rgba(248,250,252,0.82)'; ]]] - font-size: 15px - font-weight: 600 diff --git a/hosts/Nixos/configuration.nix b/hosts/Nixos/configuration.nix index 22750ad..2859f11 100644 --- a/hosts/Nixos/configuration.nix +++ b/hosts/Nixos/configuration.nix @@ -9,8 +9,6 @@ let repoDir = "${homeDir}/Projetos/Sistemas/nixos-config"; codexStatusPath = "/var/lib/hass/codex_status.json"; claudeUsagePath = "/var/lib/hass/claude_usage.json"; - luzSalaBridgeUrl = "http://192.168.1.105:8766"; - luzSalaBridgeSecret = "a3c41709c9da4ea77059eb734570726950b9a70cb7af6a3bf0ece94d54d0c8c0"; haAirConditioner = "climate.ar"; haLaundryDryingToggle = "input_boolean.secar_roupas"; haClimateAction = action: data: { @@ -650,56 +648,6 @@ in { } ]; } - { - switch = [ - { - name = "Luz Sala"; - unique_id = "luz_sala_remota_switch"; - state = "{{ states('sensor.luz_sala_remota') == 'on' }}"; - turn_on.action = "rest_command.luz_sala_turn_on"; - turn_off.action = "rest_command.luz_sala_turn_off"; - icon = "mdi:lightbulb"; - } - ]; - } - ]; - - rest_command = { - luz_sala_turn_on = { - url = "${luzSalaBridgeUrl}/turn_on"; - method = "post"; - headers = { X-Secret = luzSalaBridgeSecret; }; - content_type = "application/json"; - payload = "{}"; - }; - luz_sala_turn_off = { - url = "${luzSalaBridgeUrl}/turn_off"; - method = "post"; - headers = { X-Secret = luzSalaBridgeSecret; }; - content_type = "application/json"; - payload = "{}"; - }; - luz_sala_toggle = { - url = "${luzSalaBridgeUrl}/toggle"; - method = "post"; - headers = { X-Secret = luzSalaBridgeSecret; }; - content_type = "application/json"; - payload = "{}"; - }; - }; - - rest = [ - { - resource = "${luzSalaBridgeUrl}/state"; - headers = { X-Secret = luzSalaBridgeSecret; }; - scan_interval = 10; - sensor = [{ - name = "Luz Sala Remota"; - unique_id = "luz_sala_remota"; - value_template = "{{ value_json.state }}"; - icon = "mdi:lightbulb"; - }]; - } ]; lovelace = { @@ -1333,6 +1281,10 @@ in { }; }; + systemd.tmpfiles.rules = [ + "L+ /var/lib/hass/custom_components/localtuya - hass hass - ${../../configs/home-assistant/custom_components/localtuya}" + ]; + systemd.timers."nix-flake-update" = { wantedBy = [ "timers.target" ]; timerConfig = {