diff --git a/README.md b/README.md index 01cbba9..edb7fbd 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. + +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. 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/common.py b/custom_components/localtuya/common.py index 79eadc9..5eb6d81 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,10 @@ from .const import ( DATA_CLOUD, DOMAIN, TUYA_DEVICES, + CONF_DEFAULT_VALUE, + ATTR_STATE, + CONF_RESTORE_ON_RECONNECT, + CONF_RESET_DPIDS, ) _LOGGER = logging.getLogger(__name__) @@ -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) @@ -135,13 +142,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 +190,64 @@ 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: + 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 +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): @@ -254,7 +315,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 +366,17 @@ 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 +397,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 +410,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 +498,89 @@ 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): + 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..e70076f 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], @@ -239,7 +249,42 @@ 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 = [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 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 +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 5dea150..9b117fc 100644 --- a/custom_components/localtuya/const.py +++ b/custom_components/localtuya/const.py @@ -35,11 +35,15 @@ 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" # light CONF_BRIGHTNESS_LOWER = "brightness_lower" @@ -114,3 +118,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/manifest.json b/custom_components/localtuya/manifest.json index 67c90f4..d4f55f6 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.2", + "version": "4.1.0", "documentation": "https://github.com/rospogrigio/localtuya/", "dependencies": [], "codeowners": [ diff --git a/custom_components/localtuya/number.py b/custom_components/localtuya/number.py index ba7c3ba..e8ab53d 100644 --- a/custom_components/localtuya/number.py +++ b/custom_components/localtuya/number.py @@ -8,13 +8,19 @@ 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_MIN_VALUE = "native_min_value" -CONF_MAX_VALUE = "native_max_value" +_LOGGER = logging.getLogger(__name__) DEFAULT_MIN = 0 DEFAULT_MAX = 100000 +DEFAULT_STEP = 1.0 def flow_schema(dps): @@ -28,6 +34,12 @@ 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.Optional(CONF_DEFAULT_VALUE): str, + vol.Required(CONF_RESTORE_ON_RECONNECT): bool, } @@ -49,7 +61,18 @@ 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 native_value(self) -> float: @@ -66,6 +89,11 @@ class LocaltuyaNumber(LocalTuyaEntity, NumberEntity): """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.""" @@ -75,10 +103,10 @@ 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 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..75dd217 100644 --- a/custom_components/localtuya/select.py +++ b/custom_components/localtuya/select.py @@ -4,14 +4,19 @@ 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,9 +24,14 @@ 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, } +_LOGGER = logging.getLogger(__name__) + + class LocaltuyaSelect(LocalTuyaEntity, SelectEntity): """Representation of a Tuya Enumeration.""" @@ -92,9 +102,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 0de4543..25d09fe 100644 --- a/custom_components/localtuya/strings.json +++ b/custom_components/localtuya/strings.json @@ -121,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..40e8ea1 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,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 defaualt 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 909a69c..2fa8141 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": { @@ -182,7 +184,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" } } } 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.