diff --git a/custom_components/localtuya/common.py b/custom_components/localtuya/common.py index 68b0d28..ea821a5 100644 --- a/custom_components/localtuya/common.py +++ b/custom_components/localtuya/common.py @@ -1,6 +1,7 @@ """Code shared between all platforms.""" import asyncio import logging +from datetime import timedelta from homeassistant.const import ( CONF_DEVICE_ID, @@ -9,8 +10,10 @@ from homeassistant.const import ( CONF_HOST, CONF_ID, CONF_PLATFORM, + CONF_SCAN_INTERVAL, ) from homeassistant.core import callback +from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -117,6 +120,7 @@ class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger): self._is_closing = False self._connect_task = None self._disconnect_task = None + self._unsub_interval = None self.set_logger(_LOGGER, config_entry[CONF_DEVICE_ID]) # This has to be done in case the device type is type_0d @@ -166,6 +170,16 @@ class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger): self._disconnect_task = async_dispatcher_connect( self._hass, signal, _new_entity_handler ) + + if ( + CONF_SCAN_INTERVAL in self._config_entry + and self._config_entry[CONF_SCAN_INTERVAL] > 0 + ): + self._unsub_interval = async_track_time_interval( + self._hass, + self._async_refresh, + timedelta(seconds=self._config_entry[CONF_SCAN_INTERVAL]), + ) except Exception: # pylint: disable=broad-except self.exception(f"Connect to {self._config_entry[CONF_HOST]} failed") if self._interface is not None: @@ -173,6 +187,10 @@ class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger): self._interface = None self._connect_task = None + async def _async_refresh(self, _now): + if self._interface is not None: + await self._interface.update_dps() + async def close(self): """Close connection and stop re-connect loop.""" self._is_closing = True @@ -223,7 +241,9 @@ class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger): """Device disconnected.""" signal = f"localtuya_{self._config_entry[CONF_DEVICE_ID]}" async_dispatcher_send(self._hass, signal, None) - + if self._unsub_interval is not None: + self._unsub_interval() + self._unsub_interval = None self._interface = None self.debug("Disconnected - waiting for discovery broadcast") @@ -253,13 +273,13 @@ class LocalTuyaEntity(RestoreEntity, pytuya.ContextualLogger): def _update_handler(status): """Update entity state when status was updated.""" - if status is not None: - self._status = status - self.status_updated() - else: - self._status = {} - - self.schedule_update_ha_state() + if status is None: + status = {} + if self._status != status: + self._status = status.copy() + if status: + self.status_updated() + self.schedule_update_ha_state() signal = f"localtuya_{self._config_entry.data[CONF_DEVICE_ID]}" diff --git a/custom_components/localtuya/config_flow.py b/custom_components/localtuya/config_flow.py index 731b60a..03bf80d 100644 --- a/custom_components/localtuya/config_flow.py +++ b/custom_components/localtuya/config_flow.py @@ -14,6 +14,7 @@ from homeassistant.const import ( CONF_HOST, CONF_ID, CONF_PLATFORM, + CONF_SCAN_INTERVAL, ) from homeassistant.core import callback @@ -44,6 +45,7 @@ BASIC_INFO_SCHEMA = vol.Schema( vol.Required(CONF_HOST): str, 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, } ) @@ -55,6 +57,7 @@ DEVICE_SCHEMA = vol.Schema( vol.Required(CONF_LOCAL_KEY): cv.string, 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, } ) @@ -90,6 +93,7 @@ def options_schema(entities): vol.Required(CONF_HOST): str, 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.Required( CONF_ENTITIES, description={"suggested_value": entity_names} ): cv.multi_select(entity_names), diff --git a/custom_components/localtuya/pytuya/__init__.py b/custom_components/localtuya/pytuya/__init__.py index 789091c..b7645ec 100644 --- a/custom_components/localtuya/pytuya/__init__.py +++ b/custom_components/localtuya/pytuya/__init__.py @@ -21,6 +21,7 @@ Functions json = status() # returns json payload set_version(version) # 3.1 [default] or 3.3 detect_available_dps() # returns a list of available dps provided by the device + update_dps(dps) # sends update dps command add_dps_to_request(dp_index) # adds dp_index to the list of dps used by the # device (to be queried in the payload) set_dp(on, dp_index) # Set value of any dps index. @@ -61,6 +62,7 @@ TuyaMessage = namedtuple("TuyaMessage", "seqno cmd retcode payload crc") SET = "set" STATUS = "status" HEARTBEAT = "heartbeat" +UPDATEDPS = "updatedps" # Request refresh of DPS PROTOCOL_VERSION_BYTES_31 = b"3.1" PROTOCOL_VERSION_BYTES_33 = b"3.3" @@ -76,6 +78,9 @@ SUFFIX_VALUE = 0x0000AA55 HEARTBEAT_INTERVAL = 10 +# DPS that are known to be safe to use with update_dps (0x12) command +UPDATE_DPS_WHITELIST = [18, 19, 20] # Socket (Wi-Fi) + # This is intended to match requests.json payload at # https://github.com/codetheweb/tuyapi : # type_0a devices require the 0a command as the status request @@ -90,11 +95,13 @@ PAYLOAD_DICT = { STATUS: {"hexByte": 0x0A, "command": {"gwId": "", "devId": ""}}, SET: {"hexByte": 0x07, "command": {"devId": "", "uid": "", "t": ""}}, HEARTBEAT: {"hexByte": 0x09, "command": {}}, + UPDATEDPS: {"hexByte": 0x12, "command": {"dpId": [18, 19, 20]}}, }, "type_0d": { STATUS: {"hexByte": 0x0D, "command": {"devId": "", "uid": "", "t": ""}}, SET: {"hexByte": 0x07, "command": {"devId": "", "uid": "", "t": ""}}, HEARTBEAT: {"hexByte": 0x09, "command": {}}, + UPDATEDPS: {"hexByte": 0x12, "command": {"dpId": [18, 19, 20]}}, }, } @@ -292,6 +299,8 @@ class MessageDispatcher(ContextualLogger): sem = self.listeners[self.HEARTBEAT_SEQNO] self.listeners[self.HEARTBEAT_SEQNO] = msg sem.release() + elif msg.cmd == 0x12: + self.debug("Got normal updatedps response") elif msg.cmd == 0x08: self.debug("Got status update") self.listener(msg) @@ -478,6 +487,26 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger): """Send a heartbeat message.""" return await self.exchange(HEARTBEAT) + async def update_dps(self, dps=None): + """ + Request device to update index. + + Args: + dps([int]): list of dps to update, default=detected&whitelisted + """ + if self.version == 3.3: + if dps is None: + if not self.dps_cache: + await self.detect_available_dps() + if self.dps_cache: + dps = [int(dp) for dp in self.dps_cache] + # filter non whitelisted dps + dps = list(set(dps).intersection(set(UPDATE_DPS_WHITELIST))) + self.debug("updatedps() entry (dps %s, dps_cache %s)", dps, self.dps_cache) + payload = self._generate_payload(UPDATEDPS, dps) + self.transport.write(payload) + return True + async def set_dp(self, value, dp_index): """ Set value (may be any type: bool, int or string) of any dps index. @@ -582,7 +611,10 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger): json_data["t"] = str(int(time.time())) if data is not None: - json_data["dps"] = data + if "dpId" in json_data: + json_data["dpId"] = data + else: + json_data["dps"] = data elif command_hb == 0x0D: json_data["dps"] = self.dps_to_request @@ -591,7 +623,7 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger): if self.version == 3.3: payload = self.cipher.encrypt(payload, False) - if command_hb != 0x0A: + if command_hb not in [0x0A, 0x12]: # add the 3.3 header payload = PROTOCOL_33_HEADER + payload elif command == SET: diff --git a/custom_components/localtuya/strings.json b/custom_components/localtuya/strings.json index 898b99d..b8bedc8 100644 --- a/custom_components/localtuya/strings.json +++ b/custom_components/localtuya/strings.json @@ -20,6 +20,7 @@ "device_id": "Device ID", "local_key": "Local key", "protocol_version": "Protocol Version", + "scan_interval": "Scan interval (seconds, only when not updating automatically)", "device_type": "Device type" } }, @@ -39,4 +40,4 @@ } }, "title": "LocalTuya" -} \ No newline at end of file +} diff --git a/custom_components/localtuya/translations/en.json b/custom_components/localtuya/translations/en.json index 5981c59..9f4b0e4 100644 --- a/custom_components/localtuya/translations/en.json +++ b/custom_components/localtuya/translations/en.json @@ -29,7 +29,8 @@ "host": "Host", "device_id": "Device ID", "local_key": "Local key", - "protocol_version": "Protocol Version" + "protocol_version": "Protocol Version", + "scan_interval": "Scan interval (seconds, only when not updating automatically)" } }, "pick_entity_type": { @@ -94,6 +95,7 @@ "host": "Host", "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)" } },