Implementing RESET Tuya function to enable devices which are stuck in 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
This commit is contained in:
@@ -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.
|
||||
|
||||

|
||||
|
@@ -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
|
||||
|
||||
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()
|
||||
|
||||
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):
|
||||
|
@@ -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]),
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
|
@@ -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"
|
||||
|
@@ -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,7 +313,17 @@ 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:
|
||||
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:
|
||||
@@ -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.
|
||||
|
@@ -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": {
|
||||
|
BIN
img/2-device.png
BIN
img/2-device.png
Binary file not shown.
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 29 KiB |
Reference in New Issue
Block a user