Ver Fonte

feat: discover new Tuya devices on the LAN (#5605)

Third increment for #5526. Extends the discovery service's periodic scan to a
single tinytuya.deviceScan that (a) checks product ids for configured devices
(folding in the per-device check from #5573) and (b) raises an
integration_discovery flow for each unconfigured device found, so it surfaces
in Home Assistant for one-click setup with the built-in per-unique_id ignore.

Adds ConfigFlowHandler.async_step_integration_discovery, which reuses the
existing manual-setup prefill (id/ip/version from discovery; the user supplies
the local key) and aborts if the device is already configured or ignored.
Keyed on gwId. 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 há 2 dias atrás
pai
commit
cafa367b38

+ 24 - 0
custom_components/tuya_local/config_flow.py

@@ -70,6 +70,30 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
         if self.cloud is None:
             self.cloud = Cloud(self.hass)
 
+    async def async_step_integration_discovery(self, discovery_info):
+        """Handle a device found on the LAN by the background scanner.
+
+        Pre-fills the manual setup form with the discovered id/ip/version; the
+        user still supplies the local key. Aborts if the device is already
+        configured or has been ignored.
+        """
+        device_id = discovery_info.get(CONF_DEVICE_ID)
+        await self.async_set_unique_id(device_id)
+        self._abort_if_unique_id_configured()
+        # Reuse the cloud-device plumbing that async_step_local reads for its
+        # form defaults; the local key is not known from discovery.
+        self.__cloud_device = {
+            "id": device_id,
+            "ip": discovery_info.get(CONF_HOST),
+            "version": discovery_info.get("version"),
+            "local_product_id": discovery_info.get("product_id"),
+            CONF_LOCAL_KEY: "",
+        }
+        self.context["title_placeholders"] = {
+            "name": discovery_info.get(CONF_HOST) or device_id
+        }
+        return await self.async_step_local()
+
     async def async_step_user(self, user_input=None):
         errors = {}
 

+ 97 - 49
custom_components/tuya_local/helpers/discovery.py

@@ -1,5 +1,5 @@
 """
-Active Tuya LAN discovery for configured devices.
+Active Tuya LAN discovery.
 
 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
@@ -9,22 +9,24 @@ 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 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
+current IP and ``productKey``. ``tinytuya``'s scanner sends exactly that request,
+so ``tinytuya.find_device``/``tinytuya.deviceScan`` locate devices regardless of
+how their 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:
+This module runs two active-scan tasks:
 
-- 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.
+- a fast sweep (every ``SWEEP_INTERVAL``) that relocates *unreachable* configured
+  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.
+- a slower full scan (every ``SCAN_INTERVAL``) that, from a single
+  ``deviceScan``: (a) warns, once per device per HA start, when a *configured*
+  device reports a ``productKey`` its config file does not list under
+  ``products`` (so the config can be improved); and (b) raises an
+  ``integration_discovery`` flow for each *unconfigured* device found, so it
+  surfaces in Home Assistant for one-click setup (with the built-in ignore).
 
 References:
 - tinytuya scanner discovery request (port 7000 for v3.5 devices):
@@ -36,6 +38,7 @@ import logging
 from datetime import timedelta
 
 import tinytuya
+from homeassistant.config_entries import SOURCE_INTEGRATION_DISCOVERY
 from homeassistant.const import CONF_HOST
 from homeassistant.core import HomeAssistant, callback
 from homeassistant.helpers.event import async_track_time_interval
@@ -51,8 +54,8 @@ _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.
+# How often to run the full network scan (product-id check + new-device
+# discovery). Infrequent, since neither action is time critical.
 SCAN_INTERVAL = timedelta(minutes=10)
 
 
@@ -69,8 +72,20 @@ def _find_device(device_id):
         return {"ip": None}
 
 
+def _scan_all():
+    """Scan the LAN for all Tuya devices (blocking; run in executor).
+
+    Returns tinytuya's dict keyed by IP, each value carrying ``gwId``,
+    ``productKey`` and ``version``; an empty dict on any socket error.
+    """
+    try:
+        return tinytuya.deviceScan(verbose=False, poll=False)
+    except OSError:
+        return {}
+
+
 class TuyaLANRediscovery:
-    """Active LAN discovery for configured Tuya devices."""
+    """Active LAN discovery for Tuya devices."""
 
     def __init__(self, hass: HomeAssistant) -> None:
         self._hass = hass
@@ -79,6 +94,8 @@ class TuyaLANRediscovery:
         self._scanning = False
         # device ids already warned about an unmatched product id this run.
         self._warned_products = set()
+        # gwIds an integration_discovery flow has already been raised for.
+        self._discovered = set()
 
     @callback
     def async_start(self) -> None:
@@ -89,7 +106,7 @@ class TuyaLANRediscovery:
             )
         if self._unsub_scan is None:
             self._unsub_scan = async_track_time_interval(
-                self._hass, self._async_product_scan, SCAN_INTERVAL
+                self._hass, self._async_discovery_scan, SCAN_INTERVAL
             )
 
     @callback
@@ -158,45 +175,76 @@ 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."""
+    async def _async_discovery_scan(self, now=None) -> None:
+        """Full LAN scan: product-id check for known devices, discover new ones."""
         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,
-                )
+            found = await self._hass.async_add_executor_job(_scan_all)
+            if not found:
+                return
+
+            by_gwid = {}
+            for info in found.values():
+                gwid = info.get("gwId")
+                if gwid:
+                    by_gwid[gwid] = info
+
+            configured = {}
+            for entry in self._hass.config_entries.async_entries(DOMAIN):
+                device_id = entry.data.get(CONF_DEVICE_ID)
+                if device_id:
+                    configured[device_id] = entry
+
+            for gwid, info in by_gwid.items():
+                entry = configured.get(gwid)
+                if entry is not None:
+                    await self._check_product(entry, info.get("productKey"))
+                else:
+                    self._discover_new(gwid, info)
         finally:
             self._scanning = False
 
+    async def _check_product(self, entry, product_id) -> None:
+        """Warn once per run when a configured device's product id is unlisted."""
+        device_id = entry.data.get(CONF_DEVICE_ID)
+        config_type = entry.data.get(CONF_TYPE)
+        if not product_id or not config_type or device_id in self._warned_products:
+            return
+        config = await self._hass.async_add_executor_job(get_config, config_type)
+        if config is None or config.matches_product(product_id):
+            return
+        # 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,
+        )
+
+    @callback
+    def _discover_new(self, gwid, info) -> None:
+        """Raise an integration_discovery flow for a not-yet-configured device."""
+        if gwid in self._discovered:
+            return
+        self._discovered.add(gwid)
+        self._hass.async_create_task(
+            self._hass.config_entries.flow.async_init(
+                DOMAIN,
+                context={"source": SOURCE_INTEGRATION_DISCOVERY},
+                data={
+                    CONF_DEVICE_ID: gwid,
+                    CONF_HOST: info.get("ip"),
+                    "product_id": info.get("productKey"),
+                    "version": info.get("version"),
+                },
+            )
+        )
+
 
 async def async_start_discovery(hass: HomeAssistant) -> None:
     """Start the shared LAN discovery service if not already running."""

+ 34 - 0
tests/test_config_flow.py

@@ -1619,3 +1619,37 @@ async def test_flow_choose_entities_uses_cloud_name_as_default(
     # Validate it accepts the cloud device name
     validated = schema({CONF_NAME: "My Cloud Device"})
     assert validated[CONF_NAME] == "My Cloud Device"
+
+
+@pytest.mark.asyncio
+async def test_flow_integration_discovery_shows_local_form(hass):
+    """A device found by background discovery advances to the local setup form."""
+    result = await hass.config_entries.flow.async_init(
+        DOMAIN,
+        context={"source": "integration_discovery"},
+        data={
+            CONF_DEVICE_ID: "bfdiscovered000000",
+            CONF_HOST: "192.168.1.77",
+            "product_id": "keyxyz",
+            "version": "3.5",
+        },
+    )
+    assert result["type"] == "form"
+    assert result["step_id"] == "local"
+
+
+@pytest.mark.asyncio
+async def test_flow_integration_discovery_aborts_if_configured(hass):
+    """A discovered device that is already configured (or ignored) aborts."""
+    entry = MockConfigEntry(domain=DOMAIN, unique_id="bfdiscovered000000")
+    entry.add_to_hass(hass)
+    result = await hass.config_entries.flow.async_init(
+        DOMAIN,
+        context={"source": "integration_discovery"},
+        data={
+            CONF_DEVICE_ID: "bfdiscovered000000",
+            CONF_HOST: "192.168.1.77",
+        },
+    )
+    assert result["type"] == "abort"
+    assert result["reason"] == "already_configured"

+ 106 - 14
tests/test_discovery.py

@@ -1,6 +1,7 @@
 """Tests for the active Tuya LAN rediscovery sweeper."""
 
 import logging
+from unittest.mock import AsyncMock
 
 import pytest
 from homeassistant.const import CONF_HOST
@@ -196,29 +197,45 @@ def _fake_config(matches):
     return type("Cfg", (), {"matches_product": lambda self, pid: matches})()
 
 
+def _scan_result(gwid=DEVID, product="keyabc123", ip="192.168.1.10"):
+    """A tinytuya.deviceScan-style result: keyed by IP, carrying gwId/productKey."""
+    info = {"gwId": gwid, "ip": ip, "version": "3.5"}
+    if product is not None:
+        info["productKey"] = product
+    return {ip: info}
+
+
+def _patch_flow_init(hass, mocker):
+    """Patch the config-entries flow init with an awaitable mock."""
+    return mocker.patch.object(
+        hass.config_entries.flow, "async_init", new_callable=AsyncMock
+    )
+
+
 @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"},
+        "custom_components.tuya_local.helpers.discovery._scan_all",
+        return_value=_scan_result(),
     )
     mocker.patch(
         "custom_components.tuya_local.helpers.discovery.get_config",
         return_value=_fake_config(False),
     )
+    _patch_flow_init(hass, mocker)
     disc = TuyaLANRediscovery(hass)
 
     with caplog.at_level(
         logging.WARNING, logger="custom_components.tuya_local.helpers.discovery"
     ):
-        await disc._async_product_scan()
+        await disc._async_discovery_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 disc._async_discovery_scan()
         await hass.async_block_till_done()
         assert "keyabc123" not in caplog.text
 
@@ -228,33 +245,35 @@ 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"},
+        "custom_components.tuya_local.helpers.discovery._scan_all",
+        return_value=_scan_result(),
     )
     mocker.patch(
         "custom_components.tuya_local.helpers.discovery.get_config",
         return_value=_fake_config(True),
     )
+    _patch_flow_init(hass, mocker)
     with caplog.at_level(
         logging.WARNING, logger="custom_components.tuya_local.helpers.discovery"
     ):
-        await TuyaLANRediscovery(hass)._async_product_scan()
+        await TuyaLANRediscovery(hass)._async_discovery_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."""
+    """If the scan reports 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"},
+        "custom_components.tuya_local.helpers.discovery._scan_all",
+        return_value=_scan_result(product=None),
     )
     get_config = mocker.patch(
         "custom_components.tuya_local.helpers.discovery.get_config",
     )
-    await TuyaLANRediscovery(hass)._async_product_scan()
+    _patch_flow_init(hass, mocker)
+    await TuyaLANRediscovery(hass)._async_discovery_scan()
     await hass.async_block_till_done()
     get_config.assert_not_called()
 
@@ -264,21 +283,94 @@ 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"},
+        "custom_components.tuya_local.helpers.discovery._scan_all",
+        return_value=_scan_result(),
     )
     mocker.patch(
         "custom_components.tuya_local.helpers.discovery.get_config",
         return_value=None,
     )
+    _patch_flow_init(hass, mocker)
     with caplog.at_level(
         logging.WARNING, logger="custom_components.tuya_local.helpers.discovery"
     ):
-        await TuyaLANRediscovery(hass)._async_product_scan()
+        await TuyaLANRediscovery(hass)._async_discovery_scan()
         await hass.async_block_till_done()
     assert "keyabc123" not in caplog.text
 
 
+@pytest.mark.asyncio
+async def test_discovery_raises_flow_for_unknown_device(hass, mocker):
+    """An unconfigured device on the LAN starts an integration_discovery flow."""
+    mocker.patch(
+        "custom_components.tuya_local.helpers.discovery._scan_all",
+        return_value=_scan_result(gwid="bfunknown000000000", ip="192.168.1.99"),
+    )
+    init = _patch_flow_init(hass, mocker)
+
+    await TuyaLANRediscovery(hass)._async_discovery_scan()
+    await hass.async_block_till_done()
+
+    init.assert_awaited_once()
+    args, kwargs = init.call_args
+    assert args[0] == DOMAIN
+    assert kwargs["context"]["source"] == "integration_discovery"
+    assert kwargs["data"][CONF_DEVICE_ID] == "bfunknown000000000"
+    assert kwargs["data"][CONF_HOST] == "192.168.1.99"
+
+
+@pytest.mark.asyncio
+async def test_discovery_skips_configured_device(hass, mocker):
+    """A device already configured is not offered for discovery again."""
+    _make_entry(hass, host="192.168.1.10")  # DEVID is configured
+    mocker.patch(
+        "custom_components.tuya_local.helpers.discovery._scan_all",
+        return_value=_scan_result(gwid=DEVID),
+    )
+    mocker.patch(
+        "custom_components.tuya_local.helpers.discovery.get_config",
+        return_value=_fake_config(True),
+    )
+    init = _patch_flow_init(hass, mocker)
+
+    await TuyaLANRediscovery(hass)._async_discovery_scan()
+    await hass.async_block_till_done()
+
+    init.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_discovery_raises_flow_only_once_per_device(hass, mocker):
+    """Repeated scans do not spawn duplicate flows for the same new device."""
+    mocker.patch(
+        "custom_components.tuya_local.helpers.discovery._scan_all",
+        return_value=_scan_result(gwid="bfunknown000000000", ip="192.168.1.99"),
+    )
+    init = _patch_flow_init(hass, mocker)
+    disc = TuyaLANRediscovery(hass)
+
+    await disc._async_discovery_scan()
+    await hass.async_block_till_done()
+    await disc._async_discovery_scan()
+    await hass.async_block_till_done()
+
+    assert init.await_count == 1
+
+
+@pytest.mark.asyncio
+async def test_discovery_scan_handles_empty_result(hass, mocker):
+    """An empty scan (e.g. socket error) does nothing and does not raise."""
+    _make_entry(hass, host="192.168.1.10")
+    mocker.patch(
+        "custom_components.tuya_local.helpers.discovery._scan_all",
+        return_value={},
+    )
+    init = _patch_flow_init(hass, mocker)
+    await TuyaLANRediscovery(hass)._async_discovery_scan()
+    await hass.async_block_till_done()
+    init.assert_not_awaited()
+
+
 def test_module_exposes_expected_intervals():
     """Guard the cadences against accidental change."""
     assert discovery.SWEEP_INTERVAL.total_seconds() == 60