Compare commits

...
38 Commits
Author SHA1 Message Date
Marc Billow 498da49817 Merge pull request #171 from mbillow/claude/issue-triage-gating-gf6bg4
Fix unsound gating from #170, cover remaining fan-speed icons
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
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
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
blka b18500dec3 Merge upstream/main into ac-additive-entities
Conflicts resolved:
- capabilities/airconditioner.py: keep my NumberDesc/SelectDesc imports +
  upstream's hoist of _filter_usage_percent -> common.filter_usage_percent
  (local def removed by upstream's auto-merge; AIR_FILTER.air_filter_usage
  now references the common helper). Upstream's ANOMALY_LOAD, HREF_WIND_OSCILLATION
  and oscillation climate-write kind kept; my beep/tropical/INFO/AIR_QUALITY/
  threshold-Select/filter-hours kept. Both change sets coexist.
- translations/{en,nl}.json: switch section -- keep both my 'beep' and
  upstream's display/pet_filter_activation/auto_empty/dustbin_auto_close/
  uvc_intensive_mode entries.

Regenerated the 8 AC + dehumidifier goldens (+ the 2 new upstream AC fixtures
ara_ww_tp1_22, windfree_oscillation) against the merged code. 631 tests pass.
2026-07-27 22:36:06 +02:00
blka 51fa1c5ea8 Address review: per-item versions, writable threshold, unit_fn, gating
Per mbillow's review on PR #129 (CHANGES_REQUESTED). Items 1-7 + smaller.

1. INFO: expose each /information/vs/0 version item per (type, ordinal) instead
   of collapsing to a single first-Firmware value. Boards carry 1-3 Firmware
   items (separate MCUs) plus an Outdoor unit and (window AC) a Touch IC item;
   each is a distinct version string. _info_version(items, type_, ordinal)
   + _has_info_version exists_fn gate, so an item with no number (tp2x_rac_20k's
   second Firmware) suppresses the entity rather than binding unknown.

2. air_filter_threshold is locally writable, not cloud-only -- confirmed live
   on ARTIK051_PRAC: POST filterDesiredUsage=700 to /filter/airdustfilter/vs/0
   -> 2.04, read-back 700, persists; restored to 500. Converted from a
   read-only sensor to a SelectDesc keyed to the device's
   supportedFilterDesiredUsage enum (options_field), with a write_fn that
   POSTs the scalar field. Only binds where the enum is advertised; boards
   without it leave this writable field unexposed rather than guess the valid
   set (don't-guess).

3. air_filter_usage_hours uses unit_fn reading filterCapacityUnit ('Hour'->'h')
   instead of a hardcoded unit, so a board advertising a different unit doesn't
   mislabel a duration statistic.

4. air_filter_usage_hours state_class is total_increasing, not measurement --
   filterUsage is a lifetime hour counter that resets on filter replacement.

5. INFO and air_filter_threshold now carry exists_fn (AIR_QUALITY already did),
   so they don't bind a permanently-unknown entity when their item/field is
   absent. Also fixes the caww_tp2 golden nuance (filterDesiredUsage absent
   -> no threshold key, matching what HA would actually create).

6. AIR_QUALITY: CleanLevel is corroborated as numeric by a top-level
   x.com.samsung.da.cleanLevel scalar (tp1x_da_ac_rac_01011 reports both as
   '1'), so clean_level is now an int measurement; odor/dust/fine_dust stay
   string diagnostics. Reinstated the 2-element-array ambiguity note (Dust/
   FineDust/SuperFineDust report ['0','0']; v[1] meaning unconfirmed, v[0]
   taken as the reading). SuperFineDust is now modeled for consistency with
   Dust/FineDust (same shape), rather than skipped without reason.

7. _beep_write restores the last non-Mute Volume level on 'On' instead of
   forcing Volume_100, so an intermediate setting (e.g. Volume_50 set via
   the cloud) survives an off/on cycle; falls back to 100 when no prior level.

Smaller: test_air_quality now asserts the tp1x_da_ac_rac_01011 clean_level==1
non-zero reading (the one value_fn-regression catch in the corpus); renamed
by_type's _AC_IGNORED -> _IGNORED_LESS_INFO to resolve the two-meaning
collision with the capabilities module's _AC_IGNORED (href strings); golden
regenerated for the 8 AC fixtures + dehumidifier.

612 tests pass.
2026-07-27 22:22:56 +02:00
blka 3da01f80d0 feat(airconditioner): add beep, tropical night, filter hours/threshold, air-quality, sw/fw version
Layer the ARTIK051_PRAC additive entities onto the upstream registry on a
fresh branch (additive-only; the round-1 display-light/mute-once/WindFree
work was independently shipped upstream and is not redone here).

New entities, all driven by single-token option_write or item reads:
- beep (switch): Volume_Mute/Volume_100 option token; single-token merge --
  a full options RMW reverts on ARTIK051_PRAC. Cloud counterpart:
  samsungce.airConditionerAudioFeedback (on/off only; level is cloud-only).
- tropical_night_mode (number 0-16): Sleep_<N> option token. Cloud:
  custom.airConditionerTropicalNightMode.
- air_filter_usage_hours / air_filter_threshold (duration sensors, h):
  raw filterUsage count and filterDesiredUsage alarm threshold. Threshold
  SET is cloud-only (samsungce.dustFilterAlarm); local read-only.
- clean_level / odor / dust / fine_dust (diagnostic sensors): /sensors/vs/0
  items[]. No unit advertised on the resource, so no device_class until a
  populated reading + unit is observed (don't-guess rule). SuperFineDust
  intentionally not modeled.
- software_version / firmware_version (diagnostic sensors):
  /information/vs/0 items[]. The href is globally ignored as identity
  plumbing; the AC registry drops that ignore so INFO is the sole cap on it.

Translations: en.json + nl.json (every-language-mirrors-english invariant).
Golden regression: regenerated the 8 AC fixtures + dehumidifier (reuses
AIR_FILTER) for the new state keys.

608 tests pass.
2026-07-27 14:43:07 +02:00
63 changed files with 5694 additions and 101 deletions
+35 -4
View File
@@ -6,6 +6,7 @@ description: >-
raises the "incomplete capability coverage" repair, a diagnostics JSON needs
triaging, or you're mapping OCF resources to HA entities. Covers reading dumps,
OCF-standard vs vendor hrefs, the diagnostic/config/normal entity taxonomy,
preferring dynamic (device-reported) select options over hardcoded lists,
ensuring every href is bound or ignored, and locking it in with a fixture +
golden + test.
---
@@ -103,7 +104,37 @@ sub-polled between summary polls. Pick descriptor types from `entities.py`
as a gap for a human, or ignore it with a documented reason — never invent an
entity on a hunch (`ignored.py`'s rule).
## 5. Names and enum labels live in translations, never in Python
## 5. Select options: read them from the device, don't hardcode
A `SelectDesc`'s `options` should come from the device's own advertised list
whenever the resource carries one, not from a Python tuple typed in from a
single dump. Two dynamic forms already exist in the repo and should be
reached for first:
- `options_field='x.com.samsung.da.supportedModes'` (or whatever the
resource's own supported-values field is called) — reads the live rep on
the capability's own href. See `laundry.py`'s `buzzer_sound`/
`finish_sound` (`options_field='supportedBuzzerSound'`/
`'supportedFinishSound'`).
- `options=<callable>` — for option lists that live on a **different**
resource than the select's own href (e.g. a course table keyed off a
sibling href). See `laundry.cycle_select`'s `options=cycle_options`.
A static `options=(...)` tuple is a coverage gap waiting to happen: the next
dump from a different board generation will report modes/values the tuple
doesn't have, and both the HA options list *and* `write_fn`'s validation (if
it checks the same tuple) will silently reject values the device itself
advertises as supported. That's exactly what happened with `oven._OVEN_MODES`
in issue #138 — a hardcoded list rejected `AirFryer`/`Dehydrate`/
`SelfClean`/etc. even though the device's own `supportedModes` field listed
them. Reach for a static tuple only when the dump genuinely has no
supported-values field to read (e.g. the NV7000BS-class oven dump
`_OVEN_MODES` was inferred before any live oven dump existed — see that
module's docstring), and treat it as an interim best-guess rather than a
permanent design choice: migrate it to `options_field`/a callable the moment
a dump with a real supported-values list surfaces, instead of just adding
the new values to the static tuple.
## 6. Names and enum labels live in translations, never in Python
Descriptors have **no `name` field**. Every entity is named from the shipped
catalog, keyed by `translation_key` — which defaults to the descriptor's own
@@ -142,7 +173,7 @@ no `[%key:...%]` resolution (that's Core build tooling). Every other language
must mirror `en.json` key for key — also enforced by
`tests/test_translations.py`.
## 6. Coverage discipline: bound or ignored
## 7. Coverage discipline: bound or ignored
Every href in the dump must resolve, or the repair fires. If a resource isn't
worth an entity, add it to `capabilities/ignored.py` (a no-entity `Capability`)
@@ -156,7 +187,7 @@ friendlier href**.
ignored because washers bind it. When only one family should ignore an href
that another binds, scope the ignore to that family's registry.
## 7. Reuse before writing new code
## 8. Reuse before writing new code
Check `common.py` (generic OCF: power, energy, alarms, water) and `laundry.py`
(shared washer/dryer/dishwasher: buzzer, job status, `cycle_select` + course
@@ -165,7 +196,7 @@ registry uses `fridge.FIRMWARE_UPDATE`; all three laundry families share
`laundry.cycle_select`. If two families hand-roll the same helper, hoist it to a
shared module rather than copying.
## 8. Lock it in
## 9. Lock it in
1. Add a **scrubbed** fixture `tests/fixtures/<type>_device.json`
(`{"device0": [ {devcol rep}, {href, rep}, ... ]}`) — replace serials, MACs,
+14
View File
@@ -3,6 +3,19 @@
<!-- light mode -->
<img src="custom_components/localthings/brand/logo@2x.png#gh-light-mode-only" alt="LocalThings Logo"/>
<p align="center">
<img alt="GitHub Repo stars" src="https://img.shields.io/github/stars/mbillow/localthings" />
<img alt="GitHub watchers" src="https://img.shields.io/github/watchers/mbillow/localthings" />
</p>
<p align="center">
<img alt="GitHub Release" src="https://img.shields.io/github/v/release/mbillow/localthings" />
<img alt="hacs validation" src="https://img.shields.io/github/check-runs/mbillow/localthings/main?nameFilter=HACS%20validation&label=hacs%20validation" />
<img alt="hassfest" src="https://img.shields.io/github/check-runs/mbillow/localthings/main?nameFilter=Hassfest%20validation&label=hassfest" />
<img alt="tests" src="https://img.shields.io/github/check-runs/mbillow/localthings/main?nameFilter=Pytest&label=tests" />
</p>
# LocalThings
**A native Home Assistant custom integration for local control of newer-generation Samsung connected appliances.** No cloud round-trip. Add a device through HA's normal *Settings > Devices & Services* flow and it talks CoAP-over-DTLS straight to the appliance on your LAN.
@@ -35,6 +48,7 @@ Your state stays on your LAN: HA talks to the appliance over a direct DTLS sessi
| Washer | `by_type/washer.py` |
| Water purifier | `by_type/water_purifier.py` |
| Vacuum clean/auto-empty station | `by_type/vacuum_station.py` |
| Air dresser | `by_type/air_dresser.py` |
Each registry composes shared and family-specific `Capability` objects from `registry/capabilities/`; those modules document the individual resources/entities in more depth than a README table can stay current with.
+122 -5
View File
@@ -50,6 +50,8 @@ from .registry.capabilities.airconditioner import (
HREF_WIND_DIRECTION as WIND_DIRECTION_HREF,
HREF_WIND_OSCILLATION as WIND_OSCILLATION_HREF,
HREF_CONVENIENT as CONVENIENT_HREF,
HREF_AIRFLOW as AIRFLOW_HREF,
is_legacy_board,
)
from .registry.capabilities.common import normalize_temp_unit
@@ -141,6 +143,22 @@ def _oscillation_swing(rep: dict) -> str | None:
return 'horizontal'
return 'off'
def _wind_strength_label(code, rep: dict) -> str:
"""Human label for a /wind/strength/vs/0 code from the device's own
modesName array (parallel-indexed with supportedModes), lowercased for
HA -- used only for codes _DEVICE_TO_FAN doesn't already cover (issue
#155, TP1X_DA-AC-RAC-01001_0000: codes "0"/"31"-"35" instead of the
"0"-"4" scale _DEVICE_TO_FAN was built from, with modesName giving
"Auto"/"1"/"2"/"3"/"4"/"MAX"). No per-model numeric map -- mirrors
preset_mode's dynamic code->str resolution. Falls back to the raw code
lowercased when modesName is absent or misaligned."""
supported = rep.get('x.com.samsung.da.supportedModes') or []
names = rep.get('x.com.samsung.da.modesName') or []
if code in supported and len(names) == len(supported):
return str(names[supported.index(code)]).lower()
return str(code).lower()
# Preset (convenient mode): resolved dynamically from the device's own
# /mode/convenient/vs/0 supportedModes -- no per-model table. The device 'Off'
# code maps to HA's PRESET_NONE ("no preset active"); every other code is
@@ -228,8 +246,57 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
# -- resource helpers ---------------------------------------------------
# Preset codes on legacy ARTIK051 boards, learned by driving the same unit
# through its cloud integration and reading the local token back each time:
# Nano=windFree, Quiet, Comfort, 2Step, Speed=Fast Turbo, Off=none.
_LEGACY_PRESET_CODES = ('Off', 'Nano', 'Quiet', 'Comfort', '2Step', 'Speed')
def _legacy_convenient(self) -> dict:
"""A /mode/convenient/vs/0-shaped rep built from the Comode_* token in
/mode/vs/0's options, for boards that have no convenient resource."""
options = (self.coordinator.resource(MODE_HREF) or {}).get(
'x.com.samsung.da.options') or []
for option in options:
if isinstance(option, str) and option.startswith('Comode_'):
return {_MODES_FIELD: [option.split('_', 1)[1]],
_SUPPORTED_FIELD: list(self._LEGACY_PRESET_CODES)}
return {}
def _legacy_airflow(self) -> dict:
"""The /airflow/vs/0 rep, but only when it is the fan/swing channel to
use -- i.e. this board has no /wind/strength/vs/0.
Delegates the board-generation test to is_legacy_board (the same
test capabilities/airconditioner.py's token entities are gated on)
instead of re-implementing it. Uses last_resources rather than a
two-key presence dict built from coordinator.resource()'s truthiness
-- resource() 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. A
presence dict built from truthiness alone would disagree with the
token entities on a board reporting a genuinely empty /airflow/vs/0,
silently reintroducing the drift this delegation exists to prevent.
"""
if not is_legacy_board(self.coordinator.last_resources):
return {}
return self.coordinator.resource(AIRFLOW_HREF) or {}
def _legacy_preset(self) -> bool:
"""Whether presets come from the Comode_* token rather than a resource.
Gated on the same board test as _legacy_airflow, not on the convenient
rep being empty alone: newer boards carry Comode tokens too, so a
momentarily empty /mode/convenient/vs/0 there must not silently switch
the preset read (and write) over to the token path.
"""
return (not self.coordinator.resource(CONVENIENT_HREF)
and bool(self._legacy_airflow()))
def _rep(self, href: str) -> dict:
return self.coordinator.resource(href) or {}
rep = self.coordinator.resource(href) or {}
if not rep and href == CONVENIENT_HREF and self._legacy_airflow():
return self._legacy_convenient()
return rep
def _is_on(self) -> bool:
# Prefer the vendor /power/vs/0 (present on every observed board and
@@ -373,11 +440,27 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
@property
def fan_mode(self):
return self._read_mode(WIND_STRENGTH_HREF, _DEVICE_TO_FAN)
airflow = self._legacy_airflow()
if airflow:
return _DEVICE_TO_FAN.get(str(airflow.get('x.com.samsung.da.speedLevel')))
rep = self._rep(WIND_STRENGTH_HREF)
code = _first(rep.get(_MODES_FIELD))
if code is None:
return None
return _DEVICE_TO_FAN.get(code) or _wind_strength_label(code, rep)
@property
def fan_modes(self) -> list[str]:
return self._read_modes(WIND_STRENGTH_HREF, _DEVICE_TO_FAN)
if self._legacy_airflow():
# This resource carries no supportedModes, so the full scale is offered.
return list(_DEVICE_TO_FAN.values())
rep = self._rep(WIND_STRENGTH_HREF)
modes = []
for code in self._supported(WIND_STRENGTH_HREF):
mode = _DEVICE_TO_FAN.get(code) or _wind_strength_label(code, rep)
if mode not in modes:
modes.append(mode)
return modes
def _swing_via_direction(self) -> bool:
"""True when WIND_DIRECTION_HREF is the swing channel to use --
@@ -387,12 +470,17 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
@property
def swing_mode(self):
airflow = self._legacy_airflow()
if airflow:
return _DEVICE_TO_SWING.get(airflow.get('x.com.samsung.da.direction'))
if self._swing_via_direction():
return self._read_mode(WIND_DIRECTION_HREF, _DEVICE_TO_SWING)
return _oscillation_swing(self._rep(WIND_OSCILLATION_HREF))
@property
def swing_modes(self) -> list[str]:
if self._legacy_airflow():
return list(_SWING_TO_DEVICE.keys())
if self._swing_via_direction():
return self._read_modes(WIND_DIRECTION_HREF, _DEVICE_TO_SWING)
if self._rep(WIND_OSCILLATION_HREF):
@@ -468,9 +556,37 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
await self.coordinator.async_send_command(self._bound, (kind, device))
async def async_set_fan_mode(self, fan_mode: str) -> None:
await self._set_mapped('fan', _FAN_TO_DEVICE, fan_mode)
if self._legacy_airflow():
level = _FAN_TO_DEVICE.get(fan_mode)
if level is not None:
await self.coordinator.async_send_command(
self._bound, ('fan_legacy', level))
return
supported = self._supported(WIND_STRENGTH_HREF)
device = _FAN_TO_DEVICE.get(fan_mode)
# A static hit is only trustworthy if this unit's own supportedModes
# actually includes that code -- a board can use non-standard codes
# (issue #155's "31"-"35") while still spelling a standard label
# ("Low"/"High") in modesName, in which case _FAN_TO_DEVICE.get would
# return a plausible-looking code ('1'/'3') the device never
# advertised at all. Fall through to the live scan whenever the
# static guess isn't actually one of this unit's own codes.
if device is None or (supported and device not in supported):
rep = self._rep(WIND_STRENGTH_HREF)
for code in supported:
if _wind_strength_label(code, rep) == fan_mode:
device = code
break
if device is not None:
await self.coordinator.async_send_command(self._bound, ('fan', device))
async def async_set_swing_mode(self, swing_mode: str) -> None:
if self._legacy_airflow():
code = _SWING_TO_DEVICE.get(swing_mode)
if code is not None:
await self.coordinator.async_send_command(
self._bound, ('swing_legacy', code))
return
if self._swing_via_direction():
await self._set_mapped('swing', _SWING_TO_DEVICE, swing_mode)
return
@@ -489,5 +605,6 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
# a fixed transform of the HA value -- e.g. 'NanoSleep' -> 'nanosleep').
for code in self._supported(CONVENIENT_HREF):
if _preset_to_ha(code) == preset_mode:
await self.coordinator.async_send_command(self._bound, ('preset', code))
kind = 'preset_legacy' if self._legacy_preset() else 'preset'
await self.coordinator.async_send_command(self._bound, (kind, code))
return
+10 -9
View File
@@ -366,14 +366,22 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if reg is None:
reg = for_device_by_resources(resources)
unbound: list[str] = []
hot, warm = set(), set()
def _tier_log(href: str, tier: str) -> None:
if tier == 'hot':
hot.add(href)
elif tier == 'warm':
warm.add(href)
if reg is not None:
self._log.debug("device type: %s (oneUiVersion=%r)", reg.name, one_ui)
bound = discover(resources, reg.capabilities, reg.pattern_capabilities,
log=unbound.append)
log=unbound.append, tier_log=_tier_log)
self.device_type_name = reg.name
else:
self._log.warning("unknown device type oneUiVersion=%r; using common caps", one_ui)
bound = discover(resources, CAPABILITIES, log=unbound.append)
bound = discover(resources, CAPABILITIES, log=unbound.append, tier_log=_tier_log)
self.device_type_name = None
self.bound = bound
self._unbound_hrefs = unbound
@@ -398,13 +406,6 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
)
self._update_coverage_gap_issue(reg is None, unbound, name)
hot, warm = set(), set()
for be in bound:
tier = be.capability.poll_tier
if tier == 'hot':
hot.add(be.href)
elif tier == 'warm':
warm.add(be.href)
self._hot_hrefs = sorted(hot)
self._warm_hrefs = sorted(warm)
+35 -11
View File
@@ -1,15 +1,18 @@
"""Fan platform for Samsung range hoods and air purifiers.
Three FanDesc-bound hrefs exist, dispatched by href in async_setup_entry
Four FanDesc-bound hrefs exist, dispatched by href in async_setup_entry
below since each needs different HA fan semantics: the range hood's fan
speed and the older ARTIK051_TVTL air-purifier family's Auto/Sleep/Low/
Medium/High (issue #56) are both an ordered set of numeric levels
(SET_SPEED) -- the latter confirmed monotonic in capabilities/
air_purifier.py's module docstring, with no named-mode list to preserve
since this board never self-reports one. The newer TP1X air-purifier
family's modes (Smart/Max/Mid/WindFree/Sleep, issue #130) are named
behaviors with no linear order (PRESET_MODE), reported directly by that
board's own supportedModes."""
since this board never self-reports one. The TP1X air-purifier family's
modes (Smart/Max/Mid/WindFree/Sleep, issue #130) and the A-VTWW-TP2-21
family's /wind/strength/vs/0 modes (issue #151) are both named behaviors
with no linear order (PRESET_MODE) -- LocalThingsAirPurifierFan handles
both hrefs, the only difference being whether the label comes straight
from supportedModes or from a parallel modesName array (see
_label_for_code)."""
from __future__ import annotations
@@ -29,6 +32,7 @@ from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
from .registry.capabilities.air_purifier import HREF_AIRFLOW
from .registry.capabilities.air_purifier import HREF_MODE as AIR_PURIFIER_FAN_HREF
from .registry.capabilities.air_purifier import HREF_WIND_STRENGTH as AIR_PURIFIER_WIND_STRENGTH_HREF
from .registry.entities import FanDesc
_LOGGER = logging.getLogger(__name__)
@@ -42,6 +46,7 @@ _OFF_SPEED_CODE = '0'
_MODES_FIELD = 'x.com.samsung.da.modes'
_SUPPORTED_MODES_FIELD = 'x.com.samsung.da.supportedModes'
_MODES_NAME_FIELD = 'x.com.samsung.da.modesName'
async def async_setup_entry(
@@ -54,7 +59,7 @@ async def async_setup_entry(
for bound in coordinator.bound:
if not (isinstance(bound.desc, FanDesc) and _is_included(bound, coordinator)):
continue
if bound.href == AIR_PURIFIER_FAN_HREF:
if bound.href in (AIR_PURIFIER_FAN_HREF, AIR_PURIFIER_WIND_STRENGTH_HREF):
entities.append(LocalThingsAirPurifierFan(coordinator, bound))
elif bound.href == HREF_AIRFLOW:
entities.append(LocalThingsAirflowFan(coordinator, bound))
@@ -243,10 +248,29 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity):
return str(power).lower() == 'on'
return bool(self._rep(POWER_HREF).get('value'))
def _label_for_code(self, code) -> str:
"""Lowercased HA preset label for a device mode code.
The TP1X_DA-AC-AIR board (issue #130) reports its named modes
directly as supportedModes ('Smart'/'Max'/...), so the code IS the
label. The A-VTWW-TP2-21 board (issue #151) instead reports numeric
wind-strength codes ('87'/'89'/...) with a separate modesName array
(parallel-indexed with supportedModes) giving the actual names --
same shape as climate.py's _wind_strength_label, and coincidentally
the same word set (Smart/Max/WindFree/Sleep), so both board
generations land on identical HA preset values without needing
their own translation catalog entry."""
rep = self._mode_rep()
supported = list(rep.get(_SUPPORTED_MODES_FIELD, ()))
names = rep.get(_MODES_NAME_FIELD)
if names and code in supported and len(names) == len(supported):
return str(names[supported.index(code)]).lower()
return str(code).lower()
@property
def preset_modes(self) -> list[str]:
return [
str(code).lower()
self._label_for_code(code)
for code in self._mode_rep().get(_SUPPORTED_MODES_FIELD, ())
]
@@ -254,7 +278,7 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity):
def preset_mode(self) -> str | None:
modes = self._mode_rep().get(_MODES_FIELD)
code = modes[0] if isinstance(modes, (list, tuple)) and modes else modes
return str(code).lower() if code is not None else None
return self._label_for_code(code) if code is not None else None
async def async_turn_on(
self, percentage: int | None = None, preset_mode: str | None = None,
@@ -269,10 +293,10 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity):
async def async_set_preset_mode(self, preset_mode: str) -> None:
# Reverse-resolve against the unit's own supportedModes -- the
# write needs the raw device code (e.g. 'WindFree'), not the
# lowercased HA value.
# write needs the raw device code (e.g. 'WindFree', or '90' on the
# modesName-labelled board), not the lowercased HA value.
for code in self._mode_rep().get(_SUPPORTED_MODES_FIELD, ()):
if str(code).lower() == preset_mode:
if self._label_for_code(code) == preset_mode:
await self.coordinator.async_send_command(self._bound, ('mode', code))
return
_LOGGER.warning(
+66
View File
@@ -0,0 +1,66 @@
{
"entity": {
"climate": {
"airconditioner": {
"state_attributes": {
"fan_mode": {
"state": {
"1": "mdi:fan-speed-1",
"2": "mdi:fan-speed-1",
"3": "mdi:fan-speed-2",
"4": "mdi:fan-speed-3",
"5": "mdi:fan-speed-3",
"turbo": "mdi:fan-speed-3",
"max": "mdi:fan-speed-3"
}
},
"preset_mode": {
"state": {
"ai_comfort": "mdi:creation",
"quiet": "mdi:volume-off",
"smart": "mdi:brain",
"speed": "mdi:speedometer",
"nano": "mdi:weather-dust",
"nanosleep": "mdi:sleep",
"longwind": "mdi:weather-windy",
"motiondirect": "mdi:account-arrow-left",
"motionindirect": "mdi:account-arrow-right",
"drycomfort": "mdi:water-percent",
"2step": "mdi:stairs"
}
}
}
}
},
"fan": {
"air_purifier_fan": {
"state_attributes": {
"preset_mode": {
"state": {
"smart": "mdi:brain",
"max": "mdi:fan-speed-3",
"mid": "mdi:fan-speed-2",
"windfree": "mdi:weather-dust",
"sleep": "mdi:sleep"
}
}
}
}
},
"sensor": {
"machine_state": {
"state": {
"idle": "mdi:power-standby",
"active": "mdi:play",
"pause": "mdi:pause"
}
},
"connection_mode": {
"state": {
"observe": "mdi:broadcast",
"poll": "mdi:sync"
}
}
}
}
}
+1 -1
View File
@@ -12,5 +12,5 @@
"pyOpenSSL>=23.0",
"smartthings-local>=0.1.1"
],
"version": "0.14.0"
"version": "0.15.0"
}
@@ -3,9 +3,9 @@ from typing import Optional
from ._base import DeviceRegistry
from . import (
air_purifier, airconditioner, cooktop, dehumidifier, dishwasher, dryer,
induction_cooktop, microwave, oven, range as _range, range_hood,
refrigerator, vacuum_station, washer, water_purifier,
air_dresser, air_purifier, airconditioner, cooktop, dehumidifier,
dishwasher, dryer, induction_cooktop, microwave, oven, range as _range,
range_hood, refrigerator, vacuum_station, washer, water_purifier,
)
__all__ = [
@@ -15,6 +15,7 @@ __all__ = [
_REGISTRY_BY_KEY: dict[str, DeviceRegistry] = {
'air_dresser': air_dresser.REGISTRY,
'air_purifier': air_purifier.REGISTRY,
'airpurifier': air_purifier.REGISTRY,
'airconditioner': airconditioner.REGISTRY,
@@ -200,6 +201,13 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi
# here) -- no WAC-specific resources needed.
if key is None and '_WAC_' in (model_num or ''):
key = 'airconditioner'
# Wind-Free 2-in-1 systems (floor-standing + wall-mounted indoor units
# sharing one outdoor unit and one local IP, e.g. TP2X_FAC_BORA_21K,
# issues #150/#153) report no oneUiVersion and carry the '_FAC_' token
# instead of '_RAC_'/'_PRAC_'. Same TP1X/TP2X-class resource surface as
# the room-AC models above.
if key is None and '_FAC_' in (model_num or ''):
key = 'airconditioner'
# ARA-WW-class wall-mount RACs (e.g. ARA-WW-TP1-22-COMMON, issues #115/
# #116/#117/#120) report no oneUiVersion and no '_RAC_'/'-RAC-' token at
# all -- the board family is spelled 'ARA-WW-' instead. Same resource
@@ -212,8 +220,23 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi
key = 'airconditioner'
# Air purifiers (e.g. ARTIK051_TVTL_18K, issue #56) report no
# oneUiVersion either, and carry the '_TVTL_' board-family token.
# Room air conditioners on the ARTIK051 board (e.g. ARTIK051_KRAC_18K,
# issue #136) report no oneUiVersion and carry a '_KRAC_' token. The '_RAC_'
# check above cannot 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' and exposed nothing but power. Same
# ARTIK051 board family as the '_TVTL_' air purifier below.
if key is None and '_KRAC_' in (model_num or ''):
key = 'airconditioner'
if key is None and '_TVTL_' in (model_num or ''):
key = 'air_purifier'
# BESPOKE Cube Air (e.g. A-VTWW-TP2-21-COMMON, issue #151) reports no
# oneUiVersion and carries the hyphenated '-VTWW-' board-family token
# (distinct from the underscore-delimited '_TVTL_' ARTIK051 family
# above). Its fan lives on /wind/strength/vs/0 rather than /mode/vs/0 --
# see capabilities/air_purifier.py's WIND_STRENGTH_FAN.
if key is None and '-VTWW-' in (model_num or '').upper():
key = 'air_purifier'
model_identity = f'{model_num} {description}'.upper()
if key is None and ('_COOKTOP' in model_identity or '_GB_CT_' in model_identity):
key = 'cooktop'
@@ -262,6 +285,14 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi
# station state) shares no hrefs with anything else already modeled.
if key is None and '-VSKR-' in (model_num or '').upper():
key = 'vacuum_station'
# AirDresser (e.g. DA_DF_A51_20_COMMON, issue #162) -- reports no
# oneUiVersion and carries the '_DF_' (Dresser Function) board-family
# token. Every resource it exposes (course select, wrinkle-prevent
# setting, diagnosis) is already handled by the shared laundry
# machinery; it needs its own device type only because none of the
# washer/dryer/dishwasher consumer-model prefixes below match it.
if key is None and '_DF_' in (model_num or ''):
key = 'air_dresser'
# Consumer-model prefix from `description` (washer/dryer/dishwasher) --
# last, since it's the fuzziest match (a bare 2-letter prefix) and would
# otherwise shadow the more specific board-family tokens above.
@@ -0,0 +1,24 @@
"""AirDresser device registry (issue #162).
Reuses washer/dryer/dishwasher's shared laundry surface -- job-beginning
status, operational state, and diagnosis. air_dresser.py holds the two
pieces specific to this device type: a minimal wrinkle-prevent-only
/washer/vs/0 capability, and the course select's own translation key.
"""
from ..capabilities import air_dresser, common, dishwasher, ignored, laundry, operational
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='air_dresser',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
air_dresser.AIR_DRESSER_SETTINGS,
air_dresser.AIR_DRESSER_COURSE,
air_dresser.AIR_DRESSER_SANITIZE,
laundry.JOB_BEGINNING_STATUS,
operational.OPERATIONAL_STATE,
dishwasher.DIAGNOSIS,
]),
)
@@ -1,6 +1,6 @@
"""Air-purifier device registry.
Spans two board generations sharing this one registry (see
Spans three board generations sharing this one registry (see
capabilities/air_purifier.py's module docstring for the per-href
match_fn discriminators that keep them from colliding):
@@ -12,6 +12,10 @@ match_fn discriminators that keep them from colliding):
reported; reuses airconditioner.DISPLAY_LIGHT and airconditioner.MUTE_ONCE
for /light/vs/0 and /option/muteonce/vs/0, which are identical shapes on
the shared DA-AC- board family.
- A-VTWW-TP2-21-COMMON-class (issue #151). Reports no oneUiVersion and no
existing modelNum token; falls back to unknown until routed here. Its fan
is WIND_STRENGTH_FAN on /wind/strength/vs/0 rather than FAN on
/mode/vs/0 -- see that capability's comment.
Reuses dishwasher.DIAGNOSIS for /diagnosis/vs/0 (identical field/write
contract).
@@ -33,6 +37,7 @@ REGISTRY = DeviceRegistry(
air_purifier.AIRFLOW_VS_FALLBACK,
air_purifier.MODE,
air_purifier.FAN,
air_purifier.WIND_STRENGTH_FAN,
air_purifier.DISPLAY,
air_purifier.HEPA_FILTER,
air_purifier.PANEL_STATUS,
@@ -22,6 +22,7 @@ REGISTRY = DeviceRegistry(
airconditioner.AIR_PURIFY,
airconditioner.AUTO_CLEAN,
airconditioner.AIR_FILTER,
airconditioner.AIR_QUALITY,
airconditioner.DISPLAY_LIGHT,
airconditioner.MUTE_ONCE,
airconditioner.CURRENT_LIMIT,
@@ -0,0 +1,80 @@
"""Capabilities specific to the AirDresser family (Samsung DA_DF-class,
issues #162/#157).
This board family reports no oneUiVersion and no /information/vs/0 token
any existing family routes on, so it gets its own device type -- but most
of the resources it exposes are already handled by the shared laundry
machinery:
/washer/vs/0 -> AIR_DRESSER_SETTINGS (wrinkle_prevent only -- a
dedicated capability rather than reusing
dryer.DRYER_SETTINGS wholesale: this board never
populates dryLevel/dryTime/dryerType at all, and
those are permanently-empty-not-just-absent
dryer-only fields on an AirDresser, not merely unset
ones, so binding them here would ship three sensors
that can never read anything on this device type)
/course/vs/0 -> AIR_DRESSER_COURSE (cycle select), table id from
/st/airdressercourse/vs/0 (issue #157's
DA_DF_TP2_20_COMMON reports "Table_00"; issue #162's
DA_DF_A51_20_COMMON has no table resource at all and
falls back to the name-only 'cycle' key, same as an
unrecognized table on washer/dryer). Options come
from laundry.cycle_options -- #157's board populates
/wm/editcourse/vs/0's editCourseList directly; #162's
has no editcourse resource at all and falls through
to cycle_options' supportedOptions decode instead.
Course names aren't identified yet for either table
(no code->name mapping was reported), so they render
as their raw codes until named in translations, same
as dryer.py's unidentified codes.
/diagnosis/vs/0 -> reuses dishwasher.DIAGNOSIS
/airdresseroption/sanitize/vs/0 -> AIR_DRESSER_SANITIZE (issue #157 only;
#162's board doesn't report this resource at all)
"""
from ..capability import Capability
from ..entities import SwitchDesc
from .laundry import cycle_select
def _wrinkle_write(p, rep, href=None):
if p not in ('On', 'Off'):
return None
return ['washer', 'vs', '0'], {'x.com.samsung.da.wrinklePrevent': p}
def _sanitize_write(p, rep, href=None):
if p not in ('On', 'Off'):
return None
return ['airdresseroption', 'sanitize', 'vs', '0'], {'x.com.samsung.da.sanitize': p}
AIR_DRESSER_SETTINGS = Capability(
href='/washer/vs/0',
poll_tier='warm',
entities=(
SwitchDesc(key='wrinkle_prevent', field='x.com.samsung.da.wrinklePrevent',
icon='mdi:iron',
value_fn=lambda v: v == 'On',
write_fn=_wrinkle_write),
),
)
AIR_DRESSER_COURSE = Capability(
href='/course/vs/0',
entities=(
cycle_select(translation_key='air_dresser_cycle', icon='mdi:tshirt-crew',
table_href='/st/airdressercourse/vs/0'),
),
)
AIR_DRESSER_SANITIZE = Capability(
href='/airdresseroption/sanitize/vs/0',
poll_tier='warm',
entities=(
SwitchDesc(key='sanitize', field='x.com.samsung.da.sanitize',
icon='mdi:weather-sunny',
value_fn=lambda v: v == 'On',
write_fn=_sanitize_write),
),
)
@@ -74,6 +74,7 @@ from .laundry import bool_option_exists, bool_option_value, option_value, option
# are mutually exclusive via this presence check rather than colliding.
HREF_MODE = '/mode/vs/0'
HREF_AIRFLOW = '/airflow/0'
HREF_WIND_STRENGTH = '/wind/strength/vs/0'
def _has_top_level_modes(rep, resources):
@@ -136,14 +137,22 @@ DEVICE_ACTIVE = Capability(
),
)
def _power_write(power_href, value):
"""Shared 'power' payload handling for this family's three FanDesc write
functions -- targets whichever power href fan.py's _power_payload picked
(the board may only report /power/0); a hardcoded vendor href here would
silently no-op on such a board even though the entity's own is_on
already falls back to reading it correctly."""
if power_href == '/power/0':
return ['power', '0'], {'value': bool(value)}
return (['power', 'vs', '0'],
{'x.com.samsung.da.power': 'On' if value else 'Off'})
def _airflow_fan_write(payload, rep, href=None):
kind, value, *args = payload
if kind == 'power':
power_href = args[0] if args else '/power/vs/0'
if power_href == '/power/0':
return ['power', '0'], {'value': bool(value)}
return (['power', 'vs', '0'],
{'x.com.samsung.da.power': 'On' if value else 'Off'})
return _power_write(args[0] if args else '/power/vs/0', value)
if kind == 'speed':
return ['airflow', '0'], {'speed': int(value)}
return None
@@ -233,15 +242,7 @@ MODE = Capability(
def _fan_write(payload, rep, href=None):
kind, value, *args = payload
if kind == 'power':
# Targets whichever power href fan.py's _power_payload picked (the
# board may only report /power/0) -- a hardcoded vendor href here
# would silently no-op on such a board even though the entity's
# own is_on already falls back to reading it correctly.
power_href = args[0] if args else '/power/vs/0'
if power_href == '/power/0':
return ['power', '0'], {'value': bool(value)}
return (['power', 'vs', '0'],
{'x.com.samsung.da.power': 'On' if value else 'Off'})
return _power_write(args[0] if args else '/power/vs/0', value)
if kind == 'mode':
return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': [value]}
return None
@@ -272,6 +273,38 @@ FAN = Capability(
),
)
def _wind_strength_fan_write(payload, rep, href=None):
kind, value, *args = payload
if kind == 'power':
return _power_write(args[0] if args else '/power/vs/0', value)
if kind == 'mode':
return ['wind', 'strength', 'vs', '0'], {'x.com.samsung.da.modes': value}
return None
# A-VTWW-TP2-21-COMMON (issue #151): named preset modes like FAN above, but
# on a distinct href with numeric codes ("87"/"89"/"90"/"91") instead of
# self-describing supportedModes -- x.com.samsung.da.modesName gives the
# actual names (SMART/MAX/WINDFREE/Sleep), read live by fan.py's
# LocalThingsAirPurifierFan._label_for_code rather than a hardcoded
# per-model map. modes here is a bare string ('87'), not a single-element
# list like HREF_MODE's -- _wind_strength_fan_write writes it back as-is.
#
# key is 'wind_strength_fan', NOT 'fan' -- FAN above shares this registry
# and also uses a FanDesc; BoundEntity's unique_id is built from key alone
# (entity.py's _key), not href, so two same-key FanDescs in one registry
# would collide if a board ever bound both (see AIRFLOW_GENERIC's own
# comment on this exact hazard -- missed here in the initial cut).
WIND_STRENGTH_FAN = Capability(
href=HREF_WIND_STRENGTH,
poll_tier='warm',
entities=(
FanDesc(key='wind_strength_fan', translation_key='air_purifier_fan',
field='x.com.samsung.da.modes', write_fn=_wind_strength_fan_write),
),
)
# ---------------------------------------------------------------------------
# TP1X_DA-AC-AIR-class additions (issue #130). This board reports several
# resources the older ARTIK051_TVTL family never did.
@@ -425,4 +458,8 @@ COVERAGE = [
# functionState both 'false'). Same "needs a multi-field schedule
# editor" treatment as fridge.py's /defrost/reservation/vs/0.
Capability(href='/dnd/autosleep/vs/0'),
# Empty ({}) on the A-VTWW-TP2-21 dump (issue #151) -- this board's
# convenient-mode-equivalent behavior lives entirely in WIND_STRENGTH_FAN
# above instead.
Capability(href='/mode/convenient/vs/0'),
]
@@ -15,10 +15,142 @@ different schema (see capabilities/__init__.py). They live only in the AC
by_type registry.
"""
from ..capability import Capability
from ..entities import BinarySensorDesc, ClimateDesc, SensorDesc, SwitchDesc
from ..entities import (
BinarySensorDesc, ClimateDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc,
)
from .common import filter_usage_percent, normalize_temp_unit
from .laundry import option_write
def _int(v):
try:
return int(v)
except (TypeError, ValueError):
return None
def _beep_on(rep):
"""Beep on/off from the `Volume_*` option token: Volume_Mute = off,
Volume_100 (and any non-Mute) = on. None when no Volume_ slot.
_option_token (defined further below, alongside the legacy-board token
helpers) returns the token's *value* half (e.g. 'Mute', '100'), not the
full 'Volume_100' token -- shared with _option_token_num/_option_token_on,
which this module's other options[] readers already rely on.
"""
tok = _option_token(rep, 'Volume')
if tok is None:
return None
return tok != 'Mute'
def _beep_write(payload, rep, href=None):
"""Toggle beep via a single-token /mode/vs/0 options write (option_write's
one-token merge -- a full options RMW reverts on ARTIK051_PRAC). 'On'
restores the last non-Mute level rather than forcing Volume_100, so a
user's intermediate setting (e.g. Volume_50 set via the cloud) survives an
off/on cycle; falls back to 100 when no prior level is known."""
if payload not in ('On', 'Off'):
return None
if payload == 'Off':
token = 'Mute'
else:
prev = _option_token(rep, 'Volume')
token = prev if (prev and prev != 'Mute') else '100'
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': option_write('Volume', token),
}
def _tropical_night_value(rep):
"""Tropical night mode level (0-16) from the `Sleep_<N>` option token.
_option_token returns the token's value half already (e.g. '16' for
'Sleep_16'), same convention as _beep_on above.
"""
tok = _option_token(rep, 'Sleep')
if tok is None:
return None
return _int(tok)
def _tropical_night_write(value, rep, href=None):
"""Set tropical night level via a single-token `Sleep_<N>` options write.
Samsung cloud counterpart: custom.airConditionerTropicalNightMode (0-16)."""
try:
level = int(round(float(value)))
except (TypeError, ValueError):
return None
if not 0 <= level <= 16:
return None
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': option_write('Sleep', str(level)),
}
def _filter_unit(rep):
"""Unit of the filter-usage fields, normalised from filterCapacityUnit
('Hour' -> 'h'). Wired through unit_fn so a board advertising a different
unit doesn't silently mislabel a duration statistic."""
u = rep.get('x.com.samsung.da.filterCapacityUnit')
return {'Hour': 'h', 'Minute': 'min', 'Second': 's'}.get(u, u or 'h')
def _threshold_write(payload, rep, href=None):
"""filterDesiredUsage is locally writable: a plain scalar POST of the
field to /filter/airdustfilter/vs/0 is 2.04-accepted and persists
(confirmed live on ARTIK051_PRAC: POST 700 -> 2.04, read-back 700). The
Select only surfaces where the device advertises
supportedFilterDesiredUsage, so the valid options are known rather than
guessed; boards without that enum leave this writable field unexposed."""
return ['filter', 'airdustfilter', 'vs', '0'], {
'x.com.samsung.da.filterDesiredUsage': payload,
}
def _sensor_item_value(items, type_):
"""First value of the /sensors/vs/0 item with the given
x.com.samsung.da.type. The resource exposes no unit, so no device_class is
set until a populated reading + unit is observed (the 'don't guess' rule).
Dust/FineDust/SuperFineDust report a 2-element array (['0','0']) while
CleanLevel/Odor report a single element -- the second element's meaning is
unconfirmed, so v[0] is taken as the reading and v[1] is dropped; left as
a string rather than coerced numeric because only CleanLevel has
corroborating evidence (a top-level x.com.samsung.da.cleanLevel scalar)."""
for it in (items or []):
if isinstance(it, dict) and it.get('x.com.samsung.da.type') == type_:
v = it.get('x.com.samsung.da.value')
if isinstance(v, list) and v:
return str(v[0])
return None
return None
def _has_sensor_type(type_):
"""True when the /sensors/vs/0 items[] array lists an item of this type.
This proves the type is *listed*, not that the reading is real: issue
#166 (ARxxTXFCAWKNEU, board ARTIK051_PRAC_20K) lists all five item types
with permanent zero values on both its units, and the reporter confirmed
none of these sensors are physically present. A top-level
x.com.samsung.da.cleanLevel scalar (separate from the CleanLevel item)
looked like a corroborating "this reading is real" signal at first --
present alongside genuinely populated readings on tp1x_da_ac_rac_01011
and the tp1x_da_ac_air air purifier fixture, absent on every all-zero
ARTIK051_PRAC_20K dump including both #166 units -- but it doesn't hold
up as a general rule: air_purifier_device.json (ARTIK051_TVTL_18K),
air_purifier_vtww_device.json, and range_hood_device.json all carry
genuinely populated, non-AC-family Dust/FineDust/SuperFineDust readings
with no such scalar. So this stays item-type presence only -- the
entities are disabled by default instead (see AIR_QUALITY below) rather
than existence-gated on a signal that would silently drop real readings
on hardware this repo hasn't seen yet."""
def fn(rep, resources):
return any(isinstance(i, dict) and i.get('x.com.samsung.da.type') == type_
for i in (rep.get('x.com.samsung.da.items') or []))
return fn
# ---------------------------------------------------------------------------
# Canonical AC resource hrefs. The climate entity (climate.py) binds the
# primary HREF_MODE via CLIMATE below and reads the CLIMATE_CONSUMED_HREFS
@@ -43,11 +175,18 @@ HREF_WIND_DIRECTION = '/wind/direction/vs/0' # swing_mode
HREF_WIND_OSCILLATION = '/wind/oscillation/vs/0' # swing_mode fallback
HREF_CONVENIENT = '/mode/convenient/vs/0' # preset_mode
HREF_TEMPS_VS = '/temperatures/vs/0' # vendor temp fallback (items[] array)
# Legacy ARTIK051 boards (ARTIK051_KRAC_18K, issue #136) carry no /wind/* resources
# at all: fan speed and vane direction sit together in this one resource, as
# x.com.samsung.da.speedLevel (the same 0-4 scale as _DEVICE_TO_FAN) and
# x.com.samsung.da.direction (the same codes as _DEVICE_TO_SWING). Their
# convenient-mode preset is a Comode_* token in /mode/vs/0's options instead of a
# resource of its own -- see climate.py's _legacy_airflow/_legacy_convenient.
HREF_AIRFLOW = '/airflow/vs/0' # legacy fan_mode + swing_mode
CLIMATE_CONSUMED_HREFS = [
HREF_POWER, HREF_POWER_VS, HREF_TEMP_CURRENT, HREF_TEMP_DESIRED,
HREF_TEMP_CONTROL, HREF_TEMPS_VS, HREF_WIND_STRENGTH, HREF_WIND_DIRECTION,
HREF_WIND_OSCILLATION, HREF_CONVENIENT,
HREF_WIND_OSCILLATION, HREF_CONVENIENT, HREF_AIRFLOW,
]
@@ -127,6 +266,96 @@ def _display_light_write(payload, rep, href=None):
{'x.com.samsung.da.options': option_write('Light', token)})
# ---------------------------------------------------------------------------
# Legacy ARTIK051 boards keep several settings that newer boards expose as their
# own resources (/option/*, /electriccurrent/vs/0, ...) as `<Prefix>_<value>`
# tokens inside /mode/vs/0's options blob instead. Reads pull the token apart;
# writes reuse option_write's single-token merge, exactly like the display light.
# ---------------------------------------------------------------------------
def _option_token(rep, prefix):
"""Value part of a `<prefix>_<value>` token in /mode/vs/0's options."""
for option in _mode_options(rep):
if isinstance(option, str) and option.startswith(prefix + '_'):
return option.split('_', 1)[1]
return None
def is_legacy_board(resources):
"""True for the board generation whose airflow lives in /airflow/vs/0.
Newer families carry several of the same option tokens (Volume, Sleep,
OutdoorTemp, Autoclean) *alongside* dedicated resources for those settings,
so an ungated token entity would either duplicate an existing one or apply a
scale calibrated elsewhere. Every AC dump on record has one shape or the
other: /airflow/vs/0 with no /wind/* at all, or /wind/strength/vs/0 with no
/airflow/vs/0. Same test as climate.py's _legacy_airflow(), so the entities
below and the climate entity can never disagree about the generation.
"""
return HREF_AIRFLOW in resources and HREF_WIND_STRENGTH not in resources
def _has_option_token(prefix):
return lambda rep, resources: (
is_legacy_board(resources) and _option_token(rep, prefix) is not None)
def _option_token_on(prefix):
return lambda rep: _option_token(rep, prefix) == 'On'
def _option_token_num(prefix, offset=0, divisor=1):
def read(rep):
raw = _option_token(rep, prefix)
try:
return (float(raw) - offset) / divisor
except (TypeError, ValueError):
return None
return read
def _option_switch_write(prefix):
def write(payload, rep, href=None):
return (['mode', 'vs', '0'],
{'x.com.samsung.da.options': option_write(prefix, payload)})
return write
def _option_number_write(prefix):
def write(payload, rep, href=None):
return (['mode', 'vs', '0'],
{'x.com.samsung.da.options':
option_write(prefix, str(int(round(float(payload)))))})
return write
def _humidity(rep):
"""Relative humidity, preferring the 5%-rounded field where it exists.
ARTIK051 boards have no fivepercentHumidity field at all and report the
plain x.com.samsung.da.humidity instead -- and only populate it while the
Air monitoring option is on: the unit measures for roughly half a minute
(51% observed, matching what the same unit's cloud integration reported at
that moment), then zeroes the field and switches Air monitoring back off by
itself. So 0 reads as "not measuring" and is reported as unknown rather than
as 0% humidity, which would poison long-term history.
That zero-as-"not measuring" carve-out is specific to the ARTIK051
fallback field's hardware quirk -- every other board's fivepercentHumidity
has never been documented getting stuck at zero, and collapsing a
genuine 0% reading there to unknown is a regression, not a safeguard
(issue #160). So fivepercentHumidity passes 0 through unchanged; only the
humidity fallback applies the zero-collapse.
"""
if 'x.com.samsung.da.fivepercentHumidity' in rep:
return _num(rep['x.com.samsung.da.fivepercentHumidity'])
if 'x.com.samsung.da.humidity' in rep:
value = _num(rep['x.com.samsung.da.humidity'])
return value if value else None
return None
def _climate_write(payload, rep, href=None):
"""Map a (kind, value) command from the climate platform to the
(path_segs, body) for that one sub-write. `value` is already the raw device
@@ -180,6 +409,16 @@ def _climate_write(payload, rep, href=None):
'vertical': 'Swing' if value in ('vertical', 'both') else 'Fix',
'horizontal': 'Swing' if value in ('horizontal', 'both') else 'Fix',
})
if kind == 'fan_legacy':
return (['airflow', 'vs', '0'],
{'x.com.samsung.da.speedLevel': str(value)})
if kind == 'swing_legacy':
return (['airflow', 'vs', '0'],
{'x.com.samsung.da.direction': value})
if kind == 'preset_legacy':
# Single-token options merge, same mechanism as _display_light_write.
return (['mode', 'vs', '0'],
{'x.com.samsung.da.options': option_write('Comode', value)})
if kind == 'preset':
return (['mode', 'convenient', 'vs', '0'], {'x.com.samsung.da.modes': value})
return None
@@ -204,6 +443,95 @@ CLIMATE = Capability(
exists_fn=_has_display_light_option,
write_fn=_display_light_write,
icon='mdi:led-on', entity_category='config'),
# Beep on/off from the `Volume_*` option token (Volume_Mute/Volume_100).
# Single-token option_write; a full options RMW reverts on ARTIK051_PRAC.
# Gated off the legacy ARTIK051 board generation (see is_legacy_board):
# that generation's own Volume_ token is already modeled as the
# buzzer_volume Number below, and both reading the same options[] slot
# into two entities would be redundant.
SwitchDesc(key='beep', rep_fn=_beep_on,
exists_fn=lambda rep, resources: (
not is_legacy_board(resources)
and _option_token(rep, 'Volume') is not None),
write_fn=_beep_write,
icon='mdi:volume-high', entity_category='config'),
# Tropical night mode level (0-16) from the `Sleep_<N>` option token.
# Single-token option_write. Cloud: custom.airConditionerTropicalNightMode.
# Gated off the legacy board for the same reason as beep above -- its
# Sleep_ token is already the good_sleep Number below.
#
# exists_fn only proves the Sleep_ token slot is present, not that
# tropical night mode is a real feature of the unit: issue #166
# (ARxxTXFCAWKNEU) reports Sleep_0 in every dump -- the exact same
# always-there-at-zero shape as the issue #17 dump #164 was verified
# against -- yet the reporter confirmed their remote/app has no
# tropical night mode control at all. Samsung's OCF options[] blob
# carries this scaffolding token regardless of physical capability,
# so there's no reliable signal here to gate on (same 'don't guess'
# rule as elsewhere in this file, just with no signal to guess from).
# Registered but disabled by default, same precedent as
# fridge.rack_count / cooktop.paired_hood_* -- units that do have the
# feature can enable it themselves.
NumberDesc(key='tropical_night_mode', rep_fn=_tropical_night_value,
exists_fn=lambda rep, resources: (
not is_legacy_board(resources)
and _option_token(rep, 'Sleep') is not None),
write_fn=_tropical_night_write,
native_min=0, native_max=16, step=1,
enabled_default=False,
icon='mdi:weather-night', entity_category='config'),
# Settings that this board generation keeps as options[] tokens.
SwitchDesc(key='spi', rep_fn=_option_token_on('Spi'),
exists_fn=_has_option_token('Spi'),
write_fn=_option_switch_write('Spi'),
icon='mdi:air-purifier', entity_category='config'),
# Shares AUTO_CLEAN's catalog entry rather than duplicating it: same
# feature, different board generation (that one is a /option/autoclean/
# vs/0 field, absent here). Distinct key, so nothing collides if some
# future board ever reported both.
SwitchDesc(key='auto_clean_legacy', translation_key='auto_clean',
rep_fn=_option_token_on('Autoclean'),
exists_fn=_has_option_token('Autoclean'),
write_fn=_option_switch_write('Autoclean'),
icon='mdi:fan-auto', entity_category='config'),
SwitchDesc(key='air_monitoring', rep_fn=_option_token_on('AirMonitoring'),
exists_fn=_has_option_token('AirMonitoring'),
write_fn=_option_switch_write('AirMonitoring'),
icon='mdi:air-filter', entity_category='config'),
NumberDesc(key='buzzer_volume', rep_fn=_option_token_num('Volume'),
exists_fn=_has_option_token('Volume'),
write_fn=_option_number_write('Volume'),
native_min=0, native_max=100, step=10,
icon='mdi:volume-high', entity_category='config'),
# "Good Sleep" timer. 0 = off; the upper bound is a guess (the token
# carries no range hint and only 0 has been observed on hardware), so a
# write above 0 is unverified.
NumberDesc(key='good_sleep', rep_fn=_option_token_num('Sleep'),
exists_fn=_has_option_token('Sleep'),
write_fn=_option_number_write('Sleep'),
native_min=0, native_max=12, step=1, unit='h',
icon='mdi:sleep', entity_category='config'),
# Outdoor temperature, offset by 55. Two calibration points on one unit:
# token 75 while an independent outdoor thermometer in the same install
# read 20.3 C, and token 74 against a 19.4 C forecast. Fahrenheit fits far
# worse (74 F = 23.3 C); the issue #136 unit's 81 gives 26 C in a warmer
# climate, which is also plausible.
SensorDesc(key='outdoor_temperature',
rep_fn=_option_token_num('OutdoorTemp', offset=55),
exists_fn=_has_option_token('OutdoorTemp'),
device_class='temperature', state_class='measurement',
unit='°C', icon='mdi:home-thermometer-outline'),
# Filter time in tenths of an hour: the token read 1710 while the official
# Samsung app displayed "171 hours 0 minutes" for the filter on the same
# unit, and the .0 matching the app's "0 minutes" pins the scale. Whether
# it counts up or down is NOT established -- it was seen rising (171.0 ->
# 171.5) while the unit ran, which contradicts the app's "remaining"
# wording, so the entity is deliberately named neutrally.
SensorDesc(key='filter_time',
rep_fn=_option_token_num('FilterTime', divisor=10),
exists_fn=_has_option_token('FilterTime'),
device_class='duration', unit='h',
state_class='measurement', icon='mdi:air-filter'),
),
)
@@ -242,6 +570,28 @@ AIR_FILTER = Capability(
SensorDesc(key='air_filter_usage', rep_fn=filter_usage_percent,
unit='%', state_class='measurement',
icon='mdi:air-filter', entity_category='diagnostic'),
# filterUsage is a lifetime hour counter that only resets on filter
# replacement -- total_increasing so HA's long-term statistics handle
# the reset rather than treating it as a bounded measurement.
SensorDesc(key='air_filter_usage_hours',
field='x.com.samsung.da.filterUsage',
device_class='duration',
state_class='total_increasing',
unit_fn=_filter_unit,
icon='mdi:air-filter', entity_category='diagnostic',
value_fn=_int),
# The alarm threshold (filterDesiredUsage) is a locally writable option:
# see _threshold_write. Surfaces as a Select only where the device
# advertises supportedFilterDesiredUsage; boards without that enum
# leave it unexposed rather than guess the valid set.
SelectDesc(key='air_filter_threshold',
field='x.com.samsung.da.filterDesiredUsage',
options_field='x.com.samsung.da.supportedFilterDesiredUsage',
exists_fn=lambda rep, res: bool(
rep.get('x.com.samsung.da.supportedFilterDesiredUsage')),
icon='mdi:alarm', entity_category='config',
write_fn=_threshold_write,
value_fn=lambda v: str(v) if v is not None else None),
SensorDesc(key='air_filter_status', field='x.com.samsung.da.filterStatus',
device_class='enum',
options=('normal', 'wash', 'replace'),
@@ -357,18 +707,73 @@ CURRENT_TEMPERATURE_VS = Capability(
# Only the vendor resource's `fivepercentHumidity` (current reading, rounded
# to the nearest 5%) has live data on the issue #75 dump -- its `humidity`
# field, and the OCF-standard /humidity/0 resource entirely, both read a
# stuck "0" there and stay ignored per the 'don't guess' rule (see
# _AC_IGNORED below).
# stuck "0" there, so /humidity/0 stays ignored per the 'don't guess' rule
# (see _AC_IGNORED below).
#
# ARTIK051 boards (issue #136) have no fivepercentHumidity field at all, and
# there the plain `humidity` field is not stuck: it carries a real reading
# (51%, matching the same unit's cloud integration at that moment) for as long
# as the Air monitoring option is on, which the unit itself switches back off
# after roughly a minute -- so most dumps catch it at 0. Hence _humidity's
# fallback, and hence 0 reading as "not measuring" rather than 0%: on both
# board generations a zero here means no measurement, never dry air.
# /humidity/0 stayed 0 throughout that same observation too.
HUMIDITY = Capability(
href='/humidity/vs/0',
poll_tier='warm',
entities=(
SensorDesc(key='humidity', field='x.com.samsung.da.fivepercentHumidity',
device_class='humidity', state_class='measurement', unit='%',
value_fn=_num),
SensorDesc(key='humidity', rep_fn=_humidity,
device_class='humidity', state_class='measurement', unit='%'),
),
)
# /sensors/vs/0 items[] carry live air-quality readings. Removed from
# _AC_IGNORED below so AIR_QUALITY is the sole cap on the href. CleanLevel is
# corroborated as numeric by a top-level x.com.samsung.da.cleanLevel scalar
# (tp1x_da_ac_rac_01011 reports both as '1'), so it's a measurement; the others
# are 1- or 2-element arrays with no corroborating scalar, so they stay string
# diagnostics (see _sensor_item_value for the 2-element ambiguity and why only
# v[0] is taken). No unit is advertised on the resource, so no device_class.
#
# exists_fn (_has_sensor_type) only proves the item *type* is listed, not
# that the unit actually carries that sensor: issue #166 (ARxxTXFCAWKNEU,
# board ARTIK051_PRAC_20K) reports all five item types on both its units,
# values permanently '0'/['0','0'] -- yet the reporter confirmed none apply
# to their model. A tighter existence gate was tried (requiring the
# corroborating cleanLevel scalar above) but doesn't hold up as a general
# rule -- see _has_sensor_type's docstring -- and risks silently dropping
# real readings on hardware that reports them without that scalar. So these
# stay bound whenever the type is listed, same as before #166, and disabled
# by default instead (same precedent as fridge.rack_count /
# cooktop.paired_hood_model / this file's own tropical_night_mode): units
# that do have the sensor can enable it themselves.
AIR_QUALITY = Capability(
href='/sensors/vs/0',
poll_tier='cold',
entities=(
SensorDesc(key='clean_level', field='x.com.samsung.da.items',
icon='mdi:broom', entity_category='diagnostic',
state_class='measurement',
exists_fn=_has_sensor_type('CleanLevel'),
enabled_default=False,
value_fn=lambda items: _int(_sensor_item_value(items, 'CleanLevel'))),
*tuple(
SensorDesc(key=key, field='x.com.samsung.da.items',
icon=icon, entity_category='diagnostic',
exists_fn=_has_sensor_type(type_),
enabled_default=False,
value_fn=lambda items, t=type_: _sensor_item_value(items, t))
for key, icon, type_ in (
('odor', 'mdi:weather-windy', 'Odor'),
('dust', 'mdi:cloud', 'Dust'),
('fine_dust', 'mdi:cloud-outline', 'FineDust'),
('super_fine_dust', 'mdi:weather-fog', 'SuperFineDust'),
)
),
),
)
# ---------------------------------------------------------------------------
# AC-scoped coverage: the CLIMATE_CONSUMED_HREFS above (read by the climate
# entity) plus vendor duplicates / all-zero-ambiguous / plumbing resources.
@@ -388,15 +793,18 @@ HUMIDITY = Capability(
# of waiting on the summary sweep.
# ---------------------------------------------------------------------------
_AC_IGNORED = [
# All-zero and ambiguously encoded on this model (2-value arrays); the
# 'don't guess' rule -- leave unmodeled rather than invent entities.
'/sensors/vs/0',
# Stuck at "0" on every dump seen -- HUMIDITY above reads the vendor
# resource's usable fivepercentHumidity field instead; this OCF-standard
# one has no corresponding live value confirmed yet.
'/humidity/0',
# Presence-personalization plumbing (empty item list here).
'/personality/presence/vs/0',
# OCF-standard mirror of /airflow/vs/0 ({speed, direction} vs
# {speedLevel, direction}, identical values). The climate entity reads and
# writes the vendor form, which is the one confirmed on hardware here --
# note that air_purifier.py found the opposite ordering on its family, so
# neither form is reliable sight-unseen and this one stays unmodeled.
'/airflow/0',
# --- TP1X/TP2X-class housekeeping / opaque blobs. These carry no
# user-actionable state or no documented write contract, so per the
# 'don't guess' rule they are ignored rather than modeled.
@@ -427,6 +835,19 @@ _AC_IGNORED = [
# topology -- indoor/outdoor unit pairing, per-unit serials, MCU info.
# Commissioning-time plumbing, not user-actionable appliance state.
'/sac/installationinfo/vs/0',
# Wind-Free 2-in-1 systems (one outdoor unit driving a floor-standing
# *and* a wall-mounted indoor unit over one shared local IP, e.g.
# TP2X_FAC_BORA_21K, issues #150/#153): an opaque paired-subdevice id
# list, same "remote device ids, not user-actionable locally" role as
# /remotedeviceinfo/vs/0 above. This integration talks to whichever
# single local endpoint the config entry was set up against; the
# second indoor unit isn't independently reachable through this
# resource (or any other in the dump) -- it would need its own local
# DTLS session/IP, which SmartThings pairing doesn't expose here.
'/subdevices/vs/0',
# Undocumented single int (runningMode: 0 on every dump seen), no
# supported-values list to interpret it against -- 'don't guess'.
'/runn/vs/0',
]
# Built as bare no-entity caps; folded into the AC registry (not global).
@@ -91,12 +91,25 @@ def _ml_to_l(v):
def _active_alarm_codes(items):
"""Join active alarm codes; skip retained rows Samsung leaves as Deleted.
"""Join active alarm codes; skip retained rows Samsung leaves as Deleted,
and any code ending in '_OFF'.
Laundry boards keep a Deleted ErrorCode row in /alarms/vs/0 after the
condition clears (see WD7000B diagnostics). Surface only live alarms so
HA doesn't stick on a stale ErrorCode. Range-hood has its own stricter
helper that also drops ErrorCode_OFF.
HA doesn't stick on a stale ErrorCode.
Samsung pre-populates this array with one row per alarm *type* the board
supports, each carrying its own '<Name>_OFF' placeholder code when that
alarm isn't firing -- confirmed across independent device families
(ErrorCode_OFF, FilterAlarm_OFF, OV_E_OFF, CT_E_OFF, WaterTankFull_OFF,
AC_V_0002_OFF all appear in fixtures with no corresponding active
condition). An alarm that's actually firing instead reports a plain,
unsuffixed code (FilterAlarm, DoorA_Opened, SNSF_Reached) -- issue #166's
AC dump has both a FilterAlarm_OFF placeholder and shows what a live
filter alert looks like: code 'FilterAlarm' (no suffix), state
'Created'. Range-hood previously special-cased only the literal
'ErrorCode_OFF' string in its own stricter helper; this generalizes
the same rule to the whole '_OFF' suffix convention.
"""
if not items or not isinstance(items, list):
return 'none'
@@ -105,6 +118,7 @@ def _active_alarm_codes(items):
for i in items
if i.get('x.com.samsung.da.code')
and str(i.get('x.com.samsung.da.state', '')).lower() != 'deleted'
and not str(i.get('x.com.samsung.da.code', '')).lower().endswith('_off')
]
return ', '.join(codes) if codes else 'none'
@@ -53,6 +53,10 @@ IGNORED: list[Capability] = [
Capability(href='/setting/vs/0'), # supported/selected UI language
Capability(href='/timezone/vs/0'), # redundant with HA's own timezone
Capability(href='/wm/setinfo/vs/0'), # model/manufacturing metadata
# Resource-monitoring poll-interval config (a bare minPeriod in
# milliseconds, issue #165's TP1X_REF_21K fridge) -- internal transport
# plumbing, not appliance state.
Capability(href='/rm/control/vs/0'),
# Demand Response Load Control — utility-company grid signals; requires
# cloud registration with a utility program we don't support locally.
@@ -107,6 +111,11 @@ IGNORED: list[Capability] = [
# exposed by dryer.DRYER_COURSE at /course/vs/0
# (x.com.samsung.da.st.dryerMode is "Table_03_Course_<same hex code>").
Capability(href='/st/dryercourse/vs/0'),
# AirDresser counterpart of the above (issue #157): read only for its
# courseTable id (air_dresser.AIR_DRESSER_COURSE's table_href), no
# entity of its own -- same "no entity, just the table id" role as
# /st/washercourse/vs/0 and /st/dryercourse/vs/0.
Capability(href='/st/airdressercourse/vs/0'),
# Empty on every washer dump seen so far.
Capability(href='/wm/welcomemsg/vs/0'),
# User-saved custom course slots (F1-FA). No controllable/observable
@@ -21,7 +21,12 @@ different from an oven, and defined fresh here:
* Lamp: this family's option-array token is bare 'Lamp' (issue #137's
'Lamp_Off'), not oven.py's 'UpperLamp' -- and it's genuinely absent on
the combi dump (issue #121), so it's gated with exists_fn rather than
assumed universal like oven.py's lamp switch.
assumed universal like oven.py's lamp switch. Issue #137's dump only
ever showed 'Off', so 'On' was a guess at the paired value; issue #152's
ME7500D dump is the first to show a real non-Off value, and it's 'High'
(a brightness level, not literally 'On') -- the switch now treats any
non-Off/non-None value as "on" for reads, and writes back 'High'/'Off'
(the two confirmed tokens) rather than the never-confirmed 'On'.
Note: cooking-mode writes are unproven here, same caveat as oven.py's
OVEN_MODE -- exposed as a SelectDesc for fidelity, first real-world write
@@ -107,8 +112,21 @@ def _power_level_watts(v):
return int_or_none(s)
def _cooking_mode_options(resources):
"""Live mode list from the device's own /mode/vs/0 supportedModes when
it reports one (both known dumps do); the union-of-all-dumps
_MICROWAVE_MODES guess otherwise. Same live-first, static-fallback
pattern as oven._oven_mode_options -- a fixed list here would offer
users modes their own unit doesn't have (issue #152's ME7500D reports
only 4 of _MICROWAVE_MODES' 11)."""
rep = resources.get('/mode/vs/0') or {}
live = rep.get('x.com.samsung.da.supportedModes')
return list(live) if live else list(_MICROWAVE_MODES)
def _mode_write(p, rep, href=None):
if p not in _MICROWAVE_MODES:
valid = rep.get('x.com.samsung.da.supportedModes') or _MICROWAVE_MODES
if p not in valid:
return None
return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': [p]}
@@ -132,8 +150,12 @@ def _lamp_write(p, rep, href=None):
return None
if not rep.get('x.com.samsung.da.options'):
return None
# 'High' and 'Off' are the two tokens actually confirmed on live dumps
# (issues #137/#152) -- 'On' has never been observed and the device
# likely doesn't recognize it (see module docstring).
token = 'High' if p == 'On' else 'Off'
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': option_write('Lamp', p),
'x.com.samsung.da.options': option_write('Lamp', token),
}
@@ -178,7 +200,7 @@ MICROWAVE_MODE = Capability(
# SelectDesc first — test_microwave_mode_options_nonempty uses entities[0]
SelectDesc(key='cooking_mode', field='x.com.samsung.da.modes',
icon='mdi:tune',
options=_MICROWAVE_MODES,
options=_cooking_mode_options,
value_fn=lambda v: v[0] if v else None,
write_fn=_mode_write),
SwitchDesc(key='sound', field='x.com.samsung.da.options',
@@ -189,7 +211,7 @@ MICROWAVE_MODE = Capability(
SwitchDesc(key='lamp', field='x.com.samsung.da.options',
icon='mdi:track-light',
exists_fn=_lamp_exists,
value_fn=lambda opts: option_value(opts, 'Lamp') == 'On',
value_fn=lambda opts: option_value(opts, 'Lamp') not in (None, 'Off'),
write_fn=_lamp_write),
),
)
@@ -51,6 +51,14 @@ SETPOINT_STEP_F = 5
# Mode options seen on NV7000BS-class. No dump exists so this list is inferred
# from Samsung documentation and firmware observations. The firmware will
# reject unknown modes; missing entries here are a coverage gap, not a bug.
#
# This is a fallback only, used when a device's own /mode/vs/0 doesn't report
# x.com.samsung.da.supportedModes at all -- see _oven_mode_options/
# _oven_mode_write below. issue #138's range dump (NE63A6511SS/AA) reports
# ConvectionRoast/KeepWarm/BreadProof/AirFryer/Dehydrate/SelfClean/SteamClean
# in its own supportedModes; those are read live rather than added here, per
# the adding-device-support skill's preference for device-reported option
# lists over hardcoded ones.
_OVEN_MODES = (
'NoOperation',
'Bake',
@@ -173,8 +181,20 @@ def _cook_time_write(p, rep, href=None):
}
def _oven_mode_options(resources):
"""Live mode list from the device's own /mode/vs/0 supportedModes when
it reports one; the NV7000BS-era _OVEN_MODES guess otherwise. Mirrors
laundry.py's options_field pattern (buzzer_sound/finish_sound), but
needs the callable form rather than options_field because a static
fallback has to kick in when the device's own field is absent."""
rep = resources.get('/mode/vs/0') or {}
live = rep.get('x.com.samsung.da.supportedModes')
return list(live) if live else list(_OVEN_MODES)
def _oven_mode_write(p, rep, href=None):
if p not in _OVEN_MODES:
valid = rep.get('x.com.samsung.da.supportedModes') or _OVEN_MODES
if p not in valid:
return None
return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': [p]}
@@ -351,7 +371,7 @@ OVEN_MODE = Capability(
# SelectDesc first — test_oven_mode_options_nonempty uses entities[0]
SelectDesc(key='oven_mode', field='x.com.samsung.da.modes',
icon='mdi:tune',
options=_OVEN_MODES,
options=_oven_mode_options,
value_fn=lambda v: v[0] if v else None,
write_fn=_oven_mode_write),
SwitchDesc(key='lamp', field='x.com.samsung.da.options',
@@ -73,7 +73,15 @@ def discover(
registry: dict[str, list[Capability]],
pattern_caps: Iterable[Capability] = (),
log: Optional[Callable[[str], None]] = None,
tier_log: Optional[Callable[[str, str], None]] = None,
) -> list[BoundEntity]:
"""`tier_log(href, poll_tier)` fires for every href a capability actually
matches, even a no-entity "coverage-only" capability (see COVERAGE lists
in capabilities/*.py) that `_bind()` turns into zero `BoundEntity` rows.
Callers that need a href's poll cadence (the coordinator's hot/warm
sub-poll and OBSERVE-attempt lists) must use this, not `bound` -- a
coverage-only capability's `poll_tier` would otherwise be silently
dropped since it never appears in `bound`."""
out: list[BoundEntity] = []
for href, rep in resources.items():
@@ -91,6 +99,8 @@ def discover(
inst = instance_suffix(href)
out.extend(_bind(cap, href, inst, _instance_name(cap, rep)))
matched = True
if tier_log is not None:
tier_log(href, cap.poll_tier)
if matched:
continue
@@ -109,6 +119,8 @@ def discover(
segs = [s for s in src.strip('/').split('/') if s and not s.isdigit() and s != 'vs']
out.extend(_bind(cap, href, inst, _instance_name(cap, rep), '_'.join(segs)))
matched = True
if tier_log is not None:
tier_log(href, cap.poll_tier)
break
if not matched and not caps and log is not None:
@@ -120,7 +120,8 @@
"state_attributes": {
"fan_mode": {
"state": {
"turbo": "Turbo"
"turbo": "Turbo",
"max": "Max"
}
},
"preset_mode": {
@@ -134,7 +135,8 @@
"longwind": "Long wind",
"motionindirect": "Motion indirect",
"motiondirect": "Motion direct",
"drycomfort": "Dry comfort"
"drycomfort": "Dry comfort",
"2step": "2-Step"
}
}
}
@@ -156,6 +158,9 @@
}
},
"number": {
"buzzer_volume": {
"name": "Beep volume"
},
"cook_time": {
"name": "Cook time"
},
@@ -165,6 +170,9 @@
"dispense_capacity": {
"name": "Dispense capacity"
},
"good_sleep": {
"name": "Good Sleep"
},
"instance_setpoint": {
"name": "{instance_name} setpoint"
},
@@ -179,12 +187,18 @@
},
"target_humidity": {
"name": "Target humidity"
},
"tropical_night_mode": {
"name": "Tropical night mode"
}
},
"select": {
"ai_energy_level": {
"name": "AI Energy Mode level"
},
"air_filter_threshold": {
"name": "Filter alarm threshold"
},
"beverage_zone_mode": {
"name": "Beverage zone mode",
"state": {
@@ -572,6 +586,9 @@
"air_filter_usage": {
"name": "Filter usage"
},
"air_filter_usage_hours": {
"name": "Filter usage hours"
},
"air_sensing_state": {
"name": "Air sensing state"
},
@@ -609,9 +626,15 @@
"current_limit_level": {
"name": "Current limit level"
},
"filter_time": {
"name": "Filter time"
},
"hepa_filter_usage": {
"name": "HEPA filter usage"
},
"outdoor_temperature": {
"name": "Outdoor temperature"
},
"panel_status": {
"name": "Panel status"
},
@@ -736,9 +759,6 @@
"hood_filter_usage": {
"name": "Filter usage"
},
"humidity": {
"name": "Humidity"
},
"instance_temperature": {
"name": "{instance_name} temperature"
},
@@ -869,12 +889,18 @@
"ai_energy_level": {
"name": "AI Energy Mode"
},
"air_monitoring": {
"name": "Air monitoring"
},
"air_purify": {
"name": "Air purification"
},
"auto_clean": {
"name": "Auto clean"
},
"beep": {
"name": "Beep"
},
"display": {
"name": "Display"
},
@@ -887,6 +913,9 @@
"dustbin_auto_close": {
"name": "Dustbin auto-close"
},
"spi": {
"name": "Purifying ion"
},
"uvc_intensive_mode": {
"name": "UV-C intensive mode"
},
@@ -120,7 +120,8 @@
"state_attributes": {
"fan_mode": {
"state": {
"turbo": "Turbo"
"turbo": "Turbo",
"max": "Max"
}
},
"preset_mode": {
@@ -134,7 +135,8 @@
"longwind": "Lange wind",
"motionindirect": "Beweging indirect",
"motiondirect": "Beweging direct",
"drycomfort": "Droog comfort"
"drycomfort": "Droog comfort",
"2step": "2-Step"
}
}
}
@@ -156,6 +158,9 @@
}
},
"number": {
"buzzer_volume": {
"name": "Zoemervolume"
},
"cook_time": {
"name": "Bereidingstijd"
},
@@ -165,6 +170,9 @@
"dispense_capacity": {
"name": "Schenkhoeveelheid"
},
"good_sleep": {
"name": "Good Sleep"
},
"instance_setpoint": {
"name": "Instelpunt {instance_name}"
},
@@ -179,12 +187,18 @@
},
"target_humidity": {
"name": "Doelvochtigheid"
},
"tropical_night_mode": {
"name": "Tropische nachtmodus"
}
},
"select": {
"ai_energy_level": {
"name": "Niveau AI Energy Mode"
},
"air_filter_threshold": {
"name": "Filteralarmdrempel"
},
"beverage_zone_mode": {
"name": "Modus drankenzone",
"state": {
@@ -572,6 +586,9 @@
"air_filter_usage": {
"name": "Filterverbruik"
},
"air_filter_usage_hours": {
"name": "Filterverbruik (uren)"
},
"air_sensing_state": {
"name": "Status luchtmeting"
},
@@ -609,9 +626,15 @@
"current_limit_level": {
"name": "Niveau stroombegrenzing"
},
"filter_time": {
"name": "Filtertijd"
},
"hepa_filter_usage": {
"name": "HEPA-filtergebruik"
},
"outdoor_temperature": {
"name": "Buitentemperatuur"
},
"panel_status": {
"name": "Paneelstatus"
},
@@ -736,9 +759,6 @@
"hood_filter_usage": {
"name": "Filterverbruik"
},
"humidity": {
"name": "Luchtvochtigheid"
},
"instance_temperature": {
"name": "Temperatuur {instance_name}"
},
@@ -869,12 +889,18 @@
"ai_energy_level": {
"name": "AI Energy Mode"
},
"air_monitoring": {
"name": "Luchtmonitoring"
},
"air_purify": {
"name": "Luchtzuivering"
},
"auto_clean": {
"name": "Automatisch reinigen"
},
"beep": {
"name": "Piep"
},
"display": {
"name": "Display"
},
@@ -887,6 +913,9 @@
"dustbin_auto_close": {
"name": "Stofreservoir automatisch sluiten"
},
"spi": {
"name": "Ionisatie"
},
"uvc_intensive_mode": {
"name": "Intensieve UV-C-modus"
},
+209
View File
@@ -0,0 +1,209 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/alarms/vs/0",
"rep": {}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "0000000000",
"x.com.samsung.da.countryCode": "VN"
}
},
{
"href": "/course/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"HOMECARE_WIZARD_V2"
],
"x.com.samsung.da.options": [
"DeviceType_015E",
"UpdateAllow_NotAllowed",
"Course_01",
"SteamPush_None",
"SeamlessControl_Disable",
"KidsLockBypass_On",
"WashingTimes_58",
"DrumCleanProposal_30",
"SpecialFunction_4",
"AvailableDelayTime_0",
"ProgressTimeSet_AA01E0AC00B4B20690",
"WrinklePreventRunning_Off",
"SendToDevice_Off",
"GMT_12",
"Alarm_Off",
"SilentSet_0F0FF0F0F0F0F0F0F0F00F0F0F0FF0F0F00FF0",
"Silent_Off",
"WrinklePreventSet_0F0F0F0F0F0F0F0FF0F00F0F0F0F0F0F0F0FF0",
"DelayEndSet_0F0F0F0FF00F0F0F0F0F0F0F0F0F0F0F0F0F0F",
"OneTimeCourse_761F00200000000000000000000000",
"DownloadCourseList_090A0B12100C140D0F",
"EnergyLevelSet_0502020104030204050405020303010302010305",
"MostUsed_01",
"UsagesDB_ok",
"EnergyKW_396",
"DrumCleanLog_2024-01-01T00:00:00",
"TimeSync_NotSupported"
],
"x.com.samsung.da.supportedOptions": [
"001020403051A1B1C0708"
]
}
},
{
"href": "/cycleinterface/vs/0",
"rep": {}
},
{
"href": "/diagnosis/vs/0",
"rep": {
"x.com.samsung.da.diagnosisStart": "Ready"
}
},
{
"href": "/energy/consumption/0",
"rep": {}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.instantaneousPowerUnit": "W",
"x.com.samsung.da.instantaneousPower": "-500",
"x.com.samsung.da.cumulativePower": "78200",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.cumulativeDate": "1785272400",
"x.com.samsung.da.cumulativeDateUTC": "1785240000"
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+09:00"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "DA_DF_A51_20_COMMON|20261441|3801010200131100020100FF00000000",
"x.com.samsung.da.description": "DA_DF_A51_20_COMMON_DF8600T/DC92-02656A_0018",
"x.com.samsung.da.serialNum": "REDACTED",
"x.com.samsung.da.otnDUID": "REDACTED",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "DA_DF_A51_20_COMMON|20261441|3801010200131100020100FF00000000",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02672A230710(E431)",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "DA_DF_A51_20_COMMON",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "20060507,20072702",
"x.com.samsung.da.newVersionAvailable": "0"
}
]
}
},
{
"href": "/kidslock/0",
"rep": {
"value": false
}
},
{
"href": "/kidslock/vs/0",
"rep": {
"x.com.samsung.da.kidsLock": "Ready"
}
},
{
"href": "/operational/state/0",
"rep": {
"currentMachineState": "REDACTED",
"machineStates": "REDACTED",
"jobStates": [
"None",
"Steaming",
"Airwashing",
"Drying",
"Finish"
],
"currentJobState": "None",
"remainingTime": "00:39:00",
"progressPercentage": "1"
}
},
{
"href": "/operational/state/vs/0",
"rep": {
"x.com.samsung.da.state": "Ready",
"x.com.samsung.da.remainingTime": "00:39:00",
"x.com.samsung.da.progressPercentage": "1",
"x.com.samsung.da.progress": "None",
"x.com.samsung.da.delayEndTime": "00:00:00",
"x.com.samsung.da.supportedProgress": [
"None",
"Steaming",
"Airwashing",
"Drying",
"Finish"
]
}
},
{
"href": "/power/0",
"rep": {
"value": true
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "On"
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "10",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/remotectrl/0",
"rep": {
"value": false
}
},
{
"href": "/remotectrl/vs/0",
"rep": {
"x.com.samsung.da.remoteControlEnabled": "false"
}
},
{
"href": "/washer/vs/0",
"rep": {
"x.com.samsung.da.wrinklePrevent": "Off"
}
},
{
"href": "/wm/jobbeginingstatus/vs/0",
"rep": {}
}
]
}
+282
View File
@@ -0,0 +1,282 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/airdresseroption/sanitize/vs/0",
"rep": {
"x.com.samsung.da.sanitize": "Off",
"x.com.samsung.da.supportedSanitize": [
"On",
"Off"
]
}
},
{
"href": "/alarms/vs/0",
"rep": {}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "3171000000",
"x.com.samsung.da.countryCode": "KR"
}
},
{
"href": "/course/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"HOMECARE_WIZARD_V2"
],
"x.com.samsung.da.options": [
"DeviceType_015E",
"UpdateAllow_NotAllowed",
"Course_22",
"AiOption_On",
"SteamPush_None",
"SeamlessControl_Disable",
"KidsLockBypass_On",
"FilterUsage_384019D5",
"WashingTimes_6",
"DrumCleanProposal_30",
"ProgressTimeSet_AA01A4AC00F0B20690",
"WrinklePreventRunning_Off",
"SendToDevice_Off",
"WeatherPush_Off",
"GMT_12",
"Alarm_Off",
"WrinklePreventSet_0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0FF00F0F0F0F0F0F0F0F0F0F0FF0F0",
"DelayEndSet_0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0FF0F0F00F0F0FF0F0F0F0F00F0F",
"EnergyLevelSet_0502010102010202030303010401040505050503030502040503030303030405",
"UsagesDB_ok",
"DrumCleanLog_2023-04-02T08:08:55|2023-10-02T03:38:19|2024-05-26T03:25:41|2024-10-01T01:07:20|2026-06-16T02:23:29"
],
"x.com.samsung.da.supportedOptions": [
"12261062361020E61020961021261020C61021E61020B61021061020A61061461021361061661022462062562062F62062062040F62042761023061021561021A61021B61021C61022A61022B61022C61022D61022E6102076102086102"
]
}
},
{
"href": "/cycleinterface/vs/0",
"rep": {
"x.com.samsung.da.cycleInterfaceEnabled": "Off"
}
},
{
"href": "/diagnosis/vs/0",
"rep": {
"x.com.samsung.da.diagnosisStart": "Ready"
}
},
{
"href": "/energy/consumption/0",
"rep": {}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.instantaneousPower": "-500",
"x.com.samsung.da.instantaneousPowerUnit": "W",
"x.com.samsung.da.cumulativePower": "63600",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.cumulativeDate": "1784404800",
"x.com.samsung.da.cumulativeDateUTC": "1784372400"
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+09:00"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "DA_DF_TP2_20_COMMON|20286141|380101010015110F0201000100010000",
"x.com.samsung.da.description": "DA_DF_TP2_20_COMMON_DF9500A/DC92-02888A_0002",
"x.com.samsung.da.serialNum": "REDACTED",
"x.com.samsung.da.otnDUID": "REDACTED",
"x.com.samsung.da.diagProtocolType": "WIFI_HTTPS",
"x.com.samsung.da.diagLogType": [
"errCode",
"dump"
],
"x.com.samsung.da.diagDumpType": "file",
"x.com.samsung.da.diagEndPoint": "SSM",
"x.com.samsung.da.diagMnid": "0AJT",
"x.com.samsung.da.diagSetupid": "A00",
"x.com.samsung.da.diagMinVersion": "1.0",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "DA_DF_TP2_20_COMMON|20286141|380101010015110F0201000100010000",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02673A250416(F822)",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Firmware_1_DB",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "21020115,23030652",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Firmware_2_DB",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "19111852,FFFFFFFF"
}
]
}
},
{
"href": "/kidslock/0",
"rep": {
"value": false
}
},
{
"href": "/kidslock/vs/0",
"rep": {
"x.com.samsung.da.kidsLock": "Ready"
}
},
{
"href": "/operational/state/0",
"rep": {
"currentMachineState": "REDACTED",
"machineStates": "REDACTED",
"jobStates": [
"None",
"Steaming",
"Airwashing",
"Drying",
"Finish"
],
"currentJobState": "None",
"remainingTime": "00:39:00",
"progressPercentage": "1"
}
},
{
"href": "/operational/state/vs/0",
"rep": {
"x.com.samsung.da.state": "Ready",
"x.com.samsung.da.remainingTime": "00:39:00",
"x.com.samsung.da.progressPercentage": "1",
"x.com.samsung.da.progress": "None",
"x.com.samsung.da.delayEndTime": "00:00:00",
"x.com.samsung.da.supportedProgress": [
"None",
"Steaming",
"Airwashing",
"Drying",
"Finish"
]
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "Micom",
"x.com.samsung.da.newVersionAvailable": "false"
}
},
{
"href": "/power/0",
"rep": {
"value": false
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "Off"
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/remotectrl/0",
"rep": {
"value": false
}
},
{
"href": "/remotectrl/vs/0",
"rep": {
"x.com.samsung.da.remoteControlEnabled": "false"
}
},
{
"href": "/st/airdressercourse/vs/0",
"rep": {
"x.com.samsung.da.st.airdresserMode": "Table_00_Course_22",
"x.com.samsung.da.st.courseTable": "Table_00"
}
},
{
"href": "/washer/vs/0",
"rep": {
"x.com.samsung.da.wrinklePrevent": "Off"
}
},
{
"href": "/wm/editcourse/vs/0",
"rep": {
"x.com.samsung.da.editCourseList": "EditCourseList_22230C10252F0F0E09121E0B0A14131624202730151A1B1C2A2B2C2D2E0708",
"x.com.samsung.da.fixedCourseList": "FixedCourseList_22270F"
}
},
{
"href": "/wm/jobbeginingstatus/vs/0",
"rep": {}
},
{
"href": "/wm/personalcourse/vs/0",
"rep": {
"x.com.samsung.da.courses": [
"F1_00",
"F2_00",
"F3_00",
"F4_00",
"F5_00",
"F6_00",
"F7_00",
"F8_00",
"F9_00",
"FA_00"
],
"x.com.samsung.da.maxCourseNum": "10"
}
},
{
"href": "/wm/setinfo/vs/0",
"rep": {
"x.com.samsung.da.isModelSettingWithoutSC": "false",
"x.com.samsung.da.aiCourse": "false",
"x.com.samsung.da.isModelSettingPowerOnOff": "true"
}
},
{
"href": "/wm/welcomemsg/vs/0",
"rep": {}
}
]
}
+310
View File
@@ -0,0 +1,310 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/airlevelcheck/vs/0",
"rep": {
"x.com.samsung.da.periodicSensingActivationState": "Off",
"x.com.samsung.da.periodicSensingInterval": "600",
"x.com.samsung.da.startSensingOnce": "On",
"x.com.samsung.da.sensingState": "NonProcessing",
"x.com.samsung.da.lastSensingTime": "1785195000",
"x.com.samsung.da.lastSensingLevel": "Kr1",
"x.com.samsung.da.autoExeState": "Off",
"x.com.samsung.da.supportedAutoExeState": [
"Off",
"Airpurify",
"Alarm"
],
"x.com.samsung.da.periodicSensingSkipStatus": "Off",
"x.com.samsung.da.periodicSensingSkipTime": "15002100"
}
},
{
"href": "/alarms/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "ErrorCode_OFF",
"x.com.samsung.da.triggeredTime": "2024-01-01T00:00:00",
"x.com.samsung.da.state": "Deleted"
}
]
}
},
{
"href": "/availablecontrolsets/vs/0",
"rep": {
"x.com.samsung.da.sets": "0000000000000000000000000000",
"x.com.samsung.da.id": "VTL",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "3017000000",
"x.com.samsung.da.airconOptionList": [
"WELCOMECARE",
"AI_PURIFY"
],
"x.com.samsung.da.countryCode": ""
}
},
{
"href": "/da/softreset/vs/0",
"rep": {
"x.com.samsung.da.softwarereset": "false"
}
},
{
"href": "/devicespecificinfo/vs/0",
"rep": {
"x.com.samsung.da.deviceActive": true
}
},
{
"href": "/dnd/autosleep/vs/0",
"rep": {
"x.com.samsung.da.startTime": "14:00:00",
"x.com.samsung.da.endTime": "22:00:00",
"x.com.samsung.da.visible": "true",
"x.com.samsung.da.useTimeSetting": "true",
"x.com.samsung.da.functionState": "true"
}
},
{
"href": "/energy/consumption/0",
"rep": {}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.cumulativePower": "196696",
"x.com.samsung.da.cumulativeDate": "1785245199",
"x.com.samsung.da.cumulativeDateUTC": "1785212799",
"x.com.samsung.da.cumulativeUnit": "Wh"
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+09:00"
}
},
{
"href": "/filter/hepafilter/vs/0",
"rep": {
"x.com.samsung.da.filterUsage": "0",
"x.com.samsung.da.filterUsageResolution": "1",
"x.com.samsung.da.filterStatus": "normal",
"x.com.samsung.da.filterCapacity": "8760",
"x.com.samsung.da.filterCapacityUnit": "Hour",
"x.com.samsung.da.filterResetType": [
"replaceable"
]
}
},
{
"href": "/humidity/0",
"rep": {}
},
{
"href": "/humidity/vs/0",
"rep": {}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "A-VTWW-TP2-21-COMMON|10231241|70000535001511EC0505090201000000",
"x.com.samsung.da.description": "A-VTWW-TP2-21-COMMON",
"x.com.samsung.da.serialNum": "REDACTED",
"x.com.samsung.da.otnDUID": "REDACTED",
"x.com.samsung.da.diagProtocolType": "WIFI_HTTPS",
"x.com.samsung.da.diagLogType": [
"errCode",
"dump"
],
"x.com.samsung.da.diagDumpType": "file",
"x.com.samsung.da.diagEndPoint": "SSM",
"x.com.samsung.da.diagMnid": "0AJT",
"x.com.samsung.da.diagSetupid": "101",
"x.com.samsung.da.diagMinVersion": "1.0",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02308A260310",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "21063003,21063002",
"x.com.samsung.da.newVersionAvailable": "0"
}
]
}
},
{
"href": "/mode/convenient/vs/0",
"rep": {}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.options": [
"CountOfSlave_0",
"SlavePower01_NotSupported",
"Pollution_Off",
"OptionCode_23418",
"SmartSleep_Off"
]
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "",
"x.com.samsung.da.newVersionAvailable": "false",
"x.com.samsung.da.newVersionNo": "00000000",
"x.com.samsung.da.currentVersionInfo": "00000000",
"otnStatus": "None",
"flashingProgress": "",
"otnTarget": "main",
"otnCompleteDate": "noHistory",
"otnList": [
{
"type": "WIFI",
"modelId": "A-VTWW-TP2-21-COMMON",
"versions": [
"12260310"
],
"visVersion": "260310"
},
{
"type": "Micom",
"modelId": "09501023124110231341",
"versions": [
"21063003",
"21063002"
],
"visVersion": "210630"
}
]
}
},
{
"href": "/power/0",
"rep": {
"value": false
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "Off"
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "7",
"x.com.samsung.da.longnotisubscription": "true",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/sensors/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Sensor for Dust",
"x.com.samsung.da.type": "Dust",
"x.com.samsung.da.value": [
"5",
"1"
]
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Sensor for FineDust",
"x.com.samsung.da.type": "FineDust",
"x.com.samsung.da.value": [
"5",
"1"
]
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Sensor for Odor",
"x.com.samsung.da.type": "Odor",
"x.com.samsung.da.value": [
"1"
]
},
{
"x.com.samsung.da.id": "3",
"x.com.samsung.da.description": "Sensor for CleanLevel",
"x.com.samsung.da.type": "CleanLevel",
"x.com.samsung.da.value": [
"1"
]
},
{
"x.com.samsung.da.id": "4",
"x.com.samsung.da.description": "Sensor for SuperFineDust",
"x.com.samsung.da.type": "SuperFineDust",
"x.com.samsung.da.value": [
"5",
"1"
]
}
],
"x.com.samsung.da.total": "5"
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "Asia/Seoul",
"offset": "+09:00",
"DST": "OFF"
}
},
{
"href": "/wind/strength/vs/0",
"rep": {
"x.com.samsung.da.modes": "87",
"x.com.samsung.da.supportedModes": [
"87",
"89",
"90",
"91"
],
"x.com.samsung.da.modesName": [
"SMART",
"MAX",
"WINDFREE",
"Sleep"
]
}
}
]
}
@@ -0,0 +1,232 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/airflow/0",
"rep": {
"speed": 3,
"direction": "Fix"
}
},
{
"href": "/airflow/vs/0",
"rep": {
"x.com.samsung.da.speedLevel": "3",
"x.com.samsung.da.direction": "Fix"
}
},
{
"href": "/alarms/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "ErrorCode_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-28T00:18:15",
"x.com.samsung.da.state": "Deleted"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "FilterAlarm_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-28T00:18:15",
"x.com.samsung.da.state": "Deleted"
}
]
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "0000000000"
}
},
{
"href": "/diagnosis/vs/0",
"rep": {
"x.com.samsung.da.diagnosisStart": "Ready"
}
},
{
"href": "/energy/consumption/0",
"rep": {}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.cumulativePower": "0"
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+01:00",
"x.com.samsung.supprtedtype": 1
}
},
{
"href": "/humidity/0",
"rep": {
"humidity": 0
}
},
{
"href": "/humidity/vs/0",
"rep": {
"x.com.samsung.da.humidity": "0"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "ARTIK051_KRAC_18K|10193441|60010119001111110200000000000000",
"x.com.samsung.da.description": "ARTIK051_KRAC_18K",
"x.com.samsung.da.serialNum": "**REDACTED**",
"x.com.samsung.da.otnDUID": "**REDACTED**",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02016A200825",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "18020800,17120500",
"x.com.samsung.da.newVersionAvailable": "0"
}
]
}
},
{
"href": "/mode/0",
"rep": {
"supportedModes": [
"Cool",
"Dry",
"Wind",
"Auto",
"Heat",
"HOMECARE_WIZARD_V2"
],
"modes": [
"Cool"
]
}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"Cool",
"Dry",
"Wind",
"Auto",
"Heat",
"HOMECARE_WIZARD_V2"
],
"x.com.samsung.da.modes": [
"Cool"
],
"x.com.samsung.da.options": [
"Comode_Off",
"Sleep_0",
"OutdoorTemp_74",
"CoolCapa_25",
"WarmCapa_32",
"Spi_Off",
"Autoclean_Off",
"Light_Off",
"Volume_100",
"AirMonitoring_Off",
"AutocleanProgress_1",
"StopAutoClean_Idle",
"FilterTime_1715",
"FilterAlarmTime_500",
"OptionCode_35882",
"ExtendOptionCode_7",
"RacInfo_None",
"UpdateAllow_NotAllowed"
]
}
},
{
"href": "/personality/presence/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "",
"x.com.samsung.da.deviceId": "**REDACTED**",
"x.com.samsung.da.value": ""
}
]
}
},
{
"href": "/power/0",
"rep": {
"value": false
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "Off"
}
},
{
"href": "/temperature/current/0",
"rep": {
"range": [
16.0,
30.0
],
"units": "C",
"temperature": 21.0
}
},
{
"href": "/temperature/desired/0",
"rep": {
"range": [
16.0,
30.0
],
"units": "C",
"temperature": 20.0
}
},
{
"href": "/temperatures/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Temperature",
"x.com.samsung.da.desired": "20",
"x.com.samsung.da.current": "21",
"x.com.samsung.da.maximum": "30",
"x.com.samsung.da.minimum": "16",
"x.com.samsung.da.unit": "Celsius"
}
]
}
}
]
}
+446
View File
@@ -0,0 +1,446 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/alarms/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "ErrorCode_OFF",
"x.com.samsung.da.triggeredTime": "2024-01-01T00:00:00"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "AC_V_0002_OFF",
"x.com.samsung.da.triggeredTime": "2024-01-01T00:00:00"
}
]
}
},
{
"href": "/availablecontrolsets/vs/0",
"rep": {
"x.com.samsung.da.sets": "000000B4012C0000404B0C000000",
"x.com.samsung.da.id": "FAC",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "2824500000",
"x.com.samsung.da.airconOptionList": [
"HOMECARE_WIZARD_V2",
"ENERGY_2.0",
"AI_2.0",
"DeviceTypeMaster",
"SingleCommand_1"
]
}
},
{
"href": "/diagnosis/vs/0",
"rep": {}
},
{
"href": "/drlc/0",
"rep": {
"DRLevel": 0,
"start": "1970-01-01T00:00:00Z",
"duration": 0,
"override": false
}
},
{
"href": "/drlc/vs/0",
"rep": {
"x.com.samsung.da.drlcLevel": "0",
"x.com.samsung.da.duration": "00:00:00",
"x.com.samsung.da.drlcStartTime": "1970-01-01T00:00:00Z",
"x.com.samsung.da.override": "Off"
}
},
{
"href": "/energy/consumption/0",
"rep": {
"energy": 100.0,
"power": 65278.0
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.cumulativeConsumption": "100.000000",
"x.com.samsung.da.instantaneousPower": "65278.000000",
"x.com.samsung.da.usageThreshold": "0.000000",
"x.com.samsung.da.cumulativePower": "1278818",
"x.com.samsung.da.cumulativeDate": "1785243600",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.instantaneousPowerUnit": "W",
"x.com.samsung.da.cumulativePowerType": "individual"
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+09:00",
"x.com.samsung.supprtedtype": 1
}
},
{
"href": "/filter/airdustfilter/vs/0",
"rep": {
"x.com.samsung.da.filterUsage": "100",
"x.com.samsung.da.filterUsageResolution": "1",
"x.com.samsung.da.filterDesiredUsage": "112",
"x.com.samsung.da.filterStatus": "wash",
"x.com.samsung.da.filterCapacity": "112",
"x.com.samsung.da.filterCapacityUnit": "Hour",
"x.com.samsung.da.filterResetType": [
"washable"
],
"x.com.samsung.da.supportedFilterDesiredUsage": [
"112",
"224",
"336",
"448"
]
}
},
{
"href": "/humidity/0",
"rep": {
"humidity": 0
}
},
{
"href": "/humidity/vs/0",
"rep": {
"x.com.samsung.da.humidity": "0.000000",
"x.com.samsung.da.fivepercentHumidity": "73"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "TP2X_FAC_BORA_21K|10233041|600001110015110006000C1200830000",
"x.com.samsung.da.description": "TP2X_FAC_BORA_21K",
"x.com.samsung.da.serialNum": "REDACTED",
"x.com.samsung.da.otnDUID": "REDACTED",
"x.com.samsung.da.diagProtocolType": "WIFI_HTTPS",
"x.com.samsung.da.diagLogType": [
"errCode",
"dump"
],
"x.com.samsung.da.diagDumpType": "file",
"x.com.samsung.da.diagEndPoint": "SSM",
"x.com.samsung.da.diagMnid": "0AJT",
"x.com.samsung.da.diagSetupid": "000",
"x.com.samsung.da.diagMinVersion": "1.0",
"x.com.samsung.da.serialNumOption": "REDACTED",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02337A260424",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "2102240021022200",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Outdoor",
"x.com.samsung.da.number": "2103300110000300"
}
]
}
},
{
"href": "/keepnormalstate/vs/0",
"rep": {
"x.com.samsung.da.keepnormal": 1
}
},
{
"href": "/mode/convenient/vs/0",
"rep": {
"x.com.samsung.da.modes": "Off",
"x.com.samsung.da.supportedModes": [
"Off",
"Sleep",
"Quiet",
"Speed"
]
}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"AIComfort",
"Cool",
"Dry",
"Wind"
],
"x.com.samsung.da.modes": [
"AIComfort"
],
"x.com.samsung.da.options": [
"Operation_Family",
"Blooming_0",
"OnTimer_0",
"OffTimer_0",
"Sleep_16",
"ArtificialWorking_Off",
"ComfortAICooling_Off",
"AiTempChanged_Off",
"AiTemp_270",
"welcomecare_Off",
"Panel_Close",
"Weather_Off",
"Volume_100",
"StopAutoClean_Idle",
"DiagnosisAI_Off",
"Display_Off",
"ProgressDiagnosisAI_1",
"ResultDiagnosisAI_Normal",
"Service_Off",
"SmartCoolClean_Off",
"ProgressSmartClean_0",
"OutDoorVentil_Off",
"FreezeAlarmSetting_On",
"DesiredFreezeAlarm_112",
"OptionCode_529",
"ExtendOptionCode_16975",
"RacInfo_First",
"RacInfo_None_Second",
"ModelInfo_16K_BORA_VENT2",
"UpdateAllow_NotAllowed",
"EnergySaveIcon_Off",
"DurationOn_0",
"welcomecareElapsedTime_0",
"welcomecareThresholdTemp_0",
"welcomecareStartDate_0000",
"welcomecareEndDate_0000",
"welcomecareSeason_None"
]
}
},
{
"href": "/option/autoclean/vs/0",
"rep": {
"x.com.samsung.da.status": "Stop",
"x.com.samsung.da.settingStatus": "On",
"x.com.samsung.da.progress": "0",
"x.com.samsung.da.supportedStatus": [
"Start",
"SpeedClean",
"QuietClean",
"Stop"
],
"x.com.samsung.da.supportedSettingStatus": [
"On",
"SpeedClean",
"QuietClean",
"Off"
]
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "",
"x.com.samsung.da.newVersionAvailable": "false",
"x.com.samsung.da.newVersionNo": "00000000",
"x.com.samsung.da.currentVersionInfo": "00000000",
"otnStatus": "None",
"flashingProgress": "",
"otnTarget": "main",
"otnCompleteDate": "noHistory",
"otnList": [
{
"type": "WIFI",
"modelId": "AFA-KR-TP2-21-AF9X00",
"versions": [
"10260424"
],
"visVersion": "260424"
},
{
"type": "Micom",
"modelId": "04511023304110232941",
"versions": [
"21022400",
"21022200"
],
"visVersion": "210224"
},
{
"type": "Micom",
"modelId": "04511022974110229941",
"versions": [
"21033001",
"10000300"
],
"visVersion": "210330"
},
{
"type": "Micom",
"modelId": "045110230741FFFFFFFF",
"versions": [
"22050300",
"FFFFFFFF"
],
"visVersion": "220503"
}
]
}
},
{
"href": "/personality/presence/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "",
"x.com.samsung.da.deviceId": "REDACTED",
"x.com.samsung.da.value": ""
}
]
}
},
{
"href": "/power/0",
"rep": {
"value": false
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "Off"
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.longnotisubscription": "true",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/runn/vs/0",
"rep": {
"x.com.samsung.da.runningMode": 0
}
},
{
"href": "/subdevices/vs/0",
"rep": {
"x.com.samsung.da.subdeviceIdList": "REDACTED"
}
},
{
"href": "/temperature/control/vs/0",
"rep": {
"x.com.samsung.da.increment": "1"
}
},
{
"href": "/temperature/current/0",
"rep": {
"range": [
18.0,
30.0
],
"units": "C",
"temperature": 31.0
}
},
{
"href": "/temperature/desired/0",
"rep": {
"range": [
18.0,
30.0
],
"units": "C",
"temperature": 27.0
}
},
{
"href": "/temperatures/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Temperature",
"x.com.samsung.da.desired": "27.0",
"x.com.samsung.da.current": "31.0",
"x.com.samsung.da.maximum": "30",
"x.com.samsung.da.minimum": "18",
"x.com.samsung.da.increment": "1.0",
"x.com.samsung.da.unit": "Celsius"
}
]
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "Asia/Seoul",
"offset": "+09:00",
"DST": "OFF"
}
},
{
"href": "/wind/direction/vs/0",
"rep": {
"x.com.samsung.da.modes": "NotSupported",
"x.com.samsung.da.supportedModes": [
"NotSupported"
]
}
},
{
"href": "/wind/strength/vs/0",
"rep": {
"x.com.samsung.da.modes": "0",
"x.com.samsung.da.supportedModes": [
"0",
"2",
"3",
"4"
],
"x.com.samsung.da.modesName": [
"Auto",
"Mid",
"High",
"Turbo"
]
}
}
]
}
+525
View File
@@ -0,0 +1,525 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/aisleep/vs/0",
"rep": {
"x.com.samsung.da.displayNightMode": "Off",
"x.com.samsung.da.elapsedTime": "0",
"x.com.samsung.da.requestFeedback": "Off",
"x.com.samsung.da.resultFeedback": "0",
"x.com.samsung.da.statusFeedback": "Idle",
"x.com.samsung.da.sleepTime": "14002200"
}
},
{
"href": "/alarms/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "ErrorCode_OFF",
"x.com.samsung.da.triggeredTime": "2024-01-01T00:00:00",
"x.com.samsung.da.state": "Deleted"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "FilterAlarm_OFF",
"x.com.samsung.da.triggeredTime": "2024-01-01T00:00:00",
"x.com.samsung.da.state": "Deleted"
}
]
}
},
{
"href": "/availablecontrolsets/vs/0",
"rep": {
"x.com.samsung.da.sets": "1DBF00A0012C3F61024804000000",
"x.com.samsung.da.id": "RAC",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "1150000000",
"x.com.samsung.da.airconOptionList": [
"SingleCommand_1",
"DR",
"HOMECARE_WIZARD_V2",
"AI_RAC_KOREA_COOLONLY_3.0",
"AI_3.0",
"Auto_To_AI"
]
}
},
{
"href": "/connectionconfig/vs/0",
"rep": {
"autoReconnectionMinVersion": "1.0",
"autoReconnection": "true",
"autoReconnectionProtocolType": [
"helper_hotspot",
"ble_ocf"
],
"supportedWiFiAuthType": [
"OPEN",
"WEP",
"WPA-PSK",
"WPA2-PSK",
"SAE"
],
"supportedWiFiCryptoType": [
"TKIP",
"AES",
"WEP-64",
"WEP-128"
],
"supportedWiFiFreq": [
"2.4G"
],
"calmConnectionCare": {
"version": "1.0",
"role": [
"things"
]
}
}
},
{
"href": "/drlc/0",
"rep": {
"DRLevel": 0,
"start": "1970-01-01T00:00:00Z",
"duration": 0,
"override": false
}
},
{
"href": "/drlc/vs/0",
"rep": {
"x.com.samsung.da.drlcLevel": "0",
"x.com.samsung.da.duration": "00:00:00",
"x.com.samsung.da.drlcStartTime": "1970-01-01T00:00:00Z",
"x.com.samsung.da.override": "Off",
"x.com.samsung.da.realSaving": "Off"
}
},
{
"href": "/energy/consumption/0",
"rep": {
"power": 338.0
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.instantaneousPower": "338.000000",
"x.com.samsung.da.cumulativePower": "1362848",
"x.com.samsung.da.cumulativeSavedPower": "0",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.instantaneousPowerUnit": "W"
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+09:00",
"x.com.samsung.supprtedtype": 1
}
},
{
"href": "/filter/airdustfilter/vs/0",
"rep": {
"x.com.samsung.da.filterUsage": "2",
"x.com.samsung.da.filterUsageResolution": "1",
"x.com.samsung.da.filterDesiredUsage": "500",
"x.com.samsung.da.filterStatus": "normal",
"x.com.samsung.da.filterCapacity": "500",
"x.com.samsung.da.filterCapacityUnit": "Hour",
"x.com.samsung.da.filterResetType": [
"replaceable",
"washable"
]
}
},
{
"href": "/humidity/0",
"rep": {
"humidity": 0
}
},
{
"href": "/humidity/vs/0",
"rep": {
"x.com.samsung.da.humidity": "0",
"x.com.samsung.da.fivepercentHumidity": "42"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "TP1X_DA-AC-RAC-01001_0000|10250041|6001051C001711014E00482200912000",
"x.com.samsung.da.description": "TP1X_DA-AC-RAC-01001_0000",
"x.com.samsung.da.serialNum": "REDACTED",
"x.com.samsung.da.otnDUID": "REDACTED",
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
"x.com.samsung.da.diagLogType": [
"errCode",
"dump"
],
"x.com.samsung.da.diagDumpType": "file",
"x.com.samsung.da.diagEndPoint": "SSM",
"x.com.samsung.da.diagMnid": "0AJT",
"x.com.samsung.da.diagSetupid": "AR1",
"x.com.samsung.da.diagMinVersion": "3.0",
"x.com.samsung.da.diagTsId": "DA01",
"x.com.samsung.da.serialNumOption": "REDACTED",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02646A260327",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "102500A23012700",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "102472A22120800,102528A10000100",
"x.com.samsung.da.newVersionAvailable": "0"
}
]
}
},
{
"href": "/keepnormalstate/vs/0",
"rep": {
"x.com.samsung.da.keepnormal": 0
}
},
{
"href": "/light/vs/0",
"rep": {
"mode": "On",
"supportedModes": [
"On",
"Off"
]
}
},
{
"href": "/mode/convenient/vs/0",
"rep": {
"x.com.samsung.da.modes": "Off",
"x.com.samsung.da.supportedModes": [
"Off",
"Sleep",
"Quiet",
"Smart",
"Nano",
"NanoSleep"
]
}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"AIComfort",
"Cool",
"Dry",
"Wind"
],
"x.com.samsung.da.modes": [
"Cool"
],
"x.com.samsung.da.options": [
"Sleep_16",
"ArtificialWorking_Off",
"ComfortAICooling_Off",
"AiTempChanged_Off",
"AiTemp_280",
"welcomecare_Off",
"OutdoorConnection_Connected",
"OutdoorTemp_86",
"CoolCapa_28",
"WarmCapa_0",
"Volume_100",
"StopAutoClean_Set",
"Autoclean_On",
"DiagnosisAI_Off",
"ProgressDiagnosisAI_0",
"ResultDiagnosisAI_Normal",
"SmartCoolClean_Off",
"ProgressSmartClean_0",
"FreezeAlarmSetting_Off",
"DesiredFreezeAlarm_240",
"WashAlarm_Off",
"OptionCode_56440",
"ExtendOptionCode_246479",
"RacInfo_None",
"UpdateAllow_NotAllowed",
"DurationOn_115",
"welcomecareElapsedTime_0",
"welcomecareThresholdTemp_0",
"welcomecareStartDate_0000",
"welcomecareEndDate_0000",
"welcomecareSeason_None"
]
}
},
{
"href": "/option/autoclean/vs/0",
"rep": {
"x.com.samsung.da.status": "Stop",
"x.com.samsung.da.settingStatus": "On",
"x.com.samsung.da.progress": "0",
"x.com.samsung.da.supportedStatus": [
"Start",
"Stop"
],
"x.com.samsung.da.supportedSettingStatus": [
"On",
"Off"
]
}
},
{
"href": "/option/muteonce/vs/0",
"rep": {
"muteonce": "Off"
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "",
"x.com.samsung.da.newVersionAvailable": "false",
"x.com.samsung.da.newVersionNo": "00000000",
"x.com.samsung.da.currentVersionInfo": "00000000",
"otnStatus": "None",
"flashingProgress": "",
"otnTarget": "main",
"otnCompleteDate": "noHistory",
"otnList": [
{
"type": "WIFI",
"modelId": "ARA-WW-TP1-24-ARXX00",
"versions": [
"11260327"
],
"visVersion": "260327"
},
{
"type": "Micom",
"modelId": "045210250041FFFFFFFF",
"versions": [
"23012700",
"FFFFFFFF"
],
"visVersion": "230127"
},
{
"type": "Micom",
"modelId": "04521024724110252841",
"versions": [
"22120800",
"10000100"
],
"visVersion": "221208"
}
]
}
},
{
"href": "/personality/presence/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "",
"x.com.samsung.da.deviceId": "REDACTED",
"x.com.samsung.da.value": ""
}
]
}
},
{
"href": "/power/0",
"rep": {
"value": true
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "On",
"causeSource": "SMTS"
}
},
{
"href": "/quickcontrol/info/vs/0",
"rep": {
"supportedVersion": "1.0"
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.longnotisubscription": "true",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/remotedatacontrol/vs/0",
"rep": {
"x.com.samsung.da.status": "Off",
"x.com.samsung.da.connectionStatus": "Disconnected"
}
},
{
"href": "/remotedeviceinfo/vs/0",
"rep": {
"x.com.samsung.da.didList": ""
}
},
{
"href": "/remotetemperature/vs/0",
"rep": {
"x.com.samsung.da.temperature": "",
"x.com.samsung.da.unit": "",
"x.com.samsung.da.error": ""
}
},
{
"href": "/reserverulesets/vs/0",
"rep": {
"x.com.samsung.da.sets": "1DFFFFFFFF101E121EFFFFFFFFFFFF01000000012F0100000F000000001C00",
"x.com.samsung.da.id": "RAC",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/sensors/vs/0",
"rep": {}
},
{
"href": "/temperature/control/vs/0",
"rep": {
"x.com.samsung.da.increment": "1"
}
},
{
"href": "/temperature/current/0",
"rep": {
"range": [
16,
30
],
"units": "C",
"temperature": 28.0
}
},
{
"href": "/temperature/desired/0",
"rep": {
"range": [
16,
30
],
"units": "C",
"temperature": 25.0
}
},
{
"href": "/temperatures/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Temperature",
"x.com.samsung.da.desired": "25.0",
"x.com.samsung.da.current": "28.0",
"x.com.samsung.da.maximum": "30",
"x.com.samsung.da.minimum": "16",
"x.com.samsung.da.increment": "1.0",
"x.com.samsung.da.unit": "Celsius"
}
]
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "Asia/Seoul",
"offset": "+09:00",
"DST": "OFF"
}
},
{
"href": "/wind/direction/vs/0",
"rep": {
"x.com.samsung.da.modes": "Fix",
"x.com.samsung.da.supportedModes": [
"Fix",
"Up_And_Low",
"Left_And_Right",
"All"
]
}
},
{
"href": "/wind/strength/vs/0",
"rep": {
"x.com.samsung.da.modes": "32",
"x.com.samsung.da.supportedModes": [
"0",
"31",
"32",
"33",
"34",
"35"
],
"x.com.samsung.da.modesName": [
"Auto",
"1",
"2",
"3",
"4",
"MAX"
]
}
},
{
"href": "/wirelessinfo/vs/0",
"rep": {
"macaddressWiFi": "REDACTED",
"macaddressBLE": "REDACTED",
"connectedApSsid": "REDACTED"
}
}
]
}
+20
View File
@@ -0,0 +1,20 @@
{
"state_keys": [
"alarm_code",
"child_lock",
"completion_minutes",
"cycle",
"cycle_active",
"delay_start_hours",
"diagnosis_status",
"energy_kwh",
"finish_time",
"job_beginning_status",
"machine_state",
"power_switch",
"progress",
"progress_percentage",
"remote_control",
"wrinkle_prevent"
]
}
+22
View File
@@ -0,0 +1,22 @@
{
"state_keys": [
"alarm_code",
"child_lock",
"completion_minutes",
"cycle",
"cycle_active",
"delay_start_hours",
"diagnosis_status",
"energy_kwh",
"finish_time",
"firmware_update",
"job_beginning_status",
"machine_state",
"power_switch",
"progress",
"progress_percentage",
"remote_control",
"sanitize",
"wrinkle_prevent"
]
}
+17
View File
@@ -0,0 +1,17 @@
{
"state_keys": [
"alarm_code",
"clean_level",
"device_active",
"dust",
"energy_kwh",
"fine_dust",
"firmware_update",
"hepa_filter_status",
"hepa_filter_usage",
"odor",
"power_switch",
"super_fine_dust",
"wind_strength_fan"
]
}
+9 -1
View File
@@ -2,16 +2,24 @@
"state_keys": [
"air_filter_status",
"air_filter_usage",
"air_filter_usage_hours",
"air_purify",
"alarm_code",
"auto_clean",
"beep",
"clean_level",
"climate",
"current_temperature_c",
"diagnosis_status",
"display_light",
"dust",
"energy_kwh",
"energy_saved_kwh",
"fine_dust",
"humidity",
"power_watts"
"odor",
"power_watts",
"super_fine_dust",
"tropical_night_mode"
]
}
+5 -2
View File
@@ -2,8 +2,10 @@
"state_keys": [
"air_filter_status",
"air_filter_usage",
"air_filter_usage_hours",
"alarm_code",
"auto_clean",
"beep",
"climate",
"current_temperature_c",
"display_light",
@@ -12,6 +14,7 @@
"firmware_update",
"humidity",
"mute_once",
"power_watts"
"power_watts",
"tropical_night_mode"
]
}
}
@@ -0,0 +1,18 @@
{
"state_keys": [
"air_monitoring",
"alarm_code",
"auto_clean_legacy",
"buzzer_volume",
"climate",
"current_temperature_c",
"diagnosis_status",
"display_light",
"energy_kwh",
"filter_time",
"good_sleep",
"humidity",
"outdoor_temperature",
"spi"
]
}
+4 -1
View File
@@ -2,8 +2,10 @@
"state_keys": [
"air_filter_status",
"air_filter_usage",
"air_filter_usage_hours",
"alarm_code",
"auto_clean",
"beep",
"climate",
"current_temperature_c",
"diagnosis_status",
@@ -13,6 +15,7 @@
"firmware_update",
"humidity",
"mute_once",
"power_watts"
"power_watts",
"tropical_night_mode"
]
}
+20
View File
@@ -0,0 +1,20 @@
{
"state_keys": [
"air_filter_status",
"air_filter_threshold",
"air_filter_usage",
"air_filter_usage_hours",
"alarm_code",
"auto_clean",
"beep",
"climate",
"current_temperature_c",
"diagnosis_status",
"energy_kwh",
"firmware_update",
"humidity",
"power_energy_kwh",
"power_watts",
"tropical_night_mode"
]
}
@@ -1,20 +1,27 @@
{
"state_keys": [
"air_filter_status",
"air_filter_threshold",
"air_filter_usage",
"air_filter_usage_hours",
"air_purify",
"alarm_code",
"auto_clean",
"clean_level",
"climate",
"current_temperature_c",
"display_light",
"dust",
"energy_kwh",
"energy_saved_kwh",
"fine_dust",
"firmware_update",
"humidity",
"mute_once",
"selfcheck_error",
"selfcheck_result",
"selfcheck_status"
"selfcheck_status",
"super_fine_dust",
"tropical_night_mode"
]
}
+5 -1
View File
@@ -1,10 +1,13 @@
{
"state_keys": [
"air_filter_status",
"air_filter_threshold",
"air_filter_usage",
"air_filter_usage_hours",
"air_purify",
"alarm_code",
"auto_clean",
"beep",
"climate",
"current_limit_enabled",
"current_limit_level",
@@ -16,6 +19,7 @@
"mute_once",
"selfcheck_error",
"selfcheck_result",
"selfcheck_status"
"selfcheck_status",
"tropical_night_mode"
]
}
@@ -0,0 +1,20 @@
{
"state_keys": [
"air_filter_status",
"air_filter_usage",
"air_filter_usage_hours",
"alarm_code",
"auto_clean",
"beep",
"climate",
"current_temperature_c",
"display_light",
"energy_kwh",
"energy_saved_kwh",
"firmware_update",
"humidity",
"mute_once",
"power_watts",
"tropical_night_mode"
]
}
@@ -2,8 +2,10 @@
"state_keys": [
"air_filter_status",
"air_filter_usage",
"air_filter_usage_hours",
"alarm_code",
"auto_clean",
"beep",
"climate",
"current_temperature_c",
"display_light",
@@ -12,6 +14,7 @@
"firmware_update",
"humidity",
"mute_once",
"power_watts"
"power_watts",
"tropical_night_mode"
]
}
+4 -1
View File
@@ -2,8 +2,10 @@
"state_keys": [
"air_filter_status",
"air_filter_usage",
"air_filter_usage_hours",
"alarm_code",
"auto_clean",
"beep",
"climate",
"current_temperature_c",
"display_light",
@@ -11,6 +13,7 @@
"firmware_update",
"humidity",
"mute_once",
"power_watts"
"power_watts",
"tropical_night_mode"
]
}
+9 -1
View File
@@ -2,15 +2,23 @@
"state_keys": [
"air_filter_status",
"air_filter_usage",
"air_filter_usage_hours",
"air_purify",
"alarm_code",
"auto_clean",
"beep",
"clean_level",
"climate",
"current_temperature_c",
"diagnosis_status",
"display_light",
"dust",
"energy_kwh",
"fine_dust",
"humidity",
"power_watts"
"odor",
"power_watts",
"super_fine_dust",
"tropical_night_mode"
]
}
@@ -1,9 +1,12 @@
{
"state_keys": [
"air_filter_status",
"air_filter_threshold",
"air_filter_usage",
"air_filter_usage_hours",
"alarm_code",
"auto_clean",
"beep",
"climate",
"current_temperature_c",
"display_light",
@@ -16,6 +19,7 @@
"overload_protection_mode",
"selfcheck_error",
"selfcheck_result",
"selfcheck_status"
"selfcheck_status",
"tropical_night_mode"
]
}
}
+2
View File
@@ -2,8 +2,10 @@
"state_keys": [
"air_filter_status",
"air_filter_usage",
"air_filter_usage_hours",
"alarm_code",
"auto_clean",
"beep",
"climate",
"current_temperature_c",
"display_light",
+2
View File
@@ -1,7 +1,9 @@
{
"state_keys": [
"air_filter_status",
"air_filter_threshold",
"air_filter_usage",
"air_filter_usage_hours",
"alarm_code",
"auto_clean",
"energy_kwh",
+22
View File
@@ -0,0 +1,22 @@
{
"state_keys": [
"alarm_code",
"cavity_state",
"child_lock",
"cloud_connected",
"cook_time",
"cooking_mode",
"cycle_active",
"door_open",
"energy_kwh",
"fan",
"finish_time",
"firmware_update",
"lamp",
"machine_state",
"operation_time_minutes",
"power_level",
"progress_percentage",
"sound"
]
}
+27
View File
@@ -0,0 +1,27 @@
{
"state_keys": [
"alarm_code",
"child_lock",
"cloud_connected",
"cook_time",
"cooktop_running_state",
"current_temp_c",
"cycle_active",
"door_open",
"fast_preheat",
"finish_time",
"firmware_update",
"lamp",
"machine_state",
"natural_steam",
"operation_time_minutes",
"oven_mode",
"oven_setpoint",
"oven_state",
"power_switch",
"progress_percentage",
"remote_control",
"sound",
"warming_center_state"
]
}
+31
View File
@@ -0,0 +1,31 @@
{
"state_keys": [
"ai_energy_level",
"alarm_code",
"auto_door_opener",
"brightness_level",
"cabinet_light_dim",
"cabinet_light_switch",
"cooler_setpoint",
"cooler_temperature",
"day_brightness",
"door_alert",
"door_cooler_open",
"door_freezer_open",
"energy_kwh",
"energy_saved_kwh",
"firmware_update",
"freezer_setpoint",
"freezer_temperature",
"fridge_sound",
"night_end",
"night_start",
"power_energy_kwh",
"power_watts",
"rapid_freezing",
"rapid_fridge",
"selfcheck_error",
"selfcheck_result",
"selfcheck_status"
]
}
+269
View File
@@ -0,0 +1,269 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/alarms/vs/0",
"rep": {
"rt": [
"x.com.samsung.da.alarms"
],
"if": [
"oic.if.baseline",
"oic.if.s"
],
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "OV_E_OFF",
"x.com.samsung.da.triggeredTime": "2024-01-01T00:00:00"
}
]
}
},
{
"href": "/connected/vs/0",
"rep": {
"x.com.samsung.da.connected": "On",
"rt": [
"x.com.samsung.da.connected"
],
"if": [
"oic.if.baseline",
"oic.if.s"
]
}
},
{
"href": "/doors/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Door",
"x.com.samsung.da.openState": "Close"
}
],
"rt": [
"x.com.samsung.da.doors"
],
"if": [
"oic.if.baseline",
"oic.if.s"
]
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.instantaneousPower": "-500",
"x.com.samsung.da.instantaneousPowerUnit": "W",
"x.com.samsung.da.cumulativePower": "103700",
"x.com.samsung.da.cumulativeUnit": "Wh"
}
},
{
"href": "/hood/fanspeed/vs/0",
"rep": {
"x.com.samsung.da.hood.fanSpeed": "0",
"x.com.samsung.da.hood.supportedFanSpeed": [
"0",
"1",
"2",
"3",
"4"
],
"x.com.samsung.da.hood.settableMaxFanSpeed": "4",
"x.com.samsung.da.hood.settableMinFanSpeed": "0",
"rt": [
"x.com.samsung.da.hood.fanSpeed"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "TP1X_DA-KS-MICROWAVE-01051|40475141|50040000011811000A00000000000000",
"x.com.samsung.da.description": "ME7500D-/AA2",
"x.com.samsung.da.serialNum": "REDACTED",
"x.com.samsung.da.otnDUID": "REDACTED",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "24111400",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "04751A23121900",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "04754B23120800",
"x.com.samsung.da.newVersionAvailable": "0"
}
],
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
"x.com.samsung.da.diagLogType": [
"errCode",
"dump"
],
"x.com.samsung.da.diagDumpType": "file",
"x.com.samsung.da.diagEndPoint": "SSM",
"x.com.samsung.da.diagMnid": "0AJT",
"x.com.samsung.da.diagSetupid": "KM5",
"x.com.samsung.da.diagMinVersion": "3.0",
"x.com.samsung.da.diagTsId": "DA01"
}
},
{
"href": "/kidslock/vs/0",
"rep": {
"x.com.samsung.da.kidsLock": "Ready"
}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"NoOperation",
"MicroWave",
"Autocook",
"KeepWarm"
],
"x.com.samsung.da.modes": [
"NoOperation"
],
"x.com.samsung.da.options": [
"DeviceType_ME7500D-/AA2",
"TimeAutoSync_On",
"weight_LBS",
"TimeSystem_12",
"Sound_Off",
"RemindBeep_Off",
"FilterRemind_Off",
"Lamp_High"
],
"x.com.samsung.da.defaultMode": "MicroWave",
"rt": [
"x.com.samsung.da.mode"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/operational/state/vs/0",
"rep": {
"x.com.samsung.da.state": "Ready",
"x.com.samsung.da.operationTime": "00:00:00",
"x.com.samsung.da.remainingTime": "00:00:00",
"x.com.samsung.da.progressPercentage": "0",
"rt": [
"x.com.samsung.da.operation"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "",
"x.com.samsung.da.newVersionAvailable": "false",
"flashingProgress": "",
"otnStatus": "None",
"otnList": [
{
"type": "WIFI",
"modelId": "AKS-WW-TP1-23-MICROWAVE-OTR",
"versions": [
"40241114"
],
"visVersion": "241114"
},
{
"type": "Micom",
"modelId": "074240475141FFFFFFFF",
"versions": [
"23121900",
"FFFFFFFF"
],
"visVersion": "231219"
},
{
"type": "Micom",
"modelId": "074240475442FFFFFFFF",
"versions": [
"23120800",
"FFFFFFFF"
],
"visVersion": "231208"
}
]
}
},
{
"href": "/oven/vs/0",
"rep": {
"x.com.samsung.da.state": "Ready",
"x.com.samsung.da.recipe": "00000000000000",
"x.com.samsung.da.powerLevel": "0",
"rt": [
"x.com.samsung.da.oven"
],
"if": [
"oic.if.baseline",
"oic.if.s"
]
}
},
{
"href": "/quickcontrol/info/vs/0",
"rep": {
"supportedVersion": "1.0"
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "America/New_York",
"offset": "-04:00",
"DST": "ON"
}
},
{
"href": "/wirelessinfo/vs/0",
"rep": {
"macaddressWiFi": "REDACTED",
"macaddressBLE": "REDACTED"
}
}
]
}
+200
View File
@@ -0,0 +1,200 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/alarms/vs/0",
"rep": {
"rt": [
"x.com.samsung.da.alarms"
],
"if": [
"oic.if.baseline",
"oic.if.s"
],
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "OV_E_OFF",
"x.com.samsung.da.triggeredTime": "2024-01-01T00:00:00"
}
]
}
},
{
"href": "/configuration/vs/0",
"rep": {}
},
{
"href": "/connected/vs/0",
"rep": {
"x.com.samsung.da.connected": "On"
}
},
{
"href": "/cooktopmonitoring/vs/0",
"rep": {
"x.com.samsung.da.warmingCenterState": "Off",
"x.com.samsung.da.cooktopMonitoring": "0",
"x.com.samsung.da.cooktopRunningState": "Ready",
"supportedHoodLampStateList": [
"Run",
"Ready"
]
}
},
{
"href": "/doors/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Door",
"x.com.samsung.da.openState": "Close",
"x.com.samsung.da.lock": "Unlock"
}
],
"rt": [
"x.com.samsung.da.doors"
],
"if": [
"oic.if.baseline",
"oic.if.s"
]
}
},
{
"href": "/kidslock/vs/0",
"rep": {
"x.com.samsung.da.kidsLock": "Ready"
}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"Bake",
"Broil",
"ConvectionBake",
"ConvectionRoast",
"KeepWarm",
"BreadProof",
"AirFryer",
"Dehydrate",
"SelfClean",
"SteamClean"
],
"x.com.samsung.da.modes": [
"NoOperation"
],
"x.com.samsung.da.options": [
"DeviceType_NE6516A-/AA0",
"SettingPossible_0",
"UpperLamp_Off",
"Sound_Off",
"AdjustingTemp_0",
"Sabbath_Off",
"EnergySaving_On",
"BurnerOnAlert_Off"
],
"x.com.samsung.da.defaultMode": "ConvectionBake",
"x.com.samsung.da.modeSpec": "[{\"mode\":\"Bake\",\"version\":\"0100\",\"default\":\"Normal\",\"control\":\"Start&Setting\",\"cavity\":\"Single\",\"tempMinC\":\"80\",\"tempMaxC\":\"285\",\"tempDefaultC\":\"175\",\"tempListLengthC\":\"0\",\"tempMinF\":\"175\",\"tempMaxF\":\"550\",\"tempDefaultF\":\"350\",\"tempListLengthF\":\"0\",\"timeMin\":\"00:01:00\",\"timeMax\":\"09:59:00\",\"timeDefault\":\"01:00:00\"},{\"mode\":\"ConvectionRoast\",\"version\":\"0100\",\"default\":\"Normal\",\"control\":\"Start&Setting\",\"cavity\":\"Single\",\"tempMinC\":\"80\",\"tempMaxC\":\"285\",\"tempDefaultC\":\"160\",\"tempListLengthC\":\"0\",\"tempMinF\":\"175\",\"tempMaxF\":\"550\",\"tempDefaultF\":\"325\",\"tempListLengthF\":\"0\",\"timeMin\":\"00:01:00\",\"timeMax\":\"09:59:00\",\"timeDefault\":\"01:00:00\"},{\"mode\":\"AirFryer\",\"version\":\"0100\",\"default\":\"Normal\",\"control\":\"Start&Setting\",\"cavity\":\"Single\",\"tempMinC\":\"175\",\"tempMaxC\":\"260\",\"tempDefaultC\":\"220\",\"tempListLengthC\":\"0\",\"tempMinF\":\"350\",\"tempMaxF\":\"500\",\"tempDefaultF\":\"425\",\"tempListLengthF\":\"0\",\"timeMin\":\"00:01:00\",\"timeMax\":\"09:59:00\",\"timeDefault\":\"01:00:00\"}]",
"rt": [
"x.com.samsung.da.mode"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/operational/state/vs/0",
"rep": {
"x.com.samsung.da.state": "Ready",
"x.com.samsung.da.operationTime": "00:00:00",
"x.com.samsung.da.remainingTime": "00:00:00",
"x.com.samsung.da.progressPercentage": "1",
"rt": [
"x.com.samsung.da.operation"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "",
"x.com.samsung.da.newVersionAvailable": "false"
}
},
{
"href": "/oven/vs/0",
"rep": {
"x.com.samsung.da.state": "Ready",
"rt": [
"x.com.samsung.da.oven"
],
"if": [
"oic.if.baseline",
"oic.if.s"
]
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "On"
}
},
{
"href": "/remotectrl/vs/0",
"rep": {
"x.com.samsung.da.remoteControlEnabled": "true",
"rt": [
"x.com.samsung.da.configuration"
],
"if": [
"oic.if.baseline",
"oic.if.s"
]
}
},
{
"href": "/temperatures/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Temperature",
"x.com.samsung.da.desired": "0",
"x.com.samsung.da.current": "175",
"x.com.samsung.da.increment": "1",
"x.com.samsung.da.unit": "Fahrenheit"
}
],
"rt": [
"x.com.samsung.da.temperatures"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
}
]
}
+470
View File
@@ -0,0 +1,470 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/alarms/vs/0",
"rep": {
"rt": [
"x.com.samsung.da.alarms"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/cabinet/light/enhanced/vs/0",
"rep": {
"light.control.status": "Off",
"level.brightness.daytime": "100",
"level.brightness.nighttime": "100",
"night.starttime": "2025-08-08T19:00:00",
"night.duration.minute": "540",
"timezone.offset": "+02:00",
"rt": [
"x.com.samsung.da.light.enhanced"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/cabinet/light/total/vs/0",
"rep": {
"x.com.samsung.da.lightLevel": "100",
"x.com.samsung.da.lightResolution": "3",
"x.com.samsung.da.lightControl.off.include": "Off",
"x.com.samsung.da.lightControl": "Off",
"x.com.samsung.da.lightControl.hide": "true",
"light.dimming.status": "On",
"rt": [
"x.com.samsung.da.cabinetlight"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "",
"x.com.samsung.da.countryCode": ""
}
},
{
"href": "/connectionconfig/vs/0",
"rep": {
"autoReconnectionMinVersion": "1.0",
"autoReconnection": "true",
"autoReconnectionProtocolType": [
"helper_hotspot",
"ble_ocf"
],
"supportedWiFiAuthType": [
"OPEN",
"WEP",
"WPA-PSK",
"WPA2-PSK",
"SAE"
],
"supportedWiFiCryptoType": [
"TKIP",
"AES",
"WEP-64",
"WEP-128"
],
"supportedWiFiFreq": [
"2.4G"
],
"calmConnectionCare": {
"version": "1.0",
"role": [
"things"
]
}
}
},
{
"href": "/dginformation/vs/0",
"rep": {
"enrolmentstatus": "Unknown",
"devicestate": "Unknown",
"lockstatus": "Unknown",
"nextduedate": "",
"workingminutes": 0,
"paymentinfo": {
"emiplan": "Unknown",
"currency": "Unknown",
"totalemi": 0,
"totalemipaid": 0
}
}
},
{
"href": "/door/cooler/0",
"rep": {
"openState": "Close",
"rt": [
"oic.r.door"
],
"if": [
"oic.if.baseline",
"oic.if.s"
]
}
},
{
"href": "/door/freezer/0",
"rep": {
"openState": "Close",
"rt": [
"oic.r.door"
],
"if": [
"oic.if.baseline",
"oic.if.s"
]
}
},
{
"href": "/doors/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.openState": "Close",
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Door"
},
{
"x.com.samsung.da.openState": "Close",
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Door"
}
]
}
},
{
"href": "/drlc/vs/0",
"rep": {
"x.com.samsung.da.drlcLevel": "0",
"x.com.samsung.da.override": "Not_Supported",
"x.com.samsung.da.durationminutes": "0",
"x.com.samsung.da.realSaving": "Off"
}
},
{
"href": "/energy/ailevel/vs/0",
"rep": {
"aiLevel": "1",
"supportedAiLevel": [
"1",
"2"
]
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.cumulativeConsumption": "281",
"x.com.samsung.da.instantaneousPower": "48",
"x.com.samsung.da.cumulativePower": "216620",
"x.com.samsung.da.cumulativeSavedPower": "0",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.instantaneousPowerUnit": "W"
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+01:00",
"x.com.samsung.supprtedtype": 1
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "TP1X_REF_21K|00175941|00050126001811344100000020090000",
"x.com.samsung.da.description": "TP1X_REF_21K",
"x.com.samsung.da.serialNum": "REDACTED",
"x.com.samsung.da.otnDUID": "REDACTED",
"x.com.samsung.da.diagDumpType": "file",
"x.com.samsung.da.diagEndPoint": "SSM",
"x.com.samsung.da.diagLogType": [
"errCode",
"dump"
],
"x.com.samsung.da.diagMnid": "0AJT",
"x.com.samsung.da.diagSetupid": "RB2",
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
"x.com.samsung.da.diagMinVersion": "3.0",
"x.com.samsung.da.diagTsId": "DA01",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "WiFi Module",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "260617",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Micom",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "24070109, FFFFFFFF, 24062000, FFFFFFFF",
"x.com.samsung.da.newVersionAvailable": "0"
}
]
}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.modes": [
"RVACATION_OFF",
"FCONVERT_FREEZER"
],
"x.com.samsung.da.supportedModes": [
"HOMECARE_WIZARD_V2",
"ENERGY_REPORT_MODEL",
"18K_REF_OUTDOOR_CONTROL_V2"
],
"rt": [
"x.com.samsung.da.mode"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "",
"x.com.samsung.da.newVersionAvailable": "false",
"otnStatus": "None",
"flashingProgress": "0",
"otnCompleteDate": "noHistory",
"scheduledTime": "None",
"swVersionInfo": {
"platform": "Tizen Lite",
"oneUiVersion": "7.0 Refrigerator",
"osVersion": "4.0"
},
"otnList": []
}
},
{
"href": "/quickcontrol/info/vs/0",
"rep": {
"supportedVersion": "1.0"
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/refrigeration/vs/0",
"rep": {
"x.com.samsung.da.rapidFridge": "Off",
"x.com.samsung.da.rapidFreezing": "Off",
"rt": [
"x.com.samsung.da.fridge"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/rm/control/vs/0",
"rep": {
"minPeriod": "9000"
}
},
{
"href": "/runningmode/vs/0",
"rep": {
"x.com.samsung.da.runningMode": 0
}
},
{
"href": "/selfcheck/vs/0",
"rep": {
"x.com.samsung.da.supportedActions": [
"Start"
],
"x.com.samsung.da.status": "Ready",
"x.com.samsung.da.result": "Success",
"x.com.samsung.da.error": [
"ErrorCode_None"
]
}
},
{
"href": "/settings/sound/alert/door/vs/0",
"rep": {
"alert.door": "1",
"supportedAlert.door": [
"1",
"2",
"3",
"4"
],
"rt": [
"x.com.samsung.alert.door"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/status/lock/vs/0",
"rep": {
"x.com.samsung.da.device.sound": "Off",
"rt": [
"x.com.samsung.da.lockstatus"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/temperature/current/cooler/0",
"rep": {
"temperature": 6.0,
"range": [
1.0,
7.0
],
"units": "C",
"rt": [
"oic.r.temperature"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/temperature/current/freezer/0",
"rep": {
"temperature": -17.0,
"range": [
-23.0,
-15.0
],
"units": "C",
"rt": [
"oic.r.temperature"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/temperature/desired/cooler/0",
"rep": {
"temperature": 6.0,
"range": [
1.0,
7.0
],
"units": "C",
"rt": [
"oic.r.temperature"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/temperature/desired/freezer/0",
"rep": {
"temperature": -17.0,
"range": [
-23.0,
-15.0
],
"units": "C",
"rt": [
"oic.r.temperature"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/temperatures/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Freezer",
"x.com.samsung.da.desired": "-17",
"x.com.samsung.da.current": "-17",
"x.com.samsung.da.maximum": "-15",
"x.com.samsung.da.minimum": "-23",
"x.com.samsung.da.unit": "Celsius"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Fridge",
"x.com.samsung.da.desired": "6",
"x.com.samsung.da.current": "6",
"x.com.samsung.da.maximum": "7",
"x.com.samsung.da.minimum": "1",
"x.com.samsung.da.unit": "Celsius"
}
]
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "Europe/Rome",
"offset": "+02:00",
"DST": "ON"
}
},
{
"href": "/wirelessinfo/vs/0",
"rep": {
"macaddressWiFi": "REDACTED",
"macaddressBLE": "REDACTED",
"connectedApSsid": "SPRDFL24"
}
}
]
}
+70
View File
@@ -0,0 +1,70 @@
"""Tests for the AirDresser device type (DA_DF_A51_20_COMMON, issue #162)."""
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import for_device_by_model
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
def _air_dresser():
resources = _load_device('air_dresser')
info = resources['/information/vs/0']
reg = for_device_by_model(
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'])
return reg, resources
def _state():
reg, resources = _air_dresser()
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
return flatten(bound, resources)
def test_resolves_to_air_dresser_registry():
reg, _ = _air_dresser()
assert reg is not None and reg.name == 'air_dresser'
def test_no_unbound_hrefs():
reg, resources = _air_dresser()
unbound = []
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
assert unbound == []
def test_course_select_present_and_reads_current_selection():
"""This was the actual reported gap: /course/vs/0 was previously
entirely unbound, so no cycle/mode select existed at all."""
state = _state()
assert state['cycle'] == '01'
def test_course_options_derived_from_supported_options_fallback():
"""No /wm/editcourse/vs/0 on this board at all, so the option list must
come from laundry.cycle_options' supportedOptions decode rather than
editCourseList -- confirmed distinct codes, current selection included."""
from custom_components.localthings.registry.capabilities.laundry import cycle_options
_, resources = _air_dresser()
codes = cycle_options(resources)
assert codes == ['01', '02', '04', '03', '05', '1A', '1B', '1C', '07', '08']
def test_wrinkle_prevent_present_dry_level_fields_absent():
"""AIR_DRESSER_SETTINGS only binds wrinkle_prevent -- dryLevel/dryTime/
dryerType are dryer-only fields this device never reports, so they
should not appear as always-empty entities."""
state = _state()
assert state['wrinkle_prevent'] is False
for key in ('dry_level', 'dry_time', 'dryer_type'):
assert key not in state, key
def test_expected_entities_present():
state = _state()
for key in (
'power_switch', 'child_lock', 'remote_control', 'cycle',
'machine_state', 'progress', 'progress_percentage', 'finish_time',
'completion_minutes', 'delay_start_hours', 'diagnosis_status',
'job_beginning_status', 'wrinkle_prevent', 'energy_kwh',
):
assert key in state, key
@@ -0,0 +1,82 @@
"""Tests for the DA_DF_TP2_20_COMMON AirDresser (model DF9500A, issue #157).
A different board generation than issue #162's DA_DF_A51_20_COMMON, routed
into the same air_dresser registry (both carry the '_DF_' modelNum token).
Exercises the two things this board does differently: a populated
/wm/editcourse/vs/0 (so cycle_options() never needs the supportedOptions
fallback #162 relies on) and the new /airdresseroption/sanitize/vs/0
capability #162's dump doesn't report at all.
"""
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import air_dresser, for_device_by_model
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
def _air_dresser():
resources = _load_device('air_dresser_tp2_20')
info = resources['/information/vs/0']
reg = for_device_by_model(
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'])
return reg, resources
def _state():
reg, resources = _air_dresser()
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
return flatten(bound, resources)
def test_resolves_to_air_dresser_registry():
reg, _ = _air_dresser()
assert reg is not None and reg.name == 'air_dresser'
def test_no_unbound_hrefs():
"""Confirms /st/airdressercourse/vs/0 and /airdresseroption/sanitize/vs/0
-- the two hrefs this board reports that #162's dump doesn't -- are
covered (ignored.py and AIR_DRESSER_SANITIZE respectively)."""
reg, resources = _air_dresser()
unbound = []
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
assert unbound == []
def test_course_options_come_from_edit_course_list_not_the_fallback():
"""Unlike issue #162's board, this one populates /wm/editcourse/vs/0
directly, so cycle_options() should never reach the
supportedOptions-decode fallback."""
from custom_components.localthings.registry.capabilities.laundry import cycle_options
_, resources = _air_dresser()
codes = cycle_options(resources)
assert codes[:3] == ['22', '23', '0C']
assert codes == cycle_options({'/wm/editcourse/vs/0': resources['/wm/editcourse/vs/0']})
def test_course_select_reads_current_selection():
state = _state()
assert state['cycle'] == '22'
def test_course_translation_key_falls_back_to_cycle_for_unidentified_table():
"""Table_00's course codes aren't identified yet, so the select's
translation_key resolves to the generic 'cycle' catalog entry rather
than a table-specific one that doesn't exist."""
_, resources = _air_dresser()
desc = air_dresser.REGISTRY.capabilities['/course/vs/0'][0].entities[0]
assert desc.translation_key(resources) == 'cycle'
def test_sanitize_present_and_toggles():
state = _state()
assert state['sanitize'] is False
desc = next(
e for e in air_dresser.REGISTRY.capabilities['/airdresseroption/sanitize/vs/0'][0].entities
if e.key == 'sanitize'
)
path, body = desc.write_fn('On', {})
assert path == ['airdresseroption', 'sanitize', 'vs', '0']
assert body == {'x.com.samsung.da.sanitize': 'On'}
assert desc.write_fn('Sparkle', {}) is None
+128
View File
@@ -0,0 +1,128 @@
"""HA fan-entity mapping tests for the A-VTWW-TP2-21-COMMON BESPOKE Cube Air
(issue #151).
This board's /wind/strength/vs/0 reports numeric wind-strength codes
("87"/"89"/"90"/"91") with a separate modesName array ("SMART"/"MAX"/
"WINDFREE"/"Sleep") giving the actual names, unlike the TP1X_DA-AC-AIR
family's /mode/vs/0 (issue #130) where supportedModes IS the name list
already. LocalThingsAirPurifierFan._label_for_code resolves both shapes
without a per-model map.
"""
from custom_components.localthings.fan import LocalThingsAirPurifierFan
from custom_components.localthings.registry.by_type import for_device_by_model
from custom_components.localthings.registry.capabilities.air_purifier import HREF_WIND_STRENGTH
from custom_components.localthings.registry.discovery import discover
from custom_components.localthings.registry.entities import FanDesc
from tests.conftest import _load_device
class _FakeCoordinator:
device_serial = 'TEST-VTWW-SERIAL'
device_info = {}
data = {}
def __init__(self, resources):
self.last_resources = resources
self.commands = []
def resource(self, href):
return self.last_resources.get(href, {})
async def async_send_command(self, bound, payload):
self.commands.append((bound, payload))
def _resources():
return _load_device('air_purifier_vtww')
def _reg(resources):
info = resources['/information/vs/0']
return for_device_by_model(
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'])
def _entity(resources, coordinator=None):
reg = _reg(resources)
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
fan_bound = next(
item for item in bound
if isinstance(item.desc, FanDesc) and item.href == HREF_WIND_STRENGTH
)
return LocalThingsAirPurifierFan(coordinator or _FakeCoordinator(resources), fan_bound)
def test_resolves_to_air_purifier_registry():
assert _reg(_resources()).name == 'air_purifier'
def test_no_unbound_hrefs():
resources = _resources()
reg = _reg(resources)
unbound = []
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
assert unbound == []
def test_preset_modes_come_from_modes_name_not_the_raw_codes():
entity = _entity(_resources())
assert entity.preset_modes == ['smart', 'max', 'windfree', 'sleep']
def test_preset_mode_reads_the_current_code_via_modes_name():
"""Fixture's current mode is '87' -> modesName[0] 'SMART'."""
entity = _entity(_resources())
assert entity.preset_mode == 'smart'
async def test_set_preset_mode_writes_back_the_raw_code():
resources = _resources()
coordinator = _FakeCoordinator(resources)
entity = _entity(resources, coordinator)
await entity.async_set_preset_mode('windfree')
assert coordinator.commands[-1][1] == ('mode', '90')
def test_is_on_reads_vendor_power():
entity = _entity(_resources())
assert entity.is_on is False
def test_wind_strength_fan_key_does_not_collide_with_mode_fan():
"""WIND_STRENGTH_FAN and FAN both live in this registry and both are
FanDesc-typed; BoundEntity's unique_id is built from key alone (entity.py's
_key), not href, so a shared key would silently shadow one entity if a
board ever bound both (see AIRFLOW_GENERIC's own comment on this exact
hazard). No real dump reports both hrefs today, but the keys must stay
distinct regardless."""
from custom_components.localthings.registry.capabilities import air_purifier
fan_keys = {
entity.key
for cap in (air_purifier.FAN, air_purifier.WIND_STRENGTH_FAN)
for entity in cap.entities
}
assert fan_keys == {'fan', 'wind_strength_fan'}
def test_both_fan_hrefs_bound_simultaneously_produce_distinct_entities():
"""Synthetic combination (no real dump reports both hrefs) proving the
two FanDescs don't shadow each other in flatten()'s key-based state dict
even if a future board did report both."""
from custom_components.localthings.registry.adapter import flatten
resources = _resources()
resources['/mode/vs/0'] = {
'x.com.samsung.da.modes': ['Smart'],
'x.com.samsung.da.supportedModes': ['Smart', 'Max', 'Mid', 'WindFree', 'Sleep'],
}
reg = _reg(resources)
unbound = []
bound = discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
assert unbound == []
state = flatten(bound, resources)
assert state['fan'] == 'Smart'
assert state['wind_strength_fan'] == '87'
+251
View File
@@ -0,0 +1,251 @@
"""ARTIK051_KRAC_18K room air conditioner (issue #136).
This board generation predates every AC dump the registry was built from and
differs from all of them in three ways, each covered below:
* No ``/wind/*`` resources at all -- fan speed and vane direction share one
``/airflow/vs/0`` resource (``speedLevel`` on the same 0-4 scale as
``_DEVICE_TO_FAN``, ``direction`` with the same codes as ``_DEVICE_TO_SWING``).
* No ``/mode/convenient/vs/0`` -- the convenient-mode preset is a ``Comode_*``
token in ``/mode/vs/0``'s ``options`` array.
* Several settings (SPI, auto clean, air monitoring, beep volume, Good Sleep,
outdoor temperature, filter time) are ``options`` tokens too, where newer
boards have dedicated resources.
Fan, swing, preset, SPI and beep-volume writes were all confirmed on hardware
by read-back on the unit this fixture is dumped from. The issue #136 unit is
the same model with a slightly different token set (no ``Spi``,
``FilterTime_5460``, ``OutdoorTemp_81``), which the token entities' presence
gating handles the same way it handles newer boards.
"""
from custom_components.localthings.climate import LocalThingsClimate
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import (
airconditioner, for_device_by_model,
)
from custom_components.localthings.registry.capabilities.airconditioner import (
HREF_AIRFLOW, HREF_WIND_STRENGTH, _option_number_write,
_option_switch_write, is_legacy_board,
)
from custom_components.localthings.registry.discovery import discover
from custom_components.localthings.registry.entities import ClimateDesc
from tests.conftest import _load_device
FIXTURE = 'airconditioner_artik051_krac_18k'
MODEL = 'ARTIK051_KRAC_18K|10193441|60010119001111010100000000000000'
class _FakeCoordinator:
device_serial = 'TEST-KRAC-SERIAL'
device_info = {}
data = {}
def __init__(self, resources):
self.last_resources = resources
self.commands = []
def resource(self, href):
return self.last_resources.get(href, {})
async def async_send_command(self, bound, payload):
self.commands.append((bound, payload))
def _discover(resources, registry=airconditioner.REGISTRY):
unbound = []
bound = discover(resources, registry.capabilities,
registry.pattern_capabilities, log=unbound.append)
return bound, unbound
def _state(fixture=FIXTURE):
resources = _load_device(fixture)
bound, _ = _discover(resources)
return flatten(bound, resources)
def _climate(resources, coordinator=None):
bound, _ = _discover(resources)
climate_bound = next(
item for item in bound if isinstance(item.desc, ClimateDesc)
)
return LocalThingsClimate(
coordinator or _FakeCoordinator(resources), climate_bound,
)
# -- device type --------------------------------------------------------------
def test_krac_model_resolves_to_the_airconditioner_registry():
"""The '_RAC_' token check can't see '_KRAC_' -- the 'K' sits between the
underscore and 'RAC' -- and the consumer-prefix fallback only covers
washers/dryers/dishwashers, so this model resolved to 'unknown' and
exposed nothing but power."""
registry = for_device_by_model(MODEL, 'ARTIK051_KRAC_18K')
assert registry is not None
assert registry.name == 'airconditioner'
def test_no_unbound_hrefs():
_, unbound = _discover(_load_device(FIXTURE))
assert unbound == []
# -- option-token entities ----------------------------------------------------
def test_token_entities_present_with_calibrated_values():
state = _state()
# token/10 hours: 1715 displayed as "171 hours 0 minutes"... at 1710 in the
# official app on this unit, which pins the scale (the .5 here is a later
# reading). Whether it counts up or down is deliberately not asserted --
# see the descriptor comment.
assert state['filter_time'] == 171.5
# token - 55 == 19 C, against a 19.4 C forecast at the time of the dump.
assert state['outdoor_temperature'] == 19.0
assert state['buzzer_volume'] == 100.0
assert state['good_sleep'] == 0.0
assert state['spi'] is False
assert state['auto_clean_legacy'] is False
assert state['air_monitoring'] is False
def test_token_entities_stay_off_newer_boards():
"""Newer families carry Volume/Sleep/OutdoorTemp/Autoclean tokens too,
while also exposing those settings as dedicated resources -- ungated, the
token entities would duplicate them (auto clean) or apply a scale
calibrated on another board generation (outdoor temperature)."""
state = _state('airconditioner_tp1x_rac')
for key in ('spi', 'auto_clean_legacy', 'air_monitoring', 'buzzer_volume',
'good_sleep', 'outdoor_temperature', 'filter_time'):
assert key not in state, key
def test_climate_legacy_airflow_gate_agrees_with_is_legacy_board():
"""issue #161: climate.py's _legacy_airflow() delegates to
capabilities/airconditioner.py's is_legacy_board() instead of
re-implementing the same presence/absence 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."""
legacy_resources = _load_device(FIXTURE)
assert is_legacy_board(legacy_resources) is True
assert _climate(legacy_resources)._legacy_airflow() == legacy_resources[HREF_AIRFLOW]
newer_resources = _load_device('airconditioner_tp1x_rac')
assert is_legacy_board(newer_resources) is False
assert _climate(newer_resources)._legacy_airflow() == {}
assert HREF_WIND_STRENGTH in newer_resources
def test_absent_token_yields_no_entity():
"""The issue #136 unit of this same model reports no Spi token."""
resources = _load_device(FIXTURE)
options = resources['/mode/vs/0']['x.com.samsung.da.options']
resources['/mode/vs/0']['x.com.samsung.da.options'] = [
option for option in options if not option.startswith('Spi_')
]
bound, _ = _discover(resources)
assert 'spi' not in flatten(bound, resources)
def test_humidity_reads_the_vendor_field_and_treats_zero_as_unknown():
"""This board has no fivepercentHumidity field; the plain humidity field
only carries a reading while Air monitoring is on, and the unit switches
that back off by itself after about a minute."""
resources = _load_device(FIXTURE)
assert resources['/humidity/vs/0']['x.com.samsung.da.humidity'] == '0'
bound, _ = _discover(resources)
assert flatten(bound, resources)['humidity'] is None
resources['/humidity/vs/0']['x.com.samsung.da.humidity'] = '51'
bound, _ = _discover(resources)
assert flatten(bound, resources)['humidity'] == 51.0
def test_option_writes_carry_one_token():
"""Both go through option_write's single-token merge, the same mechanism
the display light already uses on this href. Confirmed on hardware by
read-back: Spi_On/Spi_Off, and the volume token surviving a write."""
assert _option_switch_write('Spi')('On', {}) == (
['mode', 'vs', '0'], {'x.com.samsung.da.options': ['Spi_On']},
)
# The number platform hands over a float; the device wants an integer token.
assert _option_number_write('Volume')(70.0, {}) == (
['mode', 'vs', '0'], {'x.com.samsung.da.options': ['Volume_70']},
)
# -- climate entity: fan, swing and preset off /airflow/vs/0 ------------------
def test_fan_mode_reads_the_airflow_speed_level():
entity = _climate(_load_device(FIXTURE))
assert entity.fan_mode == 'high' # speedLevel 3 in the fixture
# No supportedModes on this resource, so the full 0-4 scale is offered.
assert entity.fan_modes == ['auto', 'low', 'medium', 'high', 'turbo']
def test_swing_mode_reads_the_airflow_direction():
resources = _load_device(FIXTURE)
entity = _climate(resources)
assert entity.swing_mode == 'off' # 'Fix' in the fixture
resources[HREF_AIRFLOW]['x.com.samsung.da.direction'] = 'All'
assert _climate(resources).swing_mode == 'both'
assert 'both' in _climate(resources).swing_modes
async def test_fan_and_swing_writes_target_the_airflow_resource():
resources = _load_device(FIXTURE)
coordinator = _FakeCoordinator(resources)
entity = _climate(resources, coordinator)
await entity.async_set_fan_mode('turbo')
await entity.async_set_swing_mode('both')
assert [payload for _, payload in coordinator.commands] == [
('fan_legacy', '4'), ('swing_legacy', 'All'),
]
def test_preset_comes_from_the_comode_token():
resources = _load_device(FIXTURE)
entity = _climate(resources)
assert entity.preset_mode == 'none' # Comode_Off in the fixture
# Codes learned by driving this unit through its cloud integration and
# reading the token back. They go through the same dynamic resolver as a
# real convenient resource's supportedModes, so 'Nano' resolves to the
# existing 'nano' preset -- already labelled WindFree in the catalog.
assert entity.preset_modes == [
'none', 'nano', 'quiet', 'comfort', '2step', 'speed',
]
options = resources['/mode/vs/0']['x.com.samsung.da.options']
resources['/mode/vs/0']['x.com.samsung.da.options'] = [
'Comode_Nano' if option.startswith('Comode_') else option
for option in options
]
assert _climate(resources).preset_mode == 'nano'
async def test_preset_write_uses_the_token_path():
resources = _load_device(FIXTURE)
coordinator = _FakeCoordinator(resources)
entity = _climate(resources, coordinator)
await entity.async_set_preset_mode('nano')
assert coordinator.commands[-1][1] == ('preset_legacy', 'Nano')
async def test_newer_boards_keep_the_resource_paths():
"""The legacy fallbacks are gated on this board's resource shape, so a
board with /wind/* and /mode/convenient/vs/0 must be untouched by them."""
resources = _load_device('airconditioner_tp1x_rac')
coordinator = _FakeCoordinator(resources)
entity = _climate(resources, coordinator)
await entity.async_set_fan_mode('high')
await entity.async_set_swing_mode('off')
await entity.async_set_preset_mode('quiet') # from its own supportedModes
kinds = [payload[0] for _, payload in coordinator.commands]
assert kinds == ['fan', 'swing', 'preset']
+348 -4
View File
@@ -9,7 +9,7 @@ from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import for_device, for_device_by_model
from custom_components.localthings.registry.capabilities import airconditioner
from custom_components.localthings.registry.discovery import discover
from custom_components.localthings.registry.entities import ClimateDesc
from custom_components.localthings.registry.entities import ClimateDesc, SelectDesc
from tests.conftest import _load_device
@@ -131,11 +131,12 @@ def test_climate_consumed_hrefs_declared_as_coverage():
(as no-entity coverage caps) so they don't leak as gaps -- but produce no
standalone entities. /temperature/current/0 and /temperatures/vs/0 are
NOT in this list -- CURRENT_TEMPERATURE / CURRENT_TEMPERATURE_VS give
those two real sensor entities (issue #75)."""
those two real sensor entities (issue #75). /sensors/vs/0 is also NOT
here -- AIR_QUALITY gives it real entity sensors."""
reg, _ = _ac()
for href in ('/power/0', '/power/vs/0', '/temperature/desired/0',
'/wind/strength/vs/0', '/mode/convenient/vs/0',
'/sensors/vs/0', '/humidity/0'):
'/humidity/0'):
caps = reg.capabilities.get(href)
assert caps, href
assert all(c.entities == () for c in caps), href
@@ -404,4 +405,347 @@ def test_current_temperature_vs_only_binds_when_ocf_href_absent():
def test_humidity_reads_five_percent_field_not_stuck_humidity_field():
desc = airconditioner.HUMIDITY.entities[0]
rep = {'x.com.samsung.da.humidity': '0', 'x.com.samsung.da.fivepercentHumidity': '42'}
assert desc.value_fn(rep.get(desc.field)) == 42.0
assert desc.rep_fn(rep) == 42.0
def test_humidity_falls_back_to_the_plain_field_where_five_percent_is_absent():
"""ARTIK051 boards (issue #136) have no fivepercentHumidity field at all.
Their plain field is not stuck -- it carries a reading while Air monitoring
is on -- so 0 means "not measuring" on both generations, not 0% humidity."""
desc = airconditioner.HUMIDITY.entities[0]
assert desc.rep_fn({'x.com.samsung.da.humidity': '51'}) == 51.0
assert desc.rep_fn({'x.com.samsung.da.humidity': '0'}) is None
assert desc.rep_fn({}) is None
def test_humidity_five_percent_field_passes_a_genuine_zero_through():
"""issue #160: fivepercentHumidity's zero-as-"not measuring" carve-out
(added in #146 to cover ARTIK051's plain humidity field) was
over-applied to fivepercentHumidity too, silently turning a real 0%
reading on every other AC board into unknown. Only the humidity
fallback field collapses 0 -- fivepercentHumidity's 0 is a real
reading."""
desc = airconditioner.HUMIDITY.entities[0]
assert desc.rep_fn({'x.com.samsung.da.fivepercentHumidity': '0'}) == 0.0
# ---------------------------------------------------------------------------
# Wind-Free 2-in-1 (TP2X_FAC_BORA_21K, issues #150/#153): a floor-standing +
# wall-mounted indoor unit pair sharing one outdoor unit and one local IP.
# Reported "no climate entity is generated, only power" -- the device simply
# fell back to 'unknown' for lack of a '_FAC_' modelNum routing token; once
# routed, it binds against the exact same CLIMATE composite every other RAC
# family uses, zero unbound hrefs, no new capabilities needed beyond ignoring
# the two hrefs (/subdevices/vs/0, /runn/vs/0) unique to this board.
# ---------------------------------------------------------------------------
def _ac_fac_bora():
resources = _load_device('airconditioner_fac_bora')
info = resources['/information/vs/0']
reg = for_device_by_model(
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'],
)
return reg, resources
def test_fac_bora_resolves_to_airconditioner_registry():
reg, _ = _ac_fac_bora()
assert reg is not None and reg.name == 'airconditioner'
def test_fac_bora_no_unbound_hrefs():
reg, resources = _ac_fac_bora()
unbound = []
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
assert unbound == []
def test_fac_bora_climate_entity_present():
"""The actual reported gap: only a power switch existed before, no
climate entity at all."""
reg, resources = _ac_fac_bora()
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
assert any(isinstance(item.desc, ClimateDesc) for item in bound)
def test_fac_bora_subdevices_and_runningmode_are_ignored_not_guessed():
assert '/subdevices/vs/0' in airconditioner._AC_IGNORED
assert '/runn/vs/0' in airconditioner._AC_IGNORED
# ---------------------------------------------------------------------------
# Additive entities layered on the ARTIK051_PRAC family on top of the upstream
# registry: beep (Volume_* option), tropical night mode (Sleep_<N> option),
# filter usage hours + alarm threshold (filterUsage / filterDesiredUsage),
# air-quality sensors (/sensors/vs/0 items), and software/firmware version
# (/information/vs/0 items). Beep and tropical night use the single-token
# option_write merge -- a full options RMW reverts on ARTIK051_PRAC (see the
# [[samsung-ac-local-vs-cloud-control]] memory).
# ---------------------------------------------------------------------------
def _beep_desc():
return next(e for e in airconditioner.CLIMATE.entities if e.key == 'beep')
def _tropical_desc():
return next(e for e in airconditioner.CLIMATE.entities
if e.key == 'tropical_night_mode')
def test_beep_read_from_volume_token():
"""Volume_100 (and any non-Mute) -> on; Volume_Mute -> off; no Volume_ slot
-> None (entity won't bind via exists_fn)."""
assert airconditioner._beep_on(
{'x.com.samsung.da.options': ['Volume_100']}) is True
assert airconditioner._beep_on(
{'x.com.samsung.da.options': ['Volume_Mute']}) is False
assert airconditioner._beep_on(
{'x.com.samsung.da.options': ['Light_Off']}) is None
assert airconditioner._beep_on({}) is None
def test_beep_write_is_single_token_options_merge():
"""One-element options array, not a full RMW (which reverts on
ARTIK051_PRAC). 'On' restores the last non-Mute level so an intermediate
setting (e.g. Volume_50) survives an off/on cycle; falls back to 100 when
no prior level is known or the prior token is itself Mute."""
write = _beep_desc().write_fn
assert write('On', {}) == (
['mode', 'vs', '0'], {'x.com.samsung.da.options': ['Volume_100']})
assert write('On', {'x.com.samsung.da.options': ['Volume_50']}) == (
['mode', 'vs', '0'], {'x.com.samsung.da.options': ['Volume_50']})
assert write('On', {'x.com.samsung.da.options': ['Volume_Mute']}) == (
['mode', 'vs', '0'], {'x.com.samsung.da.options': ['Volume_100']})
assert write('Off', {}) == (
['mode', 'vs', '0'], {'x.com.samsung.da.options': ['Volume_Mute']})
assert write('Bogus', {}) is None
def test_beep_absent_when_no_volume_token():
"""TP1X_DA-AC-RAC-01011 carries no Volume_ option -- beep must not bind."""
reg, resources = _ac_tp1x()
state = flatten(
discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
assert 'beep' not in state
def test_beep_state_on_windfree():
"""The WindFree fixture reports Volume_100 -> beep reads True."""
reg, resources = _ac_windfree()
state = flatten(
discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
assert state['beep'] is True
def test_tropical_night_read_from_sleep_token():
"""Sleep_<N> -> N; absent -> None."""
assert airconditioner._tropical_night_value(
{'x.com.samsung.da.options': ['Sleep_0']}) == 0
assert airconditioner._tropical_night_value(
{'x.com.samsung.da.options': ['Sleep_16']}) == 16
assert airconditioner._tropical_night_value(
{'x.com.samsung.da.options': ['Volume_100']}) is None
assert airconditioner._tropical_night_value({}) is None
def test_tropical_night_write_is_single_token_options_merge():
"""Valid 0-16 -> `['Sleep_<N>']`; out of range / non-numeric -> None (no
write). Cloud counterpart: custom.airConditionerTropicalNightMode (0-16)."""
write = _tropical_desc().write_fn
assert write(0, {}) == (
['mode', 'vs', '0'], {'x.com.samsung.da.options': ['Sleep_0']})
assert write(16, {}) == (
['mode', 'vs', '0'], {'x.com.samsung.da.options': ['Sleep_16']})
assert write(17, {}) is None
assert write(-1, {}) is None
assert write('not-a-number', {}) is None
# Float rounds to nearest int within range.
assert write(5.6, {}) == (
['mode', 'vs', '0'], {'x.com.samsung.da.options': ['Sleep_6']})
def test_tropical_night_disabled_by_default():
"""Issue #166: Sleep_0 is present in /mode/vs/0's options on every dump
seen, including the original issue #17 dump this capability was verified
against (see airconditioner_device.json / _ac()) -- yet the #166 reporter
confirmed their unit (also an ARTIK051_PRAC_20K board, per its own
/information/vs/0) has no tropical night mode feature at all. The token
slot's presence proves nothing about the physical feature, so this can't
be existence-gated any tighter than it already is -- registered but
disabled by default instead, same precedent as fridge.rack_count /
cooktop.paired_hood_model."""
assert _tropical_desc().enabled_default is False
def test_tropical_night_absent_when_no_sleep_token():
"""TP1X_DA-AC-WAC (window AC) carries no Sleep_ option -- tropical night
mode must not bind."""
resources = _load_device('airconditioner_window_ac')
info = resources['/information/vs/0']
reg = for_device_by_model(
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'])
state = flatten(
discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
assert 'tropical_night_mode' not in state
def test_tropical_night_state_levels_across_fixtures():
"""Sleep_0 / Sleep_6 / Sleep_16 surface as 0 / 6 / 16 respectively."""
def level(name):
res = _load_device(name)
info = res['/information/vs/0']
r = for_device_by_model(
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'])
return flatten(discover(res, r.capabilities, r.pattern_capabilities), res).get(
'tropical_night_mode')
assert level('airconditioner_windfree') == 0
assert level('airconditioner_tp1x_da_ac_rac_01011') == 6
assert level('airconditioner_tp2x_rac_20k') == 16
def test_air_filter_usage_hours_reads_raw_count():
"""filterUsage is a lifetime hour counter (41 of 500) that resets on
filter replacement -- total_increasing, not measurement. Unit comes from
filterCapacityUnit via unit_fn, not a hardcoded 'h'."""
desc = next(e for e in airconditioner.AIR_FILTER.entities
if e.key == 'air_filter_usage_hours')
assert desc.value_fn('41') == 41
assert desc.value_fn(41) == 41
assert desc.value_fn(None) is None
assert desc.value_fn('not-a-number') is None
assert desc.device_class == 'duration'
assert desc.state_class == 'total_increasing'
assert desc.unit_fn({'x.com.samsung.da.filterCapacityUnit': 'Hour'}) == 'h'
assert desc.unit_fn({'x.com.samsung.da.filterCapacityUnit': 'Minute'}) == 'min'
assert desc.unit_fn({}) == 'h' # static fallback when the field is absent
def test_air_filter_threshold_is_writable_select():
"""filterDesiredUsage is a locally writable option (confirmed live on
ARTIK051_PRAC: POST 700 -> 2.04, persisted). Exposed as a Select keyed to
the device's supportedFilterDesiredUsage enum; the write POSTs the scalar
field back to /filter/airdustfilter/vs/0. Only binds where the enum is
advertised -- boards without it leave this writable field unexposed rather
than guess the valid set."""
desc = next(e for e in airconditioner.AIR_FILTER.entities
if e.key == 'air_filter_threshold')
assert isinstance(desc, SelectDesc)
assert desc.options_field == 'x.com.samsung.da.supportedFilterDesiredUsage'
assert desc.exists_fn(
{'x.com.samsung.da.supportedFilterDesiredUsage': ['180', '300', '500', '700']},
{}) is True
assert desc.exists_fn({}, {}) is False
# Current value is stringified for option matching.
assert desc.value_fn('500') == '500'
assert desc.value_fn(500) == '500'
assert desc.value_fn(None) is None
# Write POSTs the selected option as the scalar field.
assert desc.write_fn('700', {}) == (
['filter', 'airdustfilter', 'vs', '0'],
{'x.com.samsung.da.filterDesiredUsage': '700'})
def test_air_filter_threshold_absent_without_supported_enum():
"""WindFree (ARTIK051_PRAC) advertises no supportedFilterDesiredUsage, so
the writable threshold Select must not bind there -- even though the
scalar field is present and writable. Don't expose a control whose valid
options aren't known."""
reg, resources = _ac_windfree()
state = flatten(
discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
assert 'air_filter_threshold' not in state
assert state['air_filter_usage_hours'] == 41
assert state['air_filter_usage'] == 8 # 41/500 -> 8%
def test_air_filter_threshold_binds_on_enum_board():
"""tp1x_rac advertises supportedFilterDesiredUsage -> threshold Select
binds, current value read from filterDesiredUsage."""
reg, resources = _resolve('airconditioner_tp1x_rac')
state = flatten(
discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
assert state['air_filter_threshold'] == '500'
def test_air_quality_sensors_from_sensors_vs_items():
"""/sensors/vs/0 items[] surface as diagnostic scalars (no unit advertised
on the resource, so no device_class until a populated reading + unit is
observed -- the 'don't guess' rule). CleanLevel is corroborated as numeric
by a top-level cleanLevel scalar, so it's an int measurement; the others
are string diagnostics. Dust/FineDust/SuperFineDust carry a 2-element
array whose second element is unconfirmed -- v[0] is taken as the reading
(see _sensor_item_value)."""
reg, resources = _ac_windfree()
state = flatten(
discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
assert state['clean_level'] == 0 # numeric (int), corroborated
for key in ('odor', 'dust', 'fine_dust', 'super_fine_dust'):
assert state[key] == '0' # string diagnostic
# tp1x_da_ac_rac_01011 is the only fixture with a non-zero air-quality
# reading -- the one that catches a value_fn regression.
reg2, resources2 = _ac_tp1x()
state2 = flatten(
discover(resources2, reg2.capabilities, reg2.pattern_capabilities), resources2)
assert state2['clean_level'] == 1
def test_air_quality_disabled_by_default():
"""Issue #166 (ARxxTXFCAWKNEU, board ARTIK051_PRAC_20K): /sensors/vs/0
lists all five item types with permanent zero values on both its units --
the exact same shape as the WindFree/issue #17 dumps this capability was
first verified against -- yet the reporter confirmed none of these
sensors are physically present on their model.
A tighter exists_fn (requiring a corroborating top-level
x.com.samsung.da.cleanLevel scalar) was tried and reverted: it looked
like a real signal against this repo's AC fixtures, but
air_purifier_device.json, air_purifier_vtww_device.json, and
range_hood_device.json all carry genuinely populated Dust/FineDust/
SuperFineDust readings with no such scalar, so requiring it would
silently drop real readings on hardware this repo hasn't seen yet on an
AC. These stay bound whenever the item type is listed (see
_has_sensor_type) and disabled by default instead, same precedent as
fridge.rack_count / cooktop.paired_hood_model / tropical_night_mode --
units that do have the sensor can enable it themselves."""
for key in ('clean_level', 'odor', 'dust', 'fine_dust', 'super_fine_dust'):
desc = next(e for e in airconditioner.AIR_QUALITY.entities if e.key == key)
assert desc.enabled_default is False, key
def test_air_quality_absent_when_no_sensor_items():
"""A board whose /sensors/vs/0 carries an empty items[] (the cool-only
RAC variant) binds no air-quality entities -- exists_fn gates each on its
item type, not merely on the href being present."""
reg, resources = _resolve('airconditioner_tp1x_rac_coolonly')
assert '/sensors/vs/0' in resources # the href is there, just empty
state = flatten(
discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
for key in ('clean_level', 'odor', 'dust', 'fine_dust', 'super_fine_dust'):
assert key not in state, key
def test_sensor_item_value_picks_first_value():
"""_sensor_item_value returns the first element of the value list, as a
string; None when the item is absent or its value is empty."""
items = [
{'x.com.samsung.da.type': 'Dust', 'x.com.samsung.da.value': ['0', '0']},
{'x.com.samsung.da.type': 'Odor', 'x.com.samsung.da.value': []},
]
assert airconditioner._sensor_item_value(items, 'Dust') == '0'
assert airconditioner._sensor_item_value(items, 'Odor') is None
assert airconditioner._sensor_item_value(items, 'Missing') is None
assert airconditioner._sensor_item_value(None, 'Dust') is None
def test_beep_and_tropical_night_stay_off_legacy_krac_board():
"""ARTIK051_KRAC_18K (issue #136) reports both a Volume_ and a Sleep_
option token, but they're already modeled as buzzer_volume/good_sleep
(see airconditioner.CLIMATE) -- beep/tropical_night_mode must not also
bind there, or the same options[] slot would surface as two entities."""
reg, resources = _resolve('airconditioner_artik051_krac_18k')
state = flatten(
discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
assert 'beep' not in state
assert 'tropical_night_mode' not in state
assert state['buzzer_volume'] == 100.0
assert state['good_sleep'] == 0.0
@@ -0,0 +1,134 @@
"""TP1X_DA-AC-RAC-01001_0000 fan-strength codes (model AR07C9150HZN, issue
#155).
Its /wind/strength/vs/0 reports supportedModes "0"/"31"/"32"/"33"/"34"/"35"
instead of the "0"-"4" scale climate.py's _DEVICE_TO_FAN was built from
(every other AC fixture in this repo uses "0"-"4", some with a 6th "5" --
see airconditioner_window_ac_device.json). Only "0" matched _DEVICE_TO_FAN,
so fan_modes silently dropped every speed but Auto. The fix reads the
device's own modesName labels (parallel-indexed with supportedModes) for
any code _DEVICE_TO_FAN doesn't already cover, instead of hardcoding a
second numeric scale.
"""
from custom_components.localthings.climate import (
LocalThingsClimate, _DEVICE_TO_FAN, _wind_strength_label,
)
from custom_components.localthings.registry import by_type
from custom_components.localthings.registry.discovery import discover
from custom_components.localthings.registry.entities import ClimateDesc
from tests.conftest import _load_device
FIXTURE = 'airconditioner_tp1x_rac_01001'
class _FakeCoordinator:
device_serial = 'TEST-RAC-01001-SERIAL'
device_info = {}
data = {}
def __init__(self, resources):
self.last_resources = resources
self.commands = []
def resource(self, href):
return self.last_resources.get(href, {})
async def async_send_command(self, bound, payload):
self.commands.append((bound, payload))
def _climate(resources, coordinator=None):
info = resources['/information/vs/0']
reg = by_type.for_device_by_model(
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'])
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
climate_bound = next(item for item in bound if isinstance(item.desc, ClimateDesc))
return LocalThingsClimate(coordinator or _FakeCoordinator(resources), climate_bound)
def test_wind_strength_label_reads_the_devices_own_modes_name():
rep = {
'x.com.samsung.da.supportedModes': ['0', '31', '32', '33', '34', '35'],
'x.com.samsung.da.modesName': ['Auto', '1', '2', '3', '4', 'MAX'],
}
assert _wind_strength_label('32', rep) == '2'
assert _wind_strength_label('35', rep) == 'max'
def test_wind_strength_label_falls_back_to_raw_code_when_names_absent():
assert _wind_strength_label('32', {}) == '32'
def test_fan_modes_include_every_supported_speed_not_just_auto():
"""Before the fix, only '0' matched _DEVICE_TO_FAN and fan_modes was
['auto'] -- exactly the reported symptom."""
resources = _load_device(FIXTURE)
entity = _climate(resources)
assert entity.fan_modes == ['auto', '1', '2', '3', '4', 'max']
def test_fan_mode_reads_the_current_dynamic_code():
"""Fixture's /wind/strength/vs/0 modes is '32' -> modesName '2'."""
resources = _load_device(FIXTURE)
entity = _climate(resources)
assert entity.fan_mode == '2'
def test_standard_scale_codes_still_use_device_to_fan():
"""A code _DEVICE_TO_FAN already covers keeps its existing friendly
label rather than falling through to the device's own (blunter) one --
no regression for boards using the standard "0"-"4" scale."""
resources = _load_device(FIXTURE)
resources['/wind/strength/vs/0']['x.com.samsung.da.modes'] = '0'
entity = _climate(resources)
assert entity.fan_mode == _DEVICE_TO_FAN['0'] == 'auto'
async def test_set_fan_mode_resolves_a_dynamic_label_back_to_its_code():
resources = _load_device(FIXTURE)
coordinator = _FakeCoordinator(resources)
entity = _climate(resources, coordinator)
await entity.async_set_fan_mode('max')
assert coordinator.commands[-1][1] == ('fan', '35')
async def test_set_fan_mode_still_resolves_standard_scale_labels():
resources = _load_device(FIXTURE)
coordinator = _FakeCoordinator(resources)
entity = _climate(resources, coordinator)
await entity.async_set_fan_mode('auto')
assert coordinator.commands[-1][1] == ('fan', '0')
async def test_set_fan_mode_does_not_misroute_when_static_map_and_live_codes_collide():
"""A board can use non-standard codes ('31'-'33') while modesName still
spells a standard-looking label ('Low'/'High') that _FAN_TO_DEVICE's
static reverse map also happens to have an entry for ('1'/'3') -- but
that entry is for a *different* code this unit never advertises at all.
Resolving the static hit without checking it against this unit's own
supportedModes would silently write a code the device doesn't have.
"""
resources = _load_device(FIXTURE)
resources['/wind/strength/vs/0'] = {
'x.com.samsung.da.modes': '0',
'x.com.samsung.da.supportedModes': ['0', '31', '32', '33'],
'x.com.samsung.da.modesName': ['Auto', 'Low', 'High', 'Turbo'],
}
coordinator = _FakeCoordinator(resources)
entity = _climate(resources, coordinator)
assert entity.fan_modes == ['auto', 'low', 'high', 'turbo']
await entity.async_set_fan_mode('high')
assert coordinator.commands[-1][1] == ('fan', '32')
await entity.async_set_fan_mode('low')
assert coordinator.commands[-1][1] == ('fan', '31')
await entity.async_set_fan_mode('turbo')
assert coordinator.commands[-1][1] == ('fan', '33')
+44 -2
View File
@@ -7,8 +7,8 @@ testable directly.
from homeassistant.components.climate import HVACMode
from custom_components.localthings.climate import (
_AI_COMFORT_MODE, _DEVICE_TO_HVAC, _HVAC_TO_DEVICE, PRESET_AI_COMFORT,
_preset_to_ha,
_AI_COMFORT_MODE, _DEVICE_TO_HVAC, _HVAC_TO_DEVICE,
PRESET_AI_COMFORT, _preset_to_ha,
)
@@ -74,3 +74,45 @@ def test_preset_to_ha_lowercases_other_codes():
assert _preset_to_ha('Sleep') == 'sleep'
assert _preset_to_ha('NanoSleep') == 'nanosleep'
assert _preset_to_ha('MotionIndirect') == 'motionindirect'
def test_fac_bora_wind_strength_codes_fit_the_standard_scale():
"""TP2X_FAC_BORA_21K's (issues #150/#153) /wind/strength/vs/0 codes
(0/2/3/4, skipping 1/'low') and modesName (Auto/Mid/High/Turbo) already
match _DEVICE_TO_FAN's own mapping exactly -- no dynamic modesName
fallback needed for this particular board, unlike issue #155's
TP1X_DA-AC-RAC-01001_0000.
Asserts the live climate entity's actual fan_modes/fan_mode output
(not just that the module constant _DEVICE_TO_FAN happens to have
these keys) -- a bare `code in _DEVICE_TO_FAN` check would still pass
even if fan_modes/fan_mode were completely broken, since it never
touches the entity at all.
"""
from custom_components.localthings.climate import LocalThingsClimate
from custom_components.localthings.registry import by_type
from custom_components.localthings.registry.discovery import discover
from custom_components.localthings.registry.entities import ClimateDesc
from tests.conftest import _load_device
class _FakeCoordinator:
device_serial = 'TEST-FAC-BORA-SERIAL'
device_info = {}
data = {}
def __init__(self, resources):
self.last_resources = resources
def resource(self, href):
return self.last_resources.get(href, {})
resources = _load_device('airconditioner_fac_bora')
info = resources['/information/vs/0']
reg = by_type.for_device_by_model(
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'])
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
climate_bound = next(item for item in bound if isinstance(item.desc, ClimateDesc))
entity = LocalThingsClimate(_FakeCoordinator(resources), climate_bound)
assert entity.fan_modes == ['auto', 'medium', 'high', 'turbo']
assert entity.fan_mode == 'auto'
+34
View File
@@ -184,6 +184,40 @@ class TestWmSetinfoFlags:
},
]) == 'LE'
def test_off_suffixed_placeholder_codes_filtered(self):
"""issue #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 --
confirmed not just for 'ErrorCode_OFF' but also 'FilterAlarm_OFF'
on the same dump. These were previously shown as if they were
active alarms."""
assert common._active_alarm_codes([
{
'x.com.samsung.da.code': 'ErrorCode_OFF',
'x.com.samsung.da.triggeredTime': '2026-07-28T12:59:22',
},
{
'x.com.samsung.da.code': 'FilterAlarm_OFF',
'x.com.samsung.da.triggeredTime': '2026-07-28T12:59:22',
},
]) == 'none'
def test_real_filter_alarm_not_off_suffixed_still_shown(self):
"""issue #166's actual live filter alert: code 'FilterAlarm' (no
'_OFF' suffix), state 'Created' -- distinct from the 'FilterAlarm_OFF'
placeholder and must still surface."""
assert common._active_alarm_codes([
{
'x.com.samsung.da.code': 'ErrorCode_OFF',
'x.com.samsung.da.triggeredTime': '2026-07-28T12:59:22',
},
{
'x.com.samsung.da.code': 'FilterAlarm',
'x.com.samsung.da.state': 'Created',
'x.com.samsung.da.triggeredTime': '2026-07-28T12:59:22',
},
]) == 'FilterAlarm'
class TestKidsLockFallback:
def test_generic_read_write(self):
+16
View File
@@ -209,3 +209,19 @@ def test_discover_rt_filter_gates_binding():
resources = {'/mode/vs/0': {'rt': ['x.com.samsung.da.mode'], 'x.com.samsung.da.mode': 'Bake'}}
bound = discover(resources, {'/mode/vs/0': [oven_mode_cap]})
assert bound == []
def test_discover_tier_log_fires_for_no_entity_coverage_cap():
"""A coverage-only Capability (entities=(), used to mark a href as
handled by e.g. a composite climate entity rather than its own HA
entity) produces zero BoundEntity rows -- `bound` alone can't tell a
caller its poll_tier. tier_log must still report it, or a consumer
(the coordinator's hot/warm href lists) silently treats it as 'cold'
regardless of what poll_tier the capability actually declares."""
coverage_cap = Capability(href='/wind/strength/vs/0', poll_tier='warm')
resources = {'/wind/strength/vs/0': {'x.com.samsung.da.modes': '0'}}
seen = []
bound = discover(resources, {coverage_cap.href: [coverage_cap]},
tier_log=lambda href, tier: seen.append((href, tier)))
assert bound == []
assert seen == [('/wind/strength/vs/0', 'warm')]
+178
View File
@@ -191,6 +191,27 @@ def test_registry_reproduces_golden_state_keys_for_tp1x_ref_21k_us():
)
def test_registry_reproduces_golden_state_keys_for_tp1x_ref_21k_eu():
"""TP1X_REF_21K, EU region variant (issue #165) -- self-reports
oneUiVersion "7.0 Refrigerator" like the US variant, but additionally
carries /rm/control/vs/0 (a bare resource-monitoring poll-interval
config, added to the global ignore list) that the US dump doesn't
report at all. Door sensors (the reporter's actual ask) were already
covered by fridge.DOOR_GENERIC/DOORS_FALLBACK -- this href was the only
gap keeping the coverage repair open."""
from tests.conftest import _load_device
resources = _load_device('refrigerator_tp1x_ref_21k_eu')
golden = json.loads(
(GOLDEN / 'refrigerator_tp1x_ref_21k_eu.json').read_text()
)
state_keys = _new_state_keys('refrigerator_tp1x_ref_21k_eu', resources)
assert set(state_keys) == set(golden['state_keys']), (
f"state_keys mismatch:\n"
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
)
def test_registry_reproduces_golden_state_keys_for_range_hood():
from tests.conftest import _load_device
resources = _load_device('range_hood')
@@ -457,6 +478,12 @@ def test_registry_reproduces_golden_state_keys_for_induction_cooktop():
resources = _load_device('induction_cooktop')
golden = json.loads((GOLDEN / 'induction_cooktop.json').read_text())
state_keys = _new_state_keys('induction_cooktop', resources)
assert set(state_keys) == set(golden['state_keys']), (
f"state_keys mismatch:\n"
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
)
def test_registry_reproduces_golden_state_keys_for_range_no_info():
"""NE63B8411SS (issue #74) -- reports no oneUiVersion *and* no
@@ -544,6 +571,23 @@ def test_registry_reproduces_golden_state_keys_for_range_ne8300d():
)
def test_registry_reproduces_golden_state_keys_for_range_ne63a6511():
"""NE63A6511SS/AA (issue #138) -- reports no /information/vs/0 at all,
same shape as issue #74's NE63B8411SS; resolved via the same
'Bake'-in-supportedModes + /cooktopmonitoring/vs/0 signature in
for_device_by_resources. Binds cleanly against the existing range
registry with zero unbound hrefs."""
from tests.conftest import _load_device
resources = _load_device('range_ne63a6511')
golden = json.loads((GOLDEN / 'range_ne63a6511.json').read_text())
state_keys = _new_state_keys('range_ne63a6511', resources)
assert set(state_keys) == set(golden['state_keys']), (
f"state_keys mismatch:\n"
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
)
def test_registry_reproduces_golden_state_keys_for_airconditioner_ara_ww_tp1_22():
"""ARA-WW-TP1-22-COMMON wall-mount RACs (model AR10/13/18BYEAAWKNME,
issues #115/#116/#117/#120) report no oneUiVersion and no
@@ -630,6 +674,22 @@ def test_registry_reproduces_golden_state_keys_for_microwave_me7500d():
)
def test_registry_reproduces_golden_state_keys_for_microwave_me7500d_lamp_high():
"""Same TP1X_DA-KS-MICROWAVE-01051/ME7500D board as microwave_me7500d
above, but this live capture (issue #152) is the first to report a
non-Off Lamp token ('Lamp_High'). Locks in that the lamp switch reads
it as on rather than the previously-hardcoded 'On'-only comparison."""
from tests.conftest import _load_device
resources = _load_device('microwave_me7500d_lamp_high')
golden = json.loads((GOLDEN / 'microwave_me7500d_lamp_high.json').read_text())
state_keys = _new_state_keys('microwave_me7500d_lamp_high', resources)
assert set(state_keys) == set(golden['state_keys']), (
f"state_keys mismatch:\n"
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
)
def test_registry_reproduces_golden_state_keys_for_air_purifier_tp1x_da_ac_air():
"""TP1X_DA-AC-AIR-01031_0000 (issue #130) self-reports oneUiVersion
'7.0 Air purifier' and resolves via for_device() onto the existing
@@ -671,6 +731,124 @@ def test_registry_reproduces_golden_state_keys_for_vacuum_station():
)
def test_registry_reproduces_golden_state_keys_for_artik051_krac_18k():
"""ARTIK051_KRAC_18K (issue #136) -- reports no oneUiVersion, and its
'_KRAC_' token was invisible to for_device_by_model's '_RAC_' check (the
'K' sits between the underscore and 'RAC'), so it fell back to 'unknown'
and exposed nothing but power. Same ARTIK051 board generation as the
'_TVTL_' air purifier: no /wind/* resources at all (fan and vane live in
/airflow/vs/0), no /mode/convenient/vs/0 (the preset is a Comode_* token),
and several settings carried as /mode/vs/0 options[] tokens."""
from tests.conftest import _load_device
resources = _load_device('airconditioner_artik051_krac_18k')
golden = json.loads(
(GOLDEN / 'airconditioner_artik051_krac_18k.json').read_text()
)
state_keys = _new_state_keys('airconditioner_artik051_krac_18k', resources)
assert set(state_keys) == set(golden['state_keys']), (
f"state_keys mismatch:\n"
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
)
def test_registry_reproduces_golden_state_keys_for_airconditioner_tp1x_rac_01001():
"""TP1X_DA-AC-RAC-01001_0000 (model AR07C9150HZN, issue #155) -- binds
cleanly against the existing airconditioner registry with zero unbound
hrefs (the registry/discovery side was never the gap here). Its
/wind/strength/vs/0 reports supportedModes "0"/"31"-"35" instead of the
"0"-"4" scale climate.py's _DEVICE_TO_FAN was built from, which silently
dropped every fan speed but Auto -- see
test_airconditioner_tp1x_rac_01001_fan.py for the climate-level fix."""
from tests.conftest import _load_device
resources = _load_device('airconditioner_tp1x_rac_01001')
golden = json.loads(
(GOLDEN / 'airconditioner_tp1x_rac_01001.json').read_text()
)
state_keys = _new_state_keys('airconditioner_tp1x_rac_01001', resources)
assert set(state_keys) == set(golden['state_keys']), (
f"state_keys mismatch:\n"
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
)
def test_registry_reproduces_golden_state_keys_for_air_dresser():
"""DA_DF_A51_20_COMMON AirDresser (model DF8600T, issue #162) -- reports
no oneUiVersion; resolved via the '_DF_' modelNum token fallback. Has no
/wm/editcourse/vs/0 at all, so the course select's option list comes
entirely from laundry.cycle_options' supportedOptions fallback. Binds
cleanly against the new air_dresser registry with zero unbound hrefs."""
from tests.conftest import _load_device
resources = _load_device('air_dresser')
golden = json.loads((GOLDEN / 'air_dresser.json').read_text())
state_keys = _new_state_keys('air_dresser', resources)
assert set(state_keys) == set(golden['state_keys']), (
f"state_keys mismatch:\n"
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
)
def test_registry_reproduces_golden_state_keys_for_air_dresser_tp2_20():
"""DA_DF_TP2_20_COMMON AirDresser (model DF9500A, issue #157) -- a
different board generation than issue #162's DA_DF_A51_20_COMMON, also
routed via the '_DF_' modelNum fallback into the same air_dresser
registry. Unlike #162's board, this one populates /wm/editcourse/vs/0's
editCourseList directly (no supportedOptions fallback needed) and
reports two AirDresser-specific resources #162 doesn't have:
/st/airdressercourse/vs/0 (course table id, ignored.py) and
/airdresseroption/sanitize/vs/0 (air_dresser.AIR_DRESSER_SANITIZE).
Binds cleanly with zero unbound hrefs."""
from tests.conftest import _load_device
resources = _load_device('air_dresser_tp2_20')
golden = json.loads((GOLDEN / 'air_dresser_tp2_20.json').read_text())
state_keys = _new_state_keys('air_dresser_tp2_20', resources)
assert set(state_keys) == set(golden['state_keys']), (
f"state_keys mismatch:\n"
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
)
def test_registry_reproduces_golden_state_keys_for_air_purifier_vtww():
"""A-VTWW-TP2-21-COMMON BESPOKE Cube Air (issue #151) -- reports no
oneUiVersion; resolved via the '-VTWW-' modelNum token fallback into
the existing air_purifier registry. Its fan lives on
/wind/strength/vs/0 (air_purifier.WIND_STRENGTH_FAN) rather than the
/mode/vs/0 FAN the other two board generations in this registry use.
Binds cleanly with zero unbound hrefs."""
from tests.conftest import _load_device
resources = _load_device('air_purifier_vtww')
golden = json.loads((GOLDEN / 'air_purifier_vtww.json').read_text())
state_keys = _new_state_keys('air_purifier_vtww', resources)
assert set(state_keys) == set(golden['state_keys']), (
f"state_keys mismatch:\n"
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
)
def test_registry_reproduces_golden_state_keys_for_airconditioner_fac_bora():
"""TP2X_FAC_BORA_21K Wind-Free 2-in-1 (floor-standing + wall-mounted
indoor units sharing one outdoor unit and one local IP, issues
#150/#153) -- reports no oneUiVersion; resolved via the '_FAC_'
modelNum fallback. Binds cleanly against the existing airconditioner
registry (including a real climate entity, the actual reported gap)
with zero unbound hrefs -- /subdevices/vs/0 and /runn/vs/0 are the only
hrefs this board reports that no other AC family does, both added to
_AC_IGNORED as undocumented/not-locally-actionable."""
from tests.conftest import _load_device
resources = _load_device('airconditioner_fac_bora')
golden = json.loads((GOLDEN / 'airconditioner_fac_bora.json').read_text())
state_keys = _new_state_keys('airconditioner_fac_bora', resources)
assert set(state_keys) == set(golden['state_keys']), (
f"state_keys mismatch:\n"
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
)
def test_resources_from_batch_preferred_over_flat():
from tests.conftest import _resources_from_dump
dump = {
+46 -4
View File
@@ -120,9 +120,23 @@ def test_power_level_handles_missing_value():
def test_microwave_mode_options_nonempty():
desc = microwave.MICROWAVE_MODE.entities[0]
assert len(desc.options) > 0
assert 'MicroWave' in desc.options
assert 'AirFryer' in desc.options # distinct spelling from oven.py's 'AirFry'
assert callable(desc.options)
options = desc.options({})
assert len(options) > 0
assert 'MicroWave' in options
assert 'AirFryer' in options # distinct spelling from oven.py's 'AirFry'
def test_microwave_mode_options_reads_live_supported_modes():
"""issue #152's ME7500D reports only 4 of the 11 union-of-all-dumps
_MICROWAVE_MODES -- the live supportedModes list is used verbatim when
present, same live-first pattern as oven._oven_mode_options, instead of
offering users modes their own unit doesn't have."""
desc = microwave.MICROWAVE_MODE.entities[0]
resources = {'/mode/vs/0': {
'x.com.samsung.da.supportedModes': ['NoOperation', 'MicroWave', 'Autocook', 'KeepWarm'],
}}
assert desc.options(resources) == ['NoOperation', 'MicroWave', 'Autocook', 'KeepWarm']
def test_microwave_mode_write_round_trips():
@@ -137,6 +151,19 @@ def test_microwave_mode_rejects_unknown():
assert desc.write_fn('SpaghettiMode', {}) is None
def test_microwave_mode_write_validates_against_live_supported_modes():
"""A device reporting its own supportedModes is validated against that
list, not the static union-of-all-dumps fallback -- 'AirFryer' is a
valid _MICROWAVE_MODES entry but must still be rejected for a unit
whose own supportedModes doesn't include it."""
desc = microwave.MICROWAVE_MODE.entities[0]
rep = {'x.com.samsung.da.supportedModes': ['NoOperation', 'MicroWave', 'Autocook', 'KeepWarm']}
path, body = desc.write_fn('MicroWave', rep)
assert path == ['mode', 'vs', '0']
assert body['x.com.samsung.da.modes'] == ['MicroWave']
assert desc.write_fn('AirFryer', rep) is None
# ---------------------------------------------------------------------------
# MICROWAVE_MODE — lamp/sound options-array writes
# ---------------------------------------------------------------------------
@@ -165,13 +192,28 @@ def test_lamp_gated_present_when_lamp_option_reported():
def test_lamp_write_is_single_token():
"""issue #152: the device has never been observed accepting 'On' --
only 'High'/'Off' -- so the switch's "on" write uses 'High'."""
desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'lamp')
rep = {'x.com.samsung.da.options': ['Lamp_Off']}
path, body = desc.write_fn('On', rep)
assert path == ['mode', 'vs', '0']
assert body == {'x.com.samsung.da.options': ['Lamp_On']}
assert body == {'x.com.samsung.da.options': ['Lamp_High']}
def test_lamp_write_requires_existing_options():
desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'lamp')
assert desc.write_fn('On', {}) is None
def test_lamp_reads_off_as_false():
desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'lamp')
assert desc.value_fn(['Lamp_Off']) is False
def test_lamp_reads_any_non_off_level_as_true():
"""issue #152's ME7500D reports 'Lamp_High', not the binary 'Lamp_On'
#137's dump implied -- any non-Off/non-absent value must read as on, not
just a literal 'On'."""
desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'lamp')
assert desc.value_fn(['Lamp_High']) is True
+32 -2
View File
@@ -120,12 +120,29 @@ def test_oven_setpoint_native_bounds_track_live_unit():
# ---------------------------------------------------------------------------
def test_oven_mode_options_nonempty():
assert len(oven.OVEN_MODE.entities[0].options) > 0
desc = oven.OVEN_MODE.entities[0]
assert callable(desc.options)
assert len(desc.options({})) > 0
def test_oven_mode_options_falls_back_when_no_live_supported_modes():
desc = oven.OVEN_MODE.entities[0]
assert desc.options({}) == list(oven._OVEN_MODES)
def test_oven_mode_options_reads_live_supported_modes():
"""issue #138: the device's own supportedModes list is used verbatim
when present, instead of the static _OVEN_MODES guess."""
desc = oven.OVEN_MODE.entities[0]
resources = {'/mode/vs/0': {
'x.com.samsung.da.supportedModes': ['Bake', 'AirFryer', 'SelfClean'],
}}
assert desc.options(resources) == ['Bake', 'AirFryer', 'SelfClean']
def test_oven_mode_write_round_trips():
desc = oven.OVEN_MODE.entities[0]
valid_mode = desc.options[1] # e.g. 'Bake'
valid_mode = desc.options({})[1] # e.g. 'Bake'
path, body = desc.write_fn(valid_mode, {})
assert path == ['mode', 'vs', '0']
assert body['x.com.samsung.da.modes'] == [valid_mode]
@@ -136,6 +153,19 @@ def test_oven_mode_rejects_unknown():
assert desc.write_fn('SpaghettiMode', {}) is None
def test_oven_mode_write_validates_against_live_supported_modes():
"""A device reporting its own supportedModes is validated against that
list, not the static fallback -- issue #138's AirFryer/SelfClean/etc.
are accepted, and a mode outside the device's own list is rejected even
if some other model's static guess would have allowed it."""
desc = oven.OVEN_MODE.entities[0]
rep = {'x.com.samsung.da.supportedModes': ['Bake', 'AirFryer', 'SelfClean']}
path, body = desc.write_fn('AirFryer', rep)
assert path == ['mode', 'vs', '0']
assert body['x.com.samsung.da.modes'] == ['AirFryer']
assert desc.write_fn('FrozenPizzaPlus', rep) is None
# ---------------------------------------------------------------------------
# OVEN_MODE options-array writes (lamp, sound, fast_preheat, natural_steam).
# Confirmed on real hardware (issue #54): a write only needs to carry the
@@ -0,0 +1,68 @@
"""Tests for the NE63A6511SS/AA range (issue #138) -- same no-/information/vs/0,
no-burner-status shape as issue #74's NE63B8411SS, confirming the existing
range_no_info detection path still resolves this model with zero unbound
hrefs, plus oven.OVEN_MODE reading this dump's ConvectionRoast/KeepWarm/
BreadProof/AirFryer/Dehydrate/SelfClean/SteamClean modes live from its own
/mode/vs/0 supportedModes rather than needing them hardcoded."""
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import for_device_by_resources
from custom_components.localthings.registry.capabilities import oven
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
def _range():
resources = _load_device('range_ne63a6511')
reg = for_device_by_resources(resources)
return reg, resources
def _state():
reg, resources = _range()
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
return flatten(bound, resources)
def test_resolves_to_range_registry():
reg, _ = _range()
assert reg is not None and reg.name == 'range'
def test_no_unbound_hrefs():
reg, resources = _range()
unbound = []
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
assert unbound == []
def test_expected_entities_present():
state = _state()
for key in (
'power_switch', 'oven_setpoint', 'current_temp_c', 'oven_mode',
'machine_state', 'door_open', 'cloud_connected', 'child_lock',
'cooktop_running_state', 'warming_center_state',
):
assert key in state, key
def test_oven_mode_accepts_this_devices_supported_modes():
"""This dump's /mode/vs/0 supportedModes reports ConvectionRoast,
KeepWarm, BreadProof, AirFryer, Dehydrate, SelfClean, and SteamClean --
none of which were in the static oven._OVEN_MODES fallback, so a
hardcoded write-validation list would've silently rejected them even
though the device itself advertises them. write_fn reads supportedModes
off the live rep (the same one it's called with in production -- see
coordinator.py's async_send_command), so this passes without needing
those modes added to any Python list."""
_, resources = _range()
live_rep = resources['/mode/vs/0']
desc = oven.OVEN_MODE.entities[0]
assert desc.options(resources) == live_rep['x.com.samsung.da.supportedModes']
for mode in (
'ConvectionRoast', 'KeepWarm', 'BreadProof', 'AirFryer',
'Dehydrate', 'SelfClean', 'SteamClean',
):
path, body = desc.write_fn(mode, live_rep)
assert path == ['mode', 'vs', '0']
assert body['x.com.samsung.da.modes'] == [mode]