refactor(registry): drop oneUiVersion from device-type detection

oneUiVersion looks like the signal you'd want -- the device naming its own
type, '7.0 Dishwasher' -- and it was the first thing detection consulted. It
never earned the position:

- Only 7 of 49 fixtures report it at all.
- All 7 resolve to the same registry from their modelNum board token alone.
- No device-support issue has ever been fixed by adding a mapping for it.
  Every one went through modelNum. The alias keys it needed in
  _REGISTRY_BY_KEY ('airpurifier', 'air_conditioner', 'hood') were
  speculative when the registries were first written and never used since.

So it bought a key-normalizing helper (_type_key), a lookup with a suffix
fallback (for_device), three alias keys, and a second config-flow step whose
only reason to exist was phrasing a sentence about oneUiVersion -- for a
signal that has never once been decisive.

Remove it from detection. It stays in diagnostics, where it's genuinely
useful: it names the firmware generation ('7.0 Air conditioner' is Tizen
Lite), which matters when triaging an issue.

Detection order was also duplicated in four places -- the coordinator, the
config flow's probe, the golden-regression harness, and the skill -- which
is how the harness and the shipped order drift apart. Collapse it into
by_type.resolve(resources), and call that everywhere.

The two "appliance type not recognized" config steps become one. They
differed only in whether they blamed a missing oneUiVersion, which is not a
distinction a user can act on, and never was.

Verified by the full suite (795 passing), including every golden regression
-- so entity output is byte-identical for all 49 device fixtures.

TestOneUiVersionIsNotConsulted locks in the premise rather than just the
outcome: for every dump that reports a oneUiVersion, the model strings alone
must still reach a registry. If a future device breaks that, the test says
so instead of the device silently losing half its entities.

Also note in requirements-dev.txt that Python 3.13 resolves the pinned
harness floor -- 3.12 and older resolve nothing and fail the whole install.
This commit is contained in:
Marc Billow
2026-07-29 02:14:13 +00:00
parent a863df1e59
commit cafd7d5afa
17 changed files with 229 additions and 326 deletions
+23 -22
View File
@@ -5,8 +5,8 @@ description: >-
/device/0 diagnostics dump. Use when a device-support issue lands, a device
raises the "incomplete capability coverage" repair, a diagnostics JSON needs
triaging, or you're mapping OCF resources to HA entities. Covers reading dumps,
routing an unrecognized board family to a registry (oneUiVersion, modelNum
board tokens, resource signatures),
routing an unrecognized board family to a registry (modelNum board tokens,
resource signatures),
OCF-standard vs vendor hrefs, the diagnostic/config/normal entity taxonomy,
preferring dynamic (device-reported) select options over hardcoded lists,
ensuring every href is bound or ignored, and locking it in with a fixture +
@@ -49,15 +49,7 @@ discovery = importlib.import_module('custom_components.localthings.registry.disc
adapter = importlib.import_module('custom_components.localthings.registry.adapter')
resources = json.load(open('dump.json'))['data']['resources']
info = resources.get('/information/vs/0', {})
one_ui = resources.get('/otninformation/vs/0', {}).get('swVersionInfo', {}).get('oneUiVersion', '')
# Same three-stage order the coordinator uses -- see §3.
reg = (
(by_type.for_device(one_ui) if one_ui else None)
or by_type.for_device_by_model(info.get('x.com.samsung.da.modelNum', ''),
info.get('x.com.samsung.da.description', ''))
or by_type.for_device_by_resources(resources)
)
reg = by_type.resolve(resources) # the same entry point the coordinator uses
unbound = []
bound = discovery.discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
state = adapter.flatten(bound, resources) # {entity_key: value}
@@ -71,21 +63,30 @@ regenerate a golden.
## 3. Route the device to a registry — add a row, never a branch
If `for_device*` returns `None`, the device falls back to common capabilities
and loses roughly **half** its entities (measured across the fixture corpus:
843 of 1510 bound entities survive). So routing is the first thing to fix, and
`registry/by_type/__init__.py` is deliberately kept boring:
If detection returns `None`, the device falls back to common capabilities and
loses roughly **half** its entities (measured across the fixture corpus: 843 of
1510 bound entities survive). So routing is the first thing to fix, and
`registry/by_type/__init__.py` is deliberately kept boring.
1. **`for_device(one_ui_version)`** — `/otninformation/vs/0`'s
`swVersionInfo.oneUiVersion`, e.g. `'7.0 Dishwasher'`. The device naming
its own type, so it's tried first — but only a minority of hardware
reports it, so never assume it exists.
2. **`for_device_by_model(model_num, description)`** — the workhorse. Both
`resolve(resources)` is the only entry point — the coordinator, the config
flow's probe and the golden-regression harness all call it, so the order can't
drift between what ships and what the tests assert. Two stages:
1. **`for_device_by_model(model_num, description)`** — the primary path. Both
fields come from `/information/vs/0`. Board-family tokens are matched
against `modelNum` first, then `description`, then the fuzzy two-letter
consumer-model prefix.
3. **`for_device_by_resources(resources)`** — last resort for boards that
report no `/information/vs/0` at all. Needs a *distinctive* signature.
2. **`for_device_by_resources(resources)`** — for boards that report no
`/information/vs/0` at all. Needs a *distinctive* signature.
**`oneUiVersion` is not consulted.** It looks like the obvious signal — the
device naming its own type, `'7.0 Dishwasher'` — and it used to be stage one.
But only a minority of hardware reports it, every device that does is already
typed by its modelNum board token (`TestOneUiVersionIsNotConsulted` checks that
against the whole corpus), and no device-support issue was ever fixed by adding
a mapping for it. Don't reintroduce it as a detection stage; it stays in
diagnostics as a firmware-generation marker (`'7.0 Air conditioner'` means
Tizen Lite), which is useful when triaging.
### Adding a board family