diff --git a/README.md b/README.md index 0234c5d..01cbba9 100644 --- a/README.md +++ b/README.md @@ -2,129 +2,95 @@ A Home Assistant custom Integration for local handling of Tuya-based devices. -This custom integration updates device status via push updates instead of polling, so status updates are fast (even when manually operated). +This custom integration updates device status via pushing updates instead of polling, so status updates are fast (even when manually operated). +The integration also supports the Tuya IoT Cloud APIs, for the retrieval of info and of the local_keys of the devices. + + +**NOTE: The Cloud API account configuration is not mandatory (LocalTuya can work also without it) but is strongly suggested for easy retrieval (and auto-update after re-pairing a device) of local_keys. Cloud API calls are performed only at startup, and when a local_key update is needed.** + The following Tuya device types are currently supported: -* 1 and multiple gang switches -* Wi-Fi smart plugs (including those with additional USB plugs) +* Switches * Lights * Covers * Fans -* Climates (soon) +* Climates +* Vacuums Energy monitoring (voltage, current, watts, etc.) is supported for compatible devices. +> **Currently, only Tuya protocols 3.1 and 3.3 are supported (3.4 is not).** + This repository's development began as code from [@NameLessJedi](https://github.com/NameLessJedi), [@mileperhour](https://github.com/mileperhour) and [@TradeFace](https://github.com/TradeFace). Their code was then deeply refactored to provide proper integration with Home Assistant environment, adding config flow and other features. Refer to the "Thanks to" section below. # Installation: -Copy the localtuya folder and all of its contents into your Home Assistant's custom_components folder. This folder is usually inside your `/config` folder. If you are running Hass.io, use SAMBA to copy the folder over. If you are running Home Assistant Supervised, the custom_components folder might be located at `/usr/share/hassio/homeassistant`. You may need to create the `custom_components` folder and then copy the localtuya folder and all of its contents into it +The easiest way, if you are using [HACS](https://hacs.xyz/), is to install LocalTuya through HACS. -Alternatively, you can install localtuya through [HACS](https://hacs.xyz/) by adding this repository. +For manual installation, copy the localtuya folder and all of its contents into your Home Assistant's custom_components folder. This folder is usually inside your `/config` folder. If you are running Hass.io, use SAMBA to copy the folder over. If you are running Home Assistant Supervised, the custom_components folder might be located at `/usr/share/hassio/homeassistant`. You may need to create the `custom_components` folder and then copy the localtuya folder and all of its contents into it. # Usage: -**NOTE: You must have your Tuya device's Key and ID in order to use localtuya. There are several ways to obtain the localKey depending on your environment and the devices you own. A good place to start getting info is https://github.com/codetheweb/tuyapi/blob/master/docs/SETUP.md or https://pypi.org/project/tinytuya/.** +**NOTE: You must have your Tuya device's Key and ID in order to use LocalTuya. The easiest way is to configure the Cloud API account in the integration. If you choose not to do it, there are several ways to obtain the local_keys depending on your environment and the devices you own. A good place to start getting info is https://github.com/codetheweb/tuyapi/blob/master/docs/SETUP.md or https://pypi.org/project/tinytuya/.** -**NOTE - Nov 2020: If you plan to integrate these devices on a network that has internet and blocking their internet access, you must also block DNS requests (to the local DNS server, e.g. 192.168.1.1). If you only block outbound internet, then the device will sit in a zombie state; it will refuse / not respond to any connections with the localkey. Therefore, you must first connect the devices with an active internet connection, grab each device localkey, and implement the block.** - -Devices can be configured in two ways: - -# Option one: YAML config files - -Add the proper entry to your configuration.yaml file. Several example configurations for different device types are provided below. Make sure to save when you are finished editing configuration.yaml. - -```yaml -localtuya: - - host: 192.168.1.x - device_id: xxxxx - local_key: xxxxx - friendly_name: Tuya Device - protocol_version: "3.3" - scan_interval: # optional, only needed if energy monitoring values are not updating - seconds: 30 # Values less than 10 seconds may cause stability issues - 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 - open_close_cmds: ["on_off","open_close"] # Optional, default: "on_off" - positioning_mode: ["none","position","timed"] # Optional, default: "none" - currpos_dps: 3 # Optional, required only for "position" mode - setpos_dps: 4 # Optional, required only for "position" mode - span_time: 25 # Full movement time: Optional, required only for "timed" mode - - - platform: fan - friendly_name: Device Fan - id: 3 # dps for on/off state - fan_direction: 4 # Optional, dps for fan direction - fan_direction_fwd: forward # String for the forward direction - fan_direction_rev: reverse # String for the reverse direction - fan_ordered_list: low,medium,high,auto # Optional, If this is used it will not use the min and max integers. - fan_oscilating_control: 4 # Optional, dps for fan osciallation - fan_speed_control: 3 # Optional, if ordered list not used, dps for speed control - fan_speed_min: 1 # Optional, if ordered list not used, minimum integer for speed range - fan_speed_max: 10 # Optional, if ordered list not used, maximum integer for speed range +**NOTE 2: If you plan to integrate these devices on a network that has internet and blocking their internet access, you must also block DNS requests (to the local DNS server, e.g. 192.168.1.1). If you only block outbound internet, then the device will sit in a zombie state; it will refuse / not respond to any connections with the localkey. Therefore, you must first connect the devices with an active internet connection, grab each device localkey, and implement the block.** - - platform: light - friendly_name: Device Light - id: 4 # Usually 1 or 20 - color_mode: 21 # Optional, usually 2 or 21, default: "none" - brightness: 22 # Optional, usually 3 or 22, default: "none" - color_temp: 23 # Optional, usually 4 or 23, default: "none" - color_temp_reverse: false # Optional, default: false - color: 24 # Optional, usually 5 (RGB_HSV) or 24 (HSV), default: "none" - brightness_lower: 29 # Optional, usually 0 or 29, default: 29 - brightness_upper: 1000 # Optional, usually 255 or 1000, default: 1000 - color_temp_min_kelvin: 2700 # Optional, default: 2700 - color_temp_max_kelvin: 6500 # Optional, default: 6500 - scene: 25 # Optional, usually 6 (RGB_HSV) or 25 (HSV), default: "none" - music_mode: False # Optional, some use internal mic, others, phone mic. Only internal mic is supported, default: "False" +# Adding the Integration - - 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 -``` +**NOTE: starting from v4.0.0, configuration using YAML files is no longer supported. The integration can only be configured using the config flow.** -Note that a single device can contain several different entities. Some examples: -- a cover device might have 1 (or many) cover entities, plus a switch to control backlight -- a multi-gang switch will contain several switch entities, one for each gang controlled -Restart Home Assistant when finished editing. +To start configuring the integration, just press the "+ADD INTEGRATION" button in the Settings - Integrations page, and select LocalTuya from the drop-down menu. +The Cloud API configuration page will appear, requesting to input your Tuya IoT Platform account credentials: -# Option two: Using config flow +![cloud_setup](https://github.com/rospogrigio/localtuya-homeassistant/blob/master/img/9-cloud_setup.png) -Start by going to Configuration - Devices & Services - Integration and pressing the "+ Add integration" button to create a new Integration, then select LocalTuya in the drop-down menu. -Wait for 6 seconds for the scanning of the devices in your LAN. Then, a drop-down menu will appear containing the list of detected devices: you can -select one of these, or manually input all the parameters. +To setup a Tuya IoT Platform account and setup a project in it, refer to the instructions for the official Tuya integration: +https://www.home-assistant.io/integrations/tuya/ +The place to find the Client ID and Secret is described in this link (in the ["Get Authorization Key"](https://www.home-assistant.io/integrations/tuya/#get-authorization-key) paragraph), while the User ID can be found in the "Link Tuya App Account" subtab within the Cloud project: + +![user_id.png](https://github.com/rospogrigio/localtuya-homeassistant/blob/master/img/8-user_id.png) + +> **Note: as stated in the above link, if you already have an account and an IoT project, make sure that it was created after May 25, 2021 (due to changes introduced in the cloud for Tuya 2.0). Otherwise, you need to create a new project. See the following screenshot for where to check your project creation date:** + +![project_date](https://github.com/rospogrigio/localtuya-homeassistant/blob/master/img/6-project_date.png) + +After pressing the Submit button, the first setup is complete and the Integration will be added. + +> **Note: it is not mandatory to input the Cloud API credentials: you can choose to tick the "Do not configure a Cloud API account" button, and the Integration will be added anyway.** + +After the Integration has been set up, devices can be added and configured pressing the Configure button in the Integrations page: + +![integration_configure](https://github.com/rospogrigio/localtuya-homeassistant/blob/master/img/10-integration_configure.png) + + +# Integration Configuration menu + +The configuration menu is the following: + +![config_menu](https://github.com/rospogrigio/localtuya-homeassistant/blob/master/img/11-config_menu.png) + +From this menu, you can select the "Reconfigure Cloud API account" to edit your Tuya Cloud credentials and settings, in case they have changed or if the integration was migrated from v.3.x.x versions. + +You can then proceed Adding or Editing your Tuya devices. + +# Adding/editing a device + +If you select to "Add or Edit a device", a drop-down menu will appear containing the list of detected devices (using auto-discovery if adding was selected, or the list of already configured devices if editing was selected): you can select one of these, or manually input all the parameters selecting the "..." option. > **Note: The tuya app on your device must be closed for the following steps to work reliably.** + ![discovery](https://github.com/rospogrigio/localtuya-homeassistant/blob/master/img/1-discovery.png) -If you have selected one entry, you only need to input the device's Friendly Name and the localKey. +If you have selected one entry, you only need to input the device's Friendly Name and localKey. These values will be automatically retrieved if you have configured your Cloud API account, otherwise you will need to input them manually. -Setting the scan interval is optional, only needed if energy/power values are not updating frequently enough by default. Values less than 10 seconds may cause stability issues. +Setting the scan interval is optional, it is only needed if energy/power values are not updating frequently enough by default. Values less than 10 seconds may cause stability issues. Once you press "Submit", the connection is tested to check that everything works. @@ -147,15 +113,22 @@ Once you configure the entities, the procedure is complete. You can now associat ![success](https://github.com/rospogrigio/localtuya-homeassistant/blob/master/img/5-success.png) +# Migration from LocalTuya v.3.x.x + +If you upgrade LocalTuya from v3.x.x or older, the config entry will automatically be migrated to the new setup. Everything should work as it did before the upgrade, apart from the fact that in the Integration tab you will see just one LocalTuya integration (showing the number of devices and entities configured) instead of several Integrations grouped within the LocalTuya Box. This will happen both if the old configuration was done using YAML files and with the config flow. Once migrated, you can just input your Tuya IoT account credentials to enable the support for the Cloud API (and benefit from the local_key retrieval and auto-update): see [Configuration menu](https://github.com/rospogrigio/localtuya#integration-configuration-menu). + +If you had configured LocalTuya using YAML files, you can delete all its references from within the YAML files because they will no longer be considered so they might bring confusion (only the logger configuration part needs to be kept, of course, see [Debugging](https://github.com/rospogrigio/localtuya#debugging) ). + + # Energy monitoring values You can obtain Energy monitoring (voltage, current) in two different ways: 1) Creating individual sensors, each one with the desired name. Note: Voltage and Consumption usually include the first decimal. You will need to scale the parament by 0.1 to get the correct values. -1) Access the voltage/current/current_consumption attributes of a switch, and define template sensors +2) Access the voltage/current/current_consumption attributes of a switch, and define template sensors Note: these values are already divided by 10 for Voltage and Consumption -1) On some devices, you may find that the energy values are not updating frequently enough by default. If so, set the scan interval (see above) to an appropriate value. Settings below 10 seconds may cause stability issues, 30 seconds is recommended. +3) On some devices, you may find that the energy values are not updating frequently enough by default. If so, set the scan interval (see above) to an appropriate value. Settings below 10 seconds may cause stability issues, 30 seconds is recommended. ```yaml sensor: @@ -197,6 +170,8 @@ logger: * Everything listed in https://github.com/rospogrigio/localtuya-homeassistant/issues/15 +* Support devices that use Tuya protocol v.3.4 + # Thanks to: NameLessJedi https://github.com/NameLessJedi/localtuya-homeassistant and mileperhour https://github.com/mileperhour/localtuya-homeassistant being the major sources of inspiration, and whose code for switches is substantially unchanged. @@ -206,3 +181,6 @@ TradeFace, for being the only one to provide the correct code for communication sean6541, for the working (standard) Python Handler for Tuya devices. postlund, for the ideas, for coding 95% of the refactoring and boosting the quality of this repo to levels hard to imagine (by me, at least) and teaching me A LOT of how things work in Home Assistant. + +Buy Me A Coffee +PayPal Logo diff --git a/custom_components/localtuya/__init__.py b/custom_components/localtuya/__init__.py index ad879b0..f1af7f0 100644 --- a/custom_components/localtuya/__init__.py +++ b/custom_components/localtuya/__init__.py @@ -1,98 +1,45 @@ -"""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 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_CLIENT_ID, + CONF_CLIENT_SECRET, CONF_DEVICE_ID, + CONF_DEVICES, CONF_ENTITIES, CONF_HOST, CONF_ID, CONF_PLATFORM, + CONF_REGION, + CONF_USERNAME, EVENT_HOMEASSISTANT_STOP, SERVICE_RELOAD, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.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 .config_flow import ENTRIES_VERSION, config_schema +from .const import ( + ATTR_UPDATED_AT, + CONF_NO_CLOUD, + CONF_PRODUCT_KEY, + CONF_USER_ID, + DATA_CLOUD, + DATA_DISCOVERY, + DOMAIN, + TUYA_DEVICES, +) from .discovery import TuyaDiscovery _LOGGER = logging.getLogger(__name__) @@ -116,34 +63,18 @@ 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, {}) + hass.data[DOMAIN][TUYA_DEVICES] = {} device_cache = {} async def _handle_reload(service): """Handle reload service call.""" - config = await async_integration_yaml_config(hass, DOMAIN) - - if not config or DOMAIN not in config: - return + _LOGGER.info("Service %s.reload called: reloading integration", DOMAIN) current_entries = hass.config_entries.async_entries(DOMAIN) - entries_by_id = {entry.data[CONF_DEVICE_ID]: entry for entry in current_entries} - - for conf in config[DOMAIN]: - _async_update_config_entry_if_from_yaml(hass, entries_by_id, conf) reload_tasks = [ hass.config_entries.async_reload(entry.entry_id) @@ -154,14 +85,11 @@ async def async_setup(hass: HomeAssistant, config: dict): async def _handle_set_dp(event): """Handle set_dp service call.""" - entry = async_config_entry_by_device_id(hass, event.data[CONF_DEVICE_ID]) - if not entry: + dev_id = event.data[CONF_DEVICE_ID] + if dev_id not in hass.data[DOMAIN][TUYA_DEVICES]: raise HomeAssistantError("unknown device id") - if entry.entry_id not in hass.data[DOMAIN]: - raise HomeAssistantError("device has not been discovered") - - device = hass.data[DOMAIN][entry.entry_id][TUYA_DEVICE] + device = hass.data[DOMAIN][TUYA_DEVICES][dev_id] if not device.connected: raise HomeAssistantError("not connected to device") @@ -174,63 +102,60 @@ async def async_setup(hass: HomeAssistant, config: dict): product_key = device["productKey"] # If device is not in cache, check if a config entry exists - if device_id not in device_cache: - entry = async_config_entry_by_device_id(hass, device_id) - if entry: - # Save address from config entry in cache to trigger - # potential update below - device_cache[device_id] = entry.data[CONF_HOST] - - if device_id not in device_cache: - return - entry = async_config_entry_by_device_id(hass, device_id) if entry is None: return - updates = {} + if device_id not in device_cache: + if entry and device_id in entry.data[CONF_DEVICES]: + # Save address from config entry in cache to trigger + # potential update below + host_ip = entry.data[CONF_DEVICES][device_id][CONF_HOST] + device_cache[device_id] = host_ip + + if device_id not in device_cache: + return + + dev_entry = entry.data[CONF_DEVICES][device_id] + + new_data = entry.data.copy() + updated = False if device_cache[device_id] != device_ip: - updates[CONF_HOST] = device_ip + updated = True + new_data[CONF_DEVICES][device_id][CONF_HOST] = device_ip device_cache[device_id] = device_ip - if entry.data.get(CONF_PRODUCT_KEY) != product_key: - updates[CONF_PRODUCT_KEY] = product_key + if dev_entry.get(CONF_PRODUCT_KEY) != product_key: + updated = True + new_data[CONF_DEVICES][device_id][CONF_PRODUCT_KEY] = product_key # Update settings if something changed, otherwise try to connect. Updating # settings triggers a reload of the config entry, which tears down the device # so no need to connect in that case. - if updates: - _LOGGER.debug("Update keys for device %s: %s", device_id, updates) - hass.config_entries.async_update_entry( - entry, data={**entry.data, **updates} + if updated: + _LOGGER.debug( + "Updating keys for device %s: %s %s", device_id, device_ip, product_key ) - elif entry.entry_id in hass.data[DOMAIN]: - _LOGGER.debug("Device %s found with IP %s", device_id, device_ip) + new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000)) + hass.config_entries.async_update_entry(entry, data=new_data) + device = hass.data[DOMAIN][TUYA_DEVICES][device_id] + if not device.connected: + device.async_connect() + elif device_id in hass.data[DOMAIN][TUYA_DEVICES]: + # _LOGGER.debug("Device %s found with IP %s", device_id, device_ip) - device = hass.data[DOMAIN][entry.entry_id][TUYA_DEVICE] - device.async_connect() - - discovery = TuyaDiscovery(_device_discovered) + device = hass.data[DOMAIN][TUYA_DEVICES][device_id] + if not device.connected: + device.async_connect() def _shutdown(event): """Clean up resources when shutting down.""" discovery.close() - try: - await discovery.start() - hass.data[DOMAIN][DATA_DISCOVERY] = discovery - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown) - except Exception: # pylint: disable=broad-except - _LOGGER.exception("failed to set up discovery") - async def _async_reconnect(now): """Try connecting to devices not already connected to.""" - for entry_id, value in hass.data[DOMAIN].items(): - if entry_id == DATA_DISCOVERY: - continue - - device = value[TUYA_DEVICE] + for device_id, device in hass.data[DOMAIN][TUYA_DEVICES].items(): if not device.connected: device.async_connect() @@ -246,29 +171,100 @@ 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 + discovery = TuyaDiscovery(_device_discovered) + try: + await discovery.start() + hass.data[DOMAIN][DATA_DISCOVERY] = discovery + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown) + except Exception: # pylint: disable=broad-except + _LOGGER.exception("failed to set up discovery") + + return True + + +async def async_migrate_entry(hass, config_entry: ConfigEntry): + """Migrate old entries merging all of them in one.""" + new_version = ENTRIES_VERSION + stored_entries = hass.config_entries.async_entries(DOMAIN) + if config_entry.version == 1: + _LOGGER.debug("Migrating config entry from version %s", config_entry.version) + + if config_entry.entry_id == stored_entries[0].entry_id: + _LOGGER.debug( + "Migrating the first config entry (%s)", config_entry.entry_id ) - ) + new_data = {} + new_data[CONF_REGION] = "eu" + new_data[CONF_CLIENT_ID] = "" + new_data[CONF_CLIENT_SECRET] = "" + new_data[CONF_USER_ID] = "" + new_data[CONF_USERNAME] = DOMAIN + new_data[CONF_NO_CLOUD] = True + new_data[CONF_DEVICES] = { + config_entry.data[CONF_DEVICE_ID]: config_entry.data.copy() + } + new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000)) + config_entry.version = new_version + hass.config_entries.async_update_entry( + config_entry, title=DOMAIN, data=new_data + ) + else: + _LOGGER.debug( + "Merging the config entry %s into the main one", config_entry.entry_id + ) + new_data = stored_entries[0].data.copy() + new_data[CONF_DEVICES].update( + {config_entry.data[CONF_DEVICE_ID]: config_entry.data.copy()} + ) + new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000)) + hass.config_entries.async_update_entry(stored_entries[0], data=new_data) + await hass.config_entries.async_remove(config_entry.entry_id) + + _LOGGER.info( + "Entry %s successfully migrated to version %s.", + config_entry.entry_id, + new_version, + ) return True async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): """Set up LocalTuya integration from a config entry.""" - unsub_listener = entry.add_update_listener(update_listener) + if entry.version < ENTRIES_VERSION: + _LOGGER.debug( + "Skipping setup for entry %s since its version (%s) is old", + entry.entry_id, + entry.version, + ) + return - device = TuyaDevice(hass, entry.data) + region = entry.data[CONF_REGION] + client_id = entry.data[CONF_CLIENT_ID] + secret = entry.data[CONF_CLIENT_SECRET] + user_id = entry.data[CONF_USER_ID] + tuya_api = TuyaCloudApi(hass, region, client_id, secret, user_id) + no_cloud = True + if CONF_NO_CLOUD in entry.data: + no_cloud = entry.data.get(CONF_NO_CLOUD) + if no_cloud: + _LOGGER.info("Cloud API account not configured.") + # wait 1 second to make sure possible migration has finished + await asyncio.sleep(1) + else: + res = await tuya_api.async_get_access_token() + if res != "ok": + _LOGGER.error("Cloud API connection failed: %s", res) + _LOGGER.info("Cloud API connection succeeded.") + res = await tuya_api.async_get_devices_list() + hass.data[DOMAIN][DATA_CLOUD] = tuya_api - 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, entry, dev_id) + hass.data[DOMAIN][TUYA_DEVICES][dev_id] = 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]) await asyncio.gather( *[ hass.config_entries.async_forward_entry_setup(entry, platform) @@ -277,30 +273,41 @@ 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)) + + unsub_listener = entry.add_update_listener(update_listener) + hass.data[DOMAIN][entry.entry_id] = {UNSUB_LISTENER: unsub_listener} return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): """Unload a config entry.""" + platforms = {} + + for dev_id, dev_entry in entry.data[CONF_DEVICES].items(): + for entity in dev_entry[CONF_ENTITIES]: + platforms[entity[CONF_PLATFORM]] = True + unload_ok = all( await asyncio.gather( *[ hass.config_entries.async_forward_entry_unload(entry, component) - for component in set( - entity[CONF_PLATFORM] for entity in entry.data[CONF_ENTITIES] - ) + for component in platforms ] ) ) hass.data[DOMAIN][entry.entry_id][UNSUB_LISTENER]() - await hass.data[DOMAIN][entry.entry_id][TUYA_DEVICE].close() + for dev_id, device in hass.data[DOMAIN][TUYA_DEVICES].items(): + if device.connected: + await device.close() + if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) + hass.data[DOMAIN][TUYA_DEVICES] = {} return True @@ -310,13 +317,53 @@ 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.""" + dev_id = list(device_entry.identifiers)[0][1].split("_")[-1] + + ent_reg = er.async_get(hass) + entities = { + ent.unique_id: ent.entity_id + for ent in er.async_entries_for_config_entry(ent_reg, config_entry.entry_id) + if dev_id in ent.unique_id + } + for entity_id in entities.values(): + ent_reg.async_remove(entity_id) + + if dev_id not in config_entry.data[CONF_DEVICES]: + _LOGGER.info( + "Device %s not found in config entry: finalizing device removal", dev_id + ) + return True + + await hass.data[DOMAIN][TUYA_DEVICES][dev_id].close() + + new_data = config_entry.data.copy() + new_data[CONF_DEVICES].pop(dev_id) + new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000)) + + hass.config_entries.async_update_entry( + config_entry, + data=new_data, + ) + + _LOGGER.info("Device %s removed.", dev_id) + + return True + + async def async_remove_orphan_entities(hass, entry): """Remove entities associated with config entry that has been removed.""" - ent_reg = await er.async_get_registry(hass) + return + ent_reg = er.async_get(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) } + _LOGGER.info("ENTITIES ORPHAN %s", entities) + return for entity in entry.data[CONF_ENTITIES]: if entity[CONF_ID] in entities: diff --git a/custom_components/localtuya/climate.py b/custom_components/localtuya/climate.py index 7b03f31..1400ec7 100644 --- a/custom_components/localtuya/climate.py +++ b/custom_components/localtuya/climate.py @@ -11,18 +11,18 @@ from homeassistant.components.climate import ( ClimateEntity, ) from homeassistant.components.climate.const import ( + CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, HVAC_MODE_AUTO, HVAC_MODE_HEAT, HVAC_MODE_OFF, + PRESET_AWAY, + PRESET_ECO, + PRESET_HOME, + PRESET_NONE, SUPPORT_PRESET_MODE, SUPPORT_TARGET_TEMPERATURE, SUPPORT_TARGET_TEMPERATURE_RANGE, - CURRENT_HVAC_IDLE, - CURRENT_HVAC_HEAT, - PRESET_NONE, - PRESET_ECO, - PRESET_AWAY, - PRESET_HOME, ) from homeassistant.const import ( ATTR_TEMPERATURE, @@ -37,21 +37,21 @@ from homeassistant.const import ( from .common import LocalTuyaEntity, async_setup_entry from .const import ( CONF_CURRENT_TEMPERATURE_DP, - CONF_MAX_TEMP_DP, - CONF_MIN_TEMP_DP, - CONF_PRECISION, - CONF_TARGET_PRECISION, - CONF_TARGET_TEMPERATURE_DP, - CONF_TEMPERATURE_STEP, - CONF_HVAC_MODE_DP, - CONF_HVAC_MODE_SET, + CONF_ECO_DP, + CONF_ECO_VALUE, CONF_HEURISTIC_ACTION, CONF_HVAC_ACTION_DP, CONF_HVAC_ACTION_SET, - CONF_ECO_DP, - CONF_ECO_VALUE, + CONF_HVAC_MODE_DP, + CONF_HVAC_MODE_SET, + CONF_MAX_TEMP_DP, + CONF_MIN_TEMP_DP, + CONF_PRECISION, CONF_PRESET_DP, CONF_PRESET_SET, + CONF_TARGET_PRECISION, + CONF_TARGET_TEMPERATURE_DP, + CONF_TEMPERATURE_STEP, ) _LOGGER = logging.getLogger(__name__) @@ -176,7 +176,7 @@ class LocaltuyaClimate(LocalTuyaEntity, ClimateEntity): self._has_presets = self.has_config(CONF_ECO_DP) or self.has_config( CONF_PRESET_DP ) - print("Initialized climate [{}]".format(self.name)) + _LOGGER.debug("Initialized climate [%s]", self.name) @property def supported_features(self): @@ -196,7 +196,7 @@ class LocaltuyaClimate(LocalTuyaEntity, ClimateEntity): return self._precision @property - def target_recision(self): + def target_precision(self): """Return the precision of the target.""" return self._target_precision diff --git a/custom_components/localtuya/cloud_api.py b/custom_components/localtuya/cloud_api.py new file mode 100644 index 0000000..6802171 --- /dev/null +++ b/custom_components/localtuya/cloud_api.py @@ -0,0 +1,136 @@ +"""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.""" + resp = await self.async_make_request("GET", "/v1.0/token?grant_type=1") + + 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/custom_components/localtuya/common.py b/custom_components/localtuya/common.py index ea821a5..79eadc9 100644 --- a/custom_components/localtuya/common.py +++ b/custom_components/localtuya/common.py @@ -1,10 +1,12 @@ """Code shared between all platforms.""" import asyncio import logging +import time from datetime import timedelta from homeassistant.const import ( CONF_DEVICE_ID, + CONF_DEVICES, CONF_ENTITIES, CONF_FRIENDLY_NAME, CONF_HOST, @@ -13,20 +15,22 @@ from homeassistant.const import ( CONF_SCAN_INTERVAL, ) from homeassistant.core import callback -from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) +from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.restore_state import RestoreEntity from . import pytuya from .const import ( + ATTR_UPDATED_AT, CONF_LOCAL_KEY, - CONF_PRODUCT_KEY, + CONF_MODEL, CONF_PROTOCOL_VERSION, + DATA_CLOUD, DOMAIN, - TUYA_DEVICE, + TUYA_DEVICES, ) _LOGGER = logging.getLogger(__name__) @@ -42,7 +46,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,29 +59,38 @@ 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 - ) - 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 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], + ) + ) async_add_entities(entities) @@ -90,7 +103,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}") @@ -101,7 +114,7 @@ def async_config_entry_by_device_id(hass, device_id): """Look up config entry by device id.""" current_entries = hass.config_entries.async_entries(DOMAIN) for entry in current_entries: - if entry.data[CONF_DEVICE_ID] == device_id: + if device_id in entry.data[CONF_DEVICES]: return entry return None @@ -109,11 +122,12 @@ def async_config_entry_by_device_id(hass, device_id): class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger): """Cache wrapper for pytuya.TuyaInterface.""" - def __init__(self, hass, config_entry): + 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 = {} @@ -121,10 +135,11 @@ class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger): self._connect_task = None self._disconnect_task = None self._unsub_interval = None - self.set_logger(_LOGGER, config_entry[CONF_DEVICE_ID]) + self._local_key = self._dev_config_entry[CONF_LOCAL_KEY] + 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 config_entry[CONF_ENTITIES]: + for entity in self._dev_config_entry[CONF_ENTITIES]: self.dps_to_request[entity[CONF_ID]] = None @property @@ -139,14 +154,14 @@ class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger): async def _make_connection(self): """Subscribe localtuya entity events.""" - self.debug("Connecting to %s", self._config_entry[CONF_HOST]) + self.debug("Connecting to %s", self._dev_config_entry[CONF_HOST]) try: self._interface = await pytuya.connect( - self._config_entry[CONF_HOST], - self._config_entry[CONF_DEVICE_ID], - self._config_entry[CONF_LOCAL_KEY], - float(self._config_entry[CONF_PROTOCOL_VERSION]), + self._dev_config_entry[CONF_HOST], + self._dev_config_entry[CONF_DEVICE_ID], + self._local_key, + float(self._dev_config_entry[CONF_PROTOCOL_VERSION]), self, ) self._interface.add_dps_to_request(self.dps_to_request) @@ -162,31 +177,58 @@ class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger): self.debug( "New entity %s was added to %s", entity_id, - self._config_entry[CONF_HOST], + self._dev_config_entry[CONF_HOST], ) self._dispatch_status() - signal = f"localtuya_entity_{self._config_entry[CONF_DEVICE_ID]}" + 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._config_entry - and self._config_entry[CONF_SCAN_INTERVAL] > 0 + CONF_SCAN_INTERVAL in self._dev_config_entry + and self._dev_config_entry[CONF_SCAN_INTERVAL] > 0 ): self._unsub_interval = async_track_time_interval( self._hass, self._async_refresh, - timedelta(seconds=self._config_entry[CONF_SCAN_INTERVAL]), + timedelta(seconds=self._dev_config_entry[CONF_SCAN_INTERVAL]), ) - except Exception: # pylint: disable=broad-except - self.exception(f"Connect to {self._config_entry[CONF_HOST]} failed") + except UnicodeDecodeError as e: # pylint: disable=broad-except + self.exception( + f"Connect to {self._dev_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._dev_config_entry[CONF_HOST]} failed") + if "json.decode" in str(type(e)): + await self.update_local_key() + if self._interface is not None: await self._interface.close() self._interface = None 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() @@ -201,6 +243,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._dev_config_entry[CONF_FRIENDLY_NAME], + ) async def set_dp(self, state, dp_index): """Change value of a DP of the Tuya device.""" @@ -211,7 +257,7 @@ class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger): self.exception("Failed to set DP %d to %d", dp_index, state) else: self.error( - "Not connected to device %s", self._config_entry[CONF_FRIENDLY_NAME] + "Not connected to device %s", self._dev_config_entry[CONF_FRIENDLY_NAME] ) async def set_dps(self, states): @@ -223,7 +269,7 @@ class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger): self.exception("Failed to set DPs %r", states) else: self.error( - "Not connected to device %s", self._config_entry[CONF_FRIENDLY_NAME] + "Not connected to device %s", self._dev_config_entry[CONF_FRIENDLY_NAME] ) @callback @@ -233,13 +279,13 @@ class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger): self._dispatch_status() def _dispatch_status(self): - signal = f"localtuya_{self._config_entry[CONF_DEVICE_ID]}" + 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._config_entry[CONF_DEVICE_ID]}" + 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() @@ -255,11 +301,11 @@ class LocalTuyaEntity(RestoreEntity, pytuya.ContextualLogger): """Initialize the Tuya entity.""" super().__init__() self._device = device - self._config_entry = config_entry + self._dev_config_entry = config_entry self._config = get_entity_config(config_entry, dp_id) self._dp_id = dp_id self._status = {} - self.set_logger(logger, self._config_entry.data[CONF_DEVICE_ID]) + self.set_logger(logger, self._dev_config_entry[CONF_DEVICE_ID]) async def async_added_to_hass(self): """Subscribe localtuya events.""" @@ -281,27 +327,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._dev_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._dev_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._dev_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._dev_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._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 @@ -317,7 +364,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._dev_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..695baa9 100644 --- a/custom_components/localtuya/config_flow.py +++ b/custom_components/localtuya/config_flow.py @@ -1,29 +1,46 @@ """Config flow for LocalTuya integration integration.""" import errno import logging +import time from importlib import import_module import homeassistant.helpers.config_validation as cv +import homeassistant.helpers.entity_registry as er import voluptuous as vol from homeassistant import config_entries, core, exceptions -from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, CONF_DEVICE_ID, + CONF_DEVICES, CONF_ENTITIES, CONF_FRIENDLY_NAME, CONF_HOST, CONF_ID, + CONF_NAME, CONF_PLATFORM, + CONF_REGION, CONF_SCAN_INTERVAL, + CONF_USERNAME, ) from homeassistant.core import callback -from .common import async_config_entry_by_device_id, pytuya -from .const import CONF_DPS_STRINGS # pylint: disable=unused-import +from .cloud_api import TuyaCloudApi +from .common import pytuya from .const import ( + ATTR_UPDATED_AT, + CONF_ACTION, + CONF_ADD_DEVICE, + CONF_DPS_STRINGS, + CONF_EDIT_DEVICE, CONF_LOCAL_KEY, - CONF_PRODUCT_KEY, + CONF_MODEL, + CONF_NO_CLOUD, + CONF_PRODUCT_NAME, CONF_PROTOCOL_VERSION, + CONF_SETUP_CLOUD, + CONF_USER_ID, + DATA_CLOUD, DATA_DISCOVERY, DOMAIN, PLATFORMS, @@ -32,13 +49,38 @@ from .discovery import discover _LOGGER = logging.getLogger(__name__) +ENTRIES_VERSION = 2 + PLATFORM_TO_ADD = "platform_to_add" -NO_ADDITIONAL_PLATFORMS = "no_additional_platforms" -DISCOVERED_DEVICE = "discovered_device" +NO_ADDITIONAL_ENTITIES = "no_additional_entities" +SELECTED_DEVICE = "selected_device" CUSTOM_DEVICE = "..." -BASIC_INFO_SCHEMA = vol.Schema( +CONF_ACTIONS = { + CONF_ADD_DEVICE: "Add a new device", + CONF_EDIT_DEVICE: "Edit a device", + CONF_SETUP_CLOUD: "Reconfigure Cloud API account", +} + +CONFIGURE_SCHEMA = vol.Schema( + { + vol.Required(CONF_ACTION, default=CONF_ADD_DEVICE): vol.In(CONF_ACTIONS), + } +) + +CLOUD_SETUP_SCHEMA = vol.Schema( + { + vol.Required(CONF_REGION, default="eu"): vol.In(["eu", "us", "cn", "in"]), + vol.Optional(CONF_CLIENT_ID): cv.string, + vol.Optional(CONF_CLIENT_SECRET): cv.string, + vol.Optional(CONF_USER_ID): cv.string, + vol.Optional(CONF_USERNAME, default=DOMAIN): cv.string, + vol.Required(CONF_NO_CLOUD, default=False): bool, + } +) + +CONFIGURE_DEVICE_SCHEMA = vol.Schema( { vol.Required(CONF_FRIENDLY_NAME): str, vol.Required(CONF_LOCAL_KEY): str, @@ -49,7 +91,6 @@ BASIC_INFO_SCHEMA = vol.Schema( } ) - DEVICE_SCHEMA = vol.Schema( { vol.Required(CONF_HOST): cv.string, @@ -62,30 +103,35 @@ 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()] - return vol.Schema( - {vol.Required(DISCOVERED_DEVICE): vol.In(device_list + [CUSTOM_DEVICE])} - ) +def devices_schema(discovered_devices, cloud_devices_list, add_custom_device=True): + """Create schema for devices step.""" + devices = {} + for dev_id, dev_host in discovered_devices.items(): + dev_name = dev_id + if dev_id in cloud_devices_list.keys(): + dev_name = cloud_devices_list[dev_id][CONF_NAME] + devices[dev_id] = f"{dev_name} ({dev_host})" + + if add_custom_device: + devices.update({CUSTOM_DEVICE: CUSTOM_DEVICE}) + + # devices.update( + # { + # ent.data[CONF_DEVICE_ID]: ent.data[CONF_FRIENDLY_NAME] + # for ent in entries + # } + # ) + return vol.Schema({vol.Required(SELECTED_DEVICE): vol.In(devices)}) def options_schema(entities): """Create schema for options.""" entity_names = [ - f"{entity[CONF_ID]} {entity[CONF_FRIENDLY_NAME]}" for entity in entities + f"{entity[CONF_ID]}: {entity[CONF_FRIENDLY_NAME]}" for entity in entities ] return vol.Schema( { @@ -210,10 +256,34 @@ async def validate_input(hass: core.HomeAssistant, data): return dps_string_list(detected_dps) +async def attempt_cloud_connection(hass, user_input): + """Create device.""" + cloud_api = TuyaCloudApi( + hass, + user_input.get(CONF_REGION), + user_input.get(CONF_CLIENT_ID), + user_input.get(CONF_CLIENT_SECRET), + user_input.get(CONF_USER_ID), + ) + + res = await cloud_api.async_get_access_token() + if res != "ok": + _LOGGER.error("Cloud API connection failed: %s", res) + return cloud_api, {"reason": "authentication_failed", "msg": res} + + res = await cloud_api.async_get_devices_list() + if res != "ok": + _LOGGER.error("Cloud API get_devices_list failed: %s", res) + return cloud_api, {"reason": "device_list_failed", "msg": res} + _LOGGER.info("Cloud API connection succeeded.") + + return cloud_api, {} + + class LocaltuyaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for LocalTuya integration.""" - VERSION = 1 + VERSION = ENTRIES_VERSION CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL @staticmethod @@ -224,29 +294,157 @@ 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: - if user_input[DISCOVERED_DEVICE] != CUSTOM_DEVICE: - self.selected_device = user_input[DISCOVERED_DEVICE].split(" ")[0] - return await self.async_step_basic_info() + if user_input.get(CONF_NO_CLOUD): + for i in [CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_USER_ID]: + user_input[i] = "" + return await self._create_entry(user_input) + cloud_api, res = await attempt_cloud_connection(self.hass, user_input) + + if not res: + return await self._create_entry(user_input) + errors["base"] = res["reason"] + placeholders = {"msg": res["msg"]} + + defaults = {} + defaults.update(user_input or {}) + + return self.async_show_form( + step_id="user", + data_schema=schema_defaults(CLOUD_SETUP_SCHEMA, **defaults), + errors=errors, + description_placeholders=placeholders, + ) + + async def _create_entry(self, user_input): + """Register new entry.""" + # if self._async_current_entries(): + # return self.async_abort(reason="already_configured") + + await self.async_set_unique_id(user_input.get(CONF_USER_ID)) + user_input[CONF_DEVICES] = {} + + return self.async_create_entry( + title=user_input.get(CONF_USERNAME), + data=user_input, + ) + + async def async_step_import(self, user_input): + """Handle import from YAML.""" + _LOGGER.error( + "Configuration via YAML file is no longer supported by this integration." + ) + + +class LocalTuyaOptionsFlowHandler(config_entries.OptionsFlow): + """Handle options flow for LocalTuya integration.""" + + def __init__(self, config_entry): + """Initialize localtuya options flow.""" + self.config_entry = config_entry + # self.dps_strings = config_entry.data.get(CONF_DPS_STRINGS, gen_dps_strings()) + # self.entities = config_entry.data[CONF_ENTITIES] + self.selected_device = None + self.editing_device = False + self.device_data = None + self.dps_strings = [] + self.selected_platform = None + self.discovered_devices = {} + self.entities = [] + + async def async_step_init(self, user_input=None): + """Manage basic options.""" + # device_id = self.config_entry.data[CONF_DEVICE_ID] + if user_input is not None: + if user_input.get(CONF_ACTION) == CONF_SETUP_CLOUD: + return await self.async_step_cloud_setup() + if user_input.get(CONF_ACTION) == CONF_ADD_DEVICE: + return await self.async_step_add_device() + if user_input.get(CONF_ACTION) == CONF_EDIT_DEVICE: + return await self.async_step_edit_device() + + return self.async_show_form( + step_id="init", + data_schema=CONFIGURE_SCHEMA, + ) + + async def async_step_cloud_setup(self, user_input=None): + """Handle the initial step.""" + errors = {} + placeholders = {} + if user_input is not None: + if user_input.get(CONF_NO_CLOUD): + new_data = self.config_entry.data.copy() + new_data.update(user_input) + for i in [CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_USER_ID]: + new_data[i] = "" + self.hass.config_entries.async_update_entry( + self.config_entry, + data=new_data, + ) + return self.async_create_entry( + title=new_data.get(CONF_USERNAME), data={} + ) + + cloud_api, res = await attempt_cloud_connection(self.hass, user_input) + + if not res: + new_data = self.config_entry.data.copy() + new_data.update(user_input) + cloud_devs = cloud_api.device_list + for dev_id, dev in new_data[CONF_DEVICES].items(): + if CONF_MODEL not in dev and dev_id in cloud_devs: + model = cloud_devs[dev_id].get(CONF_PRODUCT_NAME) + new_data[CONF_DEVICES][dev_id][CONF_MODEL] = model + new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000)) + + self.hass.config_entries.async_update_entry( + self.config_entry, + data=new_data, + ) + return self.async_create_entry( + title=new_data.get(CONF_USERNAME), data={} + ) + errors["base"] = res["reason"] + placeholders = {"msg": res["msg"]} + + defaults = self.config_entry.data.copy() + defaults.update(user_input or {}) + defaults[CONF_NO_CLOUD] = False + + return self.async_show_form( + step_id="cloud_setup", + data_schema=schema_defaults(CLOUD_SETUP_SCHEMA, **defaults), + errors=errors, + description_placeholders=placeholders, + ) + + async def async_step_add_device(self, user_input=None): + """Handle adding a new device.""" # Use cache if available or fallback to manual discovery - devices = {} + self.editing_device = False + self.selected_device = None + errors = {} + if user_input is not None: + if user_input[SELECTED_DEVICE] != CUSTOM_DEVICE: + self.selected_device = user_input[SELECTED_DEVICE] + + return await self.async_step_configure_device() + + self.discovered_devices = {} data = self.hass.data.get(DOMAIN) + if data and DATA_DISCOVERY in data: - devices = data[DATA_DISCOVERY].devices + self.discovered_devices = data[DATA_DISCOVERY].devices else: try: - devices = await discover() + self.discovered_devices = await discover() except OSError as ex: if ex.errno == errno.EADDRINUSE: errors["base"] = "address_in_use" @@ -256,30 +454,82 @@ class LocaltuyaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.exception("discovery failed") errors["base"] = "discovery_failed" - self.devices = { - ip: dev - for ip, dev in devices.items() - if dev["gwId"] not in self._async_current_ids() + devices = { + dev_id: dev["ip"] + for dev_id, dev in self.discovered_devices.items() + if dev["gwId"] not in self.config_entry.data[CONF_DEVICES] } return self.async_show_form( - step_id="user", + step_id="add_device", + data_schema=devices_schema( + 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.""" + 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: - await self.async_set_unique_id(user_input[CONF_DEVICE_ID]) + self.selected_device = user_input[SELECTED_DEVICE] + dev_conf = self.config_entry.data[CONF_DEVICES][self.selected_device] + self.dps_strings = dev_conf.get(CONF_DPS_STRINGS, gen_dps_strings()) + self.entities = dev_conf[CONF_ENTITIES] + return await self.async_step_configure_device() + + devices = {} + for dev_id, configured_dev in self.config_entry.data[CONF_DEVICES].items(): + devices[dev_id] = configured_dev[CONF_HOST] + + return self.async_show_form( + step_id="edit_device", + data_schema=devices_schema( + devices, self.hass.data[DOMAIN][DATA_CLOUD].device_list, False + ), + errors=errors, + ) + + async def async_step_configure_device(self, user_input=None): + """Handle input of basic info.""" + errors = {} + dev_id = self.selected_device + if user_input is not None: try: - self.basic_info = user_input - if self.selected_device is not None: - self.basic_info[CONF_PRODUCT_KEY] = self.devices[ - self.selected_device - ]["productKey"] + self.device_data = user_input.copy() + if dev_id is not None: + # self.device_data[CONF_PRODUCT_KEY] = self.devices[ + # self.selected_device + # ]["productKey"] + cloud_devs = self.hass.data[DOMAIN][DATA_CLOUD].device_list + if dev_id in cloud_devs: + self.device_data[CONF_MODEL] = cloud_devs[dev_id].get( + CONF_PRODUCT_NAME + ) + if self.editing_device: + self.device_data.update( + { + CONF_DEVICE_ID: dev_id, + CONF_DPS_STRINGS: self.dps_strings, + CONF_ENTITIES: [], + } + ) + if user_input[CONF_ENTITIES]: + entity_ids = [ + int(entity.split(":")[0]) + for entity in user_input[CONF_ENTITIES] + ] + device_config = self.config_entry.data[CONF_DEVICES][dev_id] + self.entities = [ + entity + for entity in device_config[CONF_ENTITIES] + if entity[CONF_ID] in entity_ids + ] + return await self.async_step_configure_entity() + self.dps_strings = await validate_input(self.hass, user_input) return await self.async_step_pick_entity_type() except CannotConnect: @@ -292,135 +542,94 @@ class LocaltuyaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" - # If selected device exists as a config entry, load config from it - if self.selected_device in self._async_current_ids(): - entry = async_config_entry_by_device_id(self.hass, self.selected_device) - await self.async_set_unique_id(entry.data[CONF_DEVICE_ID]) - self.basic_info = entry.data.copy() - self.dps_strings = self.basic_info.pop(CONF_DPS_STRINGS).copy() - self.entities = self.basic_info.pop(CONF_ENTITIES).copy() - return await self.async_step_pick_entity_type() - - # Insert default values from discovery if present defaults = {} - defaults.update(user_input or {}) - if self.selected_device is not None: - device = self.devices[self.selected_device] - defaults[CONF_HOST] = device.get("ip") - defaults[CONF_DEVICE_ID] = device.get("gwId") - defaults[CONF_PROTOCOL_VERSION] = device.get("version") + if self.editing_device: + # If selected device exists as a config entry, load config from it + defaults = self.config_entry.data[CONF_DEVICES][dev_id].copy() + schema = schema_defaults(options_schema(self.entities), **defaults) + placeholders = {"for_device": f" for device `{dev_id}`"} + else: + defaults[CONF_PROTOCOL_VERSION] = "3.3" + defaults[CONF_HOST] = "" + defaults[CONF_DEVICE_ID] = "" + defaults[CONF_LOCAL_KEY] = "" + defaults[CONF_FRIENDLY_NAME] = "" + if dev_id is not None: + # Insert default values from discovery and cloud if present + device = self.discovered_devices[dev_id] + defaults[CONF_HOST] = device.get("ip") + defaults[CONF_DEVICE_ID] = device.get("gwId") + defaults[CONF_PROTOCOL_VERSION] = device.get("version") + cloud_devs = self.hass.data[DOMAIN][DATA_CLOUD].device_list + if dev_id in cloud_devs: + defaults[CONF_LOCAL_KEY] = cloud_devs[dev_id].get(CONF_LOCAL_KEY) + defaults[CONF_FRIENDLY_NAME] = cloud_devs[dev_id].get(CONF_NAME) + schema = schema_defaults(CONFIGURE_DEVICE_SCHEMA, **defaults) + + placeholders = {"for_device": ""} return self.async_show_form( - step_id="basic_info", - data_schema=schema_defaults(BASIC_INFO_SCHEMA, **defaults), + step_id="configure_device", + data_schema=schema, errors=errors, + description_placeholders=placeholders, ) async def async_step_pick_entity_type(self, user_input=None): """Handle asking if user wants to add another entity.""" if user_input is not None: - if user_input.get(NO_ADDITIONAL_PLATFORMS): + if user_input.get(NO_ADDITIONAL_ENTITIES): config = { - **self.basic_info, + **self.device_data, 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 + + dev_id = self.device_data.get(CONF_DEVICE_ID) + if dev_id in self.config_entry.data[CONF_DEVICES]: + 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", + }, + ) + + new_data = self.config_entry.data.copy() + new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000)) + new_data[CONF_DEVICES].update({dev_id: config}) + + self.hass.config_entries.async_update_entry( + self.config_entry, + data=new_data, ) + return self.async_create_entry(title="", data={}) - self.platform = user_input[PLATFORM_TO_ADD] - return await self.async_step_add_entity() + 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 iff at least one + # Add a checkbox that allows bailing out from config flow if at least one # entity has been added schema = PICK_ENTITY_SCHEMA - if self.platform is not None: + if self.selected_platform is not None: schema = schema.extend( - {vol.Required(NO_ADDITIONAL_PLATFORMS, default=True): bool} + {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: - 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}, - ) + def available_dps_strings(self): + """Return list of DPs use by the device's entities.""" + available_dps = [] + used_dps = [str(entity[CONF_ID]) for entity in self.entities] + for dp_string in self.dps_strings: + dp = dp_string.split(" ")[0] + if dp not in used_dps: + available_dps.append(dp_string) + return available_dps async def async_step_entity(self, user_input=None): """Manage entity settings.""" @@ -429,13 +638,13 @@ class LocalTuyaOptionsFlowHandler(config_entries.OptionsFlow): entity = strip_dps_values(user_input, self.dps_strings) entity[CONF_ID] = self.current_entity[CONF_ID] entity[CONF_PLATFORM] = self.current_entity[CONF_PLATFORM] - self.data[CONF_ENTITIES].append(entity) + self.device_data[CONF_ENTITIES].append(entity) - if len(self.entities) == len(self.data[CONF_ENTITIES]): + if len(self.entities) == len(self.device_data[CONF_ENTITIES]): self.hass.config_entries.async_update_entry( self.config_entry, - title=self.data[CONF_FRIENDLY_NAME], - data=self.data, + title=self.device_data[CONF_FRIENDLY_NAME], + data=self.device_data, ) return self.async_create_entry(title="", data={}) @@ -454,16 +663,84 @@ class LocalTuyaOptionsFlowHandler(config_entries.OptionsFlow): }, ) + async def async_step_configure_entity(self, user_input=None): + """Manage entity settings.""" + errors = {} + if user_input is not None: + if self.editing_device: + entity = strip_dps_values(user_input, self.dps_strings) + entity[CONF_ID] = self.current_entity[CONF_ID] + entity[CONF_PLATFORM] = self.current_entity[CONF_PLATFORM] + self.device_data[CONF_ENTITIES].append(entity) + + if len(self.entities) == len(self.device_data[CONF_ENTITIES]): + # finished editing device. Let's store the new config entry.... + dev_id = self.device_data[CONF_DEVICE_ID] + new_data = self.config_entry.data.copy() + entry_id = self.config_entry.entry_id + # removing entities from registry (they will be recreated) + ent_reg = await er.async_get_registry(self.hass) + reg_entities = { + ent.unique_id: ent.entity_id + for ent in er.async_entries_for_config_entry(ent_reg, entry_id) + if dev_id in ent.unique_id + } + for entity_id in reg_entities.values(): + ent_reg.async_remove(entity_id) + + new_data[CONF_DEVICES][dev_id] = self.device_data + new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000)) + self.hass.config_entries.async_update_entry( + self.config_entry, + data=new_data, + ) + return self.async_create_entry(title="", data={}) + else: + user_input[CONF_PLATFORM] = self.selected_platform + self.entities.append(strip_dps_values(user_input, self.dps_strings)) + # new entity added. Let's check if there are more left... + user_input = None + if len(self.available_dps_strings()) == 0: + user_input = {NO_ADDITIONAL_ENTITIES: True} + return await self.async_step_pick_entity_type(user_input) + + if self.editing_device: + schema = platform_schema( + self.current_entity[CONF_PLATFORM], self.dps_strings, allow_id=False + ) + schema = schema_defaults(schema, self.dps_strings, **self.current_entity) + placeholders = { + "entity": f"entity with DP {self.current_entity[CONF_ID]}", + "platform": self.current_entity[CONF_PLATFORM], + } + else: + available_dps = self.available_dps_strings() + schema = platform_schema(self.selected_platform, available_dps) + placeholders = { + "entity": "an entity", + "platform": self.selected_platform, + } + + return self.async_show_form( + step_id="configure_entity", + data_schema=schema, + errors=errors, + description_placeholders=placeholders, + ) + async def async_step_yaml_import(self, user_input=None): """Manage YAML imports.""" - 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): """Existing configuration for entity currently being edited.""" - return self.entities[len(self.data[CONF_ENTITIES])] + return self.entities[len(self.device_data[CONF_ENTITIES])] class CannotConnect(exceptions.HomeAssistantError): diff --git a/custom_components/localtuya/const.py b/custom_components/localtuya/const.py index 4148771..c940304 100644 --- a/custom_components/localtuya/const.py +++ b/custom_components/localtuya/const.py @@ -1,13 +1,45 @@ """Constants for localtuya integration.""" +DOMAIN = "localtuya" + +DATA_DISCOVERY = "discovery" +DATA_CLOUD = "cloud_data" + +# Platforms in this list must support config flows +PLATFORMS = [ + "binary_sensor", + "climate", + "cover", + "fan", + "light", + "number", + "select", + "sensor", + "switch", + "vacuum", +] + +TUYA_DEVICES = "tuya_devices" + ATTR_CURRENT = "current" ATTR_CURRENT_CONSUMPTION = "current_consumption" ATTR_VOLTAGE = "voltage" +ATTR_UPDATED_AT = "updated_at" +# config flow CONF_LOCAL_KEY = "local_key" CONF_PROTOCOL_VERSION = "protocol_version" CONF_DPS_STRINGS = "dps_strings" +CONF_MODEL = "model" CONF_PRODUCT_KEY = "product_key" +CONF_PRODUCT_NAME = "product_name" +CONF_USER_ID = "user_id" + +CONF_ACTION = "action" +CONF_ADD_DEVICE = "add_device" +CONF_EDIT_DEVICE = "edit_device" +CONF_SETUP_CLOUD = "setup_cloud" +CONF_NO_CLOUD = "no_cloud" # light CONF_BRIGHTNESS_LOWER = "brightness_lower" @@ -81,23 +113,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/cover.py b/custom_components/localtuya/cover.py index 43f59a6..2a3eb8b 100644 --- a/custom_components/localtuya/cover.py +++ b/custom_components/localtuya/cover.py @@ -75,7 +75,7 @@ class LocaltuyaCover(LocalTuyaEntity, CoverEntity): self._state = self._stop_cmd self._previous_state = self._state self._current_cover_position = 0 - print("Initialized cover [{}]".format(self.name)) + _LOGGER.debug("Initialized cover [%s]", self.name) @property def supported_features(self): diff --git a/custom_components/localtuya/diagnostics.py b/custom_components/localtuya/diagnostics.py new file mode 100644 index 0000000..9c84a93 --- /dev/null +++ b/custom_components/localtuya/diagnostics.py @@ -0,0 +1,65 @@ +"""Diagnostics support for LocalTuya.""" +from __future__ import annotations + +import copy +import logging +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_DEVICES +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntry + +from .const import CONF_LOCAL_KEY, CONF_USER_ID, DATA_CLOUD, DOMAIN + +CLOUD_DEVICES = "cloud_devices" +DEVICE_CONFIG = "device_config" +DEVICE_CLOUD_INFO = "device_cloud_info" + +_LOGGER = logging.getLogger(__name__) + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + data = {} + data = dict(entry.data) + tuya_api = hass.data[DOMAIN][DATA_CLOUD] + # censoring private information on integration diagnostic data + for field in [CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_USER_ID]: + data[field] = f"{data[field][0:3]}...{data[field][-3:]}" + data[CONF_DEVICES] = copy.deepcopy(entry.data[CONF_DEVICES]) + for dev_id, dev in data[CONF_DEVICES].items(): + local_key = dev[CONF_LOCAL_KEY] + local_key_obfuscated = f"{local_key[0:3]}...{local_key[-3:]}" + dev[CONF_LOCAL_KEY] = local_key_obfuscated + data[CLOUD_DEVICES] = tuya_api.device_list + for dev_id, dev in data[CLOUD_DEVICES].items(): + local_key = data[CLOUD_DEVICES][dev_id][CONF_LOCAL_KEY] + local_key_obfuscated = f"{local_key[0:3]}...{local_key[-3:]}" + data[CLOUD_DEVICES][dev_id][CONF_LOCAL_KEY] = local_key_obfuscated + return data + + +async def async_get_device_diagnostics( + hass: HomeAssistant, entry: ConfigEntry, device: DeviceEntry +) -> dict[str, Any]: + """Return diagnostics for a device entry.""" + data = {} + dev_id = list(device.identifiers)[0][1].split("_")[-1] + data[DEVICE_CONFIG] = entry.data[CONF_DEVICES][dev_id].copy() + # NOT censoring private information on device diagnostic data + # local_key = data[DEVICE_CONFIG][CONF_LOCAL_KEY] + # data[DEVICE_CONFIG][CONF_LOCAL_KEY] = f"{local_key[0:3]}...{local_key[-3:]}" + + tuya_api = hass.data[DOMAIN][DATA_CLOUD] + if dev_id in tuya_api.device_list: + data[DEVICE_CLOUD_INFO] = tuya_api.device_list[dev_id] + # NOT censoring private information on device diagnostic data + # local_key = data[DEVICE_CLOUD_INFO][CONF_LOCAL_KEY] + # local_key_obfuscated = "{local_key[0:3]}...{local_key[-3:]}" + # data[DEVICE_CLOUD_INFO][CONF_LOCAL_KEY] = local_key_obfuscated + + # data["log"] = hass.data[DOMAIN][CONF_DEVICES][dev_id].logger.retrieve_log() + return data 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/light.py b/custom_components/localtuya/light.py index e99414e..7c74e49 100644 --- a/custom_components/localtuya/light.py +++ b/custom_components/localtuya/light.py @@ -33,7 +33,6 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -MIRED_TO_KELVIN_CONST = 1000000 DEFAULT_MIN_KELVIN = 2700 # MIRED 370 DEFAULT_MAX_KELVIN = 6500 # MIRED 153 @@ -154,13 +153,11 @@ class LocaltuyaLight(LocalTuyaEntity, LightEntity): CONF_BRIGHTNESS_UPPER, DEFAULT_UPPER_BRIGHTNESS ) self._upper_color_temp = self._upper_brightness - self._max_mired = round( - MIRED_TO_KELVIN_CONST - / self._config.get(CONF_COLOR_TEMP_MIN_KELVIN, DEFAULT_MIN_KELVIN) + self._max_mired = color_util.color_temperature_kelvin_to_mired( + self._config.get(CONF_COLOR_TEMP_MIN_KELVIN, DEFAULT_MIN_KELVIN) ) - self._min_mired = round( - MIRED_TO_KELVIN_CONST - / self._config.get(CONF_COLOR_TEMP_MAX_KELVIN, DEFAULT_MAX_KELVIN) + self._min_mired = color_util.color_temperature_kelvin_to_mired( + self._config.get(CONF_COLOR_TEMP_MAX_KELVIN, DEFAULT_MAX_KELVIN) ) self._color_temp_reverse = self._config.get( CONF_COLOR_TEMP_REVERSE, DEFAULT_COLOR_TEMP_REVERSE @@ -380,16 +377,17 @@ class LocaltuyaLight(LocalTuyaEntity, LightEntity): if ATTR_COLOR_TEMP in kwargs and (features & SUPPORT_COLOR_TEMP): if brightness is None: brightness = self._brightness - color_temp_value = ( - (self._max_mired - self._min_mired) - - (int(kwargs[ATTR_COLOR_TEMP]) - self._min_mired) - if self._color_temp_reverse - else (int(kwargs[ATTR_COLOR_TEMP]) - self._min_mired) - ) + mired = int(kwargs[ATTR_COLOR_TEMP]) + if self._color_temp_reverse: + mired = self._max_mired - (mired - self._min_mired) + if mired < self._min_mired: + mired = self._min_mired + elif mired > self._max_mired: + mired = self._max_mired color_temp = int( self._upper_color_temp - (self._upper_color_temp / (self._max_mired - self._min_mired)) - * color_temp_value + * (mired - self._min_mired) ) states[self._config.get(CONF_COLOR_MODE)] = MODE_WHITE states[self._config.get(CONF_BRIGHTNESS)] = brightness diff --git a/custom_components/localtuya/manifest.json b/custom_components/localtuya/manifest.json index 7f19f9f..bb6f2ea 100644 --- a/custom_components/localtuya/manifest.json +++ b/custom_components/localtuya/manifest.json @@ -1,7 +1,7 @@ { "domain": "localtuya", "name": "LocalTuya integration", - "version": "3.2.1", + "version": "4.0.0", "documentation": "https://github.com/rospogrigio/localtuya/", "dependencies": [], "codeowners": [ diff --git a/custom_components/localtuya/services.yaml b/custom_components/localtuya/services.yaml index b276d2f..f10af4a 100644 --- a/custom_components/localtuya/services.yaml +++ b/custom_components/localtuya/services.yaml @@ -1,5 +1,5 @@ reload: - description: Reload localtuya and re-process yaml configuration. + description: Reload localtuya and reconnect to all devices. set_dp: description: Change the value of a datapoint (DP) 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/switch.py b/custom_components/localtuya/switch.py index f43d910..e884095 100644 --- a/custom_components/localtuya/switch.py +++ b/custom_components/localtuya/switch.py @@ -40,7 +40,7 @@ class LocaltuyaSwitch(LocalTuyaEntity, SwitchEntity): """Initialize the Tuya switch.""" super().__init__(device, config_entry, switchid, _LOGGER, **kwargs) self._state = None - print("Initialized switch [{}]".format(self.name)) + _LOGGER.debug("Initialized switch [%s]", self.name) @property def is_on(self): diff --git a/custom_components/localtuya/translations/en.json b/custom_components/localtuya/translations/en.json index ebd22ee..82f4064 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,22 +17,86 @@ }, "step": { "user": { - "title": "Device Discovery", - "description": "Pick one of the automatically discovered devices or `...` to manually to add a device.", + "title": "Cloud API account configuration", + "description": "Input the credentials for Tuya Cloud API.", "data": { - "discovered_device": "Discovered Device" + "region": "API server region", + "client_id": "Client ID", + "client_secret": "Secret", + "user_id": "User ID", + "user_name": "Username", + "no_cloud": "Do not configure a Cloud API account" + } + } + } + }, + "options": { + "abort": { + "already_configured": "Device has already been configured.", + "device_success": "Device {dev_name} successfully {action}." + }, + "error": { + "authentication_failed": "Failed to authenticate.\n{msg}", + "cannot_connect": "Cannot connect to device. Verify that address is correct and try again.", + "device_list_failed": "Failed to retrieve device list.\n{msg}", + "invalid_auth": "Failed to authenticate with device. Verify that device id and local key are correct.", + "unknown": "An unknown error occurred. See log for details.", + "entity_already_configured": "Entity with this ID has already been configured.", + "address_in_use": "Address used for discovery is already in use. Make sure no other application is using it (TCP port 6668).", + "discovery_failed": "Something failed when discovering devices. See log for details.", + "empty_dps": "Connection to device succeeded but no datapoints found, please try again. Create a new issue and include debug logs if problem persists." + }, + "step": { + "yaml_import": { + "title": "Not Supported", + "description": "Options cannot be edited when configured via YAML." + }, + "init": { + "title": "LocalTuya Configuration", + "description": "Please select the desired action.", + "data": { + "add_device": "Add a new device", + "edit_device": "Edit a device", + "setup_cloud": "Reconfigure Cloud API account" } }, - "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.", + "add_device": { + "title": "Add a new device", + "description": "Pick one of the automatically discovered devices or `...` to manually to add a device.", + "data": { + "selected_device": "Discovered Devices" + } + }, + "edit_device": { + "title": "Edit a new device", + "description": "Pick the configured device you wish to edit.", + "data": { + "selected_device": "Configured Devices" + } + }, + "cloud_setup": { + "title": "Cloud API account configuration", + "description": "Input the credentials for Tuya Cloud API.", + "data": { + "region": "API server region", + "client_id": "Client ID", + "client_secret": "Secret", + "user_id": "User ID", + "user_name": "Username", + "no_cloud": "Do not configure Cloud API account" + } + }, + "configure_device": { + "title": "Configure Tuya device", + "description": "Fill in the device details{for_device}.", "data": { "friendly_name": "Name", "host": "Host", "device_id": "Device ID", "local_key": "Local key", "protocol_version": "Protocol Version", - "scan_interval": "Scan interval (seconds, only when not updating automatically)" + "scan_interval": "Scan interval (seconds, only when not updating automatically)", + "entities": "Entities (uncheck an entity to remove it)" } }, "pick_entity_type": { @@ -38,12 +104,12 @@ "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": { - "title": "Add new entity", - "description": "Please fill out the details for an entity with type `{platform}`. All settings except for `ID` can be changed from the Options page later.", + "configure_entity": { + "title": "Configure entity", + "description": "Please fill out the details for {entity} with type `{platform}`. All settings except for `ID` can be changed from the Options page later.", "data": { "id": "ID", "friendly_name": "Friendly name", @@ -89,6 +155,8 @@ "color_temp_max_kelvin": "Maximum Color Temperature in K", "music_mode": "Music mode available", "scene": "Scene", + "select_options": "Valid entries, separate entries by a ;", + "select_options_friendly": "User Friendly options, separate entries by a ;", "fan_speed_control": "Fan Speed Control dps", "fan_oscillating_control": "Fan Oscillating Control dps", "fan_speed_min": "minimum fan speed integer", @@ -118,100 +186,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" } diff --git a/custom_components/localtuya/translations/pt-BR.json b/custom_components/localtuya/translations/pt-BR.json new file mode 100644 index 0000000..c27dc51 --- /dev/null +++ b/custom_components/localtuya/translations/pt-BR.json @@ -0,0 +1,217 @@ +{ + "config": { + "abort": { + "already_configured": "O dispositivo já foi configurado.", + "device_updated": "A configuração do dispositivo foi atualizada!" + }, + "error": { + "cannot_connect": "Não é possível se conectar ao dispositivo. Verifique se o endereço está correto e tente novamente.", + "invalid_auth": "Falha ao autenticar com o dispositivo. Verifique se o ID do dispositivo e a chave local estão corretos.", + "unknown": "Ocorreu um erro desconhecido. Consulte o registro para obter detalhes.", + "entity_already_configured": "A entidade com este ID já foi configurada.", + "address_in_use": "O endereço usado para descoberta já está em uso. Certifique-se de que nenhum outro aplicativo o esteja usando (porta TCP 6668).", + "discovery_failed": "Algo falhou ao descobrir dispositivos. Consulte o registro para obter detalhes.", + "empty_dps": "A conexão com o dispositivo foi bem-sucedida, mas nenhum ponto de dados foi encontrado. Tente novamente. Crie um novo issue e inclua os logs de depuração se o problema persistir." + }, + "step": { + "user": { + "title": "Descoberta de dispositivo", + "description": "Escolha um dos dispositivos descobertos automaticamente ou clique em `...` para adicionar um dispositivo manualmente.", + "data": { + "discovered_device": "Dispositivo descoberto" + } + }, + "basic_info": { + "title": "Adicionar dispositivo Tuya", + "description": "Preencha os detalhes básicos do dispositivo. O nome inserido aqui será usado para identificar a própria integração (como visto na página `Integrations`). Você adicionará entidades e dará nomes a elas nas etapas a seguir.", + "data": { + "friendly_name": "Nome", + "host": "Host", + "device_id": "ID do dispositivo", + "local_key": "Local key", + "protocol_version": "Versão do protocolo", + "scan_interval": "Intervalo do escaneamento (segundos, somente quando não estiver atualizando automaticamente)" + } + }, + "pick_entity_type": { + "title": "Seleção do tipo de entidade", + "description": "Escolha o tipo de entidade que deseja adicionar.", + "data": { + "platform_to_add": "Platforma", + "no_additional_platforms": "Não adicione mais entidades" + } + }, + "add_entity": { + "title": "Adicionar nova entidade", + "description": "Por favor, preencha os detalhes de uma entidade com o tipo `{platform}`. Todas as configurações, exceto `ID`, podem ser alteradas na página Opções posteriormente.", + "data": { + "id": "ID", + "friendly_name": "Name fantasia", + "current": "Atual", + "current_consumption": "Consumo atual", + "voltage": "Voltagem", + "commands_set": "Conjunto de comandos Open_Close_Stop", + "positioning_mode": "Modo de posicão", + "current_position_dp": "Posição atual (somente para o modo de posição)", + "set_position_dp": "Definir posição (somente para o modo de posição)", + "position_inverted": "Inverter posição 0-100 (somente para o modo de posição)", + "span_time": "Tempo de abertura completo, em segundos. (somente para o modo temporizado)", + "unit_of_measurement": "Unidade de medida", + "device_class": "Classe do dispositivo", + "scaling": "Fator de escala", + "state_on": "Valor On", + "state_off": "Valor Off", + "powergo_dp": "Potência DP (Geralmente 25 ou 2)", + "idle_status_value": "Status ocioso (separado por vírgula)", + "returning_status_value": "Status de retorno", + "docked_status_value": "Status docked (separado por vírgula)", + "fault_dp": "Falha DP (Geralmente 11)", + "battery_dp": "Status da bateria DP (normalmente 14)", + "mode_dp": "Modo DP (Geralmente 27)", + "modes": "Lista de modos", + "return_mode": "Modo de retorno para base", + "fan_speed_dp": "Velocidades do ventilador DP (normalmente 30)", + "fan_speeds": "Lista de velocidades do ventilador (separadas por vírgulas)", + "clean_time_dp": "Tempo de Limpeza DP (Geralmente 33)", + "clean_area_dp": "Área Limpa DP (Geralmente 32)", + "clean_record_dp": "Limpar Registro DP (Geralmente 34)", + "locate_dp": "Localize DP (Geralmente 31)", + "paused_state": "Estado de pausa (pausa, pausado, etc)", + "stop_status": "Status de parada", + "brightness": "Brilho (somente para cor branca)", + "brightness_lower": "Valor mais baixo do brilho", + "brightness_upper": "Valor mais alto do brilho", + "color_temp": "Temperatura da cor", + "color_temp_reverse": "Temperatura da cor reversa", + "color": "Cor", + "color_mode": "Modo de cor", + "color_temp_min_kelvin": "Minima temperatura de cor em K", + "color_temp_max_kelvin": "Máxima temperatura de cor em K", + "music_mode": "Modo de música disponível", + "scene": "Cena", + "fan_speed_control": "dps de controle de velocidade do ventilador", + "fan_oscillating_control": "dps de controle oscilante do ventilador", + "fan_speed_min": "velocidade mínima do ventilador inteiro", + "fan_speed_max": "velocidade máxima do ventilador inteiro", + "fan_speed_ordered_list": "Lista de modos de velocidade do ventilador (substitui a velocidade min/max)", + "fan_direction":"direção do ventilador dps", + "fan_direction_forward": "string de dps para frente", + "fan_direction_reverse": "string dps reversa", + "current_temperature_dp": "Temperatura atual", + "target_temperature_dp": "Temperatura alvo", + "temperature_step": "Etapa de temperatura (opcional)", + "max_temperature_dp": "Max Temperatura (opcional)", + "min_temperature_dp": "Min Temperatura (opcional)", + "precision": "Precisão (opcional, para valores de DPs)", + "target_precision": "Precisão do alvo (opcional, para valores de DPs)", + "temperature_unit": "Unidade de Temperatura (opcional)", + "hvac_mode_dp": "Modo HVAC DP (opcional)", + "hvac_mode_set": "Conjunto de modo HVAC (opcional)", + "hvac_action_dp": "Ação atual de HVAC DP (opcional)", + "hvac_action_set": "Conjunto de ação atual de HVAC (opcional)", + "preset_dp": "Predefinições DP (opcional)", + "preset_set": "Conjunto de predefinições (opcional)", + "eco_dp": "Eco DP (opcional)", + "eco_value": "Valor ECO (opcional)", + "heuristic_action": "Ativar ação heurística (opcional)" + } + } + } + }, + "options": { + "step": { + "init": { + "title": "Configurar dispositivo Tuya", + "description": "Configuração básica para o ID do dispositivo `{device_id}`.", + "data": { + "friendly_name": "Nome fantasia", + "host": "Host", + "local_key": "Local key", + "protocol_version": "Versão do protocolo", + "scan_interval": "Intervalo de escaneamento (segundos, somente quando não estiver atualizando automaticamente)", + "entities": "Entidades (desmarque uma entidade para removê-la)" + } + }, + "entity": { + "title": "Adicionar nova entidade", + "description": "Por favor, preencha os detalhes de uma entidade com o tipo `{platform}`. Todas as configurações, exceto `ID`, podem ser alteradas na página Opções posteriormente.", + "data": { + "id": "ID", + "friendly_name": "Name fantasia", + "current": "Atual", + "current_consumption": "Consumo atual", + "voltage": "Voltagem", + "commands_set": "Conjunto de comandos Open_Close_Stop", + "positioning_mode": "Modo de posicão", + "current_position_dp": "Posição atual (somente para o modo de posição)", + "set_position_dp": "Definir posição (somente para o modo de posição)", + "position_inverted": "Inverter posição 0-100 (somente para o modo de posição)", + "span_time": "Tempo de abertura completo, em segundos. (somente para o modo temporizado)", + "unit_of_measurement": "Unidade de medida", + "device_class": "Classe do dispositivo", + "scaling": "Fator de escala", + "state_on": "Valor On", + "state_off": "Valor Off", + "powergo_dp": "Potência DP (Geralmente 25 ou 2)", + "idle_status_value": "Status ocioso (separado por vírgula)", + "returning_status_value": "Status de retorno", + "docked_status_value": "Status docked (separado por vírgula)", + "fault_dp": "Falha DP (Geralmente 11)", + "battery_dp": "Status da bateria DP (normalmente 14)", + "mode_dp": "Modo DP (Geralmente 27)", + "modes": "Lista de modos", + "return_mode": "Modo de retorno para base", + "fan_speed_dp": "Velocidades do ventilador DP (normalmente 30)", + "fan_speeds": "Lista de velocidades do ventilador (separadas por vírgulas)", + "clean_time_dp": "Tempo de Limpeza DP (Geralmente 33)", + "clean_area_dp": "Área Limpa DP (Geralmente 32)", + "clean_record_dp": "Limpar Registro DP (Geralmente 34)", + "locate_dp": "Localize DP (Geralmente 31)", + "paused_state": "Estado de pausa (pausa, pausado, etc)", + "stop_status": "Status de parada", + "brightness": "Brilho (somente para cor branca)", + "brightness_lower": "Valor mais baixo do brilho", + "brightness_upper": "Valor mais alto do brilho", + "color_temp": "Temperatura da cor", + "color_temp_reverse": "Temperatura da cor reversa", + "color": "Cor", + "color_mode": "Modo de cor", + "color_temp_min_kelvin": "Minima temperatura de cor em K", + "color_temp_max_kelvin": "Máxima temperatura de cor em K", + "music_mode": "Modo de música disponível", + "scene": "Cena", + "fan_speed_control": "dps de controle de velocidade do ventilador", + "fan_oscillating_control": "dps de controle oscilante do ventilador", + "fan_speed_min": "velocidade mínima do ventilador inteiro", + "fan_speed_max": "velocidade máxima do ventilador inteiro", + "fan_speed_ordered_list": "Lista de modos de velocidade do ventilador (substitui a velocidade min/max)", + "fan_direction":"direção do ventilador dps", + "fan_direction_forward": "string de dps para frente", + "fan_direction_reverse": "string dps reversa", + "current_temperature_dp": "Temperatura atual", + "target_temperature_dp": "Temperatura alvo", + "temperature_step": "Etapa de temperatura (opcional)", + "max_temperature_dp": "Max Temperatura (opcional)", + "min_temperature_dp": "Min Temperatura (opcional)", + "precision": "Precisão (opcional, para valores de DPs)", + "target_precision": "Precisão do alvo (opcional, para valores de DPs)", + "temperature_unit": "Unidade de Temperatura (opcional)", + "hvac_mode_dp": "Modo HVAC DP (opcional)", + "hvac_mode_set": "Conjunto de modo HVAC (opcional)", + "hvac_action_dp": "Ação atual de HVAC DP (opcional)", + "hvac_action_set": "Conjunto de ação atual de HVAC (opcional)", + "preset_dp": "Predefinições DP (opcional)", + "preset_set": "Conjunto de predefinições (opcional)", + "eco_dp": "Eco DP (opcional)", + "eco_value": "Valor ECO (opcional)", + "heuristic_action": "Ativar ação heurística (opcional)" + } + }, + "yaml_import": { + "title": "Não suportado", + "description": "As opções não podem ser editadas quando configuradas via YAML." + } + } + }, + "title": "LocalTuya" +} diff --git a/custom_components/localtuya/vacuum.py b/custom_components/localtuya/vacuum.py index 9a14399..7bf4ed6 100644 --- a/custom_components/localtuya/vacuum.py +++ b/custom_components/localtuya/vacuum.py @@ -7,41 +7,40 @@ from homeassistant.components.vacuum import ( DOMAIN, STATE_CLEANING, STATE_DOCKED, - STATE_IDLE, - STATE_RETURNING, - STATE_PAUSED, STATE_ERROR, + STATE_IDLE, + STATE_PAUSED, + STATE_RETURNING, SUPPORT_BATTERY, SUPPORT_FAN_SPEED, + SUPPORT_LOCATE, SUPPORT_PAUSE, SUPPORT_RETURN_HOME, SUPPORT_START, SUPPORT_STATE, SUPPORT_STATUS, SUPPORT_STOP, - SUPPORT_LOCATE, StateVacuumEntity, ) from .common import LocalTuyaEntity, async_setup_entry - from .const import ( - CONF_POWERGO_DP, - CONF_IDLE_STATUS_VALUE, - CONF_RETURNING_STATUS_VALUE, - CONF_DOCKED_STATUS_VALUE, CONF_BATTERY_DP, - CONF_MODE_DP, - CONF_MODES, - CONF_FAN_SPEED_DP, - CONF_FAN_SPEEDS, - CONF_CLEAN_TIME_DP, CONF_CLEAN_AREA_DP, CONF_CLEAN_RECORD_DP, - CONF_LOCATE_DP, + CONF_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, ) @@ -118,8 +117,7 @@ class LocaltuyaVacuum(LocalTuyaEntity, StateVacuumEntity): self._fan_speed = "" self._cleaning_mode = "" - - print("Initialized vacuum [{}]".format(self.name)) + _LOGGER.debug("Initialized vacuum [%s]", self.name) @property def supported_features(self): diff --git a/img/10-integration_configure.png b/img/10-integration_configure.png new file mode 100644 index 0000000..f023c22 Binary files /dev/null and b/img/10-integration_configure.png differ diff --git a/img/11-config_menu.png b/img/11-config_menu.png new file mode 100644 index 0000000..22f29ff Binary files /dev/null and b/img/11-config_menu.png differ diff --git a/img/6-project_date.png b/img/6-project_date.png new file mode 100644 index 0000000..de324ac Binary files /dev/null and b/img/6-project_date.png differ diff --git a/img/7-auth_keys.png b/img/7-auth_keys.png new file mode 100644 index 0000000..b8cc331 Binary files /dev/null and b/img/7-auth_keys.png differ diff --git a/img/8-user_id.png b/img/8-user_id.png new file mode 100644 index 0000000..f39bf6f Binary files /dev/null and b/img/8-user_id.png differ diff --git a/img/9-cloud_setup.png b/img/9-cloud_setup.png new file mode 100644 index 0000000..25380a3 Binary files /dev/null and b/img/9-cloud_setup.png differ diff --git a/info.md b/info.md index 0489319..900b0e0 100644 --- a/info.md +++ b/info.md @@ -5,159 +5,163 @@ ![logo](https://github.com/rospogrigio/localtuya-homeassistant/blob/master/img/logo-small.png) A Home Assistant custom Integration for local handling of Tuya-based devices. -Device status is updated receiving push updates from the device instead of polling, so status updates are extremely fast (even if manually operated). + +This custom integration updates device status via pushing updates instead of polling, so status updates are fast (even when manually operated). +The integration also supports the Tuya IoT Cloud APIs, for the retrieval of info and of the local_keys of the devices. + + +**NOTE: The Cloud API account configuration is not mandatory (LocalTuya can work also without it) but is strongly suggested for easy retrieval (and auto-update after re-pairing a device) of local_keys. Cloud API calls are performed only at startup, and when a local_key update is needed.** + The following Tuya device types are currently supported: -* 1 and multiple gang switches -* Wi-Fi smart plugs (including those with additional USB plugs) +* Switches * Lights * Covers * Fans -* Climates (soon) +* Climates +* Vacuums -Energy monitoring (voltage, current, watts, etc.) is supported for compatible devices. +Energy monitoring (voltage, current, watts, etc.) is supported for compatible devices. + +> **Currently, only Tuya protocols 3.1 and 3.3 are supported (3.4 is not).** + +This repository's development began as code from [@NameLessJedi](https://github.com/NameLessJedi), [@mileperhour](https://github.com/mileperhour) and [@TradeFace](https://github.com/TradeFace). Their code was then deeply refactored to provide proper integration with Home Assistant environment, adding config flow and other features. Refer to the "Thanks to" section below. -This repository's development has substantially started by utilizing and merging code from NameLessJedi, mileperhour and TradeFace, and then was deeply refactored to provide proper integration with Home Assistant environment, adding config flow and other features. Refer to the "Thanks to" section below. # Installation: -Copy the localtuya folder and all of its contents into your Home Assistant's custom_components folder. This is often located inside of your /config folder. If you are running Hass.io, use SAMBA to copy the folder over. If you are running Home Assistant Supervised, the custom_components folder might be located at /usr/share/hassio/homeassistant. It is possible that your custom_components folder does not exist. If that is the case, create the folder in the proper location, and then copy the localtuya folder and all of its contents inside the newly created custom_components folder. +The easiest way, if you are using [HACS](https://hacs.xyz/), is to install LocalTuya through HACS. -Alternatively, you can install localtuya through HACS by adding this repository. +For manual installation, copy the localtuya folder and all of its contents into your Home Assistant's custom_components folder. This folder is usually inside your `/config` folder. If you are running Hass.io, use SAMBA to copy the folder over. If you are running Home Assistant Supervised, the custom_components folder might be located at `/usr/share/hassio/homeassistant`. You may need to create the `custom_components` folder and then copy the localtuya folder and all of its contents into it. # Usage: -**NOTE: You must have your Tuya device's Key and ID in order to use localtuya. There are several ways to obtain the localKey depending on your environment and the devices you own. A good place to start getting info is https://github.com/codetheweb/tuyapi/blob/master/docs/SETUP.md .** +**NOTE: You must have your Tuya device's Key and ID in order to use LocalTuya. The easiest way is to configure the Cloud API account in the integration. If you choose not to do it, there are several ways to obtain the local_keys depending on your environment and the devices you own. A good place to start getting info is https://github.com/codetheweb/tuyapi/blob/master/docs/SETUP.md .** -Devices can be configured in two ways: -# 1. YAML config files +**NOTE 2: If you plan to integrate these devices on a network that has internet and blocking their internet access, you must also block DNS requests (to the local DNS server, e.g. 192.168.1.1). If you only block outbound internet, then the device will sit in a zombie state; it will refuse / not respond to any connections with the localkey. Therefore, you must first connect the devices with an active internet connection, grab each device localkey, and implement the block.** -Add the proper entry to your configuration.yaml file. Several example configurations for different device types are provided below. Make sure to save when you are finished editing configuration.yaml. -``` -localtuya: - - host: 192.168.1.x - device_id: xxxxx - local_key: xxxxx - friendly_name: Tuya Device - protocol_version: "3.3" - scan_interval: # optional, only needed if energy monitoring values are not updating - seconds: 30 # Values less than 10 seconds may cause stability issues - entities: - - platform: binary_sensor - friendly_name: Plug Status - id: 1 - device_class: power - state_on: "true" # Optional - state_off: "false" # Optional +# Adding the Integration - - platform: cover - friendly_name: Device Cover - id: 2 - open_close_cmds: ["on_off","open_close"] # Optional, default: "on_off" - positioning_mode: ["none","position","timed"] # Optional, default: "none" - currpos_dps: 3 # Optional, required only for "position" mode - setpos_dps: 4 # Optional, required only for "position" mode - 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 # Usually 1 or 20 - color_mode: 21 # Optional, usually 2 or 21, default: "none" - brightness: 22 # Optional, usually 3 or 22, default: "none" - color_temp: 23 # Optional, usually 4 or 23, default: "none" - color_temp_reverse: false # Optional, default: false - color: 24 # Optional, usually 5 (RGB_HSV) or 24 (HSV), default: "none" - brightness_lower: 29 # Optional, usually 0 or 29, default: 29 - brightness_upper: 1000 # Optional, usually 255 or 1000, default: 1000 - color_temp_min_kelvin: 2700 # Optional, default: 2700 - color_temp_max_kelvin: 6500 # Optional, default: 6500 - scene: 25 # Optional, usually 6 (RGB_HSV) or 25 (HSV), default: "none" - music_mode: False # Optional, some use internal mic, others, phone mic. Only internal mic is supported, default: "False" +**NOTE: starting from v4.0.0, configuration using YAML files is no longer supported. The integration can only be configured using the config flow.** - - 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 -``` - -Note that a single device can contain several different entities. Some examples: -- a cover device might have 1 (or many) cover entities, plus a switch to control backlight -- a multi-gang switch will contain several switch entities, one for each gang controlled +To start configuring the integration, just press the "+ADD INTEGRATION" button in the Settings - Integrations page, and select LocalTuya from the drop-down menu. +The Cloud API configuration page will appear, requesting to input your Tuya IoT Platform account credentials: -Restart Home Assistant when finished editing. +![cloud_setup](https://github.com/rospogrigio/localtuya-homeassistant/blob/master/img/9-cloud_setup.png) -# 2. Using config flow +To setup a Tuya IoT Platform account and setup a project in it, refer to the instructions for the official Tuya integration: +https://www.home-assistant.io/integrations/tuya/ +The place to find the Client ID and Secret is described in this link (in the ["Get Authorization Key"](https://www.home-assistant.io/integrations/tuya/#get-authorization-key) paragraph), while the User ID can be found in the "Link Tuya App Account" subtab within the Cloud project: + +![user_id.png](https://github.com/rospogrigio/localtuya-homeassistant/blob/master/img/8-user_id.png) + +> **Note: as stated in the above link, if you already have an account and an IoT project, make sure that it was created after May 25, 2021 (due to changes introduced in the cloud for Tuya 2.0). Otherwise, you need to create a new project. See the following screenshot for where to check your project creation date:** + +![project_date](https://github.com/rospogrigio/localtuya-homeassistant/blob/master/img/6-project_date.png) + +After pressing the Submit button, the first setup is complete and the Integration will be added. + +> **Note: it is not mandatory to input the Cloud API credentials: you can choose to tick the "Do not configure a Cloud API account" button, and the Integration will be added anyway.** + +After the Integration has been set up, devices can be added and configured pressing the Configure button in the Integrations page: + +![integration_configure](https://github.com/rospogrigio/localtuya-homeassistant/blob/master/img/10-integration_configure.png) + + +# Integration Configuration menu + +The configuration menu is the following: + +![config_menu](https://github.com/rospogrigio/localtuya-homeassistant/blob/master/img/11-config_menu.png) + +From this menu, you can select the "Reconfigure Cloud API account" to edit your Tuya Cloud credentials and settings, in case they have changed or if the integration was migrated from v.3.x.x versions. + +You can then proceed Adding or Editing your Tuya devices. + +# Adding/editing a device + +If you select to "Add or Edit a device", a drop-down menu will appear containing the list of detected devices (using auto-discovery if adding was selected, or the list of already configured devices if editing was selected): you can select one of these, or manually input all the parameters selecting the "..." option. + +> **Note: The tuya app on your device must be closed for the following steps to work reliably.** -Start by going to Configuration - Integration and pressing the "+" button to create a new Integration, then select LocalTuya in the drop-down menu. -Wait for 6 seconds for the scanning of the devices in your LAN. Then, a drop-down menu will appear containing the list of detectes devices: you can -select one of these, or manually input all the parameters. ![discovery](https://github.com/rospogrigio/localtuya-homeassistant/blob/master/img/1-discovery.png) -If you have selected one entry, you just have to input the Friendly Name of the Device, and the localKey. +If you have selected one entry, you only need to input the device's Friendly Name and localKey. These values will be automatically retrieved if you have configured your Cloud API account, otherwise you will need to input them manually. -Setting the scan interval is optional, only needed if energy/power values are not updating frequently enough by default. Values less than 10 seconds may cause stability issues. +Setting the scan interval is optional, it is only needed if energy/power values are not updating frequently enough by default. Values less than 10 seconds may cause stability issues. Once you press "Submit", the connection is tested to check that everything works. ![image](https://user-images.githubusercontent.com/1082213/146664103-ac40319e-f934-4933-90cf-2beaff1e6bac.png) -Then, it's time to add the entities: this step will take place several times. Select the entity type from the drop-down menu to set it up. -After you have defined all the needed entities leave the "Do not add more entities" checkbox checked: this will complete the procedure. + +Then, it's time to add the entities: this step will take place several times. First, select the entity type from the drop-down menu to set it up. +After you have defined all the needed entities, leave the "Do not add more entities" checkbox checked: this will complete the procedure. ![entity_type](https://github.com/rospogrigio/localtuya-homeassistant/blob/master/img/3-entity_type.png) -For each entity, the associated DP has to be selected. All the options requiring to select a DP will provide a drop-down menu showing -all the avaliable DPs found on the device (with their current status!!) for an easy identification. Each entity type has different options -to be configured, here is an example for the "switch" entity: +For each entity, the associated DP has to be selected. All the options requiring to select a DP will provide a drop-down menu showing +all the available DPs found on the device (with their current status!!) for easy identification. Each entity type has different options +to be configured. Here is an example for the "switch" entity: ![entity](https://github.com/rospogrigio/localtuya-homeassistant/blob/master/img/4-entity.png) -After all the entities have been configured, the procedure is complete, and the Device can be associated to the Area desired. +Once you configure the entities, the procedure is complete. You can now associate the device with an Area in Home Assistant ![success](https://github.com/rospogrigio/localtuya-homeassistant/blob/master/img/5-success.png) +# Migration from LocalTuya v.3.x.x + +If you upgrade LocalTuya from v3.x.x or older, the config entry will automatically be migrated to the new setup. Everything should work as it did before the upgrade, apart from the fact that in the Integration tab you will see just one LocalTuya integration (showing the number of devices and entities configured) instead of several Integrations grouped within the LocalTuya Box. This will happen both if the old configuration was done using YAML files and with the config flow. Once migrated, you can just input your Tuya IoT account credentials to enable the support for the Cloud API (and benefit from the local_key retrieval and auto-update): see [Configuration menu](https://github.com/rospogrigio/localtuya#integration-configuration-menu). + +If you had configured LocalTuya using YAML files, you can delete all its references from within the YAML files because they will no longer be considered so they might bring confusion (only the logger configuration part needs to be kept, of course, see [Debugging](https://github.com/rospogrigio/localtuya#debugging) ). + + # Energy monitoring values -Energy monitoring (voltage, current...) values can be obtained in two different ways: -1) creating individual sensors, each one with the desired name. Note: Voltage and Consumption usually include the first decimal, so 0.1 as "scaling" parameter shall be used in order to get the correct values. -2) accessing the voltage/current/current_consumption attributes of a switch, and then defining template sensors like this (please note that in this case the values are already divided by 10 for Voltage and Consumption) +You can obtain Energy monitoring (voltage, current) in two different ways: + +1) Creating individual sensors, each one with the desired name. + Note: Voltage and Consumption usually include the first decimal. You will need to scale the parament by 0.1 to get the correct values. +2) Access the voltage/current/current_consumption attributes of a switch, and define template sensors + Note: these values are already divided by 10 for Voltage and Consumption 3) On some devices, you may find that the energy values are not updating frequently enough by default. If so, set the scan interval (see above) to an appropriate value. Settings below 10 seconds may cause stability issues, 30 seconds is recommended. -``` +``` sensor: - platform: template sensors: tuya-sw01_voltage: value_template: >- {{ states.switch.sw01.attributes.voltage }} - unit_of_measurement: 'V' + unit_of_measurement: 'V' tuya-sw01_current: - value_template: >- + value_template: >- {{ states.switch.sw01.attributes.current }} - unit_of_measurement: 'mA' + unit_of_measurement: 'mA' tuya-sw01_current_consumption: value_template: >- {{ states.switch.sw01.attributes.current_consumption }} - unit_of_measurement: 'W' -``` + unit_of_measurement: 'W' +``` + +# Debugging + +Whenever you write a bug report, it helps tremendously if you include debug logs directly (otherwise we will just ask for them and it will take longer). So please enable debug logs like this and include them in your issue: + +```yaml +logger: + default: warning + logs: + custom_components.localtuya: debug +``` # Notes: @@ -165,11 +169,13 @@ Energy monitoring (voltage, current...) values can be obtained in two different # To-do list: -* Create a (good and precise) sensor (counter) for Energy (kWh) -not just Power, but based on it-. +* Create a (good and precise) sensor (counter) for Energy (kWh) -not just Power, but based on it-. Ideas: Use: https://www.home-assistant.io/components/integration/ and https://www.home-assistant.io/components/utility_meter/ - + * Everything listed in https://github.com/rospogrigio/localtuya-homeassistant/issues/15 +* Support devices that use Tuya protocol v.3.4 + # Thanks to: NameLessJedi https://github.com/NameLessJedi/localtuya-homeassistant and mileperhour https://github.com/mileperhour/localtuya-homeassistant being the major sources of inspiration, and whose code for switches is substantially unchanged. @@ -179,3 +185,6 @@ TradeFace, for being the only one to provide the correct code for communication sean6541, for the working (standard) Python Handler for Tuya devices. postlund, for the ideas, for coding 95% of the refactoring and boosting the quality of this repo to levels hard to imagine (by me, at least) and teaching me A LOT of how things work in Home Assistant. + +Buy Me A Coffee +PayPal Logo diff --git a/pylint.rc b/pylint.rc new file mode 100644 index 0000000..4ec670e --- /dev/null +++ b/pylint.rc @@ -0,0 +1,646 @@ +[MASTER] + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code. +extension-pkg-allow-list= + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code. (This is an alternative name to extension-pkg-allow-list +# for backward compatibility.) +extension-pkg-whitelist= + +# Specify a score threshold to be exceeded before program exits with error. +fail-under=10.0 + +# Files or directories to be skipped. They should be base names, not paths. +ignore=CVS + +# Files or directories matching the regex patterns are skipped. The regex +# matches against base names, not paths. +ignore-patterns= + +# Python code to execute, usually for sys.path manipulation such as +# pygtk.require(). +#init-hook= + +# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the +# number of processors available to use. +jobs=1 + +# Control the amount of potential inferred values when inferring a single +# object. This can help the performance when dealing with large functions or +# complex, nested conditions. +limit-inference-results=100 + +# List of plugins (as comma separated values of python module names) to load, +# usually to register additional checkers. +load-plugins= + +# Pickle collected data for later comparisons. +persistent=yes + +# When enabled, pylint would attempt to guess common misconfiguration and emit +# user-friendly hints instead of false-positive error messages. +suggestion-mode=yes + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +unsafe-load-any-extension=no + + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED. +confidence= + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once). You can also use "--disable=all" to +# disable everything first and then reenable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use "--disable=all --enable=classes +# --disable=W". +disable=line-too-long, + too-many-lines, + trailing-whitespace, + missing-final-newline, + trailing-newlines, + multiple-statements, + superfluous-parens, + mixed-line-endings, + unexpected-line-ending-format, + wrong-import-order, + not-context-manager, + print-statement, + parameter-unpacking, + unpacking-in-except, + old-raise-syntax, + backtick, + long-suffix, + old-ne-operator, + old-octal-literal, + import-star-module-level, + non-ascii-bytes-literal, + raw-checker-failed, + bad-inline-option, + locally-disabled, + file-ignored, + suppressed-message, + useless-suppression, + deprecated-pragma, + cyclic-import, + duplicate-code, + too-many-ancestors, + too-many-instance-attributes, + too-few-public-methods, + too-many-public-methods, + too-many-return-statements, + too-many-branches, + too-many-arguments, + too-many-locals, + too-many-statements, + too-many-boolean-expressions, + inconsistent-return-statements, + abstract-method, + unnecessary-semicolon, + bad-indentation, + unused-argument, + apply-builtin, + basestring-builtin, + buffer-builtin, + cmp-builtin, + coerce-builtin, + execfile-builtin, + file-builtin, + long-builtin, + raw_input-builtin, + reduce-builtin, + standarderror-builtin, + unicode-builtin, + xrange-builtin, + coerce-method, + delslice-method, + getslice-method, + setslice-method, + no-absolute-import, + old-division, + dict-iter-method, + dict-view-method, + next-method-called, + metaclass-assignment, + indexing-exception, + raising-string, + reload-builtin, + oct-method, + hex-method, + nonzero-method, + cmp-method, + input-builtin, + round-builtin, + intern-builtin, + unichr-builtin, + map-builtin-not-iterating, + zip-builtin-not-iterating, + range-builtin-not-iterating, + filter-builtin-not-iterating, + using-cmp-argument, + eq-without-hash, + div-method, + idiv-method, + rdiv-method, + exception-message-attribute, + invalid-str-codec, + sys-max-int, + bad-python3-import, + deprecated-string-function, + deprecated-str-translate-call, + deprecated-itertools-function, + deprecated-types-field, + next-method-defined, + dict-items-not-iterating, + dict-keys-not-iterating, + dict-values-not-iterating, + deprecated-operator-function, + deprecated-urllib-function, + xreadlines-attribute, + deprecated-sys-function, + exception-escape, + comprehension-escape, + unused-variable, + invalid-name, + dangerous-default-value, + unreachable + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +enable=use-symbolic-message-instead, + c-extension-no-member + + +[REPORTS] + +# Python expression which should return a score less than or equal to 10. You +# have access to the variables 'error', 'warning', 'refactor', and 'convention' +# which contain the number of messages in each category, as well as 'statement' +# which is the total number of statements analyzed. This score is used by the +# global evaluation report (RP0004). +evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) + +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details. +#msg-template= + +# Set the output format. Available formats are text, parseable, colorized, json +# and msvs (visual studio). You can also give a reporter class, e.g. +# mypackage.mymodule.MyReporterClass. +output-format=text + +# Tells whether to display a full report or only the messages. +reports=no + +# Activate the evaluation score. +score=no + + +[REFACTORING] + +# Maximum number of nested blocks for function / method body +max-nested-blocks=5 + +# Complete name of functions that never returns. When checking for +# inconsistent-return-statements if a never returning function is called then +# it will be considered as an explicit return statement and no message will be +# printed. +never-returning-functions=sys.exit,argparse.parse_error + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=XXX + +# Regular expression of note tags to take in consideration. +#notes-rgx= + + +[TYPECHECK] + +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators=contextlib.contextmanager + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +generated-members= + +# Tells whether missing members accessed in mixin class should be ignored. A +# mixin class is detected if its name ends with "mixin" (case insensitive). +ignore-mixin-members=yes + +# Tells whether to warn about missing members when the owner of the attribute +# is inferred to be None. +ignore-none=yes + +# This flag controls whether pylint should warn about no-member and similar +# checks whenever an opaque object is returned when inferring. The inference +# can return multiple potential results while evaluating a Python object, but +# some branches might not be evaluated, which results in partial inference. In +# that case, it might be useful to still emit no-member and other checks for +# the rest of the inferred objects. +ignore-on-opaque-inference=yes + +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=optparse.Values,thread._local,_thread._local + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis). It +# supports qualified module names, as well as Unix pattern matching. +ignored-modules= + +# Show a hint with possible names when a member name was not found. The aspect +# of finding the hint is based on edit distance. +missing-member-hint=yes + +# The minimum edit distance a name should have in order to be considered a +# similar match for a missing member name. +missing-member-hint-distance=1 + +# The total number of similar names that should be taken in consideration when +# showing a hint for a missing member. +missing-member-max-choices=1 + +# List of decorators that change the signature of a decorated function. +signature-mutators= + + +[STRING] + +# This flag controls whether inconsistent-quotes generates a warning when the +# character used as a quote delimiter is used inconsistently within a module. +check-quote-consistency=no + +# This flag controls whether the implicit-str-concat should generate a warning +# on implicit string concatenation in sequences defined over several lines. +check-str-concat-over-line-jumps=no + + +[LOGGING] + +# The type of string formatting that logging methods do. `old` means using % +# formatting, `new` is for `{}` formatting. +logging-format-style=old + +# Logging modules to check that the string format arguments are in logging +# function parameter format. +logging-modules=logging + + +[SPELLING] + +# Limits count of emitted suggestions for spelling mistakes. +max-spelling-suggestions=4 + +# Spelling dictionary name. Available dictionaries: none. To make it work, +# install the 'python-enchant' package. +spelling-dict= + +# List of comma separated words that should be considered directives if they +# appear and the beginning of a comment and should not be checked. +spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy: + +# List of comma separated words that should not be checked. +spelling-ignore-words= + +# A path to a file that contains the private dictionary; one word per line. +spelling-private-dict-file= + +# Tells whether to store unknown words to the private dictionary (see the +# --spelling-private-dict-file option) instead of raising a message. +spelling-store-unknown-words=no + + +[FORMAT] + +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format=LF + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines=^\s*(# )??$ + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + +# Maximum number of characters on a single line. +max-line-length=100 + +# Maximum number of lines in a module. +max-module-lines=1000 + +# Allow the body of a class to be on the same line as the declaration if body +# contains single statement. +single-line-class-stmt=no + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=no + + +[VARIABLES] + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid defining new builtins when possible. +additional-builtins= + +# Tells whether unused global variables should be treated as a violation. +# allow-global-unused-variables=yes + +# List of names allowed to shadow builtins +allowed-redefined-builtins= + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_, + _cb + +# A regular expression matching the name of dummy variables (i.e. expected to +# not be used). +dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ + +# Argument names that match this expression will be ignored. Default to name +# with leading underscore. +ignored-argument-names=_.*|^ignored_|^unused_ + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io + + +[BASIC] + +# Naming style matching correct argument names. +argument-naming-style=snake_case + +# Regular expression matching correct argument names. Overrides argument- +# naming-style. +#argument-rgx= + +# Naming style matching correct attribute names. +attr-naming-style=snake_case + +# Regular expression matching correct attribute names. Overrides attr-naming- +# style. +#attr-rgx= + +# Bad variable names which should always be refused, separated by a comma. +bad-names=foo, + bar, + baz, + toto, + tutu, + tata + +# Bad variable names regexes, separated by a comma. If names match any regex, +# they will always be refused +bad-names-rgxs= + +# Naming style matching correct class attribute names. +class-attribute-naming-style=any + +# Regular expression matching correct class attribute names. Overrides class- +# attribute-naming-style. +#class-attribute-rgx= + +# Naming style matching correct class constant names. +class-const-naming-style=UPPER_CASE + +# Regular expression matching correct class constant names. Overrides class- +# const-naming-style. +#class-const-rgx= + +# Naming style matching correct class names. +class-naming-style=PascalCase + +# Regular expression matching correct class names. Overrides class-naming- +# style. +#class-rgx= + +# Naming style matching correct constant names. +const-naming-style=UPPER_CASE + +# Regular expression matching correct constant names. Overrides const-naming- +# style. +#const-rgx= + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=-1 + +# Naming style matching correct function names. +function-naming-style=snake_case + +# Regular expression matching correct function names. Overrides function- +# naming-style. +#function-rgx= + +# Good variable names which should always be accepted, separated by a comma. +good-names=_, + ev, + ex, + fp, + i, + id, + j, + k, + Run, + T, + hs + +# Good variable names regexes, separated by a comma. If names match any regex, +# they will always be accepted +good-names-rgxs= + +# Include a hint for the correct naming format with invalid-name. +include-naming-hint=no + +# Naming style matching correct inline iteration names. +inlinevar-naming-style=any + +# Regular expression matching correct inline iteration names. Overrides +# inlinevar-naming-style. +#inlinevar-rgx= + +# Naming style matching correct method names. +method-naming-style=snake_case + +# Regular expression matching correct method names. Overrides method-naming- +# style. +#method-rgx= + +# Naming style matching correct module names. +module-naming-style=snake_case + +# Regular expression matching correct module names. Overrides module-naming- +# style. +#module-rgx= + +# Colon-delimited sets of names that determine each other's naming style when +# the name regexes allow several styles. +name-group= + +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=^_ + +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +# These decorators are taken in consideration only for invalid-name. +property-classes=abc.abstractproperty + +# Naming style matching correct variable names. +variable-naming-style=snake_case + +# Regular expression matching correct variable names. Overrides variable- +# naming-style. +#variable-rgx= + + +[SIMILARITIES] + +# Ignore comments when computing similarities. +ignore-comments=yes + +# Ignore docstrings when computing similarities. +ignore-docstrings=yes + +# Ignore imports when computing similarities. +ignore-imports=no + +# Minimum lines number of a similarity. +min-similarity-lines=4 + + +[CLASSES] + +# Warn about protected attribute access inside special methods +check-protected-access-in-special-methods=no + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__, + __new__, + setUp, + __post_init__ + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict, + _fields, + _replace, + _source, + _make + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=cls + + +[IMPORTS] + +# List of modules that can be imported at any level, not just the top level +# one. +allow-any-import-level= + +# Allow wildcard imports from modules that define __all__. +allow-wildcard-with-all=no + +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + +# Deprecated modules which should not be used, separated by a comma. +deprecated-modules=optparse,tkinter.tix + +# Output a graph (.gv or any supported image format) of external dependencies +# to the given file (report RP0402 must not be disabled). +ext-import-graph= + +# Output a graph (.gv or any supported image format) of all (i.e. internal and +# external) dependencies to the given file (report RP0402 must not be +# disabled). +import-graph= + +# Output a graph (.gv or any supported image format) of internal dependencies +# to the given file (report RP0402 must not be disabled). +int-import-graph= + +# Force import order to recognize a module as part of the standard +# compatibility libraries. +known-standard-library= + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant + +# Couples of modules and preferred modules, separated by a comma. +preferred-modules= + + +[DESIGN] + +# Maximum number of arguments for function / method. +max-args=5 + +# Maximum number of attributes for a class (see R0902). +max-attributes=7 + +# Maximum number of boolean expressions in an if statement (see R0916). +max-bool-expr=5 + +# Maximum number of branch for function / method body. +max-branches=12 + +# Maximum number of locals for function / method body. +max-locals=15 + +# Maximum number of parents for a class (see R0901). +max-parents=7 + +# Maximum number of public methods for a class (see R0904). +max-public-methods=20 + +# Maximum number of return / yield for function / method body. +max-returns=6 + +# Maximum number of statements in function / method body. +max-statements=50 + +# Minimum number of public methods for a class (see R0903). +min-public-methods=2 + + +[EXCEPTIONS] + +# Exceptions that will emit a warning when being caught. Defaults to +# "BaseException, Exception". +overgeneral-exceptions=BaseException, + Exception diff --git a/requirements_test.txt b/requirements_test.txt index a4e209a..8cfbf7c 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,9 +1,8 @@ -black==21.4b0 +black==22.3.0 codespell==2.0.0 flake8==3.9.2 mypy==0.901 pydocstyle==6.1.1 -cryptography==3.3.2 pylint==2.8.2 pylint-strict-informational==0.1 -homeassistant==2021.7.1 +homeassistant==2021.12.10 diff --git a/tox.ini b/tox.ini index c7d9e69..f965d9f 100644 --- a/tox.ini +++ b/tox.ini @@ -27,11 +27,11 @@ ignore_errors = True deps = {[testenv]deps} commands = - codespell -q 4 -L {[tox]cs_exclude_words} --skip="*.pyc,*.pyi,*~" custom_components + codespell -q 4 -L {[tox]cs_exclude_words} --skip="*.pyc,*.pyi,*~,*.json" custom_components flake8 custom_components black --fast --check . pydocstyle -v custom_components - pylint custom_components/localtuya + pylint custom_components/localtuya --rcfile=pylint.rc [testenv:typing] commands =