Add coverage-gap detection: ignored-hrefs registry, Repairs issue, redaction
Implements the first half of the device-capability-diagnostics spec: - registry/capabilities/ignored.py: known-noise hrefs (Bixby/voice provisioning, WiFi/BLE info, OTA/region housekeeping, and a couple of redundant hrefs) declared as no-entity Capability objects, so discover()'s existing unknown-resource reporting treats them as covered instead of flagging every device as having gaps. Folded into each by_type registry and into the global fallback CAPABILITIES set. - discovery.py: log() callback now passes the raw href instead of a formatted message, so callers can collect a clean unbound-hrefs list. - coordinator.py: wires that callback into self._unbound_hrefs, tracks device_type_name/one_ui_version, and raises (or clears) a Repairs issue when a device's type is unrecognized or it has genuinely unmodeled hrefs left over. - registry/redact.py: recursive, substring-keyed redaction for anything that looks like account/identity data, ahead of the diagnostics.py platform that will consume it. Verified against the real dishwasher/refrigerator fixtures: known-noise hrefs no longer show up as gaps, while genuine gaps (e.g. /bespoke/vs/0 on the fridge) still do.
This commit is contained in:
1 parent
b67e0e5cf5
commit
35b47d6b5a
14 files changed
+383
-11
No files matched your search
@@ -12,3 +12,7 @@ CONF_LEAF_KEY_PEM = "leaf_key_pem"
|
||||
PROBE_PORTS = [49154, 49155]
|
||||
|
||||
SUMMARY_INTERVAL_S = 30.0
|
||||
|
||||
DEVICE_SUPPORT_ISSUE_URL = (
|
||||
"https://github.com/mbillow/localthings/issues/new?template=device-support.yml"
|
||||
)
|
||||
@@ -10,6 +10,7 @@ import cbor2
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
|
||||
@@ -24,7 +25,7 @@ from .registry.identity import read_identity, DeviceIdentity
|
||||
|
||||
from .const import (
|
||||
DOMAIN, CONF_HOST, CONF_PORT, CONF_LEAF_CERT_PEM, CONF_LEAF_KEY_PEM,
|
||||
SUMMARY_INTERVAL_S,
|
||||
DEVICE_SUPPORT_ISSUE_URL, SUMMARY_INTERVAL_S,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -62,6 +63,9 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
self._subpoll_task: asyncio.Task | None = None
|
||||
self._hot_hrefs: list[str] = []
|
||||
self._warm_hrefs: list[str] = []
|
||||
self.device_type_name: str | None = None
|
||||
self.one_ui_version: str = ''
|
||||
self._unbound_hrefs: list[str] = []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Session management (all blocking — must run in executor)
|
||||
@@ -183,14 +187,20 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
one_ui = (resources.get('/otninformation/vs/0', {})
|
||||
.get('swVersionInfo', {})
|
||||
.get('oneUiVersion', ''))
|
||||
self.one_ui_version = one_ui
|
||||
reg = for_device(one_ui) if one_ui else None
|
||||
unbound: list[str] = []
|
||||
if reg is not None:
|
||||
_LOGGER.debug("device type: %s (oneUiVersion=%r)", reg.name, one_ui)
|
||||
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
|
||||
bound = discover(resources, reg.capabilities, reg.pattern_capabilities,
|
||||
log=unbound.append)
|
||||
self.device_type_name = reg.name
|
||||
else:
|
||||
_LOGGER.warning("unknown device type oneUiVersion=%r; using common caps", one_ui)
|
||||
bound = discover(resources, CAPABILITIES)
|
||||
bound = discover(resources, CAPABILITIES, log=unbound.append)
|
||||
self.device_type_name = None
|
||||
self.bound = bound
|
||||
self._unbound_hrefs = unbound
|
||||
|
||||
serial = (resources.get('/information/vs/0', {})
|
||||
.get('x.com.samsung.da.serialNum', ''))
|
||||
@@ -209,6 +219,8 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
manufacturer=mfr,
|
||||
model=model,
|
||||
)
|
||||
self._update_coverage_gap_issue(reg is None, unbound, name)
|
||||
|
||||
hot, warm = set(), set()
|
||||
for be in bound:
|
||||
tier = be.capability.poll_tier
|
||||
@@ -225,6 +237,29 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
len(bound), serial, self._hot_hrefs, self._warm_hrefs,
|
||||
)
|
||||
|
||||
def _update_coverage_gap_issue(
|
||||
self, unknown_type: bool, unbound_hrefs: list[str], device_name: str,
|
||||
) -> None:
|
||||
"""Raise or clear a Repairs issue when capability coverage is incomplete.
|
||||
|
||||
Fires once, at discovery time, either because the device type itself
|
||||
wasn't recognized or because some of its resources didn't bind to
|
||||
any capability. Diagnostics (diagnostics.py) is what a user actually
|
||||
downloads to help; this just tells them there's something to send.
|
||||
"""
|
||||
issue_id = f"device_gap_{self._entry.entry_id}"
|
||||
if unknown_type or unbound_hrefs:
|
||||
ir.async_create_issue(
|
||||
self.hass, DOMAIN, issue_id,
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="device_gap",
|
||||
translation_placeholders={"device_name": device_name},
|
||||
learn_more_url=DEVICE_SUPPORT_ISSUE_URL,
|
||||
)
|
||||
else:
|
||||
ir.async_delete_issue(self.hass, DOMAIN, issue_id)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DataUpdateCoordinator hook
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""Dishwasher device registry."""
|
||||
from ..capabilities import common, dishwasher, fridge, laundry, operational
|
||||
from ..capabilities import common, dishwasher, fridge, ignored, laundry, operational
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='dishwasher',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
common.POWER,
|
||||
common.KIDS_LOCK,
|
||||
common.REMOTE_CONTROL,
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
Note: dryer devices use port 49155 (not 49154). The config flow handles
|
||||
this automatically via port probing.
|
||||
"""
|
||||
from ..capabilities import common, dryer, fridge, laundry, operational
|
||||
from ..capabilities import common, dryer, fridge, ignored, laundry, operational
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='dryer',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
common.POWER,
|
||||
common.KIDS_LOCK,
|
||||
common.REMOTE_CONTROL,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""Oven device registry."""
|
||||
from ..capabilities import common, fridge, oven
|
||||
from ..capabilities import common, fridge, ignored, oven
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='oven',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
common.POWER,
|
||||
common.KIDS_LOCK,
|
||||
common.REMOTE_CONTROL,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""Refrigerator device registry."""
|
||||
from ..capabilities import common, fridge
|
||||
from ..capabilities import common, fridge, ignored
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='refrigerator',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
common.POWER,
|
||||
fridge.STATUS_LOCK,
|
||||
fridge.DOOR_ALERT,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from . import common, dishwasher, fridge, laundry, operational, oven
|
||||
from . import common, dishwasher, fridge, ignored, laundry, operational, oven
|
||||
from ..capability import Capability
|
||||
|
||||
|
||||
@@ -16,4 +16,4 @@ _OVEN_GLOBAL_CAPS = [
|
||||
]
|
||||
|
||||
ALL = [v for mod in (common, operational, laundry, fridge)
|
||||
for v in vars(mod).values() if _is_capability(v)] + _OVEN_GLOBAL_CAPS
|
||||
for v in vars(mod).values() if _is_capability(v)] + _OVEN_GLOBAL_CAPS + ignored.IGNORED
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Hrefs deliberately not modeled as capabilities.
|
||||
|
||||
Every entry here is either irrelevant plumbing (network/account/OTA
|
||||
housekeeping that will never be home-automation-relevant) or a duplicate of
|
||||
state we already expose through a friendlier href. Declaring them here,
|
||||
rather than leaving them unregistered, keeps discover()'s unknown-resource
|
||||
reporting limited to genuine coverage gaps.
|
||||
|
||||
Capability defaults entities=() and discover() treats any href that
|
||||
resolves to a capability at all as matched, even one with no entities, so
|
||||
this list uses the same declaration shape as a real capability with no
|
||||
extra machinery. Fold into by_type registries (per-family gap detection)
|
||||
and into the global ALL list (unknown-device-type fallback).
|
||||
|
||||
Deliberately exact hrefs only, no href_prefix/pattern capabilities: the
|
||||
unknown-device-type fallback path (coordinator's `discover(resources,
|
||||
CAPABILITIES)` call) doesn't pass pattern_caps, so a prefix-based entry
|
||||
here would silently do nothing on that path. Enumerate each known href
|
||||
instead; it's a short, stable list.
|
||||
|
||||
This list is maintainer-curated only; there is no per-installation
|
||||
override. Grow it as real /device/0 dumps surface more universal noise —
|
||||
do not add a href here on a guess. If a href's relevance is unclear, leave
|
||||
it unbound so it surfaces as a gap for a human to look at.
|
||||
"""
|
||||
from ..capability import Capability
|
||||
|
||||
IGNORED: list[Capability] = [
|
||||
# Device serial/model is read directly by the coordinator for HA device
|
||||
# identity, not modeled as an entity capability.
|
||||
Capability(href='/information/vs/0'),
|
||||
|
||||
# Bixby voice assistant: feature negotiation, account provisioning
|
||||
# (Samsung account email, access tokens), terms-of-service state, and
|
||||
# enable/disable status.
|
||||
Capability(href='/voice/feature/vs/0'),
|
||||
Capability(href='/voice/provisioning/vs/0'),
|
||||
Capability(href='/bixby/vs/0'),
|
||||
Capability(href='/bixby/status/vs/0'),
|
||||
Capability(href='/bixbyuservalidate/vs/0'),
|
||||
Capability(href='/bixbyterms/vs/0'),
|
||||
|
||||
# Network/WiFi housekeeping — MAC addresses, supported auth/crypto
|
||||
# types, no controllable or observable appliance state.
|
||||
Capability(href='/wirelessinfo/vs/0'),
|
||||
Capability(href='/connectionconfig/vs/0'),
|
||||
|
||||
# Static or internal-protocol metadata, not entity-worthy.
|
||||
Capability(href='/quickcontrol/info/vs/0'),
|
||||
Capability(href='/realtimenotiforclient/vs/0'),
|
||||
Capability(href='/file/information/vs/0'),
|
||||
Capability(href='/configuration/vs/0'), # region/countryCode
|
||||
Capability(href='/setting/vs/0'), # supported/selected UI language
|
||||
Capability(href='/timezone/vs/0'), # redundant with HA's own timezone
|
||||
Capability(href='/wm/setinfo/vs/0'), # model/manufacturing metadata
|
||||
|
||||
# Demand Response Load Control — utility-company grid signals; requires
|
||||
# cloud registration with a utility program we don't support locally.
|
||||
Capability(href='/drlc/vs/0'),
|
||||
|
||||
# Redundant with capabilities already declared elsewhere.
|
||||
# /speakersound/vs/0 duplicates /settings/sound/volume/vs/0 (laundry.SOUND_VOLUME).
|
||||
Capability(href='/speakersound/vs/0'),
|
||||
# /wm/editcourse/vs/0 is the raw encoded course-list byte string already
|
||||
# decoded and exposed via /course/vs/0 (dishwasher.CYCLE_OPTIONS).
|
||||
Capability(href='/wm/editcourse/vs/0'),
|
||||
]
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
A device advertises a set of OCF resources keyed by href. For every href
|
||||
present in the registry, emit the capability's entities bound to that resource.
|
||||
Unknown hrefs are a coverage gap, logged at debug and skipped.
|
||||
Unknown hrefs are a coverage gap; each one is passed to the optional `log`
|
||||
callback (as the raw href, not a formatted message) and otherwise skipped.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -79,6 +80,6 @@ def discover(
|
||||
break
|
||||
|
||||
if not matched and log is not None:
|
||||
log(f"unknown resource {href}")
|
||||
log(href)
|
||||
|
||||
return out
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Redact account/identity data from a raw resource tree before it leaves
|
||||
the user's Home Assistant instance (diagnostics downloads, issue reports).
|
||||
|
||||
/device/0 dumps mix appliance state with genuinely sensitive data when
|
||||
Bixby/voice is set up on the device: a Samsung account email, a Bixby
|
||||
access token, a hashed device ID, WiFi/BLE MAC addresses, the serial
|
||||
number, and otnDUID. This walks the whole tree and redacts any value whose
|
||||
key matches a known-sensitive substring, regardless of which href it's
|
||||
under — new device types will have unknown-shaped data we can't fully
|
||||
enumerate in advance, so this errs on catching the field by name rather
|
||||
than only redacting inside hrefs we already recognize.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
REDACTED = "**REDACTED**"
|
||||
|
||||
_SENSITIVE_SUBSTRINGS = (
|
||||
'mac', 'serial', 'token', 'login', 'account', 'email',
|
||||
'userid', 'deviceid', 'uuid', 'duid', 'password', 'secret',
|
||||
)
|
||||
|
||||
|
||||
def _is_sensitive_key(key: str) -> bool:
|
||||
lowered = key.lower()
|
||||
return any(s in lowered for s in _SENSITIVE_SUBSTRINGS)
|
||||
|
||||
|
||||
def redact_resources(resources):
|
||||
"""Recursively redact dict values whose key matches a sensitive substring.
|
||||
|
||||
Works on the shape produced by parse_device0_batch (dict[href, rep]) or
|
||||
any nested dict/list structure within a rep.
|
||||
"""
|
||||
if isinstance(resources, dict):
|
||||
return {
|
||||
key: (REDACTED if _is_sensitive_key(key) else redact_resources(value))
|
||||
for key, value in resources.items()
|
||||
}
|
||||
if isinstance(resources, list):
|
||||
return [redact_resources(item) for item in resources]
|
||||
return resources
|
||||
@@ -73,5 +73,11 @@
|
||||
"abort": {
|
||||
"already_configured": "This device is already configured."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"device_gap": {
|
||||
"title": "Incomplete capability coverage for {device_name}",
|
||||
"description": "This device is missing full capability coverage. Either its appliance type wasn't recognized, or some of the resources it exposes aren't modeled yet. It'll keep working with whatever is already supported. You can help expand support by going to Settings > Devices & Services > {device_name} > the menu (top right) > Download diagnostics, then filing it with the linked issue template."
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user