Files
rikku-home-assistant/custom_components/localtuya/coordinator.py
T
2025-04-21 21:03:30 -04:00

668 lines
25 KiB
Python

"""Tuya Device API"""
from __future__ import annotations
import asyncio
import errno
import logging
import time
from datetime import timedelta
from typing import Any, NamedTuple
from homeassistant.core import HomeAssistant, CALLBACK_TYPE, callback, State
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ID, CONF_DEVICES, CONF_HOST, CONF_DEVICE_ID
from homeassistant.helpers.event import async_track_time_interval, async_call_later
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
dispatcher_send,
)
from .core.cloud_api import TuyaCloudApi
from .core.pytuya import (
ContextualLogger,
DecodeError,
HEARTBEAT_INTERVAL,
TIMEOUT_CONNECT,
SubdeviceState,
TuyaListener,
TuyaProtocol,
connect as pytuya_connect,
)
from .const import (
ATTR_UPDATED_AT,
CONF_GATEWAY_ID,
CONF_LOCAL_KEY,
CONF_NODE_ID,
CONF_NO_CLOUD,
CONF_TUYA_IP,
DATA_DISCOVERY,
DOMAIN,
DeviceConfig,
RESTORE_STATES,
)
_LOGGER = logging.getLogger(__name__)
RECONNECT_INTERVAL = timedelta(seconds=5)
# Subdevice: Offline events before disconnecting the device, around 5 minutes
MIN_OFFLINE_EVENTS = 5 * 60 // HEARTBEAT_INTERVAL
class HassLocalTuyaData(NamedTuple):
"""LocalTuya data stored in homeassistant data object."""
cloud_data: TuyaCloudApi
devices: dict[str, TuyaDevice]
class TuyaDevice(TuyaListener, ContextualLogger):
"""Cache wrapper for pytuya.TuyaInterface."""
def __init__(
self,
hass: HomeAssistant,
entry: ConfigEntry[Any],
device_config: dict,
fake_gateway=False,
):
"""Initialize the cache."""
super().__init__()
self.hass = hass
self._entry = entry
self._hass_entry: HassLocalTuyaData = hass.data[DOMAIN][entry.entry_id]
self._device_config = DeviceConfig(device_config.copy())
self.id = self._device_config.id
self.local_key = self._device_config.local_key
self._status = {}
self._interface: TuyaProtocol = None
# For SubDevices
self.gateway: TuyaDevice = None
self.sub_devices: dict[str, TuyaDevice] = {}
self.subdevice_state = None
self._fake_gateway = fake_gateway
self._node_id: str = self._device_config.node_id
self._subdevice_off_count: int = 0
# last_update_time: Sleep timer, a device that reports the status every x seconds then goes into sleep.
self._last_update_time = time.monotonic() - 5
self._pending_status: dict[str, dict[str, Any]] = {}
self.is_closing = False
self._task_connect: asyncio.Task | None = None
self._task_reconnect: asyncio.Task | None = None
self._task_shutdown_entities: asyncio.Task | None = None
self._unsub_refresh: CALLBACK_TYPE | None = None
self._unsub_new_entity: CALLBACK_TYPE | None = None
self._entities = []
self._default_reset_dpids: list | None = None
dev = self._device_config
if reset_dps := dev.reset_dps:
self._default_reset_dpids = [int(id.strip()) for id in reset_dps.split(",")]
# This has to be done in case the device type is type_0d
self.dps_to_request = {}
for dp in dev.dps_strings:
self.dps_to_request[dp.split(" ")[0]] = None
self.set_logger(_LOGGER, dev.id, dev.enable_debug, self.friendly_name)
@property
def friendly_name(self):
"""Name string for log prefixes."""
name = self._device_config.name
return name if not self._fake_gateway else (name + "/G")
@property
def connected(self):
"""Return if connected to device."""
return self._interface and self._interface.is_connected
@property
def is_connecting(self):
"""Return whether device is currently connecting."""
return self._task_connect is not None
@property
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."""
if (device_sleep := self._device_config.sleep_time) > 0:
setattr(self, "low_power", True)
last_update = int(time.monotonic()) - self._last_update_time
return last_update < device_sleep
return False
@property
def is_write_only(self):
"""Return if this sub-device is BLE. We uses 0 in manual dps as mark for BLE devices.
NOTE: this may not be the best way to detect if this device is BLE
"""
return self.is_subdevice and "0" in self._device_config.manual_dps.split(",")
def add_entities(self, entities):
"""Set the entities associated with this device."""
self._entities.extend(entities)
async def async_connect(self, _now=None) -> None:
"""Connect to device if not already connected."""
if self.is_closing or self.is_connecting:
return
if self.connected:
return self._dispatch_status()
self._task_connect = asyncio.create_task(self._make_connection())
if not self.is_sleep:
await self._task_connect
async def _connect_subdevices(self):
"""Gateway: connect to sub-devices one by one."""
if not self.sub_devices:
return
for subdevice in self.sub_devices.values():
if not self.connected or self.is_closing:
break
if subdevice.subdevice_state != SubdeviceState.ABSENT:
await subdevice.async_connect()
async def _make_connection(self):
"""Subscribe localtuya entity events."""
if self.is_sleep and not self._status:
self.status_updated(RESTORE_STATES)
name, host = self._device_config.name, self._device_config.host
retry = 0
max_retries = 3
update_localkey = False
self.debug(f"Trying to connect to: {host}...", force=True)
# Connect to the device, interface should be connected for next steps.
while retry < max_retries and not self.is_closing:
retry += 1
try:
if self.is_subdevice:
gateway = self._get_gateway()
if not gateway:
update_localkey = True
break
if not gateway.connected and gateway.is_connecting:
return await self.abort_connect()
self._interface = gateway._interface
if not self._interface:
break
if self._device_config.enable_debug:
self._interface.enable_debug(True, gateway.friendly_name)
else:
self._interface = await pytuya_connect(
self._device_config.host,
self._device_config.id,
self.local_key,
float(self._device_config.protocol_version),
self._device_config.enable_debug,
self,
)
self._interface.enable_debug(
self._device_config.enable_debug, self.friendly_name
)
self._interface.add_dps_to_request(self.dps_to_request)
break # Succeed break while loop
except asyncio.CancelledError:
await self.abort_connect()
self._task_connect = None
return
except OSError as e:
await self.abort_connect()
if (
e.errno == errno.EHOSTUNREACH
and not self._status
and not self.is_sleep
):
self.warning(f"Connection failed: {e}")
break
except Exception as ex: # pylint: disable=broad-except
await self.abort_connect()
if not self.is_sleep:
self.warning(f"Failed to connect to {host}: {str(ex)}")
if "key" in str(ex):
update_localkey = True
break
# Get device status and configure DPS.
if self.connected and not self.is_closing:
try:
# If reset dpids set - then assume reset is needed before status.
reset_dpids = self._default_reset_dpids
if (reset_dpids is not None) and (len(reset_dpids) > 0):
self.debug(f"Resetting cmd for DP IDs: {reset_dpids}")
# Assume we want to request status updated for the same set of DP_IDs as the reset ones.
self._interface.set_updatedps_list(reset_dpids)
# Reset the interface
await self._interface.reset(reset_dpids, cid=self._node_id)
self.debug("Retrieving initial state")
# Usually we use status instead of detect_available_dps, but some device doesn't reports all dps when ask for status.
status = await self._interface.status(cid=self._node_id)
if status is None:
raise Exception("Failed to retrieve status")
self.status_updated(status)
except (UnicodeDecodeError, DecodeError) as e:
self.exception(f"Handshake with {host} failed: due to {type(e)}: {e}")
await self.abort_connect()
update_localkey = True
except asyncio.CancelledError:
await self.abort_connect()
self._task_connect = None
return
except Exception as e:
if not (self._fake_gateway and "Not found" in str(e)):
e = "Sub device is not connected" if self.is_subdevice else e
self.warning(f"Handshake with {host} failed: {e}")
await self.abort_connect()
if self.is_subdevice:
update_localkey = True
except:
if self._fake_gateway:
self.warning(f"Failed to use {name} as gateway.")
await self.abort_connect()
update_localkey = True
# Connect and configure the entities, at this point the device should be ready to get commands.
if self.connected and not self.is_closing:
self.debug(f"Success: connected to: {host}", force=True)
# Attempt to restore status for all entities that need to first set
# the DPS value before the device will respond with status.
for entity in self._entities:
await entity.restore_state_when_connected()
if self._unsub_new_entity is None:
def _new_entity_handler(entity_id):
self.debug(f"New entity {entity_id} was added to {host}")
self._dispatch_status()
signal = f"localtuya_entity_{self._device_config.id}"
self._unsub_new_entity = async_dispatcher_connect(
self.hass, signal, _new_entity_handler
)
if (scan_inv := int(self._device_config.scan_interval)) > 0:
self._unsub_refresh = async_track_time_interval(
self.hass, self._async_refresh, timedelta(seconds=scan_inv)
)
self._task_connect = None
# Ensure the connected sub-device is in its gateway's sub_devices
# and reset offline/absent counters
if self.gateway:
self.gateway.sub_devices[self._node_id] = self
if self.is_subdevice:
self.subdevice_state_updated(SubdeviceState.ONLINE)
if not self._status and "0" in self._device_config.manual_dps.split(","):
self.status_updated(RESTORE_STATES)
if self._pending_status:
await self.set_status()
if self.sub_devices:
asyncio.create_task(self._connect_subdevices())
self._interface.keep_alive(len(self.sub_devices) > 0)
# If not connected try to handle the errors.
if not self.connected and not self.is_closing:
if self._task_reconnect is None:
self._task_reconnect = asyncio.create_task(self._async_reconnect())
if update_localkey:
# Check if the cloud device info has changed!.
await self._update_local_key()
self._task_connect = None
async def abort_connect(self):
"""Abort the connect process to the interface[device]"""
if self.is_subdevice:
self._interface = None
self._task_connect = None
if self._interface is not None:
await self._interface.close()
self._interface = None
async def check_connection(self):
"""Ensure that the device is not still connecting; if it is, wait for it."""
if not self.connected and self._task_connect:
await self._task_connect
if not self.connected and self.gateway and self.gateway._task_connect:
await self.gateway._task_connect
if not self.connected:
self.error(f"Not connected to device {self._device_config.name}")
async def close(self):
"""Close connection and stop re-connect loop."""
if self.is_closing:
return
self.is_closing = True
tasks = [self._task_shutdown_entities, self._task_reconnect, self._task_connect]
pending_tasks = [task for task in tasks if task and task.cancel()]
await asyncio.gather(*pending_tasks, return_exceptions=True)
# Close subdevices first, to prevent them try to reconnect
# after gateway disconnected.
for subdevice in self.sub_devices.values():
await subdevice.close()
if self._unsub_new_entity:
self._unsub_new_entity()
self._unsub_new_entity = None
if self._unsub_refresh:
self._unsub_refresh()
self._unsub_refresh = None
await self.abort_connect()
if self.gateway:
self.gateway.filter_subdevices()
self.debug("Closed connection", force=True)
async def set_status(self):
"""Send self._pending_status payload to device."""
await self.check_connection()
if self._interface and self._pending_status:
payload, self._pending_status = self._pending_status.copy(), {}
try:
await self._interface.set_dps(payload, cid=self._node_id)
# bluetooth devices usually does not send updated status payload.
# NOTE: This will override the status if the BLE device fails to receive the signal.
if self.is_write_only:
self.status_updated(payload)
except (TimeoutError, Exception) as ex:
self.debug(f"Failed to set values {payload} --> {ex}", force=True)
elif not self.connected:
self.error(f"Device is not connected.")
async def set_dp(self, state, dp_index):
"""Change value of a DP of the Tuya device."""
if self._interface is not None:
self._pending_status.update({dp_index: state})
await asyncio.sleep(0.001)
await self.set_status()
else:
if self.is_sleep:
return self._pending_status.update({str(dp_index): state})
async def set_dps(self, states):
"""Change value of a DPs of the Tuya device."""
if self._interface is not None:
self._pending_status.update(states)
await asyncio.sleep(0.001)
await self.set_status()
else:
if self.is_sleep:
return self._pending_status.update(states)
async def _async_refresh(self, _now):
if self.connected:
self.debug("Refreshing dps for device")
# This a workaround for >= 3.4 devices, since there is an issue on waiting for the correct seqno
try:
await self._interface.update_dps(cid=self._node_id)
except TimeoutError:
pass
async def _async_reconnect(self):
"""Task: continuously attempt to reconnect to the device."""
attempts = 0
while True:
try:
# for sub-devices, if it is reported as offline then no need for reconnect.
if (
self.is_subdevice
and self._subdevice_off_count >= MIN_OFFLINE_EVENTS
):
await asyncio.sleep(1)
continue
# for sub-devices, if the gateway isn't connected then no need for reconnect.
if self.gateway and (
not self.gateway.connected or self.gateway.is_connecting
):
await asyncio.sleep(3)
continue
if not self._task_connect:
await self.async_connect()
if self._task_connect:
await self._task_connect
if self.connected:
if not self.is_sleep and attempts > 0:
self.info(f"Reconnect succeeded on attempt: {attempts}")
break
if self.is_closing:
break
attempts += 1
scale = (
2
if (self.subdevice_state == SubdeviceState.ABSENT)
or (attempts > MIN_OFFLINE_EVENTS)
else 1
)
await asyncio.sleep(scale * RECONNECT_INTERVAL.total_seconds())
except asyncio.CancelledError as e:
self.debug(f"Reconnect task has been canceled: {e}", force=True)
break
self._task_reconnect = None
async def _shutdown_entities(self, exc=""):
"""Shutdown device entities"""
# Delay shutdown.
if not self.is_closing:
try:
await asyncio.sleep(TIMEOUT_CONNECT + self._device_config.sleep_time)
except asyncio.CancelledError as e:
self.debug(f"Shutdown entities task has been canceled: {e}", force=True)
return
if self.connected or self.is_sleep:
self._task_shutdown_entities = None
return
signal = f"localtuya_{self._device_config.id}"
dispatcher_send(self.hass, signal, None)
if self.is_closing:
return
if self.is_subdevice:
self.info(f"Sub-device disconnected due to: {exc}")
elif hasattr(self, "low_power"):
m, s = divmod((int(time.monotonic()) - self._last_update_time), 60)
h, m = divmod(m, 60)
self.info(f"The device is still out of reach since: {h}h:{m}m:{s}s")
else:
self.info(f"Disconnected due to: {exc}")
self._task_shutdown_entities = None
async def _update_local_key(self):
"""Retrieve updated local_key from Cloud API and update the config_entry."""
if self._entry.data.get(CONF_NO_CLOUD, True):
return self.info("Ensure that localkey hasn't changed and it's correct")
dev_id = self._device_config.id
cloud_api = self._hass_entry.cloud_data
await cloud_api.async_get_devices_list(force_update=True)
cloud_devs = cloud_api.device_list
if dev_id in cloud_devs:
cloud_localkey = cloud_devs[dev_id].get(CONF_LOCAL_KEY)
if not cloud_localkey or self.local_key == cloud_localkey:
return
new_data = self._entry.data.copy()
self.local_key = cloud_localkey
if self._node_id:
from .core.helpers import get_gateway_by_deviceid
# Update Node ID.
if new_node_id := cloud_devs[dev_id].get(CONF_NODE_ID):
new_data[CONF_DEVICES][dev_id][CONF_NODE_ID] = new_node_id
# Update Gateway ID and IP
if new_gw := get_gateway_by_deviceid(dev_id, cloud_devs):
self.info(f"Gateway ID has been updated to: {new_gw.id}")
new_data[CONF_DEVICES][dev_id][CONF_GATEWAY_ID] = new_gw.id
discovery = self.hass.data[DOMAIN].get(DATA_DISCOVERY)
if discovery and (local_gw := discovery.devices.get(new_gw.id)):
new_ip = local_gw.get(CONF_TUYA_IP, self._device_config.host)
new_data[CONF_DEVICES][dev_id][CONF_HOST] = new_ip
self.info(f"IP has been updated to: {new_ip}")
new_data[CONF_DEVICES][dev_id][CONF_LOCAL_KEY] = self.local_key
new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000))
self.hass.config_entries.async_update_entry(self._entry, data=new_data)
self.info(f"Local-key has been updated")
def filter_subdevices(self):
"""Remove closed subdevices that are closed."""
self.sub_devices = {
k: v for k, v in self.sub_devices.items() if not v.is_closing
}
def _dispatch_status(self):
signal = f"localtuya_{self._device_config.id}"
dispatcher_send(self.hass, signal, self._status)
def _handle_event(self, old_status: dict, new_status: dict):
"""Handle events in HA when devices updated."""
def fire_event(event, data: dict):
"""Fire events."""
event_data = {CONF_DEVICE_ID: self.id, **data}
if len(event_data) > 1:
self.hass.bus.async_fire(f"localtuya_{event}", event_data)
event_status_update = "status_update"
event_device_dp_triggered = "device_dp_triggered"
if self._interface and old_status and new_status:
# A massive number of events that can be triggered when some devices update too quickly such as temp sensors,
# - We want only to update if status changed except for 1 DP trigger, for scene controls.
if len(self._interface.dispatched_dps) == 1:
dp, value = next(iter(self._interface.dispatched_dps.items()))
data = {"dp": dp, "value": value}
fire_event(event_device_dp_triggered, data)
if old_status != new_status:
data = {"old_status": old_status, "new_status": new_status}
fire_event(event_status_update, data)
def _get_gateway(self):
"""Return the gateway device of this sub device."""
if not self._node_id or (gateway := self.gateway) is None:
return None # Should never happen
# Ensure that sub-device still on the same gateway device.
if gateway.local_key != self.local_key:
if self.subdevice_state != SubdeviceState.ABSENT:
self.warning("Sub-device localkey doesn't match the gateway localkey")
# This will become ONLINE after successful connect
self.subdevice_state = SubdeviceState.ABSENT
return None
else:
return gateway
@callback
def status_updated(self, status: dict):
"""Device updated status."""
if self._fake_gateway:
# Fake gateways are only used to pass commands no need to update status.
return
self._last_update_time = int(time.monotonic())
self._handle_event(self._status, status)
self._status.update(status)
self._dispatch_status()
@callback
def disconnected(self, exc=""):
"""Device disconnected."""
if not self._interface:
return
self._interface = None
if self._unsub_refresh:
self._unsub_refresh()
self._unsub_refresh = None
for subdevice in self.sub_devices.values():
subdevice.disconnected("Gateway disconnected")
if self._task_connect is not None:
self._task_connect.cancel()
self._task_connect = None
# If it disconnects unexpectedly.
if self.is_closing:
return
if self._task_reconnect is None:
self._task_reconnect = asyncio.create_task(self._async_reconnect())
if self._task_shutdown_entities is not None:
self._task_shutdown_entities.cancel()
self._task_shutdown_entities = asyncio.create_task(
self._shutdown_entities(exc=exc)
)
@callback
def subdevice_state_updated(self, state: SubdeviceState):
"""Handle the reported states for Sub-Devices."""
node_id = self._node_id
old_state = self.subdevice_state
self.subdevice_state = state
# This will trigger if state is absent twice.
if old_state == state and state == SubdeviceState.ABSENT:
self._subdevice_off_count = 0
return self.disconnected("Device is absent")
elif state == SubdeviceState.ABSENT:
return self.info(f"Sub-device is absent {node_id}")
elif old_state == SubdeviceState.ABSENT:
self.info(f"Sub-device is back {node_id}")
is_online = state == SubdeviceState.ONLINE
off_count = self._subdevice_off_count
self._subdevice_off_count = 0 if is_online else off_count + 1
if is_online:
return self.info(f"Sub-device is online {node_id}") if off_count else None
else:
off_count += 1
if off_count == 1:
self.warning(f"Sub-device is offline {node_id}")
elif off_count == MIN_OFFLINE_EVENTS:
self.disconnected("Device is offline")