From 10e55e67b48ae2b6fcc276fc2057615da53999bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pierre=20St=C3=A5hl?= Date: Tue, 15 Dec 2020 09:08:46 +0100 Subject: [PATCH] Add support for passive devices (#171) * Add support for passive devices * Fix a few issues * Fix broken handling of closing a connection This fixes the following error: AttributeError: 'NoneType' object has no attribute 'write' * Always use discovery broadcasts to trigger connects From now on there's no connect loop but all attempts are initiated by received discovery messages. * Fix cancelling of heartbeat task * Verify entry has been loaded before connecting * Don't reset seqno when switching to type_d device --- custom_components/localtuya/__init__.py | 18 ++++++-- custom_components/localtuya/common.py | 45 ++++--------------- .../localtuya/pytuya/__init__.py | 36 ++++++++------- 3 files changed, 45 insertions(+), 54 deletions(-) diff --git a/custom_components/localtuya/__init__.py b/custom_components/localtuya/__init__.py index b3b2ae6..5b4804f 100644 --- a/custom_components/localtuya/__init__.py +++ b/custom_components/localtuya/__init__.py @@ -71,7 +71,12 @@ from homeassistant.helpers.reload import async_integration_yaml_config from .common import TuyaDevice from .config_flow import config_schema -from .const import CONF_PRODUCT_KEY, DATA_DISCOVERY, DOMAIN, TUYA_DEVICE +from .const import ( + CONF_PRODUCT_KEY, + DATA_DISCOVERY, + DOMAIN, + TUYA_DEVICE, +) from .discovery import TuyaDiscovery _LOGGER = logging.getLogger(__name__) @@ -155,11 +160,19 @@ async def async_setup(hass: HomeAssistant, config: dict): if entry.data.get(CONF_PRODUCT_KEY) != product_key: updates[CONF_PRODUCT_KEY] = product_key + # Update settings if something changed, otherwise try to connect. Updating + # settings triggers a reload of the config entry, which tears down the device + # so no need to connect in that case. if updates: _LOGGER.debug("Update keys for device %s: %s", device_id, updates) hass.config_entries.async_update_entry( entry, data={**entry.data, **updates} ) + elif entry.entry_id in hass.data[DOMAIN]: + _LOGGER.debug("Device %s found with IP %s", device_id, device_ip) + + device = hass.data[DOMAIN][entry.entry_id][TUYA_DEVICE] + device.connect() discovery = TuyaDiscovery(_device_discovered) @@ -209,7 +222,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): for platform in platforms ] ) - device.connect() hass.async_create_task(setup_entities()) @@ -230,7 +242,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): ) hass.data[DOMAIN][entry.entry_id][UNSUB_LISTENER]() - hass.data[DOMAIN][entry.entry_id][TUYA_DEVICE].close() + await hass.data[DOMAIN][entry.entry_id][TUYA_DEVICE].close() if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) diff --git a/custom_components/localtuya/common.py b/custom_components/localtuya/common.py index c6653c4..5d57220 100644 --- a/custom_components/localtuya/common.py +++ b/custom_components/localtuya/common.py @@ -1,7 +1,6 @@ """Code shared between all platforms.""" import asyncio import logging -from random import randrange from homeassistant.const import ( CONF_DEVICE_ID, @@ -29,8 +28,6 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -BACKOFF_TIME_UPPER_LIMIT = 300 # Five minutes - def prepare_setup_entities(hass, config_entry, platform): """Prepare ro setup entities for a platform.""" @@ -108,7 +105,6 @@ class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger): self._dps_to_request = {} self._is_closing = False self._connect_task = None - self._connection_attempts = 0 self.set_logger(_LOGGER, config_entry[CONF_DEVICE_ID]) # This has to be done in case the device type is type_0d @@ -116,33 +112,14 @@ class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger): self._dps_to_request[entity[CONF_ID]] = None def connect(self): - """Connet to device if not already connected.""" + """Connect to device if not already connected.""" if not self._is_closing and self._connect_task is None and not self._interface: - self.debug( - "Connecting to %s", - self._config_entry[CONF_HOST], - ) - self._connect_task = asyncio.ensure_future(self._make_connection()) - else: - self.debug( - "Already connecting to %s (%s) - %s, %s, %s", - self._config_entry[CONF_HOST], - self._config_entry[CONF_DEVICE_ID], - self._is_closing, - self._connect_task, - self._interface, - ) + self._connect_task = asyncio.create_task(self._make_connection()) async def _make_connection(self): - backoff = min( - randrange(2 ** self._connection_attempts), BACKOFF_TIME_UPPER_LIMIT - ) - - self.debug("Waiting %d seconds before connecting", backoff) - await asyncio.sleep(backoff) + self.debug("Connecting to %s", self._config_entry[CONF_HOST]) try: - self.debug("Connecting to %s", self._config_entry[CONF_HOST]) self._interface = await pytuya.connect( self._config_entry[CONF_HOST], self._config_entry[CONF_DEVICE_ID], @@ -158,23 +135,21 @@ class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger): raise Exception("Failed to retrieve status") self.status_updated(status) - self._connection_attempts = 0 except Exception: self.exception(f"Connect to {self._config_entry[CONF_HOST]} failed") - self._connection_attempts += 1 if self._interface is not None: - self._interface.close() + await self._interface.close() self._interface = None - self._hass.loop.call_soon(self.connect) self._connect_task = None - def close(self): + async def close(self): """Close connection and stop re-connect loop.""" self._is_closing = True if self._connect_task: self._connect_task.cancel() + await self._connect_task if self._interface: - self._interface.close() + await self._interface.close() async def set_dp(self, state, dp_index): """Change value of a DP of the Tuya device.""" @@ -209,15 +184,13 @@ class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger): async_dispatcher_send(self._hass, signal, self._status) @callback - def disconnected(self, exc): + def disconnected(self): """Device disconnected.""" - self.debug("Disconnected: %s", exc) - signal = f"localtuya_{self._config_entry[CONF_DEVICE_ID]}" async_dispatcher_send(self._hass, signal, None) self._interface = None - self.connect() + self.debug("Disconnected - waiting for discovery broadcast") class LocalTuyaEntity(Entity, pytuya.ContextualLogger): diff --git a/custom_components/localtuya/pytuya/__init__.py b/custom_components/localtuya/pytuya/__init__.py index d089b37..89f2aca 100644 --- a/custom_components/localtuya/pytuya/__init__.py +++ b/custom_components/localtuya/pytuya/__init__.py @@ -362,7 +362,7 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger): if "dps" in decoded_message: self.dps_cache.update(decoded_message["dps"]) - listener = self.listener() + listener = self.listener and self.listener() if listener is not None: listener.status_updated(self.dps_cache) @@ -377,12 +377,17 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger): while True: try: await self.heartbeat() + await asyncio.sleep(HEARTBEAT_INTERVAL) + except asyncio.CancelledError: + self.debug("Stopped heartbeat loop") + raise except Exception as ex: self.exception("Heartbeat failed (%s), disconnecting", ex) break - await asyncio.sleep(HEARTBEAT_INTERVAL) - self.debug("Stopped heartbeat loop") - self.close() + + transport = self.transport + self.transport = None + transport.close() self.transport = transport self.on_connected.set_result(True) @@ -396,24 +401,25 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger): """Disconnected from device.""" self.debug("Connection lost: %s", exc) try: - self.close() + listener = self.listener and self.listener() + if listener is not None: + listener.disconnected() except Exception: - self.exception("Failed to close connection") - finally: - try: - listener = self.listener() - if listener is not None: - listener.disconnected(exc) - except Exception: - self.exception("Failed to call disconnected callback") + self.exception("Failed to call disconnected callback") - def close(self): + async def close(self): """Close connection and abort all outstanding listeners.""" self.debug("Closing connection") if self.heartbeater is not None: self.heartbeater.cancel() + try: + await self.heartbeater + except asyncio.CancelledError: + pass + self.heartbeater = None if self.dispatcher is not None: self.dispatcher.abort() + self.dispatcher = None if self.transport is not None: transport = self.transport self.transport = None @@ -572,7 +578,7 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger): if data is not None: json_data["dps"] = data - if command_hb == 0x0D: + elif command_hb == 0x0D: json_data["dps"] = self.dps_to_request payload = json.dumps(json_data).replace(" ", "").encode("utf-8")