Skip to content

Commit e97b878

Browse files
lasswelltTom Lasswell
andauthored
feat(coordinator): auto-discover devices added after startup (#101) (#111)
Device discovery was one-shot at config-entry setup, so devices added to the Govee account after Home Assistant started stayed invisible until a manual reload (issue #101 — 8 new H5059 sensors on an H5044 hub didn't appear until the integration was reloaded). Add a throttled device-list re-check to the poll loop: every DEVICE_REDISCOVERY_INTERVAL (300s, well above the poll cadence to respect the 100/min, 10k/day API limits) re-fetch the device list; if a genuinely new device id appears, schedule a config-entry reload, which re-runs the existing, tested discovery + platform setup so the new entities are created. Deliberately reuses the proven reload path rather than refactoring dynamic entity addition across all ~10 platforms — far smaller regression surface for the same user-visible result. Only additions trigger a reload (removals stay tied to reload/restart + the existing orphan cleanup), the group filter is applied so a disabled group can't reload-loop, and the re-check is failure-isolated so a discovery hiccup never fails the state poll. Also corrects the quality_scale.yaml dynamic-devices comment, which previously claimed polling handled this (it didn't). Adds TestPeriodicRediscovery (reload on new device, no-op when unchanged, throttle, failure isolation, disabled-group ignore). Closes #101. Co-authored-by: Tom Lasswell <tom@calcyon.com>
1 parent 7b5b5c7 commit e97b878

4 files changed

Lines changed: 179 additions & 1 deletion

File tree

custom_components/govee/const.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,12 @@ def resolve_fahrenheit_conversion(sku: str, api_unit: str) -> bool:
6767
# confirmations clear the window early.
6868
OPTIMISTIC_GRACE_CAP_SECONDS: Final = 15
6969

70+
# How often (seconds) the coordinator re-checks the Govee account device list to
71+
# pick up devices added after startup (issue #101). Throttled well above the
72+
# poll interval to respect the 100/min, 10k/day API rate limits — a new device
73+
# appears within this window without a manual reload.
74+
DEVICE_REDISCOVERY_INTERVAL: Final = 300
75+
7076
# BLE constants
7177
# Govee AWS/BLE advert manufacturer ID. Verified against
7278
# Bluetooth-Devices/govee-ble (used by H5127 and related). Additional IDs

custom_components/govee/coordinator.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
from .const import (
5252
CONF_ENABLE_MQTT_CONTROL,
5353
DEFAULT_ENABLE_MQTT_CONTROL,
54+
DEVICE_REDISCOVERY_INTERVAL,
5455
DOMAIN,
5556
OPTIMISTIC_GRACE_CAP_SECONDS,
5657
)
@@ -252,6 +253,10 @@ def __init__(
252253
self._sensor_reading_changed_at: dict[str, datetime] = {}
253254
self._leak_hubs: dict[str, dict[str, Any]] = {}
254255
self._sno_to_sensor_id: dict[tuple[str, int], str] = {}
256+
# Last time the account device list was re-checked for newly added
257+
# devices (#101). Seeded to "now" so the first re-check waits a full
258+
# interval rather than firing right after setup discovery.
259+
self._last_rediscovery_check: float = time.monotonic()
255260
# Per-device queued button presses (supports multiple presses per tick)
256261
self._pending_button_presses: dict[str, int] = {}
257262
self._bff_poll_unsub: CALLBACK_TYPE | None = None
@@ -630,6 +635,52 @@ async def _discover_devices(self) -> None:
630635
except GoveeApiError as err:
631636
raise UpdateFailed(f"Failed to discover devices: {err}") from err
632637

638+
async def _async_maybe_rediscover_devices(self) -> None:
639+
"""Re-check the account device list and reload when new devices appear.
640+
641+
Device discovery is otherwise one-shot at setup, so a device added to
642+
the Govee account after Home Assistant started stays invisible until a
643+
manual reload (issue #101). Here we re-fetch the device list on a slow
644+
cadence (``DEVICE_REDISCOVERY_INTERVAL``, to respect the API rate
645+
limits); if any genuinely new device id appears, we schedule a config
646+
entry reload, which re-runs the existing, tested discovery + platform
647+
setup so the new entities are created.
648+
649+
Only additions trigger a reload — removals stay tied to reload/restart
650+
and the existing orphan-cleanup pass, to keep entity churn minimal. The
651+
method never raises: a discovery hiccup must not fail the state poll.
652+
"""
653+
now = time.monotonic()
654+
if now - self._last_rediscovery_check < DEVICE_REDISCOVERY_INTERVAL:
655+
return
656+
self._last_rediscovery_check = now
657+
658+
try:
659+
devices = await self._api_client.get_devices()
660+
except Exception as err:
661+
# Non-fatal: the regular state poll continues on the known devices.
662+
_LOGGER.debug("Periodic device re-discovery failed: %s", err)
663+
return
664+
665+
# Apply the same group filter discovery uses, so a group device left
666+
# disabled doesn't look "new" on every pass and reload-loop.
667+
candidate_ids = {
668+
device.device_id
669+
for device in devices
670+
if not (device.is_group and not self._enable_groups)
671+
}
672+
new_ids = candidate_ids - set(self._devices)
673+
if not new_ids:
674+
return
675+
676+
_LOGGER.info(
677+
"Detected %d new Govee device(s) since startup (%s) — reloading the "
678+
"integration to add them (#101)",
679+
len(new_ids),
680+
", ".join(sorted(new_ids)),
681+
)
682+
self.hass.config_entries.async_schedule_reload(self._config_entry.entry_id)
683+
633684
async def _start_mqtt(self) -> None:
634685
"""Start MQTT client for real-time updates."""
635686
if not self._iot_credentials:
@@ -1286,6 +1337,10 @@ async def _async_update_data(self) -> dict[str, GoveeDeviceState]:
12861337
12871338
Called by DataUpdateCoordinator on poll interval.
12881339
"""
1340+
# Pick up devices added to the account since setup (#101). Throttled and
1341+
# failure-isolated inside the method so it never disrupts the state poll.
1342+
await self._async_maybe_rediscover_devices()
1343+
12891344
if not self._devices:
12901345
return self._states
12911346

custom_components/govee/quality_scale.yaml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,10 @@ rules:
116116
comment: Use case examples could be expanded
117117
dynamic-devices:
118118
status: done
119-
comment: Devices discovered and updated via API polling
119+
comment: >-
120+
Devices added to the account after startup are picked up by a throttled
121+
re-check of the device list in the poll loop, which schedules a config
122+
entry reload when new devices appear (issue #101).
120123
entity-category:
121124
status: done
122125
comment: Diagnostic entities use EntityCategory.DIAGNOSTIC

tests/test_coordinator.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2314,3 +2314,117 @@ async def test_developer_poll_not_skipped_for_bridged_thermo(self, monkeypatch):
23142314

23152315
coord._api_client.get_device_state.assert_called_once()
23162316
assert result.sensor_humidity == 84.0
2317+
2318+
2319+
class TestPeriodicRediscovery:
2320+
"""New devices added after startup are picked up via reload (issue #101)."""
2321+
2322+
def _coord(self):
2323+
import custom_components.govee.coordinator as coord_mod
2324+
2325+
hass = MagicMock()
2326+
config_entry = MagicMock()
2327+
config_entry.entry_id = "test_entry"
2328+
coord = coord_mod.GoveeCoordinator(
2329+
hass=hass,
2330+
config_entry=config_entry,
2331+
api_client=MagicMock(),
2332+
iot_credentials=None,
2333+
poll_interval=60,
2334+
)
2335+
coord._enable_groups = False
2336+
return coord, coord_mod
2337+
2338+
@staticmethod
2339+
def _device(device_id, is_group=False):
2340+
return GoveeDevice(
2341+
device_id=device_id,
2342+
sku="H6001",
2343+
name=device_id,
2344+
device_type="devices.types.light",
2345+
capabilities=(),
2346+
is_group=is_group,
2347+
)
2348+
2349+
@pytest.mark.asyncio
2350+
async def test_schedules_reload_on_new_device(self):
2351+
import time
2352+
from unittest.mock import AsyncMock
2353+
2354+
coord, _ = self._coord()
2355+
dev_a = self._device("A")
2356+
coord._devices = {"A": dev_a}
2357+
coord._last_rediscovery_check = time.monotonic() - 10_000 # force elapsed
2358+
coord._api_client.get_devices = AsyncMock(
2359+
return_value=[dev_a, self._device("B")]
2360+
)
2361+
2362+
await coord._async_maybe_rediscover_devices()
2363+
2364+
coord.hass.config_entries.async_schedule_reload.assert_called_once_with(
2365+
"test_entry"
2366+
)
2367+
2368+
@pytest.mark.asyncio
2369+
async def test_no_reload_when_device_set_unchanged(self):
2370+
import time
2371+
from unittest.mock import AsyncMock
2372+
2373+
coord, _ = self._coord()
2374+
dev_a = self._device("A")
2375+
coord._devices = {"A": dev_a}
2376+
coord._last_rediscovery_check = time.monotonic() - 10_000
2377+
coord._api_client.get_devices = AsyncMock(return_value=[dev_a])
2378+
2379+
await coord._async_maybe_rediscover_devices()
2380+
2381+
coord.hass.config_entries.async_schedule_reload.assert_not_called()
2382+
2383+
@pytest.mark.asyncio
2384+
async def test_throttle_skips_when_recent(self):
2385+
import time
2386+
from unittest.mock import AsyncMock
2387+
2388+
coord, _ = self._coord()
2389+
coord._devices = {"A": self._device("A")}
2390+
coord._last_rediscovery_check = time.monotonic() # just checked
2391+
coord._api_client.get_devices = AsyncMock(return_value=[])
2392+
2393+
await coord._async_maybe_rediscover_devices()
2394+
2395+
coord._api_client.get_devices.assert_not_called()
2396+
coord.hass.config_entries.async_schedule_reload.assert_not_called()
2397+
2398+
@pytest.mark.asyncio
2399+
async def test_failure_is_isolated(self):
2400+
import time
2401+
from unittest.mock import AsyncMock
2402+
2403+
coord, _ = self._coord()
2404+
coord._devices = {"A": self._device("A")}
2405+
coord._last_rediscovery_check = time.monotonic() - 10_000
2406+
coord._api_client.get_devices = AsyncMock(side_effect=RuntimeError("boom"))
2407+
2408+
# Must not raise.
2409+
await coord._async_maybe_rediscover_devices()
2410+
2411+
coord.hass.config_entries.async_schedule_reload.assert_not_called()
2412+
2413+
@pytest.mark.asyncio
2414+
async def test_disabled_group_is_not_treated_as_new(self):
2415+
import time
2416+
from unittest.mock import AsyncMock
2417+
2418+
coord, _ = self._coord()
2419+
dev_a = self._device("A")
2420+
coord._devices = {"A": dev_a}
2421+
coord._enable_groups = False
2422+
coord._last_rediscovery_check = time.monotonic() - 10_000
2423+
# A new group device, but groups are disabled -> not "new", no reload.
2424+
coord._api_client.get_devices = AsyncMock(
2425+
return_value=[dev_a, self._device("11825917", is_group=True)]
2426+
)
2427+
2428+
await coord._async_maybe_rediscover_devices()
2429+
2430+
coord.hass.config_entries.async_schedule_reload.assert_not_called()

0 commit comments

Comments
 (0)