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:
Marc Billow committed 2026-07-07 23:49:58 -05:00
1 parent b67e0e5cf5
commit 35b47d6b5a
14 files changed
+383 -11

No files matched your search

+4
View File
@@ -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"
)
+38 -3
View File
@@ -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."
}
}
}