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
on: [pull_request]
on:
pull_request:
workflow_dispatch:
jobs:
build:
@@ -14,8 +16,7 @@ jobs:
platform:
- ubuntu-latest
python-version:
- 3.7
- 3.8
- 3.9
steps:
- uses: actions/checkout@v2
- 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."""
import asyncio
import json.decoder
import logging
import time
from datetime import timedelta
@@ -176,12 +177,13 @@ class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger):
def async_connect(self):
"""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:
self._connect_task = asyncio.create_task(self._make_connection())
async def _make_connection(self):
"""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:
self._interface = await pytuya.connect(
@@ -193,24 +195,26 @@ class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger):
self,
)
self._interface.add_dps_to_request(self.dps_to_request)
except Exception: # pylint: disable=broad-except
self.exception(f"Connect to {self._dev_config_entry[CONF_HOST]} failed")
except Exception as ex: # pylint: disable=broad-except
self.warning(
f"Failed to connect to {self._dev_config_entry[CONF_HOST]}: %s", ex
)
if self._interface is not None:
await self._interface.close()
self._interface = None
if self._interface is not None:
try:
self.debug("Retrieving initial state")
status = await self._interface.status()
if status is None:
raise Exception("Failed to retrieve status")
self._interface.start_heartbeat()
self.status_updated(status)
except Exception as ex: # pylint: disable=broad-except
try:
self.debug("Retrieving initial state")
status = await self._interface.status()
if status is None:
raise Exception("Failed to retrieve status")
self._interface.start_heartbeat()
self.status_updated(status)
except Exception as ex:
if (self._default_reset_dpids is not None) and (
len(self._default_reset_dpids) > 0
):
@@ -228,26 +232,19 @@ class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger):
self._interface.start_heartbeat()
self.status_updated(status)
else:
self.error("Initial state update failed, giving up: %r", ex)
if self._interface is not None:
await self._interface.close()
self._interface = None
except UnicodeDecodeError as e: # pylint: disable=broad-except
self.exception(
f"Connect to {self._dev_config_entry[CONF_HOST]} failed: %s",
type(e),
)
if self._interface is not None:
await self._interface.close()
self._interface = None
except (UnicodeDecodeError, json.decoder.JSONDecodeError) as ex:
self.warning("Initial state update failed (%s), trying key update", ex)
await self.update_local_key()
except Exception as e: # pylint: disable=broad-except
self.exception(
f"Connect to {self._dev_config_entry[CONF_HOST]} failed"
)
if "json.decode" in str(type(e)):
await self.update_local_key()
if self._interface is not None:
await self._interface.close()
self._interface = None
if self._interface is not None:
await self._interface.close()
self._interface = None
if self._interface is not None:
# Attempt to restore status for all entities that need to first set
@@ -270,14 +267,16 @@ class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger):
if (
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._hass,
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
async def update_local_key(self):
@@ -310,7 +309,7 @@ class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger):
await self._interface.close()
if self._disconnect_task is not None:
self._disconnect_task()
self.debug(
self.info(
"Closed connection with device %s.",
self._dev_config_entry[CONF_FRIENDLY_NAME],
)
@@ -358,7 +357,11 @@ class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger):
self._unsub_interval()
self._unsub_interval = 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):

View File

@@ -43,6 +43,7 @@ from .const import (
CONF_RESET_DPIDS,
CONF_SETUP_CLOUD,
CONF_USER_ID,
CONF_ENABLE_ADD_ENTITIES,
DATA_CLOUD,
DATA_DISCOVERY,
DOMAIN,
@@ -140,12 +141,13 @@ def options_schema(entities):
["3.1", "3.2", "3.3", "3.4"]
),
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_RESET_DPIDS): cv.string,
vol.Required(
CONF_ENTITIES, description={"suggested_value": 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:
detected_dps = await interface.detect_available_dps()
except Exception: # pylint: disable=broad-except
except Exception as ex:
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:
await interface.reset(reset_ids)
detected_dps = await interface.detect_available_dps()
except Exception: # pylint: disable=broad-except
_LOGGER.debug("No DPS able to be detected")
except Exception as ex:
_LOGGER.debug("No DPS able to be detected: %s", ex)
detected_dps = {}
# if manual DPs are set, merge these.
_LOGGER.debug("Detected DPS: %s", detected_dps)
if CONF_MANUAL_DPS in data:
manual_dps_list = [dps.strip() for dps in data[CONF_MANUAL_DPS].split(",")]
_LOGGER.debug(
"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"
else:
errors["base"] = "discovery_failed"
except Exception: # pylint: disable= broad-except
_LOGGER.exception("discovery failed")
except Exception as ex:
_LOGGER.exception("discovery failed: %s", ex)
errors["base"] = "discovery_failed"
devices = {
@@ -553,6 +556,17 @@ class LocalTuyaOptionsFlowHandler(config_entries.OptionsFlow):
CONF_PRODUCT_NAME
)
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(
{
CONF_DEVICE_ID: dev_id,
@@ -586,16 +600,29 @@ class LocalTuyaOptionsFlowHandler(config_entries.OptionsFlow):
errors["base"] = "invalid_auth"
except EmptyDpsList:
errors["base"] = "empty_dps"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
except Exception as ex:
_LOGGER.exception("Unexpected exception: %s", ex)
errors["base"] = "unknown"
defaults = {}
if self.editing_device:
# If selected device exists as a config entry, load config from it
defaults = self.config_entry.data[CONF_DEVICES][dev_id].copy()
schema = schema_defaults(options_schema(self.entities), **defaults)
cloud_devs = self.hass.data[DOMAIN][DATA_CLOUD].device_list
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:
defaults[CONF_PROTOCOL_VERSION] = "3.3"
defaults[CONF_HOST] = ""
@@ -634,17 +661,6 @@ class LocalTuyaOptionsFlowHandler(config_entries.OptionsFlow):
}
dev_id = self.device_data.get(CONF_DEVICE_ID)
if dev_id in self.config_entry.data[CONF_DEVICES]:
self.hass.config_entries.async_update_entry(
self.config_entry, data=config
)
return self.async_abort(
reason="device_success",
description_placeholders={
"dev_name": config.get(CONF_FRIENDLY_NAME),
"action": "updated",
},
)
new_data = self.config_entry.data.copy()
new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000))
@@ -727,7 +743,7 @@ class LocalTuyaOptionsFlowHandler(config_entries.OptionsFlow):
new_data = self.config_entry.data.copy()
entry_id = self.config_entry.entry_id
# removing entities from registry (they will be recreated)
ent_reg = await er.async_get_registry(self.hass)
ent_reg = er.async_get(self.hass)
reg_entities = {
ent.unique_id: ent.entity_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_NAME = "product_name"
CONF_USER_ID = "user_id"
CONF_ENABLE_ADD_ENTITIES = "add_entities"
CONF_ACTION = "action"

View File

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

View File

@@ -444,11 +444,14 @@ class MessageDispatcher(ContextualLogger):
if seqno in self.listeners:
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)
try:
await asyncio.wait_for(self.listeners[seqno].acquire(), timeout=timeout)
except asyncio.TimeoutError:
self.debug(
"Command %d timed out waiting for sequence number %d", cmd, seqno
)
del self.listeners[seqno]
raise
@@ -478,8 +481,11 @@ class MessageDispatcher(ContextualLogger):
if msg.seqno in self.listeners:
# self.debug("Dispatching sequence number %d", msg.seqno)
sem = self.listeners[msg.seqno]
self.listeners[msg.seqno] = msg
sem.release()
if isinstance(sem, asyncio.Semaphore):
self.listeners[msg.seqno] = msg
sem.release()
else:
self.debug("Got additional message without request - skipping: %s", sem)
elif msg.cmd == HEART_BEAT:
self.debug("Got heartbeat response")
if self.HEARTBEAT_SEQNO in self.listeners:
@@ -511,7 +517,7 @@ class MessageDispatcher(ContextualLogger):
if msg.cmd == CONTROL_NEW:
self.debug("Got ACK message for command %d: will ignore it", msg.cmd)
else:
self.error(
self.debug(
"Got message type %d for unknown listener %d: %s",
msg.cmd,
msg.seqno,
@@ -881,8 +887,10 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger):
try:
# self.debug("decrypting=%r", payload)
payload = cipher.decrypt(payload, False, decode_text=False)
except Exception:
self.debug("incomplete payload=%r (len:%d)", payload, len(payload))
except Exception as ex:
self.debug(
"incomplete payload=%r with len:%d (%s)", payload, len(payload), ex
)
return self.error_json(ERR_PAYLOAD)
# self.debug("decrypted 3.x payload=%r", payload)
@@ -907,8 +915,13 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger):
try:
# self.debug("decrypting=%r", payload)
payload = cipher.decrypt(payload, False)
except Exception:
self.debug("incomplete payload=%r (len:%d)", payload, len(payload))
except Exception as ex:
self.debug(
"incomplete payload=%r with len:%d (%s)",
payload,
len(payload),
ex,
)
return self.error_json(ERR_PAYLOAD)
# self.debug("decrypted 3.x payload=%r", payload)
@@ -917,9 +930,11 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger):
if not isinstance(payload, str):
try:
payload = payload.decode()
except Exception:
except Exception as ex:
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:
self.dev_type = "type_0d"
self.debug(
@@ -936,8 +951,11 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger):
self.debug("Deciphered data = %r", payload)
try:
json_payload = json.loads(payload)
except Exception:
json_payload = self.error_json(ERR_JSON, payload)
except Exception as ex:
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}}, ...}
if (
@@ -971,11 +989,12 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger):
# self.debug("decrypting %r using %r", payload, self.real_local_key)
cipher = AESCipher(self.real_local_key)
payload = cipher.decrypt(payload, False, decode_text=False)
except Exception:
except Exception as ex:
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,
len(payload),
ex,
)
return False

View File

@@ -99,6 +99,7 @@
"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)",
"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)",
"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",
"domains": ["climate", "cover", "fan", "light", "number", "select", "sensor", "switch"],
"homeassistant": "0.116.0",
"iot_class": ["Local Push"]
"homeassistant": "0.116.0"
}

View File

@@ -176,7 +176,8 @@ disable=line-too-long,
dangerous-default-value,
unreachable,
unnecessary-pass,
broad-except
broad-except,
raise-missing-from
# 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

View File

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