Two tests added while triaging #244/#226 just re-asserted a translation string against the catalog entry that had been written moments earlier (dryer_cycle_table_03's '51'/'53'/'4e', dishwasher_cycle's '83'/'86'). Neither exercises any code path -- they pass by construction and only break when someone later edits the label text for wording, not when the actual code/value mapping regresses. tests/test_translations.py already holds the invariants that matter for catalog data. Documents the anti-pattern in the adding-device-support skill so future translation-only fixes don't reach for this pattern again.
34 KiB
name, description
| name | description |
|---|---|
| adding-device-support | Add or extend support for a Samsung OCF appliance in localthings from a /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 a device to a registry from its `/oic/d` device type first and an unrecognized board family second (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 + golden + test. Also covers multi-subdevice ("composite") appliances that expose several logical indoor subdevices over one IP — triaging a missing second subdevice, and why registry hrefs stay canonical rather than indexed. |
Adding device support
localthings maps a Samsung appliance's OCF resources (/device/0 dump) to Home
Assistant entities. Each resource href is handled by a Capability that
declares the entities it produces. This skill is the workflow for turning a new
dump into coverage.
1. Get the dump and see the gaps
A user's diagnostics download (config_entry-localthings-*.json) has, under
data:
resources:{href: rep}— the parsed/device/0snapshot. This is the source of truth, not code comments. On a multi-subdevice appliance this is the subdevice the config entry connects to and only that subdevice; siblings report their own (see below).unbound_hrefs: resources that bound to no capability. The "incomplete capability coverage" repair fires whenever this is non-empty or the device type is unrecognized (coordinator._update_coverage_gap_issue).
Multi-subdevice appliances (one IP, one DTLS session, several logical indoor subdevices — issue #177) add four more, all absent/empty on an ordinary device:
subdevices: one entry per materialized sibling —kind/key/seed_path, its ownmodel, its bound hrefs, and its ownresources.subdevices_skipped: candidates whose seed answered but that produced no live primary state, with the reps the gate actually judged. An unused SmartThings slot lands here, not insubdevices.subdevice_probes:{seed_href: found}for every seed attempted — tells "checked, nothing there" apart from "never checked".multidevice:/multidevice/vs/0's rep if the board answers it. Itsnumofsubdeviceis a corroborating count, not a gate.
Goal: make unbound_hrefs empty by binding the useful resources and
ignoring the noise — and surface every genuinely useful sensor/select/switch
along the way.
2. Compute coverage without Home Assistant
The registry/ package is HA-free, so you can drive discovery directly (HA
isn't importable standalone because localthings/__init__.py pulls it in — stub
the package to skip that):
import sys, types, json, importlib
cc = types.ModuleType('custom_components'); cc.__path__=['custom_components']; sys.modules['custom_components']=cc
lt = types.ModuleType('custom_components.localthings'); lt.__path__=['custom_components/localthings']; sys.modules['custom_components.localthings']=lt
by_type = importlib.import_module('custom_components.localthings.registry.by_type')
discovery = importlib.import_module('custom_components.localthings.registry.discovery')
adapter = importlib.import_module('custom_components.localthings.registry.adapter')
data = json.load(open('dump.json'))['data']
resources = data['resources']
# identity.device_types is /oic/d's `rt` -- resolve()'s primary signal (see
# §3). Absent on dumps predating that field; () falls through to model-based
# detection exactly like a device that reports nothing there.
device_types = tuple((data.get('identity') or {}).get('device_types') or ())
reg = by_type.resolve(resources, device_types=device_types) # 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}
print('registry:', reg.name, 'unbound:', sorted(unbound))
print('state_keys:', sorted(state))
discover() binds caps (applies rt_filter/match_fn); flatten() applies
exists_fn and produces the final entity values. Use the same routine to
regenerate a golden.
A sibling subdevice's block runs through this unchanged. subdevices[i].resources
(and subdevices_skipped[i].resources) are keyed by canonical hrefs —
/mode/vs/0, never the /mode/vs/1 or /<uuid>/mode/vs/0 that subdevice
actually answers on — precisely so you can paste one into resources above and
read the result exactly like the master's. No de-indexing by hand.
3. Route the device to a registry — add a row, never a branch
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.
resolve(resources, device_types=()) 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. Three stages, most-specific evidence first:
for_device_by_oic_type(device_types)— the primary path whenever a dump has it.device_typesis/oic/d'srt, looked up against_OIC_TYPE_TO_KEY. The device naming its own type beats parsing board part numbers, so this always wins when it hits. Always check this first when triaging a new dump — see "Adding an /oic/d device type" below.for_device_by_model(model_num, description)— the fallback for everything/oic/ddoesn't resolve. Both fields come from/information/vs/0. Board-family tokens are matched againstmodelNumfirst, thendescription, then the fuzzy two-letter consumer-model prefix.for_device_by_resources(resources)— for boards that report no/information/vs/0at 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
Almost always a one-line addition to _BOARD_TOKEN_TO_KEY:
'VSKR': 'vacuum_station', # issue #131 -- stick-vacuum clean station
Matching is on whole tokens of the model string, split on any run of non-alphanumerics and upper-cased. That is what keeps this a table, and it carries rules:
- Never add a delimiter spelling.
'_RAC_'and'-RAC-'are the same entry,RAC. If you find yourself adding a second row for punctuation, the tokenizer already handled it. - Name the specific type, never the board family.
DA-AC-prefixes RAC/WAC/DHM/AIR alike — a bare'AC'row would swallow the dehumidifier and the air purifier. Same forDA,KS,WM,TP1X,ARTIK051.TestBoardTokenTableasserts these stay out. - Never add a token that can co-occur with another.
_board_family_keyreturns the first hit, which is only safe while no real model string contains two tokens naming different types.TestBoardTokenAmbiguitychecks that invariant against every fixture, so a new dump exercises it automatically — if it fails, the answer is a narrower token, not a reordering. - Two-letter tokens are a last resort.
'CT'(legacy gas cooktop) is the only one, and it is loose enough to collide by accident.
Reach past the table only when the evidence isn't a board token:
- Consumer-model prefix (
_CONSUMER_PREFIX_TO_KEY) — for washers, dryers and dishwashers, whosemodelNumis the sharedDA_WM_laundry board and whose real type is only indescription's trailing model code (..._WA8000T). Deliberately split on_only: widening it to-would read the dishwasher'sADW-WW-RTL-24-AILITEboard segment as aWWwasher. Consulted last because a two-letter prefix is the weakest evidence here —WAC(window AC) starts withWA(top-load washer). - Resource signature (
for_device_by_resources) — only when/information/vs/0is absent entirely. Require two independent shapes (e.g./oven/vs/0present and aMicroWave*entry insupportedModes), never one, or an unrelated family's/mode/vs/0will match.
Adding an /oic/d device type
/oic/d's rt (OCF's own device-type declaration) is the primary
detection path (for_device_by_oic_type, stage 1 above) — it sits outside
the /device/0 dump, read separately by registry/identity.read_identity,
which fetches three endpoints in one shot:
/oic/d— the device type itself:n(device name) andrt, a list carrying the genericoic.wk.dbase type every OCF device has alongside a concrete one (oic.d.airconditioner) or a SmartThings vendor extension (x.com.st.d.stickcleaner, for categories with nooic.d.*equivalent — same prefix convention asx.com.samsung.da.*resource fields elsewhere)./oic/p— platform identity:mnmn/mnmo(manufacturer/model)./oic/res— resource discovery, used for subdevice enumeration (§11), not device typing.
None of the three appear in resources; find them in diagnostics' identity
block (identity.device_types, identity.manufacturer, identity.model),
or read live with read_identity(sess, serial) if you're driving a device
directly.
Whenever you triage a dump, check identity.device_types before touching
_BOARD_TOKEN_TO_KEY at all — the whole point of this stage running first
is that a real /oic/d type makes board-token routing unnecessary. Two
outcomes:
- The type is already a key in
_OIC_TYPE_TO_KEY(registry/by_type/__init__.py) → detection already works; an unbound-hrefs gap on this device is a capability-coverage problem (§§4–9), not a routing one. - The type is not yet in the table → add a row. This is now the
integration's primary detection method, and it only stays that way if new
types get folded in as real dumps surface them — same discipline that
keeps
_BOARD_TOKEN_TO_KEYcurrent:
'oic.d.dishwasher': 'dishwasher',
'x.com.st.d.steamcloset': 'air_dresser',
- A string not yet seen in a dump is still fine to add on the strength of the
OCF Smart Home Device Specification's Table 9-1 alone, as long as it has the
exact same
oic.d.<category>shape as an already-confirmed entry — that shape is low-risk ahead of a dump because, unlike a board-token entry, there's no tokenizing or delimiter-spelling judgment call involved. - Only add a row once there's a real registry key on the right (a key in
_REGISTRY_BY_KEY). A type naming a product this integration has no registry for stays unmapped rather than getting coerced onto the nearest-sounding one —oic.d.robotcleanernames an actual robot vacuum, a different product from the clean/auto-empty stationvacuum_stationcovers (no vacuum-body capabilities at all; see that registry's own module docstring), so it's deliberately absent even though the string is known. - Falls through to
for_device_by_model/for_device_by_resourceswhendevice_typesis empty or maps to nothing — most hardware still doesn't populate/oic/dusefully, so those two stages stay load-bearing for everything this one doesn't catch. - On a multi-subdevice appliance,
device_typesonly ever comes from the master's/oic/d(discover_partitioned'soic_device_typesparam) — subdevices have no/oic/dof their own read today and keep resolving from their own/information/vs/0, falling back to the master's whole registry otherwise (§11).
Sharing a registry vs adding one
Route a new family to an existing registry when its resource surface
matches (most AC board families do — verify by checking the dump binds with
zero unbound hrefs). Add a new registry only when the resources genuinely
differ: vacuum_station earned one because it shares no hrefs with anything
modelled; microwave split from oven over a distinct mode vocabulary,
setpoint bounds, and a powerLevel field.
4. OCF-standard vs vendor hrefs (/x/0 vs /x/vs/0)
Samsung appliances run RT-OCF and often expose the same state twice:
/x/vs/0— vendor resource,x.com.samsung.da.*fields./x/0— standard OCF resource type (oic.r.*) with OCF's fixed field names. Confirmable against the OCF spec:/power/0{value: bool}isoic.r.switch.binary;/operational/state/0isoic.r.operational.state.
Newer firmware advertises both as Samsung migrates onto the OCF standard. There is no single "always prefer vs / always prefer non-vs" rule — decide per resource from the populated dump:
- Both populated, same state (power, kids-lock, remote): prefer the
OCF-standard
/x/0; fall back to/x/vs/0when/x/0is absent. Encode with amatch_fnpresence check — seecommon.POWER_GENERIC/POWER_VS_FALLBACK. - Only vendor populated (
/energy/consumption/0is often empty{}): use/x/vs/0. - Vendor is a superset (
/operational/state/vs/0adds fields the OCF one lacks): build on the vendor resource, ignore the OCF subset.
Course/cycle is not an OCF question — there's no standard course resource, so
/course/vs/0 (and the /st/*course/vs/0 re-encoding) are both vendor.
5. Entity taxonomy — the judgement call
For each field worth exposing, decide the entity kind and category
(entity_category on the descriptor):
- Normal / primary (no
entity_category): the things a user acts on or watches — power switch, machine state, the cycle select, energy sensors. config: user-tunable settings — sound mode, door LED, wash temperature, buzzer. Shown under the device's Configuration section.diagnostic: read-only status/troubleshooting — alarms, diagnosis, job beginning status, last-operation source.
Also set poll_tier (hot/warm/cold) on the capability for how often it's
sub-polled between summary polls. Pick descriptor types from entities.py
(SensorDesc, SelectDesc, SwitchDesc, NumberDesc, BinarySensorDesc,
TimeDesc, ButtonDesc) — the class selects the HA platform.
Educated guesses are fine — flag them, don't hide them. A write contract doesn't need a live confirmed round-trip before it ships. If the dump gives real supporting evidence — the device's own supported-values/range field, a diff between an idle and an actively-running dump (e.g. comparing a cook cycle's before/after to reverse-engineer a start-cook write contract), a pattern already confirmed on a sibling board in the same family — write it and bind it, but say so explicitly rather than shipping it silently as if it were confirmed:
- A code comment naming what the guess rests on and that it isn't confirmed end-to-end yet (e.g. "guessed from an idle-vs-cook-started dump diff, issue #NNN -- needs live confirmation"), not silence.
- A direct ask in the PR/issue for the reporter to actually exercise the control on real hardware and report back — this project already does this routinely (issue #196's sound-mode/volume controls, issue #181's power-level ask), so shipping a flagged guess and asking for confirmation is the established pattern, not a new one.
Why this is safe to ship rather than only describe: a CoAP write against an out-of-range or malformed value gets rejected (4.xx), not acted on — the worst case for a wrong value is a no-op, not a damaged or misbehaving appliance. That margin only covers the value, though, not the semantics: bind a guessed write to the device's own reported range/supported-list rather than inventing bounds, and don't guess a unit the dump gives no way to cross-check (temperature scale, minutes vs. seconds) — being syntactically valid but semantically backwards is exactly the case a rejection won't catch.
The same unit caveat applies on the read side, but with a sharper
failure mode: a guessed unit, device_class, or state_class on a
SensorDesc silently mislabels the entity in HA forever (every reading,
every graph, every long-term statistic), with no 4.xx to catch it. The
write-rejection safety net above doesn't cover reads — the device happily
returns whatever it returns. So the read-side equivalent of "bind a write
to the device's own reported range/supported-list" is: leave unit/
device_class/state_class unset when the dump gives no field that
nominates one (no supportedGrades, no second dump to compare against,
no family member whose same field is already mapped). Match an
already-bound descriptor on a sibling family when the underlying field
and value shape are identical; otherwise expose the reading without an
HA-level interpretation and let a future reporter or dump confirm it.
See air_monitor.AIR_QUALITY's docstring for the worked example
(three dust keys, no device_class, no unit).
Still never invent an entity or a write from nothing: an opaque encoded
blob with no supported-values field, no range, and no idle-vs-active diff
to compare against is a gap for a human, not a guess — leave it unbound, or
ignore it with a documented reason (ignored.py's rule).
Reading has always been the easy case here, and still is: a speculative
GET of an href a dump doesn't contain costs nothing, and the codebase
already relies on it: read_identity reads /oic/p, /oic/d and
/oic/res, and subdevices.enumerate_subdevices probes /device/<n>,
/<uuid>/device/0 and /multidevice/vs/0 on every device — and, when a
prefixed candidate's own /<uuid>/device/0 doesn't answer (issue #205: not
guaranteed even on the board this pattern was built against), every href
the master itself answered this cycle, individually under that UUID's
prefix (see §11). A RETRIEVE is non-mutating and a 4.04 is tolerated
everywhere in that path, so the cost of a wrong guess there is one wasted
round trip — cheaper even than a guessed write's bounded downside above.
6. Select options: read them from the device, don't hardcode
A SelectDesc's options should come from the device's own advertised list
whenever the resource carries one, not from a Python tuple typed in from a
single dump. Two dynamic forms already exist in the repo and should be
reached for first:
options_field='x.com.samsung.da.supportedModes'(or whatever the resource's own supported-values field is called) — reads the live rep on the capability's own href. Seelaundry.py'sbuzzer_sound/finish_sound(options_field='supportedBuzzerSound'/'supportedFinishSound').options=<callable>— for option lists that live on a different resource than the select's own href (e.g. a course table keyed off a sibling href). Seelaundry.cycle_select'soptions=cycle_options.
A static options=(...) tuple is a coverage gap waiting to happen: the next
dump from a different board generation will report modes/values the tuple
doesn't have, and both the HA options list and write_fn's validation (if
it checks the same tuple) will silently reject values the device itself
advertises as supported. That's exactly what happened with oven._OVEN_MODES
in issue #138 — a hardcoded list rejected AirFryer/Dehydrate/
SelfClean/etc. even though the device's own supportedModes field listed
them. Reach for a static tuple only when the dump genuinely has no
supported-values field to read (e.g. the NV7000BS-class oven dump
_OVEN_MODES was inferred before any live oven dump existed — see that
module's docstring), and treat it as an interim best-guess rather than a
permanent design choice: migrate it to options_field/a callable the moment
a dump with a real supported-values list surfaces, instead of just adding
the new values to the static tuple.
7. Names and enum labels live in translations, never in Python
Descriptors have no name field. Every entity is named from the shipped
catalog, keyed by translation_key — which defaults to the descriptor's own
key. So adding SensorDesc(key='filter_status', ...) obliges you to add:
"entity": { "sensor": { "filter_status": { "name": "Filter status" } } }
to translations/en.json. Skip it and the entity ships nameless;
tests/test_translations.py fails the build instead.
- Sentence case ("Filter status", not "Filter Status"), per HA's style guide — capitalize only proper nouns and Samsung feature names ("AI Energy Mode", "Storm Wash+").
- Set
translation_keyexplicitly only to share one catalog entry across descriptors, or to point at a differently-named one. Two descriptors on the same platform with the samekeyalready share an entry — intended forcommon.py's OCF/vendor fallback pairs, a silent mislabel otherwise. - Prefer HA's own vocabulary where it fits: a
device_classgives you translated states for free (binary_sensordoor/running,sensortimestamp/enum), so don't restate them.
Selects whose options are raw device codes (course/cycle, code-valued settings) additionally need those codes labelled:
options/options_fieldsupply the raw codes; the catalog maps them.- Add labels under
entity.select.<translation_key>.state.<code>, code lowercased (e.g."16": "Cotton").select.pyderives which values it normalizes from the catalog itself, so there is no Python list to keep in sync — a code with no entry simply renders as the raw code, which is the cue to identify and name it.
translations/en.json is the only place any of this lives: there is no
strings.json (Home Assistant doesn't read one from a custom integration) and
no [%key:...%] resolution (that's Core build tooling). Every other language
must mirror en.json key for key — also enforced by
tests/test_translations.py.
Don't write a test that just re-asserts a translation string. Adding
labels is a data change, not a logic change, and tests/test_translations.py
already holds the invariants that matter for data (every descriptor has a
catalog entry, every language mirrors English key-for-key, no unresolved
[%key:...%]). A test that loads the catalog and asserts
catalog["select"]["foo"]["state"]["16"] == "Cotton" right after you just
wrote that exact line into en.json doesn't exercise any code path — it
re-states the JSON file in Python, passes by construction, and only ever
fails when someone correctly edits the label later (a wording fix, a
translator's improvement). It's not a regression test, because there's no
select.py/adapter.py logic between "the JSON says X" and "the test reads
X" for it to catch drift in. If a code/label mapping is worth locking in,
test it through the code that actually consumes it instead — a write
contract (desc.write_fn(...) returns the right raw code), a read contract
(flatten() produces the right raw value from a fixture rep), or a routing
decision — never a bare literal-string comparison against the catalog you
just edited.
8. Coverage discipline: bound or ignored
Every href in the dump must resolve, or the repair fires. If a resource isn't
worth an entity, add it to capabilities/ignored.py (a no-entity Capability)
with a one-line reason. Add there only when it's irrelevant plumbing
(network/OTA/account housekeeping) or a duplicate of state exposed via a
friendlier href.
-
Global vs per-registry ignore:
ignored.IGNOREDis folded into every registry. A global ignore collides (via_build) with any real capability that binds the same href in some family — e.g./course/vs/0can't be globally ignored because washers bind it. When only one family should ignore an href that another binds, scope the ignore to that family's registry. -
Registry hrefs are always canonical — never index or prefix one. On a multi-subdevice appliance,
unbound_hrefsreports the real href a gap was seen on, so a sibling's gap shows up as/foo/vs/1or/<uuid>/foo/vs/0. Do not writeCapability(href='/foo/vs/1')for it. Binding runs against each subdevice's canonical view, so an indexed or prefixed href in a registry matches nothing on any device and fails silently — no error, no entity, and the gap stays open. Fix it on the/foo/vs/0form and every subdevice gets it at once. (registry/subdevices.pyowns the canonical ⇄ actual translation; nothing undercapabilities/orby_type/should ever mention a subdevice index.)
9. Reuse before writing new code
Check common.py (generic OCF: power, energy, alarms, water) and laundry.py
(shared washer/dryer/dishwasher: buzzer, job status, cycle_select + course
machinery) before adding a capability. Cross-family reuse is normal — the dryer
registry uses fridge.FIRMWARE_UPDATE; all three laundry families share
laundry.cycle_select. If two families hand-roll the same helper, hoist it to a
shared module rather than copying.
10. Lock it in
If the dump's diagnostics identity block carries a /oic/d device type,
confirm (or add, per "Adding an /oic/d device type" in §3) the matching
_OIC_TYPE_TO_KEY row before considering this device done — routing this
device by board token today doesn't mean the next report of the same
appliance family gets the faster, more reliable /oic/d path unless the
table actually has the row.
-
Add a scrubbed fixture
tests/fixtures/<type>_device.json({"device0": [ {devcol rep}, {href, rep}, ... ]}) — replace serials, MACs, and other PII with placeholders.A multi-subdevice dump (issue #177) may carry three more top-level keys, all optional and defaulted for every other fixture — load them with
conftest._load_device_fullrather than_load_device:oic_res: the raw/oic/reslink array, which is what enumeration reads to find/device/<n>siblings.seeds:{seed_href: raw_batch_list}— each sibling's own collection response, in the same[devcol rep, {href, rep}, ...]shape asdevice0.probes:{href: rep}for plain Property-map resources belonging to no batch (e.g. a hand-read/multidevice/vs/0).
Add a
seeds_notesaying which parts are verbatim captures and which were constructed. A fixture that quietly mixes the two is worse than no fixture: the whole point of the corpus is that it records what hardware actually did. -
Generate
tests/fixtures/golden/<type>.json({"state_keys": [...]}) with the harness in §2. A multi-subdevice fixture's golden carries a sibling's keys under a prefix (subdevice1_climate,subdevice_<uuid>_climate) alongside the unprefixed master keys — that's the entity-ID namespacing, not a bug. The master's keys are unprefixed by design and must never gain one: that's what keeps every pre-#177 device'sunique_idstable. -
Add the type to
test_golden_regression.pyand write atest_<type>_capabilities.pyasserting zero unbound hrefs and that the expected entities exist (and any misleading ones are gated). -
Run
pytest tests/ -q— and re-run the golden tests for other device types after any change tocommon.py/laundry.py, since they share those.
The new fixture is picked up automatically by the corpus-wide checks (the
all_device_fixtures conftest fixture), including
TestBoardTokenAmbiguity — so a model string that collides with an existing
board token fails the build rather than silently mistyping someone's
appliance.
Don't put a reporter's name or GitHub username in code. Fixture data
gets serials/MACs/other device PII scrubbed per point 1 above — the same
rule applies to the prose you write while fixing the issue: comments,
docstrings, seeds_note, and test/function names should say "the
reporter," "issue #NNN's reporter," or (when a module already distinguishes
multiple reporters, like subdevices.py's Pattern A/Pattern B) "the
Pattern A reporter," never a real name or handle. That prose ships in the
package and lives in git history indefinitely — unlike an issue thread or a
release-notes thank-you (both fine places to credit someone by name), it's
not somewhere a person would expect to stay named forever. If you're fixing
an issue and about to write <username>'s board/<username>'s dump in a
comment, stop and swap in a generic reference instead.
11. Triage: "one of my subdevices is missing"
For an appliance that exposes several logical indoor subdevices over one IP — a 2-in-1 air conditioner, plausibly a multi-drum washer (#19). Work down the dump in this order; each step rules out a different cause.
subdevice_probes— did we even look? Every seed attempted appears here with what it returned. An absent seed means enumeration never tried that path; afalsemeans it tried and got nothing. On a UUID-prefixed board whose/<uuid>/device/0readsfalse(issue #205 — this isn't rare, not even on the board the pattern was built against), the report also carries one probe per href the master itself answered that cycle, individually under that prefix (subdevices.enumerate_subdevices's flat fallback) — atruethere is real, confirmed-live evidence for that one href, not a guess.subdevices/flat_hrefs— for a materialized subdevice found this way,flat_hrefslists exactly which hrefs it's actually being polled on (individually, no Collection endpoint to batch through) — compare against the master's own hrefs to see what's still unconfirmed for that sibling.subdevices_skipped— did we find it and reject it? A candidate lands here when its seed(s) answered but it produced no primary (non-diagnostic), non-meter entity with a populated value. Itsresourcesblock holds the exact reps the gate judged, so you can check the call yourself. If every power/mode/temperature rep is{}, the subdevice is an unused slot and the skip is correct — a populated/energy/consumption/vs/<n>alongside them doesn't change that (issue #214: an appliance's lifetime kWh counter shows up under an unused slot's index too, and materializing on it produced a phantom duplicate air conditioner, so cumulative meters are excluded from the gate). If the operational reps are populated, the gate is wrong — that's a bug worth a fixture. A flat-fallback candidate whose only confirmed href is/information/vs/0(never bound to any entity — only ever read for device-type resolution) will always land here until more of its hrefs are confirmed live; that's the gate working as intended, not a bug to chase.multidevice.numofsubdevice— the board's own count, where it reports one.coordinator._run_discoverycompares it againstlen(materialized) + 1(materialized subdevices plus the master) and only warns on disagreement —subdevices_skippedentries don't count toward either side, since they never materialized. A strong hint, not proof; only one board family is known to expose it.- Which pattern is this board?
identity.resources['/oic/res']listing/device/1,/device/2means indexed siblings.resources['/subdevices/ vs/0']carrying asubdeviceIdListmeans a UUID-prefixed tree, and that same UUID usually shows up as an href prefix in/oic/restoo — enumerate whether or not/<uuid>/device/0itself answers, per §5's fallback. Neither present, on a device the owner insists has two subdevices, is the interesting case — that's a third mechanism and needs a new dump, not a code guess.
Two things that are not the fix: adding a capability for an indexed href
(see §8), and loosening the liveness gate to "any populated entity" — a
rejected slot routinely reports a non-None diagnostic value off an empty
resource (and, on some boards, a populated appliance-level meter), which is
exactly what the primary-entity and meter filters exist to ignore.
The mirror image: "I have one subdevice too many"
Same dump, read the other way (issue #214). A duplicate device in HA is
either a candidate that shouldn't have materialized — check subdevices
for one whose resources are all {} except a meter//information, which
is the unused-slot shape from step 3 — or a leftover registry entry from
a release that did materialize it. Those two look identical in the HA UI and
are told apart by the dump: a leftover shows subdevices: [] (or no entry
for that key) while the device is still listed in HA.
Nothing prunes a leftover automatically — subdevice enumeration is one-shot
and a real sibling can miss a poll, so auto-removal would throw away a live
subdevice's name/area/automations on a transient miss. The integration
implements async_remove_config_entry_device
(custom_components/localthings/__init__.py) instead, which is what puts a
working "Delete device" button on anything this entry no longer provides;
devices it does provide refuse removal, since HA would just recreate them.
Tell the reporter to delete the stale device, don't add a pruning pass.
Key files
registry/identity.py—read_identity,DeviceIdentity.device_types(/oic/d'srt), the primary device-type signal's source.registry/by_type/__init__.py—resolve(),for_device_by_oic_typeand_OIC_TYPE_TO_KEY,for_device_by_modeland_BOARD_TOKEN_TO_KEY/_CONSUMER_PREFIX_TO_KEY,for_device_by_resources.registry/subdevices.py—Subdevice, enumeration, canonical ⇄ actual href translation, and the materialization gate for multi-subdevice appliances.registry/discovery.py—discover(), unbound reporting, pattern caps.registry/capability.py,registry/entities.py— theCapabilityand descriptor shapes (rt_filter,match_fn,exists_fn,rep_fn,write_fn).registry/capabilities/{common,laundry,fridge,...}.py— capability defs.registry/capabilities/ignored.py— the ignore list + its philosophy.registry/by_type/*.py— per-device-type registries (what to include).registry/registry.py— the global unknown-device fallback + collision check.tests/test_golden_regression.py,tests/fixtures/— regression harness.