"""Provides the TTLock LockUpdateCoordinator.""" from __future__ import annotations import asyncio from contextlib import contextmanager from copy import deepcopy from dataclasses import asdict, dataclass from datetime import datetime, timedelta import logging from typing import TypeGuard from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo, Entity from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt from .api import TTLockApi from .const import DOMAIN, SIGNAL_NEW_DATA, TT_LOCKS from .models import Features, PassageModeConfig, SensorState, State, WebhookEvent _LOGGER = logging.getLogger(__name__) @dataclass class SensorData: """Internal state of the optional door sensor.""" opened: bool | None = None battery: int | None = None last_fetched: datetime | None = None @property def present(self) -> bool: """To indicate if a sensor is installed.""" return self.battery is not None @dataclass class LockState: """Internal state of the lock as managed by the co-oridinator.""" name: str mac: str model: str | None = None battery_level: int | None = None hardware_version: str | None = None firmware_version: str | None = None features: Features = Features.from_feature_value(None) locked: bool | None = None action_pending: bool = False last_user: str | None = None last_reason: str | None = None lock_sound: bool | None = None sensor: SensorData | None = None auto_lock_seconds: int | None = None passage_mode_config: PassageModeConfig | None = None def passage_mode_active(self, current_date: datetime = dt.now()) -> bool: """Check if passage mode is currently active.""" if self.passage_mode_config and self.passage_mode_config.enabled: current_day = current_date.isoweekday() if current_day in self.passage_mode_config.week_days: if self.passage_mode_config.all_day: return True current_minute = current_date.hour * 60 + current_date.minute if ( self.passage_mode_config.start_minute <= current_minute < self.passage_mode_config.end_minute ): # Active by schedule return True return False def auto_lock_delay(self, current_date: datetime) -> int | None: """Return the auto-lock delay in seconds, or None if auto-lock is currently disabled.""" if self.auto_lock_seconds is None or self.auto_lock_seconds <= 0: return None if self.passage_mode_active(current_date): return None return self.auto_lock_seconds def sensor_present(instance: SensorData | None) -> TypeGuard[SensorData]: """Check if a sensor is present.""" return instance is not None and instance.present @contextmanager def lock_action(controller: LockUpdateCoordinator): """Wrap a lock action so that in-progress state is managed correctly.""" controller.data.action_pending = True controller.async_update_listeners() try: yield finally: controller.data.action_pending = False controller.async_update_listeners() def lock_coordinators(hass: HomeAssistant, entry: ConfigEntry): """Help with entity setup.""" coordinators: list[LockUpdateCoordinator] = hass.data[DOMAIN][entry.entry_id][ TT_LOCKS ] yield from coordinators def coordinator_for( hass: HomeAssistant, entity_id: str ) -> LockUpdateCoordinator | None: """Given an entity_id, return the coordinator for that entity.""" for entry in hass.config_entries.async_entries(DOMAIN): for coordinator in lock_coordinators(hass, entry): for entity in coordinator.entities: if entity.entity_id == entity_id: return coordinator return None class LockUpdateCoordinator(DataUpdateCoordinator[LockState]): """Class to manage fetching Toon data from single endpoint.""" def __init__(self, hass: HomeAssistant, api: TTLockApi, lock_id: int) -> None: """Initialize the update co-ordinator for a single lock.""" self.api = api self.lock_id = lock_id super().__init__( hass, _LOGGER, name=DOMAIN, update_interval=timedelta(minutes=15) ) async_dispatcher_connect(self.hass, SIGNAL_NEW_DATA, self._process_webhook_data) async def _async_update_data(self) -> LockState: try: details = await self.api.get_lock(self.lock_id) new_data = deepcopy(self.data) or LockState( name=details.name, mac=details.mac, model=details.model, features=Features.from_feature_value(details.featureValue), ) # update mutable attributes new_data.name = details.name new_data.battery_level = details.battery_level new_data.hardware_version = details.hardwareRevision new_data.firmware_version = details.firmwareRevision if Features.door_sensor in new_data.features: # make sure we have a placeholder for sensor state if the lock supports it if new_data.sensor is None: new_data.sensor = SensorData() # only fetch sensor metadata once a day if ( new_data.sensor.last_fetched is None or new_data.sensor.last_fetched < dt.now() - timedelta(days=1) ): sensor = await self.api.get_sensor(self.lock_id) new_data.sensor.last_fetched = dt.now() if sensor: new_data.sensor.battery = sensor.battery_level else: new_data.sensor = None if new_data.locked is None: try: state = await self.api.get_lock_state(self.lock_id) new_data.locked = state.locked == State.locked if sensor_present(new_data.sensor): new_data.sensor.opened = state.opened == SensorState.opened except Exception: pass new_data.auto_lock_seconds = details.autoLockTime new_data.lock_sound = bool(details.lockSound) new_data.passage_mode_config = await self.api.get_lock_passage_mode_config( self.lock_id ) return new_data except Exception as err: raise UpdateFailed(err) from err @callback def _process_webhook_data(self, event: WebhookEvent): """Update data.""" if event.id != self.lock_id: return _LOGGER.debug("Lock %s received %s", self.unique_id, event) if not event.success: return if not self.data: return new_data = deepcopy(self.data) new_data.battery_level = event.battery_level if state := event.state: if state.locked == State.locked: new_data.locked = True elif state.locked == State.unlocked: new_data.locked = False self._handle_auto_lock(event.lock_ts, event.server_ts) if state.locked is not None: new_data.last_user = event.user new_data.last_reason = event.event.description if new_data.sensor and new_data.sensor.present and event.sensorState: if event.sensorState.opened == SensorState.opened: new_data.sensor.opened = True if event.sensorState.opened == SensorState.closed: new_data.sensor.opened = False new_data.locked = True new_data.last_reason = "Door Closed" _LOGGER.debug("Assuming auto-locked via sensor") self.async_set_updated_data(new_data) def _handle_auto_lock(self, lock_ts: datetime, server_ts: datetime): """Handle auto-locking the lock.""" auto_lock_delay = self.data.auto_lock_delay(lock_ts) computed_msg_delay = max(0, (server_ts - lock_ts).total_seconds()) if auto_lock_delay is None: _LOGGER.debug("Auto-lock is disabled") return async def _auto_locked(seconds: int, offset: float = 0): if seconds > 0 and (seconds - offset) > 0: await asyncio.sleep(seconds - offset) new_data = deepcopy(self.data) new_data.locked = True new_data.last_reason = "Auto Lock" _LOGGER.debug("Assuming lock auto locked after %s seconds", auto_lock_delay) self.async_set_updated_data(new_data) self.hass.create_task(_auto_locked(auto_lock_delay, computed_msg_delay)) @property def unique_id(self) -> str: """Unique ID prefix for all entities for the lock.""" return f"{DOMAIN}-{self.lock_id}" @property def device_info(self) -> DeviceInfo: """Device info for the lock.""" return DeviceInfo( identifiers={(DOMAIN, self.data.mac)}, manufacturer="TT Lock", model=self.data.model, name=self.data.name, sw_version=self.data.firmware_version, hw_version=self.data.hardware_version, ) @property def entities(self) -> list[Entity]: """Entities belonging to this co-ordinator.""" return [ callback.__self__ for callback, _ in list(self._listeners.values()) if isinstance(callback.__self__, Entity) ] def as_dict(self) -> dict: """Serialize for diagnostics.""" return { "unique_id": self.unique_id, "device": asdict(self.data), "entities": [ self.hass.states.get(entity.entity_id).as_dict() for entity in self.entities ], } async def lock(self) -> None: """Try to lock the lock.""" with lock_action(self): res = await self.api.lock(self.lock_id) if res: self.data.locked = True async def unlock(self) -> None: """Try to unlock the lock.""" with lock_action(self): res = await self.api.unlock(self.lock_id) if res: self.data.locked = False async def set_auto_lock(self, on: bool) -> None: """Turn on/off Autolock.""" seconds = 10 if on else 0 res = await self.api.set_auto_lock(self.lock_id, seconds) if res: self.data.auto_lock_seconds = seconds self.async_update_listeners() async def set_lock_sound(self, on: bool) -> None: """Turn on/off lock sound.""" value = 1 if on else 2 res = await self.api.set_lock_sound(self.lock_id, value) if res: self.data.lock_sound = on self.async_update_listeners()