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

+82
View File
@@ -6,6 +6,7 @@ from unittest.mock import patch
import pytest
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
from custom_components.localthings.const import (
DOMAIN, SUMMARY_INTERVAL_S,
@@ -72,3 +73,84 @@ async def test_update_failed_on_persistent_poll_error(
# Setup fails — entry should not be in hass.data
assert not result or mock_entry.entry_id not in hass.data.get(DOMAIN, {})
# ---------------------------------------------------------------------------
# Coverage-gap detection and the Repairs issue it raises
# ---------------------------------------------------------------------------
async def test_discovery_populates_device_type_and_unbound_hrefs(
hass: HomeAssistant, mock_entry, mock_coordinator_session
) -> None:
"""The real refrigerator fixture is a recognized type with some gaps left."""
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
assert coordinator.device_type_name == 'refrigerator'
assert coordinator.one_ui_version == '7.0 Refrigerator'
# Real, currently-unmodeled hrefs on this fixture (not noise — see
# tests/test_registry_ignored.py for the noise-suppression checks).
assert '/bespoke/vs/0' in coordinator._unbound_hrefs
async def test_coverage_gap_issue_created_for_real_fixture_gaps(
hass: HomeAssistant, mock_entry, mock_coordinator_session
) -> None:
"""A recognized device with leftover unbound hrefs still raises the issue."""
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
issue_id = f"device_gap_{mock_entry.entry_id}"
assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is not None
def test_update_coverage_gap_issue_creates_issue_for_unknown_type(
hass: HomeAssistant, mock_entry
) -> None:
coordinator = LocalThingsCoordinator(hass, mock_entry)
coordinator._update_coverage_gap_issue(True, [], 'Test Appliance')
issue_id = f"device_gap_{mock_entry.entry_id}"
issue = ir.async_get(hass).async_get_issue(DOMAIN, issue_id)
assert issue is not None
assert issue.translation_key == 'device_gap'
assert issue.translation_placeholders == {'device_name': 'Test Appliance'}
assert issue.is_fixable is False
def test_update_coverage_gap_issue_creates_issue_for_unbound_hrefs(
hass: HomeAssistant, mock_entry
) -> None:
coordinator = LocalThingsCoordinator(hass, mock_entry)
coordinator._update_coverage_gap_issue(False, ['/mystery/vs/0'], 'Test Appliance')
issue_id = f"device_gap_{mock_entry.entry_id}"
assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is not None
def test_update_coverage_gap_issue_absent_when_fully_covered(
hass: HomeAssistant, mock_entry
) -> None:
coordinator = LocalThingsCoordinator(hass, mock_entry)
coordinator._update_coverage_gap_issue(False, [], 'Test Appliance')
issue_id = f"device_gap_{mock_entry.entry_id}"
assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is None
def test_update_coverage_gap_issue_clears_previous_issue(
hass: HomeAssistant, mock_entry
) -> None:
"""A later discovery run with no gap deletes an issue raised earlier."""
coordinator = LocalThingsCoordinator(hass, mock_entry)
issue_id = f"device_gap_{mock_entry.entry_id}"
coordinator._update_coverage_gap_issue(True, [], 'Test Appliance')
assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is not None
coordinator._update_coverage_gap_issue(False, [], 'Test Appliance')
assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is None
+74
View File
@@ -0,0 +1,74 @@
"""Tests for registry.redact — the safety net for diagnostics downloads."""
import json
from pathlib import Path
from custom_components.localthings.registry.batch import parse_device0_batch
from custom_components.localthings.registry.redact import REDACTED, redact_resources
FIXTURES = Path(__file__).resolve().parent / 'fixtures'
def _load(name: str) -> dict:
data = json.loads((FIXTURES / name).read_text())
return parse_device0_batch(data['device0'])
def test_redacts_known_sensitive_fields_in_dishwasher_dump():
resources = _load('dishwasher_device.json')
redacted = redact_resources(resources)
info = redacted['/information/vs/0']
assert info['x.com.samsung.da.serialNum'] == REDACTED
assert info['x.com.samsung.da.otnDUID'] == REDACTED
wireless = redacted['/wirelessinfo/vs/0']
assert wireless['macaddressWiFi'] == REDACTED
assert wireless['macaddressBLE'] == REDACTED
provisioning = redacted['/voice/provisioning/vs/0']
headers = provisioning['voice.provisioning.headers']
assert headers['login_id'] == REDACTED
deviceinfo = provisioning['voice.provisioning.deviceinfo']
assert deviceinfo['voice.provisioning.deviceinfo.accesstoken'] == REDACTED
assert deviceinfo['voice.provisioning.deviceinfo.deviceid'] == REDACTED
assert deviceinfo['voice.provisioning.deviceinfo.userid'] == REDACTED
def test_ordinary_state_fields_survive_untouched():
resources = _load('dishwasher_device.json')
redacted = redact_resources(resources)
op_state = redacted['/operational/state/vs/0']
assert op_state['x.com.samsung.da.state'] == 'Run'
assert op_state['x.com.samsung.da.progress'] == 'Finish'
power = redacted['/power/vs/0']
assert power['x.com.samsung.da.power'] == 'On'
dishwasher = redacted['/dishwasher/vs/0']
assert dishwasher['x.com.samsung.da.sanitize'] == 'On'
assert dishwasher['x.com.samsung.da.rinseLevel'] == '4'
alarms = redacted['/alarms/vs/0']['x.com.samsung.da.items']
assert alarms[0]['x.com.samsung.da.code'] == 'SNSF_Reached'
def test_redacts_known_sensitive_fields_in_refrigerator_dump():
resources = _load('refrigerator_device.json')
redacted = redact_resources(resources)
info = redacted['/information/vs/0']
assert info['x.com.samsung.da.serialNum'] == REDACTED
wireless = redacted['/wirelessinfo/vs/0']
assert wireless['macaddressWiFi'] == REDACTED
assert wireless['macaddressBLE'] == REDACTED
def test_redact_resources_does_not_mutate_input():
resources = _load('dishwasher_device.json')
original_serial = resources['/information/vs/0']['x.com.samsung.da.serialNum']
redact_resources(resources)
assert resources['/information/vs/0']['x.com.samsung.da.serialNum'] == original_serial
+58
View File
@@ -0,0 +1,58 @@
"""Tests that known-noise hrefs don't show up as coverage gaps.
Each device-type registry folds in registry.capabilities.ignored.IGNORED so
that Bixby/voice/network/OTA housekeeping resources aren't reported as
unbound. This guards two things at once: that the ignored hrefs are
actually suppressed, and (via a plain import) that no ignored href
collides with a real capability declared elsewhere for the same family.
"""
import json
from pathlib import Path
from custom_components.localthings.registry.batch import parse_device0_batch
from custom_components.localthings.registry.by_type import dishwasher, refrigerator
from custom_components.localthings.registry.capabilities.ignored import IGNORED
FIXTURES = Path(__file__).resolve().parent / 'fixtures'
_IGNORED_HREFS = {cap.href for cap in IGNORED}
def _load(name: str) -> dict:
data = json.loads((FIXTURES / name).read_text())
return parse_device0_batch(data['device0'])
def _unbound_hrefs(resources, registry) -> list[str]:
unbound = []
from custom_components.localthings.registry.discovery import discover
discover(resources, registry.capabilities, registry.pattern_capabilities,
log=unbound.append)
return unbound
def test_ignored_hrefs_absent_from_dishwasher_gaps():
resources = _load('dishwasher_device.json')
unbound = _unbound_hrefs(resources, dishwasher.REGISTRY)
present_ignored = _IGNORED_HREFS & set(unbound)
assert not present_ignored, f"ignored hrefs leaked into gap report: {present_ignored}"
def test_ignored_hrefs_absent_from_refrigerator_gaps():
resources = _load('refrigerator_device.json')
unbound = _unbound_hrefs(resources, refrigerator.REGISTRY)
present_ignored = _IGNORED_HREFS & set(unbound)
assert not present_ignored, f"ignored hrefs leaked into gap report: {present_ignored}"
def test_dishwasher_fixture_still_has_genuine_gaps():
"""Sanity check that the gap mechanism isn't silencing everything.
If this starts failing because the fixture is now fully covered, that's
good news — just trim the expected set down (or to empty) rather than
deleting the test.
"""
resources = _load('dishwasher_device.json')
unbound = set(_unbound_hrefs(resources, dishwasher.REGISTRY))
assert '/wm/jobbeginingstatus/vs/0' in unbound
assert '/diagnosis/vs/0' in unbound