Skip to content

Commit

Permalink
Add Support for low-power devices #139.
Browse files Browse the repository at this point in the history
* So low-power devices e.g. sensors will reports it states each X time, then it goes into sleep mode which it will turns off wifi too. in-order to fix this I added an option that will wait for the devices to wake-up before it goes unavailable.
  • Loading branch information
xZetsubou committed Mar 5, 2024
1 parent 97868af commit bb7ab44
Show file tree
Hide file tree
Showing 12 changed files with 153 additions and 63 deletions.
13 changes: 7 additions & 6 deletions custom_components/localtuya/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@

UNSUB_LISTENER = "unsub_listener"

RECONNECT_INTERVAL = timedelta(seconds=60)
RECONNECT_INTERVAL = timedelta(seconds=5)
RECONNECT_TASK = "localtuya_reconnect_interval"

CONFIG_SCHEMA = config_schema()
Expand Down Expand Up @@ -113,6 +113,9 @@ def _device_discovered(device: dict):
if entry is None:
return

if device := hass.data[DOMAIN][entry.entry_id].devices.get(device_ip):
...

if device_id not in device_cache or device_id not in device_cache.get(
device_id, {}
):
Expand Down Expand Up @@ -367,7 +370,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass_data: HassLocalTuyaData = hass.data[DOMAIN][entry.entry_id]

for dev in hass_data.devices.values():
disconnect_devices.append(dev.close())
if dev.connected:
disconnect_devices.append(dev.close())
for entity in dev._device_config[CONF_ENTITIES]:
platforms[entity[CONF_PLATFORM]] = True

Expand All @@ -376,10 +380,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:

# Close all connection to the devices.
# Just to prevent the loop get stuck in-case it calls multiples quickly
try:
await asyncio.wait_for(asyncio.gather(*disconnect_devices), 3)
except:
pass
await asyncio.gather(*disconnect_devices)

# Unsub events.
[unsub() for unsub in hass_data.unsub_listeners]
Expand Down
125 changes: 98 additions & 27 deletions custom_components/localtuya/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
import time
from datetime import timedelta
from typing import Any, Callable, Coroutine, NamedTuple
from dataclasses import dataclass

from homeassistant.core import HomeAssistant, CALLBACK_TYPE, callback
from homeassistant.core import HomeAssistant, CALLBACK_TYPE, callback, State
from homeassistant.config_entries import ConfigEntry

from homeassistant.const import (
Expand All @@ -22,8 +23,8 @@
STATE_UNKNOWN,
CONF_ENTITY_CATEGORY,
EntityCategory,
CONF_TYPE,
CONF_ICON,
STATE_UNAVAILABLE,
)
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import (
Expand Down Expand Up @@ -53,9 +54,12 @@
ENTITY_CATEGORY,
CONF_GATEWAY_ID,
CONF_SCALING,
CONF_DEVICE_SLEEP_TIME,
CONF_DPS_STRINGS,
)

_LOGGER = logging.getLogger(__name__)
RESTORE_STATES = {"0": "restore"}


async def async_setup_entry(
Expand Down Expand Up @@ -163,11 +167,17 @@ def __init__(
self._sub_devices: dict[str, TuyaDevice] = {}

self._status = {}
# Sleep timer, a device that reports the status every x seconds then goes into sleep.
self._passive_device = self._device_config.get(CONF_DEVICE_SLEEP_TIME, 0) > 0
self._last_update_time: int = int(time.time()) - 5
self._pending_status: dict[str, dict[str, Any]] = {}

self.dps_to_request = {}
self._is_closing = False
self._connect_task: asyncio.Task | None = None
self._disconnect_task: Callable[[], None] | None = None
self._unsub_interval: Callable[[], None] = None
self._unsub_interval: CALLBACK_TYPE[[], None] = None
self._shutdown_entities_delay: CALLBACK_TYPE[[], None] = None
self._entities = []
self._local_key: str = self._device_config[CONF_LOCAL_KEY]
self._default_reset_dpids: list | None = None
Expand Down Expand Up @@ -203,6 +213,13 @@ def is_subdevice(self):
"""Return whether this is a subdevice or not."""
return self._node_id and not self._fake_gateway

@property
def is_sleep(self):
"""Return whether the device is sleep or not."""
device_sleep = self._device_config.get(CONF_DEVICE_SLEEP_TIME, 0)
last_update = int(time.time()) - self._last_update_time
return last_update < device_sleep if device_sleep > 0 else False

async def get_gateway(self):
"""Return the gateway device of this sub device."""
if not self._node_id:
Expand All @@ -227,15 +244,17 @@ async def async_connect(self, _now=None) -> None:

if not self._is_closing and not self.is_connecting and not self.connected:
try:
self._connect_task = self._hass.async_create_task(
self._make_connection()
)
await self._connect_task
self._connect_task = asyncio.create_task(self._make_connection())
if not self.is_sleep:
await self._connect_task
except (TimeoutError, asyncio.CancelledError):
...

async def _make_connection(self):
"""Subscribe localtuya entity events."""
if self.is_sleep and not self._status:
self.status_updated(RESTORE_STATES)

name = self._device_config.get(CONF_FRIENDLY_NAME)
host = name if self.is_subdevice else self._device_config.get(CONF_HOST)
retry = 0
Expand All @@ -249,20 +268,26 @@ async def _make_connection(self):
return await self.abort_connect()
self._interface = gateway._interface
else:
self._interface = await pytuya.connect(
self._device_config[CONF_HOST],
self._device_config[CONF_DEVICE_ID],
self._local_key,
float(self._device_config[CONF_PROTOCOL_VERSION]),
self._device_config.get(CONF_ENABLE_DEBUG, False),
self,
self._interface = await asyncio.wait_for(
pytuya.connect(
self._device_config[CONF_HOST],
self._device_config[CONF_DEVICE_ID],
self._local_key,
float(self._device_config[CONF_PROTOCOL_VERSION]),
self._device_config.get(CONF_ENABLE_DEBUG, False),
self,
),
5,
)
self._interface.add_dps_to_request(self.dps_to_request)
break # Succeed break while loop
except Exception as ex: # pylint: disable=broad-except
if not retry < self._connect_max_tries:
self.warning(f"Failed to connect to {host}: {str(ex)}")
await self.abort_connect()
if not retry < self._connect_max_tries and not self.is_sleep:
self.warning(f"Failed to connect to {host}: {str(ex)}")
# if self.is_sleep and not self._status:
# self.status_updated(RESTORE_STATES)
# break

if self._interface is not None:
try:
Expand Down Expand Up @@ -331,6 +356,10 @@ def _new_entity_handler(entity_id):
]
await asyncio.gather(*connect_sub_devices)

if self._pending_status:
await self.set_dps(self._pending_status)
self._pending_status = {}

self._connect_task = None

async def abort_connect(self):
Expand All @@ -353,14 +382,16 @@ async def check_connection(self):
async def close(self):
"""Close connection and stop re-connect loop."""
self._is_closing = True
if self._shutdown_entities_delay is not None:
self._shutdown_entities_delay()
if self._connect_task is not None:
self._connect_task.cancel()
await self._connect_task
self._connect_task = None
if self._interface is not None:
await self._interface.close()
self._interface = None
if self._disconnect_task is not None:
if self._disconnect_task:
self._disconnect_task()
self.debug(
f"Closed connection with {self._device_config[CONF_FRIENDLY_NAME]}",
Expand All @@ -387,11 +418,6 @@ async def update_local_key(self):
)
self.info(f"local_key updated for device {dev_id}.")

async def _async_refresh(self, _now):
if self._interface is not None:
self.debug("Refreshing dps for device")
await self._interface.update_dps(cid=self._node_id)

async def set_dp(self, state, dp_index):
"""Change value of a DP of the Tuya device."""
await self.check_connection()
Expand All @@ -401,6 +427,8 @@ async def set_dp(self, state, dp_index):
except Exception: # pylint: disable=broad-except
self.debug(f"Failed to set DP {dp_index} to {str(state)}", force=True)
else:
if self.is_sleep:
return self._pending_status.update({str(dp_index): state})
self.error(
f"Not connected to device {self._device_config[CONF_FRIENDLY_NAME]}"
)
Expand All @@ -414,10 +442,17 @@ async def set_dps(self, states):
except Exception: # pylint: disable=broad-except
self.debug(f"Failed to set DPs {states}")
else:
if self.is_sleep:
return self._pending_status.update(states)
self.error(
f"Not connected to device {self._device_config[CONF_FRIENDLY_NAME]}"
)

async def _async_refresh(self, _now):
if self._interface is not None:
self.debug("Refreshing dps for device")
await self._interface.update_dps(cid=self._node_id)

def _dispatch_status(self):
signal = f"localtuya_{self._device_config[CONF_DEVICE_ID]}"
async_dispatcher_send(self._hass, signal, self._status)
Expand Down Expand Up @@ -464,17 +499,24 @@ def status_updated(self, status: dict):
if self._fake_gateway:
# Fake gateways are only used to pass commands no need to update status.
return
self._last_update_time = int(time.time())

self._handle_event(self._status, status)
self._status.update(status)
self._dispatch_status()

@callback
def disconnected(self):
"""Device disconnected."""
sleep_time = self._device_config.get(CONF_DEVICE_SLEEP_TIME, 0)

def shutdown_entities(now=None):
"""Shutdown device entities"""
self._shutdown_entities_delay = None
if self.is_sleep:
return
if not self.connected:
self.debug(f"Disconnected: waiting for discovery broadcast", force=True)
signal = f"localtuya_{self._device_config[CONF_DEVICE_ID]}"
async_dispatcher_send(self._hass, signal, None)

Expand All @@ -493,12 +535,13 @@ def shutdown_entities(now=None):

# If it disconnects unexpectedly.
if self._is_closing is not True and not self.is_subdevice:
self.debug(f"Disconnected - waiting for discovery broadcast", force=True)
# Try to quickly reconnect.
# Try quick reconnect.
self._is_closing = False
self._config_entry.async_create_task(self._hass, self.async_connect())
asyncio.create_task(self.async_connect())
if not self._is_closing:
async_call_later(self._hass, 5, shutdown_entities)
self._shutdown_entities_delay = async_call_later(
self._hass, sleep_time + 3, shutdown_entities
)


class LocalTuyaEntity(RestoreEntity, pytuya.ContextualLogger):
Expand Down Expand Up @@ -539,11 +582,17 @@ async def async_added_to_hass(self):
if state:
self.status_restored(state)

def _update_handler(status):
async def _update_handler(status):
"""Update entity state when status was updated."""
if status is None:
status = {}
if self._status != status:
if status == RESTORE_STATES:
status = {}
stored_data: State = await self.async_get_last_state()
self.debug(f"Device is sleep restored state: {stored_data.state}")
if stored_data and stored_data.state != STATE_UNAVAILABLE:
status = {self._dp_id: stored_data.state}
self._status = status.copy()
if status:
self.status_updated()
Expand Down Expand Up @@ -755,3 +804,25 @@ class HassLocalTuyaData(NamedTuple):
cloud_data: TuyaCloudApi
devices: dict[str, TuyaDevice]
unsub_listeners: list[CALLBACK_TYPE,]


@dataclass
class DeviceConfig:
"""Represent Main Device Config."""

device_config: dict[str, Any]

def __post_init__(self) -> None:
self.id: str = self.device_config[CONF_DEVICE_ID]
self.host: str = self.device_config[CONF_HOST]
self.local_key: str = self.device_config[CONF_LOCAL_KEY]
self.entities: list = self.device_config[CONF_ENTITIES]
self.protocol_version: str = self.device_config[CONF_PROTOCOL_VERSION]
self.sleep_time = self.device_config.get(CONF_DEVICE_SLEEP_TIME, 0)
self.scan_interval = self.device_config.get(CONF_SCAN_INTERVAL, 0)
self.enable_debug = self.device_config.get(CONF_ENABLE_DEBUG, False)
self.name: str = self.device_config.get(CONF_FRIENDLY_NAME)
self.node_id = self.device_config.get(CONF_NODE_ID)
self.model = self.device_config.get(CONF_MODEL)
self.reset_dps = self.device_config.get(CONF_RESET_DPIDS)
self.dps_strings = self.device_config.get(CONF_DPS_STRINGS)
3 changes: 3 additions & 0 deletions custom_components/localtuya/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
ENTITY_CATEGORY,
PLATFORMS,
SUPPORTED_PROTOCOL_VERSIONS,
CONF_DEVICE_SLEEP_TIME,
)
from .discovery import discover

Expand Down Expand Up @@ -114,6 +115,7 @@
vol.Optional(CONF_SCAN_INTERVAL): int,
vol.Optional(CONF_MANUAL_DPS): cv.string,
vol.Optional(CONF_RESET_DPIDS): str,
vol.Optional(CONF_DEVICE_SLEEP_TIME): int,
vol.Optional(CONF_NODE_ID, default=None): vol.Any(None, cv.string),
}
)
Expand Down Expand Up @@ -213,6 +215,7 @@ def options_schema(entities):
vol.Optional(CONF_SCAN_INTERVAL): int,
vol.Optional(CONF_MANUAL_DPS): cv.string,
vol.Optional(CONF_RESET_DPIDS): cv.string,
vol.Optional(CONF_DEVICE_SLEEP_TIME): int,
vol.Required(
CONF_ENTITIES, description={"suggested_value": entity_names}
): cv.multi_select(entity_names),
Expand Down
1 change: 1 addition & 0 deletions custom_components/localtuya/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
CONF_DEFAULT_VALUE = "dps_default_value"
CONF_RESET_DPIDS = "reset_dpids"
CONF_PASSIVE_ENTITY = "is_passive_entity"
CONF_DEVICE_SLEEP_TIME = "device_sleep_time"

# ALARM
CONF_ALARM_SUPPORTED_STATES = "alarm_supported_states"
Expand Down

0 comments on commit bb7ab44

Please sign in to comment.