├── .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://github.com/rospogrigio/localtuya-homeassistant/releases)
2 | [](https://github.com/custom-components/hacs)
3 | [](https://github.com/rospogrigio)
4 |
5 | 
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 | 
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 | 
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 | 
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 | 
74 |
75 |
76 | # Integration Configuration menu
77 |
78 | The configuration menu is the following:
79 |
80 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 |
196 |
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 | 
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 | 
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 | 
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 | 
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 | 
70 |
71 |
72 | # Integration Configuration menu
73 |
74 | The configuration menu is the following:
75 |
76 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 |
222 |
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 |
--------------------------------------------------------------------------------