Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
119a4f443c | ||
|
|
9505405e45 | ||
|
|
10d5c81d9a | ||
|
|
2b67dda5dc | ||
|
|
0c8d39fb73 |
@@ -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",
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
"pyOpenSSL>=23.0",
|
||||
"smartthings-local>=0.1.1"
|
||||
],
|
||||
"version": "0.15.0"
|
||||
"version": "0.16.0"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -6,6 +6,20 @@ from typing import Optional
|
||||
|
||||
import cbor2
|
||||
|
||||
from .batch import parse_device0_batch
|
||||
|
||||
# Speculative /device/<n> siblings to probe alongside the coordinator's own
|
||||
# /device/0 seed poll (issue #177). Confirmed against a real dump: /oic/res's
|
||||
# baseline-Interface response only lists resources with the discoverable
|
||||
# policy bit set, and /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. A direct GET is the only
|
||||
# way left to check, and it's a plain RETRIEVE (non-mutating, tolerated-404
|
||||
# already the norm throughout this module) -- not the kind of guess the
|
||||
# write-contract 'don't guess' rule is about. Bounded to a couple of indices;
|
||||
# widen only if a real Composite Device ever turns out to need more.
|
||||
_SPECULATIVE_DEVICE_INDICES = (1, 2)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeviceIdentity:
|
||||
@@ -28,6 +42,38 @@ 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 _get_device_batch(sess, index: int) -> dict[str, dict]:
|
||||
"""GET /device/<index> and parse it the same way the coordinator parses
|
||||
/device/0 -- a Samsung Collection RETRIEVE returns
|
||||
[devcol-rep, {href, rep}, {href, rep}, ...], not a bare Property map or
|
||||
Link array. Missing/malformed responses fall through to {} (via
|
||||
parse_device0_batch on an empty/non-list body), same tolerated-absence
|
||||
posture as _get/_get_links above."""
|
||||
try:
|
||||
code, pl = sess.get(['device', str(index)], timeout=10.0)
|
||||
if code == 0x45 and pl:
|
||||
body = cbor2.loads(pl)
|
||||
if isinstance(body, list):
|
||||
return parse_device0_batch(body)
|
||||
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 +98,20 @@ 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'])
|
||||
extra_devices = {
|
||||
f'/device/{n}': _get_device_batch(sess, n)
|
||||
for n in _SPECULATIVE_DEVICE_INDICES
|
||||
}
|
||||
return DeviceIdentity(
|
||||
manufacturer=p.get('mnmn') or 'Samsung',
|
||||
model=p.get('mnmo') or '',
|
||||
@@ -61,5 +121,8 @@ 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},
|
||||
# /device/1 and /device/2 are always present here (empty {} when the
|
||||
# device didn't answer) so a diagnostics reader can tell "checked,
|
||||
# nothing there" apart from "never checked".
|
||||
raw={'/oic/p': p, '/oic/d': d, '/oic/res': res, **extra_devices},
|
||||
)
|
||||
|
||||
@@ -80,6 +80,84 @@ 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_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:
|
||||
|
||||
+72
-1
@@ -32,7 +32,10 @@ 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': [],
|
||||
'/device/1': {}, '/device/2': {},
|
||||
}
|
||||
|
||||
|
||||
def test_read_identity_captures_oic_d_device_types():
|
||||
@@ -69,3 +72,71 @@ 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. Real hardware (issue #177
|
||||
follow-up, a TP1X_REF_21K fridge dump) groups the response by `di`: one
|
||||
entry per logical Device, each carrying its own `links` array -- not a
|
||||
flat array of individually-`di`-tagged links. Every entry that dump
|
||||
returned had its discoverable policy bit set (`bm`'s bit 0); the whole
|
||||
x.com.samsung.da.* tree (including /device/0 itself) did not appear at
|
||||
all, meaning it's registered non-discoverable and simply invisible to
|
||||
this endpoint -- see _SPECULATIVE_DEVICE_INDICES' docstring for why that
|
||||
motivated probing /device/1 and /device/2 directly instead."""
|
||||
sess = FakeSession({
|
||||
('oic', 'res'): [
|
||||
{'di': 'aaaa', 'links': [
|
||||
{'href': '/oic/d', 'rt': ['oic.wk.d', 'oic.d.refrigerator'],
|
||||
'p': {'bm': 1}},
|
||||
{'href': '/oic/sec/doxm', 'rt': ['oic.r.doxm'], 'p': {'bm': 1}},
|
||||
]},
|
||||
],
|
||||
})
|
||||
ident = read_identity(sess, serial=None)
|
||||
assert ident.raw['/oic/res'] == [
|
||||
{'di': 'aaaa', 'links': [
|
||||
{'href': '/oic/d', 'rt': ['oic.wk.d', 'oic.d.refrigerator'],
|
||||
'p': {'bm': 1}},
|
||||
{'href': '/oic/sec/doxm', 'rt': ['oic.r.doxm'], 'p': {'bm': 1}},
|
||||
]},
|
||||
]
|
||||
|
||||
|
||||
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'] == []
|
||||
|
||||
|
||||
def test_read_identity_probes_device_1_and_2():
|
||||
"""/oic/res won't reveal a second logical Device's Collection on this
|
||||
firmware family (see test_read_identity_captures_oic_res_links), so
|
||||
/device/1 and /device/2 are probed directly -- same [devcol-rep,
|
||||
{href, rep}, ...] batch shape /device/0 itself returns, parsed the same
|
||||
way (parse_device0_batch)."""
|
||||
sess = FakeSession({
|
||||
('device', '1'): [
|
||||
{'rt': ['x.com.samsung.devcol', 'oic.wk.col']},
|
||||
{'href': '/power/vs/0', 'rep': {'x.com.samsung.da.power': 'On'}},
|
||||
],
|
||||
})
|
||||
ident = read_identity(sess, serial=None)
|
||||
assert ident.raw['/device/1'] == {
|
||||
'/power/vs/0': {'x.com.samsung.da.power': 'On'},
|
||||
}
|
||||
# /device/2 was never in the table -> 4.04 -> tolerated absence.
|
||||
assert ident.raw['/device/2'] == {}
|
||||
|
||||
|
||||
def test_read_identity_tolerates_malformed_device_probe():
|
||||
"""A bare Property map instead of a batch array (or anything else
|
||||
non-list-shaped) must not explode."""
|
||||
ident = read_identity(
|
||||
FakeSession({('device', '1'): {'not': 'a batch'}}), serial=None
|
||||
)
|
||||
assert ident.raw['/device/1'] == {}
|
||||
|
||||
Reference in New Issue
Block a user