diff --git a/custom_components/tuya_local/helpers/device_config.py b/custom_components/tuya_local/helpers/device_config.py index fd92e29242..8e3f955644 100644 --- a/custom_components/tuya_local/helpers/device_config.py +++ b/custom_components/tuya_local/helpers/device_config.py @@ -174,6 +174,12 @@ def matches(self, dps, product_ids): return product_match or len(missing_dps) == 0 + def matches_product(self, product_id): + """Whether this config lists the given Tuya product id in `products`.""" + if not product_id: + return False + return any(p.get("id") == product_id for p in self._config.get("products", [])) + def _get_all_dps(self): all_dps_list = [] all_dps_list += [d for dev in self.all_entities() for d in dev.dps()] diff --git a/custom_components/tuya_local/helpers/discovery.py b/custom_components/tuya_local/helpers/discovery.py index d366d29a6d..1600ee75c4 100644 --- a/custom_components/tuya_local/helpers/discovery.py +++ b/custom_components/tuya_local/helpers/discovery.py @@ -1,5 +1,5 @@ """ -Active Tuya LAN rediscovery. +Active Tuya LAN discovery for configured devices. When a router hands out new DHCP leases (e.g. after a reboot) a Tuya device can change IP. The integration then keeps trying the stale ``host`` stored in @@ -8,18 +8,23 @@ Tuya devices do not all announce themselves unprompted -- in particular protocol 3.4/3.5 devices stay silent until they receive a discovery request -broadcast to UDP port 7000, at which point they reply with their current IP. -``tinytuya``'s scanner sends exactly that request, so ``tinytuya.find_device`` -locates a device by its id (``gwId``) regardless of how its IP changed. This is -the same mechanism the config flow already uses via ``scan_for_device`` and the -one ``localtuya`` uses to find devices in seconds. - -This module periodically checks for *unreachable* configured devices and, for -each, looks up its current IP by device id and updates the config entry's host -in place. Updating the entry triggers the integration's existing reload, which -reconnects the device on the new IP -- no manual reconfiguration, no cloud -round-trip, history preserved. Reachable devices are never scanned, so there is -no network traffic while everything is healthy. +broadcast to UDP port 7000, at which point they reply with their id (``gwId``), +current IP and ``product_id``. ``tinytuya``'s scanner sends exactly that request, +so ``tinytuya.find_device`` locates a device by its id regardless of how its IP +changed. This is the same mechanism the config flow already uses via +``scan_for_device`` and the one ``localtuya`` uses to find devices in seconds. + +This module runs two active-scan tasks for the configured devices: + +- a fast sweep (every ``SWEEP_INTERVAL``) that relocates *unreachable* devices: + it looks up the current IP by device id and updates the config entry's host in + place. The existing update-listener reload then reconnects the device on the + new IP -- no manual reconfiguration, no cloud round-trip, history preserved. + Reachable devices are never scanned, so there is no traffic while healthy. +- a slower scan (every ``SCAN_INTERVAL``) that logs, once per device per HA + start, when a configured device reports a ``product_id`` that its config file + does not list under ``products`` -- surfacing devices that may only be + partially supported so the config can be improved. References: - tinytuya scanner discovery request (port 7000 for v3.5 devices): @@ -35,8 +40,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.event import async_track_time_interval -from ..const import CONF_DEVICE_ID, DATA_DISCOVERY, DOMAIN +from ..const import CONF_DEVICE_ID, CONF_TYPE, DATA_DISCOVERY, DOMAIN from .config import get_device_id +from .device_config import get_config _LOGGER = logging.getLogger(__name__) @@ -45,12 +51,17 @@ # relocated on the first sweep after it drops. SWEEP_INTERVAL = timedelta(seconds=60) +# How often to scan configured devices to check their product id against the +# config file. Diagnostic only, so it runs infrequently. +SCAN_INTERVAL = timedelta(minutes=10) + def _find_device(device_id): """Locate a device by id on the LAN (blocking; run in executor). Sends the Tuya discovery request and returns the scanner result dict - (``{'ip': ..., 'id': ..., ...}``), or a blank result on any socket error. + (``{'ip': ..., 'id': ..., 'product_id': ..., ...}``), or a blank result on + any socket error. """ try: return tinytuya.find_device(dev_id=device_id) @@ -59,27 +70,36 @@ def _find_device(device_id): class TuyaLANRediscovery: - """Periodically relocate unreachable Tuya devices by active scan.""" + """Active LAN discovery for configured Tuya devices.""" def __init__(self, hass: HomeAssistant) -> None: self._hass = hass - self._unsub = None + self._unsub_sweep = None + self._unsub_scan = None self._scanning = False + # device ids already warned about an unmatched product id this run. + self._warned_products = set() @callback def async_start(self) -> None: - """Begin periodic rediscovery sweeps.""" - if self._unsub is None: - self._unsub = async_track_time_interval( + """Begin periodic discovery tasks.""" + if self._unsub_sweep is None: + self._unsub_sweep = async_track_time_interval( self._hass, self._async_sweep, SWEEP_INTERVAL ) + if self._unsub_scan is None: + self._unsub_scan = async_track_time_interval( + self._hass, self._async_product_scan, SCAN_INTERVAL + ) @callback def async_stop(self, event=None) -> None: - """Stop periodic rediscovery sweeps.""" - if self._unsub is not None: - self._unsub() - self._unsub = None + """Stop periodic discovery tasks.""" + for attr in ("_unsub_sweep", "_unsub_scan"): + unsub = getattr(self, attr) + if unsub is not None: + unsub() + setattr(self, attr, None) def _unreachable_entries(self): """Yield (entry, device_id) for configured devices not returning state.""" @@ -138,9 +158,48 @@ async def _async_sweep(self, now=None) -> None: finally: self._scanning = False + async def _async_product_scan(self, now=None) -> None: + """Warn (once per device per run) about product ids the config lacks.""" + if self._scanning: + return + entries = [ + (entry, entry.data.get(CONF_DEVICE_ID), entry.data.get(CONF_TYPE)) + for entry in self._hass.config_entries.async_entries(DOMAIN) + if entry.data.get(CONF_DEVICE_ID) and entry.data.get(CONF_TYPE) + ] + if not entries: + return + + self._scanning = True + try: + for entry, device_id, config_type in entries: + if device_id in self._warned_products: + continue + found = await self._hass.async_add_executor_job(_find_device, device_id) + product_id = found.get("product_id") if found else None + if not product_id: + continue + config = await self._hass.async_add_executor_job( + get_config, config_type + ) + if config is None or config.matches_product(product_id): + continue + # WARNING so it is visible under HA's default log level; once per + # device per run to avoid noise. + self._warned_products.add(device_id) + _LOGGER.warning( + "%s: device product id %s is not listed in its config (%s); " + "please report it so support can be improved", + entry.title, + product_id, + config_type, + ) + finally: + self._scanning = False + async def async_start_discovery(hass: HomeAssistant) -> None: - """Start the shared LAN rediscovery sweeper if not already running.""" + """Start the shared LAN discovery service if not already running.""" domain_data = hass.data.setdefault(DOMAIN, {}) if domain_data.get(DATA_DISCOVERY) is not None: return @@ -152,7 +211,7 @@ async def async_start_discovery(hass: HomeAssistant) -> None: @callback def async_stop_discovery(hass: HomeAssistant) -> None: - """Stop the shared LAN rediscovery sweeper if running.""" + """Stop the shared LAN discovery service if running.""" domain_data = hass.data.get(DOMAIN, {}) rediscovery = domain_data.pop(DATA_DISCOVERY, None) if rediscovery is not None: diff --git a/tests/test_discovery.py b/tests/test_discovery.py index d880a7cd32..d2e4eddbea 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -168,27 +168,118 @@ async def test_sweep_scans_when_no_device_object(hass, mocker): @pytest.mark.asyncio async def test_start_is_idempotent_and_stop_cancels(hass, mocker): - """async_start_discovery schedules one interval; stop cancels it.""" - unsub = mocker.MagicMock() + """async_start_discovery schedules the sweep + scan intervals; stop cancels both.""" + unsub_sweep = mocker.MagicMock() + unsub_scan = mocker.MagicMock() track = mocker.patch( "custom_components.tuya_local.helpers.discovery.async_track_time_interval", - return_value=unsub, + side_effect=[unsub_sweep, unsub_scan], ) await async_start_discovery(hass) rediscovery = hass.data[DOMAIN][DATA_DISCOVERY] assert isinstance(rediscovery, TuyaLANRediscovery) - assert track.call_count == 1 + assert track.call_count == 2 - # Second call must not schedule another interval (singleton). + # Second call must not schedule more intervals (singleton). await async_start_discovery(hass) - assert track.call_count == 1 + assert track.call_count == 2 async_stop_discovery(hass) - unsub.assert_called_once() + unsub_sweep.assert_called_once() + unsub_scan.assert_called_once() assert DATA_DISCOVERY not in hass.data[DOMAIN] -def test_module_exposes_expected_interval(): - """Guard the sweep cadence against accidental change.""" +def _fake_config(matches): + """Minimal stand-in for a device config with a matches_product() method.""" + return type("Cfg", (), {"matches_product": lambda self, pid: matches})() + + +@pytest.mark.asyncio +async def test_product_scan_warns_once_on_unmatched_product(hass, caplog, mocker): + """An unmatched product id is logged at WARNING, once per device per run.""" + _make_entry(hass, host="192.168.1.10") + mocker.patch( + "custom_components.tuya_local.helpers.discovery._find_device", + return_value={"ip": "192.168.1.10", "product_id": "keyabc123"}, + ) + mocker.patch( + "custom_components.tuya_local.helpers.discovery.get_config", + return_value=_fake_config(False), + ) + disc = TuyaLANRediscovery(hass) + + with caplog.at_level( + logging.WARNING, logger="custom_components.tuya_local.helpers.discovery" + ): + await disc._async_product_scan() + await hass.async_block_till_done() + assert caplog.text.count("keyabc123") == 1 + # A second scan must not warn again for the same device. + caplog.clear() + await disc._async_product_scan() + await hass.async_block_till_done() + assert "keyabc123" not in caplog.text + + +@pytest.mark.asyncio +async def test_product_scan_silent_when_product_matches(hass, caplog, mocker): + """No warning when the product id is listed in the config.""" + _make_entry(hass, host="192.168.1.10") + mocker.patch( + "custom_components.tuya_local.helpers.discovery._find_device", + return_value={"ip": "192.168.1.10", "product_id": "keyabc123"}, + ) + mocker.patch( + "custom_components.tuya_local.helpers.discovery.get_config", + return_value=_fake_config(True), + ) + with caplog.at_level( + logging.WARNING, logger="custom_components.tuya_local.helpers.discovery" + ): + await TuyaLANRediscovery(hass)._async_product_scan() + await hass.async_block_till_done() + assert "is not listed" not in caplog.text + + +@pytest.mark.asyncio +async def test_product_scan_skips_when_no_product_id(hass, mocker): + """If the scan returns no product id, the config is not even looked up.""" + _make_entry(hass, host="192.168.1.10") + mocker.patch( + "custom_components.tuya_local.helpers.discovery._find_device", + return_value={"ip": "192.168.1.10"}, + ) + get_config = mocker.patch( + "custom_components.tuya_local.helpers.discovery.get_config", + ) + await TuyaLANRediscovery(hass)._async_product_scan() + await hass.async_block_till_done() + get_config.assert_not_called() + + +@pytest.mark.asyncio +async def test_product_scan_handles_missing_config(hass, caplog, mocker): + """A missing config file must not warn or raise.""" + _make_entry(hass, host="192.168.1.10") + mocker.patch( + "custom_components.tuya_local.helpers.discovery._find_device", + return_value={"ip": "192.168.1.10", "product_id": "keyabc123"}, + ) + mocker.patch( + "custom_components.tuya_local.helpers.discovery.get_config", + return_value=None, + ) + with caplog.at_level( + logging.WARNING, logger="custom_components.tuya_local.helpers.discovery" + ): + await TuyaLANRediscovery(hass)._async_product_scan() + await hass.async_block_till_done() + assert "keyabc123" not in caplog.text + + +def test_module_exposes_expected_intervals(): + """Guard the cadences against accidental change.""" assert discovery.SWEEP_INTERVAL.total_seconds() == 60 + assert discovery.SCAN_INTERVAL.total_seconds() == 600