Skip to content

Commit

Permalink
Implementing RESET Tuya function to enable devices which are stuck in…
Browse files Browse the repository at this point in the history
… a zombie mode to start responding to commands. Added configurable DPS for Reset command as found devices wouldn't 'wake up' unless they had exactly the right entries
  • Loading branch information
sibowler committed Jul 19, 2022
1 parent 489a6f0 commit ad1f757
Show file tree
Hide file tree
Showing 7 changed files with 140 additions and 36 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
80 changes: 60 additions & 20 deletions custom_components/localtuya/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
CONF_DEFAULT_VALUE,
ATTR_STATE,
CONF_RESTORE_ON_RECONNECT,
CONF_RESET_DPIDS,
)

_LOGGER = logging.getLogger(__name__)
Expand Down Expand Up @@ -143,6 +144,14 @@ def __init__(self, hass, config_entry, dev_id):
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
Expand Down Expand Up @@ -181,14 +190,60 @@ async def _make_connection(self):
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._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()

self.status_updated(status)
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:
Expand Down Expand Up @@ -216,22 +271,7 @@ def _new_entity_handler(entity_id):
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):
Expand Down
35 changes: 30 additions & 5 deletions custom_components/localtuya/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
CONF_NO_CLOUD,
CONF_PRODUCT_NAME,
CONF_PROTOCOL_VERSION,
CONF_RESET_DPIDS,
CONF_SETUP_CLOUD,
CONF_USER_ID,
DATA_CLOUD,
Expand Down Expand Up @@ -90,6 +91,7 @@
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,
}
)

Expand All @@ -102,6 +104,7 @@
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,
}
)

Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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],
Expand All @@ -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

Expand Down
1 change: 1 addition & 0 deletions custom_components/localtuya/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
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"
Expand Down
55 changes: 45 additions & 10 deletions custom_components/localtuya/pytuya/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
SET = "set"
STATUS = "status"
HEARTBEAT = "heartbeat"
RESET = "reset"
UPDATEDPS = "updatedps" # Request refresh of DPS

PROTOCOL_VERSION_BYTES_31 = b"3.1"
Expand Down Expand Up @@ -96,6 +97,16 @@
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": ""}},
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -301,9 +313,19 @@ def _dispatch(self, msg):
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",
Expand Down Expand Up @@ -381,6 +403,11 @@ def _status_update(msg):

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."""
Expand All @@ -403,8 +430,6 @@ async def heartbeat_loop():
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):
Expand Down Expand Up @@ -449,12 +474,13 @@ async def exchange(self, command, dps=None):
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)
Expand Down Expand Up @@ -487,6 +513,15 @@ async def heartbeat(self):
"""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.
Expand Down
3 changes: 2 additions & 1 deletion custom_components/localtuya/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Binary file modified img/2-device.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit ad1f757

Please sign in to comment.