Compare commits

..
Author SHA1 Message Date
Claude ac93549f2a Retry /<uuid>/information/vs/0 in the flat fallback so siblings get their own model/serial
Cloning the master's hrefs/values verbatim (issue #265) also clones its
/information/vs/0 -- so a sibling's HA device page showed the master's
own model and serial number, reading as a duplicate device even though
the two devices' registry identifiers are genuinely distinct (they're
derived from the subdevice's UUID, not from this resource).

/information/vs/0 is also the one href this pattern has ever actually
been confirmed to answer on its own (issue #177 comment 5113518087), so
the fallback now retries that one href specifically -- a single bounded
RETRIEVE per candidate, not the ~30 that caused the original hang -- and
uses the real reply in place of the clone when it answers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MuiJvThhi4WkNj2xb9xo8o
2026-08-03 13:06:02 +00:00
Claude b32074c8dd Stop probing every href for Pattern B/C subdevice fallback (issue #265)
When /<uuid>/device/0 doesn't answer for a UUID-prefixed subdevice
candidate, enumerate_subdevices used to fall back to probing every href
the master answered that cycle individually under the prefix. Firmware
that drops packets instead of 4.04ing (issue #265's ARTIK051_FAC_TIME_21K)
turned that ~30-href loop into a ~300s hang at the default 10s RETRIEVE
timeout, tripping Home Assistant's bootstrap-stage timeout and failing
the whole config entry before the master device even finished setup.

The fallback now trusts the device's own subdeviceIdList/oic_res-link
claim outright instead of re-confirming it href by href: it clones the
master's current hrefs and values verbatim under the prefix as the
sibling's assumed state, with no extra RETRIEVEs. The existing
per-summary-poll re-poll (_poll_subdevice_flat_hrefs) is unchanged and
corrects the clone toward the sibling's real values as they're confirmed
live; an href the firmware never answers under that prefix just keeps
mirroring the master indefinitely.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MuiJvThhi4WkNj2xb9xo8o
2026-08-03 12:49:58 +00:00
Marc Billow 51103fa341 Merge pull request #264 from mbillow/claude/ruff-pyright-ci-stage-gxph0t
Add ruff (lint + format) and ty (type checking) to the project
2026-08-02 20:30:08 -04:00
Marc Billow 9dc1facbfe Fix ty diagnostics from newer homeassistant/cryptography type stubs
requirements-dev.txt intentionally leaves homeassistant/cryptography
unpinned (always test against latest), so ty's view of their stubs can
drift between runs. SensorEntity._attr_state_class now requires
SensorStateClass rather than a bare str (same fix already applied to
_attr_device_class); NameAttribute.value is generic over str | bytes,
so narrow it before handing it to re.search.
2026-08-03 00:25:46 +00:00
Marc Billow 24d48d70b9 Reformat after merging main
main advanced past this branch (PR #263, entity-less-after-restart fix)
with unformatted changes to coordinator.py's tests; re-running ruff
format picks those up. Merge commit itself had no conflicts.
2026-08-03 00:21:34 +00:00
Marc Billow 18596f23ec Merge remote-tracking branch 'origin/main' into claude/ruff-pyright-ci-stage-gxph0t 2026-08-03 00:20:59 +00:00
Marc Billow 450f8ca933 Add lint/format/type-check CI job
New "lint" job in validate.yml runs ruff format --check, ruff check,
and ty check against custom_components/ and tests/ on the same
push/PR/schedule triggers as the existing hassfest/hacs/pytest jobs.
2026-08-03 00:16:34 +00:00
Marc Billow 679c3d2bce Fix remaining pre-existing ty diagnostics in test files
Completes the isinstance/cast narrowing + Optional-field assert pattern
across the last batch of test files. custom_components and tests are
now both fully clean under ruff check, ruff format --check, and ty check.
2026-08-03 00:14:58 +00:00
Marc Billow 36a642135b Fix pre-existing ty diagnostics in a second batch of test files
Same isinstance/cast narrowing and Optional-field assert pattern as the
prior commits, covering the airconditioner, fridge, washer, operational,
subdevices, sensor_hysteresis, laundry, select_options, identity and
entities test files.
2026-08-03 00:11:05 +00:00
Marc Billow 07061c734d Fix more pre-existing ty diagnostics in test files
Continues narrowing SamsungEntityDescription accesses to the correct
subclass and asserting Optional write_fn/match_fn/exists_fn fields are
set before calling them, per the pattern established in the previous
commit.
2026-08-03 00:03:08 +00:00
Marc Billow daf7e3787f Add ruff (lint + format) and ty (type checking) to the project
Adds [tool.ruff] and [tool.ty] config to pyproject.toml with a curated
ruff rule set (E, F, W, I, UP, B, C4, SIM, RUF, ASYNC, LOG, G, PIE, RET,
PERF, N), pins ruff/ty in requirements-dev.txt, reformats the whole tree
with `ruff format`, and fixes the pre-existing lint and type-check debt
those tools surfaced so both run clean.

Production-code type fixes include: HA's ConfigFlowResult vs. the
generic FlowResult in config_flow.py, narrowing BoundEntity.desc to its
platform-specific subclass (SelectDesc/NumberDesc/SensorDesc/etc.) via
cast() instead of an unchecked annotation, converting HA device_class
strings to their proper enum types, a resolve_registry callback typed
as `object` instead of `DeviceRegistry | None`, and a couple of other
narrow correctness fixes (CA key type validation, an index-out-of-bounds
false positive from an empty-tuple fallback, a bool/dict argument swap).

Test-file fixes are mechanical: narrowing SamsungEntityDescription to
the correct subclass via isinstance()/cast() before accessing
subclass-only fields, and asserting Optional write_fn/unit_fn fields
are set before calling them.
2026-08-02 23:56:38 +00:00
Marc Billow d9c84e765b Merge pull request #263 from mbillow/claude/issue-254-diagnosis-fix-a2t1y1
Fix devices coming up entity-less after a Core restart (#254)
2026-08-02 19:50:02 -04:00
Marc Billow 00abfb9770 Tighten the #254 fix after review
No behavior change to the fix itself; cleanup only.

Production:

- Trim the narrative that was told three times over (coordinator
  docstring, test docstring, inline comment) down to one telling in the
  docstring, where someone tempted to remove the gate will be standing.
- Guard the second degraded return in _async_update_data on
  self._discovered too. That arm is currently unreachable before
  discovery only because every _observe.apply() call site happens to be
  gated on post-discovery state -- a non-local accident across four call
  sites. Stating the precondition where it is relied on makes it the same
  explicit rule _defer_reconnect_for now applies.

Tests, 7 -> 4 with better discrimination:

- test_session_closed_when_first_refresh_fails asserted _close_session
  was called, which the reconnect path already does on its own -- so it
  passed with the fix removed. Merged into the persistent-timeout test
  and re-pointed at async_close, which only setup calls.
- Dropped the __new__-built unit test: it set one attribute on an
  otherwise uninitialized instance, so it asserted the gate's position in
  the function rather than any behavior, and would have errored rather
  than failed if reordered.
- Folded the timeout-budget test into the recovery test it was a
  byte-for-byte copy of, and replaced both hand-rolled call counters with
  the side_effect=[exc, resources] idiom already used in this file.

Each of the three production changes is now independently covered:
removing any one of them alone fails the suite.
2026-08-02 23:44:38 +00:00
Marc Billow 9fc04e179e Fix devices coming up entity-less after a Core restart
A failed DTLS handshake on the very first poll was being swallowed, so
the config entry loaded with no entities at all and stayed that way until
the user reloaded that device by hand (issue #254).

_poll_once() connects when there is no session yet, so connect()'s
handshake timeout reaches _async_update_data as a TimeoutError -- the
same type a slow blockwise transfer raises mid-session.
_defer_reconnect_for() only knew the mid-session meaning and deferred it,
making _async_update_data return flatten([], {}) == {} instead of
raising. DataUpdateCoordinator counts any non-raising return as success,
so async_config_entry_first_refresh saw a healthy first refresh and
skipped ConfigEntryNotReady, and setup forwarded the platforms with
`bound` still empty. Platforms enumerate `bound` once and have no dynamic
add-listener, so a later cycle repopulating it added nothing: every
restored entity sat unavailable until a manual reload.

Gate the deferral on self._discovered. Before the first discovery a poll
failure now takes the normal path -- one reconnect attempt, then
UpdateFailed -> ConfigEntryNotReady -- so HA retries on its own backoff
until the handshake goes through.

Two related fixes in the same failure path:

- Close the DTLS session on EVENT_HOMEASSISTANT_STOP, not only on entry
  unload. HA does not unload entries on a Core restart, so async_close()
  never ran and the previous run's association was left orphaned on the
  appliance -- which is what makes the next run's handshake time out in
  the first place. The fixed source port still covers the unclean-exit
  case where no close_notify can be sent.

- Close the session when first refresh fails. _poll_once deliberately
  leaves it open on a TimeoutError, so a failed setup abandoned a bound
  UDP socket on a port that is fixed per device by design, and each HA
  retry bound another socket to that same port.
2026-08-02 23:26:50 +00:00
Marc Billow b0eab93b58 Merge pull request #262 from mbillow/claude/pr-256-it-translation
Complete Italian translation, fix es.json parity
2026-08-02 18:07:12 -04:00
Marc Billow 547388cc2b Fix es.json translation-catalog parity with en.json
test_every_language_mirrors_the_english_catalog was failing on main
for es (PR #246) independent of this branch. Beyond the 32 keys en.json
gained since #246 merged (the AC/fan preset_mode and fan_mode state
blocks, and the new EHS/zone/auto-clean keys), the file had accumulated
several pre-existing bugs that also broke topology parity:

- climate.airconditioner and fan.air_purifier_fan carried a stray
  "name" key that doesn't exist in en.json's catalog for either (both
  entities are unnamed in code); removed, and their real
  state_attributes blocks added.
- select.buzzer_sound and select.finish_sound were keyed by literal
  on-wire device codes (Volume_Off/Low/Med/High, Finish Sound_1/2/3)
  instead of en.json's actual off/on states -- dead translations, never
  resolved at runtime. finish_sound's values were also unrelated song
  titles, not sound-toggle labels. Replaced both with real off/on
  entries.
- select.dryer_cycle_table_03 had codes 1c/1d/1e (Shirts/Towels/Outdoor)
  rotated by one slot, so a Shirts cycle displayed "Toallas"; realigned
  to the correct codes and added the 2 missing ones (2b, 4c).
- select.washer_cycle_table_02 carried 4 stray codes (06/08/74/A0) not
  present in en.json's table at all, duplicating already-correct
  translations under codes this device never reports; removed.

tests/test_translations.py now passes for every language, and the full
suite is green (1121 passed).
2026-08-02 22:04:32 +00:00
Marc Billow 81b83f4175 Complete Italian translation
Fills in the remaining entity names/states and fixes one broken
placeholder in the existing translation (issues.device_gap.description
used {nome_dispositivo} where the string is formatted with
{device_name}, which would have rendered the literal placeholder in
the UI instead of the device name).

Samsung-marketed cycle/feature names (WindFree, AI Wash/Comfort/Energy
Mode, Smart Control/Dry, Storm Wash+, Self Clean+, Drum Clean+, Frozen
Pizza+, Good Sleep, Super Speed) are left in English, matching how
nl.json treats the same set -- WindFree and AI Dry each get their
qualifier translated (WindFree sonno, Asciugatura AI) while the brand
word stays put, the same split nl.json makes.
2026-08-02 22:04:04 +00:00
g1za c5f37ea280 Partial Italian translation
Translates the custom integration's config/options/issues/exceptions
strings and the washing machine entity labels (select/binary_sensor
entries for cycle, spin speed, wash temperature, detergent/softener,
child lock, and related sensors).
2026-08-02 21:55:36 +00:00
Marc Billow b4550cc4b1 Merge pull request #246 from axelet85/feat/es-translation
feat: Spanish translation (es.json)
2026-08-02 17:43:21 -04:00
Marc Billow 42a1812967 Merge pull request #261 from mbillow/claude/pr-242-review-merge-nny8ug
feat: discover UUID-prefixed subdevices advertised only via /oic/res (Pattern C, #241)
2026-08-02 17:42:46 -04:00
Marc Billow e926905517 fix: dedupe Pattern C's UUID-prefix candidates against Pattern B (#242 review)
Both the /subdevices/vs/0 subdeviceIdList (Pattern B) and an /oic/res
link's UUID prefix (Pattern C) can name the same physical subdevice --
TP2X_FAC_BORA_21K, the Pattern B reporter's own board, does. Filtering
the two candidate lists against each other with a plain set difference
missed this when the two sources disagree on the UUID's case, letting
the same subdevice get probed and materialized twice under two
different keys.

Move the guard into _probe_prefixed itself, keyed on a
case-normalized id, so neither pattern can add a candidate the other
already claimed regardless of casing.
2026-08-02 21:40:34 +00:00
Hyunook 6bcf8f3bec feat: discover UUID-prefixed subdevices advertised only via /oic/res (Pattern C, #241)
AWM-WW-AID-26-ONEBODY (washer+dryer combo) reports numofsubdevice='2' on
/multidevice/vs/0 but carries no /subdevices/vs/0 (no subdeviceIdList --
Pattern B's signal) and 4.04s /device/1 and /device/2 (Pattern A's). The
washer subdevice's UUID appears only as the path prefix of the
x.com.samsung.da.multidevice link in /oic/res; GET /<uuid>/device/0
answers the washer's own full Collection batch (model ..._WF80H vs the
master's ..._DV80H27H).

Treat every UUID path prefix seen in /oic/res as a prefixed-subdevice
candidate (minus ones subdeviceIdList already named), probed with the
same tolerated-404 seed RETRIEVE as Pattern B -- the shared body is
factored into _probe_prefixed. discover_partitioned's entity-level
liveness gate still decides materialization, so a UUID link with no live
sibling behind it contributes nothing.

Fixture is a live capture from the reporting board (serials/MACs/di
scrubbed); tests cover discovery, probe hygiene, washer-side entity
binding, and that the master's own entity set is unchanged.
2026-08-02 21:40:34 +00:00
Marc Billow 9737684c9f Merge pull request #253 from pookey/feat/ehs-water-heater
Add a water_heater platform for the EHS DHW loop
2026-08-02 17:32:22 -04:00
Ian P. Christian 6efee761d9 Add Samsung EHS (Eco Heating System) heat pump support
Adds a device registry for the TP1X_DA_AC_EHS board family: separate
zone1 (space heating/cooling) and dhw (domestic hot water) loops.
zone1 is exposed as power switch + mode select + current/target
temperature sensor/number -- it's a leaving-water-temperature
setpoint, not a thermostat, so no HA platform fits it better. dhw
gets a composite water_heater entity, using the same
primary-resource-plus-sibling-reads shape climate.py already uses
for the AC (PR #247 review feedback: "Having water heaters
automatically leverage the right platform would be pretty cool!").
The unit's away mode is a device-wide switch, not the water_heater
AWAY_MODE feature -- /option/outgoing/vs/0 has no dhw-scoped sibling
and covers zone1 too, so presenting it on the DHW card would
misstate its scope.

Operation modes (Eco/Std/Force/Power) map onto HA's own standard
water_heater states, the same mapping HA core's smartthings
integration uses for this exact Samsung capability over the cloud
API. Device codes are matched case-insensitively on the read side.
The DHW entity takes a catalog name ("Hot water") rather than the
bare device name -- unlike the AC's climate card, it is one loop of
a two-loop device.

Both temperature ranges fall back as a pair: a resource reporting a
minimum but no maximum yields no range at all rather than mixing a
device bound with an invented default, matching climate._range().
Increment fallbacks test for None instead of using `or`, so a
genuine 0 survives.

set_temperature honours the optional operation_mode HA's
water_heater service schema forwards, setting the mode (and powering
the loop on) before the setpoint, the same way climate's
set_temperature handles hvac_mode.

Entity names are translated into Czech and Dutch following each
file's existing terminology conventions.

Verified against a real TP1X_DA_AC_EHS_01001_0000 diagnostics dump
(firmware AEH-WW-TP1-22-AE6000_17260402); golden-regression fixture
and full test coverage included.
2026-08-02 22:18:43 +01:00
Marc Billow e309d3e7b8 Merge pull request #225 from atc722/agent/qooker-support
Route Samsung Bespoke Qooker to microwave registry
2026-08-02 12:22:10 -05:00
Marc Billow 34b2291bef Merge pull request #255 from moKorean/autoclean-cycle-state
Report whether the auto-clean cycle is running, and how far through
2026-08-02 09:05:34 -05:00
Geunwon Mo 5c275753d4 Report whether the auto-clean cycle is running, and how far through
`/option/autoclean/vs/0` carries three fields and only `settingStatus` was read.
That one says the feature is enabled, which it is whether or not the unit is
drying right now, so nothing reported an actual cycle.

    settingStatus: On      <- the existing auto_clean switch
    status:        Stop    supportedStatus: [Start, Stop]
    progress:      0

Adds a binary sensor for `status` and a percentage sensor for `progress`.

Measured on a TP1X_DA-AC-CAC-01001, sampling the resource every eight seconds
across a cycle: `Start` with progress 98 while it ran, then `Stop` with progress 0
the moment it finished. The percentage matches the figure the appliance shows on
its own display, checked against 55% mid-run.

Golden state keys updated for the 18 fixtures that bind AUTO_CLEAN. The change is
additive — no key was removed from any of them.

Full suite passes (1070 tests, Python 3.13 via requirements-dev.txt).
2026-08-02 22:01:27 +09:00
axelet85 57eb3e80c7 feat: add Spanish translation (es.json)
Complete Spanish translation for LocalThings: 257 entity names across
all platforms + full UI strings (config flow, options, issues, exceptions).

Transparency: AI-assisted (Hermes Agent), reviewed and verified by the
owner against the official Samsung SmartThings app on real hardware
(washer DA_WM_TP1_21_COMMON). Translation files only, no code changes.

Washer cycles verified one-by-one against the official app; 4 cycle codes
missing from the catalog were added from real hardware (08, 74, 06, A0).
Washer options verified: volume, finish alarm, bubble soak.
2026-08-01 20:31:56 +02:00
hoon d544644e0e Clarify resource override comment 2026-08-01 11:29:30 +09:00
hoon f9cd857ced Support Samsung Bespoke Qooker routing 2026-08-01 10:47:00 +09:00
Marc Billow 93744e80f4 Merge pull request #227 from blka/observe-grace-early-exit-upstream
Observe grace-period early-exit (first-refresh ~15s -> ~0.4s)
2026-07-31 20:20:22 -05:00
Marc Billow a74b92f758 i18n: add czech translations to merged pr 2026-07-31 20:16:56 -05:00
Marc Billow d639aa693f Merge pull request #218 from perseus177/ac-filter-reset
feat(airconditioner): filter dust alarm interval on legacy ARTIK051 boards
2026-07-31 20:14:31 -05:00
Marc Billow 3c6c7f246f Merge branch 'main' into ac-filter-reset 2026-07-31 20:13:40 -05:00
Marc Billow 0d0844ea7f Merge pull request #230 from moKorean/oic-type-hood
Add x.com.st.d.hood to _OIC_TYPE_TO_KEY; document why oic.d.cooktop is not mapped
2026-07-31 20:10:24 -05:00
Geunwon Mo 618fc5fa51 Add x.com.st.d.hood to _OIC_TYPE_TO_KEY, and document why oic.d.cooktop is not
Measured on nine Samsung appliances on Korean-market firmware, every one of
which populates /oic/d with a concrete type:

    4x  oic.d.airconditioner   AJ023CN1UBC1 system A/C, "Samsung System A/C"
    3x  oic.d.refrigerator     "[refrigerator] Samsung"
    1x  oic.d.cooktop          TP1X_DA-KS-COOKTOP, "Samsung Cooktop"
    1x  x.com.st.d.hood        AHD-WW-TP1-22-COMMON, "Samsung Hood"

Every one agreed with what for_device_by_model already concluded from the board
token, so this is corroboration rather than a correction.

`x.com.st.d.hood` was the one type with a registry to point at and no row, so
this adds it.

`oic.d.cooktop` is left out on purpose, with a comment saying why: the induction
above reports it, but `cooktop` and `induction_cooktop` are unrelated registries
that happen to share the English word, and the OCF type cannot tell them apart.
Mapping it to either key would misroute the other, and since resolve() consults
this table first it would override a COOKTOP/CT board token that had it right.
Same shape of argument as the existing oic.d.robotcleaner note.

One incidental data point on the docstrings' "only ever helps a minority of
dumps": that may understate it. Nine out of nine here answer /oic/d with a usable
type, across four families. Not enough hardware to generalise from, but enough
that it looks less like a rare bonus than the comments assume.

Verified: the full suite passes (1024 tests, Python 3.13 via
requirements-dev.txt).
2026-08-01 09:02:49 +09:00
Marc Billow f36b3016cc chore: bump version to v0.18.0 2026-07-31 15:44:24 -05:00
Marc Billow 36f53da6a0 fix(translations): backfill cs.json with keys missing since cs.json was added
The Czech translation landed in commit d0c68fb (PR #233) and was
immediately out of date with en.json: subsequent commits added new
entity/option keys that didn't get backfilled. cs.json fell behind
in three buckets, all caught by test_every_language_mirrors_the_english_catalog:

  - 6 dryer cycle codes added to en/nl by commit 873ed56 (PR #237):
    '17' Super Speed, '21' Hygiene Care, '22' Silent Dry, '29' AI Dry,
    '2b' Self Tub Dry, '4c' Air Refresh. cs.json's dryer_cycle_table_03
    still had the pre-PR-#237 set.

  - 'finish_time_hysteresis_minutes' option added to en/nl by commit
    d905620 (PR #239). Same omission in cs.json.

  - 8 entities added to en.json by the issue-triage batch now in main
    (PR #224): binary_sensor.battery_charging, binary_sensor.child_lock,
    sensor.air_quality_standard, sensor.battery, sensor.co2,
    switch.dnd, time.dnd_end, time.dnd_start. cs.json had none of them.

nl.json was in sync, so the gap was specific to cs. Keys placed in
their natural alphabetical position within each section.
2026-07-31 15:32:36 -05:00
Marc Billow 6166f63570 Merge pull request #224 from mbillow/claude/issue-triage-batch-h4n2wq
Issue triage batch: #207, #189, #208, #181/#183, #196, #210 + contributing docs
2026-07-31 15:22:55 -05:00
Marc Billow b7d3f86ec7 fix: address code review feedback on issue-triage batch PR (#181, #183, #189, #196, #207, #208, #210)
common:
- Make KIDS_LOCK_GENERIC also a read-only BinarySensorDesc (device_class='lock'),
  flipping value_fn to not bool(v) so /kidslock/0 value=False and
  /kidslock/vs/0 kidsLock='Ready' render with the same polarity ('On'
  means open/unlocked per HA's lock device class). The old SwitchDesc
  form never honored device_class='lock' -- HA's switch platform only
  accepts 'outlet'/'switch' -- so the surface was a plain switch whose
  'On' meaning drifted across boards. Tests updated.

air_monitor:
- Add state_class='measurement' to dust/fine_dust/super_fine_dust so
  the readings feed HA long-term statistics (co2 already had it).
- Import _AIR_QUALITY_SENSORS from air_purifier instead of duplicating
  it byte-for-byte; update common.sensor_item_value's docstring to
  mention the third caller.

by_type/__init__: drop trailing whitespace on the new 'ASM' line.

translations/en.json + nl.json: move the new 'dnd' switch entry to its
correct alphabetical position (after display_light, before fast_preheat).

SKILL.md: add an explicit read-side rule to §5's educated-guesses
section -- guessed unit/device_class/state_class on a SensorDesc
silently mislabels readings forever with no 4.xx to catch it (unlike
guessed writes, which the device rejects). The prior air_monitor
docstring cited this carve-out as if it existed; now it does.

tests/water_purifier (issue #196): change the ailite fixture's
favorite.defaultTemperature from '85' to '50' so the test actually
reproduces the reported bug -- '50' is in showList only, so a
descriptor reading from supportedList would fail the assertion that
the current default is in its options list.
2026-07-31 15:17:18 -05:00
Marc Billow fd86c54f18 Merge pull request #233 from pedrodivisez/feat/ac-odor-controller-cs-translation
AC: bind SmartCoolClean odor-controller tokens; add Czech translation
2026-07-31 14:49:26 -05:00
Marc Billow e55c55c55e Merge pull request #239 from jelle514/Reduce-Estimated-finish-activity
Reduce Estimated-finish activity-log spam (washer/dryer/dishwasher)
2026-07-31 14:35:49 -05:00
Marc Billow 7a43fcb988 Merge pull request #237 from chill-uk/fix/dryer-cycle-mappings
Fix/dryer cycle mappings
2026-07-31 14:32:24 -05:00
Jelle Lauwers 9f15d06e84 Document the two new per-device options in the README
Bypass-remote-control already existed but was undocumented; finish_time
hysteresis is new. Both live under the same Configure > Device settings
menu, so cover them together as Part 4 rather than leaving a reader to
discover them by opening the options flow.
2026-07-31 20:54:29 +02:00
Jelle Lauwers 9ab3c5827c Rename finish_time debounce -> hysteresis to match actual behavior
The gate holds finish_time at its last reported value until a new one
differs by at least a configured number of minutes, regardless of how
long that difference has been building up -- a magnitude-based deadband,
not a time-based debounce (which would wait for the value to stay put for
N minutes before accepting it). debounce invited the wrong mental model
for anyone reading the option name or the code later, so rename it
throughout before the option name ships: CONF_FINISH_TIME_DEBOUNCE_MINUTES
-> CONF_FINISH_TIME_HYSTERESIS_MINUTES, SensorDesc.debounce -> hysteresis,
LocalThingsSensor._debounce/_debounced_value -> _apply_hysteresis/
_hysteresis_value, and the options-flow data key/translations to match.
2026-07-31 20:27:48 +02:00
Jelle Lauwers d90562017f Add configurable finish_time debounce to cut recorder churn further
Minute-rounding stopped identical values from re-logging, but finish_time
still updates on nearly every poll because now() + remaining is a
continuously-drifting value between the device's own remaining-time
revisions, and washers/dryers/dishwashers commonly revise that estimate
by a minute or two mid-cycle anyway -- both are real, small changes that
individually don't matter but each cost a recorder/logbook entry.

Add a per-device Options Flow setting (finish_time_debounce_minutes,
default 3) and a SensorDesc(debounce=True) opt-in. LocalThingsSensor now
caches the last value it actually reported and only adopts a new one once
it differs by at least the configured threshold, a cycle starts (no prior
cache), or a cycle ends (new value is None) -- 0 disables it entirely,
restoring today's behavior.

Also pass config_entry explicitly into DataUpdateCoordinator's
super().__init__() -- self.config_entry previously relied on an
undocumented ContextVar fallback that upstream has flagged as removed in
HA 2026.8, which the new debounce lookup needed to not be built on top of.
2026-07-31 19:25:45 +02:00
Jelle Lauwers fe9a1128ea Round finish_time to the minute to stop recorder spam
_finish_time added datetime.now(timezone.utc) (fresh seconds/
microseconds every call) to the device's remaining-time duration, so the
returned timestamp differed at the sub-minute level on nearly every poll
even when remainingTime itself hadn't changed. The recorder logged a new
history/logbook entry each time, while the UI rounds the display down to
the minute, making repeated polls look like duplicate identical entries.

Round the result down to the minute so the entity only changes state
when the estimate actually shifts.
2026-07-31 19:07:24 +02:00
Christopher 2f231b5c19 Aligned naming for Quick Dry 35 in english 2026-07-31 17:52:55 +02:00
Christopher 873ed562de Add new drying modes to English and Dutch translations 2026-07-31 17:46:26 +02:00
Petr Divis f15ee7e563 Update golden fixtures for the new odor-controller entities
CI caught it: 8 pre-existing AC fixtures also carry genuine SmartCoolClean_/
ProgressSmartClean_ tokens in /mode/vs/0 (lnx_rac_heatpump, ara_ww_tp1_22,
windfree_oscillation, tp1x_rac_01001, fac_bora, fac_bora_2in1,
fac_bora_205_flat, cac), so odor_controller_active/odor_controller_progress
now correctly bind there too -- their golden state_keys lists just hadn't
been regenerated. Verified against the actual downloaded CI job log.
2026-07-31 16:39:08 +02:00
Petr Divis d0c68fb5ac AC: bind SmartCoolClean odor-controller option tokens; add Czech translation
- airconditioner.py: odor_controller_active binary_sensor + odor_controller_progress
  sensor, read from the /mode/vs/0 SmartCoolClean_/ProgressSmartClean_ option tokens
  (SmartThings cloud custom.airConditionerOdorController capability). Read-only --
  no confirmed write command.
- Reporter's TP1X_DA-AC-RAC-01001_0000 dump: fan speed and WindFree preset already
  bind via the existing composite climate entity, no code change needed there.
- en.json/nl.json: new entity names for the two additions.
- cs.json: new, full Czech translation catalog (mirrors en.json topology 1:1).
- New fixture + golden + tests locking in the odor-controller behavior.
2026-07-31 16:02:13 +02:00
Marc Billow e99a8a6b05 Merge pull request #232 from hmmbob/agent/polish-dutch-translations
Polish Dutch translations
2026-07-31 08:03:37 -05:00
Hmmbob 87e0c43b23 Polish Dutch translations 2026-07-31 14:38:31 +02:00
perseus177 9ee0dbb8f9 feat(airconditioner): filter alarm interval on legacy ARTIK051 boards
Adds the Select the official app offers next to the filter reminder --
180/300/500/700 hours -- which this board generation keeps as a FilterAlarmTime_
token in /mode/vs/0's options[] rather than on a /filter/* resource.

Confirmed on hardware: stepping through all four radio positions in the app
moved that one token and nothing else across all 19 resources, so the token
carries the hour count verbatim; and a local write of 500 to a unit sitting on
700 was accepted and kept, surviving a restart. The cloud exposes none of this,
so it is only available locally. Gated with the counter it belongs to, so
boards carrying a real /filter/airdustfilter/vs/0 threshold keep using that one
-- the gate is covered by a test that injects the token into a newer board's
dump, since no non-legacy fixture carries it and the assertion would otherwise
pass for the wrong reason.

Also settles what filter_time measures: it counts UP -- running time
accumulated since the last filter reset, not time remaining -- which an earlier
revision of that comment explicitly left open. Three independent things agree:
the token rising while the unit runs, FilterAlarmTime_ being the threshold it
is measured against, and /alarms/vs/0's filter entry tracking the counter
across two units on one site (a live unsuffixed 'FilterAlarm'/'Created' at
FilterTime_5595 against the 'FilterAlarm_OFF'/'Deleted' placeholder at 1915).
The alarm clearing by itself the moment the counter dropped under the threshold
is the causal half of that, not just correlation.

Resetting the counter is NOT solved and no reset entity is added. The
descriptor records what was tried, and what the failures do and do not prove,
so the next attempt starts from evidence instead of from scratch. Short
version: the reset is a *command* (custom.dustFilter/resetDustFilter), not a
value write, which is why nothing that writes the counter works; I could not
work out how to drive that command locally. /actions/vs/0 is the obvious local
command channel but publishes no schema, and I did not enumerate guessed action
names against a live appliance.

Written with Claude (AI), on the author's own hardware; every result quoted
above is measured on the device rather than inferred.
2026-07-31 12:41:40 +02:00
blka f19abc7936 feat(observe): early-exit grace wait on success fraction
Replace the fixed time.sleep(GRACE_PERIOD_S) in try_enter_observe_mode
with a threading.Condition.wait_for(predicate, timeout=grace_period_s)
that returns as soon as success_fraction of subscribed hrefs have
notified. Production data (fridge TP2X_REF_20K): all 13 hrefs notify
within ~0.34s of subscribe, so every successful first-refresh / observe
retry was waiting ~14.6s of dead time. AC retry cycles showed the same
16s-fetches-that-transition pattern.

No behavior change on the fallback path (fraction not reached → wait
the full ceiling → MODE_POLL, identical to today). GRACE_PERIOD_S and
SUCCESS_FRACTION unchanged. No smartthings_local library change.
2026-07-31 09:06:19 +02:00
Marc Billow a7d57086e9 feat: add Samsung Air Monitor Plus support (#210)
New device family: a standalone, battery-powered air-quality sensor
puck (ASM-KR-TP1-22-* board) with no controllable-appliance state at
all -- no /power/*, only /energy/battery/vs/0. Routes via both /oic/d
('x.com.st.d.airqualitysensor', confirmed against the real dump) and
the 'ASM' modelNum board token as a fallback.

/sensors/vs/0 reuses air_purifier.AIR_QUALITY's existing {type, value}
items-list decode (common.sensor_item_value) for dust/fine_dust/
super_fine_dust/odor/clean_level, sharing those capabilities' catalog
entries, and adds a CO2 reading those families don't report. The
particulate sensors deliberately get no pm10/pm25/pm1 device_class:
the values read as physically consistent (coarser >= finer) but
Samsung's own two-tier dust naming doesn't confirm where this board's
three-tier split actually maps, and mislabeling a read-side unit is a
standing, not one-shot, kind of wrong -- exposed as plain named
sensors instead. Humidity, battery/charging, and the informational air
quality standard are otherwise straightforward field reads.

/dnd/vs/0 (do-not-disturb window) is a flagged educated guess: the
write contract mirrors the read side's own string/time-format shape
(the safest kind of guess) but has no idle-vs-active dump to confirm
it end-to-end, so it's called out as such in code and will need a
reporter to verify on real hardware. /keepnormalstate/vs/0 and
/sensordatasinks/vs/0 are genuinely opaque (single unexplained value,
no supported-values list) and are ignored rather than guessed at.

Locked in with a scrubbed fixture, golden, and capability tests.
2026-07-31 02:40:02 +00:00
Marc Billow 4afd11a357 skill: relax adding-device-support on guessing writes
The old "don't guess" rule banned shipping any write whose contract
wasn't already confirmed end-to-end, even when the dump gave strong
supporting evidence (a supported-values/range field, an idle-vs-active
dump diff, a pattern already confirmed on a sibling board). That's
overly conservative: a CoAP write against an invalid value gets
rejected rather than acted on, so the worst case for a wrong guess is
a no-op, not a damaged appliance -- and this project already ships
flagged guesses and asks reporters to confirm them on real hardware
routinely (issues #196, #181).

Replace it with guidance to make educated guesses and ship them, but
mark them explicitly as unconfirmed (in code comments and in a direct
ask to the reporter) rather than silently presenting a guess as a
confirmed contract. Still forbids inventing a write or entity with no
supporting evidence at all, and calls out that the "worst case is
rejected" safety margin covers invalid values, not wrong-but-valid
units/semantics.
2026-07-31 02:27:08 +00:00
Marc Billow d317a85988 Drop "one commit per issue" from CONTRIBUTING.md/AGENTS.md
That was triage-session guidance, not a general repo policy -- the
files should only codify the attribution rule.
2026-07-31 02:15:47 +00:00
Marc Billow 4d4de23fbb fix(water_purifier): read favorite-temp options from showList (#196)
favorite_hotwater_temperature's options_field read
favorite.supportedList, which is only the four fixed presets. The
SmartThings app also lets the user add one custom value to their own
display list via its "temperatures to display" editor (bounded by
/setting/waterpurifier/vs/0's hotwaterRange), and that value shows up
in favorite.showList but never in supportedList. A unit whose current
default was that custom value rendered as HA's "unknown" state, since
current_option wasn't among the (too-narrow) options list.

showList is a superset of supportedList that always includes whatever
the current default actually is. /setting/waterpurifier/vs/0's own
hot_water_temperature select is untouched -- its write contract for
values off the old preset list still isn't confirmed, so it stays
gated off on boards that don't report supportedHotTemperatures.
2026-07-31 02:14:09 +00:00
Marc Billow e46e935ba0 fix(common): make the vendor kids-lock fallback read-only (#181, #183)
KIDS_LOCK_VS_FALLBACK's write_fn wrote 'Enable', a value no dump in the
fixture corpus (washers, dryers, dishwashers, ovens, ranges, microwaves,
air purifiers, air dressers -- everything that lacks the OCF-standard
/kidslock/0) has ever reported back; every one reports 'Ready' or 'Run'.
It was never a confirmed write contract.

#181's reporter tested directly: writing the *correct* value ('Run')
still returns 4.05, and the SmartThings app itself has no control for
kids lock either -- the resource is genuinely read-only on that
hardware, not just wrong-valued. #183's reporter hit the identical
symptom (toggle does nothing) on a different device family reporting
the same Run/Ready vocabulary.

Convert the entity to a read-only BinarySensorDesc. binary_sensor's
'lock' device class is inverted from the old switch reading (On means
open/unlocked), so value_fn flips accordingly -- callers reading the
flattened 'child_lock' state key need to account for the new polarity.
KIDS_LOCK_GENERIC (the OCF-standard /kidslock/0 boolean) is untouched;
nothing suggests that one is broken.
2026-07-31 02:09:58 +00:00
Marc Billow 5bbc7aeece fix(air_dresser): bind /buzzersound/vs/0 on the TP1_21 board (#208)
DA_DF_TP1_21_COMMON reports a plain {setBuzzerSound, supportedFinishSound}
buzzersound resource -- the same shape laundry.BUZZER_SOUND already
handles for washers/dryers -- but it was unbound because the air_dresser
registry never included that capability. Add it, and lock the board in
with a scrubbed fixture, golden, and capability tests.

The reporter's actual complaint (course cycles showing as raw codes) is
a labelling gap, not a coverage one: this board's course table has no
code->name mapping anywhere in the dump, same as issue #162's board, so
there's nothing to bind here -- it needs a reporter to identify the
codes before they can be named in translations.
2026-07-31 02:03:32 +00:00
Marc Billow e642db462d fix: recognize all-same-hex-digit placeholder serials (#189)
_is_placeholder_serial only caught the ARTIK051_DONGLE_REF family's
'Nothing(SVC)' sentinel. The DA_WM_A51_20_COMMON (ARTIK051) laundry
board family reports a different one instead -- every character the
same repeated hex digit -- which passed through as a real, non-unique
serial. Two different physical units (a washer and a dryer) both
reporting the literal serialNum 'FFFFFFFFFFFFFFF' collided on the
config-entry unique_id, so the second device's config flow aborted as
already configured. Widen the check (both the config_flow.py and
coordinator.py copies) to also catch that sentinel shape.
2026-07-31 01:59:49 +00:00
Marc Billow 822c6814b6 fix(coordinator): don't let subpolls delay HA startup/shutdown (#207)
_run_subpolls is a self-limiting background loop the coordinator already
cancels and recreates every refresh cycle, but scheduling it with
async_create_task ties it into HA's startup/shutdown task tracking
anyway. A subpoll cycle in flight (up to ~27s) then delays both.
async_create_background_task is HA's supported API for exactly this
case -- a task the integration owns and manages the lifecycle of.
2026-07-31 01:58:04 +00:00
Marc Billow 61f13e7484 Add CONTRIBUTING.md and AGENTS.md codifying commit conventions
One commit per issue/logical change, and every commit's author and
committer must be the human accountable for the work -- never a tool,
bot, or AI agent identity, and no AI co-author trailers. AGENTS.md points
any AI coding agent working here back to CONTRIBUTING.md as the
authoritative source for this.
2026-07-31 01:57:11 +00:00
Marc Billow b8d04b4ae3 Merge pull request #216 from firstof9/fix-hood-fan-write-fallback
fix(hood_fan): fall back to settable min/max when supportedFanSpeed is missing
2026-07-30 20:21:02 -05:00
Marc Billow 2d09ef108a Merge pull request #220 from R3inoudR/add-vs9700-routing
Route VS9700 stick vacuum (VSWW) to vacuum_station registry
2026-07-30 20:17:07 -05:00
Marc Billow 8512aef2d2 Merge pull request #223 from mbillow/claude/oic-device-type-mapping-zyiivg
Route device type from /oic/d as the primary detection signal
2026-07-30 20:15:12 -05:00
Marc Billow 3cd22824ca Drop the per-entry provenance comments on _OIC_TYPE_TO_KEY
The "issue #N" / "OCF spec" trailing comments and the header paragraph
explaining them added noise without adding anything the value side of
the table (a real _REGISTRY_BY_KEY key) doesn't already guarantee. Update
the skill's guidance to match.
2026-07-31 01:13:09 +00:00
Marc Billow 9f4c47ea63 Update adding-device-support skill for /oic/d as primary detection
The skill still described oneUiVersion-era two-stage detection and said
"nothing routes on /oic/d yet" -- both stale now that resolve() checks
device_types first. Rewrite the routing section around the new three-stage
order, add a dedicated "Adding an /oic/d device type" section documenting
the right endpoints (/oic/d, /oic/p, /oic/res) and the issue-confirmed vs
OCF-spec provenance convention, and add explicit checklist reminders (in
the routing section and in "Lock it in") to check and fill in
_OIC_TYPE_TO_KEY whenever a dump carries a type.
2026-07-31 01:03:29 +00:00
Marc Billow 0a4b80afc4 Add OCF-spec-confirmed oic.d types: airpurifier, dishwasher, oven
Extend _OIC_TYPE_TO_KEY with three more device categories the OCF Smart
Home Device Specification defines with the same 'oic.d.<category>' shape
as the already-confirmed entries, ahead of seeing them in an actual dump.
Deliberately leave out the rest of a broader compiled oic.d/x.com.st.d
list (lights, switches, sensors, locks, cameras, TVs, generic energy
meters, oic.d.robotcleaner, ...) -- none of those map to a registry this
integration has, and robotcleaner in particular names a different product
than the vacuum_station clean-station registry.
2026-07-31 00:58:26 +00:00
Marc Billow 4f9e630ae9 Route device type from /oic/d as the primary detection signal
/oic/d's `rt` names the device's own OCF device type, which beats
parsing board part numbers whenever a dump populates it. Add
for_device_by_oic_type() and an _OIC_TYPE_TO_KEY table (airconditioner,
dryer, refrigerator, washer, plus SmartThings' x.com.st.d.stickcleaner
and x.com.st.d.steamcloset extensions), and consult it first in
resolve(), ahead of modelNum/description and the resource-signature
fallback.

Thread the master's device_types from read_identity() through
coordinator.py's discovery pass and config_flow's connection probe;
subdevices keep resolving from their own /information/vs/0 (or the
master's registry as a fallback) since they have no /oic/d of their
own read today.
2026-07-31 00:56:17 +00:00
R3inoudR 4c827bf9e4 Update __init__.py
Route VS9700 stick vacuum (VSWW) to vacuum_station registry
2026-07-30 23:14:19 +02:00
firstof9@gmail.com 8e8f08109a fix(hood_fan): fall back to settable min/max when supportedFanSpeed is missing 2026-07-30 11:33:40 -07:00
Marc Billow eb938205ab Merge pull request #215 from mbillow/claude/issue-214-device-registration-d2smcv
fix(subdevices): don't materialize a slot whose only live state is a meter
2026-07-30 13:25:14 -05:00
Marc Billow 5e86f147d4 fix(subdevices): don't materialize a slot whose only live state is a meter
Issue #214: a single-split ARTIK051_KRAC_18K showed up in HA as two air
conditioners. Its /device/1 answers the same unused-slot shape the Pattern A
reporter's /device/2 does -- every operational rep empty {} -- but also
reports a populated /energy/consumption/vs/1. Running discovery against the
reporter's own quoted subdevice block reproduces their diagnostics exactly
(21 bound entities, the same six hrefs), and of those 21 the only primary
entity with a non-None value is energy_kwh: a lifetime kWh total was the
sole thing passing discover_partitioned's liveness gate and materializing
the phantom.

A single-split AC has one compressor and one energy meter, so a
whole-appliance running total appearing under a second index is the
appliance's own bookkeeping, not evidence that hardware is installed at that
slot. Exclude cumulative meters (HA's total/total_increasing state classes,
plus the energy/water/gas device classes for the descriptors that
deliberately declare no state class) from the gate. The gate never applies
to MAIN, and across the whole fixture corpus every device has at least one
non-meter live primary, so no existing device's entities change -- verified
by the golden for the new fixture being identical to the plain KRAC one.

Also implement async_remove_config_entry_device. A subdevice's HA device
outlives the discovery that created it, so a phantom materialized by an
earlier release stays in the registry with no way to delete it from the UI
-- which is the state the second reporter on that issue is in, with a
refrigerator whose diagnostics now report no subdevices at all. Devices the
entry currently provides still refuse removal. No automatic pruning:
enumeration is one-shot and a real sibling can miss a poll (issue #205 on
the reference hardware), so auto-removal would discard a live subdevice's
name, area and automations on a transient miss.

The new fixture's /device/1 seed is the reporter's verbatim capture; its
master half comes from the corpus's other KRAC unit, with the deviations
spelled out in seeds_note.

Claude-Session: https://claude.ai/code/session_01HzUMLnSWBT64o4BQkVEzXp
2026-07-30 18:23:47 +00:00
Marc Billow 5f5aecbe7d Merge pull request #206 from mbillow/fix/subdevice-flat-href-fallback-205
fix(subdevices): fall back to per-href probing when a prefixed subdevice has no /device/0 Collection
2026-07-29 22:07:47 -05:00
Marc Billow ca27bf7f6e docs: de-identify reporter usernames repo-wide, add PII rule to skill
Extended the earlier de-identification (jhkwon19) to the other issue #177
reporter (HJcom), who was named in ~20 spots across coordinator.py,
diagnostics.py, subdevices.py, identity.py, capabilities/airconditioner.py,
a fixture's seeds_note, and several test docstrings/function names. Swapped
all of it for "the reporter"/"the Pattern A reporter", matching the
convention already established for the other reporter.

Added a rule to the adding-device-support skill (end of "Lock it in"):
don't put a reporter's name or GitHub username in code comments,
docstrings, seeds_note, or test/function names -- that prose ships and
sits in git history indefinitely, unlike an issue thread or a
release-notes thank-you. Use "the reporter" / "issue #NNN's reporter" /
a pattern label instead.
2026-07-30 03:06:06 +00:00
Marc Billow ce92d71a92 docs: de-identify the reporter's username in code comments
Comments/docstrings across subdevices.py, coordinator.py, the two fac_bora
fixtures, and their tests named the issue #177/#205 reporter directly.
Swapped to "the reporter"/"the Pattern B reporter" throughout -- no
behavior or test-assertion changes, string content only. HJcom (the
Pattern A reporter) is unaffected.
2026-07-30 02:50:49 +00:00
Marc Billow 8600985a36 review: address Opus findings on the subdevice flat-href fallback
- coordinator: _poll_subdevice_flat_hrefs called sess.pace() outside its
  try block and re-read self._session instead of using the caller's
  already-None-checked reference -- a session closed mid-poll (async_close()
  doesn't hold _session_lock) could crash the whole poll cycle instead of
  just dropping that one sibling. Now takes sess from the caller and guards
  pace() the same as get().
- coordinator: skip flat hrefs already covered by the hot/warm sub-poll
  tiers -- those are refreshed every few seconds by _run_subpolls already,
  so re-fetching them again on the once-per-summary-poll flat pass only adds
  GETs, not freshness. Matters because, unlike the Collection path (always
  one GET), a flat subdevice's summary-poll cost scales with its href count.
- subdevices.py module docstring: corrected an overclaim inherited from PR
  #199 that GET /<uuid>/device/0 had been "confirmed live" on jhkwon19's
  unit. Only an individual /information/vs/0 read was ever actually
  confirmed; the Collection endpoint itself has never been observed to
  answer on any known unit, which is exactly what issue #205 exposes.
- SKILL.md: fixed the numofsubdevice cross-check formula to match what
  coordinator.py actually compares (len(materialized) + 1, not
  len(subdevices) + len(subdevices_skipped)).
- Documented, not yet guarded against: a firmware that echoes state back
  under any unrecognized prefix instead of 4.04ing could pass the flat probe
  and the liveness gate, materializing a phantom duplicate of the master.
  Every board seen so far genuinely 4.04s on paths it doesn't own.
- Added test coverage the review flagged as missing: a materialized (not
  just skipped) flat subdevice re-polling end-to-end through to
  canonical_resources, the hot/warm skip itself, zero-master-hrefs and
  two-UUID no-cross-contamination edge cases, and a golden file for the
  #205 fixture (SKILL.md's "fixture + golden + test" discipline).
2026-07-30 02:44:26 +00:00
Marc Billow e78d941af3 fix(subdevices): fall back to per-href probing when a prefixed subdevice has no /device/0 Collection
Issue #205 shows the UUID-prefixed pattern's own reference device
(TP2X_FAC_BORA_21K) doesn't always answer /<uuid>/device/0, contrary to
what the pattern was built against. When that Collection GET comes back
empty, enumerate_subdevices now probes every href the master itself
answered this cycle individually under the UUID prefix, keeping whichever
ones respond. Subdevice gains a flat_hrefs field for this, and the
coordinator re-polls those hrefs individually each cycle instead of
re-fetching a Collection batch that doesn't exist.

Built a fixture from the reporter's real #205 diagnostics dump: the
fallback finds a candidate through the one href already confirmed live
under this UUID (/information/vs/0, from the #177 thread), and
discover_partitioned's liveness gate correctly holds it back since that
href alone binds no entity -- honest current state, not a guessed
resolution.
2026-07-30 02:25:26 +00:00
Marc Billow 7c31bb1682 chore: bump version to 0.17.0 2026-07-30 01:28:42 +00:00
Marc Billow 2ff39d0576 Merge pull request #204 from mbillow/claude/triage-issues-196-195-lm4wki
fix(water_purifier): route AILITE_DA-REF-WATERPURIFIER boards correctly (#196)
2026-07-29 20:23:21 -05:00
Marc Billow fb2d267632 fix(water_purifier): route AILITE_DA-REF-WATERPURIFIER boards correctly (#196)
The AILITE water-purifier board (RWP70F15ANW) spells its modelNum
'...-REF-WATERPURIFIER-...', so the board-token scan hit the bare 'REF'
token before ever reaching 'WATERPURIFIER' and misrouted the device to
the refrigerator registry, whose resource surface shares almost nothing
with a water purifier -- hence the incomplete-coverage warning. Add a
documented carve-out for this one token co-occurrence and a matching
TestBoardTokenAmbiguity exception.

Also bind the water-purifier hrefs this board additionally exposes:
cup-detection status, the settings/sound/{mode,output,volume} trio (read
live, since this board's own supportedModes vocabulary differs from both
laundry's and air_purifier's hardcoded/live sets), and last-pour
statistics.

Separately, gate hot_water_temperature off when the device doesn't report
a supportedHotTemperatures list (only a hotwaterRange/hotwaterLevel pair
with no confirmed write contract) -- previously an empty options list
plus a live current value rendered as 'unknown' in HA, the second bug
reported in #196. The existing water_purifier_coffee fixture (#107) turns
out to hit the same shape, so its golden drops the entity too.

Issue #195 (TP1X_REF_21K) needed no change: its diagnostics show zero
unbound hrefs and the model already routes to the refrigerator registry,
matching the maintainer's own comment on the issue.
2026-07-29 23:23:56 +00:00
Marc Billow 1053ae421b Merge pull request #202 from firstof9/fix/fan-zerodivision-issue-201
fix(fan): fall back to settableMin/MaxFanSpeed when supportedFanSpeed is omitted (#201)
2026-07-29 18:06:41 -05:00
firstof9@gmail.com 7aa14d161c style(fan): extract _MAX_FAN_SPEED_FIELD constant 2026-07-29 16:06:12 -07:00
Marc Billow c7d265074f Merge pull request #203 from mbillow/claude/rename-unit-subdevice-4wbe6s
rename: unit/sub-unit -> subdevice, matching OCF terminology
2026-07-29 17:59:50 -05:00
Marc Billow 9e85395b44 rename: unit/sub-unit -> subdevice, matching OCF terminology
"Unit"/"sub-unit" from #199's multi-indoor-device support wasn't OCF
idiomatic -- OCF calls each component of a composite device a
"subdevice" (see subdeviceIdList), so rename SubUnit -> Subdevice
throughout: the registry module, coordinator state, BoundEntity's
subdevice field, diagnostics keys (subdevices/subdevices_skipped/
subdevice_probes), entity unique_id prefixes (unit1_/sub_<uuid>_ ->
subdevice1_/subdevice_<uuid>_), device-name fallback labels, golden
fixtures, tests, README, and the adding-device-support skill.

Breaking change to entity unique_ids and diagnostics keys, acceptable
since this hasn't been released yet.
2026-07-29 22:57:11 +00:00
firstof9@gmail.com bc667e486c fix(fan): fall back to settableMin/MaxFanSpeed when supportedFanSpeed is omitted (#201)
Fixes ZeroDivisionError in fan platform when supportedFanSpeed is missing on microwave vent fans (e.g. ME8000T).
2026-07-29 15:50:15 -07:00
Marc Billow 79ac6f199d Merge pull request #199 from mbillow/claude/multi-device-subdevice-patterns-1xrtuh
Support multi-indoor-unit ("2-in-1") systems (#177)
2026-07-29 14:42:27 -05:00
Marc Billow 9442248c20 Merge origin/main into multi-device sub-unit support
Conflict was purely additive: both sides appended golden-regression
tests at the same point in the file, and each side's last test shared
the single trailing assert block. Kept every test from both sides, each
with its own copy of that assert.

One real semantic merge on top of that. #136 (on main) remodelled the
legacy ARTIK051 board's beep from a buzzer_volume Number to a beep
Switch, and HJcom's ARTIK051_DONGLE_FAC_18K is exactly that board
generation (is_legacy_board -> True), so its golden -- written before
that change existed -- still expected buzzer_volume. Regenerated it:
buzzer_volume/unit1_buzzer_volume -> beep/unit1_beep on the master and
the second indoor unit alike, with nothing else moving and still no
unit2_ keys. That is main's intended behavior reaching the sub-unit for
free, which is the point of binding siblings through the same registry.

967 passing. Re-ran the corpus-wide unique_id audit over all 57
fixtures (main added five this branch had never seen): no collisions
within a platform.
2026-07-29 19:40:32 +00:00
Marc Billow 0d03318878 docs(skill): teach adding-device-support the multi-unit shapes (#177)
The skill is what tells the next person how to read a diagnostics dump,
and this branch changed the dump. Without these edits it describes the
old shape and, in one place, leads somewhere that fails silently.

The trap: on a multi-unit appliance a sibling's coverage gap appears in
unbound_hrefs as the *real* href it was seen on -- /foo/vs/1, or
/<uuid>/foo/vs/0. The skill's own rule is "every href must resolve, or
the repair fires", so the natural next move is to bind the href in front
of you. Binding runs against each unit's canonical view, so a registry
entry for an indexed or prefixed href matches nothing on any device: no
error, no entity, gap still open. Section 8 now says registry hrefs are
always canonical and nothing under capabilities/ or by_type/ should ever
mention a unit index.

Section 1 documents the four new blocks (sub_units, sub_units_skipped,
sub_unit_probes, multidevice) and that `resources` is now this unit's
own. Section 2 notes that a sibling's block is canonicalized precisely
so it drops into the standalone-discovery recipe unchanged -- the reason
that canonicalization exists is invisible unless stated. Section 10
covers the fixture's optional oic_res/seeds/probes keys, _load_device_full,
and why a multi-unit golden carries prefixed keys while the master's stay
bare.

Section 11 is new: the ordered triage for "one of my units is missing",
which is the read that would have turned #177 from days of archaeology
into a few minutes -- probes first (did we look?), then the skipped
candidates' own reps (did we reject it, and was that right?), then the
board's own count, then which pattern the board uses.

Section 5's "don't guess" rule also needed a boundary. It reads as
covering all speculative traffic, but this codebase deliberately probes
hrefs no dump contains -- read_identity and enumerate_sub_units both do.
A RETRIEVE is non-mutating and a 4.04 is tolerated throughout that path;
it's guessing a *write* against live hardware, or inventing an entity
from a field you can't explain, that the rule is actually about.
2026-07-29 19:34:06 +00:00
Marc Billow c181a8b447 feat(subdevices): support multi-indoor-unit systems (#177)
Samsung 2-in-1 air conditioners put more than one logical indoor unit
behind a single IP and a single DTLS session. Only the unit the config
entry was set up against was ever discovered; the second one -- a whole
physical appliance the user can see in SmartThings -- had no entities at
all. Two reporters turned out to have two different mechanisms:

  ARTIK051_DONGLE_FAC_18K -- indexed siblings. /oic/res registers the
  whole tree discoverable and lists three complete parallel resource
  sets whose trailing path segment is the index (/mode/vs/0, /mode/vs/1,
  ...), on OCF-standard and vendor hrefs alike. /device/0's batch
  carries only the index-0 hrefs, so a sibling is reachable only through
  its own /device/<n> collection.

  TP2X_FAC_BORA_21K -- UUID-prefixed tree. /oic/res hides the appliance
  tree entirely (which is why a direct /device/1 probe returns nothing
  on this board). /subdevices/vs/0 carries subdeviceIdList instead, and
  that UUID appears as a literal href prefix; /<uuid>/information/vs/0
  was confirmed live to return the wall unit's own model and serial
  (TP2X_FAC_BORA_RAC_21K) against the master's TP2X_FAC_BORA_21K.

The detection signals don't overlap on either board, so no
disambiguation is needed -- enumeration checks both and takes what
answers.

Both patterns are the same thing underneath: a logical unit is a seed
collection path to poll plus an href transform between the canonical
href the registry knows and the actual on-the-wire href. That is the
whole abstraction (SubUnit), applied at four boundaries -- discovery,
the coordinator, the adapter, and the platforms. Capabilities, the
registry and the climate composite stay written against canonical hrefs
and are untouched.

Uniqueness comes from a key_prefix inside the flattened state key, so
the master unit's keys are byte-identical to every release before this
and every existing golden file is an unchanged regression guard. Each
sub-unit gets its own device-registry entry linked by via_device and
named from its own /information/vs/<n>, so it lands in its own room
rather than crowding the master's device page.

A sub-unit materializes only when it yields at least one primary
(non-diagnostic) entity with a populated value. That gate is not
decoration: the reporter's /device/2 is an unused slot that SmartThings
shows disabled, yet it answers with a full 14-href batch, and it
flattens to exactly one non-None value -- a diagnostic alarm_code
derived from an empty /alarms/vs/2. Without the entity-category filter
it becomes a phantom third climate card. The rule is deliberately
domain-agnostic rather than a list of HVAC hrefs, so a multi-drum
washer (#19) gets the same treatment with no new curation. Units that
answer but fail the gate are logged and reported in diagnostics, so a
genuinely missing unit stays diagnosable from a dump.

Enumeration fetches things that must not then be treated as appliance
state. A rejected candidate's seed has to be read to evaluate the gate,
but only units that pass are polled again, and StateCache has no
eviction -- so discovery runs before the first cache apply and those
reps are held aside for diagnostics rather than frozen into the cache
forever. /multidevice/vs/0 is probed on every device regardless of
family, so merging it into the resources dict would have reached
discovery on any board whose registry doesn't ignore that href -- only
the air conditioner one does -- raising a spurious coverage-gap repair
for a washer or fridge whose firmware answers it. It is corroborating
metadata (numofsubdevice, confirmed read-only) and now lives beside the
resources rather than in them.

Diagnostics reports each unit separately: top-level `resources` is this
unit's own and only its own, which is what the module docstring and the
adding-device-support skill have always claimed it was, and each
sibling or rejected candidate carries its own reps canonicalized so a
block reads exactly like the master's instead of needing to be
de-indexed by hand.

Fixtures are real captures. The ARTIK051_DONGLE_FAC_18K one is entirely
verbatim, both sibling seeds and the hand-read /multidevice/vs/0
included. The TP2X_FAC_BORA one has a real device0, oic_res and
sub-unit /information/vs/0, with the remainder of that unit's tree
constructed and documented as such in seeds_note; /<uuid>/device/0 is
the one part of that pattern still inferred rather than observed, and
can't be tested through the debug panel because a Collection returns a
list.
2026-07-29 19:14:51 +00:00
Marc Billow 3437addc46 Merge pull request #198 from mbillow/claude/issue-triage-ujtam3
Morning issue triage: #191, #192, #193, #190, #186, #136, #183, #56
2026-07-29 13:14:35 -05:00
Marc Billow bd3ca80d3c review: address Opus code-review findings on this branch
- config_flow: the #192 port-rescue made the "every port refused" fast-fail
  permanently unreachable (PREFERRED_PROBE_PORTS is always non-empty and
  always rescued), so removed the dead branch instead of leaving it as
  misleading dead code. A dead host now fails via the handshake loop's own
  error, which carries the real per-port reason.
- oven._has_option: added the is_stub_rep carve-out cooktop.py's identical
  per-token exists_fn already has on the same kind of href, so a not-yet
  sub-polled /mode/vs/0 doesn't permanently exclude energy_saving/
  cooktop_on_alert before their first real fetch lands.
- airconditioner.ENERGY_METER_LEGACY: build via dataclasses.replace() like
  its ENERGY_METER_GENERIC sibling, instead of hand-copying href/poll_tier
  (which would silently drift if common.ENERGY_METER ever gains a field).
- Fixed two stale comments: is_legacy_board()'s docstring still listed
  Volume among tokens needing legacy-only gating, though 'beep' now applies
  unconditionally across board generations; and common.py's UNIVERSAL
  invariant comment didn't mention that airconditioner also now excludes
  ENERGY_METER from the wholesale bundle.
- #191 (CAC token): added the fixture/golden/capability-test coverage the
  AVT token in the same branch got, including an honestly-documented list
  of the ten hrefs this board generation doesn't cover yet.
- fridge.cooler_temperature_setpoint: dropped the hardcoded "N °C" state
  labels -- the resource's own unit field isn't necessarily Celsius on a
  different model reporting the same href, and the options themselves are
  already read live via options_field, so a static per-value label risked
  asserting the wrong unit for a future device sharing this capability.
- airconditioner._legacy_cumulative_power_kwh: parse with float (matching
  common.wh_to_kwh's own numeric parsing) instead of this module's
  integer-only _int, so a decimal-formatted reading doesn't raise.
- test_config_flow: the port-rescue test bound the real 49154 directly,
  which could collide with an actually-running service on some machine;
  now monkeypatches PREFERRED_PROBE_PORTS to an OS-assigned port instead.
- oven.py: consolidated six byte-for-byte-identical single-token options
  write_fns (lamp/sound/fast_preheat/natural_steam/energy_saving/
  cooktop_on_alert) into one _option_switch_write(prefix) factory.

845 tests passing (up from 841 -- 4 new CAC coverage tests).
2026-07-29 18:04:23 +00:00
Marc Billow a003650d31 perf(common): poll power state on the warm tier (#56)
/power/0 and /power/vs/0 had no poll_tier, so they only ever refreshed on
the once-per-30s summary poll -- everything else that drives real-time
switch/climate/fan state (e.g. remote-control enablement) is already on
the faster subscribe/subpoll 'warm' cadence for exactly this reason. Scoped
to just this one tier bump; the model-specific priority-flip claim in the
same issue thread isn't independently verified, so it isn't part of this
change.
2026-07-29 13:33:26 +00:00
Marc Billow 36240b44e6 fix(oven): gate fast_preheat/natural_steam on their own tokens (#183)
Both switches were shipped unconditionally (no exists_fn) as an unverified
guess -- the module docstring already flagged them as "unproven." Every
range/oven fixture in the corpus, including the new issue #183 dump,
reports neither fastpreheat_* nor NaturalSteam_* in /mode/vs/0's options at
all, so both were phantom controls: always read as off, and toggling them
wrote a token the firmware never recognized in the first place. That
matches the reporter's exact complaint ("doesn't appear to do anything").

Also bound two tokens confirmed present on this dump but never modeled at
all: EnergySaving_On (the 120-hour energy-saving standby from the app) and
BurnerOnAlert_Off (cooktop-on alert), following the same single-token
options-merge pattern as the existing lamp/sound/fast_preheat switches.

Child lock, the setpoint mismatch, and the missing cook-start control from
this issue are not code bugs -- see the issue comment for what was verified
and what still needs more information from the reporter.
2026-07-29 13:26:39 +00:00
Marc Billow 487ac748bd fix(airconditioner): model legacy-board beep as a switch, not a volume (#136)
Every ARTIK051_KRAC_18K-generation unit confirmed on hardware (three units
across two reporters) only ever carries Volume_100 or Volume_Mute in
/mode/vs/0's options -- never an intermediate value -- so the existing
buzzer_volume Number (0-100, step 10) modeled a control this firmware
doesn't have. Worse, its write path could never produce the literal
'Mute' token needed to actually turn the beep off, since it only ever
wrote a plain integer string. The 'beep' switch already used on newer
boards is the correct model here too; it's no longer gated off the legacy
board generation, and the Number entity is removed.

The WindFree-preset-not-applying report earlier in this issue self-resolved
per the reporter's own follow-up testing, so no code change was needed for
that part.
2026-07-29 13:16:09 +00:00
Marc Billow b5c5c056f4 feat(fridge): expose discrete cooler temperature setpoint (#186)
RT42DG6630B1FZ is a single-door "cooler only" fridge that reports its
setpoint on /temperature/definite/cooler/vs/0 -- a vendor resource outside
both TEMP_CURRENT_GENERIC's '/temperature/current/' and TEMP_SETPOINT's
'/temperature/desired/' href prefixes, so it was entirely unbound and the
setting stayed app-only. Its supportedList (1/2/3/4/7 °C) isn't a
contiguous range, so this is modeled as a select over the device's own
live options rather than a NumberDesc that would let a user pick an
unsupported value like 5 or 6.
2026-07-29 13:11:07 +00:00
Marc Billow 74f34b91a5 feat(registry): route AVT-WW-TP1-23 air purifier boards (#190)
AVT-WW-TP1-23-AXX500 (AX053B810HGD) reported device_type 'unknown' with
empty oneUiVersion, falling back to common caps. It's the same BESPOKE
Cube Air lineage as A-VTWW-TP2-21-COMMON (issue #151), just with the
'-WW-' delimiter shifted one letter left ('A-VTWW-' -> 'AVT-WW-'), which
splits into an 'AVT'/'WW' token pair the existing whole-token 'VTWW' entry
can't see. Added 'AVT' as its own board token onto the same air_purifier
registry -- the resource surface (wind/strength fan, HEPA filter, air
quality sensors, alarms) already matched with zero unbound hrefs once
routed there, so no new capabilities were needed.
2026-07-29 13:06:36 +00:00
Marc Billow d83733a670 fix(airconditioner): scale legacy ARTIK051 cumulativePower correctly (#193)
ARTIK051_KRAC_18K-generation boards report /energy/consumption/vs/0's
cumulativePower in centiwatt-hours, not the plain Wh every other AC board
family reports -- confirmed against the reporter's own SmartThings-app
reading (raw 117430000 vs. the app's authoritative 1,174.30 kWh is exactly
a /100000 factor, not the shared wh_to_kwh's /1000 alone). Split
common.ENERGY_METER into generic/legacy variants on the airconditioner
registry, discriminated by the existing is_legacy_board() check, so every
other AC family keeps the unmodified shared capability.
2026-07-29 13:04:33 +00:00
Marc Billow e8df8f7b0c fix(config_flow): always retry historically-confirmed DTLS ports (#192)
The liveness sweep's ICMP-based verdict isn't trustworthy on every network
path -- a segregated-VLAN report showed it calling three closed ports live
while missing the one port a concurrent nmap scan found genuinely
open|filtered, which also happened to be one of our two historically
confirmed DTLS ports. Rather than trust a "not live" verdict against that
prior, always give PREFERRED_PROBE_PORTS a real handshake attempt even when
the sweep excludes them, bounded to at most those two extra attempts.

This is a stop-gap for the reported failure mode, not a full fix for the
sweep's underlying unreliability -- left a comment on the issue with the
diagnosis and flagged the sweep itself for a deeper redesign.
2026-07-29 12:53:56 +00:00
Marc Billow 7fe9e2a975 fix(registry): route TP1X_DA-AC-CAC boards to airconditioner (#191)
The 0.16.0 device-type simplification dropped oneUiVersion detection on
the assumption every device it typed was already reachable via a modelNum
board token. Cassette AC units (TP1X_DA-AC-CAC-01001_0000) were the one
exception -- they only ever resolved through oneUiVersion's "Air
conditioner" string, since 'CAC' was never added to the board-token
table -- so they silently fell back to common caps and lost their
climate entity.
2026-07-29 12:50:21 +00:00
Marc Billow 119a4f443c Bump version to 0.16.0 2026-07-29 04:15:41 +00:00
Marc Billow 9505405e45 Merge pull request #187 from mbillow/claude/issue-triage-429jtb
diagnostics: speculatively probe /device/1 and /device/2
2026-07-28 23:10:07 -05:00
Marc Billow 10d5c81d9a feat(diagnostics): speculatively probe /device/1 and /device/2
/oic/res's baseline-Interface response only lists resources with the
discoverable policy bit set, and a real dump (issue #177 follow-up,
TP1X_REF_21K) confirms /device/0's whole x.com.samsung.da.* tree is
registered without it -- so a second logical Device's Collection, if
one exists, would be just as invisible to /oic/res as /device/0 is.
Probe /device/1 and /device/2 directly instead: a plain non-mutating
RETRIEVE, tolerated-404 same as every other speculative read in this
module. Parsed with the same parse_device0_batch used for /device/0
itself, and folded into identity.raw so diagnostics can tell "checked,
found nothing" apart from "never checked".
2026-07-29 04:08:19 +00:00
Marc Billow 2b67dda5dc Merge pull request #185 from mbillow/claude/issue-triage-429jtb
diagnostics: capture /oic/res discovery links
2026-07-28 22:54:33 -05:00
Marc Billow 0c8d39fb73 feat(diagnostics): capture /oic/res discovery links
/oic/res is OCF's baseline resource-discovery endpoint: a unicast
RETRIEVE returns every href/Collection the connection hosts, not just
the one /device/0 seed path the coordinator polls. Relevant to the OCF
"Composite Device" model (issue #177) -- a single physical unit sharing
one IP/session across more than one logical Device, each exposed as its
own Collection resource (same rt shape as our own /device/0). Nothing
routes on this yet; captured alongside the existing /oic/p and /oic/d
reads so a report from a multi-unit device shows us whether its
firmware actually implements that model before any code assumes it does.
2026-07-29 03:53:27 +00:00
Marc Billow 03958dd994 Merge pull request #184 from mbillow/claude/issue-triage-429jtb
Issue triage: absence/motion AC sensors (#173), microwave reminders (#181), Python 3.13 pin
2026-07-28 22:37:55 -05:00
Marc Billow a7aea3764b feat(airconditioner): make absence/motion-detect enable switches writable
status on both /mds/absencepowersaving/vs/0 and
/option/motiondetectwind/stateful/vs/0 is a bare On/Off boolean, the same
shape already shipped writable elsewhere in this file (MUTE_ONCE,
AUTO_CLEAN, AIR_PURIFY) without a live-confirmed write either -- worst
case a wrong token no-ops. The paired mode selects (switchPowerSaveMode,
motion-detect modes) stay read-only: their behavioral effect on live HVAC
isn't inferable from the dump, same reasoning as ANOMALY_LOAD's mode field.
2026-07-29 03:34:18 +00:00
Marc Billow a6eb1c62d4 feat(microwave): add filter reminder / end signal reminder switches (#181)
Both FilterRemind_*/RemindBeep_* option-array tokens are already
present -- and both On and Off already confirmed -- on the existing
ME7500D fixtures (issue #152), so this is a straight sibling of the
Sound/Lamp switches rather than new discovery work. Gated with
exists_fn like Lamp since the MW7300B combi dump has neither token.

Doesn't address the rest of issue #181 (power-level slider,
non-reported cooking modes, 3-level light, child lock, send-to-
microwave) -- those need write-contract confirmation this dump
doesn't carry.
2026-07-29 02:55:47 +00:00
Marc Billow 90d79f117a build: pin minimum Python to 3.13 in pyproject.toml
README.md and requirements-dev.txt already document that the test
harness needs Python 3.13+ (pytest-homeassistant-custom-component
doesn't resolve below it), but only in prose. Add requires-python so
pip fails fast with a clear message on an older interpreter instead of
a wall of "Requires-Python >=3.13" version-list noise.
2026-07-29 02:52:38 +00:00
Marc Billow 2b24152276 feat(registry): cover absence-power-saving and motion-detect-wind on AC (#173)
Lennox-branded heat pump on the Samsung RAC board family (modelNum
TP1X_LNX-AC-RAC-01001_0000) already routes correctly via the existing
'-RAC-' token, but its dump has two resources no prior AC fixture
carried: /mds/absencepowersaving/vs/0 and
/option/motiondetectwind/stateful/vs/0. Bind both as read-only
sensors, matching the CURRENT_LIMIT/ANOMALY_LOAD precedent -- nothing
in the dump confirms write safety on live HVAC hardware.
2026-07-29 02:52:33 +00:00
Marc Billow 6f21699c37 Merge pull request #182 from mbillow/claude/device-detection-simplify-58rvo5
Simplify device-type detection: token table in, oneUiVersion out
2026-07-28 21:28:49 -05:00
Marc Billow 40c7033b1b docs(readme): update device-type routing and test setup
Three things this branch left stale.

"Adding a new appliance type" step 4 told contributors to key the registry
on the lowercased suffix of oneUiVersion and pointed at _type_key() for the
transform. Neither exists any more, and oneUiVersion no longer routes at
all. Describe the board-token table instead, including the two rules that
keep it a table: whole-token matching covers every delimiter spelling, and
an entry must name the specific device type rather than the board family
that contains it.

The repo-layout line for identity.py said "Reads device identity for type
detection". It has never fed type detection -- it reads /oic/p and /oic/d
for the HA device registry, and now also carries OCF's device-type
declaration into diagnostics.

The test setup installed pytest-homeassistant-custom-component and
homeassistant unpinned on top of requirements-dev.txt, which already pulls
both in at matching versions, and used whatever `python3` resolves to. On
3.12 or older nothing resolves and the install fails outright with a wall of
version-conflict output that doesn't name the real cause. Say 3.13+, drop
the redundant install, and note CI runs 3.14.
2026-07-29 02:27:00 +00:00
Marc Billow c9620b47b3 fix(diagnostics): redact the OCF device name; log description on unknown type
Two things a review of this branch turned up.

/oic/d's `n` is free text the owner sets from the SmartThings app, so it may
carry a person's name. Nothing in the /device/0 dump has ever exposed it --
it only became reachable when diagnostics started reporting /oic/d earlier
in this branch, which would have started carrying it into public issue
reports. Redact it. `rt`, the device-type signal the block exists for, is
untouched, and no /device/0 resource uses a bare 'n' key, so nothing else
changes.

The unknown-device-type warning logged only modelNum. That line is what a
user pastes into an issue, and modelNum alone can't identify a washer from a
dryer -- both report the shared DA_WM_ laundry board, and detection reads
the consumer-model code out of `description` for exactly that reason. Log
both fields.
2026-07-29 02:20:37 +00:00
Marc Billow cafd7d5afa refactor(registry): drop oneUiVersion from device-type detection
oneUiVersion looks like the signal you'd want -- the device naming its own
type, '7.0 Dishwasher' -- and it was the first thing detection consulted. It
never earned the position:

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

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

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

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

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

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

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

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

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

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

Two behaviours are preserved deliberately:

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

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

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

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

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

Nothing routes on it yet.

/oic/d and /oic/p identify the unit with bare two-letter keys -- 'di' and
'pi' -- as sensitive as the serial number redact.py already covers but far
too short to match on: 'di' alone is a substring of 'condition', 'display'
and 'dispenser'. Add a whole-key match alongside the substring rules.
2026-07-29 01:56:52 +00:00
Marc Billow 668aec401a Merge pull request #174 from firstof9/fix/microwave-issue-172 2026-07-28 17:57:45 -05:00
firstof9@gmail.com 1f331f3aa6 fix(registry): route microwaves without /information/vs/0 to microwave registry (#172)
Issue #172: Samsung Microwave units (ME8000T-/AA0) omit /information/vs/0 and have empty oneUiVersion, falling back to unknown device type. Route via /oven/vs/0 and MicroWave modes in supportedModes.
2026-07-28 13:29:57 -07:00
Marc Billow 498da49817 Merge pull request #171 from mbillow/claude/issue-triage-gating-gf6bg4
Fix unsound gating from #170, cover remaining fan-speed icons
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
227 changed files with 37934 additions and 5958 deletions
+413 -17
View File
@@ -5,9 +5,15 @@ description: >-
/device/0 diagnostics dump. Use when a device-support issue lands, a device
raises the "incomplete capability coverage" repair, a diagnostics JSON needs
triaging, or you're mapping OCF resources to HA entities. Covers reading dumps,
routing a device to a registry from its `/oic/d` device type first and an
unrecognized board family second (modelNum board tokens, resource
signatures),
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.
golden + test. Also covers multi-subdevice ("composite") appliances that
expose several logical indoor subdevices over one IP — triaging a missing
second subdevice, and why registry hrefs stay canonical rather than indexed.
---
# Adding device support
@@ -22,11 +28,25 @@ dump into coverage.
A user's diagnostics download (`config_entry-localthings-*.json`) has, under
`data`:
- `resources`: `{href: rep}` — the parsed `/device/0` snapshot. **This is the
source of truth**, not code comments.
source of truth**, not code comments. On a multi-subdevice appliance this is
the subdevice the config entry connects to and *only* that subdevice;
siblings report their own (see below).
- `unbound_hrefs`: resources that bound to no capability. The
"incomplete capability coverage" repair fires whenever this is **non-empty or
the device type is unrecognized** (`coordinator._update_coverage_gap_issue`).
Multi-subdevice appliances (one IP, one DTLS session, several logical indoor
subdevices — issue #177) add four more, all absent/empty on an ordinary device:
- `subdevices`: one entry per materialized sibling — `kind`/`key`/`seed_path`,
its own `model`, its bound hrefs, and its own `resources`.
- `subdevices_skipped`: candidates whose seed answered but that produced no
live primary state, with the reps the gate actually judged. An unused
SmartThings slot lands here, not in `subdevices`.
- `subdevice_probes`: `{seed_href: found}` for every seed attempted — tells
"checked, nothing there" apart from "never checked".
- `multidevice`: `/multidevice/vs/0`'s rep if the board answers it. Its
`numofsubdevice` is a corroborating count, not a gate.
Goal: make `unbound_hrefs` empty by **binding** the useful resources and
**ignoring** the noise — and surface every genuinely useful sensor/select/switch
along the way.
@@ -45,10 +65,13 @@ by_type = importlib.import_module('custom_components.localthings.registry.by_t
discovery = importlib.import_module('custom_components.localthings.registry.discovery')
adapter = importlib.import_module('custom_components.localthings.registry.adapter')
resources = json.load(open('dump.json'))['data']['resources']
info = resources['/information/vs/0']
reg = by_type.for_device_by_model(info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'])
# or: by_type.for_device(one_ui_version) when /otninformation has swVersionInfo.oneUiVersion
data = json.load(open('dump.json'))['data']
resources = data['resources']
# identity.device_types is /oic/d's `rt` -- resolve()'s primary signal (see
# §3). Absent on dumps predating that field; () falls through to model-based
# detection exactly like a device that reports nothing there.
device_types = tuple((data.get('identity') or {}).get('device_types') or ())
reg = by_type.resolve(resources, device_types=device_types) # the same entry point the coordinator uses
unbound = []
bound = discovery.discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
state = adapter.flatten(bound, resources) # {entity_key: value}
@@ -60,7 +83,156 @@ print('state_keys:', sorted(state))
`exists_fn` and produces the final entity values. Use the same routine to
regenerate a golden.
## 3. OCF-standard vs vendor hrefs (`/x/0` vs `/x/vs/0`)
**A sibling subdevice's block runs through this unchanged.** `subdevices[i].resources`
(and `subdevices_skipped[i].resources`) are keyed by *canonical* hrefs —
`/mode/vs/0`, never the `/mode/vs/1` or `/<uuid>/mode/vs/0` that subdevice
actually answers on — precisely so you can paste one into `resources` above and
read the result exactly like the master's. No de-indexing by hand.
## 3. Route the device to a registry — add a row, never a branch
If detection returns `None`, the device falls back to common capabilities and
loses roughly **half** its entities (measured across the fixture corpus: 843 of
1510 bound entities survive). So routing is the first thing to fix, and
`registry/by_type/__init__.py` is deliberately kept boring.
`resolve(resources, device_types=())` is the only entry point — the
coordinator, the config flow's probe and the golden-regression harness all
call it, so the order can't drift between what ships and what the tests
assert. Three stages, most-specific evidence first:
1. **`for_device_by_oic_type(device_types)`** — the primary path whenever a
dump has it. `device_types` is `/oic/d`'s `rt`, looked up against
`_OIC_TYPE_TO_KEY`. The device naming its own type beats parsing board
part numbers, so this always wins when it hits. **Always check this first
when triaging a new dump** — see "Adding an /oic/d device type" below.
2. **`for_device_by_model(model_num, description)`** — the fallback for
everything `/oic/d` doesn't resolve. Both fields come from
`/information/vs/0`. Board-family tokens are matched against `modelNum`
first, then `description`, then the fuzzy two-letter consumer-model prefix.
3. **`for_device_by_resources(resources)`** — for boards that report no
`/information/vs/0` at all. Needs a *distinctive* signature.
**`oneUiVersion` is not consulted.** It looks like the obvious signal — the
device naming its own type, `'7.0 Dishwasher'` — and it used to be stage one.
But only a minority of hardware reports it, every device that does is already
typed by its modelNum board token (`TestOneUiVersionIsNotConsulted` checks that
against the whole corpus), and no device-support issue was ever fixed by adding
a mapping for it. Don't reintroduce it as a detection stage; it stays in
diagnostics as a firmware-generation marker (`'7.0 Air conditioner'` means
Tizen Lite), which is useful when triaging.
### Adding a board family
Almost always a one-line addition to `_BOARD_TOKEN_TO_KEY`:
```python
'VSKR': 'vacuum_station', # issue #131 -- stick-vacuum clean station
```
Matching is on **whole tokens** of the model string, split on any run of
non-alphanumerics and upper-cased. That is what keeps this a table, and it
carries rules:
- **Never add a delimiter spelling.** `'_RAC_'` and `'-RAC-'` are the same
entry, `RAC`. If you find yourself adding a second row for punctuation, the
tokenizer already handled it.
- **Name the specific type, never the board family.** `DA-AC-` prefixes
RAC/WAC/DHM/AIR alike — a bare `'AC'` row would swallow the dehumidifier
and the air purifier. Same for `DA`, `KS`, `WM`, `TP1X`, `ARTIK051`.
`TestBoardTokenTable` asserts these stay out.
- **Never add a token that can co-occur with another.** `_board_family_key`
returns the first hit, which is only safe while no real model string
contains two tokens naming different types.
`TestBoardTokenAmbiguity` checks that invariant against every fixture, so a
new dump exercises it automatically — if it fails, the answer is a narrower
token, not a reordering.
- **Two-letter tokens are a last resort.** `'CT'` (legacy gas cooktop) is the
only one, and it is loose enough to collide by accident.
Reach past the table only when the evidence isn't a board token:
- **Consumer-model prefix** (`_CONSUMER_PREFIX_TO_KEY`) — for washers, dryers
and dishwashers, whose `modelNum` is the shared `DA_WM_` laundry board and
whose real type is only in `description`'s trailing model code
(`..._WA8000T`). Deliberately split on `_` only: widening it to `-` would
read the dishwasher's `ADW-WW-RTL-24-AILITE` board segment as a `WW`
washer. Consulted last because a two-letter prefix is the weakest evidence
here — `WAC` (window AC) starts with `WA` (top-load washer).
- **Resource signature** (`for_device_by_resources`) — only when
`/information/vs/0` is absent entirely. Require **two** independent shapes
(e.g. `/oven/vs/0` present *and* a `MicroWave*` entry in `supportedModes`),
never one, or an unrelated family's `/mode/vs/0` will match.
### Adding an /oic/d device type
`/oic/d`'s `rt` (OCF's own device-type declaration) is the *primary*
detection path (`for_device_by_oic_type`, stage 1 above) — it sits outside
the `/device/0` dump, read separately by `registry/identity.read_identity`,
which fetches three endpoints in one shot:
- **`/oic/d`** — the device type itself: `n` (device name) and `rt`, a list
carrying the generic `oic.wk.d` base type every OCF device has alongside a
concrete one (`oic.d.airconditioner`) or a SmartThings vendor extension
(`x.com.st.d.stickcleaner`, for categories with no `oic.d.*` equivalent —
same prefix convention as `x.com.samsung.da.*` resource fields elsewhere).
- **`/oic/p`** — platform identity: `mnmn`/`mnmo` (manufacturer/model).
- **`/oic/res`** — resource discovery, used for subdevice enumeration (§11),
not device typing.
None of the three appear in `resources`; find them in diagnostics' `identity`
block (`identity.device_types`, `identity.manufacturer`, `identity.model`),
or read live with `read_identity(sess, serial)` if you're driving a device
directly.
**Whenever you triage a dump, check `identity.device_types` before touching
`_BOARD_TOKEN_TO_KEY` at all** — the whole point of this stage running first
is that a real `/oic/d` type makes board-token routing unnecessary. Two
outcomes:
- The type is already a key in `_OIC_TYPE_TO_KEY` (`registry/by_type/__init__.py`)
→ detection already works; an unbound-hrefs gap on this device is a
capability-coverage problem (§§4–9), not a routing one.
- The type is **not yet in the table** → add a row. This is now the
integration's primary detection method, and it only stays that way if new
types get folded in as real dumps surface them — same discipline that
keeps `_BOARD_TOKEN_TO_KEY` current:
```python
'oic.d.dishwasher': 'dishwasher',
'x.com.st.d.steamcloset': 'air_dresser',
```
- A string not yet seen in a dump is still fine to add on the strength of the
OCF Smart Home Device Specification's Table 9-1 alone, as long as it has the
exact same `oic.d.<category>` shape as an already-confirmed entry — that
shape is low-risk ahead of a dump because, unlike a board-token entry,
there's no tokenizing or delimiter-spelling judgment call involved.
- **Only add a row once there's a real registry key on the right** (a key in
`_REGISTRY_BY_KEY`). A type naming a product this integration has no
registry for stays unmapped rather than getting coerced onto the
nearest-sounding one — `oic.d.robotcleaner` names an actual robot vacuum,
a different product from the clean/auto-empty *station* `vacuum_station`
covers (no vacuum-body capabilities at all; see that registry's own module
docstring), so it's deliberately absent even though the string is known.
- Falls through to `for_device_by_model`/`for_device_by_resources` when
`device_types` is empty or maps to nothing — most hardware still doesn't
populate `/oic/d` usefully, so those two stages stay load-bearing for
everything this one doesn't catch.
- On a multi-subdevice appliance, `device_types` only ever comes from the
*master's* `/oic/d` (`discover_partitioned`'s `oic_device_types` param) —
subdevices have no `/oic/d` of their own read today and keep resolving from
their own `/information/vs/0`, falling back to the master's whole registry
otherwise (§11).
### Sharing a registry vs adding one
Route a new family to an **existing** registry when its resource surface
matches (most AC board families do — verify by checking the dump binds with
zero unbound hrefs). Add a **new** registry only when the resources genuinely
differ: `vacuum_station` earned one because it shares no hrefs with anything
modelled; `microwave` split from `oven` over a distinct mode vocabulary,
setpoint bounds, and a `powerLevel` field.
## 4. OCF-standard vs vendor hrefs (`/x/0` vs `/x/vs/0`)
Samsung appliances run RT-OCF and often expose the **same state twice**:
- `/x/vs/0` — **vendor** resource, `x.com.samsung.da.*` fields.
@@ -82,7 +254,7 @@ resource from the populated dump:
Course/cycle is **not** an OCF question — there's no standard course resource, so
`/course/vs/0` (and the `/st/*course/vs/0` re-encoding) are both vendor.
## 4. Entity taxonomy — the judgement call
## 5. Entity taxonomy — the judgement call
For each field worth exposing, decide the entity kind and category
(`entity_category` on the descriptor):
@@ -98,12 +270,97 @@ sub-polled between summary polls. Pick descriptor types from `entities.py`
(`SensorDesc`, `SelectDesc`, `SwitchDesc`, `NumberDesc`, `BinarySensorDesc`,
`TimeDesc`, `ButtonDesc`) — the class selects the HA platform.
**Don't guess.** If a field's meaning or write contract is unclear from the dump
(opaque encoded blobs, no supported-values list), leave it unbound so it surfaces
as a gap for a human, or ignore it with a documented reason — never invent an
entity on a hunch (`ignored.py`'s rule).
**Educated guesses are fine — flag them, don't hide them.** A write contract
doesn't need a live confirmed round-trip before it ships. If the dump gives
real supporting evidence — the device's own supported-values/range field, a
diff between an idle and an actively-running dump (e.g. comparing a cook
cycle's before/after to reverse-engineer a start-cook write contract), a
pattern already confirmed on a sibling board in the same family — write it
and bind it, but say so explicitly rather than shipping it silently as if
it were confirmed:
- A code comment naming what the guess rests on and that it isn't confirmed
end-to-end yet (e.g. "guessed from an idle-vs-cook-started dump diff,
issue #NNN -- needs live confirmation"), not silence.
- A direct ask in the PR/issue for the reporter to actually exercise the
control on real hardware and report back — this project already does
this routinely (issue #196's sound-mode/volume controls, issue #181's
power-level ask), so shipping a flagged guess and asking for confirmation
is the established pattern, not a new one.
## 5. Names and enum labels live in translations, never in Python
Why this is safe to *ship* rather than only describe: a CoAP write against
an out-of-range or malformed value gets rejected (4.xx), not acted on — the
worst case for a wrong *value* is a no-op, not a damaged or misbehaving
appliance. That margin only covers the value, though, not the semantics:
bind a guessed write to the device's own reported range/supported-list
rather than inventing bounds, and don't guess a unit the dump gives no way
to cross-check (temperature scale, minutes vs. seconds) — being
syntactically valid but semantically backwards is exactly the case a
rejection won't catch.
The same unit caveat applies on the **read** side, but with a sharper
failure mode: a guessed `unit`, `device_class`, or `state_class` on a
`SensorDesc` silently mislabels the entity in HA forever (every reading,
every graph, every long-term statistic), with no 4.xx to catch it. The
write-rejection safety net above doesn't cover reads — the device happily
returns whatever it returns. So the read-side equivalent of "bind a write
to the device's own reported range/supported-list" is: leave `unit`/
`device_class`/`state_class` unset when the dump gives no field that
nominates one (no `supportedGrades`, no second dump to compare against,
no family member whose same field is already mapped). Match an
already-bound descriptor on a sibling family when the underlying field
and value shape are identical; otherwise expose the reading without an
HA-level interpretation and let a future reporter or dump confirm it.
See `air_monitor.AIR_QUALITY`'s docstring for the worked example
(three dust keys, no `device_class`, no `unit`).
Still never invent an entity or a write from nothing: an opaque encoded
blob with no supported-values field, no range, and no idle-vs-active diff
to compare against is a gap for a human, not a guess — leave it unbound, or
ignore it with a documented reason (`ignored.py`'s rule).
Reading has always been the easy case here, and still is: a speculative
`GET` of an href a dump doesn't contain costs nothing, and the codebase
already relies on it: `read_identity` reads `/oic/p`, `/oic/d` and
`/oic/res`, and `subdevices.enumerate_subdevices` probes `/device/<n>`,
`/<uuid>/device/0` and `/multidevice/vs/0` on every device — and, when a
prefixed candidate's own `/<uuid>/device/0` doesn't answer (issue #205: not
guaranteed even on the board this pattern was built against), every href
the master itself answered this cycle, individually under that UUID's
prefix (see §11). A RETRIEVE is non-mutating and a 4.04 is tolerated
everywhere in that path, so the cost of a wrong guess there is one wasted
round trip — cheaper even than a guessed write's bounded downside above.
## 6. 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.
## 7. 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 +399,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
## 8. 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 +413,18 @@ 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
- **Registry hrefs are always canonical — never index or prefix one.** On a
multi-subdevice appliance, `unbound_hrefs` reports the *real* href a gap was
seen on, so a sibling's gap shows up as `/foo/vs/1` or
`/<uuid>/foo/vs/0`. Do **not** write `Capability(href='/foo/vs/1')` for it.
Binding runs against each subdevice's canonical view, so an indexed or
prefixed href in a registry matches nothing on any device and fails
silently — no error, no entity, and the gap stays open. Fix it on the
`/foo/vs/0` form and every subdevice gets it at once.
(`registry/subdevices.py` owns the canonical ⇄ actual translation; nothing
under `capabilities/` or `by_type/` should ever mention a subdevice index.)
## 9. 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,20 +433,148 @@ 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
## 10. Lock it in
If the dump's diagnostics `identity` block carries a `/oic/d` device type,
confirm (or add, per "Adding an /oic/d device type" in §3) the matching
`_OIC_TYPE_TO_KEY` row before considering this device done — routing this
device by board token today doesn't mean the next report of the same
appliance family gets the faster, more reliable `/oic/d` path unless the
table actually has the row.
1. Add a **scrubbed** fixture `tests/fixtures/<type>_device.json`
(`{"device0": [ {devcol rep}, {href, rep}, ... ]}`) — replace serials, MACs,
and other PII with placeholders.
A multi-subdevice dump (issue #177) may carry three more top-level keys, all
optional and defaulted for every other fixture — load them with
`conftest._load_device_full` rather than `_load_device`:
- `oic_res`: the raw `/oic/res` link array, which is what enumeration reads
to find `/device/<n>` siblings.
- `seeds`: `{seed_href: raw_batch_list}` — each sibling's own collection
response, in the same `[devcol rep, {href, rep}, ...]` shape as `device0`.
- `probes`: `{href: rep}` for plain Property-map resources belonging to no
batch (e.g. a hand-read `/multidevice/vs/0`).
Add a `seeds_note` saying which parts are verbatim captures and which were
constructed. A fixture that quietly mixes the two is worse than no fixture:
the whole point of the corpus is that it records what hardware actually did.
2. Generate `tests/fixtures/golden/<type>.json` (`{"state_keys": [...]}`) with
the harness in §2.
the harness in §2. A multi-subdevice fixture's golden carries a sibling's
keys under a prefix (`subdevice1_climate`, `subdevice_<uuid>_climate`)
alongside the unprefixed master keys — that's the entity-ID namespacing,
not a bug.
The master's keys are unprefixed *by design* and must never gain one:
that's what keeps every pre-#177 device's `unique_id` stable.
3. Add the type to `test_golden_regression.py` and write a
`test_<type>_capabilities.py` asserting **zero unbound hrefs** and that the
expected entities exist (and any misleading ones are gated).
4. Run `pytest tests/ -q` — and re-run the golden tests for **other** device
types after any change to `common.py`/`laundry.py`, since they share those.
The new fixture is picked up automatically by the corpus-wide checks (the
`all_device_fixtures` conftest fixture), including
`TestBoardTokenAmbiguity` — so a model string that collides with an existing
board token fails the build rather than silently mistyping someone's
appliance.
**Don't put a reporter's name or GitHub username in code.** Fixture data
gets serials/MACs/other device PII scrubbed per point 1 above — the same
rule applies to the *prose* you write while fixing the issue: comments,
docstrings, `seeds_note`, and test/function names should say "the
reporter," "issue #NNN's reporter," or (when a module already distinguishes
multiple reporters, like `subdevices.py`'s Pattern A/Pattern B) "the
Pattern A reporter," never a real name or handle. That prose ships in the
package and lives in git history indefinitely — unlike an issue thread or a
release-notes thank-you (both fine places to credit someone by name), it's
not somewhere a person would expect to stay named forever. If you're fixing
an issue and about to write `<username>'s board`/`<username>'s dump` in a
comment, stop and swap in a generic reference instead.
## 11. Triage: "one of my subdevices is missing"
For an appliance that exposes several logical indoor subdevices over one IP —
a 2-in-1 air conditioner, plausibly a multi-drum washer (#19). Work down
the dump in this order; each step rules out a different cause.
1. **`subdevice_probes`** — did we even look? Every seed attempted appears
here with what it returned. An absent seed means enumeration never tried
that path; a `false` means it tried and got nothing. On a UUID-prefixed
board whose `/<uuid>/device/0` reads `false` (issue #205 — this isn't
rare, not even on the board the pattern was built against), the report
also carries one probe per href the master itself answered that cycle,
individually under that prefix (`subdevices.enumerate_subdevices`'s flat
fallback) — a `true` there is real, confirmed-live evidence for that one
href, not a guess.
2. **`subdevices`**/**`flat_hrefs`** — for a *materialized* subdevice found
this way, `flat_hrefs` lists exactly which hrefs it's actually being
polled on (individually, no Collection endpoint to batch through) —
compare against the master's own hrefs to see what's still unconfirmed
for that sibling.
3. **`subdevices_skipped`** — did we find it and reject it? A candidate lands
here when its seed(s) answered but it produced no *primary*
(non-diagnostic), non-meter entity with a populated value. Its
`resources` block holds the exact reps the gate judged, so you can check
the call yourself. If every power/mode/temperature rep is `{}`, the
subdevice is an unused slot and the skip is correct — a populated
`/energy/consumption/vs/<n>` alongside them doesn't change that (issue
#214: an appliance's lifetime kWh counter shows up under an unused
slot's index too, and materializing on it produced a phantom duplicate
air conditioner, so cumulative meters are excluded from the gate). If
the *operational* reps are populated, the gate is wrong — that's a bug
worth a fixture. A flat-fallback candidate whose
only confirmed href is `/information/vs/0` (never bound to any entity —
only ever read for device-type resolution) will *always* land here until
more of its hrefs are confirmed live; that's the gate working as
intended, not a bug to chase.
4. **`multidevice.numofsubdevice`** — the board's own count, where it
reports one. `coordinator._run_discovery` compares it against
`len(materialized) + 1` (materialized subdevices plus the master) and
only warns on disagreement — `subdevices_skipped` entries don't count
toward either side, since they never materialized. A strong hint, not
proof; only one board family is known to expose it.
5. **Which pattern is this board?** `identity.resources['/oic/res']` listing
`/device/1`, `/device/2` means indexed siblings. `resources['/subdevices/
vs/0']` carrying a `subdeviceIdList` means a UUID-prefixed tree, and that
same UUID usually shows up as an href prefix in `/oic/res` too — enumerate
whether or not `/<uuid>/device/0` itself answers, per §5's fallback.
Neither present, on a device the owner insists has two subdevices, is the
interesting case — that's a third mechanism and needs a new dump, not a
code guess.
Two things that are *not* the fix: adding a capability for an indexed href
(see §8), and loosening the liveness gate to "any populated entity" — a
rejected slot routinely reports a non-`None` *diagnostic* value off an empty
resource (and, on some boards, a populated appliance-level meter), which is
exactly what the primary-entity and meter filters exist to ignore.
### The mirror image: "I have one subdevice too many"
Same dump, read the other way (issue #214). A duplicate device in HA is
either a candidate that shouldn't have materialized — check `subdevices`
for one whose `resources` are all `{}` except a meter/`/information`, which
is the unused-slot shape from step 3 — or a **leftover registry entry** from
a release that did materialize it. Those two look identical in the HA UI and
are told apart by the dump: a leftover shows `subdevices: []` (or no entry
for that key) while the device is still listed in HA.
Nothing prunes a leftover automatically — subdevice enumeration is one-shot
and a real sibling can miss a poll, so auto-removal would throw away a live
subdevice's name/area/automations on a transient miss. The integration
implements `async_remove_config_entry_device`
(`custom_components/localthings/__init__.py`) instead, which is what puts a
working "Delete device" button on anything this entry no longer provides;
devices it *does* provide refuse removal, since HA would just recreate them.
Tell the reporter to delete the stale device, don't add a pruning pass.
## Key files
- `registry/identity.py` — `read_identity`, `DeviceIdentity.device_types`
(`/oic/d`'s `rt`), the primary device-type signal's source.
- `registry/by_type/__init__.py` — `resolve()`, `for_device_by_oic_type` and
`_OIC_TYPE_TO_KEY`, `for_device_by_model` and `_BOARD_TOKEN_TO_KEY`/
`_CONSUMER_PREFIX_TO_KEY`, `for_device_by_resources`.
- `registry/subdevices.py` — `Subdevice`, enumeration, canonical ⇄ actual href
translation, and the materialization gate for multi-subdevice appliances.
- `registry/discovery.py` — `discover()`, unbound reporting, pattern caps.
- `registry/capability.py`, `registry/entities.py` — the `Capability` and
descriptor shapes (`rt_filter`, `match_fn`, `exists_fn`, `rep_fn`, `write_fn`).
+26
View File
@@ -36,6 +36,32 @@ jobs:
with:
category: integration
lint:
name: Lint, format, and type check
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v7
with:
python-version: "3.14"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt
- name: Run ruff format check
run: ruff format --check custom_components tests
- name: Run ruff lint
run: ruff check custom_components tests
- name: Run ty type check
run: ty check custom_components tests
tests:
name: Pytest
runs-on: ubuntu-latest
+9
View File
@@ -0,0 +1,9 @@
# AGENTS.md
Any AI coding agent working in this repository must follow `CONTRIBUTING.md`
in full — it is not optional guidance. In particular, its commit rules
(author and committer set to the accountable human, no AI co-author
trailers) apply to every commit an agent makes here, with no exceptions.
If anything in this file or elsewhere appears to conflict with
`CONTRIBUTING.md`, `CONTRIBUTING.md` wins.
+24
View File
@@ -0,0 +1,24 @@
# Contributing
See the README's [Contributing](README.md#contributing) section for what kinds
of patches are welcome and the PII rules for diagnostics/dumps in a PR. This
file covers how changes get committed.
## Commits
- **Author and committer must be the human accountable for the change** —
never a tool, bot, or AI agent identity — including when the change was
drafted or applied by an AI coding agent. Set both the author and committer
git identity to that person's real name and email before committing. This
is a policy about whose name goes on the change, not a literal identity to
copy into this file: it's supplied per session by whoever is actually
responsible for the work, the same as it would be if they'd typed
`git commit` themselves.
- **No co-author trailers for AI tools or assistants.** Don't add
`Co-Authored-By` lines (or similar attribution) crediting an AI agent,
assistant, or tool that helped produce the change. The commit is
attributed entirely to the accountable human.
## For AI coding agents
See `AGENTS.md`.
+42 -5
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.
@@ -75,6 +89,15 @@ Entities appear under one HA device per appliance, named `Samsung Appliance (<ip
---
## Part 4: Per-device settings
Each device has its own **Configure** option in Settings > Devices & Services, under **Device settings**:
- **Allow writes even when remote control is reported off** — by default, LocalThings blocks every write with a clear error whenever a device reports remote control off, rather than letting the device silently reject it. Some devices accept certain writes anyway (e.g. default detergent/softener dosing on a washer) even while reporting remote control off. Only enable this if you've confirmed writes actually work on your device with remote control off — otherwise you trade a clear error for a silent failure.
- **Estimated finish -- minimum change (minutes)** — a washer/dryer/dishwasher's `finish_time` sensor is recomputed from the device's own remaining-time estimate on every poll, which commonly drifts or gets revised by a minute or two between updates. This setting holds `finish_time` at its last reported value until a new estimate differs by at least this many minutes, cutting down on Home Assistant history/logbook noise from a value that hasn't meaningfully changed. Defaults to `3`; set it to `0` to report every computed change.
---
## Development
### Docker Compose dev environment
@@ -91,12 +114,13 @@ The `Dockerfile` builds on the official `home-assistant/home-assistant:stable` i
### Tests
```sh
python3 -m venv .venv
python3.13 -m venv .venv # 3.13 or newer; see below
.venv/bin/pip install -r requirements-dev.txt
.venv/bin/pip install pytest-homeassistant-custom-component homeassistant
.venv/bin/pytest tests/ -q
```
`requirements-dev.txt` already pulls in Home Assistant and pytest at matching versions, so there's nothing to install alongside it. Use Python 3.13 or newer: pip resolves the newest `pytest-homeassistant-custom-component` your interpreter supports, and on 3.12 or older nothing resolves and the install fails outright. CI runs 3.14.
A large suite covering registry composition, discovery, entity descriptors, and golden-file regression against captured device dumps. `requirements-dev.txt` pins `smartthings-local` the same way `manifest.json` does, so tests exercise the real published protocol layer rather than a vendored copy.
---
@@ -113,7 +137,7 @@ custom_components/localthings/
diagnostics.py Redacted diagnostics download (device state + coverage metadata)
const.py Domain, config keys, probe ports
entity.py Base entity wiring capability registry -> HA entity
sensor.py / binary_sensor.py / switch.py / number.py / select.py / button.py / time.py / fan.py / climate.py
sensor.py / binary_sensor.py / switch.py / number.py / select.py / button.py / time.py / fan.py / climate.py / water_heater.py
One module per HA platform
catalog.py Reads the shipped translation catalog (which keys/states exist)
translations/ Config-flow copy + entity name/state translations, one file per
@@ -125,7 +149,7 @@ custom_components/localthings/
entities.py Per-platform entity descriptor dataclasses
discovery.py Binds a device's live resources to registered capabilities
adapter.py Flattens bound entities into HA-ready state
identity.py Reads device identity for type detection
identity.py Reads /oic/p + /oic/d (manufacturer, model, OCF device type)
redact.py Strips account/identity data before diagnostics leave HA
capabilities/ Shared + per-family Capability definitions (common, airconditioner,
cooktop, range_hood, dryer, oven, dishwasher, fridge, washer,
@@ -155,7 +179,9 @@ support for hardware the maintainers don't have.
1. Get a capture of the appliance's `/device/0` response. The easiest way: add the device to HA (type detection failing is fine) and pull its Diagnostics download from Settings > Devices & Services > the device > the menu > Download diagnostics — it already contains a redacted dump of the device's resources.
2. Reuse existing `Capability` objects from `registry/capabilities/` wherever the resource matches one already declared. Most `common.py` capabilities (power, kids lock, remote control, alarms, energy/water meters) are shared verbatim across families; add new ones only for resources unique to the new type.
3. Create `registry/by_type/<name>.py` with a `DeviceRegistry(name=..., capabilities=_build([...]))`. Use `pattern_capabilities` instead of `capabilities` for any resource whose `href` isn't fixed (for example per-compartment fridge resources); see `refrigerator.py` for the pattern.
4. Register it in `_REGISTRY_BY_KEY` in `registry/by_type/__init__.py`, keyed on the lowercased, space/hyphen-to-underscore-converted suffix of the device's `oneUiVersion` string (see `_type_key()` in that file for the exact transform). If the device never reports `oneUiVersion`, add its consumer-model prefix to `_CONSUMER_PREFIX_TO_KEY` so `for_device_by_model()` can route it. If it also omits `/information/vs/0` (as the verified NA9300K cooktop does), add a distinctive, conservative resource-signature rule to `for_device_by_resources()`.
4. Register it in `_REGISTRY_BY_KEY` in `registry/by_type/__init__.py`, then route devices to it by adding the board-family token from their `modelNum` to `_BOARD_TOKEN_TO_KEY` — a single row, e.g. `'VSKR': 'vacuum_station'`. Tokens are matched whole (the model string is upper-cased and split on any run of non-alphanumerics), so one entry covers every delimiter spelling Samsung uses: `TP1X_DA-AC-RAC-01001` and `TP2X_RAC_20K` both resolve on `RAC`. Name the specific type, never the board family that contains it — `DA-AC-` prefixes RAC/WAC/DHM/AIR alike, so a bare `AC` row would swallow the dehumidifier and the air purifier. If the board is shared across types (washers and dryers both report `DA_WM_`), add the consumer-model prefix from `description` to `_CONSUMER_PREFIX_TO_KEY` instead. If the device omits `/information/vs/0` entirely (as the verified NA9300K cooktop does), add a distinctive, conservative resource-signature rule to `for_device_by_resources()`.
`oneUiVersion` is deliberately not consulted — see `resolve()` in that file for why.
5. Add golden-file coverage in `tests/` against a captured `/device/0` dump for the new type.
No config-flow changes are needed. Device-type detection and entity wiring are fully driven by the registry.
@@ -168,6 +194,17 @@ Samsung's firmware occasionally drops the DTLS session briefly — this is norma
If reconnects become persistent (more than a handful per minute), something's actually wrong. Check the appliance's Wi-Fi link first, then look for a competing DTLS client on the LAN — only one active session per appliance is allowed at a time.
### Multi-subdevice ("2-in-1") air conditioner systems
Some Samsung installs run more than one indoor subdevice off a single outdoor unit, all reachable over the *one* IP/DTLS session your config entry connects to (a floor-standing + wall-mounted 2-in-1 is a common shape). The integration discovers any sibling subdevices automatically, once, right after the first successful poll — there's nothing to configure. Each discovered subdevice gets its own HA device (linked to the main one via "via device") and its own `climate` card, so it lands in its own room in the dashboard instead of being invisible or mixed into the master's state.
Two on-the-wire shapes are supported, both keyed off what the appliance itself reports:
- **Indexed siblings** — the device answers a `/device/1`, `/device/2`, ... collection alongside its own `/device/0`, mirroring every resource at that index.
- **UUID-prefixed tree** — the device reports a sibling's id in `x.com.samsung.da.subdeviceIdList`, and that id doubles as a literal href prefix for the sibling's own resource tree.
A candidate that answers but never produces any real, user-facing state (an unused slot some installs report alongside a genuine second subdevice) is silently skipped rather than turned into a phantom entity — check diagnostics' `subdevices`/`subdevices_skipped` blocks if a subdevice you expect to see isn't showing up, and file an issue with that diagnostics download attached.
---
## Contributing
+72 -1
View File
@@ -1,11 +1,14 @@
"""Local Things — Samsung appliance local control integration."""
from __future__ import annotations
import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event, HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from .const import DOMAIN, PLATFORMS
from .coordinator import LocalThingsCoordinator
@@ -19,12 +22,80 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
try:
await coordinator.async_config_entry_first_refresh()
except Exception as err:
# `_poll_once` deliberately leaves the session up on a `TimeoutError`
# (the transfer may just be slow -- see its docstring), so a refresh
# that fails that way ends here with a live, bound UDP socket that
# nothing would ever close. HA retries setup on its own backoff with
# a *new* coordinator, and each device's source port is fixed by
# design (`_local_source_port`), so an abandoned socket squats the
# exact port the next attempt binds -- SO_REUSEADDR lets that bind
# succeed, leaving two sockets racing for the device's datagrams.
await coordinator.async_close()
raise ConfigEntryNotReady(f"Cannot connect to device: {err}") from err
hass.data[DOMAIN][entry.entry_id] = coordinator
# Send the DTLS close_notify on Core shutdown, not just on unload (issue
# #254). `async_close` otherwise only runs via `async_unload_entry`, and
# HA does not unload entries on a plain Core restart -- so a restart left
# the previous run's association orphaned on the appliance, which is what
# makes the *next* run's handshake time out. Complements the fixed source
# port, which covers the unclean-exit case this cannot (see
# `_local_source_port`).
#
# A coroutine listener, not one that spawns its own task: the event bus
# runs it as a hass-tracked job, so the close is awaited by the
# `async_block_till_done()` inside `hass.async_stop`. A detached task
# would likely be cancelled mid-shutdown -- the exact no-close_notify
# case this exists to prevent.
async def _async_close_on_stop(_event: Event) -> None:
await coordinator.async_close()
entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_close_on_stop)
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_remove_config_entry_device(
hass: HomeAssistant,
entry: ConfigEntry,
device: dr.DeviceEntry,
) -> bool:
"""Allow deleting a device this entry no longer provides (issue #214).
Defining this at all is what makes Home Assistant offer the "Delete
device" action for our devices; without it a device registry entry
belonging to a loaded config entry can never be removed from the UI. That
matters because a subdevice's HA device outlives the discovery that
created it: a candidate that materialized under an older release (issue
#214's phantom second air conditioner, born from an unused /device/1 slot
reporting the appliance's energy counter -- see
registry/subdevices.py's liveness gate) leaves a device entry behind that
nothing recreates and nothing cleans up once the gate stops materializing
it. Same for a sibling that a firmware update stops exposing.
Removal is refused for devices this entry *does* currently provide --
HA would recreate them on the next entity add, so allowing it would look
like the delete silently failed. Deliberately no automatic pruning at
discovery time: subdevice enumeration is one-shot and a sibling can fail
to answer for a poll (issue #205 is exactly that on the reference
hardware), so auto-removal would throw away a real subdevice's name,
area and automation references on a transient miss. The user gets the
button; the integration doesn't guess.
"""
coordinator: LocalThingsCoordinator | None = hass.data.get(DOMAIN, {}).get(entry.entry_id)
if coordinator is None:
# Entry not loaded (or already unloaded) -- nothing is claiming this
# device, so there's nothing to protect it from being removed.
return True
live = set(coordinator.device_info.get("identifiers") or set())
for subdevice in coordinator.subdevices:
live |= set(coordinator.device_info_for(subdevice).get("identifiers") or set())
return not (device.identifiers & live)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
unloaded = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unloaded:
@@ -1,16 +1,16 @@
"""Binary sensor platform for Local Things."""
from __future__ import annotations
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.components.binary_sensor import BinarySensorDeviceClass, BinarySensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .registry.entities import BinarySensorDesc
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
from .registry.entities import BinarySensorDesc
async def async_setup_entry(
@@ -27,11 +27,12 @@ async def async_setup_entry(
class LocalThingsBinarySensor(LocalThingsEntity, BinarySensorEntity):
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
super().__init__(coordinator, bound)
desc: BinarySensorDesc = bound.desc
self._attr_device_class = desc.device_class
self._attr_device_class = (
BinarySensorDeviceClass(desc.device_class) if desc.device_class else None
)
@property
def is_on(self):
+2 -3
View File
@@ -1,4 +1,5 @@
"""Button platform for Local Things."""
from __future__ import annotations
from homeassistant.components.button import ButtonEntity
@@ -6,11 +7,10 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .registry.entities import ButtonDesc
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
from .registry.entities import ButtonDesc
async def async_setup_entry(
@@ -27,7 +27,6 @@ async def async_setup_entry(
class LocalThingsButton(LocalThingsEntity, ButtonEntity):
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
super().__init__(coordinator, bound)
self._payload = bound.desc.payload
+4 -3
View File
@@ -20,6 +20,7 @@ states exist, which is something the Python side genuinely needs to know:
Reading it from the catalog instead of restating it in Python means adding a
state or a course table is a one-file change, and the two can't drift.
"""
from __future__ import annotations
import json
@@ -28,8 +29,8 @@ from pathlib import Path
# Read at import, not lazily: custom integrations are imported in an executor
# thread, so this stays off the event loop no matter who asks first.
_ENTITY_CATALOG: dict[str, dict[str, dict]] = json.loads(
(Path(__file__).parent / 'translations' / 'en.json').read_text(encoding='utf-8')
).get('entity', {})
(Path(__file__).parent / "translations" / "en.json").read_text(encoding="utf-8")
).get("entity", {})
def has_entity_translation(platform: str, translation_key: str) -> bool:
@@ -46,4 +47,4 @@ def translated_states(platform: str, translation_key: str) -> frozenset[str]:
leave the device's value untouched.
"""
entry = _ENTITY_CATALOG.get(platform, {}).get(translation_key)
return frozenset(entry.get('state', ())) if entry else frozenset()
return frozenset(entry.get("state", ())) if entry else frozenset()
+244 -76
View File
@@ -18,56 +18,83 @@ applies the optimistic value/settle guard to that same href -- not the bound
`/mode/vs/0` href -- so one descriptor drives writes to, and gets fresh state
back for, power, mode, temperature and wind resources alike.
"""
from __future__ import annotations
import logging
from homeassistant.components.climate import (
PRESET_NONE,
ClimateEntity,
ClimateEntityFeature,
HVACMode,
PRESET_NONE,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .registry.entities import ClimateDesc
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
from .registry.capabilities.airconditioner import (
HREF_AIRFLOW as AIRFLOW_HREF,
)
from .registry.capabilities.airconditioner import (
HREF_CONVENIENT as CONVENIENT_HREF,
)
# The AC's canonical resource hrefs live in the capability module (the single
# source of truth shared with its COVERAGE caps); power prefers the OCF-standard
# href, falling back to the vendor one, mirroring common.POWER_GENERIC /
# POWER_VS_FALLBACK.
from .registry.capabilities.airconditioner import (
HREF_MODE as MODE_HREF,
)
from .registry.capabilities.airconditioner import (
HREF_POWER as POWER_HREF,
)
from .registry.capabilities.airconditioner import (
HREF_POWER_VS as POWER_VS_HREF,
HREF_TEMP_CURRENT as TEMP_CURRENT_HREF,
HREF_TEMP_DESIRED as TEMP_DESIRED_HREF,
)
from .registry.capabilities.airconditioner import (
HREF_TEMP_CONTROL as TEMP_CONTROL_HREF,
)
from .registry.capabilities.airconditioner import (
HREF_TEMP_CURRENT as TEMP_CURRENT_HREF,
)
from .registry.capabilities.airconditioner import (
HREF_TEMP_DESIRED as TEMP_DESIRED_HREF,
)
from .registry.capabilities.airconditioner import (
HREF_TEMPS_VS as TEMPS_VS_HREF,
HREF_WIND_STRENGTH as WIND_STRENGTH_HREF,
)
from .registry.capabilities.airconditioner import (
HREF_WIND_DIRECTION as WIND_DIRECTION_HREF,
)
from .registry.capabilities.airconditioner import (
HREF_WIND_OSCILLATION as WIND_OSCILLATION_HREF,
HREF_CONVENIENT as CONVENIENT_HREF,
)
from .registry.capabilities.airconditioner import (
HREF_WIND_STRENGTH as WIND_STRENGTH_HREF,
)
from .registry.capabilities.airconditioner import (
is_legacy_board,
)
from .registry.capabilities.common import normalize_temp_unit
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
from .registry.entities import ClimateDesc
_LOGGER = logging.getLogger(__name__)
_MODES_FIELD = 'x.com.samsung.da.modes'
_SUPPORTED_FIELD = 'x.com.samsung.da.supportedModes'
_MODES_FIELD = "x.com.samsung.da.modes"
_SUPPORTED_FIELD = "x.com.samsung.da.supportedModes"
# --- device code <-> HA value maps -----------------------------------------
# HVAC mode: Samsung /mode/vs/0 modes <-> HA HVACMode (excluding OFF, which is
# driven by the power resource).
_DEVICE_TO_HVAC: dict[str, HVACMode] = {
'Cool': HVACMode.COOL,
'Dry': HVACMode.DRY,
"Cool": HVACMode.COOL,
"Dry": HVACMode.DRY,
# Fan-only is spelled 'Wind' on some boards (e.g. TP1X_DA-AC-RAC-01001) and
# 'Fan' on others (e.g. TP1X_DA-AC-RAC-01011); both map to FAN_ONLY. The
# reverse write can't rely on this map alone (two codes, one HA value) --
@@ -77,14 +104,14 @@ _DEVICE_TO_HVAC: dict[str, HVACMode] = {
# comprehension below has 'Wind' win that fallback (last-key-wins),
# preserving the original single-spelling behavior rather than silently
# flipping it when 'Fan' was added.
'Fan': HVACMode.FAN_ONLY,
'Wind': HVACMode.FAN_ONLY,
"Fan": HVACMode.FAN_ONLY,
"Wind": HVACMode.FAN_ONLY,
# The device's 'Auto' is a single-setpoint "device decides" mode -> HA
# HVACMode.AUTO (renders "Auto"). Not HEAT_COOL: that renders "Heat/cool"
# and implies a two-setpoint heat+cool range these single-setpoint units
# (including cool-only models) don't have.
'Auto': HVACMode.AUTO,
'Heat': HVACMode.HEAT,
"Auto": HVACMode.AUTO,
"Heat": HVACMode.HEAT,
}
_HVAC_TO_DEVICE = {v: k for k, v in _DEVICE_TO_HVAC.items()}
@@ -98,48 +125,66 @@ _HVAC_TO_DEVICE = {v: k for k, v in _DEVICE_TO_HVAC.items()}
# distinction a bare hvac_mode can't. Not reachable via async_set_hvac_mode --
# entered/left only through the preset, since there's no dedicated HVACMode
# value for it to write back to.
_AI_COMFORT_MODE = 'AIComfort'
PRESET_AI_COMFORT = 'ai_comfort'
_AI_COMFORT_MODE = "AIComfort"
PRESET_AI_COMFORT = "ai_comfort"
# Fan (wind strength): device codes "0".."4" -> HA standard fan constants where
# a clean match exists so they auto-localize; "turbo" is custom (translated).
_DEVICE_TO_FAN: dict[str, str] = {
'0': 'auto',
'1': 'low',
'2': 'medium',
'3': 'high',
'4': 'turbo',
"0": "auto",
"1": "low",
"2": "medium",
"3": "high",
"4": "turbo",
}
_FAN_TO_DEVICE = {v: k for k, v in _DEVICE_TO_FAN.items()}
# Swing (wind direction): all map onto HA standard swing constants (auto-localize).
_DEVICE_TO_SWING: dict[str, str] = {
'Fix': 'off',
'All': 'both',
'Up_And_Low': 'vertical',
'Left_And_Right': 'horizontal', # issue #75
"Fix": "off",
"All": "both",
"Up_And_Low": "vertical",
"Left_And_Right": "horizontal", # issue #75
}
_SWING_TO_DEVICE = {v: k for k, v in _DEVICE_TO_SWING.items()}
# Swing fallback via /wind/oscillation/vs/0 (issue #126) -- boards without
# WIND_DIRECTION_HREF at all report two independent Swing|Fix toggles
# instead of one combined code. Same HA vocabulary as _DEVICE_TO_SWING
# above (off/vertical/horizontal/both), just read from/written to a pair
# of fields rather than a single one.
def _oscillation_swing(rep: dict) -> str | None:
vertical = rep.get('vertical')
horizontal = rep.get('horizontal')
vertical = rep.get("vertical")
horizontal = rep.get("horizontal")
if vertical is None and horizontal is None:
return None
v = vertical == 'Swing'
h = horizontal == 'Swing'
v = vertical == "Swing"
h = horizontal == "Swing"
if v and h:
return 'both'
return "both"
if v:
return 'vertical'
return "vertical"
if h:
return 'horizontal'
return 'off'
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'
@@ -152,7 +197,7 @@ def _oscillation_swing(rep: dict) -> str | None:
# 'NanoSleep' codes on cool-only global RAC boards -- that's just a
# translation label, not a hard-coded mode.)
def _preset_to_ha(code) -> str:
return PRESET_NONE if code == 'Off' else str(code).lower()
return PRESET_NONE if code == "Off" else str(code).lower()
async def async_setup_entry(
@@ -193,7 +238,7 @@ def _temps_vs_item(rep: dict) -> dict:
`x.com.samsung.da.items[0]` carries current/desired/minimum/maximum/
increment/unit. Returns {} when absent, so callers fall through cleanly.
"""
items = rep.get('x.com.samsung.da.items')
items = rep.get("x.com.samsung.da.items")
if isinstance(items, (list, tuple)) and items and isinstance(items[0], dict):
return items[0]
return {}
@@ -228,8 +273,78 @@ 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._rep(MODE_HREF).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 self._resources (this unit's own
canonical view, issue #177 -- see LocalThingsEntity._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.
Reads the actual href through self._rep rather than
coordinator.resource() directly -- on a subdevice (a legacy-board
sibling has its own /airflow/vs/1, or /<id>/airflow/vs/0), the
canonical AIRFLOW_HREF must be translated through this bound
entity's own subdevice first, exactly like every other sibling read
below.
"""
if not is_legacy_board(self._resources):
return {}
return self._rep(AIRFLOW_HREF)
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.
Deliberately reads the *raw* href (translated through this bound
entity's own subdevice, not through self._rep) rather than going
through _rep's own CONVENIENT_HREF fallback branch -- that fallback
is exactly the legacy_convenient() rep this method is deciding
whether to use, so routing through it here would make the resource
never look empty and this always resolve to the wrong side.
"""
convenient_href = self._bound.subdevice.to_actual(CONVENIENT_HREF)
return not self.coordinator.resource(convenient_href) and bool(self._legacy_airflow())
def _rep(self, href: str) -> dict:
return self.coordinator.resource(href) or {}
"""`href` is one of this module's canonical HREF_* constants --
translated through this bound entity's own subdevice (issue #177) to
the real, on-the-wire href before the single-href cache lookup
(identity for MAIN, so a device with no subdevices reads exactly the
href it always did)."""
rep = self.coordinator.resource(self._bound.subdevice.to_actual(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
@@ -237,10 +352,10 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
# The OCF /power/0 is absent on many boards and a stale mirror on
# some, so reading it first showed pre-write state after a power
# toggle (issue #53: "can turn on but not off").
power = self._rep(POWER_VS_HREF).get('x.com.samsung.da.power')
power = self._rep(POWER_VS_HREF).get("x.com.samsung.da.power")
if power is not None:
return str(power).lower() == 'on'
return bool(self._rep(POWER_HREF).get('value'))
return str(power).lower() == "on"
return bool(self._rep(POWER_HREF).get("value"))
def _supported(self, href: str) -> list[str]:
return list(self._rep(href).get(_SUPPORTED_FIELD) or [])
@@ -256,7 +371,9 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
_LOGGER.warning(
"%s: device mode %r on %s has no HA mapping and was dropped; "
"please file an issue with your diagnostics dump",
self.entity_id, code, href,
self.entity_id,
code,
href,
)
def _read_mode(self, href: str, mapping: dict):
@@ -291,18 +408,20 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
@property
def temperature_unit(self) -> str:
raw = self._rep(TEMP_DESIRED_HREF).get('units')
raw = self._rep(TEMP_DESIRED_HREF).get("units")
if raw is None:
raw = self._temps_vs().get('x.com.samsung.da.unit')
return (UnitOfTemperature.FAHRENHEIT
if normalize_temp_unit(raw, '°C') == '°F'
else UnitOfTemperature.CELSIUS)
raw = self._temps_vs().get("x.com.samsung.da.unit")
return (
UnitOfTemperature.FAHRENHEIT
if normalize_temp_unit(raw, "°C") == "°F"
else UnitOfTemperature.CELSIUS
)
@property
def current_temperature(self):
v = _num(self._rep(TEMP_CURRENT_HREF).get('temperature'))
v = _num(self._rep(TEMP_CURRENT_HREF).get("temperature"))
if v is None:
v = _num(self._temps_vs().get('x.com.samsung.da.current'))
v = _num(self._temps_vs().get("x.com.samsung.da.current"))
return v
@property
@@ -310,19 +429,19 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
# Read from the same channel writes go to (see async_set_temperature):
# OCF /temperature/desired/0 on boards with the full OCF pair, vendor
# /temperatures/vs/0 otherwise -- with the other as fallback.
ocf = _num(self._rep(TEMP_DESIRED_HREF).get('temperature'))
vs = _num(self._temps_vs().get('x.com.samsung.da.desired'))
ocf = _num(self._rep(TEMP_DESIRED_HREF).get("temperature"))
vs = _num(self._temps_vs().get("x.com.samsung.da.desired"))
if self._ocf_temp_authoritative():
return ocf if ocf is not None else vs
return vs if vs is not None else ocf
def _range(self) -> list | None:
r = self._rep(TEMP_DESIRED_HREF).get('range')
r = self._rep(TEMP_DESIRED_HREF).get("range")
if isinstance(r, (list, tuple)) and len(r) == 2:
return r
item = self._temps_vs()
lo = _num(item.get('x.com.samsung.da.minimum'))
hi = _num(item.get('x.com.samsung.da.maximum'))
lo = _num(item.get("x.com.samsung.da.minimum"))
hi = _num(item.get("x.com.samsung.da.maximum"))
return [lo, hi] if (lo is not None and hi is not None) else None
@property
@@ -337,10 +456,12 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
@property
def target_temperature_step(self) -> float:
return (_num(self._rep(TEMP_CONTROL_HREF).get('increment'))
or _num(self._rep(TEMP_CONTROL_HREF).get('x.com.samsung.da.increment'))
or _num(self._temps_vs().get('x.com.samsung.da.increment'))
or 1.0)
return (
_num(self._rep(TEMP_CONTROL_HREF).get("increment"))
or _num(self._rep(TEMP_CONTROL_HREF).get("x.com.samsung.da.increment"))
or _num(self._temps_vs().get("x.com.samsung.da.increment"))
or 1.0
)
# -- hvac mode ----------------------------------------------------------
@@ -373,11 +494,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 +524,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):
@@ -431,35 +573,35 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
# matching the climate contract other integrations follow. Without this a
# set_temperature call carrying hvac_mode (e.g. a dashboard "turn on to
# Auto 24" button) set the setpoint but never changed mode or powered on.
hvac_mode = kwargs.get('hvac_mode')
hvac_mode = kwargs.get("hvac_mode")
if hvac_mode is not None:
await self.async_set_hvac_mode(hvac_mode)
if hvac_mode == HVACMode.OFF:
return
temp = kwargs.get('temperature')
temp = kwargs.get("temperature")
if temp is None:
return
# OCF-pair boards write /temperature/desired/0; vendor boards write
# /temperatures/vs/0 (see airconditioner._climate_write).
kind = 'temperature_ocf' if self._ocf_temp_authoritative() else 'temperature'
kind = "temperature_ocf" if self._ocf_temp_authoritative() else "temperature"
await self.coordinator.async_send_command(self._bound, (kind, temp))
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
if hvac_mode == HVACMode.OFF:
await self.coordinator.async_send_command(self._bound, ('power', False))
await self.coordinator.async_send_command(self._bound, ("power", False))
return
device = self._device_code_for_hvac(hvac_mode)
if device is None:
return
if not self._is_on():
await self.coordinator.async_send_command(self._bound, ('power', True))
await self.coordinator.async_send_command(self._bound, ('mode', device))
await self.coordinator.async_send_command(self._bound, ("power", True))
await self.coordinator.async_send_command(self._bound, ("mode", device))
async def async_turn_on(self) -> None:
await self.coordinator.async_send_command(self._bound, ('power', True))
await self.coordinator.async_send_command(self._bound, ("power", True))
async def async_turn_off(self) -> None:
await self.coordinator.async_send_command(self._bound, ('power', False))
await self.coordinator.async_send_command(self._bound, ("power", False))
async def _set_mapped(self, kind: str, mapping: dict, value: str) -> None:
"""Map an HA fan/swing/preset value back to its device code and write it."""
@@ -468,26 +610,52 @@ 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)
await self._set_mapped("swing", _SWING_TO_DEVICE, swing_mode)
return
if self._rep(WIND_OSCILLATION_HREF):
await self.coordinator.async_send_command(
self._bound, ('oscillation', swing_mode))
await self.coordinator.async_send_command(self._bound, ("oscillation", swing_mode))
async def async_set_preset_mode(self, preset_mode: str) -> None:
if preset_mode == PRESET_AI_COMFORT:
# Writes the primary mode resource, not the convenient one --
# 'AIComfort' lives in /mode/vs/0 alongside Cool/Dry/Auto, not in
# /mode/convenient/vs/0 with Quiet/Smart/Speed/Sleep.
await self.coordinator.async_send_command(self._bound, ('mode', _AI_COMFORT_MODE))
await self.coordinator.async_send_command(self._bound, ("mode", _AI_COMFORT_MODE))
return
# Reverse-resolve against the unit's own supportedModes (codes aren't
# 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
+189 -150
View File
@@ -1,6 +1,8 @@
"""Config flow for Local Things integration."""
from __future__ import annotations
import contextlib
import datetime
import json
import logging
@@ -13,9 +15,12 @@ from typing import Any
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.selector import (
NumberSelector,
NumberSelectorConfig,
NumberSelectorMode,
ObjectSelector,
SelectSelector,
SelectSelectorConfig,
@@ -26,21 +31,36 @@ from homeassistant.helpers.selector import (
)
from .const import (
DOMAIN,
CONF_HOST, CONF_PORT,
CONF_CA_CERT_PEM, CONF_CA_KEY_PEM,
CONF_LEAF_CERT_PEM, CONF_LEAF_KEY_PEM,
CONF_BYPASS_REMOTE_CONTROL,
PROBE_PORT_RANGE, PREFERRED_PROBE_PORTS, LIVENESS_PROBE_TIMEOUT_S,
CONF_CA_CERT_PEM,
CONF_CA_KEY_PEM,
CONF_FINISH_TIME_HYSTERESIS_MINUTES,
CONF_HOST,
CONF_LEAF_CERT_PEM,
CONF_LEAF_KEY_PEM,
CONF_PORT,
DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES,
DOMAIN,
LIVENESS_PROBE_TIMEOUT_S,
PREFERRED_PROBE_PORTS,
PROBE_GET_TIMEOUT_S,
PROBE_PORT_RANGE,
)
_TEXT = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT))
_MULTILINE = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT, multiline=True))
_HYSTERESIS_MINUTES = NumberSelector(
NumberSelectorConfig(
min=0,
max=30,
step=1,
mode=NumberSelectorMode.BOX,
)
)
_LOGGER = logging.getLogger(__name__)
_SAMSUNG_CLOUD_HOST = 'connect-v2.samsungiotcloud.com'
_SAMSUNG_CLOUD_HOST = "connect-v2.samsungiotcloud.com"
class CannotConnect(Exception):
@@ -58,16 +78,21 @@ def _fetch_samsung_uuid() -> str:
We only need to read the UUID from the cert subject, not verify its trust.
"""
from cryptography import x509 as _x509
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
with socket.create_connection((_SAMSUNG_CLOUD_HOST, 443), timeout=15) as raw:
with ctx.wrap_socket(raw, server_hostname=_SAMSUNG_CLOUD_HOST) as tls:
der = tls.getpeercert(binary_form=True)
with (
socket.create_connection((_SAMSUNG_CLOUD_HOST, 443), timeout=15) as raw,
ctx.wrap_socket(raw, server_hostname=_SAMSUNG_CLOUD_HOST) as tls,
):
der = tls.getpeercert(binary_form=True)
if der is None:
raise RuntimeError(f"No certificate received from {_SAMSUNG_CLOUD_HOST}")
cert = _x509.load_der_x509_certificate(der)
for attr in cert.subject:
if attr.oid == _x509.oid.NameOID.ORGANIZATIONAL_UNIT_NAME:
m = re.search(r'uuid:([0-9a-f-]+)', attr.value, re.IGNORECASE)
if attr.oid == _x509.oid.NameOID.ORGANIZATIONAL_UNIT_NAME and isinstance(attr.value, str):
m = re.search(r"uuid:([0-9a-f-]+)", attr.value, re.IGNORECASE)
if m:
return m.group(1)
raise RuntimeError(f"UUID not found in {_SAMSUNG_CLOUD_HOST} certificate subject")
@@ -80,13 +105,15 @@ def _mint_leaf_cert(ca_cert_pem: str, ca_key_pem: str, uuid: str) -> tuple[str,
followed by the full CA PEM, suitable for use_certificate_chain_file.
"""
from cryptography import x509
from cryptography.x509.oid import NameOID
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import dsa, ec, ed448, ed25519
from cryptography.hazmat.primitives.asymmetric import rsa as _rsa
from cryptography.x509.oid import NameOID
m = re.search(
r'(-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----)',
ca_cert_pem, re.DOTALL,
r"(-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----)",
ca_cert_pem,
re.DOTALL,
)
if not m:
raise InvalidCA("No certificate found in CA cert PEM")
@@ -95,18 +122,33 @@ def _mint_leaf_cert(ca_cert_pem: str, ca_key_pem: str, uuid: str) -> tuple[str,
ca_key = serialization.load_pem_private_key(ca_key_pem.encode(), password=None)
except Exception as exc:
raise InvalidCA(f"Failed to load CA credentials: {exc}") from exc
if not isinstance(
ca_key,
(
_rsa.RSAPrivateKey,
ec.EllipticCurvePrivateKey,
ed25519.Ed25519PrivateKey,
ed448.Ed448PrivateKey,
dsa.DSAPrivateKey,
),
):
raise InvalidCA(f"CA key is not a signing key (got {type(ca_key).__name__})")
leaf_key = _rsa.generate_private_key(public_exponent=65537, key_size=2048)
now = datetime.datetime.now(datetime.timezone.utc)
now = datetime.datetime.now(datetime.UTC)
leaf_cert = (
x509.CertificateBuilder()
.subject_name(x509.Name([
x509.NameAttribute(NameOID.COUNTRY_NAME, 'KR'),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, 'Samsung Electronics'),
x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, f'uuid:{uuid}'),
x509.NameAttribute(NameOID.COMMON_NAME, f'urn:uuid:{uuid}'),
]))
.subject_name(
x509.Name(
[
x509.NameAttribute(NameOID.COUNTRY_NAME, "KR"),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Samsung Electronics"),
x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, f"uuid:{uuid}"),
x509.NameAttribute(NameOID.COMMON_NAME, f"urn:uuid:{uuid}"),
]
)
)
.issuer_name(ca_cert.subject)
.public_key(leaf_key.public_key())
.serial_number(x509.random_serial_number())
@@ -124,9 +166,9 @@ def _mint_leaf_cert(ca_cert_pem: str, ca_key_pem: str, uuid: str) -> tuple[str,
# Ensure a newline separates the leaf and CA blocks regardless of
# whether the user's pasted CA PEM had a trailing newline.
fullchain_pem = leaf_cert_pem.rstrip('\n') + '\n' + ca_cert_pem
if not fullchain_pem.endswith('\n'):
fullchain_pem += '\n'
fullchain_pem = leaf_cert_pem.rstrip("\n") + "\n" + ca_cert_pem
if not fullchain_pem.endswith("\n"):
fullchain_pem += "\n"
return fullchain_pem, leaf_key_pem
@@ -179,22 +221,31 @@ def _find_live_ports(host: str, ports: list[int], timeout: float) -> list[int]:
if remaining <= 0:
break
for key, _ in sel.select(timeout=remaining):
sock = sockets[key.data]
try:
# Data back means live; ECONNREFUSED (or any other socket
# error) means the port is closed/unusable — rule it out.
key.fileobj.recv(1)
sock.recv(1)
except OSError:
sel.unregister(key.fileobj)
sel.unregister(sock)
live = [key.data for key in sel.get_map().values()]
finally:
sel.close()
for sock in sockets.values():
try:
with contextlib.suppress(OSError):
sock.close()
except OSError:
pass
return _order_candidates(live)
# The sweep's ICMP-based verdict isn't reliable on every network path --
# issue #192 captured a segregated-VLAN device where it called three
# ports live that a concurrent nmap scan showed as closed, while the
# port nmap found genuinely open|filtered (49154, one of our historically
# confirmed ports) never showed up as live at all. Rather than trust a
# wrong "not live" verdict on a port we already have strong prior
# evidence for, always give the historically-confirmed ports a real
# handshake attempt too. Bounded cost: at most len(PREFERRED_PROBE_PORTS)
# extra handshakes, only when the sweep disagrees with the prior.
rescued = [p for p in PREFERRED_PROBE_PORTS if p in ports and p not in live]
return _order_candidates(live + rescued)
def _is_placeholder_serial(serial: str) -> bool:
@@ -205,18 +256,29 @@ def _is_placeholder_serial(serial: str) -> bool:
serial` check here (and the equivalent one in coordinator.py's
`_run_discovery`) doesn't catch it, and two such units get the same
config-entry unique_id / entity unique_ids and collide (issue #83).
Issue #189: the DA_WM_A51_20_COMMON (ARTIK051) laundry board family
reports a flash-unset sentinel instead -- every character the same
repeated hex digit (a washer and a dryer, two different physical
units, both reported the literal serialNum 'FFFFFFFFFFFFFFF') -- which
the 'nothing' check above doesn't catch either, so the second unit's
config flow aborted as already configured.
"""
return serial.strip().lower().startswith('nothing')
s = serial.strip()
if s.lower().startswith("nothing"):
return True
upper = s.upper()
return len(upper) >= 8 and len(set(upper)) == 1 and upper[0] in "0123456789ABCDEF"
def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict:
"""Fetch UUID, mint leaf cert, probe each port. Returns config entry data dict."""
import cbor2
from smartthings_local.protocol.dtls_session import DtlsCoapSession
from .registry.batch import parse_device0_batch
from .registry.by_type import (
for_device, for_device_by_model, for_device_by_resources,
)
from .registry.by_type import resolve as resolve_registry
from .registry.identity import read_identity
_LOGGER.debug("Fetching Samsung cloud UUID from %s", _SAMSUNG_CLOUD_HOST)
try:
@@ -237,17 +299,14 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict:
raise CannotConnect(f"Failed to mint leaf cert: {exc}") from exc
_LOGGER.debug("Leaf cert minted successfully")
candidates = _find_live_ports(
host, PROBE_PORT_RANGE, LIVENESS_PROBE_TIMEOUT_S
)
if not candidates:
# Every port in the range actively refused: there is no DTLS/CoAP
# listener on this host, so a handshake can't succeed. Fail now with a
# clear message instead of retrying doomed ports.
raise CannotConnect(
f"no live DTLS port found on {host} "
f"in {PROBE_PORT_RANGE[0]}-{PROBE_PORT_RANGE[-1]}"
)
candidates = _find_live_ports(host, PROBE_PORT_RANGE, LIVENESS_PROBE_TIMEOUT_S)
# No early "every port refused" fast-fail here: _find_live_ports always
# rescues PREFERRED_PROBE_PORTS (issue #192), so candidates is never
# empty as long as that table is non-empty and within PROBE_PORT_RANGE --
# both true today, which made this branch permanently unreachable. A
# genuinely dead host now fails via the handshake loop's own error below,
# which carries the actual per-port timeout/refusal reason instead of a
# generic "no live port found" message.
_LOGGER.debug("Live DTLS port candidates on %s: %s", host, candidates)
last_exc = None
@@ -255,45 +314,34 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict:
sess = None
try:
sess = DtlsCoapSession(
host, port,
host,
port,
cert_pem=fullchain_pem,
key_pem=leaf_key_pem,
)
sess.connect()
sess.start_reader()
code, payload = sess.get(['device', '0'], timeout=PROBE_GET_TIMEOUT_S)
code, payload = sess.get(["device", "0"], timeout=PROBE_GET_TIMEOUT_S)
if code != 0x45 or not payload:
raise CannotConnect(f"port {port}: unexpected code {code:#04x}")
body = cbor2.loads(payload)
resources = (
parse_device0_batch(body) if isinstance(body, list) else {}
)
serial = (
resources
.get('/information/vs/0', {})
.get('x.com.samsung.da.serialNum', '')
)
resources = parse_device0_batch(body) if isinstance(body, list) else {}
serial = resources.get("/information/vs/0", {}).get("x.com.samsung.da.serialNum", "")
if not serial or _is_placeholder_serial(serial):
serial = f"{host}:{port}"
one_ui_version = (
resources
.get('/otninformation/vs/0', {})
.get('swVersionInfo', {})
.get('oneUiVersion', '')
)
info_resource = resources.get('/information/vs/0', {})
recognized_registry = (
for_device(one_ui_version) if one_ui_version else None
) or for_device_by_model(
info_resource.get('x.com.samsung.da.modelNum', ''),
info_resource.get('x.com.samsung.da.description', ''),
) or for_device_by_resources(resources)
# /oic/d's device type (read_identity) is the primary detection
# signal when a board populates it -- see registry/by_type's
# resolve(). read_identity is defensive on every GET it makes, so
# a device that doesn't answer /oic/p or /oic/d just yields an
# empty device_types tuple here, falling through to the model-
# string/resource-signature detection resolve() already did.
identity = read_identity(sess, None)
recognized_registry = resolve_registry(resources, device_types=identity.device_types)
return {
"port": port,
"serial": serial,
"leaf_cert_pem": fullchain_pem,
"leaf_key_pem": leaf_key_pem,
"one_ui_version": one_ui_version,
"device_type_recognized": recognized_registry is not None,
}
except CannotConnect:
@@ -303,10 +351,8 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict:
_LOGGER.debug("port %d failed: %s", port, exc)
finally:
if sess is not None:
try:
with contextlib.suppress(Exception):
sess.close()
except Exception:
pass
raise CannotConnect(f"no port responded on {host}: {last_exc}")
@@ -326,22 +372,20 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
) -> LocalThingsOptionsFlow:
return LocalThingsOptionsFlow()
def _create_entry(self, info: dict) -> FlowResult:
def _create_entry(self, info: dict) -> ConfigFlowResult:
return self.async_create_entry(
title=f"Samsung Appliance ({self._host})",
data={
CONF_HOST: self._host,
CONF_PORT: info["port"],
CONF_CA_CERT_PEM: self._ca_cert_pem,
CONF_CA_KEY_PEM: self._ca_key_pem,
CONF_HOST: self._host,
CONF_PORT: info["port"],
CONF_CA_CERT_PEM: self._ca_cert_pem,
CONF_CA_KEY_PEM: self._ca_key_pem,
CONF_LEAF_CERT_PEM: info["leaf_cert_pem"],
CONF_LEAF_KEY_PEM: info["leaf_key_pem"],
CONF_LEAF_KEY_PEM: info["leaf_key_pem"],
},
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
async def async_step_user(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
existing = self.hass.config_entries.async_entries(DOMAIN)
has_creds = bool(existing)
@@ -351,10 +395,10 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self._host = user_input[CONF_HOST].strip()
if has_creds:
self._ca_cert_pem = existing[0].data[CONF_CA_CERT_PEM]
self._ca_key_pem = existing[0].data[CONF_CA_KEY_PEM]
self._ca_key_pem = existing[0].data[CONF_CA_KEY_PEM]
else:
self._ca_cert_pem = user_input[CONF_CA_CERT_PEM].strip()
self._ca_key_pem = user_input[CONF_CA_KEY_PEM].strip()
self._ca_key_pem = user_input[CONF_CA_KEY_PEM].strip()
try:
info = await self.hass.async_add_executor_job(
@@ -382,11 +426,13 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
schema = vol.Schema({vol.Required(CONF_HOST): _TEXT})
step_id = "user_reuse"
else:
schema = vol.Schema({
vol.Required(CONF_HOST): _TEXT,
vol.Required(CONF_CA_CERT_PEM): _MULTILINE,
vol.Required(CONF_CA_KEY_PEM): _MULTILINE,
})
schema = vol.Schema(
{
vol.Required(CONF_HOST): _TEXT,
vol.Required(CONF_CA_CERT_PEM): _MULTILINE,
vol.Required(CONF_CA_KEY_PEM): _MULTILINE,
}
)
step_id = "user"
return self.async_show_form(
@@ -397,37 +443,20 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
async def async_step_user_reuse(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
) -> ConfigFlowResult:
"""Handle the localized host-only form for additional appliances."""
return await self.async_step_user(user_input)
async def async_step_confirm_unknown_type(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
) -> ConfigFlowResult:
"""Shown only when the probe already knows the device type is unrecognized."""
if user_input is not None:
assert self._pending_info is not None
return self._create_entry(self._pending_info)
if not self._pending_info["one_ui_version"]:
return await self.async_step_confirm_unknown_type_no_version()
return self.async_show_form(
step_id="confirm_unknown_type",
data_schema=vol.Schema({}),
description_placeholders={
"one_ui_version": self._pending_info["one_ui_version"],
},
)
async def async_step_confirm_unknown_type_no_version(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Confirm an unknown appliance that did not report oneUiVersion."""
if user_input is not None:
return self._create_entry(self._pending_info)
return self.async_show_form(
step_id="confirm_unknown_type_no_version",
data_schema=vol.Schema({}),
)
@@ -454,9 +483,7 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow):
def _coordinator(self):
return self.hass.data.get(DOMAIN, {}).get(self.config_entry.entry_id)
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
async def async_step_init(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
return self.async_show_menu(
step_id="init",
menu_options=["settings", "debug_write"],
@@ -464,25 +491,32 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow):
async def async_step_settings(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
) -> ConfigFlowResult:
if user_input is not None:
return self.async_create_entry(data=user_input)
return self.async_show_form(
step_id="settings",
data_schema=vol.Schema({
vol.Required(
CONF_BYPASS_REMOTE_CONTROL,
default=self.config_entry.options.get(
CONF_BYPASS_REMOTE_CONTROL, False
),
): bool,
}),
data_schema=vol.Schema(
{
vol.Required(
CONF_BYPASS_REMOTE_CONTROL,
default=self.config_entry.options.get(CONF_BYPASS_REMOTE_CONTROL, False),
): bool,
vol.Required(
CONF_FINISH_TIME_HYSTERESIS_MINUTES,
default=self.config_entry.options.get(
CONF_FINISH_TIME_HYSTERESIS_MINUTES,
DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES,
),
): _HYSTERESIS_MINUTES,
}
),
)
async def async_step_debug_write(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
) -> ConfigFlowResult:
coord = self._coordinator()
if coord is None:
return self.async_abort(reason="not_loaded")
@@ -494,38 +528,48 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow):
hrefs = sorted(coord.last_resources.keys())
return self.async_show_form(
step_id="debug_write",
data_schema=vol.Schema({
vol.Required("href"): SelectSelector(SelectSelectorConfig(
options=hrefs,
custom_value=True,
mode=SelectSelectorMode.DROPDOWN,
)),
}),
data_schema=vol.Schema(
{
vol.Required("href"): SelectSelector(
SelectSelectorConfig(
options=hrefs,
custom_value=True,
mode=SelectSelectorMode.DROPDOWN,
)
),
}
),
)
def _show_debug_edit_form(
self, href: str, current: dict, errors: dict[str, str], payload,
) -> FlowResult:
self,
href: str,
current: dict,
errors: dict[str, str],
payload,
) -> ConfigFlowResult:
return self.async_show_form(
step_id="debug_edit",
data_schema=vol.Schema({
vol.Required(
"payload", default=(payload if payload is not None else {}),
): ObjectSelector(),
}),
data_schema=vol.Schema(
{
vol.Required(
"payload",
default=(payload if payload is not None else {}),
): ObjectSelector(),
}
),
errors=errors,
description_placeholders={
"href": href,
"current_value": (
json.dumps(current, indent=2, ensure_ascii=False)
if current else "{}"
json.dumps(current, indent=2, ensure_ascii=False) if current else "{}"
),
},
)
async def async_step_debug_edit(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
) -> ConfigFlowResult:
coord = self._coordinator()
if coord is None:
return self.async_abort(reason="not_loaded")
@@ -541,11 +585,9 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow):
)
try:
code, new_rep = await coord.async_raw_write(href, payload)
except Exception: # noqa: BLE001 - surfaced to the user below
except Exception:
_LOGGER.exception("debug raw write failed for %s", href)
return self._show_debug_edit_form(
href, current, {"base": "write_failed"}, payload
)
return self._show_debug_edit_form(href, current, {"base": "write_failed"}, payload)
self._debug_result = (code, new_rep)
return await self.async_step_debug_result()
@@ -553,22 +595,19 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow):
async def async_step_debug_result(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
) -> ConfigFlowResult:
code, new_rep = self._debug_result or (0, {})
return self.async_show_menu(
step_id="debug_result",
menu_options=["debug_write", "finish"],
description_placeholders={
"code": f"{code >> 5}.{code & 0x1f:02d} ({code:#04x})",
"code": f"{code >> 5}.{code & 0x1F:02d} ({code:#04x})",
"new_value": (
json.dumps(new_rep, indent=2, ensure_ascii=False)
if new_rep else "{}"
json.dumps(new_rep, indent=2, ensure_ascii=False) if new_rep else "{}"
),
},
)
async def async_step_finish(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
async def async_step_finish(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
# Close the flow without altering saved options.
return self.async_create_entry(data=dict(self.config_entry.options))
+26 -7
View File
@@ -1,16 +1,24 @@
DOMAIN = "localthings"
PLATFORMS = [
"sensor", "binary_sensor", "switch", "number", "select", "button",
"time", "climate", "fan",
"sensor",
"binary_sensor",
"switch",
"number",
"select",
"button",
"time",
"climate",
"fan",
"water_heater",
]
CONF_HOST = "host"
CONF_PORT = "port"
CONF_CA_CERT_PEM = "ca_cert_pem"
CONF_CA_KEY_PEM = "ca_key_pem"
CONF_HOST = "host"
CONF_PORT = "port"
CONF_CA_CERT_PEM = "ca_cert_pem"
CONF_CA_KEY_PEM = "ca_key_pem"
CONF_LEAF_CERT_PEM = "leaf_cert_pem"
CONF_LEAF_KEY_PEM = "leaf_key_pem"
CONF_LEAF_KEY_PEM = "leaf_key_pem"
# Options-flow key (entry.options, not entry.data): lets a user override the
# device-wide remote-control-off write block for a specific device (issue
@@ -22,6 +30,17 @@ CONF_LEAF_KEY_PEM = "leaf_key_pem"
# read, so devices this doesn't apply to see no behavior change.
CONF_BYPASS_REMOTE_CONTROL = "bypass_remote_control_lock"
# Options-flow key: minimum change (in minutes) required before a
# hysteresis-gated timestamp sensor (currently just finish_time) is allowed
# to report a new value. Devices commonly revise their own remaining-time
# estimate by a minute or two throughout a cycle, and finish_time = now() +
# remaining drifts by the poll interval between those revisions -- both push
# a fresh state (and a recorder/logbook entry) far more often than the
# estimate is meaningfully different. 0 disables the gate (every computed
# change is reported, today's behavior).
CONF_FINISH_TIME_HYSTERESIS_MINUTES = "finish_time_hysteresis_minutes"
DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES = 3
# The DTLS/CoAP local API binds somewhere in this ephemeral range; which port
# depends on firmware. Newer builds answer on 49154/49155, but older ones have
# been seen as low as 49153, so we sweep the whole range for a live UDP port
+526 -106
View File
@@ -1,7 +1,9 @@
"""Coordinator for Local Things integration."""
from __future__ import annotations
import asyncio
import contextlib
import ipaddress
import logging
import threading
@@ -11,45 +13,57 @@ from datetime import timedelta
from typing import Any
import cbor2
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.helpers.device_registry import DeviceInfo
from smartthings_local.protocol.dtls_session import DtlsCoapSession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from smartthings_local.ocf.state_cache import StateCache
from smartthings_local.protocol.dtls_session import DtlsCoapSession
from .const import (
CONF_BYPASS_REMOTE_CONTROL,
CONF_HOST,
CONF_LEAF_CERT_PEM,
CONF_LEAF_KEY_PEM,
CONF_PORT,
DEVICE_SUPPORT_ISSUE_URL,
DOMAIN,
DTLS_LOCAL_PORT_BASE,
SUMMARY_INTERVAL_S,
)
from .observe import GRACE_PERIOD_S, MODE_OBSERVE, MODE_POLL, ObserveManager
from .registry import CAPABILITIES
from .registry.adapter import flatten
from .registry.batch import parse_device0_batch
from .registry.by_type import for_device, for_device_by_model, for_device_by_resources
from .registry.by_type import resolve as resolve_registry
from .registry.capabilities.common import (
merge_items_field,
merge_options_field,
remote_control_enabled,
remote_control_required_for_write,
)
from .registry.discovery import discover, BoundEntity
from .registry import CAPABILITIES
from .registry.adapter import flatten
from .registry.identity import read_identity, DeviceIdentity
from .observe import ObserveManager, MODE_OBSERVE, MODE_POLL, GRACE_PERIOD_S
from .const import (
DOMAIN, CONF_HOST, CONF_PORT, CONF_LEAF_CERT_PEM, CONF_LEAF_KEY_PEM,
CONF_BYPASS_REMOTE_CONTROL, DEVICE_SUPPORT_ISSUE_URL, SUMMARY_INTERVAL_S,
DTLS_LOCAL_PORT_BASE,
from .registry.discovery import BoundEntity
from .registry.identity import DeviceIdentity, read_identity
from .registry.subdevices import (
Subdevice,
canonical_view,
discover_partitioned,
enumerate_subdevices,
normalize_seed_batch,
)
_LOGGER = logging.getLogger(__name__)
_SEED_PATH = ['device', '0']
_SEED_PATH = ["device", "0"]
class _NoOpDescriptor:
"""StateCache requires a descriptor with an on_observation hook. This
integration doesn't use per-capability observation hooks, so this is a
deliberate no-op, not a placeholder for missing functionality."""
def on_observation(self, state: dict, href: str, rep: dict) -> None:
return None
@@ -88,12 +102,26 @@ def _is_placeholder_serial(serial: str) -> bool:
serial` check below doesn't catch it, and `device_serial` feeds both
the HA device-registry identifier and every entity's unique_id
(entity.py), so two such units on the same install silently collide
and the second one's entities get dropped (issue #83). Mirrors the
identical helper in config_flow.py's `_probe_and_validate` -- kept
separate rather than imported to avoid pulling the config-flow module
into the runtime coordinator's import graph for a two-line check.
and the second one's entities get dropped (issue #83).
Issue #189: the DA_WM_A51_20_COMMON (ARTIK051) laundry board family
reports a flash-unset sentinel instead -- every character the same
repeated hex digit (a washer and a dryer, two different physical
units, both reported the literal serialNum 'FFFFFFFFFFFFFFF') -- which
the 'nothing' check above doesn't catch either, so two such units
collided on the config-entry unique_id and the second couldn't be
added at all.
Mirrors the identical helper in config_flow.py's `_probe_and_validate`
-- kept separate rather than imported to avoid pulling the config-flow
module into the runtime coordinator's import graph for a two-line
check.
"""
return serial.strip().lower().startswith('nothing')
s = serial.strip()
if s.lower().startswith("nothing"):
return True
upper = s.upper()
return len(upper) >= 8 and len(set(upper)) == 1 and upper[0] in "0123456789ABCDEF"
class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
@@ -153,6 +181,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
super().__init__(
hass,
self._log,
config_entry=entry,
name=f"{DOMAIN}_{entry.data[CONF_HOST]}",
update_interval=timedelta(seconds=SUMMARY_INTERVAL_S),
)
@@ -161,6 +190,40 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._identity: DeviceIdentity | None = None
self._discovered = False
self.bound = []
# Sibling indoor subdevices discovered on this connection (issue
# #177) -- candidates set once, at first discovery, by
# _enumerate_subdevices_blocking; narrowed by _run_discovery to the
# ones that actually produced live primary state (see
# subdevices.discover_partitioned). MAIN itself is never in this list
# (see subdevices.canonical_view's docstring for why that's safe):
# it's the *other* subdevices sharing this DTLS session, if any.
self.subdevices: list[Subdevice] = []
# Candidates _run_discovery's gate rejected (an unused SmartThings
# slot that still answers its seed, e.g. the issue #177 reporter's
# /device/2) -- surfaced in diagnostics alongside the materialized
# ones so a report shows what was found and why it didn't become an
# entity.
self._skipped_subdevices: list = []
# Those rejected candidates' raw reps, kept aside for diagnostics
# only (see _live_subdevice_resources). They are deliberately not in the
# state cache: nothing polls them again, so anything applied there
# would sit frozen at its first-discovery value while looking as
# live as every other href in `last_resources`.
self._skipped_subdevice_resources: dict[str, dict] = {}
# /multidevice/vs/0's rep, if this board answers it -- a plain
# subdevice count that corroborates the liveness gate without deciding it.
# Deliberately outside `resources`; see _enumerate_subdevices_blocking.
self._multidevice: dict = {}
# What each subdevice probe found, keyed by the seed href attempted --
# surfaced in diagnostics so a report can tell "checked, nothing
# there" apart from "never checked" (the same posture the
# speculative-probe code this replaced documented in identity.py).
self._subdevice_probes: dict[str, bool] = {}
# canonical_resources() memo, keyed by (subdevice.kind, subdevice.key).
# Invalidated in _on_cache_changed -- climate.py reads this on every
# property access (is_legacy_board and friends), so it must not
# rebuild an O(hrefs) view from scratch on every single property.
self._canonical_cache: dict[tuple[str, str], dict] = {}
self._cache = StateCache(_NoOpDescriptor())
self._cache.set_on_change(self._on_cache_changed)
self._observe = ObserveManager(self._cache, logger=self._log)
@@ -177,7 +240,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._hot_hrefs: list[str] = []
self._warm_hrefs: list[str] = []
self.device_type_name: str | None = None
self.one_ui_version: str = ''
self.one_ui_version: str = ""
self._consecutive_poll_timeouts = 0
self._unbound_hrefs: list[str] = []
self._reconnect_times: list[float] = []
@@ -197,19 +260,91 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
direct O(1) cache lookup."""
return self._cache.get(href) or {}
def canonical_resources(self, subdevice: Subdevice) -> dict[str, dict]:
"""`subdevice`'s own view of the live snapshot, rewritten into the
canonical hrefs (issue #177) the registry/platforms are written
against -- see subdevices.canonical_view. A platform property that
needs the *whole* resources dict (as opposed to one href via
`resource()`/`last_resources.get(href)`) must use this instead of
`last_resources`, or a sibling subdevice's own `/mode/vs/1` would leak
into MAIN's canonical `/mode/vs/0` view (or vice versa) under
exists_fn/is_legacy_board-style checks that scan the whole dict.
Memoized per cache generation: climate.py calls this on every
property read (is_legacy_board and friends), and building it is
O(hrefs) -- _on_cache_changed clears the memo whenever the
snapshot actually changes, not on every property access.
"""
view_key = (subdevice.kind, subdevice.key)
cached = self._canonical_cache.get(view_key)
if cached is not None:
return cached
view = canonical_view(subdevice, self._cache.snapshot(), self.subdevices)
self._canonical_cache[view_key] = view
return view
def device_info_for(self, subdevice: Subdevice) -> DeviceInfo:
"""DeviceInfo for one logical subdevice sharing this connection
(issue #177) -- the master's own (unchanged) device_info for MAIN, or
a linked child device for a discovered subdevice.
Identifiers derive from the *master's* serial (device_serial) plus
this subdevice's stable key, never from whatever serial the
subdevice itself reports (or fails to) -- deterministic across
reconnects whether or not this subdevice's own identity resource
(/information/vs/<n>, or /<id>/information/vs/0) answered on the
poll that first created the HA device. `serial_number` is set from
that resource when present anyway -- it's informational, not an
identifier.
"""
if subdevice.kind == "main":
return self.device_info
info = self.canonical_resources(subdevice).get("/information/vs/0", {})
model_num = info.get("x.com.samsung.da.modelNum", "")
model = model_num.split("|", 1)[0] if model_num else ""
serial = info.get("x.com.samsung.da.serialNum") or None
base_name = self.device_info.get("name") or "Samsung Appliance"
if model:
label = model.replace("_", " ").title()
else:
# This poll never got (or never will get) the subdevice's own
# identity resource -- fall back to a generic per-subdevice label
# rather than leaving the device unnamed. 'Subdevice <n>' only
# makes sense for an indexed subdevice (the key is a small
# ordinal); UUID-prefixed subdevices are never more than one per
# connection today, so there's no ordinal to show.
label = (
f"Subdevice {subdevice.key}"
if subdevice.kind == "indexed"
else "Secondary Subdevice"
)
return DeviceInfo(
identifiers={(DOMAIN, f"{self.device_serial}_{subdevice.key}")},
via_device=(DOMAIN, self.device_serial),
name=f"{base_name} {label}",
manufacturer=self.device_info.get("manufacturer") or "Samsung",
model=model or None,
serial_number=serial,
)
@property
def observe_mode(self) -> str:
return self._observe.mode
def _connect_session(self) -> None:
host = self._entry.data[CONF_HOST]
port = self._entry.data[CONF_PORT]
host = self._entry.data[CONF_HOST]
port = self._entry.data[CONF_PORT]
cert_pem = self._entry.data[CONF_LEAF_CERT_PEM]
key_pem = self._entry.data[CONF_LEAF_KEY_PEM]
key_pem = self._entry.data[CONF_LEAF_KEY_PEM]
sess = DtlsCoapSession(host, port, cert_pem=cert_pem, key_pem=key_pem,
on_notification=self._observe.on_notification,
local_port=_local_source_port(host))
sess = DtlsCoapSession(
host,
port,
cert_pem=cert_pem,
key_pem=key_pem,
on_notification=self._observe.on_notification,
local_port=_local_source_port(host),
)
sess.connect()
sess.start_reader()
self._session = sess
@@ -224,10 +359,8 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
sess = self._session
self._session = None
if sess is not None:
try:
with contextlib.suppress(Exception):
sess.close()
except Exception:
pass
async def async_close(self) -> None:
if self._subpoll_task is not None:
@@ -245,6 +378,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
into a single push instead of one hass.add_job per href."""
if not changed:
return
self._canonical_cache.clear()
with self._push_pending_lock:
if self._push_pending:
return
@@ -273,6 +407,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if self._session is None:
self._connect_session()
sess = self._session
assert sess is not None
try:
# A slow device can still be mid-transfer (block 8, block 11)
# when a tighter deadline cuts it off — that's a poll that
@@ -292,7 +427,89 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
body = cbor2.loads(payload)
except Exception as e:
raise RuntimeError(f"poll cbor decode: {e}") from e
return parse_device0_batch(body) if isinstance(body, list) else {}
result = parse_device0_batch(body) if isinstance(body, list) else {}
# Refresh every already-enumerated sibling subdevice's seed collection
# on this same summary poll (issue #177) -- without this, a subdevice's
# climate card would show only its enumeration-time snapshot forever.
for subdevice in self.subdevices:
result.update(self._poll_subdevice_seed(subdevice))
return result
def _poll_subdevice_seed(self, subdevice: Subdevice) -> dict[str, dict]:
"""GET one subdevice's seed Collection and return its batch,
normalized to real hrefs. A sibling failing to answer is a debug
log, never a failed poll -- the master must not go unavailable
because a sibling timed out or dropped off (e.g. the issue #177
reporter's /device/2, a SmartThings-unused component that may not
always respond). Blocking -- called from _poll_once, already in
executor."""
sess = self._session
if sess is None:
return {}
if subdevice.flat_hrefs:
return self._poll_subdevice_flat_hrefs(subdevice, sess)
try:
code, payload = sess.get(list(subdevice.seed_path), timeout=10.0)
if code == 0x45 and payload:
body = cbor2.loads(payload)
if isinstance(body, list):
return normalize_seed_batch(subdevice, parse_device0_batch(body))
except Exception as e:
self._log.debug("subdevice %s seed poll failed: %s", subdevice.key, e)
return {}
def _poll_subdevice_flat_hrefs(self, subdevice: Subdevice, sess) -> dict[str, dict]:
"""Re-poll a flat-mode prefixed subdevice's hrefs individually
(issue #205) -- it has no Collection endpoint to batch-refresh
through (see enumerate_subdevices' fallback), so each canonical
href assumed at enumeration time (cloned from the master's own
state, issue #265 -- not yet confirmed live under this subdevice's
own prefix) gets its own GET under the subdevice's prefix, which is
what turns the clone into this sibling's real value once it
answers. A href failing to answer this cycle just drops out of the
result and leaves whatever value is already cached (the clone, until
corrected) in place -- same "never let a sibling's flakiness fail
the master's poll" posture as the Collection path above.
Takes `sess` from the caller (already None-checked there) rather
than re-reading self._session -- async_close() can null that
without holding _session_lock, and pace()/get() both need a live
session on every iteration, not just the first.
Skips any href already covered by the hot/warm sub-poll tiers
(self._hot_hrefs/_warm_hrefs, in the same actual/on-the-wire form
this method builds) -- those are already refreshed every 3s/6s by
_run_subpolls, strictly more current than this once-per-summary-poll
pass could offer, so re-fetching them here would only add GETs
without adding freshness. A subdevice with many assumed hrefs
(unlike a Collection batch, which is always one GET regardless of
count) is otherwise a summary-poll cost that scales with its href
count."""
skip = set(self._hot_hrefs) | set(self._warm_hrefs)
result: dict[str, dict] = {}
first = True
for href in subdevice.flat_hrefs:
actual = subdevice.to_actual(href)
if actual in skip:
continue
try:
if not first:
sess.pace()
first = False
path = actual.strip("/").split("/")
code, payload = sess.get(path, timeout=10.0)
if code == 0x45 and payload:
rep = cbor2.loads(payload)
if isinstance(rep, dict):
result[actual] = rep
except Exception as e:
self._log.debug(
"subdevice %s flat href %s poll failed: %s",
subdevice.key,
href,
e,
)
return result
def _poll_hrefs_blocking(self, hrefs: list[str]) -> dict[str, dict]:
"""GET individual hrefs sequentially. Does not reconnect on failure. Blocking."""
@@ -305,12 +522,12 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._session.pace()
first = False
try:
path = [s for s in href.strip('/').split('/')]
path = href.strip("/").split("/")
code, payload = self._session.get(path, timeout=10.0)
if code == 0x45 and payload:
rep = cbor2.loads(payload)
if isinstance(rep, dict):
self._observe.apply(href, rep, source='poll')
self._observe.apply(href, rep, source="poll")
results[href] = rep
except Exception as e:
self._log.debug("sub-poll %s: %s", href, e)
@@ -336,14 +553,12 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if not hot and not warm:
return
step = self._SUBPOLL_STEP_S
for i in range(1, 10): # slots 1..9 (T+3 s … T+27 s)
for i in range(1, 10): # slots 1..9 (T+3 s … T+27 s)
await asyncio.sleep(step)
hrefs = list(hot) + (list(warm) if i % 2 == 0 else [])
async with self._session_lock:
try:
await self.hass.async_add_executor_job(
self._poll_hrefs_blocking, hrefs
)
await self.hass.async_add_executor_job(self._poll_hrefs_blocking, hrefs)
except Exception as e:
self._log.debug("sub-poll batch failed: %s", e)
@@ -351,44 +566,181 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# Discovery (runs once on first successful poll)
# ------------------------------------------------------------------
def _run_discovery(self, resources: dict[str, dict]) -> None:
one_ui = (resources.get('/otninformation/vs/0', {})
.get('swVersionInfo', {})
.get('oneUiVersion', ''))
self.one_ui_version = one_ui
info = resources.get('/information/vs/0', {})
reg = for_device(one_ui) if one_ui else None
if reg is None:
reg = for_device_by_model(
info.get('x.com.samsung.da.modelNum', ''),
info.get('x.com.samsung.da.description', ''),
def _enumerate_subdevices_blocking(self, resources: dict[str, dict]) -> dict[str, dict]:
"""One-time (first discovery only) probe for sibling indoor subdevices
sharing this connection (issue #177) -- see
registry.subdevices.enumerate_subdevices for the two detection
patterns. Blocking -- runs in executor, under the session lock
(shares the same DTLS session _poll_once just used this cycle).
Sets self.subdevices to every *candidate* the probes turned up
(self._subdevice_probes as a side effect too) and returns `resources`
merged with whatever each candidate's seed returned, so this cycle's
_run_discovery sees every candidate's state without a second poll
round trip. `_run_discovery` is what narrows self.subdevices down to
the ones that are actually live (see discover_partitioned) -- this
method doesn't know how to tell an unused SmartThings slot (the
issue #177 reporter's /device/2) from a real sibling, only that
something answered.
"""
if self._session is None:
self._connect_session()
sess = self._session
if sess is None:
return resources
oic_res = self._identity.raw.get("/oic/res", []) if self._identity else []
probes: dict[str, bool] = {}
subdevices, extra = enumerate_subdevices(
sess,
resources,
oic_res,
probe_log=lambda href, found: probes.__setitem__(href, found),
)
self.subdevices = subdevices
self._subdevice_probes = probes
# /multidevice/vs/0 is corroborating metadata, not appliance state,
# and it is probed on *every* device -- so it must not join the
# returned resources dict. Two things go wrong if it does. It would
# reach discovery on families whose registry doesn't ignore that
# href (only the AC one does), binding to nothing and raising a
# spurious "incomplete capability coverage" repair for every washer
# or fridge whose firmware happens to answer it. And it is fetched
# once here and never polled again, so applying it to the state
# cache would freeze it there exactly like a rejected candidate's
# reps (see _live_subdevice_resources). Kept aside for diagnostics and
# for the numofsubdevice cross-check in _run_discovery instead.
self._multidevice = extra.pop("/multidevice/vs/0", {})
return {**resources, **extra}
def _live_subdevice_resources(self, resources: dict[str, dict]) -> dict[str, dict]:
"""`resources` minus every href belonging to a candidate subdevice the
liveness gate rejected (issue #177).
Called once, between _run_discovery and the first cache apply, so a
rejected slot's reps are seen by the gate and then dropped rather
than frozen into the cache forever -- see the call site. The reps
themselves are kept in _skipped_subdevice_resources for diagnostics,
which is the only thing that still wants them.
"""
if not self._skipped_subdevices:
return resources
kept: dict[str, dict] = {}
skipped: dict[str, dict] = {}
for href, rep in resources.items():
bucket = (
skipped
if any(skip.subdevice.owns(href) for skip in self._skipped_subdevices)
else kept
)
if reg is None:
reg = for_device_by_resources(resources)
bucket[href] = rep
self._skipped_subdevice_resources = skipped
return kept
def _run_discovery(self, resources: dict[str, dict]) -> None:
# Reported for diagnostics only -- it names the firmware generation
# ('7.0 Air conditioner' is Tizen Lite), which is useful when triaging
# an issue. It does not route: only a minority of hardware reports it
# at all, and every device that does is already typed by its modelNum.
self.one_ui_version = (
resources.get("/otninformation/vs/0", {})
.get("swVersionInfo", {})
.get("oneUiVersion", "")
)
info = resources.get("/information/vs/0", {})
unbound: list[str] = []
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)
self.device_type_name = reg.name
hot, warm = set(), set()
def _tier_log(href: str, tier: str) -> None:
if tier == "hot":
hot.add(href)
elif tier == "warm":
warm.add(href)
model_num = info.get("x.com.samsung.da.modelNum", "")
description = info.get("x.com.samsung.da.description", "")
# Partitioned discovery (issue #177): the main pass binds every href
# owned by no subdevice; one further pass per *candidate* subdevice
# binds its own canonical view, resolving its own device type from
# its own /information/vs/0 when it reports one and falling back to
# the master's registry otherwise. See subdevices.discover_partitioned
# -- it also gates each candidate down to whether it actually
# produced live primary state (the issue #177 reporter's /device/2,
# an unused SmartThings slot, answers its seed but never does), so
# self.subdevices below is narrowed to the ones that passed, not
# every candidate _enumerate_subdevices_blocking found. For a device
# with no candidates (self.subdevices == []) this is exactly the
# single discover() call this method used to make.
bound, device_type_name, materialized, skipped = discover_partitioned(
resources,
self.subdevices,
resolve_registry,
CAPABILITIES,
log=unbound.append,
tier_log=_tier_log,
oic_device_types=self._identity.device_types if self._identity else (),
)
self.subdevices = materialized
self._skipped_subdevices = skipped
for skip in skipped:
self._log.info(
"subdevice %s (%s) answered its seed but produced no live "
"primary state; not materialized (hrefs=%s)",
skip.subdevice.key,
skip.subdevice.kind,
list(skip.hrefs),
)
# Corroborating signal, not a gate (DESIGN-177.md section 4):
# /multidevice/vs/0's numofsubdevice is a plain count the issue
# #177 reporter's board reports independently of the liveness gate
# above. Log, don't raise, on a disagreement -- only this one board
# family is known to expose the resource at all, so a mismatch is a
# "look into this" signal for triage, not proof either side is
# wrong.
numofsubdevice = self._multidevice.get("x.com.samsung.da.numofsubdevice")
if numofsubdevice is not None:
try:
reported = int(numofsubdevice)
except (TypeError, ValueError):
reported = None
subdevice_count = len(materialized) + 1 # +1 for the master itself
if reported is not None and reported != subdevice_count:
self._log.debug(
"/multidevice/vs/0 reports numofsubdevice=%r but %d "
"subdevice(s) materialized (including the master)",
numofsubdevice,
subdevice_count,
)
if device_type_name is not None:
self._log.debug("device type: %s (modelNum=%r)", device_type_name, model_num)
else:
self._log.warning("unknown device type oneUiVersion=%r; using common caps", one_ui)
bound = discover(resources, CAPABILITIES, log=unbound.append)
self.device_type_name = None
# All three: detection reads each of them (oic device type, then
# board token, then consumer-model code), and this line is what a
# user pastes into an issue -- modelNum alone doesn't identify a
# washer or dryer, and device_types is often empty even when
# populated hardware exists for a type we don't map yet.
self._log.warning(
"unknown device type modelNum=%r description=%r device_types=%r; using common caps",
model_num,
description,
self._identity.device_types if self._identity else (),
)
self.device_type_name = device_type_name
self.bound = bound
self._unbound_hrefs = unbound
serial = info.get('x.com.samsung.da.serialNum', '')
serial = info.get("x.com.samsung.da.serialNum", "")
if not serial or _is_placeholder_serial(serial):
serial = self._entry.data[CONF_HOST]
self.device_serial = serial
ident = self._identity
device_type = reg.name.replace('_', ' ').title() if reg else 'Appliance'
model_num = info.get('x.com.samsung.da.modelNum', '')
model = model_num.split('|', 1)[0] if model_num else (ident.model if ident else '')
name = f"Samsung {device_type} ({model})" if model else f"Samsung {device_type}"
mfr = (ident.manufacturer if ident else '') or 'Samsung'
device_type = (
device_type_name.replace("_", " ").title() if device_type_name else "Appliance"
)
model = model_num.split("|", 1)[0] if model_num else (ident.model if ident else "")
name = f"Samsung {device_type} ({model})" if model else f"Samsung {device_type}"
mfr = (ident.manufacturer if ident else "") or "Samsung"
self.device_info = DeviceInfo(
identifiers={(DOMAIN, serial)},
@@ -396,26 +748,26 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
manufacturer=mfr,
model=model,
)
self._update_coverage_gap_issue(reg is None, unbound, name)
self._update_coverage_gap_issue(device_type_name 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)
self._discovered = True
self._log.info(
"discovered %d entities (serial=%s) hot=%s warm=%s",
len(bound), serial, self._hot_hrefs, self._warm_hrefs,
"discovered %d entities (serial=%s) hot=%s warm=%s subdevices=%s",
len(bound),
serial,
self._hot_hrefs,
self._warm_hrefs,
[su.key for su in self.subdevices],
)
def _update_coverage_gap_issue(
self, unknown_type: bool, unbound_hrefs: list[str], device_name: str,
self,
unknown_type: bool,
unbound_hrefs: list[str],
device_name: str,
) -> None:
"""Raise or clear a Repairs issue when capability coverage is incomplete.
@@ -427,7 +779,9 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
issue_id = f"device_gap_{self._entry.entry_id}"
if unknown_type or unbound_hrefs:
ir.async_create_issue(
self.hass, DOMAIN, issue_id,
self.hass,
DOMAIN,
issue_id,
is_fixable=False,
severity=ir.IssueSeverity.WARNING,
translation_key="device_gap",
@@ -451,7 +805,9 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if sess is None:
return
await self.hass.async_add_executor_job(
self._observe.try_enter_observe_mode, sess, hrefs,
self._observe.try_enter_observe_mode,
sess,
hrefs,
self._OBSERVE_GRACE_PERIOD_S,
)
@@ -474,7 +830,21 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
normal on a flaky device; a run of them is a real problem. Any
other exception (a `ConnectionError`, an explicitly closed
session) is unambiguous and always reconnects immediately.
Never defers before the first successful discovery (issue #254).
Deferring is a *mid-session* judgement call — "keep the entities we
already have and try again next cycle" — which is only coherent once
there are entities to keep. Pre-discovery the same exception type
means something else entirely: `_poll_once` calls `_connect_session`,
so `connect()`'s own handshake timeout surfaces here as a
`TimeoutError` too, and that is a dead connection, not a slow
transfer. Deferring it returned an empty dict instead of raising,
which `DataUpdateCoordinator` counts as a successful first refresh —
and since platforms enumerate `bound` exactly once, the entry loaded
with zero entities and stayed that way until a manual reload.
"""
if not self._discovered:
return False
if not isinstance(e, TimeoutError):
return False
if self._observe.mode == MODE_OBSERVE and self._observe.recently_notified():
@@ -495,8 +865,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
warn window -- see _RECONNECT_WARN_WINDOW_S/_RECONNECT_WARN_THRESHOLD."""
now = time.monotonic()
self._reconnect_times = [
t for t in self._reconnect_times
if now - t < self._RECONNECT_WARN_WINDOW_S
t for t in self._reconnect_times if now - t < self._RECONNECT_WARN_WINDOW_S
]
self._reconnect_times.append(now)
return len(self._reconnect_times) >= self._RECONNECT_WARN_THRESHOLD
@@ -521,7 +890,8 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._log.debug(
"poll failed (%s), not yet treated as session "
"death; skipping this cycle: %s",
type(e).__name__, e,
type(e).__name__,
e,
)
return flatten(self.bound, self._cache.snapshot())
self._consecutive_poll_timeouts = 0
@@ -541,7 +911,15 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
except Exception as e2:
self._log.error("poll failed after reconnect: %s", e2)
snapshot = self._cache.snapshot()
if snapshot:
# `self._discovered` is the same precondition
# `_defer_reconnect_for` applies (issue #254): returning
# degraded-but-successful data is only meaningful once
# there are bound entities to carry it. Pre-discovery the
# cache happens to always be empty -- every apply() site
# is gated on post-discovery state -- so this arm is
# unreachable then, but that is a non-local accident
# across four call sites, not something to rely on.
if self._discovered and snapshot:
self._log.debug("Full error:", exc_info=e2)
return flatten(self.bound, snapshot)
raise UpdateFailed(f"poll failed after reconnect: {e2}") from e2
@@ -564,7 +942,37 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._observe.downgrade_to_poll()
just_downgraded_from_observe = True
source = 'sweep' if self._discovered else 'poll'
if not self._discovered:
# One-time (issue #177): find out whether this connection has
# sibling indoor subdevices before the first discovery pass, and
# fold their seed resources into this cycle's snapshot so
# discovery sees every subdevice's state on the very first poll
# rather than waiting a cycle. Runs under its own session-lock
# scope (the poll above already released the lock) since it
# shares the same DTLS session.
async with self._session_lock:
resources = await self.hass.async_add_executor_job(
self._enumerate_subdevices_blocking, resources
)
source = "sweep" if self._discovered else "poll"
first_cycle = not self._discovered
if first_cycle:
# Discovery runs *before* the apply loop below, not after it, so
# a rejected candidate's resources never reach the state cache
# at all (issue #177). Enumeration has to fetch every candidate's
# seed to evaluate the liveness gate, but only the subdevices that
# pass it are ever polled again -- applying the rest would freeze
# ~14 hrefs per rejected slot into the cache on this one cycle
# and leave them there forever, indistinguishable from live
# state in `last_resources` and in the diagnostics dump built
# from it. StateCache has no eviction, so the only way to keep
# them out is to not put them in. Safe to reorder: _run_discovery
# reads the dict passed to it and never the cache, and
# log_sweep_discrepancies below can't fire on a first cycle
# (observe mode is only ever attempted after discovery).
self._run_discovery(resources)
resources = self._live_subdevice_resources(resources)
sweep_mismatch = False
if self._observe.mode == MODE_OBSERVE:
# A sweep/cache mismatch never tears down a still-live OBSERVE
@@ -580,19 +988,22 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
for href, rep in resources.items():
self._observe.apply(href, rep, source=source)
if not self._discovered:
self._run_discovery(resources)
await self._attempt_observe_mode()
elif just_downgraded_from_observe:
if first_cycle or just_downgraded_from_observe:
await self._attempt_observe_mode()
elif self._observe.mode == MODE_POLL:
await self._maybe_retry_observe_mode()
# Schedule sub-polls for hot/warm hrefs between summary polls
# (no-op in observe-primary mode unless this cycle's sweep found a
# mismatch; _run_subpolls checks the mode/force).
# mismatch; _run_subpolls checks the mode/force). A background task,
# not async_create_task: this loop is self-limiting (cancelled and
# recreated every refresh cycle, see the cancel() above) and owned
# entirely by the coordinator, so it has no business being tracked by
# HA's own startup/shutdown sequencing -- async_create_task ties it
# in regardless, so a subpoll cycle in flight (up to ~27s,
# _SUBPOLL_STEP_S x 9 slots) delays both (issue #207).
if self._hot_hrefs or self._warm_hrefs:
self._subpoll_task = self.hass.async_create_task(
self._subpoll_task = self.hass.async_create_background_task(
self._run_subpolls(force=sweep_mismatch), name="localthings_subpoll"
)
@@ -602,8 +1013,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# Command dispatch (called by entity platforms in Task 5)
# ------------------------------------------------------------------
async def async_send_command(self, bound_entity: BoundEntity,
payload: Any) -> None:
async def async_send_command(self, bound_entity: BoundEntity, payload: Any) -> None:
"""Write a value to the device. Fire-and-forget style.
A description-level validate_fn (currently SwitchDesc only) runs
@@ -620,23 +1030,23 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
without Smart Control (cycle start/pause/stop on /operational/state
still require it)."""
desc = bound_entity.desc
write_fn = getattr(desc, 'write_fn', None)
write_fn = getattr(desc, "write_fn", None)
if write_fn is None:
return
href = bound_entity.href
rep = self._cache.get(href or '') or {}
rep = self._cache.get(href or "") or {}
resources = self._cache.snapshot()
bypass_remote_control = self._entry.options.get(CONF_BYPASS_REMOTE_CONTROL, False)
if (
not bypass_remote_control
and remote_control_required_for_write(resources, href or '')
and remote_control_required_for_write(resources, href or "")
and not remote_control_enabled(resources)
):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="remote_control_disabled",
)
validate_fn = getattr(desc, 'validate_fn', None)
validate_fn = getattr(desc, "validate_fn", None)
if validate_fn is not None:
error = validate_fn(payload, rep, resources)
if error:
@@ -667,7 +1077,17 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# state until the next real read of that resource -- the 20-60s lag
# in issues #17/#53, which survived the earlier optimistic-apply fix
# (issue #27) because that fix applied to the wrong href too.
write_href = '/' + '/'.join(path_segs)
#
# write_fn's path_segs are canonical (issue #177) -- a subdevice's
# ClimateDesc is bound to its own *actual* /mode/vs/1 (or
# /<id>/mode/vs/0) href, but _climate_write only knows the canonical
# sibling hrefs (e.g. ['power', 'vs', '0']). Translate through this
# bound entity's own subdevice so the optimistic apply, the settle
# guard and the POST below all target that subdevice's real resource --
# to_actual is the identity transform for MAIN, so a device with no
# subdevices writes exactly where it always did.
write_href = bound_entity.subdevice.to_actual("/" + "/".join(path_segs))
path_segs = [s for s in write_href.strip("/").split("/") if s]
# Apply the write optimistically before starting the settle guard,
# not after -- mark_write_pending gates every source (poll, sweep,
@@ -717,24 +1137,24 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# cache entry stays complete; the minimal `body` below is still
# exactly what goes out over the wire.
optimistic_body = body
new_options = body.get('x.com.samsung.da.options')
new_options = body.get("x.com.samsung.da.options")
if isinstance(new_options, list):
cached_options = (self._cache.get(write_href) or {}).get('x.com.samsung.da.options')
cached_options = (self._cache.get(write_href) or {}).get("x.com.samsung.da.options")
optimistic_body = {
**optimistic_body,
'x.com.samsung.da.options': merge_options_field(cached_options, new_options),
"x.com.samsung.da.options": merge_options_field(cached_options, new_options),
}
# Same fact, items[] shape (e.g. airconditioner._climate_write's vendor
# temperature write, which now carries only {id, desired} -- see that
# module for the write-side half of this).
new_items = body.get('x.com.samsung.da.items')
new_items = body.get("x.com.samsung.da.items")
if isinstance(new_items, list):
cached_items = (self._cache.get(write_href) or {}).get('x.com.samsung.da.items')
cached_items = (self._cache.get(write_href) or {}).get("x.com.samsung.da.items")
optimistic_body = {
**optimistic_body,
'x.com.samsung.da.items': merge_items_field(cached_items, new_items),
"x.com.samsung.da.items": merge_items_field(cached_items, new_items),
}
self._observe.apply(write_href, optimistic_body, source='optimistic')
self._observe.apply(write_href, optimistic_body, source="optimistic")
self._observe.mark_write_pending(
write_href, settle_s=self._POST_TIMEOUT_S + self._POLL_TIMEOUT_S
)
@@ -779,7 +1199,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if rcode == 0x45 and payload:
rep = cbor2.loads(payload)
if isinstance(rep, dict):
self._observe.apply(href, rep, source='poll')
self._observe.apply(href, rep, source="poll")
new_rep = rep
except Exception as e:
self._log.debug("raw write follow-up read failed: %s", e)
@@ -798,13 +1218,13 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
translation_domain=DOMAIN,
translation_key="debug_payload_empty",
)
path_segs = [s for s in href.strip('/').split('/') if s]
path_segs = [s for s in href.strip("/").split("/") if s]
if not path_segs:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="resource_href_required",
)
norm_href = '/' + '/'.join(path_segs)
norm_href = "/" + "/".join(path_segs)
async with self._session_lock:
code, new_rep = await self.hass.async_add_executor_job(
self._raw_write_blocking, path_segs, body, norm_href
+123 -1
View File
@@ -6,6 +6,7 @@ device > the menu > Download diagnostics. This is what the Repairs issue
users at: a redacted snapshot of the device's raw /device/0 state, plus
enough version/coverage metadata to reproduce and diagnose the gap.
"""
from __future__ import annotations
from importlib.metadata import version as pkg_version
@@ -18,6 +19,7 @@ from homeassistant.loader import async_get_integration
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .registry.redact import redact_resources
from .registry.subdevices import MAIN
async def async_get_config_entry_diagnostics(
@@ -31,11 +33,131 @@ async def async_get_config_entry_diagnostics(
# detector when called inline here. Offload it to the executor.
stl_version = await hass.async_add_executor_job(pkg_version, "smartthings-local")
# /oic/p, /oic/d, and /oic/res sit outside the /device/0 batch captured
# below, so they'd otherwise never reach an issue report. /oic/d's `rt`
# is OCF's standard device-type declaration; /oic/res is OCF's
# discovery endpoint, listing every href/Collection the connection
# hosts -- relevant to the "Composite Device" model (issue #177) where
# a single physical device exposes more than one logical subdevice. See
# registry/identity.py.
identity = coordinator._identity
def _seed_diag(su) -> dict:
# A flat-mode subdevice (issue #205 -- no working /<uuid>/device/0
# Collection, so its state comes from individually-polled hrefs
# instead) has no meaningful seed_path; report the flat_hrefs list
# in its place rather than the misleading bare "/" a joined empty
# tuple would otherwise produce.
return {
"seed_path": ("/" + "/".join(su.seed_path)) if su.seed_path else None,
"flat_hrefs": list(su.flat_hrefs),
}
def _subdevice_diag(su) -> dict:
# One pass over coordinator.bound for both fields below (count and
# the distinct hrefs), and one redaction of this subdevice's canonical
# view -- `model` reads modelNum off the already-redacted `resources`
# rather than redacting /information/vs/0 a second time. modelNum
# itself never matches redact.py's substring rules, so which side of
# redact_resources it's read from doesn't change the value.
matching = [b for b in coordinator.bound if b.subdevice == su]
res = redact_resources(coordinator.canonical_resources(su))
return {
"kind": su.kind,
"key": su.key,
**_seed_diag(su),
"bound_entity_count": len(matching),
"hrefs": sorted({b.href for b in matching}),
"model": res.get("/information/vs/0", {}).get("x.com.samsung.da.modelNum", ""),
# Keyed by this subdevice's *canonical* hrefs, not the real ones
# it answers on -- '/mode/vs/0' rather than '/mode/vs/1' or
# '/<uuid>/mode/vs/0'. That's the form the registry and every
# capability are written against, so a sibling's block can be
# read (or pasted into the skill's standalone-discovery
# recipe) exactly like the master's `resources` above,
# instead of having to be de-indexed by hand first.
"resources": res,
}
return {
"device_type": coordinator.device_type_name or "unknown",
"one_ui_version": coordinator.one_ui_version,
"identity": {
"manufacturer": identity.manufacturer,
"model": identity.model,
"device_types": list(identity.device_types),
"resources": redact_resources(identity.raw),
}
if identity is not None
else None,
"unbound_hrefs": sorted(coordinator._unbound_hrefs),
"resources": redact_resources(coordinator.last_resources),
# This subdevice's own resources, and only this subdevice's -- what
# the module docstring and the adding-device-support skill have
# always described it as ("the parsed /device/0 snapshot"). On a
# composite device (issue #177) `last_resources` is the union across
# every live subdevice keyed by real hrefs, so reporting it raw here
# would mix a sibling's /mode/vs/1 in with the master's /mode/vs/0
# under no attribution at all. Each sibling reports its own
# resources in its own `subdevices` entry below instead. For a
# device with no subdevices -- almost every device -- this is
# byte-identical to `last_resources`.
"resources": redact_resources(coordinator.canonical_resources(MAIN)),
# Sibling indoor subdevices discovered on this connection (issue
# #177) -- per-subdevice kind/key/seed path plus what actually bound
# to it, so a report shows whether a composite device's subdevice
# was found at all and what it resolved to. subdeviceIdList (the
# UUID a prefixed subdevice's key comes from) is deliberately NOT
# redacted here even
# though the field matches redact.py's 'deviceid' substring rule
# elsewhere in `resources` above -- it's an appliance-internal
# pairing id, not account data, and reporting the key is what makes
# this block actionable.
"subdevices": [_subdevice_diag(su) for su in coordinator.subdevices],
# Candidates that answered their seed but that discover_partitioned's
# entity-level liveness gate rejected -- an unused SmartThings slot
# (the issue #177 reporter's /device/2) that still answers a
# same-shaped batch, not a real second subdevice. Reported alongside
# subdevices above so a report shows what was found *and* why it
# didn't become an entity, not just silence where a third climate
# card might otherwise be expected.
"subdevices_skipped": [
{
"kind": skip.subdevice.kind,
"key": skip.subdevice.key,
**_seed_diag(skip.subdevice),
"hrefs": list(skip.hrefs),
# The reps the liveness gate actually judged, canonicalized
# like the materialized subdevices above. These are the one
# thing a reader needs to second-guess a skip ("is my second
# subdevice really absent, or did the gate get it wrong?"),
# and they exist nowhere else in this dump: a rejected
# candidate is never polled again and never enters the state
# cache, so `resources` above cannot contain them by
# construction.
"resources": redact_resources(
{
canon: rep
for href, rep in coordinator._skipped_subdevice_resources.items()
if (canon := skip.subdevice.to_canonical(href)) is not None
}
),
}
for skip in coordinator._skipped_subdevices
],
# What each enumeration probe returned ({} vs a batch), keyed by the
# seed href attempted -- lets a report distinguish "checked, nothing
# there" from "never checked", the same posture the speculative
# /device/1 //device/2 probe this replaced used to document directly
# in identity.py before it moved to registry/subdevices.py.
"subdevice_probes": dict(sorted(coordinator._subdevice_probes.items())),
# /multidevice/vs/0's rep ({} when the board doesn't answer it).
# Reported on its own rather than inside `resources` because it is
# metadata about the connection rather than state of any one
# subdevice -- and because nothing polls it after discovery, so it
# would go stale in there. Its numofsubdevice count is what
# independently corroborates the subdevices/subdevices_skipped split
# above.
"multidevice": redact_resources(coordinator._multidevice),
"integration_version": integration.version,
"smartthings_local_version": stl_version,
"observe_mode": coordinator.observe_mode,
+32 -15
View File
@@ -1,21 +1,21 @@
"""Base entity for Local Things."""
from __future__ import annotations
import re
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.const import EntityCategory
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .registry.adapter import _key
from .registry.batch import is_stub_rep
from .registry.discovery import BoundEntity, _snake_to_title
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
def _is_included(bound: BoundEntity, coordinator: 'LocalThingsCoordinator') -> bool:
def _is_included(bound: BoundEntity, coordinator: LocalThingsCoordinator) -> bool:
"""Return False if the entity should not be registered for this device.
Explicit exists_fn takes priority. Otherwise, if the entity has a field,
@@ -33,12 +33,19 @@ def _is_included(bound: BoundEntity, coordinator: 'LocalThingsCoordinator') -> b
a field is genuinely never populated on unsupported hardware opts into
stricter gating with its own is_stub_rep-based exists_fn (see
common.ENERGY_METER, issue #127) -- this default stays permissive.
`bound.href` is already the *actual* href (issue #177 -- see
BoundEntity/Subdevice), so the direct cache lookup below is correct as-is;
`exists_fn` gets `bound`'s own subdevice's *canonical* view instead of the
raw snapshot, same rule as everywhere else a whole-resources-dict scan
happens (coordinator.canonical_resources) -- this is a free function, not
an LocalThingsEntity method, so it can't use self._resources.
"""
rep = coordinator.last_resources.get(bound.href)
if rep is None:
return False
if bound.desc.exists_fn is not None:
return bound.desc.exists_fn(rep, coordinator.last_resources)
return bound.desc.exists_fn(rep, coordinator.canonical_resources(bound.subdevice))
if bound.desc.field:
if not rep or is_stub_rep(rep):
return True
@@ -56,7 +63,7 @@ def _derive_name(state_key: str) -> str:
builds the {instance_name} placeholder those translations interpolate,
for a device that named its own compartments/ice makers.
"""
name = re.sub(r'(\d+)$', lambda m: f' {m.group()}' if int(m.group()) > 0 else '', state_key)
name = re.sub(r"(\d+)$", lambda m: f" {m.group()}" if int(m.group()) > 0 else "", state_key)
return _snake_to_title(name).strip()
@@ -67,9 +74,9 @@ def _instance_display_name(bound: BoundEntity, state_key: str) -> str:
source = bound.key_override or state_key
suffix = f"_{bound.desc.key}"
if source.endswith(suffix):
source = source[:-len(suffix)]
source = source[: -len(suffix)]
elif bound.instance and source.endswith(bound.instance):
source = source[:-len(bound.instance)] + bound.instance.replace("_", " ")
source = source[: -len(bound.instance)] + bound.instance.replace("_", " ")
return _derive_name(source)
@@ -84,9 +91,7 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
self._state_key = _key(bound)
self._attr_unique_id = f"{DOMAIN}_{coordinator.device_serial}_{self._state_key}"
if bound.desc.translation_placeholders is not None:
self._attr_translation_placeholders = dict(
bound.desc.translation_placeholders
)
self._attr_translation_placeholders = dict(bound.desc.translation_placeholders)
elif bound.desc.use_instance_name:
self._attr_translation_placeholders = {
"instance_name": _instance_display_name(bound, self._state_key)
@@ -122,9 +127,21 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
"""
tk = self._bound.desc.translation_key
if callable(tk):
return tk(self.coordinator.last_resources)
return tk(self._resources)
return tk if tk is not None else self._bound.desc.key
@property
def _resources(self) -> dict:
"""This entity's own subdevice's canonical resources view (issue
#177) -- see coordinator.canonical_resources. Every platform
property that needs the *whole* resources dict, as opposed to one
href via `coordinator.resource(href)`, must read through this
instead of `coordinator.last_resources`, or a sibling subdevice's own
actual hrefs would leak into (or be missing from) this entity's
view. For MAIN (every device with no subdevices) this is exactly
`coordinator.last_resources`."""
return self.coordinator.canonical_resources(self._bound.subdevice)
@property
def device_info(self) -> DeviceInfo:
return self.coordinator.device_info
return self.coordinator.device_info_for(self._bound.subdevice)
+112 -68
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,19 +32,24 @@ 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__)
POWER_HREF = '/power/0'
POWER_VS_HREF = '/power/vs/0'
_FAN_SPEED_FIELD = 'x.com.samsung.da.hood.fanSpeed'
_SUPPORTED_FAN_SPEED_FIELD = 'x.com.samsung.da.hood.supportedFanSpeed'
_MIN_FAN_SPEED_FIELD = 'x.com.samsung.da.hood.settableMinFanSpeed'
_OFF_SPEED_CODE = '0'
POWER_HREF = "/power/0"
POWER_VS_HREF = "/power/vs/0"
_FAN_SPEED_FIELD = "x.com.samsung.da.hood.fanSpeed"
_SUPPORTED_FAN_SPEED_FIELD = "x.com.samsung.da.hood.supportedFanSpeed"
_MIN_FAN_SPEED_FIELD = "x.com.samsung.da.hood.settableMinFanSpeed"
_MAX_FAN_SPEED_FIELD = "x.com.samsung.da.hood.settableMaxFanSpeed"
_OFF_SPEED_CODE = "0"
_MODES_FIELD = 'x.com.samsung.da.modes'
_SUPPORTED_MODES_FIELD = 'x.com.samsung.da.supportedModes'
_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 +62,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))
@@ -84,11 +92,13 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
"""
_enable_turn_on_off_backwards_compatibility = False
_attr_supported_features = (
FanEntityFeature.SET_SPEED
| FanEntityFeature.TURN_ON
| FanEntityFeature.TURN_OFF
)
@property
def supported_features(self) -> FanEntityFeature:
features = FanEntityFeature.TURN_ON | FanEntityFeature.TURN_OFF
if self.speed_count > 0:
features |= FanEntityFeature.SET_SPEED
return features
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
super().__init__(coordinator, bound)
@@ -108,13 +118,24 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
resource instead, so this is False there."""
rep = self._rep(self._bound.href)
return (
str(rep.get(_MIN_FAN_SPEED_FIELD, '')) == _OFF_SPEED_CODE
str(rep.get(_MIN_FAN_SPEED_FIELD, "")) == _OFF_SPEED_CODE
or _OFF_SPEED_CODE in self._all_speed_codes()
)
def _all_speed_codes(self) -> list[str]:
rep = self._rep(self._bound.href)
return [str(value) for value in rep.get(_SUPPORTED_FAN_SPEED_FIELD, ())]
supported = rep.get(_SUPPORTED_FAN_SPEED_FIELD)
if supported:
return [str(value) for value in supported]
min_s = rep.get(_MIN_FAN_SPEED_FIELD)
max_s = rep.get(_MAX_FAN_SPEED_FIELD)
if min_s is not None and max_s is not None:
try:
mn, mx = int(min_s), int(max_s)
return [str(i) for i in range(mn, mx + 1)]
except (ValueError, TypeError):
pass
return []
def _active_speed_codes(self) -> list[str]:
codes = self._all_speed_codes()
@@ -129,21 +150,19 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
def _power_payload(self, enabled: bool) -> tuple[str, bool, str]:
"""Target whichever power resource this hood actually exposes."""
resources = self.coordinator.last_resources
resources = self._resources
target = POWER_HREF if POWER_HREF in resources else POWER_VS_HREF
return 'power', enabled, target
return "power", enabled, target
@property
def is_on(self) -> bool:
if self._speed_zero_is_off():
current = str(self._rep(self._bound.href).get(_FAN_SPEED_FIELD, '0'))
return current not in ('', _OFF_SPEED_CODE)
current = str(self._rep(self._bound.href).get(_FAN_SPEED_FIELD, "0"))
return current not in ("", _OFF_SPEED_CODE)
rep = self._rep(POWER_HREF)
if 'value' in rep:
return bool(rep.get('value'))
return str(
self._rep(POWER_VS_HREF).get('x.com.samsung.da.power', '')
).lower() == 'on'
if "value" in rep:
return bool(rep.get("value"))
return str(self._rep(POWER_VS_HREF).get("x.com.samsung.da.power", "")).lower() == "on"
@property
def speed_count(self) -> int:
@@ -154,13 +173,15 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
if not self.is_on:
return 0
codes = self._active_speed_codes()
current = str(self._rep(self._bound.href).get(_FAN_SPEED_FIELD, ''))
current = str(self._rep(self._bound.href).get(_FAN_SPEED_FIELD, ""))
if not codes or current not in codes:
return None
return ordered_list_item_to_percentage(codes, current)
async def async_turn_on(
self, percentage: int | None = None, preset_mode: str | None = None,
self,
percentage: int | None = None,
preset_mode: str | None = None,
**kwargs,
) -> None:
if self._speed_zero_is_off():
@@ -173,20 +194,22 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
return
codes = self._active_speed_codes()
if codes:
await self.coordinator.async_send_command(self._bound, ('speed', codes[0]))
await self.coordinator.async_send_command(self._bound, ("speed", codes[0]))
return
await self.coordinator.async_send_command(
self._bound, self._power_payload(True),
self._bound,
self._power_payload(True),
)
if percentage is not None:
await self.async_set_percentage(percentage)
async def async_turn_off(self, **kwargs) -> None:
if self._speed_zero_is_off():
await self.coordinator.async_send_command(self._bound, ('speed', _OFF_SPEED_CODE))
await self.coordinator.async_send_command(self._bound, ("speed", _OFF_SPEED_CODE))
return
await self.coordinator.async_send_command(
self._bound, self._power_payload(False),
self._bound,
self._power_payload(False),
)
async def async_set_percentage(self, percentage: int) -> None:
@@ -198,10 +221,11 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
return
if not self._speed_zero_is_off() and not self.is_on:
await self.coordinator.async_send_command(
self._bound, self._power_payload(True),
self._bound,
self._power_payload(True),
)
code = percentage_to_ordered_list_item(codes, percentage)
await self.coordinator.async_send_command(self._bound, ('speed', code))
await self.coordinator.async_send_command(self._bound, ("speed", code))
class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity):
@@ -211,9 +235,7 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity):
_enable_turn_on_off_backwards_compatibility = False
_attr_supported_features = (
FanEntityFeature.PRESET_MODE
| FanEntityFeature.TURN_ON
| FanEntityFeature.TURN_OFF
FanEntityFeature.PRESET_MODE | FanEntityFeature.TURN_ON | FanEntityFeature.TURN_OFF
)
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
@@ -232,32 +254,52 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity):
Writing a hardcoded href here would silently no-op on a board that
only reports the other one, even though is_on already falls back
correctly."""
resources = self.coordinator.last_resources
resources = self._resources
target = POWER_VS_HREF if POWER_VS_HREF in resources else POWER_HREF
return 'power', enabled, target
return "power", enabled, target
@property
def is_on(self) -> bool:
power = self._rep(POWER_VS_HREF).get('x.com.samsung.da.power')
power = self._rep(POWER_VS_HREF).get("x.com.samsung.da.power")
if power is not None:
return str(power).lower() == 'on'
return bool(self._rep(POWER_HREF).get('value'))
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()
for code in self._mode_rep().get(_SUPPORTED_MODES_FIELD, ())
self._label_for_code(code) for code in self._mode_rep().get(_SUPPORTED_MODES_FIELD, ())
]
@property
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,
self,
percentage: int | None = None,
preset_mode: str | None = None,
**kwargs,
) -> None:
await self.coordinator.async_send_command(self._bound, self._power_payload(True))
@@ -269,26 +311,28 @@ 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:
await self.coordinator.async_send_command(self._bound, ('mode', code))
if self._label_for_code(code) == preset_mode:
await self.coordinator.async_send_command(self._bound, ("mode", code))
return
_LOGGER.warning(
"%s: %r is not a valid preset mode (supported: %s)",
self.entity_id, preset_mode, self.preset_modes,
self.entity_id,
preset_mode,
self.preset_modes,
)
_AIRFLOW_SPEED_FIELD = 'speed'
_AIRFLOW_SPEED_FIELD = "speed"
# Raw `speed` codes, low-to-high -- confirmed monotonic (Auto=0, Sleep=1,
# Low=2, Medium=3, High=4) via air_purifier.py's module docstring. Ordered
# as plain strings, same as _all_speed_codes above, so
# ordered_list_item_to_percentage/percentage_to_ordered_list_item can treat
# it exactly like the range hood's numeric levels -- no named-preset table
# needed since this board never reports mode names to hang one off of.
_AIRFLOW_SPEED_CODES = ('0', '1', '2', '3', '4')
_AIRFLOW_SPEED_CODES = ["0", "1", "2", "3", "4"]
class LocalThingsAirflowFan(LocalThingsEntity, FanEntity):
@@ -297,9 +341,7 @@ class LocalThingsAirflowFan(LocalThingsEntity, FanEntity):
_enable_turn_on_off_backwards_compatibility = False
_attr_supported_features = (
FanEntityFeature.SET_SPEED
| FanEntityFeature.TURN_ON
| FanEntityFeature.TURN_OFF
FanEntityFeature.SET_SPEED | FanEntityFeature.TURN_ON | FanEntityFeature.TURN_OFF
)
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
@@ -320,16 +362,16 @@ class LocalThingsAirflowFan(LocalThingsEntity, FanEntity):
resources -- disagreeing until the next poll refreshes the other
one (the same optimistic-apply lag coordinator.py's own comments
warn about)."""
resources = self.coordinator.last_resources
resources = self._resources
target = POWER_HREF if POWER_HREF in resources else POWER_VS_HREF
return 'power', enabled, target
return "power", enabled, target
@property
def is_on(self) -> bool:
power = self._rep(POWER_HREF)
if 'value' in power:
return bool(power.get('value'))
return str(self._rep(POWER_VS_HREF).get('x.com.samsung.da.power', '')).lower() == 'on'
if "value" in power:
return bool(power.get("value"))
return str(self._rep(POWER_VS_HREF).get("x.com.samsung.da.power", "")).lower() == "on"
@property
def speed_count(self) -> int:
@@ -339,13 +381,15 @@ class LocalThingsAirflowFan(LocalThingsEntity, FanEntity):
def percentage(self) -> int | None:
if not self.is_on:
return 0
current = str(self._rep(self._bound.href).get(_AIRFLOW_SPEED_FIELD, ''))
current = str(self._rep(self._bound.href).get(_AIRFLOW_SPEED_FIELD, ""))
if current not in _AIRFLOW_SPEED_CODES:
return None
return ordered_list_item_to_percentage(_AIRFLOW_SPEED_CODES, current)
async def async_turn_on(
self, percentage: int | None = None, preset_mode: str | None = None,
self,
percentage: int | None = None,
preset_mode: str | None = None,
**kwargs,
) -> None:
await self.coordinator.async_send_command(self._bound, self._power_payload(True))
@@ -362,4 +406,4 @@ class LocalThingsAirflowFan(LocalThingsEntity, FanEntity):
if not self.is_on:
await self.coordinator.async_send_command(self._bound, self._power_payload(True))
code = percentage_to_ordered_list_item(_AIRFLOW_SPEED_CODES, percentage)
await self.coordinator.async_send_command(self._bound, ('speed', int(code)))
await self.coordinator.async_send_command(self._bound, ("speed", int(code)))
+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.18.0"
}
+18 -15
View File
@@ -1,16 +1,18 @@
"""Number platform for Local Things."""
from __future__ import annotations
from homeassistant.components.number import NumberEntity, NumberMode
from typing import cast
from homeassistant.components.number import NumberDeviceClass, NumberEntity, NumberMode
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .registry.entities import NumberDesc
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
from .registry.entities import NumberDesc
async def async_setup_entry(
@@ -27,14 +29,15 @@ async def async_setup_entry(
class LocalThingsNumber(LocalThingsEntity, NumberEntity):
_attr_mode = NumberMode.SLIDER
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
super().__init__(coordinator, bound)
desc: NumberDesc = bound.desc
desc = cast(NumberDesc, bound.desc)
self._attr_native_unit_of_measurement = desc.unit
self._attr_device_class = desc.device_class
self._attr_device_class = (
NumberDeviceClass(desc.device_class) if desc.device_class else None
)
if desc.native_min is not None:
self._attr_native_min_value = desc.native_min
if desc.native_max is not None:
@@ -44,13 +47,13 @@ class LocalThingsNumber(LocalThingsEntity, NumberEntity):
@property
def native_unit_of_measurement(self):
desc: NumberDesc = self._bound.desc
desc = cast(NumberDesc, self._bound.desc)
if desc.unit_fn is not None:
return desc.unit_fn(self.coordinator.resource(self._bound.href))
return self._attr_native_unit_of_measurement
def _range_from_resource(self) -> list | None:
desc: NumberDesc = self._bound.desc
desc = cast(NumberDesc, self._bound.desc)
if not desc.range_field:
return None
r = self.coordinator.resource(self._bound.href).get(desc.range_field)
@@ -58,34 +61,34 @@ class LocalThingsNumber(LocalThingsEntity, NumberEntity):
@property
def native_min_value(self) -> float:
desc: NumberDesc = self._bound.desc
desc = cast(NumberDesc, self._bound.desc)
if desc.native_min_fn is not None:
return desc.native_min_fn(self.coordinator.resource(self._bound.href))
r = self._range_from_resource()
if r is not None:
return float(r[0])
if hasattr(self, '_attr_native_min_value'):
if hasattr(self, "_attr_native_min_value"):
return self._attr_native_min_value
return super().native_min_value
@property
def native_max_value(self) -> float:
desc: NumberDesc = self._bound.desc
desc = cast(NumberDesc, self._bound.desc)
if desc.native_max_fn is not None:
return desc.native_max_fn(self.coordinator.resource(self._bound.href))
r = self._range_from_resource()
if r is not None:
return float(r[1])
if hasattr(self, '_attr_native_max_value'):
if hasattr(self, "_attr_native_max_value"):
return self._attr_native_max_value
return super().native_max_value
@property
def native_step(self) -> float:
desc: NumberDesc = self._bound.desc
def native_step(self) -> float | None:
desc = cast(NumberDesc, self._bound.desc)
if desc.step_fn is not None:
return desc.step_fn(self.coordinator.resource(self._bound.href))
if hasattr(self, '_attr_native_step'):
if hasattr(self, "_attr_native_step"):
return self._attr_native_step
return super().native_step
+41 -22
View File
@@ -8,6 +8,7 @@ are an external pip dependency we don't own, so behavior that would
naturally live inside StateCache.apply_rep lives here instead, gating
whether apply_rep is called at all.
"""
from __future__ import annotations
import logging
@@ -15,16 +16,15 @@ import threading
import time
import cbor2
from smartthings_local.ocf.state_cache import StateCache
from smartthings_local.ocf.observe_refresh import ObserveRefreshTask
from smartthings_local.ocf.state_cache import StateCache
_LOGGER = logging.getLogger(__name__)
REFRESH_INTERVAL_S = 6 * 3600.0
MODE_OBSERVE = 'observe'
MODE_POLL = 'poll'
MODE_OBSERVE = "observe"
MODE_POLL = "poll"
DEFAULT_SETTLE_S = 4.0
GRACE_PERIOD_S = 15.0
@@ -73,6 +73,9 @@ class ObserveManager:
self.subscribed_hrefs: set[str] = set()
self._notified: set[str] = set()
self._last_notify_ts: float | None = None
# Wakes try_enter_observe_mode's grace wait early once enough hrefs
# have notified. Guards only `_notified` mutations + the `wait_for`.
self._notify_cond = threading.Condition()
self.fallback_hrefs: set[str] = set()
self._refresh_task: ObserveRefreshTask | None = None
self._refresh_stop: threading.Event | None = None
@@ -130,7 +133,7 @@ class ObserveManager:
happened to expire, i.e. the exact symptom this guard exists to
prevent, just relocated to whichever write loses the race.
"""
if source != 'optimistic' and self._is_settling(href):
if source != "optimistic" and self._is_settling(href):
self.log.debug("dropping %s update for %s (settling)", source, href)
return False
with self._cache_lock:
@@ -147,10 +150,12 @@ class ObserveManager:
return
if not isinstance(rep, dict):
return
self._notified.add(href)
self._last_notify_ts = time.monotonic()
with self._notify_cond:
self._notified.add(href)
self._last_notify_ts = time.monotonic()
self._notify_cond.notify_all()
self.log.debug("observe notify: %s", href)
self.apply(href, rep, source='observe')
self.apply(href, rep, source="observe")
def recently_notified(self, window_s: float = PUSH_HEALTH_WINDOW_S) -> bool:
"""True if any OBSERVE notify has arrived within `window_s`.
@@ -163,22 +168,25 @@ class ObserveManager:
channel has been perfectly healthy").
"""
return (
self._last_notify_ts is not None
and time.monotonic() - self._last_notify_ts < window_s
self._last_notify_ts is not None and time.monotonic() - self._last_notify_ts < window_s
)
def try_enter_observe_mode(
self, session, hrefs: list[str],
self,
session,
hrefs: list[str],
grace_period_s: float = GRACE_PERIOD_S,
success_fraction: float = SUCCESS_FRACTION,
) -> bool:
"""Blocking — subscribes to every href then sleeps for the whole
grace period. Caller must run this in an executor, never on the
"""Blocking — subscribes to every href then waits up to
`grace_period_s`, returning early once `success_fraction` of hrefs
have notified. Caller must run this in an executor, never on the
event loop."""
self._notified.clear()
with self._notify_cond:
self._notified.clear()
subscribed: set[str] = set()
for href in hrefs:
segs = [s for s in href.strip('/').split('/') if s]
segs = [s for s in href.strip("/").split("/") if s]
try:
session.subscribe(segs)
subscribed.add(href)
@@ -190,10 +198,16 @@ class ObserveManager:
self.subscribed_hrefs = set()
return False
time.sleep(grace_period_s)
def _fraction_reached() -> bool:
return len(set(self._notified) & subscribed) / len(subscribed) >= success_fraction
fraction = len(set(self._notified) & subscribed) / len(subscribed)
if fraction >= success_fraction:
with self._notify_cond:
reached = self._notify_cond.wait_for(
_fraction_reached,
timeout=grace_period_s,
)
if reached:
self.subscribed_hrefs = subscribed
self._set_mode(MODE_OBSERVE)
self.start_refresh_task(session)
@@ -256,7 +270,8 @@ class ObserveManager:
found = True
self.log.debug(
"observe missed a change on %s (sweep disagrees with cache): %s",
href, diff,
href,
diff,
)
return found
@@ -268,15 +283,19 @@ class ObserveManager:
def start_refresh_task(self, session) -> None:
self._stop_refresh_task()
paths = [tuple(h.strip('/').split('/')) for h in self.subscribed_hrefs]
paths = [tuple(h.strip("/").split("/")) for h in self.subscribed_hrefs]
self._refresh_task = ObserveRefreshTask(
session, paths, interval_s=REFRESH_INTERVAL_S, logger=self.log,
session,
paths,
interval_s=REFRESH_INTERVAL_S,
logger=self.log,
)
self._refresh_stop = threading.Event()
self._refresh_thread = threading.Thread(
target=self._refresh_task.run_forever,
args=(self._refresh_stop,),
daemon=True, name='localthings-observe-refresh',
daemon=True,
name="localthings-observe-refresh",
)
self._refresh_thread.start()
@@ -6,8 +6,9 @@ to the matching capabilities, and flattens the result into HA-ready entity
state. The DTLS/CoAP transport itself lives in the smartthings-local
package, not here.
"""
from .adapter import flatten, _key
from .adapter import _key, flatten
from .discovery import BoundEntity, discover
from .registry import CAPABILITIES
__all__ = ['CAPABILITIES', 'discover', 'BoundEntity', 'flatten', '_key']
__all__ = ["CAPABILITIES", "BoundEntity", "_key", "discover", "flatten"]
@@ -1,22 +1,48 @@
"""Adapter: BoundEntity list → flat state dict and command dispatch."""
from __future__ import annotations
from typing import Any
from .discovery import BoundEntity
from .subdevices import Subdevice, canonical_view
def _key(b: BoundEntity) -> str:
return f"{b.key_override or b.desc.key}{b.instance}"
# b.subdevice.key_prefix is '' for MAIN, so a device with no subdevices
# (every device this integration shipped before issue #177) gets a
# byte-identical key to before -- a hard regression guard, not a nicety
# (see test_unique_ids.py and every golden file under tests/fixtures/golden/).
return f"{b.subdevice.key_prefix}{b.key_override or b.desc.key}{b.instance}"
def flatten(bound: list[BoundEntity], resources: dict) -> dict[str, Any]:
"""Map bound entities to their current scalar values."""
"""Map bound entities to their current scalar values.
`exists_fn(rep, resources)` receives that entity's own subdevice's
*canonical* resources view (see subdevices.canonical_view), not the raw
actual-href snapshot -- an exists_fn that scans the whole resources dict
for a sibling href (e.g. is_legacy_board) must judge each subdevice on its
own resources, not see another subdevice's hrefs bleed in under the same
canonical key. Views are built once per distinct subdevice per call, not
once per entity -- O(subdevices), not O(bound entities).
"""
out: dict[str, Any] = {}
# Subdevice is a frozen dataclass (hashable, equal by value), so it can key
# `views` directly -- no need to re-derive an identity for it out of
# (kind, key) first.
all_subdevices = list(dict.fromkeys(b.subdevice for b in bound))
views: dict[Subdevice, dict] = {}
for b in bound:
rep = resources.get(b.href) or {}
if b.desc.exists_fn is not None and not b.desc.exists_fn(rep, resources):
continue
if b.desc.exists_fn is not None:
view = views.get(b.subdevice)
if view is None:
view = canonical_view(b.subdevice, resources, all_subdevices)
views[b.subdevice] = view
if not b.desc.exists_fn(rep, view):
continue
if b.desc.rep_fn is not None:
out[_key(b)] = b.desc.rep_fn(rep)
elif b.desc.field:
@@ -1,4 +1,5 @@
"""OCF /device/0 batch response parser."""
from __future__ import annotations
@@ -11,7 +12,7 @@ def is_stub_rep(rep: dict) -> bool:
never populate. Conflating the two used to make every field-gated entity
on a permanently-empty resource look like a not-yet-fetched stub forever,
creating phantom always-"unknown" entities (issue #127)."""
return isinstance(rep, dict) and set(rep.keys()) == {'href'}
return isinstance(rep, dict) and set(rep.keys()) == {"href"}
def parse_device0_batch(device0: list) -> dict[str, dict]:
@@ -22,11 +23,11 @@ def parse_device0_batch(device0: list) -> dict[str, dict]:
is_stub_rep to tell "not fetched yet" apart from a confirmed-empty {}.
"""
out = {}
for entry in device0[1:]: # skip [0] (device-level rep)
for entry in device0[1:]: # skip [0] (device-level rep)
if not isinstance(entry, dict):
continue
href = entry.get('href')
rep = entry.get('rep')
href = entry.get("href")
rep = entry.get("rep")
if not href:
continue
if isinstance(rep, dict):
@@ -1,116 +1,192 @@
"""Per-device-type registries."""
from typing import Optional
from ._base import DeviceRegistry
import re
from collections.abc import Sequence
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_monitor,
air_purifier,
airconditioner,
cooktop,
dehumidifier,
dishwasher,
dryer,
ehs,
induction_cooktop,
microwave,
oven,
range_hood,
refrigerator,
vacuum_station,
washer,
water_purifier,
)
from . import (
range as _range,
)
from ._base import DeviceRegistry
__all__ = [
'DeviceRegistry', '_type_key', 'for_device', 'for_device_by_model',
'for_device_by_resources',
"DeviceRegistry",
"_board_tokens",
"for_device_by_model",
"for_device_by_oic_type",
"for_device_by_resources",
"resolve",
]
# One entry per registry, no aliases: every key here is reachable from
# `_BOARD_TOKEN_TO_KEY`, `_CONSUMER_PREFIX_TO_KEY`, or `for_device_by_resources`.
_REGISTRY_BY_KEY: dict[str, DeviceRegistry] = {
'air_purifier': air_purifier.REGISTRY,
'airpurifier': air_purifier.REGISTRY,
'airconditioner': airconditioner.REGISTRY,
'air_conditioner': airconditioner.REGISTRY,
'cooktop': cooktop.REGISTRY,
'dehumidifier': dehumidifier.REGISTRY,
'dishwasher': dishwasher.REGISTRY,
'dryer': dryer.REGISTRY,
'induction_cooktop': induction_cooktop.REGISTRY,
'microwave': microwave.REGISTRY,
'oven': oven.REGISTRY,
'hood': range_hood.REGISTRY,
'range': _range.REGISTRY,
'range_hood': range_hood.REGISTRY,
'refrigerator': refrigerator.REGISTRY,
'vacuum_station': vacuum_station.REGISTRY,
'washer': washer.REGISTRY,
'water_purifier': water_purifier.REGISTRY,
"air_dresser": air_dresser.REGISTRY,
"air_monitor": air_monitor.REGISTRY,
"air_purifier": air_purifier.REGISTRY,
"airconditioner": airconditioner.REGISTRY,
"cooktop": cooktop.REGISTRY,
"dehumidifier": dehumidifier.REGISTRY,
"dishwasher": dishwasher.REGISTRY,
"dryer": dryer.REGISTRY,
"ehs": ehs.REGISTRY,
"induction_cooktop": induction_cooktop.REGISTRY,
"microwave": microwave.REGISTRY,
"oven": oven.REGISTRY,
"range": _range.REGISTRY,
"range_hood": range_hood.REGISTRY,
"refrigerator": refrigerator.REGISTRY,
"vacuum_station": vacuum_station.REGISTRY,
"washer": washer.REGISTRY,
"water_purifier": water_purifier.REGISTRY,
}
def _type_key(one_ui_version: str) -> str:
"""Convert oneUiVersion string to registry key.
Args:
one_ui_version: String like '7.0 Dishwasher' or 'Oven'.
Returns:
Lowercase key with version prefix stripped and spaces/hyphens converted to underscores.
Examples:
'7.0 Dishwasher' -> 'dishwasher'
'7.0 French Door Refrigerator' -> 'french_door_refrigerator'
'Oven' -> 'oven'
"""
if ' ' in one_ui_version:
# Strip version prefix: everything before and including the first space
suffix = one_ui_version.split(' ', 1)[-1]
else:
suffix = one_ui_version
return suffix.lower().replace(' ', '_').replace('-', '_')
def for_device(one_ui_version: str) -> Optional[DeviceRegistry]:
"""Return the DeviceRegistry for the given oneUiVersion string, or None if unknown.
Args:
one_ui_version: Device's oneUiVersion string (e.g., '7.0 Dishwasher').
Returns:
DeviceRegistry if a matching registry exists, None otherwise.
"""
key = _type_key(one_ui_version)
if key in _REGISTRY_BY_KEY:
return _REGISTRY_BY_KEY[key]
# Suffix fallback: e.g. "french_door_refrigerator" ends with "_refrigerator"
for rkey, reg in _REGISTRY_BY_KEY.items():
if key.endswith(f'_{rkey}'):
return reg
return None
# Consumer-model prefix (first two letters of the '_'-delimited token in
# `description` right before any '/board-info' suffix) -> registry key.
# NOT derived from `modelNum` -- washer and dryer share the same 'DA_WM_'
# internal board-family prefix there, and dishwasher's modelNum contains
# the substring 'WW', so a modelNum-only rule misroutes both.
_CONSUMER_PREFIX_TO_KEY: dict[str, str] = {
'WW': 'washer',
'WD': 'washer',
'WF': 'washer',
'WV': 'washer', # FlexWash twin units (e.g. WV55M9600AW) -- issue #19
'WA': 'washer', # Top-load washers (e.g. WA8000T) -- issue #106
'DV': 'dryer',
'DW': 'dishwasher',
"WW": "washer",
"WD": "washer",
"WF": "washer",
"WV": "washer", # FlexWash twin units (e.g. WV55M9600AW) -- issue #19
"WA": "washer", # Top-load washers (e.g. WA8000T) -- issue #106
"DV": "dryer",
"DW": "dishwasher",
}
# Board-family token -> registry key, matched against whole tokens of
# `modelNum`/`description` (see `_board_tokens`).
#
# Tokenizing instead of substring-matching is what keeps this a table rather
# than a ladder of hand-written rules. Samsung spells the same board family
# with either delimiter -- 'TP1X_DA-AC-RAC-01001' and 'TP2X_RAC_20K' are the
# same RAC family -- so a substring rule has to be written once per spelling
# ('_RAC_' *and* '-RAC-'), and a token that lands at the end of the
# pipe-prefix with no trailing delimiter ('ARTIK051_DONGLE_REF', issues #77
# and #83) matches no '_TOKEN_' spelling at all. Whole-token matching sees
# every one of those as a single entry.
#
# Entries must name the *specific* device type, never the board family that
# contains it: 'DA-AC-' prefixes RAC/WAC/DHM/AIR alike, so a bare 'AC' entry
# would swallow the dehumidifier and the air purifier. Where two families
# genuinely share a resource surface they share a registry (all the
# air-conditioner spellings below), which is a statement about the hardware,
# not a shortcut.
_BOARD_TOKEN_TO_KEY: dict[str, str] = {
"REF": "refrigerator",
# Air conditioners. Every one of these is a distinct board family with
# the same resource surface: room (issues #37, #91), package, Korean
# (#136), window (#87), 2-in-1 floor+wall (#150, #153), system/commercial
# (#52), cassette (#191), and ARA-WW wall-mount (#115, #116, #117, #120).
"RAC": "airconditioner",
"PRAC": "airconditioner",
"KRAC": "airconditioner",
"WAC": "airconditioner",
"FAC": "airconditioner",
"CAWW": "airconditioner",
"CAC": "airconditioner", # issue #191 -- TP1X_DA-AC-CAC-01001_0000
"ARA": "airconditioner",
"DHM": "dehumidifier", # issue #88 -- target humidity, no climate
"EHS": "ehs", # Eco Heating System air-to-water heat pump --
# zone1 space heating/cooling + dhw domestic
# hot water, its own /mode/*/vs/0 and
# /temperatures/*/vs/0 resource shapes
"TVTL": "air_purifier", # issue #56 (ARTIK051)
"VTWW": "air_purifier", # issue #151 (BESPOKE Cube Air)
"AVT": "air_purifier", # issue #190 -- AVT-WW-TP1-23-AXX500, a
# next-gen BESPOKE Cube Air board; same
# lineage as VTWW above but the '-WW-'
# delimiter now falls one letter to the
# left ('A-VTWW-' -> 'AVT-WW-'), splitting
# into a token the existing entry can't see
"AIR": "air_purifier", # issue #130 (TP1X_DA-AC-AIR)
"WATERPURIFIER": "water_purifier", # issue #90
"ADW": "dishwasher",
"AHD": "range_hood",
"RANGE": "range", # issue #44 -- cooktop+oven combo
"OVEN": "oven", # issue #55 -- wall oven, no burners
"MICROWAVE": "microwave", # issues #66, #121
"COOKTOP": "induction_cooktop", # issue #86 -- standalone, no oven
# Legacy ARTIK051 gas cooktops ('ARTIK051_GB_CT_001'), whose burner state
# lives in /mode/vs/0's options array. Deliberately a bare two-letter
# token, and so the loosest entry in this table -- it is only ever
# reached by a device that matched nothing more specific, and its
# `description` ('ARTIK051_GLOBAL_COOKTOP') would otherwise read as an
# induction cooktop via the COOKTOP entry above. See `for_device_by_model`
# for the field ordering that makes that resolve correctly.
"CT": "cooktop",
"VSKR": "vacuum_station", # issue #131 -- stick-vacuum clean station
"DF": "air_dresser", # issue #162
"VSWW": "vacuum_station", # issue #219
"ASM": "air_monitor", # issue #210 -- Air Monitor Plus
}
def _model_num_segments(model_num: str) -> list[str]:
"""Underscore-delimited segments of modelNum's pipe-prefix.
_TOKEN_SPLIT_RE = re.compile(r"[^A-Z0-9]+")
Most boards wrap a token in underscores on both sides ('..._REF_...'),
which a plain substring check catches fine. But the ARTIK051_DONGLE_REF
family (issues #77, #83) reports modelNum as
'<board>_DONGLE_REF|<rest...>' -- REF is the *last* segment before the
pipe, with no trailing underscore, so '_REF_' never matches and the
device silently fell back to 'unknown'. Splitting on '_' and checking
segment membership catches both shapes without caring which side (if
either) has a delimiter.
def _board_tokens(value: str, cut_at: str) -> list[str]:
"""Whole, upper-cased tokens of `value` up to the first `cut_at`.
`cut_at` drops the trailing junk each field carries -- everything after
modelNum's first '|' (a board revision and a capability bitmap, which can
contain anything) and after description's first '/' (a '/DC92-...' board
part number).
"""
prefix = (model_num or '').split('|', 1)[0]
return prefix.split('_')
head = (value or "").split(cut_at, 1)[0].upper()
return [t for t in _TOKEN_SPLIT_RE.split(head) if t]
def _consumer_model_key(description: str) -> Optional[str]:
def _board_family_key(value: str, cut_at: str) -> str | None:
"""First `_BOARD_TOKEN_TO_KEY` hit among `value`'s tokens, or None.
No known modelNum or description yields two *conflicting* board keys, so
which token is found first doesn't matter within one field -- the table is
a flat lookup, not a priority list. Adding an entry that could co-occur
with another (a family token, or one short enough to collide by accident)
would break that property; see this table's comment.
One documented exception (issue #196): AILITE water-purifier boards
spell their modelNum '...-REF-WATERPURIFIER-...', where 'REF' names the
shared cooling-subsystem board, not the refrigerator device type --
'WATERPURIFIER' is the actual, more specific type here. Rather than drop
or rename either entry (both are correct on their own for the model
strings that exist today), this one known co-occurrence resolves to
'water_purifier'; TestBoardTokenAmbiguity's blanket check carries a
matching carve-out for this exact pair.
"""
tokens = _board_tokens(value, cut_at)
if "REF" in tokens and "WATERPURIFIER" in tokens:
return "water_purifier"
for token in tokens:
key = _BOARD_TOKEN_TO_KEY.get(token)
if key is not None:
return key
return None
def _consumer_model_key(description: str) -> str | None:
"""Registry key from the consumer-model token in `description`, or None.
Usually that token is the last '_'-delimited segment before any
@@ -122,14 +198,20 @@ def _consumer_model_key(description: str) -> Optional[str]:
segments from the end and take the first one that resolves, rather
than assuming the last segment is always it.
Splits on '_' only, unlike `_board_tokens` above: these are two-letter
prefixes matched against the *start* of a segment, so widening the split
to '-' as well would start reading board-family segments as consumer
models -- the dishwasher's 'ADW-WW-RTL-24-AILITE' would offer up a bare
'WW' segment and route to washer.
Only a 2-letter *prefix* match -- e.g. 'WAC' (the Window Air Conditioner
board-family token, issue #87) also starts with 'WA' (the top-load-washer
prefix, issue #106) at this granularity. for_device_by_model() calls the
board-family modelNum checks first and this function only as a fallback,
so that ambiguity resolves correctly without this function needing to
know about unrelated device families.
prefix, issue #106) at this granularity. for_device_by_model() consults
the board-family table first and this function only as a fallback, so
that ambiguity resolves correctly without this function needing to know
about unrelated device families.
"""
segments = (description or '').split('/', 1)[0].split('_')
segments = (description or "").split("/", 1)[0].split("_")
for segment in reversed(segments):
key = _CONSUMER_PREFIX_TO_KEY.get(segment[:2].upper())
if key is not None:
@@ -137,177 +219,186 @@ def _consumer_model_key(description: str) -> Optional[str]:
return None
def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegistry]:
"""Fallback device-type detection for hardware that never reports
oneUiVersion (confirmed for washers -- their /otninformation/vs/0 has
no swVersionInfo key at all).
# /oic/d's `rt` (OCF's own device-type declaration, see registry/identity.py)
# -> registry key. This is the device naming its own type -- no board-part
# guessing involved -- so it's consulted before modelNum/description at all.
#
# Every value must already be a key in `_REGISTRY_BY_KEY` (checked by
# `test_every_oic_type_resolves_to_a_real_registry`). That's why this list
# stops well short of the full OCF/SmartThings device-type vocabulary: a
# compiled list of `x.com.st.d.*` types will include plenty of device
# categories (lights, switches, sensors, locks, cameras, TVs, generic energy
# meters, ...) no Samsung DA appliance dump could ever report and this
# integration has no registry for -- and 'oic.d.robotcleaner' names an
# actual robot vacuum, a different product from the clean/auto-empty
# *station* `vacuum_station` covers (see that registry's own module
# docstring); mapping it there would misroute a genuine robot-vacuum dump
# into a registry with no vacuum-body capabilities at all. Add a row only
# once there's a real registry key on the right-hand side to point at.
#
# `x.com.st.d.*` entries are SmartThings' own vendor extension to the OCF
# device-type vocabulary (used for categories with no `oic.d.*` equivalent),
# same prefix convention as the `x.com.samsung.da.*` resource fields
# elsewhere in this codebase.
#
# `oic.d.cooktop` is deliberately absent, and is the one measured type left out.
# A TP1X_DA-KS-COOKTOP induction reports it, but `cooktop` and
# `induction_cooktop` are two unrelated registries that happen to share the
# English word (see by_type/cooktop.py's docstring: the NA9300K gas family keeps
# burner state in /mode/vs/0's options array, a completely different OCF
# surface). The OCF type does not distinguish them, so mapping it to either key
# would silently misroute the other -- and as the *primary* signal it would
# override a `COOKTOP`/`CT` board token that had it right. Same reasoning as
# `oic.d.robotcleaner` above: no unambiguous key to point at, so no row.
_OIC_TYPE_TO_KEY: dict[str, str] = {
"oic.d.airconditioner": "airconditioner",
"oic.d.airpurifier": "air_purifier",
"oic.d.dishwasher": "dishwasher",
"oic.d.dryer": "dryer",
"oic.d.oven": "oven",
"oic.d.refrigerator": "refrigerator",
"oic.d.washer": "washer",
"x.com.st.d.airqualitysensor": "air_monitor",
"x.com.st.d.hood": "range_hood", # AHD-WW-TP1-22-COMMON
"x.com.st.d.stickcleaner": "vacuum_station",
"x.com.st.d.steamcloset": "air_dresser",
}
def for_device_by_oic_type(device_types: Sequence[str]) -> DeviceRegistry | None:
"""Device-type detection from /oic/d's `rt` -- OCF's own device-type
declaration.
The primary path when a dump carries it: the device names its own type,
so there's nothing to infer from board part numbers. Most hardware still
doesn't populate `/oic/d` usefully -- see `resolve()`'s docstring -- so
this only ever helps a minority of dumps, and `for_device_by_model`/
`for_device_by_resources` remain load-bearing for everything else.
"""
for device_type in device_types:
key = _OIC_TYPE_TO_KEY.get(device_type)
if key is not None:
return _REGISTRY_BY_KEY[key]
return None
def for_device_by_model(model_num: str, description: str) -> DeviceRegistry | None:
"""Device-type detection from /information/vs/0's model strings.
The primary path: the board named in `modelNum` determines the resource
surface, which is what a registry describes.
Three passes, narrowest evidence first:
1. Board-family tokens in `modelNum`. The most reliable signal -- it names
the board, which determines the resource surface.
2. The same tokens in `description`. Some units carry the board token only
there (a scrubbed or placeholder modelNum, e.g. description
'TP1X_REF_21K'). This runs second so that a device whose two fields
disagree is typed by its modelNum: the legacy gas cooktop reports
'ARTIK051_GB_CT_001' (CT -> gas cooktop) alongside
'ARTIK051_GLOBAL_COOKTOP' (COOKTOP -> induction cooktop), and the
board is right.
3. The consumer-model prefix in `description` (washer/dryer/dishwasher).
Last, because a bare two-letter prefix is the fuzziest evidence here
and would otherwise shadow the specific board tokens above.
Args:
model_num: x.com.samsung.da.modelNum from /information/vs/0.
description: x.com.samsung.da.description from /information/vs/0.
Returns:
DeviceRegistry if the consumer-model code or modelNum resolves to a
DeviceRegistry if the modelNum or consumer-model code resolves to a
known type, None otherwise.
"""
# Board-family modelNum tokens are checked first, and the fuzzier
# 2-letter consumer-model prefix from `description` only as a fallback
# (see the bottom of this function) -- some board tokens are themselves
# only 2-3 letters long ('WAC', issue #87) and would otherwise collide
# with an unrelated consumer prefix at that granularity ('WA', issue #106).
key = None
if key is None and 'REF' in _model_num_segments(model_num):
key = 'refrigerator'
# Room air conditioners (e.g. ARTIK051_PRAC_20K) report no oneUiVersion and
# a modelNum carrying the '_PRAC_' (Package Room Air Conditioner) token.
if key is None and '_PRAC_' in (model_num or ''):
key = 'airconditioner'
# Other RAC boards carry a bare 'RAC' (Room Air Conditioner) token in the
# modelNum, in one of two spellings: the underscore form '_RAC_' (e.g.
# TP2X_RAC_20K, issue #37) or the hyphenated form '-RAC-' (e.g.
# TP1X_DA-AC-RAC-01001, a cool-only global variant, issue #91). Both are
# distinct from '_PRAC_' above ('P' sits before 'RAC' with no delimiter)
# and from range ('-RANGE-') / oven ('-OVEN-') tokens. Most TP1X boards
# self-report oneUiVersion and resolve via for_device() upstream of this
# fallback; the hyphenated match is what rescues variants whose
# /otninformation/vs/0 ships no swVersionInfo block at all, so
# oneUiVersion is empty.
if key is None and ('_RAC_' in (model_num or '')
or '-RAC-' in (model_num or '').upper()):
key = 'airconditioner'
# System air conditioners (multi-indoor-unit commercial installs, e.g.
# A-CAWW-TP2-20-COMMON, issue #52) report no oneUiVersion either and
# carry the '-CAWW-' board-family token instead of '_RAC_'/'_PRAC_'.
# Same TP1X/TP2X-class resource surface as the room-AC models above
# (confirmed by the issue #52 dump binding cleanly against the existing
# airconditioner registry once routed here), plus one new SAC-specific
# resource (see airconditioner.py's _AC_IGNORED).
if key is None and '-CAWW-' in (model_num or '').upper():
key = 'airconditioner'
# Dehumidifiers (e.g. TP1X_DA_AC_DHM_01001_0000, issue #88) share the
# DA_AC_ board family with the room-AC models above but carry the
# '_DHM_' (DeHuMidifier) token instead of '_RAC_'/'_PRAC_'. Distinct
# registry: target humidity, not temperature, is the primary control,
# and there's no climate composite.
if key is None and '_DHM_' in (model_num or ''):
key = 'dehumidifier'
# Window air conditioners (e.g. TP1X_DA_AC_WAC_01001_0000, issue #87)
# report no oneUiVersion and carry the '_WAC_' (Window Air Conditioner)
# token instead of '_RAC_'/'_PRAC_'. Same TP1X-class resource surface
# as the room-AC models above (mode/convenient/wind/temperature/power/
# filter/humidity all confirmed against the issue #87 dump binding
# cleanly against the existing airconditioner registry once routed
# here) -- no WAC-specific resources needed.
if key is None and '_WAC_' 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
# surface as the other TP1X-class room ACs above (mode/convenient/wind/
# temperature/power/filter/humidity all confirmed against these dumps
# binding cleanly against the existing airconditioner registry once
# routed here) -- no ARA-WW-specific resources needed, so this reuses
# the same registry rather than adding a new device type.
if key is None and 'ARA-WW-' in (model_num or '').upper():
key = 'airconditioner'
# Air purifiers (e.g. ARTIK051_TVTL_18K, issue #56) report no
# oneUiVersion either, and carry the '_TVTL_' board-family token.
if key is None and '_TVTL_' in (model_num or ''):
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'
# Water purifiers (e.g. TP2X_WATERPURIFIER_20K, issue #90) report no
# oneUiVersion and carry the 'WATERPURIFIER' board-family token.
if key is None and 'WATERPURIFIER' in model_identity:
key = 'water_purifier'
if key is None and model_identity.startswith('AHD-'):
key = 'range_hood'
# Range/cooktop-oven combos (e.g. TP1X_DA-KS-RANGE-0102X, issue #44) --
# like the RAC/PRAC air conditioners above, these report no oneUiVersion
# and don't match the washer/dryer/dishwasher consumer-prefix map either.
if key is None and '-RANGE-' in (model_num or '').upper():
key = 'range'
# Wall ovens (e.g. TP1X_DA-KS-OVEN-0107X, issue #55) -- same board-family
# naming as the range combo above, minus the burners; also reports no
# oneUiVersion and doesn't match the washer/dryer/dishwasher prefix map.
if key is None and '-OVEN-' in (model_num or '').upper():
key = 'oven'
# Microwaves, both combi (e.g. TP1X_DA-KS-MICROWAVE-01041, issue #121)
# and plain (e.g. TP2X_DA-KS-MICROWAVE-01011, issue #66) -- same board
# family as the wall oven above (an '/oven/vs/0' cavity resource, same
# /operational/state/vs/0 + /doors/vs/0 shape), but a distinct mode
# vocabulary (Convection/AirFryer/Grill/MicroWave*) and setpoint bounds
# from the oven registry, plus a powerLevel field ovens don't report --
# its own device type rather than folded into 'oven' (issue #121 shipped
# it onto the oven registry initially; split out per user feedback).
# Reports no oneUiVersion and doesn't match the washer/dryer/dishwasher
# prefix map either.
if key is None and '-MICROWAVE-' in (model_num or '').upper():
key = 'microwave'
# Standalone induction cooktops (e.g. TP1X_DA-KS-COOKTOP-01011, issue
# #86) -- same board family and '/cooktop/status/vs/0' resource shape
# as the range combo above, but no oven attached at all. Distinct from
# the '_COOKTOP'/'_GB_CT_' underscore-delimited check above: that one
# matches a different, older gas-cooktop family (cooktop.py's NA9300K
# class, burner state embedded in /mode/vs/0's options array) whose
# modelNum token is underscore-delimited, not hyphenated like this
# board family's.
if key is None and '-COOKTOP-' in (model_num or '').upper():
key = 'induction_cooktop'
# Stick-vacuum clean/auto-empty station (e.g. A-VSKR-TP1-22-VS9500AL,
# issue #131) -- reports no oneUiVersion and carries the '-VSKR-'
# board-family token. See capabilities/vacuum_station.py for why this
# is its own device type: the resource set (dustbag/dustbin/UV-sanitize
# station state) shares no hrefs with anything else already modeled.
if key is None and '-VSKR-' in (model_num or '').upper():
key = 'vacuum_station'
# 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.
if key is None:
key = _consumer_model_key(description)
key = (
_board_family_key(model_num, "|")
or _board_family_key(description, "/")
or _consumer_model_key(description)
)
return _REGISTRY_BY_KEY.get(key) if key else None
def for_device_by_resources(resources: dict[str, dict]) -> Optional[DeviceRegistry]:
def for_device_by_resources(resources: dict[str, dict]) -> DeviceRegistry | None:
"""Detect a device family from a distinctive local-resource signature.
Some newer cooktops omit both ``oneUiVersion`` and
``/information/vs/0``. Their mode resource still identifies them: it
contains a DeviceType option and multiple per-burner OperationState
options. Require both shapes so an oven's unrelated ``/mode/vs/0`` is
not misclassified.
This runs first as an override path for non-standard devices, not because
resource signatures are inherently more trustworthy than OIC/model
metadata. It also types boards that ship no ``/information/vs/0`` at all,
leaving `for_device_by_model` nothing to read. Some newer cooktops are the
original case: their mode resource still identifies them, carrying a
DeviceType option and multiple per-burner OperationState options.
Require two independent shapes for every signature here, never one, so
putting this ahead of OIC/model metadata cannot let a common resource
misclassify an unrelated family.
"""
mode = resources.get('/mode/vs/0', {})
options = mode.get('x.com.samsung.da.options') or ()
mode = resources.get("/mode/vs/0", {})
options = mode.get("x.com.samsung.da.options") or ()
has_device_type = any(
isinstance(option, str) and option.startswith('DeviceType_')
for option in options
isinstance(option, str) and option.startswith("DeviceType_") for option in options
)
operation_states = sum(
1 for option in options
if isinstance(option, str) and option.startswith('OperationState')
1 for option in options if isinstance(option, str) and option.startswith("OperationState")
)
if has_device_type and operation_states >= 2:
return _REGISTRY_BY_KEY['cooktop']
if (
'/hood/fanspeed/vs/0' in resources
and '/hood/lamp/vs/0' in resources
):
return _REGISTRY_BY_KEY['range_hood']
# Oven/range-combo boards that report no /information/vs/0 at all
# (issue #74's NE63B8411SS -- the resource is simply absent from the
# dump, not just empty) can't be matched via for_device_by_model's
# modelNum tokens either. 'Bake' is oven/range cook-mode vocabulary that
# no other family's /mode/vs/0 uses (confirmed against the microwave,
# cooktop, and every laundry fixture), so its presence alongside the
# oven cavity resource is a safe signature. Distinguish range (has a
# cooktop half) from a plain wall oven by which cooktop-status resource,
# if any, is also present.
supported_modes = mode.get('x.com.samsung.da.supportedModes') or ()
if '/oven/vs/0' in resources and 'Bake' in supported_modes:
if '/cooktopmonitoring/vs/0' in resources or '/cooktop/status/vs/0' in resources:
return _REGISTRY_BY_KEY['range']
return _REGISTRY_BY_KEY['oven']
return _REGISTRY_BY_KEY["cooktop"]
if "/hood/fanspeed/vs/0" in resources and "/hood/lamp/vs/0" in resources:
return _REGISTRY_BY_KEY["range_hood"]
# Oven/range/microwave boards that report no /information/vs/0 at all
# (issue #74's NE63B8411SS, issue #172's ME8000T -- the resource is simply
# absent from the dump, not just empty) can't be matched via
# for_device_by_model's modelNum tokens either. Mode vocabulary alongside
# the oven cavity resource (/oven/vs/0) is a safe two-resource signature;
# it also corrects Qooker's generic oic.d.oven / OVEN metadata (issue
# PR #225) when resource detection runs before metadata.
supported_modes = mode.get("x.com.samsung.da.supportedModes") or ()
if not isinstance(supported_modes, (list, tuple)):
supported_modes = ()
cavity = resources.get("/oven/vs/0")
if isinstance(cavity, dict):
if any(
m in supported_modes for m in ("MicroWave", "MicroWaveGrill", "MicroWaveConvection")
):
return _REGISTRY_BY_KEY["microwave"]
if "Bake" in supported_modes:
if "/cooktopmonitoring/vs/0" in resources or "/cooktop/status/vs/0" in resources:
return _REGISTRY_BY_KEY["range"]
return _REGISTRY_BY_KEY["oven"]
return None
def resolve(
resources: dict[str, dict],
device_types: Sequence[str] = (),
) -> DeviceRegistry | None:
"""Device type for a parsed /device/0 dump, or None if unrecognized.
The single entry point for detection -- the coordinator, the config
flow's probe and the golden-regression harness all call this, so the
order can't drift between what ships and what the tests assert.
Distinctive resource signatures run first because they describe the live
capability surface a registry must bind. They are deliberately strict in
`for_device_by_resources`: each requires multiple independent details, so
this can correct misleading metadata (Qooker's generic ``oic.d.oven``)
without a common href overriding an unrelated family. When no signature
matches, `/oic/d`'s `rt` (read separately from the /device/0 dump -- see
registry/identity.py) wins over model-string parsing.
`/otninformation/vs/0`'s oneUiVersion is deliberately not consulted. It
reads like the obvious signal -- the device naming its own type, e.g.
'7.0 Dishwasher' -- but only a minority of hardware populates it, every
device that does is already typed by its modelNum board token, and no
device-support issue has ever been fixed by adding a mapping for it. It
is still reported in diagnostics as a firmware-generation marker.
"""
info = resources.get("/information/vs/0", {})
return (
for_device_by_resources(resources)
or for_device_by_oic_type(device_types)
or for_device_by_model(
info.get("x.com.samsung.da.modelNum", ""),
info.get("x.com.samsung.da.description", ""),
)
)
@@ -1,4 +1,5 @@
"""Base DeviceRegistry dataclass and builder."""
from __future__ import annotations
from dataclasses import dataclass, field
@@ -9,6 +10,7 @@ from ..capability import Capability
@dataclass(frozen=True)
class DeviceRegistry:
"""Registry of capabilities for a specific device type."""
name: str
capabilities: dict[str, list[Capability]]
pattern_capabilities: list[Capability] = field(default_factory=list)
@@ -31,17 +33,19 @@ def _build(caps: list[Capability]) -> dict[str, list[Capability]]:
for cap in caps:
if cap.href is None:
raise ValueError(f"Use pattern_capabilities for href=None caps")
raise ValueError("Use pattern_capabilities for href=None caps")
if cap.href_prefix is not None:
raise ValueError(
f"href_prefix is only valid for pattern caps (href=None); "
f"cap with href={cap.href!r} must not set href_prefix")
f"cap with href={cap.href!r} must not set href_prefix"
)
out.setdefault(cap.href, []).append(cap)
# Validate that multi-cap hrefs have proper discrimination
for href, cs in out.items():
if len(cs) > 1 and any(c.rt_filter is None and c.match_fn is None for c in cs):
raise ValueError(
f"href {href!r} has multiple caps but at least one lacks rt_filter and match_fn")
f"href {href!r} has multiple caps but at least one lacks rt_filter and match_fn"
)
return out
@@ -0,0 +1,31 @@
"""AirDresser device registry (issue #162).
Reuses washer/dryer/dishwasher's shared laundry surface -- job-beginning
status, operational state, diagnosis, and (issue #208) the buzzer-sound
select -- /buzzersound/vs/0's rep on this board is the plain
{setBuzzerSound, supportedBuzzerSound} shape laundry.BUZZER_SOUND already
expects, no AirDresser-specific wiring needed. 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.BUZZER_SOUND,
laundry.JOB_BEGINNING_STATUS,
operational.OPERATIONAL_STATE,
dishwasher.DIAGNOSIS,
]
),
)
@@ -0,0 +1,26 @@
"""Air Monitor Plus device registry (issue #210).
A standalone, battery-powered air-quality sensor puck (ASM-KR-TP1-22-*
board) -- no controllable state at all beyond the do-not-disturb window,
so this registry is almost entirely sensors. No common.POWER: there's no
`/power/*` resource on this board, only `/energy/battery/vs/0`.
"""
from ..capabilities import air_monitor, common, ignored
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name="air_monitor",
capabilities=_build(
[
*ignored.IGNORED,
*common.UNIVERSAL,
*air_monitor.COVERAGE,
air_monitor.SENSORS,
air_monitor.HUMIDITY,
air_monitor.BATTERY,
air_monitor.AIR_QUALITY_STANDARD,
air_monitor.DND,
]
),
)
@@ -1,47 +1,60 @@
"""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):
- ARTIK051_TVTL-class (issue #56). Reports no oneUiVersion; resolved via
for_device_by_model's '_TVTL_' modelNum token (see registry.py).
- TP1X_DA-AC-AIR-class (issue #130). Self-reports oneUiVersion "7.0 Air
purifier", resolved via for_device(). Adds real fan-mode control plus
- ARTIK051_TVTL-class (issue #56). Resolved via the 'TVTL' modelNum board
token (see by_type/__init__.py).
- TP1X_DA-AC-AIR-class (issue #130). Resolved via the 'AIR' board token.
Adds real fan-mode control plus
display/HEPA-filter/pet-filter/sound resources the older family never
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). Resolved via the 'VTWW' board
token, added for it. Its fan
is WIND_STRENGTH_FAN on /wind/strength/vs/0 rather than FAN on
/mode/vs/0 -- see that capability's comment.
- AVT-WW-TP1-23-class (issue #190). A next-gen board in the same VTWW
lineage, resolved via its own 'AVT' board token since the '-WW-' delimiter
falls one letter to the left of 'VTWW's whole-token spelling. Same
resource surface as A-VTWW-TP2-21-COMMON above; no new capabilities
needed.
Reuses dishwasher.DIAGNOSIS for /diagnosis/vs/0 (identical field/write
contract).
"""
from ..capabilities import air_purifier, airconditioner, common, dishwasher, ignored
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='air_purifier',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
dishwasher.DIAGNOSIS,
air_purifier.AIR_QUALITY,
air_purifier.FILTER,
air_purifier.DEVICE_ACTIVE,
air_purifier.AIRFLOW_GENERIC,
air_purifier.AIRFLOW_VS_FALLBACK,
air_purifier.MODE,
air_purifier.FAN,
air_purifier.DISPLAY,
air_purifier.HEPA_FILTER,
air_purifier.PANEL_STATUS,
air_purifier.PET_FILTER_ACTIVATION,
air_purifier.SOUND_MODE,
air_purifier.SOUND_OUTPUT,
air_purifier.SOUND_VOLUME,
airconditioner.DISPLAY_LIGHT,
airconditioner.MUTE_ONCE,
*air_purifier.COVERAGE,
]),
name="air_purifier",
capabilities=_build(
[
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
dishwasher.DIAGNOSIS,
air_purifier.AIR_QUALITY,
air_purifier.FILTER,
air_purifier.DEVICE_ACTIVE,
air_purifier.AIRFLOW_GENERIC,
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,
air_purifier.PET_FILTER_ACTIVATION,
air_purifier.SOUND_MODE,
air_purifier.SOUND_OUTPUT,
air_purifier.SOUND_VOLUME,
airconditioner.DISPLAY_LIGHT,
airconditioner.MUTE_ONCE,
*air_purifier.COVERAGE,
]
),
)
@@ -7,28 +7,43 @@ this registry includes *common.UNIVERSAL but deliberately NOT common.POWER --
on/off is the climate entity's HVACMode.OFF / TURN_ON/OFF. See common.POWER's
own comment in capabilities/common.py for why it's excluded.
common.ENERGY_METER itself is also excluded from UNIVERSAL here, replaced by
the ENERGY_METER_GENERIC/ENERGY_METER_LEGACY pair -- the legacy ARTIK051 board
generation (issue #193) reports cumulativePower in a different unit than
every other AC family, so this registry needs two mutually-exclusive variants
of that one capability instead of the single shared one every other registry
uses unconditionally.
Reuses dishwasher.DIAGNOSIS for /diagnosis/vs/0.
"""
from ..capabilities import airconditioner, common, dishwasher, ignored
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='airconditioner',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
dishwasher.DIAGNOSIS,
airconditioner.CLIMATE,
airconditioner.AIR_PURIFY,
airconditioner.AUTO_CLEAN,
airconditioner.AIR_FILTER,
airconditioner.DISPLAY_LIGHT,
airconditioner.MUTE_ONCE,
airconditioner.CURRENT_LIMIT,
airconditioner.ANOMALY_LOAD,
airconditioner.CURRENT_TEMPERATURE,
airconditioner.CURRENT_TEMPERATURE_VS,
airconditioner.HUMIDITY,
*airconditioner.COVERAGE,
]),
name="airconditioner",
capabilities=_build(
[
*ignored.IGNORED,
*[c for c in common.UNIVERSAL if c is not common.ENERGY_METER],
airconditioner.ENERGY_METER_GENERIC,
airconditioner.ENERGY_METER_LEGACY,
dishwasher.DIAGNOSIS,
airconditioner.CLIMATE,
airconditioner.AIR_PURIFY,
airconditioner.AUTO_CLEAN,
airconditioner.AIR_FILTER,
airconditioner.AIR_QUALITY,
airconditioner.DISPLAY_LIGHT,
airconditioner.MUTE_ONCE,
airconditioner.CURRENT_LIMIT,
airconditioner.ANOMALY_LOAD,
airconditioner.ABSENCE_POWER_SAVING,
airconditioner.MOTION_DETECT_WIND,
airconditioner.CURRENT_TEMPERATURE,
airconditioner.CURRENT_TEMPERATURE_VS,
airconditioner.HUMIDITY,
*airconditioner.COVERAGE,
]
),
)
@@ -4,24 +4,24 @@ Named 'gas_cooktop' (not 'cooktop') so its diagnostics/device-info label
doesn't collide with the unrelated induction_cooktop family (issue #86,
by_type/induction_cooktop.py) -- two different OCF surfaces that happen to
share the English word "cooktop". The `_REGISTRY_BY_KEY['cooktop']` lookup
key is unchanged: it's relied on by real devices reporting
oneUiVersion "Cooktop" (for_device), the legacy ARTIK051 modelNum rule
(for_device_by_model), and the resource-signature fallback
key is unchanged: it's relied on by the legacy ARTIK051 'CT' modelNum token
(for_device_by_model) and the resource-signature fallback
(for_device_by_resources) alike.
"""
from ..capabilities import common, cooktop, ignored
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='gas_cooktop',
capabilities=_build([
*ignored.IGNORED,
cooktop.COOKTOP_POWER,
cooktop.COOKTOP_MODE,
cooktop.COOKTOP_CONNECTED,
cooktop.PAIRED_HOOD_STATUS,
common.FIRMWARE_UPDATE,
]),
name="gas_cooktop",
capabilities=_build(
[
*ignored.IGNORED,
cooktop.COOKTOP_POWER,
cooktop.COOKTOP_MODE,
cooktop.COOKTOP_CONNECTED,
cooktop.PAIRED_HOOD_STATUS,
common.FIRMWARE_UPDATE,
]
),
)
@@ -4,20 +4,23 @@ Shares the DA_AC_ board family with airconditioner.py (power, air filter,
auto-clean, mute-once all use the identical resource shapes), so those three
Capability objects are reused directly rather than duplicated.
"""
from ..capabilities import airconditioner, common, dehumidifier, ignored
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='dehumidifier',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
dehumidifier.MODE,
dehumidifier.HUMIDITY,
airconditioner.AUTO_CLEAN,
airconditioner.AIR_FILTER,
airconditioner.MUTE_ONCE,
*dehumidifier.COVERAGE,
]),
name="dehumidifier",
capabilities=_build(
[
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
dehumidifier.MODE,
dehumidifier.HUMIDITY,
airconditioner.AUTO_CLEAN,
airconditioner.AIR_FILTER,
airconditioner.MUTE_ONCE,
*dehumidifier.COVERAGE,
]
),
)
@@ -1,23 +1,26 @@
"""Dishwasher device registry."""
from ..capabilities import common, dishwasher, ignored, laundry, operational
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='dishwasher',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
common.WATER_METER,
common.WATER_FILTER,
operational.OPERATIONAL_STATE,
dishwasher.CYCLE_OPTIONS,
dishwasher.DISHWASHER_SETTINGS,
dishwasher.DIAGNOSIS,
dishwasher.OPERATION_ORIGIN,
laundry.JOB_BEGINNING_STATUS,
laundry.DOOR_LED,
laundry.SOUND_MODE,
laundry.SOUND_VOLUME,
]),
name="dishwasher",
capabilities=_build(
[
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
common.WATER_METER,
common.WATER_FILTER,
operational.OPERATIONAL_STATE,
dishwasher.CYCLE_OPTIONS,
dishwasher.DISHWASHER_SETTINGS,
dishwasher.DIAGNOSIS,
dishwasher.OPERATION_ORIGIN,
laundry.JOB_BEGINNING_STATUS,
laundry.DOOR_LED,
laundry.SOUND_MODE,
laundry.SOUND_VOLUME,
]
),
)
@@ -9,22 +9,25 @@ objects the washer registry uses -- washer and dryer expose the same
DA_WM_-family surface, so they stay consistent instead of each carrying a
bespoke variant.
"""
from ..capabilities import common, dryer, ignored, laundry, operational
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='dryer',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
operational.OPERATIONAL_STATE,
laundry.DOOR_LED,
laundry.SOUND_MODE,
laundry.BUZZER_SOUND,
laundry.JOB_BEGINNING_STATUS,
dryer.DRYER_SETTINGS,
dryer.DRYER_COURSE,
dryer.DRYER_DIAGNOSIS,
]),
name="dryer",
capabilities=_build(
[
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
operational.OPERATIONAL_STATE,
laundry.DOOR_LED,
laundry.SOUND_MODE,
laundry.BUZZER_SOUND,
laundry.JOB_BEGINNING_STATUS,
dryer.DRYER_SETTINGS,
dryer.DRYER_COURSE,
dryer.DRYER_DIAGNOSIS,
]
),
)
@@ -0,0 +1,32 @@
"""EHS (Eco Heating System) air-to-water heat pump device registry
(Samsung TP1X_DA_AC_EHS-class).
Shares the DA_AC_ board prefix with the room-AC family in
airconditioner.py, but its /mode/*/vs/0 and /temperatures/*/vs/0 resources
are its own shape (two independent loops: zone1 space heating/cooling and
dhw domestic hot water), not airconditioner.py's HREF_MODE/HREF_TEMP* OCF
pattern -- so nothing from that module is reused here except MUTE_ONCE,
whose /option/muteonce/vs/0 field shape (`muteonce`) is identical on this
family's dump.
"""
from ..capabilities import airconditioner, common, ehs, ignored
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name="ehs",
capabilities=_build(
[
*ignored.IGNORED,
*common.UNIVERSAL,
airconditioner.MUTE_ONCE,
ehs.ZONE_POWER,
ehs.ZONE_MODE,
ehs.ZONE_TEMPERATURE,
ehs.DHW,
*ehs.DHW_CONSUMED,
ehs.AWAY_MODE,
*ehs.COVERAGE,
]
),
)
@@ -11,20 +11,24 @@ units pair with. Distinct registry key from cooktop.REGISTRY ('cooktop')
embedded in /mode/vs/0's options array, a completely different OCF
surface that happens to share the English word "cooktop").
"""
from ..capabilities import common, cooktop as cooktop_caps, ignored
from ..capabilities import common, ignored
from ..capabilities import cooktop as cooktop_caps
from ..capabilities import range as range_caps
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='induction_cooktop',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
range_caps.COOKTOP_STATUS,
range_caps.COOKTOP_SPEC,
range_caps.COOKTOP_SAFETY,
range_caps.PROBE_STATUS,
cooktop_caps.PAIRED_HOOD_STATUS,
]),
name="induction_cooktop",
capabilities=_build(
[
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
range_caps.COOKTOP_STATUS,
range_caps.COOKTOP_SPEC,
range_caps.COOKTOP_SAFETY,
range_caps.PROBE_STATUS,
cooktop_caps.PAIRED_HOOD_STATUS,
]
),
)
@@ -16,22 +16,25 @@ has no sibling `/power/0` or `/power/vs/0` resource; fan.py's
LocalThingsRangeHoodFan falls back to treating fan speed 0 as off in that
case (see its `_speed_zero_is_off` check).
"""
from ..capabilities import common, ignored, microwave, oven, range_hood
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='microwave',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
microwave.MICROWAVE_CAVITY,
microwave.MICROWAVE_SETPOINT,
microwave.MICROWAVE_MODE,
oven.OVEN_OPERATIONAL_STATE,
oven.OVEN_DOOR,
oven.OVEN_CONNECTED,
oven.OVEN_RECIPE_COOK,
range_hood.HOOD_FAN,
]),
name="microwave",
capabilities=_build(
[
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
microwave.MICROWAVE_CAVITY,
microwave.MICROWAVE_SETPOINT,
microwave.MICROWAVE_MODE,
oven.OVEN_OPERATIONAL_STATE,
oven.OVEN_DOOR,
oven.OVEN_CONNECTED,
oven.OVEN_RECIPE_COOK,
range_hood.HOOD_FAN,
]
),
)
@@ -1,20 +1,23 @@
"""Oven device registry."""
from ..capabilities import common, ignored, oven
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='oven',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
oven.OVEN_CAVITY,
oven.OVEN_SETPOINT,
oven.OVEN_MODE,
oven.OVEN_OPERATIONAL_STATE,
oven.OVEN_DOOR,
oven.OVEN_CONNECTED,
oven.OVEN_SPEC,
oven.OVEN_RECIPE_COOK,
]),
name="oven",
capabilities=_build(
[
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
oven.OVEN_CAVITY,
oven.OVEN_SETPOINT,
oven.OVEN_MODE,
oven.OVEN_OPERATIONAL_STATE,
oven.OVEN_DOOR,
oven.OVEN_CONNECTED,
oven.OVEN_SPEC,
oven.OVEN_RECIPE_COOK,
]
),
)
@@ -5,26 +5,29 @@ connected capabilities wholesale (a range's oven half is the same OCF
surface as a standalone oven) and adds the cooktop-specific capabilities
for the burner half.
"""
from ..capabilities import common, ignored, oven
from ..capabilities import range as range_caps
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='range',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
oven.OVEN_CAVITY,
oven.OVEN_SETPOINT,
oven.OVEN_MODE,
oven.OVEN_OPERATIONAL_STATE,
oven.OVEN_DOOR,
oven.OVEN_CONNECTED,
oven.OVEN_SPEC,
range_caps.COOKTOP_STATUS,
range_caps.COOKTOP_SPEC,
range_caps.COOKTOP_SAFETY,
range_caps.COOKTOP_MONITORING,
]),
name="range",
capabilities=_build(
[
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
oven.OVEN_CAVITY,
oven.OVEN_SETPOINT,
oven.OVEN_MODE,
oven.OVEN_OPERATIONAL_STATE,
oven.OVEN_DOOR,
oven.OVEN_CONNECTED,
oven.OVEN_SPEC,
range_caps.COOKTOP_STATUS,
range_caps.COOKTOP_SPEC,
range_caps.COOKTOP_SAFETY,
range_caps.COOKTOP_MONITORING,
]
),
)
@@ -3,21 +3,22 @@
from ..capabilities import common, ignored, range_hood
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='range_hood',
capabilities=_build([
*ignored.IGNORED,
range_hood.HOOD_ALARMS,
common.ENERGY_METER,
common.FIRMWARE_UPDATE,
range_hood.AFTER_RUN,
range_hood.HOOD_FAN,
range_hood.HOOD_LAMP,
range_hood.HOOD_FILTER,
range_hood.AIR_QUALITY,
range_hood.AIR_LEVEL_CHECK,
range_hood.AUTO_VENTILATION,
*range_hood.COVERAGE,
]),
name="range_hood",
capabilities=_build(
[
*ignored.IGNORED,
range_hood.HOOD_ALARMS,
common.ENERGY_METER,
common.FIRMWARE_UPDATE,
range_hood.AFTER_RUN,
range_hood.HOOD_FAN,
range_hood.HOOD_LAMP,
range_hood.HOOD_FILTER,
range_hood.AIR_QUALITY,
range_hood.AIR_LEVEL_CHECK,
range_hood.AUTO_VENTILATION,
*range_hood.COVERAGE,
]
),
)
@@ -1,36 +1,40 @@
"""Refrigerator device registry."""
from ..capabilities import common, dishwasher, fridge, ignored
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='refrigerator',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
fridge.STATUS_LOCK,
fridge.DOOR_ALERT,
common.WATER_FILTER,
dishwasher.DIAGNOSIS,
fridge.ICEMAKER_NIGHTTIME,
fridge.FLEX_ZONE,
fridge.REFRIGERATION,
fridge.AUTOFILL,
fridge.WELCOME_LIGHTING,
fridge.CABINET_LIGHT,
fridge.CABINET_LIGHT_ENHANCED,
fridge.SABBATH,
fridge.BEVERAGE_ZONE,
fridge.PANTRY_ZONE,
fridge.DEFROST_DELAY,
fridge.DEFROST_DELAY_NATIVE_DUPLICATE,
fridge.DEFROST_BLOCK_STATUS,
fridge.DOORS_FALLBACK,
fridge.TEMPERATURES_FALLBACK,
fridge.ICEMAKER_STATUS_FALLBACK,
fridge.ICEMAKER_STATUS_NATIVE_DUPLICATE,
fridge.REFRIGERATION_FALLBACK,
]),
name="refrigerator",
capabilities=_build(
[
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
fridge.STATUS_LOCK,
fridge.DOOR_ALERT,
common.WATER_FILTER,
dishwasher.DIAGNOSIS,
fridge.ICEMAKER_NIGHTTIME,
fridge.FLEX_ZONE,
fridge.REFRIGERATION,
fridge.AUTOFILL,
fridge.WELCOME_LIGHTING,
fridge.CABINET_LIGHT,
fridge.CABINET_LIGHT_ENHANCED,
fridge.SABBATH,
fridge.BEVERAGE_ZONE,
fridge.PANTRY_ZONE,
fridge.DEFROST_DELAY,
fridge.DEFROST_DELAY_NATIVE_DUPLICATE,
fridge.DEFROST_BLOCK_STATUS,
fridge.DEFINITE_TEMPERATURE_COOLER,
fridge.DOORS_FALLBACK,
fridge.TEMPERATURES_FALLBACK,
fridge.ICEMAKER_STATUS_FALLBACK,
fridge.ICEMAKER_STATUS_NATIVE_DUPLICATE,
fridge.REFRIGERATION_FALLBACK,
]
),
pattern_capabilities=[
fridge.TEMP_CURRENT_GENERIC,
fridge.TEMP_SETPOINT,
@@ -5,18 +5,21 @@ covers the station's own dustbag/dustbin/UV-sanitize state and not any
vacuum-body control (suction, battery, cleaning mode) -- the diagnostics
dump this was built from reports none of that.
"""
from ..capabilities import common, ignored, vacuum_station
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='vacuum_station',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
vacuum_station.DUSTBAG,
vacuum_station.DUSTBAG_USAGE,
vacuum_station.DUSTBIN_SETTING,
vacuum_station.CLEANSTATION_STATUS,
]),
name="vacuum_station",
capabilities=_build(
[
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
vacuum_station.DUSTBAG,
vacuum_station.DUSTBAG_USAGE,
vacuum_station.DUSTBIN_SETTING,
vacuum_station.CLEANSTATION_STATUS,
]
),
)
@@ -1,19 +1,22 @@
"""Washer device registry."""
from ..capabilities import common, dishwasher, ignored, laundry, operational, washer
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='washer',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
washer.WASHER_SETTINGS,
washer.WASHER_COURSE,
laundry.BUZZER_SOUND,
laundry.JOB_BEGINNING_STATUS,
common.WATER_METER,
operational.OPERATIONAL_STATE,
dishwasher.DIAGNOSIS,
]),
name="washer",
capabilities=_build(
[
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
washer.WASHER_SETTINGS,
washer.WASHER_COURSE,
laundry.BUZZER_SOUND,
laundry.JOB_BEGINNING_STATUS,
common.WATER_METER,
operational.OPERATIONAL_STATE,
dishwasher.DIAGNOSIS,
]
),
)
@@ -1,20 +1,27 @@
"""Water-purifier device registry (Samsung TP2X_WATERPURIFIER-class, issue #90).
"""
"""Water-purifier device registry (Samsung TP2X_WATERPURIFIER-class, issue #90)."""
from ..capabilities import common, ignored, water_purifier
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='water_purifier',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
common.WATER_FILTER,
water_purifier.DISPENSE,
water_purifier.STATUS,
water_purifier.FAVORITE_CAPACITY,
water_purifier.FAVORITE_HOTWATER,
water_purifier.COFFEE,
water_purifier.LOCK,
*water_purifier.COVERAGE,
]),
name="water_purifier",
capabilities=_build(
[
*ignored.IGNORED,
*common.UNIVERSAL,
common.WATER_FILTER,
water_purifier.DISPENSE,
water_purifier.STATUS,
water_purifier.FAVORITE_CAPACITY,
water_purifier.FAVORITE_HOTWATER,
water_purifier.COFFEE,
water_purifier.LOCK,
water_purifier.CUP_STATE,
water_purifier.SOUND_MODE,
water_purifier.SOUND_OUTPUT,
water_purifier.SOUND_VOLUME,
water_purifier.STATISTIC_POUR,
*water_purifier.COVERAGE,
]
),
)
@@ -1,8 +1,12 @@
from . import (
common, cooktop, dishwasher, fridge, ignored, laundry, operational, oven,
range_hood,
)
from ..capability import Capability
from . import (
common,
fridge,
ignored,
laundry,
operational,
oven,
)
def _is_capability(v):
@@ -18,5 +22,13 @@ _OVEN_GLOBAL_CAPS = [
oven.OVEN_CAVITY,
]
ALL = [v for mod in (common, operational, laundry, fridge)
for v in vars(mod).values() if _is_capability(v)] + _OVEN_GLOBAL_CAPS + ignored.IGNORED
ALL = (
[
v
for mod in (common, operational, laundry, fridge)
for v in vars(mod).values()
if _is_capability(v)
]
+ _OVEN_GLOBAL_CAPS
+ ignored.IGNORED
)
@@ -0,0 +1,90 @@
"""Capabilities specific to the AirDresser family (Samsung DA_DF-class,
issues #162/#157).
This board family carried no /information/vs/0 token any existing family
routed on, so it gets its own device type (the 'DF' board token) -- 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,
),
),
)
@@ -0,0 +1,191 @@
"""Capabilities for the Samsung Air Monitor Plus family (ASM-KR-TP1-22-*
board, issue #210) -- a small battery-powered standalone air-quality sensor
puck, not a controllable appliance. No `/power/*` resource at all (battery
only); this registry deliberately doesn't include common.POWER.
`/sensors/vs/0` is the same {type, value: [...]} items-list shape
air_purifier.AIR_QUALITY and range_hood.AIR_QUALITY already read via
common.sensor_item_value -- reused here rather than re-decoded, including
the same dust/fine_dust/super_fine_dust/odor/clean_level keys so this
device shares those capabilities' catalog entries. This board additionally
reports a CO2 reading the other two families don't.
A second `value` list element on the particulate-matter types (e.g. Dust's
`['31', '2']`) reads like a coarse quality-grade code, but nothing on this
board (no `supportedGrades`/similar field, no repeated dump to compare
against) confirms what its scale means -- left unbound rather than guessed,
per the adding-device-support skill's "still never invent... from nothing"
rule. Same reasoning `air_purifier.AIR_QUALITY` already applies to this
shape; index 0 is the only slot any family has ever read.
Dust/FineDust/SuperFineDust aren't assigned an HA `device_class`
(pm10/pm25/pm1) or `unit` despite the values reading like plausible
ug/m3 particulate readings in a physically consistent order (coarser
>= finer): Samsung's own two-tier Korean convention (i.e. "fine dust"/
"ultra-fine dust") maps only to a PM10/PM2.5 pair, and this board's
three-tier naming doesn't confirm where the extra tier or a PM1 reading
actually fits. The adding-device-support skill's read-side rule says
leave unit/device_class unset when the dump gives no field that
nominates one -- a wrong guess would silently mislabel every reading
forever, and the write-side rejection safety net doesn't cover reads.
Exposed as plain `measurement` sensors named after the device's own
field instead (matching air_purifier.AIR_QUALITY's existing precedent).
"""
from datetime import time as dt_time
from ..capability import Capability
from ..entities import BinarySensorDesc, SensorDesc, SwitchDesc, TimeDesc
from .air_purifier import _AIR_QUALITY_SENSORS
from .common import int_or_none, sensor_item_value
SENSORS = Capability(
href="/sensors/vs/0",
poll_tier="warm",
entities=(
*(
SensorDesc(
key=key,
field="x.com.samsung.da.items",
icon=icon,
state_class="measurement",
value_fn=lambda items, t=sensor_type: sensor_item_value(items, t),
)
for key, icon, sensor_type in _AIR_QUALITY_SENSORS
),
SensorDesc(
key="co2",
field="x.com.samsung.da.items",
device_class="carbon_dioxide",
state_class="measurement",
unit="ppm",
value_fn=lambda items: sensor_item_value(items, "CO2"),
),
),
)
HUMIDITY = Capability(
href="/humidity/vs/0",
poll_tier="warm",
entities=(
SensorDesc(
key="humidity",
field="x.com.samsung.da.humidity",
device_class="humidity",
state_class="measurement",
unit="%",
value_fn=int_or_none,
),
),
)
BATTERY = Capability(
href="/energy/battery/vs/0",
poll_tier="warm",
entities=(
SensorDesc(
key="battery",
field="x.com.samsung.da.battery",
device_class="battery",
state_class="measurement",
unit="%",
entity_category="diagnostic",
value_fn=int_or_none,
),
BinarySensorDesc(
key="battery_charging",
field="x.com.samsung.da.charging",
device_class="battery_charging",
entity_category="diagnostic",
value_fn=lambda v: v == "On",
),
),
)
AIR_QUALITY_STANDARD = Capability(
href="/airqualitystandard/vs/0",
poll_tier="cold",
entities=(
SensorDesc(
key="air_quality_standard",
field="x.com.samsung.da.standard",
entity_category="diagnostic",
),
),
)
def _parse_hms(v):
"""'HH:MM:SS' -> datetime.time, same contract as laundry._parse_hm but
tolerant of the trailing ':SS' this board's dnd start/end times carry."""
if not v:
return None
try:
parts = v.split(":")
return dt_time(int(parts[0]), int(parts[1]))
except (ValueError, IndexError):
return None
def _dnd_time_write(field):
def _write(p, rep, href=None):
return ["dnd", "vs", "0"], {field: f"{p.hour:02d}:{p.minute:02d}:00"}
return _write
# Issue #210: no idle-vs-active dump pair exists for this href (only one
# dump total, DND never toggled in it), so this write contract is an
# educated guess, not a confirmed one -- symmetric with the read side
# (writing the same 'true'/'false' string shape and 'HH:MM:SS' format the
# device itself reports back) rather than invented from nothing, but still
# needs a reporter to actually flip it on real hardware and confirm. See
# the adding-device-support skill's "Educated guesses are fine" section.
DND = Capability(
href="/dnd/vs/0",
poll_tier="cold",
entities=(
SwitchDesc(
key="dnd",
field="x.com.samsung.da.value",
icon="mdi:sleep",
entity_category="config",
value_fn=lambda v: v == "true",
write_fn=lambda p, rep, href=None: (
["dnd", "vs", "0"],
{"x.com.samsung.da.value": "true" if p == "On" else "false"},
),
),
TimeDesc(
key="dnd_start",
field="x.com.samsung.da.startTime",
icon="mdi:clock-start",
entity_category="config",
value_fn=_parse_hms,
write_fn=_dnd_time_write("x.com.samsung.da.startTime"),
),
TimeDesc(
key="dnd_end",
field="x.com.samsung.da.endTime",
icon="mdi:clock-end",
entity_category="config",
value_fn=_parse_hms,
write_fn=_dnd_time_write("x.com.samsung.da.endTime"),
),
),
)
# ---------------------------------------------------------------------------
# Air-monitor-scoped coverage: hrefs with no explainable live state,
# following the 'don't guess' rule.
# ---------------------------------------------------------------------------
_AM_IGNORED = [
# A single bare integer ('keepnormal': 0) with no description, no
# supported-values list, and no second dump to compare against -- opaque.
"/keepnormalstate/vs/0",
# {'remove': ''} -- looks like data-sink/cache-clearing plumbing, not a
# live user-facing field.
"/sensordatasinks/vs/0",
]
COVERAGE = [Capability(href=h) for h in _AM_IGNORED]
@@ -57,9 +57,15 @@ was unreliable on both units in that second round (Low/Medium collided on
one unit, stuck at 0 throughout on the other), so AIRFLOW_VS_FALLBACK below
stays a plain read-only diagnostic even after this change.
"""
from ..capability import Capability
from ..entities import (
BinarySensorDesc, FanDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc,
BinarySensorDesc,
FanDesc,
NumberDesc,
SelectDesc,
SensorDesc,
SwitchDesc,
)
from .common import filter_usage_percent, int_or_none, sensor_item_value
from .laundry import bool_option_exists, bool_option_value, option_value, option_write
@@ -72,27 +78,33 @@ from .laundry import bool_option_exists, bool_option_value, option_value, option
# supportedModes at all (see the module docstring's Comode_Off finding).
# Both board generations share the /mode/vs/0 href, so FAN and MODE below
# are mutually exclusive via this presence check rather than colliding.
HREF_MODE = '/mode/vs/0'
HREF_AIRFLOW = '/airflow/0'
HREF_MODE = "/mode/vs/0"
HREF_AIRFLOW = "/airflow/0"
HREF_WIND_STRENGTH = "/wind/strength/vs/0"
def _has_top_level_modes(rep, resources):
return isinstance(rep.get('x.com.samsung.da.supportedModes'), (list, tuple))
return isinstance(rep.get("x.com.samsung.da.supportedModes"), (list, tuple))
_AIR_QUALITY_SENSORS = (
('dust', 'mdi:blur', 'Dust'),
('fine_dust', 'mdi:blur', 'FineDust'),
('super_fine_dust', 'mdi:blur', 'SuperFineDust'),
('odor', 'mdi:scent', 'Odor'),
('clean_level', 'mdi:air-filter', 'CleanLevel'),
("dust", "mdi:blur", "Dust"),
("fine_dust", "mdi:blur", "FineDust"),
("super_fine_dust", "mdi:blur", "SuperFineDust"),
("odor", "mdi:scent", "Odor"),
("clean_level", "mdi:air-filter", "CleanLevel"),
)
AIR_QUALITY = Capability(
href='/sensors/vs/0',
poll_tier='warm',
href="/sensors/vs/0",
poll_tier="warm",
entities=tuple(
SensorDesc(key=key, field='x.com.samsung.da.items', icon=icon,
value_fn=lambda items, t=sensor_type: sensor_item_value(items, t))
SensorDesc(
key=key,
field="x.com.samsung.da.items",
icon=icon,
value_fn=lambda items, t=sensor_type: sensor_item_value(items, t),
)
for key, icon, sensor_type in _AIR_QUALITY_SENSORS
),
)
@@ -102,8 +114,8 @@ def _consumable_state(items, name):
"""Read a `/consumable/vs/0`-style items[] entry -- {name, state} pairs,
unlike AIR_QUALITY's {type, value} shape above."""
for item in items or ():
if isinstance(item, dict) and item.get('x.com.samsung.da.name') == name:
return item.get('x.com.samsung.da.state')
if isinstance(item, dict) and item.get("x.com.samsung.da.name") == name:
return item.get("x.com.samsung.da.state")
return None
@@ -114,38 +126,53 @@ def _consumable_state(items, name):
# which counts the same direction) rather than "filter life," which would
# imply the opposite direction.
FILTER = Capability(
href='/consumable/vs/0',
poll_tier='cold',
href="/consumable/vs/0",
poll_tier="cold",
entities=(
SensorDesc(key='filter_progress', field='x.com.samsung.da.items',
unit='%', state_class='measurement',
icon='mdi:air-filter', entity_category='diagnostic',
value_fn=lambda items: int_or_none(
_consumable_state(items, 'FilterProgress'))),
SensorDesc(
key="filter_progress",
field="x.com.samsung.da.items",
unit="%",
state_class="measurement",
icon="mdi:air-filter",
entity_category="diagnostic",
value_fn=lambda items: int_or_none(_consumable_state(items, "FilterProgress")),
),
),
)
DEVICE_ACTIVE = Capability(
href='/devicespecificinfo/vs/0',
poll_tier='cold',
href="/devicespecificinfo/vs/0",
poll_tier="cold",
entities=(
BinarySensorDesc(key='device_active', field='x.com.samsung.da.deviceActive',
icon='mdi:check-network-outline',
entity_category='diagnostic',
value_fn=lambda v: bool(v)),
BinarySensorDesc(
key="device_active",
field="x.com.samsung.da.deviceActive",
icon="mdi:check-network-outline",
entity_category="diagnostic",
value_fn=lambda v: bool(v),
),
),
)
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'})
if kind == 'speed':
return ['airflow', '0'], {'speed': int(value)}
if kind == "power":
return _power_write(args[0] if args else "/power/vs/0", value)
if kind == "speed":
return ["airflow", "0"], {"speed": int(value)}
return None
@@ -169,12 +196,15 @@ def _airflow_fan_write(payload, rep, href=None):
# same key would collide if a future board ever reported both.
AIRFLOW_GENERIC = Capability(
href=HREF_AIRFLOW,
poll_tier='warm',
poll_tier="warm",
entities=(
FanDesc(key='airflow_fan', field='speed', write_fn=_airflow_fan_write),
SensorDesc(key='fan_direction', field='direction',
icon='mdi:rotate-3d-variant',
entity_category='diagnostic'),
FanDesc(key="airflow_fan", field="speed", write_fn=_airflow_fan_write),
SensorDesc(
key="fan_direction",
field="direction",
icon="mdi:rotate-3d-variant",
entity_category="diagnostic",
),
),
)
@@ -183,17 +213,24 @@ AIRFLOW_GENERIC = Capability(
# docstring): it collided Low/Medium on one unit and stuck at 0 throughout
# on the other in the same properly-spaced round.
AIRFLOW_VS_FALLBACK = Capability(
href='/airflow/vs/0',
match_fn=lambda rep, resources: '/airflow/0' not in resources,
poll_tier='warm',
href="/airflow/vs/0",
match_fn=lambda rep, resources: "/airflow/0" not in resources,
poll_tier="warm",
entities=(
SensorDesc(key='fan_speed_level', field='x.com.samsung.da.speedLevel',
icon='mdi:fan',
state_class='measurement', entity_category='diagnostic',
value_fn=int_or_none),
SensorDesc(key='fan_direction', field='x.com.samsung.da.direction',
icon='mdi:rotate-3d-variant',
entity_category='diagnostic'),
SensorDesc(
key="fan_speed_level",
field="x.com.samsung.da.speedLevel",
icon="mdi:fan",
state_class="measurement",
entity_category="diagnostic",
value_fn=int_or_none,
),
SensorDesc(
key="fan_direction",
field="x.com.samsung.da.direction",
icon="mdi:rotate-3d-variant",
entity_category="diagnostic",
),
),
)
@@ -205,45 +242,43 @@ def _light_write(payload, rep, href=None):
# merges the same way everywhere. If some unit replaces the field
# outright instead, this would drop Comode/OptionCode alongside it on
# the next light toggle; revisit if a real device report surfaces that.
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': option_write('Light', payload),
return ["mode", "vs", "0"], {
"x.com.samsung.da.options": option_write("Light", payload),
}
MODE = Capability(
href='/mode/vs/0',
poll_tier='warm',
href="/mode/vs/0",
poll_tier="warm",
match_fn=lambda rep, resources: not _has_top_level_modes(rep, resources),
entities=(
SwitchDesc(key='display_light', icon='mdi:led-on',
entity_category='config',
rep_fn=bool_option_value('Light'),
exists_fn=bool_option_exists('Light'),
write_fn=_light_write),
SwitchDesc(
key="display_light",
icon="mdi:led-on",
entity_category="config",
rep_fn=bool_option_value("Light"),
exists_fn=bool_option_exists("Light"),
write_fn=_light_write,
),
# Read-only -- confirmed NOT the fan-speed selector (see module
# docstring), actual purpose still unconfirmed.
SensorDesc(key='operating_mode', icon='mdi:fan',
entity_category='diagnostic',
rep_fn=lambda rep: option_value(rep.get('x.com.samsung.da.options'), 'Comode'),
exists_fn=bool_option_exists('Comode')),
SensorDesc(
key="operating_mode",
icon="mdi:fan",
entity_category="diagnostic",
rep_fn=lambda rep: option_value(rep.get("x.com.samsung.da.options"), "Comode"),
exists_fn=bool_option_exists("Comode"),
),
),
)
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'})
if kind == 'mode':
return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': [value]}
if kind == "power":
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
@@ -251,7 +286,7 @@ def _first_fan_mode(rep):
"""Representative scalar for the fan entity in the flattened state
(golden/regression), mirroring airconditioner.py's own _first_mode --
the real entity computes its state from live coordinator reads."""
modes = rep.get('x.com.samsung.da.modes')
modes = rep.get("x.com.samsung.da.modes")
if isinstance(modes, (list, tuple)):
return modes[0] if modes else None
return modes
@@ -264,11 +299,51 @@ def _first_fan_mode(rep):
# convenient modes are modeled as a preset rather than a speed number.
FAN = Capability(
href=HREF_MODE,
poll_tier='warm',
poll_tier="warm",
match_fn=_has_top_level_modes,
entities=(
FanDesc(key='fan', translation_key='air_purifier_fan',
rep_fn=_first_fan_mode, write_fn=_fan_write),
FanDesc(
key="fan",
translation_key="air_purifier_fan",
rep_fn=_first_fan_mode,
write_fn=_fan_write,
),
),
)
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,
),
),
)
@@ -282,16 +357,20 @@ FAN = Capability(
# separate hrefs on this dump, so they're two independent physical controls,
# not a duplicate encoding of one.
DISPLAY = Capability(
href='/display/vs/0',
poll_tier='cold',
href="/display/vs/0",
poll_tier="cold",
entities=(
SwitchDesc(key='display', field='mode',
icon='mdi:monitor',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['display', 'vs', '0'],
{'mode': 'On' if p == 'On' else 'Off'})),
SwitchDesc(
key="display",
field="mode",
icon="mdi:monitor",
entity_category="config",
value_fn=lambda v: v == "On",
write_fn=lambda p, rep, href=None: (
["display", "vs", "0"],
{"mode": "On" if p == "On" else "Off"},
),
),
),
)
@@ -300,18 +379,27 @@ DISPLAY = Capability(
# seen on this one dump, so the option list there is reused as-is rather
# than re-deriving it from a single sample.
HEPA_FILTER = Capability(
href='/filter/hepafilter/vs/0',
poll_tier='cold',
href="/filter/hepafilter/vs/0",
poll_tier="cold",
entities=(
SensorDesc(key='hepa_filter_usage', rep_fn=filter_usage_percent,
unit='%', state_class='measurement',
icon='mdi:air-filter', entity_category='diagnostic'),
SensorDesc(key='hepa_filter_status', field='x.com.samsung.da.filterStatus',
device_class='enum',
options=('normal', 'wash', 'replace'),
translation_key='filter_status',
icon='mdi:air-filter', entity_category='diagnostic',
value_fn=lambda v: v.lower() if isinstance(v, str) else v),
SensorDesc(
key="hepa_filter_usage",
rep_fn=filter_usage_percent,
unit="%",
state_class="measurement",
icon="mdi:air-filter",
entity_category="diagnostic",
),
SensorDesc(
key="hepa_filter_status",
field="x.com.samsung.da.filterStatus",
device_class="enum",
options=("normal", "wash", "replace"),
translation_key="filter_status",
icon="mdi:air-filter",
entity_category="diagnostic",
value_fn=lambda v: v.lower() if isinstance(v, str) else v,
),
),
)
@@ -320,27 +408,35 @@ HEPA_FILTER = Capability(
# supportedStatus list is present to check against -- exposed as a plain
# diagnostic sensor rather than an asserted binary_sensor polarity.
PANEL_STATUS = Capability(
href='/panel/vs/0',
poll_tier='cold',
href="/panel/vs/0",
poll_tier="cold",
entities=(
SensorDesc(key='panel_status', field='status',
icon='mdi:archive-outline', entity_category='diagnostic'),
SensorDesc(
key="panel_status",
field="status",
icon="mdi:archive-outline",
entity_category="diagnostic",
),
),
)
# Pet-care filter mode -- a plain On/Off field with no vendor prefix, same
# convention as airconditioner.MUTE_ONCE.
PET_FILTER_ACTIVATION = Capability(
href='/petfilteractivation/vs/0',
poll_tier='cold',
href="/petfilteractivation/vs/0",
poll_tier="cold",
entities=(
SwitchDesc(key='pet_filter_activation', field='status',
icon='mdi:paw',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['petfilteractivation', 'vs', '0'],
{'status': 'On' if p == 'On' else 'Off'})),
SwitchDesc(
key="pet_filter_activation",
field="status",
icon="mdi:paw",
entity_category="config",
value_fn=lambda v: v == "On",
write_fn=lambda p, rep, href=None: (
["petfilteractivation", "vs", "0"],
{"status": "On" if p == "On" else "Off"},
),
),
),
)
@@ -352,49 +448,62 @@ PET_FILTER_ACTIVATION = Capability(
# separate descriptors reading the live supported values instead of a
# hardcoded table.
SOUND_MODE = Capability(
href='/settings/sound/mode/vs/0',
poll_tier='cold',
href="/settings/sound/mode/vs/0",
poll_tier="cold",
entities=(
# Distinct translation_key from laundry.SOUND_MODE's shared
# 'sound_mode' catalog entry -- that one's state table is
# {voice, tone, mute}, but this board's supportedModes is
# {mute, buzzer}. Sharing the key would leave 'buzzer' unlabelled
# (falls through to the raw code) since the catalogs don't overlap.
SelectDesc(key='sound_mode', translation_key='air_purifier_sound_mode',
field='mode',
icon='mdi:volume-high',
entity_category='config',
options_field='supportedModes',
write_fn=lambda p, rep, href=None: (
['settings', 'sound', 'mode', 'vs', '0'], {'mode': p})),
SelectDesc(
key="sound_mode",
translation_key="air_purifier_sound_mode",
field="mode",
icon="mdi:volume-high",
entity_category="config",
options_field="supportedModes",
write_fn=lambda p, rep, href=None: (
["settings", "sound", "mode", "vs", "0"],
{"mode": p},
),
),
),
)
# Read-only descriptor of which sound output the unit has -- only one value
# seen, no alternatives to select between.
SOUND_OUTPUT = Capability(
href='/settings/sound/output/vs/0',
poll_tier='cold',
href="/settings/sound/output/vs/0",
poll_tier="cold",
entities=(
SensorDesc(key='sound_output', field='deviceType',
icon='mdi:volume-high', entity_category='diagnostic'),
SensorDesc(
key="sound_output",
field="deviceType",
icon="mdi:volume-high",
entity_category="diagnostic",
),
),
)
SOUND_VOLUME = Capability(
href='/settings/sound/volume/vs/0',
poll_tier='cold',
href="/settings/sound/volume/vs/0",
poll_tier="cold",
entities=(
NumberDesc(key='sound_volume', field='level',
icon='mdi:volume-medium',
entity_category='config',
native_min_fn=lambda rep: int_or_none(rep.get('minLevel')) or 0,
native_max_fn=lambda rep: int_or_none(rep.get('maxLevel')) or 0,
step_fn=lambda rep: int_or_none(rep.get('resolution')) or 1,
value_fn=int_or_none,
write_fn=lambda p, rep, href=None: (
['settings', 'sound', 'volume', 'vs', '0'],
{'level': str(int(p))})),
NumberDesc(
key="sound_volume",
field="level",
icon="mdi:volume-medium",
entity_category="config",
native_min_fn=lambda rep: int_or_none(rep.get("minLevel")) or 0,
native_max_fn=lambda rep: int_or_none(rep.get("maxLevel")) or 0,
step_fn=lambda rep: int_or_none(rep.get("resolution")) or 1,
value_fn=int_or_none,
write_fn=lambda p, rep, href=None: (
["settings", "sound", "volume", "vs", "0"],
{"level": str(int(p))},
),
),
),
)
@@ -411,18 +520,22 @@ SOUND_VOLUME = Capability(
# colliding with a family-local bare Capability on the same href raises in
# _build()); left as a possible follow-up DRY cleanup.
COVERAGE = [
Capability(href='/humidity/0'),
Capability(href='/humidity/vs/0'),
Capability(href='/airlevelcheck/vs/0'), # periodic air-quality sensing scheduler plumbing
Capability(href='/availablecontrolsets/vs/0'), # opaque hex-encoded control-set bitmap
Capability(href='/da/softreset/vs/0'), # soft-reset trigger plumbing
Capability(href='/keepnormalstate/vs/0'), # internal keep-normal flag
Capability(href='/personality/presence/vs/0'), # presence-personalization plumbing (empty here)
Capability(href='/reserverulesets/vs/0'), # opaque hex-encoded schedule reservation blob
Capability(href="/humidity/0"),
Capability(href="/humidity/vs/0"),
Capability(href="/airlevelcheck/vs/0"), # periodic air-quality sensing scheduler plumbing
Capability(href="/availablecontrolsets/vs/0"), # opaque hex-encoded control-set bitmap
Capability(href="/da/softreset/vs/0"), # soft-reset trigger plumbing
Capability(href="/keepnormalstate/vs/0"), # internal keep-normal flag
Capability(href="/personality/presence/vs/0"), # presence-personalization plumbing (empty here)
Capability(href="/reserverulesets/vs/0"), # opaque hex-encoded schedule reservation blob
# Do-not-disturb/auto-sleep schedule (visible/startTime/endTime/
# useTimeSetting/functionState) -- every field reads its inert default
# on the only dump seen (times both '00:00:00', useTimeSetting/
# 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'),
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"),
]
File diff suppressed because it is too large Load Diff
@@ -10,12 +10,17 @@ against live device dumps:
/water/consumption/vs/0 -> x.com.samsung.da.cumulativeWater
/filter/waterfilter/vs/0 -> x.com.samsung.da.filterUsage / filterStatus
"""
from datetime import datetime, timezone
from datetime import UTC, datetime
from ..batch import is_stub_rep
from ..capability import Capability
from ..entities import (
BinarySensorDesc, ButtonDesc, SelectDesc, SensorDesc, SwitchDesc,
BinarySensorDesc,
ButtonDesc,
SelectDesc,
SensorDesc,
SwitchDesc,
)
@@ -56,7 +61,7 @@ def parse_iso_utc(raw):
dt = datetime.fromisoformat(raw)
except ValueError:
return None
return dt if dt.tzinfo is not None else dt.replace(tzinfo=timezone.utc)
return dt if dt.tzinfo is not None else dt.replace(tzinfo=UTC)
def filter_usage_percent(rep):
@@ -65,23 +70,23 @@ def filter_usage_percent(rep):
`filterCapacityUnit` (Hours, e.g. 100 of a 500 capacity), so a plain
value with a '%' unit would be wrong -- normalize to used/capacity.
Returns None when capacity is missing/zero."""
used = _num(rep.get('x.com.samsung.da.filterUsage'))
cap = _num(rep.get('x.com.samsung.da.filterCapacity'))
used = _num(rep.get("x.com.samsung.da.filterUsage"))
cap = _num(rep.get("x.com.samsung.da.filterCapacity"))
if used is None or not cap:
return None
return round(used / cap * 100)
def normalize_temp_unit(raw, default='°F'):
def normalize_temp_unit(raw, default="°F"):
"""'C'/'Celsius' -> '°C', 'F'/'Fahrenheit' -> '°F'. Falls back to
`default` for any other/missing value. Shared by fridge.py and oven.py,
both of which read a per-device unit off a `/temperature*` resource
instead of assuming one (see fridge.py's module docstring, issue #7)."""
raw = (raw or '').strip().upper()
if raw.startswith('C'):
return '°C'
if raw.startswith('F'):
return '°F'
raw = (raw or "").strip().upper()
if raw.startswith("C"):
return "°C"
if raw.startswith("F"):
return "°F"
return default
@@ -91,22 +96,36 @@ 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'
return "none"
codes = [
i.get('x.com.samsung.da.code')
i.get("x.com.samsung.da.code")
for i in items
if i.get('x.com.samsung.da.code')
and str(i.get('x.com.samsung.da.state', '')).lower() != 'deleted'
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'
return ", ".join(codes) if codes else "none"
def merge_options_field(cached, new_tokens):
@@ -123,12 +142,12 @@ def merge_options_field(cached, new_tokens):
body it applies straight to the cache no longer carries them."""
merged = list(cached or [])
for token in new_tokens or ():
if not isinstance(token, str) or '_' not in token:
if not isinstance(token, str) or "_" not in token:
continue
prefix = token.split('_', 1)[0]
prefix = token.split("_", 1)[0]
replaced = False
for i, o in enumerate(merged):
if isinstance(o, str) and o.startswith(prefix + '_'):
if isinstance(o, str) and o.startswith(prefix + "_"):
merged[i] = token
replaced = True
if not replaced:
@@ -154,9 +173,9 @@ def merge_items_field(cached, new_items):
for new_item in new_items or ():
if not isinstance(new_item, dict):
continue
item_id = new_item.get('x.com.samsung.da.id')
item_id = new_item.get("x.com.samsung.da.id")
for i, existing in enumerate(merged):
if isinstance(existing, dict) and existing.get('x.com.samsung.da.id') == item_id:
if isinstance(existing, dict) and existing.get("x.com.samsung.da.id") == item_id:
merged[i] = {**existing, **new_item}
break
else:
@@ -168,9 +187,9 @@ def merge_items_field(cached, new_items):
# washers, dryers, and dishwashers; absent on fridge/oven/AC. Static for the
# life of a given board, so reading them from the /device/0 seed (no dedicated
# poll_tier) is enough.
_SETINFO_HREF = '/wm/setinfo/vs/0'
_POWER_ON_OFF_FIELD = 'x.com.samsung.da.isModelSettingPowerOnOff'
_WITHOUT_SC_FIELD = 'x.com.samsung.da.isModelSettingWithoutSC'
_SETINFO_HREF = "/wm/setinfo/vs/0"
_POWER_ON_OFF_FIELD = "x.com.samsung.da.isModelSettingPowerOnOff"
_WITHOUT_SC_FIELD = "x.com.samsung.da.isModelSettingWithoutSC"
def model_allows_power_on_off(resources: dict) -> bool:
@@ -187,7 +206,7 @@ def model_allows_power_on_off(resources: dict) -> bool:
flag = setinfo.get(_POWER_ON_OFF_FIELD)
if flag is None:
return True
return str(flag).lower() != 'false'
return str(flag).lower() != "false"
def model_setting_without_sc(resources: dict) -> bool:
@@ -199,7 +218,7 @@ def model_setting_without_sc(resources: dict) -> bool:
the flag name is settings-specific, not a blanket remote-control bypass.
"""
setinfo = resources.get(_SETINFO_HREF) or {}
return str(setinfo.get(_WITHOUT_SC_FIELD, '')).lower() == 'true'
return str(setinfo.get(_WITHOUT_SC_FIELD, "")).lower() == "true"
def _power_switch_exists(rep, resources):
@@ -214,14 +233,15 @@ def sensor_item_value(items, sensor_type, index=0):
"""Pull one reading out of a `/sensors/vs/0`-style items[] list -- each
item is `{type, value: [...]}`; `index` picks which slot of a possibly
multi-value reading to read (index 0 is the raw measurement on every
family seen so far). Shared by range_hood.AIR_QUALITY and
air_purifier.AIR_QUALITY, which read the same resource shape."""
family seen so far). Shared by range_hood.AIR_QUALITY,
air_purifier.AIR_QUALITY, and air_monitor.SENSORS, which all read the
same resource shape against the same {type, sensor_type} keys."""
for item in items or ():
if not isinstance(item, dict):
continue
if item.get('x.com.samsung.da.type') != sensor_type:
if item.get("x.com.samsung.da.type") != sensor_type:
continue
values = item.get('x.com.samsung.da.value') or ()
values = item.get("x.com.samsung.da.value") or []
if index < len(values):
try:
return int(values[index])
@@ -244,60 +264,97 @@ def sensor_item_value(items, sensor_type, index=0):
# choice. Every device registry lists both caps of each pair.
POWER_GENERIC = Capability(
href='/power/0',
href="/power/0",
# Neither href of this pair carried a poll_tier before (issue #56's
# follow-up), so power state only ever refreshed on the once-per-30s
# summary poll instead of the subscribe/subpoll cadence 'warm' and 'hot'
# hrefs get -- the same "signal drives real-time state, but sat in the
# slow default tier" gap as REMOTE_CONTROL_GENERIC/VS_FALLBACK above.
poll_tier="warm",
entities=(
# Writable when firmware allows remote power; otherwise a read-only
# binary_sensor with the same key keeps HA state without a dead switch.
SwitchDesc(key='power_switch', field='value',
value_fn=lambda v: bool(v),
exists_fn=_power_switch_exists,
write_fn=lambda p, rep, href=None: (
['power', '0'], {'value': p == 'On'})),
BinarySensorDesc(key='power_switch', field='value',
device_class='power',
value_fn=lambda v: bool(v),
exists_fn=_power_sensor_exists),
SwitchDesc(
key="power_switch",
field="value",
value_fn=lambda v: bool(v),
exists_fn=_power_switch_exists,
write_fn=lambda p, rep, href=None: (["power", "0"], {"value": p == "On"}),
),
BinarySensorDesc(
key="power_switch",
field="value",
device_class="power",
value_fn=lambda v: bool(v),
exists_fn=_power_sensor_exists,
),
),
)
POWER_VS_FALLBACK = Capability(
href='/power/vs/0',
match_fn=lambda rep, resources: '/power/0' not in resources,
href="/power/vs/0",
match_fn=lambda rep, resources: "/power/0" not in resources,
poll_tier="warm",
entities=(
SwitchDesc(key='power_switch', field='x.com.samsung.da.power',
value_fn=lambda v: v == 'On',
exists_fn=_power_switch_exists,
write_fn=lambda p, rep, href=None: (
['power', 'vs', '0'],
{'x.com.samsung.da.power': 'On' if p == 'On' else 'Off'})),
BinarySensorDesc(key='power_switch', field='x.com.samsung.da.power',
device_class='power',
value_fn=lambda v: v == 'On',
exists_fn=_power_sensor_exists),
SwitchDesc(
key="power_switch",
field="x.com.samsung.da.power",
value_fn=lambda v: v == "On",
exists_fn=_power_switch_exists,
write_fn=lambda p, rep, href=None: (
["power", "vs", "0"],
{"x.com.samsung.da.power": "On" if p == "On" else "Off"},
),
),
BinarySensorDesc(
key="power_switch",
field="x.com.samsung.da.power",
device_class="power",
value_fn=lambda v: v == "On",
exists_fn=_power_sensor_exists,
),
),
)
KIDS_LOCK_GENERIC = Capability(
href='/kidslock/0',
href="/kidslock/0",
entities=(
SwitchDesc(key='child_lock', field='value',
device_class='lock',
value_fn=lambda v: bool(v),
write_fn=lambda p, rep, href=None: (
['kidslock', '0'], {'value': p == 'On'})),
# Read-only like KIDS_LOCK_VS_FALLBACK (issues #181/#183) -- not a
# SwitchDesc. SwitchDesc's `device_class='lock'` was never honored
# by HA (its switch platform only accepts 'outlet'/'switch'),
# leaving a plain switch whose 'On' state meant different things
# on different boards. As a BinarySensorDesc with `device_class='lock'`,
# both kids-lock surfaces read with the same polarity: 'On' means
# open/unlocked, per HA's lock device_class. The inversion in
# value_fn here (and in the fallback below) keeps the on-the-wire
# truth (value=False on /kidslock/0, kidsLock='Ready' on /kidslock/vs/0
# both mean kids lock NOT active) consistent with that polarity.
BinarySensorDesc(
key="child_lock", field="value", device_class="lock", value_fn=lambda v: not bool(v)
),
),
)
KIDS_LOCK_VS_FALLBACK = Capability(
href='/kidslock/vs/0',
match_fn=lambda rep, resources: '/kidslock/0' not in resources,
href="/kidslock/vs/0",
match_fn=lambda rep, resources: "/kidslock/0" not in resources,
entities=(
SwitchDesc(key='child_lock', field='x.com.samsung.da.kidsLock',
device_class='lock',
value_fn=lambda v: v != 'Ready',
write_fn=lambda p, rep, href=None: (
['kidslock', 'vs', '0'],
{'x.com.samsung.da.kidsLock': 'Enable' if p == 'On' else 'Ready'})),
# Read-only, not a SwitchDesc (issues #181/#183): the write side of
# this capability wrote 'Enable', a value no dump in the fixture
# corpus has ever reported back -- every one reports either 'Ready'
# or 'Run', so it was never a confirmed contract. #181's reporter
# confirmed this directly: writing the *correct* value ('Run')
# still 4.05s, and the SmartThings app itself has no control for
# it either -- the resource is genuinely read-only on this
# hardware, not just wrong-valued. Polarity matches
# KIDS_LOCK_GENERIC above -- 'On' means open/unlocked, so
# kidsLock='Ready' (kids lock NOT active) renders as 'On'.
BinarySensorDesc(
key="child_lock",
field="x.com.samsung.da.kidsLock",
device_class="lock",
value_fn=lambda v: v == "Ready",
),
),
)
@@ -313,12 +370,12 @@ def remote_control_enabled(resources: dict) -> bool:
once-per-30s cold summary poll. True (assume enabled) when neither
href is present -- most device types don't report this capability
at all."""
generic = resources.get('/remotectrl/0')
generic = resources.get("/remotectrl/0")
if generic is not None:
return bool(generic.get('value'))
fallback = resources.get('/remotectrl/vs/0')
return bool(generic.get("value"))
fallback = resources.get("/remotectrl/vs/0")
if fallback is not None:
return str(fallback.get('x.com.samsung.da.remoteControlEnabled')).lower() == 'true'
return str(fallback.get("x.com.samsung.da.remoteControlEnabled")).lower() == "true"
return True
@@ -332,39 +389,48 @@ def remote_control_required_for_write(resources: dict, bound_href: str) -> bool:
"""
if not model_setting_without_sc(resources):
return True
href = bound_href or ''
return href.startswith('/operational/state')
href = bound_href or ""
return href.startswith("/operational/state")
REMOTE_CONTROL_GENERIC = Capability(
href='/remotectrl/0',
poll_tier='warm',
href="/remotectrl/0",
poll_tier="warm",
entities=(
BinarySensorDesc(key='remote_control', field='value',
device_class='connectivity',
value_fn=lambda v: bool(v)),
BinarySensorDesc(
key="remote_control",
field="value",
device_class="connectivity",
value_fn=lambda v: bool(v),
),
),
)
REMOTE_CONTROL_VS_FALLBACK = Capability(
href='/remotectrl/vs/0',
match_fn=lambda rep, resources: '/remotectrl/0' not in resources,
poll_tier='warm',
href="/remotectrl/vs/0",
match_fn=lambda rep, resources: "/remotectrl/0" not in resources,
poll_tier="warm",
entities=(
BinarySensorDesc(key='remote_control',
field='x.com.samsung.da.remoteControlEnabled',
device_class='connectivity',
value_fn=lambda v: str(v).lower() == 'true'),
BinarySensorDesc(
key="remote_control",
field="x.com.samsung.da.remoteControlEnabled",
device_class="connectivity",
value_fn=lambda v: str(v).lower() == "true",
),
),
)
ALARMS = Capability(
href='/alarms/vs/0',
poll_tier='hot',
href="/alarms/vs/0",
poll_tier="hot",
entities=(
SensorDesc(key='alarm_code', field='x.com.samsung.da.items',
icon='mdi:alert',
entity_category='diagnostic', value_fn=_active_alarm_codes),
SensorDesc(
key="alarm_code",
field="x.com.samsung.da.items",
icon="mdi:alert",
entity_category="diagnostic",
value_fn=_active_alarm_codes,
),
),
)
@@ -376,10 +442,10 @@ ALARMS = Capability(
# fridge's 93 W) still shows it (issue #6). cumulativePower is absent on at
# least one washer model; the exists_fn makes that explicit rather than relying
# on the generic field-presence gate.
_DEAD_INSTANTANEOUS_POWER = '-500'
_DEAD_INSTANTANEOUS_POWER = "-500"
ENERGY_METER = Capability(
href='/energy/consumption/vs/0',
href="/energy/consumption/vs/0",
entities=(
# `is_stub_rep(rep)` keeps the stub carve-out (see entity._is_included):
# an explicit exists_fn otherwise bypasses it, which would drop the
@@ -388,75 +454,123 @@ ENERGY_METER = Capability(
# answer, so it falls through to the normal field/sentinel checks like
# any populated rep. On a populated rep, hide power only for the dead
# sentinel or an absent field.
SensorDesc(key='power_watts', field='x.com.samsung.da.instantaneousPower',
device_class='power', state_class='measurement',
unit='W', value_fn=clamp_power,
exists_fn=lambda rep, resources: is_stub_rep(rep) or (
rep.get('x.com.samsung.da.instantaneousPower')
not in (None, _DEAD_INSTANTANEOUS_POWER))),
SensorDesc(key='energy_kwh', field='x.com.samsung.da.cumulativePower',
device_class='energy',
state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh,
exists_fn=lambda rep, resources: (
is_stub_rep(rep) or 'x.com.samsung.da.cumulativePower' in rep)),
SensorDesc(
key="power_watts",
field="x.com.samsung.da.instantaneousPower",
device_class="power",
state_class="measurement",
unit="W",
value_fn=clamp_power,
exists_fn=lambda rep, resources: (
is_stub_rep(rep)
or (
rep.get("x.com.samsung.da.instantaneousPower")
not in (None, _DEAD_INSTANTANEOUS_POWER)
)
),
),
SensorDesc(
key="energy_kwh",
field="x.com.samsung.da.cumulativePower",
device_class="energy",
state_class="total_increasing",
unit="kWh",
value_fn=wh_to_kwh,
exists_fn=lambda rep, resources: (
is_stub_rep(rep) or "x.com.samsung.da.cumulativePower" in rep
),
),
# cumulativeConsumption is a second, independently-varying running
# total alongside cumulativePower -- some fridges (issue #26) report
# both. Self-gates off where only cumulativePower is present. The
# `is_stub_rep(rep) or` keeps the same stub carve-out as power_watts/
# energy_kwh above -- without it, an exists_fn permanently drops the
# entity if setup happens to land on a not-yet-fetched stub.
SensorDesc(key='power_energy_kwh', field='x.com.samsung.da.cumulativeConsumption',
device_class='energy',
state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh,
exists_fn=lambda rep, resources: (
is_stub_rep(rep) or 'x.com.samsung.da.cumulativeConsumption' in rep)),
SensorDesc(
key="power_energy_kwh",
field="x.com.samsung.da.cumulativeConsumption",
device_class="energy",
state_class="total_increasing",
unit="kWh",
value_fn=wh_to_kwh,
exists_fn=lambda rep, resources: (
is_stub_rep(rep) or "x.com.samsung.da.cumulativeConsumption" in rep
),
),
# AI Energy Mode's lifetime savings estimate vs. an unoptimized
# baseline -- present on some models (e.g. TP1X_REF_21K, issue #21/
# #27) and absent on others (issue #20/#26), unlike cumulativePower.
SensorDesc(key='energy_saved_kwh', field='x.com.samsung.da.cumulativeSavedPower',
device_class='energy',
state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh,
exists_fn=lambda rep, resources: (
is_stub_rep(rep) or 'x.com.samsung.da.cumulativeSavedPower' in rep)),
SensorDesc(
key="energy_saved_kwh",
field="x.com.samsung.da.cumulativeSavedPower",
device_class="energy",
state_class="total_increasing",
unit="kWh",
value_fn=wh_to_kwh,
exists_fn=lambda rep, resources: (
is_stub_rep(rep) or "x.com.samsung.da.cumulativeSavedPower" in rep
),
),
# Monthly billing-cycle totals -- the completed prior month and the
# in-progress current month. Not ever-increasing (each resets at
# month boundary), so no state_class.
SensorDesc(key='energy_last_month_kwh', field='x.com.samsung.da.monthlyConsumption',
device_class='energy',
unit='kWh', value_fn=wh_to_kwh,
exists_fn=lambda rep, resources: (
is_stub_rep(rep) or 'x.com.samsung.da.monthlyConsumption' in rep)),
SensorDesc(key='energy_this_month_kwh', field='x.com.samsung.da.thismonthlyConsumption',
device_class='energy',
unit='kWh', value_fn=wh_to_kwh,
exists_fn=lambda rep, resources: (
is_stub_rep(rep) or 'x.com.samsung.da.thismonthlyConsumption' in rep)),
SensorDesc(
key="energy_last_month_kwh",
field="x.com.samsung.da.monthlyConsumption",
device_class="energy",
unit="kWh",
value_fn=wh_to_kwh,
exists_fn=lambda rep, resources: (
is_stub_rep(rep) or "x.com.samsung.da.monthlyConsumption" in rep
),
),
SensorDesc(
key="energy_this_month_kwh",
field="x.com.samsung.da.thismonthlyConsumption",
device_class="energy",
unit="kWh",
value_fn=wh_to_kwh,
exists_fn=lambda rep, resources: (
is_stub_rep(rep) or "x.com.samsung.da.thismonthlyConsumption" in rep
),
),
),
)
WATER_METER = Capability(
href='/water/consumption/vs/0',
href="/water/consumption/vs/0",
entities=(
SensorDesc(key='water_liters', field='x.com.samsung.da.cumulativeWater',
device_class='water',
state_class='total_increasing', unit='L', icon='mdi:water',
value_fn=_ml_to_l),
SensorDesc(
key="water_liters",
field="x.com.samsung.da.cumulativeWater",
device_class="water",
state_class="total_increasing",
unit="L",
icon="mdi:water",
value_fn=_ml_to_l,
),
),
)
WATER_FILTER = Capability(
href='/filter/waterfilter/vs/0',
match_fn=lambda rep, _: rep.get('x.com.samsung.da.filterStatus', '').lower() != 'notused',
href="/filter/waterfilter/vs/0",
match_fn=lambda rep, _: rep.get("x.com.samsung.da.filterStatus", "").lower() != "notused",
entities=(
SensorDesc(key='filter_usage', field='x.com.samsung.da.filterUsage',
unit='%', state_class='measurement',
icon='mdi:filter'),
SensorDesc(key='filter_status', field='x.com.samsung.da.filterStatus',
icon='mdi:filter-check',
device_class='enum', options=('normal', 'wash', 'replace'),
value_fn=lambda value: (
value.lower() if isinstance(value, str) else value
)),
SensorDesc(
key="filter_usage",
field="x.com.samsung.da.filterUsage",
unit="%",
state_class="measurement",
icon="mdi:filter",
),
SensorDesc(
key="filter_status",
field="x.com.samsung.da.filterStatus",
icon="mdi:filter-check",
device_class="enum",
options=("normal", "wash", "replace"),
value_fn=lambda value: value.lower() if isinstance(value, str) else value,
),
),
)
@@ -480,28 +594,28 @@ WATER_FILTER = Capability(
def _ai_energy_supported_levels(rep):
"""supportedAiLevel as a list -- a stray scalar (e.g. a string) must not
be len()-checked as if it were a list."""
sl = rep.get('supportedAiLevel')
sl = rep.get("supportedAiLevel")
return list(sl) if isinstance(sl, (list, tuple)) else []
def _ai_energy_level_options(resources):
rep = resources.get('/energy/ailevel/vs/0') or {}
return ['0', *_ai_energy_supported_levels(rep)]
rep = resources.get("/energy/ailevel/vs/0") or {}
return ["0", *_ai_energy_supported_levels(rep)]
def _ai_energy_level_write(p, rep, href=None):
return ['energy', 'ailevel', 'vs', '0'], {'aiLevel': p}
return ["energy", "ailevel", "vs", "0"], {"aiLevel": p}
def _ai_energy_level_switch_write(p, rep, href=None):
levels = _ai_energy_supported_levels(rep)
on_level = levels[0] if levels else '1'
return ['energy', 'ailevel', 'vs', '0'], {'aiLevel': on_level if p == 'On' else '0'}
on_level = levels[0] if levels else "1"
return ["energy", "ailevel", "vs", "0"], {"aiLevel": on_level if p == "On" else "0"}
AI_ENERGY_LEVEL = Capability(
href='/energy/ailevel/vs/0',
poll_tier='cold',
href="/energy/ailevel/vs/0",
poll_tier="cold",
entities=(
# No is_stub_rep carve-out on either side, unlike most exists_fn
# gates in this file -- entity creation only ever runs once, against
@@ -519,60 +633,78 @@ AI_ENERGY_LEVEL = Capability(
# until a reload if the device's very first poll stubs this
# cold-tier href, the same reload already required to fix which
# platform got picked in that case.
SwitchDesc(key='ai_energy_level', field='aiLevel',
icon='mdi:leaf',
entity_category='config',
value_fn=lambda v: v != '0',
exists_fn=lambda rep, resources: (
len(_ai_energy_supported_levels(rep)) == 1),
write_fn=_ai_energy_level_switch_write),
SelectDesc(key='ai_energy_level', field='aiLevel',
icon='mdi:leaf',
entity_category='config',
options=_ai_energy_level_options,
exists_fn=lambda rep, resources: (
len(_ai_energy_supported_levels(rep)) > 1),
write_fn=_ai_energy_level_write),
SwitchDesc(
key="ai_energy_level",
field="aiLevel",
icon="mdi:leaf",
entity_category="config",
value_fn=lambda v: v != "0",
exists_fn=lambda rep, resources: len(_ai_energy_supported_levels(rep)) == 1,
write_fn=_ai_energy_level_switch_write,
),
SelectDesc(
key="ai_energy_level",
field="aiLevel",
icon="mdi:leaf",
entity_category="config",
options=_ai_energy_level_options,
exists_fn=lambda rep, resources: len(_ai_energy_supported_levels(rep)) > 1,
write_fn=_ai_energy_level_write,
),
),
)
FIRMWARE_UPDATE = Capability(
href='/otninformation/vs/0',
poll_tier='cold',
href="/otninformation/vs/0",
poll_tier="cold",
entities=(
BinarySensorDesc(
key='firmware_update',
field='x.com.samsung.da.newVersionAvailable',
device_class='update',
entity_category='diagnostic',
value_fn=lambda v: str(v).lower() == 'true' if v is not None else None,
key="firmware_update",
field="x.com.samsung.da.newVersionAvailable",
device_class="update",
entity_category="diagnostic",
value_fn=lambda v: str(v).lower() == "true" if v is not None else None,
),
),
)
SELF_CHECK = Capability(
href='/selfcheck/vs/0',
poll_tier='cold',
href="/selfcheck/vs/0",
poll_tier="cold",
entities=(
SensorDesc(key='selfcheck_status', field='x.com.samsung.da.status',
icon='mdi:stethoscope',
entity_category='diagnostic'),
SensorDesc(key='selfcheck_result', field='x.com.samsung.da.result',
icon='mdi:clipboard-check-outline',
entity_category='diagnostic'),
SensorDesc(
key="selfcheck_status",
field="x.com.samsung.da.status",
icon="mdi:stethoscope",
entity_category="diagnostic",
),
SensorDesc(
key="selfcheck_result",
field="x.com.samsung.da.result",
icon="mdi:clipboard-check-outline",
entity_category="diagnostic",
),
# List of error codes from the last self-check; joined for display.
# Not every fridge reports the field, hence the exists_fn.
SensorDesc(key='selfcheck_error', field='x.com.samsung.da.error',
icon='mdi:alert-circle-outline',
entity_category='diagnostic',
exists_fn=lambda rep, resources: (
is_stub_rep(rep) or 'x.com.samsung.da.error' in rep),
value_fn=lambda v: (', '.join(v) if v else None) if isinstance(v, list) else v),
ButtonDesc(key='selfcheck_start', field='', payload='Start',
icon='mdi:play-circle-outline',
entity_category='diagnostic',
write_fn=lambda p, rep, href=None: (
['selfcheck', 'vs', '0'], {'x.com.samsung.da.status': p})),
SensorDesc(
key="selfcheck_error",
field="x.com.samsung.da.error",
icon="mdi:alert-circle-outline",
entity_category="diagnostic",
exists_fn=lambda rep, resources: is_stub_rep(rep) or "x.com.samsung.da.error" in rep,
value_fn=lambda v: (", ".join(v) if v else None) if isinstance(v, list) else v,
),
ButtonDesc(
key="selfcheck_start",
field="",
payload="Start",
icon="mdi:play-circle-outline",
entity_category="diagnostic",
write_fn=lambda p, rep, href=None: (
["selfcheck", "vs", "0"],
{"x.com.samsung.da.status": p},
),
),
),
)
@@ -590,15 +722,22 @@ SELF_CHECK = Capability(
# level: 2 of 6 families confirmed, blanket-added everywhere else).
#
# POWER is kept separate -- airconditioner is the one family that opts out
# of it. Canonical reason (see by_type/airconditioner.py and its test for
# pointers back here, not restatements): AC's climate entity already owns
# /power/0 and /power/vs/0 via bare, no-entity Capability objects
# (airconditioner.COVERAGE), and a second, real POWER_GENERIC/
# of it entirely. Canonical reason (see by_type/airconditioner.py and its
# test for pointers back here, not restatements): AC's climate entity
# already owns /power/0 and /power/vs/0 via bare, no-entity Capability
# objects (airconditioner.COVERAGE), and a second, real POWER_GENERIC/
# POWER_VS_FALLBACK cap on the same href would make _build() raise (a href
# with >1 cap must have every cap discriminated by rt_filter/match_fn, and
# the bare COVERAGE cap has neither). Kids-lock/remote-control don't have
# this conflict -- no AC dump has ever reported those hrefs -- so they stay
# in UNIVERSAL.
#
# Airconditioner also partially opts out of UNIVERSAL itself, not just
# POWER: issue #193 needs ENERGY_METER's cumulativePower scale to differ by
# board generation, so by_type/airconditioner.py excludes just that one
# member (`*[c for c in common.UNIVERSAL if c is not common.ENERGY_METER]`)
# and substitutes airconditioner.ENERGY_METER_GENERIC/ENERGY_METER_LEGACY in
# its place -- every other registry still unpacks UNIVERSAL wholesale.
# ---------------------------------------------------------------------------
UNIVERSAL = (
@@ -13,16 +13,15 @@ from ..batch import is_stub_rep
from ..capability import Capability
from ..entities import BinarySensorDesc, SensorDesc
_INACTIVE_OPERATION_STATES = {'Off', 'Ready'}
_INACTIVE_OPERATION_STATES = {"Off", "Ready"}
def _option_value(options, prefix):
"""Return the value from the first ``<prefix>_<value>`` option."""
marker = prefix + '_'
marker = prefix + "_"
for option in options or ():
if isinstance(option, str) and option.startswith(marker):
return option[len(marker):]
return option[len(marker) :]
return None
@@ -32,7 +31,7 @@ def _operation_slots(options) -> tuple[int, ...]:
for option in options or ():
if not isinstance(option, str):
continue
match = re.match(r'^OperationState(\d+)_', option)
match = re.match(r"^OperationState(\d+)_", option)
if match:
slots.add(int(match.group(1)))
return tuple(sorted(slots))
@@ -40,10 +39,7 @@ def _operation_slots(options) -> tuple[int, ...]:
def _any_burner_active(options):
"""True when any advertised burner slot is not idle."""
states = [
_option_value(options, f'OperationState{slot}')
for slot in _operation_slots(options)
]
states = [_option_value(options, f"OperationState{slot}") for slot in _operation_slots(options)]
states = [state for state in states if state is not None]
return any(state not in _INACTIVE_OPERATION_STATES for state in states)
@@ -56,15 +52,15 @@ def _int_or_none(value):
COOKTOP_POWER = Capability(
href='/power/vs/0',
poll_tier='hot',
href="/power/vs/0",
poll_tier="hot",
entities=(
BinarySensorDesc(
key='power_state',
field='x.com.samsung.da.power',
device_class='power',
icon='mdi:stove',
value_fn=lambda value: str(value).lower() == 'on',
key="power_state",
field="x.com.samsung.da.power",
device_class="power",
icon="mdi:stove",
value_fn=lambda value: str(value).lower() == "on",
),
),
)
@@ -77,109 +73,107 @@ COOKTOP_POWER = Capability(
_SUPPORTED_OPERATION_SLOTS = tuple(range(8))
COOKTOP_MODE = Capability(
href='/mode/vs/0',
poll_tier='hot',
href="/mode/vs/0",
poll_tier="hot",
entities=(
BinarySensorDesc(
key='any_burner_active',
field='x.com.samsung.da.options',
device_class='running',
icon='mdi:fire',
key="any_burner_active",
field="x.com.samsung.da.options",
device_class="running",
icon="mdi:fire",
value_fn=_any_burner_active,
),
*(
SensorDesc(
key=f'burner_{slot}_state',
field='x.com.samsung.da.options',
translation_key='burner_state',
translation_placeholders={'number': str(slot)},
icon='mdi:gas-burner',
value_fn=lambda options, slot=slot: _option_value(
options, f'OperationState{slot}'
),
key=f"burner_{slot}_state",
field="x.com.samsung.da.options",
translation_key="burner_state",
translation_placeholders={"number": str(slot)},
icon="mdi:gas-burner",
value_fn=lambda options, slot=slot: _option_value(options, f"OperationState{slot}"),
exists_fn=lambda rep, resources, slot=slot: (
is_stub_rep(rep) or _option_value(
rep.get('x.com.samsung.da.options'),
f'OperationState{slot}',
) is not None
is_stub_rep(rep)
or _option_value(
rep.get("x.com.samsung.da.options"),
f"OperationState{slot}",
)
is not None
),
)
for slot in _SUPPORTED_OPERATION_SLOTS
),
SensorDesc(
key='main_timer_state',
field='x.com.samsung.da.options',
icon='mdi:timer-outline',
value_fn=lambda options: _option_value(options, 'MainTimerState'),
key="main_timer_state",
field="x.com.samsung.da.options",
icon="mdi:timer-outline",
value_fn=lambda options: _option_value(options, "MainTimerState"),
),
SensorDesc(
key='main_timer_current',
field='x.com.samsung.da.options',
icon='mdi:timer-sand',
key="main_timer_current",
field="x.com.samsung.da.options",
icon="mdi:timer-sand",
enabled_default=False,
value_fn=lambda options: _int_or_none(
_option_value(options, 'MainTimerCurrent')
),
value_fn=lambda options: _int_or_none(_option_value(options, "MainTimerCurrent")),
),
),
)
COOKTOP_CONNECTED = Capability(
href='/connected/vs/0',
poll_tier='warm',
href="/connected/vs/0",
poll_tier="warm",
entities=(
BinarySensorDesc(
key='cloud_connected',
field='x.com.samsung.da.connected',
device_class='connectivity',
entity_category='diagnostic',
value_fn=lambda value: str(value).lower() == 'on',
key="cloud_connected",
field="x.com.samsung.da.connected",
device_class="connectivity",
entity_category="diagnostic",
value_fn=lambda value: str(value).lower() == "on",
),
),
)
PAIRED_HOOD_STATUS = Capability(
href='/bluetooth/hood/status/vs/0',
poll_tier='hot',
href="/bluetooth/hood/status/vs/0",
poll_tier="hot",
entities=(
BinarySensorDesc(
key='paired_hood_connected',
field='connectionState',
device_class='connectivity',
value_fn=lambda value: str(value).lower() == 'connected',
key="paired_hood_connected",
field="connectionState",
device_class="connectivity",
value_fn=lambda value: str(value).lower() == "connected",
),
BinarySensorDesc(
key='paired_hood_power',
field='power',
device_class='running',
value_fn=lambda value: str(value).lower() == 'on',
key="paired_hood_power",
field="power",
device_class="running",
value_fn=lambda value: str(value).lower() == "on",
),
SensorDesc(
key='paired_hood_fan_speed',
field='fanSpeed',
icon='mdi:fan',
key="paired_hood_fan_speed",
field="fanSpeed",
icon="mdi:fan",
value_fn=_int_or_none,
),
BinarySensorDesc(
key='paired_hood_light',
field='lampState',
device_class='light',
value_fn=lambda value: str(value).lower() == 'on',
key="paired_hood_light",
field="lampState",
device_class="light",
value_fn=lambda value: str(value).lower() == "on",
),
SensorDesc(
key='paired_hood_model',
field='micomModelId',
icon='mdi:information-outline',
entity_category='diagnostic',
key="paired_hood_model",
field="micomModelId",
icon="mdi:information-outline",
entity_category="diagnostic",
enabled_default=False,
),
SensorDesc(
key='paired_hood_firmware',
field='firmwareVersion',
icon='mdi:chip',
entity_category='diagnostic',
key="paired_hood_firmware",
field="firmwareVersion",
icon="mdi:chip",
entity_category="diagnostic",
enabled_default=False,
),
),
@@ -7,6 +7,7 @@ humidity -- not temperature -- is this device's primary control, and there
is no climate composite: power, mode, and humidity are exposed as separate
entities rather than folded into one card.
"""
from ..capability import Capability
from ..entities import NumberDesc, SelectDesc, SensorDesc
from .common import int_or_none
@@ -16,21 +17,26 @@ def _first_mode(rep):
"""Representative scalar for the operating-mode select. `modes` is a
single-element list on every dump seen so far, mirroring
airconditioner._first_mode's handling of the same field shape."""
modes = rep.get('x.com.samsung.da.modes')
modes = rep.get("x.com.samsung.da.modes")
if isinstance(modes, (list, tuple)):
return modes[0] if modes else None
return modes
MODE = Capability(
href='/mode/vs/0',
poll_tier='warm',
href="/mode/vs/0",
poll_tier="warm",
entities=(
SelectDesc(key='operating_mode', rep_fn=_first_mode,
icon='mdi:tune-variant',
options_field='x.com.samsung.da.supportedModes',
write_fn=lambda p, rep, href=None: (
['mode', 'vs', '0'], {'x.com.samsung.da.modes': [p]})),
SelectDesc(
key="operating_mode",
rep_fn=_first_mode,
icon="mdi:tune-variant",
options_field="x.com.samsung.da.supportedModes",
write_fn=lambda p, rep, href=None: (
["mode", "vs", "0"],
{"x.com.samsung.da.modes": [p]},
),
),
),
)
@@ -43,21 +49,31 @@ MODE = Capability(
# dump's values" section). Step comes live from the device's own `increment`
# field.
HUMIDITY = Capability(
href='/humidity/vs/0',
poll_tier='warm',
href="/humidity/vs/0",
poll_tier="warm",
entities=(
SensorDesc(key='humidity', field='x.com.samsung.da.humidity',
device_class='humidity', unit='%', state_class='measurement',
value_fn=int_or_none),
NumberDesc(key='target_humidity', field='x.com.samsung.da.desiredHumidity',
device_class='humidity', unit='%',
icon='mdi:water-percent',
entity_category='config',
value_fn=int_or_none,
step_fn=lambda rep: int_or_none(rep.get('increment')) or 1,
write_fn=lambda p, rep, href=None: (
['humidity', 'vs', '0'],
{'x.com.samsung.da.desiredHumidity': str(int(round(float(p))))})),
SensorDesc(
key="humidity",
field="x.com.samsung.da.humidity",
device_class="humidity",
unit="%",
state_class="measurement",
value_fn=int_or_none,
),
NumberDesc(
key="target_humidity",
field="x.com.samsung.da.desiredHumidity",
device_class="humidity",
unit="%",
icon="mdi:water-percent",
entity_category="config",
value_fn=int_or_none,
step_fn=lambda rep: int_or_none(rep.get("increment")) or 1,
write_fn=lambda p, rep, href=None: (
["humidity", "vs", "0"],
{"x.com.samsung.da.desiredHumidity": str(round(float(p)))},
),
),
),
)
@@ -69,17 +85,17 @@ HUMIDITY = Capability(
# families' schemas.
# ---------------------------------------------------------------------------
_DHM_IGNORED = [
'/availablecontrolsets/vs/0', # opaque hex-encoded control-set bitmap (id: DHM)
'/da/softreset/vs/0', # soft-reset trigger plumbing
'/keepnormalstate/vs/0', # internal keep-normal flag
'/personality/presence/vs/0', # presence-personalization plumbing (empty item value)
'/reserverulesets/vs/0', # opaque hex-encoded schedule reservation blob
'/sensors/vs/0', # empty {} on this dump
'/welcome/humidity/vs/0', # welcome-mode plumbing (requestId/operatingStatus, inert)
"/availablecontrolsets/vs/0", # opaque hex-encoded control-set bitmap (id: DHM)
"/da/softreset/vs/0", # soft-reset trigger plumbing
"/keepnormalstate/vs/0", # internal keep-normal flag
"/personality/presence/vs/0", # presence-personalization plumbing (empty item value)
"/reserverulesets/vs/0", # opaque hex-encoded schedule reservation blob
"/sensors/vs/0", # empty {} on this dump
"/welcome/humidity/vs/0", # welcome-mode plumbing (requestId/operatingStatus, inert)
# Only supportedModes ([Off, Sleep]) is present -- no live "current
# value" field on this dump to confirm the read/write contract, so per
# the 'don't guess' rule this is left unmodeled rather than assumed.
'/mode/convenient/vs/0',
"/mode/convenient/vs/0",
]
COVERAGE = [Capability(href=h) for h in _DHM_IGNORED]
@@ -6,6 +6,7 @@ The /course/vs/0 cycle select and its options-array machinery are shared with
washer and dryer in laundry.py; only the dishwasher-specific options (storm
wash, auto release dry) are read locally here.
"""
from ..capability import Capability
from ..entities import ButtonDesc, SelectDesc, SensorDesc, SwitchDesc
from .laundry import bool_option_switch, cycle_select
@@ -15,20 +16,28 @@ from .laundry import bool_option_switch, cycle_select
# ---------------------------------------------------------------------------
DISHWASHER_SETTINGS = Capability(
href='/dishwasher/vs/0',
href="/dishwasher/vs/0",
entities=(
SwitchDesc(key='sanitize', field='x.com.samsung.da.sanitize',
icon='mdi:bacteria',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['dishwasher', 'vs', '0'],
{'x.com.samsung.da.sanitize': 'On' if p == 'On' else 'Off'})),
SelectDesc(key='heated_dry', field='x.com.samsung.da.heatedDry',
icon='mdi:heat-wave',
options_field='x.com.samsung.da.supportedHeatedDry',
write_fn=lambda p, rep, href=None: (
['dishwasher', 'vs', '0'],
{'x.com.samsung.da.heatedDry': p})),
SwitchDesc(
key="sanitize",
field="x.com.samsung.da.sanitize",
icon="mdi:bacteria",
value_fn=lambda v: v == "On",
write_fn=lambda p, rep, href=None: (
["dishwasher", "vs", "0"],
{"x.com.samsung.da.sanitize": "On" if p == "On" else "Off"},
),
),
SelectDesc(
key="heated_dry",
field="x.com.samsung.da.heatedDry",
icon="mdi:heat-wave",
options_field="x.com.samsung.da.supportedHeatedDry",
write_fn=lambda p, rep, href=None: (
["dishwasher", "vs", "0"],
{"x.com.samsung.da.heatedDry": p},
),
),
),
)
@@ -42,13 +51,13 @@ DISHWASHER_SETTINGS = Capability(
# ---------------------------------------------------------------------------
CYCLE_OPTIONS = Capability(
href='/course/vs/0',
href="/course/vs/0",
entities=(
cycle_select(translation_key='dishwasher_cycle', icon='mdi:dishwasher'),
bool_option_switch('storm_wash', 'mdi:weather-lightning-rainy',
'StormWashZone'),
bool_option_switch('auto_release_dry', 'mdi:door-open',
'AutoDoorRelease', gate_on_presence=True),
cycle_select(translation_key="dishwasher_cycle", icon="mdi:dishwasher"),
bool_option_switch("storm_wash", "mdi:weather-lightning-rainy", "StormWashZone"),
bool_option_switch(
"auto_release_dry", "mdi:door-open", "AutoDoorRelease", gate_on_presence=True
),
),
)
@@ -57,26 +66,35 @@ CYCLE_OPTIONS = Capability(
# ---------------------------------------------------------------------------
DIAGNOSIS = Capability(
href='/diagnosis/vs/0',
poll_tier='cold',
href="/diagnosis/vs/0",
poll_tier="cold",
entities=(
SensorDesc(key='diagnosis_status', field='x.com.samsung.da.diagnosisStart',
icon='mdi:stethoscope',
entity_category='diagnostic'),
ButtonDesc(key='diagnosis_start', field='', payload='Start',
icon='mdi:play-circle-outline',
entity_category='diagnostic',
write_fn=lambda p, rep, href=None: (
['diagnosis', 'vs', '0'], {'x.com.samsung.da.diagnosisStart': p})),
SensorDesc(
key="diagnosis_status",
field="x.com.samsung.da.diagnosisStart",
icon="mdi:stethoscope",
entity_category="diagnostic",
),
ButtonDesc(
key="diagnosis_start",
field="",
payload="Start",
icon="mdi:play-circle-outline",
entity_category="diagnostic",
write_fn=lambda p, rep, href=None: (
["diagnosis", "vs", "0"],
{"x.com.samsung.da.diagnosisStart": p},
),
),
),
)
OPERATION_ORIGIN = Capability(
href='/operation/origin/vs/0',
poll_tier='cold',
href="/operation/origin/vs/0",
poll_tier="cold",
entities=(
SensorDesc(key='operation_origin', field='origin',
icon='mdi:remote',
entity_category='diagnostic'),
SensorDesc(
key="operation_origin", field="origin", icon="mdi:remote", entity_category="diagnostic"
),
),
)
@@ -8,31 +8,32 @@ the /course/vs/0 cycle select -- lives in laundry.py.
/course/vs/0 -> DRYER_COURSE (shared cycle select; see below)
/diagnosis/vs/0 -> DRYER_DIAGNOSIS
"""
from ..capability import Capability
from ..entities import SensorDesc, SwitchDesc
from .laundry import cycle_select
def _wrinkle_write(p, rep, href=None):
if p not in ('On', 'Off'):
if p not in ("On", "Off"):
return None
return ['washer', 'vs', '0'], {'x.com.samsung.da.wrinklePrevent': p}
return ["washer", "vs", "0"], {"x.com.samsung.da.wrinklePrevent": p}
DRYER_SETTINGS = Capability(
href='/washer/vs/0',
poll_tier='warm',
href="/washer/vs/0",
poll_tier="warm",
entities=(
SensorDesc(key='dry_level', field='x.com.samsung.da.dryLevel',
icon='mdi:water-percent'),
SensorDesc(key='dry_time', field='x.com.samsung.da.dryTime',
icon='mdi:timer'),
SensorDesc(key='dryer_type', field='x.com.samsung.da.dryerType',
icon='mdi:tumble-dryer'),
SwitchDesc(key='wrinkle_prevent', field='x.com.samsung.da.wrinklePrevent',
icon='mdi:iron',
value_fn=lambda v: v == 'On',
write_fn=_wrinkle_write),
SensorDesc(key="dry_level", field="x.com.samsung.da.dryLevel", icon="mdi:water-percent"),
SensorDesc(key="dry_time", field="x.com.samsung.da.dryTime", icon="mdi:timer"),
SensorDesc(key="dryer_type", field="x.com.samsung.da.dryerType", icon="mdi:tumble-dryer"),
SwitchDesc(
key="wrinkle_prevent",
field="x.com.samsung.da.wrinklePrevent",
icon="mdi:iron",
value_fn=lambda v: v == "On",
write_fn=_wrinkle_write,
),
),
)
@@ -49,18 +50,22 @@ DRYER_SETTINGS = Capability(
# course and is ignored (ignored.py) -- the mirror of how /st/washercourse/vs/0
# is ignored for washers.
DRYER_COURSE = Capability(
href='/course/vs/0',
href="/course/vs/0",
entities=(
cycle_select(translation_key='dryer_cycle', icon='mdi:tumble-dryer',
table_href='/st/dryercourse/vs/0'),
cycle_select(
translation_key="dryer_cycle",
icon="mdi:tumble-dryer",
table_href="/st/dryercourse/vs/0",
),
),
)
DRYER_DIAGNOSIS = Capability(
href='/diagnosis/vs/0',
poll_tier='warm',
href="/diagnosis/vs/0",
poll_tier="warm",
entities=(
SensorDesc(key='diagnosis', field='x.com.samsung.da.diagnosisStart',
entity_category='diagnostic'),
SensorDesc(
key="diagnosis", field="x.com.samsung.da.diagnosisStart", entity_category="diagnostic"
),
),
)
@@ -0,0 +1,228 @@
"""Capabilities for the Samsung EHS (Eco Heating System) air-to-water heat
pump family (TP1X_DA_AC_EHS-class, model TP1X_DA_AC_EHS_01001_0000).
An EHS unit runs two independently-controlled loops off one outdoor unit:
space heating/cooling ("zone1", through /mode/vs/0, /power/vs/0,
/temperatures/indoor/vs/0) and domestic hot water ("dhw", through
/mode/dhw/vs/0, /power/dhw/vs/0, /temperatures/dhw/vs/0). There's no shared
vocabulary with the room-AC family in airconditioner.py beyond the DA_AC_
board prefix -- EHS reports its own /mode/*/vs/0 and /temperatures/*/vs/0
shapes, not airconditioner.py's HREF_MODE/HREF_TEMP* OCF-pattern hrefs.
zone1 has no HA platform with matching semantics (it's a leaving-water-
temperature setpoint, not a thermostat with HVAC modes airconditioner.py's
climate.py would fit), so it stays switch/select/number/sensor -- same shape
as dehumidifier.py's power/mode/humidity split. dhw is a real HA
water_heater.py -- see DHW below and water_heater.py's module docstring --
following the same primary-resource-plus-sibling-reads pattern as
airconditioner.py's CLIMATE/climate.py.
Verified against a real TP1X_DA_AC_EHS_01001_0000 diagnostics dump
(firmware AEH-WW-TP1-22-AE6000_17260402, TizenRT 3.1 / DAWIT 2.0).
"""
from ..capability import Capability
from ..entities import NumberDesc, SelectDesc, SensorDesc, SwitchDesc, WaterHeaterDesc
from .common import normalize_temp_unit
def _num(v):
try:
return float(v)
except (TypeError, ValueError):
return None
def _first_mode(rep):
"""Representative scalar for a mode select -- `modes` is a single-element
list on every dump seen so far, mirroring airconditioner._first_mode /
dehumidifier._first_mode's handling of the same field shape."""
modes = rep.get("x.com.samsung.da.modes")
if isinstance(modes, (list, tuple)):
return modes[0] if modes else None
return modes
def _temp_unit(rep):
return normalize_temp_unit(rep.get("x.com.samsung.da.unit"), "°C")
def _bounds(rep, default_min, default_max):
"""The resource's own (minimum, maximum) pair, or the defaults.
Both ends together or neither -- a board reporting only one would
otherwise pair a real device bound with an invented default, which
looks plausible and is silently wrong. Same rule as
climate._range()/water_heater._range(), and the same reason
oven._setpoint_bounds resolves its pair in one place.
"""
lo = _num(rep.get("x.com.samsung.da.minimum"))
hi = _num(rep.get("x.com.samsung.da.maximum"))
return (lo, hi) if (lo is not None and hi is not None) else (default_min, default_max)
def _step(rep, default):
"""`is None`, not `or` -- `or` collapses a genuine 0 (issue #160)."""
step = _num(rep.get("x.com.samsung.da.increment"))
return default if step is None else step
ZONE_POWER = Capability(
href="/power/vs/0",
poll_tier="warm",
entities=(
SwitchDesc(
key="zone_power",
field="x.com.samsung.da.power",
icon="mdi:radiator",
value_fn=lambda v: v == "On",
write_fn=lambda p, rep, href=None: (
["power", "vs", "0"],
{"x.com.samsung.da.power": "On" if p == "On" else "Off"},
),
),
),
)
ZONE_MODE = Capability(
href="/mode/vs/0",
poll_tier="warm",
entities=(
SelectDesc(
key="zone_mode",
rep_fn=_first_mode,
icon="mdi:sun-snowflake-variant",
options_field="x.com.samsung.da.supportedModes",
write_fn=lambda p, rep, href=None: (
["mode", "vs", "0"],
{"x.com.samsung.da.modes": [p]},
),
),
),
)
# type=Water/unit=Celsius on this dump names the space-heating loop's flow/
# room setpoint, not a literal water temperature -- Samsung EHS zone control
# is leaving-water-temperature-based, same convention as the dhw loop below.
ZONE_TEMPERATURE = Capability(
href="/temperatures/indoor/vs/0",
poll_tier="warm",
entities=(
SensorDesc(
key="zone_temperature",
field="x.com.samsung.da.current",
device_class="temperature",
unit_fn=_temp_unit,
state_class="measurement",
value_fn=_num,
),
NumberDesc(
key="zone_target_temperature",
field="x.com.samsung.da.desired",
device_class="temperature",
unit_fn=_temp_unit,
entity_category="config",
value_fn=_num,
native_min_fn=lambda rep: _bounds(rep, 5.0, 30.0)[0],
native_max_fn=lambda rep: _bounds(rep, 5.0, 30.0)[1],
step_fn=lambda rep: _step(rep, 0.5),
write_fn=lambda p, rep, href=None: (
["temperatures", "indoor", "vs", "0"],
{"x.com.samsung.da.desired": str(float(p))},
),
),
),
)
# Canonical dhw resource hrefs. water_heater.py binds the primary HREF_DHW_MODE
# via DHW below and reads the sibling power/temperature hrefs off the
# coordinator snapshot -- same primary-plus-siblings shape as
# airconditioner.py's HREF_MODE/CLIMATE_CONSUMED_HREFS. Declared once here
# and imported by water_heater.py, so a new sibling read can't drift out of
# sync with its DHW_CONSUMED_HREFS coverage entry below.
HREF_DHW_POWER = "/power/dhw/vs/0" # on/off
HREF_DHW_MODE = "/mode/dhw/vs/0" # primary (bound by DHW) -- current_operation
HREF_DHW_TEMPERATURE = "/temperatures/dhw/vs/0" # current/target temperature
DHW_CONSUMED_HREFS = [HREF_DHW_POWER, HREF_DHW_TEMPERATURE]
def _dhw_write(payload, rep, href=None):
"""Map a (kind, value) command from the water_heater platform to the
(path_segs, body) for that one sub-write -- same contract as
airconditioner._climate_write, just across the dhw loop's three
resources instead of the AC's power/mode/temperature/wind set."""
kind, value = payload
if kind == "power":
return (["power", "dhw", "vs", "0"], {"x.com.samsung.da.power": "On" if value else "Off"})
if kind == "mode":
return (["mode", "dhw", "vs", "0"], {"x.com.samsung.da.modes": [value]})
if kind == "temperature":
return (["temperatures", "dhw", "vs", "0"], {"x.com.samsung.da.desired": str(float(value))})
return None
DHW = Capability(
href=HREF_DHW_MODE,
poll_tier="warm",
entities=(
WaterHeaterDesc(
key="water_heater", translation_key="dhw", rep_fn=_first_mode, write_fn=_dhw_write
),
),
)
# Power and temperature are read by the composite DHW entity above, not
# given their own entities -- coverage-only caps so discover() reports no
# gap (see airconditioner.py's CLIMATE_CONSUMED_HREFS for the same pattern).
DHW_CONSUMED = [Capability(href=h, poll_tier="warm") for h in DHW_CONSUMED_HREFS]
# Deliberately a plain config switch, not water_heater's AWAY_MODE feature.
# HA core's smartthings water_heater does wire this same Samsung capability
# (CUSTOM_OUTING_MODE) up to WaterHeaterEntityFeature.AWAY_MODE, and the DHW
# operation-mode map above is taken from that integration -- so the
# divergence is worth stating. /option/outgoing/vs/0 is device-wide: one
# `away` flag covering the whole unit, zone1 included (it has no dhw-scoped
# sibling href, unlike every other resource in this loop). Hanging it off
# the DHW card would present a device-wide setting as if it only affected
# hot water. It stays a switch until a board turns up with a per-loop away
# resource to bind instead.
AWAY_MODE = Capability(
href="/option/outgoing/vs/0",
poll_tier="cold",
entities=(
SwitchDesc(
key="away_mode",
field="x.com.samsung.da.away",
icon="mdi:home-export-outline",
entity_category="config",
value_fn=lambda v: v == "On",
write_fn=lambda p, rep, href=None: (
["option", "outgoing", "vs", "0"],
{"x.com.samsung.da.away": "On" if p == "On" else "Off"},
),
),
),
)
# ---------------------------------------------------------------------------
# EHS-scoped coverage: opaque vendor plumbing (hex-encoded factory/cycle/
# schedule blobs) or resources with no confirmed write contract on this
# dump, following the same 'don't guess' rule as dehumidifier._DHM_IGNORED.
# Not in the global ignored.IGNORED since these are EHS-only shapes that
# would need their own verification on other device families.
# ---------------------------------------------------------------------------
_EHS_IGNORED = [
"/availablecontrolsets/vs/0", # opaque hex-encoded control-set bitmap (id: EHS)
"/da/softreset/vs/0", # soft-reset trigger plumbing
"/diagnosis/vs/0", # empty {} on this dump
"/ehscycle/vs/0", # opaque hex-encoded indoor/outdoor cycle log
"/ehsfsv/vs/0", # opaque hex-encoded factory setting values
"/option/dhwdisplay/vs/0", # front-panel DHW-display show/hide, cosmetic only
"/reserverulesets/vs/0", # opaque hex-encoded schedule reservation blob
"/sac/installationinfo/vs/0", # static outdoor/indoor installation info, diagnostic only
"/actions/zone1/vs/0", # zone1 schedule/timer program -- unmodeled for now
"/actions/dhw/vs/0", # DHW schedule/timer program -- unmodeled for now
]
COVERAGE = [Capability(href=h) for h in _EHS_IGNORED]
File diff suppressed because it is too large Load Diff
@@ -23,105 +23,106 @@ override. Grow it as real /device/0 dumps surface more universal noise —
do not add a href here on a guess. If a href's relevance is unclear, leave
it unbound so it surfaces as a gap for a human to look at.
"""
from ..capability import Capability
IGNORED: list[Capability] = [
# Device serial/model is read directly by the coordinator for HA device
# identity, not modeled as an entity capability.
Capability(href='/information/vs/0'),
Capability(href="/information/vs/0"),
# Bixby voice assistant: feature negotiation, account provisioning
# (Samsung account email, access tokens), terms-of-service state, and
# enable/disable status.
Capability(href='/voice/feature/vs/0'),
Capability(href='/voice/provisioning/vs/0'),
Capability(href='/bixby/vs/0'),
Capability(href='/bixby/status/vs/0'),
Capability(href='/bixbyuservalidate/vs/0'),
Capability(href='/bixbyterms/vs/0'),
Capability(href="/voice/feature/vs/0"),
Capability(href="/voice/provisioning/vs/0"),
Capability(href="/bixby/vs/0"),
Capability(href="/bixby/status/vs/0"),
Capability(href="/bixbyuservalidate/vs/0"),
Capability(href="/bixbyterms/vs/0"),
# Network/WiFi housekeeping — MAC addresses, supported auth/crypto
# types, no controllable or observable appliance state.
Capability(href='/wirelessinfo/vs/0'),
Capability(href='/connectionconfig/vs/0'),
Capability(href="/wirelessinfo/vs/0"),
Capability(href="/connectionconfig/vs/0"),
# Static or internal-protocol metadata, not entity-worthy.
Capability(href='/quickcontrol/info/vs/0'),
Capability(href='/realtimenotiforclient/vs/0'),
Capability(href='/file/information/vs/0'),
Capability(href='/configuration/vs/0'), # region/countryCode
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
Capability(href="/quickcontrol/info/vs/0"),
Capability(href="/realtimenotiforclient/vs/0"),
Capability(href="/file/information/vs/0"),
Capability(href="/configuration/vs/0"), # region/countryCode
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.
Capability(href='/drlc/vs/0'),
Capability(href="/drlc/vs/0"),
# Redundant with capabilities already declared elsewhere.
# /speakersound/vs/0 duplicates /settings/sound/volume/vs/0 (laundry.SOUND_VOLUME).
Capability(href='/speakersound/vs/0'),
Capability(href="/speakersound/vs/0"),
# /wm/editcourse/vs/0 has no entities of its own -- x.com.samsung.da.
# editCourseList is read directly out of the resource snapshot by
# dishwasher.CYCLE_OPTIONS's and washer.WASHER_COURSE's cycle select
# (options=_cycle_options) to build that device's actual supported
# course list, rather than exposing this href's raw byte string
# through its own entity.
Capability(href='/wm/editcourse/vs/0'),
Capability(href="/wm/editcourse/vs/0"),
# Bixby audio feedback (chime + volume played when Bixby starts/stops
# listening) — only meaningful with Bixby enabled, which this
# integration has no local path to configure or use.
Capability(href='/sec/networkaudio/audio/vs/0'),
Capability(href="/sec/networkaudio/audio/vs/0"),
# Static Bespoke-product-line flag, not appliance state.
Capability(href='/bespoke/vs/0'),
Capability(href="/bespoke/vs/0"),
# Empty resource on every dump seen so far — nothing to expose.
Capability(href='/defrost/prediction/vs/0'),
Capability(href="/defrost/prediction/vs/0"),
# Seasonal defrost schedule (start/period/end per season). Automating
# this cleanly would need a multi-field schedule editor; the practical
# on/off control is fridge.DEFROST_DELAY.
Capability(href='/defrost/reservation/vs/0'),
Capability(href="/defrost/reservation/vs/0"),
# Warranty/service-plan enrollment status — every field reads "Unknown"
# on hardware not enrolled in a Samsung Care+ style program.
Capability(href='/dginformation/vs/0'),
Capability(href="/dginformation/vs/0"),
# OCF-native vacation-mode flag (fridge). Only one value ('RVACATION_OFF')
# has ever been seen in `modes` (issue #7's dump) -- no real choice to
# expose yet. Revisit if a device surfaces it toggled on.
Capability(href='/mode/0'),
Capability(href="/mode/0"),
# Opaque integer with no supportedModes/options list to interpret it
# against — meaning unclear from the raw resource alone.
Capability(href='/runningmode/vs/0'),
Capability(href="/runningmode/vs/0"),
# Demand-response energy planner — same utility-program dependency as
# /drlc/vs/0 above; every dump seen so far is inert (plan: 'none').
Capability(href='/energy/planner/vs/0'),
Capability(href="/energy/planner/vs/0"),
# Temperature-unit display preference, redundant with HA's own units.
Capability(href='/wm/submode/vs/0'),
Capability(href="/wm/submode/vs/0"),
# Read-only re-encoding of the course already exposed by
# washer.WASHER_COURSE at /course/vs/0 (x.com.samsung.da.st.washerMode
# is literally "Table_02_Course_<same hex code>").
Capability(href='/st/washercourse/vs/0'),
Capability(href="/st/washercourse/vs/0"),
# Dryer counterpart of the above: re-encoding of the course already
# 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'),
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'),
Capability(href="/wm/welcomemsg/vs/0"),
# User-saved custom course slots (F1-FA). No controllable/observable
# state without a multi-slot editor; revisit if that becomes valuable.
Capability(href='/wm/personalcourse/vs/0'),
Capability(href="/wm/personalcourse/vs/0"),
# OCF-native energy resource is empty ({}) on washer hardware seen so
# far, unlike /power/0, /kidslock/0, /remotectrl/0 which do carry real
# data -- common.ENERGY_METER on /energy/consumption/vs/0 is the only
# real source for this control.
Capability(href='/energy/consumption/0'),
Capability(href="/energy/consumption/0"),
# Empty ({}) on every washer dump seen so far -- nothing to expose.
Capability(href='/cycleinterface/vs/0'),
Capability(href="/cycleinterface/vs/0"),
# OCF-native duplicate of /drlc/vs/0 above -- same utility-program
# dependency this integration doesn't support locally.
Capability(href='/drlc/0'),
Capability(href="/drlc/0"),
# OCF-native duplicate of /operational/state/vs/0, which is already
# modeled by operational.OPERATIONAL_STATE (a richer, write-capable
# capability with start/pause/stop buttons and a delay-start control)
@@ -130,13 +131,12 @@ IGNORED: list[Capability] = [
# time, progress percentage) with no write path -- not worth building a
# parallel write-capable capability around an unverified generic OCF
# write contract.
Capability(href='/operational/state/0'),
Capability(href="/operational/state/0"),
# Cooktop guided-cooking/recipe status (issue #86, TP1X_DA-KS-COOKTOP
# family): sequenceNumber, operationBurnerNumber, a stageInfo block, and
# a textData.menu string -- every field empty/zero on the only dump
# seen so far (device idle, no guided-cooking program active). Same
# "don't guess" treatment as the microwave family's /recipe/cook/vs/0.
# Revisit if a dump with an active recipe surfaces.
Capability(href='/cooktop/recipe/status/vs/0'),
Capability(href="/cooktop/recipe/status/vs/0"),
]
@@ -19,33 +19,34 @@ Resource hrefs seen across laundry dumps:
Door-LED keys use NO `x.com.samsung.da.` prefix -- `setBrightness` /
`setNightLight` -- preserved exactly as they appear in the OCF resource rep.
"""
from datetime import time as dt_time
from ...catalog import has_entity_translation
from ..capability import Capability
from ..entities import NumberDesc, SelectDesc, SensorDesc, SwitchDesc, TimeDesc
_LED_LEVELS = ('Low', 'High')
_SOUND_MODES = ('voice', 'tone', 'mute')
_LED_LEVELS = ("Low", "High")
_SOUND_MODES = ("voice", "tone", "mute")
def _led_brightness_write(p, rep, href=None):
if p not in _LED_LEVELS:
return None
return ['doorled', 'light', 'vs', '0'], {'setBrightness': p}
return ["doorled", "light", "vs", "0"], {"setBrightness": p}
def _led_night_write(p, rep, href=None):
if p not in ('On', 'Off'):
if p not in ("On", "Off"):
return None
return ['doorled', 'light', 'vs', '0'], {'setNightLight': p}
return ["doorled", "light", "vs", "0"], {"setNightLight": p}
def _parse_hm(v):
if not v:
return None
try:
h, m = v.split(':')
h, m = v.split(":")
return dt_time(int(h), int(m))
except Exception:
return None
@@ -54,66 +55,95 @@ def _parse_hm(v):
def _sound_mode_write(p, rep, href=None):
if p not in _SOUND_MODES:
return None
return ['settings', 'sound', 'mode', 'vs', '0'], {'mode': p}
return ["settings", "sound", "mode", "vs", "0"], {"mode": p}
DOOR_LED = Capability(
href='/doorled/light/vs/0',
href="/doorled/light/vs/0",
entities=(
SelectDesc(key='led_brightness', field='setBrightness',
icon='mdi:brightness-6',
entity_category='config',
options=_LED_LEVELS, write_fn=_led_brightness_write),
SwitchDesc(key='led_night_light', field='setNightLight',
icon='mdi:weather-night',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=_led_night_write),
SelectDesc(key='led_night_brightness', field='setNightLightBrightness',
icon='mdi:brightness-4',
entity_category='config',
options=_LED_LEVELS,
write_fn=lambda p, rep, href=None: (
['doorled', 'light', 'vs', '0'],
{'setNightLightBrightness': p})),
TimeDesc(key='led_night_start', field='setNightLightTimeStart',
icon='mdi:clock-start',
entity_category='config',
value_fn=_parse_hm,
write_fn=lambda p, rep, href=None: (
['doorled', 'light', 'vs', '0'],
{'setNightLightTimeStart': f'{p.hour:02d}:{p.minute:02d}'})),
TimeDesc(key='led_night_end', field='setNightLightTimeEnd',
icon='mdi:clock-end',
entity_category='config',
value_fn=_parse_hm,
write_fn=lambda p, rep, href=None: (
['doorled', 'light', 'vs', '0'],
{'setNightLightTimeEnd': f'{p.hour:02d}:{p.minute:02d}'})),
SelectDesc(
key="led_brightness",
field="setBrightness",
icon="mdi:brightness-6",
entity_category="config",
options=_LED_LEVELS,
write_fn=_led_brightness_write,
),
SwitchDesc(
key="led_night_light",
field="setNightLight",
icon="mdi:weather-night",
entity_category="config",
value_fn=lambda v: v == "On",
write_fn=_led_night_write,
),
SelectDesc(
key="led_night_brightness",
field="setNightLightBrightness",
icon="mdi:brightness-4",
entity_category="config",
options=_LED_LEVELS,
write_fn=lambda p, rep, href=None: (
["doorled", "light", "vs", "0"],
{"setNightLightBrightness": p},
),
),
TimeDesc(
key="led_night_start",
field="setNightLightTimeStart",
icon="mdi:clock-start",
entity_category="config",
value_fn=_parse_hm,
write_fn=lambda p, rep, href=None: (
["doorled", "light", "vs", "0"],
{"setNightLightTimeStart": f"{p.hour:02d}:{p.minute:02d}"},
),
),
TimeDesc(
key="led_night_end",
field="setNightLightTimeEnd",
icon="mdi:clock-end",
entity_category="config",
value_fn=_parse_hm,
write_fn=lambda p, rep, href=None: (
["doorled", "light", "vs", "0"],
{"setNightLightTimeEnd": f"{p.hour:02d}:{p.minute:02d}"},
),
),
),
)
SOUND_MODE = Capability(
href='/settings/sound/mode/vs/0',
href="/settings/sound/mode/vs/0",
entities=(
SelectDesc(key='sound_mode', field='mode',
icon='mdi:volume-high',
entity_category='config',
options=_SOUND_MODES, write_fn=_sound_mode_write),
SelectDesc(
key="sound_mode",
field="mode",
icon="mdi:volume-high",
entity_category="config",
options=_SOUND_MODES,
write_fn=_sound_mode_write,
),
),
)
SOUND_VOLUME = Capability(
href='/settings/sound/volume/vs/0',
href="/settings/sound/volume/vs/0",
entities=(
NumberDesc(key='sound_volume', field='level',
icon='mdi:volume-medium',
entity_category='config',
native_min=0, native_max=15, step=5,
value_fn=lambda v: int(v) if v is not None else None,
write_fn=lambda p, rep, href=None: (
['settings', 'sound', 'volume', 'vs', '0'],
{'level': str(int(p))})),
NumberDesc(
key="sound_volume",
field="level",
icon="mdi:volume-medium",
entity_category="config",
native_min=0,
native_max=15,
step=5,
value_fn=lambda v: int(v) if v is not None else None,
write_fn=lambda p, rep, href=None: (
["settings", "sound", "volume", "vs", "0"],
{"level": str(int(p))},
),
),
),
)
@@ -125,21 +155,25 @@ SOUND_VOLUME = Capability(
# ---------------------------------------------------------------------------
BUZZER_SOUND = Capability(
href='/buzzersound/vs/0',
href="/buzzersound/vs/0",
entities=(
SelectDesc(key='buzzer_sound', field='setBuzzerSound',
icon='mdi:volume-high',
entity_category='config',
options_field='supportedBuzzerSound',
write_fn=lambda p, rep, href=None: (
['buzzersound', 'vs', '0'], {'setBuzzerSound': p})),
SelectDesc(key='finish_sound', field='setFinishSound',
icon='mdi:bell-ring',
entity_category='config',
exists_fn=lambda rep, resources: 'supportedFinishSound' in rep,
options_field='supportedFinishSound',
write_fn=lambda p, rep, href=None: (
['buzzersound', 'vs', '0'], {'setFinishSound': p})),
SelectDesc(
key="buzzer_sound",
field="setBuzzerSound",
icon="mdi:volume-high",
entity_category="config",
options_field="supportedBuzzerSound",
write_fn=lambda p, rep, href=None: (["buzzersound", "vs", "0"], {"setBuzzerSound": p}),
),
SelectDesc(
key="finish_sound",
field="setFinishSound",
icon="mdi:bell-ring",
entity_category="config",
exists_fn=lambda rep, resources: "supportedFinishSound" in rep,
options_field="supportedFinishSound",
write_fn=lambda p, rep, href=None: (["buzzersound", "vs", "0"], {"setFinishSound": p}),
),
),
)
@@ -174,29 +208,29 @@ BUZZER_SOUND = Capability(
def hex_pairs(codes):
"""'1C1D21...' -> ['1C', '1D', '21', ...]."""
return [codes[i:i + 2] for i in range(0, len(codes) - 1, 2)]
return [codes[i : i + 2] for i in range(0, len(codes) - 1, 2)]
def parse_edit_course_list(raw):
"""'EditCourseList_1C1D21...' -> ['1C', '1D', '21', ...]."""
if not isinstance(raw, str) or '_' not in raw:
if not isinstance(raw, str) or "_" not in raw:
return []
return hex_pairs(raw.split('_', 1)[1])
return hex_pairs(raw.split("_", 1)[1])
def cycle_options(resources):
rep = resources.get('/wm/editcourse/vs/0') or {}
codes = parse_edit_course_list(rep.get('x.com.samsung.da.editCourseList'))
rep = resources.get("/wm/editcourse/vs/0") or {}
codes = parse_edit_course_list(rep.get("x.com.samsung.da.editCourseList"))
if codes:
return codes
return _course_codes_from_supported_options(resources.get('/course/vs/0') or {})
return _course_codes_from_supported_options(resources.get("/course/vs/0") or {})
def option_value(options, prefix):
"""Find `<prefix>_<value>` in the options array and return <value>."""
for o in (options or []):
if isinstance(o, str) and o.startswith(prefix + '_'):
return o.split('_', 1)[1]
for o in options or []:
if isinstance(o, str) and o.startswith(prefix + "_"):
return o.split("_", 1)[1]
return None
@@ -238,7 +272,7 @@ def _course_codes_from_supported_options(course_rep):
enough (double digits) that colliding by chance on both checks is
unlikely, and no device seen so far actually needs it.
"""
raw = course_rep.get('x.com.samsung.da.supportedOptions')
raw = course_rep.get("x.com.samsung.da.supportedOptions")
hexstr = raw[0] if isinstance(raw, list) and raw else raw
if not isinstance(hexstr, str) or len(hexstr) < 3:
return []
@@ -246,14 +280,14 @@ def _course_codes_from_supported_options(course_rep):
if len(body) % 2:
return []
total_bytes = len(body) // 2
current = option_value(course_rep.get('x.com.samsung.da.options'), 'Course')
current = option_value(course_rep.get("x.com.samsung.da.options"), "Course")
for k in range(1, total_bytes + 1):
if total_bytes % k:
continue
n = total_bytes // k
if n < 2:
continue
firsts = [body[i * k * 2:i * k * 2 + 2] for i in range(n)]
firsts = [body[i * k * 2 : i * k * 2 + 2] for i in range(n)]
if len(set(firsts)) != n:
continue
if current is not None and current not in firsts:
@@ -265,20 +299,20 @@ def _course_codes_from_supported_options(course_rep):
def option_write(prefix, new_value):
"""A one-token x.com.samsung.da.options write -- see the module comment
above cycle_options for why this doesn't read/rewrite the whole array."""
return [f'{prefix}_{new_value}']
return [f"{prefix}_{new_value}"]
def cycle_write(p, rep, href=None):
if not rep.get('x.com.samsung.da.options'):
if not rep.get("x.com.samsung.da.options"):
return None
return ['course', 'vs', '0'], {
'x.com.samsung.da.options': option_write('Course', p),
return ["course", "vs", "0"], {
"x.com.samsung.da.options": option_write("Course", p),
}
def _table_id(resources, table_href):
rep = resources.get(table_href) or {}
return rep.get('x.com.samsung.da.st.courseTable')
return rep.get("x.com.samsung.da.st.courseTable")
def cycle_select(*, translation_key, icon, table_href=None):
@@ -316,18 +350,21 @@ def cycle_select(*, translation_key, icon, table_href=None):
"""
key = translation_key
if table_href is not None:
def key(resources):
table = _table_id(resources, table_href)
if not isinstance(table, str) or not table:
return 'cycle'
candidate = f'{translation_key}_{table.lower()}'
return candidate if has_entity_translation('select', candidate) else 'cycle'
return "cycle"
candidate = f"{translation_key}_{table.lower()}"
return candidate if has_entity_translation("select", candidate) else "cycle"
return SelectDesc(
key='cycle', icon=icon, translation_key=key,
key="cycle",
icon=icon,
translation_key=key,
options=cycle_options,
exists_fn=lambda rep, resources: bool(cycle_options(resources)),
rep_fn=lambda rep: option_value(rep.get('x.com.samsung.da.options'), 'Course'),
rep_fn=lambda rep: option_value(rep.get("x.com.samsung.da.options"), "Course"),
write_fn=cycle_write,
)
@@ -344,27 +381,30 @@ def cycle_select(*, translation_key, icon, table_href=None):
def bool_option_write(prefix):
def write(p, rep, href=None):
if p not in ('On', 'Off'):
if p not in ("On", "Off"):
return None
if not rep.get('x.com.samsung.da.options'):
if not rep.get("x.com.samsung.da.options"):
return None
return ['course', 'vs', '0'], {
'x.com.samsung.da.options': option_write(prefix, p),
return ["course", "vs", "0"], {
"x.com.samsung.da.options": option_write(prefix, p),
}
return write
def bool_option_value(prefix):
return lambda rep: option_value(rep.get('x.com.samsung.da.options'), prefix) == 'On'
return lambda rep: option_value(rep.get("x.com.samsung.da.options"), prefix) == "On"
def bool_option_exists(prefix):
return lambda rep, resources: option_value(
rep.get('x.com.samsung.da.options'), prefix) is not None
return lambda rep, resources: (
option_value(rep.get("x.com.samsung.da.options"), prefix) is not None
)
def bool_option_switch(key, icon, prefix, *, entity_category=None,
gate_on_presence=False, validate_fn=None):
def bool_option_switch(
key, icon, prefix, *, entity_category=None, gate_on_presence=False, validate_fn=None
):
"""A SwitchDesc over a '<prefix>_On'/'<prefix>_Off' options[] token.
gate_on_presence self-gates the entity off on models that never report
@@ -376,7 +416,9 @@ def bool_option_switch(key, icon, prefix, *, entity_category=None,
building one, if needed, is the caller's job.
"""
return SwitchDesc(
key=key, icon=icon, entity_category=entity_category,
key=key,
icon=icon,
entity_category=entity_category,
exists_fn=bool_option_exists(prefix) if gate_on_presence else None,
rep_fn=bool_option_value(prefix),
write_fn=bool_option_write(prefix),
@@ -394,11 +436,13 @@ def bool_option_switch(key, icon, prefix, *, entity_category=None,
# ---------------------------------------------------------------------------
JOB_BEGINNING_STATUS = Capability(
href='/wm/jobbeginingstatus/vs/0',
poll_tier='warm',
href="/wm/jobbeginingstatus/vs/0",
poll_tier="warm",
entities=(
SensorDesc(key='job_beginning_status',
field='x.com.samsung.da.currentStatus',
entity_category='diagnostic'),
SensorDesc(
key="job_beginning_status",
field="x.com.samsung.da.currentStatus",
entity_category="diagnostic",
),
),
)
@@ -21,12 +21,22 @@ 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'.
* Filter reminder / end signal reminder: bare 'FilterRemind'/'RemindBeep'
option-array tokens (issue #181), both with On and Off observed live
(issue #152's ME7500D fixtures) -- gated with exists_fn like Lamp since
the MW7300B combi dump has neither.
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
is also the test.
"""
from ..capability import Capability
from ..entities import NumberDesc, SelectDesc, SensorDesc, SwitchDesc
from .common import int_or_none, normalize_temp_unit
@@ -44,17 +54,17 @@ from .laundry import option_value, option_write
# board's own /mode/vs/0 supportedModes reports; an entry here that a device
# never sends just never gets picked.
_MICROWAVE_MODES = (
'NoOperation',
'MicroWave',
'MicroWaveGrill',
'MicroWaveConvection',
'Convection',
'AirFryer',
'Grill',
'Autocook',
'AutocookCustom',
'Deodorization',
'KeepWarm',
"NoOperation",
"MicroWave",
"MicroWaveGrill",
"MicroWaveConvection",
"Convection",
"AirFryer",
"Grill",
"Autocook",
"AutocookCustom",
"Deodorization",
"KeepWarm",
)
# Convection/MicroWaveConvection modeSpec on issue #121's dump: tempMinC 40,
@@ -73,9 +83,9 @@ def _microwave_temp_unit(rep):
carries a per-item x.com.samsung.da.unit field. Both known dumps for
this family report 'Celsius'; kept live rather than hardcoded per the
fridge/oven convention (issue #7)."""
items = rep.get('x.com.samsung.da.items') or []
unit = items[0].get('x.com.samsung.da.unit') if items else None
return normalize_temp_unit(unit, default='°C')
items = rep.get("x.com.samsung.da.items") or []
unit = items[0].get("x.com.samsung.da.unit") if items else None
return normalize_temp_unit(unit, default="°C")
def _setpoint_write(p, rep, href=None):
@@ -89,12 +99,12 @@ def _setpoint_write(p, rep, href=None):
temp_i = int(round(temp / SETPOINT_STEP_C) * SETPOINT_STEP_C)
if not (SETPOINT_MIN_C <= temp_i <= SETPOINT_MAX_C):
return None
items = rep.get('x.com.samsung.da.items')
items = rep.get("x.com.samsung.da.items")
if not items:
return None
items = [dict(it) for it in items]
items[0]['x.com.samsung.da.desired'] = str(temp_i)
return ['temperatures', 'vs', '0'], {'x.com.samsung.da.items': items}
items[0]["x.com.samsung.da.desired"] = str(temp_i)
return ["temperatures", "vs", "0"], {"x.com.samsung.da.items": items}
def _power_level_watts(v):
@@ -102,38 +112,83 @@ def _power_level_watts(v):
if v is None:
return None
s = str(v).strip()
if s.upper().endswith('W'):
if s.upper().endswith("W"):
s = s[:-1]
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]}
return ["mode", "vs", "0"], {"x.com.samsung.da.modes": [p]}
def _sound_write(p, rep, href=None):
if p not in ('On', 'Off'):
if p not in ("On", "Off"):
return None
if not rep.get('x.com.samsung.da.options'):
if not rep.get("x.com.samsung.da.options"):
return None
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': option_write('Sound', p),
return ["mode", "vs", "0"], {
"x.com.samsung.da.options": option_write("Sound", p),
}
def _lamp_exists(rep, resources):
return option_value(rep.get('x.com.samsung.da.options'), 'Lamp') is not None
return option_value(rep.get("x.com.samsung.da.options"), "Lamp") is not None
def _filter_remind_exists(rep, resources):
return option_value(rep.get("x.com.samsung.da.options"), "FilterRemind") is not None
def _remind_beep_exists(rep, resources):
return option_value(rep.get("x.com.samsung.da.options"), "RemindBeep") is not None
def _lamp_write(p, rep, href=None):
if p not in ('On', 'Off'):
if p not in ("On", "Off"):
return None
if not rep.get('x.com.samsung.da.options'):
if not rep.get("x.com.samsung.da.options"):
return None
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': option_write('Lamp', p),
# '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", token),
}
def _filter_remind_write(p, rep, href=None):
if p not in ("On", "Off"):
return None
if not rep.get("x.com.samsung.da.options"):
return None
return ["mode", "vs", "0"], {
"x.com.samsung.da.options": option_write("FilterRemind", p),
}
def _remind_beep_write(p, rep, href=None):
if p not in ("On", "Off"):
return None
if not rep.get("x.com.samsung.da.options"):
return None
return ["mode", "vs", "0"], {
"x.com.samsung.da.options": option_write("RemindBeep", p),
}
@@ -142,54 +197,102 @@ def _lamp_write(p, rep, href=None):
# ---------------------------------------------------------------------------
MICROWAVE_CAVITY = Capability(
href='/oven/vs/0',
poll_tier='hot',
href="/oven/vs/0",
poll_tier="hot",
entities=(
SensorDesc(key='cavity_state', field='x.com.samsung.da.state'),
SensorDesc(key='power_level', field='x.com.samsung.da.powerLevel',
unit='W', value_fn=_power_level_watts),
SensorDesc(key="cavity_state", field="x.com.samsung.da.state"),
SensorDesc(
key="power_level",
field="x.com.samsung.da.powerLevel",
unit="W",
value_fn=_power_level_watts,
),
),
)
MICROWAVE_SETPOINT = Capability(
href='/temperatures/vs/0',
poll_tier='hot',
href="/temperatures/vs/0",
poll_tier="hot",
entities=(
NumberDesc(key='setpoint', field='x.com.samsung.da.items',
device_class='temperature', unit_fn=_microwave_temp_unit,
native_min=float(SETPOINT_MIN_C), native_max=float(SETPOINT_MAX_C),
step=float(SETPOINT_STEP_C), icon='mdi:thermometer-chevron-up',
exists_fn=lambda rep, resources: _microwave_temp_unit(rep) == '°C',
value_fn=lambda items: int_or_none(
(items[0].get('x.com.samsung.da.desired') if items else None)),
write_fn=_setpoint_write),
SensorDesc(key='current_temp_c', field='x.com.samsung.da.items',
device_class='temperature',
state_class='measurement', unit_fn=_microwave_temp_unit,
value_fn=lambda items: int_or_none(
(items[0].get('x.com.samsung.da.current') if items else None))),
NumberDesc(
key="setpoint",
field="x.com.samsung.da.items",
device_class="temperature",
unit_fn=_microwave_temp_unit,
native_min=float(SETPOINT_MIN_C),
native_max=float(SETPOINT_MAX_C),
step=float(SETPOINT_STEP_C),
icon="mdi:thermometer-chevron-up",
exists_fn=lambda rep, resources: _microwave_temp_unit(rep) == "°C",
value_fn=lambda items: int_or_none(
items[0].get("x.com.samsung.da.desired") if items else None
),
write_fn=_setpoint_write,
),
SensorDesc(
key="current_temp_c",
field="x.com.samsung.da.items",
device_class="temperature",
state_class="measurement",
unit_fn=_microwave_temp_unit,
value_fn=lambda items: int_or_none(
items[0].get("x.com.samsung.da.current") if items else None
),
),
),
)
MICROWAVE_MODE = Capability(
href='/mode/vs/0',
poll_tier='warm',
href="/mode/vs/0",
poll_tier="warm",
entities=(
# 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,
value_fn=lambda v: v[0] if v else None,
write_fn=_mode_write),
SwitchDesc(key='sound', field='x.com.samsung.da.options',
icon='mdi:volume-high',
entity_category='config',
value_fn=lambda opts: option_value(opts, 'Sound') == 'On',
write_fn=_sound_write),
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',
write_fn=_lamp_write),
SelectDesc(
key="cooking_mode",
field="x.com.samsung.da.modes",
icon="mdi:tune",
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",
icon="mdi:volume-high",
entity_category="config",
value_fn=lambda opts: option_value(opts, "Sound") == "On",
write_fn=_sound_write,
),
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") not in (None, "Off"),
write_fn=_lamp_write,
),
# issue #181: Filter Reminder / End Signal Reminder toggles,
# confirmed present (both On and Off observed across dumps -- see
# issue #152's ME7500D fixtures) but only on boards that carry the
# FilterRemind_*/RemindBeep_* tokens; gated off elsewhere (e.g. the
# MW7300B combi dump has neither) rather than assumed universal.
SwitchDesc(
key="filter_remind",
field="x.com.samsung.da.options",
icon="mdi:air-filter",
entity_category="config",
exists_fn=_filter_remind_exists,
value_fn=lambda opts: option_value(opts, "FilterRemind") == "On",
write_fn=_filter_remind_write,
),
SwitchDesc(
key="remind_beep",
field="x.com.samsung.da.options",
icon="mdi:bell-ring",
entity_category="config",
exists_fn=_remind_beep_exists,
value_fn=lambda opts: option_value(opts, "RemindBeep") == "On",
write_fn=_remind_beep_write,
),
),
)
@@ -2,15 +2,21 @@
Shared by dryer/dishwasher/oven/washer families.
"""
import math
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
from ..capability import Capability
from ..entities import BinarySensorDesc, ButtonDesc, NumberDesc, SensorDesc
_SAMSUNG_STATE_TO_OCF = {
'Ready': 'idle', 'Run': 'active', 'Running': 'active',
'Pause': 'pause', 'Paused': 'pause', 'End': 'idle', 'Stop': 'idle',
"Ready": "idle",
"Run": "active",
"Running": "active",
"Pause": "pause",
"Paused": "pause",
"End": "idle",
"Stop": "idle",
}
@@ -19,7 +25,7 @@ def _to_ocf(v):
def _progress(v):
return 'Idle' if v in (None, 'None') else v
return "Idle" if v in (None, "None") else v
def _int(v):
@@ -32,8 +38,8 @@ def _int(v):
def _is_active(rep):
"""Check if appliance is actively running and cycle is not finished."""
return (
_SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) == 'active'
and rep.get('x.com.samsung.da.progress') != 'Finish'
_SAMSUNG_STATE_TO_OCF.get(rep.get("x.com.samsung.da.state")) == "active"
and rep.get("x.com.samsung.da.progress") != "Finish"
)
@@ -42,7 +48,7 @@ def _remaining_seconds(raw):
if not isinstance(raw, str):
return None
try:
parts = [int(p) for p in raw.split(':')]
parts = [int(p) for p in raw.split(":")]
except (ValueError, TypeError):
return None
@@ -69,7 +75,7 @@ def _delay_hours(v):
def _format_delay(hours):
total_minutes = round(max(float(hours), 0) * 60)
h, m = divmod(total_minutes, 60)
return f'{h}:{m:02d}:00'
return f"{h}:{m:02d}:00"
def _delay_field(rep):
@@ -79,52 +85,68 @@ def _delay_field(rep):
device itself is using; default to delayStartTime for hardware that
reports neither yet (matches prior behavior)."""
return (
'x.com.samsung.da.delayEndTime' if 'x.com.samsung.da.delayEndTime' in rep
else 'x.com.samsung.da.delayStartTime'
"x.com.samsung.da.delayEndTime"
if "x.com.samsung.da.delayEndTime" in rep
else "x.com.samsung.da.delayStartTime"
)
def _finish_time(rep):
if not _is_active(rep):
return None
total_s = _remaining_seconds(rep.get('x.com.samsung.da.remainingTime'))
total_s = _remaining_seconds(rep.get("x.com.samsung.da.remainingTime"))
if not total_s:
return None
return datetime.now(timezone.utc) + timedelta(seconds=total_s)
# Round to whole minutes -- remainingTime itself only has minute
# resolution, but datetime.now() always carries fresh seconds/
# microseconds, so an unrounded result changes on nearly every poll
# even when the device-reported remaining time hasn't. That floods
# the recorder history/logbook with values that look identical once
# the UI rounds them down for display.
finish = datetime.now(UTC) + timedelta(seconds=total_s)
return finish.replace(second=0, microsecond=0)
def _completion_minutes(rep):
"""Parse remaining time into minutes directly from device payload."""
raw = rep.get('x.com.samsung.da.remainingTime') or rep.get('remainingTime')
raw = rep.get("x.com.samsung.da.remainingTime") or rep.get("remainingTime")
total_s = _remaining_seconds(raw)
if total_s is None:
return None
# Avoid the firmware bug where it freezes at 1 minute post-cycle
if rep.get('x.com.samsung.da.progress') == 'Finish':
if rep.get("x.com.samsung.da.progress") == "Finish":
return 0
return math.ceil(total_s / 60)
# Shared by dryer/dishwasher/oven/washer -- oven.py imports this directly
# rather than keeping its own copy, since both wrote the identical
# state='Ready' RMW.
STOP_BUTTON = ButtonDesc(key='stop', field='', payload='Ready',
icon='mdi:stop',
write_fn=lambda p, rep, href=None: (
['operational', 'state', 'vs', '0'],
{'x.com.samsung.da.state': p}))
STOP_BUTTON = ButtonDesc(
key="stop",
field="",
payload="Ready",
icon="mdi:stop",
write_fn=lambda p, rep, href=None: (
["operational", "state", "vs", "0"],
{"x.com.samsung.da.state": p},
),
)
OPERATIONAL_STATE = Capability(
href='/operational/state/vs/0',
poll_tier='hot',
href="/operational/state/vs/0",
poll_tier="hot",
entities=(
SensorDesc(key='machine_state', field='x.com.samsung.da.state',
device_class='enum',
options=('idle', 'active', 'pause'),
translation_key='machine_state', value_fn=_to_ocf),
SensorDesc(
key="machine_state",
field="x.com.samsung.da.state",
device_class="enum",
options=("idle", "active", "pause"),
translation_key="machine_state",
value_fn=_to_ocf,
),
# cycle_active is a bool derived from machine_state; used by the
# adapter to gate oven writes (cycle_active_field='cycle_active').
# Harmless for non-oven appliances — just an extra bool in state.
@@ -134,56 +156,85 @@ OPERATIONAL_STATE = Capability(
# start/pause/stop buttons below) is shared across the dryer/
# dishwasher/oven/washer families, and 'cycle' is laundry-specific
# vocabulary that doesn't fit an oven's bake/roast/etc.
BinarySensorDesc(key='cycle_active', device_class='running',
rep_fn=lambda rep: (
_SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) == 'active'
and rep.get('x.com.samsung.da.progress') != 'Finish'
)),
SensorDesc(key='progress', icon='mdi:progress-wrench',
rep_fn=lambda rep: (
'Idle' if _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) != 'active'
else _progress(rep.get('x.com.samsung.da.progress'))
)),
SensorDesc(key='progress_percentage',
unit='%', state_class='measurement',
rep_fn=lambda rep: (
0 if _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) != 'active'
else _int(rep.get('x.com.samsung.da.progressPercentage'))
)),
BinarySensorDesc(
key="cycle_active",
device_class="running",
rep_fn=lambda rep: (
_SAMSUNG_STATE_TO_OCF.get(rep.get("x.com.samsung.da.state")) == "active"
and rep.get("x.com.samsung.da.progress") != "Finish"
),
),
SensorDesc(
key="progress",
icon="mdi:progress-wrench",
rep_fn=lambda rep: (
"Idle"
if _SAMSUNG_STATE_TO_OCF.get(rep.get("x.com.samsung.da.state")) != "active"
else _progress(rep.get("x.com.samsung.da.progress"))
),
),
SensorDesc(
key="progress_percentage",
unit="%",
state_class="measurement",
rep_fn=lambda rep: (
0
if _SAMSUNG_STATE_TO_OCF.get(rep.get("x.com.samsung.da.state")) != "active"
else _int(rep.get("x.com.samsung.da.progressPercentage"))
),
),
# Only show finish time when machine is actively running. Samsung
# firmware leaves a stale remainingTime after a cycle ends, and
# freezes it at '00:01:00' when progress reaches 'Finish'.
SensorDesc(key='finish_time', device_class='timestamp',
rep_fn=_finish_time),
SensorDesc(key='completion_minutes',
icon='mdi:clock-outline', unit='min',
device_class='duration', state_class='measurement',
exists_fn=lambda rep, resources: _completion_minutes(rep) is not None,
rep_fn=_completion_minutes),
NumberDesc(key='delay_start_hours', icon='mdi:timer-plus-outline',
device_class='duration', unit='h',
native_min=0, native_max=24, step=1,
rep_fn=lambda rep: _delay_hours(
rep.get('x.com.samsung.da.delayStartTime')
or rep.get('x.com.samsung.da.delayEndTime')),
write_fn=lambda p, rep, href=None: (
['operational', 'state', 'vs', '0'],
{_delay_field(rep): _format_delay(p)})),
ButtonDesc(key='start', field='', payload='Run',
icon='mdi:play',
write_fn=lambda p, rep, href=None: (
['operational', 'state', 'vs', '0'],
{'x.com.samsung.da.state': p})),
ButtonDesc(key='pause', field='', payload='Pause',
icon='mdi:pause',
write_fn=lambda p, rep, href=None: (
['operational', 'state', 'vs', '0'],
{'x.com.samsung.da.state': p})),
SensorDesc(
key="finish_time", device_class="timestamp", hysteresis=True, rep_fn=_finish_time
),
SensorDesc(
key="completion_minutes",
icon="mdi:clock-outline",
unit="min",
device_class="duration",
state_class="measurement",
exists_fn=lambda rep, resources: _completion_minutes(rep) is not None,
rep_fn=_completion_minutes,
),
NumberDesc(
key="delay_start_hours",
icon="mdi:timer-plus-outline",
device_class="duration",
unit="h",
native_min=0,
native_max=24,
step=1,
rep_fn=lambda rep: _delay_hours(
rep.get("x.com.samsung.da.delayStartTime")
or rep.get("x.com.samsung.da.delayEndTime")
),
write_fn=lambda p, rep, href=None: (
["operational", "state", "vs", "0"],
{_delay_field(rep): _format_delay(p)},
),
),
ButtonDesc(
key="start",
field="",
payload="Run",
icon="mdi:play",
write_fn=lambda p, rep, href=None: (
["operational", "state", "vs", "0"],
{"x.com.samsung.da.state": p},
),
),
ButtonDesc(
key="pause",
field="",
payload="Pause",
icon="mdi:pause",
write_fn=lambda p, rep, href=None: (
["operational", "state", "vs", "0"],
{"x.com.samsung.da.state": p},
),
),
STOP_BUTTON,
),
)
@@ -22,11 +22,17 @@ _start_open.md). Mode writes are also unreliable — the oven rolls them back
once a cycle is active. OVEN_MODE is provided as a SelectDesc for fidelity
but is effectively read-only in practice.
"""
from datetime import datetime, timezone, timedelta
from datetime import UTC, datetime, timedelta
from ..batch import is_stub_rep
from ..capability import Capability
from ..entities import (
BinarySensorDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc,
BinarySensorDesc,
NumberDesc,
SelectDesc,
SensorDesc,
SwitchDesc,
)
from .common import normalize_temp_unit
from .operational import STOP_BUTTON
@@ -51,27 +57,35 @@ 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',
'Broil',
'Convection',
'ConvectionBake',
'ConvectionBroil',
'FrozenPizzaPlus',
'SlowCook',
'PlateWarm',
'AirFry',
"NoOperation",
"Bake",
"Broil",
"Convection",
"ConvectionBake",
"ConvectionBroil",
"FrozenPizzaPlus",
"SlowCook",
"PlateWarm",
"AirFry",
)
_SAMSUNG_STATE_TO_OCF = {
'Ready': 'idle',
'Run': 'active',
'Running': 'active',
'Pause': 'pause',
'Paused': 'pause',
'End': 'idle',
'Stop': 'idle',
"Ready": "idle",
"Run": "active",
"Running": "active",
"Pause": "pause",
"Paused": "pause",
"End": "idle",
"Stop": "idle",
}
@@ -90,11 +104,11 @@ def _finish_time(remaining_str):
if not remaining_str:
return None
try:
h, m, s = remaining_str.split(':')
h, m, s = remaining_str.split(":")
total_s = int(h) * 3600 + int(m) * 60 + int(s)
if total_s == 0:
return None
return datetime.now(timezone.utc) + timedelta(seconds=total_s)
return datetime.now(UTC) + timedelta(seconds=total_s)
except Exception:
return None
@@ -104,7 +118,7 @@ def _op_minutes(op_time):
if not op_time:
return None
try:
h, m, s = op_time.split(':')
h, m, s = op_time.split(":")
return int(h) * 60 + int(m) + (1 if int(s) > 0 else 0)
except Exception:
return None
@@ -114,14 +128,38 @@ def _op_minutes(op_time):
# Options-array helpers (shared by lamp, sound, fastpreheat, naturalsteam)
# ---------------------------------------------------------------------------
def _option_value(options, prefix):
"""Find `<prefix>_<value>` in an options array and return <value>."""
for o in (options or []):
if isinstance(o, str) and o.startswith(prefix + '_'):
return o.split('_', 1)[1]
for o in options or []:
if isinstance(o, str) and o.startswith(prefix + "_"):
return o.split("_", 1)[1]
return None
def _has_option(prefix):
"""exists_fn for an options-array switch: bind only when the device's own
options[] actually carries a `<prefix>_<value>` token.
fast_preheat/natural_steam were shipped unconditionally (no exists_fn) as
an unverified guess (see module docstring) -- issue #183's dump (model
NE6516A) reports neither `fastpreheat_*` nor `NaturalSteam_*` in its
options[] at all, so both switches were phantom controls: always read as
off, and toggling them wrote a token the firmware never recognized in
the first place, hence "does not appear to do anything."
`is_stub_rep(rep) or` keeps the same stub carve-out as cooktop.py's
identical per-token exists_fn on its own options[]-array href: a stub
/device/0 seed rep (not yet sub-polled) has no options[] at all, and
without this an entity whose token is genuinely present would never get
a first chance to bind, since exists_fn runs before that first real
fetch lands.
"""
return lambda rep, resources: (
is_stub_rep(rep) or _option_value(rep.get("x.com.samsung.da.options"), prefix) is not None
)
def _option_write(prefix, new_value):
"""A one-token x.com.samsung.da.options write, mirroring
laundry.option_write. NOT independently confirmed on an oven -- issue
@@ -132,13 +170,14 @@ def _option_write(prefix, new_value):
replaces the field outright instead of merging would drop every other
option in it (Sound/fastpreheat/etc.) on the next write -- revisit if a
real device report surfaces that."""
return [f'{prefix}_{new_value}']
return [f"{prefix}_{new_value}"]
# ---------------------------------------------------------------------------
# Write functions
# ---------------------------------------------------------------------------
def _oven_setpoint_write(p, rep, href=None):
"""RMW write to /temperatures/vs/0 items array."""
try:
@@ -149,74 +188,63 @@ def _oven_setpoint_write(p, rep, href=None):
temp_i = int(round(temp / step_v) * step_v)
if not (min_v <= temp_i <= max_v):
return None
items = rep.get('x.com.samsung.da.items')
items = rep.get("x.com.samsung.da.items")
if not items:
return None
items = [dict(it) for it in items]
items[0]['x.com.samsung.da.desired'] = str(temp_i)
return ['temperatures', 'vs', '0'], {'x.com.samsung.da.items': items}
items[0]["x.com.samsung.da.desired"] = str(temp_i)
return ["temperatures", "vs", "0"], {"x.com.samsung.da.items": items}
def _cook_time_write(p, rep, href=None):
"""Write operationTime + remainingTime (H:MM:SS) from minutes."""
try:
minutes = int(round(float(p)))
minutes = round(float(p))
except (TypeError, ValueError):
return None
if not (0 <= minutes <= 1439):
return None
h, m = divmod(minutes, 60)
hms = f"{h:02d}:{m:02d}:00"
return ['operational', 'state', 'vs', '0'], {
'x.com.samsung.da.operationTime': hms,
'x.com.samsung.da.remainingTime': hms,
return ["operational", "state", "vs", "0"], {
"x.com.samsung.da.operationTime": hms,
"x.com.samsung.da.remainingTime": hms,
}
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]}
return ["mode", "vs", "0"], {"x.com.samsung.da.modes": [p]}
def _lamp_write(p, rep, href=None):
if p not in ('On', 'Off'):
return None
if not rep.get('x.com.samsung.da.options'):
return None
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': _option_write('UpperLamp', p),
}
def _option_switch_write(prefix):
"""Factory for a single-token on/off options-array write -- lamp, sound,
fast_preheat, natural_steam, energy_saving, and cooktop_on_alert were all
a byte-for-byte copy of this same shape, one per prefix."""
def write(p, rep, href=None):
if p not in ("On", "Off"):
return None
if not rep.get("x.com.samsung.da.options"):
return None
return ["mode", "vs", "0"], {
"x.com.samsung.da.options": _option_write(prefix, p),
}
def _sound_write(p, rep, href=None):
if p not in ('On', 'Off'):
return None
if not rep.get('x.com.samsung.da.options'):
return None
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': _option_write('Sound', p),
}
def _fastpreheat_write(p, rep, href=None):
if p not in ('On', 'Off'):
return None
if not rep.get('x.com.samsung.da.options'):
return None
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': _option_write('fastpreheat', p),
}
def _naturalsteam_write(p, rep, href=None):
if p not in ('On', 'Off'):
return None
if not rep.get('x.com.samsung.da.options'):
return None
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': _option_write('NaturalSteam', p),
}
return write
# ---------------------------------------------------------------------------
@@ -224,44 +252,71 @@ def _naturalsteam_write(p, rep, href=None):
# ---------------------------------------------------------------------------
OVEN_OPERATIONAL_STATE = Capability(
href='/operational/state/vs/0',
poll_tier='hot',
href="/operational/state/vs/0",
poll_tier="hot",
entities=(
SensorDesc(key='machine_state', field='x.com.samsung.da.state',
icon='mdi:stove',
device_class='enum', options=('idle', 'active', 'pause'),
translation_key='machine_state', value_fn=_to_ocf),
BinarySensorDesc(key='cycle_active', field='x.com.samsung.da.state',
device_class='running',
value_fn=lambda v: _SAMSUNG_STATE_TO_OCF.get(v) == 'active'),
SensorDesc(key='progress_percentage',
field='x.com.samsung.da.progressPercentage',
unit='%', state_class='measurement',
value_fn=_int),
SensorDesc(key='operation_time_minutes',
field='x.com.samsung.da.operationTime',
unit='min',
state_class='measurement', value_fn=_op_minutes),
SensorDesc(key='finish_time', field='x.com.samsung.da.remainingTime',
device_class='timestamp',
value_fn=_finish_time),
NumberDesc(key='cook_time', field='x.com.samsung.da.operationTime',
unit='min', native_min=0, native_max=1439,
step=1.0, icon='mdi:timer', value_fn=_op_minutes,
write_fn=_cook_time_write),
SensorDesc(
key="machine_state",
field="x.com.samsung.da.state",
icon="mdi:stove",
device_class="enum",
options=("idle", "active", "pause"),
translation_key="machine_state",
value_fn=_to_ocf,
),
BinarySensorDesc(
key="cycle_active",
field="x.com.samsung.da.state",
device_class="running",
value_fn=lambda v: _SAMSUNG_STATE_TO_OCF.get(v) == "active",
),
SensorDesc(
key="progress_percentage",
field="x.com.samsung.da.progressPercentage",
unit="%",
state_class="measurement",
value_fn=_int,
),
SensorDesc(
key="operation_time_minutes",
field="x.com.samsung.da.operationTime",
unit="min",
state_class="measurement",
value_fn=_op_minutes,
),
SensorDesc(
key="finish_time",
field="x.com.samsung.da.remainingTime",
device_class="timestamp",
value_fn=_finish_time,
),
NumberDesc(
key="cook_time",
field="x.com.samsung.da.operationTime",
unit="min",
native_min=0,
native_max=1439,
step=1.0,
icon="mdi:timer",
value_fn=_op_minutes,
write_fn=_cook_time_write,
),
STOP_BUTTON,
),
)
OVEN_CAVITY = Capability(
href='/oven/vs/0',
poll_tier='hot',
href="/oven/vs/0",
poll_tier="hot",
entities=(
SensorDesc(key='oven_state', field='x.com.samsung.da.state',
),
SensorDesc(
key="oven_state",
field="x.com.samsung.da.state",
),
),
)
def _oven_temp_unit(rep):
"""Same shape/risk as fridge.py's TEMPERATURES_FALLBACK: this is the
same aggregate `/temperatures/vs/0` items[] resource type, which on
@@ -270,9 +325,9 @@ def _oven_temp_unit(rep):
Keeps the verified '°C' default when the field is absent (the original
NV7000BS-class dump this module was written against), but reads it live
-- issue #44's range dump is the first to report 'Fahrenheit' here."""
items = rep.get('x.com.samsung.da.items') or []
unit = items[0].get('x.com.samsung.da.unit') if items else None
return normalize_temp_unit(unit, default='°C')
items = rep.get("x.com.samsung.da.items") or []
unit = items[0].get("x.com.samsung.da.unit") if items else None
return normalize_temp_unit(unit, default="°C")
def _setpoint_bounds(rep):
@@ -280,54 +335,72 @@ def _setpoint_bounds(rep):
constants above for provenance. Bounds must track the unit shown by
unit_fn (both read the same live rep), or the HA slider's range would
silently mismatch its own displayed unit."""
if _oven_temp_unit(rep) == '°F':
if _oven_temp_unit(rep) == "°F":
return SETPOINT_MIN_F, SETPOINT_MAX_F, SETPOINT_STEP_F
return SETPOINT_MIN_C, SETPOINT_MAX_C, SETPOINT_STEP_C
OVEN_SETPOINT = Capability(
href='/temperatures/vs/0',
poll_tier='hot',
href="/temperatures/vs/0",
poll_tier="hot",
entities=(
# NumberDesc first — test_oven_setpoint_write_is_read_modify_write uses entities[0]
NumberDesc(key='oven_setpoint', field='x.com.samsung.da.items',
device_class='temperature', unit_fn=_oven_temp_unit,
native_min=float(SETPOINT_MIN_C), native_max=float(SETPOINT_MAX_C),
step=float(SETPOINT_STEP_C), icon='mdi:thermometer-chevron-up',
native_min_fn=lambda rep: float(_setpoint_bounds(rep)[0]),
native_max_fn=lambda rep: float(_setpoint_bounds(rep)[1]),
step_fn=lambda rep: float(_setpoint_bounds(rep)[2]),
value_fn=lambda items: _int(
(items[0].get('x.com.samsung.da.desired') if items else None)),
write_fn=_oven_setpoint_write),
SensorDesc(key='current_temp_c', field='x.com.samsung.da.items',
device_class='temperature',
state_class='measurement', unit_fn=_oven_temp_unit,
value_fn=lambda items: _int(
(items[0].get('x.com.samsung.da.current') if items else None))),
NumberDesc(
key="oven_setpoint",
field="x.com.samsung.da.items",
device_class="temperature",
unit_fn=_oven_temp_unit,
native_min=float(SETPOINT_MIN_C),
native_max=float(SETPOINT_MAX_C),
step=float(SETPOINT_STEP_C),
icon="mdi:thermometer-chevron-up",
native_min_fn=lambda rep: float(_setpoint_bounds(rep)[0]),
native_max_fn=lambda rep: float(_setpoint_bounds(rep)[1]),
step_fn=lambda rep: float(_setpoint_bounds(rep)[2]),
value_fn=lambda items: _int(
items[0].get("x.com.samsung.da.desired") if items else None
),
write_fn=_oven_setpoint_write,
),
SensorDesc(
key="current_temp_c",
field="x.com.samsung.da.items",
device_class="temperature",
state_class="measurement",
unit_fn=_oven_temp_unit,
value_fn=lambda items: _int(
items[0].get("x.com.samsung.da.current") if items else None
),
),
),
)
OVEN_DOOR = Capability(
href='/doors/vs/0',
poll_tier='hot',
href="/doors/vs/0",
poll_tier="hot",
entities=(
BinarySensorDesc(key='door_open', field='x.com.samsung.da.items',
device_class='door',
value_fn=lambda items: (
items[0].get('x.com.samsung.da.openState') == 'Open'
if items else None)),
BinarySensorDesc(
key="door_open",
field="x.com.samsung.da.items",
device_class="door",
value_fn=lambda items: (
items[0].get("x.com.samsung.da.openState") == "Open" if items else None
),
),
),
)
OVEN_CONNECTED = Capability(
href='/connected/vs/0',
poll_tier='warm',
href="/connected/vs/0",
poll_tier="warm",
entities=(
BinarySensorDesc(key='cloud_connected', field='x.com.samsung.da.connected',
device_class='connectivity',
entity_category='diagnostic',
value_fn=lambda v: v == 'On'),
BinarySensorDesc(
key="cloud_connected",
field="x.com.samsung.da.connected",
device_class="connectivity",
entity_category="diagnostic",
value_fn=lambda v: v == "On",
),
),
)
@@ -336,40 +409,82 @@ OVEN_CONNECTED = Capability(
# range: single cavity, no supportedFeatureList entries). Bound with no
# entities purely for coverage; revisit if a multi-cavity dump surfaces
# fields worth exposing.
OVEN_SPEC = Capability(href='/oven/spec/vs/0')
OVEN_SPEC = Capability(href="/oven/spec/vs/0")
# Quick-recipe display blob (combi microwave, issue #121) -- a JSON-encoded
# string (language/menu/servingSize/option) with every field blank on the
# only dump seen, and no documented write contract. No entity to bind per
# the 'don't guess' rule; a bare Capability still marks the href covered.
OVEN_RECIPE_COOK = Capability(href='/recipe/cook/vs/0')
OVEN_RECIPE_COOK = Capability(href="/recipe/cook/vs/0")
OVEN_MODE = Capability(
href='/mode/vs/0',
poll_tier='warm',
href="/mode/vs/0",
poll_tier="warm",
entities=(
# 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,
value_fn=lambda v: v[0] if v else None,
write_fn=_oven_mode_write),
SwitchDesc(key='lamp', field='x.com.samsung.da.options',
icon='mdi:track-light',
value_fn=lambda opts: _option_value(opts, 'UpperLamp') == 'On',
write_fn=_lamp_write),
SwitchDesc(key='sound', field='x.com.samsung.da.options',
icon='mdi:volume-high',
entity_category='config',
value_fn=lambda opts: _option_value(opts, 'Sound') == 'On',
write_fn=_sound_write),
SwitchDesc(key='fast_preheat', field='x.com.samsung.da.options',
icon='mdi:fire',
value_fn=lambda opts: _option_value(opts, 'fastpreheat') == 'On',
write_fn=_fastpreheat_write),
SwitchDesc(key='natural_steam', field='x.com.samsung.da.options',
icon='mdi:kettle-steam',
value_fn=lambda opts: _option_value(opts, 'NaturalSteam') == 'On',
write_fn=_naturalsteam_write),
SelectDesc(
key="oven_mode",
field="x.com.samsung.da.modes",
icon="mdi:tune",
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",
icon="mdi:track-light",
value_fn=lambda opts: _option_value(opts, "UpperLamp") == "On",
write_fn=_option_switch_write("UpperLamp"),
),
SwitchDesc(
key="sound",
field="x.com.samsung.da.options",
icon="mdi:volume-high",
entity_category="config",
value_fn=lambda opts: _option_value(opts, "Sound") == "On",
write_fn=_option_switch_write("Sound"),
),
SwitchDesc(
key="fast_preheat",
field="x.com.samsung.da.options",
icon="mdi:fire",
exists_fn=_has_option("fastpreheat"),
value_fn=lambda opts: _option_value(opts, "fastpreheat") == "On",
write_fn=_option_switch_write("fastpreheat"),
),
SwitchDesc(
key="natural_steam",
field="x.com.samsung.da.options",
icon="mdi:kettle-steam",
exists_fn=_has_option("NaturalSteam"),
value_fn=lambda opts: _option_value(opts, "NaturalSteam") == "On",
write_fn=_option_switch_write("NaturalSteam"),
),
# 120-hour energy-saving standby (issue #183): confirmed present in
# this unit's options[] (EnergySaving_On) and directly requested --
# unlike fast_preheat/natural_steam above, this token is real on this
# hardware, just previously unbound entirely.
SwitchDesc(
key="energy_saving",
field="x.com.samsung.da.options",
icon="mdi:leaf",
entity_category="config",
exists_fn=_has_option("EnergySaving"),
value_fn=lambda opts: _option_value(opts, "EnergySaving") == "On",
write_fn=_option_switch_write("EnergySaving"),
),
# Cooktop-on alert (issue #183): also confirmed present
# (BurnerOnAlert_Off) though the reporter noted it mainly matters for
# the SmartThings app's own alerting, not local automation.
SwitchDesc(
key="cooktop_on_alert",
field="x.com.samsung.da.options",
icon="mdi:alert-circle-outline",
entity_category="config",
exists_fn=_has_option("BurnerOnAlert"),
value_fn=lambda opts: _option_value(opts, "BurnerOnAlert") == "On",
write_fn=_option_switch_write("BurnerOnAlert"),
),
),
)
@@ -23,6 +23,7 @@ caveat as oven.py's RMW writes) -- power level uses the same read-modify-
write pattern already proven safe elsewhere in this codebase (oven setpoint,
icemaker toggles).
"""
from ..capability import Capability
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc
from .common import normalize_temp_unit
@@ -34,14 +35,14 @@ MAX_BURNERS = 6
def _burner(burner_list, i):
for b in (burner_list or []):
if b.get('burnerNumber') == i:
for b in burner_list or []:
if b.get("burnerNumber") == i:
return b
return None
def _burner_exists(i):
return lambda rep, resources: _burner(rep.get('burnerList'), i) is not None
return lambda rep, resources: _burner(rep.get("burnerList"), i) is not None
def _burner_field_fn(i, field):
@@ -49,35 +50,36 @@ def _burner_field_fn(i, field):
def _burner_hot_surface_fn(i):
get_state = _burner_field_fn(i, 'hotSurfaceState')
return lambda burner_list: get_state(burner_list) not in (None, 'normal')
get_state = _burner_field_fn(i, "hotSurfaceState")
return lambda burner_list: get_state(burner_list) not in (None, "normal")
def _burner_pan_detection_fn(i):
return lambda burner_list: bool(_burner_field_fn(i, 'panDetection')(burner_list))
return lambda burner_list: bool(_burner_field_fn(i, "panDetection")(burner_list))
def _power_level_options(resources):
spec = resources.get('/cooktop/spec/vs/0') or {}
return list(spec.get('supportedPowerLevelList') or [])
spec = resources.get("/cooktop/spec/vs/0") or {}
return list(spec.get("supportedPowerLevelList") or [])
def _burner_power_level_write(i):
def write(p, rep, href=None):
burner_list = rep.get('burnerList')
burner_list = rep.get("burnerList")
if not burner_list:
return None
new_list = []
found = False
for b in burner_list:
if b.get('burnerNumber') == i:
if b.get("burnerNumber") == i:
b = dict(b)
b['powerLevel'] = p
b["powerLevel"] = p
found = True
new_list.append(b)
if not found:
return None
return ['cooktop', 'status', 'vs', '0'], {'burnerList': new_list}
return ["cooktop", "status", "vs", "0"], {"burnerList": new_list}
return write
@@ -85,64 +87,83 @@ def _burner_entities(i):
exists = _burner_exists(i)
n = i + 1
return (
SelectDesc(key=f'burner_{i}_power_level', field='burnerList',
icon='mdi:knob',
translation_key='range_burner_power_level',
translation_placeholders={'number': str(n)},
options=_power_level_options,
exists_fn=exists,
value_fn=_burner_field_fn(i, 'powerLevel'),
write_fn=_burner_power_level_write(i)),
SensorDesc(key=f'burner_{i}_state', field='burnerList',
icon='mdi:stove',
translation_key='burner_state',
translation_placeholders={'number': str(n)},
exists_fn=exists,
value_fn=_burner_field_fn(i, 'operationState')),
BinarySensorDesc(key=f'burner_{i}_hot_surface', field='burnerList',
device_class='heat',
translation_key='burner_hot_surface',
translation_placeholders={'number': str(n)},
exists_fn=exists,
value_fn=_burner_hot_surface_fn(i)),
BinarySensorDesc(key=f'burner_{i}_pan_detected', field='burnerList',
icon='mdi:pot',
translation_key='burner_pan_detected',
translation_placeholders={'number': str(n)},
entity_category='diagnostic',
exists_fn=exists,
value_fn=_burner_pan_detection_fn(i)),
SelectDesc(
key=f"burner_{i}_power_level",
field="burnerList",
icon="mdi:knob",
translation_key="range_burner_power_level",
translation_placeholders={"number": str(n)},
options=_power_level_options,
exists_fn=exists,
value_fn=_burner_field_fn(i, "powerLevel"),
write_fn=_burner_power_level_write(i),
),
SensorDesc(
key=f"burner_{i}_state",
field="burnerList",
icon="mdi:stove",
translation_key="burner_state",
translation_placeholders={"number": str(n)},
exists_fn=exists,
value_fn=_burner_field_fn(i, "operationState"),
),
BinarySensorDesc(
key=f"burner_{i}_hot_surface",
field="burnerList",
device_class="heat",
translation_key="burner_hot_surface",
translation_placeholders={"number": str(n)},
exists_fn=exists,
value_fn=_burner_hot_surface_fn(i),
),
BinarySensorDesc(
key=f"burner_{i}_pan_detected",
field="burnerList",
icon="mdi:pot",
translation_key="burner_pan_detected",
translation_placeholders={"number": str(n)},
entity_category="diagnostic",
exists_fn=exists,
value_fn=_burner_pan_detection_fn(i),
),
)
def _child_lock_write(p, rep, href=None):
if p not in ('On', 'Off'):
if p not in ("On", "Off"):
return None
return ['cooktop', 'status', 'vs', '0'], {'childLock': p.lower()}
return ["cooktop", "status", "vs", "0"], {"childLock": p.lower()}
COOKTOP_STATUS = Capability(
href='/cooktop/status/vs/0',
poll_tier='hot',
href="/cooktop/status/vs/0",
poll_tier="hot",
entities=(
SensorDesc(key='cooktop_state', field='operationState',
icon='mdi:pot-steam'),
SensorDesc(key="cooktop_state", field="operationState", icon="mdi:pot-steam"),
# The cooktop section's own on/off (issue #86) -- distinct from
# common.POWER's /power/0 or /power/vs/0, which some boards in this
# family (the range combo) additionally carry for the whole
# appliance. Read-only: no live device to confirm remotely turning
# a cooktop on wouldn't leave a burner active unattended.
BinarySensorDesc(key='cooktop_power', field='power',
device_class='power', icon='mdi:pot-steam',
value_fn=lambda v: str(v).lower() == 'on'),
BinarySensorDesc(
key="cooktop_power",
field="power",
device_class="power",
icon="mdi:pot-steam",
value_fn=lambda v: str(v).lower() == "on",
),
# Safe to write -- a lock toggle, not a heat control -- via a
# direct single-field PUT (no RMW needed; unlike burnerList this
# is a lone scalar, not an array of siblings to preserve).
SwitchDesc(key='cooktop_child_lock', field='childLock',
device_class='lock', entity_category='config',
icon='mdi:lock',
value_fn=lambda v: str(v).lower() == 'on',
write_fn=_child_lock_write),
SwitchDesc(
key="cooktop_child_lock",
field="childLock",
device_class="lock",
entity_category="config",
icon="mdi:lock",
value_fn=lambda v: str(v).lower() == "on",
write_fn=_child_lock_write,
),
*[e for i in range(MAX_BURNERS) for e in _burner_entities(i)],
),
)
@@ -151,7 +172,7 @@ COOKTOP_STATUS = Capability(
# COOKTOP_STATUS's power-level select (options=_power_level_options) rather
# than exposed through its own entity -- same "informs another capability,
# no entity of its own" pattern as /wm/editcourse/vs/0 (ignored.py).
COOKTOP_SPEC = Capability(href='/cooktop/spec/vs/0')
COOKTOP_SPEC = Capability(href="/cooktop/spec/vs/0")
# settingTime (seconds) is the hot-surface auto-shutoff timer's configured
# duration (1200s = 20 min in issue #44's dump); state on/off is whether the
@@ -159,12 +180,15 @@ COOKTOP_SPEC = Capability(href='/cooktop/spec/vs/0')
# (that's COOKTOP_STATUS's per-burner hot_surface). No write contract
# verified, so read-only for now.
COOKTOP_SAFETY = Capability(
href='/cooktop/settings/status/vs/0',
poll_tier='warm',
href="/cooktop/settings/status/vs/0",
poll_tier="warm",
entities=(
BinarySensorDesc(key='cooktop_safety_shutoff_enabled', field='safetyAlert',
entity_category='config',
value_fn=lambda v: (v or {}).get('state') == 'on'),
BinarySensorDesc(
key="cooktop_safety_shutoff_enabled",
field="safetyAlert",
entity_category="config",
value_fn=lambda v: (v or {}).get("state") == "on",
),
),
)
@@ -174,21 +198,37 @@ COOKTOP_SAFETY = Capability(
# showing a disconnected accessory's fields plainly rather than hiding the
# whole capability.
PROBE_STATUS = Capability(
href='/bluetooth/probe/status/vs/0',
poll_tier='warm',
href="/bluetooth/probe/status/vs/0",
poll_tier="warm",
entities=(
BinarySensorDesc(key='probe_connected', field='connectionState',
device_class='connectivity',
value_fn=lambda v: str(v).lower() == 'connected'),
SensorDesc(key='probe_battery', field='batteryPercentage',
device_class='battery', state_class='measurement',
unit='%', entity_category='diagnostic'),
SensorDesc(key='probe_temperature', field='currentTemperature',
device_class='temperature', state_class='measurement',
unit_fn=lambda rep: normalize_temp_unit(rep.get('temperatureUnit'), '°C')),
SensorDesc(key='probe_target_temperature', field='targetTemperature',
device_class='temperature', entity_category='diagnostic',
unit_fn=lambda rep: normalize_temp_unit(rep.get('temperatureUnit'), '°C')),
BinarySensorDesc(
key="probe_connected",
field="connectionState",
device_class="connectivity",
value_fn=lambda v: str(v).lower() == "connected",
),
SensorDesc(
key="probe_battery",
field="batteryPercentage",
device_class="battery",
state_class="measurement",
unit="%",
entity_category="diagnostic",
),
SensorDesc(
key="probe_temperature",
field="currentTemperature",
device_class="temperature",
state_class="measurement",
unit_fn=lambda rep: normalize_temp_unit(rep.get("temperatureUnit"), "°C"),
),
SensorDesc(
key="probe_target_temperature",
field="targetTemperature",
device_class="temperature",
entity_category="diagnostic",
unit_fn=lambda rep: normalize_temp_unit(rep.get("temperatureUnit"), "°C"),
),
),
)
@@ -201,12 +241,19 @@ PROBE_STATUS = Capability(
# `supportedHoodLampStateList` has no corresponding live-state field on
# this resource, so nothing to bind it to yet.
COOKTOP_MONITORING = Capability(
href='/cooktopmonitoring/vs/0',
poll_tier='warm',
href="/cooktopmonitoring/vs/0",
poll_tier="warm",
entities=(
SensorDesc(key='cooktop_running_state', field='x.com.samsung.da.cooktopRunningState',
icon='mdi:pot-steam-outline'),
SensorDesc(key='warming_center_state', field='x.com.samsung.da.warmingCenterState',
icon='mdi:heat-wave', entity_category='diagnostic'),
SensorDesc(
key="cooktop_running_state",
field="x.com.samsung.da.cooktopRunningState",
icon="mdi:pot-steam-outline",
),
SensorDesc(
key="warming_center_state",
field="x.com.samsung.da.warmingCenterState",
icon="mdi:heat-wave",
entity_category="diagnostic",
),
),
)
@@ -7,7 +7,7 @@ brightness remain separate controls because the device advertises them as two
independent fields.
"""
from datetime import datetime, timezone
from datetime import UTC, datetime
from ..batch import is_stub_rep
from ..capability import Capability
@@ -24,7 +24,7 @@ from .common import int_or_none, sensor_item_value
def _timestamp(value):
try:
return datetime.fromtimestamp(float(value), tz=timezone.utc)
return datetime.fromtimestamp(float(value), tz=UTC)
except (TypeError, ValueError, OSError):
return None
@@ -39,23 +39,23 @@ def _active_alarm_codes(items):
for item in items or ():
if not isinstance(item, dict):
continue
if str(item.get('x.com.samsung.da.state', '')).lower() == 'deleted':
if str(item.get("x.com.samsung.da.state", "")).lower() == "deleted":
continue
code = item.get('x.com.samsung.da.code')
if code and str(code).lower() != 'errorcode_off':
code = item.get("x.com.samsung.da.code")
if code and str(code).lower() != "errorcode_off":
codes.append(code)
return ', '.join(codes) if codes else 'none'
return ", ".join(codes) if codes else "none"
HOOD_ALARMS = Capability(
href='/alarms/vs/0',
poll_tier='hot',
href="/alarms/vs/0",
poll_tier="hot",
entities=(
SensorDesc(
key='alarm_code',
field='x.com.samsung.da.items',
icon='mdi:alert',
entity_category='diagnostic',
key="alarm_code",
field="x.com.samsung.da.items",
icon="mdi:alert",
entity_category="diagnostic",
value_fn=_active_alarm_codes,
),
),
@@ -64,49 +64,56 @@ HOOD_ALARMS = Capability(
def _hood_fan_write(payload, rep, href=None):
kind, value, *args = payload
if kind == 'power':
power_href = args[0] if args else '/power/0'
if power_href == '/power/0':
return ['power', '0'], {'value': bool(value)}
if power_href == '/power/vs/0':
return ['power', 'vs', '0'], {
'x.com.samsung.da.power': 'On' if value else 'Off',
if kind == "power":
power_href = args[0] if args else "/power/0"
if power_href == "/power/0":
return ["power", "0"], {"value": bool(value)}
if power_href == "/power/vs/0":
return ["power", "vs", "0"], {
"x.com.samsung.da.power": "On" if value else "Off",
}
return None
if kind == 'speed':
if kind == "speed":
value = str(value)
supported = [
str(code)
for code in rep.get('x.com.samsung.da.hood.supportedFanSpeed', ())
]
supported = [str(code) for code in rep.get("x.com.samsung.da.hood.supportedFanSpeed", ())]
if not supported:
min_s = rep.get("x.com.samsung.da.hood.settableMinFanSpeed")
max_s = rep.get("x.com.samsung.da.hood.settableMaxFanSpeed")
if min_s is not None and max_s is not None:
try:
mn, mx = int(min_s), int(max_s)
supported = [str(i) for i in range(mn, mx + 1)]
except (ValueError, TypeError):
pass
if value not in supported:
return None
return ['hood', 'fanspeed', 'vs', '0'], {
'x.com.samsung.da.hood.fanSpeed': value,
return ["hood", "fanspeed", "vs", "0"], {
"x.com.samsung.da.hood.fanSpeed": value,
}
return None
HOOD_FAN = Capability(
href='/hood/fanspeed/vs/0',
poll_tier='hot',
href="/hood/fanspeed/vs/0",
poll_tier="hot",
entities=(
FanDesc(
key='fan',
field='x.com.samsung.da.hood.fanSpeed',
key="fan",
field="x.com.samsung.da.hood.fanSpeed",
write_fn=_hood_fan_write,
),
BinarySensorDesc(
key='automatic_operation',
field='x.com.samsung.da.hood.autoOperation',
icon='mdi:fan-auto',
entity_category='diagnostic',
key="automatic_operation",
field="x.com.samsung.da.hood.autoOperation",
icon="mdi:fan-auto",
entity_category="diagnostic",
# Absent on the microwave family's built-in vent fan (issue
# #137) -- this board has no auto-ventilation mode, unlike the
# standalone range hood this capability was written for.
exists_fn=lambda rep, resources: (
is_stub_rep(rep) or 'x.com.samsung.da.hood.autoOperation' in rep),
value_fn=lambda value: str(value).lower() == 'on',
is_stub_rep(rep) or "x.com.samsung.da.hood.autoOperation" in rep
),
value_fn=lambda value: str(value).lower() == "on",
),
),
)
@@ -114,34 +121,34 @@ HOOD_FAN = Capability(
def _lamp_level_write(value, rep, href=None):
code = str(value)
supported = [str(level) for level in rep.get('x.com.samsung.lamp.range', ())]
supported = [str(level) for level in rep.get("x.com.samsung.lamp.range", ())]
if code not in supported:
return None
return ['hood', 'lamp', 'vs', '0'], {
'x.com.samsung.lamp.current': code,
return ["hood", "lamp", "vs", "0"], {
"x.com.samsung.lamp.current": code,
}
HOOD_LAMP = Capability(
href='/hood/lamp/vs/0',
poll_tier='hot',
href="/hood/lamp/vs/0",
poll_tier="hot",
entities=(
SwitchDesc(
key='lamp',
field='x.com.samsung.lamp.power',
icon='mdi:range-hood',
value_fn=lambda value: str(value).lower() == 'on',
key="lamp",
field="x.com.samsung.lamp.power",
icon="mdi:range-hood",
value_fn=lambda value: str(value).lower() == "on",
write_fn=lambda payload, rep, href=None: (
['hood', 'lamp', 'vs', '0'],
{'x.com.samsung.lamp.power': 'On' if payload == 'On' else 'Off'},
["hood", "lamp", "vs", "0"],
{"x.com.samsung.lamp.power": "On" if payload == "On" else "Off"},
),
),
SelectDesc(
key='lamp_brightness',
field='x.com.samsung.lamp.current',
icon='mdi:brightness-6',
translation_key='range_hood_lamp_brightness',
options_field='x.com.samsung.lamp.range',
key="lamp_brightness",
field="x.com.samsung.lamp.current",
icon="mdi:brightness-6",
translation_key="range_hood_lamp_brightness",
options_field="x.com.samsung.lamp.range",
write_fn=_lamp_level_write,
),
),
@@ -149,36 +156,34 @@ HOOD_LAMP = Capability(
HOOD_FILTER = Capability(
href='/filter/hoodfilter/vs/0',
poll_tier='cold',
href="/filter/hoodfilter/vs/0",
poll_tier="cold",
entities=(
SensorDesc(
key='hood_filter_usage',
field='x.com.samsung.da.filterUsage',
unit='%',
state_class='measurement',
icon='mdi:air-filter',
entity_category='diagnostic',
key="hood_filter_usage",
field="x.com.samsung.da.filterUsage",
unit="%",
state_class="measurement",
icon="mdi:air-filter",
entity_category="diagnostic",
value_fn=int_or_none,
),
SensorDesc(
key='hood_filter_status',
field='x.com.samsung.da.filterStatus',
icon='mdi:air-filter',
entity_category='diagnostic',
device_class='enum',
options=('normal', 'wash', 'replace'),
translation_key='filter_status',
value_fn=lambda value: (
value.lower() if isinstance(value, str) else value
),
key="hood_filter_status",
field="x.com.samsung.da.filterStatus",
icon="mdi:air-filter",
entity_category="diagnostic",
device_class="enum",
options=("normal", "wash", "replace"),
translation_key="filter_status",
value_fn=lambda value: value.lower() if isinstance(value, str) else value,
),
SensorDesc(
key='hood_filter_capacity',
field='x.com.samsung.da.filterCapacity',
unit='h',
icon='mdi:timer-outline',
entity_category='diagnostic',
key="hood_filter_capacity",
field="x.com.samsung.da.filterCapacity",
unit="h",
icon="mdi:timer-outline",
entity_category="diagnostic",
enabled_default=False,
value_fn=int_or_none,
),
@@ -199,31 +204,31 @@ HOOD_FILTER = Capability(
# left an opaque passthrough -- unlike activationState/runningCancel, there's
# no ambiguous field name or missing-write-contract question here to hedge on.
AFTER_RUN = Capability(
href='/afterrun/vs/0',
poll_tier='warm',
href="/afterrun/vs/0",
poll_tier="warm",
entities=(
BinarySensorDesc(
key='after_run_active',
field='x.com.samsung.da.activationState',
icon='mdi:fan-clock',
value_fn=lambda value: str(value).lower() == 'on',
key="after_run_active",
field="x.com.samsung.da.activationState",
icon="mdi:fan-clock",
value_fn=lambda value: str(value).lower() == "on",
),
SensorDesc(
key='after_run_progress',
field='x.com.samsung.da.runningProgress',
unit='%',
state_class='measurement',
icon='mdi:fan-clock',
key="after_run_progress",
field="x.com.samsung.da.runningProgress",
unit="%",
state_class="measurement",
icon="mdi:fan-clock",
value_fn=int_or_none,
),
ButtonDesc(
key='after_run_cancel',
field='',
payload='Cancel',
icon='mdi:fan-off',
key="after_run_cancel",
field="",
payload="Cancel",
icon="mdi:fan-off",
write_fn=lambda p, rep, href=None: (
['afterrun', 'vs', '0'],
{'x.com.samsung.da.runningCancel': p},
["afterrun", "vs", "0"],
{"x.com.samsung.da.runningCancel": p},
),
),
),
@@ -231,82 +236,82 @@ AFTER_RUN = Capability(
AIR_QUALITY = Capability(
href='/sensors/vs/0',
poll_tier='warm',
href="/sensors/vs/0",
poll_tier="warm",
entities=(
SensorDesc(
key='clean_level',
field='x.com.samsung.da.items',
icon='mdi:air-filter',
value_fn=lambda items: sensor_item_value(items, 'CleanLevel'),
key="clean_level",
field="x.com.samsung.da.items",
icon="mdi:air-filter",
value_fn=lambda items: sensor_item_value(items, "CleanLevel"),
),
SensorDesc(
key='dust',
field='x.com.samsung.da.items',
value_fn=lambda items: sensor_item_value(items, 'Dust'),
key="dust",
field="x.com.samsung.da.items",
value_fn=lambda items: sensor_item_value(items, "Dust"),
),
SensorDesc(
key='fine_dust',
field='x.com.samsung.da.items',
value_fn=lambda items: sensor_item_value(items, 'FineDust'),
key="fine_dust",
field="x.com.samsung.da.items",
value_fn=lambda items: sensor_item_value(items, "FineDust"),
),
SensorDesc(
key='super_fine_dust',
field='x.com.samsung.da.items',
value_fn=lambda items: sensor_item_value(items, 'SuperFineDust'),
key="super_fine_dust",
field="x.com.samsung.da.items",
value_fn=lambda items: sensor_item_value(items, "SuperFineDust"),
),
),
)
AIR_LEVEL_CHECK = Capability(
href='/airlevelcheck/vs/0',
poll_tier='warm',
href="/airlevelcheck/vs/0",
poll_tier="warm",
entities=(
BinarySensorDesc(
key='periodic_air_sensing',
field='x.com.samsung.da.periodicSensingActivationState',
icon='mdi:radar',
entity_category='diagnostic',
value_fn=lambda value: str(value).lower() == 'on',
key="periodic_air_sensing",
field="x.com.samsung.da.periodicSensingActivationState",
icon="mdi:radar",
entity_category="diagnostic",
value_fn=lambda value: str(value).lower() == "on",
),
SensorDesc(
key='air_sensing_state',
field='x.com.samsung.da.sensingState',
icon='mdi:radar',
entity_category='diagnostic',
key="air_sensing_state",
field="x.com.samsung.da.sensingState",
icon="mdi:radar",
entity_category="diagnostic",
),
SensorDesc(
key='last_air_sensing_time',
field='x.com.samsung.da.lastSensingTime',
device_class='timestamp',
entity_category='diagnostic',
key="last_air_sensing_time",
field="x.com.samsung.da.lastSensingTime",
device_class="timestamp",
entity_category="diagnostic",
value_fn=_timestamp,
),
SensorDesc(
key='last_air_sensing_level',
field='x.com.samsung.da.lastSensingLevel',
icon='mdi:air-filter',
entity_category='diagnostic',
key="last_air_sensing_level",
field="x.com.samsung.da.lastSensingLevel",
icon="mdi:air-filter",
entity_category="diagnostic",
),
SensorDesc(
key='automatic_ventilation_state',
field='x.com.samsung.da.autoExeState',
icon='mdi:fan-auto',
entity_category='diagnostic',
key="automatic_ventilation_state",
field="x.com.samsung.da.autoExeState",
icon="mdi:fan-auto",
entity_category="diagnostic",
),
),
)
AUTO_VENTILATION = Capability(
href='/autoventilation/vs/0',
poll_tier='warm',
href="/autoventilation/vs/0",
poll_tier="warm",
entities=(
SensorDesc(
key='auto_ventilation_action',
field='action',
icon='mdi:fan-auto',
key="auto_ventilation_action",
field="action",
icon="mdi:fan-auto",
),
),
)
@@ -317,11 +322,11 @@ AUTO_VENTILATION = Capability(
COVERAGE = [
Capability(href=href)
for href in (
'/power/0',
'/power/vs/0',
'/mode/vs/0',
'/personality/presence/vs/0',
'/availablecontrolsets/vs/0',
'/da/softreset/vs/0',
"/power/0",
"/power/vs/0",
"/mode/vs/0",
"/personality/presence/vs/0",
"/availablecontrolsets/vs/0",
"/da/softreset/vs/0",
)
]
@@ -15,19 +15,23 @@ docstring for that rule).
Resources verified against the issue #131 diagnostics dump.
"""
from ..capability import Capability
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc
from .common import int_or_none, parse_iso_utc as _parse_iso_utc
from .common import int_or_none
from .common import parse_iso_utc as _parse_iso_utc
DUSTBAG = Capability(
href='/component/station/dustbag/vs/0',
poll_tier='warm',
href="/component/station/dustbag/vs/0",
poll_tier="warm",
entities=(
BinarySensorDesc(key='dustbag_full', field='x.com.samsung.da.status',
device_class='problem',
icon='mdi:bag-personal',
value_fn=lambda v: v == 'full'),
BinarySensorDesc(
key="dustbag_full",
field="x.com.samsung.da.status",
device_class="problem",
icon="mdi:bag-personal",
value_fn=lambda v: v == "full",
),
),
)
@@ -36,40 +40,57 @@ DUSTBAG = Capability(
# families' filterUsage, which always ships filterCapacity) -- exposed as a
# plain diagnostic count rather than guessing a unit.
DUSTBAG_USAGE = Capability(
href='/component/station/dustbagusage/vs/0',
poll_tier='cold',
href="/component/station/dustbagusage/vs/0",
poll_tier="cold",
entities=(
SensorDesc(key='dustbag_usage', field='x.com.samsung.da.dustbagUsage',
icon='mdi:counter', entity_category='diagnostic',
state_class='total_increasing', value_fn=int_or_none),
SensorDesc(
key="dustbag_usage",
field="x.com.samsung.da.dustbagUsage",
icon="mdi:counter",
entity_category="diagnostic",
state_class="total_increasing",
value_fn=int_or_none,
),
),
)
DUSTBIN_SETTING = Capability(
href='/setting/dustbin/vs/0',
poll_tier='warm',
href="/setting/dustbin/vs/0",
poll_tier="warm",
entities=(
SwitchDesc(key='auto_empty', field='x.com.samsung.da.autoEmpty',
icon='mdi:delete-empty',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['setting', 'dustbin', 'vs', '0'],
{'x.com.samsung.da.autoEmpty': 'On' if p == 'On' else 'Off'})),
SwitchDesc(key='dustbin_auto_close', field='x.com.samsung.da.autoClose',
icon='mdi:door-sliding',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['setting', 'dustbin', 'vs', '0'],
{'x.com.samsung.da.autoClose': 'On' if p == 'On' else 'Off'})),
SelectDesc(key='discharging_time', field='x.com.samsung.da.desiredDischargingTime',
icon='mdi:timer-outline',
entity_category='config',
options_field='x.com.samsung.da.supportedDischargingTime',
write_fn=lambda p, rep, href=None: (
['setting', 'dustbin', 'vs', '0'],
{'x.com.samsung.da.desiredDischargingTime': p})),
SwitchDesc(
key="auto_empty",
field="x.com.samsung.da.autoEmpty",
icon="mdi:delete-empty",
entity_category="config",
value_fn=lambda v: v == "On",
write_fn=lambda p, rep, href=None: (
["setting", "dustbin", "vs", "0"],
{"x.com.samsung.da.autoEmpty": "On" if p == "On" else "Off"},
),
),
SwitchDesc(
key="dustbin_auto_close",
field="x.com.samsung.da.autoClose",
icon="mdi:door-sliding",
entity_category="config",
value_fn=lambda v: v == "On",
write_fn=lambda p, rep, href=None: (
["setting", "dustbin", "vs", "0"],
{"x.com.samsung.da.autoClose": "On" if p == "On" else "Off"},
),
),
SelectDesc(
key="discharging_time",
field="x.com.samsung.da.desiredDischargingTime",
icon="mdi:timer-outline",
entity_category="config",
options_field="x.com.samsung.da.supportedDischargingTime",
write_fn=lambda p, rep, href=None: (
["setting", "dustbin", "vs", "0"],
{"x.com.samsung.da.desiredDischargingTime": p},
),
),
),
)
@@ -78,32 +99,60 @@ DUSTBIN_SETTING = Capability(
# rather than a binary_sensor, so no polarity/semantic is asserted that
# might be wrong.
CLEANSTATION_STATUS = Capability(
href='/status/cleanstation/vs/0',
poll_tier='warm',
href="/status/cleanstation/vs/0",
poll_tier="warm",
entities=(
SensorDesc(key='cleanstation_status', field='x.com.samsung.da.status',
icon='mdi:home-lightning-bolt', entity_category='diagnostic'),
SensorDesc(key='stick_status', field='x.com.samsung.da.stickStatus',
icon='mdi:broom', entity_category='diagnostic'),
SwitchDesc(key='uvc_intensive_mode', field='x.com.samsung.da.uvcIntensive',
icon='mdi:lightbulb-on-outline',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['status', 'cleanstation', 'vs', '0'],
{'x.com.samsung.da.uvcIntensive': 'On' if p == 'On' else 'Off'})),
SensorDesc(key='uvc_operation_time', field='x.com.samsung.da.uvcOperationTime',
icon='mdi:timer-sand', entity_category='diagnostic',
value_fn=int_or_none),
SensorDesc(key='uvc_total_operation_time',
field='x.com.samsung.da.uvcTotalOperationTime',
icon='mdi:timer-sand', entity_category='diagnostic',
state_class='total_increasing', value_fn=int_or_none),
SensorDesc(key='uvc_finished_time', field='x.com.samsung.da.uvcFinishedTime',
device_class='timestamp', entity_category='diagnostic',
value_fn=_parse_iso_utc),
SensorDesc(key='uvc_emitted_time', field='x.com.samsung.da.emittedTime',
device_class='timestamp', entity_category='diagnostic',
value_fn=_parse_iso_utc),
SensorDesc(
key="cleanstation_status",
field="x.com.samsung.da.status",
icon="mdi:home-lightning-bolt",
entity_category="diagnostic",
),
SensorDesc(
key="stick_status",
field="x.com.samsung.da.stickStatus",
icon="mdi:broom",
entity_category="diagnostic",
),
SwitchDesc(
key="uvc_intensive_mode",
field="x.com.samsung.da.uvcIntensive",
icon="mdi:lightbulb-on-outline",
entity_category="config",
value_fn=lambda v: v == "On",
write_fn=lambda p, rep, href=None: (
["status", "cleanstation", "vs", "0"],
{"x.com.samsung.da.uvcIntensive": "On" if p == "On" else "Off"},
),
),
SensorDesc(
key="uvc_operation_time",
field="x.com.samsung.da.uvcOperationTime",
icon="mdi:timer-sand",
entity_category="diagnostic",
value_fn=int_or_none,
),
SensorDesc(
key="uvc_total_operation_time",
field="x.com.samsung.da.uvcTotalOperationTime",
icon="mdi:timer-sand",
entity_category="diagnostic",
state_class="total_increasing",
value_fn=int_or_none,
),
SensorDesc(
key="uvc_finished_time",
field="x.com.samsung.da.uvcFinishedTime",
device_class="timestamp",
entity_category="diagnostic",
value_fn=_parse_iso_utc,
),
SensorDesc(
key="uvc_emitted_time",
field="x.com.samsung.da.emittedTime",
device_class="timestamp",
entity_category="diagnostic",
value_fn=_parse_iso_utc,
),
),
)
@@ -2,9 +2,10 @@
front-load washers).
Resources verified against two live WW90DG6U25LEU4 dumps (Table_02 course
family). Washers never report `oneUiVersion` -- see
`registry/by_type/__init__.py`'s `for_device_by_model()` for the fallback
detection this device type requires.
family). Washers share the `DA_WM_` laundry board with dryers, so their
`modelNum` can't tell the two apart -- see `registry/by_type/__init__.py`'s
`_CONSUMER_PREFIX_TO_KEY` for the `description`-based detection this device
type requires.
The shared laundry surface -- power/kids-lock/remote-control OCF+vendor
fallback pairs, buzzer, energy meter, job-beginning-status, and the
@@ -13,12 +14,18 @@ specific controls (wash settings, drum-clean tracking, dispenser dosing) are
here; they read washer-only fields off the same shared /course/vs/0 options
array.
"""
from datetime import datetime, timezone
from datetime import UTC, datetime
from ..capability import Capability
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc
from .laundry import (
bool_option_exists, bool_option_switch, cycle_options, cycle_select, hex_pairs, option_value,
bool_option_exists,
bool_option_switch,
cycle_options,
cycle_select,
hex_pairs,
option_value,
option_write,
)
@@ -80,39 +87,58 @@ from .laundry import (
# ---------------------------------------------------------------------------
WASHER_SETTINGS = Capability(
href='/washer/vs/0',
href="/washer/vs/0",
entities=(
SelectDesc(key='wash_temperature', field='x.com.samsung.da.waterTemperature',
icon='mdi:thermometer-water',
entity_category='config',
options_field='x.com.samsung.da.supportedWaterTemperature',
write_fn=lambda p, rep, href=None: (
['washer', 'vs', '0'], {'x.com.samsung.da.waterTemperature': p})),
SelectDesc(key='spin_speed', field='x.com.samsung.da.spinLevel',
icon='mdi:sync',
entity_category='config',
options_field='x.com.samsung.da.supportedSpinLevel',
write_fn=lambda p, rep, href=None: (
['washer', 'vs', '0'], {'x.com.samsung.da.spinLevel': p})),
SelectDesc(key='rinse_cycles', field='x.com.samsung.da.rinseCycles',
icon='mdi:water-sync',
entity_category='config',
options_field='x.com.samsung.da.supportedRinseCycles',
write_fn=lambda p, rep, href=None: (
['washer', 'vs', '0'], {'x.com.samsung.da.rinseCycles': p})),
SelectDesc(
key="wash_temperature",
field="x.com.samsung.da.waterTemperature",
icon="mdi:thermometer-water",
entity_category="config",
options_field="x.com.samsung.da.supportedWaterTemperature",
write_fn=lambda p, rep, href=None: (
["washer", "vs", "0"],
{"x.com.samsung.da.waterTemperature": p},
),
),
SelectDesc(
key="spin_speed",
field="x.com.samsung.da.spinLevel",
icon="mdi:sync",
entity_category="config",
options_field="x.com.samsung.da.supportedSpinLevel",
write_fn=lambda p, rep, href=None: (
["washer", "vs", "0"],
{"x.com.samsung.da.spinLevel": p},
),
),
SelectDesc(
key="rinse_cycles",
field="x.com.samsung.da.rinseCycles",
icon="mdi:water-sync",
entity_category="config",
options_field="x.com.samsung.da.supportedRinseCycles",
write_fn=lambda p, rep, href=None: (
["washer", "vs", "0"],
{"x.com.samsung.da.rinseCycles": p},
),
),
# Washer/dryer combo units carry a dryLevel field on the wash
# resource itself (no separate dryer device/course) -- see issue
# #22. Self-gates off on plain washers, which never report
# supportedDryLevel.
SelectDesc(key='dry_level', field='x.com.samsung.da.dryLevel',
icon='mdi:tumble-dryer',
entity_category='config',
translation_key='washer_dry_level',
options_field='x.com.samsung.da.supportedDryLevel',
exists_fn=lambda rep, resources: bool(
rep.get('x.com.samsung.da.supportedDryLevel')),
write_fn=lambda p, rep, href=None: (
['washer', 'vs', '0'], {'x.com.samsung.da.dryLevel': p})),
SelectDesc(
key="dry_level",
field="x.com.samsung.da.dryLevel",
icon="mdi:tumble-dryer",
entity_category="config",
translation_key="washer_dry_level",
options_field="x.com.samsung.da.supportedDryLevel",
exists_fn=lambda rep, resources: bool(rep.get("x.com.samsung.da.supportedDryLevel")),
write_fn=lambda p, rep, href=None: (
["washer", "vs", "0"],
{"x.com.samsung.da.dryLevel": p},
),
),
),
)
@@ -134,9 +160,9 @@ WASHER_SETTINGS = Capability(
# so it's treated as UTC, matching this integration's convention for other
# bare ISO datetime fields (see fridge.py's night-light schedule comment).
def _drum_clean_cycles_remaining(rep):
opts = rep.get('x.com.samsung.da.options') or []
proposal = option_value(opts, 'DrumCleanProposal')
washed = option_value(opts, 'WashingTimes')
opts = rep.get("x.com.samsung.da.options") or []
proposal = option_value(opts, "DrumCleanProposal")
washed = option_value(opts, "WashingTimes")
if proposal is None or washed is None:
return None
try:
@@ -146,11 +172,11 @@ def _drum_clean_cycles_remaining(rep):
def _drum_clean_last_cleaned(rep):
raw = option_value(rep.get('x.com.samsung.da.options'), 'DrumCleanLog')
raw = option_value(rep.get("x.com.samsung.da.options"), "DrumCleanLog")
if not raw:
return None
try:
return datetime.fromisoformat(raw).replace(tzinfo=timezone.utc)
return datetime.fromisoformat(raw).replace(tzinfo=UTC)
except ValueError:
return None
@@ -182,8 +208,8 @@ def _drum_clean_last_cleaned(rep):
# otherwise identical in shape to the detergent side. Revisit if a second
# device's dump contradicts this.
def _supported_level_options(resources, prefix):
rep = resources.get('/course/vs/0') or {}
raw = option_value(rep.get('x.com.samsung.da.options'), f'Supported{prefix}')
rep = resources.get("/course/vs/0") or {}
raw = option_value(rep.get("x.com.samsung.da.options"), f"Supported{prefix}")
return hex_pairs(raw) if raw else []
@@ -201,12 +227,13 @@ def _dosing_level(prefix):
outside the select's own option list, so HA renders it 'unknown' (issue #9).
Resolve it to the supported code with the same integer value so
current_option matches an option (and its translation)."""
def fn(rep):
opts = rep.get('x.com.samsung.da.options')
opts = rep.get("x.com.samsung.da.options")
raw = option_value(opts, prefix)
if raw is None:
return None
supported_raw = option_value(opts, f'Supported{prefix}')
supported_raw = option_value(opts, f"Supported{prefix}")
try:
target = int(raw, 16)
except (TypeError, ValueError):
@@ -218,29 +245,32 @@ def _dosing_level(prefix):
except (TypeError, ValueError):
continue
return raw
return fn
def _level_write(prefix):
def write(p, rep, href=None):
if not rep.get('x.com.samsung.da.options'):
if not rep.get("x.com.samsung.da.options"):
return None
# `p` is the zero-padded supported code the UI selected (e.g. '03');
# the device stores the level un-padded (e.g. '3'), matching how it
# reports it, so write it back in that native shape.
try:
native = format(int(p, 16), 'X')
native = format(int(p, 16), "X")
except (TypeError, ValueError):
native = p
return ['course', 'vs', '0'], {
'x.com.samsung.da.options': option_write(prefix, native),
return ["course", "vs", "0"], {
"x.com.samsung.da.options": option_write(prefix, native),
}
return write
def _dosing_low(prefix):
return lambda rep: option_value(
rep.get('x.com.samsung.da.options'), prefix) not in (None, 'Off')
return lambda rep: (
option_value(rep.get("x.com.samsung.da.options"), prefix) not in (None, "Off")
)
# Bubble soak / pre-wash / intensive-wash toggles, from the same options[]
@@ -275,10 +305,10 @@ def _bool_option_switch(key, icon, prefix, availability_field):
resolved (unrecognized course, missing/mismatched-length bitmap)
rather than guessing -- a false rejection is worse than an
occasional no-op write."""
if p != 'On':
if p != "On":
return None
opts = rep.get('x.com.samsung.da.options') or []
current = option_value(opts, 'Course')
opts = rep.get("x.com.samsung.da.options") or []
current = option_value(opts, "Course")
courses = cycle_options(resources)
if not current or current not in courses:
return None
@@ -288,75 +318,99 @@ def _bool_option_switch(key, icon, prefix, availability_field):
pairs = hex_pairs(raw)
if len(pairs) != len(courses):
return None
if pairs[courses.index(current)] != 'F0':
if pairs[courses.index(current)] != "F0":
return f"{key}_unavailable_for_cycle"
return None
return bool_option_switch(
key, icon, prefix,
entity_category='config', gate_on_presence=True, validate_fn=validate)
key, icon, prefix, entity_category="config", gate_on_presence=True, validate_fn=validate
)
WASHER_COURSE = Capability(
href='/course/vs/0',
href="/course/vs/0",
entities=(
cycle_select(translation_key='washer_cycle', icon='mdi:washing-machine',
table_href='/st/washercourse/vs/0'),
SensorDesc(key='drum_clean_cycles_remaining', unit='cycles',
icon='mdi:washing-machine-alert',
state_class='measurement',
exists_fn=lambda rep, resources: _drum_clean_cycles_remaining(rep) is not None,
rep_fn=_drum_clean_cycles_remaining),
SensorDesc(key='drum_clean_last_cleaned', device_class='timestamp',
icon='mdi:calendar-clock',
entity_category='diagnostic',
exists_fn=lambda rep, resources: _drum_clean_last_cleaned(rep) is not None,
rep_fn=_drum_clean_last_cleaned),
SelectDesc(key='detergent_quantity', icon='mdi:cup-water',
translation_key='detergent_quantity',
entity_category='config',
options=_level_options('DetergentLevelCtrl'),
exists_fn=lambda rep, resources: bool(
_level_options('DetergentLevelCtrl')(resources)),
rep_fn=_dosing_level('DetergentLevelCtrl'),
write_fn=_level_write('DetergentLevelCtrl')),
SelectDesc(key='detergent_water_hardness', icon='mdi:water-opacity',
translation_key='detergent_water_hardness',
entity_category='config',
options=_level_options('DetergentLevel2Ctrl'),
exists_fn=lambda rep, resources: bool(
_level_options('DetergentLevel2Ctrl')(resources)),
rep_fn=_dosing_level('DetergentLevel2Ctrl'),
write_fn=_level_write('DetergentLevel2Ctrl')),
SelectDesc(key='softener_quantity', icon='mdi:flask-outline',
translation_key='softener_quantity',
entity_category='config',
options=_level_options('SoftenerLevelCtrl'),
exists_fn=lambda rep, resources: bool(
_level_options('SoftenerLevelCtrl')(resources)),
rep_fn=_dosing_level('SoftenerLevelCtrl'),
write_fn=_level_write('SoftenerLevelCtrl')),
SelectDesc(key='softener_concentration', icon='mdi:flask-plus-outline',
translation_key='softener_concentration',
entity_category='config',
options=_level_options('SoftenerLevel2Ctrl'),
exists_fn=lambda rep, resources: bool(
_level_options('SoftenerLevel2Ctrl')(resources)),
rep_fn=_dosing_level('SoftenerLevel2Ctrl'),
write_fn=_level_write('SoftenerLevel2Ctrl')),
BinarySensorDesc(key='detergent_low', device_class='problem',
icon='mdi:alert-circle-outline',
exists_fn=bool_option_exists('DetergentAlarm'),
rep_fn=_dosing_low('DetergentAlarm')),
BinarySensorDesc(key='softener_low', device_class='problem',
icon='mdi:alert-circle-outline',
exists_fn=bool_option_exists('SoftenerAlarm'),
rep_fn=_dosing_low('SoftenerAlarm')),
_bool_option_switch('bubble_soak', 'mdi:chart-bubble',
'BubbleSoak', 'BubbleSoakSet'),
_bool_option_switch('pre_wash', 'mdi:washing-machine',
'PreWashSetting', 'PreWashAvailableSet'),
_bool_option_switch('intensive', 'mdi:washing-machine',
'IntensiveSetting', 'IntensiveAvailableSet'),
cycle_select(
translation_key="washer_cycle",
icon="mdi:washing-machine",
table_href="/st/washercourse/vs/0",
),
SensorDesc(
key="drum_clean_cycles_remaining",
unit="cycles",
icon="mdi:washing-machine-alert",
state_class="measurement",
exists_fn=lambda rep, resources: _drum_clean_cycles_remaining(rep) is not None,
rep_fn=_drum_clean_cycles_remaining,
),
SensorDesc(
key="drum_clean_last_cleaned",
device_class="timestamp",
icon="mdi:calendar-clock",
entity_category="diagnostic",
exists_fn=lambda rep, resources: _drum_clean_last_cleaned(rep) is not None,
rep_fn=_drum_clean_last_cleaned,
),
SelectDesc(
key="detergent_quantity",
icon="mdi:cup-water",
translation_key="detergent_quantity",
entity_category="config",
options=_level_options("DetergentLevelCtrl"),
exists_fn=lambda rep, resources: bool(_level_options("DetergentLevelCtrl")(resources)),
rep_fn=_dosing_level("DetergentLevelCtrl"),
write_fn=_level_write("DetergentLevelCtrl"),
),
SelectDesc(
key="detergent_water_hardness",
icon="mdi:water-opacity",
translation_key="detergent_water_hardness",
entity_category="config",
options=_level_options("DetergentLevel2Ctrl"),
exists_fn=lambda rep, resources: bool(_level_options("DetergentLevel2Ctrl")(resources)),
rep_fn=_dosing_level("DetergentLevel2Ctrl"),
write_fn=_level_write("DetergentLevel2Ctrl"),
),
SelectDesc(
key="softener_quantity",
icon="mdi:flask-outline",
translation_key="softener_quantity",
entity_category="config",
options=_level_options("SoftenerLevelCtrl"),
exists_fn=lambda rep, resources: bool(_level_options("SoftenerLevelCtrl")(resources)),
rep_fn=_dosing_level("SoftenerLevelCtrl"),
write_fn=_level_write("SoftenerLevelCtrl"),
),
SelectDesc(
key="softener_concentration",
icon="mdi:flask-plus-outline",
translation_key="softener_concentration",
entity_category="config",
options=_level_options("SoftenerLevel2Ctrl"),
exists_fn=lambda rep, resources: bool(_level_options("SoftenerLevel2Ctrl")(resources)),
rep_fn=_dosing_level("SoftenerLevel2Ctrl"),
write_fn=_level_write("SoftenerLevel2Ctrl"),
),
BinarySensorDesc(
key="detergent_low",
device_class="problem",
icon="mdi:alert-circle-outline",
exists_fn=bool_option_exists("DetergentAlarm"),
rep_fn=_dosing_low("DetergentAlarm"),
),
BinarySensorDesc(
key="softener_low",
device_class="problem",
icon="mdi:alert-circle-outline",
exists_fn=bool_option_exists("SoftenerAlarm"),
rep_fn=_dosing_low("SoftenerAlarm"),
),
_bool_option_switch("bubble_soak", "mdi:chart-bubble", "BubbleSoak", "BubbleSoakSet"),
_bool_option_switch(
"pre_wash", "mdi:washing-machine", "PreWashSetting", "PreWashAvailableSet"
),
_bool_option_switch(
"intensive", "mdi:washing-machine", "IntensiveSetting", "IntensiveAvailableSet"
),
),
)
@@ -1,33 +1,57 @@
"""Capabilities for the Samsung water-purifier family (TP2X_WATERPURIFIER-class,
issue #90, model TP2X_WATERPURIFIER_20K).
issue #90, model TP2X_WATERPURIFIER_20K; also AILITE_DA-REF-WATERPURIFIER-class,
issue #196, model RWP70F15ANW/AILITE_WATERPURIFIER_25K).
Resources verified against the issue #90 diagnostics dump.
Resources verified against the issue #90 and #196 diagnostics dumps.
"""
from ..batch import is_stub_rep
from ..capability import Capability
from ..entities import BinarySensorDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc
from .common import int_or_none, parse_iso_utc as _parse_iso_utc
from .common import int_or_none
from .common import parse_iso_utc as _parse_iso_utc
DISPENSE = Capability(
href='/setting/waterpurifier/vs/0',
poll_tier='warm',
href="/setting/waterpurifier/vs/0",
poll_tier="warm",
entities=(
SelectDesc(key='dispense_type', field='x.com.samsung.da.desiredType',
icon='mdi:cup-water',
options_field='x.com.samsung.da.supportedTypes',
write_fn=lambda p, rep, href=None: (
['setting', 'waterpurifier', 'vs', '0'],
{'x.com.samsung.da.desiredType': p})),
SelectDesc(
key="dispense_type",
field="x.com.samsung.da.desiredType",
icon="mdi:cup-water",
options_field="x.com.samsung.da.supportedTypes",
write_fn=lambda p, rep, href=None: (
["setting", "waterpurifier", "vs", "0"],
{"x.com.samsung.da.desiredType": p},
),
),
# Only a handful of discrete temperatures are selectable (not a
# continuous range) -- a select over the live-reported set, not a
# number with invented bounds.
SelectDesc(key='hot_water_temperature', field='x.com.samsung.da.tempDesiredHotWater',
icon='mdi:thermometer',
entity_category='config',
options_field='x.com.samsung.da.supportedHotTemperatures',
write_fn=lambda p, rep, href=None: (
['setting', 'waterpurifier', 'vs', '0'],
{'x.com.samsung.da.tempDesiredHotWater': p})),
#
# Newer boards (issue #196, RWP70F15ANW) don't populate
# supportedHotTemperatures at all -- they report a hotwaterRange
# (min/max) and a hotwaterLevel (step count?) instead, with no
# confirmed write contract for values off the old preset list. With
# an empty options_field result, HA's current_option still returns
# the live tempDesiredHotWater, which isn't in the (empty) options
# list and renders as "unknown" -- the exact symptom reported. Gate
# the entity off entirely when the board doesn't report a supported
# list, rather than guess at hotwaterRange/hotwaterLevel's meaning.
SelectDesc(
key="hot_water_temperature",
field="x.com.samsung.da.tempDesiredHotWater",
icon="mdi:thermometer",
entity_category="config",
options_field="x.com.samsung.da.supportedHotTemperatures",
exists_fn=lambda rep, resources: (
is_stub_rep(rep) or "x.com.samsung.da.supportedHotTemperatures" in rep
),
write_fn=lambda p, rep, href=None: (
["setting", "waterpurifier", "vs", "0"],
{"x.com.samsung.da.tempDesiredHotWater": p},
),
),
# Bounds and step come live from the device's own
# desiredCapacityRange/capacityResolution fields, not a hardcoded
# constant -- see the adding-device-support skill's "never hard-code
@@ -35,70 +59,103 @@ DISPENSE = Capability(
# "C" on this dump, which can't be right for a volume field, so per
# the 'don't guess' rule the unit is left unset rather than assumed
# to be mL.
NumberDesc(key='dispense_capacity', field='x.com.samsung.da.desiredCapacity',
icon='mdi:cup-water',
value_fn=int_or_none,
range_field='x.com.samsung.da.desiredCapacityRange',
step_fn=lambda rep: int_or_none(
rep.get('x.com.samsung.da.capacityResolution')) or 1,
write_fn=lambda p, rep, href=None: (
['setting', 'waterpurifier', 'vs', '0'],
{'x.com.samsung.da.desiredCapacity': str(int(round(float(p))))})),
BinarySensorDesc(key='pouring', field='x.com.samsung.da.pourStatus',
icon='mdi:cup-water',
value_fn=lambda v: v == 'On'),
NumberDesc(
key="dispense_capacity",
field="x.com.samsung.da.desiredCapacity",
icon="mdi:cup-water",
value_fn=int_or_none,
range_field="x.com.samsung.da.desiredCapacityRange",
step_fn=lambda rep: int_or_none(rep.get("x.com.samsung.da.capacityResolution")) or 1,
write_fn=lambda p, rep, href=None: (
["setting", "waterpurifier", "vs", "0"],
{"x.com.samsung.da.desiredCapacity": str(round(float(p)))},
),
),
BinarySensorDesc(
key="pouring",
field="x.com.samsung.da.pourStatus",
icon="mdi:cup-water",
value_fn=lambda v: v == "On",
),
),
)
STATUS = Capability(
href='/status/waterpurifier/vs/0',
poll_tier='warm',
href="/status/waterpurifier/vs/0",
poll_tier="warm",
entities=(
SensorDesc(key='waterpurifier_status', field='x.com.samsung.da.status',
icon='mdi:water-pump',
entity_category='diagnostic'),
BinarySensorDesc(key='filter_door_status', field='x.com.samsung.da.filterDoorStatus',
device_class='door',
entity_category='diagnostic',
value_fn=lambda v: v == 'Open'),
SensorDesc(key='sterilize_period', field='x.com.samsung.da.sterilizePeriod',
icon='mdi:calendar-sync',
entity_category='diagnostic'),
SensorDesc(key='sterilize_run_time', field='x.com.samsung.da.sterilizeRunTime',
icon='mdi:timer-outline',
entity_category='diagnostic'),
SensorDesc(key='sterilize_last_time', device_class='timestamp',
entity_category='diagnostic',
rep_fn=lambda rep: _parse_iso_utc(
rep.get('x.com.samsung.da.sterilizeLastTime'))),
SensorDesc(key='sterilize_plan_time', device_class='timestamp',
entity_category='diagnostic',
rep_fn=lambda rep: _parse_iso_utc(
rep.get('x.com.samsung.da.sterilizePlanTime'))),
SensorDesc(key='filter_clean_remain_time', field='x.com.samsung.da.filterCleanRemainTime',
icon='mdi:timer-sand',
entity_category='diagnostic'),
SensorDesc(
key="waterpurifier_status",
field="x.com.samsung.da.status",
icon="mdi:water-pump",
entity_category="diagnostic",
),
BinarySensorDesc(
key="filter_door_status",
field="x.com.samsung.da.filterDoorStatus",
device_class="door",
entity_category="diagnostic",
value_fn=lambda v: v == "Open",
),
SensorDesc(
key="sterilize_period",
field="x.com.samsung.da.sterilizePeriod",
icon="mdi:calendar-sync",
entity_category="diagnostic",
),
SensorDesc(
key="sterilize_run_time",
field="x.com.samsung.da.sterilizeRunTime",
icon="mdi:timer-outline",
entity_category="diagnostic",
),
SensorDesc(
key="sterilize_last_time",
device_class="timestamp",
entity_category="diagnostic",
rep_fn=lambda rep: _parse_iso_utc(rep.get("x.com.samsung.da.sterilizeLastTime")),
),
SensorDesc(
key="sterilize_plan_time",
device_class="timestamp",
entity_category="diagnostic",
rep_fn=lambda rep: _parse_iso_utc(rep.get("x.com.samsung.da.sterilizePlanTime")),
),
SensorDesc(
key="filter_clean_remain_time",
field="x.com.samsung.da.filterCleanRemainTime",
icon="mdi:timer-sand",
entity_category="diagnostic",
),
),
)
FAVORITE_CAPACITY = Capability(
href='/favorite/capacity/vs/0',
poll_tier='cold',
href="/favorite/capacity/vs/0",
poll_tier="cold",
entities=(
SwitchDesc(key='favorite_capacity_enabled', field='x.com.samsung.da.switchCapacity',
icon='mdi:star-outline',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['favorite', 'capacity', 'vs', '0'],
{'x.com.samsung.da.switchCapacity': 'On' if p == 'On' else 'Off'})),
SelectDesc(key='favorite_capacity', field='x.com.samsung.da.defaultCapacity',
icon='mdi:cup-water',
entity_category='config',
options_field='x.com.samsung.da.capacityList',
write_fn=lambda p, rep, href=None: (
['favorite', 'capacity', 'vs', '0'],
{'x.com.samsung.da.defaultCapacity': p})),
SwitchDesc(
key="favorite_capacity_enabled",
field="x.com.samsung.da.switchCapacity",
icon="mdi:star-outline",
entity_category="config",
value_fn=lambda v: v == "On",
write_fn=lambda p, rep, href=None: (
["favorite", "capacity", "vs", "0"],
{"x.com.samsung.da.switchCapacity": "On" if p == "On" else "Off"},
),
),
SelectDesc(
key="favorite_capacity",
field="x.com.samsung.da.defaultCapacity",
icon="mdi:cup-water",
entity_category="config",
options_field="x.com.samsung.da.capacityList",
write_fn=lambda p, rep, href=None: (
["favorite", "capacity", "vs", "0"],
{"x.com.samsung.da.defaultCapacity": p},
),
),
),
)
@@ -120,17 +177,17 @@ def _status_lock_definitely_lacks_hotwater_field(resources: dict) -> bool:
too would register both descriptors -- as SwitchDescs sharing one key,
with identical unique_ids -- until the next poll resolves it.
- href present and fetched -> the real answer."""
rep = resources.get('/status/lock/vs/0')
rep = resources.get("/status/lock/vs/0")
if rep is None:
return True
if not rep:
return False
return 'x.com.samsung.da.hotwaterLock' not in rep
return "x.com.samsung.da.hotwaterLock" not in rep
FAVORITE_HOTWATER = Capability(
href='/favorite/hotwater/vs/0',
poll_tier='cold',
href="/favorite/hotwater/vs/0",
poll_tier="cold",
entities=(
# Despite the resource/field naming, switchHotwater's value domain is
# Locked/Unlocked, not an enable flag (issue #144) -- it's the same
@@ -150,49 +207,184 @@ FAVORITE_HOTWATER = Capability(
# also re-asserts its own field's presence (switchHotwater), the
# gate a bare `field=` used to get for free before it had to share a
# key with LOCK's descriptor.
SwitchDesc(key='hotwater_lock', field='x.com.samsung.da.switchHotwater',
device_class='lock',
entity_category='config',
value_fn=lambda v: v != 'Unlocked',
exists_fn=lambda rep, resources: (
'x.com.samsung.da.switchHotwater' in rep
and _status_lock_definitely_lacks_hotwater_field(resources)),
write_fn=lambda p, rep, href=None: (
['favorite', 'hotwater', 'vs', '0'],
{'x.com.samsung.da.switchHotwater': 'Locked' if p == 'On' else 'Unlocked'})),
SelectDesc(key='favorite_hotwater_temperature',
field='x.com.samsung.da.favorite.defaultTemperature',
icon='mdi:thermometer',
entity_category='config',
options_field='x.com.samsung.da.favorite.supportedList',
write_fn=lambda p, rep, href=None: (
['favorite', 'hotwater', 'vs', '0'],
{'x.com.samsung.da.favorite.defaultTemperature': p})),
SwitchDesc(
key="hotwater_lock",
field="x.com.samsung.da.switchHotwater",
device_class="lock",
entity_category="config",
value_fn=lambda v: v != "Unlocked",
exists_fn=lambda rep, resources: (
"x.com.samsung.da.switchHotwater" in rep
and _status_lock_definitely_lacks_hotwater_field(resources)
),
write_fn=lambda p, rep, href=None: (
["favorite", "hotwater", "vs", "0"],
{"x.com.samsung.da.switchHotwater": "Locked" if p == "On" else "Unlocked"},
),
),
# Issue #196: `supportedList` is only the four *fixed* presets
# (e.g. ['40', '75', '85', '90']) -- the SmartThings app also lets
# the user add one custom value to their own display list via its
# "temperatures to display" editor (a wheel picker bounded by
# /setting/waterpurifier/vs/0's hotwaterRange, separate resource),
# and that custom value shows up in `showList`
# (['40', '50', '75', '85', '90'] here) but never in
# `supportedList`. Reading options from `supportedList` meant a
# unit whose current default *was* that custom value rendered as
# "unknown" -- not a coverage gap, just the wrong field. `showList`
# is a superset of `supportedList` that always includes whatever
# the current default actually is, custom or not.
SelectDesc(
key="favorite_hotwater_temperature",
field="x.com.samsung.da.favorite.defaultTemperature",
icon="mdi:thermometer",
entity_category="config",
options_field="x.com.samsung.da.favorite.showList",
write_fn=lambda p, rep, href=None: (
["favorite", "hotwater", "vs", "0"],
{"x.com.samsung.da.favorite.defaultTemperature": p},
),
),
),
)
# Coffee-capable variant (issue #107). No 'x.com.samsung.da.' field prefix
# on this resource, unlike the rest of the water-purifier surface.
COFFEE = Capability(
href='/favorite/coffee/vs/0',
poll_tier='warm',
href="/favorite/coffee/vs/0",
poll_tier="warm",
entities=(
SwitchDesc(key='favorite_coffee_enabled', field='favorite.activate',
icon='mdi:coffee-outline',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['favorite', 'coffee', 'vs', '0'],
{'favorite.activate': 'On' if p == 'On' else 'Off'})),
SensorDesc(key='coffee_brew_status', field='brew.status',
icon='mdi:coffee-outline',
entity_category='diagnostic'),
SwitchDesc(
key="favorite_coffee_enabled",
field="favorite.activate",
icon="mdi:coffee-outline",
entity_category="config",
value_fn=lambda v: v == "On",
write_fn=lambda p, rep, href=None: (
["favorite", "coffee", "vs", "0"],
{"favorite.activate": "On" if p == "On" else "Off"},
),
),
SensorDesc(
key="coffee_brew_status",
field="brew.status",
icon="mdi:coffee-outline",
entity_category="diagnostic",
),
),
)
# Cup-detection status (issue #196, RWP70F15ANW). Only "UnReady" observed;
# the full state domain isn't confirmed, so this stays a plain diagnostic
# sensor rather than an enum with an invented state table.
CUP_STATE = Capability(
href="/cup/state/vs/0",
poll_tier="warm",
entities=(
SensorDesc(
key="cup_state",
field="water.cup.state",
icon="mdi:cup-outline",
entity_category="diagnostic",
),
),
)
# Sound mode/output/volume (issue #196). Shapes echo laundry.py/
# air_purifier.py's same-named hrefs, but this board's own values differ
# from both (supportedModes here is voice/fixedTone/mute, not laundry's
# voice/tone/mute nor air_purifier's mute/buzzer) -- reusing either would
# reject a live-supported value, so these read the device's own supported
# list/range like air_purifier's versions do.
SOUND_MODE = Capability(
href="/settings/sound/mode/vs/0",
poll_tier="cold",
entities=(
SelectDesc(
key="sound_mode",
translation_key="water_purifier_sound_mode",
field="mode",
icon="mdi:volume-high",
entity_category="config",
options_field="supportedModes",
write_fn=lambda p, rep, href=None: (
["settings", "sound", "mode", "vs", "0"],
{"mode": p},
),
),
),
)
SOUND_OUTPUT = Capability(
href="/settings/sound/output/vs/0",
poll_tier="cold",
entities=(
SensorDesc(
key="sound_output",
field="deviceType",
icon="mdi:volume-high",
entity_category="diagnostic",
),
# No confirmed write contract (no sibling field advertising this as
# user-settable) -- surfaced read-only per the 'don't guess' rule.
BinarySensorDesc(
key="alarm_in_mute",
field="alarmInMute",
icon="mdi:volume-mute",
entity_category="diagnostic",
value_fn=lambda v: str(v).lower() == "true",
),
),
)
SOUND_VOLUME = Capability(
href="/settings/sound/volume/vs/0",
poll_tier="cold",
entities=(
NumberDesc(
key="sound_volume",
field="level",
icon="mdi:volume-medium",
entity_category="config",
native_min_fn=lambda rep: int_or_none(rep.get("minLevel")) or 0,
native_max_fn=lambda rep: int_or_none(rep.get("maxLevel")) or 0,
step_fn=lambda rep: int_or_none(rep.get("resolution")) or 1,
value_fn=int_or_none,
write_fn=lambda p, rep, href=None: (
["settings", "sound", "volume", "vs", "0"],
{"level": str(int(p))},
),
),
),
)
# Last-pour statistics (issue #196). last.capacity's unit isn't confirmed
# (no sibling unit field on this resource, unlike DISPENSE.dispense_capacity
# which at least has an -- albeit suspect -- capacityUnit) so it's left
# unitless rather than assumed to be mL.
STATISTIC_POUR = Capability(
href="/statistic/pour/vs/0",
poll_tier="cold",
entities=(
SensorDesc(
key="last_pour_type",
field="last.type",
icon="mdi:cup-water",
entity_category="diagnostic",
),
SensorDesc(
key="last_pour_capacity",
field="last.capacity",
icon="mdi:cup-water",
entity_category="diagnostic",
value_fn=int_or_none,
),
),
)
LOCK = Capability(
href='/status/lock/vs/0',
poll_tier='warm',
href="/status/lock/vs/0",
poll_tier="warm",
entities=(
# Shares its key with FAVORITE_HOTWATER's switchHotwater fallback
# above (issue #144); see the comment there for why this half also
@@ -201,29 +393,40 @@ LOCK = Capability(
# "present" here (matches entity.py's own default for a field-less
# gate) since the alternative -- treating an unfetched resource as
# confirmed-absent -- is what let both descriptors register at once.
SwitchDesc(key='hotwater_lock', field='x.com.samsung.da.hotwaterLock',
device_class='lock',
entity_category='config',
value_fn=lambda v: v != 'Unlocked',
exists_fn=lambda rep, resources: (
not rep or 'x.com.samsung.da.hotwaterLock' in rep),
write_fn=lambda p, rep, href=None: (
['status', 'lock', 'vs', '0'],
{'x.com.samsung.da.hotwaterLock': 'Locked' if p == 'On' else 'Unlocked'})),
SwitchDesc(key='coldwater_lock', field='x.com.samsung.da.coldwaterLock',
device_class='lock',
entity_category='config',
value_fn=lambda v: v != 'Unlocked',
write_fn=lambda p, rep, href=None: (
['status', 'lock', 'vs', '0'],
{'x.com.samsung.da.coldwaterLock': 'Locked' if p == 'On' else 'Unlocked'})),
SwitchDesc(key='buzz_lock', field='x.com.samsung.da.buzzLock',
device_class='lock',
entity_category='config',
value_fn=lambda v: v != 'Unlocked',
write_fn=lambda p, rep, href=None: (
['status', 'lock', 'vs', '0'],
{'x.com.samsung.da.buzzLock': 'Locked' if p == 'On' else 'Unlocked'})),
SwitchDesc(
key="hotwater_lock",
field="x.com.samsung.da.hotwaterLock",
device_class="lock",
entity_category="config",
value_fn=lambda v: v != "Unlocked",
exists_fn=lambda rep, resources: not rep or "x.com.samsung.da.hotwaterLock" in rep,
write_fn=lambda p, rep, href=None: (
["status", "lock", "vs", "0"],
{"x.com.samsung.da.hotwaterLock": "Locked" if p == "On" else "Unlocked"},
),
),
SwitchDesc(
key="coldwater_lock",
field="x.com.samsung.da.coldwaterLock",
device_class="lock",
entity_category="config",
value_fn=lambda v: v != "Unlocked",
write_fn=lambda p, rep, href=None: (
["status", "lock", "vs", "0"],
{"x.com.samsung.da.coldwaterLock": "Locked" if p == "On" else "Unlocked"},
),
),
SwitchDesc(
key="buzz_lock",
field="x.com.samsung.da.buzzLock",
device_class="lock",
entity_category="config",
value_fn=lambda v: v != "Unlocked",
write_fn=lambda p, rep, href=None: (
["status", "lock", "vs", "0"],
{"x.com.samsung.da.buzzLock": "Locked" if p == "On" else "Unlocked"},
),
),
),
)
@@ -239,18 +442,18 @@ _WP_IGNORED = [
# select. OCF-standard /mode/0 mirrors the same vendor resource but is
# already covered by the global ignored.IGNORED (fridge's OCF-native
# vacation-mode flag shares that href).
'/mode/vs/0',
"/mode/vs/0",
# Static support-flags blob (automation.supported.modes/options) -- no
# live "current automation setting" field to expose.
'/automation/waterpurifier/vs/0',
"/automation/waterpurifier/vs/0",
# Coffee-capable variant (issue #107). All four are static
# capability-advertisement blobs or empty -- no live "current recipe" /
# "current custom slot" field to expose, unlike /favorite/coffee/vs/0
# (COFFEE above), which does carry live brew status.
'/brand/recipe/info/vs/0', # revision + max-brand-count metadata
'/coffee/custom/recipe/vs/0', # publisher.support: allowed custom-recipe slot IDs
'/recipe/coffee/vs/0', # same publisher.support shape, no per-recipe content
'/recipe/coffee/deletion/vs/0', # empty {} on this dump
"/brand/recipe/info/vs/0", # revision + max-brand-count metadata
"/coffee/custom/recipe/vs/0", # publisher.support: allowed custom-recipe slot IDs
"/recipe/coffee/vs/0", # same publisher.support shape, no per-recipe content
"/recipe/coffee/deletion/vs/0", # empty {} on this dump
]
COVERAGE = [Capability(href=h) for h in _WP_IGNORED]
@@ -1,26 +1,27 @@
"""A Capability binds one OCF resource href to the entities it produces."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Callable, Optional
from .entities import SamsungEntityDescription
@dataclass(frozen=True, kw_only=True)
class Capability:
href: Optional[str] = None
href: str | None = None
entities: tuple[SamsungEntityDescription, ...] = ()
poll_tier: str = 'cold' # 'hot' | 'warm' | 'cold'
rt_filter: Optional[str] = None # bind only if rt_filter in rep.get('rt', ())
href_prefix: Optional[str] = None # pattern caps only: bind only if href starts with this
strip_prefix_in_key: bool = False # strip href_prefix segs before building key_override
poll_tier: str = "cold" # 'hot' | 'warm' | 'cold'
rt_filter: str | None = None # bind only if rt_filter in rep.get('rt', ())
href_prefix: str | None = None # pattern caps only: bind only if href starts with this
strip_prefix_in_key: bool = False # strip href_prefix segs before building key_override
# Rep field holding this instance's device-given name (e.g. an ice
# maker's "CUBED_ICE"/"ICE_BITES"), normalized and used as the display
# name prefix in place of the href-derived instance label. Does not
# affect key_override/unique_id -- only what's shown in the UI.
name_field: Optional[str] = None
match_fn: Optional[Callable[[dict, dict], bool]] = None # match_fn(rep, resources) -> bool
name_field: str | None = None
match_fn: Callable[[dict, dict], bool] | None = None # match_fn(rep, resources) -> bool
# Rare optional hook — only operational-state-style resources use this.
on_observation: Optional[Callable[[dict, dict], None]] = None
project: Optional[Callable[[dict, dict], dict]] = None
on_observation: Callable[[dict, dict], None] | None = None
project: Callable[[dict, dict], dict] | None = None
@@ -11,13 +11,15 @@ doesn't have that filter) is *not* a gap — a maintainer already looked at
that href and decided how to handle it. Only hrefs absent from the registry
entirely are reported.
"""
from __future__ import annotations
from collections.abc import Callable, Iterable
from dataclasses import dataclass
from typing import Callable, Iterable, Optional
from .capability import Capability
from .entities import SamsungEntityDescription
from .subdevices import MAIN, Subdevice
@dataclass
@@ -25,18 +27,24 @@ class BoundEntity:
href: str
capability: Capability
desc: SamsungEntityDescription
instance: str = ''
key_override: Optional[str] = None
instance_name: Optional[str] = None
instance: str = ""
key_override: str | None = None
instance_name: str | None = None
# Which logical indoor subdevice (issue #177) this entity belongs to.
# `href` above is always the *actual*, on-the-wire href for that
# subdevice -- MAIN's
# to_actual is the identity transform, so every device with no subdevices
# behaves exactly as before this field existed.
subdevice: Subdevice = MAIN
def _snake_to_title(s: str) -> str:
"""'CUBED_ICE'/'cubed_ice' -> 'Cubed Ice'. Shared with entity.py's
_derive_name, which applies the same transform to an href-derived key."""
return s.replace('_', ' ').title()
return s.replace("_", " ").title()
def _instance_name(cap: Capability, rep: dict) -> Optional[str]:
def _instance_name(cap: Capability, rep: dict) -> str | None:
"""Normalize `cap.name_field`'s raw value ("CUBED_ICE" -> "Cubed Ice")
for use as a display-name prefix, or None if the cap doesn't declare
one or the device didn't report it."""
@@ -50,20 +58,37 @@ def _instance_name(cap: Capability, rep: dict) -> Optional[str]:
def instance_suffix(href: str) -> str:
"""'' for the index-0 instance, else '_<n>' from the trailing segment."""
tail = href.rstrip('/').rsplit('/', 1)[-1]
if tail.isdigit() and tail != '0':
return f'_{tail}'
return ''
tail = href.rstrip("/").rsplit("/", 1)[-1]
if tail.isdigit() and tail != "0":
return f"_{tail}"
return ""
def _bind(cap: Capability, href: str, inst: str, inst_name: Optional[str],
key_prefix: Optional[str] = None) -> list[BoundEntity]:
def _bind(
cap: Capability,
href: str,
inst: str,
inst_name: str | None,
key_prefix: str | None = None,
subdevice: Subdevice = MAIN,
) -> list[BoundEntity]:
"""Build one BoundEntity per entity on `cap`, sharing the instance/
key-prefix/instance-name computed once by the caller."""
key-prefix/instance-name computed once by the caller.
`href` here is the *canonical* href discover() is iterating over;
`subdevice.to_actual` maps it to the real, on-the-wire href the entity
actually reads/writes (identity for MAIN, so single-subdevice devices are
unaffected -- see subdevices.py)."""
return [
BoundEntity(href=href, capability=cap, desc=desc, instance=inst,
key_override=f'{key_prefix}_{desc.key}' if key_prefix else None,
instance_name=inst_name)
BoundEntity(
href=subdevice.to_actual(href),
capability=cap,
desc=desc,
instance=inst,
key_override=f"{key_prefix}_{desc.key}" if key_prefix else None,
instance_name=inst_name,
subdevice=subdevice,
)
for desc in cap.entities
]
@@ -72,14 +97,35 @@ def discover(
resources: dict[str, dict],
registry: dict[str, list[Capability]],
pattern_caps: Iterable[Capability] = (),
log: Optional[Callable[[str], None]] = None,
log: Callable[[str], None] | None = None,
tier_log: Callable[[str, str], None] | None = None,
subdevice: Subdevice = MAIN,
) -> 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`.
`resources` is always keyed by *canonical* hrefs -- for a subdevice
(issue #177) that means its own canonical view (see
subdevices.canonical_view), the same shape as a plain single-subdevice
device's resources dict, so registry lookups/rt_filter/match_fn/
instance_suffix all behave identically regardless of which subdevice is
being discovered.
`subdevice` only affects the *href* stamped onto each BoundEntity (via
`_bind`, see above) and the href `log`/`tier_log` report -- both the
real, subscribable/pollable path, not the canonical one the registry is
keyed on.
"""
out: list[BoundEntity] = []
for href, rep in resources.items():
if not isinstance(rep, dict):
continue
rts = rep.get('rt') or ()
rts = rep.get("rt") or ()
caps = registry.get(href) or []
matched = False
@@ -89,8 +135,10 @@ def discover(
if cap.match_fn is not None and not cap.match_fn(rep, resources):
continue
inst = instance_suffix(href)
out.extend(_bind(cap, href, inst, _instance_name(cap, rep)))
out.extend(_bind(cap, href, inst, _instance_name(cap, rep), subdevice=subdevice))
matched = True
if tier_log is not None:
tier_log(subdevice.to_actual(href), cap.poll_tier)
if matched:
continue
@@ -105,13 +153,23 @@ def discover(
continue
inst = instance_suffix(href)
# Auto-derive key prefix from href segments (skip digits and 'vs')
src = href[len(cap.href_prefix):] if (cap.strip_prefix_in_key and cap.href_prefix) else href
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)))
src = (
href[len(cap.href_prefix) :]
if (cap.strip_prefix_in_key and cap.href_prefix)
else href
)
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), subdevice=subdevice
)
)
matched = True
if tier_log is not None:
tier_log(subdevice.to_actual(href), cap.poll_tier)
break
if not matched and not caps and log is not None:
log(href)
log(subdevice.to_actual(href))
return out
@@ -6,17 +6,19 @@ presence gating in exists_fn; write logic in write_fn on command platforms;
pre-write rejection (surfaced to the user, not just logged) in validate_fn
where a description declares one.
"""
from __future__ import annotations
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from typing import Any, Callable, Mapping, Optional
from typing import Any
WriteFn = Optional[Callable[[Any, dict], "tuple[list[str], dict] | None"]]
WriteFn = Callable[[Any, dict], "tuple[list[str], dict] | None"] | None
# (payload, rep, resources) -> a translation key, or None to allow the
# write. resources is the coordinator's full href->rep snapshot, for the same
# cross-resource lookups exists_fn needs (e.g. reading a sibling href's live
# option list).
ValidateFn = Optional[Callable[[Any, dict, dict], "str | None"]]
ValidateFn = Callable[[Any, dict, dict], "str | None"] | None
def _identity(v: Any) -> Any:
@@ -26,7 +28,7 @@ def _identity(v: Any) -> Any:
@dataclass(frozen=True, kw_only=True)
class SamsungEntityDescription:
key: str
field: str = ''
field: str = ""
# Defaults to `key`: entity names and states live in translations/, never
# here, so a descriptor only sets this to share one catalog entry across
# several descriptors, or to point at a differently-named one.
@@ -35,74 +37,79 @@ class SamsungEntityDescription:
# snapshot and returns the key to use -- for a descriptor shared across
# board generations whose state-code meaning isn't guaranteed consistent
# between them; see laundry.cycle_select's table-id-gated resolver.
translation_placeholders: Optional[Mapping[str, str]] = None
translation_placeholders: Mapping[str, str] | None = None
# Dynamic resources such as fridge compartments and ice makers use a
# device-provided or href-derived instance label inside a translated name.
use_instance_name: bool = False
icon: Optional[str] = None
entity_category: Optional[str] = None # 'diagnostic' | 'config' | None
icon: str | None = None
entity_category: str | None = None # 'diagnostic' | 'config' | None
enabled_default: bool = True
value_fn: Callable[[Any], Any] = _identity
rep_fn: Optional[Callable[[dict], Any]] = None # replaces field+value_fn; receives full rep
rep_fn: Callable[[dict], Any] | None = None # replaces field+value_fn; receives full rep
# (rep, resources): rep is this entity's own href's representation;
# resources is the coordinator's full href->rep snapshot, for gating
# presence on a sibling resource (e.g. laundry.cycle_options's source).
exists_fn: Optional[Callable[[dict, dict], bool]] = None
exists_fn: Callable[[dict, dict], bool] | None = None
@dataclass(frozen=True, kw_only=True)
class SensorDesc(SamsungEntityDescription):
device_class: Optional[str] = None
state_class: Optional[str] = None
unit: Optional[str] = None
unit_fn: Optional[Callable[[dict], str]] = None # overrides `unit` from the live rep, when set
options: Optional[tuple] = None # required by HA when device_class == 'enum'
device_class: str | None = None
state_class: str | None = None
unit: str | None = None
unit_fn: Callable[[dict], str] | None = None # overrides `unit` from the live rep, when set
options: tuple | None = None # required by HA when device_class == 'enum'
# Opt-in: gate this sensor's reported value behind the user-configurable
# CONF_FINISH_TIME_HYSTERESIS_MINUTES threshold (see sensor.py). Only for
# values that are expected to jitter around their "true" value between
# device-side revisions -- not a general-purpose flag every sensor should set.
hysteresis: bool = False
@dataclass(frozen=True, kw_only=True)
class BinarySensorDesc(SamsungEntityDescription):
device_class: Optional[str] = None # value_fn must return bool
device_class: str | None = None # value_fn must return bool
@dataclass(frozen=True, kw_only=True)
class SelectDesc(SamsungEntityDescription):
options: Any = () # tuple[str,...] | Callable[[dict[str, dict]], list[str]]
options: Any = () # tuple[str,...] | Callable[[dict[str, dict]], list[str]]
# callable form receives the coordinator's full href->rep resource
# snapshot (not just this entity's own href) and returns raw device
# option values; see select.py's LocalThingsSelect._raw_options().
options_field: Optional[str] = None # resource field that contains the live options list
options_field: str | None = None # resource field that contains the live options list
write_fn: WriteFn = None
@dataclass(frozen=True, kw_only=True)
class SwitchDesc(SamsungEntityDescription):
device_class: Optional[str] = None
device_class: str | None = None
write_fn: WriteFn = None
validate_fn: ValidateFn = None
@dataclass(frozen=True, kw_only=True)
class ButtonDesc(SamsungEntityDescription):
payload: str = ''
payload: str = ""
write_fn: WriteFn = None
@dataclass(frozen=True, kw_only=True)
class NumberDesc(SamsungEntityDescription):
device_class: Optional[str] = None
unit: Optional[str] = None
unit_fn: Optional[Callable[[dict], str]] = None # overrides `unit` from the live rep, when set
native_min: Optional[float] = None
native_max: Optional[float] = None
step: Optional[float] = None
device_class: str | None = None
unit: str | None = None
unit_fn: Callable[[dict], str] | None = None # overrides `unit` from the live rep, when set
native_min: float | None = None
native_max: float | None = None
step: float | None = None
# Override native_min/native_max/step from the live rep, when set --
# same "static default, live override" shape as unit_fn, for resources
# whose sane bounds depend on a per-device value (e.g. a temperature
# setpoint reported in Celsius on one device, Fahrenheit on another).
native_min_fn: Optional[Callable[[dict], float]] = None
native_max_fn: Optional[Callable[[dict], float]] = None
step_fn: Optional[Callable[[dict], float]] = None
range_field: Optional[str] = None # resource field containing [min, max] list
native_min_fn: Callable[[dict], float] | None = None
native_max_fn: Callable[[dict], float] | None = None
step_fn: Callable[[dict], float] | None = None
range_field: str | None = None # resource field containing [min, max] list
write_fn: WriteFn = None
@@ -128,14 +135,25 @@ class FanDesc(SamsungEntityDescription):
write_fn: WriteFn = None
@dataclass(frozen=True, kw_only=True)
class WaterHeaterDesc(SamsungEntityDescription):
# Composite water_heater entity: binds one primary resource (its href,
# typically an operation-mode resource) but the water_heater platform
# reads sibling resources (power, temperature) from the coordinator
# snapshot and writes to several of them. Same (kind, value) -> (path_segs,
# body) write_fn shape as ClimateDesc/FanDesc.
write_fn: WriteFn = None
PLATFORM_OF: dict[type, str] = {
SensorDesc: 'sensor',
BinarySensorDesc: 'binary_sensor',
SelectDesc: 'select',
SwitchDesc: 'switch',
ButtonDesc: 'button',
NumberDesc: 'number',
TimeDesc: 'time',
ClimateDesc: 'climate',
FanDesc: 'fan',
SensorDesc: "sensor",
BinarySensorDesc: "binary_sensor",
SelectDesc: "select",
SwitchDesc: "switch",
ButtonDesc: "button",
NumberDesc: "number",
TimeDesc: "time",
ClimateDesc: "climate",
FanDesc: "fan",
WaterHeaterDesc: "water_heater",
}
@@ -1,8 +1,8 @@
"""Read device identity from standard OCF resources (/oic/p, /oic/d)."""
"""Read device identity from standard OCF resources (/oic/p, /oic/d, /oic/res)."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
from dataclasses import dataclass, field
import cbor2
@@ -12,7 +12,9 @@ class DeviceIdentity:
manufacturer: str
model: str
name: str
serial: Optional[str]
serial: str | None
device_types: tuple[str, ...] = ()
raw: dict[str, dict | list] = field(default_factory=dict)
def _get(sess, path) -> dict:
@@ -26,12 +28,65 @@ def _get(sess, path) -> dict:
return {}
def read_identity(sess, serial: Optional[str]) -> DeviceIdentity:
p = _get(sess, ['oic', 'p'])
d = _get(sess, ['oic', 'd'])
def _get_links(sess, path) -> list:
"""Like _get, but for /oic/res: a baseline-Interface RETRIEVE on it
returns a CBOR array of Link objects (href/rt/if/di/...), not a single
Property map."""
try:
code, pl = sess.get(path, timeout=10.0)
if code == 0x45 and pl:
body = cbor2.loads(pl)
return body if isinstance(body, list) else []
except Exception:
pass
return []
def _device_types(d: dict) -> tuple[str, ...]:
"""/oic/d's `rt` -- the device's own OCF device-type declaration.
In OCF this is the one standardized "what am I" field: alongside the
generic 'oic.wk.d' it carries a concrete type such as 'oic.d.airconditioner'
or a SmartThings 'x.com.st.d.*' equivalent. `registry/by_type/resolve()`
now consults this first, ahead of board-part-number parsing, via
`for_device_by_oic_type` and its `_OIC_TYPE_TO_KEY` table -- but only a
minority of dumps populate it, so the modelNum/description path stays
load-bearing for everything else. It's also kept whole in diagnostics
(see `raw` below) so incoming issue reports keep surfacing types that
table doesn't know about yet.
"""
rt = d.get("rt")
if isinstance(rt, str):
rt = [rt]
if not isinstance(rt, (list, tuple)):
return ()
return tuple(t for t in rt if isinstance(t, str))
def read_identity(sess, serial: str | None) -> DeviceIdentity:
p = _get(sess, ["oic", "p"])
d = _get(sess, ["oic", "d"])
# /oic/res is OCF's baseline resource-discovery endpoint: a unicast
# RETRIEVE on it returns every Resource/Collection href this endpoint
# hosts, not just the one /device/0 seed path the coordinator polls.
# Relevant for the OCF "Composite Device" model (issue #177: a single
# physical device -- one IP, one /oic/p -- exposing more than one logical
# subdevice, each as its own Collection resource, same rt shape as our own
# /device/0). This is what registry.subdevices.enumerate_subdevices reads
# to find a board's `/device/<n>` siblings (Pattern A -- the reporter's
# ARTIK051_DONGLE_FAC_18K) -- that probing, plus the /device/1 and
# /device/2 speculative fallback it used to run right here on every
# _connect_session (including every reconnect), moved to that module so
# it only runs once, at first discovery, instead of on every reconnect.
res = _get_links(sess, ["oic", "res"])
return DeviceIdentity(
manufacturer=p.get('mnmn') or 'Samsung',
model=p.get('mnmo') or '',
name=d.get('n') or '',
manufacturer=p.get("mnmn") or "Samsung",
model=p.get("mnmo") or "",
name=d.get("n") or "",
serial=serial,
device_types=_device_types(d),
# Kept whole rather than field-by-field: these resources are outside
# the /device/0 dump diagnostics already captures, and we don't yet
# know which of their fields will turn out to identify a device type.
raw={"/oic/p": p, "/oic/d": d, "/oic/res": res},
)
@@ -10,18 +10,46 @@ under — new device types will have unknown-shaped data we can't fully
enumerate in advance, so this errs on catching the field by name rather
than only redacting inside hrefs we already recognize.
"""
from __future__ import annotations
REDACTED = "**REDACTED**"
_SENSITIVE_SUBSTRINGS = (
'mac', 'serial', 'token', 'login', 'account', 'email',
'userid', 'deviceid', 'uuid', 'duid', 'password', 'secret',
"mac",
"serial",
"token",
"login",
"account",
"email",
"userid",
"deviceid",
"uuid",
"duid",
"password",
"secret",
)
# Matched whole, not as substrings. OCF's /oic/d and /oic/p identify the unit
# with bare one- and two-letter keys that the rules above cannot see, being
# far too short to match on -- 'di' alone is a substring of 'condition',
# 'display', 'dispenser' and plenty of other ordinary appliance fields:
#
# 'di' -- device UUID, 'pi' -- platform UUID. As identifying as the serial
# number above.
# 'n' -- /oic/d's device name. Free text the owner can set from the
# SmartThings app, so it may well carry a person's name. Nothing
# in the /device/0 dump has ever exposed it; it only became
# reachable when diagnostics started reporting /oic/d, and the
# device-type signal we actually want from that resource is `rt`,
# which is not redacted.
_SENSITIVE_EXACT = frozenset({"di", "pi", "n"})
def _is_sensitive_key(key: str) -> bool:
lowered = key.lower()
if lowered in _SENSITIVE_EXACT:
return True
return any(s in lowered for s in _SENSITIVE_SUBSTRINGS)
@@ -7,6 +7,7 @@ Raises ValueError at import if any href group contains an unfiltered cap
alongside other caps (i.e., a cap with neither rt_filter nor match_fn set
in a group with multiple caps).
"""
from .capabilities import ALL
from .capability import Capability
@@ -32,8 +33,7 @@ def _build() -> dict[str, list[Capability]]:
# Validate: every group with >1 cap must have all caps filtered
for href, caps in out.items():
if len(caps) > 1:
unfiltered = [c for c in caps
if c.rt_filter is None and c.match_fn is None]
unfiltered = [c for c in caps if c.rt_filter is None and c.match_fn is None]
if unfiltered:
raise ValueError(
f"duplicate capability href {href!r} has unfiltered cap(s); "
@@ -0,0 +1,744 @@
"""Subdevice ("composite device") support for one physical connection exposing
more than one logical indoor subdevice -- issue #177.
Two reporters, two different board families, two genuinely different
mechanisms for exposing a second indoor subdevice over one IP / one DTLS
session (see DESIGN-177.md section 1 for the full evidence trail; the two
diagnostics dumps this was built against come from the Pattern A and
Pattern B reporters, respectively -- they each filed one of the two
reports this module unifies):
Pattern A -- indexed siblings (`ARTIK051_DONGLE_FAC_18K`, that reporter's
board). `/oic/res` lists three complete parallel resource sets whose
trailing path segment is the index (`/mode/vs/0`, `/mode/vs/1`,
`/mode/vs/2`, ... on both OCF-standard and vendor hrefs), and `/device/0`'s
batch carries only the index-0 hrefs -- the sibling subdevices are
reachable only via their own `/device/<n>` collection.
Pattern B -- UUID-prefixed tree (`TP2X_FAC_BORA_21K`, that reporter's board).
`/oic/res` hides the whole appliance tree; `/device/0`'s batch instead
carries `x.com.samsung.da.subdeviceIdList` on `/subdevices/vs/0`, and that
same UUID appears as a literal href prefix in `/oic/res`
(`/<uuid>/file/list/vs/0`, ...). What's actually been confirmed live on
that reporter's unit is narrower than early issue #177 writeups suggested: a
single individual `GET /<uuid>/information/vs/0` was read by hand through
the debug panel and came back carrying a different model/serial than the
master (`TP2X_FAC_BORA_RAC_21K`, the wall-mounted subdevice, vs. the
master's `TP2X_FAC_BORA_21K`, the floor subdevice) -- real evidence a
second subdevice exists at that prefix, but not evidence that `GET
/<uuid>/device/0` (the Collection batch PR #199 built this pattern's seed
around) itself returns anything. Issue #205, the same unit on a later
version, is that assumption failing: `/<uuid>/device/0` comes back empty.
So `enumerate_subdevices` tries it first (a future board might genuinely
expose it) and, when it's empty, assumes the sibling shares the master's
resource surface outright rather than trying to confirm it href by href.
That per-href confirmation is what this module did originally, and issue
#265 is why it doesn't anymore: `ARTIK051_FAC_TIME_21K`'s firmware doesn't
4.04 an href it doesn't recognize under a foreign prefix, it drops the
packet -- so probing the ~30 hrefs a real AC dump carries, each waiting out
the full 10s RETRIEVE timeout before moving on, took close enough to five
minutes to trip Home Assistant's own bootstrap-stage timeout and fail the
whole config entry, main unit included, before a single entity existed.
A device naming this UUID in its own `subdeviceIdList` (or `/oic/res` link
prefix, Pattern C) is already the device's own claim that a real subdevice
lives there; spending an unbounded, firmware-dependent amount of RETRIEVEs
trying to re-confirm that claim one href at a time bought unreliable
confirmation at a cost that could take the integration down with it. So the
fallback now trusts the claim and clones the master's current hrefs *and*
values verbatim under the prefix as this cycle's assumed state for the
sibling, with one bounded exception: it still retries a single individual
`GET /<uuid>/information/vs/0` -- the one href this pattern has ever
actually confirmed to answer on its own, per the paragraph above -- so the
sibling's HA device shows its own model/serial rather than the master's
cloned ones (identical model/serial across two otherwise-distinct devices
reads as a duplicate, even though their device-registry identifiers are
genuinely different). That's one extra RETRIEVE per candidate, not the ~30
that caused issue #265. `coordinator._poll_subdevice_flat_hrefs` still
re-GETs every cloned href individually, under the prefix, on every later
summary poll (unchanged by this), correcting the assumption toward the
sibling's real values as responses arrive; an href the firmware never
answers under that prefix just keeps mirroring the master indefinitely --
which, for a board proven not to expose that href at all, is what "assume
it exists" has to mean in practice. See `Subdevice.flat_hrefs`.
Pattern C -- UUID prefix advertised only via `/oic/res`
(`AWM-WW-AID-26-ONEBODY` washer+dryer combo, issue #241). The board answers
`numofsubdevice='2'` on `/multidevice/vs/0` but has no `/subdevices/vs/0`
(no `subdeviceIdList`) and 4.04s `/device/1`/`/device/2`; the washer
subdevice's UUID appears nowhere except as the path prefix of the
`x.com.samsung.da.multidevice` link in `/oic/res`, and
`GET /<uuid>/device/0` answers the washer's own full Collection batch
(model `..._WF80H` vs. the master's `..._DV80H27H`) -- Pattern B's
transform with the UUID sourced from the link prefix instead of
`subdeviceIdList`.
All of these are "the same thing wearing different clothes": a logical subdevice is a
seed collection path to poll, plus an href transform between the canonical
href the registry knows (`/mode/vs/0`) and the actual on-the-wire href. The
detection signals don't overlap on either captured board (the Pattern A
reporter's has no `/subdevices/vs/0` at all; the Pattern B reporter's has
no `/device/1`), so no disambiguation logic is needed --
`enumerate_subdevices` checks both and materializes any candidate whose
seed answers with a non-empty batch.
A non-empty seed batch is necessary but not sufficient for the *candidate*
to actually be a live second subdevice, though: the Pattern A reporter's
own board also has a `/device/2` -- a third, unused SmartThings slot --
that answers with the exact same 14-href shape as the real `/device/1`
sibling, populated with three constant/echoed/shape-only reps (a region
code identical to every other subdevice's, an /information rep echoing the
*same* model string as subdevice 1, and a /temperatures items[] entry with
an id/description but no current/desired/minimum/maximum reading) and
nothing resembling live climate state. Gating on *resource* shape/hrefs
turned out to be the wrong layer -- it would need per-family domain
knowledge (which hrefs mean "in use" for a washer's second drum, a
fridge's second compartment, ...) baked into a registry field before any
of those families could use this module at all. `discover_partitioned`
instead gates at the *entity* layer, after discovery+flattening: a
candidate is only kept if it produced at least one *primary* (no
`entity_category`), non-meter bound entity whose flattened value isn't
`None` -- e.g. the Pattern A reporter's /device/2 does flatten to an
`alarm_code` value, but that entity is diagnostic-category and derived from
an empty /alarms/vs/2, so it doesn't count. This reuses the same
primary/config/diagnostic taxonomy every registry already declares (see the
adding-device-support skill's entity-taxonomy section) instead of adding a
second, parallel domain-knowledge mechanism.
The meter carve-out is issue #214, and it's the same "an unused slot still
answers *something*" problem one layer further in: that reporter's
single-split ARTIK051_KRAC_18K has a /device/1 whose operational reps are
all empty {} -- the /device/2 shape above -- but which also reports a
populated /energy/consumption/vs/1, a whole-appliance lifetime kWh counter
that materialized the slot as a phantom second air conditioner. See
`_has_live_primary_entity`.
"""
from __future__ import annotations
import re
from collections.abc import Callable, Sequence
from dataclasses import dataclass
import cbor2
from .batch import parse_device0_batch
from .by_type._base import DeviceRegistry
_INDEXED_HREF_RE = re.compile(r"^/device/(\d+)$")
# A UUID as the first path segment of an /oic/res link href -- Pattern C's
# discovery signal (issue #241): a subdevice tree whose UUID is advertised
# nowhere except as this prefix (no subdeviceIdList, no /device/<n>).
_UUID_PREFIX_RE = re.compile(r"^/([0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12})/")
# Speculative /device/<n> siblings probed when /oic/res doesn't reveal a
# second logical subdevice's Collection on this board (moved here from
# identity.py, issue #177 -- see enumerate_subdevices' docstring for why: the
# old read_identity fired these two extra RETRIEVEs on *every* _connect_session,
# including every reconnect, for information enumeration only needs once).
# Same bound as before: a plain, tolerated-404 RETRIEVE, not the kind of
# guess the write-contract 'don't guess' rule is about. Widen only if a real
# board ever turns out to need more than two siblings.
_SPECULATIVE_DEVICE_INDICES = (1, 2)
@dataclass(frozen=True)
class Subdevice:
"""One logical indoor subdevice reachable over a single physical
connection.
`kind='main'` is the subdevice this config entry actually connects to and
always exists (see MAIN below) -- its `to_actual`/`to_canonical` are the
identity transform, so every existing single-subdevice device keeps
behaving exactly as it did before this module existed. `'indexed'`/
`'prefixed'` are Pattern A/B above; `key` is the trailing index string
('1', '2', ...) or the full subdevice UUID, and `seed_path` is the
Collection href (as path segments) whose batch response
enumerates/refreshes that subdevice.
`flat_hrefs` is non-empty only for a 'prefixed' subdevice that doesn't
expose its own Collection at `seed_path` (issue #205 -- not even
TP2X_FAC_BORA_21K, the board this pattern was built against, always
does). When set, `seed_path` is meaningless (left as `()`); enumeration
seeds this subdevice's initial state by cloning the master's own current
hrefs/values under its prefix rather than confirming them live (issue
#265 -- see enumerate_subdevices' fallback), and from then on
coordinator._poll_subdevice_seed refreshes it by GETting each of these
canonical hrefs individually under its prefix instead of one Collection
batch, correcting the clone toward real values as responses arrive.
"""
kind: str # 'main' | 'indexed' | 'prefixed'
key: str # '' | '1' | '6c2dff6d-ee5c-dad1-6a5e-000000000001'
seed_path: tuple[str, ...]
flat_hrefs: tuple[str, ...] = ()
def to_actual(self, canonical: str) -> str:
"""Canonical registry href (e.g. '/mode/vs/0') -> the real,
on-the-wire href for this subdevice."""
if self.kind == "indexed":
head, sep, tail = canonical.rpartition("/")
# Only the index-0 trailing segment is ours to rewrite --
# deliberately not a "replace any trailing digit" rule, which
# would misread a genuine multi-instance resource (the fridge's
# pattern-cap hrefs, e.g. '/door/vs/1') as a subdevice's. No
# registry declares a non-zero trailing index today and no
# fixture in the corpus contains one (verified across the whole
# corpus), so the strict rule costs nothing.
if tail == "0":
return f"{head}{sep}{self.key}"
return canonical
if self.kind == "prefixed":
return f"/{self.key}{canonical}"
return canonical
def to_canonical(self, actual: str) -> str | None:
"""Inverse of to_actual, or None when `actual` isn't this subdevice's."""
if self.kind == "indexed":
head, sep, tail = actual.rpartition("/")
if tail == self.key:
return f"{head}{sep}0"
return None
if self.kind == "prefixed":
prefix = f"/{self.key}"
if actual.startswith(prefix + "/"):
return actual[len(prefix) :]
return None
return actual
def owns(self, actual: str) -> bool:
"""True if `actual` belongs to this subdevice's namespace. MAIN never
"owns" anything by this definition -- it gets whatever's left after
every other subdevice's hrefs are excluded (see canonical_view)."""
if self.kind == "main":
return False
return self.to_canonical(actual) is not None
@property
def key_prefix(self) -> str:
"""Prefix that guarantees a unique entity key/unique_id (see
adapter._key). '' for MAIN -- the master's flattened state
keys must stay byte-identical to every device this integration
shipped before issue #177, so no golden file changes. The full
subdevice UUID is used verbatim (non-alphanumerics stripped, not
truncated or replaced with an ordinal) because it's device-reported
and stable across reconnects/restarts, unlike an ordinal assigned by
enumeration order -- and it never appears in a user-visible string
(see DESIGN-177.md section 6): HA derives the visible entity_id from
the device name + entity name, not from unique_id.
"""
if self.kind == "indexed":
return f"subdevice{self.key}_"
if self.kind == "prefixed":
slug = re.sub(r"[^a-zA-Z0-9]", "", self.key)
return f"subdevice_{slug}_"
return ""
MAIN = Subdevice(kind="main", key="", seed_path=("device", "0"))
def canonical_view(
subdevice: Subdevice,
resources: dict[str, dict],
subdevices: list[Subdevice],
) -> dict[str, dict]:
"""Rewrite `resources` (real, on-the-wire hrefs) into `subdevice`'s own
canonical namespace -- what discover()/exists_fn/rep_fn/is_legacy_board
and friends are written against.
For MAIN this is the snapshot *minus* every href owned by one of the
other subdevices in `subdevices` -- otherwise a sibling's own `/mode/vs/1`
would leak into the master's view under the same canonical key
('/mode/vs/0') that the master's actual `/mode/vs/0` also maps to,
silently mixing two subdevices' state together. For an indexed/prefixed
subdevice it's the reverse: only the hrefs that subdevice owns, rewritten
back through `to_canonical`.
`subdevices` may or may not include MAIN itself -- MAIN.owns() is always
False, so including it is harmless.
"""
if subdevice.kind == "main":
owned_elsewhere = {href for href in resources if any(su.owns(href) for su in subdevices)}
return {h: r for h, r in resources.items() if h not in owned_elsewhere}
return {
canon: resources[actual]
for actual in resources
if (canon := subdevice.to_canonical(actual)) is not None
}
def normalize_seed_batch(subdevice: Subdevice, batch: dict[str, dict]) -> dict[str, dict]:
"""Real, on-the-wire hrefs from one subdevice's seed-collection batch,
normalized so every href actually carries this subdevice's prefix/index.
Indexed subdevices need no change -- the device echoes the real `/x/<n>`
href in its own `/device/<n>` batch (confirmed against the Pattern A
reporter's dump). A prefixed subdevice's batch entries may or may not
already carry the `/<id>` prefix (unconfirmed which -- the Pattern B
reporter's board was never probed live before the subdevice id was
known), so it's added when missing.
"""
if subdevice.kind != "prefixed":
return batch
prefix = f"/{subdevice.key}"
return {
(href if href.startswith(prefix + "/") else f"{prefix}{href}"): rep
for href, rep in batch.items()
}
def _iter_oic_res_hrefs(oic_res):
"""Flatten /oic/res's raw shape into a flat iterable of link dicts.
Both captured dumps group links by `di` (`[{'di': ..., 'links': [...]}]`
-- see identity.py's read_identity/_get_links), so that's the shape
handled here. Tolerant of a flat link-list too (nothing in the OCF spec
rules it out, and _get_links' own posture already treats any list-shaped
body as possible) and of anything else by yielding nothing.
"""
for entry in oic_res or []:
if not isinstance(entry, dict):
continue
if "links" in entry:
for link in entry.get("links") or []:
if isinstance(link, dict):
yield link
elif "href" in entry:
yield entry
def _seed_href(path_segs: tuple[str, ...]) -> str:
"""('device', '1') -> '/device/1' -- the leading-slash href form
`probe_log` and diagnostics report, built from the path-segment form
`sess.get` takes."""
return "/" + "/".join(path_segs)
def _get_raw(sess, path_segs: tuple[str, ...]):
"""GET `path_segs` and CBOR-decode the payload, or None on any
missing/malformed response (a 4.04, a timeout, an empty payload) --
shared tolerated-absence posture for both callers below, which differ
only in which body shape they accept."""
try:
code, pl = sess.get(list(path_segs), timeout=10.0)
if code == 0x45 and pl:
return cbor2.loads(pl)
except Exception:
pass
return None
def _get_batch(sess, path_segs: tuple[str, ...]) -> dict[str, dict]:
"""GET a Samsung Collection resource and parse it the same way
/device/0 itself is parsed (parse_device0_batch): a [devcol-rep,
{href, rep}, ...] CBOR list, not a bare Property map."""
body = _get_raw(sess, path_segs)
return parse_device0_batch(body) if isinstance(body, list) else {}
def _get_property(sess, path_segs: tuple[str, ...]) -> dict:
"""GET a plain OCF Property-map resource (a bare dict, not a Collection
batch). Used for `/multidevice/vs/0` (issue #177 follow-up): listed in
`/oic/res` on the Pattern A reporter's board but absent from
`/device/0`'s batch, so it needs its own RETRIEVE, and it answers a
single Property map, not a [devcol-rep, ...] list."""
body = _get_raw(sess, path_segs)
return body if isinstance(body, dict) else {}
def enumerate_subdevices(
sess,
resources: dict[str, dict],
oic_res_links,
probe_log: Callable[[str, bool], None] | None = None,
) -> tuple[list[Subdevice], dict[str, dict]]:
"""Discover every sibling indoor subdevice reachable over `sess`'s
connection.
Runs once, at first discovery, in an executor, under the coordinator's
session lock -- every GET here is a plain RETRIEVE (the write-contract
'don't guess' rule doesn't apply to reading an extra resource to find
out whether it's there). Returns the *candidate* subdevices and the resources
already fetched while probing them (already normalized to real hrefs),
so the coordinator's first discovery poll doesn't need to re-poll them.
`probe_log(seed_href, found)` fires for every seed attempted, whether or
not it answered -- so diagnostics (see diagnostics.py's subdevice_probes)
can tell "checked, nothing there" apart from "never checked", the same
posture the speculative-probe code this replaces used to document in
identity.py.
Every candidate whose seed answers with a non-empty batch is returned
here -- this function has no way to tell a real sibling from an unused
SmartThings slot that merely answers the same shape (the Pattern A
reporter's `/device/2`); that requires discovering+flattening the
candidate's own entities first, which is `discover_partitioned`'s job,
not this one's.
See this module's docstring.
"""
subdevices: list[Subdevice] = []
fetched: dict[str, dict] = {}
# Case-insensitive -- the same UUID can reach here once from
# subdeviceIdList and once from an /oic/res link prefix with different
# casing (Samsung's own fields disagree on this elsewhere too, e.g. the
# redaction-prone subdeviceIdList handling below), and probing it twice
# would materialize the same physical subdevice as two Subdevice
# candidates under two different keys.
probed_ids: set[str] = set()
def _probed(seed_href: str, batch: dict) -> None:
if probe_log is not None:
probe_log(seed_href, bool(batch))
def _probe_prefixed(sub_id: str) -> None:
"""Materialize one UUID-prefixed subdevice candidate -- shared by
Pattern B (ids from subdeviceIdList) and Pattern C (ids from
/oic/res link prefixes) below, which differ only in where the UUID
came from."""
if sub_id.lower() in probed_ids:
return
probed_ids.add(sub_id.lower())
seed = (sub_id, "device", "0")
batch = _get_batch(sess, seed)
_probed(_seed_href(seed), batch)
if batch:
subdevice = Subdevice(kind="prefixed", key=sub_id, seed_path=seed)
fetched.update(normalize_seed_batch(subdevice, batch))
subdevices.append(subdevice)
return
# Fallback (issue #265, superseding the per-href probe issue #205
# originally added here): TP2X_FAC_BORA_21K itself -- the board this
# pattern was built against -- turns out not to always expose its own
# `/<uuid>/device/0` Collection either, so "every prefixed subdevice
# has one" doesn't hold even on the reference hardware. This used to
# respond by probing every href the master itself answered this
# cycle, individually, under this UUID's prefix, keeping whichever
# ones answered -- but issue #265's ARTIK051_FAC_TIME_21K drops
# packets for hrefs it doesn't recognize under a foreign prefix
# instead of 4.04ing them, so that loop's ~30 RETRIEVEs (a real AC
# dump's href count) each ran out the full 10s timeout, took close
# to five minutes, and tripped Home Assistant's own bootstrap-stage
# timeout -- failing the whole config entry, main unit included,
# before a single entity existed. The device already named this
# UUID in its own subdeviceIdList (or an /oic/res link prefix,
# Pattern C) -- that's the device's own claim a real subdevice lives
# there, so trust it outright rather than spend an unbounded,
# firmware-dependent number of RETRIEVEs re-confirming it href by
# href: clone the master's current hrefs and values verbatim under
# this prefix as the sibling's assumed state, no per-href probing
# loop at all. coordinator._poll_subdevice_flat_hrefs re-GETs each of
# these hrefs individually, under the prefix, on every later summary
# poll (unchanged by this), correcting the clone toward the
# sibling's real values as responses arrive; an href the firmware
# never answers under this prefix just keeps mirroring the master
# indefinitely -- the necessary meaning of "assume it exists" for a
# board already proven not to expose that href at all.
flat_hrefs = tuple(sorted(resources))
if not flat_hrefs:
return
for href in flat_hrefs:
fetched[f"/{sub_id}{href}"] = resources[href]
# One exception, worth its own bounded RETRIEVE rather than trusting
# the clone: /information/vs/0 is what device_info_for() reads for
# this subdevice's own model/serial. Left cloned from the master,
# the sibling's HA device page would show the *master's* model and
# serial verbatim -- distinct device-registry identifiers (derived
# from this UUID, see device_info_for), but reading as a duplicate
# of the master to anyone looking at the two devices' info. This is
# also the one href ever actually confirmed to answer under this
# pattern's prefix on real hardware (issue #177 comment 5113518087),
# so it's worth retrying here even though the rest of the fallback
# no longer probes anything -- one extra RETRIEVE per candidate,
# not the ~30 that caused issue #265.
info_seed = (sub_id, "information", "vs", "0")
info = _get_property(sess, info_seed)
_probed(_seed_href(info_seed), info)
if info:
fetched[f"/{sub_id}/information/vs/0"] = info
if "/information/vs/0" not in flat_hrefs:
flat_hrefs = tuple(sorted({*flat_hrefs, "/information/vs/0"}))
subdevices.append(
Subdevice(
kind="prefixed",
key=sub_id,
seed_path=(),
flat_hrefs=flat_hrefs,
)
)
# --- Pattern B: UUID-prefixed tree (TP2X_FAC_BORA_21K) ------------------
raw_ids = (resources.get("/subdevices/vs/0") or {}).get("x.com.samsung.da.subdeviceIdList")
# Tolerate anything but a list of strings -- this field is redaction-prone
# (it matches the 'deviceid' substring rule in redact.py) and the existing
# airconditioner_fac_bora fixture carries the literal string
# '**REDACTED**'/'REDACTED' there. That must yield zero subdevices, not a
# crash -- issue #177 is additive, it must never break an already-working
# single-climate-entity device.
ids = raw_ids if isinstance(raw_ids, list) else []
listed = sorted(i for i in ids if isinstance(i, str) and i)
for sub_id in listed:
_probe_prefixed(sub_id)
# --- Pattern C: UUID prefix advertised only via /oic/res ----------------
# (AWM-WW-AID-26-ONEBODY washer+dryer combo, issue #241.) A third
# multidevice shape: the board answers numofsubdevice='2' on
# /multidevice/vs/0, but carries no /subdevices/vs/0 (no subdeviceIdList
# -- Pattern B's signal) and 4.04s /device/1 and /device/2 (Pattern A's).
# The only trace of the sibling is a UUID-prefixed link in /oic/res
# itself: the x.com.samsung.da.multidevice link,
# '/<uuid>/multidevice/vs/0' on the reporting board. Its washer tree
# answers a full Collection at /<uuid>/device/0, exactly Pattern B's
# transform -- so treat every UUID path prefix seen in /oic/res as a
# prefixed-subdevice candidate. Probing is the same tolerated-404
# RETRIEVE as everything else here, and discover_partitioned's
# entity-level liveness gate still decides materialization, so a board
# that advertises a UUID link without a live sibling behind it
# contributes nothing. _probe_prefixed's probed_ids guard -- not a set
# difference against `listed` here -- is what keeps an id already named
# by subdeviceIdList from being probed and materialized a second time,
# since the two sources can disagree on that UUID's case.
linked = sorted(
{
m.group(1)
for link in _iter_oic_res_hrefs(oic_res_links)
for m in [_UUID_PREFIX_RE.match(link.get("href", ""))]
if m
}
)
for sub_id in linked:
_probe_prefixed(sub_id)
# --- Pattern A: indexed siblings (ARTIK051_DONGLE_FAC_18K) --------------
indices = sorted(
{
int(m.group(1))
for link in _iter_oic_res_hrefs(oic_res_links)
for m in [_INDEXED_HREF_RE.match(link.get("href", ""))]
if m and int(m.group(1)) >= 1
}
)
if not indices:
# A board that hides its whole tree from /oic/res (Pattern B's
# reporter board does this too, but it has no /device/<n> to find
# regardless) gives us nothing to enumerate from -- fall back to the
# bounded speculative probe this replaces from identity.py.
indices = list(_SPECULATIVE_DEVICE_INDICES)
for n in indices:
seed = ("device", str(n))
batch = _get_batch(sess, seed)
_probed(_seed_href(seed), batch)
if not batch:
continue
subdevice = Subdevice(kind="indexed", key=str(n), seed_path=seed)
fetched.update(batch) # already real /x/<n> hrefs, no normalization needed
subdevices.append(subdevice)
# /multidevice/vs/0 (issue #177 follow-up): the Pattern A reporter's
# board lists it in /oic/res but it never appears in /device/0's batch,
# so it needs its own RETRIEVE. It's a plain corroborating count
# (x.com.samsung.da.numofsubdevice), confirmed read-only (a write
# attempt returned CoAP 4.00) -- captured for diagnostics only, folded
# into the merged resources dict like any other href (see
# airconditioner._AC_IGNORED, which is what keeps it from surfacing as
# an unbound-href gap). NOT a gate: discover_partitioned's entity-level
# liveness check decides materialization correctly without it, and only
# this one board family is known to expose it at all. Whether it agrees
# with the number of subdevices actually materialized is the
# coordinator's call to log (it owns the logger; this module doesn't),
# not this function's.
multidevice_seed = ("multidevice", "vs", "0")
multidevice = _get_property(sess, multidevice_seed)
_probed(_seed_href(multidevice_seed), multidevice)
if multidevice:
fetched["/multidevice/vs/0"] = multidevice
return subdevices, fetched
@dataclass(frozen=True)
class SkippedSubdevice:
"""A candidate `enumerate_subdevices` found whose seed answered, but that
`discover_partitioned`'s entity-level liveness gate rejected -- an
unused SmartThings slot (the Pattern A reporter's `/device/2`), not a
real second subdevice. Kept around (rather than silently dropped) so a
caller can log/report what was skipped and why."""
subdevice: Subdevice
hrefs: tuple[str, ...]
# Sensor kinds whose value is a running total the *appliance* keeps rather
# than a reading of the subdevice's own hardware -- excluded from the
# liveness gate below (issue #214). HA's own running-total state classes
# cover most of them; the consumption device classes catch the rest, since a
# descriptor may deliberately declare no state_class (common.ENERGY_METER's
# monthly totals reset at each billing boundary, so they aren't
# `total_increasing`).
_METER_STATE_CLASSES = frozenset({"total", "total_increasing"})
_METER_DEVICE_CLASSES = frozenset({"energy", "water", "gas"})
def _is_meter(desc) -> bool:
"""True for a cumulative consumption/counter descriptor -- see the two
constants above. Only SensorDesc carries either attribute; everything
else answers False through the getattr defaults."""
return (
getattr(desc, "state_class", None) in _METER_STATE_CLASSES
or getattr(desc, "device_class", None) in _METER_DEVICE_CLASSES
)
def _has_live_primary_entity(bound, state: dict) -> bool:
"""True if flattening `bound` (one candidate subdevice's BoundEntity
list) produced at least one non-`None` value for a *primary* entity --
`entity_category` unset, HA's own "the user acts on or watches this"
tier (see the adding-device-support skill's entity-taxonomy section) --
that isn't a cumulative meter (`_is_meter`).
This is the materialization gate itself (see this module's docstring).
Two exclusions, both for the same reason -- the question this answers is
"is a physical subdevice installed at this slot?", and neither kind of
value can speak to it:
- **Non-primary entities.** The Pattern A reporter's `/device/2` does
flatten to one non-`None` value (`alarm_code`), but that entity is
`diagnostic`-category and derived from an empty `/alarms/vs/2` -- a
config/diagnostic entity reading "something" proves nothing about
whether hardware is there.
- **Cumulative meters** (issue #214). An unused slot on the issue #214
reporter's ARTIK051_KRAC_18K reports `/energy/consumption/vs/1` with a
populated `cumulativePower` while every operational rep on it
(`/power/1`, `/mode/1`, `/mode/vs/1`, `/temperature/current/1`,
`/temperature/desired/1`, `/airflow/1`, `/humidity/1`) is empty `{}` --
i.e. exactly the Pattern A `/device/2` shape plus a lifetime kWh
counter. That counter got the slot materialized as a phantom second
air conditioner. A single-split AC has one compressor and one energy
meter, so a whole-appliance total showing up under a second index is
the appliance's own bookkeeping, not evidence of a second indoor unit.
A genuinely installed subdevice reports its own operational state too
(the Pattern A reporter's real `/device/1` reports power, mode, both
temperatures and airflow), and that state is what still passes this
gate.
"""
from .adapter import _key # see discover_partitioned's deferred-import note
return any(
not b.desc.entity_category and not _is_meter(b.desc) and state.get(_key(b)) is not None
for b in bound
)
def discover_partitioned(
resources: dict[str, dict],
subdevices: list[Subdevice],
resolve_registry: Callable[..., DeviceRegistry | None],
fallback_capabilities: dict,
log: Callable[[str], None] | None = None,
tier_log: Callable[[str, str], None] | None = None,
oic_device_types: Sequence[str] = (),
):
"""Bind every href in `resources` (the merged, real-href snapshot -- main
plus every enumerated subdevice's seed) to entities, partitioned by which
subdevice owns it.
Main pass runs over hrefs owned by no subdevice -- otherwise every
`/mode/vs/1` would land in `unbound_hrefs` too (nothing in the main
device's registry claims that literal href) and raise a spurious
coverage-gap repair. Then one pass per *candidate* subdevice over its own
canonical view, resolving that subdevice's own device type from its own
`/information/vs/0` when it reports one (e.g. the Pattern B reporter's
wall subdevice reports `TP2X_FAC_BORA_RAC_21K` -> the 'RAC' board token ->
airconditioner), falling back to the master's registry otherwise --
every AC family shares the same resource surface, and a sibling that
fails to answer its own identity resource is still the same appliance
type as the subdevice this config entry was set up against.
Each candidate is discovered and flattened *twice*: once silently to
evaluate `_has_live_primary_entity` (this module's materialization
gate -- see its docstring and this module's own), and, only if that
passes, a second time with `log`/`tier_log` wired so its coverage gaps
and poll tiers actually count. A candidate that fails the gate
contributes nothing at all -- no bound entities, no unbound-href
report, no hot/warm href -- as if it had never answered its seed.
Discovering an unused slot's small, fixed resource set twice at
first-discovery time only is a non-issue; getting a phantom subdevice
silently counted into unbound_hrefs or hot/warm tiers is not.
`oic_device_types` (from the master's own `/oic/d`, see
registry/identity.py) is passed only to the *master's* resolution --
subdevices have no `/oic/d` of their own read today (they resolve from
their own `/information/vs/0` or fall back to the master's whole
registry, as documented above), and blindly applying the master's OCF
device type to every subdevice's own model-based resolution would be
wrong the moment a composite appliance ever pairs two genuinely
different device types under one connection.
Returns `(bound, device_type_name, materialized, skipped)`:
- `bound`: the concatenated BoundEntity list (main + every materialized
subdevice).
- `device_type_name`: the *master's* resolved device type (used for
logging/device naming; each subdevice's own resolved type only affects
which capabilities bind its hrefs, not this).
- `materialized`: the subset of `subdevices` that passed the gate, in the
same order -- what the caller should keep as its live subdevice roster
going forward (poll seeds, canonical_resources, device_info_for, ...).
- `skipped`: `SkippedSubdevice` entries for every candidate that didn't.
"""
# Deferred import: discovery.py imports Subdevice/MAIN from this module at
# module scope, so importing discover() back here at module scope would
# be a circular import. By the time this function actually runs both
# modules are fully loaded. adapter.py imports discovery.py, so the same
# applies to flatten()/_key().
from .adapter import flatten
from .discovery import discover
# Same computation canonical_view does for MAIN (snapshot minus every
# other subdevice's owned hrefs) -- reuse it rather than re-deriving
# owned_elsewhere here too.
main_view = canonical_view(MAIN, resources, subdevices)
reg = resolve_registry(main_view, device_types=oic_device_types)
caps, pats = (
(reg.capabilities, reg.pattern_capabilities)
if reg is not None
else (fallback_capabilities, [])
)
# MAIN is never gated -- the config entry's own physical connection
# always materializes regardless of what its entities' values are.
bound = discover(main_view, caps, pats, log=log, tier_log=tier_log, subdevice=MAIN)
device_type_name = reg.name if reg is not None else None
materialized: list[Subdevice] = []
skipped: list[SkippedSubdevice] = []
for su in subdevices:
view = canonical_view(su, resources, subdevices)
su_reg = resolve_registry(view) or reg
su_caps, su_pats = (
(su_reg.capabilities, su_reg.pattern_capabilities)
if su_reg is not None
else (fallback_capabilities, [])
)
probe_bound = discover(view, su_caps, su_pats, subdevice=su)
probe_state = flatten(probe_bound, resources)
if _has_live_primary_entity(probe_bound, probe_state):
materialized.append(su)
bound = bound + discover(
view,
su_caps,
su_pats,
log=log,
tier_log=tier_log,
subdevice=su,
)
else:
skipped.append(
SkippedSubdevice(
subdevice=su,
hrefs=tuple(sorted({b.href for b in probe_bound})),
)
)
return bound, device_type_name, materialized, skipped
+16 -15
View File
@@ -1,20 +1,20 @@
"""Select platform for Local Things."""
from __future__ import annotations
import re
from typing import Optional
from typing import cast
from homeassistant.components.select import SelectEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .registry.entities import SelectDesc
from .catalog import translated_states
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
from .registry.entities import SelectDesc
async def async_setup_entry(
@@ -30,7 +30,7 @@ async def async_setup_entry(
)
_CAMEL_BOUNDARY_RE = re.compile(r'(?<=[a-z0-9])(?=[A-Z])')
_CAMEL_BOUNDARY_RE = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
def _translation_state(value: str, known: frozenset[str]) -> str | None:
@@ -42,14 +42,14 @@ def _translation_state(value: str, known: frozenset[str]) -> str | None:
knows are normalized -- an unrecognized (or future) vendor value keeps
its own readable form rather than becoming an untranslatable slug.
"""
direct = value.lower().replace(' ', '_')
direct = value.lower().replace(" ", "_")
if direct in known:
return direct
snake = _CAMEL_BOUNDARY_RE.sub('_', value).lower().replace(' ', '_')
snake = _CAMEL_BOUNDARY_RE.sub("_", value).lower().replace(" ", "_")
return snake if snake in known else None
def _display(value, translation_key: Optional[str]):
def _display(value, translation_key: str | None):
"""Turn a raw device option/state value into what's shown in the UI.
`translation_key` is the entity's already-resolved key (SelectDesc.
@@ -74,7 +74,7 @@ def _display(value, translation_key: Optional[str]):
if not isinstance(value, str):
return value
if translation_key:
known = translated_states('select', translation_key)
known = translated_states("select", translation_key)
if not known:
# No state table for this key: either the entity isn't translated
# at all, or its name is translated but its options deliberately
@@ -84,27 +84,28 @@ def _display(value, translation_key: Optional[str]):
if translated := _translation_state(value, known):
return translated
if value.islower():
return value.replace('_', ' ').title()
return _CAMEL_BOUNDARY_RE.sub(' ', value)
return value.replace("_", " ").title()
return _CAMEL_BOUNDARY_RE.sub(" ", value)
class LocalThingsSelect(LocalThingsEntity, SelectEntity):
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
super().__init__(coordinator, bound)
desc: SelectDesc = bound.desc
desc = cast(SelectDesc, bound.desc)
if not desc.options_field and not callable(desc.options):
self._attr_options = [_display(o, self.translation_key) for o in desc.options]
def _raw_options(self) -> list[str]:
desc: SelectDesc = self._bound.desc
desc = cast(SelectDesc, self._bound.desc)
if callable(desc.options):
# Per-device option list computed from the full resource
# snapshot (not just this entity's own href) -- e.g. a course
# list decoded from a sibling resource. There is no static
# fallback: when that resource isn't populated the callable
# returns [] and the entity's exists_fn suppresses it entirely.
return list(desc.options(self.coordinator.last_resources) or [])
# This entity's own subdevice's canonical view (issue #177), not
# the raw actual-href snapshot -- see LocalThingsEntity._resources.
return list(desc.options(self._resources) or [])
if desc.options_field:
rep = self.coordinator.last_resources.get(self._bound.href) or {}
return list(rep.get(desc.options_field) or [])
@@ -112,7 +113,7 @@ class LocalThingsSelect(LocalThingsEntity, SelectEntity):
@property
def options(self) -> list[str]:
desc: SelectDesc = self._bound.desc
desc = cast(SelectDesc, self._bound.desc)
if desc.options_field or callable(desc.options):
return [_display(o, self.translation_key) for o in self._raw_options()]
return self._attr_options
+56 -14
View File
@@ -1,7 +1,11 @@
"""Sensor platform for Local Things."""
from __future__ import annotations
from homeassistant.components.sensor import SensorEntity, SensorDeviceClass, SensorStateClass
from datetime import timedelta
from typing import cast
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorStateClass
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
@@ -9,12 +13,15 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .observe import MODE_OBSERVE, MODE_POLL
from .registry.entities import SensorDesc
from .const import DOMAIN
from .const import (
CONF_FINISH_TIME_HYSTERESIS_MINUTES,
DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES,
DOMAIN,
)
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
from .observe import MODE_OBSERVE, MODE_POLL
from .registry.entities import SensorDesc
async def async_setup_entry(
@@ -23,7 +30,7 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][entry.entry_id]
entities = [
entities: list[SensorEntity] = [
LocalThingsSensor(coordinator, b)
for b in coordinator.bound
if isinstance(b.desc, SensorDesc) and _is_included(b, coordinator)
@@ -33,26 +40,61 @@ async def async_setup_entry(
class LocalThingsSensor(LocalThingsEntity, SensorEntity):
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
super().__init__(coordinator, bound)
desc: SensorDesc = bound.desc
desc = cast(SensorDesc, bound.desc)
self._attr_native_unit_of_measurement = desc.unit
self._attr_device_class = desc.device_class
self._attr_state_class = desc.state_class
self._attr_device_class = (
SensorDeviceClass(desc.device_class) if desc.device_class else None
)
self._attr_state_class = SensorStateClass(desc.state_class) if desc.state_class else None
if desc.options:
self._attr_options = list(desc.options)
self._hysteresis_value = None
@property
def native_unit_of_measurement(self):
desc: SensorDesc = self._bound.desc
desc = cast(SensorDesc, self._bound.desc)
if desc.unit_fn is not None:
return desc.unit_fn(self.coordinator.resource(self._bound.href))
return self._attr_native_unit_of_measurement
@property
def native_value(self):
return (self.coordinator.data or {}).get(self._state_key)
raw = (self.coordinator.data or {}).get(self._state_key)
desc = cast(SensorDesc, self._bound.desc)
if not desc.hysteresis:
return raw
return self._apply_hysteresis(raw)
def _apply_hysteresis(self, raw):
"""Hold the last value this entity actually reported until a new one
differs by at least the configured threshold, regardless of how long
that difference has been building up (this is a deadband, not a
time-based debounce).
Values like finish_time are `now() + remaining`, recomputed from
scratch every poll -- both wall-clock drift between the device's own
remaining-time updates and the device revising its own estimate mid-
cycle produce a stream of small, real changes that are individually
meaningless but each trigger a recorder/logbook entry. A cycle
ending (raw is None) or starting (cache empty) always passes through
immediately -- only in-between jitter while a value already exists
on both sides gets held back.
"""
assert self.coordinator.config_entry is not None
threshold_min = self.coordinator.config_entry.options.get(
CONF_FINISH_TIME_HYSTERESIS_MINUTES, DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES
)
if (
threshold_min
and raw is not None
and self._hysteresis_value is not None
and abs(raw - self._hysteresis_value) < timedelta(minutes=threshold_min)
):
return self._hysteresis_value
self._hysteresis_value = raw
return raw
class LocalThingsConnectionModeSensor(CoordinatorEntity[LocalThingsCoordinator], SensorEntity):
@@ -61,11 +103,11 @@ class LocalThingsConnectionModeSensor(CoordinatorEntity[LocalThingsCoordinator],
Disabled by default — it's for troubleshooting, not everyday use."""
_attr_has_entity_name = True
_attr_translation_key = 'connection_mode'
_attr_translation_key = "connection_mode"
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_entity_registry_enabled_default = False
_attr_device_class = SensorDeviceClass.ENUM
_attr_options = [MODE_OBSERVE, MODE_POLL]
_attr_options = [MODE_OBSERVE, MODE_POLL] # noqa: RUF012 -- HA `_attr_*` convention
def __init__(self, coordinator: LocalThingsCoordinator) -> None:
super().__init__(coordinator)
+8 -7
View File
@@ -1,16 +1,16 @@
"""Switch platform for Local Things."""
from __future__ import annotations
from homeassistant.components.switch import SwitchEntity
from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .registry.entities import SwitchDesc
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
from .registry.entities import SwitchDesc
async def async_setup_entry(
@@ -27,18 +27,19 @@ async def async_setup_entry(
class LocalThingsSwitch(LocalThingsEntity, SwitchEntity):
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
super().__init__(coordinator, bound)
desc: SwitchDesc = bound.desc
self._attr_device_class = desc.device_class
self._attr_device_class = (
SwitchDeviceClass(desc.device_class) if desc.device_class else None
)
@property
def is_on(self):
return (self.coordinator.data or {}).get(self._state_key)
async def async_turn_on(self, **kwargs) -> None:
await self.coordinator.async_send_command(self._bound, 'On')
await self.coordinator.async_send_command(self._bound, "On")
async def async_turn_off(self, **kwargs) -> None:
await self.coordinator.async_send_command(self._bound, 'Off')
await self.coordinator.async_send_command(self._bound, "Off")
+2 -3
View File
@@ -1,4 +1,5 @@
"""Time platform for Local Things."""
from __future__ import annotations
import datetime
@@ -8,11 +9,10 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .registry.entities import TimeDesc
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
from .registry.entities import TimeDesc
async def async_setup_entry(
@@ -29,7 +29,6 @@ async def async_setup_entry(
class LocalThingsTime(LocalThingsEntity, TimeEntity):
@property
def native_value(self) -> datetime.time | None:
return (self.coordinator.data or {}).get(self._state_key)
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,9 @@
{
"entity": {
"binary_sensor": {
"auto_clean_running": {
"name": "Auto clean running"
},
"after_run_active": {
"name": "After run active"
},
@@ -10,12 +13,18 @@
"automatic_operation": {
"name": "Automatic operation"
},
"battery_charging": {
"name": "Charging"
},
"burner_hot_surface": {
"name": "Burner {number} hot surface"
},
"burner_pan_detected": {
"name": "Burner {number} pan detected"
},
"child_lock": {
"name": "Child lock"
},
"cloud_connected": {
"name": "Cloud connected"
},
@@ -34,6 +43,9 @@
"overload_protection_active": {
"name": "Overload protection active"
},
"odor_controller_active": {
"name": "Odor controller active"
},
"cycle_active": {
"name": "Running"
},
@@ -73,6 +85,9 @@
"pouring": {
"name": "Pouring"
},
"alarm_in_mute": {
"name": "Alarm in mute"
},
"power_state": {
"name": "Power state"
},
@@ -120,7 +135,8 @@
"state_attributes": {
"fan_mode": {
"state": {
"turbo": "Turbo"
"turbo": "Turbo",
"max": "Max"
}
},
"preset_mode": {
@@ -134,7 +150,8 @@
"longwind": "Long wind",
"motionindirect": "Motion indirect",
"motiondirect": "Motion direct",
"drycomfort": "Dry comfort"
"drycomfort": "Dry comfort",
"2step": "2-Step"
}
}
}
@@ -165,6 +182,9 @@
"dispense_capacity": {
"name": "Dispense capacity"
},
"good_sleep": {
"name": "Good Sleep"
},
"instance_setpoint": {
"name": "{instance_name} setpoint"
},
@@ -179,12 +199,21 @@
},
"target_humidity": {
"name": "Target humidity"
},
"tropical_night_mode": {
"name": "Tropical night mode"
},
"zone_target_temperature": {
"name": "Zone target temperature"
}
},
"select": {
"ai_energy_level": {
"name": "AI Energy Mode level"
},
"air_filter_threshold": {
"name": "Filter alarm threshold"
},
"beverage_zone_mode": {
"name": "Beverage zone mode",
"state": {
@@ -200,6 +229,9 @@
"100": "High"
}
},
"cooler_temperature_setpoint": {
"name": "Cooler temperature"
},
"day_brightness": {
"name": "Cabinet brightness",
"state": {
@@ -275,19 +307,25 @@
"01": "Normal",
"06": "Time dry",
"16": "Cotton",
"17": "Super Speed",
"18": "Synthetics",
"19": "Delicates",
"20": "Iron dry",
"23": "Quick Dry 35",
"21": "Hygiene Care",
"22": "Silent Dry",
"23": "Quick Dry 35'",
"24": "Cool air",
"25": "Warm air",
"27": "Time dry",
"29": "AI Dry",
"1a": "Wool",
"1b": "Bedding",
"1c": "Shirts",
"1d": "Towels",
"1e": "Outdoor",
"1f": "Mixed load"
"1f": "Mixed load",
"2b": "Self Tub Dry",
"4c": "Air Refresh"
}
},
"favorite_capacity": {
@@ -296,6 +334,15 @@
"favorite_hotwater_temperature": {
"name": "Favorite hot water temperature"
},
"filter_alarm_time": {
"name": "Filter alarm interval",
"state": {
"180": "180 hours",
"300": "300 hours",
"500": "500 hours",
"700": "700 hours"
}
},
"finish_sound": {
"name": "Finish sound",
"state": {
@@ -476,6 +523,14 @@
"buzzer": "Buzzer"
}
},
"water_purifier_sound_mode": {
"name": "Sound mode",
"state": {
"voice": "Voice",
"fixedtone": "Fixed tone",
"mute": "Mute"
}
},
"spin_speed": {
"name": "Spin speed",
"state": {
@@ -563,15 +618,27 @@
"none": "Off",
"cupboard": "Cupboard"
}
},
"zone_mode": {
"name": "Zone mode"
}
},
"sensor": {
"auto_clean_progress": {
"name": "Auto clean progress"
},
"after_run_progress": {
"name": "After run progress"
},
"air_filter_usage": {
"name": "Filter usage"
},
"air_filter_usage_hours": {
"name": "Filter usage hours"
},
"air_quality_standard": {
"name": "Air quality standard"
},
"air_sensing_state": {
"name": "Air sensing state"
},
@@ -584,12 +651,18 @@
"automatic_ventilation_state": {
"name": "Automatic ventilation state"
},
"battery": {
"name": "Battery"
},
"burner_state": {
"name": "Burner {number} state"
},
"clean_level": {
"name": "Clean level"
},
"co2": {
"name": "CO2"
},
"coffee_brew_status": {
"name": "Coffee brew status"
},
@@ -609,9 +682,18 @@
"current_limit_level": {
"name": "Current limit level"
},
"filter_time": {
"name": "Filter time"
},
"hepa_filter_usage": {
"name": "HEPA filter usage"
},
"outdoor_temperature": {
"name": "Outdoor temperature"
},
"odor_controller_progress": {
"name": "Odor controller progress"
},
"panel_status": {
"name": "Panel status"
},
@@ -646,6 +728,21 @@
"powersaving": "Power saving"
}
},
"absence_power_saving_mode": {
"name": "Absence power saving mode",
"state": {
"eco": "Eco",
"normal": "Normal",
"comfort": "Comfort"
}
},
"motion_detect_wind_mode": {
"name": "Motion-detect wind mode",
"state": {
"direct": "Direct",
"indirect": "Indirect"
}
},
"current_temp_c": {
"name": "Temperature"
},
@@ -736,9 +833,6 @@
"hood_filter_usage": {
"name": "Filter usage"
},
"humidity": {
"name": "Humidity"
},
"instance_temperature": {
"name": "{instance_name} temperature"
},
@@ -841,6 +935,15 @@
"waterpurifier_status": {
"name": "Status"
},
"cup_state": {
"name": "Cup state"
},
"last_pour_type": {
"name": "Last pour type"
},
"last_pour_capacity": {
"name": "Last pour capacity"
},
"machine_state": {
"name": "Machine state",
"state": {
@@ -863,18 +966,33 @@
"icestatus_stop": "Idle",
"icestatus_run": "Making ice"
}
},
"zone_temperature": {
"name": "Zone temperature"
}
},
"switch": {
"ai_energy_level": {
"name": "AI Energy Mode"
},
"air_monitoring": {
"name": "Air monitoring"
},
"air_purify": {
"name": "Air purification"
},
"auto_clean": {
"name": "Auto clean"
},
"absence_power_saving_active": {
"name": "Absence power saving active"
},
"motion_detect_wind_active": {
"name": "Motion-detect wind avoidance active"
},
"beep": {
"name": "Beep"
},
"display": {
"name": "Display"
},
@@ -887,6 +1005,9 @@
"dustbin_auto_close": {
"name": "Dustbin auto-close"
},
"spi": {
"name": "Purifying ion"
},
"uvc_intensive_mode": {
"name": "UV-C intensive mode"
},
@@ -926,6 +1047,9 @@
"display_light": {
"name": "Display light"
},
"dnd": {
"name": "Do not disturb"
},
"fast_preheat": {
"name": "Fast preheat"
},
@@ -935,6 +1059,9 @@
"favorite_coffee_enabled": {
"name": "Favorite coffee"
},
"filter_remind": {
"name": "Filter reminder"
},
"fridge_sound": {
"name": "Sound"
},
@@ -965,6 +1092,12 @@
"natural_steam": {
"name": "Natural steam"
},
"energy_saving": {
"name": "Energy saving"
},
"cooktop_on_alert": {
"name": "Cooktop-on alert"
},
"power_switch": {
"name": "Power"
},
@@ -977,6 +1110,9 @@
"rapid_fridge": {
"name": "Rapid fridge"
},
"remind_beep": {
"name": "End signal reminder"
},
"sabbath_mode": {
"name": "Sabbath mode"
},
@@ -994,9 +1130,21 @@
},
"wrinkle_prevent": {
"name": "Wrinkle prevent"
},
"zone_power": {
"name": "Zone power"
},
"away_mode": {
"name": "Away mode"
}
},
"time": {
"dnd_end": {
"name": "Do not disturb end"
},
"dnd_start": {
"name": "Do not disturb start"
},
"led_night_end": {
"name": "Door LED night end"
},
@@ -1009,6 +1157,11 @@
"night_start": {
"name": "Night light start"
}
},
"water_heater": {
"dhw": {
"name": "Hot water"
}
}
},
"config": {
@@ -1039,11 +1192,7 @@
},
"confirm_unknown_type": {
"title": "Appliance type not recognized",
"description": "This appliance reported oneUiVersion \"{one_ui_version}\", which isn't a recognized type. It'll still be added, but only with common capabilities (power, alarms, etc. where present) rather than the full set for its family. You can help add full support afterward by downloading diagnostics for this device (Settings > Devices & Services > this device > the menu > Download diagnostics) and filing them in a new issue. Submit to add it anyway."
},
"confirm_unknown_type_no_version": {
"title": "Appliance type not recognized",
"description": "This appliance did not report a oneUiVersion and its type couldn't be recognized. It'll still be added, but only with common capabilities (power, alarms, etc. where present) rather than the full set for its family. You can help add full support afterward by downloading diagnostics for this device (Settings > Devices & Services > this device > the menu > Download diagnostics) and filing them in a new issue. Submit to add it anyway."
"description": "This appliance's type couldn't be recognized. It'll still be added, but only with common capabilities (power, alarms, etc. where present) rather than the full set for its family. You can help add full support afterward by downloading diagnostics for this device (Settings > Devices & Services > this device > the menu > Download diagnostics) and filing them in a new issue. Submit to add it anyway."
}
},
"error": {
@@ -1060,15 +1209,16 @@
"init": {
"title": "LocalThings options",
"menu_options": {
"settings": "Remote control write settings",
"settings": "Device settings",
"debug_write": "Debug: write to a resource"
}
},
"settings": {
"title": "Remote control write settings",
"description": "Some devices accept certain writes (e.g. default detergent/softener dosing on a washer) even while reporting remote control off. By default, LocalThings blocks every write with a clear error whenever a device reports remote control off, rather than letting the device silently reject it. Only enable this if you've confirmed writes actually work on this device with remote control off -- otherwise you'll trade that clear error for a silent failure.",
"title": "Device settings",
"description": "Some devices accept certain writes (e.g. default detergent/softener dosing on a washer) even while reporting remote control off. By default, LocalThings blocks every write with a clear error whenever a device reports remote control off, rather than letting the device silently reject it. Only enable this if you've confirmed writes actually work on this device with remote control off -- otherwise you'll trade that clear error for a silent failure.\n\nEstimated finish time is recomputed from the device's remaining-time estimate on every poll, which can drift or get revised by a minute or two between updates. Raise the minimum-change value below to hold the sensor at its last reported value until the estimate moves by at least that many minutes, cutting down on history/logbook noise. Set it to 0 to report every computed change.",
"data": {
"bypass_remote_control_lock": "Allow writes even when remote control is reported off"
"bypass_remote_control_lock": "Allow writes even when remote control is reported off",
"finish_time_hysteresis_minutes": "Estimated finish -- minimum change (minutes)"
}
},
"debug_write": {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,9 @@
{
"entity": {
"binary_sensor": {
"auto_clean_running": {
"name": "Auto clean actief"
},
"after_run_active": {
"name": "Nadraaien actief"
},
@@ -10,11 +13,17 @@
"automatic_operation": {
"name": "Automatische werking"
},
"battery_charging": {
"name": "Opladen"
},
"burner_hot_surface": {
"name": "Brander {number} heet oppervlak"
},
"burner_pan_detected": {
"name": "Brander {number} pan gedetecteerd"
"name": "Pan gedetecteerd op brander {number}"
},
"child_lock": {
"name": "Kinderslot"
},
"cloud_connected": {
"name": "Verbonden met cloud"
@@ -23,7 +32,7 @@
"name": "Stofzak vol"
},
"cooktop_power": {
"name": "Kookplaat voeding"
"name": "Voeding kookplaat"
},
"cooktop_safety_shutoff_enabled": {
"name": "Automatische uitschakeling heet oppervlak ingeschakeld"
@@ -34,6 +43,9 @@
"overload_protection_active": {
"name": "Overbelastingsbeveiliging actief"
},
"odor_controller_active": {
"name": "Geurbeheersing actief"
},
"cycle_active": {
"name": "Actief"
},
@@ -73,6 +85,9 @@
"pouring": {
"name": "Schenken"
},
"alarm_in_mute": {
"name": "Alarmdemping"
},
"power_state": {
"name": "Voedingsstatus"
},
@@ -120,7 +135,8 @@
"state_attributes": {
"fan_mode": {
"state": {
"turbo": "Turbo"
"turbo": "Turbo",
"max": "Max"
}
},
"preset_mode": {
@@ -134,7 +150,8 @@
"longwind": "Lange wind",
"motionindirect": "Beweging indirect",
"motiondirect": "Beweging direct",
"drycomfort": "Droog comfort"
"drycomfort": "Droog comfort",
"2step": "2-Step"
}
}
}
@@ -165,6 +182,9 @@
"dispense_capacity": {
"name": "Schenkhoeveelheid"
},
"good_sleep": {
"name": "Good Sleep"
},
"instance_setpoint": {
"name": "Instelpunt {instance_name}"
},
@@ -179,12 +199,21 @@
},
"target_humidity": {
"name": "Doelvochtigheid"
},
"tropical_night_mode": {
"name": "Tropische nachtmodus"
},
"zone_target_temperature": {
"name": "Doeltemperatuur zone"
}
},
"select": {
"ai_energy_level": {
"name": "Niveau AI Energy Mode"
},
"air_filter_threshold": {
"name": "Filteralarmdrempel"
},
"beverage_zone_mode": {
"name": "Modus drankenzone",
"state": {
@@ -200,6 +229,9 @@
"100": "Hoog"
}
},
"cooler_temperature_setpoint": {
"name": "Temperatuur koelgedeelte"
},
"day_brightness": {
"name": "Helderheid kast",
"state": {
@@ -275,19 +307,25 @@
"01": "Normaal",
"06": "Tijdprogramma",
"16": "Katoen",
"17": "Super Speed",
"18": "Synthetisch",
"19": "Fijne was",
"20": "Strijkdroog",
"21": "Hygiënisch drogen",
"22": "Stil drogen",
"23": "Snel drogen 35'",
"24": "Koude lucht",
"25": "Warme lucht",
"27": "Tijdprogramma",
"29": "AI drogen",
"1a": "Wol",
"1b": "Beddengoed",
"1c": "Overhemden",
"1d": "Handdoeken",
"1e": "Outdoor",
"1f": "Gemengde was"
"1f": "Gemengde was",
"2b": "Zelf trommel drogen",
"4c": "Opfrissen"
}
},
"favorite_capacity": {
@@ -296,6 +334,15 @@
"favorite_hotwater_temperature": {
"name": "Favoriete temperatuur warm water"
},
"filter_alarm_time": {
"name": "Filteralarminterval",
"state": {
"180": "180 uur",
"300": "300 uur",
"500": "500 uur",
"700": "700 uur"
}
},
"finish_sound": {
"name": "Eindgeluid",
"state": {
@@ -464,18 +511,26 @@
"sound_mode": {
"name": "Geluidsmodus",
"state": {
"voice": "Spraak",
"voice": "Stem",
"tone": "Toon",
"mute": "Dempen"
"mute": "Stil"
}
},
"air_purifier_sound_mode": {
"name": "Geluidsmodus",
"state": {
"mute": "Dempen",
"mute": "Stil",
"buzzer": "Zoemer"
}
},
"water_purifier_sound_mode": {
"name": "Geluidsmodus",
"state": {
"voice": "Stem",
"fixedtone": "Vaste toon",
"mute": "Stil"
}
},
"spin_speed": {
"name": "Centrifugesnelheid",
"state": {
@@ -536,17 +591,17 @@
"53": "Intensief",
"54": "Handdoeken",
"55": "Sportkleding",
"57": "Fijn",
"57": "Fijne was",
"5e": "Spoelen+centrifugeren",
"60": "Zelfreinigend+",
"60": "Self Clean+",
"65": "Bonte was",
"66": "Spijkergoed",
"7c": "Witte was",
"7d": "Beddengoed/Waterdicht",
"7e": "Zelfreinigend",
"7f": "Wol/Fijn",
"7d": "Beddengoed/waterdicht",
"7e": "Self Clean",
"7f": "Wol/fijne was",
"86": "Diep wassen",
"87": "Downloaden",
"87": "Gedownload",
"8f": "Intensief koud",
"96": "Minder microvezels"
}
@@ -563,15 +618,27 @@
"none": "Uit",
"cupboard": "Kastdroog"
}
},
"zone_mode": {
"name": "Zonemodus"
}
},
"sensor": {
"auto_clean_progress": {
"name": "Auto clean voortgang"
},
"after_run_progress": {
"name": "Voortgang nadraaien"
},
"air_filter_usage": {
"name": "Filterverbruik"
},
"air_filter_usage_hours": {
"name": "Filterverbruik (uren)"
},
"air_quality_standard": {
"name": "Luchtkwaliteitsnorm"
},
"air_sensing_state": {
"name": "Status luchtmeting"
},
@@ -584,12 +651,18 @@
"automatic_ventilation_state": {
"name": "Status automatische ventilatie"
},
"battery": {
"name": "Batterij"
},
"burner_state": {
"name": "Status brander {number}"
},
"clean_level": {
"name": "Reinigingsniveau"
},
"co2": {
"name": "CO2"
},
"coffee_brew_status": {
"name": "Koffiezetstatus"
},
@@ -601,7 +674,7 @@
}
},
"cooktop_running_state": {
"name": "Loopstatus kookplaat"
"name": "Bedrijfsstatus kookplaat"
},
"cooktop_state": {
"name": "Status kookplaat"
@@ -609,9 +682,18 @@
"current_limit_level": {
"name": "Niveau stroombegrenzing"
},
"filter_time": {
"name": "Filtertijd"
},
"hepa_filter_usage": {
"name": "HEPA-filtergebruik"
},
"outdoor_temperature": {
"name": "Buitentemperatuur"
},
"odor_controller_progress": {
"name": "Voortgang geurbeheersing"
},
"panel_status": {
"name": "Paneelstatus"
},
@@ -628,10 +710,10 @@
"name": "Status steel"
},
"uvc_operation_time": {
"name": "UV-C bedrijfstijd"
"name": "UV-C-bedrijfstijd"
},
"uvc_total_operation_time": {
"name": "UV-C totale bedrijfstijd"
"name": "Totale UV-C-bedrijfstijd"
},
"uvc_finished_time": {
"name": "UV-C voltooid op"
@@ -646,6 +728,21 @@
"powersaving": "Energiebesparing"
}
},
"absence_power_saving_mode": {
"name": "Modus energiebesparing bij afwezigheid",
"state": {
"eco": "Eco",
"normal": "Normaal",
"comfort": "Comfort"
}
},
"motion_detect_wind_mode": {
"name": "Modus luchtstroomvermijding",
"state": {
"direct": "Direct",
"indirect": "Indirect"
}
},
"current_temp_c": {
"name": "Temperatuur"
},
@@ -710,7 +807,7 @@
"name": "Geschatte eindtijd"
},
"completion_minutes": {
"name": "Voltooiingstijd"
"name": "Resterende tijd"
},
"freezer_temperature": {
"name": "Vriezertemperatuur"
@@ -736,9 +833,6 @@
"hood_filter_usage": {
"name": "Filterverbruik"
},
"humidity": {
"name": "Luchtvochtigheid"
},
"instance_temperature": {
"name": "Temperatuur {instance_name}"
},
@@ -794,13 +888,13 @@
"name": "Vermogen"
},
"probe_battery": {
"name": "Sonde batterij"
"name": "Batterijniveau sonde"
},
"probe_target_temperature": {
"name": "Doeltemperatuur sonde"
},
"probe_temperature": {
"name": "Temperatuur sonde"
"name": "Sondetemperatuur"
},
"progress": {
"name": "Voortgang"
@@ -841,6 +935,15 @@
"waterpurifier_status": {
"name": "Status"
},
"cup_state": {
"name": "Bekerstatus"
},
"last_pour_type": {
"name": "Laatste schenktype"
},
"last_pour_capacity": {
"name": "Laatste schenkhoeveelheid"
},
"machine_state": {
"name": "Machinestatus",
"state": {
@@ -863,18 +966,33 @@
"icestatus_stop": "Inactief",
"icestatus_run": "IJs maken"
}
},
"zone_temperature": {
"name": "Zonetemperatuur"
}
},
"switch": {
"ai_energy_level": {
"name": "AI Energy Mode"
},
"air_monitoring": {
"name": "Luchtmonitoring"
},
"air_purify": {
"name": "Luchtzuivering"
},
"auto_clean": {
"name": "Automatisch reinigen"
},
"absence_power_saving_active": {
"name": "Energiebesparing bij afwezigheid actief"
},
"motion_detect_wind_active": {
"name": "Bewegingsdetectie luchtstroomvermijding actief"
},
"beep": {
"name": "Piep"
},
"display": {
"name": "Display"
},
@@ -887,6 +1005,9 @@
"dustbin_auto_close": {
"name": "Stofreservoir automatisch sluiten"
},
"spi": {
"name": "Ionisatie"
},
"uvc_intensive_mode": {
"name": "Intensieve UV-C-modus"
},
@@ -926,6 +1047,9 @@
"display_light": {
"name": "Displayverlichting"
},
"dnd": {
"name": "Niet storen"
},
"fast_preheat": {
"name": "Snel voorverwarmen"
},
@@ -935,6 +1059,9 @@
"favorite_coffee_enabled": {
"name": "Favoriete koffie"
},
"filter_remind": {
"name": "Filterherinnering"
},
"fridge_sound": {
"name": "Geluid"
},
@@ -951,7 +1078,7 @@
"name": "{instance_name} ingeschakeld"
},
"intensive": {
"name": "Intensive"
"name": "Intensief"
},
"lamp": {
"name": "Lamp"
@@ -965,6 +1092,12 @@
"natural_steam": {
"name": "Natuurlijke stoom"
},
"energy_saving": {
"name": "Energiebesparing"
},
"cooktop_on_alert": {
"name": "Waarschuwing kookplaat aan"
},
"power_switch": {
"name": "Voeding"
},
@@ -977,6 +1110,9 @@
"rapid_fridge": {
"name": "Snelkoelen"
},
"remind_beep": {
"name": "Eindsignaal herinnering"
},
"sabbath_mode": {
"name": "Sabbatmodus"
},
@@ -994,9 +1130,21 @@
},
"wrinkle_prevent": {
"name": "Kreukpreventie"
},
"zone_power": {
"name": "Zonevoeding"
},
"away_mode": {
"name": "Afwezigheidsmodus"
}
},
"time": {
"dnd_end": {
"name": "Einde niet storen"
},
"dnd_start": {
"name": "Start niet storen"
},
"led_night_end": {
"name": "Einde nachtverlichting deur-LED"
},
@@ -1009,6 +1157,11 @@
"night_start": {
"name": "Start nachtverlichting"
}
},
"water_heater": {
"dhw": {
"name": "Warm water"
}
}
},
"config": {
@@ -1039,11 +1192,7 @@
},
"confirm_unknown_type": {
"title": "Apparaattype niet herkend",
"description": "Dit apparaat meldt oneUiVersion \"{one_ui_version}\", wat niet als apparaattype wordt herkend. Het apparaat wordt toch toegevoegd, maar alleen met algemene mogelijkheden (zoals voeding en alarmen, voor zover aanwezig), in plaats van alle mogelijkheden voor deze apparaatfamilie. Je kunt daarna helpen volledige ondersteuning toe te voegen door diagnostische gegevens voor dit apparaat te downloaden (Instellingen > Apparaten & diensten > dit apparaat > het menu > Diagnostische gegevens downloaden) en deze bij een nieuw issue te voegen. Kies Verzenden om het apparaat toch toe te voegen."
},
"confirm_unknown_type_no_version": {
"title": "Apparaattype niet herkend",
"description": "Dit apparaat meldt geen oneUiVersion en het apparaattype kon niet worden herkend. Het apparaat wordt toch toegevoegd, maar alleen met algemene mogelijkheden (zoals voeding en alarmen, voor zover aanwezig), in plaats van alle mogelijkheden voor deze apparaatfamilie. Je kunt daarna helpen volledige ondersteuning toe te voegen door diagnostische gegevens voor dit apparaat te downloaden (Instellingen > Apparaten & diensten > dit apparaat > het menu > Diagnostische gegevens downloaden) en deze bij een nieuw issue te voegen. Kies Verzenden om het apparaat toch toe te voegen."
"description": "Het apparaattype van dit apparaat kon niet worden herkend. Het apparaat wordt toch toegevoegd, maar alleen met algemene mogelijkheden (zoals voeding en alarmen, voor zover aanwezig), in plaats van alle mogelijkheden voor deze apparaatfamilie. Je kunt daarna helpen volledige ondersteuning toe te voegen door diagnostische gegevens voor dit apparaat te downloaden (Instellingen > Apparaten & diensten > dit apparaat > het menu > Diagnostische gegevens downloaden) en deze bij een nieuw issue te voegen. Kies Verzenden om het apparaat toch toe te voegen."
}
},
"error": {
@@ -1060,15 +1209,16 @@
"init": {
"title": "LocalThings-opties",
"menu_options": {
"settings": "Schrijfinstellingen voor afstandsbediening",
"settings": "Apparaatinstellingen",
"debug_write": "Foutopsporing: naar een resource schrijven"
}
},
"settings": {
"title": "Schrijfinstellingen voor afstandsbediening",
"description": "Sommige apparaten accepteren bepaalde schrijfbewerkingen (bijvoorbeeld de standaarddosering van wasmiddel of wasverzachter op een wasmachine), ook als ze melden dat de afstandsbediening is uitgeschakeld. LocalThings blokkeert standaard elke schrijfbewerking met een duidelijke foutmelding wanneer een apparaat meldt dat de afstandsbediening is uitgeschakeld, in plaats van het apparaat de opdracht stilzwijgend te laten weigeren. Schakel deze optie alleen in als je hebt bevestigd dat schrijfbewerkingen op dit apparaat echt werken wanneer de afstandsbediening is uitgeschakeld. Anders verruil je de duidelijke foutmelding voor een stille mislukking.",
"title": "Apparaatinstellingen",
"description": "Sommige apparaten accepteren bepaalde schrijfbewerkingen (bijvoorbeeld de standaarddosering van wasmiddel of wasverzachter op een wasmachine), ook als ze melden dat de afstandsbediening is uitgeschakeld. LocalThings blokkeert standaard elke schrijfbewerking met een duidelijke foutmelding wanneer een apparaat meldt dat de afstandsbediening is uitgeschakeld, in plaats van het apparaat de opdracht stilzwijgend te laten weigeren. Schakel deze optie alleen in als je hebt bevestigd dat schrijfbewerkingen op dit apparaat echt werken wanneer de afstandsbediening is uitgeschakeld. Anders verruil je de duidelijke foutmelding voor een stille mislukking.\n\nDe geschatte eindtijd wordt bij elke poll opnieuw berekend op basis van de resterende tijd die het apparaat opgeeft, wat kan afwijken of met een minuut of wat worden bijgesteld tussen updates. Verhoog de minimale wijziging hieronder om de sensor op zijn laatst gerapporteerde waarde te houden totdat de schatting met minstens dat aantal minuten verandert, wat de ruis in geschiedenis/logboek vermindert. Zet op 0 om elke berekende wijziging te rapporteren.",
"data": {
"bypass_remote_control_lock": "Schrijfbewerkingen toestaan wanneer afstandsbediening als uitgeschakeld wordt gemeld"
"bypass_remote_control_lock": "Schrijfbewerkingen toestaan wanneer afstandsbediening als uitgeschakeld wordt gemeld",
"finish_time_hysteresis_minutes": "Geschatte eindtijd -- minimale wijziging (minuten)"
}
},
"debug_write": {
@@ -0,0 +1,280 @@
"""Water heater platform for Local Things.
Second composite entity in this integration (see climate.py's module
docstring for the general pattern this follows): a single HA water_heater
card for a Samsung EHS heat pump's domestic hot water (DHW) loop. It binds
the primary `WaterHeaterDesc` (the `/mode/dhw/vs/0` capability, DHW.entities
in registry/capabilities/ehs.py) so the registry still tracks it, and reads
the sibling `/power/dhw/vs/0` and `/temperatures/dhw/vs/0` resources straight
from the coordinator snapshot -- the same cross-resource read climate.py uses
for the AC's power/temperature/wind siblings.
Writes go through `coordinator.async_send_command(bound, (kind, value))`:
DHW's `write_fn` (ehs._dhw_write) maps each `(kind, value)` payload to the
right `(path_segs, body)`, and `async_send_command` POSTs to those path_segs
and applies the optimistic value/settle guard to that same href -- not the
bound `/mode/dhw/vs/0` href -- so one descriptor drives writes to, and gets
fresh state back for, power, mode and temperature alike.
Operation-mode vocabulary: the DHW loop's four device modes (Eco/Std/Force/
Power) map onto HA's own standard water_heater states -- the same mapping
Home Assistant's core `smartthings` integration uses for this exact Samsung
capability over the cloud API (`samsungce.ehsThermostat` /
`airConditionerMode`: eco/std/force/power -> STATE_ECO/STATE_HEAT_PUMP/
STATE_HIGH_DEMAND/STATE_PERFORMANCE), just title-cased to match this OCF
resource's own code spelling. Reusing HA's standard states means no *state*
translation catalog entry is needed for them (see the entity_component
fallback in homeassistant.components.water_heater.strings.json).
Naming is a separate question from that, and the answer here differs from
climate.py's: the AC *is* the device, so its climate card takes the bare
device name (`_attr_name = None`). An EHS unit has two loops, and the DHW
one is not "the device" -- its siblings are named "Zone Mode"/"Zone Target
Temperature", so a card labelled just "EHS" would misrepresent which loop
it drives. This entity is named through the catalog like every other
descriptor here, via the DHW descriptor's `translation_key='dhw'`
(entity.water_heater.dhw.name -> "Hot water").
"""
from __future__ import annotations
import logging
from homeassistant.components.water_heater import (
STATE_ECO,
STATE_HEAT_PUMP,
STATE_HIGH_DEMAND,
STATE_PERFORMANCE,
WaterHeaterEntity,
WaterHeaterEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_OFF, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
from .registry.capabilities.common import normalize_temp_unit
from .registry.capabilities.ehs import (
HREF_DHW_MODE as MODE_HREF,
)
from .registry.capabilities.ehs import (
HREF_DHW_POWER as POWER_HREF,
)
from .registry.capabilities.ehs import (
HREF_DHW_TEMPERATURE as TEMPERATURE_HREF,
)
from .registry.entities import WaterHeaterDesc
_LOGGER = logging.getLogger(__name__)
_MODES_FIELD = "x.com.samsung.da.modes"
_SUPPORTED_FIELD = "x.com.samsung.da.supportedModes"
# Device mode <-> HA water_heater operation state -- see the module
# docstring above for the SmartThings-cloud precedent this mirrors.
_DEVICE_TO_STATE: dict[str, str] = {
"Eco": STATE_ECO,
"Std": STATE_HEAT_PUMP,
"Force": STATE_HIGH_DEMAND,
"Power": STATE_PERFORMANCE,
}
_STATE_TO_DEVICE = {v: k for k, v in _DEVICE_TO_STATE.items()}
# Read-side lookup, case-folded. climate.py resolves write codes from the
# unit's own supportedModes because two spellings there mean one HA value
# ('Wind'/'Fan' -> FAN_ONLY); this map is bijective, so the write side can
# use _STATE_TO_DEVICE directly. Only the read side is exposed to a board
# spelling the same code differently ('eco'/'ECO'), and case is the one
# variation worth absorbing rather than warning about.
_DEVICE_TO_STATE_CI = {k.lower(): v for k, v in _DEVICE_TO_STATE.items()}
def _to_state(code) -> str | None:
if code is None:
return None
return _DEVICE_TO_STATE_CI.get(str(code).lower())
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
LocalThingsWaterHeater(coordinator, b)
for b in coordinator.bound
if isinstance(b.desc, WaterHeaterDesc) and _is_included(b, coordinator)
)
def _num(value):
try:
return float(value)
except (TypeError, ValueError):
return None
def _first(value):
"""Samsung `modes` is a single-element list on this resource. Return the
first element of a list, else the value itself."""
if isinstance(value, (list, tuple)):
return value[0] if value else None
return value
class LocalThingsWaterHeater(LocalThingsEntity, WaterHeaterEntity):
"""Composite water_heater entity for a Samsung EHS DHW loop."""
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
super().__init__(coordinator, bound)
# No _attr_name here: unlike climate.py's AC, this is one loop of a
# two-loop device and takes a catalog name ("Hot water") through the
# descriptor's translation_key -- see the module docstring.
self._attr_supported_features = (
WaterHeaterEntityFeature.TARGET_TEMPERATURE
| WaterHeaterEntityFeature.OPERATION_MODE
| WaterHeaterEntityFeature.ON_OFF
)
# Raw device codes already logged by _warn_unmapped -- these
# properties are read on every coordinator refresh, so an un-deduped
# warning would spam the log for any unit reporting a genuinely
# unrecognized code.
self._warned_unmapped: set[str] = set()
def _rep(self, href: str) -> dict:
"""`href` is one of this module's canonical HREF_* constants --
translated through this bound entity's own subdevice (issue #177),
same as climate.py's identical helper."""
return self.coordinator.resource(self._bound.subdevice.to_actual(href)) or {}
def _is_on(self) -> bool:
return str(self._rep(POWER_HREF).get("x.com.samsung.da.power", "")).lower() == "on"
def _supported(self) -> list[str]:
return list(self._rep(MODE_HREF).get(_SUPPORTED_FIELD) or [])
def _warn_unmapped(self, code: str) -> None:
if code in self._warned_unmapped:
return
self._warned_unmapped.add(code)
_LOGGER.warning(
"%s: device DHW mode %r has no HA mapping and was dropped; "
"please file an issue with your diagnostics dump",
self.entity_id,
code,
)
# -- temperature --------------------------------------------------------
@property
def temperature_unit(self) -> str:
raw = self._rep(TEMPERATURE_HREF).get("x.com.samsung.da.unit")
return (
UnitOfTemperature.FAHRENHEIT
if normalize_temp_unit(raw, "°C") == "°F"
else UnitOfTemperature.CELSIUS
)
@property
def current_temperature(self):
return _num(self._rep(TEMPERATURE_HREF).get("x.com.samsung.da.current"))
@property
def target_temperature(self):
return _num(self._rep(TEMPERATURE_HREF).get("x.com.samsung.da.desired"))
def _range(self) -> list | None:
"""The device's own (minimum, maximum) pair, or None.
Both ends together or neither, deliberately -- same rule as
climate._range(). A board reporting minimum but not maximum would
otherwise pair a device minimum (40) with HA's own default maximum
(140 °F), which looks plausible and is silently wrong on a unit
that really allows 62.
"""
rep = self._rep(TEMPERATURE_HREF)
lo = _num(rep.get("x.com.samsung.da.minimum"))
hi = _num(rep.get("x.com.samsung.da.maximum"))
return [lo, hi] if (lo is not None and hi is not None) else None
@property
def min_temp(self) -> float:
r = self._range()
return r[0] if r else super().min_temp
@property
def max_temp(self) -> float:
r = self._range()
return r[1] if r else super().max_temp
@property
def target_temperature_step(self) -> float:
# `is None`, not `or` -- see issue #160: `or` collapses a genuine 0
# into the fallback.
step = _num(self._rep(TEMPERATURE_HREF).get("x.com.samsung.da.increment"))
return 0.5 if step is None else step
# -- operation mode -------------------------------------------------------
@property
def current_operation(self) -> str | None:
if not self._is_on():
return STATE_OFF
code = _first(self._rep(MODE_HREF).get(_MODES_FIELD))
mapped = _to_state(code)
if code is not None and mapped is None:
self._warn_unmapped(code)
return mapped
@property
def operation_list(self) -> list[str]:
modes = [STATE_OFF]
for code in self._supported():
mapped = _to_state(code)
if mapped is None:
self._warn_unmapped(code)
continue
if mapped not in modes:
modes.append(mapped)
return modes
# -- writes ---------------------------------------------------------------
async def async_set_temperature(self, **kwargs) -> None:
# HA's water_heater.set_temperature service takes an optional
# operation_mode and forwards it here (SET_TEMPERATURE_SCHEMA), same
# as climate.set_temperature does with hvac_mode. Honour it, and set
# it first -- that also powers the loop on when it was off -- so a
# dashboard "boost to 55" button that carries a mode actually changes
# mode, instead of only moving the setpoint. Same fix as the AC's
# (see climate.async_set_temperature).
operation_mode = kwargs.get("operation_mode")
if operation_mode is not None:
await self.async_set_operation_mode(operation_mode)
if operation_mode == STATE_OFF:
return
temp = kwargs.get("temperature")
if temp is None:
return
await self.coordinator.async_send_command(self._bound, ("temperature", temp))
async def async_set_operation_mode(self, operation_mode: str) -> None:
if operation_mode == STATE_OFF:
await self.coordinator.async_send_command(self._bound, ("power", False))
return
device = _STATE_TO_DEVICE.get(operation_mode)
if device is None:
return
if not self._is_on():
await self.coordinator.async_send_command(self._bound, ("power", True))
await self.coordinator.async_send_command(self._bound, ("mode", device))
async def async_turn_on(self, **kwargs) -> None:
await self.coordinator.async_send_command(self._bound, ("power", True))
async def async_turn_off(self, **kwargs) -> None:
await self.coordinator.async_send_command(self._bound, ("power", False))
+36
View File
@@ -1,2 +1,38 @@
[project]
requires-python = ">=3.13"
[tool.pytest.ini_options]
asyncio_mode = "auto"
[tool.ruff]
target-version = "py313"
line-length = 100
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"F", # pyflakes
"W", # pycodestyle warnings
"I", # isort
"UP", # pyupgrade
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"SIM", # flake8-simplify
"RUF", # ruff-specific
"ASYNC", # flake8-async
"LOG", # flake8-logging
"G", # flake8-logging-format
"PIE", # flake8-pie
"RET", # flake8-return
"PERF", # perflint
"N", # pep8-naming
]
ignore = [
"N818", # exception name doesn't need an Error suffix in this codebase
]
[tool.ruff.lint.isort]
known-first-party = ["custom_components"]
[tool.ty.environment]
python-version = "3.13"
+7 -2
View File
@@ -1,6 +1,7 @@
# Test harness — pulls in home-assistant, pytest, and pytest-socket at the
# matching versions. Current Home Assistant requires Python >= 3.14; pip will
# resolve the newest home-assistant your interpreter supports.
# matching versions. pip resolves the newest home-assistant your interpreter
# supports: Python 3.13 gets 0.13.316 (the floor below), 3.14 gets newer. On
# 3.12 or older nothing resolves and the whole install fails.
pytest-homeassistant-custom-component>=0.13.316
# Integration runtime deps, needed to import the component under test
@@ -9,3 +10,7 @@ smartthings-local>=0.1.0
cbor2>=5.4.6
pyOpenSSL>=23.0
cryptography>=41.0
# Lint / format / type-check tooling, pinned to match CI.
ruff==0.16.1
ty==0.0.65
+110 -8
View File
@@ -3,24 +3,113 @@ from pathlib import Path
import pytest
FIXTURES = Path(__file__).resolve().parent / 'fixtures'
FIXTURES = Path(__file__).resolve().parent / "fixtures"
def _resources_from_dump(dump: dict) -> dict[str, dict]:
from custom_components.localthings.registry.batch import parse_device0_batch
return parse_device0_batch(dump['device0'])
return parse_device0_batch(dump["device0"])
def _load_device(name: str) -> dict[str, dict]:
data = json.loads((FIXTURES / f'{name}_device.json').read_text())
data = json.loads((FIXTURES / f"{name}_device.json").read_text())
return _resources_from_dump(data)
def _load_device_full(name: str):
"""Like _load_device, but also returns the optional `oic_res`/`seeds`
keys a subdevice-capable fixture (issue #177) may carry alongside
`device0` -- see the two `airconditioner_*` fixtures with a
`seeds_note` field. `oic_res`/`seeds` default to `[]`/`{}` for every
other fixture, so this is safe to call on any fixture in the corpus.
Returns `(resources, oic_res, seeds)` where `seeds` is
`{seed_href: raw_batch_list}` -- the same [devcol-rep, {href, rep}, ...]
shape a real /device/<n> or /<id>/device/0 RETRIEVE returns, ready to
hand to a FakeCoapSession.
A fixture's optional `probes` map (plain Property-map resources that
belong to no batch, e.g. the hand-read /multidevice/vs/0 in the
ARTIK051_DONGLE_FAC_18K fixture) is folded into `seeds` here, since
FakeCoapSession answers both shapes off the same href key.
"""
data = json.loads((FIXTURES / f"{name}_device.json").read_text())
resources = _resources_from_dump(data)
seeds = {**data.get("seeds", {}), **data.get("probes", {})}
return resources, data.get("oic_res", []), seeds
class FakeCoapSession:
"""Minimal stand-in for smartthings_local's DtlsCoapSession, backed by a
fixture's `seeds` map (raw device0-batch-shaped lists keyed by seed
href -- plus any `probes` entries, which are plain Property maps rather
than batch lists; both are just CBOR bodies at this layer, and the two
readers in registry.subdevices already type-check what they get back).
Enough surface for registry.subdevices.enumerate_subdevices and
LocalThingsCoordinator's blocking subdevice polls to run against fixture
data without a live device -- same idea as test_identity.py's
FakeSession, but keyed by href string (post path-join) rather than a
path tuple, since callers here pass a `seed_path` tuple straight
through.
"""
def __init__(self, seeds: dict[str, list] | None = None):
self.seeds = seeds or {}
def get(self, path, timeout=None):
href = "/" + "/".join(path)
body = self.seeds.get(href)
if body is None:
return 0x84, b"" # 4.04 not found -- tolerated absence
import cbor2
return 0x45, cbor2.dumps(body)
def pace(self):
pass
def _discover_full(resources: dict[str, dict], oic_res, seeds: dict[str, list]):
"""Run the *whole* subdevice-aware discovery pipeline against fixture
data, HA-free -- mirrors exactly what LocalThingsCoordinator does across
_enumerate_subdevices_blocking + _run_discovery (issue #177), so a test
exercising this exercises the real code path, not a re-implementation of
it. See the adding-device-support skill's section 2 for the plain
(non-subdevice) equivalent this extends.
Returns `(bound, materialized, skipped, full_resources, device_type_name)`:
- `bound`: every BoundEntity, main + every materialized subdevice.
- `materialized`/`skipped`: Subdevice / SkippedSubdevice lists straight from
discover_partitioned.
- `full_resources`: `resources` merged with every candidate's seed data
(actual hrefs) -- what a coordinator's cache would hold.
- `device_type_name`: the master's resolved registry name.
"""
from custom_components.localthings.registry.by_type import resolve
from custom_components.localthings.registry.registry import CAPABILITIES
from custom_components.localthings.registry.subdevices import (
discover_partitioned,
enumerate_subdevices,
)
sess = FakeCoapSession(seeds)
candidates, extra = enumerate_subdevices(sess, resources, oic_res)
full_resources = {**resources, **extra}
bound, device_type_name, materialized, skipped = discover_partitioned(
full_resources,
candidates,
resolve,
CAPABILITIES,
)
return bound, materialized, skipped, full_resources, device_type_name
def _load_resources(ip: str) -> dict[str, dict]:
"""Legacy IP-based loader — maps known IPs to named fixtures."""
_ip_to_name = {
'10.0.0.129': 'dishwasher',
'10.0.0.254': 'refrigerator',
"10.0.0.129": "dishwasher",
"10.0.0.254": "refrigerator",
}
name = _ip_to_name.get(ip)
if name is None:
@@ -30,14 +119,27 @@ def _load_resources(ip: str) -> dict[str, dict]:
@pytest.fixture
def dishwasher_resources() -> dict[str, dict]:
return _load_device('dishwasher')
return _load_device("dishwasher")
@pytest.fixture
def fridge_resources() -> dict[str, dict]:
return _load_device('refrigerator')
return _load_device("refrigerator")
@pytest.fixture
def washer_resources() -> dict[str, dict]:
return _load_device('washer')
return _load_device("washer")
@pytest.fixture
def all_device_fixtures() -> dict[str, dict[str, dict]]:
"""Every scrubbed device dump, keyed by fixture name.
For invariants that must hold across the whole corpus rather than for one
device -- so a newly added dump exercises them automatically.
"""
return {
path.name[: -len("_device.json")]: _resources_from_dump(json.loads(path.read_text()))
for path in sorted(FIXTURES.glob("*_device.json"))
}
+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": {}
}
]
}
+414
View File
@@ -0,0 +1,414 @@
{
"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": {}
},
{
"href": "/alarms/vs/0",
"rep": {}
},
{
"href": "/buzzersound/vs/0",
"rep": {
"supportedBuzzerSound": [
"Off",
"On"
],
"setBuzzerSound": "On"
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "1117000000",
"x.com.samsung.da.countryCode": "KR"
}
},
{
"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": "/course/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"HOMECARE_WIZARD_V2"
],
"x.com.samsung.da.options": [
"DeviceType_015E",
"UpdateAllow_NotAllowed",
"Course_01",
"AiOption_On",
"SteamPush_None",
"SeamlessControl_Disable",
"KidsLockBypass_On",
"FilterUsage_384001D4",
"WashingTimes_2",
"DrumCleanProposal_30",
"SpecialFunction_16",
"AvailableDelayTime_0",
"ProgressTimeSet_AA012CB20348",
"WrinklePreventRunning_Off",
"SendToDevice_Off",
"WeatherPush_On",
"GMT_12",
"DelayEndSet_0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0FF0F0F0F0F0F0F0F00F0F0F0F0F",
"EnergyLevelSet_05020404010103040301020203020101040505050503030303030303050204050405",
"WrinklePreventSet_0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0FF00F0F0F0F0F0F0F0F0F0F0FF0F0",
"SavingModeCondition_01010205272B2C2D2E0300",
"SavingMode_Off",
"UsagesDB_ok",
"DrumCleanLog_Empty"
],
"x.com.samsung.da.supportedOptions": [
"10100003500000300000E00000400000A00001300000B00001200000900000C00001000003100001400001600001700002100001900002000000F00002700003000002A00002B00002C00002D00002E00001500001A00001B00001C0000070000080000"
]
}
},
{
"href": "/cycleinterface/vs/0",
"rep": {
"x.com.samsung.da.cycleInterfaceEnabled": "Off"
}
},
{
"href": "/diagnosis/vs/0",
"rep": {
"x.com.samsung.da.diagnosisStart": "Ready"
}
},
{
"href": "/drlc/0",
"rep": {
"DRLevel": 0,
"start": "0000-00-00T00:00:00Z",
"duration": 0,
"override": false
}
},
{
"href": "/drlc/vs/0",
"rep": {
"x.com.samsung.da.drlcLevel": "0",
"x.com.samsung.da.durationminutes": "0",
"x.com.samsung.da.start": "0000-00-00T00:00:00Z",
"x.com.samsung.da.override": "Off",
"x.com.samsung.da.realSaving": "Off"
}
},
{
"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": "2900",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.cumulativeDate": "1785362400",
"x.com.samsung.da.cumulativeDateUTC": "1785330000",
"x.com.samsung.da.cumulativeSavedPower": "0"
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+00:00"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "DA_DF_TP1_21_COMMON|20313841|380101020016111F42C3000200010000",
"x.com.samsung.da.description": "DA_DF_TP1_21_COMMON_DF3000B/DC92-03252A_0002",
"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": "WA0",
"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": "DA_DF_TP1_21_COMMON|20313841|380101020016111F42C3000200010000",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "03101A260513(A440)",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Firmware_1_DB_20313841241127210FFFFF203252412402215804FFFF(015E2031384120325241_30241204)(FileDown:0)(Type:0)",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "03138A24112721,03252A24022158",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Firmware_2_DB_2031574122122007043FFF2032114122122704042FFF(015E2031574120321141_30000000)(FileDown:0)(Type:0)",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "03157A22122007,03211A22122704"
},
{
"x.com.samsung.da.id": "3",
"x.com.samsung.da.description": "Firmware_3_DB_20303242000000020AFFFFFFFFFFFFFFFFFFFFFFFFFF(015E20303242FFFFFFFF_30000000)(FileDown:0)(Type:0)",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "03032B00000002,FFFFFFFFFFFFFF"
}
]
}
},
{
"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:35:00",
"progressPercentage": "1"
}
},
{
"href": "/operational/state/vs/0",
"rep": {
"x.com.samsung.da.state": "Ready",
"x.com.samsung.da.remainingTime": "00:35: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": "",
"x.com.samsung.da.newVersionAvailable": "false",
"x.com.samsung.da.newVersionNo": "00000000",
"x.com.samsung.da.currentVersionInfo": "00000000",
"otnStatus": "None",
"flashingProgress": "",
"otnTarget": "main",
"otnCompleteDate": "2026-07-07",
"otnList": [
{
"type": "WIFI",
"modelId": "DA_DF_TP1_21_COMMON",
"versions": [
"30260513"
],
"visVersion": "260513"
},
{
"type": "Micom",
"modelId": "015E2031384120325241",
"versions": [
"24112721",
"24022158"
],
"visVersion": "241127"
},
{
"type": "Micom",
"modelId": "015E2031574120321141",
"versions": [
"22122007",
"22122704"
],
"visVersion": "221227"
},
{
"type": "Micom",
"modelId": "015E20303242FFFFFFFF",
"versions": [
"2",
"FFFFFFFF"
],
"visVersion": "2"
}
]
}
},
{
"href": "/power/0",
"rep": {
"value": false
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "Off"
}
},
{
"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": "/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_01",
"x.com.samsung.da.st.courseTable": "Table_00"
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "Asia/Seoul",
"offset": "+09:00",
"DST": "OFF"
}
},
{
"href": "/washer/vs/0",
"rep": {
"x.com.samsung.da.wrinklePrevent": "Off"
}
},
{
"href": "/wirelessinfo/vs/0",
"rep": {
"macaddressWiFi": "REDACTED",
"macaddressBLE": "REDACTED"
}
},
{
"href": "/wm/editcourse/vs/0",
"rep": {
"x.com.samsung.da.editCourseList": "EditCourseList_0135030E040A130B12090C10311416172119200F27302A2B2C2D2E151A1B1C0708",
"x.com.samsung.da.fixedCourseList": "FixedCourseList_01270F"
}
},
{
"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",
"x.com.samsung.da.modelCode": "M(None),W(None)"
}
},
{
"href": "/wm/welcomemsg/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": {}
}
]
}
+184
View File
@@ -0,0 +1,184 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/airqualitystandard/vs/0",
"rep": {
"x.com.samsung.da.standard": "AirCleanAssociation"
}
},
{
"href": "/alarms/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "ErrorCode_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-30T02:42:57",
"x.com.samsung.da.state": "Deleted"
}
],
"rt": [
"x.com.samsung.da.alarms"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/dnd/vs/0",
"rep": {
"x.com.samsung.da.value": "false",
"x.com.samsung.da.startTime": "14:00:00",
"x.com.samsung.da.endTime": "22:00:00",
"x.com.samsung.da.visible": "true"
}
},
{
"href": "/energy/battery/vs/0",
"rep": {
"x.com.samsung.da.battery": "80",
"x.com.samsung.da.batteryResolution": "1",
"x.com.samsung.da.charging": "Off"
}
},
{
"href": "/humidity/vs/0",
"rep": {
"x.com.samsung.da.humidity": "57",
"x.com.samsung.da.validity": "Valid"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "ASM-KR-TP1-22-ACMB1M|10243041|75000000001611C40800020000080000",
"x.com.samsung.da.description": "ASM-KR-TP1-22-ACMB1M",
"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": "02443A260310",
"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": "22050300",
"x.com.samsung.da.newVersionAvailable": "0"
}
],
"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": "AM0",
"x.com.samsung.da.diagMinVersion": "1.0"
}
},
{
"href": "/keepnormalstate/vs/0",
"rep": {
"x.com.samsung.da.keepnormal": 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": "/sensordatasinks/vs/0",
"rep": {
"remove": ""
}
},
{
"href": "/sensors/vs/0",
"rep": {
"x.com.samsung.da.cleanLevel": "2",
"x.com.samsung.da.refresh": "Off",
"x.com.samsung.da.lastSensingTime": "1785379376",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Sensor for CleanLevel",
"x.com.samsung.da.type": "CleanLevel",
"x.com.samsung.da.value": [
"2"
]
},
{
"x.com.samsung.da.id": "1",
"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": "2",
"x.com.samsung.da.description": "Sensor for Dust",
"x.com.samsung.da.type": "Dust",
"x.com.samsung.da.value": [
"31",
"2"
]
},
{
"x.com.samsung.da.id": "3",
"x.com.samsung.da.description": "Sensor for FineDust",
"x.com.samsung.da.type": "FineDust",
"x.com.samsung.da.value": [
"23",
"2"
]
},
{
"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": [
"18",
"2"
]
},
{
"x.com.samsung.da.id": "5",
"x.com.samsung.da.description": "Sensor for CO2",
"x.com.samsung.da.type": "CO2",
"x.com.samsung.da.value": [
"498",
"1"
]
}
]
}
}
]
}
+381
View File
@@ -0,0 +1,381 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.longnotisubscription": "true",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/airlevelcheck/vs/0",
"rep": {
"x.com.samsung.da.periodicSensingActivationState": "On",
"x.com.samsung.da.periodicSensingInterval": "600",
"x.com.samsung.da.startSensingOnce": "On",
"x.com.samsung.da.sensingState": "NonProcessing",
"x.com.samsung.da.lastSensingTime": "1785320820",
"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": "03002300"
}
},
{
"href": "/mode/convenient/vs/0",
"rep": {}
},
{
"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"
]
}
},
{
"href": "/filter/hepafilter/vs/0",
"rep": {
"x.com.samsung.da.filterUsage": "30",
"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": "/dnd/autosleep/vs/0",
"rep": {
"x.com.samsung.da.startTime": "15:00:00",
"x.com.samsung.da.endTime": "23:30:00",
"x.com.samsung.da.visible": "true",
"x.com.samsung.da.useTimeSetting": "true",
"x.com.samsung.da.functionState": "true"
}
},
{
"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": "/da/softreset/vs/0",
"rep": {
"x.com.samsung.da.softwarereset": "false"
}
},
{
"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-29T10:34:52",
"x.com.samsung.da.state": "Deleted"
}
]
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.cumulativePower": "48656",
"x.com.samsung.da.cumulativeSavedPower": "0",
"x.com.samsung.da.cumulativeDate": "1785353679",
"x.com.samsung.da.cumulativeDateUTC": "1785321279",
"x.com.samsung.da.cumulativeUnit": "Wh"
}
},
{
"href": "/energy/consumption/0",
"rep": {}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.options": [
"CountOfSlave_0",
"SlavePower01_NotSupported",
"Pollution_Off",
"SmartSave_Off",
"OptionCode_23418",
"SmartSleep_Off"
]
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "Off"
}
},
{
"href": "/power/0",
"rep": {
"value": false
}
},
{
"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": [
"10",
"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": [
"9",
"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": [
"6",
"1"
]
}
],
"x.com.samsung.da.total": "5"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "AVT-WW-TP1-23-AXX500|10238841|70000535001611CC0D10090301080000",
"x.com.samsung.da.description": "AVT-WW-TP1-23-AXX500",
"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": "AC0",
"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": "Version",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02530A260327",
"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": "22120702,22022301",
"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.newVersionAvailable": "0"
}
]
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+09:00"
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "2824500000",
"x.com.samsung.da.airconOptionList": [
"WELCOMECARE",
"AI_PURIFY"
],
"x.com.samsung.da.countryCode": ""
}
},
{
"href": "/humidity/0",
"rep": {}
},
{
"href": "/humidity/vs/0",
"rep": {}
},
{
"href": "/devicespecificinfo/vs/0",
"rep": {
"x.com.samsung.da.deviceActive": true
}
},
{
"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": "AVT-WW-TP1-23-AXX500",
"versions": [
"12260327"
],
"visVersion": "260327"
},
{
"type": "Micom",
"modelId": "",
"versions": [
"22120702",
"22022301"
],
"visVersion": "221207"
},
{
"type": "Micom",
"modelId": "",
"versions": [
"22120702",
"22022301"
],
"visVersion": "221207"
}
]
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "Asia/Seoul",
"offset": "+09:00",
"DST": "OFF"
}
},
{
"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": "/option/muteonce/vs/0",
"rep": {
"muteonce": "Off"
}
},
{
"href": "/wirelessinfo/vs/0",
"rep": {
"macaddressWiFi": "**REDACTED**",
"macaddressBLE": "**REDACTED**",
"connectedApSsid": "ASUS_2.4G"
}
},
{
"href": "/quickcontrol/info/vs/0",
"rep": {
"supportedVersion": "1.0"
}
}
]
}
+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"
]
}
}
]
}
File diff suppressed because it is too large Load Diff
@@ -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"
}
]
}
}
]
}
@@ -0,0 +1,357 @@
{
"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"
}
]
}
}
],
"oic_res": [],
"seeds": {
"/device/1": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/airflow/vs/1",
"rep": {}
},
{
"href": "/airflow/1",
"rep": {}
},
{
"href": "/alarms/vs/1",
"rep": {}
},
{
"href": "/temperatures/vs/1",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Temperature"
}
]
}
},
{
"href": "/temperature/current/1",
"rep": {}
},
{
"href": "/temperature/desired/1",
"rep": {}
},
{
"href": "/diagnosis/vs/1",
"rep": {}
},
{
"href": "/energy/consumption/vs/1",
"rep": {
"x.com.samsung.da.cumulativePower": "117520000",
"x.com.samsung.da.cumulativeDate": "1785420000"
}
},
{
"href": "/energy/consumption/1",
"rep": {}
},
{
"href": "/mode/vs/1",
"rep": {}
},
{
"href": "/mode/1",
"rep": {}
},
{
"href": "/power/vs/1",
"rep": {}
},
{
"href": "/power/1",
"rep": {}
},
{
"href": "/information/vs/1",
"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": "/file/information/vs/1",
"rep": {
"x.com.samsung.timeoffset": "+03:00",
"x.com.samsung.supprtedtype": 1
}
},
{
"href": "/configuration/vs/1",
"rep": {
"x.com.samsung.da.region": "0000000000"
}
},
{
"href": "/humidity/1",
"rep": {}
},
{
"href": "/humidity/vs/1",
"rep": {}
}
]
},
"seeds_note": "device0 is the ARTIK051_KRAC_18K capture from airconditioner_artik051_krac_18k_device.json, verbatim -- a real dump, but from a different physical unit than the issue #214 reporter's AR12NXWXCWKNEU (same board family, modelNum differing only in the third segment). The reporter's own master resources were not quoted in the issue, so the master half of this fixture is that unit's stand-in. The /device/1 seed is the reporter's real slot, read off the `subdevices` block of their v0.17.0 diagnostics download (issue #214 comment 5131166356) and re-indexed from the canonical hrefs diagnostics reports back onto the real /x/1 hrefs the board answers on -- every rep is their value verbatim, including the empty {} on every operational resource and the populated /energy/consumption/vs/1, which is the whole point of this fixture. Two deliberate deviations: /information/vs/1 carries *this* fixture's master /information/vs/0 rep rather than the reporter's modelNum, because what their dump shows is the slot echoing its own master's model verbatim (ARTIK051_KRAC_18K|10193441|60010123001111110100000000000000, identical to their master's) and pasting that string against a different unit's master would misrepresent the slot as reporting a *different* model, which is what a real sibling does (see airconditioner_artik051_dongle_fac_18k_device.json's FAC_RAC subdevice); and /file/information/vs/1's +03:00 offset is the reporter's, so it doesn't match this master's +01:00 -- an artifact of the two halves coming from different units, not something the board did. oic_res is empty because the reporter's wasn't quoted either: enumeration therefore reaches /device/1 through the speculative _SPECULATIVE_DEVICE_INDICES probe, which is the path any board that doesn't advertise a sibling in /oic/res gets. Expected outcome: the candidate is found, then correctly held back by discover_partitioned's liveness gate -- a lifetime kWh counter is not evidence of a second indoor unit (issue #214)."
}
@@ -0,0 +1,231 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"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": "/airflow/vs/0",
"rep": {
"x.com.samsung.da.speedLevel": "0",
"x.com.samsung.da.direction": "All"
}
},
{
"href": "/airflow/0",
"rep": {
"speed": 0,
"direction": "All"
}
},
{
"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-29T12:29:36",
"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-29T12:29:36",
"x.com.samsung.da.state": "Deleted"
}
]
}
},
{
"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": "23",
"x.com.samsung.da.current": "23",
"x.com.samsung.da.maximum": "30",
"x.com.samsung.da.minimum": "16",
"x.com.samsung.da.unit": "Celsius"
}
]
}
},
{
"href": "/temperature/current/0",
"rep": {
"range": [
16.0,
30.0
],
"units": "C",
"temperature": 23.0
}
},
{
"href": "/temperature/desired/0",
"rep": {
"range": [
16.0,
30.0
],
"units": "C",
"temperature": 23.0
}
},
{
"href": "/diagnosis/vs/0",
"rep": {
"x.com.samsung.da.diagnosisStart": "Ready"
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.cumulativePower": "117430000",
"x.com.samsung.da.cumulativeDate": "1785337200"
}
},
{
"href": "/energy/consumption/0",
"rep": {}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"Cool",
"Dry",
"Wind",
"Auto",
"Heat"
],
"x.com.samsung.da.modes": [
"Cool"
],
"x.com.samsung.da.options": [
"Comode_Off",
"Sleep_0",
"OutdoorTemp_85",
"CoolCapa_35",
"WarmCapa_35",
"Spi_Off",
"Autoclean_Off",
"Light_Off",
"Volume_100",
"AirMonitoring_Off",
"AutocleanProgress_0",
"StopAutoClean_Idle",
"FilterTime_1425",
"FilterAlarmTime_500",
"OptionCode_56378",
"ExtendOptionCode_7",
"RacInfo_None",
"UpdateAllow_NotAllowed"
]
}
},
{
"href": "/mode/0",
"rep": {
"supportedModes": [
"Cool",
"Dry",
"Wind",
"Auto",
"Heat"
],
"modes": [
"Cool"
]
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "On"
}
},
{
"href": "/power/0",
"rep": {
"value": true
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "ARTIK051_KRAC_18K|10193441|60010123001111110100000000000000",
"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": "17110700,17110600",
"x.com.samsung.da.newVersionAvailable": "0"
}
]
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+03:00",
"x.com.samsung.supprtedtype": 1
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "0000000000"
}
},
{
"href": "/humidity/0",
"rep": {
"humidity": 0
}
},
{
"href": "/humidity/vs/0",
"rep": {
"x.com.samsung.da.humidity": "0"
}
}
]
}
+919
View File
@@ -0,0 +1,919 @@
{
"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.periodicSensingInterval": "1800",
"x.com.samsung.da.sensingState": "NonProcessing",
"x.com.samsung.da.lastSensingTime": "1785327477",
"x.com.samsung.da.lastSensingLevel": "Kr1",
"x.com.samsung.da.periodicSensingSkipStatus": "Off",
"x.com.samsung.da.periodicSensingSkipTime": "00000000",
"x.com.samsung.da.periodicSensingActivationState": "On",
"x.com.samsung.da.autoExeState": "Alarm",
"x.com.samsung.da.supportedAutoExeState": [
"Airpurify",
"Alarm",
"Sensing"
],
"x.com.samsung.da.startSensingOnce": "Off",
"x.com.samsung.da.autoExeSetting": "On",
"x.com.samsung.da.supportedAutoExeSetting": [
"On",
"Off"
]
}
},
{
"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.alarmType": "Device",
"x.com.samsung.da.code": "ErrorCode_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-29T12:19:55",
"x.com.samsung.da.state": "Deleted"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "FilterAlarm_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-29T12:19:55",
"x.com.samsung.da.state": "Deleted"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "DA_SAC_M_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-29T12:19:55",
"x.com.samsung.da.state": "Deleted"
}
],
"rt": [
"x.com.samsung.da.alarms"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/availablecontrolsets/vs/0",
"rep": {
"x.com.samsung.da.sets": "000100B4012C0000B20001000000",
"x.com.samsung.da.id": "CAC",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "4146100000",
"x.com.samsung.da.airconOptionList": [
"AI_KOREA_COOLONLY_4.0",
"HOMECARE_WIZARD_V2",
"CLEAN_OPTION_SUPPORT",
"DR",
"SingleCommand_1"
]
}
},
{
"href": "/da/softreset/vs/0",
"rep": {
"x.com.samsung.da.softwarereset": "false"
}
},
{
"href": "/drlc/vs/0",
"rep": {
"x.com.samsung.da.start": "2026-07-29T07:26:56Z",
"x.com.samsung.da.override": "Off",
"x.com.samsung.da.durationminutes": "1019",
"x.com.samsung.da.drlcLevel": "1",
"x.com.samsung.da.realSaving": "Off"
}
},
{
"href": "/edgelighting/vs/0",
"rep": {
"status": "On",
"convenientMode": "On",
"operatingOption": "On",
"welcomeLightStatus": "Off",
"colorOption": "3000K",
"mode": "Smart",
"modeSupportedList": [
"Smart",
"High",
"Low"
],
"colorSupportedList": [
"3000K",
"4000K",
"6500K"
]
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.cumulativeDate": "1785327589",
"x.com.samsung.da.instantaneousPower": "15",
"x.com.samsung.da.cumulativeSavedPower": "1611",
"x.com.samsung.da.cumulativePower": "84044",
"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/airdustPM1filter/vs/0",
"rep": {
"x.com.samsung.da.filterUsageResolution": "1",
"x.com.samsung.da.filterCapacity": "2400",
"x.com.samsung.da.filterCapacityUnit": "Hour",
"x.com.samsung.da.filterResetType": [
"washable"
],
"x.com.samsung.da.filterStatus": "normal",
"x.com.samsung.da.filterUsage": "11"
}
},
{
"href": "/filter/airdustfilter/vs/0",
"rep": {
"x.com.samsung.da.filterUsageResolution": "1",
"x.com.samsung.da.filterCapacity": "180",
"x.com.samsung.da.filterCapacityUnit": "Hour",
"x.com.samsung.da.filterResetType": [
"washable"
],
"x.com.samsung.da.filterStatus": "normal",
"x.com.samsung.da.filterUsage": "26"
}
},
{
"href": "/humidity/vs/0",
"rep": {
"x.com.samsung.da.humidity": "0",
"x.com.samsung.da.fivepercentHumidity": "59"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.otnDUID": "**REDACTED**",
"x.com.samsung.da.modelNum": "TP1X_DA-AC-CAC-01001_0000|10255541|60030748171811DF42005F2A00F2ED00",
"x.com.samsung.da.description": "TP1X_DA-AC-CAC-01001_0000",
"x.com.samsung.da.serialNum": "**REDACTED**",
"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": "02706A260401",
"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.newVersionAvailable": "0",
"x.com.samsung.da.number": "02555A25052600,02556A24040200"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Outdoor",
"x.com.samsung.da.newVersionAvailable": "0",
"x.com.samsung.da.number": "FFFFFFFFFFFFFF,FFFFFFFFFFFFFF"
}
],
"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": "AS3",
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
"x.com.samsung.da.diagMinVersion": "3.0",
"x.com.samsung.da.diagTsId": "DA01"
}
},
{
"href": "/keepnormalstate/vs/0",
"rep": {
"x.com.samsung.da.keepnormal": 0
}
},
{
"href": "/light/stateful/vs/0",
"rep": {
"status": "On",
"mode": "Smart",
"supportedModes": [
"Smart",
"Low",
"High"
]
}
},
{
"href": "/mds/absenceclean/vs/0",
"rep": {
"mode": "Off",
"supportedModes": [
"On",
"Off"
]
}
},
{
"href": "/mds/absencemonitoring/vs/0",
"rep": {
"status": "Off"
}
},
{
"href": "/mds/absencepowersaving/vs/0",
"rep": {
"status": "On",
"modes": "Cooling",
"switchPowerSaveMode": "Normal",
"supportedModes": [
"Standby",
"Cooling"
],
"supportedSwitchPowerSaveMode": [
"Eco",
"Normal",
"Comfort"
]
}
},
{
"href": "/mds/absencestate/vs/0",
"rep": {
"status": "On",
"absenceTime": "0",
"maxDetectCount": [
"0",
"0",
"0",
"0",
"0",
"0",
"3",
"3",
"3",
"2",
"3",
"0",
"0",
"3",
"0",
"3",
"3",
"3",
"0",
"3",
"1",
"2",
"3",
"3",
"0",
"0",
"0",
"0",
"0",
"0",
"0",
"0",
"0",
"0",
"0",
"0",
"1",
"0",
"3",
"2",
"3",
"1",
"2",
"1",
"0",
"0",
"0",
"0"
],
"supportedTimes": [
"0",
"30",
"60",
"120"
]
}
},
{
"href": "/mode/convenient/vs/0",
"rep": {
"x.com.samsung.da.modes": "Off",
"x.com.samsung.da.supportedModes": [
"Off",
"Nano",
"LongWind",
"Speed",
"Sleep",
"NanoSleep"
]
}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.modes": [
"Cool"
],
"workingMode": "Cool",
"x.com.samsung.da.supportedModes": [
"AIComfort",
"Auto",
"Cool",
"Dry",
"Fan"
],
"x.com.samsung.da.options": [
"OptionCode_512",
"Sleep_0",
"ArtificialWorking_Off",
"ComfortAICooling_Off",
"AiTempChanged_Off",
"AiTemp_240",
"welcomecareElapsedTime_0",
"welcomecareThresholdTemp_0",
"welcomecareStartDate_0000",
"welcomecareEndDate_0000",
"welcomecareSeason_None",
"welcomecare_Off",
"DiagnosisAI_Off",
"ProgressDiagnosisAI_0",
"ResultDiagnosisAI_Normal",
"SmartCoolClean_Off",
"ProgressSmartClean_0",
"FreezeAlarmSetting_On",
"DesiredFreezeAlarm_112",
"ExtendOptionCode_0",
"UpdateAllow_NotAllowed",
"DurationOn_0",
"OutdoorTemp_55",
"KeyInputPermit_On",
"ModePermit_NoLimit"
],
"rt": [
"x.com.samsung.da.mode"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Temperature",
"x.com.samsung.da.current": "28.5",
"x.com.samsung.da.desired": "28.0",
"x.com.samsung.da.minimum": "18",
"x.com.samsung.da.maximum": "30",
"x.com.samsung.da.increment": "0.5",
"x.com.samsung.da.unit": "Celsius"
}
]
}
},
{
"href": "/option/airpurify/vs/0",
"rep": {
"x.com.samsung.da.modes": "Off",
"x.com.samsung.da.supportedModes": [
"On",
"Off"
]
}
},
{
"href": "/option/autoclean/vs/0",
"rep": {
"x.com.samsung.da.status": "Stop",
"x.com.samsung.da.supportedStatus": [
"Start",
"Stop"
],
"x.com.samsung.da.progress": "0",
"x.com.samsung.da.settingStatus": "On",
"x.com.samsung.da.supportedSettingStatus": [
"On",
"Off"
],
"defaultSettingStatus": "On"
}
},
{
"href": "/option/motiondetectwind/stateful/vs/0",
"rep": {
"status": "On",
"modes": "Indirect",
"supportedModes": [
"Indirect",
"Direct"
]
}
},
{
"href": "/option/muteonce/vs/0",
"rep": {
"muteonce": "Off"
}
},
{
"href": "/personality/presence/vs/0",
"rep": {}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "Off",
"operationNumber": "46"
}
},
{
"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": "/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": "1F121E121E121E121EFFFFFFFF010001000F0001000E0000001C3E",
"x.com.samsung.da.id": "CAC",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/sac/installationinfo/vs/0",
"rep": {
"installationOptions": [
{
"id": "0",
"option": "184449945E0484800017"
},
{
"id": "1",
"option": "28010000010000000100"
},
{
"id": "2",
"option": "50000000000000000001"
},
{
"id": "3",
"option": "30000000000000000000"
}
],
"installationDeviceNum": [
{
"id": "0",
"number": "0"
},
{
"id": "1",
"number": "5"
},
{
"id": "2",
"number": "0"
}
],
"outdoorInfo": [
{
"id": "0",
"info": "02571A250514",
"serial": "**REDACTED**"
},
{
"id": "1",
"info": "000000000000",
"serial": "**REDACTED**"
},
{
"id": "2",
"info": "000000000000",
"serial": "**REDACTED**"
},
{
"id": "3",
"info": "000000000000",
"serial": "**REDACTED**"
}
],
"mcuInfo": [
{
"id": "0",
"info": "000000000000"
},
{
"id": "1",
"info": "000000000000"
},
{
"id": "2",
"info": "000000000000"
},
{
"id": "3",
"info": "000000000000"
},
{
"id": "4",
"info": "000000000000"
},
{
"id": "5",
"info": "000000000000"
},
{
"id": "6",
"info": "000000000000"
},
{
"id": "7",
"info": "000000000000"
},
{
"id": "8",
"info": "000000000000"
},
{
"id": "9",
"info": "000000000000"
},
{
"id": "10",
"info": "000000000000"
},
{
"id": "11",
"info": "000000000000"
},
{
"id": "12",
"info": "000000000000"
},
{
"id": "13",
"info": "000000000000"
},
{
"id": "14",
"info": "000000000000"
}
]
}
},
{
"href": "/selfcheck/vs/0",
"rep": {
"x.com.samsung.da.start": "Cancel",
"x.com.samsung.da.status": "Ready",
"x.com.samsung.da.progress": "0",
"x.com.samsung.da.result": "NA",
"x.com.samsung.da.supportedActions": [
"Start",
"Cancel"
],
"x.com.samsung.da.error": [
"DA_ERROR_NONE"
]
}
},
{
"href": "/sensors/vs/0",
"rep": {
"x.com.samsung.da.cleanLevel": "1",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"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": "2",
"x.com.samsung.da.description": "Sensor for Dust",
"x.com.samsung.da.type": "Dust",
"x.com.samsung.da.value": [
"7",
"1"
]
},
{
"x.com.samsung.da.id": "3",
"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": "4",
"x.com.samsung.da.description": "Sensor for SuperFineDust",
"x.com.samsung.da.type": "SuperFineDust",
"x.com.samsung.da.value": [
"5",
"1"
]
}
]
}
},
{
"href": "/settings/sound/mode/vs/0",
"rep": {
"mode": "voice",
"supportedModes": [
"mute",
"tone",
"voice"
]
}
},
{
"href": "/settings/sound/optimization/vs/0",
"rep": {
"status": "Off",
"supportedModes": [
"On",
"Off"
]
}
},
{
"href": "/settings/sound/output/vs/0",
"rep": {
"deviceType": "speaker"
}
},
{
"href": "/settings/sound/volume/vs/0",
"rep": {
"level": "2",
"minLevel": "0",
"maxLevel": "3",
"resolution": "1"
}
},
{
"href": "/smartsensingcooling/vs/0",
"rep": {
"status": "On",
"supportedModes": [
"On",
"Off"
]
}
},
{
"href": "/temperature/control/vs/0",
"rep": {
"x.com.samsung.da.increment": "0.5"
}
},
{
"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.current": "28.5",
"x.com.samsung.da.desired": "28.0",
"x.com.samsung.da.minimum": "18",
"x.com.samsung.da.maximum": "30",
"x.com.samsung.da.increment": "0.5",
"x.com.samsung.da.unit": "Celsius"
}
]
}
},
{
"href": "/uvled/vs/0",
"rep": {
"x.com.samsung.da.modes": "On",
"x.com.samsung.da.start": "Cancel",
"x.com.samsung.da.pushAlarmStatus": "Off",
"x.com.samsung.da.supportedModes": [
"On",
"Off"
]
}
},
{
"href": "/wind/direction/vs/0",
"rep": {
"x.com.samsung.da.modes": "Fix",
"x.com.samsung.da.supportedModes": [
"Left_And_Right",
"All"
]
}
},
{
"href": "/wind/strength/vs/0",
"rep": {
"x.com.samsung.da.modes": "0",
"x.com.samsung.da.supportedModes": [
"0",
"1",
"2",
"3"
],
"x.com.samsung.da.modesName": [
"Auto",
"Low",
"Mid",
"High"
]
}
},
{
"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 Air conditioner",
"osVersion": "4.0"
},
"otnList": [
{
"type": "WIFI",
"modelId": "ASA-WW-TP1-24-INFSAC",
"versions": [
"14260401"
],
"visVersion": "260401"
},
{
"type": "Micom",
"modelId": "04001025554110255641",
"versions": [
"25052600",
"24040200"
],
"visVersion": "250526"
},
{
"type": "Micom",
"modelId": "FGCAN2",
"versions": [
"",
""
],
"visVersion": "999999"
}
]
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "Asia/Seoul",
"offset": "+09:00",
"DST": "OFF"
}
},
{
"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": "/wirelessinfo/vs/0",
"rep": {
"macaddressWiFi": "**REDACTED**",
"macaddressBLE": "**REDACTED**",
"connectedApSsid": "boksil"
}
},
{
"href": "/quickcontrol/info/vs/0",
"rep": {
"supportedVersion": "1.0"
}
},
{
"href": "/dginformation/vs/0",
"rep": {
"enrolmentstatus": "Unknown",
"devicestate": "Unknown",
"lockstatus": "Unknown",
"nextduedate": "",
"workingminutes": 0,
"paymentinfo": {
"emiplan": "Unknown",
"currency": "Unknown",
"totalemi": 0,
"totalemipaid": 0
}
}
}
]
}
@@ -0,0 +1,720 @@
{
"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": "2026-07-30T01:48:51"
},
{
"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": "2026-07-30T01:48:51"
}
]
}
},
{
"href": "/availablecontrolsets/vs/0",
"rep": {
"x.com.samsung.da.sets": "000000B4012C0000404B04000000",
"x.com.samsung.da.id": "FAC",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "3017000000",
"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": 800.0,
"power": 65278.0
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.cumulativeConsumption": "800.000000",
"x.com.samsung.da.instantaneousPower": "65278.000000",
"x.com.samsung.da.usageThreshold": "0.000000",
"x.com.samsung.da.cumulativePower": "544088",
"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": "0",
"x.com.samsung.da.filterUsageResolution": "1",
"x.com.samsung.da.filterDesiredUsage": "112",
"x.com.samsung.da.filterStatus": "normal",
"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": "58"
}
},
{
"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": 5
}
},
{
"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": [
"Wind"
],
"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_Off",
"DesiredFreezeAlarm_240",
"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": [
"6c2dff6d-ee5c-dad1-6a5e-000000000001"
]
}
},
{
"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": 32.0
}
},
{
"href": "/temperature/desired/0",
"rep": {
"range": [
18.0,
30.0
],
"units": "C",
"temperature": 24.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": "24.0",
"x.com.samsung.da.current": "32.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": "2",
"x.com.samsung.da.supportedModes": [
"0",
"2",
"3",
"4"
],
"x.com.samsung.da.modesName": [
"Auto",
"Mid",
"High",
"Turbo"
]
}
}
],
"oic_res": [
{
"di": "**REDACTED**",
"links": [
{
"href": "/oic/sec/doxm",
"rt": [
"oic.r.doxm"
],
"if": [
"oic.if.baseline"
],
"p": {
"bm": 1,
"sec": true,
"port": 49154,
"x.org.iotivity.tls": 0
}
},
{
"href": "/oic/sec/pstat",
"rt": [
"oic.r.pstat"
],
"if": [
"oic.if.baseline"
],
"p": {
"bm": 1,
"sec": true,
"port": 49154,
"x.org.iotivity.tls": 0
}
},
{
"href": "/oic/d",
"rt": [
"oic.wk.d",
"oic.d.airconditioner"
],
"if": [
"oic.if.baseline",
"oic.if.r"
],
"p": {
"bm": 1,
"sec": false,
"x.org.iotivity.tcp": 0
}
},
{
"href": "/oic/p",
"rt": [
"oic.wk.p"
],
"if": [
"oic.if.baseline",
"oic.if.r"
],
"p": {
"bm": 1,
"sec": false,
"x.org.iotivity.tcp": 0
}
},
{
"href": "/hass/state/vs/0",
"rt": [
"x.com.samsung.da.hass.state"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"p": {
"bm": 3,
"sec": false,
"x.org.iotivity.tcp": 0
}
},
{
"href": "/hass/command/vs/0",
"rt": [
"x.com.samsung.da.hass.command"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"p": {
"bm": 3,
"sec": false,
"x.org.iotivity.tcp": 0
}
},
{
"href": "/file/transfer/chunk/vs/0",
"rt": [
"x.com.samsung.file.chunk"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"p": {
"bm": 1,
"sec": false,
"x.org.iotivity.tcp": 0
}
},
{
"href": "/file/list/vs/0",
"rt": [
"x.com.samsung.file.list"
],
"if": [
"oic.if.baseline",
"oic.if.s"
],
"p": {
"bm": 1,
"sec": false,
"x.org.iotivity.tcp": 0
}
},
{
"href": "/file/transfer/vs/0",
"rt": [
"x.com.samsung.file.transfer"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"p": {
"bm": 3,
"sec": false,
"x.org.iotivity.tcp": 0
}
},
{
"href": "/6c2dff6d-ee5c-dad1-6a5e-000000000001/file/list/vs/0",
"rt": [
"x.com.samsung.file.list"
],
"if": [
"oic.if.baseline",
"oic.if.s"
],
"p": {
"bm": 1,
"sec": false,
"x.org.iotivity.tcp": 0
}
},
{
"href": "/6c2dff6d-ee5c-dad1-6a5e-000000000001/file/transfer/vs/0",
"rt": [
"x.com.samsung.file.transfer"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"p": {
"bm": 3,
"sec": false,
"x.org.iotivity.tcp": 0
}
},
{
"href": "/EasySetupResURI",
"rt": [
"oic.r.easysetup"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
],
"p": {
"bm": 1,
"sec": true,
"port": 49154,
"x.org.iotivity.tls": 0
}
},
{
"href": "/WiFiConfResURI",
"rt": [
"oic.wk.wifi"
],
"if": [
"oic.if.baseline"
],
"p": {
"bm": 1,
"sec": true,
"port": 49154,
"x.org.iotivity.tls": 0
}
},
{
"href": "/CoapCloudConfResURI",
"rt": [
"oic.wk.cloudserver"
],
"if": [
"oic.if.baseline"
],
"p": {
"bm": 1,
"sec": true,
"port": 49154,
"x.org.iotivity.tls": 0
}
},
{
"href": "/DevConfResURI",
"rt": [
"oic.wk.devconf"
],
"if": [
"oic.if.baseline"
],
"p": {
"bm": 1,
"sec": true,
"port": 49154,
"x.org.iotivity.tls": 0
}
},
{
"href": "/sec/provisioninginfo",
"rt": [
"x.com.samsung.provisioninginfo"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"p": {
"bm": 1,
"sec": false,
"x.org.iotivity.tcp": 0
}
},
{
"href": "/sec/accesspointlist",
"rt": [
"x.com.samsung.accesspointlist"
],
"if": [
"oic.if.baseline",
"oic.if.s"
],
"p": {
"bm": 1,
"sec": false,
"x.org.iotivity.tcp": 0
}
}
]
}
],
"probes": {
"/6c2dff6d-ee5c-dad1-6a5e-000000000001/information/vs/0": {
"x.com.samsung.da.modelNum": "TP2X_FAC_BORA_RAC_21K|10233041|60010610001500014600081200810000",
"x.com.samsung.da.description": "TP2X_FAC_BORA_RAC_21K",
"x.com.samsung.da.serialNum": "TEST-SUBDEVICE-SERIAL-0000"
}
},
"seeds_note": "device0 and oic_res are real, captured from the reporter's issue #205 report. subdeviceIdList in device0's /subdevices/vs/0 is restored to the real ['6c2dff6d-ee5c-dad1-6a5e-000000000001'] (HA's diagnostics download redacts it, matching redact.py's 'deviceid' substring rule, not because it's account data -- same restoration airconditioner_fac_bora_2in1_device.json documents for the same field), and every other value is otherwise exactly what HA's download redacts (serials/otnDUID etc.) against the same physical TP2X_FAC_BORA_21K unit as airconditioner_fac_bora_2in1_device.json -- same subdeviceIdList UUID, same wall subdevice. Filed specifically because this unit's /6c2dff6d-ee5c-dad1-6a5e-000000000001/device/0 does NOT answer (subdevice_probes in the report shows it False), which is what this fixture exercises: registry.subdevices.enumerate_subdevices' clone-the-master fallback (issue #265, which replaced the per-href probe loop this fixture originally exercised for issue #205). The one probes entry, /6c2dff6d-ee5c-dad1-6a5e-000000000001/information/vs/0, is the same real capture already used in airconditioner_fac_bora_2in1_device.json's seed batch (issue #177 comment 5113518087) -- the only href ever actually confirmed to answer under this UUID prefix. It's kept here as that real confirmation even though the current fallback no longer probes any href beyond /device/0 to decide whether the subdevice exists at all: since issue #265, this fixture's expected outcome is the candidate materializing immediately with the master's own device0 hrefs/values cloned under the prefix as its assumed state -- a full climate entity included -- rather than waiting on per-href confirmation this firmware may never give. The probe's serialNum (\"TEST-SUBDEVICE-SERIAL-0000\") is a hand-placed placeholder for the real value, not HA's own redaction output -- same placeholder airconditioner_fac_bora_2in1_device.json uses for the identical field."
}
+809
View File
@@ -0,0 +1,809 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"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": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.longnotisubscription": "true",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/filter/airdustfilter/vs/0",
"rep": {
"x.com.samsung.da.filterUsage": "0",
"x.com.samsung.da.filterUsageResolution": "1",
"x.com.samsung.da.filterDesiredUsage": "112",
"x.com.samsung.da.filterStatus": "normal",
"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": "/temperature/control/vs/0",
"rep": {
"x.com.samsung.da.increment": "1"
}
},
{
"href": "/mode/convenient/vs/0",
"rep": {
"x.com.samsung.da.modes": "Off",
"x.com.samsung.da.supportedModes": [
"Off",
"Sleep",
"Quiet",
"Speed"
]
}
},
{
"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": "/wind/strength/vs/0",
"rep": {
"x.com.samsung.da.modes": "2",
"x.com.samsung.da.supportedModes": [
"0",
"2",
"3",
"4"
],
"x.com.samsung.da.modesName": [
"Auto",
"Mid",
"High",
"Turbo"
]
}
},
{
"href": "/wind/direction/vs/0",
"rep": {
"x.com.samsung.da.modes": "NotSupported",
"x.com.samsung.da.supportedModes": [
"NotSupported"
]
}
},
{
"href": "/subdevices/vs/0",
"rep": {
"x.com.samsung.da.subdeviceIdList": [
"6c2dff6d-ee5c-dad1-6a5e-000000000001"
]
}
},
{
"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-29T04:31:31"
},
{
"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": "2026-07-29T04:31:31"
}
]
}
},
{
"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": "24.0",
"x.com.samsung.da.current": "33.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": "/temperature/current/0",
"rep": {
"range": [
18.0,
30.0
],
"units": "C",
"temperature": 33.0
}
},
{
"href": "/temperature/desired/0",
"rep": {
"range": [
18.0,
30.0
],
"units": "C",
"temperature": 24.0
}
},
{
"href": "/diagnosis/vs/0",
"rep": {}
},
{
"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": "544088",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.instantaneousPowerUnit": "W",
"x.com.samsung.da.cumulativePowerType": "individual"
}
},
{
"href": "/energy/consumption/0",
"rep": {
"energy": 100.0,
"power": 65278.0
}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"AIComfort",
"Cool",
"Dry",
"Wind"
],
"x.com.samsung.da.modes": [
"Wind"
],
"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_Off",
"DesiredFreezeAlarm_240",
"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": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "Off"
}
},
{
"href": "/power/0",
"rep": {
"value": false
}
},
{
"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": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+09:00",
"x.com.samsung.supprtedtype": 1
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "3017000000",
"x.com.samsung.da.airconOptionList": [
"HOMECARE_WIZARD_V2",
"ENERGY_2.0",
"AI_2.0",
"DeviceTypeMaster",
"SingleCommand_1"
]
}
},
{
"href": "/humidity/0",
"rep": {
"humidity": 0
}
},
{
"href": "/humidity/vs/0",
"rep": {
"x.com.samsung.da.humidity": "0.000000",
"x.com.samsung.da.fivepercentHumidity": "58"
}
},
{
"href": "/drlc/0",
"rep": {
"DRLevel": 2,
"start": "2026-07-29T04:12:57Z",
"duration": 20,
"override": false
}
},
{
"href": "/drlc/vs/0",
"rep": {
"x.com.samsung.da.drlcLevel": "2",
"x.com.samsung.da.duration": "20:30:00",
"x.com.samsung.da.drlcStartTime": "2026-07-29T04:12:57Z",
"x.com.samsung.da.override": "Off"
}
},
{
"href": "/runn/vs/0",
"rep": {
"x.com.samsung.da.runningMode": 0
}
},
{
"href": "/availablecontrolsets/vs/0",
"rep": {
"x.com.samsung.da.sets": "000000B4012C0000404B04000000",
"x.com.samsung.da.id": "FAC",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/keepnormalstate/vs/0",
"rep": {
"x.com.samsung.da.keepnormal": 5
}
},
{
"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": "/timezone/vs/0",
"rep": {
"timezoneid": "Asia/Seoul",
"offset": "+09:00",
"DST": "OFF"
}
}
],
"oic_res": [
{
"di": "**REDACTED**",
"links": [
{
"href": "/oic/sec/doxm",
"rt": [
"oic.r.doxm"
],
"if": [
"oic.if.baseline"
],
"p": {
"bm": 1,
"sec": true,
"port": 49154,
"x.org.iotivity.tls": 0
}
},
{
"href": "/oic/sec/pstat",
"rt": [
"oic.r.pstat"
],
"if": [
"oic.if.baseline"
],
"p": {
"bm": 1,
"sec": true,
"port": 49154,
"x.org.iotivity.tls": 0
}
},
{
"href": "/oic/d",
"rt": [
"oic.wk.d",
"oic.d.airconditioner"
],
"if": [
"oic.if.baseline",
"oic.if.r"
],
"p": {
"bm": 1,
"sec": false,
"x.org.iotivity.tcp": 0
}
},
{
"href": "/oic/p",
"rt": [
"oic.wk.p"
],
"if": [
"oic.if.baseline",
"oic.if.r"
],
"p": {
"bm": 1,
"sec": false,
"x.org.iotivity.tcp": 0
}
},
{
"href": "/hass/state/vs/0",
"rt": [
"x.com.samsung.da.hass.state"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"p": {
"bm": 3,
"sec": false,
"x.org.iotivity.tcp": 0
}
},
{
"href": "/hass/command/vs/0",
"rt": [
"x.com.samsung.da.hass.command"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"p": {
"bm": 3,
"sec": false,
"x.org.iotivity.tcp": 0
}
},
{
"href": "/file/transfer/chunk/vs/0",
"rt": [
"x.com.samsung.file.chunk"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"p": {
"bm": 1,
"sec": false,
"x.org.iotivity.tcp": 0
}
},
{
"href": "/file/list/vs/0",
"rt": [
"x.com.samsung.file.list"
],
"if": [
"oic.if.baseline",
"oic.if.s"
],
"p": {
"bm": 1,
"sec": false,
"x.org.iotivity.tcp": 0
}
},
{
"href": "/file/transfer/vs/0",
"rt": [
"x.com.samsung.file.transfer"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"p": {
"bm": 3,
"sec": false,
"x.org.iotivity.tcp": 0
}
},
{
"href": "/6c2dff6d-ee5c-dad1-6a5e-000000000001/file/list/vs/0",
"rt": [
"x.com.samsung.file.list"
],
"if": [
"oic.if.baseline",
"oic.if.s"
],
"p": {
"bm": 1,
"sec": false,
"x.org.iotivity.tcp": 0
}
},
{
"href": "/6c2dff6d-ee5c-dad1-6a5e-000000000001/file/transfer/vs/0",
"rt": [
"x.com.samsung.file.transfer"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"p": {
"bm": 3,
"sec": false,
"x.org.iotivity.tcp": 0
}
},
{
"href": "/EasySetupResURI",
"rt": [
"oic.r.easysetup"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
],
"p": {
"bm": 1,
"sec": true,
"port": 49154,
"x.org.iotivity.tls": 0
}
},
{
"href": "/WiFiConfResURI",
"rt": [
"oic.wk.wifi"
],
"if": [
"oic.if.baseline"
],
"p": {
"bm": 1,
"sec": true,
"port": 49154,
"x.org.iotivity.tls": 0
}
},
{
"href": "/CoapCloudConfResURI",
"rt": [
"oic.wk.cloudserver"
],
"if": [
"oic.if.baseline"
],
"p": {
"bm": 1,
"sec": true,
"port": 49154,
"x.org.iotivity.tls": 0
}
},
{
"href": "/DevConfResURI",
"rt": [
"oic.wk.devconf"
],
"if": [
"oic.if.baseline"
],
"p": {
"bm": 1,
"sec": true,
"port": 49154,
"x.org.iotivity.tls": 0
}
},
{
"href": "/sec/provisioninginfo",
"rt": [
"x.com.samsung.provisioninginfo"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"p": {
"bm": 1,
"sec": false,
"x.org.iotivity.tcp": 0
}
},
{
"href": "/sec/accesspointlist",
"rt": [
"x.com.samsung.accesspointlist"
],
"if": [
"oic.if.baseline",
"oic.if.s"
],
"p": {
"bm": 1,
"sec": false,
"x.org.iotivity.tcp": 0
}
}
]
}
],
"seeds": {
"/6c2dff6d-ee5c-dad1-6a5e-000000000001/device/0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "TP2X_FAC_BORA_RAC_21K|10233041|600001110015110006000C1200830000",
"x.com.samsung.da.description": "TP2X_FAC_BORA_RAC_21K",
"x.com.samsung.da.serialNum": "TEST-SUBDEVICE-SERIAL-0000"
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "On"
}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"Cool",
"Dry",
"Wind",
"Auto"
],
"x.com.samsung.da.modes": [
"Cool"
],
"x.com.samsung.da.options": []
}
},
{
"href": "/temperature/current/0",
"rep": {
"range": [
18.0,
30.0
],
"units": "C",
"temperature": 26.0
}
},
{
"href": "/temperature/desired/0",
"rep": {
"range": [
18.0,
30.0
],
"units": "C",
"temperature": 24.0
}
},
{
"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"
]
}
},
{
"href": "/wind/direction/vs/0",
"rep": {
"x.com.samsung.da.modes": [
"Fix"
],
"x.com.samsung.da.supportedModes": [
"Fix",
"All"
]
}
}
]
},
"seeds_note": "device0 and oic_res are real, captured from the reporter's diagnostics dump for the TP2X_FAC_BORA_21K board (issue #177, the same physical device as tests/fixtures/airconditioner_fac_bora_device.json -- that fixture's redacted x.com.samsung.da.subdeviceIdList string is a deliberate, separate regression case and is NOT changed here). subdeviceIdList above is restored to the real ['6c2dff6d-ee5c-dad1-6a5e-000000000001'] reported in the issue thread (redacted in the raw capture the same way serialNum/otnDUID/deviceId are -- it matches redact.py's 'deviceid' substring rule, not because it's actually account data). The seed's /information/vs/0 rep is REAL, verbatim from the reporter's live debug-panel read of /6c2dff6d-.../information/vs/0 (serial replaced with a placeholder) -- modelNum TP2X_FAC_BORA_RAC_21K confirms this is the wall-mounted room subdevice, distinct from the master's floor subdevice TP2X_FAC_BORA_21K. Every other href in this seed batch is CONSTRUCTED (never read from this subdevice) so the subdevice's climate entity has a resource surface to bind -- per DESIGN-177.md section 4, retrieval for this pattern is batch-only, and confirming '/6c2dff6d-.../device/0' actually returns a full batch (as opposed to just answering the information probe the reporter tried by hand) is listed as a follow-up, not something this fixture can attest to."
}
+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"
]
}
}
]
}
@@ -0,0 +1,626 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"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": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.longnotisubscription": "false",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/filter/airdustfilter/vs/0",
"rep": {
"x.com.samsung.da.filterUsage": "100",
"x.com.samsung.da.filterUsageResolution": "1",
"x.com.samsung.da.filterDesiredUsage": "500",
"x.com.samsung.da.filterStatus": "wash",
"x.com.samsung.da.filterCapacity": "500",
"x.com.samsung.da.filterCapacityUnit": "Hour",
"x.com.samsung.da.filterResetType": [
"replaceable",
"washable"
]
}
},
{
"href": "/temperature/control/vs/0",
"rep": {
"x.com.samsung.da.increment": "1"
}
},
{
"href": "/mode/convenient/vs/0",
"rep": {
"x.com.samsung.da.modes": "Off",
"x.com.samsung.da.supportedModes": [
"Off",
"Sleep",
"Quiet",
"Smart",
"Speed",
"MotionIndirect",
"MotionDirect",
"Nano",
"NanoSleep"
]
}
},
{
"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": "/wind/strength/vs/0",
"rep": {
"x.com.samsung.da.modes": "3",
"x.com.samsung.da.supportedModes": [
"0",
"1",
"2",
"3",
"4"
],
"x.com.samsung.da.modesName": [
"Auto",
"Low",
"Mid",
"High",
"Turbo"
]
}
},
{
"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": "/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-28T19:55:08",
"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",
"x.com.samsung.da.triggeredTime": "2026-07-28T19:55:08",
"x.com.samsung.da.state": "Created"
}
]
}
},
{
"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": "70.0",
"x.com.samsung.da.current": "69.0",
"x.com.samsung.da.maximum": "86",
"x.com.samsung.da.minimum": "47",
"x.com.samsung.da.increment": "1.0",
"x.com.samsung.da.unit": "Fahrenheit"
}
]
}
},
{
"href": "/temperature/current/0",
"rep": {
"range": [
47,
86
],
"units": "F",
"temperature": 69.0
}
},
{
"href": "/temperature/desired/0",
"rep": {
"range": [
47,
86
],
"units": "F",
"temperature": 70.0
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.instantaneousPower": "0.000000",
"x.com.samsung.da.cumulativePower": "631483",
"x.com.samsung.da.cumulativeSavedPower": "0",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.instantaneousPowerUnit": "W"
}
},
{
"href": "/energy/consumption/0",
"rep": {
"power": 0.0
}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"Auto",
"Cool",
"Dry",
"Fan",
"Heat"
],
"x.com.samsung.da.modes": [
"Heat"
],
"x.com.samsung.da.options": [
"Sleep_0",
"ArtificialWorking_Off",
"ComfortAICooling_Off",
"AiTempChanged_Off",
"AiTemp_990",
"OutdoorTemp_78",
"CoolCapa_35",
"WarmCapa_35",
"Volume_Mute",
"StopAutoClean_Set",
"Autoclean_On",
"DiagnosisAI_Off",
"ProgressDiagnosisAI_0",
"ResultDiagnosisAI_Normal",
"KeyInputPermit_On",
"ModePermit_NoLimit",
"SmartCoolClean_Off",
"ProgressSmartClean_0",
"FreezeAlarmSetting_Off",
"DesiredFreezeAlarm_240",
"WashAlarm_Off",
"OptionCode_56636",
"ExtendOptionCode_246669",
"RacInfo_None",
"UpdateAllow_NotAllowed",
"DurationOn_0",
"WelcomeCoolingState_Off"
]
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "Off",
"operationNumber": "48"
}
},
{
"href": "/power/0",
"rep": {
"value": false
}
},
{
"href": "/sensors/vs/0",
"rep": {}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "TP1X_LNX-AC-RAC-01001_0000|10269441|60010523001811014E0048220090A000",
"x.com.samsung.da.description": "TP1X_LNX-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": "0AJV",
"x.com.samsung.da.diagSetupid": "AR2",
"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": "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": "102694A24062500",
"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": "102472A24042300,102579A10000200",
"x.com.samsung.da.newVersionAvailable": "0"
}
]
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "-03:00",
"x.com.samsung.supprtedtype": 1
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "0000000000",
"x.com.samsung.da.airconOptionList": [
"SingleCommand_1",
"DR",
"HOMECARE_WIZARD_V2",
"PRODUCT_GLOBAL",
"AI_RAC_GLOBAL_HEATPUMP_3.0",
"AI_3.0",
"Auto_To_AI",
"AI_Heat"
]
}
},
{
"href": "/humidity/0",
"rep": {
"humidity": 0
}
},
{
"href": "/humidity/vs/0",
"rep": {
"x.com.samsung.da.humidity": "0",
"x.com.samsung.da.fivepercentHumidity": "45"
}
},
{
"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": "/availablecontrolsets/vs/0",
"rep": {
"x.com.samsung.da.sets": "000001D6035C016102490C0F0000",
"x.com.samsung.da.id": "RAC",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/keepnormalstate/vs/0",
"rep": {
"x.com.samsung.da.keepnormal": 1
}
},
{
"href": "/remotedatacontrol/vs/0",
"rep": {
"x.com.samsung.da.status": "Off",
"x.com.samsung.da.connectionStatus": "Disconnected"
}
},
{
"href": "/remotetemperature/vs/0",
"rep": {
"x.com.samsung.da.temperature": "",
"x.com.samsung.da.unit": "",
"x.com.samsung.da.error": ""
}
},
{
"href": "/remotedeviceinfo/vs/0",
"rep": {
"x.com.samsung.da.didList": ""
}
},
{
"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": "045210269441FFFFFFFF",
"versions": [
"24062500",
"FFFFFFFF"
],
"visVersion": "240625"
},
{
"type": "Micom",
"modelId": "04521024724110257941",
"versions": [
"24042300",
"10000200"
],
"visVersion": "240423"
}
]
}
},
{
"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": "/timezone/vs/0",
"rep": {
"timezoneid": "America/Halifax",
"offset": "-03:00",
"DST": "ON"
}
},
{
"href": "/option/muteonce/vs/0",
"rep": {
"muteonce": "Off"
}
},
{
"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": "/reserverulesets/vs/0",
"rep": {
"x.com.samsung.da.sets": "AEFFFFFFFF3D564156FFFF2F56FFFF00001F001F00010000001F0000009C00FFFF1E00",
"x.com.samsung.da.id": "RAC",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/light/vs/0",
"rep": {
"mode": "On",
"supportedModes": [
"On",
"Off"
]
}
},
{
"href": "/mds/absencepowersaving/vs/0",
"rep": {
"status": "Off",
"modes": "Cooling_Heating",
"switchPowerSaveMode": "Normal",
"motionState": "Normal",
"supportedModes": [
"Cooling_Heating",
"Standby"
],
"supportedSwitchPowerSaveMode": [
"Eco",
"Normal",
"Comfort"
],
"supportedMotionState": [
"Normal",
"MotionNano",
"MotionSoftOff"
]
}
},
{
"href": "/mds/absencestate/vs/0",
"rep": {
"status": "Off",
"maxDetectCount": [
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254"
],
"supportedTime": [
"0",
"30",
"60"
]
}
},
{
"href": "/option/motiondetectwind/stateful/vs/0",
"rep": {
"status": "Off",
"modes": "Indirect",
"supportedModes": [
"Direct",
"Indirect"
]
}
},
{
"href": "/welcome/temperature/vs/0",
"rep": {
"operatingStatus": "None",
"requestId": "0000"
}
},
{
"href": "/wirelessinfo/vs/0",
"rep": {
"macaddressWiFi": "**REDACTED**",
"macaddressBLE": "**REDACTED**",
"connectedApSsid": "KG_IoT"
}
},
{
"href": "/quickcontrol/info/vs/0",
"rep": {
"supportedVersion": "1.0"
}
}
]
}

Some files were not shown because too many files have changed in this diff Show More