Skip to content

Commit

Permalink
Merge pull request #956 from sibowler/restore_state
Browse files Browse the repository at this point in the history
Support Passive DPS devices, Restoring state after power off & NumberEntity enhancements
  • Loading branch information
rospogrigio authored Sep 5, 2022
2 parents 2bae03c + 19a0cb6 commit 7142304
Show file tree
Hide file tree
Showing 14 changed files with 421 additions and 54 deletions.
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,13 @@ If you have selected one entry, you only need to input the device's Friendly Nam

Setting the scan interval is optional, it is only needed if energy/power values are not updating frequently enough by default. Values less than 10 seconds may cause stability issues.

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://user-images.githubusercontent.com/1082213/146664103-ac40319e-f934-4933-90cf-2beaff1e6bac.png)
![image](https://github.com/rospogrigio/localtuya-homeassistant/blob/master/img/2-device.png)


Then, it's time to add the entities: this step will take place several times. First, select the entity type from the drop-down menu to set it up.
Expand Down
7 changes: 7 additions & 0 deletions custom_components/localtuya/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ def device_class(self):

def status_updated(self):
"""Device status was updated."""
super().status_updated()

state = str(self.dps(self._dp_id)).lower()
if state == self._config[CONF_STATE_ON].lower():
self._is_on = True
Expand All @@ -63,6 +65,11 @@ def status_updated(self):
"State for entity %s did not match state patterns", self.entity_id
)

# No need to restore state for a sensor
async def restore_state_when_connected(self):
"""Do nothing for a sensor."""
return


async_setup_entry = partial(
async_setup_entry, DOMAIN, LocaltuyaBinarySensor, flow_schema
Expand Down
212 changes: 191 additions & 21 deletions custom_components/localtuya/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
CONF_ID,
CONF_PLATFORM,
CONF_SCAN_INTERVAL,
STATE_UNKNOWN,
)
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import (
Expand All @@ -31,6 +32,10 @@
DATA_CLOUD,
DOMAIN,
TUYA_DEVICES,
CONF_DEFAULT_VALUE,
ATTR_STATE,
CONF_RESTORE_ON_RECONNECT,
CONF_RESET_DPIDS,
)

_LOGGER = logging.getLogger(__name__)
Expand Down Expand Up @@ -91,6 +96,8 @@ async def async_setup_entry(
entity_config[CONF_ID],
)
)
# Once the entities have been created, add to the TuyaDevice instance
tuyainterface.add_entities(entities)
async_add_entities(entities)


Expand Down Expand Up @@ -135,13 +142,31 @@ def __init__(self, hass, config_entry, dev_id):
self._connect_task = None
self._disconnect_task = None
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
for entity in self._dev_config_entry[CONF_ENTITIES]:
self.dps_to_request[entity[CONF_ID]] = None

def add_entities(self, entities):
"""Set the entities associated with this device."""
self._entities.extend(entities)

@property
def is_connecting(self):
"""Return whether device is currently connecting."""
return self._connect_task is not None

@property
def connected(self):
"""Return if connected to device."""
Expand All @@ -165,13 +190,64 @@ 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()

if self._interface is not None:
await self._interface.close()
self._interface = None

self.status_updated(status)
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:
await entity.restore_state_when_connected()

def _new_entity_handler(entity_id):
self.debug(
Expand All @@ -195,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 Expand Up @@ -254,7 +315,7 @@ async def set_dp(self, state, dp_index):
try:
await self._interface.set_dp(state, dp_index)
except Exception: # pylint: disable=broad-except
self.exception("Failed to set DP %d to %d", dp_index, state)
self.exception("Failed to set DP %d to %s", dp_index, str(state))
else:
self.error(
"Not connected to device %s", self._dev_config_entry[CONF_FRIENDLY_NAME]
Expand Down Expand Up @@ -305,6 +366,17 @@ def __init__(self, device, config_entry, dp_id, logger, **kwargs):
self._config = get_entity_config(config_entry, dp_id)
self._dp_id = dp_id
self._status = {}
self._state = None
self._last_state = None

# Default value is available to be provided by Platform entities if required
self._default_value = self._config.get(CONF_DEFAULT_VALUE)

""" Restore on connect setting is available to be provided by Platform entities
if required"""
self._restore_on_reconnect = (
self._config.get(CONF_RESTORE_ON_RECONNECT) or False
)
self.set_logger(logger, self._dev_config_entry[CONF_DEVICE_ID])

async def async_added_to_hass(self):
Expand All @@ -325,6 +397,8 @@ def _update_handler(status):
self._status = status.copy()
if status:
self.status_updated()

# Update HA
self.schedule_update_ha_state()

signal = f"localtuya_{self._dev_config_entry[CONF_DEVICE_ID]}"
Expand All @@ -336,6 +410,22 @@ def _update_handler(status):
signal = f"localtuya_entity_{self._dev_config_entry[CONF_DEVICE_ID]}"
async_dispatcher_send(self.hass, signal, self.entity_id)

@property
def extra_state_attributes(self):
"""Return entity specific state attributes to be saved.
These attributes are then available for restore when the
entity is restored at startup.
"""
attributes = {}
if self._state is not None:
attributes[ATTR_STATE] = self._state
elif self._last_state is not None:
attributes[ATTR_STATE] = self._last_state

self.debug("Entity %s - Additional attributes: %s", self.name, attributes)
return attributes

@property
def device_info(self):
"""Return device information for the device registry."""
Expand Down Expand Up @@ -408,9 +498,89 @@ def status_updated(self):
Override in subclasses and update entity specific state.
"""
state = self.dps(self._dp_id)
self._state = state

# Keep record in last_state as long as not during connection/re-connection,
# as last state will be used to restore the previous state
if (state is not None) and (not self._device.is_connecting):
self._last_state = state

def status_restored(self, stored_state):
"""Device status was restored.
Override in subclasses and update entity specific state.
"""
raw_state = stored_state.attributes.get(ATTR_STATE)
if raw_state is not None:
self._last_state = raw_state
self.debug(
"Restoring state for entity: %s - state: %s",
self.name,
str(self._last_state),
)

def default_value(self):
"""Return default value of this entity.
Override in subclasses to specify the default value for the entity.
"""
# Check if default value has been set - if not, default to the entity defaults.
if self._default_value is None:
self._default_value = self.entity_default_value()

return self._default_value

def entity_default_value(self): # pylint: disable=no-self-use
"""Return default value of the entity type.
Override in subclasses to specify the default value for the entity.
"""
return 0

@property
def restore_on_reconnect(self):
"""Return whether the last state should be restored on a reconnect.
Useful where the device loses settings if powered off
"""
return self._restore_on_reconnect

async def restore_state_when_connected(self):
"""Restore if restore_on_reconnect is set, or if no status has been yet found.
Which indicates a DPS that needs to be set before it starts returning
status.
"""
if not self.restore_on_reconnect and (str(self._dp_id) in self._status):
self.debug(
"Entity %s (DP %d) - Not restoring as restore on reconnect is \
disabled for this entity and the entity has an initial status",
self.name,
self._dp_id,
)
return

self.debug("Attempting to restore state for entity: %s", self.name)
# Attempt to restore the current state - in case reset.
restore_state = self._state

# If no state stored in the entity currently, go from last saved state
if (restore_state == STATE_UNKNOWN) | (restore_state is None):
self.debug("No current state for entity")
restore_state = self._last_state

# If no current or saved state, then use the default value
if restore_state is None:
self.debug("No last restored state - using default")
restore_state = self.default_value()

self.debug(
"Entity %s (DP %d) - Restoring state: %s",
self.name,
self._dp_id,
str(restore_state),
)

# Manually initialise
await self._device.set_dp(restore_state, self._dp_id)
Loading

0 comments on commit 7142304

Please sign in to comment.