Files
localthings/tests/localthings/test_diagnostics.py
T
Marc Billow 10d5c81d9a feat(diagnostics): speculatively probe /device/1 and /device/2
/oic/res's baseline-Interface response only lists resources with the
discoverable policy bit set, and a real dump (issue #177 follow-up,
TP1X_REF_21K) confirms /device/0's whole x.com.samsung.da.* tree is
registered without it -- so a second logical Device's Collection, if
one exists, would be just as invisible to /oic/res as /device/0 is.
Probe /device/1 and /device/2 directly instead: a plain non-mutating
RETRIEVE, tolerated-404 same as every other speculative read in this
module. Parsed with the same parse_device0_batch used for /device/0
itself, and folded into identity.raw so diagnostics can tell "checked,
found nothing" apart from "never checked".
2026-07-29 04:08:19 +00:00

211 lines
8.3 KiB
Python

"""Tests for the diagnostics platform."""
from __future__ import annotations
import json
import threading
from pathlib import Path
from homeassistant.core import HomeAssistant
from custom_components.localthings import diagnostics as diagnostics_mod
from custom_components.localthings.const import DOMAIN
from custom_components.localthings.diagnostics import async_get_config_entry_diagnostics
from custom_components.localthings.registry.redact import REDACTED
_MANIFEST_VERSION = json.loads(
(Path(__file__).parents[2] / 'custom_components' / 'localthings' / 'manifest.json').read_text()
)['version']
async def test_diagnostics_shape_and_redaction(
hass: HomeAssistant, mock_entry, mock_coordinator_session
) -> None:
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
diagnostics = await async_get_config_entry_diagnostics(hass, mock_entry)
assert diagnostics["device_type"] == 'refrigerator'
assert diagnostics["one_ui_version"] == '7.0 Refrigerator'
assert diagnostics["unbound_hrefs"] == []
assert diagnostics["integration_version"] == _MANIFEST_VERSION
assert diagnostics["smartthings_local_version"]
resources = diagnostics["resources"]
assert resources['/information/vs/0']['x.com.samsung.da.serialNum'] == REDACTED
assert resources['/wirelessinfo/vs/0']['macaddressWiFi'] == REDACTED
# Ordinary state survives.
assert resources['/status/lock/vs/0']['x.com.samsung.da.ado.devicecontrol'] == 'On'
async def test_diagnostics_include_ocf_identity(
hass: HomeAssistant, mock_entry, mock_coordinator_session
) -> None:
"""/oic/p and /oic/d are outside the /device/0 batch, so diagnostics is
the only place an issue report can carry them -- and `rt` there is OCF's
own device-type declaration."""
from custom_components.localthings.registry.identity import DeviceIdentity
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator = hass.data[DOMAIN][mock_entry.entry_id]
coordinator._identity = DeviceIdentity(
manufacturer='Samsung Electronics',
model='RF9000B',
name='Family Hub',
serial=None,
device_types=('oic.wk.d', 'oic.d.refrigerator'),
raw={
'/oic/p': {'mnmn': 'Samsung Electronics', 'pi': '12-34-56'},
'/oic/d': {'n': 'Family Hub', 'di': 'ab-cd-ef',
'rt': ['oic.wk.d', 'oic.d.refrigerator']},
},
)
diag = await async_get_config_entry_diagnostics(hass, mock_entry)
identity = diag['identity']
assert identity['model'] == 'RF9000B'
assert identity['device_types'] == ['oic.wk.d', 'oic.d.refrigerator']
# The raw payloads ride along whole -- we don't yet know which of their
# fields identify a device type, so nothing is dropped up front beyond
# what redaction takes out.
assert identity['resources']['/oic/p']['mnmn'] == 'Samsung Electronics'
assert identity['resources']['/oic/d']['di'] == REDACTED
assert identity['resources']['/oic/p']['pi'] == REDACTED
# The owner-settable device name is redacted; `rt` -- the reason this
# block exists -- is not.
assert identity['resources']['/oic/d']['n'] == REDACTED
assert identity['resources']['/oic/d']['rt'] == ['oic.wk.d', 'oic.d.refrigerator']
async def test_diagnostics_include_oic_res_links(
hass: HomeAssistant, mock_entry, mock_coordinator_session
) -> None:
"""/oic/res -- OCF's resource-discovery endpoint -- rides along in
identity.raw the same way /oic/p and /oic/d do. Its response is a list
of Link objects (one per href/Collection the endpoint hosts), not a
single Property map, so redaction has to walk into the list too."""
from custom_components.localthings.registry.identity import DeviceIdentity
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator = hass.data[DOMAIN][mock_entry.entry_id]
coordinator._identity = DeviceIdentity(
manufacturer='Samsung Electronics',
model='RF9000B',
name='Family Hub',
serial=None,
device_types=('oic.wk.d', 'oic.d.refrigerator'),
raw={
'/oic/p': {'mnmn': 'Samsung Electronics'},
'/oic/d': {'rt': ['oic.wk.d', 'oic.d.refrigerator']},
'/oic/res': [
{'di': 'aaaa-1111', 'href': '/device/0',
'rt': ['x.com.samsung.devcol', 'oic.wk.col']},
{'di': 'bbbb-2222', 'href': '/device/1',
'rt': ['x.com.samsung.devcol', 'oic.wk.col']},
],
},
)
diag = await async_get_config_entry_diagnostics(hass, mock_entry)
links = diag['identity']['resources']['/oic/res']
assert len(links) == 2
assert links[0]['di'] == REDACTED
assert links[0]['href'] == '/device/0'
assert links[1]['di'] == REDACTED
assert links[1]['href'] == '/device/1'
assert links[1]['rt'] == ['x.com.samsung.devcol', 'oic.wk.col']
async def test_diagnostics_include_speculative_device_probes(
hass: HomeAssistant, mock_entry, mock_coordinator_session
) -> None:
"""/device/1 and /device/2 (issue #177's Composite Device follow-up) ride
along in identity.raw the same {href: rep} shape as the main /device/0
dump under "resources" -- ordinary redaction, no special-casing needed."""
from custom_components.localthings.registry.identity import DeviceIdentity
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator = hass.data[DOMAIN][mock_entry.entry_id]
coordinator._identity = DeviceIdentity(
manufacturer='Samsung Electronics',
model='RF9000B',
name='Family Hub',
serial=None,
device_types=('oic.wk.d', 'oic.d.refrigerator'),
raw={
'/oic/p': {}, '/oic/d': {}, '/oic/res': [],
'/device/1': {
'/information/vs/0': {'x.com.samsung.da.serialNum': 'SECRET123'},
'/power/vs/0': {'x.com.samsung.da.power': 'On'},
},
'/device/2': {},
},
)
diag = await async_get_config_entry_diagnostics(hass, mock_entry)
device1 = diag['identity']['resources']['/device/1']
assert device1['/information/vs/0']['x.com.samsung.da.serialNum'] == REDACTED
assert device1['/power/vs/0']['x.com.samsung.da.power'] == 'On'
assert diag['identity']['resources']['/device/2'] == {}
async def test_diagnostics_identity_none_when_unavailable(
hass: HomeAssistant, mock_entry, mock_coordinator_session
) -> None:
"""read_identity is best-effort: a device that answers neither resource
(or a session that never connected) must not break the download."""
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
diag = await async_get_config_entry_diagnostics(hass, mock_entry)
assert diag['identity'] is None
async def test_diagnostics_include_observe_mode_fields(
hass: HomeAssistant, mock_entry, mock_coordinator_session
) -> None:
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
diag = await async_get_config_entry_diagnostics(hass, mock_entry)
assert diag['observe_mode'] == 'poll'
assert diag['observe_subscribed_hrefs'] == []
assert diag['observe_fallback_hrefs'] == []
assert 'observe_last_mode_change' in diag
assert diag['observe_href_freshness_s'] == {}
async def test_dependency_version_read_off_the_event_loop(
hass: HomeAssistant, mock_entry, mock_coordinator_session, monkeypatch
) -> None:
"""importlib.metadata.version() does blocking disk I/O, so it must run in
the executor, not on the event loop (issue #9's logs flagged it)."""
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
loop_thread_id = threading.get_ident() # this coroutine runs on the loop
seen: dict[str, int] = {}
real_pkg_version = diagnostics_mod.pkg_version
def _spy(name: str) -> str:
seen['thread_id'] = threading.get_ident()
return real_pkg_version(name)
monkeypatch.setattr(diagnostics_mod, 'pkg_version', _spy)
diag = await async_get_config_entry_diagnostics(hass, mock_entry)
assert diag['smartthings_local_version']
assert seen['thread_id'] != loop_thread_id