diff --git a/custom_components/localtuya/__init__.py b/custom_components/localtuya/__init__.py index ad879b0..e08980a 100644 --- a/custom_components/localtuya/__init__.py +++ b/custom_components/localtuya/__init__.py @@ -1,83 +1,18 @@ -"""The LocalTuya integration integration. - -Sample YAML config with all supported entity types (default values -are pre-filled for optional fields): - -localtuya: - - host: 192.168.1.x - device_id: xxxxx - local_key: xxxxx - friendly_name: Tuya Device - protocol_version: "3.3" - entities: - - platform: binary_sensor - friendly_name: Plug Status - id: 1 - device_class: power - state_on: "true" # Optional - state_off: "false" # Optional - - - platform: cover - friendly_name: Device Cover - id: 2 - commands_set: # Optional, default: "on_off_stop" - ["on_off_stop","open_close_stop","fz_zz_stop","1_2_3"] - positioning_mode: ["none","position","timed"] # Optional, default: "none" - currpos_dp: 3 # Optional, required only for "position" mode - setpos_dp: 4 # Optional, required only for "position" mode - position_inverted: [True,False] # Optional, default: False - span_time: 25 # Full movement time: Optional, required only for "timed" mode - - - platform: fan - friendly_name: Device Fan - id: 3 - - - platform: light - friendly_name: Device Light - id: 4 - brightness: 20 - brightness_lower: 29 # Optional - brightness_upper: 1000 # Optional - color_temp: 21 - - - platform: sensor - friendly_name: Plug Voltage - id: 20 - scaling: 0.1 # Optional - device_class: voltage # Optional - unit_of_measurement: "V" # Optional - - - platform: switch - friendly_name: Plug - id: 1 - current: 18 # Optional - current_consumption: 19 # Optional - voltage: 20 # Optional - - - platform: vacuum - friendly_name: Vacuum - id: 28 - idle_status_value: "standby,sleep" - returning_status_value: "docking" - docked_status_value: "charging,chargecompleted" - battery_dp: 14 - mode_dp: 27 - modes: "smart,standby,chargego,wall_follow,spiral,single" - fan_speed_dp: 30 - fan_speeds: "low,normal,high" - clean_time_dp: 33 - clean_area_dp: 32 -""" +"""The LocalTuya integration. """ import asyncio import logging +import time from datetime import timedelta import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import DeviceEntry import homeassistant.helpers.entity_registry as er import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry + +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_DEVICE_ID, + CONF_DEVICES, CONF_ENTITIES, CONF_HOST, CONF_ID, @@ -85,14 +20,22 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, SERVICE_RELOAD, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.reload import async_integration_yaml_config +from .cloud_api import TuyaCloudApi from .common import TuyaDevice, async_config_entry_by_device_id from .config_flow import config_schema -from .const import CONF_PRODUCT_KEY, DATA_DISCOVERY, DOMAIN, TUYA_DEVICE +from .const import ( + ATTR_UPDATED_AT, + CONF_PRODUCT_KEY, + DATA_CLOUD, + DATA_DISCOVERY, + DOMAIN, + TUYA_DEVICE +) from .discovery import TuyaDiscovery _LOGGER = logging.getLogger(__name__) @@ -116,16 +59,6 @@ SERVICE_SET_DP_SCHEMA = vol.Schema( ) -@callback -def _async_update_config_entry_if_from_yaml(hass, entries_by_id, conf): - """Update a config entry with the latest yaml.""" - device_id = conf[CONF_DEVICE_ID] - - if device_id in entries_by_id and entries_by_id[device_id].source == SOURCE_IMPORT: - entry = entries_by_id[device_id] - hass.config_entries.async_update_entry(entry, data=conf.copy()) - - async def async_setup(hass: HomeAssistant, config: dict): """Set up the LocalTuya integration component.""" hass.data.setdefault(DOMAIN, {}) @@ -140,10 +73,7 @@ async def async_setup(hass: HomeAssistant, config: dict): return current_entries = hass.config_entries.async_entries(DOMAIN) - entries_by_id = {entry.data[CONF_DEVICE_ID]: entry for entry in current_entries} - - for conf in config[DOMAIN]: - _async_update_config_entry_if_from_yaml(hass, entries_by_id, conf) + # entries_by_id = {entry.data[CONF_DEVICE_ID]: entry for entry in current_entries} reload_tasks = [ hass.config_entries.async_reload(entry.entry_id) @@ -211,6 +141,18 @@ async def async_setup(hass: HomeAssistant, config: dict): device = hass.data[DOMAIN][entry.entry_id][TUYA_DEVICE] device.async_connect() + client_id = 'xx' + secret = 'xx' + uid = 'xx' + tuya_api = TuyaCloudApi(hass, "eu", client_id, secret, uid) + res = await tuya_api.async_get_access_token() + _LOGGER.debug("ACCESS TOKEN RES: %s", res) + res = await tuya_api.async_get_devices_list() + for dev_id, dev in tuya_api._device_list.items(): + print(f"Name: {dev['name']} \t dev_id {dev['id']} \t key {dev['local_key']} ") + hass.data[DOMAIN][DATA_CLOUD] = tuya_api + + _LOGGER.debug("\n\nSTARTING DISCOVERY") discovery = TuyaDiscovery(_device_discovered) def _shutdown(event): @@ -246,13 +188,13 @@ async def async_setup(hass: HomeAssistant, config: dict): DOMAIN, SERVICE_SET_DP, _handle_set_dp, schema=SERVICE_SET_DP_SCHEMA ) - for host_config in config.get(DOMAIN, []): - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=host_config - ) - ) + return True + +async def async_migrate_entry(domain): + """Migrate old entry.""" + print("WHYYYYYYY") + _LOGGER.error("WHHHYYYYYY") return True @@ -260,15 +202,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): """Set up LocalTuya integration from a config entry.""" unsub_listener = entry.add_update_listener(update_listener) - device = TuyaDevice(hass, entry.data) + # device = TuyaDevice(hass, entry.data) + # + # hass.data[DOMAIN][entry.entry_id] = { + # UNSUB_LISTENER: unsub_listener, + # TUYA_DEVICE: device, + # } + # + async def setup_entities(dev_id): + dev_entry = entry.data[CONF_DEVICES][dev_id] + device = TuyaDevice(hass, dev_entry) + hass.data[DOMAIN][dev_id] = { + UNSUB_LISTENER: unsub_listener, + TUYA_DEVICE: device, + } - hass.data[DOMAIN][entry.entry_id] = { - UNSUB_LISTENER: unsub_listener, - TUYA_DEVICE: device, - } - - async def setup_entities(): - platforms = set(entity[CONF_PLATFORM] for entity in entry.data[CONF_ENTITIES]) + platforms = set(entity[CONF_PLATFORM] for entity in dev_entry[CONF_ENTITIES]) + print("DEV {} platforms: {}".format(dev_id, platforms)) await asyncio.gather( *[ hass.config_entries.async_forward_entry_setup(entry, platform) @@ -277,9 +227,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): ) device.async_connect() - await async_remove_orphan_entities(hass, entry) + await async_remove_orphan_entities(hass, entry) - hass.async_create_task(setup_entities()) + for dev_id in entry.data[CONF_DEVICES]: + hass.async_create_task(setup_entities(dev_id)) return True @@ -310,13 +261,60 @@ async def update_listener(hass, config_entry): await hass.config_entries.async_reload(config_entry.entry_id) +async def async_remove_config_entry_device( + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry +) -> bool: + """Remove a config entry from a device.""" + print("REMOVING {} FROM {}".format(device_entry.identifiers, config_entry.data)) + dev_id = list(device_entry.identifiers)[0][1].split("_")[-1] + _LOGGER.debug("Removing %s", dev_id) + + if dev_id not in config_entry.data[CONF_DEVICES]: + _LOGGER.debug( + "Device ID %s not found in config entry: finalizing device removal", + dev_id + ) + return True + + _LOGGER.debug("Closing device connection for %s", dev_id) + await hass.data[DOMAIN][dev_id][TUYA_DEVICE].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.debug("Config entry updated") + + ent_reg = await er.async_get_registry(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) + print("REMOVED {}".format(entity_id)) + _LOGGER.debug("Removed %s entities: finalizing device removal", len(entities)) + + return True + + async def async_remove_orphan_entities(hass, entry): """Remove entities associated with config entry that has been removed.""" ent_reg = await er.async_get_registry(hass) entities = { - int(ent.unique_id.split("_")[-1]): ent.entity_id + ent.unique_id: ent.entity_id for ent in er.async_entries_for_config_entry(ent_reg, entry.entry_id) } + print("ENTITIES ORPHAN {}".format(entities)) + return + res = ent_reg.async_remove('switch.aa') + print("RESULT ORPHAN {}".format(res)) + # del entities[101] for entity in entry.data[CONF_ENTITIES]: if entity[CONF_ID] in entities: diff --git a/custom_components/localtuya/cloud_api.py b/custom_components/localtuya/cloud_api.py index 2385f25..e4b4140 100755 --- a/custom_components/localtuya/cloud_api.py +++ b/custom_components/localtuya/cloud_api.py @@ -4,14 +4,21 @@ import hashlib import hmac import json import requests -import sys import time # Signature algorithm. -def calc_sign(msg,key): - sign = hmac.new(msg=bytes(msg, 'latin-1'),key = bytes(key, 'latin-1'), digestmod = hashlib.sha256).hexdigest().upper() - return sign +def calc_sign(msg, key): + sign = ( + hmac.new( + msg=bytes(msg, "latin-1"), + key=bytes(key, "latin-1"), + digestmod=hashlib.sha256, + ) + .hexdigest() + .upper() + ) + return sign class TuyaCloudApi: @@ -20,84 +27,107 @@ class TuyaCloudApi: 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._access_token = "" + 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, t, url, headers, body = None): + def generate_payload(self, method, t, url, headers, body=None): payload = self._client_id + self._access_token + t - payload += (method + '\n' + - hashlib.sha256(bytes((body or "").encode('utf-8'))).hexdigest() + '\n' + # Content-SHA256 - ''.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 (extracted from 'url') + 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 + ) # print("PAYLOAD: {}".format(payload)) return payload - + async def async_make_request(self, method, url, body=None, headers={}): """Perform requests.""" - t = str(int(time.time()*1000)) - payload = self.generate_payload(method, t, url, headers, body) - default_par={ - 'client_id':self._client_id, - 'access_token':self._access_token, - 'sign':calc_sign(payload, self._secret), - 't':t, - 'sign_method':'HMAC-SHA256', - } + t = str(int(time.time() * 1000)) + payload = self.generate_payload(method, t, url, headers, body) + default_par = { + "client_id": self._client_id, + "access_token": self._access_token, + "sign": calc_sign(payload, self._secret), + "t": t, + "sign_method": "HMAC-SHA256", + } full_url = self._base_url + url print("\n" + method + ": [{}]".format(full_url)) if method == "GET": - func = functools.partial(requests.get, full_url, headers=dict(default_par,**headers)) + 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)) + func = functools.partial( + requests.post, + full_url, + headers=dict(default_par, **headers), + data=json.dumps(body), + ) print("BODY: [{}]".format(body)) elif method == "PUT": - func = functools.partial(requests.put, full_url, headers=dict(default_par,**headers), data=json.dumps(body)) - + func = functools.partial( + requests.put, + full_url, + headers=dict(default_par, **headers), + data=json.dumps(body), + ) + r = await self._hass.async_add_executor_job(func) - - #r = json.dumps(r.json(), indent=2, ensure_ascii=False) # Beautify the request result format for easy printing and viewing + # r = json.dumps(r.json(), indent=2, ensure_ascii=False) # Beautify the format return r async def async_get_access_token(self): """Obtain a valid access token.""" - r = await self.async_make_request("GET", f'/v1.0/token?grant_type=1') - + r = await self.async_make_request("GET", "/v1.0/token?grant_type=1") + if not r.ok: - print("Request failed, status {}".format(r.status)) return "Request failed, status " + str(r.status) r_json = r.json() - if not r_json['success']: - print("Request failed, reply is {}".format(json.dumps(r_json, indent=2, ensure_ascii=False))) + if not r_json["success"]: return f"Error {r_json['code']}: {r_json['msg']}" print(r.json()) - self._access_token = r.json()['result']['access_token'] + self._access_token = r.json()["result"]["access_token"] print("GET_ACCESS_TOKEN: {}".format(self._access_token)) return "ok" - + async def async_get_devices_list(self): """Obtain the list of devices associated to a user.""" - r = await self.async_make_request("GET", url=f'/v1.0/users/{self._user_id}/devices') - + r = await self.async_make_request( + "GET", url=f"/v1.0/users/{self._user_id}/devices" + ) + if not r.ok: - print("Request failed, status {}".format(r.status)) - return None + return "Request failed, status " + str(r.status) r_json = r.json() - if not r_json['success']: - print("Request failed, reply is {}".format(json.dumps(r_json, indent=2, ensure_ascii=False))) - return None + if not r_json["success"]: + print( + "Request failed, reply is {}".format( + json.dumps(r_json, indent=2, ensure_ascii=False) + ) + ) + return f"Error {r_json['code']}: {r_json['msg']}" - return r - + self._device_list = {dev['id']: dev for dev in r_json["result"]} + # print("DEV__LIST: {}".format(self._device_list)) + return "ok" diff --git a/custom_components/localtuya/common.py b/custom_components/localtuya/common.py index ea821a5..1c7f61d 100644 --- a/custom_components/localtuya/common.py +++ b/custom_components/localtuya/common.py @@ -5,10 +5,12 @@ from datetime import timedelta from homeassistant.const import ( CONF_DEVICE_ID, + CONF_DEVICES, CONF_ENTITIES, CONF_FRIENDLY_NAME, CONF_HOST, CONF_ID, + CONF_MODEL, CONF_PLATFORM, CONF_SCAN_INTERVAL, ) @@ -23,9 +25,9 @@ from homeassistant.helpers.restore_state import RestoreEntity from . import pytuya from .const import ( CONF_LOCAL_KEY, - CONF_PRODUCT_KEY, CONF_PROTOCOL_VERSION, DOMAIN, + DATA_CLOUD, TUYA_DEVICE, ) @@ -42,7 +44,7 @@ def prepare_setup_entities(hass, config_entry, platform): if not entities_to_setup: return None, None - tuyainterface = hass.data[DOMAIN][config_entry.entry_id][TUYA_DEVICE] + tuyainterface = [] return tuyainterface, entities_to_setup @@ -55,28 +57,44 @@ async def async_setup_entry( This is a generic method and each platform should lock domain and entity_class with functools.partial. """ - tuyainterface, entities_to_setup = prepare_setup_entities( - hass, config_entry, domain + print("ASYNC_SETUP_ENTRY: {} {} {}".format( + config_entry.data, + entity_class, + flow_schema(None).items()) ) - if not entities_to_setup: - return - - dps_config_fields = list(get_dps_for_platform(flow_schema)) - entities = [] - for device_config in entities_to_setup: - # Add DPS used by this platform to the request list - for dp_conf in dps_config_fields: - if dp_conf in device_config: - tuyainterface.dps_to_request[device_config[dp_conf]] = None - entities.append( - entity_class( - tuyainterface, - config_entry, - device_config[CONF_ID], - ) - ) + 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 len(entities_to_setup) > 0: + + tuyainterface = hass.data[DOMAIN][dev_id][TUYA_DEVICE] + + 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], + ) + ) + print("ADDING {} entities".format(len(entities))) async_add_entities(entities) @@ -90,7 +108,7 @@ def get_dps_for_platform(flow_schema): def get_entity_config(config_entry, dp_id): """Return entity config for a given DPS id.""" - for entity in config_entry.data[CONF_ENTITIES]: + 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}") @@ -121,6 +139,7 @@ class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger): self._connect_task = None self._disconnect_task = None self._unsub_interval = None + self._local_key = self._config_entry[CONF_LOCAL_KEY] self.set_logger(_LOGGER, config_entry[CONF_DEVICE_ID]) # This has to be done in case the device type is type_0d @@ -145,7 +164,7 @@ class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger): self._interface = await pytuya.connect( self._config_entry[CONF_HOST], self._config_entry[CONF_DEVICE_ID], - self._config_entry[CONF_LOCAL_KEY], + self._local_key, float(self._config_entry[CONF_PROTOCOL_VERSION]), self, ) @@ -180,8 +199,30 @@ class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger): self._async_refresh, timedelta(seconds=self._config_entry[CONF_SCAN_INTERVAL]), ) - except Exception: # pylint: disable=broad-except + except UnicodeDecodeError as e: # pylint: disable=broad-except + dev_id = self._config_entry[CONF_DEVICE_ID] + cloud_devs = self._hass.data[DOMAIN][DATA_CLOUD]._device_list + if dev_id in cloud_devs: + old_key = self._local_key + self._local_key = cloud_devs[dev_id].get(CONF_LOCAL_KEY) + self.error( + "New local key for %s: from %s to %s", + dev_id, + old_key, + self._local_key + ) + self.exception( + f"Connect to {self._config_entry[CONF_HOST]} failed: %s", + type(e) + ) + if self._interface is not None: + await self._interface.close() + self._interface = None + + except Exception as e: # pylint: disable=broad-except self.exception(f"Connect to {self._config_entry[CONF_HOST]} failed") + self.error("BBBB: %s", type(e)) + if self._interface is not None: await self._interface.close() self._interface = None @@ -201,6 +242,10 @@ class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger): await self._interface.close() if self._disconnect_task is not None: self._disconnect_task() + self.debug( + "Closed connection with device %s.", + self._config_entry[CONF_FRIENDLY_NAME] + ) async def set_dp(self, state, dp_index): """Change value of a DP of the Tuya device.""" @@ -259,7 +304,7 @@ class LocalTuyaEntity(RestoreEntity, pytuya.ContextualLogger): self._config = get_entity_config(config_entry, dp_id) self._dp_id = dp_id self._status = {} - self.set_logger(logger, self._config_entry.data[CONF_DEVICE_ID]) + self.set_logger(logger, self._config_entry[CONF_DEVICE_ID]) async def async_added_to_hass(self): """Subscribe localtuya events.""" @@ -281,27 +326,28 @@ class LocalTuyaEntity(RestoreEntity, pytuya.ContextualLogger): self.status_updated() self.schedule_update_ha_state() - signal = f"localtuya_{self._config_entry.data[CONF_DEVICE_ID]}" + signal = f"localtuya_{self._config_entry[CONF_DEVICE_ID]}" self.async_on_remove( async_dispatcher_connect(self.hass, signal, _update_handler) ) - signal = f"localtuya_entity_{self._config_entry.data[CONF_DEVICE_ID]}" + signal = f"localtuya_entity_{self._config_entry[CONF_DEVICE_ID]}" async_dispatcher_send(self.hass, signal, self.entity_id) @property def device_info(self): """Return device information for the device registry.""" + model = self._config_entry.get(CONF_MODEL, "Tuya generic") return { "identifiers": { # Serial numbers are unique identifiers within a specific domain - (DOMAIN, f"local_{self._config_entry.data[CONF_DEVICE_ID]}") + (DOMAIN, f"local_{self._config_entry[CONF_DEVICE_ID]}") }, - "name": self._config_entry.data[CONF_FRIENDLY_NAME], - "manufacturer": "Unknown", - "model": self._config_entry.data.get(CONF_PRODUCT_KEY, "Tuya generic"), - "sw_version": self._config_entry.data[CONF_PROTOCOL_VERSION], + "name": self._config_entry[CONF_FRIENDLY_NAME], + "manufacturer": "Tuya", + "model": f"{model} ({self._config_entry[CONF_DEVICE_ID]})", + "sw_version": self._config_entry[CONF_PROTOCOL_VERSION], } @property @@ -317,7 +363,7 @@ class LocalTuyaEntity(RestoreEntity, pytuya.ContextualLogger): @property def unique_id(self): """Return unique device identifier.""" - return f"local_{self._config_entry.data[CONF_DEVICE_ID]}_{self._dp_id}" + return f"local_{self._config_entry[CONF_DEVICE_ID]}_{self._dp_id}" def has_config(self, attr): """Return if a config parameter has a valid value.""" diff --git a/custom_components/localtuya/config_flow.py b/custom_components/localtuya/config_flow.py index 03bf80d..902efac 100644 --- a/custom_components/localtuya/config_flow.py +++ b/custom_components/localtuya/config_flow.py @@ -1,30 +1,45 @@ """Config flow for LocalTuya integration integration.""" import errno import logging +import time from importlib import import_module import homeassistant.helpers.config_validation as cv import voluptuous as vol from homeassistant import config_entries, core, exceptions -from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( CONF_DEVICE_ID, + CONF_DEVICES, CONF_ENTITIES, CONF_FRIENDLY_NAME, CONF_HOST, CONF_ID, + CONF_MODEL, + CONF_NAME, CONF_PLATFORM, CONF_SCAN_INTERVAL, + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, + CONF_REGION, + ) from homeassistant.core import callback +from .cloud_api import TuyaCloudApi from .common import async_config_entry_by_device_id, pytuya -from .const import CONF_DPS_STRINGS # pylint: disable=unused-import from .const import ( + CONF_ACTION, + CONF_ADD_DEVICE, + CONF_EDIT_DEVICE, + CONF_SETUP_CLOUD, CONF_LOCAL_KEY, - CONF_PRODUCT_KEY, + CONF_PRODUCT_NAME, CONF_PROTOCOL_VERSION, + CONF_USER_ID, + CONF_DPS_STRINGS, + ATTR_UPDATED_AT, DATA_DISCOVERY, + DATA_CLOUD, DOMAIN, PLATFORMS, ) @@ -33,11 +48,32 @@ from .discovery import discover _LOGGER = logging.getLogger(__name__) PLATFORM_TO_ADD = "platform_to_add" -NO_ADDITIONAL_PLATFORMS = "no_additional_platforms" +NO_ADDITIONAL_ENTITIES = "no_additional_entities" DISCOVERED_DEVICE = "discovered_device" CUSTOM_DEVICE = "..." +CONF_ACTIONS = { + CONF_ADD_DEVICE: "Add a new device", + CONF_EDIT_DEVICE: "Edit a device", + CONF_SETUP_CLOUD: "Reconfigure Cloud API account", +} + +CONFIGURE_SCHEMA = vol.Schema( + { + vol.Required(CONF_ACTION, default=CONF_ADD_DEVICE): vol.In(CONF_ACTIONS), + } +) + +CLOUD_SETUP_SCHEMA = vol.Schema( + { + vol.Required(CONF_REGION, default="eu"): vol.In(["eu", "us", "cn", "in"]), + vol.Required(CONF_CLIENT_ID): cv.string, + vol.Required(CONF_CLIENT_SECRET): cv.string, + vol.Required(CONF_USER_ID): cv.string, + } +) + BASIC_INFO_SCHEMA = vol.Schema( { vol.Required(CONF_FRIENDLY_NAME): str, @@ -49,7 +85,6 @@ BASIC_INFO_SCHEMA = vol.Schema( } ) - DEVICE_SCHEMA = vol.Schema( { vol.Required(CONF_HOST): cv.string, @@ -62,23 +97,29 @@ DEVICE_SCHEMA = vol.Schema( ) PICK_ENTITY_SCHEMA = vol.Schema( - {vol.Required(PLATFORM_TO_ADD, default=PLATFORMS[0]): vol.In(PLATFORMS)} + {vol.Required(PLATFORM_TO_ADD, default="switch"): vol.In(PLATFORMS)} ) -def user_schema(devices, entries): - """Create schema for user step.""" - devices = {dev_id: dev["ip"] for dev_id, dev in devices.items()} - devices.update( - { - ent.data[CONF_DEVICE_ID]: ent.data[CONF_FRIENDLY_NAME] - for ent in entries - if ent.source != SOURCE_IMPORT - } - ) - device_list = [f"{key} ({value})" for key, value in devices.items()] +def devices_schema(discovered_devices, cloud_devices_list): + """Create schema for devices step.""" + devices = {} + for dev_id, dev in discovered_devices.items(): + dev_name = dev_id + if dev_id in cloud_devices_list.keys(): + dev_name = cloud_devices_list[dev_id][CONF_NAME] + devices[dev_id] = f"{dev_name} ({discovered_devices[dev_id]['ip']})" + + devices.update({CUSTOM_DEVICE: CUSTOM_DEVICE}) + + # devices.update( + # { + # ent.data[CONF_DEVICE_ID]: ent.data[CONF_FRIENDLY_NAME] + # for ent in entries + # } + # ) return vol.Schema( - {vol.Required(DISCOVERED_DEVICE): vol.In(device_list + [CUSTOM_DEVICE])} + {vol.Required(DISCOVERED_DEVICE): vol.In(devices)} ) @@ -210,10 +251,36 @@ async def validate_input(hass: core.HomeAssistant, data): return dps_string_list(detected_dps) +async def attempt_cloud_connection(hass, user_input): + """Create device.""" + tuya_api = TuyaCloudApi( + hass, + user_input.get(CONF_REGION), + user_input.get(CONF_CLIENT_ID), + user_input.get(CONF_CLIENT_SECRET), + user_input.get(CONF_USER_ID) + ) + + res = await tuya_api.async_get_access_token() + _LOGGER.debug("ACCESS TOKEN RES: %s", res) + if res != "ok": + return {"reason": "authentication_failed", "msg": res} + + res = await tuya_api.async_get_devices_list() + _LOGGER.debug("DEV LIST RES: %s", res) + if res != "ok": + return {"reason": "device_list_failed", "msg": res} + + for dev_id, dev in tuya_api._device_list.items(): + print(f"Name: {dev['name']} \t dev_id {dev['id']} \t key {dev['local_key']} ") + + return {} + + class LocaltuyaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for LocalTuya integration.""" - VERSION = 1 + VERSION = 2 CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL @staticmethod @@ -224,29 +291,139 @@ class LocaltuyaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self): """Initialize a new LocaltuyaConfigFlow.""" - self.basic_info = None - self.dps_strings = [] - self.platform = None - self.devices = {} - self.selected_device = None - self.entities = [] async def async_step_user(self, user_input=None): """Handle the initial step.""" errors = {} + placeholders = {} if user_input is not None: + print("ECCOCI") + res = await attempt_cloud_connection(self.hass, user_input) + + if len(res) == 0: + return await self._create_entry(user_input) + errors["base"] = res["reason"] + placeholders = {"msg": res["msg"]} + + defaults = {} + defaults[CONF_REGION] = 'eu' + defaults[CONF_CLIENT_ID] = 'xx' + defaults[CONF_CLIENT_SECRET] = 'xx' + defaults[CONF_USER_ID] = 'xx' + defaults.update(user_input or {}) + + return self.async_show_form( + step_id="user", + data_schema=schema_defaults(CLOUD_SETUP_SCHEMA, **defaults), + errors=errors, + description_placeholders=placeholders + ) + + async def _create_entry(self, user_input): + """Register new entry.""" + # if not self.unique_id: + # await self.async_set_unique_id(password) + # self._abort_if_unique_id_configured() + if self._async_current_entries(): + return self.async_abort(reason="already_configured") + + await self.async_set_unique_id(user_input.get(CONF_USER_ID)) + user_input[CONF_DEVICES] = {} + + return self.async_create_entry( + title="LocalTuya", + 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.") + # await self.async_set_unique_id(user_input[CONF_DEVICE_ID]) + # self._abort_if_unique_id_configured(updates=user_input) + # return self.async_create_entry( + # title=f"{user_input[CONF_FRIENDLY_NAME]} (YAML)", data=user_input + # ) + + +class LocalTuyaOptionsFlowHandler(config_entries.OptionsFlow): + """Handle options flow for LocalTuya integration.""" + + def __init__(self, config_entry): + """Initialize localtuya options flow.""" + self.config_entry = config_entry + # self.dps_strings = config_entry.data.get(CONF_DPS_STRINGS, gen_dps_strings()) + # self.entities = config_entry.data[CONF_ENTITIES] + self.selected_device = None + self.basic_info = None + self.dps_strings = [] + self.selected_platform = None + self.devices = {} + self.entities = [] + self.data = None + + async def async_step_init(self, user_input=None): + """Manage basic options.""" + # device_id = self.config_entry.data[CONF_DEVICE_ID] + if user_input is not None: + if user_input.get(CONF_ACTION) == CONF_SETUP_CLOUD: + return await self.async_step_cloud_setup() + if user_input.get(CONF_ACTION) == CONF_ADD_DEVICE: + return await self.async_step_add_device() + + return self.async_show_form( + step_id="init", + data_schema=CONFIGURE_SCHEMA, + ) + + async def async_step_cloud_setup(self, user_input=None): + """Handle the initial step.""" + errors = {} + placeholders = {} + if user_input is not None: + res = await attempt_cloud_connection(self.hass, user_input) + + if len(res) == 0: + new_data = self.config_entry.data.copy() + new_data.update(user_input) + print("CURR_ENTRY {}".format(self.config_entry)) + print("NEW DATA {}".format(new_data)) + + self.hass.config_entries.async_update_entry( + self.config_entry, + data=new_data, + ) + return self.async_create_entry(title="", data={}) + errors["base"] = res["reason"] + placeholders = {"msg": res["msg"]} + + defaults = self.config_entry.data.copy() + defaults.update(user_input or {}) + + return self.async_show_form( + step_id="cloud_setup", + data_schema=schema_defaults(CLOUD_SETUP_SCHEMA, **defaults), + errors=errors, + description_placeholders=placeholders + ) + + async def async_step_add_device(self, user_input=None): + """Handle adding a new device.""" + # Use cache if available or fallback to manual discovery + errors = {} + if user_input is not None: + print("Selected {}".format(user_input)) if user_input[DISCOVERED_DEVICE] != CUSTOM_DEVICE: - self.selected_device = user_input[DISCOVERED_DEVICE].split(" ")[0] + self.selected_device = user_input[DISCOVERED_DEVICE] return await self.async_step_basic_info() - # Use cache if available or fallback to manual discovery - devices = {} + discovered_devices = {} data = self.hass.data.get(DOMAIN) + if data and DATA_DISCOVERY in data: - devices = data[DATA_DISCOVERY].devices + discovered_devices = data[DATA_DISCOVERY].devices else: try: - devices = await discover() + discovered_devices = await discover() except OSError as ex: if ex.errno == errno.EADDRINUSE: errors["base"] = "address_in_use" @@ -258,29 +435,38 @@ class LocaltuyaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.devices = { ip: dev - for ip, dev in devices.items() - if dev["gwId"] not in self._async_current_ids() + for ip, dev in discovered_devices.items() + if dev["gwId"] not in self.config_entry.data[CONF_DEVICES] } return self.async_show_form( - step_id="user", + step_id="add_device", + data_schema=devices_schema( + self.devices, + self.hass.data[DOMAIN][DATA_CLOUD]._device_list + ), errors=errors, - data_schema=user_schema(self.devices, self._async_current_entries()), ) async def async_step_basic_info(self, user_input=None): """Handle input of basic info.""" errors = {} + dev_id = self.selected_device if user_input is not None: - await self.async_set_unique_id(user_input[CONF_DEVICE_ID]) + print("INPUT3!! {} {}".format(user_input, CONF_DEVICE_ID)) try: self.basic_info = user_input - if self.selected_device is not None: - self.basic_info[CONF_PRODUCT_KEY] = self.devices[ - self.selected_device - ]["productKey"] + if dev_id is not None: + # self.basic_info[CONF_PRODUCT_KEY] = self.devices[ + # self.selected_device + # ]["productKey"] + cloud_devs = self.hass.data[DOMAIN][DATA_CLOUD]._device_list + if dev_id in cloud_devs: + self.basic_info[CONF_MODEL] = cloud_devs[dev_id].get(CONF_PRODUCT_NAME) + self.dps_strings = await validate_input(self.hass, user_input) + print("ZIO KEN!! {} ".format(self.dps_strings)) return await self.async_step_pick_entity_type() except CannotConnect: errors["base"] = "cannot_connect" @@ -293,22 +479,27 @@ class LocaltuyaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "unknown" # If selected device exists as a config entry, load config from it - if self.selected_device in self._async_current_ids(): - entry = async_config_entry_by_device_id(self.hass, self.selected_device) - await self.async_set_unique_id(entry.data[CONF_DEVICE_ID]) - self.basic_info = entry.data.copy() - self.dps_strings = self.basic_info.pop(CONF_DPS_STRINGS).copy() - self.entities = self.basic_info.pop(CONF_ENTITIES).copy() - return await self.async_step_pick_entity_type() + if self.selected_device in self.config_entry.data[CONF_DEVICES]: + print("ALREADY EXISTING!! {}".format(self.selected_device)) + # entry = self.config_entry.data[CONF_DEVICES][self.selected_device] + # await self.async_set_unique_id(entry.data[CONF_DEVICE_ID]) + # self.basic_info = entry.data.copy() + # self.dps_strings = self.basic_info.pop(CONF_DPS_STRINGS).copy() + # self.entities = self.basic_info.pop(CONF_ENTITIES).copy() + # return await self.async_step_pick_entity_type() - # Insert default values from discovery if present + # Insert default values from discovery and cloud if present defaults = {} defaults.update(user_input or {}) - if self.selected_device is not None: - device = self.devices[self.selected_device] + if dev_id is not None: + device = self.devices[dev_id] defaults[CONF_HOST] = device.get("ip") defaults[CONF_DEVICE_ID] = device.get("gwId") defaults[CONF_PROTOCOL_VERSION] = device.get("version") + cloud_devs = self.hass.data[DOMAIN][DATA_CLOUD]._device_list + if dev_id in cloud_devs: + defaults[CONF_LOCAL_KEY] = cloud_devs[dev_id].get(CONF_LOCAL_KEY) + defaults[CONF_FRIENDLY_NAME] = cloud_devs[dev_id].get(CONF_NAME) return self.async_show_form( step_id="basic_info", @@ -316,116 +507,12 @@ class LocaltuyaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_pick_entity_type(self, user_input=None): - """Handle asking if user wants to add another entity.""" - if user_input is not None: - if user_input.get(NO_ADDITIONAL_PLATFORMS): - config = { - **self.basic_info, - CONF_DPS_STRINGS: self.dps_strings, - CONF_ENTITIES: self.entities, - } - entry = async_config_entry_by_device_id(self.hass, self.unique_id) - if entry: - self.hass.config_entries.async_update_entry(entry, data=config) - return self.async_abort(reason="device_updated") - return self.async_create_entry( - title=config[CONF_FRIENDLY_NAME], data=config - ) - - self.platform = user_input[PLATFORM_TO_ADD] - return await self.async_step_add_entity() - - # Add a checkbox that allows bailing out from config flow iff at least one - # entity has been added - schema = PICK_ENTITY_SCHEMA - if self.platform is not None: - schema = schema.extend( - {vol.Required(NO_ADDITIONAL_PLATFORMS, default=True): bool} - ) - - return self.async_show_form(step_id="pick_entity_type", data_schema=schema) - - async def async_step_add_entity(self, user_input=None): - """Handle adding a new entity.""" - errors = {} - if user_input is not None: - already_configured = any( - switch[CONF_ID] == int(user_input[CONF_ID].split(" ")[0]) - for switch in self.entities - ) - if not already_configured: - user_input[CONF_PLATFORM] = self.platform - self.entities.append(strip_dps_values(user_input, self.dps_strings)) - return await self.async_step_pick_entity_type() - - errors["base"] = "entity_already_configured" - - return self.async_show_form( - step_id="add_entity", - data_schema=platform_schema(self.platform, self.dps_strings), - errors=errors, - description_placeholders={"platform": self.platform}, - ) - - async def async_step_import(self, user_input): - """Handle import from YAML.""" - await self.async_set_unique_id(user_input[CONF_DEVICE_ID]) - self._abort_if_unique_id_configured(updates=user_input) - return self.async_create_entry( - title=f"{user_input[CONF_FRIENDLY_NAME]} (YAML)", data=user_input - ) - - -class LocalTuyaOptionsFlowHandler(config_entries.OptionsFlow): - """Handle options flow for LocalTuya integration.""" - - def __init__(self, config_entry): - """Initialize localtuya options flow.""" - self.config_entry = config_entry - self.dps_strings = config_entry.data.get(CONF_DPS_STRINGS, gen_dps_strings()) - self.entities = config_entry.data[CONF_ENTITIES] - self.data = None - - async def async_step_init(self, user_input=None): - """Manage basic options.""" - device_id = self.config_entry.data[CONF_DEVICE_ID] - if user_input is not None: - self.data = user_input.copy() - self.data.update( - { - CONF_DEVICE_ID: device_id, - CONF_DPS_STRINGS: self.dps_strings, - CONF_ENTITIES: [], - } - ) - if len(user_input[CONF_ENTITIES]) > 0: - entity_ids = [ - int(entity.split(" ")[0]) for entity in user_input[CONF_ENTITIES] - ] - self.entities = [ - entity - for entity in self.config_entry.data[CONF_ENTITIES] - if entity[CONF_ID] in entity_ids - ] - return await self.async_step_entity() - - # Not supported for YAML imports - if self.config_entry.source == config_entries.SOURCE_IMPORT: - return await self.async_step_yaml_import() - - return self.async_show_form( - step_id="init", - data_schema=schema_defaults( - options_schema(self.entities), **self.config_entry.data - ), - description_placeholders={"device_id": device_id}, - ) - async def async_step_entity(self, user_input=None): """Manage entity settings.""" errors = {} if user_input is not None: + print("INPUT3!! {} {}".format(user_input, CONF_DEVICE_ID)) + print("ZIO KEN!! {} ".format(self.dps_strings)) 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] @@ -454,11 +541,105 @@ class LocalTuyaOptionsFlowHandler(config_entries.OptionsFlow): }, ) + 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: + print("INPUT3!! {} {}".format(user_input, self.basic_info)) + print("AAAZIO KEN!! {} ".format(self.dps_strings)) + print("NAAA!! {} ".format(user_input.get(NO_ADDITIONAL_ENTITIES))) + if user_input.get(NO_ADDITIONAL_ENTITIES): + print("INPUT4!! {}".format(self.dps_strings)) + print("INPUT4!! {}".format(self.entities)) + config = { + **self.basic_info, + CONF_DPS_STRINGS: self.dps_strings, + CONF_ENTITIES: self.entities, + } + print("NEW CONFIG!! {}".format(config)) + # self.config_entry.data[CONF_DEVICES] + + entry = async_config_entry_by_device_id(self.hass, self.unique_id) + dev_id = self.basic_info.get(CONF_DEVICE_ID) + if dev_id in self.config_entry.data[CONF_DEVICES]: + print("AGGIORNO !! {}".format(dev_id)) + self.hass.config_entries.async_update_entry(self.config_entry, data=config) + return self.async_abort( + reason="device_success", + description_placeholders={ + "dev_name": config.get(CONF_FRIENDLY_NAME), + "action": "updated" + } + ) + + print("CREO NUOVO DEVICE!! {}".format(dev_id)) + new_data = self.config_entry.data.copy() + print("PRE: {}".format(new_data)) + new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000)) + # new_data[CONF_DEVICES]["AZZ"] = "OK" + new_data[CONF_DEVICES].update({dev_id: config}) + print("POST: {}".format(new_data)) + + self.hass.config_entries.async_update_entry( + self.config_entry, + data=new_data, + ) + return self.async_create_entry(title="", data={}) + self.async_create_entry(title="", data={}) + print("DONE! now message {}".format(new_data)) + return self.async_abort( + reason="device_success", + description_placeholders={ + "dev_name": config.get(CONF_FRIENDLY_NAME), + "action": "created" + } + ) + # return self.async_create_entry( + # title=config[CONF_FRIENDLY_NAME], data=config + # ) + print("MA ZZZIO KEN!! {} ".format(self.dps_strings)) + + self.selected_platform = user_input[PLATFORM_TO_ADD] + return await self.async_step_add_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_add_entity(self, user_input=None): + """Handle adding a new entity.""" + errors = {} + if user_input is not None: + print("INPUT3!! {} {}".format(user_input, CONF_DEVICE_ID)) + already_configured = any( + switch[CONF_ID] == int(user_input[CONF_ID].split(" ")[0]) + for switch in self.entities + ) + if not already_configured: + user_input[CONF_PLATFORM] = self.selected_platform + self.entities.append(strip_dps_values(user_input, self.dps_strings)) + return await self.async_step_pick_entity_type() + + errors["base"] = "entity_already_configured" + + return self.async_show_form( + step_id="add_entity", + data_schema=platform_schema(self.selected_platform, self.dps_strings), + errors=errors, + description_placeholders={"platform": self.selected_platform}, + ) + async def async_step_yaml_import(self, user_input=None): """Manage YAML imports.""" - if user_input is not None: - return self.async_create_entry(title="", data={}) - return self.async_show_form(step_id="yaml_import") + _LOGGER.error("Configuration via YAML file is no longer supported by this integration.") + # if user_input is not None: + # return self.async_create_entry(title="", data={}) + # return self.async_show_form(step_id="yaml_import") @property def current_entity(self): diff --git a/custom_components/localtuya/const.py b/custom_components/localtuya/const.py index 4148771..f50fca2 100644 --- a/custom_components/localtuya/const.py +++ b/custom_components/localtuya/const.py @@ -1,13 +1,43 @@ """Constants for localtuya integration.""" +DOMAIN = "localtuya" + +DATA_DISCOVERY = "discovery" +DATA_CLOUD = "cloud_data" + +# Platforms in this list must support config flows +PLATFORMS = [ + "binary_sensor", + "climate", + "cover", + "fan", + "light", + "number", + "select", + "sensor", + "switch", + "vacuum", +] + +TUYA_DEVICE = "tuya_device" + ATTR_CURRENT = "current" ATTR_CURRENT_CONSUMPTION = "current_consumption" ATTR_VOLTAGE = "voltage" +ATTR_UPDATED_AT = "updated_at" +# config flow CONF_LOCAL_KEY = "local_key" CONF_PROTOCOL_VERSION = "protocol_version" CONF_DPS_STRINGS = "dps_strings" CONF_PRODUCT_KEY = "product_key" +CONF_PRODUCT_NAME = "product_name" +CONF_USER_ID = "user_id" + +CONF_ACTION = "action" +CONF_ADD_DEVICE = "add_device" +CONF_EDIT_DEVICE = "edit_device" +CONF_SETUP_CLOUD = "setup_cloud" # light CONF_BRIGHTNESS_LOWER = "brightness_lower" @@ -81,23 +111,3 @@ CONF_FAULT_DP = "fault_dp" CONF_PAUSED_STATE = "paused_state" CONF_RETURN_MODE = "return_mode" CONF_STOP_STATUS = "stop_status" - -DATA_DISCOVERY = "discovery" - -DOMAIN = "localtuya" - -# Platforms in this list must support config flows -PLATFORMS = [ - "binary_sensor", - "climate", - "cover", - "fan", - "light", - "number", - "select", - "sensor", - "switch", - "vacuum", -] - -TUYA_DEVICE = "tuya_device" diff --git a/custom_components/localtuya/diagnostics.py b/custom_components/localtuya/diagnostics.py index a18d5d6..d4b5cda 100644 --- a/custom_components/localtuya/diagnostics.py +++ b/custom_components/localtuya/diagnostics.py @@ -6,7 +6,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntry -from .const import DOMAIN +from .const import DOMAIN, DATA_CLOUD async def async_get_config_entry_diagnostics( @@ -16,6 +16,8 @@ async def async_get_config_entry_diagnostics( data = {} data = {**entry.data} # print("DATA is {}".format(data)) + tuya_api = hass.data[DOMAIN][DATA_CLOUD] + data["cloud_devices"] = tuya_api._device_list # censoring private information # data["token"] = re.sub(r"[^\-]", "*", data["token"]) diff --git a/custom_components/localtuya/discovery.py b/custom_components/localtuya/discovery.py index a753c5f..d18e376 100644 --- a/custom_components/localtuya/discovery.py +++ b/custom_components/localtuya/discovery.py @@ -71,7 +71,7 @@ class TuyaDiscovery(asyncio.DatagramProtocol): def device_found(self, device): """Discover a new device.""" - if device.get("ip") not in self.devices: + if device.get("gwId") not in self.devices: self.devices[device.get("gwId")] = device _LOGGER.debug("Discovered device: %s", device) diff --git a/custom_components/localtuya/strings.json b/custom_components/localtuya/strings.json index b8bedc8..4db7e70 100644 --- a/custom_components/localtuya/strings.json +++ b/custom_components/localtuya/strings.json @@ -12,16 +12,13 @@ }, "step": { "user": { - "title": "Add Tuya device", - "description": "Fill in the basic details and pick device type. The name entered here will be used to identify the integration itself (as seen in the `Integrations` page). You will name each sub-device in the following steps.", + "title": "Main Configuration", + "description": "Input the credentials for Tuya Cloud API.", "data": { - "name": "Name", - "host": "Host", - "device_id": "Device ID", - "local_key": "Local key", - "protocol_version": "Protocol Version", - "scan_interval": "Scan interval (seconds, only when not updating automatically)", - "device_type": "Device type" + "region": "API server region", + "client_id": "Client ID", + "client_secret": "Secret", + "user_id": "User ID" } }, "power_outlet": { @@ -39,5 +36,98 @@ } } }, + "options": { + "step": { + "init": { + "title": "LocalTuya Configuration", + "description": "Please select the desired actionSSSS.", + "data": { + "add_device": "Add a new device", + "edit_device": "Edit a device", + "delete_device": "Delete a device", + "setup_cloud": "Reconfigure Cloud API account" + } + }, + "entity": { + "title": "Entity Configuration", + "description": "Editing entity with DPS `{id}` and platform `{platform}`.", + "data": { + "id": "ID", + "friendly_name": "Friendly name", + "current": "Current", + "current_consumption": "Current Consumption", + "voltage": "Voltage", + "commands_set": "Open_Close_Stop Commands Set", + "positioning_mode": "Positioning mode", + "current_position_dp": "Current Position (for *position* mode only)", + "set_position_dp": "Set Position (for *position* mode only)", + "position_inverted": "Invert 0-100 position (for *position* mode only)", + "span_time": "Full opening time, in secs. (for *timed* mode only)", + "unit_of_measurement": "Unit of Measurement", + "device_class": "Device Class", + "scaling": "Scaling Factor", + "state_on": "On Value", + "state_off": "Off Value", + "powergo_dp": "Power DP (Usually 25 or 2)", + "idle_status_value": "Idle Status (comma-separated)", + "returning_status_value": "Returning Status", + "docked_status_value": "Docked Status (comma-separated)", + "fault_dp": "Fault DP (Usually 11)", + "battery_dp": "Battery status DP (Usually 14)", + "mode_dp": "Mode DP (Usually 27)", + "modes": "Modes list", + "return_mode": "Return home mode", + "fan_speed_dp": "Fan speeds DP (Usually 30)", + "fan_speeds": "Fan speeds list (comma-separated)", + "clean_time_dp": "Clean Time DP (Usually 33)", + "clean_area_dp": "Clean Area DP (Usually 32)", + "clean_record_dp": "Clean Record DP (Usually 34)", + "locate_dp": "Locate DP (Usually 31)", + "paused_state": "Pause state (pause, paused, etc)", + "stop_status": "Stop status", + "brightness": "Brightness (only for white color)", + "brightness_lower": "Brightness Lower Value", + "brightness_upper": "Brightness Upper Value", + "color_temp": "Color Temperature", + "color_temp_reverse": "Color Temperature Reverse", + "color": "Color", + "color_mode": "Color Mode", + "color_temp_min_kelvin": "Minimum Color Temperature in K", + "color_temp_max_kelvin": "Maximum Color Temperature in K", + "music_mode": "Music mode available", + "scene": "Scene", + "fan_speed_control": "Fan Speed Control dps", + "fan_oscillating_control": "Fan Oscillating Control dps", + "fan_speed_min": "minimum fan speed integer", + "fan_speed_max": "maximum fan speed integer", + "fan_speed_ordered_list": "Fan speed modes list (overrides speed min/max)", + "fan_direction":"fan direction dps", + "fan_direction_forward": "forward dps string", + "fan_direction_reverse": "reverse dps string", + "current_temperature_dp": "Current Temperature", + "target_temperature_dp": "Target Temperature", + "temperature_step": "Temperature Step (optional)", + "max_temperature_dp": "Max Temperature (optional)", + "min_temperature_dp": "Min Temperature (optional)", + "precision": "Precision (optional, for DPs values)", + "target_precision": "Target Precision (optional, for DPs values)", + "temperature_unit": "Temperature Unit (optional)", + "hvac_mode_dp": "HVAC Mode DP (optional)", + "hvac_mode_set": "HVAC Mode Set (optional)", + "hvac_action_dp": "HVAC Current Action DP (optional)", + "hvac_action_set": "HVAC Current Action Set (optional)", + "preset_dp": "Presets DP (optional)", + "preset_set": "Presets Set (optional)", + "eco_dp": "Eco DP (optional)", + "eco_value": "Eco value (optional)", + "heuristic_action": "Enable heuristic action (optional)" + } + }, + "yaml_import": { + "title": "Not Supported", + "description": "Options cannot be edited when configured via YAML." + } + } + }, "title": "LocalTuya" } diff --git a/custom_components/localtuya/translations/en.json b/custom_components/localtuya/translations/en.json index ebd22ee..7a5b314 100644 --- a/custom_components/localtuya/translations/en.json +++ b/custom_components/localtuya/translations/en.json @@ -5,7 +5,9 @@ "device_updated": "Device configuration has been updated!" }, "error": { + "authentication_failed": "Failed to authenticate.\n{msg}", "cannot_connect": "Cannot connect to device. Verify that address is correct and try again.", + "device_list_failed": "Failed to retrieve device list.\n{msg}", "invalid_auth": "Failed to authenticate with device. Verify that device id and local key are correct.", "unknown": "An unknown error occurred. See log for details.", "entity_already_configured": "Entity with this ID has already been configured.", @@ -15,15 +17,57 @@ }, "step": { "user": { - "title": "Device Discovery", + "title": "Cloud API account configuration", + "description": "Input the credentials for Tuya Cloud API.", + "data": { + "region": "API server region", + "client_id": "Client ID", + "client_secret": "Secret", + "user_id": "User ID" + } + } + } + }, + "options": { + "abort": { + "already_configured": "Device has already been configured.", + "device_success": "Device {dev_name} successfully {action}." + }, + "error": { + "authentication_failed": "Failed to authenticate.\n{msg}", + "cannot_connect": "Cannot connect to device. Verify that address is correct and try again.", + "device_list_failed": "Failed to retrieve device list.\n{msg}", + "invalid_auth": "Failed to authenticate with device. Verify that device id and local key are correct.", + "unknown": "An unknown error occurred. See log for details.", + "entity_already_configured": "Entity with this ID has already been configured.", + "address_in_use": "Address used for discovery is already in use. Make sure no other application is using it (TCP port 6668).", + "discovery_failed": "Something failed when discovering devices. See log for details.", + "empty_dps": "Connection to device succeeded but no datapoints found, please try again. Create a new issue and include debug logs if problem persists." + }, + "step": { + "yaml_import": { + "title": "Not Supported", + "description": "Options cannot be edited when configured via YAML." + }, + "init": { + "title": "LocalTuya Configuration", + "description": "Please select the desired action.", + "data": { + "add_device": "AAAAA a new device", + "edit_device": "Edit a device", + "setup_cloud": "Reconfigure Cloud API account" + } + }, + "add_device": { + "title": "Add a new device", "description": "Pick one of the automatically discovered devices or `...` to manually to add a device.", "data": { - "discovered_device": "Discovered Device" + "discovered_device": "Discovered Devices" } }, "basic_info": { "title": "Add Tuya device", - "description": "Fill in the basic device details. The name entered here will be used to identify the integration itself (as seen in the `Integrations` page). You will add entities and give them names in the following steps.", + "description": "Fill in the basic device details. The name entered here will be used to identify the device itself (as seen in the `Integrations` page). You will add entities and give them names in the following steps.", "data": { "friendly_name": "Name", "host": "Host", @@ -38,7 +82,7 @@ "description": "Please pick the type of entity you want to add.", "data": { "platform_to_add": "Platform", - "no_additional_platforms": "Do not add any more entities" + "no_additional_entities": "Do not add any more entities" } }, "add_entity": { @@ -118,100 +162,5 @@ } } }, - "options": { - "step": { - "init": { - "title": "Configure Tuya Device", - "description": "Basic configuration for device id `{device_id}`.", - "data": { - "friendly_name": "Friendly Name", - "host": "Host", - "local_key": "Local key", - "protocol_version": "Protocol Version", - "scan_interval": "Scan interval (seconds, only when not updating automatically)", - "entities": "Entities (uncheck an entity to remove it)" - } - }, - "entity": { - "title": "Entity Configuration", - "description": "Editing entity with DPS `{id}` and platform `{platform}`.", - "data": { - "id": "ID", - "friendly_name": "Friendly name", - "current": "Current", - "current_consumption": "Current Consumption", - "voltage": "Voltage", - "commands_set": "Open_Close_Stop Commands Set", - "positioning_mode": "Positioning mode", - "current_position_dp": "Current Position (for *position* mode only)", - "set_position_dp": "Set Position (for *position* mode only)", - "position_inverted": "Invert 0-100 position (for *position* mode only)", - "span_time": "Full opening time, in secs. (for *timed* mode only)", - "unit_of_measurement": "Unit of Measurement", - "device_class": "Device Class", - "scaling": "Scaling Factor", - "state_on": "On Value", - "state_off": "Off Value", - "powergo_dp": "Power DP (Usually 25 or 2)", - "idle_status_value": "Idle Status (comma-separated)", - "returning_status_value": "Returning Status", - "docked_status_value": "Docked Status (comma-separated)", - "fault_dp": "Fault DP (Usually 11)", - "battery_dp": "Battery status DP (Usually 14)", - "mode_dp": "Mode DP (Usually 27)", - "modes": "Modes list", - "return_mode": "Return home mode", - "fan_speed_dp": "Fan speeds DP (Usually 30)", - "fan_speeds": "Fan speeds list (comma-separated)", - "clean_time_dp": "Clean Time DP (Usually 33)", - "clean_area_dp": "Clean Area DP (Usually 32)", - "clean_record_dp": "Clean Record DP (Usually 34)", - "locate_dp": "Locate DP (Usually 31)", - "paused_state": "Pause state (pause, paused, etc)", - "stop_status": "Stop status", - "brightness": "Brightness (only for white color)", - "brightness_lower": "Brightness Lower Value", - "brightness_upper": "Brightness Upper Value", - "color_temp": "Color Temperature", - "color_temp_reverse": "Color Temperature Reverse", - "color": "Color", - "color_mode": "Color Mode", - "color_temp_min_kelvin": "Minimum Color Temperature in K", - "color_temp_max_kelvin": "Maximum Color Temperature in K", - "music_mode": "Music mode available", - "scene": "Scene", - "fan_speed_control": "Fan Speed Control dps", - "fan_oscillating_control": "Fan Oscillating Control dps", - "fan_speed_min": "minimum fan speed integer", - "fan_speed_max": "maximum fan speed integer", - "fan_speed_ordered_list": "Fan speed modes list (overrides speed min/max)", - "fan_direction":"fan direction dps", - "fan_direction_forward": "forward dps string", - "fan_direction_reverse": "reverse dps string", - "current_temperature_dp": "Current Temperature", - "target_temperature_dp": "Target Temperature", - "temperature_step": "Temperature Step (optional)", - "max_temperature_dp": "Max Temperature (optional)", - "min_temperature_dp": "Min Temperature (optional)", - "precision": "Precision (optional, for DPs values)", - "target_precision": "Target Precision (optional, for DPs values)", - "temperature_unit": "Temperature Unit (optional)", - "hvac_mode_dp": "HVAC Mode DP (optional)", - "hvac_mode_set": "HVAC Mode Set (optional)", - "hvac_action_dp": "HVAC Current Action DP (optional)", - "hvac_action_set": "HVAC Current Action Set (optional)", - "preset_dp": "Presets DP (optional)", - "preset_set": "Presets Set (optional)", - "eco_dp": "Eco DP (optional)", - "eco_value": "Eco value (optional)", - "heuristic_action": "Enable heuristic action (optional)" - } - }, - "yaml_import": { - "title": "Not Supported", - "description": "Options cannot be edited when configured via YAML." - } - } - }, "title": "LocalTuya" }