|
| 1 | +import asyncio |
| 2 | +import logging |
| 3 | +import time |
| 4 | +from typing import cast |
| 5 | + |
| 6 | +from homeassistant.components.event import EventDeviceClass, EventEntity |
| 7 | +from homeassistant.config_entries import ConfigEntry |
| 8 | +from homeassistant.const import EVENT_HOMEASSISTANT_STARTED |
| 9 | +from homeassistant.core import CoreState, HomeAssistant, callback |
| 10 | +from homeassistant.helpers.entity_platform import AddEntitiesCallback |
| 11 | +from plugp100.new.child.tapohubchildren import TriggerButtonDevice |
| 12 | +from plugp100.new.components.trigger_log_component import TriggerLogComponent |
| 13 | +from plugp100.responses.hub_childs.s200b_device_state import ( |
| 14 | + RotationEvent, |
| 15 | + S200BEvent, |
| 16 | + SingleClickEvent, |
| 17 | +) |
| 18 | + |
| 19 | +from custom_components.tapo.const import DOMAIN |
| 20 | +from custom_components.tapo.coordinators import HassTapoDeviceData, TapoDataCoordinator |
| 21 | +from custom_components.tapo.entity import CoordinatedTapoEntity |
| 22 | + |
| 23 | +_LOGGER = logging.getLogger(__name__) |
| 24 | + |
| 25 | +# Cache validity window — within the same update cycle, all entities |
| 26 | +# reuse the same event log fetch instead of hitting the hub 3 times. |
| 27 | +_CACHE_VALIDITY_S = 0.2 |
| 28 | + |
| 29 | + |
| 30 | +def _get_hub_lock(hass, entry_id): |
| 31 | + """Get or create a per-hub asyncio.Lock to serialize event log fetches. |
| 32 | +
|
| 33 | + All child devices on the same hub share one lock so HTTP requests |
| 34 | + to the hub never overlap, which keeps latency low. |
| 35 | + """ |
| 36 | + key = f"tapo_hub_event_lock_{entry_id}" |
| 37 | + lock = hass.data.get(key) |
| 38 | + if lock is None: |
| 39 | + lock = asyncio.Lock() |
| 40 | + hass.data[key] = lock |
| 41 | + return lock |
| 42 | + |
| 43 | + |
| 44 | +async def fetch_event_logs(coordinator, device, page_size=5, hass=None, entry_id=None): |
| 45 | + """Fetch event logs with per-cycle caching on the coordinator. |
| 46 | +
|
| 47 | + Returns (logs, latency_ms). The first caller in each update cycle |
| 48 | + does the actual HTTP request; subsequent callers get the cached result. |
| 49 | + A per-hub lock serializes requests across child devices. |
| 50 | + """ |
| 51 | + cache = getattr(coordinator, "_event_log_cache", None) |
| 52 | + now = time.monotonic() |
| 53 | + |
| 54 | + if cache and (now - cache["timestamp"]) < _CACHE_VALIDITY_S: |
| 55 | + return cache["logs"], cache["latency_ms"] |
| 56 | + |
| 57 | + # Acquire hub-level lock if available |
| 58 | + lock = None |
| 59 | + if hass and entry_id: |
| 60 | + lock = _get_hub_lock(hass, entry_id) |
| 61 | + |
| 62 | + if lock: |
| 63 | + async with lock: |
| 64 | + return await _do_fetch(coordinator, device, page_size) |
| 65 | + else: |
| 66 | + return await _do_fetch(coordinator, device, page_size) |
| 67 | + |
| 68 | + |
| 69 | +async def _do_fetch(coordinator, device, page_size): |
| 70 | + """Perform the actual HTTP fetch and update cache.""" |
| 71 | + # Re-check cache inside the lock in case another device just fetched |
| 72 | + cache = getattr(coordinator, "_event_log_cache", None) |
| 73 | + now = time.monotonic() |
| 74 | + if cache and (now - cache["timestamp"]) < _CACHE_VALIDITY_S: |
| 75 | + return cache["logs"], cache["latency_ms"] |
| 76 | + |
| 77 | + start = now |
| 78 | + result = await device.get_event_logs(page_size=page_size, start_id=0) |
| 79 | + logs = result.get_or_raise() |
| 80 | + latency_ms = (time.monotonic() - start) * 1000 |
| 81 | + |
| 82 | + coordinator._event_log_cache = { |
| 83 | + "timestamp": now, |
| 84 | + "logs": logs, |
| 85 | + "latency_ms": latency_ms, |
| 86 | + } |
| 87 | + |
| 88 | + return logs, latency_ms |
| 89 | + |
| 90 | +EVENT_SINGLE_CLICK = "single_click" |
| 91 | +EVENT_ROTATION = "rotation" |
| 92 | + |
| 93 | + |
| 94 | +async def async_setup_entry( |
| 95 | + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback |
| 96 | +): |
| 97 | + data = cast(HassTapoDeviceData, hass.data[DOMAIN][entry.entry_id]) |
| 98 | + for child_coordinator in data.child_coordinators: |
| 99 | + device = child_coordinator.device |
| 100 | + if isinstance(device, TriggerButtonDevice) and device.has_component( |
| 101 | + TriggerLogComponent |
| 102 | + ): |
| 103 | + async_add_entities( |
| 104 | + [ |
| 105 | + TapoButtonEvent(child_coordinator, device), |
| 106 | + TapoDialEvent(child_coordinator, device), |
| 107 | + ], |
| 108 | + True, |
| 109 | + ) |
| 110 | + |
| 111 | + |
| 112 | +class _TapoEventBase(CoordinatedTapoEntity, EventEntity): |
| 113 | + """Base class that polls event logs and delegates to subclasses.""" |
| 114 | + |
| 115 | + _attr_has_entity_name = True |
| 116 | + |
| 117 | + def __init__( |
| 118 | + self, |
| 119 | + coordinator: TapoDataCoordinator, |
| 120 | + device: TriggerButtonDevice, |
| 121 | + ) -> None: |
| 122 | + super().__init__(coordinator, device) |
| 123 | + self._device: TriggerButtonDevice = device |
| 124 | + self._last_event_id: int | None = None |
| 125 | + self._ha_started: bool = False |
| 126 | + |
| 127 | + async def async_added_to_hass(self) -> None: |
| 128 | + await super().async_added_to_hass() |
| 129 | + if self.hass.state is CoreState.running: |
| 130 | + self._ha_started = True |
| 131 | + else: |
| 132 | + self.hass.bus.async_listen_once( |
| 133 | + EVENT_HOMEASSISTANT_STARTED, self._on_ha_started |
| 134 | + ) |
| 135 | + |
| 136 | + @callback |
| 137 | + def _on_ha_started(self, _event) -> None: |
| 138 | + self._ha_started = True |
| 139 | + |
| 140 | + @callback |
| 141 | + def _handle_coordinator_update(self) -> None: |
| 142 | + if not self._ha_started: |
| 143 | + return |
| 144 | + self.hass.async_create_task(self._poll_and_fire_events()) |
| 145 | + self.async_write_ha_state() |
| 146 | + |
| 147 | + async def _poll_and_fire_events(self) -> None: |
| 148 | + try: |
| 149 | + logs, _ = await fetch_event_logs( |
| 150 | + self.coordinator, self._device, |
| 151 | + hass=self.hass, |
| 152 | + entry_id=getattr(self.coordinator, "_hub_entry_id", None), |
| 153 | + ) |
| 154 | + except Exception: |
| 155 | + _LOGGER.debug("Failed to fetch event logs for %s", self._device.device_id) |
| 156 | + return |
| 157 | + |
| 158 | + if not logs.events: |
| 159 | + return |
| 160 | + |
| 161 | + latest = logs.events[0] |
| 162 | + event_id = latest.id |
| 163 | + |
| 164 | + if self._last_event_id is None: |
| 165 | + self._last_event_id = event_id |
| 166 | + return |
| 167 | + |
| 168 | + if event_id == self._last_event_id: |
| 169 | + return |
| 170 | + |
| 171 | + new_events: list[S200BEvent] = [] |
| 172 | + for event in logs.events: |
| 173 | + if event.id == self._last_event_id: |
| 174 | + break |
| 175 | + new_events.append(event) |
| 176 | + |
| 177 | + self._last_event_id = event_id |
| 178 | + |
| 179 | + for event in reversed(new_events): |
| 180 | + self._handle_event(event) |
| 181 | + |
| 182 | + def _handle_event(self, event: S200BEvent) -> None: |
| 183 | + raise NotImplementedError |
| 184 | + |
| 185 | + |
| 186 | +class TapoButtonEvent(_TapoEventBase): |
| 187 | + """Event entity for S200B/S200D button presses.""" |
| 188 | + |
| 189 | + _attr_name = "Button Event" |
| 190 | + _attr_device_class = EventDeviceClass.BUTTON |
| 191 | + _attr_event_types = [EVENT_SINGLE_CLICK] |
| 192 | + |
| 193 | + @property |
| 194 | + def unique_id(self): |
| 195 | + return super().unique_id + "_button_event" |
| 196 | + |
| 197 | + def _handle_event(self, event: S200BEvent) -> None: |
| 198 | + if isinstance(event, SingleClickEvent): |
| 199 | + self._trigger_event(EVENT_SINGLE_CLICK) |
| 200 | + self.async_write_ha_state() |
| 201 | + |
| 202 | + |
| 203 | +class TapoDialEvent(_TapoEventBase): |
| 204 | + """Event entity for S200B/S200D dial rotation.""" |
| 205 | + |
| 206 | + _attr_name = "Dial Event" |
| 207 | + _attr_event_types = [EVENT_ROTATION] |
| 208 | + |
| 209 | + @property |
| 210 | + def unique_id(self): |
| 211 | + return super().unique_id + "_dial_event" |
| 212 | + |
| 213 | + def _handle_event(self, event: S200BEvent) -> None: |
| 214 | + if isinstance(event, RotationEvent): |
| 215 | + self._trigger_event(EVENT_ROTATION, {"degrees": event.degrees}) |
| 216 | + self.async_write_ha_state() |
0 commit comments