Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Tuya v2 integration New Update #53510

Merged
merged 60 commits into from
Sep 7, 2021
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
059c431
[init] tuya v2 stable version
tsutsuku Jul 26, 2021
c82734e
[add] translations
tsutsuku Jul 26, 2021
fbd3279
[update] update code by following the review suggestion.
tsutsuku Jul 27, 2021
1752dec
[update] change _LOGGER.info to debug
tsutsuku Jul 28, 2021
3b5531c
udpate: remove all but switch platform
dengweijun Aug 2, 2021
c147724
[udpate] modify references files .coveragec about tuya
dengweijun Aug 2, 2021
5600d0a
[update] fix warning
dengweijun Aug 3, 2021
8f29c18
[update] fix warning
dengweijun Aug 4, 2021
1828a47
[update] 'E722 do not use bare except'
dengweijun Aug 4, 2021
fe8415d
fix: F401 'sys' imported but unused
dengweijun Aug 4, 2021
aea763d
fix warning : Catching too general exception
dengweijun Aug 5, 2021
7ca4c64
fix: check isort
dengweijun Aug 6, 2021
f9ec990
fix: check isort
dengweijun Aug 6, 2021
791c9c0
Merge branch 'tuya-v2' of https://github.com/TuyaInc/core into tuya-v2
dengweijun Aug 6, 2021
7b4badc
code review modify
Aug 19, 2021
2421fbf
send_command should be async
Aug 20, 2021
eaebd9f
Merge pull request #1 from METISU/tuya-v2
tsutsuku Aug 23, 2021
019e0e7
remove line.
zlinoliver Aug 23, 2021
832020a
Delete platform dic, use TUYA_SUPPORT_HA_TYPE to maintain
Aug 25, 2021
11d6f42
codeReview modify
Aug 26, 2021
fb55817
convert TUYA_HA_DEVICES into a dictionary and user device_id as key t…
Aug 27, 2021
f3caf31
move DeviceListener outside
Aug 27, 2021
0ac70f7
code review
Aug 27, 2021
d5b65c5
delete log
Aug 27, 2021
659b7d0
correct the code rules
Aug 27, 2021
e059a13
Don't move directly to another steps form. A step is only allowed to …
Aug 28, 2021
69f2778
We shouldn't log the user input since it contains credentials.
Aug 28, 2021
a1a05ca
store the URL in a string constant
Aug 28, 2021
2c26c27
code Specification
Aug 28, 2021
13c01af
Decoupling step login, user input cannot be empty
Aug 28, 2021
2c9ca64
These files don't matter. They will be automatically deleted when the…
Aug 30, 2021
c0dd73d
It's not the responsibility of integrations to encrypt data
Aug 30, 2021
6f4fcf9
This shouldn't be needed
Aug 30, 2021
b2e9ae2
Merge pull request #2 from METISU/tuya-v2
tsutsuku Aug 31, 2021
1d8fb11
Don't store the entity in this shared container. Just store the devic…
Aug 31, 2021
2619344
Include the device id in this signal to let the dispatch helper look …
Aug 31, 2021
18e4499
Delete redundant comments
Sep 1, 2021
ee3d9cc
All the data stored for the domain in hass.data should be indexed fir…
Sep 1, 2021
f936f83
Please don't move to another step's form directly. If we need to go t…
Sep 1, 2021
934fbbf
Code Specification
Sep 1, 2021
9f2b768
Store the device id in a set in hass.data[DOMAIN][TUYA_HA_DEVICES] wh…
Sep 1, 2021
4e87cc3
Merge pull request #3 from METISU/tuya-v2
tsutsuku Sep 2, 2021
0c7d8b6
revert this change
Sep 2, 2021
d11caa1
The current Tuya integration doesn't have a YAML configuration. We sh…
Sep 2, 2021
9e5b109
sort this below __init__.py
Sep 2, 2021
3db3d04
change variable names that have double underscore as prefix
Sep 2, 2021
d89ef76
code review
Sep 2, 2021
0a33484
code review
Sep 2, 2021
8d1131e
unit testing adjustment
Sep 3, 2021
3d11a65
Merge pull request #4 from METISU/tuya-v2
tsutsuku Sep 3, 2021
3c04711
code review
Sep 6, 2021
31e4362
ci build errors
Sep 6, 2021
4b986f6
Merge pull request #5 from METISU/tuya-v2
tsutsuku Sep 6, 2021
5ce76c9
unit test optimization and crash protection
Sep 6, 2021
c86ed96
delete blank lines
Sep 6, 2021
5dbee34
Merge pull request #6 from METISU/tuya-v2
tsutsuku Sep 7, 2021
3cc9202
ci build errors
Sep 7, 2021
89b1c37
Merge pull request #7 from METISU/tuya-v2
zlinoliver Sep 7, 2021
b6e8378
code review
Sep 7, 2021
3342e5a
Merge pull request #8 from METISU/tuya-v2
zlinoliver Sep 7, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -1094,6 +1094,8 @@ omit =
homeassistant/components/transmission/const.py
homeassistant/components/transmission/errors.py
homeassistant/components/travisci/sensor.py
homeassistant/components/tuya/base.py
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved
homeassistant/components/tuya/aes_cbc.py
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved
homeassistant/components/tuya/__init__.py
homeassistant/components/tuya/climate.py
homeassistant/components/tuya/const.py
Expand Down
2 changes: 1 addition & 1 deletion CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -521,7 +521,7 @@ homeassistant/components/trafikverket_train/* @endor-force
homeassistant/components/trafikverket_weatherstation/* @endor-force
homeassistant/components/transmission/* @engrbm87 @JPHutchins
homeassistant/components/tts/* @pvizeli
homeassistant/components/tuya/* @ollo69
homeassistant/components/tuya/* @Tuya
homeassistant/components/twentemilieu/* @frenck
homeassistant/components/twinkly/* @dr1rrb
homeassistant/components/ubus/* @noltari
Expand Down
290 changes: 290 additions & 0 deletions homeassistant/components/tuya/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,290 @@
#!/usr/bin/env python3
"""Support for Tuya Smart devices."""

import itertools
import json
import logging
from typing import Any

from tuya_iot import (
ProjectType,
TuyaDevice,
TuyaDeviceListener,
TuyaDeviceManager,
TuyaHomeManager,
TuyaOpenAPI,
TuyaOpenMQ,
tuya_logger,
)
import voluptuous as vol

from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_send

from .aes_cbc import AES_ACCOUNT_KEY, KEY_KEY, XOR_KEY, AesCBC as Aes
from .const import (
CONF_ACCESS_ID,
CONF_ACCESS_SECRET,
CONF_APP_TYPE,
CONF_COUNTRY_CODE,
CONF_ENDPOINT,
CONF_PASSWORD,
CONF_PROJECT_TYPE,
CONF_USERNAME,
DOMAIN,
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved
TUYA_DEVICE_MANAGER,
TUYA_DISCOVERY_NEW,
TUYA_HA_DEVICES,
TUYA_HA_TUYA_MAP,
TUYA_HOME_MANAGER,
TUYA_MQTT_LISTENER,
TUYA_SETUP_PLATFORM,
TUYA_SUPPORT_HA_TYPE,
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved
)

_LOGGER = logging.getLogger(__name__)

CONFIG_SCHEMA = vol.Schema(
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved
vol.All(
cv.deprecated(DOMAIN),
{
DOMAIN: vol.Schema(
{
vol.Required(CONF_PROJECT_TYPE): int,
vol.Required(CONF_ENDPOINT): cv.string,
vol.Required(CONF_ACCESS_ID): cv.string,
vol.Required(CONF_ACCESS_SECRET): cv.string,
CONF_USERNAME: cv.string,
CONF_PASSWORD: cv.string,
CONF_COUNTRY_CODE: cv.string,
CONF_APP_TYPE: cv.string,
}
)
},
),
extra=vol.ALLOW_EXTRA,
)

# decrypt or encrypt entry info

MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved

def entry_decrypt(hass: HomeAssistant, entry: ConfigEntry, init_entry_data):
"""Decrypt code from config entry."""
aes = Aes()
# decrypt the new account info
if XOR_KEY in init_entry_data:
_LOGGER.info("tuya.__init__.exist_xor_cache-->True")
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved
key_iv = aes.xor_decrypt(init_entry_data[XOR_KEY], init_entry_data[KEY_KEY])
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved
cbc_key = key_iv[0:16]
cbc_iv = key_iv[16:32]
decrpyt_str = aes.cbc_decrypt(cbc_key, cbc_iv, init_entry_data[AES_ACCOUNT_KEY])
# _LOGGER.info(f"tuya.__init__.exist_xor_cache:::decrpyt_str-->{decrpyt_str}")
entry_data = aes.json_to_dict(decrpyt_str)
else:
# if not exist xor cache, use old account info
_LOGGER.info("tuya.__init__.exist_xor_cache-->False")
entry_data = init_entry_data
cbc_key = aes.random_16()
cbc_iv = aes.random_16()
access_id = init_entry_data[CONF_ACCESS_ID]
access_id_entry = aes.cbc_encrypt(cbc_key, cbc_iv, access_id)
c = cbc_key + cbc_iv
c_xor_entry = aes.xor_encrypt(c, access_id_entry)
# account info encrypted with AES-CBC
user_input_encrpt = aes.cbc_encrypt(
cbc_key, cbc_iv, json.dumps(init_entry_data)
)
# update old account info
hass.config_entries.async_update_entry(
entry,
data={
AES_ACCOUNT_KEY: user_input_encrpt,
XOR_KEY: c_xor_entry,
KEY_KEY: access_id_entry,
},
)
return entry_data


async def _init_tuya_sdk(hass: HomeAssistant, entry: ConfigEntry) -> bool:
init_entry_data = entry.data
# decrypt or encrypt entry info
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved
entry_data = entry_decrypt(hass, entry, init_entry_data)
project_type = ProjectType(entry_data[CONF_PROJECT_TYPE])
api = TuyaOpenAPI(
entry_data[CONF_ENDPOINT],
entry_data[CONF_ACCESS_ID],
entry_data[CONF_ACCESS_SECRET],
project_type,
)

api.set_dev_channel("hass")

response = (
await hass.async_add_executor_job(
api.login, entry_data[CONF_USERNAME], entry_data[CONF_PASSWORD]
)
if project_type == ProjectType.INDUSTY_SOLUTIONS
else await hass.async_add_executor_job(
api.login,
entry_data[CONF_USERNAME],
entry_data[CONF_PASSWORD],
entry_data[CONF_COUNTRY_CODE],
entry_data[CONF_APP_TYPE],
)
)
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved
if response.get("success", False) is False:
_LOGGER.error(f"Tuya login error response: {response}")
return False
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved

tuya_mq = TuyaOpenMQ(api)
tuya_mq.start()
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved

device_manager = TuyaDeviceManager(api, tuya_mq)

# Get device list
home_manager = TuyaHomeManager(api, tuya_mq, device_manager)
await hass.async_add_executor_job(home_manager.update_device_cache)
hass.data[DOMAIN][TUYA_HOME_MANAGER] = home_manager
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved

class DeviceListener(TuyaDeviceListener):
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved
"""Device Update Listener."""

def update_device(self, device: TuyaDevice):
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved
for ha_device in hass.data[DOMAIN][TUYA_HA_DEVICES]:
if ha_device.tuya_device.id == device.id:
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved
_LOGGER.debug(f"_update-->{self};->>{ha_device.tuya_device.status}")
ha_device.schedule_update_ha_state()
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved

def add_device(self, device: TuyaDevice):
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved

device_add = False

_LOGGER.info(
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved
f"""add device category->{device.category}; keys->,
{hass.data[DOMAIN][TUYA_HA_TUYA_MAP].keys()}"""
)
if device.category in itertools.chain(
*hass.data[DOMAIN][TUYA_HA_TUYA_MAP].values()
):
ha_tuya_map = hass.data[DOMAIN][TUYA_HA_TUYA_MAP]

remove_hass_device(hass, device.id)

for key, tuya_list in ha_tuya_map.items():
if device.category in tuya_list:
device_add = True
async_dispatcher_send(
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved
hass, TUYA_DISCOVERY_NEW.format(key), [device.id]
)

if device_add:
device_manager = hass.data[DOMAIN][TUYA_DEVICE_MANAGER]
device_manager.mq.stop()
tuya_mq = TuyaOpenMQ(device_manager.api)
tuya_mq.start()

device_manager.mq = tuya_mq
tuya_mq.add_message_listener(device_manager._on_message)

def remove_device(self, device_id: str):
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved
_LOGGER.info(f"tuya remove device:{device_id}")
remove_hass_device(hass, device_id)

__listener = DeviceListener()
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved
hass.data[DOMAIN][TUYA_MQTT_LISTENER] = __listener
device_manager.add_device_listener(__listener)
hass.data[DOMAIN][TUYA_DEVICE_MANAGER] = device_manager

# Clean up device entities
await cleanup_device_registry(hass)

_LOGGER.info(f"init support type->{TUYA_SUPPORT_HA_TYPE}")

for platform in TUYA_SUPPORT_HA_TYPE:
_LOGGER.info(f"tuya async platform-->{platform}")
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, platform)
)
hass.data[DOMAIN][TUYA_SETUP_PLATFORM].add(platform)
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved

return True


async def cleanup_device_registry(hass: HomeAssistant):
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved
"""Remove deleted device registry entry if there are no remaining entities."""

device_registry = hass.helpers.device_registry.async_get(hass)
device_manager = hass.data[DOMAIN][TUYA_DEVICE_MANAGER]

for dev_id, device_entity in list(device_registry.devices.items()):
for item in device_entity.identifiers:
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved
if DOMAIN == item[0] and item[1] not in device_manager.device_map.keys():
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved
device_registry.async_remove_device(dev_id)
break


def remove_hass_device(hass: HomeAssistant, device_id: str):
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved
"""Remove device from hass cache."""
device_registry = hass.helpers.device_registry.async_get(hass)
entity_registry = hass.helpers.entity_registry.async_get(hass)
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved
for entity in list(entity_registry.entities.values()):
if entity.unique_id.startswith(f"ty{device_id}"):
entity_registry.async_remove(entity.entity_id)
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved
if device_registry.async_get(entity.device_id):
device_registry.async_remove_device(entity.device_id)


async def async_setup(hass, config):
"""Set up the Tuya integration."""
tuya_logger.setLevel(_LOGGER.level)
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved
conf = config.get(DOMAIN)

_LOGGER.info(f"Tuya async setup conf {conf}")
if conf is not None:

async def flow_init() -> Any:
try:
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=conf
)
except Exception as inst:
_LOGGER.error(inst.args)
_LOGGER.info("Tuya async setup flow_init")
return result

hass.async_create_task(flow_init())

return True


async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved
"""Unloading the Tuya platforms."""
_LOGGER.info("integration unload")
unload = await hass.config_entries.async_unload_platforms(
entry, hass.data[DOMAIN]["setup_platform"]
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved
)
if unload:
__device_manager = hass.data[DOMAIN][TUYA_DEVICE_MANAGER]
__device_manager.mq.stop()
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved
__device_manager.remove_device_listener(hass.data[DOMAIN][TUYA_MQTT_LISTENER])

hass.data.pop(DOMAIN)

return unload


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved
"""Async setup hass config entry."""
_LOGGER.info(f"tuya.__init__.async_setup_entry-->{entry.data}")

hass.data[DOMAIN] = {TUYA_HA_TUYA_MAP: {}, TUYA_HA_DEVICES: []}
hass.data[DOMAIN][TUYA_SETUP_PLATFORM] = set()
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved

success = await _init_tuya_sdk(hass, entry)
if not success:
return False

return True
90 changes: 90 additions & 0 deletions homeassistant/components/tuya/aes_cbc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""AES-CBC encryption and decryption for account info."""
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved

import base64 as b64
from binascii import a2b_hex, b2a_hex
import json
import random

from Crypto.Cipher import AES

AES_ACCOUNT_KEY = "o0o0o0"
XOR_KEY = "00oo00"
KEY_KEY = "oo00oo"


class AesCBC:
"""AES helper."""

def random_16(self):
"""Return random 16."""
str = ""
return str.join(
random.choice("abcdefghijklmnopqrstuvwxyz!@#$%^&*1234567890")
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved
for i in range(16)
)

def add_to_16(self, text):
"""Add to 16."""
if len(text.encode("utf-8")) % 16:
add = 16 - (len(text.encode("utf-8")) % 16)
else:
add = 0
text = text + ("\0" * add)
return text.encode("utf-8")

def cbc_encrypt(self, key, iv, text):
"""Cbc encrypt."""
key = key.encode("utf-8")
mode = AES.MODE_CBC
iv = bytes(iv, encoding="utf8")
text = self.add_to_16(text)
cryptos = AES.new(key, mode, iv)
cipher_text = cryptos.encrypt(text)
return str(b2a_hex(cipher_text), encoding="utf-8")

def cbc_decrypt(self, key, iv, text):
"""Cbc decrypt."""
key = key.encode("utf-8")
iv = bytes(iv, encoding="utf8")
mode = AES.MODE_CBC
cryptos = AES.new(key, mode, iv)
plain_text = cryptos.decrypt(a2b_hex(text))
return bytes.decode(plain_text).rstrip("\0")

def xor_encrypt(self, data, key):
"""Xor encrypt."""
lkey = len(key)
secret = []
num = 0
for each in data:
if num >= lkey:
num = num % lkey
secret.append(chr(ord(each) ^ ord(key[num])))
num += 1
return b64.b64encode("".join(secret).encode()).decode()

def xor_decrypt(self, secret, key):
"""Xor decrypt."""
tips = b64.b64decode(secret.encode()).decode()
lkey = len(key)
secret = []
num = 0
for each in tips:
if num >= lkey:
num = num % lkey
secret.append(chr(ord(each) ^ ord(key[num])))
num += 1
return "".join(secret)

def json_to_dict(self, json_str):
"""Json to dict."""
return json.loads(json_str)

def b64_encrypt(self, text):
"""Base64 encrypt."""
return b64.b64encode(text.encode()).decode()

def b64_decrypt(self, text):
"""Base64 decrypt."""
return b64.b64decode(text).decode()
Loading