First full dryer dump (issue #14, DV90BB5245AES1) surfaced 5 unbound hrefs and an "incomplete capability coverage" repair. Handle them and, while here, make the washer/dryer/dishwasher families consistent instead of each carrying a bespoke variant of the same controls. Dryer coverage: - /power/0, /kidslock/0, /remotectrl/0: bind via the OCF-native + vendor fallback pairs (prefer the standard OCF resource, fall back to -vs). - /buzzersound/vs/0: new Buzzer sound select. - /course/vs/0: cycle select shared with washer/dishwasher; ignore the /st/dryercourse/vs/0 re-encoding (mirror of /st/washercourse/vs/0). Consistency / de-duplication: - Move generic OCF controls (power/kids-lock/remote-control fallback pairs, energy meter) into common.py; every registry uses them. - Move shared laundry controls (buzzer, job-beginning-status, and the /course/vs/0 cycle-select machinery) into laundry.py; washer and dishwasher stop hand-rolling their own copies. - Energy meter is now sentinel-aware everywhere: the dead '-500' instantaneousPower reading no longer shows a misleading 0 W (fixes it on dryers and dishwashers, matching the earlier washer fix). - Job-beginning-status reads x.com.samsung.da.currentStatus, the field every dump actually carries; the dryer sensor was previously blank. Adds a scrubbed dryer fixture, golden, and capability tests, plus an .claude/skills/adding-device-support skill capturing the dump-reading, OCF-vs-vendor, entity-taxonomy, and coverage workflow. Bumps to 0.6.0.
8.3 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, OCF-standard vs vendor hrefs, the diagnostic/config/normal entity taxonomy, ensuring every href is bound or ignored, and locking it in with a fixture + golden + test. |
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.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).
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')
resources = json.load(open('dump.json'))['data']['resources']
info = resources['/information/vs/0']
reg = by_type.for_device_by_model(info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'])
# or: by_type.for_device(one_ui_version) when /otninformation has swVersionInfo.oneUiVersion
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.
3. 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.
4. 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.
Don't guess. If a field's meaning or write contract is unclear from the dump
(opaque encoded blobs, no supported-values list), leave it unbound so it surfaces
as a gap for a human, or ignore it with a documented reason — never invent an
entity on a hunch (ignored.py's rule).
5. Enum selects need translation support
Any select whose options are raw device codes (course/cycle, and code-valued settings) must render through translations, not Python:
- Set
translation_key='<family>_cycle'(or similar) on theSelectDesc;options/options_fieldsupply the raw codes. - Add the labels to both
strings.jsonandtranslations/en.jsonunderentity.select.<translation_key>.state.<code>, with the code lowercased (e.g."16": "Cotton"). Codes with no entry render as the raw code — that's the cue to identify and name them.
6. 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.
7. 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.
8. Lock it in
- Add a scrubbed fixture
tests/fixtures/<type>_device.json({"device0": [ {devcol rep}, {href, rep}, ... ]}) — replace serials, MACs, and other PII with placeholders. - Generate
tests/fixtures/golden/<type>.json({"state_keys": [...]}) with the harness in §2. - 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.
Key files
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.