81dbc6fa03bfaae80eef8b72d674bb50186b63de
13
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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 |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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). |
||
|
|
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"). |
||
|
|
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. |