diff --git a/README.md b/README.md index 6948d91..a14e3a1 100644 --- a/README.md +++ b/README.md @@ -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. **Note: Any DPS added using this option will have a -1 value during setup.** + +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). This scenario mostly occurs when the device is blocked from accessing the internet. The DPids will vary between devices, but typically "18,19,20" is used. 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. @@ -103,8 +107,11 @@ After you have defined all the needed entities, leave the "Do not add more entit ![entity_type](https://github.com/rospogrigio/localtuya-homeassistant/blob/master/img/3-entity_type.png) For each entity, the associated DP has to be selected. All the options requiring to select a DP will provide a drop-down menu showing -all the available DPs found on the device (with their current status!!) for easy identification. Each entity type has different options -to be configured. Here is an example for the "switch" entity: +all the available DPs found on the device (with their current status!!) for easy identification. + +**Note: If your device requires an LocalTuya to send an initialisation value to the entity for it to work, this can be configured (in supported entities) through the 'Passive entity' option. Optionally you can specify the initialisation value to be sent** + +Each entity type has different options to be configured. Here is an example for the "switch" entity: ![entity](https://github.com/rospogrigio/localtuya-homeassistant/blob/master/img/4-entity.png) diff --git a/custom_components/localtuya/binary_sensor.py b/custom_components/localtuya/binary_sensor.py index 1a3d28a..273880c 100644 --- a/custom_components/localtuya/binary_sensor.py +++ b/custom_components/localtuya/binary_sensor.py @@ -53,6 +53,8 @@ class LocaltuyaBinarySensor(LocalTuyaEntity, BinarySensorEntity): 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 @@ -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/climate.py b/custom_components/localtuya/climate.py index 1400ec7..f6433b2 100644 --- a/custom_components/localtuya/climate.py +++ b/custom_components/localtuya/climate.py @@ -72,6 +72,10 @@ HVAC_MODE_SETS = { "True/False": { HVAC_MODE_HEAT: True, }, + "1/0": { + HVAC_MODE_HEAT: "1", + HVAC_MODE_AUTO: "0", + }, } HVAC_ACTION_SETS = { "True/False": { diff --git a/custom_components/localtuya/common.py b/custom_components/localtuya/common.py index 79eadc9..aa8992f 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,11 @@ from .const import ( DATA_CLOUD, DOMAIN, TUYA_DEVICES, + CONF_DEFAULT_VALUE, + ATTR_STATE, + CONF_RESTORE_ON_RECONNECT, + CONF_RESET_DPIDS, + CONF_PASSIVE_ENTITY, ) _LOGGER = logging.getLogger(__name__) @@ -91,6 +97,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,13 +143,31 @@ 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._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.""" @@ -165,13 +191,67 @@ 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: + if (self._default_reset_dpids is not None) and ( + len(self._default_reset_dpids) > 0 + ): + 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: + await entity.restore_state_when_connected() def _new_entity_handler(entity_id): self.debug( @@ -195,22 +275,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): @@ -254,7 +319,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 +370,20 @@ 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) + + # Determine whether is a passive entity + self._is_passive_entity = self._config.get(CONF_PASSIVE_ENTITY) or False + + """ 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 +404,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 +417,22 @@ 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. + + 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.""" @@ -408,9 +505,96 @@ 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 (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) or (not self._is_passive_entity) + ): + self.debug( + "Entity %s (DP %d) - Not restoring as restore on reconnect is " + + "disabled for this entity and the entity has an initial status " + + "or it is not a passive entity", + 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: + if self._is_passive_entity: + self.debug("No last restored state - using default") + restore_state = self.default_value() + else: + self.debug("Not a passive entity and no state found - aborting restore") + return + + 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..1eeb3b5 100644 --- a/custom_components/localtuya/config_flow.py +++ b/custom_components/localtuya/config_flow.py @@ -38,12 +38,14 @@ from .const import ( CONF_NO_CLOUD, CONF_PRODUCT_NAME, CONF_PROTOCOL_VERSION, + CONF_RESET_DPIDS, CONF_SETUP_CLOUD, CONF_USER_ID, DATA_CLOUD, DATA_DISCOVERY, DOMAIN, PLATFORMS, + CONF_MANUAL_DPS, ) from .discovery import discover @@ -88,6 +90,8 @@ 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, + vol.Optional(CONF_RESET_DPIDS): str, } ) @@ -99,6 +103,8 @@ 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, + vol.Optional(CONF_RESET_DPIDS): str, } ) @@ -140,6 +146,8 @@ 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.Optional(CONF_RESET_DPIDS): str, vol.Required( CONF_ENTITIES, description={"suggested_value": entity_names} ): cv.multi_select(entity_names), @@ -231,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], @@ -238,8 +248,43 @@ async def validate_input(hass: core.HomeAssistant, data): data[CONF_LOCAL_KEY], float(data[CONF_PROTOCOL_VERSION]), ) + 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, + ) + 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 len(reset_ids) > 0: + 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 = [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 + (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 str(new_dps) not in detected_dps: + detected_dps[new_dps] = -1 - detected_dps = await interface.detect_available_dps() except (ConnectionRefusedError, ConnectionResetError) as ex: raise CannotConnect from ex except ValueError as ex: @@ -253,6 +298,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..8010d18 100644 --- a/custom_components/localtuya/const.py +++ b/custom_components/localtuya/const.py @@ -35,11 +35,16 @@ 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" +CONF_RESET_DPIDS = "reset_dpids" +CONF_PASSIVE_ENTITY = "is_passive_entity" # light CONF_BRIGHTNESS_LOWER = "brightness_lower" @@ -73,6 +78,7 @@ CONF_FAN_ORDERED_LIST = "fan_speed_ordered_list" CONF_FAN_DIRECTION = "fan_direction" CONF_FAN_DIRECTION_FWD = "fan_direction_forward" CONF_FAN_DIRECTION_REV = "fan_direction_reverse" +CONF_FAN_DPS_TYPE = "fan_dps_type" # sensor CONF_SCALING = "scaling" @@ -113,3 +119,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..3b6b86d 100644 --- a/custom_components/localtuya/cover.py +++ b/custom_components/localtuya/cover.py @@ -228,5 +228,10 @@ class LocaltuyaCover(LocalTuyaEntity, CoverEntity): # store the time of the last movement change self._timer_start = time.time() + # 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 (self._state is not None) and (not self._device.is_connecting): + self._last_state = self._state + async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaCover, flow_schema) diff --git a/custom_components/localtuya/fan.py b/custom_components/localtuya/fan.py index d2b4583..584ea84 100644 --- a/custom_components/localtuya/fan.py +++ b/custom_components/localtuya/fan.py @@ -32,6 +32,7 @@ from .const import ( CONF_FAN_SPEED_CONTROL, CONF_FAN_SPEED_MAX, CONF_FAN_SPEED_MIN, + CONF_FAN_DPS_TYPE, ) _LOGGER = logging.getLogger(__name__) @@ -48,6 +49,7 @@ def flow_schema(dps): vol.Optional(CONF_FAN_SPEED_MIN, default=1): cv.positive_int, vol.Optional(CONF_FAN_SPEED_MAX, default=9): cv.positive_int, vol.Optional(CONF_FAN_ORDERED_LIST, default="disabled"): cv.string, + vol.Optional(CONF_FAN_DPS_TYPE, default="str"): vol.In(["str", "int"]), } @@ -73,6 +75,7 @@ class LocaltuyaFan(LocalTuyaEntity, FanEntity): ) self._ordered_list = self._config.get(CONF_FAN_ORDERED_LIST).split(",") self._ordered_list_mode = None + self._dps_type = int if self._config.get(CONF_FAN_DPS_TYPE) == "int" else str if isinstance(self._ordered_list, list) and len(self._ordered_list) > 1: self._use_ordered_list = True @@ -138,7 +141,7 @@ class LocaltuyaFan(LocalTuyaEntity, FanEntity): await self.async_turn_on() if self._use_ordered_list: await self._device.set_dp( - str( + self._dps_type( percentage_to_ordered_list_item(self._ordered_list, percentage) ), self._config.get(CONF_FAN_SPEED_CONTROL), @@ -151,7 +154,7 @@ class LocaltuyaFan(LocalTuyaEntity, FanEntity): else: await self._device.set_dp( - str( + self._dps_type( math.ceil( percentage_to_ranged_value(self._speed_range, percentage) ) @@ -221,7 +224,7 @@ class LocaltuyaFan(LocalTuyaEntity, FanEntity): ) if current_speed is not None: self._percentage = ordered_list_item_to_percentage( - self._ordered_list, current_speed + self._ordered_list, str(current_speed) ) else: diff --git a/custom_components/localtuya/manifest.json b/custom_components/localtuya/manifest.json index bfaf4cc..10b1b5a 100644 --- a/custom_components/localtuya/manifest.json +++ b/custom_components/localtuya/manifest.json @@ -1,7 +1,7 @@ { "domain": "localtuya", "name": "LocalTuya integration", - "version": "4.0.1", + "version": "4.1.1", "documentation": "https://github.com/rospogrigio/localtuya/", "dependencies": [], "codeowners": [ diff --git a/custom_components/localtuya/number.py b/custom_components/localtuya/number.py index 596eb01..23d7ea9 100644 --- a/custom_components/localtuya/number.py +++ b/custom_components/localtuya/number.py @@ -8,13 +8,20 @@ from homeassistant.const import CONF_DEVICE_CLASS, STATE_UNKNOWN from .common import LocalTuyaEntity, async_setup_entry -_LOGGER = logging.getLogger(__name__) +from .const import ( + CONF_MIN_VALUE, + CONF_MAX_VALUE, + CONF_DEFAULT_VALUE, + CONF_RESTORE_ON_RECONNECT, + CONF_STEPSIZE_VALUE, + CONF_PASSIVE_ENTITY, +) -CONF_MIN_VALUE = "min_value" -CONF_MAX_VALUE = "max_value" +_LOGGER = logging.getLogger(__name__) DEFAULT_MIN = 0 DEFAULT_MAX = 100000 +DEFAULT_STEP = 1.0 def flow_schema(dps): @@ -28,6 +35,13 @@ def flow_schema(dps): vol.Coerce(float), vol.Range(min=-1000000.0, max=1000000.0), ), + vol.Required(CONF_STEPSIZE_VALUE, default=DEFAULT_STEP): vol.All( + vol.Coerce(float), + vol.Range(min=0.0, max=1000000.0), + ), + vol.Required(CONF_RESTORE_ON_RECONNECT): bool, + vol.Required(CONF_PASSIVE_ENTITY): bool, + vol.Optional(CONF_DEFAULT_VALUE): str, } @@ -49,36 +63,52 @@ class LocaltuyaNumber(LocalTuyaEntity, NumberEntity): if CONF_MIN_VALUE in self._config: self._min_value = self._config.get(CONF_MIN_VALUE) - self._max_value = self._config.get(CONF_MAX_VALUE) + self._max_value = DEFAULT_MAX + if CONF_MAX_VALUE in self._config: + self._max_value = self._config.get(CONF_MAX_VALUE) + + self._step_size = DEFAULT_STEP + if CONF_STEPSIZE_VALUE in self._config: + self._step_size = self._config.get(CONF_STEPSIZE_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: + def native_value(self) -> float: """Return sensor state.""" return self._state @property - def min_value(self) -> float: + def native_min_value(self) -> float: """Return the minimum value.""" return self._min_value @property - def max_value(self) -> float: + def native_max_value(self) -> float: """Return the maximum value.""" return self._max_value + @property + def native_step(self) -> float: + """Return the maximum value.""" + return self._step_size + @property def device_class(self): """Return the class of this device.""" return self._config.get(CONF_DEVICE_CLASS) - async def async_set_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """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 the minimum value as the default for this entity type.""" + return self._min_value async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaNumber, flow_schema) 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/select.py b/custom_components/localtuya/select.py index 29d11c9..f643e08 100644 --- a/custom_components/localtuya/select.py +++ b/custom_components/localtuya/select.py @@ -4,14 +4,20 @@ 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, + CONF_PASSIVE_ENTITY, +) def flow_schema(dps): @@ -19,9 +25,15 @@ def flow_schema(dps): return { vol.Required(CONF_OPTIONS): str, vol.Optional(CONF_OPTIONS_FRIENDLY): str, + vol.Required(CONF_RESTORE_ON_RECONNECT): bool, + vol.Required(CONF_PASSIVE_ENTITY): bool, + vol.Optional(CONF_DEFAULT_VALUE): str, } +_LOGGER = logging.getLogger(__name__) + + class LocaltuyaSelect(LocalTuyaEntity, SelectEntity): """Representation of a Tuya Enumeration.""" @@ -92,9 +104,24 @@ class LocaltuyaSelect(LocalTuyaEntity, SelectEntity): def status_updated(self): """Device status was updated.""" + super().status_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 the first option as the default value for this entity type.""" + 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..0eb0ae4 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/strings.json b/custom_components/localtuya/strings.json index 4db7e70..25d09fe 100644 --- a/custom_components/localtuya/strings.json +++ b/custom_components/localtuya/strings.json @@ -104,6 +104,7 @@ "fan_direction":"fan direction dps", "fan_direction_forward": "forward dps string", "fan_direction_reverse": "reverse dps string", + "fan_dps_type": "DP value type", "current_temperature_dp": "Current Temperature", "target_temperature_dp": "Target Temperature", "temperature_step": "Temperature Step (optional)", @@ -120,7 +121,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" } }, "yaml_import": { diff --git a/custom_components/localtuya/switch.py b/custom_components/localtuya/switch.py index e884095..bc664bf 100644 --- a/custom_components/localtuya/switch.py +++ b/custom_components/localtuya/switch.py @@ -10,9 +10,13 @@ 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, + CONF_PASSIVE_ENTITY, ) _LOGGER = logging.getLogger(__name__) @@ -24,6 +28,9 @@ 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.Required(CONF_RESTORE_ON_RECONNECT): bool, + vol.Required(CONF_PASSIVE_ENTITY): bool, + vol.Optional(CONF_DEFAULT_VALUE): str, } @@ -59,6 +66,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 +82,10 @@ 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 as the default value for this entity type.""" + 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..4b3ddb0 100644 --- a/custom_components/localtuya/translations/en.json +++ b/custom_components/localtuya/translations/en.json @@ -96,7 +96,9 @@ "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)", + "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": { @@ -165,6 +167,7 @@ "fan_direction":"fan direction dps", "fan_direction_forward": "forward dps string", "fan_direction_reverse": "reverse dps string", + "fan_dps_type": "DP value type", "current_temperature_dp": "Current Temperature", "target_temperature_dp": "Target Temperature", "temperature_step": "Temperature Step (optional)", @@ -181,7 +184,13 @@ "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", + "is_passive_entity": "Passive entity - requires integration to send initialisation value" } } } diff --git a/img/2-device.png b/img/2-device.png index b82b235..4e6623d 100644 Binary files a/img/2-device.png and b/img/2-device.png differ diff --git a/info.md b/info.md index 900b0e0..c346691 100644 --- a/info.md +++ b/info.md @@ -96,10 +96,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. After you have defined all the needed entities, leave the "Do not add more entities" checkbox checked: this will complete the procedure.