668 lines
25 KiB
Python
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")
|