├── .gitignore ├── img ├── icon.png ├── logo.png ├── icon@2x.png ├── logo@2x.png ├── 2-device.png ├── 4-entity.png ├── 5-success.png ├── 8-user_id.png ├── logo-small.png ├── 1-discovery.png ├── 7-auth_keys.png ├── 11-config_menu.png ├── 3-entity_type.png ├── 6-project_date.png ├── 9-cloud_setup.png └── 10-integration_configure.png ├── tuyadebug.tgz ├── hacs.json ├── requirements_test.txt ├── custom_components └── localtuya │ ├── manifest.json │ ├── services.yaml │ ├── binary_sensor.py │ ├── sensor.py │ ├── diagnostics.py │ ├── discovery.py │ ├── switch.py │ ├── number.py │ ├── const.py │ ├── select.py │ ├── cloud_api.py │ ├── strings.json │ ├── vacuum.py │ ├── fan.py │ ├── cover.py │ ├── translations │ ├── it.json │ ├── pt-BR.json │ └── en.json │ ├── __init__.py │ ├── climate.py │ └── light.py ├── .dependabot └── config.yml ├── .github ├── workflows │ ├── validate.yml │ └── tox.yaml └── ISSUE_TEMPLATE │ └── bug_report.md ├── tox.ini ├── setup.cfg ├── pyproject.toml ├── info.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | __pycache__ 3 | .tox 4 | tuyadebug/ 5 | .pre-commit-config.yaml -------------------------------------------------------------------------------- /img/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rospogrigio/localtuya/HEAD/img/icon.png -------------------------------------------------------------------------------- /img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rospogrigio/localtuya/HEAD/img/logo.png -------------------------------------------------------------------------------- /tuyadebug.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rospogrigio/localtuya/HEAD/tuyadebug.tgz -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Local Tuya", 3 | "homeassistant": "2024.1.0" 4 | } 5 | -------------------------------------------------------------------------------- /img/icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rospogrigio/localtuya/HEAD/img/icon@2x.png -------------------------------------------------------------------------------- /img/logo@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rospogrigio/localtuya/HEAD/img/logo@2x.png -------------------------------------------------------------------------------- /img/2-device.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rospogrigio/localtuya/HEAD/img/2-device.png -------------------------------------------------------------------------------- /img/4-entity.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rospogrigio/localtuya/HEAD/img/4-entity.png -------------------------------------------------------------------------------- /img/5-success.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rospogrigio/localtuya/HEAD/img/5-success.png -------------------------------------------------------------------------------- /img/8-user_id.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rospogrigio/localtuya/HEAD/img/8-user_id.png -------------------------------------------------------------------------------- /img/logo-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rospogrigio/localtuya/HEAD/img/logo-small.png -------------------------------------------------------------------------------- /img/1-discovery.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rospogrigio/localtuya/HEAD/img/1-discovery.png -------------------------------------------------------------------------------- /img/7-auth_keys.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rospogrigio/localtuya/HEAD/img/7-auth_keys.png -------------------------------------------------------------------------------- /img/11-config_menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rospogrigio/localtuya/HEAD/img/11-config_menu.png -------------------------------------------------------------------------------- /img/3-entity_type.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rospogrigio/localtuya/HEAD/img/3-entity_type.png -------------------------------------------------------------------------------- /img/6-project_date.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rospogrigio/localtuya/HEAD/img/6-project_date.png -------------------------------------------------------------------------------- /img/9-cloud_setup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rospogrigio/localtuya/HEAD/img/9-cloud_setup.png -------------------------------------------------------------------------------- /img/10-integration_configure.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rospogrigio/localtuya/HEAD/img/10-integration_configure.png -------------------------------------------------------------------------------- /requirements_test.txt: -------------------------------------------------------------------------------- 1 | black==22.3.0 2 | codespell==2.0.0 3 | flake8==3.9.2 4 | mypy==0.901 5 | pydocstyle==6.1.1 6 | pylint==2.8.2 7 | pylint-strict-informational==0.1 8 | homeassistant==2021.12.10 9 | -------------------------------------------------------------------------------- /custom_components/localtuya/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "localtuya", 3 | "name": "LocalTuya integration", 4 | "codeowners": [ 5 | "@rospogrigio", "@postlund" 6 | ], 7 | "config_flow": true, 8 | "dependencies": [], 9 | "documentation": "https://github.com/rospogrigio/localtuya/", 10 | "iot_class": "local_push", 11 | "issue_tracker": "https://github.com/rospogrigio/localtuya/issues", 12 | "requirements": [], 13 | "version": "5.2.3" 14 | } 15 | -------------------------------------------------------------------------------- /custom_components/localtuya/services.yaml: -------------------------------------------------------------------------------- 1 | reload: 2 | description: Reload localtuya and reconnect to all devices. 3 | 4 | set_dp: 5 | description: Change the value of a datapoint (DP) 6 | fields: 7 | device_id: 8 | description: Device ID of device to change datapoint value for 9 | example: 11100118278aab4de001 10 | dp: 11 | description: Datapoint index 12 | example: 1 13 | value: 14 | description: New value to set 15 | example: False 16 | -------------------------------------------------------------------------------- /.dependabot/config.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | update_configs: 3 | - package_manager: "python" 4 | directory: "/" 5 | update_schedule: "live" 6 | default_reviewers: 7 | - postlund 8 | - rospogrigio 9 | - ultratoto14 10 | default_labels: 11 | - dependencies 12 | default_assignees: 13 | - postlund 14 | - rospogrigio 15 | - ultratoto14 16 | automerged_updates: 17 | - match: 18 | dependency_type: "all" 19 | update_type: "all" 20 | -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: HACS Validate 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | workflow_dispatch: 9 | 10 | jobs: 11 | validate-hacs: 12 | runs-on: "ubuntu-latest" 13 | steps: 14 | - uses: "actions/checkout@v3" 15 | - name: HACS validation 16 | uses: "hacs/action@main" 17 | with: 18 | category: "integration" 19 | - name: Hassfest validation 20 | uses: home-assistant/actions/hassfest@master 21 | -------------------------------------------------------------------------------- /.github/workflows/tox.yaml: -------------------------------------------------------------------------------- 1 | name: Tox PR CI 2 | 3 | on: 4 | pull_request: 5 | workflow_dispatch: 6 | 7 | jobs: 8 | build: 9 | name: >- 10 | ${{ matrix.python-version }} 11 | / 12 | ${{ matrix.platform }} 13 | runs-on: ${{ matrix.platform }} 14 | strategy: 15 | matrix: 16 | platform: 17 | - ubuntu-latest 18 | python-version: 19 | - 3.9 20 | steps: 21 | - uses: actions/checkout@v2 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v2 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade setuptools pip 29 | python -m pip install tox-gh-actions 30 | - name: Run tox 31 | run: tox -q -p auto 32 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | skipsdist = true 3 | envlist = py{311}, lint, typing 4 | skip_missing_interpreters = True 5 | cs_exclude_words = hass,unvalid 6 | 7 | [gh-actions] 8 | python = 9 | 3.11: clean, py311, lint, typing 10 | 11 | [testenv] 12 | passenv = TOXENV,CI 13 | allowlist_externals = 14 | true 15 | setenv = 16 | LANG=en_US.UTF-8 17 | PYTHONPATH = {toxinidir}/localtuya-homeassistant 18 | deps = 19 | -r{toxinidir}/requirements_test.txt 20 | commands = 21 | true # TODO: Run tests later 22 | #pytest -n auto --log-level=debug -v --timeout=30 --durations=10 {posargs} 23 | 24 | [testenv:lint] 25 | ignore_errors = True 26 | deps = 27 | {[testenv]deps} 28 | commands = 29 | codespell -q 4 -L {[tox]cs_exclude_words} --skip="*.pyc,*.pyi,*~,*.json" custom_components 30 | flake8 custom_components 31 | black --fast --check . 32 | pydocstyle -v custom_components 33 | # pylint custom_components/localtuya --rcfile=pylint.rc 34 | 35 | [testenv:typing] 36 | commands = 37 | mypy --ignore-missing-imports --follow-imports=skip custom_components 38 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = .git,.tox 3 | max-line-length = 120 4 | ignore = E203, W503 5 | 6 | [mypy] 7 | python_version = 3.11 8 | ignore_errors = true 9 | follow_imports = silent 10 | ignore_missing_imports = true 11 | warn_incomplete_stub = true 12 | warn_redundant_casts = true 13 | warn_unused_configs = true 14 | 15 | [mypy-homeassistant.block_async_io,homeassistant.bootstrap,homeassistant.components,homeassistant.config_entries,homeassistant.config,homeassistant.const,homeassistant.core,homeassistant.data_entry_flow,homeassistant.exceptions,homeassistant.__init__,homeassistant.loader,homeassistant.__main__,homeassistant.requirements,homeassistant.runner,homeassistant.setup,homeassistant.util,homeassistant.auth.*,homeassistant.components.automation.*,homeassistant.components.binary_sensor.*,homeassistant.components.calendar.*,homeassistant.components.cover.*,homeassistant.components.device_automation.*,homeassistant.components.frontend.*,homeassistant.components.geo_location.*,homeassistant.components.group.*,homeassistant.components.history.*,homeassistant.components.http.*,homeassistant.components.image_processing.*,homeassistant.components.integration.*,homeassistant.components.light.*,homeassistant.components.lock.*,homeassistant.components.mailbox.*,homeassistant.components.media_player.*,homeassistant.components.notify.*,homeassistant.components.persistent_notification.*,homeassistant.components.proximity.*,homeassistant.components.remote.*,homeassistant.components.scene.*,homeassistant.components.sensor.*,homeassistant.components.sun.*,homeassistant.components.switch.*,homeassistant.components.systemmonitor.*,homeassistant.components.tts.*,homeassistant.components.vacuum.*,homeassistant.components.water_heater.*,homeassistant.components.weather.*,homeassistant.components.websocket_api.*,homeassistant.components.zone.*,homeassistant.helpers.*,homeassistant.scripts.*,homeassistant.util.*,homeassistant.components.select.*,homeassistant.components.number.*] 16 | strict = true 17 | ignore_errors = false 18 | warn_unreachable = true 19 | # TODO: turn these off, address issues 20 | allow_any_generics = true 21 | implicit_reexport = true 22 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | target-version = ["py311"] 3 | include = 'custom_components/localtuya/.*\.py' 4 | 5 | # pylint config stolen from Home Assistant 6 | # Use a conservative default here; 2 should speed up most setups and not hurt 7 | # any too bad. Override on command line as appropriate. 8 | # Disabled for now: https://github.com/PyCQA/pylint/issues/3584 9 | #jobs = 2 10 | load-plugins = [ 11 | "pylint_strict_informational", 12 | ] 13 | persistent = false 14 | extension-pkg-whitelist = [ 15 | "ciso8601", 16 | "cv2", 17 | ] 18 | 19 | [tool.pylint.BASIC] 20 | good-names = [ 21 | "_", 22 | "ev", 23 | "ex", 24 | "fp", 25 | "i", 26 | "id", 27 | "j", 28 | "k", 29 | "Run", 30 | "T", 31 | "hs", 32 | ] 33 | 34 | [tool.pylint."MESSAGES CONTROL"] 35 | # Reasons disabled: 36 | # format - handled by black 37 | # locally-disabled - it spams too much 38 | # duplicate-code - unavoidable 39 | # cyclic-import - doesn't test if both import on load 40 | # abstract-class-little-used - prevents from setting right foundation 41 | # unused-argument - generic callbacks and setup methods create a lot of warnings 42 | # too-many-* - are not enforced for the sake of readability 43 | # too-few-* - same as too-many-* 44 | # abstract-method - with intro of async there are always methods missing 45 | # inconsistent-return-statements - doesn't handle raise 46 | # too-many-ancestors - it's too strict. 47 | # wrong-import-order - isort guards this 48 | disable = [ 49 | "format", 50 | "abstract-class-little-used", 51 | "abstract-method", 52 | "cyclic-import", 53 | "duplicate-code", 54 | "inconsistent-return-statements", 55 | "locally-disabled", 56 | "not-context-manager", 57 | "too-few-public-methods", 58 | "too-many-ancestors", 59 | "too-many-arguments", 60 | "too-many-branches", 61 | "too-many-instance-attributes", 62 | "too-many-lines", 63 | "too-many-locals", 64 | "too-many-public-methods", 65 | "too-many-return-statements", 66 | "too-many-statements", 67 | "too-many-boolean-expressions", 68 | "unused-argument", 69 | "wrong-import-order", 70 | ] 71 | enable = [ 72 | "use-symbolic-message-instead", 73 | ] 74 | 75 | [tool.pylint.REPORTS] 76 | score = false 77 | 78 | [tool.pylint.FORMAT] 79 | expected-line-ending-format = "LF" 80 | 81 | [tool.pylint.MISCELLANEOUS] 82 | notes = "XXX" 83 | -------------------------------------------------------------------------------- /custom_components/localtuya/binary_sensor.py: -------------------------------------------------------------------------------- 1 | """Platform to present any Tuya DP as a binary sensor.""" 2 | import logging 3 | from functools import partial 4 | 5 | import voluptuous as vol 6 | from homeassistant.components.binary_sensor import ( 7 | DEVICE_CLASSES_SCHEMA, 8 | DOMAIN, 9 | BinarySensorEntity, 10 | ) 11 | from homeassistant.const import CONF_DEVICE_CLASS 12 | 13 | from .common import LocalTuyaEntity, async_setup_entry 14 | 15 | _LOGGER = logging.getLogger(__name__) 16 | 17 | CONF_STATE_ON = "state_on" 18 | CONF_STATE_OFF = "state_off" 19 | 20 | 21 | def flow_schema(dps): 22 | """Return schema used in config flow.""" 23 | return { 24 | vol.Required(CONF_STATE_ON, default="True"): str, 25 | vol.Required(CONF_STATE_OFF, default="False"): str, 26 | vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, 27 | } 28 | 29 | 30 | class LocaltuyaBinarySensor(LocalTuyaEntity, BinarySensorEntity): 31 | """Representation of a Tuya binary sensor.""" 32 | 33 | def __init__( 34 | self, 35 | device, 36 | config_entry, 37 | sensorid, 38 | **kwargs, 39 | ): 40 | """Initialize the Tuya binary sensor.""" 41 | super().__init__(device, config_entry, sensorid, _LOGGER, **kwargs) 42 | self._is_on = False 43 | 44 | @property 45 | def is_on(self): 46 | """Return sensor state.""" 47 | return self._is_on 48 | 49 | @property 50 | def device_class(self): 51 | """Return the class of this device.""" 52 | return self._config.get(CONF_DEVICE_CLASS) 53 | 54 | def status_updated(self): 55 | """Device status was updated.""" 56 | super().status_updated() 57 | 58 | state = str(self.dps(self._dp_id)).lower() 59 | if state == self._config[CONF_STATE_ON].lower(): 60 | self._is_on = True 61 | elif state == self._config[CONF_STATE_OFF].lower(): 62 | self._is_on = False 63 | else: 64 | self.warning( 65 | "State for entity %s did not match state patterns", self.entity_id 66 | ) 67 | 68 | # No need to restore state for a sensor 69 | async def restore_state_when_connected(self): 70 | """Do nothing for a sensor.""" 71 | return 72 | 73 | 74 | async_setup_entry = partial( 75 | async_setup_entry, DOMAIN, LocaltuyaBinarySensor, flow_schema 76 | ) 77 | -------------------------------------------------------------------------------- /custom_components/localtuya/sensor.py: -------------------------------------------------------------------------------- 1 | """Platform to present any Tuya DP as a sensor.""" 2 | import logging 3 | from functools import partial 4 | 5 | import voluptuous as vol 6 | from homeassistant.components.sensor import DEVICE_CLASSES, DOMAIN 7 | from homeassistant.const import ( 8 | CONF_DEVICE_CLASS, 9 | CONF_UNIT_OF_MEASUREMENT, 10 | STATE_UNKNOWN, 11 | ) 12 | 13 | from .common import LocalTuyaEntity, async_setup_entry 14 | from .const import CONF_SCALING 15 | 16 | _LOGGER = logging.getLogger(__name__) 17 | 18 | DEFAULT_PRECISION = 2 19 | 20 | 21 | def flow_schema(dps): 22 | """Return schema used in config flow.""" 23 | return { 24 | vol.Optional(CONF_UNIT_OF_MEASUREMENT): str, 25 | vol.Optional(CONF_DEVICE_CLASS): vol.In(DEVICE_CLASSES), 26 | vol.Optional(CONF_SCALING): vol.All( 27 | vol.Coerce(float), vol.Range(min=-1000000.0, max=1000000.0) 28 | ), 29 | } 30 | 31 | 32 | class LocaltuyaSensor(LocalTuyaEntity): 33 | """Representation of a Tuya sensor.""" 34 | 35 | def __init__( 36 | self, 37 | device, 38 | config_entry, 39 | sensorid, 40 | **kwargs, 41 | ): 42 | """Initialize the Tuya sensor.""" 43 | super().__init__(device, config_entry, sensorid, _LOGGER, **kwargs) 44 | self._state = STATE_UNKNOWN 45 | 46 | @property 47 | def state(self): 48 | """Return sensor state.""" 49 | return self._state 50 | 51 | @property 52 | def device_class(self): 53 | """Return the class of this device.""" 54 | return self._config.get(CONF_DEVICE_CLASS) 55 | 56 | @property 57 | def unit_of_measurement(self): 58 | """Return the unit of measurement of this entity, if any.""" 59 | return self._config.get(CONF_UNIT_OF_MEASUREMENT) 60 | 61 | def status_updated(self): 62 | """Device status was updated.""" 63 | state = self.dps(self._dp_id) 64 | scale_factor = self._config.get(CONF_SCALING) 65 | if scale_factor is not None and isinstance(state, (int, float)): 66 | state = round(state * scale_factor, DEFAULT_PRECISION) 67 | self._state = state 68 | 69 | # No need to restore state for a sensor 70 | async def restore_state_when_connected(self): 71 | """Do nothing for a sensor.""" 72 | return 73 | 74 | 75 | async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaSensor, flow_schema) 76 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve localtuya 4 | title: '' 5 | labels: 'bug' 6 | assignees: '' 7 | 8 | --- 9 | 15 | ## The problem 16 | 20 | 21 | 22 | ## Environment 23 | 26 | - Localtuya version: 27 | - Home Assistant Core version: 28 | - [] Does the device work using the Home Assistant Tuya Cloud component ? 29 | - [] Does the device work using the Tinytuya (https://github.com/jasonacox/tinytuya) command line tool ? 30 | - [] Was the device working with earlier versions of localtuya ? Which one? 31 | - [] Are you using the Tuya/SmartLife App in parallel ? 32 | 33 | ## Steps to reproduce 34 | 37 | 1. 38 | 2. 39 | 3. 40 | 41 | 42 | ## DP dump 43 | 47 | 48 | ## Provide Home Assistant traceback/logs 49 | 58 | ``` 59 | put your log output between these markers 60 | ``` 61 | 62 | 63 | ## Additional information 64 | 65 | -------------------------------------------------------------------------------- /custom_components/localtuya/diagnostics.py: -------------------------------------------------------------------------------- 1 | """Diagnostics support for LocalTuya.""" 2 | from __future__ import annotations 3 | 4 | import copy 5 | import logging 6 | from typing import Any 7 | 8 | from homeassistant.config_entries import ConfigEntry 9 | from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_DEVICES 10 | from homeassistant.core import HomeAssistant 11 | from homeassistant.helpers.device_registry import DeviceEntry 12 | 13 | from .const import CONF_LOCAL_KEY, CONF_USER_ID, DATA_CLOUD, DOMAIN 14 | 15 | CLOUD_DEVICES = "cloud_devices" 16 | DEVICE_CONFIG = "device_config" 17 | DEVICE_CLOUD_INFO = "device_cloud_info" 18 | 19 | _LOGGER = logging.getLogger(__name__) 20 | 21 | 22 | async def async_get_config_entry_diagnostics( 23 | hass: HomeAssistant, entry: ConfigEntry 24 | ) -> dict[str, Any]: 25 | """Return diagnostics for a config entry.""" 26 | data = {} 27 | data = dict(entry.data) 28 | tuya_api = hass.data[DOMAIN][DATA_CLOUD] 29 | # censoring private information on integration diagnostic data 30 | for field in [CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_USER_ID]: 31 | data[field] = f"{data[field][0:3]}...{data[field][-3:]}" 32 | data[CONF_DEVICES] = copy.deepcopy(entry.data[CONF_DEVICES]) 33 | for dev_id, dev in data[CONF_DEVICES].items(): 34 | local_key = dev[CONF_LOCAL_KEY] 35 | local_key_obfuscated = f"{local_key[0:3]}...{local_key[-3:]}" 36 | dev[CONF_LOCAL_KEY] = local_key_obfuscated 37 | data[CLOUD_DEVICES] = tuya_api.device_list 38 | for dev_id, dev in data[CLOUD_DEVICES].items(): 39 | local_key = data[CLOUD_DEVICES][dev_id][CONF_LOCAL_KEY] 40 | local_key_obfuscated = f"{local_key[0:3]}...{local_key[-3:]}" 41 | data[CLOUD_DEVICES][dev_id][CONF_LOCAL_KEY] = local_key_obfuscated 42 | return data 43 | 44 | 45 | async def async_get_device_diagnostics( 46 | hass: HomeAssistant, entry: ConfigEntry, device: DeviceEntry 47 | ) -> dict[str, Any]: 48 | """Return diagnostics for a device entry.""" 49 | data = {} 50 | dev_id = list(device.identifiers)[0][1].split("_")[-1] 51 | data[DEVICE_CONFIG] = entry.data[CONF_DEVICES][dev_id].copy() 52 | # NOT censoring private information on device diagnostic data 53 | # local_key = data[DEVICE_CONFIG][CONF_LOCAL_KEY] 54 | # data[DEVICE_CONFIG][CONF_LOCAL_KEY] = f"{local_key[0:3]}...{local_key[-3:]}" 55 | 56 | tuya_api = hass.data[DOMAIN][DATA_CLOUD] 57 | if dev_id in tuya_api.device_list: 58 | data[DEVICE_CLOUD_INFO] = tuya_api.device_list[dev_id] 59 | # NOT censoring private information on device diagnostic data 60 | # local_key = data[DEVICE_CLOUD_INFO][CONF_LOCAL_KEY] 61 | # local_key_obfuscated = "{local_key[0:3]}...{local_key[-3:]}" 62 | # data[DEVICE_CLOUD_INFO][CONF_LOCAL_KEY] = local_key_obfuscated 63 | 64 | # data["log"] = hass.data[DOMAIN][CONF_DEVICES][dev_id].logger.retrieve_log() 65 | return data 66 | -------------------------------------------------------------------------------- /custom_components/localtuya/discovery.py: -------------------------------------------------------------------------------- 1 | """Discovery module for Tuya devices. 2 | 3 | Entirely based on tuya-convert.py from tuya-convert: 4 | 5 | https://github.com/ct-Open-Source/tuya-convert/blob/master/scripts/tuya-discovery.py 6 | """ 7 | import asyncio 8 | import json 9 | import logging 10 | from hashlib import md5 11 | 12 | from cryptography.hazmat.backends import default_backend 13 | from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes 14 | 15 | _LOGGER = logging.getLogger(__name__) 16 | 17 | UDP_KEY = md5(b"yGAdlopoPVldABfn").digest() 18 | 19 | DEFAULT_TIMEOUT = 6.0 20 | 21 | 22 | def decrypt_udp(message): 23 | """Decrypt encrypted UDP broadcasts.""" 24 | 25 | def _unpad(data): 26 | return data[: -ord(data[len(data) - 1 :])] 27 | 28 | cipher = Cipher(algorithms.AES(UDP_KEY), modes.ECB(), default_backend()) 29 | decryptor = cipher.decryptor() 30 | return _unpad(decryptor.update(message) + decryptor.finalize()).decode() 31 | 32 | 33 | class TuyaDiscovery(asyncio.DatagramProtocol): 34 | """Datagram handler listening for Tuya broadcast messages.""" 35 | 36 | def __init__(self, callback=None): 37 | """Initialize a new BaseDiscovery.""" 38 | self.devices = {} 39 | self._listeners = [] 40 | self._callback = callback 41 | 42 | async def start(self): 43 | """Start discovery by listening to broadcasts.""" 44 | loop = asyncio.get_running_loop() 45 | listener = loop.create_datagram_endpoint( 46 | lambda: self, local_addr=("0.0.0.0", 6666), reuse_port=True 47 | ) 48 | encrypted_listener = loop.create_datagram_endpoint( 49 | lambda: self, local_addr=("0.0.0.0", 6667), reuse_port=True 50 | ) 51 | 52 | self._listeners = await asyncio.gather(listener, encrypted_listener) 53 | _LOGGER.debug("Listening to broadcasts on UDP port 6666 and 6667") 54 | 55 | def close(self): 56 | """Stop discovery.""" 57 | self._callback = None 58 | for transport, _ in self._listeners: 59 | transport.close() 60 | 61 | def datagram_received(self, data, addr): 62 | """Handle received broadcast message.""" 63 | data = data[20:-8] 64 | try: 65 | data = decrypt_udp(data) 66 | except Exception: # pylint: disable=broad-except 67 | data = data.decode() 68 | 69 | decoded = json.loads(data) 70 | self.device_found(decoded) 71 | 72 | def device_found(self, device): 73 | """Discover a new device.""" 74 | if device.get("gwId") not in self.devices: 75 | self.devices[device.get("gwId")] = device 76 | _LOGGER.debug("Discovered device: %s", device) 77 | 78 | if self._callback: 79 | self._callback(device) 80 | 81 | 82 | async def discover(): 83 | """Discover and return devices on local network.""" 84 | discovery = TuyaDiscovery() 85 | try: 86 | await discovery.start() 87 | await asyncio.sleep(DEFAULT_TIMEOUT) 88 | finally: 89 | discovery.close() 90 | return discovery.devices 91 | -------------------------------------------------------------------------------- /custom_components/localtuya/switch.py: -------------------------------------------------------------------------------- 1 | """Platform to locally control Tuya-based switch devices.""" 2 | import logging 3 | from functools import partial 4 | 5 | import voluptuous as vol 6 | from homeassistant.components.switch import DOMAIN, SwitchEntity 7 | 8 | from .common import LocalTuyaEntity, async_setup_entry 9 | from .const import ( 10 | ATTR_CURRENT, 11 | ATTR_CURRENT_CONSUMPTION, 12 | ATTR_STATE, 13 | ATTR_VOLTAGE, 14 | CONF_CURRENT, 15 | CONF_CURRENT_CONSUMPTION, 16 | CONF_DEFAULT_VALUE, 17 | CONF_PASSIVE_ENTITY, 18 | CONF_RESTORE_ON_RECONNECT, 19 | CONF_VOLTAGE, 20 | ) 21 | 22 | _LOGGER = logging.getLogger(__name__) 23 | 24 | 25 | def flow_schema(dps): 26 | """Return schema used in config flow.""" 27 | return { 28 | vol.Optional(CONF_CURRENT): vol.In(dps), 29 | vol.Optional(CONF_CURRENT_CONSUMPTION): vol.In(dps), 30 | vol.Optional(CONF_VOLTAGE): vol.In(dps), 31 | vol.Required(CONF_RESTORE_ON_RECONNECT): bool, 32 | vol.Required(CONF_PASSIVE_ENTITY): bool, 33 | vol.Optional(CONF_DEFAULT_VALUE): str, 34 | } 35 | 36 | 37 | class LocaltuyaSwitch(LocalTuyaEntity, SwitchEntity): 38 | """Representation of a Tuya switch.""" 39 | 40 | def __init__( 41 | self, 42 | device, 43 | config_entry, 44 | switchid, 45 | **kwargs, 46 | ): 47 | """Initialize the Tuya switch.""" 48 | super().__init__(device, config_entry, switchid, _LOGGER, **kwargs) 49 | self._state = None 50 | _LOGGER.debug("Initialized switch [%s]", self.name) 51 | 52 | @property 53 | def is_on(self): 54 | """Check if Tuya switch is on.""" 55 | return self._state 56 | 57 | @property 58 | def extra_state_attributes(self): 59 | """Return device state attributes.""" 60 | attrs = {} 61 | if self.has_config(CONF_CURRENT): 62 | attrs[ATTR_CURRENT] = self.dps(self._config[CONF_CURRENT]) 63 | if self.has_config(CONF_CURRENT_CONSUMPTION): 64 | attrs[ATTR_CURRENT_CONSUMPTION] = ( 65 | self.dps(self._config[CONF_CURRENT_CONSUMPTION]) / 10 66 | ) 67 | if self.has_config(CONF_VOLTAGE): 68 | attrs[ATTR_VOLTAGE] = self.dps(self._config[CONF_VOLTAGE]) / 10 69 | 70 | # Store the state 71 | if self._state is not None: 72 | attrs[ATTR_STATE] = self._state 73 | elif self._last_state is not None: 74 | attrs[ATTR_STATE] = self._last_state 75 | return attrs 76 | 77 | async def async_turn_on(self, **kwargs): 78 | """Turn Tuya switch on.""" 79 | await self._device.set_dp(True, self._dp_id) 80 | 81 | async def async_turn_off(self, **kwargs): 82 | """Turn Tuya switch off.""" 83 | await self._device.set_dp(False, self._dp_id) 84 | 85 | # Default value is the "OFF" state 86 | def entity_default_value(self): 87 | """Return False as the default value for this entity type.""" 88 | return False 89 | 90 | 91 | async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaSwitch, flow_schema) 92 | -------------------------------------------------------------------------------- /custom_components/localtuya/number.py: -------------------------------------------------------------------------------- 1 | """Platform to present any Tuya DP as a number.""" 2 | import logging 3 | from functools import partial 4 | 5 | import voluptuous as vol 6 | from homeassistant.components.number import DOMAIN, NumberEntity 7 | from homeassistant.const import CONF_DEVICE_CLASS, STATE_UNKNOWN 8 | 9 | from .common import LocalTuyaEntity, async_setup_entry 10 | from .const import ( 11 | CONF_DEFAULT_VALUE, 12 | CONF_MAX_VALUE, 13 | CONF_MIN_VALUE, 14 | CONF_PASSIVE_ENTITY, 15 | CONF_RESTORE_ON_RECONNECT, 16 | CONF_STEPSIZE_VALUE, 17 | ) 18 | 19 | _LOGGER = logging.getLogger(__name__) 20 | 21 | DEFAULT_MIN = 0 22 | DEFAULT_MAX = 100000 23 | DEFAULT_STEP = 1.0 24 | 25 | 26 | def flow_schema(dps): 27 | """Return schema used in config flow.""" 28 | return { 29 | vol.Optional(CONF_MIN_VALUE, default=DEFAULT_MIN): vol.All( 30 | vol.Coerce(float), 31 | vol.Range(min=-1000000.0, max=1000000.0), 32 | ), 33 | vol.Required(CONF_MAX_VALUE, default=DEFAULT_MAX): vol.All( 34 | vol.Coerce(float), 35 | vol.Range(min=-1000000.0, max=1000000.0), 36 | ), 37 | vol.Required(CONF_STEPSIZE_VALUE, default=DEFAULT_STEP): vol.All( 38 | vol.Coerce(float), 39 | vol.Range(min=0.0, max=1000000.0), 40 | ), 41 | vol.Required(CONF_RESTORE_ON_RECONNECT): bool, 42 | vol.Required(CONF_PASSIVE_ENTITY): bool, 43 | vol.Optional(CONF_DEFAULT_VALUE): str, 44 | } 45 | 46 | 47 | class LocaltuyaNumber(LocalTuyaEntity, NumberEntity): 48 | """Representation of a Tuya Number.""" 49 | 50 | def __init__( 51 | self, 52 | device, 53 | config_entry, 54 | sensorid, 55 | **kwargs, 56 | ): 57 | """Initialize the Tuya sensor.""" 58 | super().__init__(device, config_entry, sensorid, _LOGGER, **kwargs) 59 | self._state = STATE_UNKNOWN 60 | 61 | self._min_value = DEFAULT_MIN 62 | if CONF_MIN_VALUE in self._config: 63 | self._min_value = self._config.get(CONF_MIN_VALUE) 64 | 65 | self._max_value = DEFAULT_MAX 66 | if CONF_MAX_VALUE in self._config: 67 | self._max_value = self._config.get(CONF_MAX_VALUE) 68 | 69 | self._step_size = DEFAULT_STEP 70 | if CONF_STEPSIZE_VALUE in self._config: 71 | self._step_size = self._config.get(CONF_STEPSIZE_VALUE) 72 | 73 | # Override standard default value handling to cast to a float 74 | default_value = self._config.get(CONF_DEFAULT_VALUE) 75 | if default_value is not None: 76 | self._default_value = float(default_value) 77 | 78 | @property 79 | def native_value(self) -> float: 80 | """Return sensor state.""" 81 | return self._state 82 | 83 | @property 84 | def native_min_value(self) -> float: 85 | """Return the minimum value.""" 86 | return self._min_value 87 | 88 | @property 89 | def native_max_value(self) -> float: 90 | """Return the maximum value.""" 91 | return self._max_value 92 | 93 | @property 94 | def native_step(self) -> float: 95 | """Return the maximum value.""" 96 | return self._step_size 97 | 98 | @property 99 | def device_class(self): 100 | """Return the class of this device.""" 101 | return self._config.get(CONF_DEVICE_CLASS) 102 | 103 | async def async_set_native_value(self, value: float) -> None: 104 | """Update the current value.""" 105 | await self._device.set_dp(value, self._dp_id) 106 | 107 | # Default value is the minimum value 108 | def entity_default_value(self): 109 | """Return the minimum value as the default for this entity type.""" 110 | return self._min_value 111 | 112 | 113 | async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaNumber, flow_schema) 114 | -------------------------------------------------------------------------------- /custom_components/localtuya/const.py: -------------------------------------------------------------------------------- 1 | """Constants for localtuya integration.""" 2 | 3 | DOMAIN = "localtuya" 4 | 5 | DATA_DISCOVERY = "discovery" 6 | DATA_CLOUD = "cloud_data" 7 | 8 | # Platforms in this list must support config flows 9 | PLATFORMS = [ 10 | "binary_sensor", 11 | "climate", 12 | "cover", 13 | "fan", 14 | "light", 15 | "number", 16 | "select", 17 | "sensor", 18 | "switch", 19 | "vacuum", 20 | ] 21 | 22 | TUYA_DEVICES = "tuya_devices" 23 | 24 | ATTR_CURRENT = "current" 25 | ATTR_CURRENT_CONSUMPTION = "current_consumption" 26 | ATTR_VOLTAGE = "voltage" 27 | ATTR_UPDATED_AT = "updated_at" 28 | 29 | # config flow 30 | CONF_LOCAL_KEY = "local_key" 31 | CONF_ENABLE_DEBUG = "enable_debug" 32 | CONF_PROTOCOL_VERSION = "protocol_version" 33 | CONF_DPS_STRINGS = "dps_strings" 34 | CONF_MODEL = "model" 35 | CONF_PRODUCT_KEY = "product_key" 36 | CONF_PRODUCT_NAME = "product_name" 37 | CONF_USER_ID = "user_id" 38 | CONF_ENABLE_ADD_ENTITIES = "add_entities" 39 | 40 | 41 | CONF_ACTION = "action" 42 | CONF_ADD_DEVICE = "add_device" 43 | CONF_EDIT_DEVICE = "edit_device" 44 | CONF_SETUP_CLOUD = "setup_cloud" 45 | CONF_NO_CLOUD = "no_cloud" 46 | CONF_MANUAL_DPS = "manual_dps_strings" 47 | CONF_DEFAULT_VALUE = "dps_default_value" 48 | CONF_RESET_DPIDS = "reset_dpids" 49 | CONF_PASSIVE_ENTITY = "is_passive_entity" 50 | 51 | # light 52 | CONF_BRIGHTNESS_LOWER = "brightness_lower" 53 | CONF_BRIGHTNESS_UPPER = "brightness_upper" 54 | CONF_COLOR = "color" 55 | CONF_COLOR_MODE = "color_mode" 56 | CONF_COLOR_MODE_SET = "color_mode_set" 57 | CONF_COLOR_TEMP_MIN_KELVIN = "color_temp_min_kelvin" 58 | CONF_COLOR_TEMP_MAX_KELVIN = "color_temp_max_kelvin" 59 | CONF_COLOR_TEMP_REVERSE = "color_temp_reverse" 60 | CONF_MUSIC_MODE = "music_mode" 61 | 62 | # switch 63 | CONF_CURRENT = "current" 64 | CONF_CURRENT_CONSUMPTION = "current_consumption" 65 | CONF_VOLTAGE = "voltage" 66 | 67 | # cover 68 | CONF_COMMANDS_SET = "commands_set" 69 | CONF_POSITIONING_MODE = "positioning_mode" 70 | CONF_CURRENT_POSITION_DP = "current_position_dp" 71 | CONF_SET_POSITION_DP = "set_position_dp" 72 | CONF_POSITION_INVERTED = "position_inverted" 73 | CONF_SPAN_TIME = "span_time" 74 | 75 | # fan 76 | CONF_FAN_SPEED_CONTROL = "fan_speed_control" 77 | CONF_FAN_OSCILLATING_CONTROL = "fan_oscillating_control" 78 | CONF_FAN_SPEED_MIN = "fan_speed_min" 79 | CONF_FAN_SPEED_MAX = "fan_speed_max" 80 | CONF_FAN_ORDERED_LIST = "fan_speed_ordered_list" 81 | CONF_FAN_DIRECTION = "fan_direction" 82 | CONF_FAN_DIRECTION_FWD = "fan_direction_forward" 83 | CONF_FAN_DIRECTION_REV = "fan_direction_reverse" 84 | CONF_FAN_DPS_TYPE = "fan_dps_type" 85 | 86 | # sensor 87 | CONF_SCALING = "scaling" 88 | 89 | # climate 90 | CONF_TARGET_TEMPERATURE_DP = "target_temperature_dp" 91 | CONF_CURRENT_TEMPERATURE_DP = "current_temperature_dp" 92 | CONF_TEMPERATURE_STEP = "temperature_step" 93 | CONF_MAX_TEMP_DP = "max_temperature_dp" 94 | CONF_MIN_TEMP_DP = "min_temperature_dp" 95 | CONF_TEMP_MAX = "max_temperature_const" 96 | CONF_TEMP_MIN = "min_temperature_const" 97 | CONF_PRECISION = "precision" 98 | CONF_TARGET_PRECISION = "target_precision" 99 | CONF_HVAC_MODE_DP = "hvac_mode_dp" 100 | CONF_HVAC_MODE_SET = "hvac_mode_set" 101 | CONF_HVAC_FAN_MODE_DP = "hvac_fan_mode_dp" 102 | CONF_HVAC_FAN_MODE_SET = "hvac_fan_mode_set" 103 | CONF_HVAC_SWING_MODE_DP = "hvac_swing_mode_dp" 104 | CONF_HVAC_SWING_MODE_SET = "hvac_swing_mode_set" 105 | CONF_PRESET_DP = "preset_dp" 106 | CONF_PRESET_SET = "preset_set" 107 | CONF_HEURISTIC_ACTION = "heuristic_action" 108 | CONF_HVAC_ACTION_DP = "hvac_action_dp" 109 | CONF_HVAC_ACTION_SET = "hvac_action_set" 110 | CONF_ECO_DP = "eco_dp" 111 | CONF_ECO_VALUE = "eco_value" 112 | 113 | # vacuum 114 | CONF_POWERGO_DP = "powergo_dp" 115 | CONF_IDLE_STATUS_VALUE = "idle_status_value" 116 | CONF_RETURNING_STATUS_VALUE = "returning_status_value" 117 | CONF_DOCKED_STATUS_VALUE = "docked_status_value" 118 | CONF_BATTERY_DP = "battery_dp" 119 | CONF_MODE_DP = "mode_dp" 120 | CONF_MODES = "modes" 121 | CONF_FAN_SPEED_DP = "fan_speed_dp" 122 | CONF_FAN_SPEEDS = "fan_speeds" 123 | CONF_CLEAN_TIME_DP = "clean_time_dp" 124 | CONF_CLEAN_AREA_DP = "clean_area_dp" 125 | CONF_CLEAN_RECORD_DP = "clean_record_dp" 126 | CONF_LOCATE_DP = "locate_dp" 127 | CONF_FAULT_DP = "fault_dp" 128 | CONF_PAUSED_STATE = "paused_state" 129 | CONF_RETURN_MODE = "return_mode" 130 | CONF_STOP_STATUS = "stop_status" 131 | 132 | # number 133 | CONF_MIN_VALUE = "min_value" 134 | CONF_MAX_VALUE = "max_value" 135 | CONF_STEPSIZE_VALUE = "step_size" 136 | 137 | # select 138 | CONF_OPTIONS = "select_options" 139 | CONF_OPTIONS_FRIENDLY = "select_options_friendly" 140 | 141 | # States 142 | ATTR_STATE = "raw_state" 143 | CONF_RESTORE_ON_RECONNECT = "restore_on_reconnect" 144 | -------------------------------------------------------------------------------- /custom_components/localtuya/select.py: -------------------------------------------------------------------------------- 1 | """Platform to present any Tuya DP as an enumeration.""" 2 | import logging 3 | from functools import partial 4 | 5 | import voluptuous as vol 6 | from homeassistant.components.select import DOMAIN, SelectEntity 7 | from homeassistant.const import CONF_DEVICE_CLASS, STATE_UNKNOWN 8 | 9 | from .common import LocalTuyaEntity, async_setup_entry 10 | from .const import ( 11 | CONF_DEFAULT_VALUE, 12 | CONF_OPTIONS, 13 | CONF_OPTIONS_FRIENDLY, 14 | CONF_PASSIVE_ENTITY, 15 | CONF_RESTORE_ON_RECONNECT, 16 | ) 17 | 18 | 19 | def flow_schema(dps): 20 | """Return schema used in config flow.""" 21 | return { 22 | vol.Required(CONF_OPTIONS): str, 23 | vol.Optional(CONF_OPTIONS_FRIENDLY): str, 24 | vol.Required(CONF_RESTORE_ON_RECONNECT): bool, 25 | vol.Required(CONF_PASSIVE_ENTITY): bool, 26 | vol.Optional(CONF_DEFAULT_VALUE): str, 27 | } 28 | 29 | 30 | _LOGGER = logging.getLogger(__name__) 31 | 32 | 33 | class LocaltuyaSelect(LocalTuyaEntity, SelectEntity): 34 | """Representation of a Tuya Enumeration.""" 35 | 36 | def __init__( 37 | self, 38 | device, 39 | config_entry, 40 | sensorid, 41 | **kwargs, 42 | ): 43 | """Initialize the Tuya sensor.""" 44 | super().__init__(device, config_entry, sensorid, _LOGGER, **kwargs) 45 | self._state = STATE_UNKNOWN 46 | self._state_friendly = "" 47 | self._valid_options = self._config.get(CONF_OPTIONS).split(";") 48 | 49 | # Set Display options 50 | self._display_options = [] 51 | display_options_str = "" 52 | if CONF_OPTIONS_FRIENDLY in self._config: 53 | display_options_str = self._config.get(CONF_OPTIONS_FRIENDLY).strip() 54 | _LOGGER.debug("Display Options Configured: %s", display_options_str) 55 | 56 | if display_options_str.find(";") >= 0: 57 | self._display_options = display_options_str.split(";") 58 | elif len(display_options_str.strip()) > 0: 59 | self._display_options.append(display_options_str) 60 | else: 61 | # Default display string to raw string 62 | _LOGGER.debug("No Display options configured - defaulting to raw values") 63 | self._display_options = self._valid_options 64 | 65 | _LOGGER.debug( 66 | "Total Raw Options: %s - Total Display Options: %s", 67 | str(len(self._valid_options)), 68 | str(len(self._display_options)), 69 | ) 70 | if len(self._valid_options) > len(self._display_options): 71 | # If list of display items smaller than list of valid items, 72 | # then default remaining items to be the raw value 73 | _LOGGER.debug( 74 | "Valid options is larger than display options - \ 75 | filling up with raw values" 76 | ) 77 | for i in range(len(self._display_options), len(self._valid_options)): 78 | self._display_options.append(self._valid_options[i]) 79 | 80 | @property 81 | def current_option(self) -> str: 82 | """Return the current value.""" 83 | return self._state_friendly 84 | 85 | @property 86 | def options(self) -> list: 87 | """Return the list of values.""" 88 | return self._display_options 89 | 90 | @property 91 | def device_class(self): 92 | """Return the class of this device.""" 93 | return self._config.get(CONF_DEVICE_CLASS) 94 | 95 | async def async_select_option(self, option: str) -> None: 96 | """Update the current value.""" 97 | option_value = self._valid_options[self._display_options.index(option)] 98 | _LOGGER.debug("Sending Option: " + option + " -> " + option_value) 99 | await self._device.set_dp(option_value, self._dp_id) 100 | 101 | def status_updated(self): 102 | """Device status was updated.""" 103 | super().status_updated() 104 | 105 | state = self.dps(self._dp_id) 106 | 107 | # Check that received status update for this entity. 108 | if state is not None: 109 | try: 110 | self._state_friendly = self._display_options[ 111 | self._valid_options.index(state) 112 | ] 113 | except Exception: # pylint: disable=broad-except 114 | # Friendly value couldn't be mapped 115 | self._state_friendly = state 116 | 117 | # Default value is the first option 118 | def entity_default_value(self): 119 | """Return the first option as the default value for this entity type.""" 120 | return self._valid_options[0] 121 | 122 | 123 | async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaSelect, flow_schema) 124 | -------------------------------------------------------------------------------- /custom_components/localtuya/cloud_api.py: -------------------------------------------------------------------------------- 1 | """Class to perform requests to Tuya Cloud APIs.""" 2 | import functools 3 | import hashlib 4 | import hmac 5 | import json 6 | import logging 7 | import time 8 | 9 | import requests 10 | 11 | _LOGGER = logging.getLogger(__name__) 12 | 13 | 14 | # Signature algorithm. 15 | def calc_sign(msg, key): 16 | """Calculate signature for request.""" 17 | sign = ( 18 | hmac.new( 19 | msg=bytes(msg, "latin-1"), 20 | key=bytes(key, "latin-1"), 21 | digestmod=hashlib.sha256, 22 | ) 23 | .hexdigest() 24 | .upper() 25 | ) 26 | return sign 27 | 28 | 29 | class TuyaCloudApi: 30 | """Class to send API calls.""" 31 | 32 | def __init__(self, hass, region_code, client_id, secret, user_id): 33 | """Initialize the class.""" 34 | self._hass = hass 35 | self._base_url = f"https://openapi.tuya{region_code}.com" 36 | self._client_id = client_id 37 | self._secret = secret 38 | self._user_id = user_id 39 | self._access_token = "" 40 | self.device_list = {} 41 | 42 | def generate_payload(self, method, timestamp, url, headers, body=None): 43 | """Generate signed payload for requests.""" 44 | payload = self._client_id + self._access_token + timestamp 45 | 46 | payload += method + "\n" 47 | # Content-SHA256 48 | payload += hashlib.sha256(bytes((body or "").encode("utf-8"))).hexdigest() 49 | payload += ( 50 | "\n" 51 | + "".join( 52 | [ 53 | "%s:%s\n" % (key, headers[key]) # Headers 54 | for key in headers.get("Signature-Headers", "").split(":") 55 | if key in headers 56 | ] 57 | ) 58 | + "\n/" 59 | + url.split("//", 1)[-1].split("/", 1)[-1] # Url 60 | ) 61 | # _LOGGER.debug("PAYLOAD: %s", payload) 62 | return payload 63 | 64 | async def async_make_request(self, method, url, body=None, headers={}): 65 | """Perform requests.""" 66 | timestamp = str(int(time.time() * 1000)) 67 | payload = self.generate_payload(method, timestamp, url, headers, body) 68 | default_par = { 69 | "client_id": self._client_id, 70 | "access_token": self._access_token, 71 | "sign": calc_sign(payload, self._secret), 72 | "t": timestamp, 73 | "sign_method": "HMAC-SHA256", 74 | } 75 | full_url = self._base_url + url 76 | # _LOGGER.debug("\n" + method + ": [%s]", full_url) 77 | 78 | if method == "GET": 79 | func = functools.partial( 80 | requests.get, full_url, headers=dict(default_par, **headers) 81 | ) 82 | elif method == "POST": 83 | func = functools.partial( 84 | requests.post, 85 | full_url, 86 | headers=dict(default_par, **headers), 87 | data=json.dumps(body), 88 | ) 89 | # _LOGGER.debug("BODY: [%s]", body) 90 | elif method == "PUT": 91 | func = functools.partial( 92 | requests.put, 93 | full_url, 94 | headers=dict(default_par, **headers), 95 | data=json.dumps(body), 96 | ) 97 | 98 | resp = await self._hass.async_add_executor_job(func) 99 | # r = json.dumps(r.json(), indent=2, ensure_ascii=False) # Beautify the format 100 | return resp 101 | 102 | async def async_get_access_token(self): 103 | """Obtain a valid access token.""" 104 | try: 105 | resp = await self.async_make_request("GET", "/v1.0/token?grant_type=1") 106 | except requests.exceptions.ConnectionError: 107 | return "Request failed, status ConnectionError" 108 | 109 | if not resp.ok: 110 | return "Request failed, status " + str(resp.status) 111 | 112 | r_json = resp.json() 113 | if not r_json["success"]: 114 | return f"Error {r_json['code']}: {r_json['msg']}" 115 | 116 | self._access_token = resp.json()["result"]["access_token"] 117 | return "ok" 118 | 119 | async def async_get_devices_list(self): 120 | """Obtain the list of devices associated to a user.""" 121 | resp = await self.async_make_request( 122 | "GET", url=f"/v1.0/users/{self._user_id}/devices" 123 | ) 124 | 125 | if not resp.ok: 126 | return "Request failed, status " + str(resp.status) 127 | 128 | r_json = resp.json() 129 | if not r_json["success"]: 130 | # _LOGGER.debug( 131 | # "Request failed, reply is %s", 132 | # json.dumps(r_json, indent=2, ensure_ascii=False) 133 | # ) 134 | return f"Error {r_json['code']}: {r_json['msg']}" 135 | 136 | self.device_list = {dev["id"]: dev for dev in r_json["result"]} 137 | # _LOGGER.debug("DEV_LIST: %s", self.device_list) 138 | 139 | return "ok" 140 | -------------------------------------------------------------------------------- /custom_components/localtuya/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Device has already been configured.", 5 | "unsupported_device_type": "Unsupported device type!" 6 | }, 7 | "error": { 8 | "cannot_connect": "Cannot connect to device. Verify that address is correct.", 9 | "invalid_auth": "Failed to authenticate with device. Verify that device id and local key are correct.", 10 | "unknown": "An unknown error occurred. See log for details.", 11 | "switch_already_configured": "Switch with this ID has already been configured." 12 | }, 13 | "step": { 14 | "user": { 15 | "title": "Main Configuration", 16 | "description": "Input the credentials for Tuya Cloud API.", 17 | "data": { 18 | "region": "API server region", 19 | "client_id": "Client ID", 20 | "client_secret": "Secret", 21 | "user_id": "User ID" 22 | } 23 | }, 24 | "power_outlet": { 25 | "title": "Add subswitch", 26 | "description": "You are about to add subswitch number `{number}`. If you want to add another, tick `Add another switch` before continuing.", 27 | "data": { 28 | "id": "ID", 29 | "name": "Name", 30 | "friendly_name": "Friendly name", 31 | "current": "Current", 32 | "current_consumption": "Current Consumption", 33 | "voltage": "Voltage", 34 | "add_another_switch": "Add another switch" 35 | } 36 | } 37 | } 38 | }, 39 | "options": { 40 | "step": { 41 | "init": { 42 | "title": "LocalTuya Configuration", 43 | "description": "Please select the desired actionSSSS.", 44 | "data": { 45 | "add_device": "Add a new device", 46 | "edit_device": "Edit a device", 47 | "delete_device": "Delete a device", 48 | "setup_cloud": "Reconfigure Cloud API account" 49 | } 50 | }, 51 | "entity": { 52 | "title": "Entity Configuration", 53 | "description": "Editing entity with DPS `{id}` and platform `{platform}`.", 54 | "data": { 55 | "id": "ID", 56 | "friendly_name": "Friendly name", 57 | "current": "Current", 58 | "current_consumption": "Current Consumption", 59 | "voltage": "Voltage", 60 | "commands_set": "Open_Close_Stop Commands Set", 61 | "positioning_mode": "Positioning mode", 62 | "current_position_dp": "Current Position (for *position* mode only)", 63 | "set_position_dp": "Set Position (for *position* mode only)", 64 | "position_inverted": "Invert 0-100 position (for *position* mode only)", 65 | "span_time": "Full opening time, in secs. (for *timed* mode only)", 66 | "unit_of_measurement": "Unit of Measurement", 67 | "device_class": "Device Class", 68 | "scaling": "Scaling Factor", 69 | "state_on": "On Value", 70 | "state_off": "Off Value", 71 | "powergo_dp": "Power DP (Usually 25 or 2)", 72 | "idle_status_value": "Idle Status (comma-separated)", 73 | "returning_status_value": "Returning Status", 74 | "docked_status_value": "Docked Status (comma-separated)", 75 | "fault_dp": "Fault DP (Usually 11)", 76 | "battery_dp": "Battery status DP (Usually 14)", 77 | "mode_dp": "Mode DP (Usually 27)", 78 | "modes": "Modes list", 79 | "return_mode": "Return home mode", 80 | "fan_speed_dp": "Fan speeds DP (Usually 30)", 81 | "fan_speeds": "Fan speeds list (comma-separated)", 82 | "clean_time_dp": "Clean Time DP (Usually 33)", 83 | "clean_area_dp": "Clean Area DP (Usually 32)", 84 | "clean_record_dp": "Clean Record DP (Usually 34)", 85 | "locate_dp": "Locate DP (Usually 31)", 86 | "paused_state": "Pause state (pause, paused, etc)", 87 | "stop_status": "Stop status", 88 | "brightness": "Brightness (only for white color)", 89 | "brightness_lower": "Brightness Lower Value", 90 | "brightness_upper": "Brightness Upper Value", 91 | "color_temp": "Color Temperature", 92 | "color_temp_reverse": "Color Temperature Reverse", 93 | "color": "Color", 94 | "color_mode": "Color Mode", 95 | "color_temp_min_kelvin": "Minimum Color Temperature in K", 96 | "color_temp_max_kelvin": "Maximum Color Temperature in K", 97 | "music_mode": "Music mode available", 98 | "scene": "Scene", 99 | "fan_speed_control": "Fan Speed Control dps", 100 | "fan_oscillating_control": "Fan Oscillating Control dps", 101 | "fan_speed_min": "minimum fan speed integer", 102 | "fan_speed_max": "maximum fan speed integer", 103 | "fan_speed_ordered_list": "Fan speed modes list (overrides speed min/max)", 104 | "fan_direction": "fan direction dps", 105 | "fan_direction_forward": "forward dps string", 106 | "fan_direction_reverse": "reverse dps string", 107 | "fan_dps_type": "DP value type", 108 | "current_temperature_dp": "Current Temperature", 109 | "target_temperature_dp": "Target Temperature", 110 | "temperature_step": "Temperature Step (optional)", 111 | "max_temperature_dp": "Max Temperature (optional)", 112 | "min_temperature_dp": "Min Temperature (optional)", 113 | "precision": "Precision (optional, for DPs values)", 114 | "target_precision": "Target Precision (optional, for DPs values)", 115 | "temperature_unit": "Temperature Unit (optional)", 116 | "hvac_mode_dp": "HVAC Mode DP (optional)", 117 | "hvac_mode_set": "HVAC Mode Set (optional)", 118 | "hvac_action_dp": "HVAC Current Action DP (optional)", 119 | "hvac_action_set": "HVAC Current Action Set (optional)", 120 | "preset_dp": "Presets DP (optional)", 121 | "preset_set": "Presets Set (optional)", 122 | "eco_dp": "Eco DP (optional)", 123 | "eco_value": "Eco value (optional)", 124 | "heuristic_action": "Enable heuristic action (optional)", 125 | "dps_default_value": "Default value when un-initialised (optional)", 126 | "restore_on_reconnect": "Restore the last set value in HomeAssistant after a lost connection", 127 | "min_value": "Minimum Value", 128 | "max_value": "Maximum Value", 129 | "step_size": "Minimum increment between numbers" 130 | } 131 | }, 132 | "yaml_import": { 133 | "title": "Not Supported", 134 | "description": "Options cannot be edited when configured via YAML." 135 | } 136 | } 137 | }, 138 | "title": "LocalTuya" 139 | } -------------------------------------------------------------------------------- /custom_components/localtuya/vacuum.py: -------------------------------------------------------------------------------- 1 | """Platform to locally control Tuya-based vacuum devices.""" 2 | import logging 3 | from functools import partial 4 | 5 | import voluptuous as vol 6 | from homeassistant.components.vacuum import ( 7 | DOMAIN, 8 | StateVacuumEntity, VacuumActivity, VacuumEntityFeature, 9 | ) 10 | 11 | from .common import LocalTuyaEntity, async_setup_entry 12 | from .const import ( 13 | CONF_BATTERY_DP, 14 | CONF_CLEAN_AREA_DP, 15 | CONF_CLEAN_RECORD_DP, 16 | CONF_CLEAN_TIME_DP, 17 | CONF_DOCKED_STATUS_VALUE, 18 | CONF_FAN_SPEED_DP, 19 | CONF_FAN_SPEEDS, 20 | CONF_FAULT_DP, 21 | CONF_IDLE_STATUS_VALUE, 22 | CONF_LOCATE_DP, 23 | CONF_MODE_DP, 24 | CONF_MODES, 25 | CONF_PAUSED_STATE, 26 | CONF_POWERGO_DP, 27 | CONF_RETURN_MODE, 28 | CONF_RETURNING_STATUS_VALUE, 29 | CONF_STOP_STATUS, 30 | ) 31 | 32 | _LOGGER = logging.getLogger(__name__) 33 | 34 | CLEAN_TIME = "clean_time" 35 | CLEAN_AREA = "clean_area" 36 | CLEAN_RECORD = "clean_record" 37 | MODES_LIST = "cleaning_mode_list" 38 | MODE = "cleaning_mode" 39 | FAULT = "fault" 40 | 41 | DEFAULT_IDLE_STATUS = "standby,sleep" 42 | DEFAULT_RETURNING_STATUS = "docking" 43 | DEFAULT_DOCKED_STATUS = "charging,chargecompleted" 44 | DEFAULT_MODES = "smart,wall_follow,spiral,single" 45 | DEFAULT_FAN_SPEEDS = "low,normal,high" 46 | DEFAULT_PAUSED_STATE = "paused" 47 | DEFAULT_RETURN_MODE = "chargego" 48 | DEFAULT_STOP_STATUS = "standby" 49 | 50 | 51 | def flow_schema(dps): 52 | """Return schema used in config flow.""" 53 | return { 54 | vol.Required(CONF_IDLE_STATUS_VALUE, default=DEFAULT_IDLE_STATUS): str, 55 | vol.Required(CONF_POWERGO_DP): vol.In(dps), 56 | vol.Required(CONF_DOCKED_STATUS_VALUE, default=DEFAULT_DOCKED_STATUS): str, 57 | vol.Optional( 58 | CONF_RETURNING_STATUS_VALUE, default=DEFAULT_RETURNING_STATUS 59 | ): str, 60 | vol.Optional(CONF_BATTERY_DP): vol.In(dps), 61 | vol.Optional(CONF_MODE_DP): vol.In(dps), 62 | vol.Optional(CONF_MODES, default=DEFAULT_MODES): str, 63 | vol.Optional(CONF_RETURN_MODE, default=DEFAULT_RETURN_MODE): str, 64 | vol.Optional(CONF_FAN_SPEED_DP): vol.In(dps), 65 | vol.Optional(CONF_FAN_SPEEDS, default=DEFAULT_FAN_SPEEDS): str, 66 | vol.Optional(CONF_CLEAN_TIME_DP): vol.In(dps), 67 | vol.Optional(CONF_CLEAN_AREA_DP): vol.In(dps), 68 | vol.Optional(CONF_CLEAN_RECORD_DP): vol.In(dps), 69 | vol.Optional(CONF_LOCATE_DP): vol.In(dps), 70 | vol.Optional(CONF_FAULT_DP): vol.In(dps), 71 | vol.Optional(CONF_PAUSED_STATE, default=DEFAULT_PAUSED_STATE): str, 72 | vol.Optional(CONF_STOP_STATUS, default=DEFAULT_STOP_STATUS): str, 73 | } 74 | 75 | 76 | class LocaltuyaVacuum(LocalTuyaEntity, StateVacuumEntity): 77 | """Tuya vacuum device.""" 78 | 79 | def __init__(self, device, config_entry, switchid, **kwargs): 80 | """Initialize a new LocaltuyaVacuum.""" 81 | super().__init__(device, config_entry, switchid, _LOGGER, **kwargs) 82 | self._state = None 83 | self._battery_level = None 84 | self._attrs = {} 85 | 86 | self._idle_status_list = [] 87 | if self.has_config(CONF_IDLE_STATUS_VALUE): 88 | self._idle_status_list = self._config[CONF_IDLE_STATUS_VALUE].split(",") 89 | 90 | self._modes_list = [] 91 | if self.has_config(CONF_MODES): 92 | self._modes_list = self._config[CONF_MODES].split(",") 93 | self._attrs[MODES_LIST] = self._modes_list 94 | 95 | self._docked_status_list = [] 96 | if self.has_config(CONF_DOCKED_STATUS_VALUE): 97 | self._docked_status_list = self._config[CONF_DOCKED_STATUS_VALUE].split(",") 98 | 99 | self._fan_speed_list = [] 100 | if self.has_config(CONF_FAN_SPEEDS): 101 | self._fan_speed_list = self._config[CONF_FAN_SPEEDS].split(",") 102 | 103 | self._fan_speed = "" 104 | self._cleaning_mode = "" 105 | _LOGGER.debug("Initialized vacuum [%s]", self.name) 106 | 107 | @property 108 | def supported_features(self): 109 | """Flag supported features.""" 110 | supported_features = ( 111 | VacuumEntityFeature.START 112 | | VacuumEntityFeature.PAUSE 113 | | VacuumEntityFeature.STOP 114 | | VacuumEntityFeature.STATUS 115 | | VacuumEntityFeature.STATE 116 | ) 117 | 118 | if self.has_config(CONF_RETURN_MODE): 119 | supported_features = supported_features | VacuumEntityFeature.RETURN_HOME 120 | if self.has_config(CONF_FAN_SPEED_DP): 121 | supported_features = supported_features | VacuumEntityFeature.FAN_SPEED 122 | if self.has_config(CONF_BATTERY_DP): 123 | supported_features = supported_features | VacuumEntityFeature.BATTERY 124 | if self.has_config(CONF_LOCATE_DP): 125 | supported_features = supported_features | VacuumEntityFeature.LOCATE 126 | 127 | return supported_features 128 | 129 | @property 130 | def state(self): 131 | """Return the vacuum state.""" 132 | return self._state 133 | 134 | @property 135 | def battery_level(self): 136 | """Return the current battery level.""" 137 | return self._battery_level 138 | 139 | @property 140 | def extra_state_attributes(self): 141 | """Return the specific state attributes of this vacuum cleaner.""" 142 | return self._attrs 143 | 144 | @property 145 | def fan_speed(self): 146 | """Return the current fan speed.""" 147 | return self._fan_speed 148 | 149 | @property 150 | def fan_speed_list(self) -> list: 151 | """Return the list of available fan speeds.""" 152 | return self._fan_speed_list 153 | 154 | async def async_start(self, **kwargs): 155 | """Turn the vacuum on and start cleaning.""" 156 | await self._device.set_dp(True, self._config[CONF_POWERGO_DP]) 157 | 158 | async def async_pause(self, **kwargs): 159 | """Stop the vacuum cleaner, do not return to base.""" 160 | await self._device.set_dp(False, self._config[CONF_POWERGO_DP]) 161 | 162 | async def async_return_to_base(self, **kwargs): 163 | """Set the vacuum cleaner to return to the dock.""" 164 | if self.has_config(CONF_RETURN_MODE): 165 | await self._device.set_dp( 166 | self._config[CONF_RETURN_MODE], self._config[CONF_MODE_DP] 167 | ) 168 | else: 169 | _LOGGER.error("Missing command for return home in commands set.") 170 | 171 | async def async_stop(self, **kwargs): 172 | """Turn the vacuum off stopping the cleaning.""" 173 | if self.has_config(CONF_STOP_STATUS): 174 | await self._device.set_dp( 175 | self._config[CONF_STOP_STATUS], self._config[CONF_MODE_DP] 176 | ) 177 | else: 178 | _LOGGER.error("Missing command for stop in commands set.") 179 | 180 | async def async_clean_spot(self, **kwargs): 181 | """Perform a spot clean-up.""" 182 | return None 183 | 184 | async def async_locate(self, **kwargs): 185 | """Locate the vacuum cleaner.""" 186 | if self.has_config(CONF_LOCATE_DP): 187 | await self._device.set_dp("", self._config[CONF_LOCATE_DP]) 188 | 189 | async def async_set_fan_speed(self, fan_speed, **kwargs): 190 | """Set the fan speed.""" 191 | await self._device.set_dp(fan_speed, self._config[CONF_FAN_SPEED_DP]) 192 | 193 | async def async_send_command(self, command, params=None, **kwargs): 194 | """Send a command to a vacuum cleaner.""" 195 | if command == "set_mode" and "mode" in params: 196 | mode = params["mode"] 197 | await self._device.set_dp(mode, self._config[CONF_MODE_DP]) 198 | 199 | def status_updated(self): 200 | """Device status was updated.""" 201 | state_value = str(self.dps(self._dp_id)) 202 | 203 | if state_value in self._idle_status_list: 204 | self._state = VacuumActivity.IDLE 205 | elif state_value in self._docked_status_list: 206 | self._state = VacuumActivity.DOCKED 207 | elif state_value == self._config[CONF_RETURNING_STATUS_VALUE]: 208 | self._state = VacuumActivity.RETURNING 209 | elif state_value == self._config[CONF_PAUSED_STATE]: 210 | self._state = VacuumActivity.PAUSED 211 | else: 212 | self._state = VacuumActivity.CLEANING 213 | 214 | if self.has_config(CONF_BATTERY_DP): 215 | self._battery_level = self.dps_conf(CONF_BATTERY_DP) 216 | 217 | self._cleaning_mode = "" 218 | if self.has_config(CONF_MODES): 219 | self._cleaning_mode = self.dps_conf(CONF_MODE_DP) 220 | self._attrs[MODE] = self._cleaning_mode 221 | 222 | self._fan_speed = "" 223 | if self.has_config(CONF_FAN_SPEEDS): 224 | self._fan_speed = self.dps_conf(CONF_FAN_SPEED_DP) 225 | 226 | if self.has_config(CONF_CLEAN_TIME_DP): 227 | self._attrs[CLEAN_TIME] = self.dps_conf(CONF_CLEAN_TIME_DP) 228 | 229 | if self.has_config(CONF_CLEAN_AREA_DP): 230 | self._attrs[CLEAN_AREA] = self.dps_conf(CONF_CLEAN_AREA_DP) 231 | 232 | if self.has_config(CONF_CLEAN_RECORD_DP): 233 | self._attrs[CLEAN_RECORD] = self.dps_conf(CONF_CLEAN_RECORD_DP) 234 | 235 | if self.has_config(CONF_FAULT_DP): 236 | self._attrs[FAULT] = self.dps_conf(CONF_FAULT_DP) 237 | if self._attrs[FAULT] != 0: 238 | self._state = VacuumActivity.ERROR 239 | 240 | 241 | async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaVacuum, flow_schema) 242 | -------------------------------------------------------------------------------- /custom_components/localtuya/fan.py: -------------------------------------------------------------------------------- 1 | """Platform to locally control Tuya-based fan devices.""" 2 | import logging 3 | import math 4 | from functools import partial 5 | 6 | import homeassistant.helpers.config_validation as cv 7 | import voluptuous as vol 8 | from homeassistant.components.fan import ( 9 | DIRECTION_FORWARD, 10 | DIRECTION_REVERSE, 11 | DOMAIN, 12 | FanEntityFeature, 13 | FanEntity, 14 | ) 15 | from homeassistant.util.percentage import ( 16 | int_states_in_range, 17 | ordered_list_item_to_percentage, 18 | percentage_to_ordered_list_item, 19 | percentage_to_ranged_value, 20 | ranged_value_to_percentage, 21 | ) 22 | 23 | from .common import LocalTuyaEntity, async_setup_entry 24 | from .const import ( 25 | CONF_FAN_DIRECTION, 26 | CONF_FAN_DIRECTION_FWD, 27 | CONF_FAN_DIRECTION_REV, 28 | CONF_FAN_DPS_TYPE, 29 | CONF_FAN_ORDERED_LIST, 30 | CONF_FAN_OSCILLATING_CONTROL, 31 | CONF_FAN_SPEED_CONTROL, 32 | CONF_FAN_SPEED_MAX, 33 | CONF_FAN_SPEED_MIN, 34 | ) 35 | 36 | _LOGGER = logging.getLogger(__name__) 37 | 38 | 39 | def flow_schema(dps): 40 | """Return schema used in config flow.""" 41 | return { 42 | vol.Optional(CONF_FAN_SPEED_CONTROL): vol.In(dps), 43 | vol.Optional(CONF_FAN_OSCILLATING_CONTROL): vol.In(dps), 44 | vol.Optional(CONF_FAN_DIRECTION): vol.In(dps), 45 | vol.Optional(CONF_FAN_DIRECTION_FWD, default="forward"): cv.string, 46 | vol.Optional(CONF_FAN_DIRECTION_REV, default="reverse"): cv.string, 47 | vol.Optional(CONF_FAN_SPEED_MIN, default=1): cv.positive_int, 48 | vol.Optional(CONF_FAN_SPEED_MAX, default=9): cv.positive_int, 49 | vol.Optional(CONF_FAN_ORDERED_LIST, default="disabled"): cv.string, 50 | vol.Optional(CONF_FAN_DPS_TYPE, default="str"): vol.In(["str", "int"]), 51 | } 52 | 53 | 54 | class LocaltuyaFan(LocalTuyaEntity, FanEntity): 55 | """Representation of a Tuya fan.""" 56 | 57 | def __init__( 58 | self, 59 | device, 60 | config_entry, 61 | fanid, 62 | **kwargs, 63 | ): 64 | """Initialize the entity.""" 65 | super().__init__(device, config_entry, fanid, _LOGGER, **kwargs) 66 | self._is_on = False 67 | self._oscillating = None 68 | self._direction = None 69 | self._percentage = None 70 | self._speed_range = ( 71 | self._config.get(CONF_FAN_SPEED_MIN), 72 | self._config.get(CONF_FAN_SPEED_MAX), 73 | ) 74 | self._ordered_list = self._config.get(CONF_FAN_ORDERED_LIST).split(",") 75 | self._ordered_list_mode = None 76 | self._dps_type = int if self._config.get(CONF_FAN_DPS_TYPE) == "int" else str 77 | 78 | if isinstance(self._ordered_list, list) and len(self._ordered_list) > 1: 79 | self._use_ordered_list = True 80 | _LOGGER.debug( 81 | "Fan _use_ordered_list: %s > %s", 82 | self._use_ordered_list, 83 | self._ordered_list, 84 | ) 85 | else: 86 | self._use_ordered_list = False 87 | _LOGGER.debug("Fan _use_ordered_list: %s", self._use_ordered_list) 88 | 89 | @property 90 | def oscillating(self): 91 | """Return current oscillating status.""" 92 | return self._oscillating 93 | 94 | @property 95 | def current_direction(self): 96 | """Return the current direction of the fan.""" 97 | return self._direction 98 | 99 | @property 100 | def is_on(self): 101 | """Check if Tuya fan is on.""" 102 | return self._is_on 103 | 104 | @property 105 | def percentage(self): 106 | """Return the current percentage.""" 107 | return self._percentage 108 | 109 | async def async_turn_on( 110 | self, 111 | speed: str = None, 112 | percentage: int = None, 113 | preset_mode: str = None, 114 | **kwargs, 115 | ) -> None: 116 | """Turn on the entity.""" 117 | _LOGGER.debug("Fan async_turn_on") 118 | await self._device.set_dp(True, self._dp_id) 119 | if percentage is not None: 120 | await self.async_set_percentage(percentage) 121 | else: 122 | self.schedule_update_ha_state() 123 | 124 | async def async_turn_off(self, **kwargs) -> None: 125 | """Turn off the entity.""" 126 | _LOGGER.debug("Fan async_turn_off") 127 | 128 | await self._device.set_dp(False, self._dp_id) 129 | self.schedule_update_ha_state() 130 | 131 | async def async_set_percentage(self, percentage): 132 | """Set the speed of the fan.""" 133 | _LOGGER.debug("Fan async_set_percentage: %s", percentage) 134 | 135 | if percentage is not None: 136 | if percentage == 0: 137 | return await self.async_turn_off() 138 | if not self.is_on: 139 | await self.async_turn_on() 140 | if self._use_ordered_list: 141 | await self._device.set_dp( 142 | self._dps_type( 143 | percentage_to_ordered_list_item(self._ordered_list, percentage) 144 | ), 145 | self._config.get(CONF_FAN_SPEED_CONTROL), 146 | ) 147 | _LOGGER.debug( 148 | "Fan async_set_percentage: %s > %s", 149 | percentage, 150 | percentage_to_ordered_list_item(self._ordered_list, percentage), 151 | ) 152 | 153 | else: 154 | await self._device.set_dp( 155 | self._dps_type( 156 | math.ceil( 157 | percentage_to_ranged_value(self._speed_range, percentage) 158 | ) 159 | ), 160 | self._config.get(CONF_FAN_SPEED_CONTROL), 161 | ) 162 | _LOGGER.debug( 163 | "Fan async_set_percentage: %s > %s", 164 | percentage, 165 | percentage_to_ranged_value(self._speed_range, percentage), 166 | ) 167 | self.schedule_update_ha_state() 168 | 169 | async def async_oscillate(self, oscillating: bool) -> None: 170 | """Set oscillation.""" 171 | _LOGGER.debug("Fan async_oscillate: %s", oscillating) 172 | await self._device.set_dp( 173 | oscillating, self._config.get(CONF_FAN_OSCILLATING_CONTROL) 174 | ) 175 | self.schedule_update_ha_state() 176 | 177 | async def async_set_direction(self, direction): 178 | """Set the direction of the fan.""" 179 | _LOGGER.debug("Fan async_set_direction: %s", direction) 180 | 181 | if direction == DIRECTION_FORWARD: 182 | value = self._config.get(CONF_FAN_DIRECTION_FWD) 183 | 184 | if direction == DIRECTION_REVERSE: 185 | value = self._config.get(CONF_FAN_DIRECTION_REV) 186 | await self._device.set_dp(value, self._config.get(CONF_FAN_DIRECTION)) 187 | self.schedule_update_ha_state() 188 | 189 | @property 190 | def supported_features(self) -> FanEntityFeature: 191 | """Flag supported features.""" 192 | features = FanEntityFeature(0) 193 | 194 | if self.has_config(CONF_FAN_OSCILLATING_CONTROL): 195 | features |= FanEntityFeature.OSCILLATE 196 | 197 | if self.has_config(CONF_FAN_SPEED_CONTROL): 198 | features |= FanEntityFeature.SET_SPEED 199 | 200 | if self.has_config(CONF_FAN_DIRECTION): 201 | features |= FanEntityFeature.DIRECTION 202 | 203 | features |= FanEntityFeature.TURN_OFF 204 | features |= FanEntityFeature.TURN_ON 205 | 206 | return features 207 | 208 | @property 209 | def speed_count(self) -> int: 210 | """Speed count for the fan.""" 211 | speed_count = int_states_in_range(self._speed_range) 212 | _LOGGER.debug("Fan speed_count: %s", speed_count) 213 | return speed_count 214 | 215 | def status_updated(self): 216 | """Get state of Tuya fan.""" 217 | self._is_on = self.dps(self._dp_id) 218 | 219 | current_speed = self.dps_conf(CONF_FAN_SPEED_CONTROL) 220 | if self._use_ordered_list: 221 | _LOGGER.debug( 222 | "Fan current_speed ordered_list_item_to_percentage: %s from %s", 223 | current_speed, 224 | self._ordered_list, 225 | ) 226 | if current_speed is not None: 227 | self._percentage = ordered_list_item_to_percentage( 228 | self._ordered_list, str(current_speed) 229 | ) 230 | 231 | else: 232 | _LOGGER.debug( 233 | "Fan current_speed ranged_value_to_percentage: %s from %s", 234 | current_speed, 235 | self._speed_range, 236 | ) 237 | if current_speed is not None: 238 | self._percentage = ranged_value_to_percentage( 239 | self._speed_range, int(current_speed) 240 | ) 241 | 242 | _LOGGER.debug("Fan current_percentage: %s", self._percentage) 243 | 244 | if self.has_config(CONF_FAN_OSCILLATING_CONTROL): 245 | self._oscillating = self.dps_conf(CONF_FAN_OSCILLATING_CONTROL) 246 | _LOGGER.debug("Fan current_oscillating : %s", self._oscillating) 247 | 248 | if self.has_config(CONF_FAN_DIRECTION): 249 | value = self.dps_conf(CONF_FAN_DIRECTION) 250 | if value is not None: 251 | if value == self._config.get(CONF_FAN_DIRECTION_FWD): 252 | self._direction = DIRECTION_FORWARD 253 | 254 | if value == self._config.get(CONF_FAN_DIRECTION_REV): 255 | self._direction = DIRECTION_REVERSE 256 | _LOGGER.debug("Fan current_direction : %s > %s", value, self._direction) 257 | 258 | 259 | async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaFan, flow_schema) 260 | -------------------------------------------------------------------------------- /custom_components/localtuya/cover.py: -------------------------------------------------------------------------------- 1 | """Platform to locally control Tuya-based cover devices.""" 2 | import asyncio 3 | import logging 4 | import time 5 | from functools import partial 6 | 7 | import voluptuous as vol 8 | from homeassistant.components.cover import ( 9 | ATTR_POSITION, 10 | DOMAIN, 11 | CoverEntity, CoverEntityFeature, 12 | ) 13 | 14 | from .common import LocalTuyaEntity, async_setup_entry 15 | from .const import ( 16 | CONF_COMMANDS_SET, 17 | CONF_CURRENT_POSITION_DP, 18 | CONF_POSITION_INVERTED, 19 | CONF_POSITIONING_MODE, 20 | CONF_SET_POSITION_DP, 21 | CONF_SPAN_TIME, 22 | ) 23 | 24 | _LOGGER = logging.getLogger(__name__) 25 | 26 | COVER_ONOFF_CMDS = "on_off_stop" 27 | COVER_OPENCLOSE_CMDS = "open_close_stop" 28 | COVER_FZZZ_CMDS = "fz_zz_stop" 29 | COVER_12_CMDS = "1_2_3" 30 | COVER_MODE_NONE = "none" 31 | COVER_MODE_POSITION = "position" 32 | COVER_MODE_TIMED = "timed" 33 | COVER_TIMEOUT_TOLERANCE = 3.0 34 | 35 | DEFAULT_COMMANDS_SET = COVER_ONOFF_CMDS 36 | DEFAULT_POSITIONING_MODE = COVER_MODE_NONE 37 | DEFAULT_SPAN_TIME = 25.0 38 | 39 | 40 | def flow_schema(dps): 41 | """Return schema used in config flow.""" 42 | return { 43 | vol.Optional(CONF_COMMANDS_SET): vol.In( 44 | [COVER_ONOFF_CMDS, COVER_OPENCLOSE_CMDS, COVER_FZZZ_CMDS, COVER_12_CMDS] 45 | ), 46 | vol.Optional(CONF_POSITIONING_MODE, default=DEFAULT_POSITIONING_MODE): vol.In( 47 | [COVER_MODE_NONE, COVER_MODE_POSITION, COVER_MODE_TIMED] 48 | ), 49 | vol.Optional(CONF_CURRENT_POSITION_DP): vol.In(dps), 50 | vol.Optional(CONF_SET_POSITION_DP): vol.In(dps), 51 | vol.Optional(CONF_POSITION_INVERTED, default=False): bool, 52 | vol.Optional(CONF_SPAN_TIME, default=DEFAULT_SPAN_TIME): vol.All( 53 | vol.Coerce(float), vol.Range(min=1.0, max=300.0) 54 | ), 55 | } 56 | 57 | 58 | class LocaltuyaCover(LocalTuyaEntity, CoverEntity): 59 | """Tuya cover device.""" 60 | 61 | def __init__(self, device, config_entry, switchid, **kwargs): 62 | """Initialize a new LocaltuyaCover.""" 63 | super().__init__(device, config_entry, switchid, _LOGGER, **kwargs) 64 | commands_set = DEFAULT_COMMANDS_SET 65 | if self.has_config(CONF_COMMANDS_SET): 66 | commands_set = self._config[CONF_COMMANDS_SET] 67 | self._open_cmd = commands_set.split("_")[0] 68 | self._close_cmd = commands_set.split("_")[1] 69 | self._stop_cmd = commands_set.split("_")[2] 70 | self._timer_start = time.time() 71 | self._state = self._stop_cmd 72 | self._previous_state = self._state 73 | self._current_cover_position = 0 74 | _LOGGER.debug("Initialized cover [%s]", self.name) 75 | 76 | @property 77 | def supported_features(self): 78 | """Flag supported features.""" 79 | supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP 80 | if self._config[CONF_POSITIONING_MODE] != COVER_MODE_NONE: 81 | supported_features = supported_features | CoverEntityFeature.SET_POSITION 82 | return supported_features 83 | 84 | @property 85 | def current_cover_position(self): 86 | """Return current cover position in percent.""" 87 | if self._config[CONF_POSITIONING_MODE] == COVER_MODE_NONE: 88 | return None 89 | return self._current_cover_position 90 | 91 | @property 92 | def is_opening(self): 93 | """Return if cover is opening.""" 94 | state = self._state 95 | return state == self._open_cmd 96 | 97 | @property 98 | def is_closing(self): 99 | """Return if cover is closing.""" 100 | state = self._state 101 | return state == self._close_cmd 102 | 103 | @property 104 | def is_closed(self): 105 | """Return if the cover is closed or not.""" 106 | if self._config[CONF_POSITIONING_MODE] == COVER_MODE_NONE: 107 | return False 108 | 109 | if self._current_cover_position == 0: 110 | return True 111 | if self._current_cover_position == 100: 112 | return False 113 | return False 114 | 115 | async def async_set_cover_position(self, **kwargs): 116 | """Move the cover to a specific position.""" 117 | self.debug("Setting cover position: %r", kwargs[ATTR_POSITION]) 118 | if self._config[CONF_POSITIONING_MODE] == COVER_MODE_TIMED: 119 | newpos = float(kwargs[ATTR_POSITION]) 120 | 121 | currpos = self.current_cover_position 122 | posdiff = abs(newpos - currpos) 123 | mydelay = posdiff / 100.0 * self._config[CONF_SPAN_TIME] 124 | if newpos > currpos: 125 | self.debug("Opening to %f: delay %f", newpos, mydelay) 126 | await self.async_open_cover() 127 | else: 128 | self.debug("Closing to %f: delay %f", newpos, mydelay) 129 | await self.async_close_cover() 130 | self.hass.async_create_task(self.async_stop_after_timeout(mydelay)) 131 | self.debug("Done") 132 | 133 | elif self._config[CONF_POSITIONING_MODE] == COVER_MODE_POSITION: 134 | converted_position = int(kwargs[ATTR_POSITION]) 135 | if self._config[CONF_POSITION_INVERTED]: 136 | converted_position = 100 - converted_position 137 | 138 | if 0 <= converted_position <= 100 and self.has_config(CONF_SET_POSITION_DP): 139 | await self._device.set_dp( 140 | converted_position, self._config[CONF_SET_POSITION_DP] 141 | ) 142 | 143 | async def async_stop_after_timeout(self, delay_sec): 144 | """Stop the cover if timeout (max movement span) occurred.""" 145 | await asyncio.sleep(delay_sec) 146 | await self.async_stop_cover() 147 | 148 | async def async_open_cover(self, **kwargs): 149 | """Open the cover.""" 150 | self.debug("Launching command %s to cover ", self._open_cmd) 151 | await self._device.set_dp(self._open_cmd, self._dp_id) 152 | if self._config[CONF_POSITIONING_MODE] == COVER_MODE_TIMED: 153 | # for timed positioning, stop the cover after a full opening timespan 154 | # instead of waiting the internal timeout 155 | self.hass.async_create_task( 156 | self.async_stop_after_timeout( 157 | self._config[CONF_SPAN_TIME] + COVER_TIMEOUT_TOLERANCE 158 | ) 159 | ) 160 | 161 | async def async_close_cover(self, **kwargs): 162 | """Close cover.""" 163 | self.debug("Launching command %s to cover ", self._close_cmd) 164 | await self._device.set_dp(self._close_cmd, self._dp_id) 165 | if self._config[CONF_POSITIONING_MODE] == COVER_MODE_TIMED: 166 | # for timed positioning, stop the cover after a full opening timespan 167 | # instead of waiting the internal timeout 168 | self.hass.async_create_task( 169 | self.async_stop_after_timeout( 170 | self._config[CONF_SPAN_TIME] + COVER_TIMEOUT_TOLERANCE 171 | ) 172 | ) 173 | 174 | async def async_stop_cover(self, **kwargs): 175 | """Stop the cover.""" 176 | self.debug("Launching command %s to cover ", self._stop_cmd) 177 | await self._device.set_dp(self._stop_cmd, self._dp_id) 178 | 179 | def status_restored(self, stored_state): 180 | """Restore the last stored cover status.""" 181 | if self._config[CONF_POSITIONING_MODE] == COVER_MODE_TIMED: 182 | stored_pos = stored_state.attributes.get("current_position") 183 | if stored_pos is not None: 184 | self._current_cover_position = stored_pos 185 | self.debug("Restored cover position %s", self._current_cover_position) 186 | 187 | def status_updated(self): 188 | """Device status was updated.""" 189 | self._previous_state = self._state 190 | self._state = self.dps(self._dp_id) 191 | if self._state.isupper(): 192 | self._open_cmd = self._open_cmd.upper() 193 | self._close_cmd = self._close_cmd.upper() 194 | self._stop_cmd = self._stop_cmd.upper() 195 | 196 | if self.has_config(CONF_CURRENT_POSITION_DP): 197 | curr_pos = self.dps_conf(CONF_CURRENT_POSITION_DP) 198 | if self._config[CONF_POSITION_INVERTED]: 199 | self._current_cover_position = 100 - curr_pos 200 | else: 201 | self._current_cover_position = curr_pos 202 | if ( 203 | self._config[CONF_POSITIONING_MODE] == COVER_MODE_TIMED 204 | and self._state != self._previous_state 205 | ): 206 | if self._previous_state != self._stop_cmd: 207 | # the state has changed, and the cover was moving 208 | time_diff = time.time() - self._timer_start 209 | pos_diff = round(time_diff / self._config[CONF_SPAN_TIME] * 100.0) 210 | if self._previous_state == self._close_cmd: 211 | pos_diff = -pos_diff 212 | self._current_cover_position = min( 213 | 100, max(0, self._current_cover_position + pos_diff) 214 | ) 215 | 216 | change = "stopped" if self._state == self._stop_cmd else "inverted" 217 | self.debug( 218 | "Movement %s after %s sec., position difference %s", 219 | change, 220 | time_diff, 221 | pos_diff, 222 | ) 223 | 224 | # store the time of the last movement change 225 | self._timer_start = time.time() 226 | 227 | # Keep record in last_state as long as not during connection/re-connection, 228 | # as last state will be used to restore the previous state 229 | if (self._state is not None) and (not self._device.is_connecting): 230 | self._last_state = self._state 231 | 232 | 233 | async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaCover, flow_schema) 234 | -------------------------------------------------------------------------------- /custom_components/localtuya/translations/it.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Il dispositivo è già stato configurato.", 5 | "device_updated": "La configurazione del dispositivo è stata aggiornata." 6 | }, 7 | "error": { 8 | "authentication_failed": "Autenticazione fallita. Errore:\n{msg}", 9 | "cannot_connect": "Impossibile connettersi al dispositivo. Verifica che l'indirizzo sia corretto e riprova.", 10 | "device_list_failed": "Impossibile recuperare l'elenco dei dispositivi.\n{msg}", 11 | "invalid_auth": "Impossibile autenticarsi con il dispositivo. Verificare che device_id e local_key siano corretti.", 12 | "unknown": "Si è verificato un errore sconosciuto. Vedere registro per i dettagli.", 13 | "entity_already_configured": "L'entity con questo ID è già stata configurata.", 14 | "address_in_use": "L'indirizzo utilizzato per il discovery è già in uso. Assicurarsi che nessun'altra applicazione lo stia utilizzando (porta TCP 6668).", 15 | "discovery_failed": "Qualcosa è fallito nella discovery dei dispositivi. Vedi registro per i dettagli.", 16 | "empty_dps": "La connessione al dispositivo è riuscita ma non sono stati trovati i datapoint, riprova. Crea un nuovo Issue e includi i log di debug se il problema persiste." 17 | }, 18 | "step": { 19 | "user": { 20 | "title": "Configurazione dell'account Cloud API", 21 | "description": "Inserisci le credenziali per l'account Cloud API Tuya.", 22 | "data": { 23 | "region": "Regione del server API", 24 | "client_id": "Client ID", 25 | "client_secret": "Secret", 26 | "user_id": "User ID", 27 | "user_name": "Username", 28 | "no_cloud": "Non configurare un account Cloud API" 29 | } 30 | } 31 | } 32 | }, 33 | "options": { 34 | "abort": { 35 | "already_configured": "Il dispositivo è già stato configurato.", 36 | "device_success": "Dispositivo {dev_name} {action} con successo.", 37 | "no_entities": "Non si possono rimuovere tutte le entities da un device.\nPer rimuovere un device, entrarci nel menu Devices, premere sui 3 punti nel riquadro 'Device info', e premere il pulsante Delete." 38 | }, 39 | "error": { 40 | "authentication_failed": "Autenticazione fallita. Errore:\n{msg}", 41 | "cannot_connect": "Impossibile connettersi al dispositivo. Verifica che l'indirizzo sia corretto e riprova.", 42 | "device_list_failed": "Impossibile recuperare l'elenco dei dispositivi.\n{msg}", 43 | "invalid_auth": "Impossibile autenticarsi con il dispositivo. Verificare che device_id e local_key siano corretti.", 44 | "unknown": "Si è verificato un errore sconosciuto. Vedere registro per i dettagli.", 45 | "entity_already_configured": "L'entity con questo ID è già stata configurata.", 46 | "address_in_use": "L'indirizzo utilizzato per il discovery è già in uso. Assicurarsi che nessun'altra applicazione lo stia utilizzando (porta TCP 6668).", 47 | "discovery_failed": "Qualcosa è fallito nella discovery dei dispositivi. Vedi registro per i dettagli.", 48 | "empty_dps": "La connessione al dispositivo è riuscita ma non sono stati trovati i datapoint, riprova. Crea un nuovo Issue e includi i log di debug se il problema persiste." 49 | }, 50 | "step": { 51 | "yaml_import": { 52 | "title": "Non supportato", 53 | "description": "Le impostazioni non possono essere configurate tramite file YAML." 54 | }, 55 | "init": { 56 | "title": "Configurazione LocalTuya", 57 | "description": "Seleziona l'azione desiderata.", 58 | "data": { 59 | "add_device": "Aggiungi un nuovo dispositivo", 60 | "edit_device": "Modifica un dispositivo", 61 | "setup_cloud": "Riconfigurare l'account Cloud API" 62 | } 63 | }, 64 | "add_device": { 65 | "title": "Aggiungi un nuovo dispositivo", 66 | "description": "Scegli uno dei dispositivi trovati automaticamente o `...` per aggiungere manualmente un dispositivo.", 67 | "data": { 68 | "selected_device": "Dispositivi trovati" 69 | } 70 | }, 71 | "edit_device": { 72 | "title": "Modifica un dispositivo", 73 | "description": "Scegli il dispositivo configurato che si desidera modificare.", 74 | "data": { 75 | "selected_device": "Dispositivi configurati" 76 | } 77 | }, 78 | "cloud_setup": { 79 | "title": "Configurazione dell'account Cloud API", 80 | "description": "Inserisci le credenziali per l'account Cloud API Tuya.", 81 | "data": { 82 | "region": "Regione del server API", 83 | "client_id": "Client ID", 84 | "client_secret": "Secret", 85 | "user_id": "User ID", 86 | "user_name": "Username", 87 | "no_cloud": "Non configurare l'account Cloud API" 88 | } 89 | }, 90 | "configure_device": { 91 | "title": "Configura il dispositivo", 92 | "description": "Compila i dettagli del dispositivo {for_device}.", 93 | "data": { 94 | "friendly_name": "Nome", 95 | "host": "Host", 96 | "device_id": "ID del dispositivo", 97 | "local_key": "Chiave locale", 98 | "protocol_version": "Versione del protocollo", 99 | "enable_debug": "Abilita il debugging per questo device (il debug va abilitato anche in configuration.yaml)", 100 | "scan_interval": "Intervallo di scansione (secondi, solo quando non si aggiorna automaticamente)", 101 | "entities": "Entities (deseleziona un'entity per rimuoverla)" 102 | } 103 | }, 104 | "pick_entity_type": { 105 | "title": "Selezione del tipo di entity", 106 | "description": "Scegli il tipo di entity che desideri aggiungere.", 107 | "data": { 108 | "platform_to_add": "piattaforma", 109 | "no_additional_entities": "Non aggiungere altre entity" 110 | } 111 | }, 112 | "configure_entity": { 113 | "title": "Configurare entity", 114 | "description": "Compila i dettagli per {entity} con tipo `{platform}`.Tutte le impostazioni ad eccezione di `id` possono essere modificate dalla pagina delle opzioni in seguito.", 115 | "data": { 116 | "id": "ID", 117 | "friendly_name": "Nome amichevole", 118 | "current": "Corrente", 119 | "current_consumption": "Potenza", 120 | "voltage": "Tensione", 121 | "commands_set": "Set di comandi Aperto_Chiuso_Stop", 122 | "positioning_mode": "Modalità di posizionamento", 123 | "current_position_dp": "Posizione attuale (solo per la modalità *posizione*)", 124 | "set_position_dp": "Imposta posizione (solo per modalità *posizione*)", 125 | "position_inverted": "Inverti posizione 0-100 (solo per modalità *posizione*)", 126 | "span_time": "Tempo di apertura totale, in sec. (solo per modalità *a tempo*)", 127 | "unit_of_measurement": "Unità di misura", 128 | "device_class": "Classe del dispositivo", 129 | "scaling": "Fattore di scala", 130 | "state_on": "Valore di ON", 131 | "state_off": "Valore di OFF", 132 | "powergo_dp": "Potenza DP (di solito 25 o 2)", 133 | "idle_status_value": "Stato di inattività (separato da virgole)", 134 | "returning_status_value": "Stato di ritorno alla base", 135 | "docked_status_value": "Stato di tornato alla base (separato da virgole)", 136 | "fault_dp": "DP di guasto (di solito 11)", 137 | "battery_dp": "DP di stato batteria (di solito 14)", 138 | "mode_dp": "DP di modalità (di solito 27)", 139 | "modes": "Elenco delle modalità", 140 | "return_mode": "Ritorno in modalità home", 141 | "fan_speed_dp": "DP di velocità del ventilatore (di solito 30)", 142 | "fan_speeds": "DP di elenco delle velocità del ventilatore (separato da virgola)", 143 | "clean_time_dp": "DP di tempo di pulizia (di solito 33)", 144 | "clean_area_dp": "DP di area pulita (di solito 32)", 145 | "clean_record_dp": "DP di record delle pulizie (di solito 34)", 146 | "locate_dp": "DP di individuazione (di solito 31)", 147 | "paused_state": "Stato di pausa (pausa, pausa, ecc.)", 148 | "stop_status": "Stato di stop", 149 | "brightness": "Luminosità (solo per il colore bianco)", 150 | "brightness_lower": "Limite inferiore per la luminosità", 151 | "brightness_upper": "Limite superiore per la luminosità", 152 | "color_temp": "Temperatura di colore", 153 | "color_temp_reverse": "Temperatura di colore invertita", 154 | "color": "Colore", 155 | "color_mode": "Modalità colore", 156 | "color_temp_min_kelvin": "Minima temperatura di colore in K", 157 | "color_temp_max_kelvin": "Massima temperatura di colore in k", 158 | "music_mode": "Modalità musicale disponibile", 159 | "scene": "Scena", 160 | "select_options": "Opzioni valide, voci separate da una vigola (;)", 161 | "select_options_friendly": "Opzioni intuitive, voci separate da una virgola", 162 | "fan_speed_control": "DP di controllo di velocità del ventilatore", 163 | "fan_oscillating_control": "DP di controllo dell'oscillazione del ventilatore", 164 | "fan_speed_min": "Velocità del ventilatore minima", 165 | "fan_speed_max": "Velocità del ventilatore massima", 166 | "fan_speed_ordered_list": "Elenco delle modalità di velocità del ventilatore (sovrascrive velocità min/max)", 167 | "fan_direction":"DP di direzione del ventilatore", 168 | "fan_direction_forward": "Stringa del DP per avanti", 169 | "fan_direction_reverse": "Stringa del DP per indietro", 170 | "current_temperature_dp": "Temperatura attuale", 171 | "target_temperature_dp": "Temperatura target", 172 | "temperature_step": "Intervalli di temperatura (facoltativo)", 173 | "max_temperature_dp": "Temperatura massima (opzionale)", 174 | "min_temperature_dp": "Temperatura minima (opzionale)", 175 | "precision": "Precisione (opzionale, per valori DP)", 176 | "target_precision": "Precisione del target (opzionale, per valori DP)", 177 | "temperature_unit": "Unità di temperatura (opzionale)", 178 | "hvac_mode_dp": "Modalità HVAC attuale (opzionale)", 179 | "hvac_mode_set": "Impostazione modalità HVAC (opzionale)", 180 | "hvac_action_dp": "Azione HVAC attuale (opzionale)", 181 | "hvac_action_set": "Impostazione azione HVAC (opzionale)", 182 | "preset_dp": "Preset DP (opzionale)", 183 | "preset_set": "Set di preset (opzionale)", 184 | "eco_dp": "DP per Eco (opzionale)", 185 | "eco_value": "Valore Eco (opzionale)", 186 | "heuristic_action": "Abilita azione euristica (opzionale)" 187 | } 188 | } 189 | } 190 | }, 191 | "services": { 192 | "reload": { 193 | "name": "Reload", 194 | "description": "Reload localtuya and reconnect to all devices." 195 | }, 196 | "set_dp": { 197 | "name": "Set datapoint", 198 | "description": "Change the value of a datapoint (DP)", 199 | "fields": { 200 | "device_id": { 201 | "name": "Device ID", 202 | "description": "Device ID of device to change datapoint value for" 203 | }, 204 | "dp": { 205 | "name": "DP", 206 | "description": "Datapoint index" 207 | }, 208 | "value": { 209 | "name": "Value", 210 | "description": "New value to set" 211 | } 212 | } 213 | } 214 | }, 215 | "title": "LocalTuya" 216 | } 217 | -------------------------------------------------------------------------------- /custom_components/localtuya/translations/pt-BR.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "O dispositivo já foi configurado.", 5 | "device_updated": "A configuração do dispositivo foi atualizada!" 6 | }, 7 | "error": { 8 | "authentication_failed": "Falha ao autenticar.\n{msg}", 9 | "cannot_connect": "Não é possível se conectar ao dispositivo. Verifique se o endereço está correto e tente novamente", 10 | "device_list_failed": "Falha ao recuperar a lista de dispositivos.\n{msg}", 11 | "invalid_auth": "Falha ao autenticar com o dispositivo. Verifique se o ID do dispositivo e a chave local estão corretos.", 12 | "unknown": "Ocorreu um erro desconhecido. Consulte o registro para obter detalhes.", 13 | "entity_already_configured": "A entidade com este ID já foi configurada.", 14 | "address_in_use": "AddresO endereço usado para descoberta já está em uso. Certifique-se de que nenhum outro aplicativo o esteja usando (porta TCP 6668).s used for discovery is already in use. Make sure no other application is using it (TCP port 6668).", 15 | "discovery_failed": "Algo falhou ao descobrir dispositivos. Consulte o registro para obter detalhes.", 16 | "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." 17 | }, 18 | "step": { 19 | "user": { 20 | "title": "Configuração da conta da API do Cloud", 21 | "description": "Insira as credenciais para a API Tuya Cloud.", 22 | "data": { 23 | "region": "Região do servidor de API", 24 | "client_id": "ID do cliente", 25 | "client_secret": "Secret", 26 | "user_id": "ID de usuário", 27 | "user_name": "Nome de usuário", 28 | "no_cloud": "Não configure uma conta de API da Cloud" 29 | } 30 | } 31 | } 32 | }, 33 | "options": { 34 | "abort": { 35 | "already_configured": "O dispositivo já foi configurado.", 36 | "device_success": "Dispositivo {dev_name} {action} com sucesso.", 37 | "no_entities": "Não é possível remover todas as entidades de um dispositivo.\nSe você deseja excluir um dispositivo, insira-o no menu Dispositivos, clique nos 3 pontos no quadro 'Informações do dispositivo' e pressione o botão Excluir." 38 | }, 39 | "error": { 40 | "authentication_failed": "Falha ao autenticar.\n{msg}", 41 | "cannot_connect": "Não é possível se conectar ao dispositivo. Verifique se o endereço está correto e tente novamente", 42 | "device_list_failed": "Falha ao recuperar a lista de dispositivos.\n{msg}", 43 | "invalid_auth": "Falha ao autenticar com o dispositivo. Verifique se o ID do dispositivo e a chave local estão corretos.", 44 | "unknown": "Ocorreu um erro desconhecido. Consulte o registro para obter detalhes.", 45 | "entity_already_configured": "A entidade com este ID já foi configurada.", 46 | "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).", 47 | "discovery_failed": "Algo falhou ao descobrir dispositivos. Consulte o registro para obter detalhes.", 48 | "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." 49 | }, 50 | "step": { 51 | "yaml_import": { 52 | "title": "Não suportado", 53 | "description": "As opções não podem ser editadas quando configuradas via YAML." 54 | }, 55 | "init": { 56 | "title": "Configuração LocalTuya", 57 | "description": "Selecione a ação desejada.", 58 | "data": { 59 | "add_device": "Adicionar um novo dispositivo", 60 | "edit_device": "Editar um dispositivo", 61 | "setup_cloud": "Reconfigurar a conta da API da Cloud" 62 | } 63 | }, 64 | "add_device": { 65 | "title": "Adicionar um novo dispositivo", 66 | "description": "Escolha um dos dispositivos descobertos automaticamente ou `...` para adicionar um dispositivo manualmente.", 67 | "data": { 68 | "selected_device": "Dispositivos descobertos" 69 | } 70 | }, 71 | "edit_device": { 72 | "title": "Editar um novo dispositivo", 73 | "description": "Escolha o dispositivo configurado que você deseja editar.", 74 | "data": { 75 | "selected_device": "Dispositivos configurados" 76 | } 77 | }, 78 | "cloud_setup": { 79 | "title": "Configuração da conta da API da Cloud", 80 | "description": "Insira as credenciais para a API Tuya Cloud.", 81 | "data": { 82 | "region": "Região do servidor de API", 83 | "client_id": "ID do Cliente", 84 | "client_secret": "Secret", 85 | "user_id": "ID do usuário", 86 | "user_name": "Nome de usuário", 87 | "no_cloud": "Não configure a conta da API da Cloud" 88 | } 89 | }, 90 | "configure_device": { 91 | "title": "Configurar dispositivo Tuya", 92 | "description": "Preencha os detalhes do dispositivo {for_device}.", 93 | "data": { 94 | "friendly_name": "Nome", 95 | "host": "Host", 96 | "device_id": "ID do dispositivo", 97 | "local_key": "Local key", 98 | "protocol_version": "Versão do protocolo", 99 | "enable_debug": "Ative a depuração para este dispositivo (a depuração também deve ser ativada em configuration.yaml)", 100 | "scan_interval": "Intervalo de escaneamento (segundos, somente quando não estiver atualizando automaticamente)", 101 | "entities": "Entidades (desmarque uma entidade para removê-la)" 102 | } 103 | }, 104 | "pick_entity_type": { 105 | "title": "Seleção do tipo de entidade", 106 | "description": "Escolha o tipo de entidade que deseja adicionar.", 107 | "data": { 108 | "platform_to_add": "Plataforma", 109 | "no_additional_entities": "Não adicione mais entidades" 110 | } 111 | }, 112 | "configure_entity": { 113 | "title": "Configurar entidade", 114 | "description": "Por favor, preencha os detalhes de {entity} com o tipo `{platform}`. Todas as configurações, exceto `ID`, podem ser alteradas na página Opções posteriormente.", 115 | "data": { 116 | "id": "ID", 117 | "friendly_name": "Nome fantasia", 118 | "current": "Atual", 119 | "current_consumption": "Consumo atual", 120 | "voltage": "Voltagem", 121 | "commands_set": "Conjunto de comandos Abrir_Fechar_Parar", 122 | "positioning_mode": "Modo de posicionamento", 123 | "current_position_dp": "Posição atual (somente para o modo *posição*)", 124 | "set_position_dp": "Definir posição (somente para o modo *posição*)", 125 | "position_inverted": "Inverter 0-100 posição (somente para o modo *posição*)", 126 | "span_time": "Tempo de abertura completo, em segundos. (somente para o modo *temporizado*)", 127 | "unit_of_measurement": "Unidade de medida", 128 | "device_class": "Classe do dispositivo", 129 | "scaling": "Fator de escala", 130 | "state_on": "Valor ligado", 131 | "state_off": "Valor desligado", 132 | "powergo_dp": "Potência DP (Geralmente 25 ou 2)", 133 | "idle_status_value": "Status ocioso (separado por vírgula)", 134 | "returning_status_value": "Status de retorno", 135 | "docked_status_value": "Status encaixado (separado por vírgula)", 136 | "fault_dp": "Falha DP (Geralmente 11)", 137 | "battery_dp": "Status da bateria DP (normalmente 14)", 138 | "mode_dp": "Modo DP (Geralmente 27)", 139 | "modes": "Lista de modos", 140 | "return_mode": "Modo de retorno para casa", 141 | "fan_speed_dp": "Velocidades do ventilador DP (normalmente 30)", 142 | "fan_speeds": "Lista de velocidades do ventilador (separadas por vírgulas)", 143 | "clean_time_dp": "Tempo Limpo DP (Geralmente 33)", 144 | "clean_area_dp": "Área Limpa DP (Geralmente 32)", 145 | "clean_record_dp": "Limpar Registro DP (Geralmente 34)", 146 | "locate_dp": "Localize DP (Geralmente 31)", 147 | "paused_state": "Estado de pausa (pausa, pausado, etc)", 148 | "stop_status": "Status de parada", 149 | "brightness": "Brilho (somente para cor branca)", 150 | "brightness_lower": "Valor mais baixo de brilho", 151 | "brightness_upper": "Valor superior de brilho", 152 | "color_temp": "Temperatura da cor", 153 | "color_temp_reverse": "Temperatura da cor reversa", 154 | "color": "Cor", 155 | "color_mode": "Modo de cor", 156 | "color_temp_min_kelvin": "Temperatura de cor mínima em K", 157 | "color_temp_max_kelvin": "Temperatura máxima de cor em K", 158 | "music_mode": "Modo de música disponível", 159 | "scene": "Cena", 160 | "select_options": "Entradas válidas, entradas separadas por um ;", 161 | "select_options_friendly": "Opções fantasia ​​ao usuário, entradas separadas por um ;", 162 | "fan_speed_control": "Dps de controle de velocidade do ventilador", 163 | "fan_oscillating_control": "Dps de controle oscilante do ventilador", 164 | "fan_speed_min": "Velocidade mínima do ventilador inteiro", 165 | "fan_speed_max": "Velocidade máxima do ventilador inteiro", 166 | "fan_speed_ordered_list": "Lista de modos de velocidade do ventilador (substitui a velocidade min/max)", 167 | "fan_direction":"Direção do ventilador dps", 168 | "fan_direction_forward": "Seqüência de dps para frente", 169 | "fan_direction_reverse": "String dps reversa", 170 | "current_temperature_dp": "Temperatura atual", 171 | "target_temperature_dp": "Temperatura alvo", 172 | "temperature_step": "Etapa de temperatura (opcional)", 173 | "max_temperature_dp": "Temperatura máxima (opcional)", 174 | "min_temperature_dp": "Temperatura mínima (opcional)", 175 | "precision": "Precisão (opcional, para valores de DPs)", 176 | "target_precision": "Precisão do alvo (opcional, para valores de DPs)", 177 | "temperature_unit": "Unidade de Temperatura (opcional)", 178 | "hvac_mode_dp": "Modo HVAC DP (opcional)", 179 | "hvac_mode_set": "Conjunto de modo HVAC (opcional)", 180 | "hvac_action_dp": "Ação atual de HVAC DP (opcional)", 181 | "hvac_action_set": "Conjunto de ação atual HVAC (opcional)", 182 | "preset_dp": "Predefinições DP (opcional)", 183 | "preset_set": "Conjunto de predefinições (opcional)", 184 | "eco_dp": "Eco DP (opcional)", 185 | "eco_value": "Valor eco (opcional)", 186 | "heuristic_action": "Ativar ação heurística (opcional)" 187 | } 188 | } 189 | } 190 | }, 191 | "services": { 192 | "reload": { 193 | "name": "Reload", 194 | "description": "Reload localtuya and reconnect to all devices." 195 | }, 196 | "set_dp": { 197 | "name": "Set datapoint", 198 | "description": "Change the value of a datapoint (DP)", 199 | "fields": { 200 | "device_id": { 201 | "name": "Device ID", 202 | "description": "Device ID of device to change datapoint value for" 203 | }, 204 | "dp": { 205 | "name": "DP", 206 | "description": "Datapoint index" 207 | }, 208 | "value": { 209 | "name": "Value", 210 | "description": "New value to set" 211 | } 212 | } 213 | } 214 | }, 215 | "title": "LocalTuya" 216 | } 217 | -------------------------------------------------------------------------------- /info.md: -------------------------------------------------------------------------------- 1 | [![](https://img.shields.io/github/release/rospogrigio/localtuya-homeassistant/all.svg?style=for-the-badge)](https://github.com/rospogrigio/localtuya-homeassistant/releases) 2 | [![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg?style=for-the-badge)](https://github.com/custom-components/hacs) 3 | [![](https://img.shields.io/badge/MAINTAINER-%40rospogrigio-green?style=for-the-badge)](https://github.com/rospogrigio) 4 | 5 | ![logo](https://github.com/rospogrigio/localtuya-homeassistant/blob/master/img/logo-small.png) 6 | 7 | A Home Assistant custom Integration for local handling of Tuya-based devices. 8 | 9 | This custom integration updates device status via pushing updates instead of polling, so status updates are fast (even when manually operated). 10 | The integration also supports the Tuya IoT Cloud APIs, for the retrieval of info and of the local_keys of the devices. 11 | 12 | 13 | **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.** 14 | 15 | 16 | The following Tuya device types are currently supported: 17 | * Switches 18 | * Lights 19 | * Covers 20 | * Fans 21 | * Climates 22 | * Vacuums 23 | 24 | Energy monitoring (voltage, current, watts, etc.) is supported for compatible devices. 25 | 26 | > **Currently, Tuya protocols from 3.1 to 3.4 are supported.** 27 | 28 | 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. 29 | 30 | 31 | # Installation: 32 | 33 | The easiest way, if you are using [HACS](https://hacs.xyz/), is to install LocalTuya through HACS. 34 | 35 | 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. 36 | 37 | 38 | # Usage: 39 | 40 | **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 .** 41 | 42 | 43 | **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.** 44 | 45 | 46 | # Adding the Integration 47 | 48 | 49 | **NOTE: starting from v4.0.0, configuration using YAML files is no longer supported. The integration can only be configured using the config flow.** 50 | 51 | 52 | To start configuring the integration, just press the "+ADD INTEGRATION" button in the Settings - Integrations page, and select LocalTuya from the drop-down menu. 53 | The Cloud API configuration page will appear, requesting to input your Tuya IoT Platform account credentials: 54 | 55 | ![cloud_setup](https://github.com/rospogrigio/localtuya-homeassistant/blob/master/img/9-cloud_setup.png) 56 | 57 | To setup a Tuya IoT Platform account and setup a project in it, refer to the instructions for the official Tuya integration: 58 | https://www.home-assistant.io/integrations/tuya/ 59 | 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: 60 | 61 | ![user_id.png](https://github.com/rospogrigio/localtuya-homeassistant/blob/master/img/8-user_id.png) 62 | 63 | > **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:** 64 | 65 | ![project_date](https://github.com/rospogrigio/localtuya-homeassistant/blob/master/img/6-project_date.png) 66 | 67 | After pressing the Submit button, the first setup is complete and the Integration will be added. 68 | 69 | > **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.** 70 | 71 | After the Integration has been set up, devices can be added and configured pressing the Configure button in the Integrations page: 72 | 73 | ![integration_configure](https://github.com/rospogrigio/localtuya-homeassistant/blob/master/img/10-integration_configure.png) 74 | 75 | 76 | # Integration Configuration menu 77 | 78 | The configuration menu is the following: 79 | 80 | ![config_menu](https://github.com/rospogrigio/localtuya-homeassistant/blob/master/img/11-config_menu.png) 81 | 82 | 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. 83 | 84 | You can then proceed Adding or Editing your Tuya devices. 85 | 86 | # Adding/editing a device 87 | 88 | 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. 89 | 90 | > **Note: The tuya app on your device must be closed for the following steps to work reliably.** 91 | 92 | 93 | ![discovery](https://github.com/rospogrigio/localtuya-homeassistant/blob/master/img/1-discovery.png) 94 | 95 | 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. 96 | 97 | 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. 98 | 99 | Setting the 'Manual DPS To Add' is optional, it is only needed if the device doesn't advertise the DPS correctly until the entity has been properly initiailised. This setting can often be avoided by first connecting/initialising the device with the Tuya App, then closing the app and then adding the device in the integration. 100 | 101 | Setting the 'DPIDs to send in RESET command' is optional. It is used when a device doesn't respond to any Tuya commands after a power cycle, but can be connected to (zombie state). The DPids will vary between devices, but typically "18,19,20" is used (and will be the default if none specified). If the wrong entries are added here, then the device may not come out of the zombie state. Typically only sensor DPIDs entered here. 102 | 103 | Once you press "Submit", the connection is tested to check that everything works. 104 | 105 | ![image](https://github.com/rospogrigio/localtuya-homeassistant/blob/master/img/2-device.png) 106 | 107 | 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. 108 | After you have defined all the needed entities, leave the "Do not add more entities" checkbox checked: this will complete the procedure. 109 | 110 | ![entity_type](https://github.com/rospogrigio/localtuya-homeassistant/blob/master/img/3-entity_type.png) 111 | 112 | 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 113 | all the available DPs found on the device (with their current status!!) for easy identification. Each entity type has different options 114 | to be configured. Here is an example for the "switch" entity: 115 | 116 | ![entity](https://github.com/rospogrigio/localtuya-homeassistant/blob/master/img/4-entity.png) 117 | 118 | Once you configure the entities, the procedure is complete. You can now associate the device with an Area in Home Assistant 119 | 120 | ![success](https://github.com/rospogrigio/localtuya-homeassistant/blob/master/img/5-success.png) 121 | 122 | 123 | # Migration from LocalTuya v.3.x.x 124 | 125 | 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). 126 | 127 | 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) ). 128 | 129 | 130 | # Energy monitoring values 131 | 132 | You can obtain Energy monitoring (voltage, current) in two different ways: 133 | 134 | 1) Creating individual sensors, each one with the desired name. 135 | Note: Voltage and Consumption usually include the first decimal. You will need to scale the parament by 0.1 to get the correct values. 136 | 2) Access the voltage/current/current_consumption attributes of a switch, and define template sensors 137 | Note: these values are already divided by 10 for Voltage and Consumption 138 | 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. 139 | 140 | ``` 141 | sensor: 142 | - platform: template 143 | sensors: 144 | tuya-sw01_voltage: 145 | value_template: >- 146 | {{ states.switch.sw01.attributes.voltage }} 147 | unit_of_measurement: 'V' 148 | tuya-sw01_current: 149 | value_template: >- 150 | {{ states.switch.sw01.attributes.current }} 151 | unit_of_measurement: 'mA' 152 | tuya-sw01_current_consumption: 153 | value_template: >- 154 | {{ states.switch.sw01.attributes.current_consumption }} 155 | unit_of_measurement: 'W' 156 | ``` 157 | 158 | # Debugging 159 | 160 | 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: 161 | 162 | ```yaml 163 | logger: 164 | default: warning 165 | logs: 166 | custom_components.localtuya: debug 167 | custom_components.localtuya.pytuya: debug 168 | ``` 169 | 170 | Then, edit the device that is showing problems and check the "Enable debugging for this device" button. 171 | 172 | # Notes: 173 | 174 | * Do not declare anything as "tuya", such as by initiating a "switch.tuya". Using "tuya" launches Home Assistant's built-in, cloud-based Tuya integration in lieu of localtuya. 175 | 176 | # To-do list: 177 | 178 | * Create a (good and precise) sensor (counter) for Energy (kWh) -not just Power, but based on it-. 179 | Ideas: Use: https://www.home-assistant.io/components/integration/ and https://www.home-assistant.io/components/utility_meter/ 180 | 181 | * Everything listed in https://github.com/rospogrigio/localtuya-homeassistant/issues/15 182 | 183 | # Thanks to: 184 | 185 | 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. 186 | 187 | TradeFace, for being the only one to provide the correct code for communication with the type_0d devices (in particular, the 0x0d command for the status instead of the 0x0a, and related needs such as double reply to be received): https://github.com/TradeFace/tuya/ 188 | 189 | sean6541, for the working (standard) Python Handler for Tuya devices. 190 | 191 | jasonacox, for the TinyTuya project from where I could import the code to communicate with devices using protocol 3.4. 192 | 193 | 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. 194 | 195 | Buy Me A Coffee 196 | PayPal Logo 197 | -------------------------------------------------------------------------------- /custom_components/localtuya/__init__.py: -------------------------------------------------------------------------------- 1 | """The LocalTuya integration.""" 2 | import asyncio 3 | import logging 4 | import time 5 | from datetime import timedelta 6 | 7 | import homeassistant.helpers.config_validation as cv 8 | import homeassistant.helpers.entity_registry as er 9 | import voluptuous as vol 10 | from homeassistant.config_entries import ConfigEntry 11 | from homeassistant.const import ( 12 | CONF_CLIENT_ID, 13 | CONF_CLIENT_SECRET, 14 | CONF_DEVICE_ID, 15 | CONF_DEVICES, 16 | CONF_ENTITIES, 17 | CONF_HOST, 18 | CONF_ID, 19 | CONF_PLATFORM, 20 | CONF_REGION, 21 | CONF_USERNAME, 22 | EVENT_HOMEASSISTANT_STOP, 23 | SERVICE_RELOAD, 24 | ) 25 | from homeassistant.core import HomeAssistant 26 | from homeassistant.exceptions import HomeAssistantError 27 | from homeassistant.helpers.device_registry import DeviceEntry 28 | from homeassistant.helpers.event import async_track_time_interval 29 | from homeassistant.helpers.service import async_register_admin_service 30 | 31 | from .cloud_api import TuyaCloudApi 32 | from .common import TuyaDevice, async_config_entry_by_device_id 33 | from .config_flow import ENTRIES_VERSION, config_schema 34 | from .const import ( 35 | ATTR_UPDATED_AT, 36 | CONF_NO_CLOUD, 37 | CONF_PRODUCT_KEY, 38 | CONF_USER_ID, 39 | DATA_CLOUD, 40 | DATA_DISCOVERY, 41 | DOMAIN, 42 | TUYA_DEVICES, 43 | ) 44 | from .discovery import TuyaDiscovery 45 | 46 | _LOGGER = logging.getLogger(__name__) 47 | 48 | UNSUB_LISTENER = "unsub_listener" 49 | 50 | RECONNECT_INTERVAL = timedelta(seconds=60) 51 | 52 | CONFIG_SCHEMA = config_schema() 53 | 54 | CONF_DP = "dp" 55 | CONF_VALUE = "value" 56 | 57 | SERVICE_SET_DP = "set_dp" 58 | SERVICE_SET_DP_SCHEMA = vol.Schema( 59 | { 60 | vol.Required(CONF_DEVICE_ID): cv.string, 61 | vol.Required(CONF_DP): int, 62 | vol.Required(CONF_VALUE): object, 63 | } 64 | ) 65 | 66 | 67 | async def async_setup(hass: HomeAssistant, config: dict): 68 | """Set up the LocalTuya integration component.""" 69 | hass.data.setdefault(DOMAIN, {}) 70 | hass.data[DOMAIN][TUYA_DEVICES] = {} 71 | 72 | device_cache = {} 73 | 74 | async def _handle_reload(service): 75 | """Handle reload service call.""" 76 | _LOGGER.info("Service %s.reload called: reloading integration", DOMAIN) 77 | 78 | current_entries = hass.config_entries.async_entries(DOMAIN) 79 | 80 | reload_tasks = [ 81 | hass.config_entries.async_reload(entry.entry_id) 82 | for entry in current_entries 83 | ] 84 | 85 | await asyncio.gather(*reload_tasks) 86 | 87 | async def _handle_set_dp(event): 88 | """Handle set_dp service call.""" 89 | dev_id = event.data[CONF_DEVICE_ID] 90 | if dev_id not in hass.data[DOMAIN][TUYA_DEVICES]: 91 | raise HomeAssistantError("unknown device id") 92 | 93 | device = hass.data[DOMAIN][TUYA_DEVICES][dev_id] 94 | if not device.connected: 95 | raise HomeAssistantError("not connected to device") 96 | 97 | await device.set_dp(event.data[CONF_VALUE], event.data[CONF_DP]) 98 | 99 | def _device_discovered(device): 100 | """Update address of device if it has changed.""" 101 | device_ip = device["ip"] 102 | device_id = device["gwId"] 103 | product_key = device["productKey"] 104 | 105 | # If device is not in cache, check if a config entry exists 106 | entry = async_config_entry_by_device_id(hass, device_id) 107 | if entry is None: 108 | return 109 | 110 | if device_id not in device_cache: 111 | if entry and device_id in entry.data[CONF_DEVICES]: 112 | # Save address from config entry in cache to trigger 113 | # potential update below 114 | host_ip = entry.data[CONF_DEVICES][device_id][CONF_HOST] 115 | device_cache[device_id] = host_ip 116 | 117 | if device_id not in device_cache: 118 | return 119 | 120 | dev_entry = entry.data[CONF_DEVICES][device_id] 121 | 122 | new_data = entry.data.copy() 123 | updated = False 124 | 125 | if device_cache[device_id] != device_ip: 126 | updated = True 127 | new_data[CONF_DEVICES][device_id][CONF_HOST] = device_ip 128 | device_cache[device_id] = device_ip 129 | 130 | if dev_entry.get(CONF_PRODUCT_KEY) != product_key: 131 | updated = True 132 | new_data[CONF_DEVICES][device_id][CONF_PRODUCT_KEY] = product_key 133 | 134 | # Update settings if something changed, otherwise try to connect. Updating 135 | # settings triggers a reload of the config entry, which tears down the device 136 | # so no need to connect in that case. 137 | if updated: 138 | _LOGGER.debug( 139 | "Updating keys for device %s: %s %s", device_id, device_ip, product_key 140 | ) 141 | new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000)) 142 | hass.config_entries.async_update_entry(entry, data=new_data) 143 | 144 | elif device_id in hass.data[DOMAIN][TUYA_DEVICES]: 145 | _LOGGER.debug("Device %s found with IP %s", device_id, device_ip) 146 | 147 | device = hass.data[DOMAIN][TUYA_DEVICES].get(device_id) 148 | if not device: 149 | _LOGGER.warning(f"Could not find device for device_id {device_id}") 150 | elif not device.connected: 151 | device.async_connect() 152 | 153 | 154 | def _shutdown(event): 155 | """Clean up resources when shutting down.""" 156 | discovery.close() 157 | 158 | async def _async_reconnect(now): 159 | """Try connecting to devices not already connected to.""" 160 | for device_id, device in hass.data[DOMAIN][TUYA_DEVICES].items(): 161 | if not device.connected: 162 | device.async_connect() 163 | 164 | async_track_time_interval(hass, _async_reconnect, RECONNECT_INTERVAL) 165 | 166 | async_register_admin_service( 167 | hass, 168 | DOMAIN, 169 | SERVICE_RELOAD, 170 | _handle_reload, 171 | ) 172 | 173 | hass.services.async_register( 174 | DOMAIN, SERVICE_SET_DP, _handle_set_dp, schema=SERVICE_SET_DP_SCHEMA 175 | ) 176 | 177 | discovery = TuyaDiscovery(_device_discovered) 178 | try: 179 | await discovery.start() 180 | hass.data[DOMAIN][DATA_DISCOVERY] = discovery 181 | hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown) 182 | except Exception: # pylint: disable=broad-except 183 | _LOGGER.exception("failed to set up discovery") 184 | 185 | return True 186 | 187 | 188 | async def async_migrate_entry(hass, config_entry: ConfigEntry): 189 | """Migrate old entries merging all of them in one.""" 190 | new_version = ENTRIES_VERSION 191 | stored_entries = hass.config_entries.async_entries(DOMAIN) 192 | if config_entry.version == 1: 193 | _LOGGER.debug("Migrating config entry from version %s", config_entry.version) 194 | 195 | if config_entry.entry_id == stored_entries[0].entry_id: 196 | _LOGGER.debug( 197 | "Migrating the first config entry (%s)", config_entry.entry_id 198 | ) 199 | new_data = {} 200 | new_data[CONF_REGION] = "eu" 201 | new_data[CONF_CLIENT_ID] = "" 202 | new_data[CONF_CLIENT_SECRET] = "" 203 | new_data[CONF_USER_ID] = "" 204 | new_data[CONF_USERNAME] = DOMAIN 205 | new_data[CONF_NO_CLOUD] = True 206 | new_data[CONF_DEVICES] = { 207 | config_entry.data[CONF_DEVICE_ID]: config_entry.data.copy() 208 | } 209 | new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000)) 210 | config_entry.version = new_version 211 | hass.config_entries.async_update_entry( 212 | config_entry, title=DOMAIN, data=new_data 213 | ) 214 | else: 215 | _LOGGER.debug( 216 | "Merging the config entry %s into the main one", config_entry.entry_id 217 | ) 218 | new_data = stored_entries[0].data.copy() 219 | new_data[CONF_DEVICES].update( 220 | {config_entry.data[CONF_DEVICE_ID]: config_entry.data.copy()} 221 | ) 222 | new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000)) 223 | hass.config_entries.async_update_entry(stored_entries[0], data=new_data) 224 | await hass.config_entries.async_remove(config_entry.entry_id) 225 | 226 | _LOGGER.info( 227 | "Entry %s successfully migrated to version %s.", 228 | config_entry.entry_id, 229 | new_version, 230 | ) 231 | 232 | return True 233 | 234 | 235 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): 236 | """Set up LocalTuya integration from a config entry.""" 237 | if entry.version < ENTRIES_VERSION: 238 | _LOGGER.debug( 239 | "Skipping setup for entry %s since its version (%s) is old", 240 | entry.entry_id, 241 | entry.version, 242 | ) 243 | return 244 | 245 | region = entry.data[CONF_REGION] 246 | client_id = entry.data[CONF_CLIENT_ID] 247 | secret = entry.data[CONF_CLIENT_SECRET] 248 | user_id = entry.data[CONF_USER_ID] 249 | tuya_api = TuyaCloudApi(hass, region, client_id, secret, user_id) 250 | no_cloud = True 251 | if CONF_NO_CLOUD in entry.data: 252 | no_cloud = entry.data.get(CONF_NO_CLOUD) 253 | if no_cloud: 254 | _LOGGER.info("Cloud API account not configured.") 255 | # wait 1 second to make sure possible migration has finished 256 | await asyncio.sleep(1) 257 | else: 258 | res = await tuya_api.async_get_access_token() 259 | if res != "ok": 260 | _LOGGER.error("Cloud API connection failed: %s", res) 261 | else: 262 | _LOGGER.info("Cloud API connection succeeded.") 263 | res = await tuya_api.async_get_devices_list() 264 | hass.data[DOMAIN][DATA_CLOUD] = tuya_api 265 | 266 | platforms = set() 267 | for dev_id in entry.data[CONF_DEVICES].keys(): 268 | entities = entry.data[CONF_DEVICES][dev_id][CONF_ENTITIES] 269 | platforms = platforms.union( 270 | set(entity[CONF_PLATFORM] for entity in entities) 271 | ) 272 | hass.data[DOMAIN][TUYA_DEVICES][dev_id] = TuyaDevice(hass, entry, dev_id) 273 | 274 | # Setup all platforms at once, letting HA handling each platform and avoiding 275 | # potential integration restarts while elements are still initialising. 276 | await hass.config_entries.async_forward_entry_setups(entry, platforms) 277 | 278 | async def setup_entities(device_ids): 279 | for dev_id in device_ids: 280 | hass.data[DOMAIN][TUYA_DEVICES][dev_id].async_connect() 281 | 282 | await async_remove_orphan_entities(hass, entry) 283 | 284 | hass.async_create_task(setup_entities(entry.data[CONF_DEVICES].keys())) 285 | 286 | unsub_listener = entry.add_update_listener(update_listener) 287 | hass.data[DOMAIN][entry.entry_id] = {UNSUB_LISTENER: unsub_listener} 288 | 289 | return True 290 | 291 | 292 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): 293 | """Unload a config entry.""" 294 | platforms = {} 295 | 296 | for dev_id, dev_entry in entry.data[CONF_DEVICES].items(): 297 | for entity in dev_entry[CONF_ENTITIES]: 298 | platforms[entity[CONF_PLATFORM]] = True 299 | 300 | unload_ok = all( 301 | await asyncio.gather( 302 | *[ 303 | hass.config_entries.async_forward_entry_unload(entry, component) 304 | for component in platforms 305 | ] 306 | ) 307 | ) 308 | 309 | hass.data[DOMAIN][entry.entry_id][UNSUB_LISTENER]() 310 | for dev_id, device in hass.data[DOMAIN][TUYA_DEVICES].items(): 311 | if device.connected: 312 | await device.close() 313 | 314 | if unload_ok: 315 | hass.data[DOMAIN][TUYA_DEVICES] = {} 316 | 317 | return True 318 | 319 | 320 | async def update_listener(hass, config_entry): 321 | """Update listener.""" 322 | await hass.config_entries.async_reload(config_entry.entry_id) 323 | 324 | 325 | async def async_remove_config_entry_device( 326 | hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry 327 | ) -> bool: 328 | """Remove a config entry from a device.""" 329 | dev_id = list(device_entry.identifiers)[0][1].split("_")[-1] 330 | 331 | ent_reg = er.async_get(hass) 332 | entities = { 333 | ent.unique_id: ent.entity_id 334 | for ent in er.async_entries_for_config_entry(ent_reg, config_entry.entry_id) 335 | if dev_id in ent.unique_id 336 | } 337 | for entity_id in entities.values(): 338 | ent_reg.async_remove(entity_id) 339 | 340 | if dev_id not in config_entry.data[CONF_DEVICES]: 341 | _LOGGER.info( 342 | "Device %s not found in config entry: finalizing device removal", dev_id 343 | ) 344 | return True 345 | 346 | await hass.data[DOMAIN][TUYA_DEVICES][dev_id].close() 347 | 348 | new_data = config_entry.data.copy() 349 | new_data[CONF_DEVICES].pop(dev_id) 350 | new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000)) 351 | 352 | hass.config_entries.async_update_entry( 353 | config_entry, 354 | data=new_data, 355 | ) 356 | 357 | _LOGGER.info("Device %s removed.", dev_id) 358 | 359 | return True 360 | 361 | 362 | async def async_remove_orphan_entities(hass, entry): 363 | """Remove entities associated with config entry that has been removed.""" 364 | return 365 | ent_reg = er.async_get(hass) 366 | entities = { 367 | ent.unique_id: ent.entity_id 368 | for ent in er.async_entries_for_config_entry(ent_reg, entry.entry_id) 369 | } 370 | _LOGGER.info("ENTITIES ORPHAN %s", entities) 371 | return 372 | 373 | for entity in entry.data[CONF_ENTITIES]: 374 | if entity[CONF_ID] in entities: 375 | del entities[entity[CONF_ID]] 376 | 377 | for entity_id in entities.values(): 378 | ent_reg.async_remove(entity_id) 379 | -------------------------------------------------------------------------------- /custom_components/localtuya/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Device has already been configured.", 5 | "device_updated": "Device configuration has been updated!" 6 | }, 7 | "error": { 8 | "authentication_failed": "Failed to authenticate.\n{msg}", 9 | "cannot_connect": "Cannot connect to device. Verify that address is correct and try again.", 10 | "device_list_failed": "Failed to retrieve device list.\n{msg}", 11 | "invalid_auth": "Failed to authenticate with device. Verify that device id and local key are correct.", 12 | "unknown": "An unknown error occurred. See log for details.", 13 | "entity_already_configured": "Entity with this ID has already been configured.", 14 | "address_in_use": "Address used for discovery is already in use. Make sure no other application is using it (TCP port 6668).", 15 | "discovery_failed": "Something failed when discovering devices. See log for details.", 16 | "empty_dps": "Connection to device succeeded but no datapoints found, please try again. Create a new issue and include debug logs if problem persists." 17 | }, 18 | "step": { 19 | "user": { 20 | "title": "Cloud API account configuration", 21 | "description": "Input the credentials for Tuya Cloud API.", 22 | "data": { 23 | "region": "API server region", 24 | "client_id": "Client ID", 25 | "client_secret": "Secret", 26 | "user_id": "User ID", 27 | "user_name": "Username", 28 | "no_cloud": "Do not configure a Cloud API account" 29 | } 30 | } 31 | } 32 | }, 33 | "options": { 34 | "abort": { 35 | "already_configured": "Device has already been configured.", 36 | "device_success": "Device {dev_name} successfully {action}.", 37 | "no_entities": "Cannot remove all entities from a device.\nIf you want to delete a device, enter it in the Devices menu, click the 3 dots in the 'Device info' frame, and press the Delete button." 38 | }, 39 | "error": { 40 | "authentication_failed": "Failed to authenticate.\n{msg}", 41 | "cannot_connect": "Cannot connect to device. Verify that address is correct and try again.", 42 | "device_list_failed": "Failed to retrieve device list.\n{msg}", 43 | "invalid_auth": "Failed to authenticate with device. Verify that device id and local key are correct.", 44 | "unknown": "An unknown error occurred. See log for details.", 45 | "entity_already_configured": "Entity with this ID has already been configured.", 46 | "address_in_use": "Address used for discovery is already in use. Make sure no other application is using it (TCP port 6668).", 47 | "discovery_failed": "Something failed when discovering devices. See log for details.", 48 | "empty_dps": "Connection to device succeeded but no datapoints found, please try again. Create a new issue and include debug logs if problem persists." 49 | }, 50 | "step": { 51 | "yaml_import": { 52 | "title": "Not Supported", 53 | "description": "Options cannot be edited when configured via YAML." 54 | }, 55 | "init": { 56 | "title": "LocalTuya Configuration", 57 | "description": "Please select the desired action.", 58 | "data": { 59 | "add_device": "Add a new device", 60 | "edit_device": "Edit a device", 61 | "setup_cloud": "Reconfigure Cloud API account" 62 | } 63 | }, 64 | "add_device": { 65 | "title": "Add a new device", 66 | "description": "Pick one of the automatically discovered devices or `...` to manually to add a device.", 67 | "data": { 68 | "selected_device": "Discovered Devices" 69 | } 70 | }, 71 | "edit_device": { 72 | "title": "Edit a new device", 73 | "description": "Pick the configured device you wish to edit.", 74 | "data": { 75 | "selected_device": "Configured Devices", 76 | "max_temperature_const": "Max Temperature Constant (optional)", 77 | "min_temperature_const": "Min Temperature Constant (optional)", 78 | "hvac_fan_mode_dp": "HVAC Fan Mode DP (optional)", 79 | "hvac_fan_mode_set": "HVAC Fan Mode Set (optional)", 80 | "hvac_swing_mode_dp": "HVAC Swing Mode DP (optional)", 81 | "hvac_swing_mode_set": "HVAC Swing Mode Set (optional)" 82 | } 83 | }, 84 | "cloud_setup": { 85 | "title": "Cloud API account configuration", 86 | "description": "Input the credentials for Tuya Cloud API.", 87 | "data": { 88 | "region": "API server region", 89 | "client_id": "Client ID", 90 | "client_secret": "Secret", 91 | "user_id": "User ID", 92 | "user_name": "Username", 93 | "no_cloud": "Do not configure Cloud API account" 94 | } 95 | }, 96 | "configure_device": { 97 | "title": "Configure Tuya device", 98 | "description": "Fill in the device details{for_device}.", 99 | "data": { 100 | "friendly_name": "Name", 101 | "host": "Host", 102 | "device_id": "Device ID", 103 | "local_key": "Local key", 104 | "protocol_version": "Protocol Version", 105 | "enable_debug": "Enable debugging for this device (debug must be enabled also in configuration.yaml)", 106 | "scan_interval": "Scan interval (seconds, only when not updating automatically)", 107 | "entities": "Entities (uncheck an entity to remove it)", 108 | "add_entities": "Add more entities in 'edit device' mode", 109 | "manual_dps_strings": "Manual DPS to add (separated by commas ',') - used when detection is not working (optional)", 110 | "reset_dpids": "DPIDs to send in RESET command (separated by commas ',')- Used when device does not respond to status requests after turning on (optional)" 111 | } 112 | }, 113 | "pick_entity_type": { 114 | "title": "Entity type selection", 115 | "description": "Please pick the type of entity you want to add.", 116 | "data": { 117 | "platform_to_add": "Platform", 118 | "no_additional_entities": "Do not add any more entities" 119 | } 120 | }, 121 | "configure_entity": { 122 | "title": "Configure entity", 123 | "description": "Please fill out the details for {entity} with type `{platform}`. All settings except for `ID` can be changed from the Options page later.", 124 | "data": { 125 | "id": "ID", 126 | "friendly_name": "Friendly name", 127 | "current": "Current", 128 | "current_consumption": "Current Consumption", 129 | "voltage": "Voltage", 130 | "commands_set": "Open_Close_Stop Commands Set", 131 | "positioning_mode": "Positioning mode", 132 | "current_position_dp": "Current Position (for *position* mode only)", 133 | "set_position_dp": "Set Position (for *position* mode only)", 134 | "position_inverted": "Invert 0-100 position (for *position* mode only)", 135 | "span_time": "Full opening time, in secs. (for *timed* mode only)", 136 | "unit_of_measurement": "Unit of Measurement", 137 | "device_class": "Device Class", 138 | "scaling": "Scaling Factor", 139 | "state_on": "On Value", 140 | "state_off": "Off Value", 141 | "powergo_dp": "Power DP (Usually 25 or 2)", 142 | "idle_status_value": "Idle Status (comma-separated)", 143 | "returning_status_value": "Returning Status", 144 | "docked_status_value": "Docked Status (comma-separated)", 145 | "fault_dp": "Fault DP (Usually 11)", 146 | "battery_dp": "Battery status DP (Usually 14)", 147 | "mode_dp": "Mode DP (Usually 27)", 148 | "modes": "Modes list", 149 | "return_mode": "Return home mode", 150 | "fan_speed_dp": "Fan speeds DP (Usually 30)", 151 | "fan_speeds": "Fan speeds list (comma-separated)", 152 | "clean_time_dp": "Clean Time DP (Usually 33)", 153 | "clean_area_dp": "Clean Area DP (Usually 32)", 154 | "clean_record_dp": "Clean Record DP (Usually 34)", 155 | "locate_dp": "Locate DP (Usually 31)", 156 | "paused_state": "Pause state (pause, paused, etc)", 157 | "stop_status": "Stop status", 158 | "brightness": "Brightness (only for white color)", 159 | "brightness_lower": "Brightness Lower Value", 160 | "brightness_upper": "Brightness Upper Value", 161 | "color_temp": "Color Temperature", 162 | "color_temp_reverse": "Color Temperature Reverse", 163 | "color": "Color", 164 | "color_mode": "Color Mode", 165 | "color_temp_min_kelvin": "Minimum Color Temperature in K", 166 | "color_temp_max_kelvin": "Maximum Color Temperature in K", 167 | "music_mode": "Music mode available", 168 | "scene": "Scene", 169 | "select_options": "Valid entries, separate entries by a ;", 170 | "select_options_friendly": "User Friendly options, separate entries by a ;", 171 | "fan_speed_control": "Fan Speed Control dps", 172 | "fan_oscillating_control": "Fan Oscillating Control dps", 173 | "fan_speed_min": "minimum fan speed integer", 174 | "fan_speed_max": "maximum fan speed integer", 175 | "fan_speed_ordered_list": "Fan speed modes list (overrides speed min/max)", 176 | "fan_direction": "fan direction dps", 177 | "fan_direction_forward": "forward dps string", 178 | "fan_direction_reverse": "reverse dps string", 179 | "fan_dps_type": "DP value type", 180 | "current_temperature_dp": "Current Temperature", 181 | "target_temperature_dp": "Target Temperature", 182 | "temperature_step": "Temperature Step (optional)", 183 | "max_temperature_dp": "Max Temperature DP (optional)", 184 | "min_temperature_dp": "Min Temperature DP (optional)", 185 | "max_temperature_const": "Max Temperature Constant (optional)", 186 | "min_temperature_const": "Min Temperature Constant (optional)", 187 | "precision": "Precision (optional, for DPs values)", 188 | "target_precision": "Target Precision (optional, for DPs values)", 189 | "temperature_unit": "Temperature Unit (optional)", 190 | "hvac_mode_dp": "HVAC Mode DP (optional)", 191 | "hvac_mode_set": "HVAC Mode Set (optional)", 192 | "hvac_fan_mode_dp": "HVAC Fan Mode DP (optional)", 193 | "hvac_fan_mode_set": "HVAC Fan Mode Set (optional)", 194 | "hvac_swing_mode_dp": "HVAC Swing Mode DP (optional)", 195 | "hvac_swing_mode_set": "HVAC Swing Mode Set (optional)", 196 | "hvac_action_dp": "HVAC Current Action DP (optional)", 197 | "hvac_action_set": "HVAC Current Action Set (optional)", 198 | "preset_dp": "Presets DP (optional)", 199 | "preset_set": "Presets Set (optional)", 200 | "eco_dp": "Eco DP (optional)", 201 | "eco_value": "Eco value (optional)", 202 | "heuristic_action": "Enable heuristic action (optional)", 203 | "dps_default_value": "Default value when un-initialised (optional)", 204 | "restore_on_reconnect": "Restore the last set value in HomeAssistant after a lost connection", 205 | "min_value": "Minimum Value", 206 | "max_value": "Maximum Value", 207 | "step_size": "Minimum increment between numbers", 208 | "is_passive_entity": "Passive entity - requires integration to send initialisation value" 209 | } 210 | } 211 | } 212 | }, 213 | "services": { 214 | "reload": { 215 | "name": "Reload", 216 | "description": "Reload localtuya and reconnect to all devices." 217 | }, 218 | "set_dp": { 219 | "name": "Set datapoint", 220 | "description": "Change the value of a datapoint (DP)", 221 | "fields": { 222 | "device_id": { 223 | "name": "Device ID", 224 | "description": "Device ID of device to change datapoint value for" 225 | }, 226 | "dp": { 227 | "name": "DP", 228 | "description": "Datapoint index" 229 | }, 230 | "value": { 231 | "name": "Value", 232 | "description": "New value to set" 233 | } 234 | } 235 | } 236 | }, 237 | "title": "LocalTuya" 238 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![logo](https://github.com/rospogrigio/localtuya-homeassistant/blob/master/img/logo-small.png) 2 | 3 | A Home Assistant custom Integration for local handling of Tuya-based devices. 4 | 5 | This custom integration updates device status via pushing updates instead of polling, so status updates are fast (even when manually operated). 6 | The integration also supports the Tuya IoT Cloud APIs, for the retrieval of info and of the local_keys of the devices. 7 | 8 | 9 | **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.** 10 | 11 | 12 | The following Tuya device types are currently supported: 13 | * Switches 14 | * Lights 15 | * Covers 16 | * Fans 17 | * Climates 18 | * Vacuums 19 | 20 | Energy monitoring (voltage, current, watts, etc.) is supported for compatible devices. 21 | 22 | > **Currently, Tuya protocols from 3.1 to 3.4 are supported.** 23 | 24 | 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. 25 | 26 | 27 | # Installation: 28 | 29 | The easiest way, if you are using [HACS](https://hacs.xyz/), is to install LocalTuya through HACS. 30 | 31 | 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. 32 | 33 | 34 | # Usage: 35 | 36 | **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/.** 37 | 38 | 39 | **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.** 40 | 41 | 42 | # Adding the Integration 43 | 44 | 45 | **NOTE: starting from v4.0.0, configuration using YAML files is no longer supported. The integration can only be configured using the config flow.** 46 | 47 | 48 | To start configuring the integration, just press the "+ADD INTEGRATION" button in the Settings - Integrations page, and select LocalTuya from the drop-down menu. 49 | The Cloud API configuration page will appear, requesting to input your Tuya IoT Platform account credentials: 50 | 51 | ![cloud_setup](https://github.com/rospogrigio/localtuya-homeassistant/blob/master/img/9-cloud_setup.png) 52 | 53 | To setup a Tuya IoT Platform account and setup a project in it, refer to the instructions for the official Tuya integration: 54 | https://www.home-assistant.io/integrations/tuya/ 55 | The Client ID and Secret can be found at `Cloud > Development > Overview` and the User ID can be found in the "Link Tuya App Account" subtab within the Cloud project: 56 | 57 | ![user_id.png](https://github.com/rospogrigio/localtuya-homeassistant/blob/master/img/8-user_id.png) 58 | 59 | > **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:** 60 | 61 | ![project_date](https://github.com/rospogrigio/localtuya-homeassistant/blob/master/img/6-project_date.png) 62 | 63 | After pressing the Submit button, the first setup is complete and the Integration will be added. 64 | 65 | > **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.** 66 | 67 | After the Integration has been set up, devices can be added and configured pressing the Configure button in the Integrations page: 68 | 69 | ![integration_configure](https://github.com/rospogrigio/localtuya-homeassistant/blob/master/img/10-integration_configure.png) 70 | 71 | 72 | # Integration Configuration menu 73 | 74 | The configuration menu is the following: 75 | 76 | ![config_menu](https://github.com/rospogrigio/localtuya-homeassistant/blob/master/img/11-config_menu.png) 77 | 78 | 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. 79 | 80 | You can then proceed Adding or Editing your Tuya devices. 81 | 82 | # Adding/editing a device 83 | 84 | 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. 85 | 86 | > **Note: The tuya app on your device must be closed for the following steps to work reliably.** 87 | 88 | 89 | ![discovery](https://github.com/rospogrigio/localtuya-homeassistant/blob/master/img/1-discovery.png) 90 | 91 | 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. 92 | 93 | 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. 94 | 95 | Setting the 'Manual DPS To Add' is optional, it is only needed if the device doesn't advertise the DPS correctly until the entity has been properly initiailised. This setting can often be avoided by first connecting/initialising the device with the Tuya App, then closing the app and then adding the device in the integration. **Note: Any DPS added using this option will have a -1 value during setup.** 96 | 97 | Setting the 'DPIDs to send in RESET command' is optional. It is used when a device doesn't respond to any Tuya commands after a power cycle, but can be connected to (zombie state). This scenario mostly occurs when the device is blocked from accessing the internet. The DPids will vary between devices, but typically "18,19,20" is used. If the wrong entries are added here, then the device may not come out of the zombie state. Typically only sensor DPIDs entered here. 98 | 99 | Once you press "Submit", the connection is tested to check that everything works. 100 | 101 | ![image](https://github.com/rospogrigio/localtuya-homeassistant/blob/master/img/2-device.png) 102 | 103 | 104 | 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. 105 | After you have defined all the needed entities, leave the "Do not add more entities" checkbox checked: this will complete the procedure. 106 | 107 | ![entity_type](https://github.com/rospogrigio/localtuya-homeassistant/blob/master/img/3-entity_type.png) 108 | 109 | 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 110 | all the available DPs found on the device (with their current status!!) for easy identification. 111 | 112 | **Note: If your device requires an LocalTuya to send an initialisation value to the entity for it to work, this can be configured (in supported entities) through the 'Passive entity' option. Optionally you can specify the initialisation value to be sent** 113 | 114 | Each entity type has different options to be configured. Here is an example for the "switch" entity: 115 | 116 | ![entity](https://github.com/rospogrigio/localtuya-homeassistant/blob/master/img/4-entity.png) 117 | 118 | Once you configure the entities, the procedure is complete. You can now associate the device with an Area in Home Assistant 119 | 120 | ![success](https://github.com/rospogrigio/localtuya-homeassistant/blob/master/img/5-success.png) 121 | 122 | 123 | # Migration from LocalTuya v.3.x.x 124 | 125 | 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). 126 | 127 | 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) ). 128 | 129 | 130 | # Energy monitoring values 131 | 132 | You can obtain Energy monitoring (voltage, current) in two different ways: 133 | 134 | 1) Creating individual sensors, each one with the desired name. 135 | Note: Voltage and Consumption usually include the first decimal. You will need to scale the parament by 0.1 to get the correct values. 136 | 2) Access the voltage/current/current_consumption attributes of a switch, and define template sensors 137 | Note: these values are already divided by 10 for Voltage and Consumption 138 | 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. 139 | 140 | ```yaml 141 | sensor: 142 | - platform: template 143 | sensors: 144 | tuya-sw01_voltage: 145 | value_template: >- 146 | {{ states.switch.sw01.attributes.voltage }} 147 | unit_of_measurement: 'V' 148 | tuya-sw01_current: 149 | value_template: >- 150 | {{ states.switch.sw01.attributes.current }} 151 | unit_of_measurement: 'mA' 152 | tuya-sw01_current_consumption: 153 | value_template: >- 154 | {{ states.switch.sw01.attributes.current_consumption }} 155 | unit_of_measurement: 'W' 156 | ``` 157 | 158 | # Climates 159 | 160 | There are a multitude of Tuya based climates out there, both heaters, 161 | thermostats and ACs. The all seems to be integrated in different ways and it's 162 | hard to find a common DP mapping. Below are a table of DP to product mapping 163 | which are currently seen working. Use it as a guide for your own mapping and 164 | please contribute to the list if you have the possibility. 165 | 166 | | DP | Moes BHT 002 | Qlima WMS S + SC52 (AB;AF) | Avatto | 167 | |-----|---------------------------------------------------------|---------------------------------------------------------|--------------------------------------------| 168 | | 1 | ID: On/Off
{true, false} | ID: On/Off
{true, false} | ID: On/Off
{true, false} | 169 | | 2 | Target temperature
Integer, scaling: 0.5 | Target temperature
Integer, scaling 1 | Target temperature
Integer, scaling 1 | 170 | | 3 | Current temperature
Integer, scaling: 0.5 | Current temperature
Integer, scaling: 1 | Current temperature
Integer, scaling: 1 | 171 | | 4 | Mode
{0, 1} | Mode
{"hot", "wind", "wet", "cold", "auto"} | ? | 172 | | 5 | Eco mode
? | Fan mode
{"strong", "high", "middle", "low", "auto"} | ? | 173 | | 15 | Not supported | Supported, unknown
{true, false} | ? | 174 | | 19 | Not supported | Temperature unit
{"c", "f"} | ? | 175 | | 23 | Not supported | Supported, unknown
Integer, eg. 68 | ? | 176 | | 24 | Not supported | Supported, unknown
Integer, eg. 64 | ? | 177 | | 101 | Not supported | Outdoor temperature
Integer. Scaling: 1 | ? | 178 | | 102 | Temperature of external sensor
Integer, scaling: 0.5 | Supported, unknown
Integer, eg. 34 | ? | 179 | | 104 | Supported, unknown
{true, false(?)} | Not supported | ? | 180 | 181 | [Moes BHT 002](https://community.home-assistant.io/t/moes-bht-002-thermostat-local-control-tuya-based/151953/47) 182 | [Avatto thermostat](https://pl.aliexpress.com/item/1005001605377377.html?gatewayAdapt=glo2pol) 183 | 184 | # Debugging 185 | 186 | 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: 187 | 188 | ```yaml 189 | logger: 190 | default: warning 191 | logs: 192 | custom_components.localtuya: debug 193 | custom_components.localtuya.pytuya: debug 194 | ``` 195 | 196 | Then, edit the device that is showing problems and check the "Enable debugging for this device" button. 197 | 198 | # Notes: 199 | 200 | * Do not declare anything as "tuya", such as by initiating a "switch.tuya". Using "tuya" launches Home Assistant's built-in, cloud-based Tuya integration in lieu of localtuya. 201 | 202 | # To-do list: 203 | 204 | * Create a (good and precise) sensor (counter) for Energy (kWh) -not just Power, but based on it-. 205 | Ideas: Use: https://www.home-assistant.io/integrations/integration/ and https://www.home-assistant.io/integrations/utility_meter/ 206 | 207 | * Everything listed in https://github.com/rospogrigio/localtuya-homeassistant/issues/15 208 | 209 | # Thanks to: 210 | 211 | 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. 212 | 213 | TradeFace, for being the only one to provide the correct code for communication with the cover (in particular, the 0x0d command for the status instead of the 0x0a, and related needs such as double reply to be received): https://github.com/TradeFace/tuya/ 214 | 215 | sean6541, for the working (standard) Python Handler for Tuya devices. 216 | 217 | jasonacox, for the TinyTuya project from where I could import the code to communicate with devices using protocol 3.4. 218 | 219 | 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. 220 | 221 | Buy Me A Coffee 222 | PayPal Logo 223 | -------------------------------------------------------------------------------- /custom_components/localtuya/climate.py: -------------------------------------------------------------------------------- 1 | """Platform to locally control Tuya-based climate devices.""" 2 | import asyncio 3 | import logging 4 | from functools import partial 5 | 6 | import voluptuous as vol 7 | from homeassistant.components.climate import ( 8 | DEFAULT_MAX_TEMP, 9 | DEFAULT_MIN_TEMP, 10 | DOMAIN, 11 | ClimateEntity, 12 | ) 13 | from homeassistant.components.climate.const import ( 14 | HVACAction, 15 | HVACMode, 16 | PRESET_AWAY, 17 | PRESET_ECO, 18 | PRESET_HOME, 19 | PRESET_NONE, 20 | ClimateEntityFeature, 21 | FAN_AUTO, 22 | FAN_LOW, 23 | FAN_MEDIUM, 24 | FAN_HIGH, 25 | FAN_TOP, 26 | SWING_ON, 27 | SWING_OFF, 28 | ) 29 | from homeassistant.const import ( 30 | ATTR_TEMPERATURE, 31 | CONF_TEMPERATURE_UNIT, 32 | PRECISION_HALVES, 33 | PRECISION_TENTHS, 34 | PRECISION_WHOLE, 35 | UnitOfTemperature, 36 | ) 37 | 38 | from .common import LocalTuyaEntity, async_setup_entry 39 | from .const import ( 40 | CONF_CURRENT_TEMPERATURE_DP, 41 | CONF_TEMP_MAX, 42 | CONF_TEMP_MIN, 43 | CONF_ECO_DP, 44 | CONF_ECO_VALUE, 45 | CONF_HEURISTIC_ACTION, 46 | CONF_HVAC_ACTION_DP, 47 | CONF_HVAC_ACTION_SET, 48 | CONF_HVAC_MODE_DP, 49 | CONF_HVAC_MODE_SET, 50 | CONF_MAX_TEMP_DP, 51 | CONF_MIN_TEMP_DP, 52 | CONF_PRECISION, 53 | CONF_PRESET_DP, 54 | CONF_PRESET_SET, 55 | CONF_TARGET_PRECISION, 56 | CONF_TARGET_TEMPERATURE_DP, 57 | CONF_TEMPERATURE_STEP, 58 | CONF_HVAC_FAN_MODE_DP, 59 | CONF_HVAC_FAN_MODE_SET, 60 | CONF_HVAC_SWING_MODE_DP, 61 | CONF_HVAC_SWING_MODE_SET, 62 | ) 63 | 64 | _LOGGER = logging.getLogger(__name__) 65 | 66 | HVAC_MODE_SETS = { 67 | "manual/auto": { 68 | HVACMode.HEAT: "manual", 69 | HVACMode.AUTO: "auto", 70 | }, 71 | "Manual/Auto": { 72 | HVACMode.HEAT: "Manual", 73 | HVACMode.AUTO: "Auto", 74 | }, 75 | "MANUAL/AUTO": { 76 | HVACMode.HEAT: "MANUAL", 77 | HVACMode.AUTO: "AUTO", 78 | }, 79 | "Manual/Program": { 80 | HVACMode.HEAT: "Manual", 81 | HVACMode.AUTO: "Program", 82 | }, 83 | "m/p": { 84 | HVACMode.HEAT: "m", 85 | HVACMode.AUTO: "p", 86 | }, 87 | "True/False": { 88 | HVACMode.HEAT: True, 89 | }, 90 | "Auto/Cold/Dry/Wind/Hot": { 91 | HVACMode.HEAT: "hot", 92 | HVACMode.FAN_ONLY: "wind", 93 | HVACMode.DRY: "wet", 94 | HVACMode.COOL: "cold", 95 | HVACMode.AUTO: "auto", 96 | }, 97 | "Cold/Dehumidify/Hot": { 98 | HVACMode.HEAT: "hot", 99 | HVACMode.DRY: "dehumidify", 100 | HVACMode.COOL: "cold", 101 | }, 102 | "1/0": { 103 | HVACMode.HEAT: "1", 104 | HVACMode.AUTO: "0", 105 | }, 106 | } 107 | HVAC_ACTION_SETS = { 108 | "True/False": { 109 | HVACAction.HEATING: True, 110 | HVACAction.IDLE: False, 111 | }, 112 | "open/close": { 113 | HVACAction.HEATING: "open", 114 | HVACAction.IDLE: "close", 115 | }, 116 | "heating/no_heating": { 117 | HVACAction.HEATING: "heating", 118 | HVACAction.IDLE: "no_heating", 119 | }, 120 | "Heat/Warming": { 121 | HVACAction.HEATING: "Heat", 122 | HVACAction.IDLE: "Warming", 123 | }, 124 | "heating/warming": { 125 | HVACAction.HEATING: "heating", 126 | HVACAction.IDLE: "warming", 127 | }, 128 | } 129 | HVAC_FAN_MODE_SETS = { 130 | "Auto/Low/Middle/High/Strong": { 131 | FAN_AUTO: "auto", 132 | FAN_LOW: "low", 133 | FAN_MEDIUM: "middle", 134 | FAN_HIGH: "high", 135 | FAN_TOP: "strong", 136 | } 137 | } 138 | HVAC_SWING_MODE_SETS = { 139 | "True/False": { 140 | SWING_ON: True, 141 | SWING_OFF: False, 142 | } 143 | } 144 | PRESET_SETS = { 145 | "Manual/Holiday/Program": { 146 | PRESET_AWAY: "Holiday", 147 | PRESET_HOME: "Program", 148 | PRESET_NONE: "Manual", 149 | }, 150 | "smart/holiday/hold": { 151 | PRESET_AWAY: "holiday", 152 | PRESET_HOME: "smart", 153 | PRESET_NONE: "hold", 154 | }, 155 | } 156 | 157 | TEMPERATURE_CELSIUS = "celsius" 158 | TEMPERATURE_FAHRENHEIT = "fahrenheit" 159 | DEFAULT_TEMPERATURE_UNIT = TEMPERATURE_CELSIUS 160 | DEFAULT_PRECISION = PRECISION_TENTHS 161 | DEFAULT_TEMPERATURE_STEP = PRECISION_HALVES 162 | # Empirically tested to work for AVATTO thermostat 163 | MODE_WAIT = 0.1 164 | 165 | 166 | def flow_schema(dps): 167 | """Return schema used in config flow.""" 168 | return { 169 | vol.Optional(CONF_TARGET_TEMPERATURE_DP): vol.In(dps), 170 | vol.Optional(CONF_CURRENT_TEMPERATURE_DP): vol.In(dps), 171 | vol.Optional(CONF_TEMPERATURE_STEP, default=PRECISION_WHOLE): vol.In( 172 | [PRECISION_WHOLE, PRECISION_HALVES, PRECISION_TENTHS] 173 | ), 174 | vol.Optional(CONF_TEMP_MIN, default=DEFAULT_MIN_TEMP): vol.Coerce(float), 175 | vol.Optional(CONF_TEMP_MAX, default=DEFAULT_MAX_TEMP): vol.Coerce(float), 176 | vol.Optional(CONF_MAX_TEMP_DP): vol.In(dps), 177 | vol.Optional(CONF_MIN_TEMP_DP): vol.In(dps), 178 | vol.Optional(CONF_PRECISION, default=PRECISION_WHOLE): vol.In( 179 | [PRECISION_WHOLE, PRECISION_HALVES, PRECISION_TENTHS] 180 | ), 181 | vol.Optional(CONF_HVAC_MODE_DP): vol.In(dps), 182 | vol.Optional(CONF_HVAC_MODE_SET): vol.In(list(HVAC_MODE_SETS.keys())), 183 | vol.Optional(CONF_HVAC_FAN_MODE_DP): vol.In(dps), 184 | vol.Optional(CONF_HVAC_FAN_MODE_SET): vol.In(list(HVAC_FAN_MODE_SETS.keys())), 185 | vol.Optional(CONF_HVAC_ACTION_DP): vol.In(dps), 186 | vol.Optional(CONF_HVAC_ACTION_SET): vol.In(list(HVAC_ACTION_SETS.keys())), 187 | vol.Optional(CONF_ECO_DP): vol.In(dps), 188 | vol.Optional(CONF_ECO_VALUE): str, 189 | vol.Optional(CONF_PRESET_DP): vol.In(dps), 190 | vol.Optional(CONF_PRESET_SET): vol.In(list(PRESET_SETS.keys())), 191 | vol.Optional(CONF_TEMPERATURE_UNIT): vol.In( 192 | [TEMPERATURE_CELSIUS, TEMPERATURE_FAHRENHEIT] 193 | ), 194 | vol.Optional(CONF_TARGET_PRECISION, default=PRECISION_WHOLE): vol.In( 195 | [PRECISION_WHOLE, PRECISION_HALVES, PRECISION_TENTHS] 196 | ), 197 | vol.Optional(CONF_HEURISTIC_ACTION): bool, 198 | } 199 | 200 | 201 | class LocaltuyaClimate(LocalTuyaEntity, ClimateEntity): 202 | """Tuya climate device.""" 203 | 204 | def __init__( 205 | self, 206 | device, 207 | config_entry, 208 | switchid, 209 | **kwargs, 210 | ): 211 | """Initialize a new LocaltuyaClimate.""" 212 | super().__init__(device, config_entry, switchid, _LOGGER, **kwargs) 213 | self._state = None 214 | self._target_temperature = None 215 | self._current_temperature = None 216 | self._hvac_mode = None 217 | self._fan_mode = None 218 | self._swing_mode = None 219 | self._preset_mode = None 220 | self._hvac_action = None 221 | self._precision = self._config.get(CONF_PRECISION, DEFAULT_PRECISION) 222 | self._target_precision = self._config.get( 223 | CONF_TARGET_PRECISION, self._precision 224 | ) 225 | self._conf_hvac_mode_dp = self._config.get(CONF_HVAC_MODE_DP) 226 | self._conf_hvac_mode_set = HVAC_MODE_SETS.get( 227 | self._config.get(CONF_HVAC_MODE_SET), {} 228 | ) 229 | self._conf_hvac_fan_mode_dp = self._config.get(CONF_HVAC_FAN_MODE_DP) 230 | self._conf_hvac_fan_mode_set = HVAC_FAN_MODE_SETS.get( 231 | self._config.get(CONF_HVAC_FAN_MODE_SET), {} 232 | ) 233 | self._conf_hvac_swing_mode_dp = self._config.get(CONF_HVAC_SWING_MODE_DP) 234 | self._conf_hvac_swing_mode_set = HVAC_SWING_MODE_SETS.get( 235 | self._config.get(CONF_HVAC_SWING_MODE_SET), {} 236 | ) 237 | self._conf_preset_dp = self._config.get(CONF_PRESET_DP) 238 | self._conf_preset_set = PRESET_SETS.get(self._config.get(CONF_PRESET_SET), {}) 239 | self._conf_hvac_action_dp = self._config.get(CONF_HVAC_ACTION_DP) 240 | self._conf_hvac_action_set = HVAC_ACTION_SETS.get( 241 | self._config.get(CONF_HVAC_ACTION_SET), {} 242 | ) 243 | self._conf_eco_dp = self._config.get(CONF_ECO_DP) 244 | self._conf_eco_value = self._config.get(CONF_ECO_VALUE, "ECO") 245 | self._has_presets = self.has_config(CONF_ECO_DP) or self.has_config( 246 | CONF_PRESET_DP 247 | ) 248 | _LOGGER.debug("Initialized climate [%s]", self.name) 249 | 250 | @property 251 | def supported_features(self): 252 | """Flag supported features.""" 253 | supported_features = ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF 254 | if self.has_config(CONF_TARGET_TEMPERATURE_DP): 255 | supported_features = supported_features | ClimateEntityFeature.TARGET_TEMPERATURE 256 | if self.has_config(CONF_MAX_TEMP_DP): 257 | supported_features = supported_features | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE 258 | if self.has_config(CONF_PRESET_DP) or self.has_config(CONF_ECO_DP): 259 | supported_features = supported_features | ClimateEntityFeature.PRESET_MODE 260 | if self.has_config(CONF_HVAC_FAN_MODE_DP) and self.has_config(CONF_HVAC_FAN_MODE_SET): 261 | supported_features = supported_features | ClimateEntityFeature.FAN_MODE 262 | if self.has_config(CONF_HVAC_SWING_MODE_DP): 263 | supported_features = supported_features | ClimateEntityFeature.SWING_MODE 264 | return supported_features 265 | 266 | @property 267 | def precision(self): 268 | """Return the precision of the system.""" 269 | return self._precision 270 | 271 | @property 272 | def target_precision(self): 273 | """Return the precision of the target.""" 274 | return self._target_precision 275 | 276 | @property 277 | def temperature_unit(self): 278 | """Return the unit of measurement used by the platform.""" 279 | if ( 280 | self._config.get(CONF_TEMPERATURE_UNIT, DEFAULT_TEMPERATURE_UNIT) 281 | == TEMPERATURE_FAHRENHEIT 282 | ): 283 | return UnitOfTemperature.FAHRENHEIT 284 | return UnitOfTemperature.CELSIUS 285 | 286 | @property 287 | def hvac_mode(self): 288 | """Return current operation ie. heat, cool, idle.""" 289 | return self._hvac_mode 290 | 291 | @property 292 | def hvac_modes(self): 293 | """Return the list of available operation modes.""" 294 | if not self.has_config(CONF_HVAC_MODE_DP): 295 | return None 296 | return list(self._conf_hvac_mode_set) + [HVACMode.OFF] 297 | 298 | @property 299 | def hvac_action(self): 300 | """Return the current running hvac operation if supported. 301 | 302 | Need to be one of CURRENT_HVAC_*. 303 | """ 304 | if self._config.get(CONF_HEURISTIC_ACTION, False): 305 | if self._hvac_mode == HVACMode.HEAT: 306 | if self._current_temperature < ( 307 | self._target_temperature - self._precision 308 | ): 309 | self._hvac_action = HVACAction.HEATING 310 | if self._current_temperature == ( 311 | self._target_temperature - self._precision 312 | ): 313 | if self._hvac_action == HVACAction.HEATING: 314 | self._hvac_action = HVACAction.HEATING 315 | if self._hvac_action == HVACAction.IDLE: 316 | self._hvac_action = HVACAction.IDLE 317 | if ( 318 | self._current_temperature + self._precision 319 | ) > self._target_temperature: 320 | self._hvac_action = HVACAction.IDLE 321 | return self._hvac_action 322 | return self._hvac_action 323 | 324 | @property 325 | def preset_mode(self): 326 | """Return current preset.""" 327 | return self._preset_mode 328 | 329 | @property 330 | def preset_modes(self): 331 | """Return the list of available presets modes.""" 332 | if not self._has_presets: 333 | return None 334 | presets = list(self._conf_preset_set) 335 | if self._conf_eco_dp: 336 | presets.append(PRESET_ECO) 337 | return presets 338 | 339 | @property 340 | def current_temperature(self): 341 | """Return the current temperature.""" 342 | return self._current_temperature 343 | 344 | @property 345 | def target_temperature(self): 346 | """Return the temperature we try to reach.""" 347 | return self._target_temperature 348 | 349 | @property 350 | def target_temperature_step(self): 351 | """Return the supported step of target temperature.""" 352 | return self._config.get(CONF_TEMPERATURE_STEP, DEFAULT_TEMPERATURE_STEP) 353 | 354 | @property 355 | def fan_mode(self): 356 | """Return the fan setting.""" 357 | return self._fan_mode 358 | 359 | @property 360 | def fan_modes(self): 361 | """Return the list of available fan modes.""" 362 | if not self.has_config(CONF_HVAC_FAN_MODE_DP): 363 | return None 364 | return list(self._conf_hvac_fan_mode_set) 365 | 366 | @property 367 | def swing_mode(self): 368 | """Return the swing setting.""" 369 | return self._swing_mode 370 | 371 | @property 372 | def swing_modes(self): 373 | """Return the list of available swing modes.""" 374 | if not self.has_config(CONF_HVAC_SWING_MODE_DP): 375 | return None 376 | return list(self._conf_hvac_swing_mode_set) 377 | 378 | async def async_set_temperature(self, **kwargs): 379 | """Set new target temperature.""" 380 | if ATTR_TEMPERATURE in kwargs and self.has_config(CONF_TARGET_TEMPERATURE_DP): 381 | temperature = round(kwargs[ATTR_TEMPERATURE] / self._target_precision) 382 | await self._device.set_dp( 383 | temperature, self._config[CONF_TARGET_TEMPERATURE_DP] 384 | ) 385 | 386 | async def async_set_fan_mode(self, fan_mode): 387 | """Set new target fan mode.""" 388 | if self._conf_hvac_fan_mode_dp is None: 389 | _LOGGER.error("Fan speed unsupported (no DP)") 390 | return 391 | if fan_mode not in self._conf_hvac_fan_mode_set: 392 | _LOGGER.error("Unsupported fan_mode: %s" % fan_mode) 393 | return 394 | await self._device.set_dp( 395 | self._conf_hvac_fan_mode_set[fan_mode], self._conf_hvac_fan_mode_dp 396 | ) 397 | 398 | async def async_set_hvac_mode(self, hvac_mode): 399 | """Set new target operation mode.""" 400 | if hvac_mode == HVACMode.OFF: 401 | await self._device.set_dp(False, self._dp_id) 402 | return 403 | if not self._state and self._conf_hvac_mode_dp != self._dp_id: 404 | await self._device.set_dp(True, self._dp_id) 405 | # Some thermostats need a small wait before sending another update 406 | await asyncio.sleep(MODE_WAIT) 407 | await self._device.set_dp( 408 | self._conf_hvac_mode_set[hvac_mode], self._conf_hvac_mode_dp 409 | ) 410 | 411 | async def async_set_swing_mode(self, swing_mode): 412 | """Set new target swing operation.""" 413 | if self._conf_hvac_swing_mode_dp is None: 414 | _LOGGER.error("Swing mode unsupported (no DP)") 415 | return 416 | if swing_mode not in self._conf_hvac_swing_mode_set: 417 | _LOGGER.error("Unsupported swing_mode: %s" % swing_mode) 418 | return 419 | await self._device.set_dp( 420 | self._conf_hvac_swing_mode_set[swing_mode], self._conf_hvac_swing_mode_dp 421 | ) 422 | 423 | async def async_turn_on(self) -> None: 424 | """Turn the entity on.""" 425 | await self._device.set_dp(True, self._dp_id) 426 | 427 | async def async_turn_off(self) -> None: 428 | """Turn the entity off.""" 429 | await self._device.set_dp(False, self._dp_id) 430 | 431 | async def async_set_preset_mode(self, preset_mode): 432 | """Set new target preset mode.""" 433 | if preset_mode == PRESET_ECO: 434 | await self._device.set_dp(self._conf_eco_value, self._conf_eco_dp) 435 | return 436 | await self._device.set_dp( 437 | self._conf_preset_set[preset_mode], self._conf_preset_dp 438 | ) 439 | 440 | @property 441 | def min_temp(self): 442 | """Return the minimum temperature.""" 443 | if self.has_config(CONF_MIN_TEMP_DP): 444 | return self.dps_conf(CONF_MIN_TEMP_DP) 445 | return self._config[CONF_TEMP_MIN] 446 | 447 | @property 448 | def max_temp(self): 449 | """Return the maximum temperature.""" 450 | if self.has_config(CONF_MAX_TEMP_DP): 451 | return self.dps_conf(CONF_MAX_TEMP_DP) 452 | return self._config[CONF_TEMP_MAX] 453 | 454 | def status_updated(self): 455 | """Device status was updated.""" 456 | self._state = self.dps(self._dp_id) 457 | 458 | if self.has_config(CONF_TARGET_TEMPERATURE_DP): 459 | self._target_temperature = ( 460 | self.dps_conf(CONF_TARGET_TEMPERATURE_DP) * self._target_precision 461 | ) 462 | 463 | if self.has_config(CONF_CURRENT_TEMPERATURE_DP): 464 | self._current_temperature = ( 465 | self.dps_conf(CONF_CURRENT_TEMPERATURE_DP) * self._precision 466 | ) 467 | 468 | if self._has_presets: 469 | if ( 470 | self.has_config(CONF_ECO_DP) 471 | and self.dps_conf(CONF_ECO_DP) == self._conf_eco_value 472 | ): 473 | self._preset_mode = PRESET_ECO 474 | else: 475 | for preset, value in self._conf_preset_set.items(): # todo remove 476 | if self.dps_conf(CONF_PRESET_DP) == value: 477 | self._preset_mode = preset 478 | break 479 | else: 480 | self._preset_mode = PRESET_NONE 481 | 482 | # Update the HVAC status 483 | if self.has_config(CONF_HVAC_MODE_DP): 484 | if not self._state: 485 | self._hvac_mode = HVACMode.OFF 486 | else: 487 | for mode, value in self._conf_hvac_mode_set.items(): 488 | if self.dps_conf(CONF_HVAC_MODE_DP) == value: 489 | self._hvac_mode = mode 490 | break 491 | else: 492 | # in case hvac mode and preset share the same dp 493 | self._hvac_mode = HVACMode.AUTO 494 | 495 | # Update the fan status 496 | if self.has_config(CONF_HVAC_FAN_MODE_DP): 497 | for mode, value in self._conf_hvac_fan_mode_set.items(): 498 | if self.dps_conf(CONF_HVAC_FAN_MODE_DP) == value: 499 | self._fan_mode = mode 500 | break 501 | else: 502 | # in case fan mode and preset share the same dp 503 | _LOGGER.debug("Unknown fan mode %s" % self.dps_conf(CONF_HVAC_FAN_MODE_DP)) 504 | self._fan_mode = FAN_AUTO 505 | 506 | # Update the swing status 507 | if self.has_config(CONF_HVAC_SWING_MODE_DP): 508 | for mode, value in self._conf_hvac_swing_mode_set.items(): 509 | if self.dps_conf(CONF_HVAC_SWING_MODE_DP) == value: 510 | self._swing_mode = mode 511 | break 512 | else: 513 | _LOGGER.debug("Unknown swing mode %s" % self.dps_conf(CONF_HVAC_SWING_MODE_DP)) 514 | self._swing_mode = SWING_OFF 515 | 516 | # Update the current action 517 | for action, value in self._conf_hvac_action_set.items(): 518 | if self.dps_conf(CONF_HVAC_ACTION_DP) == value: 519 | self._hvac_action = action 520 | 521 | 522 | async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaClimate, flow_schema) 523 | -------------------------------------------------------------------------------- /custom_components/localtuya/light.py: -------------------------------------------------------------------------------- 1 | """Platform to locally control Tuya-based light devices.""" 2 | import logging 3 | import textwrap 4 | from dataclasses import dataclass 5 | from functools import partial 6 | 7 | import homeassistant.util.color as color_util 8 | import voluptuous as vol 9 | from homeassistant.components.light import ( 10 | ATTR_BRIGHTNESS, 11 | ATTR_EFFECT, 12 | ATTR_HS_COLOR, 13 | DOMAIN, 14 | LightEntity, 15 | LightEntityFeature, 16 | ColorMode, 17 | ) 18 | from homeassistant.const import CONF_BRIGHTNESS, CONF_COLOR_TEMP, CONF_SCENE 19 | 20 | from .common import LocalTuyaEntity, async_setup_entry 21 | from .const import ( 22 | CONF_BRIGHTNESS_LOWER, 23 | CONF_BRIGHTNESS_UPPER, 24 | CONF_COLOR, 25 | CONF_COLOR_MODE, 26 | CONF_COLOR_TEMP_MAX_KELVIN, 27 | CONF_COLOR_TEMP_MIN_KELVIN, 28 | CONF_COLOR_TEMP_REVERSE, 29 | CONF_MUSIC_MODE, CONF_COLOR_MODE_SET, 30 | ) 31 | 32 | _LOGGER = logging.getLogger(__name__) 33 | 34 | DEFAULT_MIN_KELVIN = 2700 # MIRED 370 35 | DEFAULT_MAX_KELVIN = 6500 # MIRED 153 36 | 37 | DEFAULT_COLOR_TEMP_REVERSE = False 38 | 39 | DEFAULT_LOWER_BRIGHTNESS = 29 40 | DEFAULT_UPPER_BRIGHTNESS = 1000 41 | 42 | MODE_MANUAL = "manual" 43 | MODE_COLOR = "colour" 44 | MODE_MUSIC = "music" 45 | MODE_SCENE = "scene" 46 | MODE_WHITE = "white" 47 | 48 | SCENE_CUSTOM = "Custom" 49 | SCENE_MUSIC = "Music" 50 | 51 | MODES_SET = {"Colour, Music, Scene and White": 0, "Manual, Music, Scene and White": 1} 52 | 53 | SCENE_LIST_RGBW_1000 = { 54 | "Night": "000e0d0000000000000000c80000", 55 | "Read": "010e0d0000000000000003e801f4", 56 | "Meeting": "020e0d0000000000000003e803e8", 57 | "Leasure": "030e0d0000000000000001f401f4", 58 | "Soft": "04464602007803e803e800000000464602007803e8000a00000000", 59 | "Rainbow": "05464601000003e803e800000000464601007803e803e80000000046460100f003e803" 60 | + "e800000000", 61 | "Shine": "06464601000003e803e800000000464601007803e803e80000000046460100f003e803e8" 62 | + "00000000", 63 | "Beautiful": "07464602000003e803e800000000464602007803e803e80000000046460200f003e8" 64 | + "03e800000000464602003d03e803e80000000046460200ae03e803e800000000464602011303e80" 65 | + "3e800000000", 66 | } 67 | 68 | SCENE_LIST_RGBW_255 = { 69 | "Night": "bd76000168ffff", 70 | "Read": "fffcf70168ffff", 71 | "Meeting": "cf38000168ffff", 72 | "Leasure": "3855b40168ffff", 73 | "Scenario 1": "scene_1", 74 | "Scenario 2": "scene_2", 75 | "Scenario 3": "scene_3", 76 | "Scenario 4": "scene_4", 77 | } 78 | 79 | SCENE_LIST_RGB_1000 = { 80 | "Night": "000e0d00002e03e802cc00000000", 81 | "Read": "010e0d000084000003e800000000", 82 | "Working": "020e0d00001403e803e800000000", 83 | "Leisure": "030e0d0000e80383031c00000000", 84 | "Soft": "04464602007803e803e800000000464602007803e8000a00000000", 85 | "Colorful": "05464601000003e803e800000000464601007803e803e80000000046460100f003e80" 86 | + "3e800000000464601003d03e803e80000000046460100ae03e803e800000000464601011303e803" 87 | + "e800000000", 88 | "Dazzling": "06464601000003e803e800000000464601007803e803e80000000046460100f003e80" 89 | + "3e800000000", 90 | "Music": "07464602000003e803e800000000464602007803e803e80000000046460200f003e803e8" 91 | + "00000000464602003d03e803e80000000046460200ae03e803e800000000464602011303e803e80" 92 | + "0000000", 93 | } 94 | 95 | @dataclass(frozen=True) 96 | class Mode: 97 | color: str = MODE_COLOR 98 | music: str = MODE_MUSIC 99 | scene: str = MODE_SCENE 100 | white: str = MODE_WHITE 101 | 102 | def as_list(self) -> list: 103 | return [self.color, self.music, self.scene, self.white] 104 | 105 | def as_dict(self) -> dict[str, str]: 106 | default = {"Default": self.white} 107 | return {**default, "Mode Color": self.color, "Mode Scene": self.scene} 108 | 109 | MAP_MODE_SET = {0: Mode(), 1: Mode(color=MODE_MANUAL)} 110 | 111 | 112 | def map_range(value, from_lower, from_upper, to_lower, to_upper): 113 | """Map a value in one range to another.""" 114 | mapped = (value - from_lower) * (to_upper - to_lower) / ( 115 | from_upper - from_lower 116 | ) + to_lower 117 | return round(min(max(mapped, to_lower), to_upper)) 118 | 119 | 120 | def flow_schema(dps): 121 | """Return schema used in config flow.""" 122 | return { 123 | vol.Optional(CONF_BRIGHTNESS): vol.In(dps), 124 | vol.Optional(CONF_COLOR_TEMP): vol.In(dps), 125 | vol.Optional(CONF_BRIGHTNESS_LOWER, default=DEFAULT_LOWER_BRIGHTNESS): vol.All( 126 | vol.Coerce(int), vol.Range(min=0, max=10000) 127 | ), 128 | vol.Optional(CONF_BRIGHTNESS_UPPER, default=DEFAULT_UPPER_BRIGHTNESS): vol.All( 129 | vol.Coerce(int), vol.Range(min=0, max=10000) 130 | ), 131 | vol.Optional(CONF_COLOR_MODE): vol.In(dps), 132 | vol.Optional(CONF_COLOR): vol.In(dps), 133 | vol.Optional(CONF_COLOR_TEMP_MIN_KELVIN, default=DEFAULT_MIN_KELVIN): vol.All( 134 | vol.Coerce(int), vol.Range(min=1500, max=8000) 135 | ), 136 | vol.Optional(CONF_COLOR_TEMP_MAX_KELVIN, default=DEFAULT_MAX_KELVIN): vol.All( 137 | vol.Coerce(int), vol.Range(min=1500, max=8000) 138 | ), 139 | vol.Optional( 140 | CONF_COLOR_TEMP_REVERSE, 141 | default=DEFAULT_COLOR_TEMP_REVERSE, 142 | description={"suggested_value": DEFAULT_COLOR_TEMP_REVERSE}, 143 | ): bool, 144 | vol.Optional(CONF_SCENE): vol.In(dps), 145 | vol.Optional( 146 | CONF_MUSIC_MODE, default=False, description={"suggested_value": False} 147 | ): bool, 148 | } 149 | 150 | 151 | class LocaltuyaLight(LocalTuyaEntity, LightEntity): 152 | """Representation of a Tuya light.""" 153 | 154 | def __init__( 155 | self, 156 | device, 157 | config_entry, 158 | lightid, 159 | **kwargs, 160 | ): 161 | """Initialize the Tuya light.""" 162 | super().__init__(device, config_entry, lightid, _LOGGER, **kwargs) 163 | self._state = False 164 | self._brightness = None 165 | self._color_temp = None 166 | self._lower_brightness = self._config.get( 167 | CONF_BRIGHTNESS_LOWER, DEFAULT_LOWER_BRIGHTNESS 168 | ) 169 | self._upper_brightness = self._config.get( 170 | CONF_BRIGHTNESS_UPPER, DEFAULT_UPPER_BRIGHTNESS 171 | ) 172 | self._upper_color_temp = self._upper_brightness 173 | self._max_mired = color_util.color_temperature_kelvin_to_mired( 174 | self._config.get(CONF_COLOR_TEMP_MIN_KELVIN, DEFAULT_MIN_KELVIN) 175 | ) 176 | self._min_mired = color_util.color_temperature_kelvin_to_mired( 177 | self._config.get(CONF_COLOR_TEMP_MAX_KELVIN, DEFAULT_MAX_KELVIN) 178 | ) 179 | self._color_temp_reverse = self._config.get( 180 | CONF_COLOR_TEMP_REVERSE, DEFAULT_COLOR_TEMP_REVERSE 181 | ) 182 | self._modes = MAP_MODE_SET[int(self._config.get(CONF_COLOR_MODE_SET, 0))] 183 | self._hs = None 184 | self._effect = None 185 | self._effect_list = [] 186 | self._scenes = {} 187 | 188 | if self.has_config(CONF_SCENE): 189 | if self._config.get(CONF_SCENE) < 20: 190 | self._scenes = SCENE_LIST_RGBW_255 191 | elif self._config.get(CONF_BRIGHTNESS) is None: 192 | self._scenes = SCENE_LIST_RGB_1000 193 | else: 194 | self._scenes = SCENE_LIST_RGBW_1000 195 | self._effect_list = list(self._scenes.keys()) 196 | 197 | if self._config.get(CONF_MUSIC_MODE): 198 | self._effect_list.append(SCENE_MUSIC) 199 | 200 | @property 201 | def is_on(self): 202 | """Check if Tuya light is on.""" 203 | return self._state 204 | 205 | @property 206 | def brightness(self): 207 | """Return the brightness of the light.""" 208 | if self.is_color_mode or self.is_white_mode: 209 | return map_range( 210 | self._brightness, self._lower_brightness, self._upper_brightness, 0, 255 211 | ) 212 | return None 213 | 214 | @property 215 | def hs_color(self): 216 | """Return the hs color value.""" 217 | if self.is_color_mode: 218 | return self._hs 219 | if ( 220 | ColorMode.HS in self.supported_color_modes 221 | and not ColorMode.COLOR_TEMP in self.supported_color_modes 222 | ): 223 | return [0, 0] 224 | return None 225 | 226 | @property 227 | def color_temp(self): 228 | """Return the color_temp of the light.""" 229 | if self.has_config(CONF_COLOR_TEMP) and self.is_white_mode: 230 | color_temp_value = ( 231 | self._upper_color_temp - self._color_temp 232 | if self._color_temp_reverse 233 | else self._color_temp 234 | ) 235 | return int( 236 | self._max_mired 237 | - ( 238 | ((self._max_mired - self._min_mired) / self._upper_color_temp) 239 | * color_temp_value 240 | ) 241 | ) 242 | return None 243 | 244 | @property 245 | def min_mireds(self): 246 | """Return color temperature min mireds.""" 247 | return self._min_mired 248 | 249 | @property 250 | def max_mireds(self): 251 | """Return color temperature max mireds.""" 252 | return self._max_mired 253 | 254 | @property 255 | def effect(self): 256 | """Return the current effect for this light.""" 257 | if self.is_scene_mode or self.is_music_mode: 258 | return self._effect 259 | return None 260 | 261 | @property 262 | def effect_list(self): 263 | """Return the list of supported effects for this light.""" 264 | if self.is_scene_mode or self.is_music_mode: 265 | return self._effect 266 | elif (color_mode := self.__get_color_mode()) in self._scenes.values(): 267 | return self.__find_scene_by_scene_data(color_mode) 268 | return None 269 | 270 | @property 271 | def supported_color_modes(self) -> set[ColorMode] | set[str] | None: 272 | """Flag supported color modes.""" 273 | color_modes: set[ColorMode] = set() 274 | 275 | if self.has_config(CONF_COLOR_TEMP): 276 | color_modes.add(ColorMode.COLOR_TEMP) 277 | if self.has_config(CONF_COLOR): 278 | color_modes.add(ColorMode.HS) 279 | 280 | if not color_modes and self.has_config(CONF_BRIGHTNESS): 281 | return {ColorMode.BRIGHTNESS} 282 | 283 | if not color_modes: 284 | return {ColorMode.ONOFF} 285 | 286 | return color_modes 287 | 288 | @property 289 | def supported_features(self) -> LightEntityFeature: 290 | """Flag supported features.""" 291 | supports = LightEntityFeature(0) 292 | if self.has_config(CONF_SCENE) or self.has_config(CONF_MUSIC_MODE): 293 | supports |= LightEntityFeature.EFFECT 294 | return supports 295 | 296 | @property 297 | def color_mode(self) -> ColorMode: 298 | """Return the color_mode of the light.""" 299 | if len(self.supported_color_modes) == 1: 300 | return next(iter(self.supported_color_modes)) 301 | 302 | if self.is_color_mode: 303 | return ColorMode.HS 304 | if self.is_white_mode: 305 | return ColorMode.COLOR_TEMP 306 | if self._brightness: 307 | return ColorMode.BRIGHTNESS 308 | 309 | return ColorMode.ONOFF 310 | 311 | @property 312 | def is_white_mode(self): 313 | """Return true if the light is in white mode.""" 314 | color_mode = self.__get_color_mode() 315 | return color_mode is None or color_mode == self._modes.white 316 | 317 | @property 318 | def is_color_mode(self): 319 | """Return true if the light is in color mode.""" 320 | color_mode = self.__get_color_mode() 321 | return color_mode is not None and color_mode == self._modes.color 322 | 323 | @property 324 | def is_scene_mode(self): 325 | """Return true if the light is in scene mode.""" 326 | color_mode = self.__get_color_mode() 327 | return color_mode is not None and color_mode.startswith(self._modes.scene) 328 | 329 | @property 330 | def is_music_mode(self): 331 | """Return true if the light is in music mode.""" 332 | color_mode = self.__get_color_mode() 333 | return color_mode is not None and color_mode == self._modes.music 334 | 335 | def __is_color_rgb_encoded(self): 336 | return len(self.dps_conf(CONF_COLOR)) > 12 337 | 338 | def __find_scene_by_scene_data(self, data): 339 | return next( 340 | (item for item in self._effect_list if self._scenes.get(item) == data), 341 | SCENE_CUSTOM, 342 | ) 343 | 344 | def __get_color_mode(self): 345 | return ( 346 | self.dps_conf(CONF_COLOR_MODE) 347 | if self.has_config(CONF_COLOR_MODE) 348 | else self._modes.white 349 | ) 350 | 351 | async def async_turn_on(self, **kwargs): 352 | """Turn on or control the light.""" 353 | states = {} 354 | if not self.is_on: 355 | states[self._dp_id] = True 356 | features = self.supported_features 357 | brightness = None 358 | if ATTR_EFFECT in kwargs and (features & LightEntityFeature.EFFECT): 359 | scene = self._scenes.get(kwargs[ATTR_EFFECT]) 360 | if scene is not None: 361 | if scene.startswith(MODE_SCENE): 362 | states[self._config.get(CONF_COLOR_MODE)] = scene 363 | else: 364 | states[self._config.get(CONF_COLOR_MODE)] = MODE_SCENE 365 | states[self._config.get(CONF_SCENE)] = scene 366 | elif kwargs[ATTR_EFFECT] == SCENE_MUSIC: 367 | states[self._config.get(CONF_COLOR_MODE)] = MODE_MUSIC 368 | 369 | if ATTR_BRIGHTNESS in kwargs and ( 370 | ColorMode.BRIGHTNESS in self.supported_color_modes 371 | or self.has_config(CONF_BRIGHTNESS) 372 | or self.has_config(CONF_COLOR) 373 | ): 374 | brightness = map_range( 375 | int(kwargs[ATTR_BRIGHTNESS]), 376 | 0, 377 | 255, 378 | self._lower_brightness, 379 | self._upper_brightness, 380 | ) 381 | if self.is_white_mode: 382 | states[self._config.get(CONF_BRIGHTNESS)] = brightness 383 | else: 384 | if self.__is_color_rgb_encoded(): 385 | rgb = color_util.color_hsv_to_RGB( 386 | self._hs[0], 387 | self._hs[1], 388 | int(brightness * 100 / self._upper_brightness), 389 | ) 390 | color = "{:02x}{:02x}{:02x}{:04x}{:02x}{:02x}".format( 391 | round(rgb[0]), 392 | round(rgb[1]), 393 | round(rgb[2]), 394 | round(self._hs[0]), 395 | round(self._hs[1] * 255 / 100), 396 | brightness, 397 | ) 398 | else: 399 | color = "{:04x}{:04x}{:04x}".format( 400 | round(self._hs[0]), round(self._hs[1] * 10.0), brightness 401 | ) 402 | states[self._config.get(CONF_COLOR)] = color 403 | states[self._config.get(CONF_COLOR_MODE)] = MODE_COLOR 404 | 405 | if ATTR_HS_COLOR in kwargs and ColorMode.HS in self.supported_color_modes: 406 | if brightness is None: 407 | brightness = self._brightness 408 | hs = kwargs[ATTR_HS_COLOR] 409 | if hs[1] == 0 and self.has_config(CONF_BRIGHTNESS): 410 | states[self._config.get(CONF_BRIGHTNESS)] = brightness 411 | states[self._config.get(CONF_COLOR_MODE)] = MODE_WHITE 412 | else: 413 | if self.__is_color_rgb_encoded(): 414 | rgb = color_util.color_hsv_to_RGB( 415 | hs[0], hs[1], int(brightness * 100 / self._upper_brightness) 416 | ) 417 | color = "{:02x}{:02x}{:02x}{:04x}{:02x}{:02x}".format( 418 | round(rgb[0]), 419 | round(rgb[1]), 420 | round(rgb[2]), 421 | round(hs[0]), 422 | round(hs[1] * 255 / 100), 423 | brightness, 424 | ) 425 | else: 426 | color = "{:04x}{:04x}{:04x}".format( 427 | round(hs[0]), round(hs[1] * 10.0), brightness 428 | ) 429 | states[self._config.get(CONF_COLOR)] = color 430 | states[self._config.get(CONF_COLOR_MODE)] = MODE_COLOR 431 | 432 | if ColorMode.COLOR_TEMP in kwargs and ColorMode.COLOR_TEMP in self.supported_color_modes: 433 | if brightness is None: 434 | brightness = self._brightness 435 | mired = int(kwargs[ColorMode.COLOR_TEMP]) 436 | if self._color_temp_reverse: 437 | mired = self._max_mired - (mired - self._min_mired) 438 | if mired < self._min_mired: 439 | mired = self._min_mired 440 | elif mired > self._max_mired: 441 | mired = self._max_mired 442 | color_temp = int( 443 | self._upper_color_temp 444 | - (self._upper_color_temp / (self._max_mired - self._min_mired)) 445 | * (mired - self._min_mired) 446 | ) 447 | states[self._config.get(CONF_COLOR_MODE)] = MODE_WHITE 448 | states[self._config.get(CONF_BRIGHTNESS)] = brightness 449 | states[self._config.get(CONF_COLOR_TEMP)] = color_temp 450 | await self._device.set_dps(states) 451 | 452 | async def async_turn_off(self, **kwargs): 453 | """Turn Tuya light off.""" 454 | await self._device.set_dp(False, self._dp_id) 455 | 456 | def status_updated(self): 457 | """Device status was updated.""" 458 | self._state = self.dps(self._dp_id) 459 | supported = self.supported_features 460 | self._effect = None 461 | 462 | if (ColorMode.BRIGHTNESS in self.supported_color_modes 463 | or self.has_config(CONF_BRIGHTNESS) 464 | or self.has_config(CONF_COLOR) 465 | ): 466 | self._brightness = self.dps_conf(CONF_BRIGHTNESS) 467 | 468 | if ColorMode.HS in self.supported_color_modes: 469 | color = self.dps_conf(CONF_COLOR) 470 | if color is not None and not self.is_white_mode: 471 | if self.__is_color_rgb_encoded(): 472 | hue = int(color[6:10], 16) 473 | sat = int(color[10:12], 16) 474 | value = int(color[12:14], 16) 475 | self._hs = [hue, (sat * 100 / 255)] 476 | self._brightness = value 477 | else: 478 | hue, sat, value = [ 479 | int(value, 16) for value in textwrap.wrap(color, 4) 480 | ] 481 | self._hs = [hue, sat / 10.0] 482 | self._brightness = value 483 | 484 | if ColorMode.COLOR_TEMP in self.supported_color_modes: 485 | self._color_temp = self.dps_conf(CONF_COLOR_TEMP) 486 | 487 | if self.is_scene_mode and supported & LightEntityFeature.EFFECT: 488 | if self.dps_conf(CONF_COLOR_MODE) != MODE_SCENE: 489 | self._effect = self.__find_scene_by_scene_data( 490 | self.dps_conf(CONF_COLOR_MODE) 491 | ) 492 | else: 493 | self._effect = self.__find_scene_by_scene_data( 494 | self.dps_conf(CONF_SCENE) 495 | ) 496 | if self._effect == SCENE_CUSTOM: 497 | if SCENE_CUSTOM not in self._effect_list: 498 | self._effect_list.append(SCENE_CUSTOM) 499 | elif SCENE_CUSTOM in self._effect_list: 500 | self._effect_list.remove(SCENE_CUSTOM) 501 | 502 | if self.is_music_mode and supported & LightEntityFeature.EFFECT: 503 | self._effect = SCENE_MUSIC 504 | 505 | 506 | async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaLight, flow_schema) 507 | --------------------------------------------------------------------------------