Compare commits

...
Author SHA1 Message Date
Marc Billow 760d797c12 Bump version to 0.10.1 2026-07-23 03:30:16 +00:00
Marc Billow b944549f55 Merge pull request #50 from mbillow/claude/release-notes-version-bump-1obhfb
Add range/cooktop-oven combo to supported appliance types
2026-07-22 22:28:51 -05:00
Marc Billow 94551da4a0 fix: apply writes optimistically before the settle guard (issue #27)
mark_write_pending's settle window was dropping every update for a
just-written href, including the coordinator's own post-write refresh,
because nothing ever wrote the optimistic value into the cache for it
to protect. The write reflected on the device immediately but reverted
in HA until the next 30s summary sweep.
2026-07-23 03:26:00 +00:00
Marc Billow f783aa72b9 Add range/cooktop-oven combo to supported appliance types 2026-07-23 02:47:12 +00:00
Marc Billow ed461d82cd Merge pull request #49 from mbillow/claude/release-notes-version-bump-1obhfb
Bump version to 0.10.0
2026-07-22 21:45:15 -05:00
Marc Billow 62d4ed673b Bump version to 0.10.0 2026-07-23 02:44:22 +00:00
Marc Billow e74089a545 Merge pull request #48 from mbillow/claude/issue-44-device-fixes-ejmedf
Add range/cooktop-oven combo support (issue #44)
2026-07-22 21:35:10 -05:00
Marc Billow b2f3e501e5 Merge pull request #47 from mbillow/claude/remote-control-write-validation-axyqqs
feat: reject writes when a device's remote control is disabled
2026-07-22 21:32:43 -05:00
Marc Billow 8ca2c3cb16 Address opus review: Fahrenheit setpoint bounds, OVEN_SPEC coverage, docstring
- NumberDesc gains native_min_fn/native_max_fn/step_fn hooks (mirroring the
  existing unit_fn pattern) so an entity's slider bounds can track the live
  rep instead of staying pinned to whatever unit the descriptor was written
  against. Oven setpoint was hardcoded to Celsius bounds (30-270), which
  silently capped issue #44's Fahrenheit range at 270F -- below a normal
  350F bake temp. Verified Fahrenheit bounds (175-550, step 5) come from
  that dump's /mode/vs/0 Bake modeSpec.
- Wire oven.OVEN_SPEC into the oven registry, not just range -- it was only
  reachable from range before, so a standalone oven reporting
  /oven/spec/vs/0 would have false-tripped the coverage-gap repair.
- Fix a docstring in test_golden_regression.py left over from the
  cooktop.py -> range.py rename.
2026-07-23 02:32:34 +00:00
Marc Billow aec3c5e460 Rename cooktop.py capabilities module to range.py to avoid PR #23 collision
PR #23 independently adds registry/capabilities/cooktop.py for an unrelated
standalone-cooktop product (NA9300K-class, burner state encoded in
/mode/vs/0's options array) -- different hardware and a different OCF
surface than issue #44's oven+cooktop combo range, but the same file path.
Rename ours to range.py to keep both mergeable.
2026-07-23 02:32:34 +00:00
Marc Billow 321f186b0e Add range/cooktop-oven combo support (issue #44)
TP1X_DA-KS-RANGE-0102X (model NSI6DG9100SRAA) reports no oneUiVersion and
previously fell through to the unknown-device fallback, leaving /connected,
/cooktop/spec, /cooktop/settings/status, /cooktop/status, and /oven/spec
unbound. Add a 'range' device registry that reuses the oven family's
cavity/setpoint/mode/operational-state/door capabilities and adds a new
cooktop.py module modeling per-burner power level, state, and hot-surface
entities (gated so unreported burner slots don't appear), plus a hot-surface
auto-shutoff config sensor. Route range/cooktop models to it via a
'-RANGE-' modelNum token, mirroring the existing RAC/PRAC air-conditioner
fallback pattern.

The dryer (DA_WM_TP1_21_COMMON) pause/stop buttons mentioned in the same
issue are working as intended -- the reporter confirmed that's an expected
in-person-only limitation, not a bug.
2026-07-23 02:32:34 +00:00
Marc Billow 2f6b7d54e3 refactor: share remote-control read between sensor and write guard, poll it warm
Move the on/off interpretation into a single remote_control_enabled()
in registry/capabilities/common.py so the write-guard added in the
previous commit can't silently drift from the Smart Control binary
sensor's own reading of the same hrefs. Also promotes both
/remotectrl hrefs to poll_tier='warm' so the coordinator's cached
state backing that write guard doesn't lag up to a full 30s cold
summary poll behind the device's actual toggle state.
2026-07-23 02:29:03 +00:00
Marc Billow 6e5fee32b8 feat: reject writes when a device's remote control is disabled
Devices with a /remotectrl href already surface it as a read-only
"Smart Control" binary sensor, but writes weren't checking it before
now. async_send_command now blocks every write (any platform) with a
ServiceValidationError telling the user to enable remote control via
the appliance's manual, ahead of any per-description validate_fn.
2026-07-23 02:15:40 +00:00
Marc Billow 1e9437ef3c Merge pull request #46 from mbillow/claude/issue-27-investigation-ff60pw
fix(fridge,observe): merge partial cache updates, name ice makers dynamically
2026-07-22 20:31:19 -05:00
Marc Billow 36f7aa0b8c refactor: dedup naming helpers, close observe-cache merge race
/simplify pass on the issue #27 fix: extract a shared _snake_to_title
between entity.py and discovery.py, factor discover()'s two binding
loops through one _bind() helper, and close a TOCTOU race the cache
merge introduced -- apply() is the sole path StateCache mutations flow
through, so the read-then-write is now serialized under one lock
instead of two independently-locked calls.
2026-07-23 01:28:47 +00:00
Marc Billow 10ab51c7f3 fix(fridge,observe): merge partial cache updates, name ice makers dynamically
Issue #27: the flex-zone/cooler-drawer select vanished after a device
stopped including supportedOptions on an update for /mode/vs/0.
ObserveManager.apply() handed reps straight to StateCache.apply_rep,
which fully replaces the cached rep -- so a partial update (missing a
field the select's exists_fn/options_field gate on) silently erased
data a fuller update had previously supplied. apply() now merges
incoming reps onto whatever's already cached instead.

Also give ice-maker entities (and any future pattern-cap instance) a
device-given display name instead of the href-derived "Icemaker
One"/"Icemaker Two": Capability.name_field lets a pattern capability
read and normalize an instance name (e.g. iceMaker.name's "CUBED_ICE")
for use as the entity name prefix, independent of the stable
key/unique_id.
2026-07-23 01:28:35 +00:00
Marc Billow 8bf9002eb7 Merge pull request #45 from mbillow/claude/climate-entity-href-observation-2wlnlw
Fix AC climate state lag by promoting consumed hrefs to warm poll tier
2026-07-22 20:14:02 -05:00
Marc Billow fb0f24a175 Fix AC climate state lag by promoting consumed hrefs to warm poll tier
CLIMATE_CONSUMED_HREFS (power, current/target temp, fan, swing, preset)
were bound as no-entity coverage capabilities with the Capability
default poll_tier='cold'. The coordinator only OBSERVE-subscribes and
sub-polls 'hot'/'warm' hrefs, so cold-tier state only refreshed on the
~30s full /device/0 summary sweep -- matching the 20-30s HA lag reported
on issue #17 despite commands landing on the device instantly. Pin them
to 'warm', same as CLIMATE's own primary href, so they get push
notifications (or warm-tier sub-polling as a poll-only fallback).
2026-07-23 01:01:39 +00:00
Marc Billow 5944d14bca Merge pull request #43 from mbillow/claude/ai-level-href-binding-djhmlv
Bind AI energy level on washer; switch/select split, drop translations
2026-07-22 19:46:59 -05:00
Marc Billow 1493467c63 Fix AI energy level platform-flip bug found in Opus review
The switch and select shared a stub-time asymmetry: only the select had a
`not rep` carve-out, so an unfetched-stub rep at the moment platforms are
set up (entity creation runs once, ever) would instantiate a Select, while
flatten() re-evaluates exists_fn every poll against live data -- once the
resource populated to a single-level list, the switch's exists_fn would win
instead and feed the already-created Select a bool through their shared
'ai_energy_level' key, which isn't a valid select option.

Dropped the stub carve-out from the select's exists_fn so both sides
require real, populated data to decide the platform -- on a device that
stubs this cold-tier href on its very first poll, the entity now simply
doesn't appear until a reload, instead of appearing as the wrong widget
type. Added tests for the missing/empty-list supportedAiLevel shapes on
both widgets and a regression test locking in the stub behavior.
2026-07-23 00:44:58 +00:00
Marc Billow 61ee2972dc Trim duplicated comments from /simplify review
Consolidate the AC/power-exclusion rationale (previously spelled out nearly
verbatim in three places) down to one canonical explanation next to
common.POWER, with one-line pointers elsewhere. Dedupe the switch/select
test classes' identical _desc() lookup into one shared helper.
2026-07-23 00:44:58 +00:00
Marc Billow 3d5263d792 Add common.UNIVERSAL/POWER bundles to stop hand-duplicating capabilities
FIRMWARE_UPDATE and ALARMS were already copy-pasted into all 6 device-type
registries by hand; POWER/KIDS_LOCK/REMOTE_CONTROL into 5 of 6. Consolidate
into two bundles in common.py, unpacked via *common.UNIVERSAL / *common.POWER
the same way ignored.IGNORED already is:

- UNIVERSAL: ALARMS, ENERGY_METER, FIRMWARE_UPDATE (moved from fridge.py),
  SELF_CHECK (moved from fridge.py), AI_ENERGY_LEVEL, and the kids-lock/
  remote-control pairs. Safe everywhere -- discover() only binds a href
  actually present in a device's dump, so a capability with no known
  conflicting family is a no-op where the href is absent and a real,
  wanted entity where it's present. This also broadens AI_ENERGY_LEVEL,
  ENERGY_METER, and SELF_CHECK to device types they weren't confirmed on
  before, on the same reasoning.
- POWER: just POWER_GENERIC/POWER_VS_FALLBACK, applied to the 5 non-AC
  registries. Airconditioner keeps its own opt-out: its climate entity
  already owns /power/0 and /power/vs/0 via a bare, no-entity claim
  (airconditioner.COVERAGE), and a real power capability on the same
  href would make _build() raise (a href with multiple caps requires
  every cap to have rt_filter or match_fn; the bare COVERAGE cap has
  neither).

Full test suite (327 tests, all 6 device-type golden fixtures) passes
unchanged -- none of the newly-broadened capabilities bind on any existing
fixture, confirming the no-op reasoning held in practice, not just theory.
2026-07-23 00:44:58 +00:00
Marc Billow d6639c99d4 Bind AI energy level on washer; switch/select split, drop translations
Issue #40: /energy/ailevel/vs/0 was unbound on a plain washer. The
capability already existed for fridges but was gated off entirely on
single-level hardware (the common case), so it's moved to common.py
(cross-family, like fridge + washer now) and split into two entities:
a switch when supportedAiLevel has exactly one entry (aiLevel is really
just an on/off toggle there), and a select otherwise, with '0' (off)
synthesized back into the select's options since supportedAiLevel never
lists it but it's a real observed value.

Also drops the translation_key/strings.json entries -- aiLevel's raw
digit values already render fine untranslated, and translating a
handful of levels can't cover devices with more.
2026-07-23 00:44:57 +00:00
Marc Billow 5d9b470d7e Merge pull request #42 from mbillow/claude/translation-cycles-22-dxgozk
Add bubble soak, pre-wash, and intensive switches for washers (#22)
2026-07-22 14:56:26 -05:00
Marc Billow 9800cd0aa8 refactor: lift options[] boolean-toggle machinery into laundry.py
Addresses the reuse finding skipped in the previous /simplify pass:
washer's bubble soak/pre-wash/intensive switches and dishwasher's storm
wash/auto release dry switches were two separate implementations of the
same '<prefix>_On'/'<prefix>_Off' read-modify-write-on-options[] contract.

Moved bool_option_write/bool_option_value/bool_option_exists/
bool_option_switch into laundry.py (same module that already owns
cycle_write/cycle_select for the identical 'Course' token), and pointed
both washer.py and dishwasher.py at it. washer.py keeps only its
washer-specific per-course validate_fn, passed into the shared factory
as a prebuilt callable -- the factory itself has no opinion on validation.

No behavior change; re-verified every existing assertion (washer toggles,
dishwasher storm_wash/auto_release_dry, dosing alarms) plus all golden
state-key sets by hand against the refactored code.
2026-07-22 19:45:57 +00:00
Marc Billow 3469f57080 refactor: simplify washer toggle switches, move validate_fn to coordinator
/simplify pass on the bubble soak/pre-wash/intensive switches:
- Move validate_fn dispatch from switch.py into coordinator.async_send_command,
  next to the existing write_fn getattr -- every platform gets validation for
  free instead of switch.py hand-rolling it alone, and it avoids building the
  full resources snapshot twice per write (switch.py was calling
  coordinator.last_resources twice; the coordinator now snapshots once, and
  only when a validate_fn is actually present).
- Collapse the four per-switch factories (write/value/exists/validate) plus
  the _AVAILABILITY_FIELD side-table into one _bool_option_switch() that
  builds the SwitchDesc directly, so the three call sites read as one line
  each instead of six, and a typo'd prefix can no longer silently KeyError
  against a separate lookup table.
- Rename _dosing_alarm_exists to _option_exists and reuse it for the new
  switches too -- it was already the exact same "is this token present"
  check the toggles need.

No behavior change; re-verified write_fn/rep_fn/exists_fn/validate_fn against
the same fixtures and golden state-key sets as before.
2026-07-22 19:45:57 +00:00
Marc Billow f54daf3608 feat: reject bubble soak/pre-wash/intensive writes on unsupported courses
Add a validate_fn hook to SwitchDesc, checked in switch.py before dispatch
and surfaced as a ServiceValidationError so an unsupported write shows a
real error in the UI instead of the coordinator's silent log-only rejection.

Wired it into the three course-gated washer switches using their
availability bitmaps (BubbleSoakSet/PreWashAvailableSet/IntensiveAvailableSet),
which line up positionally with editCourseList. Turning a toggle off is
never blocked, and the check fails open whenever the course or bitmap can't
be resolved.

Also fixes a bug in _bool_option_write: it took a `p and 'On' or 'Off'`-style
truthy check, but switch.py always calls it with the string 'On' or 'Off' --
both truthy, so every write landed as 'On' regardless of intent.
2026-07-22 19:45:57 +00:00
Marc Billow e49b61e01e feat: add bubble soak, pre-wash, and intensive switches for washers (#22)
A follow-up dump confirmed these ride as plain BubbleSoak_On/Off,
PreWashSetting_On/Off, and IntensiveSetting_On/Off tokens in the same
/course/vs/0 options array as the cycle select, so they're exposed as
self-gating config switches the same way other options-array fields are.

Per-cycle availability (BubbleSoakSet/PreWashAvailableSet/IntensiveAvailableSet)
lines up positionally with editCourseList but isn't used for gating, since
exists_fn only runs once at setup against whatever course happened to be
active then.
2026-07-22 19:45:57 +00:00
Marc Billow dbe70ffc5b Merge pull request #41 from mbillow/claude/translation-cycles-22-dxgozk
fix: add missing washer cycle translations for combo units (#22)
2026-07-22 13:51:16 -05:00
Marc Billow 7d011bfe89 fix: add missing washer cycle translations for combo units (#22)
A washer/dryer combo user's editCourseList carries five Course_XX codes
that weren't named in washer_cycle: 36 (Wash+Dry), 37 (Air Wash),
38 (Cotton Dry), 39 (Synthetics Dry), and 1F (Intense Cold, distinct
from the existing 8F code used by non-combo models).
2026-07-22 18:50:56 +00:00
Marc Billow 9199fb7711 Merge pull request #39 from mbillow/claude/ac-support-triage-3zyjj9
feat: extend AC support for TP2X_RAC_20K and TP1X_DA-AC-RAC-01001_0000
2026-07-22 09:28:41 -05:00
Marc Billow 6611f85958 chore: bump version to 0.9.0
Covers PR #36's TP1X temperature-fallback fix and display-light switch,
plus TP2X_RAC_20K device-type recognition and full TP1X_DA-AC-RAC-01001
capability coverage (issues #37, #38).
2026-07-22 14:25:10 +00:00
Marc Billow 8470853fb6 fix: reconcile AC coverage with PR #36, bind selfcheck/mute-once for TP1X-01011 too
Rebased onto PR #36's merge (temperature fallback for /temperatures/vs/0,
display-light switch). Reconciliation, per Opus review:

- Dropped our duplicate LIGHT capability in favor of PR #36's DISPLAY_LIGHT
  (same href/entity, cosmetic differences only).
- Dropped /option/muteonce/vs/0 and /selfcheck/vs/0 from the ignore list
  PR #36 added -- both have confirmed, cleanly modelable contracts (a plain
  On/Off field, and a documented Start/Cancel self-check action already
  covered by the existing fridge.SELF_CHECK pattern), so keeping our bound
  versions instead of ignoring them. This also picks up mute_once/selfcheck_*
  on PR #36's own TP1X_DA-AC-RAC-01011 fixture -- regenerated its golden.
- Deduped the ~10 housekeeping hrefs both branches independently ignored.
- Added a unit test for climate.py's _temps_vs_item (the temperature-
  fallback selection logic), which had no coverage -- climate.py isn't
  importable in the registry-level AC tests, and the existing "no unbound
  hrefs" tests only exercise binding, not the fallback itself.
2026-07-22 14:20:58 +00:00
Marc Billow e942c822a6 feat: extend AC support for TP2X_RAC_20K and TP1X_DA-AC-RAC-01001_0000
Closes the coverage-gap repair for two more room-AC boards (issues #37,

- TP2X_RAC_20K reports no oneUiVersion and no '_PRAC_' modelNum token, so
  its device type went unrecognized -- add a '_RAC_' fallback token to
  for_device_by_model.
- Bind the resources both dumps actually populate: mute-once (switch),
  display light (switch), self-check (reused from fridge.SELF_CHECK), and
  the circuit-breaker current-limit setting (read-only diagnostic sensors,
  since its write contract isn't confirmed from the dump).
- Ignore the rest as plumbing/opaque/unconfirmed: control-set descriptor,
  keepnormalstate flag, software-reset trigger, AI Sleep handshake,
  absence-monitoring stubs, remote-data-control, remote-temperature,
  reservation ruleset, and the welcome-temperature tracker.

Adds scrubbed fixtures + goldens for both dumps and extends the AC
capability/golden-regression tests to assert zero unbound hrefs.
2026-07-22 14:20:55 +00:00
Marc Billow 4a376a98aa Merge pull request #36 from paddawan2002/fix/tp1x-airconditioner-coverage
Fix AC coverage for TP1X-class firmware (oneUiVersion "7.0 Air conditioner")
2026-07-22 09:12:33 -05:00
paddawan2002 bc9d4f4cf5 Fix AC coverage for TP1X-class (oneUiVersion "7.0 Air conditioner") 2026-07-22 11:26:01 +02:00
Marc Billow 10f2aac0bd Merge pull request #35 from aaearon/feat/fridge-tp1x-ai-energy-selfcheck
feat: add AI energy level select and self-check error sensor for TP1X refrigerator
2026-07-22 02:31:20 -05:00
Tim Schindler 0cc9d8e511 fix: render selfcheck_error as no-value on empty list; document ai_energy_level level labels 2026-07-22 09:04:24 +02:00
Marc Billow b586277031 Merge pull request #34 from aaearon/fix/flaky-observe-resubscribe
test: remove timing race from coordinator observe-mode tests
2026-07-22 01:56:58 -05:00
Tim Schindler 2a0a9f32ee feat: add AI energy level select and self-check error sensor for fridges
Adds two small, additive fridge capabilities:

- AI_ENERGY_LEVEL: a select on /energy/ailevel/vs/0 exposing the AI
  energy-saving level, gated behind supportedAiLevel actually offering
  more than one choice (previously globally ignored since every dump
  seen only reported a single supported level).
- selfcheck_error: a diagnostic sensor on the existing SELF_CHECK
  capability surfacing x.com.samsung.da.error from the last self-check,
  for hardware that reports it.

This does not touch FLEX_ZONE, /mode/vs/0, or /icemaker/status/0.
2026-07-22 08:42:31 +02:00
Tim Schindler 8dadea6796 test: address Copilot review on observe fixture typing and comment
- Parameterize FakeObserveSession.notify_on_subscribe as dict[str, Any] | None
  to match the surrounding fully-typed attributes (the dict is delivered as
  the OBSERVE rep to on_notification); add the typing.Any import.
- Reword the test_coordinator comment: subscribe() delivers a notification
  for every href it subscribes to (when notify_on_subscribe is set), not one.
2026-07-22 08:22:45 +02:00
Marc Billow 2cd93619a4 Merge pull request #33 from mbillow/claude/triage-dump-device-support-1pqxfr
feat: detect FlexWash (WV) washers; add combo dry-level select
2026-07-22 01:17:29 -05:00
Marc Billow 5a9a002a7f chore: bump version to 0.8.0
Covers the #19-#27 fridge/washer coverage-gap batch: FlexWash (WV) and
washer/dryer combo detection, the CV_FDR_ flex-zone fix (also closes #32),
the Cool Select Zone pantry select, and the new energy sensors.
2026-07-22 06:12:17 +00:00
Tim Schindler 0715eb0829 test: remove timing race from coordinator observe-mode tests
The observe tests raced a background thread (5 ms wall-clock sleep)
against the coordinator reaching its resubscribe. try_enter_observe_mode
clears _notified before subscribing, so a notify only counts if it lands
between that clear and the end of the grace sleep. When the intervening
update cycle (reconnect, cache sweep, executor hops) took longer than
5 ms, every notify was cleared and the mode stayed 'poll' —
test_reconnect_from_observe_mode_resubscribes_immediately failed roughly
1 in 7 runs.

Replace the threads with FakeObserveSession.notify_on_subscribe, which
delivers the rep synchronously from subscribe() the way a real device
answers a subscription. That puts the notify inside the grace window by
construction rather than by timing. Tests that require the resubscribe to
fail now set it to None explicitly instead of relying on the absence of a
racing thread.

No production code changed; no assertion weakened, and no sleeps, retries
or timeouts added.

Claude-Session: https://claude.ai/code/session_01PUSU6tDjHjtbExtyXDPT3N
2026-07-22 08:11:18 +02:00
Marc Billow 43542b146f fix: restore empty-stub carve-out on the new energy sensors; dedupe flex-zone set build
Review follow-up (Opus + /simplify) on the previous commit:

- power_energy_kwh/energy_saved_kwh/energy_last_month_kwh/
  energy_this_month_kwh used a plain `field in rep` exists_fn, unlike their
  siblings power_watts/energy_kwh in the same capability. Per
  entity._is_included, an explicit exists_fn bypasses the stub carve-out
  entirely -- an empty {} rep at platform setup (device/0 returned a
  not-yet-fetched stub) would permanently drop these entities for the
  session instead of picking them up once a sub-poll populates the
  resource. Restore the same `not rep or ...` guard used above.
- fridge._flex_zone_current/_flex_zone_write independently rebuilt the same
  supportedOptions set; factor into _flex_zone_supported.
- note in a comment that the flex-zone match assumes at most one
  modes/supportedOptions overlap (true on every dump seen); add the
  missing negative assertion that dry_level self-gates off on a plain
  washer (was only positively asserted on the combo fixture).
2026-07-22 06:11:16 +00:00
Marc Billow 9a899080fa fix: fridge/washer coverage gaps from the #19-#27 dump batch
Six new diagnostics dumps, six gaps closed:

- refrigerator: bind /diagnosis/vs/0 (reuse dishwasher.DIAGNOSIS -- same
  shape) into the refrigerator registry; it was never wired up there,
  tripping the coverage repair on any fridge that reports it (#20, #26).
- refrigerator: add PANTRY_ZONE for the Cool Select Zone pantry compartment
  (/status/pantry/one/vs/0, x.com.samsung.da.mode/supportedOptions) -- same
  shape as BEVERAGE_ZONE but a distinct resource/field set (#20).
- refrigerator: generalize FLEX_ZONE to identify the current mode by list
  membership in supportedOptions instead of a hardcoded
  CV_TTYPE_RF9000A_ prefix check. TP1X/Bespoke-class fridges use a
  CV_FDR_ prefix instead, which the old code didn't recognize -- the
  select existed but always read as unknown, and writing to it would have
  appended a duplicate CV_FDR_ flag rather than replacing the existing one
  (#26, #27; also closes #32).
- common: add energy_saved_kwh (x.com.samsung.da.cumulativeSavedPower) and
  power_energy_kwh (x.com.samsung.da.cumulativeConsumption) to the shared
  energy meter, plus fridge-only energy_last_month_kwh/energy_this_month_kwh
  (monthlyConsumption/thismonthlyConsumption) -- all self-gating on field
  presence (#26).
- by_type: add the WV consumer-model prefix (FlexWash twin washers, e.g.
  WV55M9600AW) to the by-model fallback map. These report no oneUiVersion
  and previously matched no prefix at all, so they fell all the way
  through to the unrecognized-device registry with zero capabilities
  bound (#19).
- washer: add a self-gating dry_level select to WASHER_SETTINGS for
  washer/dryer combo units, which carry a writable dryLevel field
  directly on /washer/vs/0 with no separate dryer resource or course
  (#22).

The reset-water-filter button and "ice type vs. two named icemakers"
requests from #26/#27 are left alone -- no write contract or exclusivity
behavior is evidenced in either dump, and both icemakers report On
simultaneously on the Bespoke unit, so synthesizing a single-select would
be a guess rather than a fix.

Adds scrubbed fixtures + goldens for FlexWash, a washer/dryer combo,
ARTIK051_REF_17K, and TP2X_REF_20K, plus unit tests for the new/changed
capabilities.
2026-07-22 06:11:15 +00:00
Marc Billow 88e630b54f feat: detect FlexWash (WV) washers; add combo dry-level select
- WV consumer-model prefix (FlexWash twin washers, e.g. WV55M9600AW)
  wasn't in the by-model fallback map, so these units fell through to
  the unknown-device registry with zero capabilities bound (#19).
- Washer/dryer combo units carry a writable dryLevel field directly on
  /washer/vs/0 with no separate dryer resource; add a self-gating
  select for it, off supportedDryLevel presence, so plain washers are
  unaffected (#22).
2026-07-22 06:11:15 +00:00
Marc Billow 70a7f861c3 Merge pull request #25 from themaanda/contrib/detect-wf-washers
Detect WF-series washer models
2026-07-22 00:20:36 -05:00
Marc Billow ff034905a2 Merge pull request #24 from themaanda/contrib/tp1x-refrigerator-mirrors
Cover TP1X refrigerator native resource mirrors
2026-07-22 00:20:03 -05:00
Marc Billow ae8bb0b621 Merge pull request #29 from mbillow/claude/bump-version-0.7.0
chore: bump version to 0.7.0
2026-07-21 19:19:04 -05:00
Marc Billow 3688cf24a0 chore: bump version to 0.7.0
Covers the air-conditioner support (#17) and the washer dosing-select /
diagnostics fixes (#9). main was still advertising 0.6.0 despite the AC work
already merging, so this moves it for the next release.
2026-07-22 00:15:22 +00:00
Marc Billow d917d757e9 Merge pull request #28 from mbillow/claude/issue-9-dosing-select-format-and-blocking-fix
fix: normalize washer dosing-select codes; stop blocking the loop in diagnostics (#9)
2026-07-21 19:10:29 -05:00
Marc Billow f4b4a04628 refactor: tidy the AC climate entity (/simplify pass)
Quality-only cleanups on the air-conditioner support, no behavior change:

- climate.py: reuse common.normalize_temp_unit for the C/F unit read (also
  handles the "Celsius"/"Fahrenheit" long forms); collapse the three
  fan/swing/preset read properties into _read_mode/_read_modes and the three
  write setters into _set_mapped, removing the copy-paste.
- Single source of truth for the climate-consumed hrefs: they lived both as
  constants in climate.py and as a list in airconditioner.py. Declare them
  once in airconditioner.py (HREF_* + CLIMATE_CONSUMED_HREFS, which also builds
  the COVERAGE caps) and import them into climate.py, so a new sibling read
  can't drift out of sync with its coverage entry.
2026-07-21 23:59:03 +00:00
Marc Billow c0298d57d9 fix: normalize washer dosing-select codes; stop blocking the loop in diagnostics (#9)
Two fixes for issue #9 (WW90T634DHE washer):

- washer dosing selects: the four detergent/softener dosing selects read their
  current value from `<Prefix>LevelCtrl_<code>` (un-padded, e.g. "3") but their
  options from `Supported<Prefix>LevelCtrl_<hexpairs>` (zero-padded, e.g. "03").
  HA's SelectEntity renders a select "unknown" whenever current_option is not in
  options, so all four sat "unknown" (idle and running) even though every other
  select worked -- which is why it was only those four. Normalize the current
  value to the supported code with the same integer value so it matches an
  option (and its translation); convert back to the device's native un-padded
  format on write.

- diagnostics: pkg_version("smartthings-local") reads package metadata off disk
  (listdir + open + read_text), tripping HA's event-loop blocking-call detector.
  Offload it to the executor. Audited the rest of the package: config_flow's
  socket/crypto and every coordinator DTLS call are already offloaded via
  async_add_executor_job -- this was the only blocking call left on the loop.

Also documents air-conditioner support in the README (device table, capability
module list, platform list), missed when that support landed.

Updates the washer dosing tests to the corrected value/write format and adds a
current-option-is-a-valid-option regression; adds a diagnostics test asserting
the version lookup runs off the event loop.
2026-07-21 23:49:30 +00:00
themaanda 0503bda0f3 Detect WF-series washer models 2026-07-21 16:59:16 -05:00
themaanda 6f53b3043b Cover TP1X refrigerator native resource mirrors 2026-07-21 16:58:42 -05:00
Marc Billow bedc662b47 Merge pull request #18 from mbillow/claude/device-support-triage-17-665d44
feat: air conditioner support with a composite climate entity (#17)
2026-07-21 09:55:35 -05:00
Marc Billow 296535d0f2 feat: air conditioner support with a composite climate entity (#17)
Add support for Samsung room air conditioners (ARTIK051_PRAC-class), the
first device whose core controls map onto a single Home Assistant `climate`
entity rather than a scatter of switches/selects/numbers.

- New `climate` platform + `ClimateDesc`: one composite entity that reads
  power, HVAC mode, current/target temperature, fan (wind) strength, swing
  (wind direction) and the convenient-mode preset across several OCF
  resources and writes back to each. On/off folds into HVACMode.OFF /
  TURN_ON/OFF; convenient mode folds into preset_mode. The entity binds one
  primary resource (/mode/vs/0) and reads its siblings from the coordinator
  snapshot, reusing the cross-resource read pattern from number/select.
- New `airconditioner` capability module + by_type registry, routed via the
  `_PRAC_` modelNum token. Reuses common ALARMS/ENERGY_METER,
  fridge.FIRMWARE_UPDATE and dishwasher.DIAGNOSIS; air purify and auto clean
  as config switches; air dust filter status/usage as diagnostics (usage
  normalized to a percentage of rated capacity).
- Climate-consumed and all-zero/ambiguous resources (temperature/wind,
  /sensors, /humidity) are declared as AC-scoped coverage so every href in
  the dump binds or is covered -- no coverage-gap repair.
- Fan/swing modes map onto HA standard constants (auto-localized); the
  custom fan `turbo`, presets `quiet/smart/speed`, and the filter-status
  enum get translations in strings.json + translations/en.json.
- Scrubbed fixture, golden, and tests: registry routing, zero unbound
  hrefs, the climate write contract, and filter-% normalization.
2026-07-21 14:38:14 +00:00
Marc Billow d7fe609159 Merge pull request #16 from mbillow/claude/dryer-support-issue-14
feat: full dryer support + consistent laundry capability surface
2026-07-20 21:17:18 -05:00
Marc Billow 15f836caeb ci: bump actions/checkout and actions/setup-python to v7 (Node 24)
Clears the 'Node 20 is being deprecated' warnings by moving the actions
we pin to their current Node 24 majors (both released 2026-07-20).
hassfest@master and hacs/action@main are third-party and float on their
own branches.
2026-07-21 02:09:55 +00:00
Marc Billow 1576d095c8 ci: run pytest in CI; complete dev requirements; unblock socket test
The suite was never run in CI (only hassfest/HACS), and requirements-dev
listed only pytest + smartthings-local while conftest.py needs the full
Home Assistant test harness.

- requirements-dev.txt: add pytest-homeassistant-custom-component (pulls
  in home-assistant + pytest + pytest-socket) and the integration's
  runtime deps (cbor2, pyOpenSSL, cryptography) needed to import it.
- .github/workflows/validate.yml: add a Pytest job on Python 3.14
  (current Home Assistant's floor) that installs requirements-dev and
  runs the suite.
- test_config_flow.py: the UDP liveness-sweep test needs real loopback
  sockets, which the HA harness blocks by default; take the pytest-socket
  socket_enabled fixture so it runs instead of erroring.

Full suite: 249 passed.
2026-07-21 02:05:57 +00:00
Marc Billow febc8fa4a0 fix: keep energy sensors on empty-stub reps; gate power on field presence
Review follow-up. An explicit exists_fn bypasses entity._is_included's
empty-{} stub carve-out, so the sentinel-aware energy meter would drop
power_watts/energy_kwh when /device/0 returns a not-yet-fetched stub for
/energy/consumption/vs/0. Restore the carve-out (`not rep or ...`), and
hide power_watts when instantaneousPower is absent from a populated rep
(not just when it's the -500 sentinel) so a partial rep can't spawn a
phantom power sensor.
2026-07-21 01:39:49 +00:00
Marc Billow 9264b912f9 feat: full dryer support + consistent laundry capability surface
First full dryer dump (issue #14, DV90BB5245AES1) surfaced 5 unbound
hrefs and an "incomplete capability coverage" repair. Handle them and,
while here, make the washer/dryer/dishwasher families consistent instead
of each carrying a bespoke variant of the same controls.

Dryer coverage:
- /power/0, /kidslock/0, /remotectrl/0: bind via the OCF-native + vendor
  fallback pairs (prefer the standard OCF resource, fall back to -vs).
- /buzzersound/vs/0: new Buzzer sound select.
- /course/vs/0: cycle select shared with washer/dishwasher; ignore the
  /st/dryercourse/vs/0 re-encoding (mirror of /st/washercourse/vs/0).

Consistency / de-duplication:
- Move generic OCF controls (power/kids-lock/remote-control fallback
  pairs, energy meter) into common.py; every registry uses them.
- Move shared laundry controls (buzzer, job-beginning-status, and the
  /course/vs/0 cycle-select machinery) into laundry.py; washer and
  dishwasher stop hand-rolling their own copies.
- Energy meter is now sentinel-aware everywhere: the dead '-500'
  instantaneousPower reading no longer shows a misleading 0 W (fixes it
  on dryers and dishwashers, matching the earlier washer fix).
- Job-beginning-status reads x.com.samsung.da.currentStatus, the field
  every dump actually carries; the dryer sensor was previously blank.

Adds a scrubbed dryer fixture, golden, and capability tests, plus an
.claude/skills/adding-device-support skill capturing the dump-reading,
OCF-vs-vendor, entity-taxonomy, and coverage workflow. Bumps to 0.6.0.
2026-07-21 01:15:12 +00:00
Marc Billow 84788174f8 Merge pull request #15 from mbillow/claude/device-port-detection-vp0q8d
feat: auto-detect DTLS port across the full 49152-49160 range
2026-07-20 16:55:31 -05:00
Marc Billow 00c3ebaf75 feat: auto-detect DTLS port across the full 49152-49160 range
The config flow only probed 49154/49155, so appliances whose local
CoAP/DTLS API binds elsewhere in the ephemeral range (e.g. a dishwasher
answering on 49153) could never be added.

Add a fast UDP liveness sweep across 49152-49160 that uses the ICMP
port-unreachable / ECONNREFUSED asymmetry to find the live port(s)
before attempting the expensive DTLS handshake. Closed ports fall out
immediately; a live-but-silent port is kept as a candidate. The real
handshake then runs only against discovered ports, preferring the
historically known 49154/49155 when several look live.

Also drop the probe's /device/0 GET deadline from a bare 15s literal to
a named PROBE_GET_TIMEOUT_S constant (10s) — the slowest observed full
dump is ~8s, and 10s matches the per-resource read timeout used
elsewhere.

Bumps version to 0.5.0.
2026-07-20 21:52:35 +00:00
Marc Billow 4cc073c88a Merge pull request #12 from mbillow/claude/readme-cleanup-b3ib70
docs: simplify and update README
2026-07-18 23:34:43 -05:00
Marc Billow 91013ece60 fix: translate machine_state sensor values; bump to 0.4.1
machine_state was rendering the raw lowercase OCF values (idle/active/
pause) untranslated in the UI. Add device_class=enum, options, and a
translation_key matching the existing pattern used by ice_making_status
and connection_mode, in both the shared operational.py capability and
oven.py's separate machine_state sensor.

Bump patch version for this fix plus the ice_type 'off' translation and
iot_class correction earlier on this branch.
2026-07-19 04:33:23 +00:00
Marc Billow 795763f3ca fix: add missing ice_type 'off' state translation
The refrigerator fixture's x.com.samsung.da.iceType.supported list
includes "Off" alongside the whiskey_iceball_* values, but strings.json
only translated the whiskey_iceball options, leaving "Off" to render
untranslated in the ice-type select.
2026-07-19 04:18:59 +00:00
Marc Billow a7ed230569 fix: correct iot_class to local_push
Coordinator prefers CoAP OBSERVE push notifications (observe.py) over
polling when the device supports it, falling back to polling only when
observe mode isn't available or drops.
2026-07-19 04:11:01 +00:00
Marc Billow 9fee5b5ec0 docs: simplify smartthings-local intro blurb 2026-07-19 04:07:52 +00:00
Marc Billow f24d6a0ae6 docs: simplify and update README
Add missing washer support to the appliance table, fix stale test-count
and repo-layout claims, trim protocol-internals jargon that belongs to
the smartthings-local library rather than this integration, and point
"Adding a new appliance type" at HA's own diagnostics download instead
of a gitignored local script.
2026-07-19 04:05:37 +00:00
86 changed files with 8979 additions and 876 deletions
@@ -0,0 +1,161 @@
---
name: adding-device-support
description: >-
Add or extend support for a Samsung OCF appliance in localthings from a
/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,
OCF-standard vs vendor hrefs, the diagnostic/config/normal entity taxonomy,
ensuring every href is bound or ignored, and locking it in with a fixture +
golden + test.
---
# Adding device support
localthings maps a Samsung appliance's OCF resources (`/device/0` dump) to Home
Assistant entities. Each resource `href` is handled by a `Capability` that
declares the entities it produces. This skill is the workflow for turning a new
dump into coverage.
## 1. Get the dump and see the gaps
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.
- `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`).
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.
## 2. Compute coverage without Home Assistant
The `registry/` package is HA-free, so you can drive discovery directly (HA
isn't importable standalone because `localthings/__init__.py` pulls it in — stub
the package to skip that):
```python
import sys, types, json, importlib
cc = types.ModuleType('custom_components'); cc.__path__=['custom_components']; sys.modules['custom_components']=cc
lt = types.ModuleType('custom_components.localthings'); lt.__path__=['custom_components/localthings']; sys.modules['custom_components.localthings']=lt
by_type = importlib.import_module('custom_components.localthings.registry.by_type')
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
unbound = []
bound = discovery.discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
state = adapter.flatten(bound, resources) # {entity_key: value}
print('registry:', reg.name, 'unbound:', sorted(unbound))
print('state_keys:', sorted(state))
```
`discover()` binds caps (applies `rt_filter`/`match_fn`); `flatten()` applies
`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`)
Samsung appliances run RT-OCF and often expose the **same state twice**:
- `/x/vs/0` — **vendor** resource, `x.com.samsung.da.*` fields.
- `/x/0` — **standard OCF** resource type (`oic.r.*`) with OCF's fixed field
names. Confirmable against the OCF spec: `/power/0` `{value: bool}` is
`oic.r.switch.binary`; `/operational/state/0` is `oic.r.operational.state`.
Newer firmware advertises both as Samsung migrates onto the OCF standard. **There
is no single "always prefer vs / always prefer non-vs" rule** — decide per
resource from the populated dump:
- **Both populated, same state** (power, kids-lock, remote): prefer the
OCF-standard `/x/0`; fall back to `/x/vs/0` when `/x/0` is absent. Encode with
a `match_fn` presence check — see `common.POWER_GENERIC` / `POWER_VS_FALLBACK`.
- **Only vendor populated** (`/energy/consumption/0` is often empty `{}`): use
`/x/vs/0`.
- **Vendor is a superset** (`/operational/state/vs/0` adds fields the OCF one
lacks): build on the vendor resource, ignore the OCF subset.
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
For each field worth exposing, decide the entity kind and category
(`entity_category` on the descriptor):
- **Normal / primary** (no `entity_category`): the things a user acts on or
watches — power switch, machine state, the cycle select, energy sensors.
- **`config`**: user-tunable settings — sound mode, door LED, wash temperature,
buzzer. Shown under the device's Configuration section.
- **`diagnostic`**: read-only status/troubleshooting — alarms, diagnosis, job
beginning status, last-operation source.
Also set `poll_tier` (`hot`/`warm`/`cold`) on the capability for how often it's
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).
## 5. Enum selects need translation support
Any select whose options are raw device codes (course/cycle, and code-valued
settings) must render through translations, not Python:
- Set `translation_key='<family>_cycle'` (or similar) on the `SelectDesc`;
`options`/`options_field` supply the **raw** codes.
- Add the labels to **both** `strings.json` and `translations/en.json` under
`entity.select.<translation_key>.state.<code>`, with the code **lowercased**
(e.g. `"16": "Cotton"`). Codes with no entry render as the raw code — that's
the cue to identify and name them.
## 6. 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`)
with a one-line reason. Add there only when it's **irrelevant plumbing**
(network/OTA/account housekeeping) or a **duplicate of state exposed via a
friendlier href**.
- **Global vs per-registry ignore:** `ignored.IGNORED` is folded into every
registry. A global ignore **collides** (via `_build`) with any real capability
that binds the same href in some family — e.g. `/course/vs/0` can't be globally
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
Check `common.py` (generic OCF: power, energy, alarms, water) and `laundry.py`
(shared washer/dryer/dishwasher: buzzer, job status, `cycle_select` + course
machinery) before adding a capability. Cross-family reuse is normal — the dryer
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
1. Add a **scrubbed** fixture `tests/fixtures/<type>_device.json`
(`{"device0": [ {devcol rep}, {href, rep}, ... ]}`) — replace serials, MACs,
and other PII with placeholders.
2. Generate `tests/fixtures/golden/<type>.json` (`{"state_keys": [...]}`) with
the harness in §2.
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.
## Key files
- `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`).
- `registry/capabilities/{common,laundry,fridge,...}.py` — capability defs.
- `registry/capabilities/ignored.py` — the ignore list + its philosophy.
- `registry/by_type/*.py` — per-device-type registries (what to include).
- `registry/registry.py` — the global unknown-device fallback + collision check.
- `tests/test_golden_regression.py`, `tests/fixtures/` — regression harness.
+22 -2
View File
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Run hassfest validation
uses: home-assistant/actions/hassfest@master
@@ -29,9 +29,29 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Run HACS validation
uses: hacs/action@main
with:
category: integration
tests:
name: Pytest
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 tests
run: pytest tests/ -q
+4 -2
View File
@@ -27,8 +27,10 @@ flows/
# Local-only research tools (not part of the HA integration)
local-tools/
# Claude Code per-project state (permissions allowlist, etc.)
.claude/
# Claude Code per-project state (permissions allowlist, etc.) — but keep
# shared, committed skills under .claude/skills/.
.claude/*
!.claude/skills/
# HA dev instance — persisted config and secrets
ha_config/
+50 -55
View File
@@ -2,31 +2,29 @@
**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.
> ### Where things live
>
> This project split into two repos partway through development.
>
> - **[`smartthings-local`](https://github.com/QuiteYellow/SmartThings-Local)** (PyPI package): the reusable protocol layer. DTLS session handling, CoAP wire encoding, Block2 reads, bounded retry/retransmit, inter-request rate limiting, cert-chain validation. No HA dependency; usable from any Python project.
> - **This repo**: the Home Assistant integration built on top of it. Config flow, a per-device-type capability registry, the polling coordinator, and all the HA entity classes.
>
> `custom_components/localthings/manifest.json` pulls in `smartthings-local` from PyPI like any other HA integration dependency.
This integration uses the [`smartthings-local`](https://github.com/QuiteYellow/SmartThings-Local) library to handle the low-level DTLS/CoAP communication with devices.
### What you get
Adding a device just needs a host IP and your CA credentials in the UI. The integration reads the appliance's `oneUiVersion` and picks the matching capability registry (dryer, oven, dishwasher, refrigerator) on its own, so there's no per-model descriptor to write for a new unit of a type that's already supported.
Adding a device just needs a host IP and your CA credentials in the UI. The integration reads the appliance's identity and picks the matching capability registry on its own, so there's no per-model descriptor to write for a new unit of a type that's already supported.
Credential setup is one-time. The first device you add asks for the AC14K_M CA cert and key (see Part 2); every device after that reuses the same stored CA and only asks for the host IP, minting its own per-device leaf cert automatically.
Credential setup is one-time. The first device you add asks for a CA certificate and key (see Part 2); every device after that reuses the same stored CA and only asks for the host IP, minting its own per-device leaf cert automatically.
Your state stays on your LAN: HA talks to the appliance over a direct DTLS session, and Samsung's cloud sees nothing from this integration. (The appliance itself still maintains its own connection to Samsung; that's firmware behavior on the device side, not something this integration controls.)
### Supported appliance types
| Type | Registry | Notable capabilities |
|---|---|---|
| Dryer | `by_type/dryer.py` | Power, kids lock, remote control, alarms, energy meter, operational state, door LED, sound mode, dryer settings/course, job-beginning status, diagnosis, firmware-update sensor |
| Oven | `by_type/oven.py` | Power, kids lock, remote control, alarms, cavity state, setpoint, mode, operational state, door, connectivity, firmware-update sensor |
| Dishwasher | `by_type/dishwasher.py` | Power, kids lock, remote control, alarms, energy + water meters, water filter, operational state, cycle options/settings, door LED, sound mode/volume, firmware-update sensor |
| Refrigerator | `by_type/refrigerator.py` | Power, kids lock, remote control, alarms, energy meter, water filter, status lock, door alert, icemaker (nighttime + generic per-compartment), flex zone, refrigeration mode, autofill, welcome/cabinet lighting, Sabbath mode, beverage zone, plus pattern-matched per-compartment temperature/setpoint/icemaker/door capabilities for multi-cavity fridges, firmware-update sensor |
| Type | Registry |
|---|---|
| Air conditioner | `by_type/airconditioner.py` |
| Dryer | `by_type/dryer.py` |
| Oven | `by_type/oven.py` |
| Range / cooktop-oven combo | `by_type/range.py` |
| Dishwasher | `by_type/dishwasher.py` |
| Refrigerator | `by_type/refrigerator.py` |
| Washer | `by_type/washer.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.
Other Tizen RT / DAWIT-family appliances almost certainly speak the same protocol underneath, since the auth path and CoAP primitives are shared across the fleet. Adding a new type means writing a new `by_type/<name>.py` registry file; it doesn't require reverse-engineering the protocol again. See **Adding a new appliance type** below.
@@ -39,23 +37,17 @@ Other Tizen RT / DAWIT-family appliances almost certainly speak the same protoco
nmap -Pn -sU -p 49152-49160 "$APPLIANCE_IP"
```
- `49154/udp` or `49155/udp` open|filtered with a DTLS handshake responding: newer firmware (Tizen RT 3.x, DAWIT 3.0+). This is what the integration talks to. The config flow probes both ports automatically, so you don't need to know which one your device uses.
- Any UDP port in `49152-49160` open|filtered with a DTLS handshake responding: newer firmware (Tizen RT 3.x, DAWIT 3.0+). This is what the integration talks to. Most devices answer on `49154`/`49155`, but some builds bind lower (e.g. `49153`). The config flow sweeps the whole range and auto-detects the live port, so you don't need to know which one your device uses.
- Only `8888/tcp` open (token-based HTTPS): older firmware (roughly 2018-2022). **Not supported here.**
---
## Part 2: One-time setup, get the AC14K_M CA credentials
The config flow (Part 3) needs a **CA certificate and CA private key** to mint each device's leaf cert itself. Specifically, it needs the `AC14K_M` intermediate CA: a cert chain that's been public for years and still ships in current Samsung firmware trust stores. It's required because every Samsung Tizen/RT-OCF appliance's factory ACL grants full CRUDN access (`perm=31` on `href=*`) to whatever identity is chained to that CA, so a cert signed by it is the one thing that lets HA talk to your appliance without Samsung's cloud in the loop. HA doesn't need the *device's* original cert or key, only something `AC14K_M` has signed, and it mints that itself once you give it the CA.
The config flow (Part 3) needs a **CA certificate and CA private key** to mint each device's leaf cert itself. Specifically, it needs the `AC14K_M` intermediate CA — a cert chain that's been public for years and still ships in current Samsung firmware trust stores. Every Samsung Tizen/RT-OCF appliance trusts identities chained to that CA with full access by default, so a cert signed by it is what lets HA talk to your appliance without Samsung's cloud in the loop. HA doesn't need the *device's* original cert or key, only something `AC14K_M` has signed, and it mints that itself once you give it the CA.
This repo doesn't include the needed CA bundle. For an example of how to obtain it, including fetching the AC14K_M cert and key and verifying they pair, see the `smartthings-local` protocol project's [`setup_cert.py`](https://github.com/QuiteYellow/SmartThings-Local/blob/main/setup_cert.py). However you obtain the CA cert and key, paste their PEM contents into the HA config flow's "CA Certificate (PEM)" and "CA Private Key (PEM)" fields in Part 3. You only need to do this once, since every appliance you add afterward reuses the same stored CA.
### Why this works
- Every Samsung Tizen/RT-OCF appliance has a factory-baked ACE in `/oic/sec/acl` granting the AC14K_M-chained identity `perm=31` on `href=*`.
- TizenRT iotivity derives the peer ID via `memmem(subject_dn, "uuid:")`, which is RDN-agnostic, so a cert with the UUID in any RDN authenticates the same way.
- You don't need the original keyholder's private key. The config flow mints its own key and has `AC14K_M` sign the leaf: different key, same identity, same access.
---
## Part 3: Add the integration in Home Assistant
@@ -64,7 +56,7 @@ This repo doesn't include the needed CA bundle. For an example of how to obtain
2. Restart HA.
3. **Settings > Devices & Services > Add Integration > LocalThings.**
4. First device: paste the appliance's IP, plus the contents of the CA private and public key from Part 2.
5. The flow fetches the current UUID from Samsung's cloud gateway, mints a leaf cert signed by your CA, probes ports `49154`/`49155`, and confirms the device answers `/device/0`. On success it creates the config entry and detects the device type automatically.
5. The flow fetches the current UUID from Samsung's cloud gateway, mints a leaf cert signed by your CA, sweeps the `49152-49160` range to find the live DTLS port, and confirms the device answers `/device/0`. On success it creates the config entry and detects the device type automatically.
6. Every subsequent device only asks for the host IP; the stored CA credentials are reused to mint that device's leaf cert.
Entities appear under one HA device per appliance, named `Samsung Appliance (<ip>)` initially. Rename freely: the config entry is keyed on the device's serial, not the name.
@@ -80,7 +72,7 @@ docker compose up -d --build
docker compose logs -f
```
The `Dockerfile` builds on the official `home-assistant/home-assistant:stable` image and pre-installs `smartthings-local`, so the dependency is present at container start instead of depending on HA's own runtime pip-install step (which needs outbound network access at exactly the moment the integration loads, and repeats on every container recreate). Re-run with `--build` whenever the pinned `smartthings-local` version changes.
The `Dockerfile` builds on the official `home-assistant/home-assistant:stable` image and pre-installs `smartthings-local`, so the dependency is present at container start instead of depending on HA's own runtime pip-install step. Re-run with `--build` whenever the pinned `smartthings-local` version changes.
`docker-compose.yml` sets `network_mode: host`, which is required since DTLS is UDP and won't traverse Docker's bridge NAT to reach LAN appliances, and bind-mounts `custom_components/localthings/` read-only into `ha_config/custom_components/`. Bump `custom_components.localthings` to `debug` in `ha_config/configuration.yaml` for verbose protocol logging.
@@ -93,7 +85,7 @@ python3 -m venv .venv
.venv/bin/pytest tests/ -q
```
`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.
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.
---
@@ -101,29 +93,32 @@ python3 -m venv .venv
```
custom_components/localthings/
manifest.json Requirements (incl. the smartthings-local PyPI dep), version, domain
__init__.py async_setup_entry / async_unload_entry
config_flow.py UUID fetch, leaf cert minting, port probing, config entry creation
coordinator.py DataUpdateCoordinator: polling, stale-state fallback, write dispatch
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
One module per HA platform
manifest.json Requirements (incl. the smartthings-local PyPI dep), version, domain
__init__.py async_setup_entry / async_unload_entry
config_flow.py UUID fetch, leaf cert minting, port probing, config entry creation
coordinator.py Polling + push update coordination, stale-state fallback, write dispatch
observe.py CoAP OBSERVE (push-mode) support layered on the coordinator
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 / climate.py
One module per HA platform
strings.json / translations/ Config-flow copy + entity state translations
registry/
batch.py /device/0 batch response parsing
capability.py Capability dataclass (href, entities, transforms)
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 (serial, oneUiVersion) for type detection
capabilities/ Shared + per-family Capability definitions (common, dryer, oven,
dishwasher, fridge, laundry, operational)
by_type/ One DeviceRegistry per appliance type, composed from capabilities/
tests/ 80+ tests: registry composition, discovery, entity descriptors,
golden-file regression against captured device dumps
requirements-dev.txt Test deps, including the smartthings-local package
docker-compose.yml / ha_config/ Local HA dev environment
registry.py Builds the global capability registry, validates href collisions
capability.py Capability dataclass (href, entities, transforms)
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
redact.py Strips account/identity data before diagnostics leave HA
capabilities/ Shared + per-family Capability definitions (common, airconditioner,
dryer, oven, dishwasher, fridge, washer, laundry, operational, ignored)
by_type/ One DeviceRegistry per appliance type, composed from capabilities/
tests/ Registry composition, discovery, entity descriptors, coordinator/observe
behavior, and golden-file regression against captured device dumps
requirements-dev.txt Test deps, including the smartthings-local package
docker-compose.yml / ha_config/ Local HA dev environment
```
---
@@ -141,21 +136,21 @@ support for hardware the maintainers don't have.
## Adding a new appliance type
1. Capture the appliance's `/device/0` response to see what resources/fields it exposes. An authenticated `DtlsCoapSession` from `smartthings_local.protocol.dtls_session` GET is enough; `local-tools/probe_device.py` wraps this.
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).
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` — confirmed true for washers — add its consumer-model prefix to `_CONSUMER_PREFIX_TO_KEY` instead, so `for_device_by_model()` can route it.
5. Add golden-file coverage in `tests/` against a captured `/device/0` dump for the new type.
No config-flow or coordinator changes are needed. Device-type detection and entity wiring are fully driven by the registry.
No config-flow changes are needed. Device-type detection and entity wiring are fully driven by the registry.
---
## Known DTLS behavior
## Known device behavior
Samsung's RT-OCF DTLS stack occasionally closes sessions actively, usually right after a Block2 GET or in the seconds after a POST. Retry/retransmit bounds and inter-request pacing live in the `smartthings-local` protocol layer (tuned against measured per-firmware request-rate ceilings); reconnect-with-backoff and stale-state fallback live in this repo's `coordinator.py`. From HA's perspective a brief reconnect looks like an entity holding its last value for one poll cycle rather than going `unavailable`.
Samsung's firmware occasionally drops the DTLS session briefly — this is normal appliance-side behavior, not a bug. The integration reconnects automatically, and from HA's perspective a brief reconnect looks like an entity holding its last value for one poll cycle rather than going `unavailable`. When an appliance supports it, the integration prefers push-based updates (instant, via `observe.py`) over polling, falling back to polling otherwise.
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: Samsung's RT-OCF DTLS allows only one active session per peer.
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.
---
@@ -163,7 +158,7 @@ If reconnects become persistent (more than a handful per minute), something's ac
Patches are welcome, especially:
- New `by_type/` registries for appliance families not yet covered (washer, AC, microwave, etc.) on the same Tizen RT 3.x firmware family.
- New `by_type/` registries for appliance families not yet covered (AC, microwave, etc.) on the same Tizen RT 3.x firmware family.
- Confirmation or refutation of compatibility on additional models within an already-supported type.
- Protocol-level fixes, which belong upstream in [`smartthings-local`](https://github.com/QuiteYellow/SmartThings-Local) rather than here. HA-side fixes (entities, config flow, coordinator, registry) belong in this repo.
+326
View File
@@ -0,0 +1,326 @@
"""Climate platform for Local Things.
The first composite entity in this integration: a single HA climate card that
unifies several OCF resources of a Samsung air conditioner. Unlike every other
platform here (one descriptor -> one resource field), a climate entity reads
power, HVAC mode, current/target temperature, fan (wind) strength, swing (wind
direction) and the convenient-mode preset from *different* resources.
It binds one primary `BoundEntity` (the `/mode/vs/0` capability) so the registry
still tracks it, and reads the sibling resources straight from the coordinator
snapshot via `coordinator.resource(href)` -- the same cross-resource read that
`number.py` (live range/unit) and `select.py` (options callable) already do.
Writes go through `coordinator.async_send_command(bound, (kind, value))`: the
CLIMATE capability's `write_fn` maps each `(kind, value)` payload to the right
`(path_segs, body)`, and `async_send_command` POSTs to those path_segs (the
bound href is only used for logging), so one descriptor drives writes to power,
mode, temperature and wind resources.
"""
from __future__ import annotations
from homeassistant.components.climate import (
ClimateEntity,
ClimateEntityFeature,
HVACMode,
)
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
# 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,
HREF_POWER as POWER_HREF,
HREF_POWER_VS as POWER_VS_HREF,
HREF_TEMP_CURRENT as TEMP_CURRENT_HREF,
HREF_TEMP_DESIRED as TEMP_DESIRED_HREF,
HREF_TEMP_CONTROL as TEMP_CONTROL_HREF,
HREF_TEMPS_VS as TEMPS_VS_HREF,
HREF_WIND_STRENGTH as WIND_STRENGTH_HREF,
HREF_WIND_DIRECTION as WIND_DIRECTION_HREF,
HREF_CONVENIENT as CONVENIENT_HREF,
)
from .registry.capabilities.common import normalize_temp_unit
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
_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,
'Wind': HVACMode.FAN_ONLY,
'Auto': HVACMode.HEAT_COOL,
'Heat': HVACMode.HEAT,
}
_HVAC_TO_DEVICE = {v: k for k, v in _DEVICE_TO_HVAC.items()}
# 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',
}
_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',
}
_SWING_TO_DEVICE = {v: k for k, v in _DEVICE_TO_SWING.items()}
# Preset (convenient mode): Off/Sleep map onto HA standard presets; Quiet/Smart/
# Speed are custom (translated).
_DEVICE_TO_PRESET: dict[str, str] = {
'Off': 'none',
'Sleep': 'sleep',
'Quiet': 'quiet',
'Smart': 'smart',
'Speed': 'speed',
}
_PRESET_TO_DEVICE = {v: k for k, v in _DEVICE_TO_PRESET.items()}
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(
LocalThingsClimate(coordinator, b)
for b in coordinator.bound
if isinstance(b.desc, ClimateDesc) and _is_included(b, coordinator)
)
def _first(value):
"""Samsung `modes` is a single-element list on some resources, a scalar on
others. 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
def _num(value):
try:
return float(value)
except (TypeError, ValueError):
return None
def _temps_vs_item(rep: dict) -> dict:
"""First item of the vendor `/temperatures/vs/0` items[] array.
Newer AC firmware (Tizen Lite, oneUiVersion "7.0 Air conditioner", e.g.
model TP1X_DA-AC-RAC-01011) does NOT expose the OCF-standard
/temperature/current/0 + /temperature/desired/0 pair; it reports current
and target under a single `/temperatures/vs/0` resource whose
`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')
if isinstance(items, (list, tuple)) and items and isinstance(items[0], dict):
return items[0]
return {}
class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
"""Composite climate entity for a Samsung air conditioner."""
# translation_key comes from the ClimateDesc (base __init__ sets
# _attr_translation_key from bound.desc), resolving the state_attributes
# translations under entity.climate.airconditioner.
# Modern climate entities opt out of the deprecated auto-added TURN_ON/OFF.
_enable_turn_on_off_backwards_compatibility = False
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
super().__init__(coordinator, bound)
# Primary/main entity for the device: no name suffix, just the device name.
self._attr_name = None
self._attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.FAN_MODE
| ClimateEntityFeature.SWING_MODE
| ClimateEntityFeature.PRESET_MODE
| ClimateEntityFeature.TURN_ON
| ClimateEntityFeature.TURN_OFF
)
# -- resource helpers ---------------------------------------------------
def _rep(self, href: str) -> dict:
return self.coordinator.resource(href) or {}
def _is_on(self) -> bool:
rep = self._rep(POWER_HREF)
if 'value' in rep:
return bool(rep.get('value'))
vs = self._rep(POWER_VS_HREF)
return str(vs.get('x.com.samsung.da.power', '')).lower() == 'on'
def _supported(self, href: str) -> list[str]:
return list(self._rep(href).get(_SUPPORTED_FIELD) or [])
def _read_mode(self, href: str, mapping: dict):
"""Current mode of a wind/convenient resource, mapped to its HA value."""
return mapping.get(_first(self._rep(href).get(_MODES_FIELD)))
def _read_modes(self, href: str, mapping: dict) -> list[str]:
"""Supported modes of a resource, mapped to HA values (unknowns dropped)."""
return [mapping[c] for c in self._supported(href) if c in mapping]
# -- temperature --------------------------------------------------------
def _temps_vs(self) -> dict:
"""Vendor `/temperatures/vs/0` items[0] (empty {} when absent)."""
return _temps_vs_item(self._rep(TEMPS_VS_HREF))
@property
def temperature_unit(self) -> str:
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)
@property
def current_temperature(self):
v = _num(self._rep(TEMP_CURRENT_HREF).get('temperature'))
if v is None:
v = _num(self._temps_vs().get('x.com.samsung.da.current'))
return v
@property
def target_temperature(self):
v = _num(self._rep(TEMP_DESIRED_HREF).get('temperature'))
if v is None:
v = _num(self._temps_vs().get('x.com.samsung.da.desired'))
return v
def _range(self) -> list | None:
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'))
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 float(r[0]) if r else super().min_temp
@property
def max_temp(self) -> float:
r = self._range()
return float(r[1]) if r else super().max_temp
@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)
# -- hvac mode ----------------------------------------------------------
@property
def hvac_mode(self) -> HVACMode:
if not self._is_on():
return HVACMode.OFF
device = _first(self._rep(MODE_HREF).get(_MODES_FIELD))
return _DEVICE_TO_HVAC.get(device, HVACMode.AUTO)
@property
def hvac_modes(self) -> list[HVACMode]:
modes = [HVACMode.OFF]
for m in self._supported(MODE_HREF):
mapped = _DEVICE_TO_HVAC.get(m)
if mapped is not None and mapped not in modes:
modes.append(mapped)
return modes
# -- fan / swing / preset ----------------------------------------------
@property
def fan_mode(self):
return self._read_mode(WIND_STRENGTH_HREF, _DEVICE_TO_FAN)
@property
def fan_modes(self) -> list[str]:
return self._read_modes(WIND_STRENGTH_HREF, _DEVICE_TO_FAN)
@property
def swing_mode(self):
return self._read_mode(WIND_DIRECTION_HREF, _DEVICE_TO_SWING)
@property
def swing_modes(self) -> list[str]:
return self._read_modes(WIND_DIRECTION_HREF, _DEVICE_TO_SWING)
@property
def preset_mode(self):
return self._read_mode(CONVENIENT_HREF, _DEVICE_TO_PRESET)
@property
def preset_modes(self) -> list[str]:
return self._read_modes(CONVENIENT_HREF, _DEVICE_TO_PRESET)
# -- writes -------------------------------------------------------------
async def async_set_temperature(self, **kwargs) -> None:
temp = kwargs.get('temperature')
if temp is not None:
await self.coordinator.async_send_command(self._bound, ('temperature', 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))
return
device = _HVAC_TO_DEVICE.get(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))
async def async_turn_on(self) -> None:
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))
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."""
device = mapping.get(value)
if device is not None:
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)
async def async_set_swing_mode(self, swing_mode: str) -> None:
await self._set_mapped('swing', _SWING_TO_DEVICE, swing_mode)
async def async_set_preset_mode(self, preset_mode: str) -> None:
await self._set_mapped('preset', _PRESET_TO_DEVICE, preset_mode)
+86 -3
View File
@@ -4,8 +4,10 @@ from __future__ import annotations
import datetime
import logging
import re
import selectors
import socket
import ssl
import time
from typing import Any
import voluptuous as vol
@@ -22,7 +24,8 @@ from .const import (
CONF_HOST, CONF_PORT,
CONF_CA_CERT_PEM, CONF_CA_KEY_PEM,
CONF_LEAF_CERT_PEM, CONF_LEAF_KEY_PEM,
PROBE_PORTS,
PROBE_PORT_RANGE, PREFERRED_PROBE_PORTS, LIVENESS_PROBE_TIMEOUT_S,
PROBE_GET_TIMEOUT_S,
)
_TEXT = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT))
@@ -120,6 +123,73 @@ def _mint_leaf_cert(ca_cert_pem: str, ca_key_pem: str, uuid: str) -> tuple[str,
return fullchain_pem, leaf_key_pem
def _order_candidates(ports: list[int]) -> list[int]:
"""Order live ports so the historically known DTLS ports are tried first."""
preferred = [p for p in PREFERRED_PROBE_PORTS if p in ports]
rest = sorted(p for p in ports if p not in PREFERRED_PROBE_PORTS)
return preferred + rest
def _find_live_ports(host: str, ports: list[int], timeout: float) -> list[int]:
"""Fast UDP liveness sweep to narrow the range before the DTLS handshake.
UDP is connectionless, but a *connected* UDP socket surfaces the ICMP
port-unreachable that a closed port returns as ECONNREFUSED on its next
recv. So we send one probe datagram per port and watch for that error:
* ECONNREFUSED -> port is closed (device actively rejected it)
* silence / any data -> port may be live (open|filtered); a candidate
This is the in-process equivalent of ``nmap -sU``: it lets us take a
nine-port range down to the one or two ports actually worth a full DTLS
handshake + /device/0 GET, and bounds the total wait to ``timeout``
instead of stalling on every dead port when a firewall swallows the ICMP
replies.
"""
sockets: dict[int, socket.socket] = {}
sel = selectors.DefaultSelector()
# A single byte is enough to provoke an ICMP port-unreach from a closed
# port; a real DTLS ClientHello is unnecessary just to test for life.
probe = b"\x00"
try:
for port in ports:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setblocking(False)
try:
sock.connect((host, port))
sock.send(probe)
except OSError:
sock.close()
continue
sockets[port] = sock
sel.register(sock, selectors.EVENT_READ, port)
# Ports drop out of the selector as they refuse; whatever is still
# registered when the deadline passes is silent-but-live (a candidate).
deadline = time.monotonic() + timeout
while sel.get_map():
remaining = deadline - time.monotonic()
if remaining <= 0:
break
for key, _ in sel.select(timeout=remaining):
try:
# Data back means live; ECONNREFUSED (or any other socket
# error) means the port is closed/unusable — rule it out.
key.fileobj.recv(1)
except OSError:
sel.unregister(key.fileobj)
live = [key.data for key in sel.get_map().values()]
finally:
sel.close()
for sock in sockets.values():
try:
sock.close()
except OSError:
pass
return _order_candidates(live)
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
@@ -146,8 +216,21 @@ 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]}"
)
_LOGGER.debug("Live DTLS port candidates on %s: %s", host, candidates)
last_exc = None
for port in PROBE_PORTS:
for port in candidates:
sess = None
try:
sess = DtlsCoapSession(
@@ -157,7 +240,7 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict:
)
sess.connect()
sess.start_reader()
code, payload = sess.get(['device', '0'], timeout=15.0)
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)
+20 -2
View File
@@ -1,6 +1,6 @@
DOMAIN = "localthings"
PLATFORMS = ["sensor", "binary_sensor", "switch", "number", "select", "button", "time"]
PLATFORMS = ["sensor", "binary_sensor", "switch", "number", "select", "button", "time", "climate"]
CONF_HOST = "host"
CONF_PORT = "port"
@@ -9,7 +9,25 @@ CONF_CA_KEY_PEM = "ca_key_pem"
CONF_LEAF_CERT_PEM = "leaf_cert_pem"
CONF_LEAF_KEY_PEM = "leaf_key_pem"
PROBE_PORTS = [49154, 49155]
# 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
# before attempting the (expensive) DTLS handshake.
PROBE_PORT_RANGE = list(range(49152, 49161))
# Ports we've historically seen complete a DTLS handshake. When more than one
# port in the range looks live, these are tried first.
PREFERRED_PROBE_PORTS = [49154, 49155]
# Per-port timeout for the cheap UDP liveness sweep. Closed ports return an
# ICMP port-unreachable almost immediately; a live-but-silent port is only
# detected by this timeout elapsing, so keep it short.
LIVENESS_PROBE_TIMEOUT_S = 1.5
# Deadline for the blockwise /device/0 GET during the config-flow probe. The
# slowest device observed returns a full dump in ~8s, so 10s leaves headroom
# without stalling setup; it matches the per-resource read timeout elsewhere.
PROBE_GET_TIMEOUT_S = 10.0
SUMMARY_INTERVAL_S = 30.0
+35 -2
View File
@@ -12,6 +12,7 @@ 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
@@ -21,6 +22,7 @@ from smartthings_local.ocf.state_cache import StateCache
from .registry.batch import parse_device0_batch
from .registry.by_type import for_device, for_device_by_model
from .registry.capabilities.common import remote_control_enabled
from .registry.discovery import discover, BoundEntity
from .registry import CAPABILITIES
from .registry.adapter import flatten
@@ -36,6 +38,12 @@ _LOGGER = logging.getLogger(__name__)
_SEED_PATH = ['device', '0']
_REMOTE_CONTROL_DISABLED_MESSAGE = (
"Remote control is turned off on this device. Check your appliance's "
"manual for how to enable remote control before Home Assistant can "
"control it."
)
class _NoOpDescriptor:
"""StateCache requires a descriptor with an on_observation hook. This
@@ -509,24 +517,49 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
async def async_send_command(self, bound_entity: BoundEntity,
payload: Any) -> None:
"""Write a value to the device. Fire-and-forget style."""
"""Write a value to the device. Fire-and-forget style.
A description-level validate_fn (currently SwitchDesc only) runs
here rather than per-platform, so rejecting a write with a
user-facing message -- as opposed to write_fn's silent no-op below
-- is available to every platform for free. The remote-control
check runs first and applies to every platform unconditionally,
ahead of any description-specific validate_fn."""
desc = bound_entity.desc
write_fn = getattr(desc, 'write_fn', None)
if write_fn is None:
return
href = bound_entity.href
rep = self._cache.get(href or '') or {}
resources = self._cache.snapshot()
if not remote_control_enabled(resources):
raise ServiceValidationError(_REMOTE_CONTROL_DISABLED_MESSAGE)
validate_fn = getattr(desc, 'validate_fn', None)
if validate_fn is not None:
error = validate_fn(payload, rep, resources)
if error:
raise ServiceValidationError(error)
result = write_fn(payload, rep, href)
if result is None:
self._log.warning("write_fn rejected payload %r for %s", payload, href)
return
path_segs, body = result
# Apply the write optimistically before starting the settle guard,
# not after -- mark_write_pending gates every source (poll, sweep,
# observe) through the same apply(), itself included, so flipping
# this order would have the guard drop the one update it exists to
# protect. Without an optimistic value in the cache for it to hold
# onto, the settle window was just delaying the real device
# confirmation for a few seconds on every write, which read exactly
# like the write being silently reverted (issue #27).
self._observe.apply(href, body, source='optimistic')
self._observe.mark_write_pending(href)
def _do_put():
sess = self._session
if sess is None:
raise RuntimeError("no session")
self._observe.mark_write_pending(href)
code, _ = sess.post(path_segs, cbor2.dumps(body), timeout=8.0)
self._log.info("PUT %s → code %#04x", href, code)
+6 -1
View File
@@ -26,13 +26,18 @@ async def async_get_config_entry_diagnostics(
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][entry.entry_id]
integration = await async_get_integration(hass, DOMAIN)
# importlib.metadata.version() reads the installed package's metadata off
# disk (listdir + open + read_text), which trips HA's event-loop blocking
# detector when called inline here. Offload it to the executor.
stl_version = await hass.async_add_executor_job(pkg_version, "smartthings-local")
return {
"device_type": coordinator.device_type_name or "unknown",
"one_ui_version": coordinator.one_ui_version,
"unbound_hrefs": sorted(coordinator._unbound_hrefs),
"resources": redact_resources(coordinator.last_resources),
"integration_version": integration.version,
"smartthings_local_version": pkg_version("smartthings-local"),
"smartthings_local_version": stl_version,
"observe_mode": coordinator.observe_mode,
"observe_subscribed_hrefs": sorted(coordinator._observe.subscribed_hrefs),
"observe_fallback_hrefs": sorted(coordinator._observe.fallback_hrefs),
+11 -3
View File
@@ -8,7 +8,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.const import EntityCategory
from .registry.adapter import _key
from .registry.discovery import BoundEntity
from .registry.discovery import BoundEntity, _snake_to_title
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
@@ -44,7 +44,7 @@ def _derive_name(state_key: str) -> str:
instance number with a space: "door_cooler_open1" → "Door Cooler Open 1".
"""
name = re.sub(r'(\d+)$', lambda m: f' {m.group()}' if int(m.group()) > 0 else '', state_key)
return name.replace('_', ' ').title().strip()
return _snake_to_title(name).strip()
class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
@@ -57,7 +57,15 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
self._bound = bound
self._state_key = _key(bound)
self._attr_unique_id = f"{DOMAIN}_{coordinator.device_serial}_{self._state_key}"
self._attr_name = bound.desc.name if bound.desc.name is not None else _derive_name(self._state_key)
if bound.desc.name is not None:
self._attr_name = bound.desc.name
elif bound.instance_name:
# A device-given instance name (e.g. an ice maker's "Cubed
# Ice") takes the place of the href-derived instance label,
# keeping the same entity-specific suffix (issue #27).
self._attr_name = f"{bound.instance_name} {_derive_name(bound.desc.key)}".strip()
else:
self._attr_name = _derive_name(self._state_key)
self._attr_translation_key = bound.desc.translation_key
self._attr_icon = bound.desc.icon
raw_cat = bound.desc.entity_category
+2 -2
View File
@@ -5,12 +5,12 @@
"config_flow": true,
"dependencies": [],
"documentation": "https://github.com/mbillow/localthings",
"iot_class": "local_polling",
"iot_class": "local_push",
"issue_tracker": "https://github.com/mbillow/localthings/issues",
"requirements": [
"cbor2>=5.4.6",
"pyOpenSSL>=23.0",
"smartthings-local>=0.1.0"
],
"version": "0.4.0"
"version": "0.10.1"
}
+15
View File
@@ -58,6 +58,9 @@ class LocalThingsNumber(LocalThingsEntity, NumberEntity):
@property
def native_min_value(self) -> float:
desc: 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])
@@ -67,6 +70,9 @@ class LocalThingsNumber(LocalThingsEntity, NumberEntity):
@property
def native_max_value(self) -> float:
desc: 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])
@@ -74,6 +80,15 @@ class LocalThingsNumber(LocalThingsEntity, NumberEntity):
return self._attr_native_max_value
return super().native_max_value
@property
def native_step(self) -> float:
desc: 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'):
return self._attr_native_step
return super().native_step
@property
def native_value(self):
return (self.coordinator.data or {}).get(self._state_key)
+27 -2
View File
@@ -69,6 +69,7 @@ class ObserveManager:
self.last_mode_change_wall = time.time()
self._settle_until: dict[str, float] = {}
self._settle_lock = threading.Lock()
self._cache_lock = threading.Lock()
self.subscribed_hrefs: set[str] = set()
self._notified: set[str] = set()
self._last_notify_ts: float | None = None
@@ -92,11 +93,35 @@ class ObserveManager:
return True
def apply(self, href: str, rep: dict, source: str) -> bool:
"""Gate a StateCache.apply_rep call through the write-settle guard."""
"""Gate a StateCache.apply_rep call through the write-settle guard.
Merges the incoming rep onto whatever's already cached for this
href rather than handing it to StateCache.apply_rep verbatim --
apply_rep does a full replace, and Samsung devices don't always
repeat every field on every update (issue #27: a /mode/vs/0
OBSERVE notify -- and, on at least one Bespoke fridge, even the
/device/0 sweep entry for that href -- can carry just `modes`,
omitting `supportedOptions`/`supportedModes` entirely). A full
replace would silently wipe fields a select entity's
exists_fn/options_field gates on the moment one partial update
comes through, even though nothing about the device's actual
supported options changed.
`apply()` is the sole path StateCache mutations flow through in
this component (poll, sweep, and OBSERVE notify all funnel here),
so `_cache_lock` serializes the read-then-write across those
threads (DTLS reader for notifies, executor threads for poll/
sweep) -- without it, two concurrent updates for the same href
could each read the same prior rep and the second writer would
silently lose the first's fields, reintroducing the exact bug
this merge fixes.
"""
if self._is_settling(href):
self.log.debug("dropping %s update for %s (settling)", source, href)
return False
return self.cache.apply_rep(href, rep, source=source)
with self._cache_lock:
merged = {**(self.cache.get(href) or {}), **rep}
return self.cache.apply_rep(href, merged, source=source)
def on_notification(self, href: str, payload: bytes) -> None:
"""Wired as DtlsCoapSession.on_notification. Runs on the DTLS
@@ -2,15 +2,18 @@
from typing import Optional
from ._base import DeviceRegistry
from . import dishwasher, dryer, oven, refrigerator, washer
from . import airconditioner, dishwasher, dryer, oven, range as _range, refrigerator, washer
__all__ = ['DeviceRegistry', '_type_key', 'for_device', 'for_device_by_model']
_REGISTRY_BY_KEY: dict[str, DeviceRegistry] = {
'airconditioner': airconditioner.REGISTRY,
'air_conditioner': airconditioner.REGISTRY,
'dishwasher': dishwasher.REGISTRY,
'dryer': dryer.REGISTRY,
'oven': oven.REGISTRY,
'range': _range.REGISTRY,
'refrigerator': refrigerator.REGISTRY,
'washer': washer.REGISTRY,
}
@@ -66,6 +69,8 @@ def for_device(one_ui_version: str) -> Optional[DeviceRegistry]:
_CONSUMER_PREFIX_TO_KEY: dict[str, str] = {
'WW': 'washer',
'WD': 'washer',
'WF': 'washer',
'WV': 'washer', # FlexWash twin units (e.g. WV55M9600AW) -- issue #19
'DV': 'dryer',
'DW': 'dishwasher',
}
@@ -88,4 +93,18 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi
key = _CONSUMER_PREFIX_TO_KEY.get(token[:2].upper())
if key is None and '_REF_' in (model_num or ''):
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'
# Older/simpler RAC boards (e.g. TP2X_RAC_20K, issue #37) use the plain
# '_RAC_' token instead -- distinct from '_PRAC_' above (no overlap: the
# 'P' sits between the underscore and 'RAC' in that token).
if key is None and '_RAC_' in (model_num or ''):
key = 'airconditioner'
# 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'
return _REGISTRY_BY_KEY.get(key) if key else None
@@ -0,0 +1,30 @@
"""Air-conditioner device registry (Samsung ARTIK051_PRAC-class, issue #17).
The first device whose core controls surface as a single composite HA `climate`
entity (see capabilities/airconditioner.py and climate.py). Power/mode/temp/wind
are consumed by that entity rather than exposed as separate switches/selects, so
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.
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.COVERAGE,
]),
)
@@ -1,16 +1,13 @@
"""Dishwasher device registry."""
from ..capabilities import common, dishwasher, dryer, fridge, ignored, laundry, operational
from ..capabilities import common, dishwasher, ignored, laundry, operational
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='dishwasher',
capabilities=_build([
*ignored.IGNORED,
common.POWER,
common.KIDS_LOCK,
common.REMOTE_CONTROL,
common.ALARMS,
common.ENERGY_METER,
*common.UNIVERSAL,
*common.POWER,
common.WATER_METER,
common.WATER_FILTER,
operational.OPERATIONAL_STATE,
@@ -18,10 +15,9 @@ REGISTRY = DeviceRegistry(
dishwasher.DISHWASHER_SETTINGS,
dishwasher.DIAGNOSIS,
dishwasher.OPERATION_ORIGIN,
dryer.JOB_BEGINNING_STATUS,
laundry.JOB_BEGINNING_STATUS,
laundry.DOOR_LED,
laundry.SOUND_MODE,
laundry.SOUND_VOLUME,
fridge.FIRMWARE_UPDATE,
]),
)
@@ -2,26 +2,29 @@
Note: dryer devices use port 49155 (not 49154). The config flow handles
this automatically via port probing.
The power/kids-lock/remote-control, buzzer, energy-meter, job-status, and
cycle-select capabilities are the shared laundry ones (laundry.py), the same
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, fridge, ignored, laundry, operational
from ..capabilities import common, dryer, ignored, laundry, operational
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='dryer',
capabilities=_build([
*ignored.IGNORED,
common.POWER,
common.KIDS_LOCK,
common.REMOTE_CONTROL,
common.ALARMS,
common.ENERGY_METER,
*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.JOB_BEGINNING_STATUS,
dryer.DRYER_DIAGNOSIS,
fridge.FIRMWARE_UPDATE,
]),
)
@@ -1,21 +1,19 @@
"""Oven device registry."""
from ..capabilities import common, fridge, ignored, oven
from ..capabilities import common, ignored, oven
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='oven',
capabilities=_build([
*ignored.IGNORED,
common.POWER,
common.KIDS_LOCK,
common.REMOTE_CONTROL,
common.ALARMS,
*common.UNIVERSAL,
*common.POWER,
oven.OVEN_CAVITY,
oven.OVEN_SETPOINT,
oven.OVEN_MODE,
oven.OVEN_OPERATIONAL_STATE,
oven.OVEN_DOOR,
oven.OVEN_CONNECTED,
fridge.FIRMWARE_UPDATE,
oven.OVEN_SPEC,
]),
)
@@ -0,0 +1,29 @@
"""Range (oven + cooktop combo) device registry — issue #44.
Reuses the oven family's cavity/setpoint/mode/operational-state/door/
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,
]),
)
@@ -1,19 +1,17 @@
"""Refrigerator device registry."""
from ..capabilities import common, fridge, ignored
from ..capabilities import common, dishwasher, fridge, ignored
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='refrigerator',
capabilities=_build([
*ignored.IGNORED,
common.POWER,
*common.UNIVERSAL,
*common.POWER,
fridge.STATUS_LOCK,
fridge.DOOR_ALERT,
common.KIDS_LOCK,
common.REMOTE_CONTROL,
common.ALARMS,
common.ENERGY_METER,
common.WATER_FILTER,
dishwasher.DIAGNOSIS,
fridge.ICEMAKER_NIGHTTIME,
fridge.FLEX_ZONE,
fridge.REFRIGERATION,
@@ -23,13 +21,14 @@ REGISTRY = DeviceRegistry(
fridge.CABINET_LIGHT_ENHANCED,
fridge.SABBATH,
fridge.BEVERAGE_ZONE,
fridge.FIRMWARE_UPDATE,
fridge.PANTRY_ZONE,
fridge.DEFROST_DELAY,
fridge.DEFROST_DELAY_NATIVE_DUPLICATE,
fridge.DEFROST_BLOCK_STATUS,
fridge.SELF_CHECK,
fridge.DOORS_FALLBACK,
fridge.TEMPERATURES_FALLBACK,
fridge.ICEMAKER_STATUS_FALLBACK,
fridge.ICEMAKER_STATUS_NATIVE_DUPLICATE,
fridge.REFRIGERATION_FALLBACK,
]),
pattern_capabilities=[
@@ -1,26 +1,19 @@
"""Washer device registry."""
from ..capabilities import common, dishwasher, fridge, ignored, operational, washer
from ..capabilities import common, dishwasher, ignored, laundry, operational, washer
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='washer',
capabilities=_build([
*ignored.IGNORED,
washer.POWER_GENERIC,
washer.POWER_VS_FALLBACK,
washer.KIDS_LOCK_GENERIC,
washer.KIDS_LOCK_VS_FALLBACK,
washer.REMOTE_CONTROL_GENERIC,
washer.REMOTE_CONTROL_VS_FALLBACK,
*common.UNIVERSAL,
*common.POWER,
washer.WASHER_SETTINGS,
washer.WASHER_COURSE,
washer.BUZZER_SOUND,
washer.WASHER_JOB_BEGINNING_STATUS,
common.ALARMS,
washer.WASHER_ENERGY_METER,
laundry.BUZZER_SOUND,
laundry.JOB_BEGINNING_STATUS,
common.WATER_METER,
operational.OPERATIONAL_STATE,
dishwasher.DIAGNOSIS,
fridge.FIRMWARE_UPDATE,
]),
)
@@ -0,0 +1,253 @@
"""Capabilities for the Samsung air-conditioner family (ARTIK051_PRAC-class).
Resources verified against the issue #17 diagnostics dump (model
ARTIK051_PRAC_20K). This is the first family whose core controls surface as a
single composite HA `climate` entity rather than a scatter of switches/selects:
power (on/off), HVAC mode, current/target temperature, fan (wind) strength,
swing (wind direction), and the convenient-mode preset all live on one climate
card. The climate platform (climate.py) reads those sibling resources from the
coordinator snapshot; here we bind the primary `/mode/vs/0` resource to the
`ClimateDesc` and mark the consumed siblings as covered.
None of these caps may go into the global `ALL`/`CAPABILITIES`: `/mode/vs/0`,
`/temperatures/vs/0`, `/humidity/*` collide with fridge/oven hrefs of a
different schema (see capabilities/__init__.py). They live only in the AC
by_type registry.
"""
from ..capability import Capability
from ..entities import BinarySensorDesc, ClimateDesc, SensorDesc, SwitchDesc
# ---------------------------------------------------------------------------
# Canonical AC resource hrefs. The climate entity (climate.py) binds the
# primary HREF_MODE via CLIMATE below and reads the CLIMATE_CONSUMED_HREFS
# siblings off the coordinator snapshot; those siblings are marked covered
# (no-entity caps) so discover() reports no gap. Declared once here and
# imported by climate.py, so a new sibling read can't drift out of sync with
# its coverage entry.
# ---------------------------------------------------------------------------
HREF_MODE = '/mode/vs/0' # primary (bound by CLIMATE)
HREF_POWER = '/power/0' # on/off -> HVACMode.OFF / TURN_ON/OFF
HREF_POWER_VS = '/power/vs/0' # vendor fallback for on/off
HREF_TEMP_CURRENT = '/temperature/current/0' # current_temperature
HREF_TEMP_DESIRED = '/temperature/desired/0' # target_temperature (write target)
HREF_TEMP_CONTROL = '/temperature/control/vs/0' # target_temperature_step
HREF_WIND_STRENGTH = '/wind/strength/vs/0' # fan_mode
HREF_WIND_DIRECTION = '/wind/direction/vs/0' # swing_mode
HREF_CONVENIENT = '/mode/convenient/vs/0' # preset_mode
HREF_TEMPS_VS = '/temperatures/vs/0' # vendor temp fallback (items[] array)
CLIMATE_CONSUMED_HREFS = [
HREF_POWER, HREF_POWER_VS, HREF_TEMP_CURRENT, HREF_TEMP_DESIRED,
HREF_TEMP_CONTROL, HREF_TEMPS_VS, HREF_WIND_STRENGTH, HREF_WIND_DIRECTION,
HREF_CONVENIENT,
]
def _num(v):
try:
return float(v)
except (TypeError, ValueError):
return None
def _filter_usage_percent(rep):
"""Filter usage as a percentage of rated capacity. The device reports
`filterUsage` as a raw count in `filterCapacityUnit` (Hours here, 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'))
if used is None or not cap:
return None
return round(used / cap * 100)
def _first_mode(rep):
"""Representative scalar for the climate entity in the flattened state
(golden/regression). The real entity computes hvac_mode from power + mode."""
modes = rep.get('x.com.samsung.da.modes')
if isinstance(modes, (list, tuple)):
return modes[0] if modes else None
return modes
def _climate_write(payload, rep, href=None):
"""Map a (kind, value) command from the climate platform to the
(path_segs, body) for that one sub-write. `value` is already the raw device
code (the platform maps HA<->device). async_send_command POSTs to path_segs,
so a single desc drives writes across power/mode/temperature/wind resources.
Read-modify-write safe: each write sends only its own field, leaving the
resource's other fields (e.g. /mode/vs/0's opaque `options` blob) untouched.
"""
kind, value = payload
if kind == 'power':
return (['power', '0'], {'value': bool(value)})
if kind == 'mode':
return (['mode', 'vs', '0'], {'x.com.samsung.da.modes': [value]})
if kind == 'temperature':
return (['temperature', 'desired', '0'], {'temperature': int(round(float(value)))})
if kind == 'fan':
return (['wind', 'strength', 'vs', '0'], {'x.com.samsung.da.modes': value})
if kind == 'swing':
return (['wind', 'direction', 'vs', '0'], {'x.com.samsung.da.modes': value})
if kind == 'preset':
return (['mode', 'convenient', 'vs', '0'], {'x.com.samsung.da.modes': value})
return None
CLIMATE = Capability(
href=HREF_MODE,
poll_tier='warm',
entities=(
ClimateDesc(key='climate', translation_key='airconditioner',
rep_fn=_first_mode, write_fn=_climate_write),
),
)
AIR_PURIFY = Capability(
href='/option/airpurify/vs/0',
poll_tier='warm',
entities=(
SwitchDesc(key='air_purify', field='x.com.samsung.da.modes',
name='Air purification', icon='mdi:air-purifier',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['option', 'airpurify', 'vs', '0'],
{'x.com.samsung.da.modes': 'On' if p == 'On' else 'Off'})),
),
)
AUTO_CLEAN = Capability(
href='/option/autoclean/vs/0',
poll_tier='cold',
entities=(
SwitchDesc(key='auto_clean', field='x.com.samsung.da.settingStatus',
name='Auto clean', icon='mdi:spray-bottle',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['option', 'autoclean', 'vs', '0'],
{'x.com.samsung.da.settingStatus': 'On' if p == 'On' else 'Off'})),
),
)
AIR_FILTER = Capability(
href='/filter/airdustfilter/vs/0',
poll_tier='cold',
entities=(
SensorDesc(key='air_filter_usage', rep_fn=_filter_usage_percent,
name='Filter usage', unit='%', state_class='measurement',
icon='mdi:air-filter', entity_category='diagnostic'),
SensorDesc(key='air_filter_status', field='x.com.samsung.da.filterStatus',
name='Filter status', device_class='enum',
options=('normal', 'wash', 'replace'),
translation_key='air_filter_status',
icon='mdi:air-filter', entity_category='diagnostic',
value_fn=lambda v: v.lower() if isinstance(v, str) else v),
),
)
DISPLAY_LIGHT = Capability(
href='/light/vs/0',
poll_tier='cold',
entities=(
SwitchDesc(key='display_light', field='mode',
name='Display light', icon='mdi:led-on',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['light', 'vs', '0'],
{'mode': 'On' if p == 'On' else 'Off'})),
),
)
# Confirmed against issue #38's dump (TP1X_DA-AC-RAC-01001_0000): a single
# boolean field, no vendor prefix, mirroring the On/Off convention used
# throughout the rest of this API.
MUTE_ONCE = Capability(
href='/option/muteonce/vs/0',
poll_tier='warm',
entities=(
SwitchDesc(key='mute_once', field='muteonce',
name='Mute once', icon='mdi:volume-mute',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['option', 'muteonce', 'vs', '0'],
{'muteonce': 'On' if p == 'On' else 'Off'})),
),
)
# Circuit-breaker current-limit setting (issue #38, TP1X board): `operation`
# toggles the limiter and `modes` picks a level out of `supportedModes`
# (seen as '3'..'9'). No vendor field-name prefix and no unit/label in the
# dump to confirm what the levels mean (amps vs. an abstract tier) --
# exposed read-only per the 'don't guess' rule rather than risking an
# unverified write to live HVAC hardware.
CURRENT_LIMIT = Capability(
href='/electriccurrent/vs/0',
poll_tier='cold',
entities=(
BinarySensorDesc(key='current_limit_enabled', field='operation',
name='Current limit enabled', icon='mdi:current-ac',
entity_category='diagnostic',
value_fn=lambda v: v == 'On'),
SensorDesc(key='current_limit_level', field='modes',
name='Current limit level', icon='mdi:current-ac',
entity_category='diagnostic'),
),
)
# ---------------------------------------------------------------------------
# AC-scoped coverage: the CLIMATE_CONSUMED_HREFS above (read by the climate
# entity) plus vendor duplicates / all-zero-ambiguous / plumbing resources.
# These are NOT in the global ignored.IGNORED because several of them
# (/mode/vs/0 handled above, /temperatures/vs/0, /humidity/*) collide with
# other families' schemas. A no-entity Capability still marks the href as
# bound so discover() reports no coverage gap.
#
# CLIMATE_CONSUMED_HREFS carry the climate card's actual displayed state
# (power, current/target temp, fan, swing, preset) -- the coordinator only
# OBSERVE-subscribes and sub-polls 'hot'/'warm' hrefs (see coordinator.py),
# so leaving these at the Capability default of 'cold' meant every state
# change was invisible until the next full /device/0 summary sweep
# (~30s -- issue #17: instant device response, 20-30s HA lag). Pin them to
# 'warm' -- same tier as CLIMATE's own primary href -- so they get push
# notifications (or, in poll-only mode, the warm sub-poll cadence) instead
# of waiting on the summary sweep.
# ---------------------------------------------------------------------------
_AC_IGNORED = [
# All-zero and ambiguously encoded on this model (2-value arrays); the
# 'don't guess' rule -- leave unmodeled rather than invent entities.
'/sensors/vs/0',
'/humidity/0',
'/humidity/vs/0',
# Presence-personalization plumbing (empty item list here).
'/personality/presence/vs/0',
# --- TP1X/TP2X-class housekeeping / opaque blobs. These carry no
# user-actionable state or no documented write contract, so per the
# 'don't guess' rule they are ignored rather than modeled.
# /option/muteonce/vs/0 and /selfcheck/vs/0 are deliberately NOT here --
# see MUTE_ONCE above and common.SELF_CHECK (via common.UNIVERSAL) in
# the by_type registry, both of which have a confirmed, cleanly
# modelable contract.
'/airlevelcheck/vs/0', # periodic air-quality sensing scheduler plumbing
'/aisleep/vs/0', # AI-sleep feedback state (no actionable control)
'/availablecontrolsets/vs/0', # opaque hex-encoded control-set bitmap
'/da/softreset/vs/0', # soft-reset trigger plumbing
'/keepnormalstate/vs/0', # internal keep-normal flag
'/mds/absencemonitoring/vs/0', # motion-detection sensor plumbing (empty here)
'/mds/absencestate/vs/0', # motion-detection state (empty here)
'/remotedatacontrol/vs/0', # remote data-control session status
'/remotetemperature/vs/0', # external temp-sensor feed (unset on this unit)
'/reserverulesets/vs/0', # opaque hex-encoded schedule reservation blob
'/welcome/temperature/vs/0', # welcome-cooling plumbing
]
# Built as bare no-entity caps; folded into the AC registry (not global).
COVERAGE = [
Capability(href=h, poll_tier='warm') for h in CLIMATE_CONSUMED_HREFS
] + [
Capability(href=h) for h in _AC_IGNORED
]
@@ -11,7 +11,9 @@ against live device dumps:
/filter/waterfilter/vs/0 -> x.com.samsung.da.filterUsage / filterStatus
"""
from ..capability import Capability
from ..entities import BinarySensorDesc, SensorDesc, SwitchDesc
from ..entities import (
BinarySensorDesc, ButtonDesc, SelectDesc, SensorDesc, SwitchDesc,
)
def _num(v):
@@ -56,8 +58,57 @@ def _active_alarm_codes(items):
return ', '.join(codes) if codes else 'none'
KIDS_LOCK = Capability(
# OCF-native / vendor '-vs' fallback pairs for power, kids-lock, remote control.
#
# These three controls exist as both a standard OCF resource (/power/0,
# oic.r.switch.binary, plain boolean 'value') and a Samsung vendor resource
# (/power/vs/0, x.com.samsung.da.power) -- Samsung advertises both as its
# firmware migrates onto the OCF standard model. Prefer the OCF-standard href
# when the device exposes it; the '-vs' href (a string-encoded duplicate for
# these three) binds only when the generic href is absent, via match_fn. Older
# firmware has only the '-vs' resource, so the pair is behaviour-identical to a
# lone '-vs' cap there. See the adding-device-support skill's "OCF-standard vs
# vendor" section for why this is preferred-non-vs-with-fallback, not a blanket
# choice. Every device registry lists both caps of each pair.
POWER_GENERIC = Capability(
href='/power/0',
entities=(
SwitchDesc(key='power_switch', field='value',
name='Power',
value_fn=lambda v: bool(v),
write_fn=lambda p, rep, href=None: (
['power', '0'], {'value': p == 'On'})),
),
)
POWER_VS_FALLBACK = Capability(
href='/power/vs/0',
match_fn=lambda rep, resources: '/power/0' not in resources,
entities=(
SwitchDesc(key='power_switch', field='x.com.samsung.da.power',
name='Power',
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'})),
),
)
KIDS_LOCK_GENERIC = Capability(
href='/kidslock/0',
entities=(
SwitchDesc(key='child_lock', field='value',
name='Child lock', device_class='lock',
value_fn=lambda v: bool(v),
write_fn=lambda p, rep, href=None: (
['kidslock', '0'], {'value': p == 'On'})),
),
)
KIDS_LOCK_VS_FALLBACK = Capability(
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',
name='Child lock', device_class='lock',
@@ -68,8 +119,41 @@ KIDS_LOCK = Capability(
),
)
REMOTE_CONTROL = Capability(
def remote_control_enabled(resources: dict) -> bool:
"""Single source of truth for the /remotectrl on/off signal, mirroring
REMOTE_CONTROL_GENERIC/_VS_FALLBACK's href/field pair and precedence
below. Used both to render the read-only Smart Control binary_sensor
(via those two descriptors) and, from coordinator.async_send_command,
to block writes outright when remote control is off. Both hrefs are
poll_tier='warm' below so that gate reads recent state (subscribed
when observe is live, subpolled every ~6s otherwise) rather than a
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')
if generic is not None:
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 True
REMOTE_CONTROL_GENERIC = Capability(
href='/remotectrl/0',
poll_tier='warm',
entities=(
BinarySensorDesc(key='remote_control', field='value',
name='Smart Control', 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',
entities=(
BinarySensorDesc(key='remote_control',
field='x.com.samsung.da.remoteControlEnabled',
@@ -78,16 +162,6 @@ REMOTE_CONTROL = Capability(
),
)
POWER = Capability(
href='/power/vs/0',
entities=(
SwitchDesc(key='power_switch', field='x.com.samsung.da.power',
name='Power',
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'})),
),
)
ALARMS = Capability(
href='/alarms/vs/0',
poll_tier='hot',
@@ -98,15 +172,66 @@ ALARMS = Capability(
),
)
# instantaneousPower is a dead field on DA_WM_-class laundry dumps (washers and
# the issue #14 dryer) and on dishwashers too: the literal sentinel '-500',
# unchanged across off/idle/running. clamp_power floors it to a misleading
# "0 W" that reads as a real idle measurement. Gate power_watts out when the
# sentinel is seen -- but only then, so a device reporting a real value (e.g. a
# 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'
ENERGY_METER = Capability(
href='/energy/consumption/vs/0',
entities=(
# `not rep` keeps the empty-{} stub carve-out (see entity._is_included):
# an explicit exists_fn otherwise bypasses it, which would drop the
# entity when /device/0 returns a not-yet-fetched stub. 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',
name='Power', device_class='power', state_class='measurement',
unit='W', value_fn=clamp_power),
unit='W', value_fn=clamp_power,
exists_fn=lambda rep, resources: not 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',
name='Energy', device_class='energy',
state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh),
state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh,
exists_fn=lambda rep, resources: (
not 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. `not
# rep or` keeps the same empty-{} 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',
name='Power energy', device_class='energy',
state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh,
exists_fn=lambda rep, resources: (
not 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',
name='Energy saved', device_class='energy',
state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh,
exists_fn=lambda rep, resources: (
not 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',
name='Energy (last month)', device_class='energy',
unit='kWh', value_fn=wh_to_kwh,
exists_fn=lambda rep, resources: (
not rep or 'x.com.samsung.da.monthlyConsumption' in rep)),
SensorDesc(key='energy_this_month_kwh', field='x.com.samsung.da.thismonthlyConsumption',
name='Energy (this month)', device_class='energy',
unit='kWh', value_fn=wh_to_kwh,
exists_fn=lambda rep, resources: (
not rep or 'x.com.samsung.da.thismonthlyConsumption' in rep)),
),
)
@@ -131,3 +256,162 @@ WATER_FILTER = Capability(
name='Filter status', icon='mdi:filter-check'),
),
)
# AI energy-saving level -- '0' is off, and supportedAiLevel lists the
# additional level(s) the device offers ('1' meaning just "on" on most
# hardware, but multi-level boards have been reported). Verified cross-family:
# fridge (issue #21) and washer (issue #40) both expose this href.
#
# supportedAiLevel is a single-entry list on most captured hardware, where a
# select would offer only one real choice against an implicit "off" -- shown
# as a switch instead. '0' itself is never in supportedAiLevel but has been
# observed live as the off value of aiLevel, so the select synthesizes it
# back in as an explicit option rather than leaving no way to turn off.
#
# No translation_key: aiLevel's values are plain digit strings, and
# select.py's _display() already renders an untranslated numeric string
# as-is -- there's nothing a strings.json entry adds that's worth maintaining
# against an unknown, growing number of future levels.
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')
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)]
def _ai_energy_level_write(p, rep, href=None):
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'}
AI_ENERGY_LEVEL = Capability(
href='/energy/ailevel/vs/0',
poll_tier='cold',
entities=(
# No `not rep` stub carve-out on either side, unlike most exists_fn
# gates in this file -- entity creation only ever runs once, against
# whichever snapshot happens to be current the moment platforms are
# set up (see entity._is_included / __init__.py's
# async_config_entry_first_refresh-before-forward-entry-setups
# ordering), while flatten() re-evaluates exists_fn every poll
# against live data. Both descriptors share key='ai_energy_level',
# so if a stub carve-out let one of them win at setup time while the
# other wins once real data lands, flatten() would feed the
# instantiated entity a value shaped for the other platform (e.g. a
# bool into a Select). Requiring real, populated data on both sides
# keeps the entity-creation decision and the live-value decision in
# permanent agreement -- the cost is this entity doesn't appear
# 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',
name='AI energy level', 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',
name='AI energy level', 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',
entities=(
BinarySensorDesc(
key='firmware_update',
field='x.com.samsung.da.newVersionAvailable',
name='Firmware update available',
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',
entities=(
SensorDesc(key='selfcheck_status', field='x.com.samsung.da.status',
name='Self-check status', icon='mdi:stethoscope',
entity_category='diagnostic'),
SensorDesc(key='selfcheck_result', field='x.com.samsung.da.result',
name='Self-check 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',
name='Self-check error', icon='mdi:alert-circle-outline',
entity_category='diagnostic',
exists_fn=lambda rep, resources: (
not 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='', name='Start self-check',
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})),
),
)
# ---------------------------------------------------------------------------
# Cross-family bundles, unpacked into every by_type registry's _build([...])
# call the same way ignored.IGNORED is (*common.UNIVERSAL / *common.POWER).
# discover() only binds a capability whose href is actually present in a
# given device's resource dump, so listing one here for a family that
# doesn't expose the href is a no-op, not a phantom entity -- see the
# adding-device-support skill's coverage-discipline section.
#
# UNIVERSAL holds every capability with no known family that both (a) has
# the href and (b) needs to model it some other way -- broadening one of
# these to a new family is a safe, harmless guess (issue #40's AI energy
# 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/
# 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.
# ---------------------------------------------------------------------------
UNIVERSAL = (
ALARMS,
ENERGY_METER,
FIRMWARE_UPDATE,
SELF_CHECK,
AI_ENERGY_LEVEL,
KIDS_LOCK_GENERIC,
KIDS_LOCK_VS_FALLBACK,
REMOTE_CONTROL_GENERIC,
REMOTE_CONTROL_VS_FALLBACK,
)
POWER = (
POWER_GENERIC,
POWER_VS_FALLBACK,
)
@@ -1,9 +1,14 @@
"""Capabilities specific to dishwasher appliances (DW9000F-class).
Resources verified against the live device dump at 10.0.0.129.
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
# ---------------------------------------------------------------------------
# /dishwasher/vs/0 — cycle wash/dry settings
@@ -28,108 +33,22 @@ DISHWASHER_SETTINGS = Capability(
)
# ---------------------------------------------------------------------------
# /course/vs/0 — cycle selection and course options (RMW on options array)
# /course/vs/0 — cycle selection (shared laundry.cycle_select) plus the
# dishwasher-only StormWashZone / AutoDoorRelease toggles that ride in the
# same options array (shared laundry.bool_option_switch, same options[]
# boolean-toggle contract washer's bubble-soak/pre-wash/intensive switches
# use). Course display names live in translations under
# entity.select.dishwasher_cycle (see laundry.cycle_select).
# ---------------------------------------------------------------------------
# Course IDs are uppercase hex strings matching the Course_XX encoding in
# options[]. Display names are not kept here -- they live in
# strings.json/translations under entity.select.dishwasher_cycle.state.<id,
# lowercased>, same as every other device-enum select in this integration
# (see fridge.py's translation_key entities and select.py's _display()), so
# they can be localized instead of hardcoded to English.
#
# No static fallback list is kept here -- a hardcoded course table would
# show options a given dishwasher model doesn't actually have (or hide ones
# it does). The only trustworthy per-device source is the live
# x.com.samsung.da.editCourseList on /wm/editcourse/vs/0. When that's
# absent (e.g. never populated until the app's course-edit screen has been
# opened at least once), the cycle select isn't created at all -- see
# CYCLE_OPTIONS's exists_fn below. (x.com.samsung.da.options' MostUsed_*
# entry was considered as a second fallback source, but its bytes beyond
# the first don't correspond to any confirmed course code on hardware we
# have dumps for, so it isn't trustworthy either -- see washer.py's
# _cycle_options docstring for the byte-level evidence.)
def _parse_edit_course_list(raw):
"""'EditCourseList_0E07908683848D808E8F' -> ['0E', '07', ...]."""
if not isinstance(raw, str) or '_' not in raw:
return []
codes = raw.split('_', 1)[1]
return [codes[i:i + 2] for i in range(0, len(codes) - 1, 2)]
def _cycle_options(resources):
rep = resources.get('/wm/editcourse/vs/0') or {}
return _parse_edit_course_list(rep.get('x.com.samsung.da.editCourseList'))
def _option_value(options, prefix):
"""Find `<prefix>_<value>` in options array and return <value>."""
for o in (options or []):
if isinstance(o, str) and o.startswith(prefix + '_'):
return o.split('_', 1)[1]
return None
def _replace_in_options(options, prefix, new_value):
return [f"{prefix}_{new_value}" if isinstance(o, str) and o.startswith(prefix + '_') else o
for o in options]
def _cycle_write(p, rep, href=None):
opts = list(rep.get('x.com.samsung.da.options') or [])
if not opts:
return None
return ['course', 'vs', '0'], {
'x.com.samsung.da.options': _replace_in_options(opts, 'Course', p),
}
def _storm_wash_write(p, rep, href=None):
if p not in ('On', 'Off'):
return None
opts = list(rep.get('x.com.samsung.da.options') or [])
if not opts:
return None
return ['course', 'vs', '0'], {
'x.com.samsung.da.options': _replace_in_options(opts, 'StormWashZone', p),
}
def _auto_release_write(p, rep, href=None):
if p not in ('On', 'Off'):
return None
opts = list(rep.get('x.com.samsung.da.options') or [])
if not opts:
return None
return ['course', 'vs', '0'], {
'x.com.samsung.da.options': _replace_in_options(opts, 'AutoDoorRelease', p),
}
CYCLE_OPTIONS = Capability(
href='/course/vs/0',
entities=(
SelectDesc(key='cycle', name='Cycle', icon='mdi:dishwasher',
translation_key='dishwasher_cycle',
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'),
write_fn=_cycle_write),
SwitchDesc(key='storm_wash', name='Storm Wash+', icon='mdi:weather-lightning-rainy',
rep_fn=lambda rep: _option_value(
rep.get('x.com.samsung.da.options'), 'StormWashZone') == 'On',
write_fn=_storm_wash_write),
SwitchDesc(key='auto_release_dry', name='Auto release dry', icon='mdi:door-open',
exists_fn=lambda rep, resources: any(
isinstance(o, str) and o.startswith('AutoDoorRelease_')
for o in (rep.get('x.com.samsung.da.options') or [])
),
rep_fn=lambda rep: _option_value(
rep.get('x.com.samsung.da.options'), 'AutoDoorRelease') == 'On',
write_fn=_auto_release_write),
cycle_select(translation_key='dishwasher_cycle', icon='mdi:dishwasher'),
bool_option_switch('storm_wash', 'Storm Wash+', 'mdi:weather-lightning-rainy',
'StormWashZone'),
bool_option_switch('auto_release_dry', 'Auto release dry', 'mdi:door-open',
'AutoDoorRelease', gate_on_presence=True),
),
)
@@ -1,37 +1,16 @@
"""Capabilities specific to the dryer family (Samsung DV5000T-class).
"""Capabilities specific to the dryer family (Samsung DA_WM_TP1/TP2-class).
Resources derived from the old dryer.py descriptor OBSERVE_PATHS and
flatten() implementation:
/washer/vs/0 -> DRYER_SETTINGS (dryLevel, dryTime, dryerType, wrinklePrevent)
/st/dryercourse/vs/0 -> DRYER_COURSE (dryer_mode SelectDesc)
/wm/jobbeginingstatus/vs/0 -> JOB_BEGINNING_STATUS
/diagnosis/vs/0 -> DRYER_DIAGNOSIS
Dryer-specific controls only. The shared laundry surface -- power/kids-lock/
remote-control fallback pairs, buzzer, energy meter, job-beginning-status, and
the /course/vs/0 cycle select -- lives in laundry.py.
Course table captured 2026-05-29 on a DA_WM_TP2_20_COMMON_DV5000T. Other
dryer models may use a different course table; options=() means HA renders
whatever the device reports, and the write_fn validates against this table.
/washer/vs/0 -> DRYER_SETTINGS (dryLevel, dryTime, dryerType, wrinklePrevent)
/course/vs/0 -> DRYER_COURSE (shared cycle select; see below)
/diagnosis/vs/0 -> DRYER_DIAGNOSIS
"""
from ..capability import Capability
from ..entities import SelectDesc, SensorDesc, SwitchDesc
# Course table: hex codes -> human names (Table_03, DV5000T-class).
_COURSE_NAMES = {
0x16: 'Cotton',
0x18: 'Synthetics',
0x19: 'Delicates',
0x1A: 'Wool',
0x1B: 'Bedding',
0x1C: 'Shirts',
0x1D: 'Towels',
0x1E: 'Outdoor',
0x1F: 'Mixed Load',
0x20: 'Iron Dry',
0x23: 'Quick Dry 35',
0x24: 'Cool Air',
0x25: 'Warm Air',
0x27: 'Time Dry',
}
_COURSE_CODE_BY_NAME = {name: code for code, name in _COURSE_NAMES.items()}
from ..entities import SensorDesc, SwitchDesc
from .laundry import cycle_select
def _wrinkle_write(p, rep, href=None):
@@ -40,20 +19,6 @@ def _wrinkle_write(p, rep, href=None):
return ['washer', 'vs', '0'], {'x.com.samsung.da.wrinklePrevent': p}
def _course_write(p, rep, href=None):
"""Encode a human course name to the Samsung hex-encoded course string."""
code = _COURSE_CODE_BY_NAME.get(p)
if code is None:
return None
return ['st', 'dryercourse', 'vs', '0'], {
'x.com.samsung.da.st.dryerMode': f'Course_{code:02X}',
}
# ---------------------------------------------------------------------------
# Capabilities
# ---------------------------------------------------------------------------
DRYER_SETTINGS = Capability(
href='/washer/vs/0',
poll_tier='warm',
@@ -71,28 +36,19 @@ DRYER_SETTINGS = Capability(
),
)
# /course/vs/0 -- cycle selection, shared with washer/dishwasher via
# laundry.cycle_select (options read live from /wm/editcourse/vs/0, written as
# an RMW on the options array). Course display names live in translations
# under entity.select.dryer_cycle (Table_03, DV5000-class, captured
# 2026-05-29). Codes 0x21 and 0x4C appear in the issue #14 DV90BB5245AES1
# editCourseList but aren't identified yet -- they render as the raw code
# until named. The /st/dryercourse/vs/0 resource re-encodes the same selected
# course and is ignored (ignored.py) -- the mirror of how /st/washercourse/vs/0
# is ignored for washers.
DRYER_COURSE = Capability(
href='/st/dryercourse/vs/0',
poll_tier='warm',
href='/course/vs/0',
entities=(
SelectDesc(key='dryer_mode', field='x.com.samsung.da.st.dryerMode',
name='Dryer mode', icon='mdi:tumble-dryer',
options=tuple(_COURSE_CODE_BY_NAME),
value_fn=lambda v: _COURSE_NAMES.get(
int(v.split('_')[1], 16) if isinstance(v, str) and '_' in v else -1
),
write_fn=_course_write),
),
)
JOB_BEGINNING_STATUS = Capability(
href='/wm/jobbeginingstatus/vs/0',
poll_tier='warm',
entities=(
SensorDesc(key='job_beginning_status',
field='x.com.samsung.da.jobBeginingStatus',
name='Job beginning status',
entity_category='diagnostic'),
cycle_select(translation_key='dryer_cycle', icon='mdi:tumble-dryer'),
),
)
@@ -19,7 +19,7 @@ import datetime
from ..capability import Capability
from ..entities import (
BinarySensorDesc, ButtonDesc, NumberDesc, SelectDesc, SensorDesc,
BinarySensorDesc, NumberDesc, SelectDesc, SensorDesc,
SwitchDesc, TimeDesc,
)
from .common import normalize_temp_unit
@@ -100,6 +100,12 @@ ICEMAKER_NIGHTTIME = Capability(
# Icemaker (generic — covers /icemaker/one/vs/0, /icemaker/two/vs/0)
# /icemaker/status/vs/0 is kept as exact-href cap and binds first.
# /icemaker/nighttime/vs/0 is excluded by match_fn (lacks iceMaker.state).
#
# Entity names are derived from x.com.samsung.da.iceMaker.name ("CUBED_ICE",
# "ICE_BITES") via name_field, not the href's "one"/"two" segment -- these two
# ice makers are independent on/off toggles that can both be enabled at once
# (issue #27), so they stay separate entities rather than a single ice-type
# select, but users still want them labeled with the device's own names.
# ---------------------------------------------------------------------------
def _icemaker_write(field):
@@ -112,6 +118,7 @@ ICEMAKER_GENERIC = Capability(
href=None,
href_prefix='/icemaker/',
match_fn=lambda rep, resources: 'x.com.samsung.da.iceMaker.state' in rep,
name_field='x.com.samsung.da.iceMaker.name',
poll_tier='warm',
entities=(
SensorDesc(key='making_status',
@@ -209,6 +216,14 @@ DEFROST_DELAY = Capability(
),
)
# OCF-native boolean mirror of DEFROST_DELAY. The captured TP1X_REF_21K
# firmware publishes the same state on both hrefs, but only the vendor resource
# above has a confirmed write contract. Bind the native mirror without another
# entity so discovery records it as an intentional duplicate.
DEFROST_DELAY_NATIVE_DUPLICATE = Capability(
href='/defrost/delay/0',
)
DEFROST_BLOCK_STATUS = Capability(
href='/defrost/block/vs/0',
poll_tier='warm',
@@ -220,28 +235,6 @@ DEFROST_BLOCK_STATUS = Capability(
),
)
# ---------------------------------------------------------------------------
# Self-check diagnostic
# ---------------------------------------------------------------------------
SELF_CHECK = Capability(
href='/selfcheck/vs/0',
poll_tier='cold',
entities=(
SensorDesc(key='selfcheck_status', field='x.com.samsung.da.status',
name='Self-check status', icon='mdi:stethoscope',
entity_category='diagnostic'),
SensorDesc(key='selfcheck_result', field='x.com.samsung.da.result',
name='Self-check result', icon='mdi:clipboard-check-outline',
entity_category='diagnostic'),
ButtonDesc(key='selfcheck_start', field='', name='Start self-check',
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})),
),
)
# ---------------------------------------------------------------------------
# Refrigeration modes (rapid cooling)
# ---------------------------------------------------------------------------
@@ -494,17 +487,64 @@ BEVERAGE_ZONE = Capability(
),
)
# ---------------------------------------------------------------------------
# Pantry / Cool Select Zone -- a convertible compartment toggled between
# wine/deli/drinks temperature presets (issue #20). Same shape as
# BEVERAGE_ZONE (a controllable named sub-zone with a mode + supported-modes
# list) but a distinct resource/field set -- x.com.samsung.da.mode /
# x.com.samsung.da.supportedOptions on /status/pantry/one/vs/0, rather than
# roomDesiredMode/roomSupportedModes on /specialzone/one/vs/0. Only a "one"
# instance has been seen; not generalized to a pattern cap until a second
# instance turns up.
# ---------------------------------------------------------------------------
def _pantry_write(p, rep, href=None):
return ['status', 'pantry', 'one', 'vs', '0'], {'x.com.samsung.da.mode': p}
PANTRY_ZONE = Capability(
href='/status/pantry/one/vs/0',
poll_tier='warm',
entities=(
SelectDesc(key='pantry_zone_mode', field='x.com.samsung.da.mode',
name='Pantry zone mode', icon='mdi:glass-wine',
translation_key='pantry_zone_mode',
entity_category='config',
options_field='x.com.samsung.da.supportedOptions',
write_fn=_pantry_write),
),
)
# ---------------------------------------------------------------------------
# Flex zone (convertible drawer — /mode/vs/0 on RF9000-class fridges)
#
# x.com.samsung.da.modes holds multiple orthogonal flags in one list.
# The flex zone entry is identified by the CV_TTYPE_RF9000A_ prefix.
# x.com.samsung.da.modes holds multiple orthogonal flags in one list; the
# flex-zone entry is whichever item is also a member of supportedOptions --
# the other flags (WATERFILTER_*, DEFROST_BLOCK_*, the CVN_*_ZONE marker)
# never appear there. The prefix on that item varies by fridge family
# (CV_TTYPE_RF9000A_ on RF9000-class, CV_FDR_ on Bespoke-class -- issue #27 /
# #26, where the old CV_TTYPE_RF9000A_-only match left this entity bound but
# stuck on None), so match by list membership instead of a hardcoded prefix.
# Write replaces only that item; other flags are preserved.
# ---------------------------------------------------------------------------
def _flex_zone_supported(rep):
return set(rep.get('x.com.samsung.da.supportedOptions') or ())
def _flex_zone_current(rep):
# Every dump seen has at most one modes/supportedOptions overlap, so
# "first match" and "strip all matches" (in the write below) agree. If a
# future device ever reports two, this reads the first and the write
# would drop both -- revisit if that turns up.
modes = rep.get('x.com.samsung.da.modes') or []
supported = _flex_zone_supported(rep)
return next((m for m in modes if m in supported), None)
def _flex_zone_write(p, rep, href=None):
modes = list(rep.get('x.com.samsung.da.modes') or [])
modes = [m for m in modes if not m.startswith('CV_TTYPE_RF9000A_')]
supported = _flex_zone_supported(rep)
modes = [m for m in (rep.get('x.com.samsung.da.modes') or []) if m not in supported]
modes.append(p)
return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': modes}
@@ -514,39 +554,17 @@ FLEX_ZONE = Capability(
poll_tier='warm',
entities=(
SelectDesc(key='flex_zone_mode',
field='x.com.samsung.da.modes',
name='Flex zone mode', icon='mdi:thermostat',
translation_key='flex_zone_mode',
entity_category='config',
options_field='x.com.samsung.da.supportedOptions',
exists_fn=lambda rep, resources: bool(
rep.get('x.com.samsung.da.supportedOptions')),
value_fn=lambda modes: next(
(m for m in (modes or [])
if m.startswith('CV_TTYPE_RF9000A_')), None),
rep_fn=_flex_zone_current,
write_fn=_flex_zone_write),
),
)
# ---------------------------------------------------------------------------
# Firmware update
# ---------------------------------------------------------------------------
FIRMWARE_UPDATE = Capability(
href='/otninformation/vs/0',
poll_tier='cold',
entities=(
BinarySensorDesc(
key='firmware_update',
field='x.com.samsung.da.newVersionAvailable',
name='Firmware update available',
device_class='update',
entity_category='diagnostic',
value_fn=lambda v: str(v).lower() == 'true' if v is not None else None,
),
),
)
# ---------------------------------------------------------------------------
# Generic door pattern capability (href=None — use as pattern_cap only)
# ---------------------------------------------------------------------------
@@ -655,6 +673,14 @@ ICEMAKER_STATUS_FALLBACK = Capability(
),
)
# OCF-native aggregate mirror of ICEMAKER_STATUS_FALLBACK. On the captured
# TP1X_REF_21K it duplicates both the vendor aggregate and the richer per-unit
# /icemaker/one|two/vs/0 resources. Its write contract is not advertised, so
# keep the proven per-unit/vendor controls and bind this as a duplicate only.
ICEMAKER_STATUS_NATIVE_DUPLICATE = Capability(
href='/icemaker/status/0',
)
# OCF-native /refrigeration/0 (issue #7's unbound_hrefs) -- the odd one out
# in this section: its three fields duplicate two *different* richer
# hrefs (REFRIGERATION's rapidFridge/rapidFreezing and
@@ -85,14 +85,9 @@ IGNORED: list[Capability] = [
# Warranty/service-plan enrollment status — every field reads "Unknown"
# on hardware not enrolled in a Samsung Care+ style program.
Capability(href='/dginformation/vs/0'),
# AI energy-saving level; only one value ('1') is ever in
# supportedAiLevel on hardware seen so far, so there's no real choice
# to expose. Revisit if a device surfaces more than one supported level.
Capability(href='/energy/ailevel/vs/0'),
# OCF-native vacation-mode flag (fridge). Only one value ('RVACATION_OFF')
# has ever been seen in `modes` (issue #7's dump), same "no real choice
# to expose yet" situation as /energy/ailevel/vs/0 above. Revisit if a
# device surfaces it toggled on.
# 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'),
# Opaque integer with no supportedModes/options list to interpret it
# against — meaning unclear from the raw resource alone.
@@ -108,6 +103,10 @@ IGNORED: list[Capability] = [
# 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'),
# 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'),
# Empty on every washer dump seen so far.
Capability(href='/wm/welcomemsg/vs/0'),
# User-saved custom course slots (F1-FA). No controllable/observable
@@ -1,13 +1,28 @@
"""Capabilities specific to the laundry-family appliances (dishwasher, dryer).
"""Capabilities shared across the laundry family (washer, dryer, dishwasher).
Resources verified against the DW9000F-class dump at 10.0.0.129.
Note: door-LED keys use NO `x.com.samsung.da.` prefix — `setBrightness` and
`setNightLight` — preserved exactly as they appear in the OCF resource rep.
Anything here is bound by more than one laundry registry, so it lives in one
place instead of being copied per family. Device-type-specific controls (wash
temperature, dry level, dishwasher storm-wash, etc.) stay in washer.py /
dryer.py / dishwasher.py; only the genuinely shared laundry surface is here.
Generic OCF controls that aren't laundry-specific (power, kids-lock, remote
control, energy meter) live in common.py, not here.
Resource hrefs seen across laundry dumps:
/doorled/light/vs/0 -> DOOR_LED (door LED brightness / night light)
/settings/sound/*/vs/0-> SOUND_MODE / SOUND_VOLUME
/buzzersound/vs/0 -> BUZZER_SOUND (buzzer + optional finish chime)
/course/vs/0 -> the cycle select + per-family course options
/wm/editcourse/vs/0 -> live editCourseList that drives the cycle options
/wm/jobbeginingstatus/vs/0 -> JOB_BEGINNING_STATUS
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 ..capability import Capability
from ..entities import NumberDesc, SelectDesc, SwitchDesc, TimeDesc
from ..entities import NumberDesc, SelectDesc, SensorDesc, SwitchDesc, TimeDesc
_LED_LEVELS = ('Low', 'High')
_SOUND_MODES = ('voice', 'tone', 'mute')
@@ -100,3 +115,177 @@ SOUND_VOLUME = Capability(
{'level': str(int(p))})),
),
)
# ---------------------------------------------------------------------------
# /buzzersound/vs/0 -- buzzer volume and (on some units) a separate finish
# chime. Fields have no 'x.com.samsung.da.' prefix in this resource. Seen on
# washers and DA_WM_TP1 dryers; the dryer dump carries only setBuzzerSound
# (no supportedFinishSound), so finish_sound self-gates off there.
# ---------------------------------------------------------------------------
BUZZER_SOUND = Capability(
href='/buzzersound/vs/0',
entities=(
SelectDesc(key='buzzer_sound', field='setBuzzerSound',
name='Buzzer sound', 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',
name='Finish sound', 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})),
),
)
# ---------------------------------------------------------------------------
# Cycle selection over /course/vs/0.
#
# The selected course and every other user-tunable option ride in the
# x.com.samsung.da.options array on /course/vs/0 as `<Prefix>_<value>` tokens;
# a write is a read-modify-write of that whole array (cycle_write). The set of
# *selectable* courses is not hardcoded -- it's read live from
# x.com.samsung.da.editCourseList on /wm/editcourse/vs/0 (cycle_options), so we
# never show a course a given model doesn't have or hide one it does. Course
# codes are uppercase hex; display names live in translations under
# entity.select.<translation_key>.state.<id lowercased> so they can be
# localized -- every device-enum select in this integration works this way.
# washer.py's course comment has the byte-level evidence for why the options[]
# MostUsed_* entry is *not* a trustworthy second source.
#
# Shared verbatim by washer, dishwasher, and dryer -- all DA_WM_-family boards
# expose the same /course/vs/0 options contract.
# ---------------------------------------------------------------------------
def hex_pairs(codes):
"""'1C1D21...' -> ['1C', '1D', '21', ...]."""
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:
return []
return hex_pairs(raw.split('_', 1)[1])
def cycle_options(resources):
rep = resources.get('/wm/editcourse/vs/0') or {}
return parse_edit_course_list(rep.get('x.com.samsung.da.editCourseList'))
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]
return None
def replace_in_options(options, prefix, new_value):
return [f"{prefix}_{new_value}" if isinstance(o, str) and o.startswith(prefix + '_') else o
for o in options]
def cycle_write(p, rep, href=None):
opts = list(rep.get('x.com.samsung.da.options') or [])
if not opts:
return None
return ['course', 'vs', '0'], {
'x.com.samsung.da.options': replace_in_options(opts, 'Course', p),
}
def cycle_select(*, translation_key, icon):
"""A 'Cycle' select over /course/vs/0, labelled from `translation_key`.
The caller supplies the family's translation key (washer_cycle /
dishwasher_cycle / dryer_cycle) and icon; the option list, current value,
and write path are all shared.
"""
return SelectDesc(
key='cycle', name='Cycle', icon=icon, translation_key=translation_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'),
write_fn=cycle_write,
)
# ---------------------------------------------------------------------------
# Plain boolean toggles over /course/vs/0's options[] array: a
# '<prefix>_On'/'<prefix>_Off' token, read-modify-written the same way as
# the 'Course' token above. Shared by washer (bubble soak, pre-wash,
# intensive -- issue #22) and dishwasher (storm wash, auto release dry) --
# both families ride this exact contract, just with different prefixes and
# different presence/validation needs on top.
# ---------------------------------------------------------------------------
def bool_option_write(prefix):
def write(p, rep, href=None):
if p not in ('On', 'Off'):
return None
opts = list(rep.get('x.com.samsung.da.options') or [])
if not opts:
return None
return ['course', 'vs', '0'], {
'x.com.samsung.da.options': replace_in_options(opts, prefix, p),
}
return write
def bool_option_value(prefix):
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
def bool_option_switch(key, name, 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
the token at all (washer's bubble soak/pre-wash/intensive); leave False
for a toggle every device in the family reports (dishwasher's storm
wash). validate_fn is passed straight through to SwitchDesc for callers
that need to reject a write against live device state (e.g. washer's
per-course availability check) -- this factory has no opinion on it and
building one, if needed, is the caller's job.
"""
return SwitchDesc(
key=key, name=name, 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),
validate_fn=validate_fn,
)
# ---------------------------------------------------------------------------
# /wm/jobbeginingstatus/vs/0 -- the "why did the cycle not start" reason
# (e.g. door open, no water). The vendor field is x.com.samsung.da.currentStatus
# on every laundry dump that populates it (washer + DA_WM_TP1 dryer). An
# earlier dryer descriptor read x.com.samsung.da.jobBeginingStatus, but no dump
# ever carried that field, so the dryer sensor was always blank -- fixed by
# sharing this one reader.
# ---------------------------------------------------------------------------
JOB_BEGINNING_STATUS = Capability(
href='/wm/jobbeginingstatus/vs/0',
poll_tier='warm',
entities=(
SensorDesc(key='job_beginning_status',
field='x.com.samsung.da.currentStatus',
name='Job beginning status',
entity_category='diagnostic'),
),
)
@@ -75,7 +75,9 @@ OPERATIONAL_STATE = Capability(
poll_tier='hot',
entities=(
SensorDesc(key='machine_state', field='x.com.samsung.da.state',
name='Machine state', value_fn=_to_ocf),
name='Machine 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.
@@ -39,6 +39,15 @@ SETPOINT_MIN_C = 30
SETPOINT_MAX_C = 270
SETPOINT_STEP_C = 5
# Verified against issue #44's range dump (NSI6DG9100SRAA, unit reported as
# "Fahrenheit" on /temperatures/vs/0): Bake mode's modeSpec on /mode/vs/0
# reports tempMinF/tempMaxF/tempIntervalF = 175/550/5. Kept as a separate
# constant set rather than converted from the Celsius bounds above, which
# are themselves unverified (no live dump; see module docstring).
SETPOINT_MIN_F = 175
SETPOINT_MAX_F = 550
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.
@@ -129,8 +138,9 @@ def _oven_setpoint_write(p, rep, href=None):
temp = float(p)
except (TypeError, ValueError):
return None
temp_i = int(round(temp / SETPOINT_STEP_C) * SETPOINT_STEP_C)
if not (SETPOINT_MIN_C <= temp_i <= SETPOINT_MAX_C):
min_v, max_v, step_v = _setpoint_bounds(rep)
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')
if not items:
@@ -223,7 +233,9 @@ OVEN_OPERATIONAL_STATE = Capability(
poll_tier='hot',
entities=(
SensorDesc(key='machine_state', field='x.com.samsung.da.state',
name='Machine state', icon='mdi:stove', value_fn=_to_ocf),
name='Machine 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',
name='Cycle active', device_class='running',
value_fn=lambda v: _SAMSUNG_STATE_TO_OCF.get(v) == 'active'),
@@ -261,14 +273,24 @@ def _oven_temp_unit(rep):
same aggregate `/temperatures/vs/0` items[] resource type, which on
fridge hardware carries a per-item `x.com.samsung.da.unit` field
('Celsius'/'Fahrenheit') that was previously hardcoded away (issue #7).
No live oven dump has surfaced a non-Celsius reading yet, so this keeps
the verified '°C' default when the field is absent, but reads it live
if a device ever reports otherwise."""
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')
def _setpoint_bounds(rep):
"""(min, max, step) for the live unit -- see the SETPOINT_*_C/_F
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':
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',
@@ -278,6 +300,9 @@ OVEN_SETPOINT = Capability(
name='Setpoint', 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),
@@ -312,6 +337,13 @@ OVEN_CONNECTED = Capability(
),
)
# Static cavity capability metadata (count/type/supported features) -- no
# per-cavity data varies at runtime on any dump seen so far (issue #44's
# 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_MODE = Capability(
href='/mode/vs/0',
poll_tier='warm',
@@ -0,0 +1,130 @@
"""Capabilities for the cooktop half of range/combo appliances (issue #44,
model TP1X_DA-KS-RANGE-0102X).
Not to be confused with PR #23's registry/capabilities/cooktop.py, which
covers an unrelated standalone-cooktop product (NA9300K-class) that encodes
burner state as strings inside /mode/vs/0's options array instead of the
structured /cooktop/status/vs/0 resource this module reads -- two different
OCF surfaces that happen to share the English word "cooktop".
Unlike the rest of the OCF surface, these hrefs use plain camelCase field
names (no `x.com.samsung.da.` prefix) -- `/cooktop/status/vs/0` already
looks like a vendor resource migrated onto OCF-standard-shaped field naming.
`/cooktop/status/vs/0` carries every burner's live state in one `burnerList`
array (indexed by `burnerNumber`, not by a separate href per burner like
fridge ice makers), so per-burner entities are hardcoded up to MAX_BURNERS
and gated by exists_fn against whichever indices the device actually
reports -- harmless over-declaration, per common.py's UNIVERSAL note, since
an index absent from burnerList just never binds.
Write surfaces here are unproven (no live device to verify against, same
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
# Observed as high as 4 (this issue's dump); user-reported hardware with 5
# burners exists. Kept a little above both since exists_fn gates unused
# slots out -- see module docstring.
MAX_BURNERS = 6
def _burner(burner_list, 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
def _burner_field_fn(i, field):
return lambda burner_list: (_burner(burner_list, i) or {}).get(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')
def _power_level_options(resources):
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')
if not burner_list:
return None
new_list = []
found = False
for b in burner_list:
if b.get('burnerNumber') == i:
b = dict(b)
b['powerLevel'] = p
found = True
new_list.append(b)
if not found:
return None
return ['cooktop', 'status', 'vs', '0'], {'burnerList': new_list}
return write
def _burner_entities(i):
exists = _burner_exists(i)
n = i + 1
return (
SelectDesc(key=f'burner_{i}_power_level', field='burnerList',
name=f'Burner {n} power level', icon='mdi:knob',
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',
name=f'Burner {n} state', icon='mdi:stove',
exists_fn=exists,
value_fn=_burner_field_fn(i, 'operationState')),
BinarySensorDesc(key=f'burner_{i}_hot_surface', field='burnerList',
name=f'Burner {n} hot surface', device_class='heat',
exists_fn=exists,
value_fn=_burner_hot_surface_fn(i)),
)
COOKTOP_STATUS = Capability(
href='/cooktop/status/vs/0',
poll_tier='hot',
entities=(
SensorDesc(key='cooktop_state', field='operationState',
name='Cooktop state', icon='mdi:pot-steam'),
*[e for i in range(MAX_BURNERS) for e in _burner_entities(i)],
),
)
# Static burner-count/power-level-list metadata, read directly by
# 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')
# 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
# feature itself is enabled -- not a live "surface is hot right now" alert
# (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',
entities=(
BinarySensorDesc(key='cooktop_safety_shutoff_enabled', field='safetyAlert',
name='Hot surface auto-shutoff enabled',
entity_category='config',
value_fn=lambda v: (v or {}).get('state') == 'on'),
),
)
@@ -5,16 +5,25 @@ 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.
The shared laundry surface -- power/kids-lock/remote-control OCF+vendor
fallback pairs, buzzer, energy meter, job-beginning-status, and the
/course/vs/0 cycle-select machinery -- lives in laundry.py. Only washer-
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 ..capability import Capability
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc
from .common import clamp_power, wh_to_kwh
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc
from .laundry import (
bool_option_exists, bool_option_switch, cycle_options, cycle_select, hex_pairs, option_value,
replace_in_options,
)
# ---------------------------------------------------------------------------
# Course_XX hex codes. The 23 codes named in strings.json/translations
# Course_XX hex codes. 23 of the codes named in strings.json/translations
# under entity.select.washer_cycle.state.<id, lowercased> were captured
# from a live WW90DG6U25LEU4's x.com.samsung.da.editCourseList
# (EditCourseList_1C1D211B1E29243328262722202325322F2E30662D8F96), matched
@@ -29,16 +38,23 @@ from .common import clamp_power, wh_to_kwh
# you'd expect to be pinned (default cycle + maintenance cycle),
# corroborating the positional match.
#
# A further 5 codes -- '36' Wash+Dry, '37' Air Wash, '38' Cotton Dry,
# '39' Synthetics Dry, and a second, distinct '1F' Intense Cold (not the
# same code as '8F' above) -- came from a WD90T654DBN/S1 washer/dryer
# combo's editCourseList and were named from that user's app screenshot
# (issue #22). Combo units carry their own course set, so these codes
# don't imply anything about '1F' on a plain washer.
#
# No static fallback list of those codes is kept here, deliberately: other
# washer models have a different actual course set (a second dump's active
# course, '65', isn't even in the list above; models with 'AI Wash'/'Mixed
# Load' -- both "applicable models only" per the manual -- would have yet
# another set), so hardcoding one device's list would show/hide the wrong
# options on a different model. _cycle_options() below reads only the live
# options on a different model. laundry.cycle_options() reads only the live
# x.com.samsung.da.editCourseList; if a device doesn't populate that
# resource, the cycle select isn't created at all (see WASHER_COURSE's
# exists_fn). x.com.samsung.da.options' MostUsed_* entry was considered as
# a fallback source (its first byte reliably equals the currently-selected
# resource, the cycle select isn't created at all (see cycle_select's
# exists_fn). x.com.samsung.da.options' MostUsed_* entry was considered as a
# fallback source (its first byte reliably equals the currently-selected
# Course_XX on both dumps we have), but the bytes after that don't
# correspond to any confirmed course code on either device -- e.g. dump 1's
# MostUsed_1C8410923FA67F00000000000000 decodes to
@@ -46,23 +62,6 @@ from .common import clamp_power, wh_to_kwh
# so it isn't trustworthy as a list of selectable courses and isn't used.
# ---------------------------------------------------------------------------
def _hex_pairs(codes):
"""'1C1D211B1E29...' -> ['1C', '1D', '21', '1B', '1E', '29', ...]."""
return [codes[i:i + 2] for i in range(0, len(codes) - 1, 2)]
def _parse_edit_course_list(raw):
"""'EditCourseList_1C1D211B1E29...' -> ['1C', '1D', '21', '1B', '1E', '29', ...]."""
if not isinstance(raw, str) or '_' not in raw:
return []
return _hex_pairs(raw.split('_', 1)[1])
def _cycle_options(resources):
rep = resources.get('/wm/editcourse/vs/0') or {}
return _parse_edit_course_list(rep.get('x.com.samsung.da.editCourseList'))
# ---------------------------------------------------------------------------
# /washer/vs/0 -- wash temperature, spin speed, rinse cycle count
#
@@ -93,34 +92,28 @@ WASHER_SETTINGS = Capability(
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',
name='Dry level', 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})),
),
)
# ---------------------------------------------------------------------------
# /course/vs/0 -- selected course, read/write (RMW on the options array,
# same shape as dishwasher.CYCLE_OPTIONS._cycle_write).
# /course/vs/0 -- the cycle select is the shared laundry.cycle_select; the
# drum-clean and dispenser-dosing entities below are washer-specific reads off
# the same options array.
# ---------------------------------------------------------------------------
def _option_value(options, prefix):
for o in (options or []):
if isinstance(o, str) and o.startswith(prefix + '_'):
return o.split('_', 1)[1]
return None
def _replace_in_options(options, prefix, new_value):
return [f"{prefix}_{new_value}" if isinstance(o, str) and o.startswith(prefix + '_') else o
for o in options]
def _cycle_write(p, rep, href=None):
opts = list(rep.get('x.com.samsung.da.options') or [])
if not opts:
return None
return ['course', 'vs', '0'], {
'x.com.samsung.da.options': _replace_in_options(opts, 'Course', p),
}
# Drum Clean+ maintenance tracking, from the same options[] array as the
# selected course. DrumCleanProposal_<N> is the wash-cycle interval between
@@ -134,8 +127,8 @@ def _cycle_write(p, rep, href=None):
# 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')
proposal = option_value(opts, 'DrumCleanProposal')
washed = option_value(opts, 'WashingTimes')
if proposal is None or washed is None:
return None
try:
@@ -145,7 +138,7 @@ 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:
@@ -182,45 +175,125 @@ def _drum_clean_last_cleaned(rep):
# 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}')
return _hex_pairs(raw) if raw else []
raw = option_value(rep.get('x.com.samsung.da.options'), f'Supported{prefix}')
return hex_pairs(raw) if raw else []
def _level_options(prefix):
return lambda resources: _supported_level_options(resources, prefix)
def _dosing_level(prefix):
"""Current dose code, normalized to the `Supported<prefix>` code format.
The device reports the selected level as `<prefix>_<code>` with the code
un-padded (e.g. '3'), but the valid codes -- which are also this select's
options and its translation keys -- come from `Supported<prefix>_<hexpairs>`
as zero-padded hex pairs (e.g. '03'). Left as '3', the current value sits
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')
raw = option_value(opts, prefix)
if raw is None:
return None
supported_raw = option_value(opts, f'Supported{prefix}')
try:
target = int(raw, 16)
except (TypeError, ValueError):
return raw
for code in hex_pairs(supported_raw) if supported_raw else []:
try:
if int(code, 16) == target:
return code
except (TypeError, ValueError):
continue
return raw
return fn
def _level_write(prefix):
def write(p, rep, href=None):
opts = list(rep.get('x.com.samsung.da.options') or [])
if not opts:
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')
except (TypeError, ValueError):
native = p
return ['course', 'vs', '0'], {
'x.com.samsung.da.options': _replace_in_options(opts, prefix, p),
'x.com.samsung.da.options': replace_in_options(opts, prefix, native),
}
return write
def _dosing_low(prefix):
return lambda rep: _option_value(
return lambda rep: option_value(
rep.get('x.com.samsung.da.options'), prefix) not in (None, 'Off')
def _dosing_alarm_exists(prefix):
return lambda rep, resources: _option_value(
rep.get('x.com.samsung.da.options'), prefix) is not None
# Bubble soak / pre-wash / intensive-wash toggles, from the same options[]
# array (issue #22 follow-up on a WD90T654DBN/S1 combo). Each rides as a
# plain '<Prefix>_On'/'<Prefix>_Off' token, confirmed by a dump taken with
# Bubble Soak switched on in the app (BubbleSoak_On) -- the same On/Off shape
# already used by AiOption and KidsLockBypass in this same array, so
# PreWashSetting/IntensiveSetting are assumed to follow suit.
#
# Each also has a differently-named hex-pair availability field that lines up
# positionally with editCourseList: BubbleSoakSet, PreWashAvailableSet,
# IntensiveAvailableSet. On the reporter's dump (course '30' at position 1 of
# 24), all three read 'F0' at that position and the toggle was writable --
# and the same dump's earlier state (course '1C' at position 0, 'BubbleSoak
# Off') decodes to '00' for that course, matching the app graying the
# control out there. 'F0'/'00' is treated as available/unavailable on that
# evidence. exists_fn (device-level presence) still only runs once, against
# the setup-time snapshot, so it isn't a fit for this per-course check --
# validate_fn runs on every write attempt instead (dispatched from
# coordinator.async_send_command, ahead of write_fn), rejecting an on-write
# for a course whose byte isn't 'F0' with a user-facing error rather than
# silently no-opping against the device. The read/write/presence machinery
# itself is laundry.bool_option_switch, shared with dishwasher's storm-wash/
# auto-release-dry toggles -- only this per-course gating is washer-only, so
# it stays here rather than in laundry.py (see laundry.bool_option_switch's
# docstring: it takes a prebuilt validate_fn and has no opinion on it).
def _bool_option_switch(key, name, icon, prefix, availability_field):
def validate(p, rep, resources):
"""Reject turning on when the selected course's byte in
`availability_field` isn't 'F0'. Turning off is never blocked. Falls
back to allowing the write whenever the availability data can't be
resolved (unrecognized course, missing/mismatched-length bitmap)
rather than guessing -- a false rejection is worse than an
occasional no-op write."""
if p != 'On':
return None
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
raw = option_value(opts, availability_field)
if raw is None:
return None
pairs = hex_pairs(raw)
if len(pairs) != len(courses):
return None
if pairs[courses.index(current)] != 'F0':
return f"{name} isn't available on the selected cycle."
return None
return bool_option_switch(
key, name, icon, prefix,
entity_category='config', gate_on_presence=True, validate_fn=validate)
WASHER_COURSE = Capability(
href='/course/vs/0',
entities=(
SelectDesc(key='cycle', name='Cycle', icon='mdi:washing-machine',
translation_key='washer_cycle',
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'),
write_fn=_cycle_write),
cycle_select(translation_key='washer_cycle', icon='mdi:washing-machine'),
SensorDesc(key='drum_clean_cycles_remaining', name='Drum clean due in',
icon='mdi:washing-machine-alert', unit='cycles',
state_class='measurement',
@@ -237,8 +310,7 @@ WASHER_COURSE = Capability(
options=_level_options('DetergentLevelCtrl'),
exists_fn=lambda rep, resources: bool(
_level_options('DetergentLevelCtrl')(resources)),
rep_fn=lambda rep: _option_value(
rep.get('x.com.samsung.da.options'), 'DetergentLevelCtrl'),
rep_fn=_dosing_level('DetergentLevelCtrl'),
write_fn=_level_write('DetergentLevelCtrl')),
SelectDesc(key='detergent_water_hardness', name='Detergent water hardness',
icon='mdi:water-opacity',
@@ -247,8 +319,7 @@ WASHER_COURSE = Capability(
options=_level_options('DetergentLevel2Ctrl'),
exists_fn=lambda rep, resources: bool(
_level_options('DetergentLevel2Ctrl')(resources)),
rep_fn=lambda rep: _option_value(
rep.get('x.com.samsung.da.options'), 'DetergentLevel2Ctrl'),
rep_fn=_dosing_level('DetergentLevel2Ctrl'),
write_fn=_level_write('DetergentLevel2Ctrl')),
SelectDesc(key='softener_quantity', name='Softener quantity', icon='mdi:flask-outline',
translation_key='washer_dosing_quantity',
@@ -256,8 +327,7 @@ WASHER_COURSE = Capability(
options=_level_options('SoftenerLevelCtrl'),
exists_fn=lambda rep, resources: bool(
_level_options('SoftenerLevelCtrl')(resources)),
rep_fn=lambda rep: _option_value(
rep.get('x.com.samsung.da.options'), 'SoftenerLevelCtrl'),
rep_fn=_dosing_level('SoftenerLevelCtrl'),
write_fn=_level_write('SoftenerLevelCtrl')),
SelectDesc(key='softener_concentration', name='Softener concentration',
icon='mdi:flask-plus-outline',
@@ -266,175 +336,21 @@ WASHER_COURSE = Capability(
options=_level_options('SoftenerLevel2Ctrl'),
exists_fn=lambda rep, resources: bool(
_level_options('SoftenerLevel2Ctrl')(resources)),
rep_fn=lambda rep: _option_value(
rep.get('x.com.samsung.da.options'), 'SoftenerLevel2Ctrl'),
rep_fn=_dosing_level('SoftenerLevel2Ctrl'),
write_fn=_level_write('SoftenerLevel2Ctrl')),
BinarySensorDesc(key='detergent_low', name='Detergent low',
icon='mdi:alert-circle-outline', device_class='problem',
exists_fn=_dosing_alarm_exists('DetergentAlarm'),
exists_fn=bool_option_exists('DetergentAlarm'),
rep_fn=_dosing_low('DetergentAlarm')),
BinarySensorDesc(key='softener_low', name='Softener low',
icon='mdi:alert-circle-outline', device_class='problem',
exists_fn=_dosing_alarm_exists('SoftenerAlarm'),
exists_fn=bool_option_exists('SoftenerAlarm'),
rep_fn=_dosing_low('SoftenerAlarm')),
),
)
# ---------------------------------------------------------------------------
# /buzzersound/vs/0 -- buzzer volume and (on some units) a separate finish
# chime. Fields have no 'x.com.samsung.da.' prefix in this resource, unlike
# most other washer hrefs.
# ---------------------------------------------------------------------------
BUZZER_SOUND = Capability(
href='/buzzersound/vs/0',
entities=(
SelectDesc(key='buzzer_sound', field='setBuzzerSound',
name='Buzzer sound', 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',
name='Finish sound', 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})),
),
)
# ---------------------------------------------------------------------------
# /wm/jobbeginingstatus/vs/0 -- same href as dryer.JOB_BEGINNING_STATUS, but
# a different field name (currentStatus, not jobBeginingStatus).
# ---------------------------------------------------------------------------
WASHER_JOB_BEGINNING_STATUS = Capability(
href='/wm/jobbeginingstatus/vs/0',
poll_tier='warm',
entities=(
SensorDesc(key='job_beginning_status',
field='x.com.samsung.da.currentStatus',
name='Job beginning status',
entity_category='diagnostic'),
),
)
# ---------------------------------------------------------------------------
# OCF-native / '-vs' fallback pairs for power, kids-lock, remote control.
#
# Same shape as fridge.py's "Aggregate-resource fallbacks": the generic OCF
# href (/power/0, plain boolean 'value') is preferred when present; the
# vendor '-vs' href (richer historically, but for these three controls just
# a string-encoded duplicate) only binds when the generic href is absent
# from this device's resource set, via match_fn. Scoped to washer.py, not
# common.py -- no other device type has been confirmed to expose the
# generic hrefs, so this must not change behavior for dishwasher/dryer/
# oven/refrigerator.
# ---------------------------------------------------------------------------
POWER_GENERIC = Capability(
href='/power/0',
entities=(
SwitchDesc(key='power_switch', field='value',
name='Power',
value_fn=lambda v: bool(v),
write_fn=lambda p, rep, href=None: (
['power', '0'], {'value': p == 'On'})),
),
)
POWER_VS_FALLBACK = Capability(
href='/power/vs/0',
match_fn=lambda rep, resources: '/power/0' not in resources,
entities=(
SwitchDesc(key='power_switch', field='x.com.samsung.da.power',
name='Power',
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'})),
),
)
KIDS_LOCK_GENERIC = Capability(
href='/kidslock/0',
entities=(
SwitchDesc(key='child_lock', field='value',
name='Child lock', device_class='lock',
value_fn=lambda v: bool(v),
write_fn=lambda p, rep, href=None: (
['kidslock', '0'], {'value': p == 'On'})),
),
)
KIDS_LOCK_VS_FALLBACK = Capability(
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',
name='Child lock', 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'})),
),
)
REMOTE_CONTROL_GENERIC = Capability(
href='/remotectrl/0',
entities=(
BinarySensorDesc(key='remote_control', field='value',
name='Smart Control', 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,
entities=(
BinarySensorDesc(key='remote_control',
field='x.com.samsung.da.remoteControlEnabled',
name='Smart Control', device_class='connectivity',
value_fn=lambda v: str(v).lower() == 'true'),
),
)
# ---------------------------------------------------------------------------
# /energy/consumption/vs/0 -- washer-specific override of common.ENERGY_METER
# (issue #6).
#
# instantaneousPower is a dead field on every TP1-class washer dump collected
# so far (7 dumps, 3 physical devices): always the literal sentinel '-500',
# unchanged between off/idle-on/mid-cycle states and across different
# courses. common.clamp_power floors that to a misleading "0 W" that reads
# as a real (if idle) measurement rather than "unsupported". Gate the entity
# out entirely when the sentinel is seen, but only then -- if some washer
# model ever reports a real value, this still shows it.
#
# cumulativePower is absent outright on at least one washer model (issue #6),
# unlike every other washer dump. entity.py's generic field-presence gate
# already excludes the entity in that case; the exists_fn here just makes
# that explicit at the capability level instead of relying on the fallback.
# ---------------------------------------------------------------------------
_DEAD_INSTANTANEOUS_POWER = '-500'
WASHER_ENERGY_METER = Capability(
href='/energy/consumption/vs/0',
entities=(
SensorDesc(key='power_watts', field='x.com.samsung.da.instantaneousPower',
name='Power', device_class='power', state_class='measurement',
unit='W', value_fn=clamp_power,
exists_fn=lambda rep, resources: (
rep.get('x.com.samsung.da.instantaneousPower')
!= _DEAD_INSTANTANEOUS_POWER)),
SensorDesc(key='energy_kwh', field='x.com.samsung.da.cumulativePower',
name='Energy', device_class='energy',
state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh,
exists_fn=lambda rep, resources: 'x.com.samsung.da.cumulativePower' in rep),
_bool_option_switch('bubble_soak', 'Bubble soak', 'mdi:chart-bubble',
'BubbleSoak', 'BubbleSoakSet'),
_bool_option_switch('pre_wash', 'Pre wash', 'mdi:washing-machine',
'PreWashSetting', 'PreWashAvailableSet'),
_bool_option_switch('intensive', 'Intensive', 'mdi:washing-machine',
'IntensiveSetting', 'IntensiveAvailableSet'),
),
)
@@ -15,6 +15,11 @@ class Capability:
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
# 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
# Rare optional hook — only operational-state-style resources use this.
on_observation: Optional[Callable[[dict, dict], None]] = None
@@ -27,6 +27,25 @@ class BoundEntity:
desc: SamsungEntityDescription
instance: str = ''
key_override: Optional[str] = None
instance_name: Optional[str] = None
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 a state key."""
return s.replace('_', ' ').title()
def _instance_name(cap: Capability, rep: dict) -> Optional[str]:
"""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."""
if not cap.name_field:
return None
raw = rep.get(cap.name_field)
if not isinstance(raw, str) or not raw:
return None
return _snake_to_title(raw)
def instance_suffix(href: str) -> str:
@@ -37,6 +56,18 @@ def instance_suffix(href: str) -> str:
return ''
def _bind(cap: Capability, href: str, inst: str, inst_name: Optional[str],
key_prefix: Optional[str] = None) -> list[BoundEntity]:
"""Build one BoundEntity per entity on `cap`, sharing the instance/
key-prefix/instance-name computed once by the caller."""
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)
for desc in cap.entities
]
def discover(
resources: dict[str, dict],
registry: dict[str, list[Capability]],
@@ -58,9 +89,7 @@ def discover(
if cap.match_fn is not None and not cap.match_fn(rep, resources):
continue
inst = instance_suffix(href)
for desc in cap.entities:
out.append(BoundEntity(href=href, capability=cap,
desc=desc, instance=inst))
out.extend(_bind(cap, href, inst, _instance_name(cap, rep)))
matched = True
if matched:
@@ -78,10 +107,7 @@ def discover(
# 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']
for desc in cap.entities:
key_override = '_'.join(segs) + '_' + desc.key
out.append(BoundEntity(href=href, capability=cap, desc=desc,
instance=inst, key_override=key_override))
out.extend(_bind(cap, href, inst, _instance_name(cap, rep), '_'.join(segs)))
matched = True
break
@@ -2,7 +2,9 @@
Frozen dataclasses so the future native HA component can consume them as
EntityDescription subclasses unchanged. Read transforms live in value_fn;
presence gating in exists_fn; write logic in write_fn on command platforms.
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
@@ -10,6 +12,11 @@ from dataclasses import dataclass, field
from typing import Any, Callable, Optional
WriteFn = Optional[Callable[[Any, dict], "tuple[list[str], dict] | None"]]
# (payload, rep, resources) -> a human-readable rejection message, 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"]]
def _identity(v: Any) -> Any:
@@ -29,7 +36,7 @@ class SamsungEntityDescription:
rep_fn: Optional[Callable[[dict], Any]] = 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. washer._cycle_options's source).
# presence on a sibling resource (e.g. laundry.cycle_options's source).
exists_fn: Optional[Callable[[dict, dict], bool]] = None
@@ -61,6 +68,7 @@ class SelectDesc(SamsungEntityDescription):
class SwitchDesc(SamsungEntityDescription):
device_class: Optional[str] = None
write_fn: WriteFn = None
validate_fn: ValidateFn = None
@dataclass(frozen=True, kw_only=True)
@@ -77,6 +85,13 @@ class NumberDesc(SamsungEntityDescription):
native_min: Optional[float] = None
native_max: Optional[float] = None
step: Optional[float] = 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
write_fn: WriteFn = None
@@ -86,6 +101,16 @@ class TimeDesc(SamsungEntityDescription):
write_fn: WriteFn = None
@dataclass(frozen=True, kw_only=True)
class ClimateDesc(SamsungEntityDescription):
# A composite entity: it binds one *primary* resource (its href) but the
# climate platform reads sibling resources (power, temperature, wind) from
# the coordinator snapshot and writes to several of them. write_fn takes a
# (kind, value) payload from the platform and returns the (path_segs, body)
# for that one sub-write, so a single desc drives multi-resource writes.
write_fn: WriteFn = None
PLATFORM_OF: dict[type, str] = {
SensorDesc: 'sensor',
BinarySensorDesc: 'binary_sensor',
@@ -94,4 +119,5 @@ PLATFORM_OF: dict[type, str] = {
ButtonDesc: 'button',
NumberDesc: 'number',
TimeDesc: 'time',
ClimateDesc: 'climate',
}
+95 -2
View File
@@ -18,6 +18,7 @@
},
"ice_type": {
"state": {
"off": "Off",
"whiskey_iceball_3": "3 Balls/Day",
"whiskey_iceball_6": "6 Balls/Day",
"whiskey_iceball_9": "9 Balls/Day"
@@ -29,13 +30,25 @@
"sp_ttype_wine_dessert": "Wine and Dessert"
}
},
"pantry_zone_mode": {
"state": {
"fdr_wine": "Wine",
"fdr_deli": "Deli",
"fdr_drinks": "Drinks"
}
},
"flex_zone_mode": {
"state": {
"cv_ttype_rf9000a_freeze": "Freeze",
"cv_ttype_rf9000a_softfreeze": "Soft Freeze",
"cv_ttype_rf9000a_meat_fish": "Meat/Fish",
"cv_ttype_rf9000a_fruit_veggies": "Fruit & Veggies",
"cv_ttype_rf9000a_beverage": "Beverage"
"cv_ttype_rf9000a_beverage": "Beverage",
"cv_fdr_wine": "Wine",
"cv_fdr_deli": "Deli",
"cv_fdr_beverage": "Beverage",
"cv_fdr_meat": "Meat",
"cv_fdr_soft_freezer": "Soft Freezer"
}
},
"dishwasher_cycle": {
@@ -76,7 +89,30 @@
"66": "Denim",
"2d": "Silent Wash",
"8f": "Intense Cold",
"96": "Less Microfiber"
"96": "Less Microfiber",
"36": "Wash+Dry",
"37": "Air Wash",
"38": "Cotton Dry",
"39": "Synthetics Dry",
"1f": "Intense Cold"
}
},
"dryer_cycle": {
"state": {
"16": "Cotton",
"18": "Synthetics",
"19": "Delicates",
"1a": "Wool",
"1b": "Bedding",
"1c": "Shirts",
"1d": "Towels",
"1e": "Outdoor",
"1f": "Mixed Load",
"20": "Iron Dry",
"23": "Quick Dry 35",
"24": "Cool Air",
"25": "Warm Air",
"27": "Time Dry"
}
},
"washer_dosing_quantity": {
@@ -100,6 +136,18 @@
"02": "2x",
"03": "3x"
}
},
"washer_dry_level": {
"state": {
"none": "Off",
"cupboard": "Cupboard",
"30": "30 min",
"60": "1 hr",
"90": "1 hr 30",
"120": "2 hr",
"180": "3 hr",
"240": "4 hr"
}
}
},
"sensor": {
@@ -114,6 +162,51 @@
"observe": "Push (observe)",
"poll": "Polling"
}
},
"machine_state": {
"state": {
"idle": "Idle",
"active": "Active",
"pause": "Paused"
}
},
"air_filter_status": {
"state": {
"normal": "Normal",
"wash": "Wash",
"replace": "Replace"
}
}
},
"climate": {
"airconditioner": {
"state_attributes": {
"fan_mode": {
"state": {
"auto": "Auto",
"low": "Low",
"medium": "Medium",
"high": "High",
"turbo": "Turbo"
}
},
"swing_mode": {
"state": {
"off": "Fixed",
"both": "All directions",
"vertical": "Up and down"
}
},
"preset_mode": {
"state": {
"none": "Off",
"sleep": "Sleep",
"quiet": "Quiet",
"smart": "Smart",
"speed": "Speed"
}
}
}
}
}
},
@@ -18,6 +18,7 @@
},
"ice_type": {
"state": {
"off": "Off",
"whiskey_iceball_3": "3 Balls/Day",
"whiskey_iceball_6": "6 Balls/Day",
"whiskey_iceball_9": "9 Balls/Day"
@@ -29,13 +30,25 @@
"sp_ttype_wine_dessert": "Wine and Dessert"
}
},
"pantry_zone_mode": {
"state": {
"fdr_wine": "Wine",
"fdr_deli": "Deli",
"fdr_drinks": "Drinks"
}
},
"flex_zone_mode": {
"state": {
"cv_ttype_rf9000a_freeze": "Freeze",
"cv_ttype_rf9000a_softfreeze": "Soft Freeze",
"cv_ttype_rf9000a_meat_fish": "Meat/Fish",
"cv_ttype_rf9000a_fruit_veggies": "Fruit & Veggies",
"cv_ttype_rf9000a_beverage": "Beverage"
"cv_ttype_rf9000a_beverage": "Beverage",
"cv_fdr_wine": "Wine",
"cv_fdr_deli": "Deli",
"cv_fdr_beverage": "Beverage",
"cv_fdr_meat": "Meat",
"cv_fdr_soft_freezer": "Soft Freezer"
}
},
"dishwasher_cycle": {
@@ -76,7 +89,30 @@
"66": "Denim",
"2d": "Silent Wash",
"8f": "Intense Cold",
"96": "Less Microfiber"
"96": "Less Microfiber",
"36": "Wash+Dry",
"37": "Air Wash",
"38": "Cotton Dry",
"39": "Synthetics Dry",
"1f": "Intense Cold"
}
},
"dryer_cycle": {
"state": {
"16": "Cotton",
"18": "Synthetics",
"19": "Delicates",
"1a": "Wool",
"1b": "Bedding",
"1c": "Shirts",
"1d": "Towels",
"1e": "Outdoor",
"1f": "Mixed Load",
"20": "Iron Dry",
"23": "Quick Dry 35",
"24": "Cool Air",
"25": "Warm Air",
"27": "Time Dry"
}
},
"washer_dosing_quantity": {
@@ -100,6 +136,18 @@
"02": "2x",
"03": "3x"
}
},
"washer_dry_level": {
"state": {
"none": "Off",
"cupboard": "Cupboard",
"30": "30 min",
"60": "1 hr",
"90": "1 hr 30",
"120": "2 hr",
"180": "3 hr",
"240": "4 hr"
}
}
},
"sensor": {
@@ -114,6 +162,51 @@
"observe": "Push (observe)",
"poll": "Polling"
}
},
"machine_state": {
"state": {
"idle": "Idle",
"active": "Active",
"pause": "Paused"
}
},
"air_filter_status": {
"state": {
"normal": "Normal",
"wash": "Wash",
"replace": "Replace"
}
}
},
"climate": {
"airconditioner": {
"state_attributes": {
"fan_mode": {
"state": {
"auto": "Auto",
"low": "Low",
"medium": "Medium",
"high": "High",
"turbo": "Turbo"
}
},
"swing_mode": {
"state": {
"off": "Fixed",
"both": "All directions",
"vertical": "Up and down"
}
},
"preset_mode": {
"state": {
"none": "Off",
"sleep": "Sleep",
"quiet": "Quiet",
"smart": "Smart",
"speed": "Speed"
}
}
}
}
}
},
+10 -1
View File
@@ -1,2 +1,11 @@
pytest>=8.0
# 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.
pytest-homeassistant-custom-component>=0.13.316
# Integration runtime deps, needed to import the component under test
# (also declared in custom_components/localthings/manifest.json).
smartthings-local>=0.1.0
cbor2>=5.4.6
pyOpenSSL>=23.0
cryptography>=41.0
+400
View File
@@ -0,0 +1,400 @@
{
"meta": {
"model": "ARTIK051_PRAC_20K",
"device_type": "airconditioner",
"source": "issue #17 diagnostics (scrubbed)",
"note": "First air-conditioner /device/0 dump; first composite HA climate entity."
},
"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"
]
}
},
{
"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.settingStatus": "Off",
"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": "1",
"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",
"All",
"Up_And_Low"
]
}
},
{
"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-21T07:20:30"
},
{
"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-21T07:20:30",
"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": "24.0",
"x.com.samsung.da.current": "27.0",
"x.com.samsung.da.maximum": "30",
"x.com.samsung.da.minimum": "16",
"x.com.samsung.da.increment": "1.0",
"x.com.samsung.da.unit": "Celsius"
}
]
}
},
{
"href": "/temperature/current/0",
"rep": {
"range": [
16.0,
30.0
],
"units": "C",
"temperature": 27.0
}
},
{
"href": "/temperature/desired/0",
"rep": {
"range": [
16.0,
30.0
],
"units": "C",
"temperature": 24.0
}
},
{
"href": "/diagnosis/vs/0",
"rep": {
"x.com.samsung.da.diagnosisStart": "Ready"
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.instantaneousPower": "0",
"x.com.samsung.da.cumulativePower": "1686632",
"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": [
"Cool",
"Dry",
"Wind",
"Auto",
"Heat"
],
"x.com.samsung.da.modes": [
"Cool"
],
"x.com.samsung.da.options": [
"Sleep_0",
"ArtificialWorking_Off",
"ComfortAICooling_Off",
"AiTempChanged_Off",
"AiTemp_240",
"OutdoorTemp_79",
"CoolCapa_50",
"WarmCapa_60",
"Light_Off",
"Volume_100",
"OptionCode_53304",
"ExtendOptionCode_197256",
"RacInfo_None",
"UpdateAllow_NotAllowed",
"DurationOn_0",
"WelcomeCoolingState_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 CleanLevel",
"x.com.samsung.da.type": "CleanLevel",
"x.com.samsung.da.value": [
"0"
]
},
{
"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": [
"0"
]
},
{
"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": [
"0",
"0"
]
},
{
"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": [
"0",
"0"
]
},
{
"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": [
"0",
"0"
]
}
]
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "ARTIK051_PRAC_20K|10217841|60010532001411004200003000000000",
"x.com.samsung.da.description": "ARTIK051_PRAC_20K",
"x.com.samsung.da.serialNum": "**REDACTED**",
"x.com.samsung.da.otnDUID": "**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": "02181A230313",
"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": "22112400,FFFFFFFF",
"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": "0000,0000"
}
]
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+00: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"
}
},
{
"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": "/timezone/vs/0",
"rep": {}
}
]
}
@@ -0,0 +1,595 @@
{
"meta": {
"note": "Samsung Room A/C, model TP1X_DA-AC-RAC-01011 (oneUiVersion '7.0 Air conditioner', Tizen Lite). Scrubbed real /device/0 dump. Adds temperature via /temperatures/vs/0 + display light. Issue #17 (newer model class).",
"issue": 17,
"model": "TP1X_DA-AC-RAC-01011",
"oneUiVersion": "7.0 Air conditioner"
},
"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": "1784508715",
"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": "Off",
"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": {
"rt": [
"x.com.samsung.da.alarms"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"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-22T07:13:19",
"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-22T07:13:19",
"x.com.samsung.da.state": "Deleted"
}
]
}
},
{
"href": "/availablecontrolsets/vs/0",
"rep": {
"x.com.samsung.da.sets": "000000A0012C016102490F0F0000",
"x.com.samsung.da.id": "RAC",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "0000000000",
"x.com.samsung.da.airconOptionList": [
"PRODUCT_GLOBAL",
"AI_GLOBAL_HEATPUMP_4.0",
"AI_3.0",
"Auto_To_AI",
"AI_Heat",
"ENERGY_2.0",
"HOMECARE_WIZARD_V2",
"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": "1970-01-01T00:00:00Z",
"x.com.samsung.da.override": "Off",
"x.com.samsung.da.durationminutes": "0",
"x.com.samsung.da.drlcLevel": "0",
"x.com.samsung.da.realSaving": "Off"
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.cumulativeDate": "1784704371",
"x.com.samsung.da.cumulativeSavedPower": "-1",
"x.com.samsung.da.cumulativePower": "75025",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.instantaneousPowerUnit": "W",
"x.com.samsung.da.cumulativePowerType": "total"
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+02:00",
"x.com.samsung.supprtedtype": 1
}
},
{
"href": "/filter/airdustfilter/vs/0",
"rep": {
"x.com.samsung.da.filterUsageResolution": "1",
"x.com.samsung.da.filterCapacity": "500",
"x.com.samsung.da.filterCapacityUnit": "Hour",
"x.com.samsung.da.filterResetType": [
"replaceable",
"washable"
],
"x.com.samsung.da.filterStatus": "normal",
"x.com.samsung.da.filterUsage": "42",
"x.com.samsung.da.filterDesiredUsage": "500",
"x.com.samsung.da.supportedFilterDesiredUsage": [
"180",
"300",
"500",
"700"
]
}
},
{
"href": "/humidity/vs/0",
"rep": {
"x.com.samsung.da.humidity": "0",
"x.com.samsung.da.fivepercentHumidity": "53"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.otnDUID": "**REDACTED**",
"x.com.samsung.da.modelNum": "TP1X_DA-AC-RAC-01011_0000|10275643|60010523001911F58E0049320092E900",
"x.com.samsung.da.description": "TP1X_DA-AC-RAC-01011_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": "02762A260401",
"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": "02756C25082500,FFFFFFFFFFFFFF"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.newVersionAvailable": "0",
"x.com.samsung.da.number": "02669A24092600,02636A10001200"
},
{
"x.com.samsung.da.id": "3",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.newVersionAvailable": "0",
"x.com.samsung.da.number": "02672A10001000,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": "AR4",
"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": 1
}
},
{
"href": "/light/vs/0",
"rep": {
"mode": "On",
"supportedModes": [
"On",
"Off"
]
}
},
{
"href": "/mds/absencemonitoring/vs/0",
"rep": {}
},
{
"href": "/mds/absencestate/vs/0",
"rep": {}
},
{
"href": "/mode/convenient/vs/0",
"rep": {
"x.com.samsung.da.modes": "Off",
"x.com.samsung.da.supportedModes": [
"Off",
"Sleep",
"Quiet",
"Smart",
"Speed",
"MotionIndirect",
"Nano",
"NanoSleep",
"DryComfort"
]
}
},
{
"href": "/mode/vs/0",
"rep": {
"rt": [
"x.com.samsung.da.mode"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"x.com.samsung.da.modes": [
"Heat"
],
"workingMode": "Heat",
"x.com.samsung.da.supportedModes": [
"Auto",
"Cool",
"Dry",
"Heat",
"Wind"
],
"x.com.samsung.da.options": [
"OptionCode_56378",
"Sleep_6",
"ArtificialWorking_Off",
"Off",
"OutdoorTemp_68",
"OutdoorConnection_Connected"
]
}
},
{
"href": "/option/airpurify/vs/0",
"rep": {
"rt": [
"x.com.samsung.da.option.airpurify"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"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/muteonce/vs/0",
"rep": {
"muteonce": "Off"
}
},
{
"href": "/personality/presence/vs/0",
"rep": {}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "Off",
"operationNumber": "39",
"displayCondition": "Enable"
}
},
{
"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": "3EFFFFFFFF101E121EFFFF101EFFFF000001001F0001001F001F0000009CACFFFF0000",
"x.com.samsung.da.id": "RAC",
"x.com.samsung.da.version": "1.0"
}
},
{
"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": [
"0",
"0"
]
},
{
"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": [
"0",
"0"
]
},
{
"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": [
"0",
"0"
]
}
]
}
},
{
"href": "/temperature/control/vs/0",
"rep": {
"x.com.samsung.da.increment": "1"
}
},
{
"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": "17.0",
"x.com.samsung.da.desired": "25.0",
"x.com.samsung.da.minimum": "16",
"x.com.samsung.da.maximum": "30",
"x.com.samsung.da.increment": "1",
"x.com.samsung.da.unit": "Celsius"
}
]
}
},
{
"href": "/welcome/temperature/vs/0",
"rep": {
"operatingStatus": "None",
"requestId": "0000"
}
},
{
"href": "/wind/direction/vs/0",
"rep": {
"x.com.samsung.da.modes": "Fix",
"x.com.samsung.da.supportedModes": [
"Fix",
"Up_And_Low",
"Left_And_Right",
"All"
]
}
},
{
"href": "/wind/strength/vs/0",
"rep": {
"x.com.samsung.da.modes": "0",
"x.com.samsung.da.supportedModes": [
"0",
"1",
"2",
"3",
"4"
],
"x.com.samsung.da.modesName": [
"Auto",
"Low",
"Mid",
"High",
"Turbo"
]
}
},
{
"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": "ARA-KR-TP1-25-ARXX00",
"versions": [
"11260401"
],
"visVersion": "260401"
}
]
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "Africa/Johannesburg",
"offset": "+02: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": "**REDACTED**"
}
},
{
"href": "/quickcontrol/info/vs/0",
"rep": {
"supportedVersion": "1.0"
}
},
{
"href": "/dginformation/vs/0",
"rep": {
"enrolmentstatus": "Unknown",
"devicestate": "Unknown",
"lockstatus": "Normal",
"nextduedate": "",
"workingminutes": 0,
"paymentinfo": {
"emiplan": "Unknown",
"currency": "Unknown",
"totalemi": 0,
"totalemipaid": 0
}
}
}
]
}
+591
View File
@@ -0,0 +1,591 @@
{
"meta": {
"model": "TP1X_DA-AC-RAC-01001_0000",
"device_type": "airconditioner",
"source": "issue #38 diagnostics (scrubbed)",
"note": "Newer TP1X RAC board (oneUiVersion '7.0 Air conditioner') with a fuller resource set than the ARTIK051/TP2X boards: display light, self-check, mute-once, and a circuit-breaker current-limit setting."
},
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/aisleep/vs/0",
"rep": {
"x.com.samsung.da.displayNightMode": "Off",
"x.com.samsung.da.elapsedTime": "0",
"x.com.samsung.da.requestFeedback": "Off",
"x.com.samsung.da.resultFeedback": 0,
"x.com.samsung.da.statusFeedback": "Idle",
"x.com.samsung.da.sleepTime": "14002200"
}
},
{
"href": "/alarms/vs/0",
"rep": {
"rt": [
"x.com.samsung.da.alarms"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"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-22T12:58:00",
"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-22T12:58:00",
"x.com.samsung.da.state": "Deleted"
}
]
}
},
{
"href": "/availablecontrolsets/vs/0",
"rep": {
"x.com.samsung.da.sets": "AEB700A0012C01610248040F0000",
"x.com.samsung.da.id": "RAC",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "0000000000",
"x.com.samsung.da.airconOptionList": [
"PRODUCT_GLOBAL",
"AI_GLOBAL_HEATPUMP_4.0",
"AI_3.0",
"Auto_To_AI",
"AI_Heat",
"ENERGY_2.0",
"HOMECARE_WIZARD_V2",
"DR",
"SingleCommand_1"
]
}
},
{
"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": "/da/softreset/vs/0",
"rep": {
"x.com.samsung.da.softwarereset": "false"
}
},
{
"href": "/dginformation/vs/0",
"rep": {
"enrolmentstatus": "Unknown",
"devicestate": "Unknown",
"lockstatus": "Unknown",
"nextduedate": "",
"workingminutes": 0,
"paymentinfo": {
"emiplan": "Unknown",
"currency": "Unknown",
"totalemi": 0,
"totalemipaid": 0
}
}
},
{
"href": "/drlc/vs/0",
"rep": {
"x.com.samsung.da.start": "2026-07-22T10:59:14Z",
"x.com.samsung.da.override": "Off",
"x.com.samsung.da.durationminutes": "1148",
"x.com.samsung.da.drlcLevel": "1",
"x.com.samsung.da.realSaving": "Off"
}
},
{
"href": "/electriccurrent/vs/0",
"rep": {
"operation": "Off",
"modes": "3",
"supportedModes": [
"3",
"4",
"5",
"6",
"7",
"8",
"9"
]
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.cumulativeDate": "1784725078",
"x.com.samsung.da.cumulativeSavedPower": "-1",
"x.com.samsung.da.cumulativePower": "160922",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.instantaneousPowerUnit": "W",
"x.com.samsung.da.cumulativePowerType": "total"
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+03:00",
"x.com.samsung.supprtedtype": 1
}
},
{
"href": "/filter/airdustfilter/vs/0",
"rep": {
"x.com.samsung.da.filterUsageResolution": "1",
"x.com.samsung.da.filterCapacity": "500",
"x.com.samsung.da.filterCapacityUnit": "Hour",
"x.com.samsung.da.filterResetType": [
"replaceable",
"washable"
],
"x.com.samsung.da.filterStatus": "normal",
"x.com.samsung.da.filterUsage": "30",
"x.com.samsung.da.filterDesiredUsage": "500",
"x.com.samsung.da.supportedFilterDesiredUsage": [
"180",
"300",
"500",
"700"
]
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.otnDUID": "**REDACTED**",
"x.com.samsung.da.modelNum": "TP1X_DA-AC-RAC-01001_0000|10267841|60010532001911000E0041220090A000",
"x.com.samsung.da.description": "TP1X_DA-AC-RAC-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": "02762A260401",
"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": "02678A24092500,FFFFFFFFFFFFFF"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.newVersionAvailable": "0",
"x.com.samsung.da.number": "02669A24092600,02636A10001200"
},
{
"x.com.samsung.da.id": "3",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.newVersionAvailable": "0",
"x.com.samsung.da.number": "02672A10001000,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": "AR2",
"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": 1
}
},
{
"href": "/light/vs/0",
"rep": {
"mode": "On",
"supportedModes": [
"On",
"Off"
]
}
},
{
"href": "/mds/absencemonitoring/vs/0",
"rep": {}
},
{
"href": "/mds/absencestate/vs/0",
"rep": {}
},
{
"href": "/mode/convenient/vs/0",
"rep": {
"x.com.samsung.da.modes": "Off",
"x.com.samsung.da.supportedModes": [
"Off",
"Sleep",
"Quiet",
"Smart",
"Speed"
]
}
},
{
"href": "/mode/vs/0",
"rep": {
"rt": [
"x.com.samsung.da.mode"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"x.com.samsung.da.modes": [
"Cool"
],
"workingMode": "Cool",
"x.com.samsung.da.supportedModes": [
"Auto",
"Cool",
"Dry",
"Fan",
"Heat"
],
"x.com.samsung.da.options": [
"OptionCode_54328",
"Sleep_0",
"ArtificialWorking_Off",
"ComfortAICooling_Off",
"AiTempChanged_Off",
"AiTemp_240",
"Volume_100",
"StopAutoClean_Idle",
"DiagnosisAI_Off",
"ProgressDiagnosisAI_0",
"ResultDiagnosisAI_Normal",
"ExtendOptionCode_230024",
"UpdateAllow_NotAllowed",
"DurationOn_120",
"OutdoorTemp_92",
"CoolCapa_50",
"WarmCapa_60",
"Autoclean_Off",
"WelcomeCoolingState_Off",
"WelcomeHeatingState_Off",
"OutdoorConnection_Connected"
]
}
},
{
"href": "/option/airpurify/vs/0",
"rep": {
"rt": [
"x.com.samsung.da.option.airpurify"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"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": "Off",
"x.com.samsung.da.supportedSettingStatus": [
"On",
"Off"
]
}
},
{
"href": "/option/muteonce/vs/0",
"rep": {
"muteonce": "Off"
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "",
"x.com.samsung.da.newVersionAvailable": "false",
"otnStatus": "None",
"flashingProgress": "0",
"otnCompleteDate": "noHistory",
"scheduledTime": "None",
"swVersionInfo": {
"platform": "Tizen Lite",
"oneUiVersion": "7.0 Air conditioner",
"osVersion": "4.0"
},
"otnList": [
{
"type": "WIFI",
"modelId": "ARA-KR-TP1-25-ARXX00",
"versions": [
"11260401"
],
"visVersion": "260401"
},
{
"type": "Micom",
"modelId": "845210267841FFFFFFFF",
"versions": [
"24092500",
"FFFFFFFF"
],
"visVersion": "240925"
},
{
"type": "Micom",
"modelId": "045210267841FFFFFFFF",
"versions": [
"24092500",
"FFFFFFFF"
],
"visVersion": "240925"
},
{
"type": "Micom",
"modelId": "04521026694110263641",
"versions": [
"24092600",
"10001200"
],
"visVersion": "240926"
},
{
"type": "Micom",
"modelId": "FGCAN3",
"versions": [
"",
""
],
"visVersion": "999999"
},
{
"type": "Micom",
"modelId": "845210266941FFFFFFFF",
"versions": [
"24092600",
"FFFFFFFF"
],
"visVersion": "240926"
}
]
}
},
{
"href": "/personality/presence/vs/0",
"rep": {}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "On",
"operationNumber": "9"
}
},
{
"href": "/quickcontrol/info/vs/0",
"rep": {
"supportedVersion": "1.0"
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.longnotisubscription": "false",
"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": "AEFFFFFFFF101E121EFFFF101EFFFF000001001F00010000001F0000000000FFFF1E00",
"x.com.samsung.da.id": "RAC",
"x.com.samsung.da.version": "1.0"
}
},
{
"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": "/temperature/control/vs/0",
"rep": {
"x.com.samsung.da.increment": "1"
}
},
{
"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": "26.0",
"x.com.samsung.da.desired": "22.0",
"x.com.samsung.da.minimum": "16",
"x.com.samsung.da.maximum": "30",
"x.com.samsung.da.increment": "1",
"x.com.samsung.da.unit": "Celsius"
}
]
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "Asia/Amman",
"offset": "+03:00",
"DST": "OFF"
}
},
{
"href": "/welcome/temperature/vs/0",
"rep": {
"operatingStatus": "None",
"requestId": "0000"
}
},
{
"href": "/wind/direction/vs/0",
"rep": {
"x.com.samsung.da.modes": "All",
"x.com.samsung.da.supportedModes": [
"Fix",
"Up_And_Low",
"Left_And_Right",
"All"
]
}
},
{
"href": "/wind/strength/vs/0",
"rep": {
"x.com.samsung.da.modes": "1",
"x.com.samsung.da.supportedModes": [
"0",
"1",
"2",
"3",
"4"
],
"x.com.samsung.da.modesName": [
"Auto",
"Low",
"Mid",
"High",
"Turbo"
]
}
},
{
"href": "/wirelessinfo/vs/0",
"rep": {
"macaddressWiFi": "**REDACTED**",
"macaddressBLE": "**REDACTED**",
"connectedApSsid": "**REDACTED**"
}
}
]
}
+380
View File
@@ -0,0 +1,380 @@
{
"meta": {
"model": "TP2X_RAC_20K",
"device_type": "airconditioner",
"source": "issue #37 diagnostics (scrubbed)",
"note": "Older/simpler RAC board reporting no oneUiVersion -- resolved via the '_RAC_' modelNum token fallback in for_device_by_model (unlike issue #38's TP1X board, which self-reports oneUiVersion)."
},
"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-22T12:42:46",
"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-22T12:42:46",
"x.com.samsung.da.state": "Deleted"
}
]
}
},
{
"href": "/availablecontrolsets/vs/0",
"rep": {
"x.com.samsung.da.sets": "000000A0012C01610249040F0000",
"x.com.samsung.da.id": "RAC",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "0000000000",
"x.com.samsung.da.airconOptionList": [
"SingleCommand_1",
"HOMECARE_WIZARD_V2",
"PRODUCT_GLOBAL",
"AI_RAC_GLOBAL_HEATPUMP_3.0",
"AI_3.0",
"Auto_To_AI",
"AI_Heat"
]
}
},
{
"href": "/energy/consumption/0",
"rep": {
"power": 0.0
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.instantaneousPower": "0.000000",
"x.com.samsung.da.cumulativePower": "717060",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.cumulativePowerType": "total",
"x.com.samsung.da.instantaneousPowerUnit": "W"
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+03:00",
"x.com.samsung.supprtedtype": 1
}
},
{
"href": "/filter/airdustfilter/vs/0",
"rep": {
"x.com.samsung.da.filterUsage": "39",
"x.com.samsung.da.filterUsageResolution": "1",
"x.com.samsung.da.filterDesiredUsage": "500",
"x.com.samsung.da.filterStatus": "normal",
"x.com.samsung.da.filterCapacity": "500",
"x.com.samsung.da.filterCapacityUnit": "Hour",
"x.com.samsung.da.filterResetType": [
"replaceable",
"washable"
]
}
},
{
"href": "/humidity/vs/0",
"rep": {
"x.com.samsung.da.humidity": "0",
"x.com.samsung.da.fivepercentHumidity": "43"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "TP2X_RAC_20K|10229641|60010523001511014600003200800000",
"x.com.samsung.da.description": "TP2X_RAC_20K",
"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": "010",
"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": "02273A260324",
"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": "102296A23012700",
"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": "/keepnormalstate/vs/0",
"rep": {
"x.com.samsung.da.keepnormal": 0
}
},
{
"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": "/mode/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"Auto",
"Cool",
"Dry",
"Wind",
"Heat",
"HOMECARE_WIZARD_V2"
],
"x.com.samsung.da.modes": [
"Cool"
],
"x.com.samsung.da.options": [
"Sleep_16",
"ArtificialWorking_Off",
"ComfortAICooling_Off",
"MotionState_Normal",
"AiTempChanged_Off",
"AiTemp_240",
"OutdoorTemp_85",
"CoolCapa_35",
"WarmCapa_35",
"Light_Off",
"Volume_100",
"StopAutoClean_Idle",
"Autoclean_Off",
"DiagnosisAI_Off",
"ProgressDiagnosisAI_1",
"ResultDiagnosisAI_Normal",
"MotionMode_PowerSave",
"OptionCode_56376",
"ExtendOptionCode_230157",
"RacInfo_None",
"UpdateAllow_NotAllowed",
"DurationOn_50",
"WelcomeCoolingState_Off"
]
}
},
{
"href": "/option/autoclean/vs/0",
"rep": {
"x.com.samsung.da.status": "Stop",
"x.com.samsung.da.settingStatus": "Off",
"x.com.samsung.da.progress": "0",
"x.com.samsung.da.supportedStatus": [
"Start",
"Stop"
],
"x.com.samsung.da.supportedSettingStatus": [
"On",
"Off"
]
}
},
{
"href": "/option/muteonce/vs/0",
"rep": {
"muteonce": "Off"
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "",
"x.com.samsung.da.newVersionAvailable": "false",
"x.com.samsung.da.newVersionNo": "00000000",
"x.com.samsung.da.currentVersionInfo": "00000000",
"otnStatus": "None",
"flashingProgress": "",
"otnTarget": "main",
"otnCompleteDate": "noHistory",
"otnList": [
{
"type": "WIFI",
"modelId": "TP2X_RAC_20K",
"versions": [
"11260324"
],
"visVersion": "260324"
},
{
"type": "Micom",
"modelId": "045210229641FFFFFFFF",
"versions": [
"23012700",
"FFFFFFFF"
],
"visVersion": "230127"
},
{
"type": "Micom",
"modelId": "04521022954110229941",
"versions": [
"21092700",
"10000300"
],
"visVersion": "210927"
}
]
}
},
{
"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": "false",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/sensors/vs/0",
"rep": {}
},
{
"href": "/temperature/control/vs/0",
"rep": {
"x.com.samsung.da.increment": "1"
}
},
{
"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": "22.0",
"x.com.samsung.da.current": "26.0",
"x.com.samsung.da.maximum": "30",
"x.com.samsung.da.minimum": "16",
"x.com.samsung.da.increment": "1.0",
"x.com.samsung.da.unit": "Celsius"
}
]
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "Asia/Amman",
"offset": "+03:00",
"DST": "OFF"
}
},
{
"href": "/wind/direction/vs/0",
"rep": {
"x.com.samsung.da.modes": "Left_And_Right",
"x.com.samsung.da.supportedModes": [
"Fix",
"All",
"Up_And_Low",
"Left_And_Right"
]
}
},
{
"href": "/wind/strength/vs/0",
"rep": {
"x.com.samsung.da.modes": "2",
"x.com.samsung.da.supportedModes": [
"0",
"1",
"2",
"3",
"4"
],
"x.com.samsung.da.modesName": [
"Auto",
"Low",
"Mid",
"High",
"Turbo"
]
}
}
]
}
+423
View File
@@ -0,0 +1,423 @@
{
"meta": {
"model": "DV90BB5245AES1",
"device_type": "dryer",
"source": "issue #14 diagnostics (scrubbed)",
"note": "First full dryer /device/0 dump. DA_WM_TP1_21_COMMON board."
},
"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": "/buzzersound/vs/0",
"rep": {
"supportedBuzzerSound": [
"Off",
"On"
],
"setBuzzerSound": "Off"
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "0000000000",
"x.com.samsung.da.countryCode": "GB"
}
},
{
"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_0165",
"UpdateAllow_NotAllowed",
"Course_16",
"AiOption_On",
"MixedLoadBell_Disable",
"MixedLoadBellNoti_Nothing",
"LaundryOutTime_0",
"SeamlessControl_Disable",
"KidsLockBypass_On",
"DetergentOnce_1",
"DetergentLeft_0",
"DetergentBase_5",
"DetergentAlarm_Off",
"DetergentType_3",
"DetergentTotal_0",
"SpecialFunction_20",
"AvailableDelayTime_182",
"LaundryPlannerUserSetTime_0",
"ProgressTimeSet_B2198CB4003C",
"WrinklePreventRunning_Off",
"SendToDevice_Off",
"GMT_02",
"DrumLight_Off",
"DryTimeSet_FF000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000070007FE020007FE020007FE00000000",
"EnergyLevelSet_0505030503020404030502030304020102",
"MostUsed_27D000",
"WrinklePreventSet_0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F",
"MixedLoadBellSet_02FF0202FFFFFFFFFFFFFFFFFFFFFFFF",
"EmptyingTheBucket_Nothing",
"GeoFenceAlarm",
"UsagesDB_ok",
"EnergyKW_396",
"DrumCleanLog_Empty",
"TimeSync_NotSupported"
],
"x.com.samsung.da.supportedOptions": [
"116D20E23D0001FD20E18D20E19D2041DD2041BD2041CD20E21D3081AD1021ED20420D10227D00025D00024D0004CD10E"
]
}
},
{
"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": "677300",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.cumulativeDate": "1784469600",
"x.com.samsung.da.cumulativeDateUTC": "1784466000"
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+01:00"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "DA_WM_TP1_21_COMMON|20314441|30010102001611000AA3031700000000",
"x.com.samsung.da.description": "DA_WM_TP1_21_COMMON_DV5000B/DC92-03266A_0004",
"x.com.samsung.da.serialNum": "0AJ8XREDACTED000",
"x.com.samsung.da.otnDUID": "REDACTEDDUID0000000000000000000000000000",
"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": "DR1",
"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_WM_TP1_21_COMMON|20314441|30010102001611000AA3031700000000",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02986A260118(A182)",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Firmware_1_DB_20314441220516500FFFFF203266412207065004FFFF(01652031444120326641_30000000)(FileDown:0)(Type:0)",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "03144A22051650,03266A22070650",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Firmware_2_DB_2032244122050903042FFFFFFFFFFFFFFFFFFFFFFFFE(016520322441FFFFFFFF_30000000)(FileDown:0)(Type:0)",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "03224A22050903,FFFFFFFFFFFFFF"
}
]
}
},
{
"href": "/kidslock/0",
"rep": {
"value": false
}
},
{
"href": "/kidslock/vs/0",
"rep": {
"x.com.samsung.da.kidsLock": "Ready"
}
},
{
"href": "/operational/state/0",
"rep": {
"currentMachineState": "Ready",
"machineStates": [
"None",
"Ready",
"Running",
"Paused",
"Finish"
],
"jobStates": [
"None",
"Drying",
"Cooling",
"Finish"
],
"currentJobState": "None",
"remainingTime": "03:02:00",
"progressPercentage": "1"
}
},
{
"href": "/operational/state/vs/0",
"rep": {
"x.com.samsung.da.state": "Ready",
"x.com.samsung.da.remainingTime": "03:02: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",
"Drying",
"Cooling",
"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-03-04",
"otnList": [
{
"type": "WIFI",
"modelId": "DA_WM_TP1_21_COMMON",
"versions": [
"30260118"
],
"visVersion": "260118"
},
{
"type": "Micom",
"modelId": "01652031444120326641",
"versions": [
"22051650",
"22070650"
],
"visVersion": "220706"
},
{
"type": "Micom",
"modelId": "016520322441FFFFFFFF",
"versions": [
"22050903",
"FFFFFFFF"
],
"visVersion": "220509"
}
]
}
},
{
"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": "/setting/vs/0",
"rep": {}
},
{
"href": "/st/dryercourse/vs/0",
"rep": {
"x.com.samsung.da.st.dryerMode": "Table_03_Course_16",
"x.com.samsung.da.st.courseTable": "Table_03"
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "Europe/London",
"offset": "+01:00",
"DST": "ON"
}
},
{
"href": "/washer/vs/0",
"rep": {
"x.com.samsung.da.wrinklePrevent": "Off",
"x.com.samsung.da.dryLevel": "Normal",
"x.com.samsung.da.supportedDryLevel": [
"None",
"Less",
"Normal",
"More"
],
"x.com.samsung.da.dryTime": "00:00:00",
"x.com.samsung.da.supportedDryTime": [
"00:00:00",
"00:20:00",
"00:30:00",
"00:40:00",
"00:50:00",
"01:00:00",
"01:30:00",
"02:00:00",
"02:30:00",
"03:00:00",
"04:00:00"
],
"x.com.samsung.da.dryerType": "Electricity"
}
},
{
"href": "/wirelessinfo/vs/0",
"rep": {
"macaddressWiFi": "00:00:00:00:00:00",
"macaddressBLE": "00:00:00:00:00:00"
}
},
{
"href": "/wm/editcourse/vs/0",
"rep": {
"x.com.samsung.da.editCourseList": "EditCourseList_16271D231F18191B1C211A1E2025244C",
"x.com.samsung.da.fixedCourseList": "FixedCourseList_161820"
}
},
{
"href": "/wm/jobbeginingstatus/vs/0",
"rep": {
"x.com.samsung.da.currentStatus": "None"
}
},
{
"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": "true",
"x.com.samsung.da.aiCourse": "false",
"x.com.samsung.da.isModelSettingPowerOnOff": "false",
"x.com.samsung.da.modelCode": "M(None),W(DV90BB5245AES1)"
}
},
{
"href": "/wm/welcomemsg/vs/0",
"rep": {}
}
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"state_keys": [
"air_filter_status",
"air_filter_usage",
"air_purify",
"alarm_code",
"auto_clean",
"climate",
"diagnosis_status",
"energy_kwh",
"energy_saved_kwh",
"power_watts"
]
}
@@ -0,0 +1,18 @@
{
"state_keys": [
"air_filter_status",
"air_filter_usage",
"air_purify",
"alarm_code",
"auto_clean",
"climate",
"display_light",
"energy_kwh",
"energy_saved_kwh",
"firmware_update",
"mute_once",
"selfcheck_error",
"selfcheck_result",
"selfcheck_status"
]
}
+20
View File
@@ -0,0 +1,20 @@
{
"state_keys": [
"air_filter_status",
"air_filter_usage",
"air_purify",
"alarm_code",
"auto_clean",
"climate",
"current_limit_enabled",
"current_limit_level",
"display_light",
"energy_kwh",
"energy_saved_kwh",
"firmware_update",
"mute_once",
"selfcheck_error",
"selfcheck_result",
"selfcheck_status"
]
}
+13
View File
@@ -0,0 +1,13 @@
{
"state_keys": [
"air_filter_status",
"air_filter_usage",
"alarm_code",
"auto_clean",
"climate",
"energy_kwh",
"firmware_update",
"mute_once",
"power_watts"
]
}
+2 -2
View File
@@ -8,6 +8,7 @@
"delay_start_hours",
"diagnosis_status",
"energy_kwh",
"energy_saved_kwh",
"finish_time",
"firmware_update",
"heated_dry",
@@ -20,7 +21,6 @@
"machine_state",
"operation_origin",
"power_switch",
"power_watts",
"progress",
"progress_percentage",
"remote_control",
@@ -40,6 +40,7 @@
"samsung_dishwasher_diagnosis_start",
"samsung_dishwasher_diagnosis_status",
"samsung_dishwasher_energy_kwh",
"samsung_dishwasher_energy_saved_kwh",
"samsung_dishwasher_finish_time",
"samsung_dishwasher_firmware_update",
"samsung_dishwasher_heated_dry",
@@ -53,7 +54,6 @@
"samsung_dishwasher_operation_origin",
"samsung_dishwasher_pause",
"samsung_dishwasher_power_switch",
"samsung_dishwasher_power_watts",
"samsung_dishwasher_progress",
"samsung_dishwasher_progress_percentage",
"samsung_dishwasher_remote_control",
+24
View File
@@ -0,0 +1,24 @@
{
"state_keys": [
"alarm_code",
"buzzer_sound",
"child_lock",
"cycle",
"cycle_active",
"delay_start_hours",
"diagnosis",
"dry_level",
"dry_time",
"dryer_type",
"energy_kwh",
"finish_time",
"firmware_update",
"job_beginning_status",
"machine_state",
"power_switch",
"progress",
"progress_percentage",
"remote_control",
"wrinkle_prevent"
]
}
+39
View File
@@ -0,0 +1,39 @@
{
"state_keys": [
"alarm_code",
"burner_0_hot_surface",
"burner_0_power_level",
"burner_0_state",
"burner_1_hot_surface",
"burner_1_power_level",
"burner_1_state",
"burner_2_hot_surface",
"burner_2_power_level",
"burner_2_state",
"burner_3_hot_surface",
"burner_3_power_level",
"burner_3_state",
"child_lock",
"cloud_connected",
"cook_time",
"cooktop_safety_shutoff_enabled",
"cooktop_state",
"current_temp_c",
"cycle_active",
"door_open",
"fast_preheat",
"finish_time",
"firmware_update",
"lamp",
"machine_state",
"natural_steam",
"operation_time_minutes",
"oven_mode",
"oven_setpoint",
"oven_state",
"power_switch",
"progress_percentage",
"remote_control",
"sound"
]
}
+12
View File
@@ -1,5 +1,6 @@
{
"state_keys": [
"ai_energy_level",
"alarm_code",
"auto_door_opener",
"autofill",
@@ -16,6 +17,9 @@
"door_cooler_open",
"door_freezer_open",
"energy_kwh",
"energy_last_month_kwh",
"energy_saved_kwh",
"energy_this_month_kwh",
"filter_status",
"filter_usage",
"firmware_update",
@@ -31,15 +35,18 @@
"icemaker_two_type",
"night_end",
"night_start",
"power_energy_kwh",
"power_watts",
"rapid_freezing",
"rapid_fridge",
"sabbath_mode",
"selfcheck_error",
"selfcheck_result",
"selfcheck_status",
"welcome_lighting"
],
"discovery_unique_ids": [
"samsung_refrigerator_ai_energy_level",
"samsung_refrigerator_alarm_code",
"samsung_refrigerator_auto_door_opener",
"samsung_refrigerator_autofill",
@@ -56,6 +63,9 @@
"samsung_refrigerator_door_cooler_open",
"samsung_refrigerator_door_freezer_open",
"samsung_refrigerator_energy_kwh",
"samsung_refrigerator_energy_last_month_kwh",
"samsung_refrigerator_energy_saved_kwh",
"samsung_refrigerator_energy_this_month_kwh",
"samsung_refrigerator_filter_status",
"samsung_refrigerator_filter_usage",
"samsung_refrigerator_firmware_update",
@@ -71,10 +81,12 @@
"samsung_refrigerator_icemaker_two_type",
"samsung_refrigerator_night_end",
"samsung_refrigerator_night_start",
"samsung_refrigerator_power_energy_kwh",
"samsung_refrigerator_power_watts",
"samsung_refrigerator_rapid_freezing",
"samsung_refrigerator_rapid_fridge",
"samsung_refrigerator_sabbath_mode",
"samsung_refrigerator_selfcheck_error",
"samsung_refrigerator_selfcheck_result",
"samsung_refrigerator_selfcheck_start",
"samsung_refrigerator_selfcheck_status",
@@ -0,0 +1,28 @@
{
"state_keys": [
"alarm_code",
"autofill",
"cooler_setpoint",
"cooler_temperature",
"defrost_active",
"defrost_delay",
"diagnosis_status",
"door_cooler_open",
"door_freezer_open",
"energy_last_month_kwh",
"energy_this_month_kwh",
"filter_status",
"filter_usage",
"firmware_update",
"freezer_setpoint",
"freezer_temperature",
"icemaker_one_enabled",
"icemaker_one_making_status",
"pantry_zone_mode",
"power_energy_kwh",
"power_watts",
"rapid_freezing",
"rapid_fridge",
"sabbath_mode"
]
}
+36
View File
@@ -0,0 +1,36 @@
{
"state_keys": [
"ai_energy_level",
"alarm_code",
"autofill",
"cooler_setpoint",
"cooler_temperature",
"defrost_active",
"defrost_delay",
"door_cooler_open",
"door_cvroom_open",
"door_freezer_open",
"energy_kwh",
"energy_last_month_kwh",
"energy_saved_kwh",
"energy_this_month_kwh",
"filter_status",
"filter_usage",
"firmware_update",
"flex_zone_mode",
"freezer_setpoint",
"freezer_temperature",
"icemaker_one_enabled",
"icemaker_one_making_status",
"icemaker_two_enabled",
"icemaker_two_making_status",
"power_energy_kwh",
"power_watts",
"rapid_freezing",
"rapid_fridge",
"sabbath_mode",
"selfcheck_error",
"selfcheck_result",
"selfcheck_status"
]
}
+29
View File
@@ -0,0 +1,29 @@
{
"state_keys": [
"alarm_code",
"cooler_setpoint",
"cooler_temperature",
"defrost_active",
"defrost_delay",
"diagnosis_status",
"door_cooler_open",
"door_cvroom_open",
"door_freezer_open",
"energy_kwh",
"energy_last_month_kwh",
"energy_this_month_kwh",
"filter_status",
"filter_usage",
"firmware_update",
"flex_zone_mode",
"freezer_setpoint",
"freezer_temperature",
"icemaker_one_enabled",
"icemaker_one_making_status",
"power_energy_kwh",
"power_watts",
"rapid_freezing",
"rapid_fridge",
"sabbath_mode"
]
}
+5
View File
@@ -1,6 +1,8 @@
{
"state_keys": [
"ai_energy_level",
"alarm_code",
"bubble_soak",
"buzzer_sound",
"child_lock",
"cycle",
@@ -11,11 +13,14 @@
"drum_clean_cycles_remaining",
"drum_clean_last_cleaned",
"energy_kwh",
"energy_saved_kwh",
"finish_time",
"firmware_update",
"intensive",
"job_beginning_status",
"machine_state",
"power_switch",
"pre_wash",
"progress",
"progress_percentage",
"remote_control",
+29
View File
@@ -0,0 +1,29 @@
{
"state_keys": [
"alarm_code",
"bubble_soak",
"child_lock",
"cycle",
"cycle_active",
"delay_start_hours",
"detergent_low",
"diagnosis_status",
"drum_clean_cycles_remaining",
"dry_level",
"energy_kwh",
"finish_time",
"firmware_update",
"intensive",
"job_beginning_status",
"machine_state",
"power_switch",
"pre_wash",
"progress",
"progress_percentage",
"remote_control",
"rinse_cycles",
"softener_low",
"spin_speed",
"wash_temperature"
]
}
+22
View File
@@ -0,0 +1,22 @@
{
"state_keys": [
"alarm_code",
"child_lock",
"cycle_active",
"delay_start_hours",
"detergent_low",
"diagnosis_status",
"drum_clean_cycles_remaining",
"energy_kwh",
"finish_time",
"job_beginning_status",
"machine_state",
"power_switch",
"progress",
"progress_percentage",
"remote_control",
"rinse_cycles",
"spin_speed",
"wash_temperature"
]
}
+466
View File
@@ -0,0 +1,466 @@
{
"meta": {
"model": "TP1X_DA-KS-RANGE-0102X (NSI6DG9100SRAA)",
"device_type": "range",
"source": "issue #44 diagnostics (scrubbed)",
"note": "Range/cooktop-oven combo, first dump of this device type. 4 burners reported (numberOfBurners=4)."
},
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "",
"x.com.samsung.da.newVersionAvailable": "false",
"flashingProgress": "",
"otnStatus": "None",
"otnList": [
{
"type": "WIFI",
"modelId": "AKS-WW-TP1-23-OVEN-3",
"versions": [
"40241114"
],
"visVersion": "241114"
},
{
"type": "Micom",
"modelId": "07174047694140477041",
"versions": [
"24052400",
"24012300"
],
"visVersion": "240524"
},
{
"type": "Micom",
"modelId": "071760141141FFFFFFFF",
"versions": [
"24010500",
"FFFFFFFF"
],
"visVersion": "240105"
},
{
"type": "Micom",
"modelId": "071740476841FFFFFFFF",
"versions": [
"23121600",
"FFFFFFFF"
],
"visVersion": "231216"
},
{
"type": "Micom",
"modelId": "07174047484140474941",
"versions": [
"24020500",
"24012600"
],
"visVersion": "240205"
}
]
}
},
{
"href": "/connected/vs/0",
"rep": {
"x.com.samsung.da.connected": "On"
}
},
{
"href": "/doors/vs/0",
"rep": {
"rt": [
"x.com.samsung.da.doors"
],
"if": [
"oic.if.baseline",
"oic.if.s"
],
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Door",
"x.com.samsung.da.openState": "Close",
"x.com.samsung.da.lock": "Unlock"
}
]
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "TP1X_DA-KS-RANGE-0102X|40476941|5001011E041811010A00000000000000",
"x.com.samsung.da.description": "NI9100D-/AA0",
"x.com.samsung.da.serialNum": "**REDACTED**",
"x.com.samsung.da.otnDUID": "**REDACTED**",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "24111400",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "04769A24052400, 04770A24012300",
"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": "01411A24010500",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "3",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "04768A23121600",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "4",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "04748A24020500, 04749A24012600",
"x.com.samsung.da.newVersionAvailable": "0"
}
],
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
"x.com.samsung.da.diagLogType": [
"errCode",
"dump"
],
"x.com.samsung.da.diagDumpType": "file",
"x.com.samsung.da.diagEndPoint": "SSM",
"x.com.samsung.da.diagMnid": "0AJT",
"x.com.samsung.da.diagSetupid": "KR3",
"x.com.samsung.da.diagMinVersion": "3.0",
"x.com.samsung.da.diagTsId": "DA01"
}
},
{
"href": "/mode/vs/0",
"rep": {
"rt": [
"x.com.samsung.da.mode"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"x.com.samsung.da.supportedModes": [
"Bake",
"Broil",
"KeepWarm",
"BreadProof",
"SelfClean",
"SteamClean"
],
"x.com.samsung.da.modes": [
"NoOperation"
],
"x.com.samsung.da.modeStateList": [
{
"mode": "Bake",
"state": "Fixed"
},
{
"mode": "Broil",
"state": "Show"
},
{
"mode": "KeepWarm",
"state": "Show"
},
{
"mode": "BreadProof",
"state": "Show"
},
{
"mode": "SelfClean",
"state": "Fixed"
},
{
"mode": "SteamClean",
"state": "Fixed"
}
],
"x.com.samsung.da.options": [
"DeviceType_NI9100D-/AA0",
"SettingPossible_5",
"TimeAutoSync_On",
"UpperLamp_On",
"TimeSystem_12",
"Sound_On",
"AdjustingTemp_0",
"Sabbath_Off",
"EnergySaving_On"
],
"x.com.samsung.da.defaultMode": "Bake",
"x.com.samsung.da.modeSpec": "[{\"mode\":\"Bake\",\"version\":\"0101\",\"default\":\"Default\",\"control\":\"Start&Setting\",\"cavity\":\"Single\",\"tempMinC\":\"80\",\"tempMaxC\":\"285\",\"tempDefaultC\":\"175\",\"tempListLengthC\":\"0\",\"tempMinF\":\"175\",\"tempMaxF\":\"550\",\"tempDefaultF\":\"350\",\"tempListLengthF\":\"0\",\"timeMin\":\"00:01:00\",\"timeMax\":\"09:59:00\",\"timeDefault\":\"01:00:00\",\"probeMinC\":\"40\",\"probeMaxC\":\"90\",\"probeDefaultC\":\"65\",\"probeMinF\":\"100\",\"probeMaxF\":\"200\",\"probeDefaultF\":\"150\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"5\",\"tempIntervalF\":\"5\",\"probeIntervalC\":\"1\",\"probeIntervalF\":\"1\"},{\"mode\":\"Broil\",\"version\":\"0101\",\"default\":\"Normal\",\"control\":\"Setting\",\"cavity\":\"Single\",\"tempMinC\":\"61442\",\"tempMaxC\":\"61441\",\"tempDefaultC\":\"61441\",\"tempListLengthC\":\"2\",\"tempListDataC\":[\"61441\",\"61442\"],\"tempMinF\":\"61442\",\"tempMaxF\":\"61441\",\"tempDefaultF\":\"61441\",\"tempListLengthF\":\"2\",\"tempListDataF\":[\"61441\",\"61442\"],\"timeMin\":\"NotSupported\",\"timeMax\":\"NotSupported\",\"timeDefault\":\"NotSupported\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"NotSupported\",\"tempIntervalF\":\"NotSupported\",\"probeIntervalC\":\"NotSupported\",\"probeIntervalF\":\"NotSupported\"},{\"mode\":\"KeepWarm\",\"version\":\"0101\",\"default\":\"Normal\",\"control\":\"Setting\",\"cavity\":\"Single\",\"tempMinC\":\"80\",\"tempMaxC\":\"80\",\"tempDefaultC\":\"80\",\"tempListLengthC\":\"1\",\"tempListDataC\":[\"80\"],\"tempMinF\":\"175\",\"tempMaxF\":\"175\",\"tempDefaultF\":\"175\",\"tempListLengthF\":\"1\",\"tempListDataF\":[\"175\"],\"timeMin\":\"NotSupported\",\"timeMax\":\"NotSupported\",\"timeDefault\":\"NotSupported\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"NotSupported\",\"tempIntervalF\":\"NotSupported\",\"probeIntervalC\":\"NotSupported\",\"probeIntervalF\":\"NotSupported\"},{\"mode\":\"BreadProof\",\"version\":\"0101\",\"default\":\"Normal\",\"control\":\"Setting\",\"cavity\":\"Single\",\"tempMinC\":\"35\",\"tempMaxC\":\"35\",\"tempDefaultC\":\"35\",\"tempListLengthC\":\"1\",\"tempListDataC\":[\"35\"],\"tempMinF\":\"95\",\"tempMaxF\":\"95\",\"tempDefaultF\":\"95\",\"tempListLengthF\":\"1\",\"tempListDataF\":[\"95\"],\"timeMin\":\"NotSupported\",\"timeMax\":\"NotSupported\",\"timeDefault\":\"NotSupported\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"NotSupported\",\"tempIntervalF\":\"NotSupported\",\"probeIntervalC\":\"NotSupported\",\"probeIntervalF\":\"NotSupported\"},{\"mode\":\"SelfClean\",\"version\":\"0101\",\"default\":\"Normal\",\"control\":\"NotSupported\",\"cavity\":\"Single\",\"tempMinC\":\"NotSupported\",\"tempMaxC\":\"NotSupported\",\"tempDefaultC\":\"NotSupported\",\"tempListLengthC\":\"0\",\"tempMinF\":\"NotSupported\",\"tempMaxF\":\"NotSupported\",\"tempDefaultF\":\"NotSupported\",\"tempListLengthF\":\"0\",\"timeMin\":\"NotSupported\",\"timeMax\":\"NotSupported\",\"timeDefault\":\"NotSupported\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"NotSupported\",\"tempIntervalF\":\"NotSupported\",\"probeIntervalC\":\"NotSupported\",\"probeIntervalF\":\"NotSupported\"},{\"mode\":\"SteamClean\",\"version\":\"0101\",\"default\":\"Normal\",\"control\":\"NotSupported\",\"cavity\":\"Single\",\"tempMinC\":\"NotSupported\",\"tempMaxC\":\"NotSupported\",\"tempDefaultC\":\"NotSupported\",\"tempListLengthC\":\"0\",\"tempMinF\":\"NotSupported\",\"tempMaxF\":\"NotSupported\",\"tempDefaultF\":\"NotSupported\",\"tempListLengthF\":\"0\",\"timeMin\":\"NotSupported\",\"timeMax\":\"NotSupported\",\"timeDefault\":\"NotSupported\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"NotSupported\",\"tempIntervalF\":\"NotSupported\",\"probeIntervalC\":\"NotSupported\",\"probeIntervalF\":\"NotSupported\"}]"
}
},
{
"href": "/operational/state/vs/0",
"rep": {
"rt": [
"x.com.samsung.da.operation"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"x.com.samsung.da.state": "Ready",
"causeSource.state": "SETB_Ready",
"x.com.samsung.da.operationTime": "00:00:00",
"x.com.samsung.da.remainingTime": "00:00:00",
"x.com.samsung.da.progressPercentage": "1"
}
},
{
"href": "/alarms/vs/0",
"rep": {
"rt": [
"x.com.samsung.da.alarms"
],
"if": [
"oic.if.baseline",
"oic.if.s"
],
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "OV_E_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-22T23:55:41"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "CT_E_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-22T23:55:41"
}
]
}
},
{
"href": "/oven/vs/0",
"rep": {
"rt": [
"x.com.samsung.da.oven"
],
"if": [
"oic.if.baseline",
"oic.if.s"
],
"x.com.samsung.da.state": "Ready"
}
},
{
"href": "/temperatures/vs/0",
"rep": {
"rt": [
"x.com.samsung.da.temperatures"
],
"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.desired": "0",
"x.com.samsung.da.current": "175",
"x.com.samsung.da.increment": "5",
"x.com.samsung.da.unit": "Fahrenheit"
}
]
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "America/Chicago",
"offset": "-05:00",
"DST": "ON"
}
},
{
"href": "/oven/spec/vs/0",
"rep": {
"cavityInfo": {
"numberOfCavity": 1,
"type": "single",
"serviceMainCavity": "single",
"cavityList": [
{
"name": "single",
"flexSupport": false,
"single": {
"resourceId": 0,
"supportedFeatureList": [
""
]
}
}
]
}
}
},
{
"href": "/kidslock/vs/0",
"rep": {
"x.com.samsung.da.kidsLock": "Ready"
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "On"
}
},
{
"href": "/remotectrl/vs/0",
"rep": {
"x.com.samsung.da.remoteControlEnabled": "false"
}
},
{
"href": "/cooktop/settings/status/vs/0",
"rep": {
"safetyAlert": {
"state": "on",
"settingTime": 1200
}
}
},
{
"href": "/cooktop/spec/vs/0",
"rep": {
"supportedFeatureList": [
""
],
"numberOfBurners": 4,
"supportedBurnerList": [
0,
1,
2,
3
],
"supportedPowerLevelList": [
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"boost",
"0",
"simmer"
],
"supportedRecipeProtocolVersion": 0
}
},
{
"href": "/cooktop/status/vs/0",
"rep": {
"power": "off",
"childLock": "off",
"operationState": "ready",
"additionalStateList": [
""
],
"burnerList": [
{
"burnerNumber": 0,
"mode": "normal",
"operationState": "ready",
"powerLevel": "0",
"hotSurfaceState": "normal",
"panDetection": true,
"timer": {
"operationState": "ready",
"cookingTime": 0,
"remainingTime": 0
}
},
{
"burnerNumber": 1,
"mode": "normal",
"operationState": "ready",
"powerLevel": "0",
"hotSurfaceState": "normal",
"panDetection": true,
"timer": {
"operationState": "ready",
"cookingTime": 0,
"remainingTime": 0
}
},
{
"burnerNumber": 2,
"mode": "normal",
"operationState": "ready",
"powerLevel": "0",
"hotSurfaceState": "normal",
"panDetection": true,
"timer": {
"operationState": "ready",
"cookingTime": 0,
"remainingTime": 0
}
},
{
"burnerNumber": 3,
"mode": "normal",
"operationState": "ready",
"powerLevel": "0",
"hotSurfaceState": "normal",
"panDetection": true,
"timer": {
"operationState": "ready",
"cookingTime": 0,
"remainingTime": 0
}
}
]
}
},
{
"href": "/wirelessinfo/vs/0",
"rep": {
"macaddressWiFi": "**REDACTED**",
"macaddressBLE": "**REDACTED**"
}
},
{
"href": "/quickcontrol/info/vs/0",
"rep": {
"supportedVersion": "1.0"
}
}
]
}
+351
View File
@@ -0,0 +1,351 @@
{
"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": "/temperatures/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Freezer",
"x.com.samsung.da.desired": "-21",
"x.com.samsung.da.current": "-21",
"x.com.samsung.da.maximum": "-15",
"x.com.samsung.da.minimum": "-23",
"x.com.samsung.da.unit": "Celsius"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Fridge",
"x.com.samsung.da.desired": "2",
"x.com.samsung.da.current": "2",
"x.com.samsung.da.maximum": "7",
"x.com.samsung.da.minimum": "1",
"x.com.samsung.da.unit": "Celsius"
}
]
}
},
{
"href": "/temperature/current/freezer/0",
"rep": {
"range": [
-23.0,
-15.0
],
"units": "C",
"temperature": -21.0
}
},
{
"href": "/temperature/desired/freezer/0",
"rep": {
"range": [
-23.0,
-15.0
],
"units": "C",
"temperature": -21.0
}
},
{
"href": "/temperature/current/cooler/0",
"rep": {
"range": [
1.0,
7.0
],
"units": "C",
"temperature": 2.0
}
},
{
"href": "/temperature/desired/cooler/0",
"rep": {
"range": [
1.0,
7.0
],
"units": "C",
"temperature": 2.0
}
},
{
"href": "/diagnosis/vs/0",
"rep": {
"x.com.samsung.da.diagnosisStart": "Ready"
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.cumulativeConsumption": "0",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.instantaneousPower": "0",
"x.com.samsung.da.instantaneousPowerUnit": "W",
"x.com.samsung.da.monthlyConsumption": "86000",
"x.com.samsung.da.thismonthlyConsumption": "64026"
}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"HOMECARE_WIZARD_V2",
"ENERGY_REPORT_MODEL",
"18K_REF_OUTDOOR_CONTROL_V2",
"SUPPORT_SABBATH_CONTROL"
],
"x.com.samsung.da.modes": [
"WATERFILTER_ENABLE"
]
}
},
{
"href": "/mode/0",
"rep": {
"supportedModes": [
"HOMECARE_WIZARD_V2",
"ENERGY_REPORT_MODEL",
"18K_REF_OUTDOOR_CONTROL_V2",
"SUPPORT_SABBATH_CONTROL"
],
"modes": [
"WATERFILTER_ENABLE"
]
}
},
{
"href": "/defrost/block/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"DEFROST_BLOCK_ON",
"DEFROST_BLOCK_OFF"
],
"x.com.samsung.da.modes": [
"DEFROST_BLOCK_OFF"
]
}
},
{
"href": "/sabbath/vs/0",
"rep": {
"x.com.samsung.da.sabbathMode": "Off"
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "ARTIK051_REF_17K|00117141|0002074E051311200103000010000000",
"x.com.samsung.da.description": "ARTIK051_REF_17K",
"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": "WiFi Module",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02144A201124",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Micom",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "2003030C,FFFFFFFF",
"x.com.samsung.da.newVersionAvailable": "0"
}
]
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "-04:00",
"x.com.samsung.supprtedtype": 1
}
},
{
"href": "/doors/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Door",
"x.com.samsung.da.openState": "Close"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Door",
"x.com.samsung.da.openState": "Close"
}
]
}
},
{
"href": "/door/freezer/0",
"rep": {
"openState": "Close"
}
},
{
"href": "/door/cooler/0",
"rep": {
"openState": "Close"
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.countryCode": "US",
"x.com.samsung.da.region": ""
}
},
{
"href": "/defrost/delay/0",
"rep": {
"value": false
}
},
{
"href": "/defrost/delay/vs/0",
"rep": {
"x.com.samsung.da.delayDefrost": "Off"
}
},
{
"href": "/defrost/reservation/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Summer Season",
"x.com.samsung.da.startTime": "0000-05-01T15:00:00",
"x.com.samsung.da.period": "04:00:00",
"x.com.samsung.da.endTime": "0000-10-31T00:00:00"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Winter Season",
"x.com.samsung.da.startTime": "0000-11-01T06:00:00",
"x.com.samsung.da.period": "04:00:00",
"x.com.samsung.da.endTime": "0000-04-30T00:00:00"
}
]
}
},
{
"href": "/icemaker/status/0",
"rep": {
"status": "On"
}
},
{
"href": "/icemaker/status/vs/0",
"rep": {
"x.com.samsung.da.iceMaker": "On"
}
},
{
"href": "/refrigeration/0",
"rep": {
"defrost": false,
"rapidFreeze": false,
"rapidCool": false
}
},
{
"href": "/refrigeration/vs/0",
"rep": {
"x.com.samsung.da.rapidFridge": "Off",
"x.com.samsung.da.rapidFreezing": "Off"
}
},
{
"href": "/drlc/0",
"rep": {
"DRLevel": 0,
"start": "00:00:00",
"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": "00:00:00",
"x.com.samsung.da.override": "Off"
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "Micom",
"x.com.samsung.da.newVersionAvailable": "false"
}
},
{
"href": "/filter/waterfilter/vs/0",
"rep": {
"x.com.samsung.da.filterUsage": "100",
"x.com.samsung.da.filterUsageResolution": "1",
"x.com.samsung.da.filterStatus": "replace",
"x.com.samsung.da.filterResetType": [
"replaceable"
]
}
},
{
"href": "/autofill/vs/0",
"rep": {
"x.com.samsung.da.autofill": "Off"
}
},
{
"href": "/icemaker/one/vs/0",
"rep": {
"x.com.samsung.da.iceMaker.name": "ICE_MAKER",
"x.com.samsung.da.iceMaker.state": "On",
"x.com.samsung.da.iceMaker.type": "toggle",
"x.com.samsung.da.iceMaker.iceMakingStatus": "ICESTATUS_STOP",
"x.com.samsung.da.iceType.desired": "NORMAL"
}
},
{
"href": "/status/pantry/one/vs/0",
"rep": {
"x.com.samsung.da.controllable": "true",
"x.com.samsung.da.name": "COOL_SELECT_ZONE",
"x.com.samsung.da.mode": "FDR_DRINKS",
"x.com.samsung.da.supportedOptions": [
"FDR_WINE",
"FDR_DELI",
"FDR_DRINKS"
]
}
}
]
}
+2 -1
View File
@@ -279,7 +279,8 @@
"rep": {
"aiLevel": "1",
"supportedAiLevel": [
"1"
"1",
"2"
],
"href": "/energy/ailevel/vs/0"
}
+253
View File
@@ -0,0 +1,253 @@
{
"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": "/temperatures/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Freezer",
"x.com.samsung.da.desired": "0",
"x.com.samsung.da.current": "0",
"x.com.samsung.da.maximum": "5",
"x.com.samsung.da.minimum": "-8",
"x.com.samsung.da.unit": "Fahrenheit"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Fridge",
"x.com.samsung.da.desired": "39",
"x.com.samsung.da.current": "39",
"x.com.samsung.da.maximum": "44",
"x.com.samsung.da.minimum": "34",
"x.com.samsung.da.unit": "Fahrenheit"
}
]
}
},
{"href": "/temperature/current/freezer/0", "rep": {"range": [-8.0, 5.0], "units": "F", "temperature": 0.0}},
{"href": "/temperature/desired/freezer/0", "rep": {"range": [-8.0, 5.0], "units": "F", "temperature": 0.0}},
{"href": "/temperature/current/cooler/0", "rep": {"range": [34.0, 44.0], "units": "F", "temperature": 39.0}},
{"href": "/temperature/desired/cooler/0", "rep": {"range": [34.0, 44.0], "units": "F", "temperature": 39.0}},
{
"href": "/selfcheck/vs/0",
"rep": {
"x.com.samsung.da.status": "Ready",
"x.com.samsung.da.result": "Success",
"x.com.samsung.da.error": ["DA_ERROR_NONE"],
"x.com.samsung.da.supportedActions": ["Start"]
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.cumulativeConsumption": "63",
"x.com.samsung.da.cumulativePower": "1831899",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.instantaneousPower": "3",
"x.com.samsung.da.instantaneousPowerUnit": "W",
"x.com.samsung.da.monthlyConsumption": "52000",
"x.com.samsung.da.thismonthlyConsumption": "38900",
"x.com.samsung.da.cumulativeSavedPower": "0"
}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"HOMECARE_WIZARD_V2",
"ENERGY_REPORT_MODEL",
"18K_REF_OUTDOOR_CONTROL_V2",
"SUPPORT_SABBATH_CONTROL"
],
"x.com.samsung.da.modes": ["CVN_CONVERTIBLE_ZONE", "CV_FDR_WINE", "WATERFILTER_DISABLE"],
"x.com.samsung.da.supportedOptions": [
"CV_FDR_WINE",
"CV_FDR_DELI",
"CV_FDR_BEVERAGE",
"CV_FDR_MEAT",
"CV_FDR_SOFT_FREEZER"
]
}
},
{
"href": "/mode/0",
"rep": {
"supportedModes": [
"HOMECARE_WIZARD_V2",
"ENERGY_REPORT_MODEL",
"18K_REF_OUTDOOR_CONTROL_V2",
"SUPPORT_SABBATH_CONTROL"
],
"modes": ["CVN_CONVERTIBLE_ZONE", "CV_FDR_WINE", "WATERFILTER_DISABLE"]
}
},
{
"href": "/defrost/block/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": ["DEFROST_BLOCK_ON", "DEFROST_BLOCK_OFF"],
"x.com.samsung.da.modes": ["DEFROST_BLOCK_OFF"]
}
},
{"href": "/sabbath/vs/0", "rep": {"x.com.samsung.da.sabbathMode": "Off"}},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "TP1X_REF_21K|00148241|00020850031611204103000021010000",
"x.com.samsung.da.description": "TP1X_REF_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": "512",
"x.com.samsung.da.diagMinVersion": "1.0",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "WiFi Module",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "250420",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Micom",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "23071923, FFFFFFFF",
"x.com.samsung.da.newVersionAvailable": "0"
}
]
}
},
{"href": "/file/information/vs/0", "rep": {"x.com.samsung.timeoffset": "-05:00", "x.com.samsung.supprtedtype": 1}},
{
"href": "/doors/vs/0",
"rep": {
"x.com.samsung.da.items": [
{"x.com.samsung.da.id": "0", "x.com.samsung.da.description": "Door", "x.com.samsung.da.openState": "Close"},
{"x.com.samsung.da.id": "1", "x.com.samsung.da.description": "Door", "x.com.samsung.da.openState": "Close"},
{"x.com.samsung.da.id": "2", "x.com.samsung.da.description": "Door", "x.com.samsung.da.openState": "Close"}
]
}
},
{"href": "/door/freezer/0", "rep": {"openState": "Close"}},
{"href": "/door/cooler/0", "rep": {"openState": "Close"}},
{"href": "/door/cvroom/0", "rep": {"openState": "Close"}},
{"href": "/configuration/vs/0", "rep": {"x.com.samsung.da.countryCode": "", "x.com.samsung.da.region": ""}},
{"href": "/defrost/delay/0", "rep": {"value": false}},
{"href": "/defrost/delay/vs/0", "rep": {"x.com.samsung.da.delayDefrost": "Off"}},
{
"href": "/defrost/reservation/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Summer Season",
"x.com.samsung.da.startTime": "0000-05-01T15:00:00",
"x.com.samsung.da.period": "04:00:00",
"x.com.samsung.da.endTime": "0000-10-31T00:00:00"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Winter Season",
"x.com.samsung.da.startTime": "0000-11-01T06:00:00",
"x.com.samsung.da.period": "04:00:00",
"x.com.samsung.da.endTime": "0000-04-30T00:00:00"
}
]
}
},
{"href": "/icemaker/status/0", "rep": {"status": "On"}},
{"href": "/icemaker/status/vs/0", "rep": {"x.com.samsung.da.iceMaker": "On"}},
{"href": "/refrigeration/0", "rep": {"defrost": false, "rapidFreeze": false, "rapidCool": false}},
{
"href": "/refrigeration/vs/0",
"rep": {"x.com.samsung.da.rapidFridge": "Off", "x.com.samsung.da.rapidFreezing": "Off"}
},
{"href": "/drlc/0", "rep": {"DRLevel": 0, "override": false}},
{
"href": "/drlc/vs/0",
"rep": {
"x.com.samsung.da.drlcLevel": "0",
"x.com.samsung.da.override": "Off",
"x.com.samsung.da.durationminutes": "0",
"x.com.samsung.da.realSaving": "Off"
}
},
{"href": "/bespoke/vs/0", "rep": {"x.com.samsung.da.BespokeProduct": "On"}},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "",
"x.com.samsung.da.newVersionAvailable": "false",
"otnStatus": "None",
"flashingProgress": "",
"otnList": [
{"type": "WIFI", "modelId": "A-RFWW-TP1-22-REV1", "versions": ["20250420"], "visVersion": "250420"},
{"type": "Micom", "modelId": "024100148241FFFFFFFF", "versions": ["23071923", "FFFFFFFF"], "visVersion": "230719"}
]
}
},
{
"href": "/filter/waterfilter/vs/0",
"rep": {
"x.com.samsung.da.filterUsage": "31",
"x.com.samsung.da.filterUsageResolution": "1",
"x.com.samsung.da.filterStatus": "normal",
"x.com.samsung.da.filterResetType": ["replaceable"]
}
},
{"href": "/autofill/vs/0", "rep": {"x.com.samsung.da.autofill": "Off"}},
{
"href": "/icemaker/one/vs/0",
"rep": {
"x.com.samsung.da.iceMaker.name": "CUBED_ICE",
"x.com.samsung.da.iceMaker.state": "On",
"x.com.samsung.da.iceMaker.type": "toggle",
"x.com.samsung.da.iceMaker.iceMakingStatus": "ICESTATUS_STOP",
"x.com.samsung.da.iceType.desired": "NORMAL"
}
},
{
"href": "/icemaker/two/vs/0",
"rep": {
"x.com.samsung.da.iceMaker.name": "ICE_BITES",
"x.com.samsung.da.iceMaker.state": "On",
"x.com.samsung.da.iceMaker.type": "toggle",
"x.com.samsung.da.iceMaker.iceMakingStatus": "ICESTATUS_STOP",
"x.com.samsung.da.iceType.desired": "On"
}
},
{"href": "/energy/ailevel/vs/0", "rep": {"aiLevel": "1", "supportedAiLevel": ["1"]}},
{
"href": "/connectionconfig/vs/0",
"rep": {
"autoReconnectionMinVersion": "1.0",
"autoReconnection": "true",
"autoReconnectionProtocolType": ["ble_ocf", null],
"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/Chicago", "offset": "-05:00", "DST": "ON"}},
{"href": "/wirelessinfo/vs/0", "rep": {"macaddressWiFi": "REDACTED", "macaddressBLE": "REDACTED"}},
{"href": "/quickcontrol/info/vs/0", "rep": {"supportedVersion": "1.0"}}
]
}
+351
View File
@@ -0,0 +1,351 @@
{
"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": "/temperatures/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Freezer",
"x.com.samsung.da.desired": "0",
"x.com.samsung.da.current": "0",
"x.com.samsung.da.maximum": "5",
"x.com.samsung.da.minimum": "-8",
"x.com.samsung.da.unit": "Fahrenheit"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Fridge",
"x.com.samsung.da.desired": "37",
"x.com.samsung.da.current": "37",
"x.com.samsung.da.maximum": "44",
"x.com.samsung.da.minimum": "34",
"x.com.samsung.da.unit": "Fahrenheit"
}
]
}
},
{
"href": "/temperature/current/freezer/0",
"rep": {
"range": [
-8.0,
5.0
],
"units": "F",
"temperature": 0.0
}
},
{
"href": "/temperature/desired/freezer/0",
"rep": {
"range": [
-8.0,
5.0
],
"units": "F",
"temperature": 0.0
}
},
{
"href": "/temperature/current/cooler/0",
"rep": {
"range": [
34.0,
44.0
],
"units": "F",
"temperature": 37.0
}
},
{
"href": "/temperature/desired/cooler/0",
"rep": {
"range": [
34.0,
44.0
],
"units": "F",
"temperature": 37.0
}
},
{
"href": "/diagnosis/vs/0",
"rep": {
"x.com.samsung.da.diagnosisStart": "Ready"
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.cumulativeConsumption": "288048",
"x.com.samsung.da.cumulativePower": "1797027",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.instantaneousPower": "115",
"x.com.samsung.da.instantaneousPowerUnit": "W",
"x.com.samsung.da.monthlyConsumption": "55400",
"x.com.samsung.da.thismonthlyConsumption": "39737"
}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"HOMECARE_WIZARD_V2",
"ENERGY_REPORT_MODEL",
"18K_REF_OUTDOOR_CONTROL_V2",
"SUPPORT_SABBATH_CONTROL"
],
"x.com.samsung.da.modes": [
"CVN_CONVERTIBLE_ZONE",
"CV_FDR_BEVERAGE",
"WATERFILTER_DISABLE"
],
"x.com.samsung.da.supportedOptions": [
"CV_FDR_WINE",
"CV_FDR_DELI",
"CV_FDR_BEVERAGE",
"CV_FDR_MEAT"
]
}
},
{
"href": "/mode/0",
"rep": {
"supportedModes": [
"HOMECARE_WIZARD_V2",
"ENERGY_REPORT_MODEL",
"18K_REF_OUTDOOR_CONTROL_V2",
"SUPPORT_SABBATH_CONTROL"
],
"modes": [
"CVN_CONVERTIBLE_ZONE",
"CV_FDR_BEVERAGE",
"WATERFILTER_DISABLE"
]
}
},
{
"href": "/defrost/block/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"DEFROST_BLOCK_ON",
"DEFROST_BLOCK_OFF"
],
"x.com.samsung.da.modes": [
"DEFROST_BLOCK_OFF"
]
}
},
{
"href": "/sabbath/vs/0",
"rep": {
"x.com.samsung.da.sabbathMode": "Off"
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "TP2X_REF_20K|00113141|0002033F051311200103000010000000",
"x.com.samsung.da.description": "TP2X_REF_20K",
"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": "WiFi Module",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02144A220110",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Micom",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "2011030E,FFFFFFFF",
"x.com.samsung.da.newVersionAvailable": "0"
}
]
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+00:00",
"x.com.samsung.supprtedtype": 1
}
},
{
"href": "/doors/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Door",
"x.com.samsung.da.openState": "Close"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Door",
"x.com.samsung.da.openState": "Close"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Door",
"x.com.samsung.da.openState": "Close"
}
]
}
},
{
"href": "/door/freezer/0",
"rep": {
"openState": "Close"
}
},
{
"href": "/door/cooler/0",
"rep": {
"openState": "Close"
}
},
{
"href": "/door/cvroom/0",
"rep": {
"openState": "Close"
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.countryCode": "US",
"x.com.samsung.da.region": ""
}
},
{
"href": "/defrost/delay/0",
"rep": {
"value": false
}
},
{
"href": "/defrost/delay/vs/0",
"rep": {
"x.com.samsung.da.delayDefrost": "Off"
}
},
{
"href": "/defrost/reservation/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Summer Season",
"x.com.samsung.da.startTime": "0000-05-01T15:00:00",
"x.com.samsung.da.period": "04:00:00",
"x.com.samsung.da.endTime": "0000-10-31T00:00:00"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Winter Season",
"x.com.samsung.da.startTime": "0000-11-01T06:00:00",
"x.com.samsung.da.period": "04:00:00",
"x.com.samsung.da.endTime": "0000-04-30T00:00:00"
}
]
}
},
{
"href": "/icemaker/status/0",
"rep": {
"status": "On"
}
},
{
"href": "/icemaker/status/vs/0",
"rep": {
"x.com.samsung.da.iceMaker": "On"
}
},
{
"href": "/refrigeration/0",
"rep": {
"defrost": false,
"rapidFreeze": false,
"rapidCool": false
}
},
{
"href": "/refrigeration/vs/0",
"rep": {
"x.com.samsung.da.rapidFridge": "Off",
"x.com.samsung.da.rapidFreezing": "Off"
}
},
{
"href": "/drlc/0",
"rep": {
"DRLevel": 0,
"override": false
}
},
{
"href": "/drlc/vs/0",
"rep": {
"x.com.samsung.da.drlcLevel": "0",
"x.com.samsung.da.override": "Off",
"x.com.samsung.da.durationminutes": "0"
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "Micom",
"x.com.samsung.da.newVersionAvailable": "false"
}
},
{
"href": "/filter/waterfilter/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.filterResetType": [
"replaceable"
]
}
},
{
"href": "/icemaker/one/vs/0",
"rep": {
"x.com.samsung.da.iceMaker.name": "ICE_MAKER",
"x.com.samsung.da.iceMaker.state": "On",
"x.com.samsung.da.iceMaker.type": "toggle",
"x.com.samsung.da.iceMaker.iceMakingStatus": "ICESTATUS_STOP",
"x.com.samsung.da.iceType.desired": "NORMAL"
}
}
]
}
+15
View File
@@ -44,6 +44,21 @@
"href": "/energy/consumption/0",
"rep": {}
},
{
"href": "/energy/ailevel/vs/0",
"rep": {
"aiLevel": "0",
"supportedAiLevel": [
"1"
],
"energySavingInfo": [
{
"aiLevel": "1",
"maxEnergySavingRate": "70"
}
]
}
},
{
"href": "/course/vs/0",
"rep": {
+325
View File
@@ -0,0 +1,325 @@
{
"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.periodicnotisubscription": "true"
}
},
{
"href": "/alarms/vs/0",
"rep": {}
},
{
"href": "/diagnosis/vs/0",
"rep": {
"x.com.samsung.da.diagnosisStart": "Ready"
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.instantaneousPower": "-500",
"x.com.samsung.da.instantaneousPowerUnit": "W",
"x.com.samsung.da.cumulativePower": "1210200",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.cumulativeDate": "1784671200",
"x.com.samsung.da.cumulativeDateUTC": "1784667600"
}
},
{
"href": "/energy/consumption/0",
"rep": {}
},
{
"href": "/course/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"HOMECARE_WIZARD_V2"
],
"x.com.samsung.da.options": [
"DeviceType_0167",
"UpdateAllow_NotAllowed",
"Course_1C",
"AiOption_On",
"LaundryOutTime_0",
"AddWashSet_0",
"AddWashAvailable_7",
"AddWashIndicator_On",
"SeamlessControl_Enable",
"KidsLockBypass_On",
"WashingTimes_25",
"DrumCleanProposal_40",
"DrumCleanLog_2024-07-10T22:36:33|2024-09-12T21:37:17|2024-12-06T02:13:28|2025-03-04T20:40:47|2025-05-25T11:02:07|2025-08-17T19:48:10|2025-10-30T20:20:13|2026-01-08T21:44:02|2026-04-02T21:17:40|2026-06-08T19:51:07",
"DetergentOnce_1",
"DetergentLeft_0",
"DetergentBase_5",
"DetergentAlarm_Off",
"DetergentType_2",
"DetergentTotal_49",
"SoftenerOnce_0",
"SoftenerLeft_0",
"SoftenerBase_0",
"SoftenerAlarm_Off",
"SoftenerType_0",
"SoftenerTotal_0",
"SpecialFunction_4",
"AvailableDelayTime_159",
"BubbleSoak_Off",
"LaundryPlannerUserSetTime_0",
"ProgressTimeSet_420BF48204D4A204F5",
"SendToDevice_Off",
"GMT_02",
"LaundryPlannerMicomSetTime_1704",
"PreWashSetting_Off",
"IntensiveSetting_Off",
"BubbleSoakSet_00F000F000F000F0F0F0F00000F000F0F00000F000000000",
"EnergyLevelSet_05030401040104020404020503020202030301010505050405",
"MostUsed_1C8410923FA67FB01F0000000000",
"PreWashAvailableSet_F0F000F0F0F000F0F0F0F00000F0F0F0F00000F000000000",
"IntensiveAvailableSet_F0F000F0F0F000F0F0F0F00000F0F0F0F00000F000000000",
"GeoFenceAlarm",
"UsagesDB_ok",
"EnergyKW_396",
"DrumCleanLog_2024-07-10T22:36:33|2024-09-12T21:37:17|2024-12-06T02:13:28|2025-03-04T20:40:47|2025-05-25T11:02:07|2025-08-17T19:48:10|2025-10-30T20:20:13|2026-01-08T21:44:02|2026-04-02T21:17:40|2026-06-08T19:51:07",
"TimeSync_NotSupported"
],
"x.com.samsung.da.supportedOptions": [
"41C8410923FA67FB01F1B847E923FA67FB01F1E811E933FA53FB01F1D841E923FA67FB01F1F8102923FA57FB01F25843E923FA57FB01F26841E920FA207B01F33857E933FA67FB00024841E930FA30FB00032833E923FA37FB00020857E943FA67FB01F22841E920FA30FB00023831E930FA57FB01F2F831E933FA30FB00021831E943FA57FB0002D841E923FA30FB00030843E923FA67FB000278000913FA67FB01F2880009000A67EB01F368410923FA640B11E3880009000A000B11E3980009000A000B11E3780009000A000B0002985209204A520B000"
]
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "Off"
}
},
{
"href": "/power/0",
"rep": {
"value": false
}
},
{
"href": "/cycleinterface/vs/0",
"rep": {}
},
{
"href": "/kidslock/vs/0",
"rep": {
"x.com.samsung.da.kidsLock": "Ready"
}
},
{
"href": "/kidslock/0",
"rep": {
"value": false
}
},
{
"href": "/operational/state/vs/0",
"rep": {
"x.com.samsung.da.state": "Ready",
"x.com.samsung.da.remainingTime": "02: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",
"Weightsensing",
"Wash",
"Rinse",
"Spin",
"Finish"
]
}
},
{
"href": "/operational/state/0",
"rep": {
"currentMachineState": "**REDACTED**",
"machineStates": "**REDACTED**",
"jobStates": [
"None",
"Weightsensing",
"Wash",
"Rinse",
"Spin",
"Finish"
],
"currentJobState": "None",
"remainingTime": "02:39:00",
"progressPercentage": "1"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "DA_WM_TP2_20_COMMON|20260041|20010102011411B402A3020700000000",
"x.com.samsung.da.description": "DA_WM_TP2_20_COMMON_WW6600R/DC92-02617A_5049",
"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": "210",
"x.com.samsung.da.diagMinVersion": "1.0",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "DA_WM_TP2_20_COMMON|20260041|20010102011411B402A3020700000000",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02674A250414(F546)",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Firmware_1_DB_20260041200911390FFFFF202617412101203104FFFF(01672026004120261741_30000000)(FileDown:0)(Type:0)",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "20091139,21012031",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Firmware_2_DB_2025984120072205042FFFFFFFFFFFFFFFFFFFFFFFFE(016720259841FFFFFFFF_30000000)(FileDown:0)(Type:0)",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "20072205,FFFFFFFF"
}
]
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+01:00"
}
},
{
"href": "/washer/vs/0",
"rep": {
"x.com.samsung.da.waterTemperature": "40",
"x.com.samsung.da.supportedWaterTemperature": [
"None",
"Cold",
"20",
"30",
"40",
"60",
"90"
],
"x.com.samsung.da.spinLevel": "1400",
"x.com.samsung.da.supportedSpinLevel": [
"RinseHold",
"NoSpin",
"400",
"800",
"1000",
"1200",
"1400"
],
"x.com.samsung.da.dryLevel": "None",
"x.com.samsung.da.supportedDryLevel": [
"None",
"Cupboard",
"30",
"60",
"90",
"120",
"180",
"240"
],
"x.com.samsung.da.rinseCycles": "2",
"x.com.samsung.da.supportedRinseCycles": [
"0",
"1",
"2",
"3",
"4",
"5"
]
}
},
{
"href": "/st/washercourse/vs/0",
"rep": {
"x.com.samsung.da.st.washerMode": "Table_02_Course_1C",
"x.com.samsung.da.st.courseTable": "Table_02"
}
},
{
"href": "/setting/vs/0",
"rep": {
"x.com.samsung.da.supportedSetLanguage": [
"ko_KR",
"en_US"
],
"x.com.samsung.da.setLanguage": "en_GB"
}
},
{
"href": "/wm/editcourse/vs/0",
"rep": {
"x.com.samsung.da.editCourseList": "EditCourseList_1C3026241E293633281D27211B202F2223323825392D1F37",
"x.com.samsung.da.fixedCourseList": "FixedCourseList_1C353A29"
}
},
{
"href": "/wm/setinfo/vs/0",
"rep": {
"x.com.samsung.da.isModelSettingWithoutSC": "true",
"x.com.samsung.da.aiCourse": "true_formobile",
"x.com.samsung.da.isModelSettingPowerOnOff": "false"
}
},
{
"href": "/wm/jobbeginingstatus/vs/0",
"rep": {
"x.com.samsung.da.currentStatus": "None"
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "Micom",
"x.com.samsung.da.newVersionAvailable": "false"
}
},
{
"href": "/remotectrl/vs/0",
"rep": {
"x.com.samsung.da.remoteControlEnabled": "false"
}
},
{
"href": "/remotectrl/0",
"rep": {
"value": false
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "0000000000",
"x.com.samsung.da.countryCode": "GB"
}
}
]
}
+253
View File
@@ -0,0 +1,253 @@
{
"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.periodicnotisubscription": "true"
}
},
{
"href": "/alarms/vs/0",
"rep": {}
},
{
"href": "/diagnosis/vs/0",
"rep": {
"x.com.samsung.da.diagnosisStart": "Ready"
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.instantaneousPowerUnit": "W",
"x.com.samsung.da.instantaneousPower": "-500",
"x.com.samsung.da.cumulativePower": "199200",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.cumulativeDate": "1784631600",
"x.com.samsung.da.cumulativeDateUTC": "1784646000"
}
},
{
"href": "/energy/consumption/0",
"rep": {}
},
{
"href": "/course/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"HOMECARE_WIZARD_V2"
],
"x.com.samsung.da.options": [
"DeviceType_0146",
"UpdateAllow_NotAllowed",
"Course_A5",
"LaundryOutTime_0",
"AddGarmentIndicator_Off",
"SteamWash_Enable",
"SeamlessControl_Disable",
"KidsLockBypass_On",
"WashingTimes_14",
"DrumCleanProposal_40",
"DetergentOnce_0",
"DetergentLeft_0",
"DetergentBase_0",
"DetergentAlarm_Off",
"DetergentType_0",
"DetergentTotal_0",
"SpecialFunction_4",
"AvailableDelayTime_124",
"LaundryPlannerUserSetTime_0",
"ProgressTimeSet_4210E08209F6A2021C",
"EnergyLevelSet_0502040502030302030105",
"UsagesDB_ok",
"EnergyKW_396",
"DrumCleanLog_2023-08-11T19:17:11|2023-11-02T03:23:13|2023-12-22T22:35:54|2024-04-08T01:27:32|2024-04-22T01:07:26|2024-10-02T19:16:45|2025-02-03T02:01:33|2025-06-16T17:36:13|2026-01-04T18:34:46|2026-05-26T02:33:16",
"TimeSync_NotSupported"
],
"x.com.samsung.da.supportedOptions": [
"301833EA41BC20E9C843EA41FC30EA98520A41FC20EA2810EA207C20EAA831EA41FC20EA5830EA20FC20E9F831EA41FC10E9E830EA21FC20E9D8000A41FC000B18520A410C000"
]
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "On"
}
},
{
"href": "/power/0",
"rep": {
"value": true
}
},
{
"href": "/cycleinterface/vs/0",
"rep": {}
},
{
"href": "/kidslock/vs/0",
"rep": {
"x.com.samsung.da.kidsLock": "Ready"
}
},
{
"href": "/kidslock/0",
"rep": {
"value": false
}
},
{
"href": "/operational/state/vs/0",
"rep": {
"x.com.samsung.da.state": "Ready",
"x.com.samsung.da.remainingTime": "02:04: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",
"Wash",
"Rinse",
"Spin",
"Finish"
]
}
},
{
"href": "/operational/state/0",
"rep": {
"currentMachineState": "**REDACTED**",
"machineStates": "**REDACTED**",
"jobStates": [
"None",
"Wash",
"Rinse",
"Spin",
"Finish"
],
"currentJobState": "None",
"remainingTime": "02:04:00",
"progressPercentage": "1"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "DA_WM_A51_20_COMMON|20198042|20020001001111400203000000000000",
"x.com.samsung.da.description": "DA_WM_A51_20_COMMON_WV9600M/DC92-01980B_0014",
"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_WM_A51_20_COMMON|20198042|20020001001111400203000000000000",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02198A230708(E257)",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "DA_WM_A51_20_COMMON",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "18030902,18090603",
"x.com.samsung.da.newVersionAvailable": "0"
}
]
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "-04:00"
}
},
{
"href": "/washer/vs/0",
"rep": {
"x.com.samsung.da.waterTemperature": "Warm",
"x.com.samsung.da.supportedWaterTemperature": [
"None",
"Cold",
"Cool",
"Warm",
"Hot",
"ExtraHot"
],
"x.com.samsung.da.spinLevel": "Low",
"x.com.samsung.da.supportedSpinLevel": [
"RinseHold",
"NoSpin",
"Low",
"Medium",
"High"
],
"x.com.samsung.da.soilLevel": "Heavy",
"x.com.samsung.da.supportedSoilLevel": [
"None",
"Light",
"Normal",
"Heavy"
],
"x.com.samsung.da.rinseCycles": "3"
}
},
{
"href": "/st/washercourse/vs/0",
"rep": {
"x.com.samsung.da.st.washerMode": "Table_00_Course_A5",
"x.com.samsung.da.st.courseTable": "Table_00"
}
},
{
"href": "/setting/vs/0",
"rep": {}
},
{
"href": "/wm/editcourse/vs/0",
"rep": {}
},
{
"href": "/wm/setinfo/vs/0",
"rep": {
"x.com.samsung.da.isModelSettingWithoutSC": "false",
"x.com.samsung.da.isModelSettingPowerOnOff": "false"
}
},
{
"href": "/wm/jobbeginingstatus/vs/0",
"rep": {}
},
{
"href": "/remotectrl/vs/0",
"rep": {
"x.com.samsung.da.remoteControlEnabled": "true"
}
},
{
"href": "/remotectrl/0",
"rep": {
"value": true
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "0000000000",
"x.com.samsung.da.countryCode": "US"
}
}
]
}
+16
View File
@@ -3,8 +3,10 @@ from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from unittest.mock import patch
import cbor2
import pytest
from pytest_homeassistant_custom_component.common import MockConfigEntry
@@ -150,12 +152,26 @@ class FakeObserveSession:
self.subscribed: list[str] = []
self.fail_hrefs: set[str] = set()
self.closed = False
# When set to a rep dict, subscribe() immediately delivers that rep
# as an OBSERVE notification for the href — what a real device does
# when it answers a subscription with the current representation.
# `try_enter_observe_mode` clears its notified set before it
# subscribes, so this is the only way to deliver a notify that
# reliably counts: it lands synchronously via on_notification,
# after that clear and before the post-sleep fraction check — a
# notify raced in from another thread can be wiped by the clear
# (or arrive after the check) depending on scheduling. Set to
# None to model a device that answers subscriptions but never
# notifies.
self.notify_on_subscribe: dict[str, Any] | None = None
def subscribe(self, path_segs):
href = '/' + '/'.join(path_segs)
if href in self.fail_hrefs:
raise ConnectionError("subscribe failed")
self.subscribed.append(href)
if self.notify_on_subscribe is not None and self.on_notification is not None:
self.on_notification(href, cbor2.dumps(self.notify_on_subscribe))
return b'\x01'
def refresh_observes(self, paths):
+107
View File
@@ -56,6 +56,113 @@ async def test_successful_setup(hass: HomeAssistant, mock_probe) -> None:
assert result['data'][CONF_CA_CERT_PEM] == MOCK_CA_CERT_PEM
def test_order_candidates_prefers_known_ports() -> None:
"""Live ports are ordered with the historically known DTLS ports first,
then the rest ascending."""
from custom_components.localthings.config_flow import _order_candidates
assert _order_candidates([49160, 49153, 49155, 49154]) == [
49154, 49155, 49153, 49160,
]
assert _order_candidates([49153]) == [49153]
def test_find_live_ports_detects_silent_port(socket_enabled) -> None:
"""The UDP liveness sweep flags a bound-but-silent port as live and drops
ports that refuse with ICMP port-unreachable.
A bound, never-recv'd UDP socket stands in for a device that listens but
stays silent (open|filtered), like the dishwasher in issue #13 on 49153.
Two sibling ports are reserved then closed so loopback refuses datagrams
to them, standing in for the closed ports the scan should discard.
`socket_enabled` lifts pytest-socket's default block (the HA test harness
disables real sockets); this test genuinely needs loopback UDP to exercise
the ICMP-unreachable path.
"""
import socket
from custom_components.localthings.config_flow import _find_live_ports
reserve = [socket.socket(socket.AF_INET, socket.SOCK_DGRAM) for _ in range(3)]
for s in reserve:
s.bind(('127.0.0.1', 0))
ports = [s.getsockname()[1] for s in reserve]
live_sock, live_port = reserve[0], ports[0]
reserve[1].close()
reserve[2].close()
closed_ports = ports[1:]
try:
result = _find_live_ports(
'127.0.0.1', [closed_ports[0], live_port, closed_ports[1]], 0.8,
)
finally:
live_sock.close()
assert result == [live_port]
async def test_probe_uses_discovered_low_port(hass: HomeAssistant, monkeypatch) -> None:
"""A device that only answers on 49153 — outside the historical
49154/49155 pair — is found by the liveness sweep and its port is stored
on the config entry (issue #13)."""
import cbor2
from custom_components.localthings import config_flow
device0 = [
{'rt': ['x.com.samsung.devcol']},
{'href': '/information/vs/0', 'rep': {
'x.com.samsung.da.modelNum':
'DA_WM_TP1_21_COMMON|20375141|20010002001811424AA30217008A0000',
'x.com.samsung.da.description':
'DA_WM_TP1_21_COMMON_WW5000C/DC92-03495A_B048',
'x.com.samsung.da.serialNum': 'DISHWASHER-49153',
}},
{'href': '/otninformation/vs/0', 'rep': {'otnStatus': 'None'}},
]
class _FakeSession:
def __init__(self, host, port, cert_pem=None, key_pem=None):
self.host, self.port = host, port
def connect(self):
pass
def start_reader(self):
pass
def get(self, path, timeout=15.0):
return 0x45, cbor2.dumps(device0)
def close(self):
pass
monkeypatch.setattr(config_flow, '_fetch_samsung_uuid', lambda: 'test-uuid')
monkeypatch.setattr(
config_flow, '_mint_leaf_cert',
lambda ca_cert, ca_key, uuid: ('FULLCHAIN', 'LEAFKEY'),
)
monkeypatch.setattr(
config_flow, '_find_live_ports',
lambda host, ports, timeout: [49153],
)
monkeypatch.setattr(
'smartthings_local.protocol.dtls_session.DtlsCoapSession', _FakeSession,
)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={'source': 'user'}
)
result = await hass.config_entries.flow.async_configure(
result['flow_id'],
{CONF_HOST: MOCK_HOST, CONF_CA_CERT_PEM: MOCK_CA_CERT_PEM, CONF_CA_KEY_PEM: MOCK_CA_KEY_PEM},
)
assert result['type'] == FlowResultType.CREATE_ENTRY
assert result['data'][CONF_PORT] == 49153
async def test_cannot_connect(hass: HomeAssistant) -> None:
"""Failed probe: form re-shown with cannot_connect error."""
from custom_components.localthings.config_flow import CannotConnect
+201 -87
View File
@@ -1,20 +1,22 @@
"""Tests for the LocalThingsCoordinator."""
from __future__ import annotations
import threading
import time
from datetime import timedelta
from unittest.mock import AsyncMock, patch
import cbor2
import pytest
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import issue_registry as ir
from custom_components.localthings.const import (
CONF_HOST, DOMAIN, SUMMARY_INTERVAL_S,
)
from custom_components.localthings.coordinator import LocalThingsCoordinator
from custom_components.localthings.registry.capabilities.common import (
remote_control_enabled,
)
from custom_components.localthings.observe import MODE_OBSERVE, MODE_POLL, PUSH_HEALTH_WINDOW_S
from .conftest import ENTRY_DATA, MOCK_SERIAL
@@ -260,21 +262,18 @@ async def test_enters_observe_mode_when_hot_warm_hrefs_notify(
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs
# try_enter_observe_mode clears prior notifications as soon as it
# starts, so notifies must land *during* its grace-period sleep (same
# pattern test_observe.py uses), not before the call.
def _notify_during_grace_period():
time.sleep(0.005)
for href in hrefs:
fake.on_notification(href, cbor2.dumps({'notified': True}))
notifier = threading.Thread(target=_notify_during_grace_period, daemon=True)
notifier.start()
# try_enter_observe_mode clears prior notifications before it
# subscribes, then checks the notified fraction after the grace-period
# sleep — a notify counts as long as it arrives in that window, not
# specifically during the sleep itself. The fake session delivers one
# synchronously from subscribe() for every href it subscribes to (see
# FakeObserveSession.notify_on_subscribe in conftest.py), which lands
# it in that window by construction rather than by timing.
fake.notify_on_subscribe = {'notified': True}
entered = await hass.async_add_executor_job(
coordinator._observe.try_enter_observe_mode,
fake, hrefs, 0.02, 0.8,
)
notifier.join()
assert entered is True
assert coordinator._observe.mode == MODE_OBSERVE
@@ -300,18 +299,11 @@ async def test_reconnect_while_observe_mode_downgrades_to_poll(
# Get the coordinator into observe mode the same way
# test_enters_observe_mode_when_hot_warm_hrefs_notify does.
def _notify_during_grace_period():
time.sleep(0.005)
for href in hrefs:
fake.on_notification(href, cbor2.dumps({'notified': True}))
notifier = threading.Thread(target=_notify_during_grace_period, daemon=True)
notifier.start()
fake.notify_on_subscribe = {'notified': True}
entered = await hass.async_add_executor_job(
coordinator._observe.try_enter_observe_mode,
fake, hrefs, 0.02, 0.8,
)
notifier.join()
assert entered is True
assert coordinator.observe_mode == MODE_OBSERVE
@@ -324,12 +316,12 @@ async def test_reconnect_while_observe_mode_downgrades_to_poll(
# Simulate the existing "poll failed, reconnecting" branch: _poll_once
# fails once (triggering the reconnect/backoff path), then succeeds.
# No fresh notifies are supplied for the immediate resubscribe attempt
# this now triggers, so its grace period (shortened for tests by the
# `_fast_coordinator_timers` autouse fixture — see conftest.py) times
# out and mode stays 'poll' — this test only asserts the tear-down half of
# the fix; test_reconnect_from_observe_mode_resubscribes_immediately
# covers the successful-immediate-resubscribe half.
# The device stops notifying on subscribe, so the immediate resubscribe
# attempt this now triggers gets nothing back and mode stays 'poll' —
# this test only asserts the tear-down half of the fix;
# test_reconnect_from_observe_mode_resubscribes_immediately covers the
# successful-immediate-resubscribe half.
fake.notify_on_subscribe = None
with (
patch(
'custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once',
@@ -362,18 +354,11 @@ async def test_poll_timeout_skips_reconnect_when_push_is_healthy(
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs
def _notify_during_grace_period():
time.sleep(0.005)
for href in hrefs:
fake.on_notification(href, cbor2.dumps({'notified': True}))
notifier = threading.Thread(target=_notify_during_grace_period, daemon=True)
notifier.start()
fake.notify_on_subscribe = {'notified': True}
entered = await hass.async_add_executor_job(
coordinator._observe.try_enter_observe_mode,
fake, hrefs, 0.02, 0.8,
)
notifier.join()
assert entered is True
assert coordinator.observe_mode == MODE_OBSERVE
@@ -408,18 +393,11 @@ async def test_poll_timeout_reconnects_after_consecutive_limit_even_with_push(
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs
def _notify_during_grace_period():
time.sleep(0.005)
for href in hrefs:
fake.on_notification(href, cbor2.dumps({'notified': True}))
notifier = threading.Thread(target=_notify_during_grace_period, daemon=True)
notifier.start()
fake.notify_on_subscribe = {'notified': True}
entered = await hass.async_add_executor_job(
coordinator._observe.try_enter_observe_mode,
fake, hrefs, 0.02, 0.8,
)
notifier.join()
assert entered is True
assert coordinator.observe_mode == MODE_OBSERVE
@@ -441,7 +419,10 @@ async def test_poll_timeout_reconnects_after_consecutive_limit_even_with_push(
assert coordinator.observe_mode == MODE_OBSERVE
# The final consecutive timeout crosses the limit and triggers the
# existing reconnect path, which then succeeds and downgrades.
# existing reconnect path, which then succeeds and downgrades. The
# device stops notifying on subscribe, so the resubscribe attempt that
# follows the reconnect finds nothing and mode stays 'poll'.
fake.notify_on_subscribe = None
with (
patch(
'custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once',
@@ -473,18 +454,11 @@ async def test_poll_timeout_counter_resets_when_push_is_healthy_again(
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs
def _notify_during_grace_period():
time.sleep(0.005)
for href in hrefs:
fake.on_notification(href, cbor2.dumps({'notified': True}))
notifier = threading.Thread(target=_notify_during_grace_period, daemon=True)
notifier.start()
fake.notify_on_subscribe = {'notified': True}
entered = await hass.async_add_executor_job(
coordinator._observe.try_enter_observe_mode,
fake, hrefs, 0.02, 0.8,
)
notifier.join()
assert entered is True
assert coordinator.observe_mode == MODE_OBSERVE
@@ -526,21 +500,11 @@ async def test_reconnect_from_observe_mode_resubscribes_immediately(
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs
def _notify(delay: float = 0.005) -> threading.Thread:
def _run():
time.sleep(delay)
for href in hrefs:
fake.on_notification(href, cbor2.dumps({'notified': True}))
t = threading.Thread(target=_run, daemon=True)
t.start()
return t
notifier = _notify()
fake.notify_on_subscribe = {'notified': True}
entered = await hass.async_add_executor_job(
coordinator._observe.try_enter_observe_mode,
fake, hrefs, 0.02, 0.8,
)
notifier.join()
assert entered is True
assert coordinator.observe_mode == MODE_OBSERVE
@@ -563,10 +527,8 @@ async def test_reconnect_from_observe_mode_resubscribes_immediately(
new=AsyncMock(),
),
):
notifier = _notify()
await coordinator.async_request_refresh()
await hass.async_block_till_done()
notifier.join()
assert coordinator.observe_mode == MODE_OBSERVE
@@ -587,21 +549,11 @@ async def test_sweep_mismatch_never_downgrades_a_live_observe_session(
hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs
assert len(hrefs) >= 2
def _notify(delay: float = 0.005) -> threading.Thread:
def _run():
time.sleep(delay)
for href in hrefs:
fake.on_notification(href, cbor2.dumps({'notified': True}))
t = threading.Thread(target=_run, daemon=True)
t.start()
return t
notifier = _notify()
fake.notify_on_subscribe = {'notified': True}
entered = await hass.async_add_executor_job(
coordinator._observe.try_enter_observe_mode,
fake, hrefs, 0.02, 0.8,
)
notifier.join()
assert entered is True
assert coordinator.observe_mode == MODE_OBSERVE
@@ -637,21 +589,11 @@ async def test_sweep_mismatch_forces_subpolls_on_a_live_observe_session(
hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs
assert len(hrefs) >= 2
def _notify(delay: float = 0.005) -> threading.Thread:
def _run():
time.sleep(delay)
for href in hrefs:
fake.on_notification(href, cbor2.dumps({'notified': True}))
t = threading.Thread(target=_run, daemon=True)
t.start()
return t
notifier = _notify()
fake.notify_on_subscribe = {'notified': True}
entered = await hass.async_add_executor_job(
coordinator._observe.try_enter_observe_mode,
fake, hrefs, 0.02, 0.8,
)
notifier.join()
assert entered is True
assert coordinator.observe_mode == MODE_OBSERVE
@@ -696,3 +638,175 @@ async def test_write_marks_href_pending_before_post(
await coordinator.async_send_command(bound, 5)
assert coordinator._observe._settle_until.get('/test/vs/0') is not None
async def test_send_command_applies_write_optimistically_before_settling(
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
) -> None:
"""The cache must reflect a write immediately, and stay put against a
stale echo for the rest of the settle window -- otherwise
mark_write_pending has nothing to protect and just delays the real
device confirmation instead, which reads to a user as the write being
silently reverted (issue #27)."""
from custom_components.localthings.registry.discovery import BoundEntity
from custom_components.localthings.registry.entities import NumberDesc
fake = mock_coordinator_observe_session
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
def _write_fn(payload, rep, href):
return (['some', 'path'], {'value': payload})
desc = NumberDesc(key='test', field='value', write_fn=_write_fn)
bound = BoundEntity(href='/test/vs/0', capability=coordinator.bound[0].capability, desc=desc)
with patch.object(fake, 'subscribe'):
fake.post = lambda *a, **k: (0x44, b'')
await coordinator.async_send_command(bound, 5)
# The optimistic value is visible right away, without waiting for a
# device response.
assert coordinator._cache.get('/test/vs/0') == {'value': 5}
# A stale update racing in behind the write (e.g. a poll/notify that
# was already in flight before the PUT) must not clobber it while the
# settle window is open.
applied = coordinator._observe.apply('/test/vs/0', {'value': 0}, source='observe')
assert applied is False
assert coordinator._cache.get('/test/vs/0') == {'value': 5}
class TestRemoteControlEnabled:
"""remote_control_enabled (registry/capabilities/common.py) is the
single source of truth for the /remotectrl on/off signal, shared by
the Smart Control binary_sensor descriptors and the coordinator's
write guard alike."""
def test_vs_fallback_href_true_is_enabled(self):
resources = {'/remotectrl/vs/0': {'x.com.samsung.da.remoteControlEnabled': 'true'}}
assert remote_control_enabled(resources) is True
def test_vs_fallback_href_false_is_disabled(self):
resources = {'/remotectrl/vs/0': {'x.com.samsung.da.remoteControlEnabled': 'false'}}
assert remote_control_enabled(resources) is False
def test_generic_href_true_is_enabled(self):
resources = {'/remotectrl/0': {'value': True}}
assert remote_control_enabled(resources) is True
def test_generic_href_false_is_disabled(self):
resources = {'/remotectrl/0': {'value': False}}
assert remote_control_enabled(resources) is False
def test_generic_href_wins_when_both_present(self):
resources = {
'/remotectrl/0': {'value': True},
'/remotectrl/vs/0': {'x.com.samsung.da.remoteControlEnabled': 'false'},
}
assert remote_control_enabled(resources) is True
def test_assumes_enabled_when_capability_absent(self):
assert remote_control_enabled({}) is True
async def test_send_command_blocked_when_remote_control_disabled(
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
) -> None:
"""A device reporting remote control off must reject every write with
a user-facing message, ahead of write_fn's silent-no-op path and any
description-level validate_fn."""
from custom_components.localthings.registry.discovery import BoundEntity
from custom_components.localthings.registry.entities import NumberDesc
fake = mock_coordinator_observe_session
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
coordinator._cache.apply_rep(
'/remotectrl/vs/0',
{'x.com.samsung.da.remoteControlEnabled': 'false'},
source='test',
)
def _write_fn(payload, rep, href):
return (['some', 'path'], {'value': payload})
desc = NumberDesc(key='test', field='value', write_fn=_write_fn)
bound = BoundEntity(href='/test/vs/0', capability=coordinator.bound[0].capability, desc=desc)
posted = False
def _post(*a, **k):
nonlocal posted
posted = True
return (0x44, b'')
with patch.object(fake, 'subscribe'):
fake.post = _post
with pytest.raises(ServiceValidationError, match="Remote control is turned off"):
await coordinator.async_send_command(bound, 5)
assert posted is False
async def test_send_command_allowed_when_remote_control_enabled(
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
) -> None:
"""The same write goes through once remote control reports enabled."""
from custom_components.localthings.registry.discovery import BoundEntity
from custom_components.localthings.registry.entities import NumberDesc
fake = mock_coordinator_observe_session
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
coordinator._cache.apply_rep(
'/remotectrl/vs/0',
{'x.com.samsung.da.remoteControlEnabled': 'true'},
source='test',
)
def _write_fn(payload, rep, href):
return (['some', 'path'], {'value': payload})
desc = NumberDesc(key='test', field='value', write_fn=_write_fn)
bound = BoundEntity(href='/test/vs/0', capability=coordinator.bound[0].capability, desc=desc)
with patch.object(fake, 'subscribe'):
fake.post = lambda *a, **k: (0x44, b'')
await coordinator.async_send_command(bound, 5)
assert coordinator._observe._settle_until.get('/test/vs/0') is not None
async def test_send_command_remote_control_check_precedes_validate_fn(
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
) -> None:
"""When both a disabled remote-control state and a failing validate_fn
apply to the same write, the remote-control message wins -- it's the
more actionable of the two and is checked first."""
from custom_components.localthings.registry.discovery import BoundEntity
from custom_components.localthings.registry.entities import SwitchDesc
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
coordinator._cache.apply_rep(
'/remotectrl/vs/0',
{'x.com.samsung.da.remoteControlEnabled': 'false'},
source='test',
)
def _write_fn(payload, rep, href=None):
return (['some', 'path'], {'value': payload})
def _validate_fn(payload, rep, resources):
return "always rejected"
desc = SwitchDesc(key='test', field='value', write_fn=_write_fn, validate_fn=_validate_fn)
bound = BoundEntity(href='/test/vs/0', capability=coordinator.bound[0].capability, desc=desc)
with pytest.raises(ServiceValidationError, match="Remote control is turned off"):
await coordinator.async_send_command(bound, 'On')
+26
View File
@@ -2,10 +2,12 @@
from __future__ import annotations
import json
import threading
from pathlib import Path
from homeassistant.core import HomeAssistant
from custom_components.localthings import diagnostics as diagnostics_mod
from custom_components.localthings.const import DOMAIN
from custom_components.localthings.diagnostics import async_get_config_entry_diagnostics
from custom_components.localthings.registry.redact import REDACTED
@@ -49,3 +51,27 @@ async def test_diagnostics_include_observe_mode_fields(
assert diag['observe_fallback_hrefs'] == []
assert 'observe_last_mode_change' in diag
assert diag['observe_href_freshness_s'] == {}
async def test_dependency_version_read_off_the_event_loop(
hass: HomeAssistant, mock_entry, mock_coordinator_session, monkeypatch
) -> None:
"""importlib.metadata.version() does blocking disk I/O, so it must run in
the executor, not on the event loop (issue #9's logs flagged it)."""
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
loop_thread_id = threading.get_ident() # this coroutine runs on the loop
seen: dict[str, int] = {}
real_pkg_version = diagnostics_mod.pkg_version
def _spy(name: str) -> str:
seen['thread_id'] = threading.get_ident()
return real_pkg_version(name)
monkeypatch.setattr(diagnostics_mod, 'pkg_version', _spy)
diag = await async_get_config_entry_diagnostics(hass, mock_entry)
assert diag['smartthings_local_version']
assert seen['thread_id'] != loop_thread_id
+22
View File
@@ -34,6 +34,28 @@ def test_apply_writes_through_when_not_settling():
assert mgr.cache.get('/oven/vs/0') == {'a': 1}
def test_apply_merges_partial_update_onto_prior_rep():
"""Regression test for issue #27: a Bespoke fridge's /mode/vs/0 notify
(and even a later sweep entry for that href) can carry only `modes`,
omitting `supportedOptions` entirely. A full replace would wipe
`supportedOptions` from the cache the moment that partial update
arrives, even though the device's supported options didn't change --
which is exactly what made the flex-zone select disappear."""
mgr = _manager()
full = {
'x.com.samsung.da.modes': ['CVN_CONVERTIBLE_ZONE', 'CV_FDR_MEAT'],
'x.com.samsung.da.supportedOptions': ['CV_FDR_WINE', 'CV_FDR_MEAT'],
}
mgr.apply('/mode/vs/0', full, source='poll')
partial = {'x.com.samsung.da.modes': ['CVN_CONVERTIBLE_ZONE', 'WATERFILTER_ENABLE']}
mgr.apply('/mode/vs/0', partial, source='observe')
cached = mgr.cache.get('/mode/vs/0')
assert cached['x.com.samsung.da.modes'] == ['CVN_CONVERTIBLE_ZONE', 'WATERFILTER_ENABLE']
assert cached['x.com.samsung.da.supportedOptions'] == ['CV_FDR_WINE', 'CV_FDR_MEAT']
def test_apply_drops_update_during_settle_window():
mgr = _manager()
mgr.cache.apply_rep('/oven/vs/0', {'a': 1}, source='seed')
+235
View File
@@ -0,0 +1,235 @@
"""Tests for Samsung air-conditioner support (issue #17).
These stay HA-free like the rest of the suite: they exercise the registry,
discovery/flatten, and the CLIMATE capability's write contract. The composite
climate entity itself lives in climate.py (imports homeassistant) and is not
importable here -- consistent with how the other HA platform files are untested.
"""
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import for_device, for_device_by_model
from custom_components.localthings.registry.capabilities import airconditioner
from custom_components.localthings.registry.discovery import discover
from custom_components.localthings.registry.entities import ClimateDesc
from tests.conftest import _load_device
def _ac():
resources = _load_device('airconditioner')
info = resources['/information/vs/0']
reg = for_device_by_model(
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'],
)
return reg, resources
def _resolve(name):
"""Mirror the coordinator's detection order: oneUiVersion first, modelNum
fallback second (needed for issue #37's board, which reports neither
oneUiVersion nor a '_PRAC_' modelNum token)."""
resources = _load_device(name)
otn = resources.get('/otninformation/vs/0', {})
one_ui = otn.get('swVersionInfo', {}).get('oneUiVersion', '')
info = resources['/information/vs/0']
reg = for_device(one_ui) if one_ui else None
if reg is None:
reg = for_device_by_model(
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'],
)
return reg, resources
def _bound():
reg, resources = _ac()
return discover(resources, reg.capabilities, reg.pattern_capabilities), resources
def _state():
bound, resources = _bound()
return flatten(bound, resources)
def test_ac_model_resolves_to_airconditioner_registry():
reg, _ = _ac()
assert reg is not None and reg.name == 'airconditioner'
def test_no_unbound_hrefs():
"""Every resource in the issue #17 dump binds or is covered -- clears the
coverage-gap repair."""
reg, resources = _ac()
unbound = []
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
assert unbound == []
def test_climate_entity_is_bound():
"""The composite climate entity binds the primary /mode/vs/0 resource."""
bound, _ = _bound()
climate = [b for b in bound if isinstance(b.desc, ClimateDesc)]
assert len(climate) == 1
assert climate[0].href == '/mode/vs/0'
def test_expected_state_keys_present():
state = _state()
for key in ('climate', 'air_purify', 'auto_clean', 'air_filter_status',
'air_filter_usage', 'diagnosis_status', 'alarm_code', 'energy_kwh'):
assert key in state, key
def test_power_and_convenient_folded_into_climate():
"""On/off is the climate entity's HVACMode.OFF and convenient mode is its
preset_mode -- neither surfaces as a standalone switch/select."""
state = _state()
assert 'power_switch' not in state
assert 'convenient_mode' not in state
def test_air_filter_usage_is_percentage_of_capacity():
"""filterUsage is a raw count in the capacity unit (100 of 500), surfaced as
a percentage rather than the misleading raw value."""
assert _state()['air_filter_usage'] == 20
def test_climate_write_targets():
"""The CLIMATE write_fn maps each (kind, value) command to the right OCF
POST target and body. `value` is already the raw device code."""
write = airconditioner.CLIMATE.entities[0].write_fn
assert write(('power', True), {}) == (['power', '0'], {'value': True})
assert write(('power', False), {}) == (['power', '0'], {'value': False})
assert write(('mode', 'Heat'), {}) == (
['mode', 'vs', '0'], {'x.com.samsung.da.modes': ['Heat']})
assert write(('temperature', 23.6), {}) == (
['temperature', 'desired', '0'], {'temperature': 24})
assert write(('fan', '2'), {}) == (
['wind', 'strength', 'vs', '0'], {'x.com.samsung.da.modes': '2'})
assert write(('swing', 'All'), {}) == (
['wind', 'direction', 'vs', '0'], {'x.com.samsung.da.modes': 'All'})
assert write(('preset', 'Sleep'), {}) == (
['mode', 'convenient', 'vs', '0'], {'x.com.samsung.da.modes': 'Sleep'})
assert write(('bogus', 1), {}) is None
def test_climate_consumed_hrefs_declared_as_coverage():
"""The climate-consumed and ambiguous hrefs are declared in the AC registry
(as no-entity coverage caps) so they don't leak as gaps -- but produce no
standalone entities."""
reg, _ = _ac()
for href in ('/power/0', '/power/vs/0', '/temperature/desired/0',
'/wind/strength/vs/0', '/mode/convenient/vs/0',
'/temperatures/vs/0', '/sensors/vs/0', '/humidity/0'):
caps = reg.capabilities.get(href)
assert caps, href
assert all(c.entities == () for c in caps), href
# ---------------------------------------------------------------------------
# TP1X_DA-AC-RAC-01011 (oneUiVersion "7.0 Air conditioner", Tizen Lite) -- a
# newer model class than the ARTIK051_PRAC dump above. It has no OCF-standard
# /temperature/current+desired pair (temperature lives on the vendor
# /temperatures/vs/0 items[] resource), exposes a /light/vs/0 display light, and
# carries extra vendor housekeeping hrefs. Issue #17 for this class (PR #36).
# ---------------------------------------------------------------------------
def _ac_tp1x():
resources = _load_device('airconditioner_tp1x_da_ac_rac_01011')
one_ui = resources['/otninformation/vs/0']['swVersionInfo']['oneUiVersion']
return for_device(one_ui), resources
def test_tp1x_resolves_to_airconditioner_registry():
reg, _ = _ac_tp1x()
assert reg is not None and reg.name == 'airconditioner'
def test_tp1x_no_unbound_hrefs():
"""Every resource in the TP1X dump binds or is covered -- including
/temperatures/vs/0, /light/vs/0 and the housekeeping hrefs absent from
the ARTIK051 dump. Clears the coverage-gap repair."""
reg, resources = _ac_tp1x()
unbound = []
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
assert unbound == []
def test_tp1x_display_light_switch_present():
"""/light/vs/0 (mode On/Off) surfaces as the display-light switch."""
reg, resources = _ac_tp1x()
state = flatten(
discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
assert state.get('display_light') is True # device reports mode == 'On'
def test_tp1x_vendor_temperature_and_light_covered():
"""The vendor temperature resource (read by the climate entity) and the
display-light resource both resolve in the registry -- no gap."""
reg, _ = _ac_tp1x()
assert reg.capabilities.get('/temperatures/vs/0'), '/temperatures/vs/0'
assert reg.capabilities.get('/light/vs/0'), '/light/vs/0'
def test_tp1x_climate_entity_is_bound():
"""The composite climate entity still binds the primary /mode/vs/0."""
reg, resources = _ac_tp1x()
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
climate = [b for b in bound if isinstance(b.desc, ClimateDesc)]
assert len(climate) == 1 and climate[0].href == '/mode/vs/0'
def test_tp2x_rac_20k_model_resolves_via_model_fallback():
"""TP2X_RAC_20K (issue #37) reports no oneUiVersion and no '_PRAC_' token
-- resolved via the '_RAC_' modelNum fallback added for this device."""
reg, _ = _resolve('airconditioner_tp2x_rac_20k')
assert reg is not None and reg.name == 'airconditioner'
def test_tp2x_rac_20k_no_unbound_hrefs():
reg, resources = _resolve('airconditioner_tp2x_rac_20k')
unbound = []
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
assert unbound == []
def test_tp1x_rac_model_resolves_via_one_ui_version():
"""TP1X_DA-AC-RAC-01001_0000 (issue #38) self-reports oneUiVersion
'7.0 Air conditioner' -- resolved via for_device(), not the modelNum
fallback."""
reg, _ = _resolve('airconditioner_tp1x_rac')
assert reg is not None and reg.name == 'airconditioner'
def test_tp1x_rac_no_unbound_hrefs():
reg, resources = _resolve('airconditioner_tp1x_rac')
unbound = []
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
assert unbound == []
def test_tp1x_rac_expected_state_keys_present():
reg, resources = _resolve('airconditioner_tp1x_rac')
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
state = flatten(bound, resources)
for key in ('climate', 'display_light', 'mute_once', 'selfcheck_status',
'selfcheck_result', 'current_limit_enabled', 'current_limit_level'):
assert key in state, key
def test_mute_once_write_target():
write = airconditioner.MUTE_ONCE.entities[0].write_fn
assert write('On', {}) == (['option', 'muteonce', 'vs', '0'], {'muteonce': 'On'})
assert write('Off', {}) == (['option', 'muteonce', 'vs', '0'], {'muteonce': 'Off'})
def test_display_light_write_target():
write = airconditioner.DISPLAY_LIGHT.entities[0].write_fn
assert write('On', {}) == (['light', 'vs', '0'], {'mode': 'On'})
assert write('Off', {}) == (['light', 'vs', '0'], {'mode': 'Off'})
def test_current_limit_is_read_only():
"""Meaning/write contract for the current-limit levels isn't confirmed
from the dump alone -- exposed as read-only diagnostic sensors rather
than a guessed writable control."""
for desc in airconditioner.CURRENT_LIMIT.entities:
assert getattr(desc, 'write_fn', None) is None
+32
View File
@@ -168,6 +168,38 @@ class TestForDeviceByModel:
assert reg is not None
assert reg.name == 'refrigerator'
def test_airconditioner_via_prac_token(self):
"""Issue #17: a room AC (ARTIK051_PRAC_20K) reports no oneUiVersion and
an unrecognized consumer token ('20K'); it falls back to the '_PRAC_'
(Package Room Air Conditioner) token in modelNum."""
from custom_components.localthings.registry.by_type import for_device_by_model
reg = for_device_by_model(
'ARTIK051_PRAC_20K|10217841|60010532001411004200003000000000',
'ARTIK051_PRAC_20K',
)
assert reg is not None
assert reg.name == 'airconditioner'
def test_washer_wf_prefix(self):
"""US front-load washers use the WF consumer-model prefix."""
from custom_components.localthings.registry.by_type import for_device_by_model
reg = for_device_by_model(
'DA_WM_TP1_21_COMMON|20313741|20010001001611244AA3021700000000',
'DA_WM_TP1_21_COMMON_WF8900B/DC92-03129A_A0AE',
)
assert reg is not None
assert reg.name == 'washer'
def test_washer_wv_prefix(self):
"""FlexWash twin washers use the WV consumer-model prefix -- issue #19."""
from custom_components.localthings.registry.by_type import for_device_by_model
reg = for_device_by_model(
'DA_WM_A51_20_COMMON|20198042|20020001001111400203000000000000',
'DA_WM_A51_20_COMMON_WV9600M/DC92-01980B_0014',
)
assert reg is not None
assert reg.name == 'washer'
def test_unknown_model_returns_none(self):
from custom_components.localthings.registry.by_type import for_device_by_model
reg = for_device_by_model('SOME-UNKNOWN-BOARD', 'SOME-UNKNOWN-BOARD')
@@ -0,0 +1,38 @@
"""Tests for the /temperatures/vs/0 fallback helper in climate.py (PR #36).
`_temps_vs_item` is a pure module-level function (plain dict in, plain dict
out) with no coordinator/entity dependency, so -- like select.py's `_display`
in test_select_display.py -- it's testable directly even though the
LocalThingsClimate entity itself needs a coordinator/hass fixture to
exercise. This covers the actual fallback-selection logic that the
registry-level "no unbound hrefs" tests in test_airconditioner_capabilities.py
don't reach: TP1X-class firmware has no OCF-standard
/temperature/current+desired pair, so current_temperature/target_temperature
would silently be None without this fallback.
"""
from custom_components.localthings.climate import _temps_vs_item
def test_temps_vs_item_returns_first_item():
rep = {
'x.com.samsung.da.items': [
{'x.com.samsung.da.current': '17.0', 'x.com.samsung.da.desired': '25.0'},
],
}
assert _temps_vs_item(rep) == {
'x.com.samsung.da.current': '17.0', 'x.com.samsung.da.desired': '25.0',
}
def test_temps_vs_item_empty_when_items_missing():
"""OCF-pair devices don't populate /temperatures/vs/0's items at all --
callers must fall through to the OCF hrefs cleanly, not raise."""
assert _temps_vs_item({}) == {}
def test_temps_vs_item_empty_when_items_is_empty_list():
assert _temps_vs_item({'x.com.samsung.da.items': []}) == {}
def test_temps_vs_item_empty_when_items_entry_is_not_a_dict():
assert _temps_vs_item({'x.com.samsung.da.items': ['not-a-dict']}) == {}
+319 -11
View File
@@ -4,28 +4,336 @@ from custom_components.localthings.registry.discovery import discover
def _reg():
return {c.href: [c] for c in (
common.KIDS_LOCK, common.REMOTE_CONTROL, common.POWER,
common.KIDS_LOCK_GENERIC, common.KIDS_LOCK_VS_FALLBACK,
common.REMOTE_CONTROL_GENERIC, common.REMOTE_CONTROL_VS_FALLBACK,
common.POWER_GENERIC, common.POWER_VS_FALLBACK,
common.ALARMS, common.ENERGY_METER, common.WATER_METER,
common.WATER_FILTER,
)}
def test_kids_lock_binary_value_fn():
desc = common.KIDS_LOCK.entities[0]
def test_kids_lock_vs_value_fn():
desc = common.KIDS_LOCK_VS_FALLBACK.entities[0]
assert desc.value_fn('Lock') is True
assert desc.value_fn('Ready') is False
def test_energy_clamps_negative_power(fridge_resources):
# instantaneousPower can read negative at idle; must clamp to 0.
pw = next(e for e in common.ENERGY_METER.entities if e.key == 'power_watts')
assert pw.value_fn(-500.0) == 0.0
assert pw.value_fn(93.0) == 93.0
def test_common_caps_discover_on_dishwasher(dishwasher_resources):
bound = discover(dishwasher_resources, _reg())
keys = {b.desc.key for b in bound}
assert 'child_lock' in keys
assert 'remote_control' in keys
assert 'power_watts' in keys
assert 'power_switch' in keys
# ---------------------------------------------------------------------------
# OCF-native / vendor '-vs' fallback pairs (power, kids-lock, remote control).
# ---------------------------------------------------------------------------
class TestPowerFallback:
def test_generic_href_read_write(self):
assert common.POWER_GENERIC.href == '/power/0'
desc = common.POWER_GENERIC.entities[0]
assert desc.value_fn(True) is True
assert desc.value_fn(False) is False
path, body = desc.write_fn('On', {})
assert path == ['power', '0']
assert body == {'value': True}
assert desc.write_fn('Off', {})[1] == {'value': False}
def test_vs_fallback_binds_only_when_generic_absent(self):
assert common.POWER_VS_FALLBACK.href == '/power/vs/0'
assert common.POWER_VS_FALLBACK.match_fn({}, {'/power/vs/0': {}}) is True
assert common.POWER_VS_FALLBACK.match_fn(
{}, {'/power/0': {}, '/power/vs/0': {}}) is False
def test_vs_fallback_read_write(self):
desc = common.POWER_VS_FALLBACK.entities[0]
assert desc.value_fn('On') is True
assert desc.value_fn('Off') is False
path, body = desc.write_fn('On', {})
assert path == ['power', 'vs', '0']
assert body == {'x.com.samsung.da.power': 'On'}
class TestKidsLockFallback:
def test_generic_read_write(self):
assert common.KIDS_LOCK_GENERIC.href == '/kidslock/0'
desc = common.KIDS_LOCK_GENERIC.entities[0]
assert desc.value_fn(True) is True
path, body = desc.write_fn('On', {})
assert path == ['kidslock', '0']
assert body == {'value': True}
def test_vs_fallback_gated(self):
assert common.KIDS_LOCK_VS_FALLBACK.match_fn({}, {'/kidslock/vs/0': {}}) is True
assert common.KIDS_LOCK_VS_FALLBACK.match_fn(
{}, {'/kidslock/0': {}, '/kidslock/vs/0': {}}) is False
class TestRemoteControlFallback:
def test_generic_read(self):
assert common.REMOTE_CONTROL_GENERIC.href == '/remotectrl/0'
desc = common.REMOTE_CONTROL_GENERIC.entities[0]
assert desc.value_fn(True) is True
assert desc.value_fn(False) is False
def test_vs_fallback_gated(self):
assert common.REMOTE_CONTROL_VS_FALLBACK.match_fn({}, {'/remotectrl/vs/0': {}}) is True
assert common.REMOTE_CONTROL_VS_FALLBACK.match_fn(
{}, {'/remotectrl/0': {}, '/remotectrl/vs/0': {}}) is False
def test_polled_warm_so_write_gating_stays_fresh(self):
"""coordinator.async_send_command blocks writes on this signal, so
it can't sit in the default 'cold' tier (refreshed only once per
30s summary poll) -- it needs the subscribe/subpoll cadence 'warm'
and 'hot' hrefs get instead."""
assert common.REMOTE_CONTROL_GENERIC.poll_tier == 'warm'
assert common.REMOTE_CONTROL_VS_FALLBACK.poll_tier == 'warm'
# ---------------------------------------------------------------------------
# Energy meter. instantaneousPower clamps negatives to 0, but the constant
# '-500' sentinel (a dead field on DA_WM_ laundry + dishwasher dumps, issue #6)
# gates power_watts out entirely so it doesn't read as a real idle "0 W".
# ---------------------------------------------------------------------------
class TestEnergyMeter:
def test_href(self):
assert common.ENERGY_METER.href == '/energy/consumption/vs/0'
def test_power_clamps_negative(self):
pw = next(e for e in common.ENERGY_METER.entities if e.key == 'power_watts')
assert pw.value_fn(-500.0) == 0.0
assert pw.value_fn(93.0) == 93.0
def test_power_watts_hidden_for_dead_sentinel(self):
pw = next(e for e in common.ENERGY_METER.entities if e.key == 'power_watts')
assert pw.exists_fn({'x.com.samsung.da.instantaneousPower': '-500'}, {}) is False
def test_power_watts_shown_for_real_value(self):
pw = next(e for e in common.ENERGY_METER.entities if e.key == 'power_watts')
assert pw.exists_fn({'x.com.samsung.da.instantaneousPower': '150'}, {}) is True
def test_energy_kwh_hidden_when_cumulative_power_absent(self):
kwh = next(e for e in common.ENERGY_METER.entities if e.key == 'energy_kwh')
assert kwh.exists_fn({'x.com.samsung.da.instantaneousPower': '-500'}, {}) is False
def test_energy_kwh_shown_when_present(self):
kwh = next(e for e in common.ENERGY_METER.entities if e.key == 'energy_kwh')
assert kwh.exists_fn({'x.com.samsung.da.cumulativePower': '58900'}, {}) is True
def test_both_entities_included_on_empty_stub(self):
"""An empty {} rep means the resource exists but data isn't fetched yet
(see entity._is_included) -- include both so sub-polls populate them."""
pw = next(e for e in common.ENERGY_METER.entities if e.key == 'power_watts')
kwh = next(e for e in common.ENERGY_METER.entities if e.key == 'energy_kwh')
assert pw.exists_fn({}, {}) is True
assert kwh.exists_fn({}, {}) is True
def test_power_watts_hidden_when_field_absent_in_populated_rep(self):
"""A populated rep that lacks instantaneousPower must not spawn a
phantom power sensor (the exists_fn replaces the field-presence gate)."""
pw = next(e for e in common.ENERGY_METER.entities if e.key == 'power_watts')
assert pw.exists_fn({'x.com.samsung.da.cumulativePower': '5'}, {}) is False
# ---------------------------------------------------------------------------
# AI energy-saving level. '0' is off; supportedAiLevel lists the additional
# level(s) on offer. A single-entry list (issue #21 fridge, issue #40 washer)
# is really a binary toggle, so it's exposed as a switch instead of a
# one-choice select; multiple entries get a select with '0' synthesized back
# in as the explicit off option (supportedAiLevel never lists '0' itself, but
# it's a real, observed value of aiLevel).
# ---------------------------------------------------------------------------
def _ai_energy_level_desc(cls_name):
return next(e for e in common.AI_ENERGY_LEVEL.entities
if e.__class__.__name__ == cls_name)
class TestAiEnergyLevelSwitch:
def _desc(self):
return _ai_energy_level_desc('SwitchDesc')
def test_href(self):
assert common.AI_ENERGY_LEVEL.href == '/energy/ailevel/vs/0'
def test_shown_only_with_single_supported_level(self):
desc = self._desc()
assert desc.exists_fn({'aiLevel': '1', 'supportedAiLevel': ['1']}, {}) is True
assert desc.exists_fn({'aiLevel': '1', 'supportedAiLevel': ['1', '2']}, {}) is False
def test_hidden_when_supported_level_is_non_list_scalar(self):
"""A stray scalar (e.g. a string) must not be len()-checked as if it
were a list -- a 2-char string would otherwise wrongly pass `== 1`
style checks."""
desc = self._desc()
assert desc.exists_fn({'aiLevel': '1', 'supportedAiLevel': '1'}, {}) is False
def test_hidden_when_supported_level_missing(self):
desc = self._desc()
assert desc.exists_fn({'aiLevel': '1'}, {}) is False
def test_hidden_when_supported_level_empty_list(self):
desc = self._desc()
assert desc.exists_fn({'aiLevel': '0', 'supportedAiLevel': []}, {}) is False
def test_hidden_on_empty_stub_rep(self):
desc = self._desc()
assert desc.exists_fn({}, {}) is False
def test_value_fn(self):
desc = self._desc()
assert desc.value_fn('0') is False
assert desc.value_fn('1') is True
def test_write_on_uses_the_single_supported_level(self):
"""The on-value is whatever the device calls its one level, not a
hardcoded '1'."""
desc = self._desc()
path, body = desc.write_fn('On', {'supportedAiLevel': ['2']})
assert path == ['energy', 'ailevel', 'vs', '0']
assert body == {'aiLevel': '2'}
def test_write_off(self):
desc = self._desc()
path, body = desc.write_fn('Off', {'supportedAiLevel': ['1']})
assert body == {'aiLevel': '0'}
class TestAiEnergyLevelSelect:
def _desc(self):
return _ai_energy_level_desc('SelectDesc')
def test_shown_only_with_multiple_supported_levels(self):
desc = self._desc()
assert desc.exists_fn({'aiLevel': '1', 'supportedAiLevel': ['1', '2']}, {}) is True
assert desc.exists_fn({'aiLevel': '1', 'supportedAiLevel': ['1']}, {}) is False
def test_hidden_when_supported_level_is_non_list_scalar(self):
desc = self._desc()
assert desc.exists_fn({'aiLevel': '1', 'supportedAiLevel': '12'}, {}) is False
def test_hidden_when_supported_level_missing(self):
desc = self._desc()
assert desc.exists_fn({'aiLevel': '1'}, {}) is False
def test_hidden_when_supported_level_empty_list(self):
desc = self._desc()
assert desc.exists_fn({'aiLevel': '0', 'supportedAiLevel': []}, {}) is False
def test_no_translation_key(self):
"""aiLevel's values are plain digits that render fine untranslated
(select.py's _display()) -- no strings.json entry to maintain
against an unknown number of future levels."""
desc = self._desc()
assert desc.translation_key is None
def test_options_synthesize_off(self):
"""'0' is never in supportedAiLevel but is a real, observed aiLevel
value -- synthesized back in as the explicit off option."""
desc = self._desc()
resources = {'/energy/ailevel/vs/0': {'supportedAiLevel': ['1', '2']}}
assert desc.options(resources) == ['0', '1', '2']
def test_options_empty_when_resource_missing(self):
desc = self._desc()
assert desc.options({}) == ['0']
def test_write(self):
desc = self._desc()
path, body = desc.write_fn('2', {})
assert path == ['energy', 'ailevel', 'vs', '0']
assert body == {'aiLevel': '2'}
class TestAiEnergyLevelStubDoesNotDecideThePlatform:
"""Issue found in review: entity *creation* runs once, against whatever
snapshot is current when platforms are set up (see
__init__.py's async_config_entry_first_refresh-before-forward-entry-setups
ordering), while flatten() re-evaluates exists_fn every poll against live
data. Both descriptors share key='ai_energy_level' (see adapter._key), so
if a stub carve-out let one of them win at setup time while the other
wins once real data lands, flatten() would feed the already-instantiated
entity a value shaped for the other platform (e.g. a bool into a Select
expecting a string option). Neither side gets a `not rep` carve-out, so
an unfetched stub can never win entity creation for either platform --
the entity simply doesn't appear until a reload happens with real data,
same as any other exists_fn-gated entity in this codebase that's unlucky
on first-poll timing, instead of appearing as the wrong widget type."""
def test_neither_widget_exists_on_empty_stub_rep(self):
switch = _ai_energy_level_desc('SwitchDesc')
select = _ai_energy_level_desc('SelectDesc')
assert switch.exists_fn({}, {}) is False
assert select.exists_fn({}, {}) is False
class TestSelfCheckError:
"""Self-check diagnostic error list -- surfaced on hardware that reports
x.com.samsung.da.error, joined into a single display string."""
def _desc(self):
return next(e for e in common.SELF_CHECK.entities if e.key == 'selfcheck_error')
def test_exists_when_field_present(self):
desc = self._desc()
assert desc.exists_fn({'x.com.samsung.da.error': ['DA_ERROR_NONE']}, {}) is True
def test_does_not_exist_when_field_absent(self):
desc = self._desc()
assert desc.exists_fn({'x.com.samsung.da.status': 'Ready'}, {}) is False
def test_exists_for_empty_stub_rep(self):
"""An empty {} rep is /device/0's not-yet-fetched-stub carve-out --
must be included-for-now, same as ENERGY_METER's fields."""
desc = self._desc()
assert desc.exists_fn({}, {}) is True
def test_value_joins_list(self):
desc = self._desc()
assert desc.value_fn(['E1', 'E2']) == 'E1, E2'
def test_value_passes_through_scalar(self):
desc = self._desc()
assert desc.value_fn('DA_ERROR_NONE') == 'DA_ERROR_NONE'
def test_value_none_for_empty_list(self):
"""An empty error list means no value to show -- None (unknown),
not an empty string."""
desc = self._desc()
assert desc.value_fn([]) is None
# ---------------------------------------------------------------------------
# Cross-family bundles (UNIVERSAL / POWER) -- unpacked into every by_type
# registry's _build([...]) call in place of the hand-duplicated capability
# lists that used to live there.
# ---------------------------------------------------------------------------
class TestUniversalAndPowerBundles:
def test_universal_contains_the_no_conflict_capabilities(self):
assert set(common.UNIVERSAL) == {
common.ALARMS, common.ENERGY_METER, common.FIRMWARE_UPDATE,
common.SELF_CHECK, common.AI_ENERGY_LEVEL,
common.KIDS_LOCK_GENERIC, common.KIDS_LOCK_VS_FALLBACK,
common.REMOTE_CONTROL_GENERIC, common.REMOTE_CONTROL_VS_FALLBACK,
}
def test_power_kept_separate_for_airconditioners_sake(self):
"""See common.POWER's own comment for why airconditioner opts out."""
assert set(common.POWER) == {common.POWER_GENERIC, common.POWER_VS_FALLBACK}
def test_no_overlap_between_the_two_bundles(self):
assert not (set(common.UNIVERSAL) & set(common.POWER))
def test_airconditioner_registry_does_not_include_power(self):
from custom_components.localthings.registry.by_type import airconditioner
bound_caps = {c for caps in airconditioner.REGISTRY.capabilities.values() for c in caps}
assert common.POWER_GENERIC not in bound_caps
assert common.POWER_VS_FALLBACK not in bound_caps
+40
View File
@@ -158,6 +158,46 @@ def test_discover_match_fn_decline_is_not_logged_as_gap():
assert seen == []
def test_discover_pattern_cap_reads_name_field():
"""A pattern cap's name_field normalizes a device-given instance name
(e.g. an ice maker's "CUBED_ICE") for use in the entity's display name,
independent of the href-derived key (issue #27)."""
cap = Capability(
href=None,
href_prefix='/icemaker/',
name_field='x.com.samsung.da.iceMaker.name',
entities=(BinarySensorDesc(key='enabled', field='x.com.samsung.da.iceMaker.state'),),
)
resources = {
'/icemaker/one/vs/0': {
'x.com.samsung.da.iceMaker.state': 'On',
'x.com.samsung.da.iceMaker.name': 'CUBED_ICE',
},
'/icemaker/two/vs/0': {
'x.com.samsung.da.iceMaker.state': 'On',
'x.com.samsung.da.iceMaker.name': 'ICE_BITES',
},
}
bound = discover(resources, {}, pattern_caps=[cap])
names = {b.href: b.instance_name for b in bound}
assert names == {
'/icemaker/one/vs/0': 'Cubed Ice',
'/icemaker/two/vs/0': 'Ice Bites',
}
def test_discover_pattern_cap_name_field_absent_leaves_instance_name_none():
cap = Capability(
href=None,
href_prefix='/icemaker/',
name_field='x.com.samsung.da.iceMaker.name',
entities=(BinarySensorDesc(key='enabled', field='x.com.samsung.da.iceMaker.state'),),
)
resources = {'/icemaker/one/vs/0': {'x.com.samsung.da.iceMaker.state': 'On'}}
bound = discover(resources, {}, pattern_caps=[cap])
assert bound[0].instance_name is None
def test_discover_rt_filter_gates_binding():
"""Cap with rt_filter must not bind a rep whose rt list does not match."""
oven_mode_cap = Capability(
+30 -32
View File
@@ -1,51 +1,49 @@
"""Tests for dishwasher-specific capabilities."""
from custom_components.localthings.registry.capabilities import dishwasher
"""Tests for dishwasher-specific capabilities.
The shared /course/vs/0 cycle-select machinery (parse_edit_course_list,
cycle_options, cycle_write) is tested in test_laundry_capabilities.py; here we
check the dishwasher wiring and its device-specific options.
"""
from custom_components.localthings.registry.capabilities import dishwasher, laundry
class TestCycleOptions:
def test_parses_edit_course_list(self):
raw = 'EditCourseList_0E07908683848D808E8F'
assert dishwasher._parse_edit_course_list(raw) == [
'0E', '07', '90', '86', '83', '84', '8D', '80', '8E', '8F',
]
def _cycle(self):
return next(e for e in dishwasher.CYCLE_OPTIONS.entities if e.key == 'cycle')
def test_parse_edit_course_list_handles_missing_or_malformed(self):
assert dishwasher._parse_edit_course_list(None) == []
assert dishwasher._parse_edit_course_list('') == []
assert dishwasher._parse_edit_course_list('no underscore') == []
def test_cycle_options_reads_live_edit_course_list(self):
resources = {
'/wm/editcourse/vs/0': {
'x.com.samsung.da.editCourseList': 'EditCourseList_0E9086',
},
}
assert dishwasher._cycle_options(resources) == ['0E', '90', '86']
def test_cycle_options_empty_when_resource_absent(self):
assert dishwasher._cycle_options({}) == []
def test_cycle_options_empty_when_resource_empty(self):
resources = {'/wm/editcourse/vs/0': {}}
assert dishwasher._cycle_options(resources) == []
def test_cycle_desc_uses_cycle_options_callable(self):
desc = next(e for e in dishwasher.CYCLE_OPTIONS.entities if e.key == 'cycle')
assert desc.options is dishwasher._cycle_options
def test_cycle_desc_uses_shared_cycle_options(self):
desc = self._cycle()
assert desc.options is laundry.cycle_options
assert desc.translation_key == 'dishwasher_cycle'
def test_exists_only_when_edit_course_list_is_live(self):
desc = next(e for e in dishwasher.CYCLE_OPTIONS.entities if e.key == 'cycle')
desc = self._cycle()
assert desc.exists_fn({}, {}) is False
assert desc.exists_fn({}, {'/wm/editcourse/vs/0': {}}) is False
live = {'/wm/editcourse/vs/0': {'x.com.samsung.da.editCourseList': 'EditCourseList_0E'}}
assert desc.exists_fn({}, live) is True
def test_cycle_write_uses_raw_code_directly(self):
desc = next(e for e in dishwasher.CYCLE_OPTIONS.entities if e.key == 'cycle')
desc = self._cycle()
rep = {'x.com.samsung.da.options': ['DeviceType_0001', 'Course_0E', 'GMT_04']}
path, body = desc.write_fn('90', rep)
assert path == ['course', 'vs', '0']
assert body == {
'x.com.samsung.da.options': ['DeviceType_0001', 'Course_90', 'GMT_04'],
}
class TestDishwasherOptions:
def test_storm_wash_read_and_write(self):
desc = next(e for e in dishwasher.CYCLE_OPTIONS.entities if e.key == 'storm_wash')
assert desc.rep_fn({'x.com.samsung.da.options': ['StormWashZone_On']}) is True
assert desc.rep_fn({'x.com.samsung.da.options': ['StormWashZone_Off']}) is False
path, body = desc.write_fn('Off', {'x.com.samsung.da.options': ['StormWashZone_On']})
assert path == ['course', 'vs', '0']
assert 'StormWashZone_Off' in body['x.com.samsung.da.options']
def test_auto_release_exists_only_when_field_present(self):
desc = next(e for e in dishwasher.CYCLE_OPTIONS.entities if e.key == 'auto_release_dry')
assert desc.exists_fn({'x.com.samsung.da.options': []}, {}) is False
assert desc.exists_fn(
{'x.com.samsung.da.options': ['AutoDoorRelease_On']}, {}) is True
+77
View File
@@ -0,0 +1,77 @@
"""Tests for dryer support and washer/dryer consistency (issue #14)."""
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import for_device_by_model
from custom_components.localthings.registry.capabilities import dryer, ignored, laundry
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
def _dryer():
resources = _load_device('dryer')
info = resources['/information/vs/0']
reg = for_device_by_model(
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'],
)
return reg, resources
def _state():
reg, resources = _dryer()
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
return flatten(bound, resources)
def test_dryer_model_resolves_to_dryer_registry():
reg, _ = _dryer()
assert reg is not None and reg.name == 'dryer'
def test_no_unbound_hrefs():
"""Every resource in the issue #14 dump binds or is ignored -- clears the
coverage-gap repair."""
reg, resources = _dryer()
unbound = []
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
assert unbound == []
def test_expected_entities_present():
state = _state()
for key in ('buzzer_sound', 'cycle', 'power_switch', 'child_lock',
'remote_control', 'dry_level', 'wrinkle_prevent', 'energy_kwh'):
assert key in state, key
def test_job_beginning_status_reads_current_status():
"""The dump carries x.com.samsung.da.currentStatus (not the old
jobBeginingStatus field the dryer descriptor used to read), so the sensor
is populated instead of blank."""
assert _state()['job_beginning_status'] == 'None' # the dump's currentStatus value
def test_power_watts_gated_for_dead_sentinel():
"""instantaneousPower is the dead '-500' sentinel on this dryer, so the
misleading 0 W power sensor is suppressed; cumulative energy still shows."""
state = _state()
assert 'power_watts' not in state
assert 'energy_kwh' in state
def test_course_bound_to_shared_course_vs_0():
"""Dryer course uses the shared /course/vs/0 cycle select with dryer_cycle
translations, consistent with washer/dishwasher."""
assert dryer.DRYER_COURSE.href == '/course/vs/0'
desc = next(e for e in dryer.DRYER_COURSE.entities if e.key == 'cycle')
assert desc.translation_key == 'dryer_cycle'
assert desc.options is laundry.cycle_options
rep = {'x.com.samsung.da.options': ['Course_16', 'GMT_02']}
assert desc.rep_fn(rep) == '16'
def test_st_dryercourse_is_ignored():
"""/st/dryercourse/vs/0 re-encodes the course exposed via /course/vs/0 and
is globally ignored -- the mirror of /st/washercourse/vs/0."""
ignored_hrefs = {c.href for c in ignored.IGNORED}
assert '/st/dryercourse/vs/0' in ignored_hrefs
assert '/st/washercourse/vs/0' in ignored_hrefs
+3 -1
View File
@@ -1,6 +1,6 @@
from custom_components.localthings.registry.entities import (
SensorDesc, BinarySensorDesc, SelectDesc, SwitchDesc, ButtonDesc,
NumberDesc, PLATFORM_OF,
NumberDesc, TimeDesc, ClimateDesc, PLATFORM_OF,
)
@@ -26,6 +26,8 @@ def test_platform_mapping_covers_all_subclasses():
assert PLATFORM_OF[SwitchDesc] == 'switch'
assert PLATFORM_OF[ButtonDesc] == 'button'
assert PLATFORM_OF[NumberDesc] == 'number'
assert PLATFORM_OF[TimeDesc] == 'time'
assert PLATFORM_OF[ClimateDesc] == 'climate'
def test_select_carries_options_and_write_fn():
+45
View File
@@ -0,0 +1,45 @@
"""Tests for LocalThingsEntity's display-name derivation
(custom_components/localthings/entity.py) -- the explicit-name,
device-given-instance-name, and href-derived fallbacks.
"""
from custom_components.localthings.entity import LocalThingsEntity
from custom_components.localthings.registry.capability import Capability
from custom_components.localthings.registry.discovery import BoundEntity
from custom_components.localthings.registry.entities import BinarySensorDesc
class _FakeCoordinator:
device_serial = 'TEST-SERIAL'
def __init__(self, last_resources=None):
self.last_resources = last_resources or {}
def _make_entity(desc, href='/x/vs/0', key_override=None, instance='', instance_name=None):
capability = Capability(href=href, entities=(desc,))
bound = BoundEntity(href=href, capability=capability, desc=desc,
instance=instance, key_override=key_override,
instance_name=instance_name)
return LocalThingsEntity(_FakeCoordinator(), bound)
def test_explicit_name_wins_over_everything():
desc = BinarySensorDesc(key='enabled', name='Explicit Name')
entity = _make_entity(desc, instance_name='Cubed Ice')
assert entity._attr_name == 'Explicit Name'
def test_instance_name_prefixes_the_derived_suffix():
"""Issue #27: an ice maker's device-given name ("Cubed Ice") replaces
the href-derived instance label ("Icemaker One") as the name prefix,
keeping the same entity-specific suffix."""
desc = BinarySensorDesc(key='enabled')
entity = _make_entity(desc, key_override='icemaker_one_enabled',
instance_name='Cubed Ice')
assert entity._attr_name == 'Cubed Ice Enabled'
def test_no_instance_name_falls_back_to_derived_state_key():
desc = BinarySensorDesc(key='enabled')
entity = _make_entity(desc, key_override='icemaker_one_enabled')
assert entity._attr_name == 'Icemaker One Enabled'
+156
View File
@@ -102,3 +102,159 @@ class TestRefrigerationFallback:
for key in ('rapid_fridge', 'rapid_freezing'):
desc = next(e for e in fridge.REFRIGERATION_FALLBACK.entities if e.key == key)
assert desc.exists_fn({}, {'/refrigeration/0': {}}) is True
class TestFlexZone:
"""Issue #32 (surfaced by #26/#27's TP2X/Bespoke dumps): the flex-zone
entry's prefix varies by fridge family (CV_TTYPE_RF9000A_ on RF9000-class,
CV_FDR_ on TP1X/Bespoke-class). The old hardcoded-prefix match left
CV_FDR_-family fridges with an always-unknown select, and a write would
have appended a duplicate flag instead of replacing the existing one."""
def test_reads_rf9000_prefix(self):
rep = {
'x.com.samsung.da.modes': ['CV_TTYPE_RF9000A_BEVERAGE', 'WATERFILTER_DISABLE'],
'x.com.samsung.da.supportedOptions': [
'CV_TTYPE_RF9000A_FREEZE', 'CV_TTYPE_RF9000A_BEVERAGE'],
}
assert fridge._flex_zone_current(rep) == 'CV_TTYPE_RF9000A_BEVERAGE'
def test_reads_cv_fdr_prefix(self):
rep = {
'x.com.samsung.da.modes': ['CVN_CONVERTIBLE_ZONE', 'CV_FDR_MEAT', 'WATERFILTER_ENABLE'],
'x.com.samsung.da.supportedOptions': [
'CV_FDR_WINE', 'CV_FDR_DELI', 'CV_FDR_BEVERAGE', 'CV_FDR_MEAT'],
}
assert fridge._flex_zone_current(rep) == 'CV_FDR_MEAT'
def test_write_replaces_rather_than_duplicates(self):
rep = {
'x.com.samsung.da.modes': ['CVN_CONVERTIBLE_ZONE', 'CV_FDR_MEAT', 'WATERFILTER_ENABLE'],
'x.com.samsung.da.supportedOptions': [
'CV_FDR_WINE', 'CV_FDR_DELI', 'CV_FDR_BEVERAGE', 'CV_FDR_MEAT'],
}
path, payload = fridge._flex_zone_write('CV_FDR_WINE', rep)
assert path == ['mode', 'vs', '0']
modes = payload['x.com.samsung.da.modes']
assert modes.count('CV_FDR_WINE') == 1
assert 'CV_FDR_MEAT' not in modes
assert 'CVN_CONVERTIBLE_ZONE' in modes and 'WATERFILTER_ENABLE' in modes
class TestTp1xNativeDuplicateResources:
"""The US TP1X_REF_21K publishes two native mirrors in addition to the
richer vendor resources. They must count as covered without producing
duplicate entities or guessing unverified write contracts."""
def test_duplicate_capabilities_have_no_entities(self):
assert fridge.DEFROST_DELAY_NATIVE_DUPLICATE.entities == ()
assert fridge.ICEMAKER_STATUS_NATIVE_DUPLICATE.entities == ()
def test_us_fixture_has_complete_coverage(self):
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import refrigerator
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
resources = _load_device('refrigerator_tp1x_ref_21k_us')
unbound = []
bound = discover(
resources,
refrigerator.REGISTRY.capabilities,
refrigerator.REGISTRY.pattern_capabilities,
log=unbound.append,
)
assert unbound == []
state = flatten(bound, resources)
assert state['defrost_delay'] is False
assert 'ice_maker_enabled' not in state
assert state['icemaker_one_enabled'] is True
assert state['icemaker_two_enabled'] is True
assert state['selfcheck_error'] == 'DA_ERROR_NONE'
class TestPantryZone:
"""Cool Select Zone pantry compartment on ARTIK051_REF_17K -- issue #20."""
def test_href(self):
assert fridge.PANTRY_ZONE.href == '/status/pantry/one/vs/0'
def test_write(self):
desc = fridge.PANTRY_ZONE.entities[0]
path, body = desc.write_fn('FDR_WINE', {})
assert path == ['status', 'pantry', 'one', 'vs', '0']
assert body == {'x.com.samsung.da.mode': 'FDR_WINE'}
class TestArtik051AndTp2xFixturesHaveCompleteCoverage:
"""issue #20 (ARTIK051_REF_17K) and #26 (TP2X_REF_20K) both triggered
the incomplete-capability-coverage repair; both must resolve to zero
unbound hrefs now that /diagnosis/vs/0 (dishwasher.DIAGNOSIS, reused for
fridges), /status/pantry/one/vs/0 (PANTRY_ZONE), and the OCF-native
defrost/icemaker mirrors are covered."""
def test_artik051_ref_17k(self):
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import refrigerator
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
resources = _load_device('refrigerator_artik051_ref_17k')
unbound = []
bound = discover(
resources,
refrigerator.REGISTRY.capabilities,
refrigerator.REGISTRY.pattern_capabilities,
log=unbound.append,
)
assert unbound == []
state = flatten(bound, resources)
assert state['pantry_zone_mode'] == 'FDR_DRINKS'
def test_tp2x_ref_20k(self):
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import refrigerator
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
resources = _load_device('refrigerator_tp2x_ref_20k')
unbound = []
bound = discover(
resources,
refrigerator.REGISTRY.capabilities,
refrigerator.REGISTRY.pattern_capabilities,
log=unbound.append,
)
assert unbound == []
state = flatten(bound, resources)
assert state['flex_zone_mode'] == 'CV_FDR_BEVERAGE'
class TestRefrigeratorAiEnergyLevelFixtureCoverage:
"""AI energy-saving level (common.AI_ENERGY_LEVEL, see
test_common_capabilities.py) is exercised end-to-end here against the
refrigerator fixture/registry.
refrigerator_device.json's supportedAiLevel was extended to two entries
(['1', '2']) specifically to exercise the select branch -- the real
captured TP1X_REF_21K_US dump only ever reports one entry (switch
branch), so the select is synthetic-fixture-only for now."""
def test_synthetic_fixture_has_complete_coverage(self):
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import refrigerator
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
resources = _load_device('refrigerator')
unbound = []
bound = discover(
resources,
refrigerator.REGISTRY.capabilities,
refrigerator.REGISTRY.pattern_capabilities,
log=unbound.append,
)
assert unbound == []
state = flatten(bound, resources)
assert state['ai_energy_level'] == '1'
+159
View File
@@ -57,6 +57,165 @@ def test_registry_reproduces_golden_state_keys_for_washer():
)
def test_registry_reproduces_golden_state_keys_for_dryer():
from tests.conftest import _load_device
resources = _load_device('dryer')
golden = json.loads((GOLDEN / 'dryer.json').read_text())
state_keys = _new_state_keys('dryer', resources)
assert set(state_keys) == set(golden['state_keys']), (
f"state_keys mismatch:\n"
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
)
def test_registry_reproduces_golden_state_keys_for_airconditioner():
from tests.conftest import _load_device
resources = _load_device('airconditioner')
golden = json.loads((GOLDEN / 'airconditioner.json').read_text())
state_keys = _new_state_keys('airconditioner', resources)
assert set(state_keys) == set(golden['state_keys']), (
f"state_keys mismatch:\n"
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
)
def test_registry_reproduces_golden_state_keys_for_tp1x_ref_21k_us():
from tests.conftest import _load_device
resources = _load_device('refrigerator_tp1x_ref_21k_us')
golden = json.loads(
(GOLDEN / 'refrigerator_tp1x_ref_21k_us.json').read_text()
)
state_keys = _new_state_keys('refrigerator_tp1x_ref_21k_us', resources)
assert set(state_keys) == set(golden['state_keys']), (
f"state_keys mismatch:\n"
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
)
def test_registry_reproduces_golden_state_keys_for_washer_flexwash():
"""FlexWash twin washers (WV-prefix consumer model, e.g. WV55M9600AW)
report no oneUiVersion and previously fell through for_device_by_model's
consumer-prefix map entirely -- issue #19."""
from tests.conftest import _load_device
resources = _load_device('washer_flexwash')
golden = json.loads((GOLDEN / 'washer_flexwash.json').read_text())
state_keys = _new_state_keys('washer_flexwash', resources)
assert set(state_keys) == set(golden['state_keys']), (
f"state_keys mismatch:\n"
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
)
def test_registry_reproduces_golden_state_keys_for_washer_dryer_combo():
"""Washer/dryer combo units carry a writable dryLevel field on
/washer/vs/0 itself, with no separate dryer resource -- issue #22."""
from tests.conftest import _load_device
resources = _load_device('washer_dryer_combo')
golden = json.loads((GOLDEN / 'washer_dryer_combo.json').read_text())
state_keys = _new_state_keys('washer_dryer_combo', resources)
assert set(state_keys) == set(golden['state_keys']), (
f"state_keys mismatch:\n"
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
)
def test_registry_reproduces_golden_state_keys_for_artik051_ref_17k():
"""ARTIK051_REF_17K's Cool Select Zone pantry compartment
(/status/pantry/one/vs/0) -- issue #20."""
from tests.conftest import _load_device
resources = _load_device('refrigerator_artik051_ref_17k')
golden = json.loads((GOLDEN / 'refrigerator_artik051_ref_17k.json').read_text())
state_keys = _new_state_keys('refrigerator_artik051_ref_17k', resources)
assert set(state_keys) == set(golden['state_keys']), (
f"state_keys mismatch:\n"
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
)
def test_registry_reproduces_golden_state_keys_for_tp2x_ref_20k():
"""TP2X_REF_20K -- CV_FDR_-prefixed flex zone (issue #32) plus the extra
energy fields (cumulativeConsumption/monthlyConsumption/
thismonthlyConsumption) surfaced by issue #26."""
from tests.conftest import _load_device
resources = _load_device('refrigerator_tp2x_ref_20k')
golden = json.loads((GOLDEN / 'refrigerator_tp2x_ref_20k.json').read_text())
state_keys = _new_state_keys('refrigerator_tp2x_ref_20k', resources)
assert set(state_keys) == set(golden['state_keys']), (
f"state_keys mismatch:\n"
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
)
def test_registry_reproduces_golden_state_keys_for_ac_tp1x_da_ac_rac_01011():
"""Newer AC firmware (Tizen Lite, oneUiVersion "7.0 Air conditioner"; model
TP1X_DA-AC-RAC-01011) reports temperature via the vendor /temperatures/vs/0
items[] resource and adds a /light/vs/0 display light, with extra vendor
housekeeping hrefs -- issue #17 for this model class (PR #36)."""
from tests.conftest import _load_device
resources = _load_device('airconditioner_tp1x_da_ac_rac_01011')
golden = json.loads(
(GOLDEN / 'airconditioner_tp1x_da_ac_rac_01011.json').read_text()
)
state_keys = _new_state_keys('airconditioner_tp1x_da_ac_rac_01011', resources)
assert set(state_keys) == set(golden['state_keys']), (
f"state_keys mismatch:\n"
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
)
def test_registry_reproduces_golden_state_keys_for_tp2x_rac_20k():
"""TP2X_RAC_20K (issue #37) -- reports no oneUiVersion; resolved via the
'_RAC_' modelNum token fallback in for_device_by_model."""
from tests.conftest import _load_device
resources = _load_device('airconditioner_tp2x_rac_20k')
golden = json.loads((GOLDEN / 'airconditioner_tp2x_rac_20k.json').read_text())
state_keys = _new_state_keys('airconditioner_tp2x_rac_20k', resources)
assert set(state_keys) == set(golden['state_keys']), (
f"state_keys mismatch:\n"
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
)
def test_registry_reproduces_golden_state_keys_for_tp1x_rac():
"""TP1X_DA-AC-RAC-01001_0000 (issue #38) -- fuller RAC board with display
light, self-check, mute-once, and a current-limit setting."""
from tests.conftest import _load_device
resources = _load_device('airconditioner_tp1x_rac')
golden = json.loads((GOLDEN / 'airconditioner_tp1x_rac.json').read_text())
state_keys = _new_state_keys('airconditioner_tp1x_rac', resources)
assert set(state_keys) == set(golden['state_keys']), (
f"state_keys mismatch:\n"
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
)
def test_registry_reproduces_golden_state_keys_for_range():
"""Range/cooktop-oven combo (model TP1X_DA-KS-RANGE-0102X, issue #44) --
reports no oneUiVersion; resolved via the '-RANGE-' modelNum token
fallback in for_device_by_model. Reuses the oven family's cavity/
setpoint/mode/operational-state capabilities and adds range.py's
per-burner capabilities for the 4 burners this dump reports."""
from tests.conftest import _load_device
resources = _load_device('range')
golden = json.loads((GOLDEN / 'range.json').read_text())
state_keys = _new_state_keys('range', resources)
assert set(state_keys) == set(golden['state_keys']), (
f"state_keys mismatch:\n"
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
)
def test_resources_from_batch_preferred_over_flat():
from tests.conftest import _resources_from_dump
dump = {
+95
View File
@@ -0,0 +1,95 @@
"""Tests for the shared laundry capabilities (washer/dryer/dishwasher)."""
from custom_components.localthings.registry.capabilities import laundry
class TestCourseHelpers:
def test_parses_edit_course_list(self):
raw = 'EditCourseList_1C1D211B1E29243328262722202325322F2E30662D8F96'
assert laundry.parse_edit_course_list(raw) == [
'1C', '1D', '21', '1B', '1E', '29', '24', '33', '28', '26', '27',
'22', '20', '23', '25', '32', '2F', '2E', '30', '66', '2D', '8F', '96',
]
def test_parse_edit_course_list_handles_missing_or_malformed(self):
assert laundry.parse_edit_course_list(None) == []
assert laundry.parse_edit_course_list('') == []
assert laundry.parse_edit_course_list('no underscore') == []
def test_cycle_options_reads_live_edit_course_list(self):
"""The device's own course list -- including a code ('65') never seen
on the primary dump -- is used as-is; there is no hardcoded table."""
resources = {
'/wm/editcourse/vs/0': {
'x.com.samsung.da.editCourseList': 'EditCourseList_651C',
},
}
assert laundry.cycle_options(resources) == ['65', '1C']
def test_cycle_options_empty_when_resource_absent_or_empty(self):
assert laundry.cycle_options({}) == []
assert laundry.cycle_options({'/wm/editcourse/vs/0': {}}) == []
def test_option_value(self):
opts = ['DeviceType_0167', 'Course_1C', 'GMT_04']
assert laundry.option_value(opts, 'Course') == '1C'
assert laundry.option_value(opts, 'Missing') is None
class TestCycleSelect:
def test_builds_labelled_cycle_select(self):
desc = laundry.cycle_select(translation_key='dryer_cycle', icon='mdi:tumble-dryer')
assert desc.key == 'cycle'
assert desc.translation_key == 'dryer_cycle'
assert desc.icon == 'mdi:tumble-dryer'
assert desc.options is laundry.cycle_options
def test_reads_raw_course_code_from_options(self):
desc = laundry.cycle_select(translation_key='dryer_cycle', icon='x')
rep = {'x.com.samsung.da.options': ['DeviceType_0167', 'Course_16', 'GMT_04']}
assert desc.rep_fn(rep) == '16'
assert desc.rep_fn({'x.com.samsung.da.options': ['GMT_04']}) is None
def test_exists_only_when_edit_course_list_is_live(self):
desc = laundry.cycle_select(translation_key='dryer_cycle', icon='x')
assert desc.exists_fn({}, {}) is False
assert desc.exists_fn({}, {'/wm/editcourse/vs/0': {}}) is False
live = {'/wm/editcourse/vs/0': {'x.com.samsung.da.editCourseList': 'EditCourseList_16'}}
assert desc.exists_fn({}, live) is True
def test_cycle_write_rmw_on_options(self):
desc = laundry.cycle_select(translation_key='dryer_cycle', icon='x')
rep = {'x.com.samsung.da.options': ['DeviceType_0167', 'Course_16', 'GMT_04']}
path, body = desc.write_fn('1D', rep)
assert path == ['course', 'vs', '0']
assert body == {
'x.com.samsung.da.options': ['DeviceType_0167', 'Course_1D', 'GMT_04'],
}
def test_cycle_write_noop_without_options(self):
desc = laundry.cycle_select(translation_key='dryer_cycle', icon='x')
assert desc.write_fn('1D', {}) is None
class TestBuzzerSound:
def test_href(self):
assert laundry.BUZZER_SOUND.href == '/buzzersound/vs/0'
def test_buzzer_sound_write(self):
desc = next(e for e in laundry.BUZZER_SOUND.entities if e.key == 'buzzer_sound')
assert desc.options_field == 'supportedBuzzerSound'
path, body = desc.write_fn('On', {})
assert path == ['buzzersound', 'vs', '0']
assert body == {'setBuzzerSound': 'On'}
def test_finish_sound_exists_only_when_supported(self):
desc = next(e for e in laundry.BUZZER_SOUND.entities if e.key == 'finish_sound')
assert desc.exists_fn({'setBuzzerSound': 'On'}, {}) is False
assert desc.exists_fn({'supportedFinishSound': ['FinishSound_1']}, {}) is True
class TestJobBeginningStatus:
def test_href_and_field(self):
assert laundry.JOB_BEGINNING_STATUS.href == '/wm/jobbeginingstatus/vs/0'
desc = laundry.JOB_BEGINNING_STATUS.entities[0]
assert desc.field == 'x.com.samsung.da.currentStatus'
assert desc.entity_category == 'diagnostic'
+40
View File
@@ -53,6 +53,46 @@ def test_oven_setpoint_rejects_missing_items():
assert desc.write_fn(200, {}) is None
# ---------------------------------------------------------------------------
# OVEN_SETPOINT — Fahrenheit bounds (issue #44 range dump reports
# unit="Fahrenheit"; bounds/step must track the live unit, not stay pinned
# to the Celsius defaults)
# ---------------------------------------------------------------------------
def _fahrenheit_rep(desired='0'):
return {'x.com.samsung.da.items': [{
'x.com.samsung.da.desired': desired,
'x.com.samsung.da.unit': 'Fahrenheit',
}]}
def test_oven_setpoint_write_uses_fahrenheit_bounds():
desc = oven.OVEN_SETPOINT.entities[0]
rep = _fahrenheit_rep()
# 350 is within F bounds (175-550) but above the C max (270) --
# confirms the write path isn't silently still clamping to Celsius.
_, body = desc.write_fn(350, rep)
assert body['x.com.samsung.da.items'][0]['x.com.samsung.da.desired'] == '350'
def test_oven_setpoint_rejects_out_of_range_fahrenheit():
desc = oven.OVEN_SETPOINT.entities[0]
rep = _fahrenheit_rep()
assert desc.write_fn(100, rep) is None # below F min (175)
assert desc.write_fn(600, rep) is None # above F max (550)
def test_oven_setpoint_native_bounds_track_live_unit():
desc = oven.OVEN_SETPOINT.entities[0]
celsius_rep = {'x.com.samsung.da.items': [{'x.com.samsung.da.unit': 'Celsius'}]}
assert desc.native_min_fn(celsius_rep) == 30.0
assert desc.native_max_fn(celsius_rep) == 270.0
fahrenheit_rep = _fahrenheit_rep()
assert desc.native_min_fn(fahrenheit_rep) == 175.0
assert desc.native_max_fn(fahrenheit_rep) == 550.0
assert desc.step_fn(fahrenheit_rep) == 5.0
# ---------------------------------------------------------------------------
# OVEN_MODE — SelectDesc with non-empty options
# ---------------------------------------------------------------------------
+84
View File
@@ -0,0 +1,84 @@
"""Tests for range/cooktop-oven combo support (issue #44)."""
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import for_device_by_model
from custom_components.localthings.registry.capabilities import range as range_caps
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
def _range():
resources = _load_device('range')
info = resources['/information/vs/0']
reg = for_device_by_model(
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'],
)
return reg, resources
def _state():
reg, resources = _range()
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
return flatten(bound, resources)
def test_range_model_resolves_to_range_registry():
reg, _ = _range()
assert reg is not None and reg.name == 'range'
def test_no_unbound_hrefs():
"""Every resource in the issue #44 dump binds or is ignored -- clears the
coverage-gap repair."""
reg, resources = _range()
unbound = []
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
assert unbound == []
def test_expected_entities_present():
state = _state()
for key in (
'power_switch', 'oven_setpoint', 'current_temp_c', 'oven_mode',
'machine_state', 'door_open', 'cloud_connected', 'cooktop_state',
'cooktop_safety_shutoff_enabled',
'burner_0_power_level', 'burner_0_state', 'burner_0_hot_surface',
'burner_3_power_level', 'burner_3_state', 'burner_3_hot_surface',
):
assert key in state, key
def test_unreported_burners_gated_out():
"""Dump reports numberOfBurners=4 (indices 0-3) -- burner slots 4/5 of
MAX_BURNERS must not appear as entities."""
state = _state()
for key in ('burner_4_power_level', 'burner_5_power_level'):
assert key not in state, key
def test_burner_power_level_write_is_read_modify_write():
desc = next(e for e in range_caps.COOKTOP_STATUS.entities
if e.key == 'burner_1_power_level')
rep = {'burnerList': [
{'burnerNumber': 0, 'powerLevel': '3'},
{'burnerNumber': 1, 'powerLevel': '0'},
]}
path, body = desc.write_fn('boost', rep)
assert path == ['cooktop', 'status', 'vs', '0']
burners = {b['burnerNumber']: b['powerLevel'] for b in body['burnerList']}
assert burners[1] == 'boost'
assert burners[0] == '3' # sibling burner untouched
def test_burner_power_level_write_rejects_missing_burner():
desc = next(e for e in range_caps.COOKTOP_STATUS.entities
if e.key == 'burner_2_power_level')
rep = {'burnerList': [{'burnerNumber': 0, 'powerLevel': '0'}]}
assert desc.write_fn('5', rep) is None
def test_burner_hot_surface_true_when_not_normal():
desc = next(e for e in range_caps.COOKTOP_STATUS.entities
if e.key == 'burner_0_hot_surface')
assert desc.value_fn([{'burnerNumber': 0, 'hotSurfaceState': 'hot'}]) is True
assert desc.value_fn([{'burnerNumber': 0, 'hotSurfaceState': 'normal'}]) is False
+1 -1
View File
@@ -36,7 +36,7 @@ def test_options_field_unaffected():
def test_callable_options_receives_full_resource_snapshot():
"""A callable options is handed the coordinator's full href->rep
snapshot, not just this entity's own href's rep -- needed for course
lists decoded from a sibling resource (see washer._cycle_options)."""
lists decoded from a sibling resource (see laundry.cycle_options)."""
calls = []
def _options_fn(resources):
+232 -132
View File
@@ -1,5 +1,10 @@
"""Tests for washer-specific capabilities."""
from custom_components.localthings.registry.capabilities import washer
"""Tests for washer-specific capabilities.
Shared laundry capabilities (cycle-select machinery, buzzer, job status) are
tested in test_laundry_capabilities.py; the OCF power/kids-lock/remote-control
fallback pairs and the energy meter in test_common_capabilities.py.
"""
from custom_components.localthings.registry.capabilities import laundry, washer
def _rep_by_href(cap, href):
@@ -35,6 +40,22 @@ class TestWasherSettings:
assert body == {'x.com.samsung.da.rinseCycles': '3'}
class TestDryLevel:
"""Washer/dryer combo units carry a writable dryLevel field on
/washer/vs/0 itself, self-gated off on plain washers -- issue #22."""
def test_exists_only_when_supported_dry_level_present(self):
desc = next(e for e in washer.WASHER_SETTINGS.entities if e.key == 'dry_level')
assert desc.exists_fn({'x.com.samsung.da.supportedDryLevel': ['None', '30']}, {}) is True
assert desc.exists_fn({}, {}) is False
def test_write(self):
desc = next(e for e in washer.WASHER_SETTINGS.entities if e.key == 'dry_level')
path, body = desc.write_fn('Cupboard', {})
assert path == ['washer', 'vs', '0']
assert body == {'x.com.samsung.da.dryLevel': 'Cupboard'}
class TestWasherCourse:
def test_href(self):
assert washer.WASHER_COURSE.href == '/course/vs/0'
@@ -57,7 +78,7 @@ class TestWasherCourse:
def test_cycle_desc_uses_cycle_options_callable(self):
desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle')
assert desc.options is washer._cycle_options
assert desc.options is laundry.cycle_options
def test_exists_only_when_edit_course_list_is_live(self):
"""No hardcoded course table is kept -- the selector only appears
@@ -79,39 +100,6 @@ class TestWasherCourse:
}
class TestCycleOptions:
def test_parses_edit_course_list(self):
raw = 'EditCourseList_1C1D211B1E29243328262722202325322F2E30662D8F96'
assert washer._parse_edit_course_list(raw) == [
'1C', '1D', '21', '1B', '1E', '29', '24', '33', '28', '26', '27',
'22', '20', '23', '25', '32', '2F', '2E', '30', '66', '2D', '8F', '96',
]
def test_parse_edit_course_list_handles_missing_or_malformed(self):
assert washer._parse_edit_course_list(None) == []
assert washer._parse_edit_course_list('') == []
assert washer._parse_edit_course_list('no underscore') == []
def test_cycle_options_reads_live_edit_course_list(self):
"""A different washer model's own course list -- including a code
('65') never seen on the primary dump -- is used as-is; there is
no hardcoded table to fall back to or reconcile against."""
resources = {
'/wm/editcourse/vs/0': {
'x.com.samsung.da.editCourseList': 'EditCourseList_651C',
},
}
assert washer._cycle_options(resources) == ['65', '1C']
def test_cycle_options_empty_when_resource_absent(self):
assert washer._cycle_options({}) == []
def test_cycle_options_empty_when_resource_empty(self):
"""The second known washer dump has /wm/editcourse/vs/0 == {}."""
resources = {'/wm/editcourse/vs/0': {}}
assert washer._cycle_options(resources) == []
class TestDrumClean:
def test_cycles_remaining(self):
"""DrumCleanProposal_40 - WashingTimes_3 == 37, matching a live
@@ -179,11 +167,32 @@ class TestDetergentSoftenerDosing:
return next(e for e in washer.WASHER_COURSE.entities if e.key == key)
def test_quantity_and_hardness_read(self):
"""The device reports the level un-padded ('3'), but the options and
translation keys are zero-padded supported codes ('03'); rep_fn
normalizes to the supported code so the value is a valid option
(issue #9 -- otherwise HA renders the select 'unknown')."""
rep = {'x.com.samsung.da.options': _DOSING_OPTIONS}
assert self._desc('detergent_quantity').rep_fn(rep) == '3'
assert self._desc('detergent_water_hardness').rep_fn(rep) == '2'
assert self._desc('softener_quantity').rep_fn(rep) == '3'
assert self._desc('softener_concentration').rep_fn(rep) == '2'
assert self._desc('detergent_quantity').rep_fn(rep) == '03'
assert self._desc('detergent_water_hardness').rep_fn(rep) == '02'
assert self._desc('softener_quantity').rep_fn(rep) == '03'
assert self._desc('softener_concentration').rep_fn(rep) == '02'
def test_current_value_is_a_valid_option_for_every_dosing_select(self):
"""The core regression: HA shows a select 'unknown' when current_option
is not in options. Each dosing select's value must be one of its own
options."""
rep = {'x.com.samsung.da.options': _DOSING_OPTIONS}
for key in ('detergent_quantity', 'detergent_water_hardness',
'softener_quantity', 'softener_concentration'):
desc = self._desc(key)
assert desc.rep_fn(rep) in desc.options(_DOSING_RESOURCES), key
def test_read_passes_through_when_no_supported_match(self):
"""A value with no matching supported code is returned as-is rather than
dropped, so an unexpected device stays visible instead of blank."""
rep = {'x.com.samsung.da.options': ['DetergentLevelCtrl_7',
'SupportedDetergentLevelCtrl_00010203']}
assert self._desc('detergent_quantity').rep_fn(rep) == '7'
def test_translation_keys(self):
"""detergent_quantity and softener_quantity share one translation_key
@@ -209,17 +218,21 @@ class TestDetergentSoftenerDosing:
assert desc.exists_fn({}, _DOSING_RESOURCES) is True
def test_quantity_write(self):
"""The UI selects a padded supported code ('01'); the write posts the
un-padded device code ('1'), mirroring how the device reports it."""
rep = {'x.com.samsung.da.options': list(_DOSING_OPTIONS)}
path, body = self._desc('detergent_quantity').write_fn('01', rep)
assert path == ['course', 'vs', '0']
assert 'DetergentLevelCtrl_01' in body['x.com.samsung.da.options']
assert 'DetergentLevelCtrl_1' in body['x.com.samsung.da.options']
assert 'DetergentLevelCtrl_3' not in body['x.com.samsung.da.options']
# untouched siblings survive the read-modify-write
assert 'SoftenerLevelCtrl_3' in body['x.com.samsung.da.options']
def test_hardness_write(self):
rep = {'x.com.samsung.da.options': list(_DOSING_OPTIONS)}
path, body = self._desc('softener_concentration').write_fn('03', rep)
assert path == ['course', 'vs', '0']
assert 'SoftenerLevel2Ctrl_03' in body['x.com.samsung.da.options']
assert 'SoftenerLevel2Ctrl_3' in body['x.com.samsung.da.options']
def test_low_reservoir_off_when_alarm_off(self):
rep = {'x.com.samsung.da.options': _DOSING_OPTIONS}
@@ -238,112 +251,199 @@ class TestDetergentSoftenerDosing:
assert self._desc('detergent_low').exists_fn(rep, {}) is True
class TestBuzzerSound:
def test_href(self):
assert washer.BUZZER_SOUND.href == '/buzzersound/vs/0'
class TestWashOptionToggles:
"""Bubble soak / pre-wash / intensive-wash switches, from the same
options[] array as the cycle select (issue #22 follow-up). Confirmed
On/Off shape from a dump with Bubble Soak toggled on in the app."""
def test_buzzer_sound_write(self):
desc = next(e for e in washer.BUZZER_SOUND.entities if e.key == 'buzzer_sound')
path, body = desc.write_fn('Volume_High', {})
assert path == ['buzzersound', 'vs', '0']
assert body == {'setBuzzerSound': 'Volume_High'}
@staticmethod
def _desc(key):
return next(e for e in washer.WASHER_COURSE.entities if e.key == key)
def test_finish_sound_exists_only_when_supported(self):
desc = next(e for e in washer.BUZZER_SOUND.entities if e.key == 'finish_sound')
assert desc.exists_fn({'setBuzzerSound': 'On'}, {}) is False
assert desc.exists_fn({'supportedFinishSound': ['FinishSound_1']}, {}) is True
@staticmethod
def _keys():
return ('bubble_soak', 'pre_wash', 'intensive')
def test_finish_sound_write(self):
desc = next(e for e in washer.BUZZER_SOUND.entities if e.key == 'finish_sound')
path, body = desc.write_fn('FinishSound_2', {})
assert path == ['buzzersound', 'vs', '0']
assert body == {'setFinishSound': 'FinishSound_2'}
@staticmethod
def _prefix(key):
return {'bubble_soak': 'BubbleSoak',
'pre_wash': 'PreWashSetting',
'intensive': 'IntensiveSetting'}[key]
def test_exists_only_when_field_present(self):
for key in self._keys():
desc = self._desc(key)
assert desc.exists_fn({'x.com.samsung.da.options': []}, {}) is False
prefix = self._prefix(key)
rep = {'x.com.samsung.da.options': [f'{prefix}_Off']}
assert desc.exists_fn(rep, {}) is True
def test_reads_off(self):
for key in self._keys():
prefix = self._prefix(key)
rep = {'x.com.samsung.da.options': [f'{prefix}_Off']}
assert self._desc(key).rep_fn(rep) is False
def test_reads_on(self):
for key in self._keys():
prefix = self._prefix(key)
rep = {'x.com.samsung.da.options': [f'{prefix}_On']}
assert self._desc(key).rep_fn(rep) is True
def test_write_on_and_off(self):
"""write_fn receives the same 'On'/'Off' string switch.py sends
(not a bool) -- covers a bug where an earlier `'On' if p else 'Off'`
implementation always wrote 'On', since any non-empty string
(including 'Off') is truthy."""
for key in self._keys():
prefix = self._prefix(key)
rep = {'x.com.samsung.da.options': [f'{prefix}_Off', 'GMT_02']}
path, body = self._desc(key).write_fn('On', rep)
assert path == ['course', 'vs', '0']
assert f'{prefix}_On' in body['x.com.samsung.da.options']
assert 'GMT_02' in body['x.com.samsung.da.options']
rep = {'x.com.samsung.da.options': [f'{prefix}_On']}
path, body = self._desc(key).write_fn('Off', rep)
assert f'{prefix}_Off' in body['x.com.samsung.da.options']
assert f'{prefix}_On' not in body['x.com.samsung.da.options']
def test_write_rejects_non_on_off_payload(self):
for key in self._keys():
prefix = self._prefix(key)
rep = {'x.com.samsung.da.options': [f'{prefix}_Off']}
assert self._desc(key).write_fn('bogus', rep) is None
class TestJobBeginningStatus:
def test_href_and_field(self):
assert washer.WASHER_JOB_BEGINNING_STATUS.href == '/wm/jobbeginingstatus/vs/0'
desc = washer.WASHER_JOB_BEGINNING_STATUS.entities[0]
assert desc.field == 'x.com.samsung.da.currentStatus'
# editCourseList and availability bitmaps from the reporter's issue #22
# follow-up dump (WD90T654DBN/S1, course '30' selected, Bubble Soak just
# turned on in the app): 24 courses, course '30' at position 1 reads 'F0'
# (available) on all three bitmaps; course '1C' at position 0 reads '00' on
# BubbleSoakSet (matching the app graying that control out for Eco 40-60).
_EDIT_COURSE_RESOURCES = {
'/wm/editcourse/vs/0': {
'x.com.samsung.da.editCourseList':
'EditCourseList_1C301E26361B1D1F253324322022232F212D272838393729',
},
}
_BUBBLE_SOAK_SET = 'BubbleSoakSet_00F000F000F000F0F0F0F00000F000F0F00000F000000000'
_PRE_WASH_AVAILABLE_SET = 'PreWashAvailableSet_F0F000F0F0F000F0F0F0F00000F0F0F0F00000F000000000'
_INTENSIVE_AVAILABLE_SET = 'IntensiveAvailableSet_F0F000F0F0F000F0F0F0F00000F0F0F0F00000F000000000'
class TestPowerFallback:
def test_generic_href(self):
assert washer.POWER_GENERIC.href == '/power/0'
class TestWashOptionToggleValidation:
"""validate_fn rejects turning a toggle on for a course whose byte in
its availability bitmap isn't 'F0', with a user-facing message switch.py
raises as ServiceValidationError -- distinct from write_fn's silent
no-op for a malformed payload."""
def test_generic_read_write(self):
desc = washer.POWER_GENERIC.entities[0]
assert desc.value_fn(True) is True
assert desc.value_fn(False) is False
path, body = desc.write_fn('On', {})
assert path == ['power', '0']
assert body == {'value': True}
path, body = desc.write_fn('Off', {})
assert body == {'value': False}
@staticmethod
def _desc(key):
return next(e for e in washer.WASHER_COURSE.entities if e.key == key)
def test_vs_fallback_binds_only_when_generic_absent(self):
assert washer.POWER_VS_FALLBACK.href == '/power/vs/0'
assert washer.POWER_VS_FALLBACK.match_fn({}, {'/power/vs/0': {}}) is True
assert washer.POWER_VS_FALLBACK.match_fn({}, {'/power/0': {}, '/power/vs/0': {}}) is False
def test_allowed_on_a_supported_course(self):
rep = {'x.com.samsung.da.options': ['Course_30', _BUBBLE_SOAK_SET]}
assert self._desc('bubble_soak').validate_fn(
'On', rep, _EDIT_COURSE_RESOURCES) is None
def test_vs_fallback_read_write(self):
desc = washer.POWER_VS_FALLBACK.entities[0]
assert desc.value_fn('On') is True
assert desc.value_fn('Off') is False
path, body = desc.write_fn('On', {})
assert path == ['power', 'vs', '0']
assert body == {'x.com.samsung.da.power': 'On'}
def test_rejected_on_an_unsupported_course(self):
rep = {'x.com.samsung.da.options': ['Course_1C', _BUBBLE_SOAK_SET]}
msg = self._desc('bubble_soak').validate_fn('On', rep, _EDIT_COURSE_RESOURCES)
assert msg == "Bubble soak isn't available on the selected cycle."
def test_pre_wash_and_intensive_use_their_own_availableset_field(self):
rep = {'x.com.samsung.da.options': ['Course_30', _PRE_WASH_AVAILABLE_SET]}
assert self._desc('pre_wash').validate_fn(
'On', rep, _EDIT_COURSE_RESOURCES) is None
rep = {'x.com.samsung.da.options': ['Course_30', _INTENSIVE_AVAILABLE_SET]}
assert self._desc('intensive').validate_fn(
'On', rep, _EDIT_COURSE_RESOURCES) is None
def test_turning_off_is_never_blocked(self):
rep = {'x.com.samsung.da.options': ['Course_1C', _BUBBLE_SOAK_SET]}
assert self._desc('bubble_soak').validate_fn(
'Off', rep, _EDIT_COURSE_RESOURCES) is None
def test_allows_write_when_course_unresolvable(self):
"""No editCourseList, no Course_ token, or a bitmap whose length
doesn't match editCourseList -- in every case, fail open rather than
block a write we can't actually verify."""
desc = self._desc('bubble_soak')
rep = {'x.com.samsung.da.options': ['Course_1C', _BUBBLE_SOAK_SET]}
assert desc.validate_fn('On', rep, {}) is None
rep = {'x.com.samsung.da.options': [_BUBBLE_SOAK_SET]}
assert desc.validate_fn('On', rep, _EDIT_COURSE_RESOURCES) is None
rep = {'x.com.samsung.da.options': ['Course_1C', 'BubbleSoakSet_00F0']}
assert desc.validate_fn('On', rep, _EDIT_COURSE_RESOURCES) is None
class TestKidsLockFallback:
def test_generic_read_write(self):
assert washer.KIDS_LOCK_GENERIC.href == '/kidslock/0'
desc = washer.KIDS_LOCK_GENERIC.entities[0]
assert desc.value_fn(True) is True
path, body = desc.write_fn('On', {})
assert path == ['kidslock', '0']
assert body == {'value': True}
class TestAiEnergyLevel:
"""Issue #40 -- /energy/ailevel/vs/0 was unbound on a plain washer.
def test_vs_fallback_gated(self):
assert washer.KIDS_LOCK_VS_FALLBACK.match_fn({}, {'/kidslock/vs/0': {}}) is True
assert washer.KIDS_LOCK_VS_FALLBACK.match_fn(
{}, {'/kidslock/0': {}, '/kidslock/vs/0': {}}) is False
The capability itself (common.AI_ENERGY_LEVEL) is tested in
test_common_capabilities.py; this just confirms it's wired into the
washer registry and that the fixture's single-entry supportedAiLevel
(['1'], matching the issue's dump) surfaces as a switch, not a select."""
def test_fixture_has_complete_coverage(self):
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import washer as washer_registry
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
resources = _load_device('washer')
unbound = []
bound = discover(
resources,
washer_registry.REGISTRY.capabilities,
washer_registry.REGISTRY.pattern_capabilities,
log=unbound.append,
)
assert unbound == []
state = flatten(bound, resources)
assert state['ai_energy_level'] is False # aiLevel '0' -- off
class TestRemoteControlFallback:
def test_generic_read(self):
assert washer.REMOTE_CONTROL_GENERIC.href == '/remotectrl/0'
desc = washer.REMOTE_CONTROL_GENERIC.entities[0]
assert desc.value_fn(True) is True
assert desc.value_fn(False) is False
class TestFlexWashAndComboFixturesHaveCompleteCoverage:
"""FlexWash (issue #19, previously unrecognized entirely) and
washer/dryer combo (issue #22, dry_level) dumps must both resolve to
zero unbound hrefs."""
def test_vs_fallback_gated(self):
assert washer.REMOTE_CONTROL_VS_FALLBACK.match_fn({}, {'/remotectrl/vs/0': {}}) is True
assert washer.REMOTE_CONTROL_VS_FALLBACK.match_fn(
{}, {'/remotectrl/0': {}, '/remotectrl/vs/0': {}}) is False
def test_flexwash(self):
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import washer as washer_registry
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
resources = _load_device('washer_flexwash')
unbound = []
bound = discover(
resources,
washer_registry.REGISTRY.capabilities,
washer_registry.REGISTRY.pattern_capabilities,
log=unbound.append,
)
assert unbound == []
state = flatten(bound, resources)
assert 'dry_level' not in state # plain washer -- no supportedDryLevel field
class TestWasherEnergyMeter:
"""Issue #6: instantaneousPower is a dead sentinel ('-500', unchanged
across off/idle-on/running-eco/running-fabrics states and across 3
physical devices) on every TP1-class washer dump collected so far;
cumulativePower is outright absent on at least one washer model."""
def test_dryer_combo(self):
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import washer as washer_registry
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
def test_href(self):
assert washer.WASHER_ENERGY_METER.href == '/energy/consumption/vs/0'
def test_power_watts_hidden_for_dead_sentinel(self):
desc = next(e for e in washer.WASHER_ENERGY_METER.entities if e.key == 'power_watts')
assert desc.exists_fn({'x.com.samsung.da.instantaneousPower': '-500'}, {}) is False
def test_power_watts_shown_for_real_value(self):
desc = next(e for e in washer.WASHER_ENERGY_METER.entities if e.key == 'power_watts')
assert desc.exists_fn({'x.com.samsung.da.instantaneousPower': '150'}, {}) is True
def test_energy_kwh_hidden_when_cumulative_power_absent(self):
desc = next(e for e in washer.WASHER_ENERGY_METER.entities if e.key == 'energy_kwh')
assert desc.exists_fn({'x.com.samsung.da.instantaneousPower': '-500'}, {}) is False
def test_energy_kwh_shown_when_present(self):
desc = next(e for e in washer.WASHER_ENERGY_METER.entities if e.key == 'energy_kwh')
assert desc.exists_fn({'x.com.samsung.da.cumulativePower': '58900'}, {}) is True
resources = _load_device('washer_dryer_combo')
unbound = []
bound = discover(
resources,
washer_registry.REGISTRY.capabilities,
washer_registry.REGISTRY.pattern_capabilities,
log=unbound.append,
)
assert unbound == []
state = flatten(bound, resources)
# the device's own "off" sentinel is the literal string 'None', not absence
assert state['dry_level'] == 'None'