diff --git a/custom_components/localthings/diagnostics.py b/custom_components/localthings/diagnostics.py index b22896b..c95a5e7 100644 --- a/custom_components/localthings/diagnostics.py +++ b/custom_components/localthings/diagnostics.py @@ -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", diff --git a/custom_components/localthings/registry/identity.py b/custom_components/localthings/registry/identity.py index c5445d1..81d6d5a 100644 --- a/custom_components/localthings/registry/identity.py +++ b/custom_components/localthings/registry/identity.py @@ -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}, ) diff --git a/tests/localthings/test_diagnostics.py b/tests/localthings/test_diagnostics.py index 446b539..f27c739 100644 --- a/tests/localthings/test_diagnostics.py +++ b/tests/localthings/test_diagnostics.py @@ -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: diff --git a/tests/test_identity.py b/tests/test_identity.py index d9fa4dd..de922fc 100644 --- a/tests/test_identity.py +++ b/tests/test_identity.py @@ -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'] == []