Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions custom_components/tuya_local/helpers/device_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()]
Expand Down
111 changes: 85 additions & 26 deletions custom_components/tuya_local/helpers/discovery.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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):
Expand All @@ -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__)

Expand All @@ -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)
Expand All @@ -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."""
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
109 changes: 100 additions & 9 deletions tests/test_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading