Prechádzať zdrojové kódy

feat: log unmatched product ids for configured devices (#5573)

Second increment for #5526 (builds on #5532). Adds a periodic (~10 min)
product scan to the discovery service: for each configured device it looks
up the product id via tinytuya.find_device and, when that id is not listed
in the device's config file (products:), logs a WARNING once per device per
HA start so the config can be improved. Adds TuyaDeviceConfig.matches_product()
and tests. The fast IP-rediscovery sweep from #5532 is unchanged.

Co-authored-by: neneonline <2820882+neneonline@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
neneonline 1 týždeň pred
rodič
commit
92c5adb38a

+ 6 - 0
custom_components/tuya_local/helpers/device_config.py

@@ -174,6 +174,12 @@ class TuyaDeviceConfig:
 
         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()]

+ 85 - 26
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 @@ it by hand.
 
 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.const import CONF_HOST
 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 @@ _LOGGER = logging.getLogger(__name__)
 # 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 @@ class TuyaLANRediscovery:
         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:

+ 100 - 9
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