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:
@@ -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)
|
||||||
|
|
||||||
|
@@ -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):
|
||||||
|
@@ -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")
|
||||||
|
Reference in New Issue
Block a user