feat(diagnostics): capture /oic/res discovery links

/oic/res is OCF's baseline resource-discovery endpoint: a unicast
RETRIEVE returns every href/Collection the connection hosts, not just
the one /device/0 seed path the coordinator polls. Relevant to the OCF
"Composite Device" model (issue #177) -- a single physical unit sharing
one IP/session across more than one logical Device, each exposed as its
own Collection resource (same rt shape as our own /device/0). Nothing
routes on this yet; captured alongside the existing /oic/p and /oic/d
reads so a report from a multi-unit device shows us whether its
firmware actually implements that model before any code assumes it does.
This commit is contained in:
Marc Billow
2026-07-29 03:53:27 +00:00
parent 03958dd994
commit 0c8d39fb73
4 changed files with 106 additions and 6 deletions
+7 -3
View File
@@ -31,9 +31,13 @@ async def async_get_config_entry_diagnostics(
# detector when called inline here. Offload it to the executor.
stl_version = await hass.async_add_executor_job(pkg_version, "smartthings-local")
# /oic/p and /oic/d sit outside the /device/0 batch captured below, so
# they'd otherwise never reach an issue report. /oic/d's `rt` is OCF's
# standard device-type declaration -- see registry/identity.py.
# /oic/p, /oic/d, and /oic/res sit outside the /device/0 batch captured
# below, so they'd otherwise never reach an issue report. /oic/d's `rt`
# is OCF's standard device-type declaration; /oic/res is OCF's
# discovery endpoint, listing every href/Collection the connection
# hosts -- relevant to the "Composite Device" model (issue #177) where
# a single physical unit exposes more than one logical Device. See
# registry/identity.py.
identity = coordinator._identity
return {
"device_type": coordinator.device_type_name or "unknown",
@@ -1,4 +1,4 @@
"""Read device identity from standard OCF resources (/oic/p, /oic/d)."""
"""Read device identity from standard OCF resources (/oic/p, /oic/d, /oic/res)."""
from __future__ import annotations
from dataclasses import dataclass, field
@@ -28,6 +28,20 @@ def _get(sess, path) -> dict:
return {}
def _get_links(sess, path) -> list:
"""Like _get, but for /oic/res: a baseline-Interface RETRIEVE on it
returns a CBOR array of Link objects (href/rt/if/di/...), not a single
Property map."""
try:
code, pl = sess.get(path, timeout=10.0)
if code == 0x45 and pl:
body = cbor2.loads(pl)
return body if isinstance(body, list) else []
except Exception:
pass
return []
def _device_types(d: dict) -> tuple[str, ...]:
"""/oic/d's `rt` -- the device's own OCF device-type declaration.
@@ -52,6 +66,16 @@ def _device_types(d: dict) -> tuple[str, ...]:
def read_identity(sess, serial: Optional[str]) -> DeviceIdentity:
p = _get(sess, ['oic', 'p'])
d = _get(sess, ['oic', 'd'])
# /oic/res is OCF's baseline resource-discovery endpoint: a unicast
# RETRIEVE on it returns every Resource/Collection href this endpoint
# hosts, not just the one /device/0 seed path the coordinator polls.
# Relevant for the OCF "Composite Device" model (issue #177: a single
# physical unit -- one IP, one /oic/p -- exposing more than one logical
# Device, each as its own Collection resource, same rt shape as our own
# /device/0). Nothing consumes this yet; captured so a report from a
# multi-unit device shows us whether its firmware actually implements
# that model before any code assumes it does.
res = _get_links(sess, ['oic', 'res'])
return DeviceIdentity(
manufacturer=p.get('mnmn') or 'Samsung',
model=p.get('mnmo') or '',
@@ -61,5 +85,5 @@ def read_identity(sess, serial: Optional[str]) -> DeviceIdentity:
# Kept whole rather than field-by-field: these resources are outside
# the /device/0 dump diagnostics already captures, and we don't yet
# know which of their fields will turn out to identify a device type.
raw={'/oic/p': p, '/oic/d': d},
raw={'/oic/p': p, '/oic/d': d, '/oic/res': res},
)
+42
View File
@@ -80,6 +80,48 @@ async def test_diagnostics_include_ocf_identity(
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_identity_none_when_unavailable(
hass: HomeAssistant, mock_entry, mock_coordinator_session
) -> None:
+31 -1
View File
@@ -32,7 +32,7 @@ def test_read_identity_tolerates_missing_resources():
assert ident.model == ''
assert ident.serial is None
assert ident.device_types == ()
assert ident.raw == {'/oic/p': {}, '/oic/d': {}}
assert ident.raw == {'/oic/p': {}, '/oic/d': {}, '/oic/res': []}
def test_read_identity_captures_oic_d_device_types():
@@ -69,3 +69,33 @@ def test_read_identity_keeps_raw_payloads_for_diagnostics():
ident = read_identity(sess, serial=None)
assert ident.raw['/oic/p']['mnmo'] == 'RF9000B'
assert ident.raw['/oic/d']['di'] == 'abc-123'
def test_read_identity_captures_oic_res_links():
"""/oic/res is OCF's discovery endpoint: a baseline RETRIEVE returns an
array of Link objects, one per Resource/Collection the endpoint hosts --
including, per the OCF 'Composite Device' model, a second logical
Device's Collection on a multi-unit system (issue #177). Nothing routes
on this yet; captured so a report from such a device shows us whether
its firmware actually exposes more than the one /device/0 seed href."""
sess = FakeSession({
('oic', 'res'): [
{'di': 'aaaa', 'href': '/device/0', 'rt': ['x.com.samsung.devcol', 'oic.wk.col']},
{'di': 'bbbb', 'href': '/device/1', 'rt': ['x.com.samsung.devcol', 'oic.wk.col']},
],
})
ident = read_identity(sess, serial=None)
assert ident.raw['/oic/res'] == [
{'di': 'aaaa', 'href': '/device/0', 'rt': ['x.com.samsung.devcol', 'oic.wk.col']},
{'di': 'bbbb', 'href': '/device/1', 'rt': ['x.com.samsung.devcol', 'oic.wk.col']},
]
def test_read_identity_tolerates_malformed_oic_res():
"""A single Property map instead of an array (or anything else
non-list-shaped) must not explode -- same defensive posture as
_device_types' handling of a malformed /oic/d rt."""
ident = read_identity(
FakeSession({('oic', 'res'): {'not': 'a list'}}), serial=None
)
assert ident.raw['/oic/res'] == []