Same isinstance/cast narrowing and Optional-field assert pattern as the
prior commits, covering the airconditioner, fridge, washer, operational,
subdevices, sensor_hysteresis, laundry, select_options, identity and
entities test files.
Adds [tool.ruff] and [tool.ty] config to pyproject.toml with a curated
ruff rule set (E, F, W, I, UP, B, C4, SIM, RUF, ASYNC, LOG, G, PIE, RET,
PERF, N), pins ruff/ty in requirements-dev.txt, reformats the whole tree
with `ruff format`, and fixes the pre-existing lint and type-check debt
those tools surfaced so both run clean.
Production-code type fixes include: HA's ConfigFlowResult vs. the
generic FlowResult in config_flow.py, narrowing BoundEntity.desc to its
platform-specific subclass (SelectDesc/NumberDesc/SensorDesc/etc.) via
cast() instead of an unchecked annotation, converting HA device_class
strings to their proper enum types, a resolve_registry callback typed
as `object` instead of `DeviceRegistry | None`, and a couple of other
narrow correctness fixes (CA key type validation, an index-out-of-bounds
false positive from an empty-tuple fallback, a bool/dict argument swap).
Test-file fixes are mechanical: narrowing SamsungEntityDescription to
the correct subclass via isinstance()/cast() before accessing
subclass-only fields, and asserting Optional write_fn/unit_fn fields
are set before calling them.
"Unit"/"sub-unit" from #199's multi-indoor-device support wasn't OCF
idiomatic -- OCF calls each component of a composite device a
"subdevice" (see subdeviceIdList), so rename SubUnit -> Subdevice
throughout: the registry module, coordinator state, BoundEntity's
subdevice field, diagnostics keys (subdevices/subdevices_skipped/
subdevice_probes), entity unique_id prefixes (unit1_/sub_<uuid>_ ->
subdevice1_/subdevice_<uuid>_), device-name fallback labels, golden
fixtures, tests, README, and the adding-device-support skill.
Breaking change to entity unique_ids and diagnostics keys, acceptable
since this hasn't been released yet.
Samsung 2-in-1 air conditioners put more than one logical indoor unit
behind a single IP and a single DTLS session. Only the unit the config
entry was set up against was ever discovered; the second one -- a whole
physical appliance the user can see in SmartThings -- had no entities at
all. Two reporters turned out to have two different mechanisms:
ARTIK051_DONGLE_FAC_18K -- indexed siblings. /oic/res registers the
whole tree discoverable and lists three complete parallel resource
sets whose trailing path segment is the index (/mode/vs/0, /mode/vs/1,
...), on OCF-standard and vendor hrefs alike. /device/0's batch
carries only the index-0 hrefs, so a sibling is reachable only through
its own /device/<n> collection.
TP2X_FAC_BORA_21K -- UUID-prefixed tree. /oic/res hides the appliance
tree entirely (which is why a direct /device/1 probe returns nothing
on this board). /subdevices/vs/0 carries subdeviceIdList instead, and
that UUID appears as a literal href prefix; /<uuid>/information/vs/0
was confirmed live to return the wall unit's own model and serial
(TP2X_FAC_BORA_RAC_21K) against the master's TP2X_FAC_BORA_21K.
The detection signals don't overlap on either board, so no
disambiguation is needed -- enumeration checks both and takes what
answers.
Both patterns are the same thing underneath: a logical unit is a seed
collection path to poll plus an href transform between the canonical
href the registry knows and the actual on-the-wire href. That is the
whole abstraction (SubUnit), applied at four boundaries -- discovery,
the coordinator, the adapter, and the platforms. Capabilities, the
registry and the climate composite stay written against canonical hrefs
and are untouched.
Uniqueness comes from a key_prefix inside the flattened state key, so
the master unit's keys are byte-identical to every release before this
and every existing golden file is an unchanged regression guard. Each
sub-unit gets its own device-registry entry linked by via_device and
named from its own /information/vs/<n>, so it lands in its own room
rather than crowding the master's device page.
A sub-unit materializes only when it yields at least one primary
(non-diagnostic) entity with a populated value. That gate is not
decoration: the reporter's /device/2 is an unused slot that SmartThings
shows disabled, yet it answers with a full 14-href batch, and it
flattens to exactly one non-None value -- a diagnostic alarm_code
derived from an empty /alarms/vs/2. Without the entity-category filter
it becomes a phantom third climate card. The rule is deliberately
domain-agnostic rather than a list of HVAC hrefs, so a multi-drum
washer (#19) gets the same treatment with no new curation. Units that
answer but fail the gate are logged and reported in diagnostics, so a
genuinely missing unit stays diagnosable from a dump.
Enumeration fetches things that must not then be treated as appliance
state. A rejected candidate's seed has to be read to evaluate the gate,
but only units that pass are polled again, and StateCache has no
eviction -- so discovery runs before the first cache apply and those
reps are held aside for diagnostics rather than frozen into the cache
forever. /multidevice/vs/0 is probed on every device regardless of
family, so merging it into the resources dict would have reached
discovery on any board whose registry doesn't ignore that href -- only
the air conditioner one does -- raising a spurious coverage-gap repair
for a washer or fridge whose firmware answers it. It is corroborating
metadata (numofsubdevice, confirmed read-only) and now lives beside the
resources rather than in them.
Diagnostics reports each unit separately: top-level `resources` is this
unit's own and only its own, which is what the module docstring and the
adding-device-support skill have always claimed it was, and each
sibling or rejected candidate carries its own reps canonicalized so a
block reads exactly like the master's instead of needing to be
de-indexed by hand.
Fixtures are real captures. The ARTIK051_DONGLE_FAC_18K one is entirely
verbatim, both sibling seeds and the hand-read /multidevice/vs/0
included. The TP2X_FAC_BORA one has a real device0, oic_res and
sub-unit /information/vs/0, with the remainder of that unit's tree
constructed and documented as such in seeds_note; /<uuid>/device/0 is
the one part of that pattern still inferred rather than observed, and
can't be tested through the debug panel because a Collection returns a
list.
Since entity.py started routing named descriptors through the catalog
under desc.key, SamsungEntityDescription.name has been read for its
value nowhere -- only twice as a flag, to decide whether an entity was
translated at all. That left 148 English names duplicated between Python
and translations/en.json with nothing keeping them honest: six had
already drifted, invisibly, because editing the Python side changes
nothing a user sees.
So the field is gone, and translation_key defaults to desc.key. A
descriptor now sets translation_key only to share one catalog entry
across descriptors or to point at a differently-named one, and the
catalog is the only place an entity name exists.
Every descriptor resolves to exactly the translation key, icon, entity
category, enabled-default and gating it did before -- with one
deliberate exception: the hood fan, previously the sole descriptor with
no key at all, now resolves to 'fan'. That is inert, because fan.py sets
_attr_name = None so the entity presents as the device itself.
The three helpers that forwarded a name into a descriptor
(laundry.bool_option_switch, washer._bool_option_switch, air_purifier's
sensor table) lose that parameter. test_translations.py now requires a
catalog entry for every descriptor rather than only translated ones.
Claude-Session: https://claude.ai/code/session_01GiibJZZLWVvyxq7mc7EDNp
Simplification (feedback: this was overcomplicated): drop the
validated_table gate entirely. cycle_select's table_href now just builds
the translation key directly from whatever course table the device
reports (washer_cycle + Table_02 -> washer_cycle_table_02) instead of
comparing against a hardcoded known-good value and falling back to no key
on any mismatch. A table we haven't shipped translations for yet (e.g.
FlexWash's Table_00) still gets a key built for it -- Home Assistant's own
missing-translation handling takes it from there, the same graceful
fallback already relied on for any individual untranslated code within an
existing table. Adding a newly-confirmed table later is just new
strings.json entries, no code change.
Independent (Opus) review of the prior version caught two real issues,
fixed here regardless of the simplification above:
- translation_key was resolved once at entity construction from whatever
coordinator.last_resources held at that moment. Discovery can run while
a sibling resource is still an empty stub (documented precedent: see
_is_included), so a callable translation_key could permanently bake in
a stale value for the entity's lifetime. Moved resolution into a
translation_key property override (Entity.translation_key is a property
upstream, not a plain attribute), re-evaluated against live coordinator
data on every access, matching how options/current_option already work.
- The supportedOptions fallback's "smallest passing K wins" docstring
claimed every larger passing K is an exact multiple of the true one.
False: the shipped dishwasher fixture has passing K=7 (true) alongside
10, 14, and 35, none of which are multiples of 7 -- position 0 always
lands on the same real course code regardless of K, which alone
satisfies the current-course guard for several unrelated splits.
Corrected the reasoning to what's actually true (an empirically-matched
heuristic across six real dumps, not a proof) and added a regression
test locking in the real dishwasher case so this isn't silently lost.
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.
Course selection is now a writable select entity sourced from each
device's own x.com.samsung.da.editCourseList (via a new options-as-callable
form on SelectDesc, reading the coordinator's full resource snapshot
instead of just the entity's own href), rather than a hardcoded course
table baked into Python. A MostUsed_ field on the same resource was
considered as a fallback but rejected after byte-level analysis showed it
doesn't reliably encode a course list. When a device never populates
editCourseList, the selector isn't created at all (exists_fn now takes
(rep, resources) to check a sibling href, matching the existing
match_fn(rep, resources) pattern).
Display names moved out of Python into strings.json/translations under
entity.select.{washer,dishwasher}_cycle.state.*, matching this
integration's existing translation_key convention (fridge.py's ice_type,
flex_zone_mode, etc.) instead of hardcoding English names in code.