diff --git a/.github/workflows/combined.yaml b/.github/workflows/combined.yaml deleted file mode 100644 index 8cad813..0000000 --- a/.github/workflows/combined.yaml +++ /dev/null @@ -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 }} diff --git a/.github/workflows/tox.yaml b/.github/workflows/tox.yaml index 40ca8d9..a7910f4 100644 --- a/.github/workflows/tox.yaml +++ b/.github/workflows/tox.yaml @@ -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 }} diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..f0e209c --- /dev/null +++ b/.github/workflows/validate.yml @@ -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 diff --git a/custom_components/localtuya/common.py b/custom_components/localtuya/common.py index 85ed21c..cd503c2 100644 --- a/custom_components/localtuya/common.py +++ b/custom_components/localtuya/common.py @@ -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): diff --git a/custom_components/localtuya/config_flow.py b/custom_components/localtuya/config_flow.py index 0099901..5c87e25 100644 --- a/custom_components/localtuya/config_flow.py +++ b/custom_components/localtuya/config_flow.py @@ -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) diff --git a/custom_components/localtuya/const.py b/custom_components/localtuya/const.py index 3a6c252..630d630 100644 --- a/custom_components/localtuya/const.py +++ b/custom_components/localtuya/const.py @@ -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" diff --git a/custom_components/localtuya/manifest.json b/custom_components/localtuya/manifest.json index 3f1e00b..28e36fa 100644 --- a/custom_components/localtuya/manifest.json +++ b/custom_components/localtuya/manifest.json @@ -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" } diff --git a/custom_components/localtuya/pytuya/__init__.py b/custom_components/localtuya/pytuya/__init__.py index ad546c4..bcc8bbe 100644 --- a/custom_components/localtuya/pytuya/__init__.py +++ b/custom_components/localtuya/pytuya/__init__.py @@ -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 diff --git a/custom_components/localtuya/translations/en.json b/custom_components/localtuya/translations/en.json index 947141c..b9beee4 100644 --- a/custom_components/localtuya/translations/en.json +++ b/custom_components/localtuya/translations/en.json @@ -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)" } diff --git a/hacs.json b/hacs.json index 3c5a41a..0496f6f 100644 --- a/hacs.json +++ b/hacs.json @@ -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" } diff --git a/pylint.rc b/pylint.rc index 223e881..ec80820 100644 --- a/pylint.rc +++ b/pylint.rc @@ -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 diff --git a/tox.ini b/tox.ini index 2602e5e..a0b7eac 100644 --- a/tox.ini +++ b/tox.ini @@ -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