feat(diagnostics): capture /oic/p and /oic/d identity

Device-type detection currently parses board part numbers out of
/information/vs/0's modelNum. OCF has a standard field for exactly this
question -- /oic/d's `rt` -- and read_identity() already fetches the
resource, but kept only `n` and threw the rest away. No captured dump has
ever included it either: /device/0 batch responses don't carry /oic/d, and
diagnostics didn't report it, so there's no evidence on whether real
hardware populates it usefully.

Keep `rt` as DeviceIdentity.device_types, keep both raw payloads whole
(we don't yet know which of their fields identify a type), and surface
them in diagnostics so incoming issue reports answer the question.

Nothing routes on it yet.

/oic/d and /oic/p identify the unit with bare two-letter keys -- 'di' and
'pi' -- as sensitive as the serial number redact.py already covers but far
too short to match on: 'di' alone is a substring of 'condition', 'display'
and 'dispenser'. Add a whole-key match alongside the substring rules.
This commit is contained in:
Marc Billow committed 2026-07-29 01:56:52 +00:00
1 parent 668aec401a
commit 288ba02cc5
6 files changed
+166 -1

No files matched your search

@@ -31,9 +31,19 @@ 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.
identity = coordinator._identity
return {
"device_type": coordinator.device_type_name or "unknown",
"one_ui_version": coordinator.one_ui_version,
"identity": {
"manufacturer": identity.manufacturer,
"model": identity.model,
"device_types": list(identity.device_types),
"resources": redact_resources(identity.raw),
} if identity is not None else None,
"unbound_hrefs": sorted(coordinator._unbound_hrefs),
"resources": redact_resources(coordinator.last_resources),
"integration_version": integration.version,
@@ -1,7 +1,7 @@
"""Read device identity from standard OCF resources (/oic/p, /oic/d)."""
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Optional
import cbor2
@@ -13,6 +13,8 @@ class DeviceIdentity:
model: str
name: str
serial: Optional[str]
device_types: tuple[str, ...] = ()
raw: dict[str, dict] = field(default_factory=dict)
def _get(sess, path) -> dict:
@@ -26,6 +28,27 @@ def _get(sess, path) -> dict:
return {}
def _device_types(d: dict) -> tuple[str, ...]:
"""/oic/d's `rt` -- the device's own OCF device-type declaration.
In OCF this is the one standardized "what am I" field: alongside the
generic 'oic.wk.d' it carries a concrete type such as 'oic.d.airconditioner'
or a Samsung 'x.com.samsung.da.*' equivalent. Nothing routes on it yet --
device-type detection currently parses board part numbers out of
/information/vs/0's modelNum instead (see registry/by_type/__init__.py) --
because no captured dump has ever included it: /device/0 batch responses
don't carry /oic/d, and diagnostics didn't report it. It's surfaced in
diagnostics so incoming issue reports can tell us whether real hardware
populates it usefully enough to route on.
"""
rt = d.get('rt')
if isinstance(rt, str):
rt = [rt]
if not isinstance(rt, (list, tuple)):
return ()
return tuple(t for t in rt if isinstance(t, str))
def read_identity(sess, serial: Optional[str]) -> DeviceIdentity:
p = _get(sess, ['oic', 'p'])
d = _get(sess, ['oic', 'd'])
@@ -34,4 +57,9 @@ def read_identity(sess, serial: Optional[str]) -> DeviceIdentity:
model=p.get('mnmo') or '',
name=d.get('n') or '',
serial=serial,
device_types=_device_types(d),
# 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},
)
@@ -19,9 +19,18 @@ _SENSITIVE_SUBSTRINGS = (
'userid', 'deviceid', 'uuid', 'duid', 'password', 'secret',
)
# Matched whole, not as substrings. OCF's /oic/d and /oic/p identify the unit
# with bare two-letter keys -- 'di' (device UUID) and 'pi' (platform UUID) --
# which are as identifying as the serial number above but far too short to
# match on: 'di' alone is a substring of 'condition', 'display', 'dispenser'
# and plenty of other perfectly ordinary appliance fields.
_SENSITIVE_EXACT = frozenset({'di', 'pi'})
def _is_sensitive_key(key: str) -> bool:
lowered = key.lower()
if lowered in _SENSITIVE_EXACT:
return True
return any(s in lowered for s in _SENSITIVE_SUBSTRINGS)