Commit Graph
391 Commits
Author SHA1 Message Date
Marc Billow cafd7d5afa refactor(registry): drop oneUiVersion from device-type detection
oneUiVersion looks like the signal you'd want -- the device naming its own
type, '7.0 Dishwasher' -- and it was the first thing detection consulted. It
never earned the position:

- Only 7 of 49 fixtures report it at all.
- All 7 resolve to the same registry from their modelNum board token alone.
- No device-support issue has ever been fixed by adding a mapping for it.
  Every one went through modelNum. The alias keys it needed in
  _REGISTRY_BY_KEY ('airpurifier', 'air_conditioner', 'hood') were
  speculative when the registries were first written and never used since.

So it bought a key-normalizing helper (_type_key), a lookup with a suffix
fallback (for_device), three alias keys, and a second config-flow step whose
only reason to exist was phrasing a sentence about oneUiVersion -- for a
signal that has never once been decisive.

Remove it from detection. It stays in diagnostics, where it's genuinely
useful: it names the firmware generation ('7.0 Air conditioner' is Tizen
Lite), which matters when triaging an issue.

Detection order was also duplicated in four places -- the coordinator, the
config flow's probe, the golden-regression harness, and the skill -- which
is how the harness and the shipped order drift apart. Collapse it into
by_type.resolve(resources), and call that everywhere.

The two "appliance type not recognized" config steps become one. They
differed only in whether they blamed a missing oneUiVersion, which is not a
distinction a user can act on, and never was.

Verified by the full suite (795 passing), including every golden regression
-- so entity output is byte-identical for all 49 device fixtures.

TestOneUiVersionIsNotConsulted locks in the premise rather than just the
outcome: for every dump that reports a oneUiVersion, the model strings alone
must still reach a registry. If a future device breaks that, the test says
so instead of the device silently losing half its entities.

Also note in requirements-dev.txt that Python 3.13 resolves the pinned
harness floor -- 3.12 and older resolve nothing and fail the whole install.
2026-07-29 02:14:13 +00:00
Marc Billow a863df1e59 refactor(registry): match board families by token table, not substring ladder
for_device_by_model() had grown to 21 sequential `if key is None` branches
and 102 comment lines against 59 lines of code -- 33 of the repo's 243
commits have touched this file. Most of that bulk came from one wrong
primitive: substring matching on a delimited string.

Samsung spells the same board family with either delimiter, so '_RAC_' and
'-RAC-' each needed their own rule, and 'ARTIK051_DONGLE_REF' (issues #77,
#83) matched no '_TOKEN_' spelling at all because REF lands at the end of
the pipe-prefix with no trailing underscore -- which is what
_model_num_segments() existed to work around. Which field a rule searched
(modelNum, or modelNum + description) was historical accident. Collisions
like WAC vs WA were resolved by one `if` physically preceding another,
invisible in the code and explained at length in prose.

Tokenize on any non-alphanumeric run, upper-case, and look the tokens up in
a flat table. Every delimiter spelling collapses to one entry, both fields
go through the same matcher in a documented order (modelNum, then
description, then the fuzzy consumer prefix), and specificity is a property
of the table rather than of line ordering.

Two behaviours are preserved deliberately:

- modelNum is matched before description, which is what keeps the legacy
  gas cooktop correct: it reports 'ARTIK051_GB_CT_001' (CT) alongside
  'ARTIK051_GLOBAL_COOKTOP' (COOKTOP, which otherwise means induction).
  It is the only known device whose two fields disagree.
- _consumer_model_key still splits on '_' only. Widening it to '-' would
  read the dishwasher's 'ADW-WW-RTL-24-AILITE' board segment as a bare 'WW'
  washer.

Verified identical: all 49 device fixtures resolve to the same registry
before and after, and every existing for_device_by_model test case passes
unchanged. The table also picks up two families that previously depended on
oneUiVersion alone (TP1X_DA-AC-AIR air purifiers, ADW dishwashers), so they
now survive firmware that omits it.

TestBoardTokenAmbiguity guards the one property the flat lookup needs --
that no real model string contains two tokens naming different device types
-- across the whole fixture corpus, so a newly added dump exercises it
automatically.

The skill gains a section on routing: what each detection stage is for, the
rules for adding a token (name the specific type, never the board family;
never add a delimiter spelling; two-letter tokens are a last resort), when
to reach for the consumer prefix or a resource signature instead, and the
measured stake -- an unrouted device loses roughly half its entities.
2026-07-29 01:57:10 +00:00
Marc Billow 288ba02cc5 feat(diagnostics): capture /oic/p and /oic/d identity
Device-type detection currently parses board part numbers out of
/information/vs/0's modelNum. OCF has a standard field for exactly this
question -- /oic/d's `rt` -- and read_identity() already fetches the
resource, but kept only `n` and threw the rest away. No captured dump has
ever included it either: /device/0 batch responses don't carry /oic/d, and
diagnostics didn't report it, so there's no evidence on whether real
hardware populates it usefully.

Keep `rt` as DeviceIdentity.device_types, keep both raw payloads whole
(we don't yet know which of their fields identify a type), and surface
them in diagnostics so incoming issue reports answer the question.

Nothing routes on it yet.

/oic/d and /oic/p identify the unit with bare two-letter keys -- 'di' and
'pi' -- as sensitive as the serial number redact.py already covers but far
too short to match on: 'di' alone is a substring of 'condition', 'display'
and 'dispenser'. Add a whole-key match alongside the substring rules.
2026-07-29 01:56:52 +00:00
Marc Billow 668aec401a Merge pull request #174 from firstof9/fix/microwave-issue-172 2026-07-28 17:57:45 -05:00
firstof9@gmail.com 1f331f3aa6 fix(registry): route microwaves without /information/vs/0 to microwave registry (#172)
Issue #172: Samsung Microwave units (ME8000T-/AA0) omit /information/vs/0 and have empty oneUiVersion, falling back to unknown device type. Route via /oven/vs/0 and MicroWave modes in supportedModes.
2026-07-28 13:29:57 -07:00
Marc Billow 498da49817 Merge pull request #171 from mbillow/claude/issue-triage-gating-gf6bg4
Fix unsound gating from #170, cover remaining fan-speed icons
v0.15.0
2026-07-28 14:10:22 -05:00
Marc Billow bbf3f3f833 Revert unsound air-quality gating from #170, cover remaining fan-speed icons
An Opus review of merged PR #170 found the cleanLevel-scalar existence
gate on AIR_QUALITY doesn't hold up as a general rule: three fixtures
in this repo (air_purifier_device.json, air_purifier_vtww_device.json,
range_hood_device.json) carry genuinely populated Dust/FineDust/
SuperFineDust readings with no such scalar, so requiring it risks
silently dropping real air-quality readings on AC hardware this repo
hasn't seen yet. Reverted _has_sensor_type to item-type presence only
(as before #170) and moved the #166 fix to enabled_default=False on
all five entities instead -- same conservative, non-existence-gated
treatment already used for tropical_night_mode and the fridge/cooktop
precedents it was modeled on. Golden fixtures and tests updated to
match; the five sensors are bound-but-disabled on windfree/#17-style
boards again rather than unbound.

Also added icons for the AC fan_mode values #170 missed -- the raw
numeric labels ("1".."5") that TP1X_DA-AC-RAC-01001 and the window-AC
board report instead of turbo/max -- and swapped the whole fan-speed
icon family to mdi:fan-speed-1/2/3 for a more purpose-built look than
the generic speedometer, applied consistently to both the AC climate
card and the air purifier fan. Fixed motiondirect/motionindirect to
match core's smartthings integration's arrow pairing (previously
inverted).

Known limitation, not fixed here: enabled_default only affects newly
registered entities. Anyone who already has tropical_night_mode or the
five air-quality sensors enabled from #164 (a narrow window before
this fix, but real) won't see them auto-disable -- they'd need to
disable them by hand in Settings > Devices > Entities. A real fix
needs a one-time entity-registry migration, which this integration has
no existing infrastructure or test coverage for; scoping that felt
like its own follow-up rather than something to bolt on here.
2026-07-28 19:07:59 +00:00
Marc Billow 91b129282b Bump version to 0.15.0 2026-07-28 18:56:07 +00:00
Marc Billow 34fe991858 Merge pull request #170 from mbillow/claude/issue-triage-gating-gf6bg4
Fix AC entity gating from #164, add missing preset/mode icons (#166, #169)
2026-07-28 13:50:53 -05:00
Marc Billow 6d185e4e3d Match WindFree icon to the official smartthings integration's choice
HA core's bundled smartthings integration (the cloud counterpart to
this same Samsung AC feature set) uses mdi:weather-dust for its
wind_free preset rather than a generic windy icon -- a better fit for
a feature about avoiding direct airflow, not blowing harder. Match it
for both the AC climate preset and the air purifier fan preset.
2026-07-28 18:47:10 +00:00
Marc Billow 5f47fc0477 Add per-state icons for the remaining entities that render with none
HA only consults icon-translation state icons when the entity has no
static icon of its own (Entity.icon, if set, always wins -- see
homeassistant.helpers.entity's state_attributes construction). Audited
every entity with a labelled state/state_attributes catalog in
translations/en.json against its descriptor's icon= setting: every
select (cycles, courses, brightness levels, ...) and most sensors
already carry a fixed icon in code, so per-state icons there would be
silently shadowed. The three that don't -- air_purifier_fan's
preset_mode, machine_state, and connection_mode -- get one per value
here.
2026-07-28 18:31:31 +00:00
Marc Billow cfa82e8853 Add icons for AC preset/fan modes not covered by HA's built-ins (#169)
HA's core climate component already ships default icons for common
preset_mode/fan_mode values (eco, away, sleep, auto, low/medium/high,
...), but this integration's own values -- WindFree (nano/nanosleep),
Quiet, Smart, Speed, Long wind, the motion-aware direct/indirect
presets, Dry comfort, 2-Step, and the turbo/max fan speeds some boards
report -- fall outside that vocabulary and rendered with the generic
circle-dot fallback (the icon the #169 screenshot is missing). Adds
icons.json with an icon per value, mirroring the state-label catalog
these same values already have in translations/en.json.
2026-07-28 18:20:10 +00:00
Marc Billow 739881de16 Gate AC tropical night mode and air-quality sensors on real capability signals (#166)
Issue #166 (ARxxTXFCAWKNEU, board ARTIK051_PRAC_20K) reported tropical
night mode, clean level, dust, fine dust, odor, and super fine dust
entities showing up even though the reporter's units have no such
physical features. All six were added in #164.

The Sleep_<N> options token backing tropical_night_mode is present in
every AC dump on record regardless of confirmed reality, so there's no
usable signal at boot time -- it's now registered but disabled by
default (matching the precedent already set by fridge.rack_count /
cooktop.paired_hood_model), letting units that do have it opt in.

/sensors/vs/0's item-type list has the same problem (all five types
always listed, permanently zero on this board), but there turned out
to be a real tell: a top-level x.com.samsung.da.cleanLevel scalar is
present only alongside genuinely populated readings on every dump on
record (tp1x_da_ac_rac_01011, the tp1x_da_ac_air air purifier fixture)
and absent on every all-zero ARTIK051_PRAC_20K dump, including both
#166 units and the original windfree/#17 fixtures this capability was
first verified against -- which, per their /information/vs/0, turn out
to be the same board revision as #166's units, so that "verification"
never actually proved a real sensor either. AIR_QUALITY's exists_fn now
requires that scalar, and the windfree/airconditioner golden fixtures
are updated to match (those five entities no longer bind there).
2026-07-28 18:12:02 +00:00
Marc Billow 6080d37f7d Merge pull request #167 from mbillow/claude/issue-triage-138-latest-y90g78
Issue triage batch: oven/AC/microwave/air-dresser fixes, 4 new device types
2026-07-28 10:21:28 -05:00
Marc Billow 71e026b804 Fix hot/warm href tiers dropped for no-entity coverage capabilities
discover() only emitted a BoundEntity per capability *entity*, so a
coverage-only Capability (entities=(), used to mark a href as handled
elsewhere -- e.g. the AC climate card's wind/strength, wind/direction,
temperature/control hrefs) produced zero rows. The coordinator computed
its hot/warm href lists by walking `bound`, so every such href's
poll_tier was silently discarded and it fell back to the ~30s summary
poll only -- no sub-poll cadence and never attempted for OCF OBSERVE.

This is the root cause of issue #166's "up to a minute" lag for
remote-driven fan-speed changes: /wind/strength/vs/0 carries poll_tier
'warm' via airconditioner.COVERAGE but never reached
_hot_hrefs/_warm_hrefs, so it wasn't in the OBSERVE-attempt href list
and only refreshed on the summary poll.

discover() now takes an optional tier_log(href, poll_tier) callback
fired for every href a capability matches, entities or not. The
coordinator uses it directly instead of deriving tiers from `bound`.
2026-07-28 15:08:12 +00:00
Marc Billow 65b0326d62 Merge remote-tracking branch 'origin/main' into claude/issue-triage-138-latest-y90g78
# Conflicts:
#	tests/test_airconditioner_capabilities.py
2026-07-28 14:51:29 +00:00
Marc Billow 6007505e6b Stop surfacing '_OFF'-suffixed placeholder alarm codes (#166)
Samsung pre-populates /alarms/vs/0 with one row per supported alarm type,
each carrying a '<Name>_OFF' placeholder code (no 'Deleted' state at all)
when that alarm isn't firing. common._active_alarm_codes only ever
filtered on 'Deleted' state, so every device using this shared capability
(including the AC family) showed these inert placeholders --
'ErrorCode_OFF', 'FilterAlarm_OFF' -- as if they were live alarms.

Confirmed the '_OFF' suffix convention holds across every alarm code seen
in this repo's fixtures so far (ErrorCode_OFF, FilterAlarm_OFF, OV_E_OFF,
CT_E_OFF, WaterTankFull_OFF, AC_V_0002_OFF, all placeholders; DoorA_Opened,
FilterAlarm, SNSF_Reached, all genuinely active with no suffix) -- issue
#166's own dump has both a FilterAlarm_OFF placeholder and, on a second
unit, a live FilterAlarm/state=Created alert, which is what motivated
generalizing range_hood.py's existing (but narrower, ErrorCode_OFF-only)
special case into the shared helper instead of duplicating it further.

The other three points in #166 (filter-usage percentage vs. filterStatus
disagreement, an "air purification" config toggle the reporter says has no
physical effect, a "beep on/off" control) don't have a confirmed code fix:
the percentage math already matches the device's own filterUsage/
filterCapacity fields (filterStatus is a separate device-computed field we
already relay verbatim, not something we derive), the air-purify resource
is correctly wired to what the board reports and its absence from the
official app's own options list suggests an inert shared-board-profile stub
rather than an integration bug, and a "Beep volume" NumberDesc keyed off
the same Volume_100 option both dumps report already exists (0 mutes it).
2026-07-28 14:43:21 +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 dc3e1255d1 Ignore fridge /rm/control/vs/0 plumbing href (#165)
TP1X_REF_21K's EU region variant reports a bare resource-monitoring
poll-interval config (minPeriod in ms) the US variant doesn't -- the only
unbound href keeping the coverage-gap repair open. Door sensors, the
reporter's actual ask, were already covered generically by
fridge.DOOR_GENERIC/DOORS_FALLACK.
2026-07-28 14:27:15 +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 e568145deb Fix microwave lamp switch reading/writing the wrong tokens (#152)
The lamp SwitchDesc was modeled on issue #137's dump, which only ever
showed 'Lamp_Off' -- 'On' was never actually confirmed as the paired
value. Issue #152's ME7500D dump (same TP1X_DA-KS-MICROWAVE-01051 family)
is the first to report a real non-Off value, and it's 'Lamp_High' (a
brightness level), not 'Lamp_On'. So the switch always read as off
regardless of the device's real state, and toggling it on wrote a token
('On') the device has never been observed to accept -- matching the
reported "light control does not have any effect."

value_fn now treats any non-Off/non-absent value as on; write_fn now
writes back 'High'/'Off', the two tokens actually confirmed live, instead
of the never-confirmed 'On'.

The reporter's suspicion about the fan is unconfirmed and this dump's own
/hood/fanspeed/vs/0 shape already matches the no-separate-power case
fan.py's LocalThingsRangeHoodFan handles correctly (issues #137/#142), so
no fan change was needed here. A second dump attached in a comment on this
issue (model ME8000T, a large combi wall-oven with a very different mode
vocabulary) reports its own distinct gap and doesn't resolve to any known
device type at all -- that's a separate, substantial device-support task
left for its own follow-up rather than folded into this fix.
2026-07-28 14:03:56 +00:00
Marc Billow fcd34f73e5 Add device support for BESPOKE Cube Air A-VTWW-TP2-21-COMMON (#151)
This board reports no oneUiVersion and no modelNum token any existing
family routed on, so it fell back to unknown -- exposing nothing but power
even though most of its resources (air quality sensors, HEPA filter,
device-active, diagnosis, plumbing hrefs) are already handled generically
by the existing air_purifier registry via the '-VTWW-' modelNum fallback.

The one genuinely new piece is its fan: this board reports wind strength as
numeric codes ("87"/"89"/"90"/"91") on /wind/strength/vs/0 with a separate
modesName array ("SMART"/"MAX"/"WINDFREE"/"Sleep") giving the actual names,
unlike the existing TP1X_DA-AC-AIR family where supportedModes IS the name
list already. Generalized LocalThingsAirPurifierFan to resolve a mode code
through modesName when present (same live-label pattern as climate.py's AC
wind-strength fix, issue #155) instead of adding a second hardcoded fan
class, and added WIND_STRENGTH_FAN reusing the same 'air_purifier_fan'
translation catalog -- both board generations land on the identical
smart/max/windfree/sleep vocabulary already labelled there.

/mode/convenient/vs/0 is empty on this dump and added to COVERAGE alongside
the existing plumbing hrefs this board also shares with the TP1X_DA-AC-AIR
family.
2026-07-28 13:58:08 +00:00
Marc Billow a3d9cce164 Extend AirDresser support to DA_DF_TP2_20_COMMON (#157)
A different board generation from issue #162's DA_DF_A51_20_COMMON, also
carrying the '_DF_' modelNum token and so already routed into the
air_dresser registry -- but reporting two resources #162's board doesn't:

  /st/airdressercourse/vs/0 -- the course table id (Table_00), read the
    same way washer/dryer read /st/washercourse|dryercourse/vs/0. Wired up
    as AIR_DRESSER_COURSE's table_href and added to the global ignore list,
    mirroring that existing pair exactly.
  /airdresseroption/sanitize/vs/0 -- a genuine on/off setting (not covered
    by any existing capability), added as AIR_DRESSER_SANITIZE.

Introducing table_href means the course select's translation_key is now
always the table-lookup callable, so the bare 'air_dresser_cycle' catalog
entry added for #162 (only ever used when no table_href was passed) is
unreachable in every case and is removed -- both boards fall back to the
shared 'cycle' entry until their course tables get named, same as
washer/dryer's own precedent for an unrecognized table.
2026-07-28 13:50:59 +00:00
Marc Billow defeffd58d Merge pull request #164 from mbillow/pr-129-ac-additive-entities
Add beep, tropical night mode, filter hours/threshold, and air-quality entities for AC
2026-07-28 08:46:06 -05:00
Marc Billow 053e15bba6 Add device support for Samsung AirDresser DA_DF_A51_20_COMMON (#162)
This board reports no oneUiVersion and no modelNum token any existing
family routed on, so it fell back to the global unknown-device CAPABILITIES
set -- exposing only power/child-lock/start-stop-pause/delay/energy/machine
state, with /course/vs/0, /diagnosis/vs/0, and /washer/vs/0 all unbound and
no course/mode select at all (the actual reported gap).

Every one of those resources turns out to already be handled by the shared
laundry machinery: /diagnosis/vs/0 reuses dishwasher.DIAGNOSIS, and
/course/vs/0's cycle select works unmodified through laundry.cycle_options'
existing supportedOptions fallback (this board has no /wm/editcourse/vs/0
at all, so editCourseList never populates). /washer/vs/0 gets a new minimal
AIR_DRESSER_SETTINGS capability (wrinkle_prevent only) rather than reusing
dryer.DRYER_SETTINGS wholesale, since this device never reports
dryLevel/dryTime/dryerType at all and binding them would ship three
permanently-unavailable sensors.

Course codes aren't identified yet (no code->name mapping was reported), so
they render as their raw codes until named in translations, same as
dryer.py's precedent for unidentified codes.
2026-07-28 13:45:49 +00:00
Marc Billow aa9cdd83b7 Read AC fan speeds from the device's own modesName, not just 0-4 (#155)
TP1X_DA-AC-RAC-01001_0000 (model AR07C9150HZN) reports /wind/strength/vs/0
supportedModes as "0"/"31"-"35" instead of the "0"-"4" scale climate.py's
_DEVICE_TO_FAN was built from. Only "0" matched, so fan_mode/fan_modes
silently dropped every speed but Auto -- exactly the reported symptom.

Rather than hardcoding a second numeric scale, codes _DEVICE_TO_FAN doesn't
cover now fall back to the device's own modesName label (parallel-indexed
with supportedModes), mirroring how preset_mode already resolves dynamically
off a device's own supportedModes instead of a per-model table. Boards using
the standard 0-4 scale are unaffected -- _DEVICE_TO_FAN is still tried
first, so existing auto/low/medium/high/turbo labels don't change.
2026-07-28 13:38:42 +00:00
Marc Billow 57e06c5ccb De-duplicate the legacy ARTIK051 AC board-generation test (#161)
capabilities/airconditioner.py's is_legacy_board() (renamed from the
private _is_legacy_board -- it's now a cross-module helper) and
climate.py's _legacy_airflow() implemented the same "does this board have
/airflow/vs/0 but no /wind/strength/vs/0" test independently, one via
literal href strings and the other via coordinator.resource() truthiness.
is_legacy_board() now uses the module's own HREF_AIRFLOW/HREF_WIND_STRENGTH
constants, and _legacy_airflow() delegates to it via a minimal two-key
presence dict (cheaper than a full last_resources snapshot copy) instead of
re-implementing the check, so the token entities and the climate card's
legacy read/write paths can't drift apart on which board generation is in
play.
2026-07-28 13:31:37 +00:00
Marc Billow 5c962801b4 Fix _tropical_night_value, which had the same stale _option_token assumption
Same issue as the beep fix in the previous commit: this also assumed
_option_token returned the full 'Sleep_<N>' token and tried to split
off the prefix itself. With the canonical value-half _option_token,
that always returned None. Read the value directly instead.
2026-07-28 13:29:49 +00:00
Marc Billow c8597532b0 Stop collapsing a genuine fivepercentHumidity=0 reading to unknown (#160)
#146's zero-as-"not measuring" carve-out was meant for ARTIK051 boards'
plain x.com.samsung.da.humidity field, which only populates while Air
monitoring is briefly on and zeroes out afterward. It was accidentally
applied to fivepercentHumidity too, which every other AC board relies on
and which has never been documented getting stuck at zero -- so a real 0%
reading on those boards silently became "unknown". Only the humidity
fallback field now collapses 0; fivepercentHumidity passes 0 through as a
real reading.
2026-07-28 13:28:46 +00:00
Marc Billow 00c1d78373 Merge main into ac-additive-entities, resolve conflicts with #146
Both PRs independently modeled the same /mode/vs/0 Volume_*/Sleep_*
option tokens: #129 as beep/tropical_night_mode, #146 (already merged)
as buzzer_volume/good_sleep gated to the legacy ARTIK051 board
generation. They also each defined a helper named _option_token with
different return semantics (full token vs. value half) in
non-overlapping parts of the file, so git didn't flag it as a
conflict even though the second definition silently shadowed the
first.

Keep a single _option_token (value-half, the one already used by
buzzer_volume/good_sleep/spi/etc.), adjust beep's read/write to that
convention, and gate beep/tropical_night_mode off the legacy board so
they don't duplicate buzzer_volume/good_sleep on ARTIK051_KRAC-class
devices. Added a regression test locking in the gate.
2026-07-28 13:28:20 +00:00
Marc Billow 1cdad7f84c Read oven cook modes live from the device instead of a hardcoded list
Follow-up to the issue #138 fix: rather than hand-adding
ConvectionRoast/KeepWarm/BreadProof/AirFryer/Dehydrate/SelfClean/SteamClean
to oven._OVEN_MODES, read them from the device's own /mode/vs/0
supportedModes when it reports one, falling back to the static
NV7000BS-era guess only when it doesn't. Matches the adding-device-support
skill's preference for device-reported option lists over hardcoded ones,
and the SelectDesc's write validation now checks the same live list it
displays instead of a separate static tuple.
2026-07-28 13:26:41 +00:00
Marc Billow 1fb27c30ae Skill: call out preferring dynamic select options over hardcoded lists
Prompted by issue #138's fix, which extended oven._OVEN_MODES with newly
confirmed modes instead of reading them from the device's own
supportedModes field via options_field -- the pattern laundry.py already
uses for buzzer/finish sound. Document that preference so future
device-support work reaches for options_field/a callable first and treats
a static tuple as a last resort, not the default.
2026-07-28 13:18:46 +00:00
Marc Billow 123455a873 Add missing oven cook modes for NE63A6511SS/AA range (issue #138)
The range/oven-combo device in issue #138 (NE63A6511SS/AA, no
/information/vs/0) already resolves cleanly to the range registry via the
issue #74 for_device_by_resources fallback, with zero unbound hrefs -- the
reporter was just on an older release (0.11.1) predating that fix.

However its /mode/vs/0 supportedModes advertises ConvectionRoast, KeepWarm,
BreadProof, AirFryer, Dehydrate, SelfClean, and SteamClean, none of which
were in oven._OVEN_MODES. Since range.py reuses oven.OVEN_MODE's SelectDesc
wholesale, those modes were silently rejected by the mode select's write
validation and missing from its options. Extend the confirmed mode list and
lock in a scrubbed fixture, golden, and test for this dump.
2026-07-28 13:12:40 +00:00
Marc Billow f5be651430 Merge pull request #146 from perseus177/artik051-krac-legacy-ac
Support ARTIK051_KRAC_18K air conditioners (issue #136)
2026-07-28 08:00:48 -05:00
blka 1bbecfa5c3 Drop /information/vs/0 version entities per review
mbillow's follow-up review: exposing read-only Software/Firmware (and
Outdoor/Touch IC) version strings is "data for the sake of exposing it" --
no user control, just clutter, and every other registry leaves
/information/vs/0 in the global ignore as identity plumbing. Conforming to
that stance rather than expanding the entity surface for no user story.

- by_type/airconditioner.py: revert to *ignored.IGNORED (no _IGNORED_LESS_INFO
  filter); drop airconditioner.INFO from the registry.
- capabilities/airconditioner.py: remove the INFO capability, the _info_version
  /_info_items_of_type /_has_info_version helpers, and HREF_INFORMATION.
- translations/{en,nl}.json: drop software_version, firmware_version,
  firmware_version_2/3, outdoor_unit_version, touch_ic_version.
- tests: drop the six INFO tests; golden regenerated for 8 AC + dehumidifier.
625 tests pass.
2026-07-28 10:07:39 +02:00
Marc Billow 6b28272647 Enhance README with GitHub badges
Added badges for GitHub stars, watchers, releases, and validations.
2026-07-27 23:26:01 -05:00
Marc Billow cc570533a0 Merge pull request #149 from mbillow/claude/ticket-127-device-coverage-xsb0m4
Distinguish not-yet-fetched stub reps from confirmed-empty ones (issue #127)
v0.14.0
2026-07-27 22:25:38 -05:00
Marc Billow 6239706065 Keep entity._is_included's default gate permissive on confirmed-empty reps
Opus review of #149 caught that swapping is_stub_rep(rep) in for the
default field-presence gate (not just the 9 hand-audited exists_fn call
sites) was too broad: it silently excludes entities on ANY resource whose
normal, valid state includes reporting {} -- /alarms/vs/0's {} is
fridge.py's documented no-alarm state, not an absence signal, and it's not
the only one (job_beginning_status, diagnosis_status, sabbath_mode,
defrost_delay, ice_maker_enabled all lost entities on real fixtures under
the broader change). That's the opposite of #127's fix: a real fridge
would have dropped its alarm sensor on first-poll timing, not just its
phantom energy sensors.

Restored the default gate to include on either a stub or a genuinely-empty
rep -- verified byte-identical to the pre-#149 baseline across all 40
fixtures, apart from the 9 deliberately-audited exists_fn sites (energy
meter, self-check error, cooktop burner, range-hood auto-op), which are
unaffected and still fix #127. Added tests/test_entity.py exercising
_is_included directly (previously untested) and fixed two now-stale
"not rep" doc references the review also flagged.
2026-07-28 03:18:23 +00:00
Marc Billow 33786ad584 Distinguish not-yet-fetched stub reps from confirmed-empty ones (issue #127)
parse_device0_batch used to collapse /device/0's {"href": "..."} "no data
yet" marker into a plain {}, indistinguishable from a resource the device
had actually polled and confirmed empty. Every exists_fn using the "not
rep or ..." stub carve-out (and entity._is_included's default field-gate)
then treated both the same way, creating phantom always-"unknown" entities
for any resource a model simply doesn't support (e.g. GSzabados's fridge's
/energy/consumption/vs/0).

is_stub_rep() now recognizes only the literal {"href": ...} marker as a
stub; a genuine {} is treated as the device's real (if empty) answer and
gates the entity off like any other missing field. Updated the energy
meter, self-check error, cooktop burner, and range-hood auto-operation
exists_fn call sites, plus three golden fixtures that had baked the
phantom-entity behavior in as "expected".
2026-07-28 02:57:30 +00:00
Marc Billow e60fdf26d2 Merge pull request #148 from mbillow/claude/triage-issues-144-147-yv80ab
Fix water-purifier hot-water lock mislabel, add range-hood after-run support
2026-07-27 21:31:31 -05:00
Marc Billow 9011f84787 Model after_run_progress as a percentage, fix Dutch after-run-progress name
runningProgress's own field name states its domain, so restore unit='%' and
state_class='measurement' rather than leaving it an opaque passthrough --
that hedge made sense for activationState (no supported-values list, no
write contract to invent) but not here, where the name itself is the
evidence.

"Nadraaien voortgang" also wasn't idiomatic Dutch (nouns don't stack that
way); "Voortgang nadraaien" matches how the rest of the catalog compounds
these names.
2026-07-28 02:29:01 +00:00
Marc Billow 0640ac3477 Fix hotwater_lock key-collision hazard, address Opus review of triage fixes
An Opus review of the previous commit found that giving the switchHotwater
fallback and LOCK.hotwater_lock the same key introduced a real bug:
adapter.flatten() (the source of coordinator.data, which every switch's
is_on reads) only ever honours exists_fn, never entity.py's implicit
own-field-presence default that gates plain registration. With only one
side of the pair gated, both descriptors still wrote the same key into the
flattened state dict, and whichever was processed last -- decided by
device-reported href order, not correctness -- silently won. Reproduced
with the existing coffee fixture: reversing resource order flipped
hotwater_lock from correct (False) to a stuck True.

Fixed by gating both sides symmetrically via a shared tri-state helper that
also treats an unfetched /status/lock/vs/0 stub as "outcome pending" rather
than "confirmed absent" -- otherwise the stub window let both descriptors
pass exists_fn at once, which would have registered two switch entities
with the same unique_id. The fallback also re-asserts its own field's
presence, a check it used to get for free before it shared LOCK's key.

Also addresses two smaller findings from the same review, both in the
range-hood after-run capability (#147): runningProgress's unit='%' was a
guess from a single "0" sample with no supported-values/range field to
confirm the domain -- inconsistent with treating activationState as
read-only for the same "don't guess" reason -- so it's now a bare
passthrough sensor; and entity_category='diagnostic' was dropped from the
two read entities since after-run is a feature the user actively watches
and cancels via the (correctly uncategorized) button, not passive
diagnostics.
2026-07-28 02:25:42 +00:00
Marc Billow b47dabeb31 Merge pull request #143 from mbillow/claude/microwave-fanspeed-coverage-nmvc86
Microwave vent fan and kimchi-refrigerator compartment coverage (#137, #142, #26)
2026-07-27 21:13:03 -05:00
Marc Billow cc3ce12aa4 Fix range hood fan power targeting and kimchi mode write validation
- _speed_zero_is_off now keys off the hood resource's own
  settableMinFanSpeed/supportedFanSpeed fields instead of asking whether
  the device has any power resource at all, so a combi appliance's cavity
  /power/0 can no longer be toggled off by turning off just the vent fan.
- async_turn_on() no longer resets an already-running fan to its lowest
  speed when called without a percentage.
- _has_separate_power() reads through the O(1) resource cache instead of
  copying the full resource snapshot on every property access.
- _kimchi_mode_write rejects values the compartment didn't advertise in
  supportMode instead of writing them blind.
- kimchi_ripening_status no longer lowercases its value, since it has no
  enum catalog entry to translate the lowercased token back through.
- Documented why KIMCHI_DOOR_GENERIC isn't deduped against the /doors/vs/0
  aggregate fallback on the one fixture that reports both.

Adds regression tests for the combi-appliance power targeting, the
already-on turn_on no-op, kimchi mode write validation, and a kimchi
select display/write casing round-trip; a translation-coverage guard for
kimchi_zone_mode codes mirroring the existing AC preset one.
2026-07-28 02:10:01 +00:00
Marc Billow 5055d6bb72 Fix water-purifier hot-water lock mislabel, add range-hood after-run support
Water purifier (#144, #145): /favorite/hotwater/vs/0's switchHotwater field
is a Locked/Unlocked hot-water lock, not a "favorite enabled" flag. It's the
same lock as LOCK.hotwater_lock, just surfaced through this href on boards
that don't populate /status/lock/vs/0's hotwaterLock -- the two now share
the hotwater_lock key/translation, gated so only one is ever active.

Range hood (#147): binds /afterrun/vs/0 (after-run activation state,
progress, and a cancel button), clearing the last unbound href for
AHD-WW-TP1-22-COMMON.
2026-07-28 02:00:57 +00:00
perseus177 55c7b88a8f fix(airconditioner): label the 2Step preset and test presets by their HA value
The legacy Comode codes resolve through the same dynamic resolver as a real
convenient resource, so Nano lands on the existing 'nano' preset (already
labelled WindFree) rather than on a 'windfree' value of its own -- the new
tests asserted the label instead of the value. 2Step had no catalog entry in
either language and would have surfaced as the raw code.

Also updates the existing five-percent-humidity test, which reached into the
descriptor's field/value_fn directly, to the rep_fn the fallback needs, and
covers the fallback itself.
2026-07-28 02:38:28 +02:00
Marc Billow 265d94eded Add kimchi-refrigerator compartment coverage (issue #26)
TP2X_REF_20K-class 3-compartment kimchi refrigerators report each
compartment's storage mode and ripening status/timer on
/status/kimchi/<slot>/vs/0, plus a top-compartment door sensor on
/kimchidoors/top/vs/0 -- all previously unbound. Bind them as pattern
capabilities (fridge.KIMCHI_ZONE, fridge.KIMCHI_DOOR_GENERIC), deriving
the per-compartment entity key and display name from the href's
top/middle/bottom segment, the same way DOOR_GENERIC/TEMP_CURRENT_GENERIC
already do.

Storage-mode option labels were translated directly from the reporter's
own SmartThings app screenshots rather than guessed from the raw device
codes or their English paraphrase, confirming the on-screen option order
matches supportMode's array order (including the freezer triplet's
-19/-21/-17°C -> Standard/Strong/Weak mapping).

Also tighten FLEX_ZONE's exists_fn: this device's /mode/vs/0 also
populates modes/supportedOptions, but with a token shape that never
overlaps (a "_[n]:[n]" suffix supportedOptions carries that modes never
repeats), so the existing "supportedOptions is nonempty" check let the
entity bind anyway and get stuck permanently on "unknown". Requiring an
actual resolvable value keeps it working for the RF9000/Bespoke-class
fridges it was built for while leaving it absent here.
2026-07-28 00:35:21 +00:00
perseus177 3032b0c032 test(airconditioner): lock in the ARTIK051_KRAC_18K surface
Fixture is a scrubbed diagnostics dump from the unit the writes and
calibrations were verified on; the issue #136 unit is the same model with a
slightly different token set (no Spi, FilterTime_5460, OutdoorTemp_81), which
the presence gating handles the same way.

Covers the pieces the golden's state_keys can't: that the token entities stay
off newer boards, that an absent token yields no entity, that humidity's zero
reads as unknown, that fan/swing/preset read and write through /airflow/vs/0
and the Comode token, and that a board with /wind/* and /mode/convenient/vs/0
still takes the resource paths.

Refs #136
2026-07-28 02:28:59 +02:00
perseus177 5b30099c42 feat(airconditioner): fan, swing, presets and option-token settings on ARTIK051 boards
This board generation predates every AC dump the registry was built from and
differs in three ways, all handled here behind presence checks so no other
family's behaviour changes:

* No /wind/* resources at all. Fan speed and vane direction share a single
  /airflow/vs/0 resource, whose speedLevel uses the same 0-4 scale as
  _DEVICE_TO_FAN and whose direction uses the same codes as _DEVICE_TO_SWING,
  so the existing maps are reused rather than duplicated. The resource reports
  no supportedModes, so the full scale is offered.
* No /mode/convenient/vs/0. The convenient-mode preset is a Comode_* token in
  /mode/vs/0's options, synthesised into a convenient-shaped rep so the
  existing dynamic preset resolver keeps working unchanged. Codes were learned
  by driving one unit through its cloud integration and reading the token back
  each time: Nano is what the app calls WindFree, plus Quiet/Comfort/2Step/
  Speed (Fast Turbo) and Off.
* Several settings that newer boards expose as dedicated resources are options
  tokens here: SPI, auto clean, air monitoring, beep volume, Good Sleep,
  outdoor temperature and filter time. Writes reuse option_write's single-token
  merge, the same mechanism the display light already uses on this href.

Newer families carry some of the same tokens *alongside* dedicated resources
for those settings, so the token entities are gated on this generation's
resource shape (/airflow/vs/0 present, /wind/strength/vs/0 absent -- the same
test the climate entity's fan/swing fallback uses, so the two can never
disagree). Without that gate they duplicated auto clean on TP1X/TP2X boards
and applied a calibration from this board to theirs.

Two calibrations, both from hardware rather than from the token names:
OutdoorTemp is offset by 55 (token 75 against a 20.3 C outdoor thermometer in
the same install, token 74 against a 19.4 C forecast; Fahrenheit fits far
worse), and FilterTime is tenths of an hour (token 1710 while the official
Samsung app displayed "171 hours 0 minutes" for the same unit's filter).
Whether filter time counts up or down is deliberately not claimed: it was seen
rising while the unit ran, which contradicts the app's "remaining" wording.

Humidity now falls back to the plain x.com.samsung.da.humidity field where
fivepercentHumidity is absent, still as one entity rather than two, and 0 reads
as "not measuring" rather than 0% -- on this board the field only carries a
reading (51%, matching the same unit's cloud integration) while Air monitoring
is on, which the unit switches back off by itself after about a minute.

Fan, swing, preset, SPI and beep-volume writes were confirmed by read-back on
hardware. Good Sleep's upper bound is a guess (only 0 has been observed), and
/airflow/0 -- the OCF-standard mirror of the vendor resource -- is ignored
rather than modelled, since air_purifier.py found the opposite reliability
ordering between these two hrefs on its own family.

Refs #136
2026-07-28 02:28:33 +02:00
perseus177 75f2be7aaf fix(registry): resolve ARTIK051_KRAC_18K to the airconditioner registry
Room air conditioners on the ARTIK051 board (ARTIK051_KRAC_18K, issue #136)
report no oneUiVersion and carry a '_KRAC_' token in modelNum. The existing
'_RAC_' check can't see it -- the 'K' sits between the underscore and 'RAC' --
and the consumer-prefix fallback only covers washers/dryers/dishwashers, so
these units fell back to 'unknown': 8 of their 19 resources ended up unbound
and the device exposed nothing but a power switch.

Same ARTIK051 board family as the '_TVTL_' air purifier handled just below.
2026-07-28 02:28:13 +02:00