diff --git a/custom_components/localtuya/binary_sensor.py b/custom_components/localtuya/binary_sensor.py index 1a3d28a..0011c2f 100644 --- a/custom_components/localtuya/binary_sensor.py +++ b/custom_components/localtuya/binary_sensor.py @@ -52,6 +52,8 @@ class LocaltuyaBinarySensor(LocalTuyaEntity, BinarySensorEntity): return self._config.get(CONF_DEVICE_CLASS) def status_updated(self): + super().status_updated() + """Device status was updated.""" state = str(self.dps(self._dp_id)).lower() if state == self._config[CONF_STATE_ON].lower(): @@ -63,6 +65,11 @@ class LocaltuyaBinarySensor(LocalTuyaEntity, BinarySensorEntity): "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 diff --git a/custom_components/localtuya/common.py b/custom_components/localtuya/common.py index 79eadc9..1dcdee0 100644 --- a/custom_components/localtuya/common.py +++ b/custom_components/localtuya/common.py @@ -13,6 +13,7 @@ from homeassistant.const import ( CONF_ID, CONF_PLATFORM, CONF_SCAN_INTERVAL, + STATE_UNKNOWN, ) from homeassistant.core import callback from homeassistant.helpers.dispatcher import ( @@ -31,6 +32,9 @@ from .const import ( DATA_CLOUD, DOMAIN, TUYA_DEVICES, + CONF_DEFAULT_VALUE, + ATTR_STATE, + CONF_RESTORE_ON_RECONNECT, ) _LOGGER = logging.getLogger(__name__) @@ -91,6 +95,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) @@ -135,6 +141,7 @@ class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger): 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.set_logger(_LOGGER, self._dev_config_entry[CONF_DEVICE_ID]) @@ -142,6 +149,10 @@ class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger): 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 connected(self): """Return if connected to device.""" @@ -173,6 +184,10 @@ class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger): self.status_updated(status) + # Attempt to restore status for all entites 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( "New entity %s was added to %s", @@ -254,7 +269,7 @@ class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger): 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] @@ -305,6 +320,16 @@ class LocalTuyaEntity(RestoreEntity, pytuya.ContextualLogger): 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): @@ -325,6 +350,8 @@ class LocalTuyaEntity(RestoreEntity, pytuya.ContextualLogger): 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]}" @@ -336,6 +363,20 @@ class LocalTuyaEntity(RestoreEntity, pytuya.ContextualLogger): 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 & 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.""" @@ -408,9 +449,83 @@ class LocalTuyaEntity(RestoreEntity, pytuya.ContextualLogger): 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 (self._device._connect_task is None): + 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: + # (stored_state.state == "unavailable") | (stored_state.state == "unknown") + # ): + self._last_state = raw_state + self.debug( + "Restoring state for entity: %s - state: %s", + self.name, + str(self._last_state), + ) + + def default_value(self): + """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): + """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): + """Returns 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) diff --git a/custom_components/localtuya/config_flow.py b/custom_components/localtuya/config_flow.py index 695baa9..203c0c6 100644 --- a/custom_components/localtuya/config_flow.py +++ b/custom_components/localtuya/config_flow.py @@ -44,6 +44,7 @@ from .const import ( DATA_DISCOVERY, DOMAIN, PLATFORMS, + CONF_MANUAL_DPS, ) from .discovery import discover @@ -88,6 +89,7 @@ CONFIGURE_DEVICE_SCHEMA = vol.Schema( vol.Required(CONF_DEVICE_ID): str, 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, } ) @@ -99,6 +101,7 @@ DEVICE_SCHEMA = vol.Schema( vol.Required(CONF_FRIENDLY_NAME): cv.string, 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, } ) @@ -140,6 +143,7 @@ def options_schema(entities): vol.Required(CONF_LOCAL_KEY): str, 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.Required( CONF_ENTITIES, description={"suggested_value": entity_names} ): cv.multi_select(entity_names), @@ -240,6 +244,22 @@ async def validate_input(hass: core.HomeAssistant, data): ) detected_dps = await interface.detect_available_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(",") + _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() + if new_dps not in detected_dps: + detected_dps[new_dps] = -1 + except (ConnectionRefusedError, ConnectionResetError) as ex: raise CannotConnect from ex except ValueError as ex: @@ -253,6 +273,8 @@ async def validate_input(hass: core.HomeAssistant, data): if not detected_dps: raise EmptyDpsList + _LOGGER.debug("Total DPS: %s", detected_dps) + return dps_string_list(detected_dps) diff --git a/custom_components/localtuya/const.py b/custom_components/localtuya/const.py index c940304..993a1f1 100644 --- a/custom_components/localtuya/const.py +++ b/custom_components/localtuya/const.py @@ -35,11 +35,14 @@ CONF_PRODUCT_KEY = "product_key" CONF_PRODUCT_NAME = "product_name" CONF_USER_ID = "user_id" + CONF_ACTION = "action" CONF_ADD_DEVICE = "add_device" CONF_EDIT_DEVICE = "edit_device" CONF_SETUP_CLOUD = "setup_cloud" CONF_NO_CLOUD = "no_cloud" +CONF_MANUAL_DPS = "manual_dps_strings" +CONF_DEFAULT_VALUE = "dps_default_value" # light CONF_BRIGHTNESS_LOWER = "brightness_lower" @@ -113,3 +116,16 @@ CONF_FAULT_DP = "fault_dp" CONF_PAUSED_STATE = "paused_state" CONF_RETURN_MODE = "return_mode" CONF_STOP_STATUS = "stop_status" + +# number +CONF_MIN_VALUE = "min_value" +CONF_MAX_VALUE = "max_value" +CONF_STEPSIZE_VALUE = "step_size" + +# select +CONF_OPTIONS = "select_options" +CONF_OPTIONS_FRIENDLY = "select_options_friendly" + +# States +ATTR_STATE = "raw_state" +CONF_RESTORE_ON_RECONNECT = "restore_on_reconnect" diff --git a/custom_components/localtuya/cover.py b/custom_components/localtuya/cover.py index 2a3eb8b..d8e426f 100644 --- a/custom_components/localtuya/cover.py +++ b/custom_components/localtuya/cover.py @@ -189,6 +189,7 @@ class LocaltuyaCover(LocalTuyaEntity, CoverEntity): self.debug("Restored cover position %s", self._current_cover_position) def status_updated(self): + super.status_updated(self) """Device status was updated.""" self._previous_state = self._state self._state = self.dps(self._dp_id) diff --git a/custom_components/localtuya/number.py b/custom_components/localtuya/number.py index 596eb01..eae033f 100644 --- a/custom_components/localtuya/number.py +++ b/custom_components/localtuya/number.py @@ -5,13 +5,19 @@ from functools import partial import voluptuous as vol from homeassistant.components.number import DOMAIN, NumberEntity from homeassistant.const import CONF_DEVICE_CLASS, STATE_UNKNOWN +from homeassistant.helpers.restore_state import RestoreEntity from .common import LocalTuyaEntity, async_setup_entry _LOGGER = logging.getLogger(__name__) -CONF_MIN_VALUE = "min_value" -CONF_MAX_VALUE = "max_value" +from .const import ( + CONF_DEFAULT_VALUE, + CONF_MIN_VALUE, + CONF_MAX_VALUE, + CONF_DEFAULT_VALUE, + CONF_RESTORE_ON_RECONNECT, +) DEFAULT_MIN = 0 DEFAULT_MAX = 100000 @@ -28,10 +34,12 @@ def flow_schema(dps): vol.Coerce(float), vol.Range(min=-1000000.0, max=1000000.0), ), + vol.Optional(CONF_DEFAULT_VALUE): str, + vol.Required(CONF_RESTORE_ON_RECONNECT): bool, } -class LocaltuyaNumber(LocalTuyaEntity, NumberEntity): +class LocaltuyaNumber(LocalTuyaEntity, NumberEntity, RestoreEntity): """Representation of a Tuya Number.""" def __init__( @@ -51,6 +59,11 @@ class LocaltuyaNumber(LocalTuyaEntity, NumberEntity): self._max_value = self._config.get(CONF_MAX_VALUE) + #Override standard default value handling to cast to a float + default_value = self._config.get(CONF_DEFAULT_VALUE) + if default_value is not None: + self._default_value = float(default_value) + @property def value(self) -> float: """Return sensor state.""" @@ -75,10 +88,9 @@ class LocaltuyaNumber(LocalTuyaEntity, NumberEntity): """Update the current value.""" await self._device.set_dp(value, self._dp_id) - def status_updated(self): - """Device status was updated.""" - state = self.dps(self._dp_id) - self._state = state + # Default value is the minimum value + def entity_default_value(self): + return self._min_value async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaNumber, flow_schema) diff --git a/custom_components/localtuya/select.py b/custom_components/localtuya/select.py index 29d11c9..71f98cb 100644 --- a/custom_components/localtuya/select.py +++ b/custom_components/localtuya/select.py @@ -4,14 +4,21 @@ from functools import partial import voluptuous as vol from homeassistant.components.select import DOMAIN, SelectEntity -from homeassistant.const import CONF_DEVICE_CLASS, STATE_UNKNOWN +from homeassistant.const import ( + CONF_DEVICE_CLASS, + STATE_UNKNOWN, +) from .common import LocalTuyaEntity, async_setup_entry _LOGGER = logging.getLogger(__name__) -CONF_OPTIONS = "select_options" -CONF_OPTIONS_FRIENDLY = "select_options_friendly" +from .const import ( + CONF_OPTIONS, + CONF_OPTIONS_FRIENDLY, + CONF_DEFAULT_VALUE, + CONF_RESTORE_ON_RECONNECT, +) def flow_schema(dps): @@ -19,6 +26,8 @@ def flow_schema(dps): return { vol.Required(CONF_OPTIONS): str, vol.Optional(CONF_OPTIONS_FRIENDLY): str, + vol.Optional(CONF_DEFAULT_VALUE): str, + vol.Required(CONF_RESTORE_ON_RECONNECT): bool, } @@ -91,10 +100,23 @@ class LocaltuyaSelect(LocalTuyaEntity, SelectEntity): await self._device.set_dp(option_value, self._dp_id) def status_updated(self): + super().status_updated() """Device status was updated.""" state = self.dps(self._dp_id) - self._state_friendly = self._display_options[self._valid_options.index(state)] - self._state = state + + # Check that received status update for this entity. + if state is not None: + try: + self._state_friendly = self._display_options[ + self._valid_options.index(state) + ] + except Exception: # pylint: disable=broad-except + # Friendly value couldn't be mapped + self._state_friendly = state + + # Default value is the first option + def entity_default_value(self): + return self._valid_options[0] async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaSelect, flow_schema) diff --git a/custom_components/localtuya/sensor.py b/custom_components/localtuya/sensor.py index c8b2ddb..e769f4e 100644 --- a/custom_components/localtuya/sensor.py +++ b/custom_components/localtuya/sensor.py @@ -66,5 +66,10 @@ class LocaltuyaSensor(LocalTuyaEntity): state = round(state * scale_factor, DEFAULT_PRECISION) self._state = state + # 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, LocaltuyaSensor, flow_schema) diff --git a/custom_components/localtuya/switch.py b/custom_components/localtuya/switch.py index e884095..c448ff5 100644 --- a/custom_components/localtuya/switch.py +++ b/custom_components/localtuya/switch.py @@ -10,9 +10,12 @@ from .const import ( ATTR_CURRENT, ATTR_CURRENT_CONSUMPTION, ATTR_VOLTAGE, + ATTR_STATE, CONF_CURRENT, CONF_CURRENT_CONSUMPTION, CONF_VOLTAGE, + CONF_DEFAULT_VALUE, + CONF_RESTORE_ON_RECONNECT, ) _LOGGER = logging.getLogger(__name__) @@ -24,6 +27,8 @@ def flow_schema(dps): vol.Optional(CONF_CURRENT): vol.In(dps), vol.Optional(CONF_CURRENT_CONSUMPTION): vol.In(dps), vol.Optional(CONF_VOLTAGE): vol.In(dps), + vol.Optional(CONF_DEFAULT_VALUE): str, + vol.Required(CONF_RESTORE_ON_RECONNECT): bool, } @@ -59,6 +64,12 @@ class LocaltuyaSwitch(LocalTuyaEntity, SwitchEntity): ) if self.has_config(CONF_VOLTAGE): attrs[ATTR_VOLTAGE] = self.dps(self._config[CONF_VOLTAGE]) / 10 + + # Store the state + if self._state is not None: + attrs[ATTR_STATE] = self._state + elif self._last_state is not None: + attrs[ATTR_STATE] = self._last_state return attrs async def async_turn_on(self, **kwargs): @@ -69,9 +80,9 @@ class LocaltuyaSwitch(LocalTuyaEntity, SwitchEntity): """Turn Tuya switch off.""" await self._device.set_dp(False, self._dp_id) - def status_updated(self): - """Device status was updated.""" - self._state = self.dps(self._dp_id) + # Default value is the "OFF" state + def entity_default_value(self): + return False async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaSwitch, flow_schema) diff --git a/custom_components/localtuya/translations/en.json b/custom_components/localtuya/translations/en.json index 82f4064..a97f120 100644 --- a/custom_components/localtuya/translations/en.json +++ b/custom_components/localtuya/translations/en.json @@ -96,7 +96,8 @@ "local_key": "Local key", "protocol_version": "Protocol Version", "scan_interval": "Scan interval (seconds, only when not updating automatically)", - "entities": "Entities (uncheck an entity to remove it)" + "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)" } }, "pick_entity_type": { @@ -181,7 +182,12 @@ "preset_set": "Presets Set (optional)", "eco_dp": "Eco DP (optional)", "eco_value": "Eco value (optional)", - "heuristic_action": "Enable heuristic action (optional)" + "heuristic_action": "Enable heuristic action (optional)", + "dps_default_value": "Default value when un-initialised (optional)", + "restore_on_reconnect": "Restore the last set value in HomeAssistant after a lost connection", + "min_value": "Minimum Value", + "max_value": "Maximum Value", + "step_size": "Minimum increment between numbers" } } }