diff --git a/README.md b/README.md index 756347f..edb7fbd 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,8 @@ Setting the scan interval is optional, it is only needed if energy/power values 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. +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. + Once you press "Submit", the connection is tested to check that everything works. ![image](https://github.com/rospogrigio/localtuya-homeassistant/blob/master/img/2-device.png) diff --git a/custom_components/localtuya/common.py b/custom_components/localtuya/common.py index 38401bc..5eb6d81 100644 --- a/custom_components/localtuya/common.py +++ b/custom_components/localtuya/common.py @@ -35,6 +35,7 @@ from .const import ( CONF_DEFAULT_VALUE, ATTR_STATE, CONF_RESTORE_ON_RECONNECT, + CONF_RESET_DPIDS, ) _LOGGER = logging.getLogger(__name__) @@ -143,6 +144,14 @@ class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger): self._unsub_interval = None self._entities = [] self._local_key = self._dev_config_entry[CONF_LOCAL_KEY] + self._default_reset_dpids = None + if CONF_RESET_DPIDS in self._dev_config_entry: + reset_ids_str = self._dev_config_entry[CONF_RESET_DPIDS].split(",") + + self._default_reset_dpids = [] + for reset_id in reset_ids_str: + self._default_reset_dpids.append(int(reset_id.strip())) + self.set_logger(_LOGGER, self._dev_config_entry[CONF_DEVICE_ID]) # This has to be done in case the device type is type_0d @@ -181,14 +190,60 @@ 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") + if self._interface is not None: + await self._interface.close() + self._interface = None - self.debug("Retrieving initial state") - status = await self._interface.status() - if status is None: - raise Exception("Failed to retrieve status") + 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.status_updated(status) + self._interface.start_heartbeat() + self.status_updated(status) + except Exception as ex: # pylint: disable=broad-except + try: + self.debug( + "Initial state update failed, trying reset command " + + "for DP IDs: %s", + self._default_reset_dpids, + ) + await self._interface.reset(self._default_reset_dpids) + + self.debug("Update completed, retrying initial state") + status = await self._interface.status() + if status is None or not status: + raise Exception("Failed to retrieve status") from ex + + self._interface.start_heartbeat() + self.status_updated(status) + + 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 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: # Attempt to restore status for all entities that need to first set # the DPS value before the device will respond with status. for entity in self._entities: @@ -216,22 +271,7 @@ class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger): self._async_refresh, timedelta(seconds=self._dev_config_entry[CONF_SCAN_INTERVAL]), ) - 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 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 self._connect_task = None async def update_local_key(self): diff --git a/custom_components/localtuya/config_flow.py b/custom_components/localtuya/config_flow.py index 203c0c6..e70076f 100644 --- a/custom_components/localtuya/config_flow.py +++ b/custom_components/localtuya/config_flow.py @@ -38,6 +38,7 @@ from .const import ( CONF_NO_CLOUD, CONF_PRODUCT_NAME, CONF_PROTOCOL_VERSION, + CONF_RESET_DPIDS, CONF_SETUP_CLOUD, CONF_USER_ID, DATA_CLOUD, @@ -90,6 +91,7 @@ CONFIGURE_DEVICE_SCHEMA = vol.Schema( vol.Required(CONF_PROTOCOL_VERSION, default="3.3"): vol.In(["3.1", "3.3"]), vol.Optional(CONF_SCAN_INTERVAL): int, vol.Optional(CONF_MANUAL_DPS): str, + vol.Optional(CONF_RESET_DPIDS): str, } ) @@ -102,6 +104,7 @@ DEVICE_SCHEMA = vol.Schema( vol.Required(CONF_PROTOCOL_VERSION, default="3.3"): vol.In(["3.1", "3.3"]), vol.Optional(CONF_SCAN_INTERVAL): int, vol.Optional(CONF_MANUAL_DPS): cv.string, + vol.Optional(CONF_RESET_DPIDS): str, } ) @@ -144,6 +147,7 @@ def options_schema(entities): vol.Required(CONF_PROTOCOL_VERSION, default="3.3"): vol.In(["3.1", "3.3"]), vol.Optional(CONF_SCAN_INTERVAL): int, vol.Optional(CONF_MANUAL_DPS): str, + vol.Optional(CONF_RESET_DPIDS): str, vol.Required( CONF_ENTITIES, description={"suggested_value": entity_names} ): cv.multi_select(entity_names), @@ -235,6 +239,8 @@ async def validate_input(hass: core.HomeAssistant, data): detected_dps = {} interface = None + + reset_ids = None try: interface = await pytuya.connect( data[CONF_HOST], @@ -243,20 +249,39 @@ async def validate_input(hass: core.HomeAssistant, data): float(data[CONF_PROTOCOL_VERSION]), ) - detected_dps = await interface.detect_available_dps() + try: + detected_dps = await interface.detect_available_dps() + except Exception: # pylint: disable=broad-except + try: + _LOGGER.debug("Initial state update failed, trying reset command") + if CONF_RESET_DPIDS in data: + reset_ids_str = data[CONF_RESET_DPIDS].split(",") + reset_ids = [] + for reset_id in reset_ids_str: + reset_ids.append(int(reset_id.strip())) + _LOGGER.debug( + "Reset DPIDs configured: %s (%s)", + data[CONF_RESET_DPIDS], + reset_ids, + ) + 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") + 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 = data[CONF_MANUAL_DPS].split(",") + 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 ) # merge the lists - for new_dps in manual_dps_list: - # trim off any whitespace - new_dps = new_dps.strip() + for new_dps in manual_dps_list + (reset_ids or []): + # If the DPS not in the detected dps list, then add with a + # default value indicating that it has been manually added if new_dps not in detected_dps: detected_dps[new_dps] = -1 diff --git a/custom_components/localtuya/const.py b/custom_components/localtuya/const.py index 993a1f1..9ae3902 100644 --- a/custom_components/localtuya/const.py +++ b/custom_components/localtuya/const.py @@ -43,6 +43,7 @@ CONF_SETUP_CLOUD = "setup_cloud" CONF_NO_CLOUD = "no_cloud" CONF_MANUAL_DPS = "manual_dps_strings" CONF_DEFAULT_VALUE = "dps_default_value" +CONF_RESET_DPIDS = "reset_dpids" # light CONF_BRIGHTNESS_LOWER = "brightness_lower" diff --git a/custom_components/localtuya/pytuya/__init__.py b/custom_components/localtuya/pytuya/__init__.py index b7645ec..d36cd5e 100644 --- a/custom_components/localtuya/pytuya/__init__.py +++ b/custom_components/localtuya/pytuya/__init__.py @@ -62,6 +62,7 @@ TuyaMessage = namedtuple("TuyaMessage", "seqno cmd retcode payload crc") SET = "set" STATUS = "status" HEARTBEAT = "heartbeat" +RESET = "reset" UPDATEDPS = "updatedps" # Request refresh of DPS PROTOCOL_VERSION_BYTES_31 = b"3.1" @@ -96,6 +97,16 @@ PAYLOAD_DICT = { SET: {"hexByte": 0x07, "command": {"devId": "", "uid": "", "t": ""}}, HEARTBEAT: {"hexByte": 0x09, "command": {}}, UPDATEDPS: {"hexByte": 0x12, "command": {"dpId": [18, 19, 20]}}, + RESET: { + "hexByte": 0x12, + "command": { + "gwId": "", + "devId": "", + "uid": "", + "t": "", + "dpId": [18, 19, 20], + }, + }, }, "type_0d": { STATUS: {"hexByte": 0x0D, "command": {"devId": "", "uid": "", "t": ""}}, @@ -217,6 +228,7 @@ class MessageDispatcher(ContextualLogger): # Heartbeats always respond with sequence number 0, so they can't be waited for like # other messages. This is a hack to allow waiting for heartbeats. HEARTBEAT_SEQNO = -100 + RESET_SEQNO = -101 def __init__(self, dev_id, listener): """Initialize a new MessageBuffer.""" @@ -301,9 +313,19 @@ class MessageDispatcher(ContextualLogger): sem.release() elif msg.cmd == 0x12: self.debug("Got normal updatedps response") + if self.RESET_SEQNO in self.listeners: + sem = self.listeners[self.RESET_SEQNO] + self.listeners[self.RESET_SEQNO] = msg + sem.release() elif msg.cmd == 0x08: - self.debug("Got status update") - self.listener(msg) + if self.RESET_SEQNO in self.listeners: + self.debug("Got reset status update") + sem = self.listeners[self.RESET_SEQNO] + self.listeners[self.RESET_SEQNO] = msg + sem.release() + else: + self.debug("Got status update") + self.listener(msg) else: self.debug( "Got message type %d for unknown listener %d: %s", @@ -381,6 +403,11 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger): def connection_made(self, transport): """Did connect to the device.""" + self.transport = transport + self.on_connected.set_result(True) + + def start_heartbeat(self): + """Start the heartbeat transmissions with the device.""" async def heartbeat_loop(): """Continuously send heart beat updates.""" @@ -403,8 +430,6 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger): self.transport = None transport.close() - self.transport = transport - self.on_connected.set_result(True) self.heartbeater = self.loop.create_task(heartbeat_loop()) def data_received(self, data): @@ -449,12 +474,13 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger): payload = self._generate_payload(command, dps) dev_type = self.dev_type - # Wait for special sequence number if heartbeat - seqno = ( - MessageDispatcher.HEARTBEAT_SEQNO - if command == HEARTBEAT - else (self.seqno - 1) - ) + # Wait for special sequence number if heartbeat or reset + seqno = self.seqno - 1 + + if command == HEARTBEAT: + seqno = MessageDispatcher.HEARTBEAT_SEQNO + elif command == RESET: + seqno = MessageDispatcher.RESET_SEQNO self.transport.write(payload) msg = await self.dispatcher.wait_for(seqno) @@ -487,6 +513,15 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger): """Send a heartbeat message.""" return await self.exchange(HEARTBEAT) + async def reset(self, dpIds=None): + """Send a reset message (3.3 only).""" + if self.version == 3.3: + self.dev_type = "type_0a" + self.debug("reset switching to dev_type %s", self.dev_type) + return await self.exchange(RESET, dpIds) + + return True + async def update_dps(self, dps=None): """ Request device to update index. diff --git a/custom_components/localtuya/translations/en.json b/custom_components/localtuya/translations/en.json index a97f120..d1da446 100644 --- a/custom_components/localtuya/translations/en.json +++ b/custom_components/localtuya/translations/en.json @@ -97,7 +97,8 @@ "protocol_version": "Protocol Version", "scan_interval": "Scan interval (seconds, only when not updating automatically)", "entities": "Entities (uncheck an entity to remove it)", - "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)" } }, "pick_entity_type": { diff --git a/img/2-device.png b/img/2-device.png index 335d787..4e6623d 100644 Binary files a/img/2-device.png and b/img/2-device.png differ