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
This commit is contained in:
Pierre Ståhl
2020-12-15 09:08:46 +01:00
committed by GitHub
parent 1d58abd0f2
commit 10e55e67b4
3 changed files with 45 additions and 54 deletions

View File

@@ -71,7 +71,12 @@ from homeassistant.helpers.reload import async_integration_yaml_config
from .common import TuyaDevice from .common import TuyaDevice
from .config_flow import config_schema 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 from .discovery import TuyaDiscovery
_LOGGER = logging.getLogger(__name__) _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: if entry.data.get(CONF_PRODUCT_KEY) != product_key:
updates[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: if updates:
_LOGGER.debug("Update keys for device %s: %s", device_id, updates) _LOGGER.debug("Update keys for device %s: %s", device_id, updates)
hass.config_entries.async_update_entry( hass.config_entries.async_update_entry(
entry, data={**entry.data, **updates} 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) discovery = TuyaDiscovery(_device_discovered)
@@ -209,7 +222,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
for platform in platforms for platform in platforms
] ]
) )
device.connect()
hass.async_create_task(setup_entities()) 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][UNSUB_LISTENER]()
hass.data[DOMAIN][entry.entry_id][TUYA_DEVICE].close() await hass.data[DOMAIN][entry.entry_id][TUYA_DEVICE].close()
if unload_ok: if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id) hass.data[DOMAIN].pop(entry.entry_id)

View File

@@ -1,7 +1,6 @@
"""Code shared between all platforms.""" """Code shared between all platforms."""
import asyncio import asyncio
import logging import logging
from random import randrange
from homeassistant.const import ( from homeassistant.const import (
CONF_DEVICE_ID, CONF_DEVICE_ID,
@@ -29,8 +28,6 @@ from .const import (
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
BACKOFF_TIME_UPPER_LIMIT = 300 # Five minutes
def prepare_setup_entities(hass, config_entry, platform): def prepare_setup_entities(hass, config_entry, platform):
"""Prepare ro setup entities for a platform.""" """Prepare ro setup entities for a platform."""
@@ -108,7 +105,6 @@ class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger):
self._dps_to_request = {} self._dps_to_request = {}
self._is_closing = False self._is_closing = False
self._connect_task = None self._connect_task = None
self._connection_attempts = 0
self.set_logger(_LOGGER, config_entry[CONF_DEVICE_ID]) self.set_logger(_LOGGER, config_entry[CONF_DEVICE_ID])
# This has to be done in case the device type is type_0d # 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 self._dps_to_request[entity[CONF_ID]] = None
def connect(self): 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: if not self._is_closing and self._connect_task is None and not self._interface:
self.debug( self._connect_task = asyncio.create_task(self._make_connection())
"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,
)
async def _make_connection(self): async def _make_connection(self):
backoff = min( self.debug("Connecting to %s", self._config_entry[CONF_HOST])
randrange(2 ** self._connection_attempts), BACKOFF_TIME_UPPER_LIMIT
)
self.debug("Waiting %d seconds before connecting", backoff)
await asyncio.sleep(backoff)
try: try:
self.debug("Connecting to %s", self._config_entry[CONF_HOST])
self._interface = await pytuya.connect( self._interface = await pytuya.connect(
self._config_entry[CONF_HOST], self._config_entry[CONF_HOST],
self._config_entry[CONF_DEVICE_ID], self._config_entry[CONF_DEVICE_ID],
@@ -158,23 +135,21 @@ class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger):
raise Exception("Failed to retrieve status") raise Exception("Failed to retrieve status")
self.status_updated(status) self.status_updated(status)
self._connection_attempts = 0
except Exception: except Exception:
self.exception(f"Connect to {self._config_entry[CONF_HOST]} failed") self.exception(f"Connect to {self._config_entry[CONF_HOST]} failed")
self._connection_attempts += 1
if self._interface is not None: if self._interface is not None:
self._interface.close() await self._interface.close()
self._interface = None self._interface = None
self._hass.loop.call_soon(self.connect)
self._connect_task = None self._connect_task = None
def close(self): async def close(self):
"""Close connection and stop re-connect loop.""" """Close connection and stop re-connect loop."""
self._is_closing = True self._is_closing = True
if self._connect_task: if self._connect_task:
self._connect_task.cancel() self._connect_task.cancel()
await self._connect_task
if self._interface: if self._interface:
self._interface.close() await self._interface.close()
async def set_dp(self, state, dp_index): async def set_dp(self, state, dp_index):
"""Change value of a DP of the Tuya device.""" """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) async_dispatcher_send(self._hass, signal, self._status)
@callback @callback
def disconnected(self, exc): def disconnected(self):
"""Device disconnected.""" """Device disconnected."""
self.debug("Disconnected: %s", exc)
signal = f"localtuya_{self._config_entry[CONF_DEVICE_ID]}" signal = f"localtuya_{self._config_entry[CONF_DEVICE_ID]}"
async_dispatcher_send(self._hass, signal, None) async_dispatcher_send(self._hass, signal, None)
self._interface = None self._interface = None
self.connect() self.debug("Disconnected - waiting for discovery broadcast")
class LocalTuyaEntity(Entity, pytuya.ContextualLogger): class LocalTuyaEntity(Entity, pytuya.ContextualLogger):

View File

@@ -362,7 +362,7 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger):
if "dps" in decoded_message: if "dps" in decoded_message:
self.dps_cache.update(decoded_message["dps"]) self.dps_cache.update(decoded_message["dps"])
listener = self.listener() listener = self.listener and self.listener()
if listener is not None: if listener is not None:
listener.status_updated(self.dps_cache) listener.status_updated(self.dps_cache)
@@ -377,12 +377,17 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger):
while True: while True:
try: try:
await self.heartbeat() await self.heartbeat()
await asyncio.sleep(HEARTBEAT_INTERVAL)
except asyncio.CancelledError:
self.debug("Stopped heartbeat loop")
raise
except Exception as ex: except Exception as ex:
self.exception("Heartbeat failed (%s), disconnecting", ex) self.exception("Heartbeat failed (%s), disconnecting", ex)
break break
await asyncio.sleep(HEARTBEAT_INTERVAL)
self.debug("Stopped heartbeat loop") transport = self.transport
self.close() self.transport = None
transport.close()
self.transport = transport self.transport = transport
self.on_connected.set_result(True) self.on_connected.set_result(True)
@@ -396,24 +401,25 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger):
"""Disconnected from device.""" """Disconnected from device."""
self.debug("Connection lost: %s", exc) self.debug("Connection lost: %s", exc)
try: try:
self.close() listener = self.listener and self.listener()
if listener is not None:
listener.disconnected()
except Exception: except Exception:
self.exception("Failed to close connection") self.exception("Failed to call disconnected callback")
finally:
try:
listener = self.listener()
if listener is not None:
listener.disconnected(exc)
except Exception:
self.exception("Failed to call disconnected callback")
def close(self): async def close(self):
"""Close connection and abort all outstanding listeners.""" """Close connection and abort all outstanding listeners."""
self.debug("Closing connection") self.debug("Closing connection")
if self.heartbeater is not None: if self.heartbeater is not None:
self.heartbeater.cancel() self.heartbeater.cancel()
try:
await self.heartbeater
except asyncio.CancelledError:
pass
self.heartbeater = None
if self.dispatcher is not None: if self.dispatcher is not None:
self.dispatcher.abort() self.dispatcher.abort()
self.dispatcher = None
if self.transport is not None: if self.transport is not None:
transport = self.transport transport = self.transport
self.transport = None self.transport = None
@@ -572,7 +578,7 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger):
if data is not None: if data is not None:
json_data["dps"] = data json_data["dps"] = data
if command_hb == 0x0D: elif command_hb == 0x0D:
json_data["dps"] = self.dps_to_request json_data["dps"] = self.dps_to_request
payload = json.dumps(json_data).replace(" ", "").encode("utf-8") payload = json.dumps(json_data).replace(" ", "").encode("utf-8")