Commit Graph
13 Commits
Author SHA1 Message Date
Marc Billow 81dbc6fa03 Key devices on the OCF device ID instead of the serial number
Two Samsung air purifiers of the same model report the identical, well
formed serialNum `BS7SP9AW400114A` (issue #381). Since the entry's
unique_id, the device registry identifiers and every entity unique_id
were all minted from that string, the second unit was refused as already
configured, and would have collided entity-for-entity even if it hadn't
been.

This is the third firmware family to ship an unusable serialNum, after
`Nothing(SVC)` (#83) and the flash-unset sentinel (#189), and the first
one no heuristic can catch: the value is well formed, it's just shared.
`is_placeholder_serial` was a dead end.

So identity moves onto /oic/d's `di`, falling back to /oic/p's `pi`, then
the serial, then the host. `di` is what the protocol already uses to
address the endpoint -- if it were wrong or shared, OCF discovery and the
DTLS association wouldn't work at all -- and it's device-scoped, where
`pi` is platform-scoped and would be shared by a board hosting several
logical devices. Both units in #381 report a distinct `di`. A board that
answers neither resource lands exactly where it did before, so no
existing hardware regresses.

The re-key can't happen in async_migrate_entry: the UUID is only readable
from the device, and an entry can load entirely from its snapshot while
the appliance is off (#295). So v3 -> v4 only records the legacy key, and
the coordinator adopts the UUID on the first live poll, rewriting the
entity registry, the device registry (including subdevice identifiers)
and the entry's unique_id together. Rewriting rather than recreating is
what lets a user keep entity_ids, names, areas, statistics and every
automation that references them.

Three rules keep that adoption from misfiring:

- A poll that reads no UUID never demotes a UUID-keyed entry back onto
  its serial, so one failed reconnect doesn't re-key every entity.
- A changed UUID is followed only when the serial still corroborates it
  (a factory reset may regenerate `di`) or when the entry was keyed on
  its IP, which was never an identity to defend.
- When the identity is rejected as a different appliance, the serial
  isn't adopted either -- otherwise the intruder would gain exactly the
  corroboration needed to win the next poll.

Also stop redacting `di`/`pi` from diagnostics. They're randomly assigned
per-unit UUIDs, not account data, and blanking them is what made the
first #381 diagnostics download unable to answer the only question it was
requested to answer. The owner-set device name stays redacted.

Fixes #381
2026-08-17 05:24:22 +00:00
Marc Billow 3675d8087b Scope learning to the device, and simplify the store
The href alone was not a sufficient key. /mode/convenient/vs/0 is
declared by three family registries with three meanings: a real preset
resource on the AC, explicitly unmodeled on the dehumidifier (no live
current-value field), empty on the air purifier. Matching on the href
globally meant a dehumidifier reporting a mode there would learn it,
persist it, and show it in diagnostics for a resource nothing offers.
The coordinator now narrows LEARNABLE to the hrefs a climate entity is
actually bound to, at discovery -- which also retires the per-rep
subdevice walk, since those hrefs are already actual.

With that, LEARNABLE is a plain frozenset of hrefs and the per-href
LearnRule goes away: its two fields were the same module constants for
its only entry. observe() now returns the codes it learned rather than a
bool the caller re-reads the store to interpret, so the log names what
was new instead of everything ever learned.

learned.py also takes ownership of the entry key and persisted shape --
the options flow was the second module that knew both, and the shape has
already changed once.

Comment trims throughout, per CONTRIBUTING: the LEARNABLE entry no
longer recounts how many reporters there were, and three copies of the
same test-stub comment are gone.
2026-08-08 19:49:25 +00:00
Marc Billow 4f3bdde6e5 Address review findings on the learned-modes store
Flatten the store to {href: [codes]}. One href carries one LEARNABLE
rule, so keying the codes by the rule's supported field too let the
write side (rule.supported_field) and both read sides (the module-level
SUPPORTED_FIELD) disagree the moment a rule used a different field --
codes learned and persisted, then never offered.

The options flow's reset step read the persisted value raw in the
entry-not-loaded branch, so malformed data aborted the one screen that
can clear it; route it through LearnedModes like every other reader.
For the same reason forget_learned_modes() now persists whenever the
entry carries a record, not only when the in-memory store had one: a
record _coerce rejected at startup exists only on the entry.
2026-08-08 19:42:20 +00:00
Marc Billow d65735ac47 Remember modes a device reports but never advertises (issue #327)
Some firmware reports a current mode that is missing from the same
resource's supportedModes. An ARTIK051 air conditioner sits in Quiet
while advertising only [Off, Sleep, Speed, Nano, NanoSleep], so HA
showed preset_mode: quiet and then refused to select it. A second
reporter has three identical units where only the two sharing an
outdoor unit hide it, which rules out a real capability difference.

learned.py remembers any such code and the coordinator persists it on
the config entry, so a mode the device only names while it is active
survives a restart. climate._supported unions it into the resource's
own list, which fixes the read and the write together --
async_set_preset_mode reverse-resolves the device code from that same
list.

Learning is allowlisted per canonical href rather than global. Across
the fixture corpus 17 dumps already report a current mode that is not
in supportedModes: an oven idling in NoOperation, a fridge's
/mode/vs/0 carrying capability tokens like WATERFILTER_DISABLE. Those
are not selectable options, and remembering one permanently would put
an option in the UI that the device can only reject. Only
/mode/convenient/vs/0 is learnable today.

On by default, with a per-device option that stops offering and
learning at once, and a reset step in the options flow for a code that
turns out to be bogus. Diagnostics report what was learned separately
from `resources`, which stays exactly what the device said.
2026-08-08 19:10:53 +00:00
Marc Billow daf7e3787f Add ruff (lint + format) and ty (type checking) to the project
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.
2026-08-02 23:56:38 +00:00
Marc Billow 9e85395b44 rename: unit/sub-unit -> subdevice, matching OCF terminology
"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.
2026-07-29 22:57:11 +00:00
Marc Billow c181a8b447 feat(subdevices): support multi-indoor-unit systems (#177)
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.
2026-07-29 19:14:51 +00:00
Marc Billow 5a73a25005 Address independent code review findings on PR #167
Two real bugs, both latent (no shipped fixture exercised them), plus a
consistency gap and a couple of correctness/DRY nits flagged by review:

- async_set_fan_mode resolved a fan_mode label against the static
  _FAN_TO_DEVICE reverse map before checking whether the resulting code is
  actually one of the unit's own supportedModes. A board using non-standard
  wind-strength codes while still spelling a standard-looking label in
  modesName (e.g. codes "31"-"33" named "Low"/"High"/"Turbo") would silently
  write a code ("1"/"3"/"4") the device never advertised. Now validates the
  static hit against the unit's own supported codes before trusting it,
  falling through to the live modesName scan otherwise.

- air_purifier.WIND_STRENGTH_FAN reused key='fan', the same key as FAN in
  the same registry -- BoundEntity's unique_id is built from key alone, not
  href, so a board reporting both hrefs would have one fan entity silently
  shadow the other. Renamed to 'wind_strength_fan' (translation_key
  unchanged). No shipped fixture reports both hrefs today, but the two caps
  living in the same registry made this a real latent hazard, the exact one
  AIRFLOW_GENERIC's own comment already documents and deliberately avoids.

- microwave.py's cooking_mode select still used a static, union-of-all-
  dumps mode list, even though both shipped microwave fixtures already
  report x.com.samsung.da.supportedModes on /mode/vs/0 -- the same shape
  oven._oven_mode_options was just built to prefer over exactly this kind
  of static list (issue #138's follow-up, this same PR's skill update).
  ME7500D advertises 4 modes; the select was offering 11. Applied the same
  live-first, static-fallback pattern.

- Added the issue #152 fixture the microwave lamp fix was missing (the
  SKILL.md step this PR itself added asks for one).

- climate.py's _legacy_airflow rebuilt a 2-key presence dict from
  coordinator.resource()'s truthiness, which collapses "href absent" and
  "href present with an empty {} rep" to the same falsy value -- while
  is_legacy_board (and discover()'s own binding) test key membership, not
  truthiness. Simplified to pass last_resources through directly, matching
  is_legacy_board's actual contract instead of a cheaper approximation of
  it, so the "can never disagree" claim in both docstrings is actually true.

- Hoisted the 'power' payload branch duplicated verbatim across
  _airflow_fan_write/_fan_write/_wind_strength_fan_write into one
  _power_write helper (registry/capabilities/air_purifier.py).

- Removed two now-unused imports (test_air_dresser_capabilities.py,
  test_air_purifier_vtww_fan.py) and replaced a tautological
  code-in-_DEVICE_TO_FAN check with one that actually exercises the live
  climate entity's fan_modes/fan_mode (test_climate_ac_modes.py).

- Fixed a pre-existing (not from this PR) no-op test on main --
  test_registry_reproduces_golden_state_keys_for_induction_cooktop computed
  golden/state_keys and never asserted on them.

756 tests pass.
2026-07-28 14:37:32 +00:00
Marc Billow daa7546208 Add device support for Wind-Free 2-in-1 AC TP2X_FAC_BORA_21K (#150, #153)
This board (a floor-standing + wall-mounted indoor unit pair sharing one
outdoor unit and one local IP) reports no oneUiVersion and carries the
'_FAC_' modelNum token, which no existing routing rule matched -- it fell
back to 'unknown' and exposed nothing but a power switch, with no climate
entity generated at all (both issues' reported symptom).

Once routed to the existing airconditioner registry, it binds cleanly
against the exact same CLIMATE composite every other room-AC family uses --
same Cool/Dry/Wind/AIComfort mode vocabulary, same wind-strength/humidity/
filter/energy resource shapes already modeled. Only two hrefs are unique to
this board: /subdevices/vs/0 (an opaque paired-subdevice id list -- issue
#150 asked whether the second indoor unit can be controlled separately;
it can't through this or any other resource in the dump, the same
"remote device ids, not locally actionable" role as the existing
/remotedeviceinfo/vs/0 ignore) and /runn/vs/0 (a single undocumented int
with no supported-values list to interpret). Both added to _AC_IGNORED
rather than guessed at.
2026-07-28 14:09:21 +00:00
Marc Billow 5547c401ed Address independent review findings on the RAC finish-up branch
- Pin the FAN_ONLY reverse-write fallback to 'Wind' (the original single
  spelling) instead of letting it silently flip to 'Fan' just because
  'Fan' was added second to the dict -- _device_code_for_hvac() resolves
  the code from a unit's own supportedModes first, so this dict is only
  a fallback for a unit reporting none at all, and that fallback
  shouldn't change behavior as an unintended side effect of insertion
  order.
- Add direct tests for _preset_to_ha() (pure function, previously
  untested) and the FAN_ONLY fallback pin.
- Cross-reference the AC family's inverted Light_On/Light_Off polarity
  against air_purifier.py's plain-polarity use of the same token name on
  the same resource name, so a future refactor doesn't assume they're
  the same thing.
- Fix a one-column continuation-line misalignment and a stale PR-number
  reference in a comment.
2026-07-27 05:11:58 +00:00
Marc Billow fa8f1b8675 Add cool-only global RAC support: WindFree, Auto mode, display light
Finishes PR #91's contribution (pedroperosin) with the requested review
changes applied, on our own branch:

- Preset resolution is now fully dynamic, read from each unit's own
  /mode/convenient/vs/0 supportedModes instead of a static per-model
  table -- any board's convenient modes (including WindFree's Nano/
  NanoSleep) surface without code changes. Unlabelled codes across
  existing fixtures (longwind, motionindirect, motiondirect, drycomfort)
  plus the two new WindFree ones are added to en.json/nl.json.
- 'Auto' now maps to HVACMode.AUTO instead of HEAT_COOL: these are
  single-setpoint "device decides" units, not two-setpoint heat+cool
  ones. 'Fan' is added alongside 'Wind' as a second FAN_ONLY spelling;
  a new _device_code_for_hvac() picks the code from the unit's own
  supportedModes since the flat map can't disambiguate two device codes
  mapping to one HA value.
- Detection gains a hyphenated '-RAC-' modelNum fallback (alongside the
  existing '_RAC_') for cool-only global RAC variants whose
  /otninformation/vs/0 ships no swVersionInfo block.
- Adds a display_light switch sourced from /mode/vs/0's opaque options
  blob (inverted Light_On/Light_Off token) for boards with no dedicated
  /light/vs/0 switch.

Write-path behavioural change (touches the path iterated on across
#9/#17/#27/#38/#54): power now targets the vendor /power/vs/0 instead of
the OCF /power/0, and _is_on() reads vendor-first. /power/0 is absent on
several known AC boards, so the previous OCF-first read/write pair could
report and act on stale state -- consistent with #53's "can turn on but
not off". Target temperature now picks its channel (OCF pair vs. vendor
items[]) based on which the unit actually reports, reading and writing
the same one.

The vendor temperature write and the new display-light write both carry
only the changed field(s), not the whole resource -- confirmed sufficient
on the wire, the device merges the rest itself. That requires the
coordinator's optimistic cache to do the same merge on the read side so a
setpoint change doesn't blank out current/min/max/unit for the settle
window: common.py gains merge_items_field() next to the existing
merge_options_field(), and async_send_command wires it in for any write
touching x.com.samsung.da.items.

New fixture/golden for the cool-only global RAC variant (TP1X_DA-AC-RAC-
01001, AI_RAC_GLOBAL_COOLONLY_3.0); display_light added to the goldens
for boards that gain the new options-based switch. Regenerated against
current main rather than copied from the original PR, since those
goldens had already shifted (current_temperature_c/humidity from issue
#75).
2026-07-27 05:00:06 +00:00
Marc Billow 45931332d4 Rework AIComfort as an HVACMode.AUTO + preset overlay, add unmapped-mode warning (issue #93)
AIComfort isn't a distinct thermodynamic operation like Cool/Dry/Heat --
it's an AI-driven overlay on top of the device's own 'Auto' behavior,
confirmed by A-CAWW-TP2-20-COMMON reporting both 'Auto' and 'AIComfort'
as separate, mutually-exclusive entries in /mode/vs/0's supportedModes.
Modeled the idiomatic HA way instead of a flat _DEVICE_TO_HVAC entry:
hvac_mode reports AUTO and a new 'ai_comfort' preset carries the
distinction. Entered/left only via the preset (writes the primary mode
resource, not the convenient one) -- there's no dedicated HVACMode
value for it, so it's not offered in the hvac_mode dropdown directly.

Also adds a once-per-(href, code) warning log when a device-reported
mode has no entry in the relevant map, so a future gap like this one
surfaces in the log instead of silently vanishing -- the exact failure
mode issue #93 called out ("this class of gap is invisible without
diffing against supportedModes").
2026-07-27 02:01:51 +00:00
Marc Billow 8c3250b163 Map AC's AIComfort mode to HVACMode.AUTO (issue #93)
A-CAWW-TP2-20-COMMON (and likely other CAWW/TP2X-class boards) reports
'AIComfort' as a distinct entry in /mode/vs/0's supportedModes,
alongside 'Auto' (already mapped to HEAT_COOL). _read_modes() silently
drops any code missing from _DEVICE_TO_HVAC, so AIComfort was
unreachable -- one of HA's five other AC hvac_modes, unused by this
device family until now.

The other two gaps in issue #93 are already addressed elsewhere and
not duplicated here:
- Fan-only via the 'Fan' device code, and the WindFree/LongWind/
  NanoSleep preset codes, are covered by the still-open PR #91, which
  replaces the static preset table with a fully dynamic resolver over
  the device's own supportedModes.
- The 'Left_And_Right' -> horizontal swing mapping is already on the
  still-open claude/device-support-issue-75-windfree-ac branch.
2026-07-27 01:43:22 +00:00