Files
localthings/.claude/skills/adding-device-support/SKILL.md
T
Marc Billow 9264b912f9 feat: full dryer support + consistent laundry capability surface
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.
2026-07-21 01:15:12 +00:00

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/0 snapshot. 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} is oic.r.switch.binary; /operational/state/0 is oic.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/0 when /x/0 is absent. Encode with a match_fn presence check — see common.POWER_GENERIC / POWER_VS_FALLBACK.
  • Only vendor populated (/energy/consumption/0 is often empty {}): use /x/vs/0.
  • Vendor is a superset (/operational/state/vs/0 adds 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 the SelectDesc; options/options_field supply the raw codes.
  • Add the labels to both strings.json and translations/en.json under entity.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.IGNORED is 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/0 can'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

  1. Add a scrubbed fixture tests/fixtures/<type>_device.json ({"device0": [ {devcol rep}, {href, rep}, ... ]}) — replace serials, MACs, and other PII with placeholders.
  2. Generate tests/fixtures/golden/<type>.json ({"state_keys": [...]}) with the harness in §2.
  3. Add the type to test_golden_regression.py and write a test_<type>_capabilities.py asserting zero unbound hrefs and that the expected entities exist (and any misleading ones are gated).
  4. Run pytest tests/ -q — and re-run the golden tests for other device types after any change to common.py/laundry.py, since they share those.

Key files

  • registry/discovery.py — discover(), unbound reporting, pattern caps.
  • registry/capability.py, registry/entities.py — the Capability and 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.