Skip to content

Commit 1f8a832

Browse files
lasswelltTom Lasswell
andauthored
feat(diagnostics): read-only LAN discovery scan to seed LAN-API work (#57) (#112)
Native LAN control (issue #57) is a large, hardware-dependent feature and the maintainer has no LAN devices to test against. The agreed first step is the diagnostics-collection helper: capture which of a user's devices answer Govee's local UDP discovery, so the community can supply the data the full feature needs. - api/lan.py: async_scan_lan_devices() — one bounded multicast 'scan' to 239.255.255.250:4001, collects responses on :4002 for a short timeout, returns the deduped per-device discovery fields (ip, device, sku, fw versions). Uses asyncio datagram endpoints (event-loop safe). Read-only: no control commands, no entities, no persistent socket. - diagnostics.py: fold a 'lan_discovery' block into the entry diagnostics download, defensively (a scan error never breaks the download). Add 'ip' to TO_REDACT so a responder's private IP isn't leaked in a publicly-attached dump (the device MAC is already redacted; SKU + firmware are kept as the signal). This touches no control path and adds no entity/config surface. A 'lan' TransportKind, local control, and per-device merging wait until captures confirm the protocol per-SKU (the point of this increment). Adds tests/test_lan.py (scan request shape, parse, dedupe, empty, malformed, bind failure — all socket-mocked) and LAN diagnostics tests (block present, IP + MAC redacted, scan-failure isolated). Existing diagnostics tests stub the scan. Refs #57. Co-authored-by: Tom Lasswell <tom@calcyon.com>
1 parent 7f24ef8 commit 1f8a832

4 files changed

Lines changed: 334 additions & 1 deletion

File tree

custom_components/govee/api/lan.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"""Read-only Govee LAN (UDP) discovery helper.
2+
3+
Govee exposes a local UDP control API (must be toggled on per device in the
4+
Govee app) on a subset of mostly-light SKUs. This module performs ONLY the
5+
discovery half — a single bounded multicast ``scan`` — so a user can capture
6+
which of their devices answer on the LAN and what they report, and attach it to
7+
a diagnostics download. That community data is the prerequisite for the full
8+
LAN transport requested in issue #57 (the maintainer has no LAN hardware to
9+
test against).
10+
11+
Deliberately scoped: no control commands, no entities, no persistent socket —
12+
one scan, collect responses for a short timeout, return them. Protocol per
13+
``docs/govee-protocol-reference.md`` §6:
14+
15+
- Scan request -> 239.255.255.250:4001 ``{"msg":{"cmd":"scan",...}}``
16+
- Scan response -> client:4002 ``{"msg":{"cmd":"scan","data":{...}}}``
17+
"""
18+
19+
from __future__ import annotations
20+
21+
import asyncio
22+
import json
23+
import logging
24+
import socket
25+
from typing import Any
26+
27+
_LOGGER = logging.getLogger(__name__)
28+
29+
# Govee LAN API network parameters (docs/govee-protocol-reference.md §6).
30+
LAN_MULTICAST_GROUP = "239.255.255.250"
31+
LAN_DISCOVERY_PORT = 4001 # devices listen here for the scan request
32+
LAN_RESPONSE_PORT = 4002 # we listen here for scan responses
33+
34+
_SCAN_REQUEST = json.dumps(
35+
{"msg": {"cmd": "scan", "data": {"account_topic": "reserve"}}}
36+
).encode("utf-8")
37+
38+
# Fields a scan response may carry; we surface exactly these (no control data).
39+
_RESPONSE_FIELDS = (
40+
"ip",
41+
"device",
42+
"sku",
43+
"bleVersionHard",
44+
"bleVersionSoft",
45+
"wifiVersionHard",
46+
"wifiVersionSoft",
47+
)
48+
49+
50+
class _ScanProtocol(asyncio.DatagramProtocol):
51+
"""Collects well-formed Govee ``scan`` responses; ignores everything else."""
52+
53+
def __init__(self) -> None:
54+
self.responses: dict[str, dict[str, Any]] = {}
55+
56+
def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None:
57+
try:
58+
payload = json.loads(data.decode("utf-8", errors="replace"))
59+
msg = payload.get("msg", {})
60+
if msg.get("cmd") != "scan":
61+
return
62+
body = msg.get("data", {})
63+
if not isinstance(body, dict):
64+
return
65+
except (ValueError, AttributeError):
66+
return
67+
68+
record = {field: body[field] for field in _RESPONSE_FIELDS if field in body}
69+
if not record:
70+
return
71+
# Dedupe by the device id (MAC) when present, else by source IP.
72+
key = str(record.get("device") or addr[0])
73+
self.responses[key] = record
74+
75+
def error_received(self, exc: Exception) -> None: # pragma: no cover - rare
76+
_LOGGER.debug("LAN scan socket error: %s", exc)
77+
78+
79+
async def async_scan_lan_devices(timeout: float = 2.0) -> list[dict[str, Any]]:
80+
"""Send one multicast ``scan`` and collect responses for ``timeout`` seconds.
81+
82+
Returns a list of per-device dicts (deduped) limited to the discovery fields
83+
above — no control surface is touched. Uses asyncio datagram endpoints so it
84+
is safe to run on the Home Assistant event loop.
85+
86+
Raises ``OSError`` if the response socket cannot be bound (e.g. port 4002 in
87+
use by another local-control app); callers should treat that as "no data".
88+
"""
89+
loop = asyncio.get_running_loop()
90+
91+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
92+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
93+
# Multicast send TTL of 2 so a scan can cross one router hop if present.
94+
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2)
95+
sock.setblocking(False)
96+
try:
97+
sock.bind(("", LAN_RESPONSE_PORT))
98+
except OSError:
99+
sock.close()
100+
raise
101+
102+
transport, protocol = await loop.create_datagram_endpoint(
103+
_ScanProtocol, sock=sock
104+
)
105+
assert isinstance(protocol, _ScanProtocol)
106+
try:
107+
transport.sendto(_SCAN_REQUEST, (LAN_MULTICAST_GROUP, LAN_DISCOVERY_PORT))
108+
await asyncio.sleep(timeout)
109+
finally:
110+
transport.close()
111+
112+
return list(protocol.responses.values())

custom_components/govee/diagnostics.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from homeassistant.core import HomeAssistant
1919
from homeassistant.helpers.device_registry import DeviceEntry
2020

21+
from .api.lan import async_scan_lan_devices
2122
from .const import CONF_API_KEY, CONF_EMAIL, CONF_PASSWORD, DOMAIN
2223
from .coordinator import GoveeCoordinator
2324
from .models.transport import TRANSPORT_KINDS
@@ -42,6 +43,9 @@
4243
"deviceName",
4344
"hub_device_id",
4445
"mac",
46+
# Local network address from the LAN-discovery scan (#57) — a private IP is
47+
# still PII in a publicly-attached diagnostics download.
48+
"ip",
4549
}
4650

4751
# Govee device IDs are MAC-derived: 8 colon-separated hex octets
@@ -200,6 +204,31 @@ def _runtime_diag(coordinator: GoveeCoordinator) -> dict[str, Any]:
200204
}
201205

202206

207+
async def _lan_discovery_diag() -> dict[str, Any]:
208+
"""Run one read-only LAN scan for the diagnostics download (issue #57).
209+
210+
Captures which of the user's devices answer Govee's local UDP discovery and
211+
what they report, so the community can supply the data the full LAN feature
212+
needs. Never raises — diagnostics must always produce output; the IP of each
213+
responder is redacted by the shared ``_redact`` pass.
214+
"""
215+
try:
216+
devices = await async_scan_lan_devices()
217+
return {
218+
"scan_attempted": True,
219+
"device_count": len(devices),
220+
"devices": devices,
221+
"error": None,
222+
}
223+
except Exception as err: # never break the diagnostics download
224+
return {
225+
"scan_attempted": True,
226+
"device_count": 0,
227+
"devices": [],
228+
"error": str(err),
229+
}
230+
231+
203232
def _redact(data: dict[str, Any]) -> dict[str, Any]:
204233
"""Redact sensitive keys, then hash any MAC-format device-map keys."""
205234
redacted: dict[str, Any] = async_redact_data(data, TO_REDACT)
@@ -235,6 +264,8 @@ async def async_get_config_entry_diagnostics(
235264
# Verbatim device-list response from the most recent discovery poll.
236265
"raw_api_devices": coordinator.api_client.last_raw_devices,
237266
"leak_sensors": _leak_diag(coordinator),
267+
# Read-only local-network scan to seed the LAN-API work (issue #57).
268+
"lan_discovery": await _lan_discovery_diag(),
238269
**_runtime_diag(coordinator),
239270
}
240271
return _redact(diagnostics_data)

tests/test_diagnostics.py

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import json
66
import re
7-
from unittest.mock import MagicMock
7+
from unittest.mock import AsyncMock, MagicMock
88

99
import pytest
1010

@@ -24,6 +24,20 @@
2424
from custom_components.govee.models.transport import TransportHealth
2525

2626

27+
@pytest.fixture(autouse=True)
28+
def _stub_lan_scan(monkeypatch):
29+
"""Stub the LAN scan so entry-diagnostics tests stay fast + offline (#57).
30+
31+
async_get_config_entry_diagnostics now runs a real UDP discovery scan;
32+
default it to "no devices" so the existing tests don't bind a socket or
33+
wait on the scan timeout. Tests that exercise the LAN block override this.
34+
"""
35+
monkeypatch.setattr(
36+
"custom_components.govee.diagnostics.async_scan_lan_devices",
37+
AsyncMock(return_value=[]),
38+
)
39+
40+
2741
def _coordinator_stub(**overrides):
2842
"""A coordinator MagicMock with all diagnostics accessors defaulted sanely."""
2943
coordinator = MagicMock()
@@ -447,3 +461,57 @@ async def test_leak_hub_device_includes_its_sensors(self) -> None:
447461
assert hub_mac not in rendered
448462
assert sensor_mac not in rendered
449463
assert _MAC_RE.search(rendered) is None
464+
465+
466+
class TestLanDiscoveryDiag:
467+
"""Entry diagnostics include a read-only LAN scan, with IP redacted (#57)."""
468+
469+
@pytest.mark.asyncio
470+
async def test_lan_block_present_and_ip_redacted(self, monkeypatch) -> None:
471+
monkeypatch.setattr(
472+
"custom_components.govee.diagnostics.async_scan_lan_devices",
473+
AsyncMock(
474+
return_value=[
475+
{
476+
"ip": "192.168.1.23",
477+
"device": "1F:80:C5:32:32:36:72:4E",
478+
"sku": "H6072",
479+
"wifiVersionSoft": "1.02.03",
480+
}
481+
]
482+
),
483+
)
484+
coordinator = _coordinator_stub()
485+
out = await async_get_config_entry_diagnostics(
486+
MagicMock(), _entry_stub(coordinator)
487+
)
488+
489+
lan = out["lan_discovery"]
490+
assert lan["scan_attempted"] is True
491+
assert lan["device_count"] == 1
492+
device = lan["devices"][0]
493+
# SKU + firmware are kept (the useful signal); IP + MAC are redacted.
494+
assert device["sku"] == "H6072"
495+
assert device["wifiVersionSoft"] == "1.02.03"
496+
assert device["ip"] == "**REDACTED**"
497+
assert device["device"] == "**REDACTED**"
498+
rendered = json.dumps(out, default=str)
499+
assert "192.168.1.23" not in rendered
500+
assert "1F:80:C5:32:32:36:72:4E" not in rendered
501+
502+
@pytest.mark.asyncio
503+
async def test_lan_scan_failure_is_isolated(self, monkeypatch) -> None:
504+
# A scan error must not break the diagnostics download.
505+
monkeypatch.setattr(
506+
"custom_components.govee.diagnostics.async_scan_lan_devices",
507+
AsyncMock(side_effect=OSError("port 4002 in use")),
508+
)
509+
coordinator = _coordinator_stub()
510+
out = await async_get_config_entry_diagnostics(
511+
MagicMock(), _entry_stub(coordinator)
512+
)
513+
514+
lan = out["lan_discovery"]
515+
assert lan["scan_attempted"] is True
516+
assert lan["device_count"] == 0
517+
assert "port 4002 in use" in lan["error"]

tests/test_lan.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
"""Tests for the read-only Govee LAN discovery helper (issue #57)."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
from unittest.mock import MagicMock
7+
8+
import pytest
9+
10+
from custom_components.govee.api import lan
11+
12+
13+
def _install_fake_endpoint(monkeypatch, responses, *, bind_error=None):
14+
"""Patch socket + event loop so async_scan_lan_devices runs offline.
15+
16+
Returns the MagicMock transport so tests can assert on sendto. ``responses``
17+
are fed to the real _ScanProtocol during the (stubbed) scan wait.
18+
"""
19+
sock = MagicMock()
20+
if bind_error is not None:
21+
sock.bind.side_effect = bind_error
22+
monkeypatch.setattr(lan.socket, "socket", lambda *a, **k: sock)
23+
24+
proto = lan._ScanProtocol()
25+
transport = MagicMock()
26+
27+
async def _create_endpoint(_factory, sock=None):
28+
return transport, proto
29+
30+
mock_loop = MagicMock()
31+
mock_loop.create_datagram_endpoint = _create_endpoint
32+
monkeypatch.setattr(lan.asyncio, "get_running_loop", lambda: mock_loop)
33+
34+
async def _sleep(_timeout):
35+
for resp in responses:
36+
proto.datagram_received(
37+
json.dumps(resp).encode("utf-8"), ("192.168.1.50", 4002)
38+
)
39+
40+
monkeypatch.setattr(lan.asyncio, "sleep", _sleep)
41+
return transport, sock
42+
43+
44+
def _scan_response(**data):
45+
return {"msg": {"cmd": "scan", "data": data}}
46+
47+
48+
@pytest.mark.asyncio
49+
async def test_sends_scan_request_to_multicast(monkeypatch):
50+
transport, _ = _install_fake_endpoint(monkeypatch, [])
51+
52+
await lan.async_scan_lan_devices(timeout=0.01)
53+
54+
transport.sendto.assert_called_once()
55+
payload, addr = transport.sendto.call_args[0]
56+
assert addr == (lan.LAN_MULTICAST_GROUP, lan.LAN_DISCOVERY_PORT)
57+
sent = json.loads(payload.decode())
58+
assert sent == {"msg": {"cmd": "scan", "data": {"account_topic": "reserve"}}}
59+
60+
61+
@pytest.mark.asyncio
62+
async def test_parses_and_returns_devices(monkeypatch):
63+
responses = [
64+
_scan_response(
65+
ip="192.168.1.23",
66+
device="1F:80:C5:32:32:36:72:4E",
67+
sku="H6072",
68+
wifiVersionSoft="1.02.03",
69+
)
70+
]
71+
_install_fake_endpoint(monkeypatch, responses)
72+
73+
devices = await lan.async_scan_lan_devices(timeout=0.01)
74+
75+
assert len(devices) == 1
76+
assert devices[0]["sku"] == "H6072"
77+
assert devices[0]["ip"] == "192.168.1.23"
78+
assert devices[0]["device"] == "1F:80:C5:32:32:36:72:4E"
79+
assert devices[0]["wifiVersionSoft"] == "1.02.03"
80+
81+
82+
@pytest.mark.asyncio
83+
async def test_dedupes_by_device_id(monkeypatch):
84+
dup = _scan_response(ip="192.168.1.23", device="AA:BB:CC:DD:EE:FF:00:11", sku="H6072")
85+
_install_fake_endpoint(monkeypatch, [dup, dup])
86+
87+
devices = await lan.async_scan_lan_devices(timeout=0.01)
88+
89+
assert len(devices) == 1
90+
91+
92+
@pytest.mark.asyncio
93+
async def test_no_responses_returns_empty(monkeypatch):
94+
_install_fake_endpoint(monkeypatch, [])
95+
96+
devices = await lan.async_scan_lan_devices(timeout=0.01)
97+
98+
assert devices == []
99+
100+
101+
@pytest.mark.asyncio
102+
async def test_ignores_malformed_and_non_scan(monkeypatch):
103+
proto = lan._ScanProtocol()
104+
# Garbage bytes.
105+
proto.datagram_received(b"not-json", ("192.168.1.9", 4002))
106+
# Valid JSON but a different command.
107+
proto.datagram_received(
108+
json.dumps({"msg": {"cmd": "devStatus", "data": {"onOff": 1}}}).encode(),
109+
("192.168.1.9", 4002),
110+
)
111+
# scan but empty data -> no usable record.
112+
proto.datagram_received(json.dumps(_scan_response()).encode(), ("192.168.1.9", 4002))
113+
114+
assert proto.responses == {}
115+
116+
117+
@pytest.mark.asyncio
118+
async def test_bind_failure_raises_oserror(monkeypatch):
119+
_install_fake_endpoint(monkeypatch, [], bind_error=OSError("port in use"))
120+
121+
with pytest.raises(OSError):
122+
await lan.async_scan_lan_devices(timeout=0.01)

0 commit comments

Comments
 (0)