Skip to content

Commit 9332c6c

Browse files
erosen14claude
andauthored
feat(core): add S200B/S200D button and dial event support (#934)
## Summary - Adds `EventEntity` support for Tapo S200B button and S200D dial/button hub children - Button event entity fires `single_click` events; dial event entity fires `rotation` events with `degrees` attribute - Includes adaptive polling with a budget-based utilization algorithm (EWMA latency + jitter tracking + hysteresis) and a configurable Poll Utilization slider (5–50%) - Shared event log cache reduces hub API calls from 3 to 1 per polling cycle - Trigger button devices default to 1s polling (vs 30s for other hub children) ## Why polling? A push-based pattern (e.g. Matter event subscriptions) would be ideal, but the S200B/S200D don't support it yet. TP-Link's [product page](https://www.tp-link.com/us/home-networking/smart-switch/tapo-s200d/) advertises Matter compatibility, and their [Matter FAQ](https://www.tp-link.com/us/support/faq/3754/) indicates button support via Matter bridging is planned but not yet available. When that lands, this polling approach can be replaced. Until then, the adaptive polling algorithm keeps it efficient — only the hub is polled (not the buttons), so there's zero battery impact, and hub load stays within a configurable budget. ## Important notes - **Double-click is not supported.** The S200D hub does not emit native `doubleClick` events — double-clicks are reported as two rapid `SingleClickEvent`s and cannot be reliably distinguished from two intentional single clicks. - The `plugp100` library already parses `doubleClick` if the hub ever sends it ([see code](https://github.com/petretiandrea/plugp100/blob/main/plugp100/responses/hub_childs/s200b_device_state.py)), so if TP-Link adds firmware support in the future, it will work automatically. ## New entities (per S200B/S200D device) | Entity | Type | Description | |--------|------|-------------| | Button Event | `event` | Fires on button press (`single_click`) | | Dial Event | `event` | Fires on dial rotation (`rotation` with `degrees`) | | Poll Latency | `sensor` (diagnostic) | Measures hub API round-trip time in ms | | Poll Utilization | `number` (config) | Slider to tune adaptive polling aggressiveness (default 35%) | ## Example blueprints Ready-to-use blueprints for button toggle and dial brightness control: [import from gist](https://gist.github.com/erosen14/8e427e9da6351abf8554cfc12bea421d) ## Adaptive polling algorithm The integration needs to poll the hub's event log to detect button presses and dial rotations. Polling too fast wastes hub resources, too slow and the dial feels laggy. Instead of a fixed interval, the algorithm measures how long each API call actually takes and adjusts accordingly. Each cycle, it tracks two things: - **Latency (L)**: how long the hub took to respond, smoothed with an exponential moving average so one slow response doesn't cause overreaction - **Jitter (J)**: how much that latency varies from cycle to cycle, also smoothed The polling interval is then computed as: `interval = (L + 2×J) / u_max`, where `u_max` is the utilization target (the Poll Utilization slider, default 35%). This means the system spends at most 35% of its time actively talking to the hub, leaving the rest as idle headroom. To avoid the interval bouncing around on small latency fluctuations, there's a 15% hysteresis dead band — the interval only changes when the new computed value differs from the current one by more than 15%. A 5-cycle cooldown after each change prevents rapid back-and-forth adjustments. The Poll Utilization slider (exposed as a config entity in HA) lets users tune the tradeoff between responsiveness and hub load. At 50% the dial feels very responsive but the hub is working harder. At 10% there's more latency but the hub barely notices the polling. 35% is a reasonable middle ground. ### Scaling and hub safety With multiple devices on the same hub, a per-hub lock serializes API requests so they don't overlap and cause timeouts. The hub only ever handles one request at a time, so it can't be overwhelmed with concurrent connections regardless of how many buttons are configured. A 200ms shared cache also means that when multiple device coordinators poll within the same window, only one actual HTTP request hits the hub. As latency increases with more devices, the adaptive algorithm automatically widens polling intervals to compensate — this is self-correcting. The hub will never crash or become unresponsive because the algorithm backs off proportionally to observed load. Other Tapo devices on the same hub (sensors, plugs, etc.) use the standard HA coordinator with 30s polling intervals. Since button polling runs on its own async loop and the lock only gates event log fetches, there's always idle time between polls for other devices to get their requests through. Tested with 1 and 2 devices: | Devices | Per-device latency | Button response | Dial feel | |---------|-------------------|-----------------|-----------| | 1 (measured) | ~50-60ms | <200ms | Smooth | | 2 (measured, 24hr) | median 96ms, p99 327ms | <200ms | Smooth | | 3-4 (projected) | ~100-150ms | <300ms | Usable | | 5-6 (projected) | ~150-250ms | <400ms | Laggy for dial, fine for button | | 8+ (projected) | ~250ms+ | <1s | Too slow for dial, still OK for button | Buttons are tolerant of higher latency — a 400ms response is fine for toggling a light. Dials are more sensitive since users expect continuous feedback while turning. For setups with many buttons and few dials, lowering the utilization slider to 10-15% keeps hub load minimal while still providing acceptable button response. ## Test plan - [x] Add S200B or S200D to a Tapo H100 hub - [x] Verify button and dial event entities appear in HA - [x] Create automations using the event entities (e.g., button toggle, dial brightness) - [x] Verify Poll Latency sensor reports values and adaptive polling stabilizes - [x] Adjust Poll Utilization slider and confirm polling interval changes accordingly - [x] Verify no double-click events are emitted (expected: two single_click events) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4f6c74f commit 9332c6c

11 files changed

Lines changed: 1194 additions & 16 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ Please be sure to have DHCP discovery enabled on your `configuration.yaml` (enab
8585
- [x] support for `T100` motion sensor hub's device
8686
- [x] support for `T110` smart door hub's device
8787
- [x] support for `S220`, `S210` switch hub's device
88-
- [x] partial support for `S200B` button hub's device (actually no events are reported to HA)
88+
- [x] support for `S200B` button and `S200D` dial hub's device (button press and dial rotation events reported to HA, double-click not supported)
8989
- [x] support for tapo powerstrip (`P300`). A special thanks go to @alxlk to support me with max one-time contribution which allows me to buy the device
9090
- [x] support for tapo switch (`P100`, `P110`, `P105`, `P115`, `P125`, `P110M`)
9191
- [x] support for tapo powerstrip (`P304M`) with individual socket support and energy monitoring via [PR #933](https://github.com/petretiandrea/home-assistant-tapo-p100/pull/933)

‎custom_components/tapo/const.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
Platform.CLIMATE,
2626
Platform.NUMBER,
2727
Platform.UPDATE,
28+
Platform.EVENT,
2829
]
2930

3031

@@ -41,6 +42,7 @@
4142
STEP_DISCOVERY_REQUIRE_AUTH = "discovery_auth_confirm"
4243

4344
DEFAULT_POLLING_RATE_S = 30 # 30 seconds
45+
DEFAULT_BUTTON_POLLING_RATE_MS = 1000 # 1 second
4446

4547
CONF_ALTERNATIVE_IP = "ip_address"
4648

‎custom_components/tapo/event.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from typing import cast
2+
3+
from homeassistant.config_entries import ConfigEntry
4+
from homeassistant.core import HomeAssistant
5+
from homeassistant.helpers.entity_platform import AddEntitiesCallback
6+
7+
from custom_components.tapo.const import DOMAIN
8+
from custom_components.tapo.coordinators import HassTapoDeviceData
9+
from custom_components.tapo.hub.event import async_setup_entry as async_setup_hub_event
10+
11+
12+
async def async_setup_entry(
13+
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
14+
):
15+
data = cast(HassTapoDeviceData, hass.data[DOMAIN][entry.entry_id])
16+
if data.coordinator.is_hub:
17+
await async_setup_hub_event(hass, entry, async_add_entities)
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
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()

‎custom_components/tapo/hub/hass_tapo_hub.py‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@
1010
from homeassistant.helpers import device_registry as dr
1111
from homeassistant.helpers.device_registry import DeviceRegistry
1212
from plugp100.new.hub_device_tracker import HubDeviceEvent, DeviceAdded
13+
from plugp100.new.child.tapohubchildren import TriggerButtonDevice
1314
from plugp100.new.tapodevice import TapoDevice
1415
from plugp100.new.tapohub import TapoHub
1516

16-
from custom_components.tapo.const import DEFAULT_POLLING_RATE_S, PLATFORMS
17+
from custom_components.tapo.const import DEFAULT_BUTTON_POLLING_RATE_MS, DEFAULT_POLLING_RATE_S, PLATFORMS
1718
from custom_components.tapo.const import DOMAIN
1819
from custom_components.tapo.coordinators import TapoDataCoordinator, HassTapoDeviceData
1920

@@ -82,12 +83,18 @@ async def setup_children(
8283
devices: List[TapoDevice],
8384
polling_rate: timedelta,
8485
) -> List[TapoDataCoordinator]:
86+
button_polling_rate = timedelta(milliseconds=DEFAULT_BUTTON_POLLING_RATE_MS)
8587
coordinators = [
86-
TapoDataCoordinator(hass, child_device, polling_rate)
88+
TapoDataCoordinator(
89+
hass,
90+
child_device,
91+
button_polling_rate if isinstance(child_device, TriggerButtonDevice) else polling_rate,
92+
)
8793
for child_device in devices
8894
]
8995

9096
for coordinator in coordinators:
97+
coordinator._hub_entry_id = self.entry.entry_id
9198
await coordinator.async_config_entry_first_refresh()
9299

93100
device_entries = [

‎custom_components/tapo/hub/number.py‎

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,16 @@
77
from homeassistant.components.number import NumberDeviceClass
88
from homeassistant.components.number import NumberEntity
99
from homeassistant.components.number import NumberMode
10+
from homeassistant.components.number import RestoreNumber
1011
from homeassistant.config_entries import ConfigEntry
12+
from homeassistant.const import EntityCategory
13+
from homeassistant.const import PERCENTAGE
1114
from homeassistant.const import UnitOfTemperature
1215
from homeassistant.core import HomeAssistant
1316
from homeassistant.helpers.entity_platform import AddEntitiesCallback
1417
from homeassistant.helpers.typing import StateType
15-
from plugp100.new.child.tapohubchildren import KE100Device
18+
from plugp100.new.child.tapohubchildren import KE100Device, TriggerButtonDevice
19+
from plugp100.new.components.trigger_log_component import TriggerLogComponent
1620
from plugp100.responses.temperature_unit import TemperatureUnit
1721

1822
from custom_components.tapo.const import DOMAIN
@@ -32,6 +36,13 @@ async def async_setup_entry(
3236
[TRVTemperatureOffset(child_coordinator, device)],
3337
True
3438
)
39+
elif isinstance(device, TriggerButtonDevice) and device.has_component(
40+
TriggerLogComponent
41+
):
42+
async_add_entities(
43+
[PollUtilization(child_coordinator, device)],
44+
True
45+
)
3546

3647

3748
class TRVTemperatureOffset(CoordinatedTapoEntity, NumberEntity):
@@ -87,3 +98,49 @@ def native_unit_of_measurement(self) -> Optional[str]:
8798
async def async_set_native_value(self, value: float) -> None:
8899
(await self.device.set_temp_offset(int(value))).get_or_raise()
89100
await self.coordinator.async_request_refresh()
101+
102+
103+
DEFAULT_POLL_UTILIZATION = 35 # percent
104+
105+
106+
class PollUtilization(CoordinatedTapoEntity, RestoreNumber):
107+
"""Max hub utilization target for the adaptive polling algorithm.
108+
109+
Lower = less aggressive polling (more stable, slower response).
110+
Higher = more aggressive polling (faster response, more hub load).
111+
The PollLatencySensor reads this value to compute the polling interval.
112+
"""
113+
114+
_attr_has_entity_name = True
115+
_attr_name = "Poll Utilization"
116+
_attr_entity_category = EntityCategory.CONFIG
117+
_attr_icon = "mdi:speedometer"
118+
_attr_native_min_value = 5
119+
_attr_native_max_value = 50
120+
_attr_native_step = 5
121+
_attr_mode = NumberMode.SLIDER
122+
_attr_native_unit_of_measurement = PERCENTAGE
123+
124+
def __init__(
125+
self,
126+
coordinator: TapoDataCoordinator,
127+
device: TriggerButtonDevice,
128+
) -> None:
129+
super().__init__(coordinator, device)
130+
self._attr_native_value = float(DEFAULT_POLL_UTILIZATION)
131+
132+
@property
133+
def unique_id(self):
134+
return super().unique_id + "_poll_utilization"
135+
136+
async def async_added_to_hass(self) -> None:
137+
await super().async_added_to_hass()
138+
if (last := await self.async_get_last_number_data()) and last.native_value is not None:
139+
self._attr_native_value = last.native_value
140+
# Store on coordinator so PollLatencySensor can read it
141+
self.coordinator._poll_utilization_pct = self._attr_native_value
142+
143+
async def async_set_native_value(self, value: float) -> None:
144+
self._attr_native_value = value
145+
self.coordinator._poll_utilization_pct = value
146+
self.async_write_ha_state()

0 commit comments

Comments
 (0)