Merge branch 'rospogrigio:master' into master

This commit is contained in:
Ovidiu D. Nițan
2023-07-17 15:43:12 +03:00
committed by GitHub
12 changed files with 144 additions and 111 deletions

View File

@@ -1,26 +0,0 @@
name: "Validation And Formatting"
on:
push:
pull_request:
schedule:
- cron: '0 0 * * *'
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
name: Download repo
with:
fetch-depth: 0
- uses: actions/setup-python@v2
name: Setup Python
- uses: actions/cache@v2
name: Cache
with:
path: |
~/.cache/pip
key: custom-component-ci
- uses: KTibow/ha-blueprint@stable
name: CI
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,6 +1,8 @@
name: Tox PR CI name: Tox PR CI
on: [pull_request] on:
pull_request:
workflow_dispatch:
jobs: jobs:
build: build:
@@ -14,8 +16,7 @@ jobs:
platform: platform:
- ubuntu-latest - ubuntu-latest
python-version: python-version:
- 3.7 - 3.9
- 3.8
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}

20
.github/workflows/validate.yml vendored Normal file
View File

@@ -0,0 +1,20 @@
name: HACS Validate
on:
push:
pull_request:
schedule:
- cron: "0 0 * * *"
workflow_dispatch:
jobs:
validate-hacs:
runs-on: "ubuntu-latest"
steps:
- uses: "actions/checkout@v3"
- name: HACS validation
uses: "hacs/action@main"
with:
category: "integration"
- name: Hassfest validation
uses: home-assistant/actions/hassfest@master

View File

@@ -1,5 +1,6 @@
"""Code shared between all platforms.""" """Code shared between all platforms."""
import asyncio import asyncio
import json.decoder
import logging import logging
import time import time
from datetime import timedelta from datetime import timedelta
@@ -176,12 +177,13 @@ class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger):
def async_connect(self): def async_connect(self):
"""Connect to device if not already connected.""" """Connect to device if not already connected."""
# self.info("async_connect: %d %r %r", self._is_closing, self._connect_task, self._interface)
if not self._is_closing and self._connect_task is None and not self._interface: if not self._is_closing and self._connect_task is None and not self._interface:
self._connect_task = asyncio.create_task(self._make_connection()) self._connect_task = asyncio.create_task(self._make_connection())
async def _make_connection(self): async def _make_connection(self):
"""Subscribe localtuya entity events.""" """Subscribe localtuya entity events."""
self.debug("Connecting to %s", self._dev_config_entry[CONF_HOST]) self.info("Trying to connect to %s...", self._dev_config_entry[CONF_HOST])
try: try:
self._interface = await pytuya.connect( self._interface = await pytuya.connect(
@@ -193,13 +195,16 @@ class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger):
self, self,
) )
self._interface.add_dps_to_request(self.dps_to_request) self._interface.add_dps_to_request(self.dps_to_request)
except Exception: # pylint: disable=broad-except except Exception as ex: # pylint: disable=broad-except
self.exception(f"Connect to {self._dev_config_entry[CONF_HOST]} failed") self.warning(
f"Failed to connect to {self._dev_config_entry[CONF_HOST]}: %s", ex
)
if self._interface is not None: if self._interface is not None:
await self._interface.close() await self._interface.close()
self._interface = None self._interface = None
if self._interface is not None: if self._interface is not None:
try:
try: try:
self.debug("Retrieving initial state") self.debug("Retrieving initial state")
status = await self._interface.status() status = await self._interface.status()
@@ -209,8 +214,7 @@ class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger):
self._interface.start_heartbeat() self._interface.start_heartbeat()
self.status_updated(status) self.status_updated(status)
except Exception as ex: # pylint: disable=broad-except except Exception as ex:
try:
if (self._default_reset_dpids is not None) and ( if (self._default_reset_dpids is not None) and (
len(self._default_reset_dpids) > 0 len(self._default_reset_dpids) > 0
): ):
@@ -228,21 +232,14 @@ class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger):
self._interface.start_heartbeat() self._interface.start_heartbeat()
self.status_updated(status) self.status_updated(status)
else:
except UnicodeDecodeError as e: # pylint: disable=broad-except self.error("Initial state update failed, giving up: %r", ex)
self.exception(
f"Connect to {self._dev_config_entry[CONF_HOST]} failed: %s",
type(e),
)
if self._interface is not None: if self._interface is not None:
await self._interface.close() await self._interface.close()
self._interface = None self._interface = None
except Exception as e: # pylint: disable=broad-except except (UnicodeDecodeError, json.decoder.JSONDecodeError) as ex:
self.exception( self.warning("Initial state update failed (%s), trying key update", ex)
f"Connect to {self._dev_config_entry[CONF_HOST]} failed"
)
if "json.decode" in str(type(e)):
await self.update_local_key() await self.update_local_key()
if self._interface is not None: if self._interface is not None:
@@ -270,14 +267,16 @@ class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger):
if ( if (
CONF_SCAN_INTERVAL in self._dev_config_entry CONF_SCAN_INTERVAL in self._dev_config_entry
and self._dev_config_entry[CONF_SCAN_INTERVAL] > 0 and int(self._dev_config_entry[CONF_SCAN_INTERVAL]) > 0
): ):
self._unsub_interval = async_track_time_interval( self._unsub_interval = async_track_time_interval(
self._hass, self._hass,
self._async_refresh, self._async_refresh,
timedelta(seconds=self._dev_config_entry[CONF_SCAN_INTERVAL]), timedelta(seconds=int(self._dev_config_entry[CONF_SCAN_INTERVAL])),
) )
self.info(f"Successfully connected to {self._dev_config_entry[CONF_HOST]}")
self._connect_task = None self._connect_task = None
async def update_local_key(self): async def update_local_key(self):
@@ -310,7 +309,7 @@ class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger):
await self._interface.close() await self._interface.close()
if self._disconnect_task is not None: if self._disconnect_task is not None:
self._disconnect_task() self._disconnect_task()
self.debug( self.info(
"Closed connection with device %s.", "Closed connection with device %s.",
self._dev_config_entry[CONF_FRIENDLY_NAME], self._dev_config_entry[CONF_FRIENDLY_NAME],
) )
@@ -358,7 +357,11 @@ class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger):
self._unsub_interval() self._unsub_interval()
self._unsub_interval = None self._unsub_interval = None
self._interface = None self._interface = None
self.debug("Disconnected - waiting for discovery broadcast")
if self._connect_task is not None:
self._connect_task.cancel()
self._connect_task = None
self.warning("Disconnected - waiting for discovery broadcast")
class LocalTuyaEntity(RestoreEntity, pytuya.ContextualLogger): class LocalTuyaEntity(RestoreEntity, pytuya.ContextualLogger):

View File

@@ -43,6 +43,7 @@ from .const import (
CONF_RESET_DPIDS, CONF_RESET_DPIDS,
CONF_SETUP_CLOUD, CONF_SETUP_CLOUD,
CONF_USER_ID, CONF_USER_ID,
CONF_ENABLE_ADD_ENTITIES,
DATA_CLOUD, DATA_CLOUD,
DATA_DISCOVERY, DATA_DISCOVERY,
DOMAIN, DOMAIN,
@@ -140,12 +141,13 @@ def options_schema(entities):
["3.1", "3.2", "3.3", "3.4"] ["3.1", "3.2", "3.3", "3.4"]
), ),
vol.Required(CONF_ENABLE_DEBUG, default=False): bool, vol.Required(CONF_ENABLE_DEBUG, default=False): bool,
vol.Optional(CONF_SCAN_INTERVAL): cv.string, vol.Optional(CONF_SCAN_INTERVAL): int,
vol.Optional(CONF_MANUAL_DPS): cv.string, vol.Optional(CONF_MANUAL_DPS): cv.string,
vol.Optional(CONF_RESET_DPIDS): cv.string, vol.Optional(CONF_RESET_DPIDS): cv.string,
vol.Required( vol.Required(
CONF_ENTITIES, description={"suggested_value": entity_names} CONF_ENTITIES, description={"suggested_value": entity_names}
): cv.multi_select(entity_names), ): cv.multi_select(entity_names),
vol.Required(CONF_ENABLE_ADD_ENTITIES, default=False): bool,
} }
) )
@@ -256,20 +258,21 @@ async def validate_input(hass: core.HomeAssistant, data):
) )
try: try:
detected_dps = await interface.detect_available_dps() detected_dps = await interface.detect_available_dps()
except Exception: # pylint: disable=broad-except except Exception as ex:
try: try:
_LOGGER.debug("Initial state update failed, trying reset command") _LOGGER.debug(
"Initial state update failed (%s), trying reset command", ex
)
if len(reset_ids) > 0: if len(reset_ids) > 0:
await interface.reset(reset_ids) await interface.reset(reset_ids)
detected_dps = await interface.detect_available_dps() detected_dps = await interface.detect_available_dps()
except Exception: # pylint: disable=broad-except except Exception as ex:
_LOGGER.debug("No DPS able to be detected") _LOGGER.debug("No DPS able to be detected: %s", ex)
detected_dps = {} detected_dps = {}
# if manual DPs are set, merge these. # if manual DPs are set, merge these.
_LOGGER.debug("Detected DPS: %s", detected_dps) _LOGGER.debug("Detected DPS: %s", detected_dps)
if CONF_MANUAL_DPS in data: if CONF_MANUAL_DPS in data:
manual_dps_list = [dps.strip() for dps in data[CONF_MANUAL_DPS].split(",")] manual_dps_list = [dps.strip() for dps in data[CONF_MANUAL_DPS].split(",")]
_LOGGER.debug( _LOGGER.debug(
"Manual DPS Setting: %s (%s)", data[CONF_MANUAL_DPS], manual_dps_list "Manual DPS Setting: %s (%s)", data[CONF_MANUAL_DPS], manual_dps_list
@@ -493,8 +496,8 @@ class LocalTuyaOptionsFlowHandler(config_entries.OptionsFlow):
errors["base"] = "address_in_use" errors["base"] = "address_in_use"
else: else:
errors["base"] = "discovery_failed" errors["base"] = "discovery_failed"
except Exception: # pylint: disable= broad-except except Exception as ex:
_LOGGER.exception("discovery failed") _LOGGER.exception("discovery failed: %s", ex)
errors["base"] = "discovery_failed" errors["base"] = "discovery_failed"
devices = { devices = {
@@ -553,6 +556,17 @@ class LocalTuyaOptionsFlowHandler(config_entries.OptionsFlow):
CONF_PRODUCT_NAME CONF_PRODUCT_NAME
) )
if self.editing_device: if self.editing_device:
if user_input[CONF_ENABLE_ADD_ENTITIES]:
self.editing_device = False
user_input[CONF_DEVICE_ID] = dev_id
self.device_data.update(
{
CONF_DEVICE_ID: dev_id,
CONF_DPS_STRINGS: self.dps_strings,
}
)
return await self.async_step_pick_entity_type()
self.device_data.update( self.device_data.update(
{ {
CONF_DEVICE_ID: dev_id, CONF_DEVICE_ID: dev_id,
@@ -586,16 +600,29 @@ class LocalTuyaOptionsFlowHandler(config_entries.OptionsFlow):
errors["base"] = "invalid_auth" errors["base"] = "invalid_auth"
except EmptyDpsList: except EmptyDpsList:
errors["base"] = "empty_dps" errors["base"] = "empty_dps"
except Exception: # pylint: disable=broad-except except Exception as ex:
_LOGGER.exception("Unexpected exception") _LOGGER.exception("Unexpected exception: %s", ex)
errors["base"] = "unknown" errors["base"] = "unknown"
defaults = {} defaults = {}
if self.editing_device: if self.editing_device:
# If selected device exists as a config entry, load config from it # If selected device exists as a config entry, load config from it
defaults = self.config_entry.data[CONF_DEVICES][dev_id].copy() defaults = self.config_entry.data[CONF_DEVICES][dev_id].copy()
schema = schema_defaults(options_schema(self.entities), **defaults) cloud_devs = self.hass.data[DOMAIN][DATA_CLOUD].device_list
placeholders = {"for_device": f" for device `{dev_id}`"} placeholders = {"for_device": f" for device `{dev_id}`"}
if dev_id in cloud_devs:
cloud_local_key = cloud_devs[dev_id].get(CONF_LOCAL_KEY)
if defaults[CONF_LOCAL_KEY] != cloud_local_key:
_LOGGER.info(
"New local_key detected: new %s vs old %s",
cloud_local_key,
defaults[CONF_LOCAL_KEY],
)
defaults[CONF_LOCAL_KEY] = cloud_devs[dev_id].get(CONF_LOCAL_KEY)
note = "\nNOTE: a new local_key has been retrieved using cloud API"
placeholders = {"for_device": f" for device `{dev_id}`.{note}"}
defaults[CONF_ENABLE_ADD_ENTITIES] = False
schema = schema_defaults(options_schema(self.entities), **defaults)
else: else:
defaults[CONF_PROTOCOL_VERSION] = "3.3" defaults[CONF_PROTOCOL_VERSION] = "3.3"
defaults[CONF_HOST] = "" defaults[CONF_HOST] = ""
@@ -634,17 +661,6 @@ class LocalTuyaOptionsFlowHandler(config_entries.OptionsFlow):
} }
dev_id = self.device_data.get(CONF_DEVICE_ID) dev_id = self.device_data.get(CONF_DEVICE_ID)
if dev_id in self.config_entry.data[CONF_DEVICES]:
self.hass.config_entries.async_update_entry(
self.config_entry, data=config
)
return self.async_abort(
reason="device_success",
description_placeholders={
"dev_name": config.get(CONF_FRIENDLY_NAME),
"action": "updated",
},
)
new_data = self.config_entry.data.copy() new_data = self.config_entry.data.copy()
new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000)) new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000))
@@ -727,7 +743,7 @@ class LocalTuyaOptionsFlowHandler(config_entries.OptionsFlow):
new_data = self.config_entry.data.copy() new_data = self.config_entry.data.copy()
entry_id = self.config_entry.entry_id entry_id = self.config_entry.entry_id
# removing entities from registry (they will be recreated) # removing entities from registry (they will be recreated)
ent_reg = await er.async_get_registry(self.hass) ent_reg = er.async_get(self.hass)
reg_entities = { reg_entities = {
ent.unique_id: ent.entity_id ent.unique_id: ent.entity_id
for ent in er.async_entries_for_config_entry(ent_reg, entry_id) for ent in er.async_entries_for_config_entry(ent_reg, entry_id)

View File

@@ -35,6 +35,7 @@ CONF_MODEL = "model"
CONF_PRODUCT_KEY = "product_key" CONF_PRODUCT_KEY = "product_key"
CONF_PRODUCT_NAME = "product_name" CONF_PRODUCT_NAME = "product_name"
CONF_USER_ID = "user_id" CONF_USER_ID = "user_id"
CONF_ENABLE_ADD_ENTITIES = "add_entities"
CONF_ACTION = "action" CONF_ACTION = "action"

View File

@@ -1,14 +1,14 @@
{ {
"domain": "localtuya", "domain": "localtuya",
"name": "LocalTuya integration", "name": "LocalTuya integration",
"version": "5.0.0",
"documentation": "https://github.com/rospogrigio/localtuya/",
"dependencies": [],
"codeowners": [ "codeowners": [
"@rospogrigio", "@postlund" "@rospogrigio", "@postlund"
], ],
"config_flow": true,
"dependencies": [],
"documentation": "https://github.com/rospogrigio/localtuya/",
"iot_class": "local_push",
"issue_tracker": "https://github.com/rospogrigio/localtuya/issues", "issue_tracker": "https://github.com/rospogrigio/localtuya/issues",
"requirements": [], "requirements": [],
"config_flow": true, "version": "5.2.1"
"iot_class": "local_push"
} }

View File

@@ -444,11 +444,14 @@ class MessageDispatcher(ContextualLogger):
if seqno in self.listeners: if seqno in self.listeners:
raise Exception(f"listener exists for {seqno}") raise Exception(f"listener exists for {seqno}")
self.debug("Command %d waiting for sequence number %d", cmd, seqno) self.debug("Command %d waiting for seq. number %d", cmd, seqno)
self.listeners[seqno] = asyncio.Semaphore(0) self.listeners[seqno] = asyncio.Semaphore(0)
try: try:
await asyncio.wait_for(self.listeners[seqno].acquire(), timeout=timeout) await asyncio.wait_for(self.listeners[seqno].acquire(), timeout=timeout)
except asyncio.TimeoutError: except asyncio.TimeoutError:
self.debug(
"Command %d timed out waiting for sequence number %d", cmd, seqno
)
del self.listeners[seqno] del self.listeners[seqno]
raise raise
@@ -478,8 +481,11 @@ class MessageDispatcher(ContextualLogger):
if msg.seqno in self.listeners: if msg.seqno in self.listeners:
# self.debug("Dispatching sequence number %d", msg.seqno) # self.debug("Dispatching sequence number %d", msg.seqno)
sem = self.listeners[msg.seqno] sem = self.listeners[msg.seqno]
if isinstance(sem, asyncio.Semaphore):
self.listeners[msg.seqno] = msg self.listeners[msg.seqno] = msg
sem.release() sem.release()
else:
self.debug("Got additional message without request - skipping: %s", sem)
elif msg.cmd == HEART_BEAT: elif msg.cmd == HEART_BEAT:
self.debug("Got heartbeat response") self.debug("Got heartbeat response")
if self.HEARTBEAT_SEQNO in self.listeners: if self.HEARTBEAT_SEQNO in self.listeners:
@@ -511,7 +517,7 @@ class MessageDispatcher(ContextualLogger):
if msg.cmd == CONTROL_NEW: if msg.cmd == CONTROL_NEW:
self.debug("Got ACK message for command %d: will ignore it", msg.cmd) self.debug("Got ACK message for command %d: will ignore it", msg.cmd)
else: else:
self.error( self.debug(
"Got message type %d for unknown listener %d: %s", "Got message type %d for unknown listener %d: %s",
msg.cmd, msg.cmd,
msg.seqno, msg.seqno,
@@ -881,8 +887,10 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger):
try: try:
# self.debug("decrypting=%r", payload) # self.debug("decrypting=%r", payload)
payload = cipher.decrypt(payload, False, decode_text=False) payload = cipher.decrypt(payload, False, decode_text=False)
except Exception: except Exception as ex:
self.debug("incomplete payload=%r (len:%d)", payload, len(payload)) self.debug(
"incomplete payload=%r with len:%d (%s)", payload, len(payload), ex
)
return self.error_json(ERR_PAYLOAD) return self.error_json(ERR_PAYLOAD)
# self.debug("decrypted 3.x payload=%r", payload) # self.debug("decrypted 3.x payload=%r", payload)
@@ -907,8 +915,13 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger):
try: try:
# self.debug("decrypting=%r", payload) # self.debug("decrypting=%r", payload)
payload = cipher.decrypt(payload, False) payload = cipher.decrypt(payload, False)
except Exception: except Exception as ex:
self.debug("incomplete payload=%r (len:%d)", payload, len(payload)) self.debug(
"incomplete payload=%r with len:%d (%s)",
payload,
len(payload),
ex,
)
return self.error_json(ERR_PAYLOAD) return self.error_json(ERR_PAYLOAD)
# self.debug("decrypted 3.x payload=%r", payload) # self.debug("decrypted 3.x payload=%r", payload)
@@ -917,9 +930,11 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger):
if not isinstance(payload, str): if not isinstance(payload, str):
try: try:
payload = payload.decode() payload = payload.decode()
except Exception: except Exception as ex:
self.debug("payload was not string type and decoding failed") self.debug("payload was not string type and decoding failed")
return self.error_json(ERR_JSON, payload) raise DecodeError("payload was not a string: %s" % ex)
# return self.error_json(ERR_JSON, payload)
if "data unvalid" in payload: if "data unvalid" in payload:
self.dev_type = "type_0d" self.dev_type = "type_0d"
self.debug( self.debug(
@@ -936,8 +951,11 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger):
self.debug("Deciphered data = %r", payload) self.debug("Deciphered data = %r", payload)
try: try:
json_payload = json.loads(payload) json_payload = json.loads(payload)
except Exception: except Exception as ex:
json_payload = self.error_json(ERR_JSON, payload) raise DecodeError(
"could not decrypt data: wrong local_key? (exception: %s)" % ex
)
# json_payload = self.error_json(ERR_JSON, payload)
# v3.4 stuffs it into {"data":{"dps":{"1":true}}, ...} # v3.4 stuffs it into {"data":{"dps":{"1":true}}, ...}
if ( if (
@@ -971,11 +989,12 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger):
# self.debug("decrypting %r using %r", payload, self.real_local_key) # self.debug("decrypting %r using %r", payload, self.real_local_key)
cipher = AESCipher(self.real_local_key) cipher = AESCipher(self.real_local_key)
payload = cipher.decrypt(payload, False, decode_text=False) payload = cipher.decrypt(payload, False, decode_text=False)
except Exception: except Exception as ex:
self.debug( self.debug(
"session key step 2 decrypt failed, payload=%r (len:%d)", "session key step 2 decrypt failed, payload=%r with len:%d (%s)",
payload, payload,
len(payload), len(payload),
ex,
) )
return False return False

View File

@@ -99,6 +99,7 @@
"enable_debug": "Enable debugging for this device (debug must be enabled also in configuration.yaml)", "enable_debug": "Enable debugging for this device (debug must be enabled also in configuration.yaml)",
"scan_interval": "Scan interval (seconds, only when not updating automatically)", "scan_interval": "Scan interval (seconds, only when not updating automatically)",
"entities": "Entities (uncheck an entity to remove it)", "entities": "Entities (uncheck an entity to remove it)",
"add_entities": "Add more entities in 'edit device' mode",
"manual_dps_strings": "Manual DPS to add (separated by commas ',') - used when detection is not working (optional)", "manual_dps_strings": "Manual DPS to add (separated by commas ',') - used when detection is not working (optional)",
"reset_dpids": "DPIDs to send in RESET command (separated by commas ',')- Used when device does not respond to status requests after turning on (optional)" "reset_dpids": "DPIDs to send in RESET command (separated by commas ',')- Used when device does not respond to status requests after turning on (optional)"
} }

View File

@@ -1,6 +1,4 @@
{ {
"name": "Local Tuya", "name": "Local Tuya",
"domains": ["climate", "cover", "fan", "light", "number", "select", "sensor", "switch"], "homeassistant": "0.116.0"
"homeassistant": "0.116.0",
"iot_class": ["Local Push"]
} }

View File

@@ -176,7 +176,8 @@ disable=line-too-long,
dangerous-default-value, dangerous-default-value,
unreachable, unreachable,
unnecessary-pass, unnecessary-pass,
broad-except broad-except,
raise-missing-from
# Enable the message, report, category or checker with the given id(s). You can # Enable the message, report, category or checker with the given id(s). You can
# either give multiple identifier separated by comma (,) or put this option # either give multiple identifier separated by comma (,) or put this option

View File

@@ -6,12 +6,11 @@ cs_exclude_words = hass,unvalid
[gh-actions] [gh-actions]
python = python =
3.8: clean, py38, lint, typing
3.9: clean, py39, lint, typing 3.9: clean, py39, lint, typing
[testenv] [testenv]
passenv = TOXENV,CI passenv = TOXENV,CI
whitelist_externals = allowlist_externals =
true true
setenv = setenv =
LANG=en_US.UTF-8 LANG=en_US.UTF-8