Compare commits

...
67 Commits
Author SHA1 Message Date
Marc Billow 60f8fdf488 Merge pull request #61 from mbillow/claude/issue-9-optimistic-write-debug-k4fjdb
feat: add options flow to bypass the remote-control write block per device (issue #54)
2026-07-23 22:40:51 -05:00
Marc Billow dccd6e1209 fix: correct backwards options-flow description wording
The bypass toggle is off by default and turned ON to allow writes with
remote control reported off, but the description said "only turn this
off if..." -- backwards from the actual control. Caught in Opus review.
2026-07-24 03:38:10 +00:00
Marc Billow 037dc9722f feat: add options flow to bypass the remote-control write block per device (issue #54)
The remote-control-off write block was device-wide and unconditional:
whenever a device reports remote control off, every write is rejected
with a user-facing error, on the assumption the device would reject it
anyway. Issue #54 reports a washer where that assumption doesn't hold --
default detergent/softener dosing writes through even with remote
control off, since they apply to the built-in programs too, not just a
custom remote-controlled cycle.

Add a per-device options flow (Settings > Devices & Services > this
device > Configure) with a single toggle, stored in entry.options (not
entry.data) so it doesn't affect the device's identity/unique_id.
coordinator.async_send_command reads it ahead of the existing
remote_control_enabled() check; defaults to False everywhere, so devices
that don't touch this option see no change in behavior.
2026-07-24 03:32:49 +00:00
Marc Billow dffe1ef455 Merge pull request #60 from mbillow/claude/issue-9-optimistic-write-debug-k4fjdb
fix: settle-window fixes for slow-settling, shared-href writes (issue #9)
2026-07-23 22:24:03 -05:00
Marc Billow 49bd4ef0cb Bump version to 0.11.0 2026-07-24 03:22:59 +00:00
Marc Billow f7faf56689 Bump version to 0.10.2 2026-07-24 03:21:16 +00:00
Marc Billow 85cca70b6b fix: don't let an in-progress settle window block a second write's own optimistic apply
Widening the settle window to tens of seconds (previous commit) made a
real gap much more likely to bite: apply() gated every source,
including 'optimistic', on _is_settling. /course/vs/0 backs several
independent washer selects (cycle, detergent quantity, softener
quantity, ...), so picking a second one while the first write's window
was still open -- routine within a ~43s window -- had its own
optimistic value silently dropped from the cache instead of shown,
recreating the exact "write doesn't seem to apply" symptom the guard
exists to prevent, just for whichever write lost the race.

Let source='optimistic' always bypass the gate; poll/sweep/observe
stay gated as before. mark_write_pending still re-arms the window
right after, so the newer write is protected going forward.
2026-07-24 03:14:58 +00:00
Marc Billow 020402c82f fix: size the write-settle window to outlast a slow-settling device (issue #9)
async_send_command's settle guard used DEFAULT_SETTLE_S's fixed few
seconds, which is long enough for fields that update instantly on the
device but not for ones that visibly take a few seconds of internal
validation or hardware movement to catch up. Issue #9's washer packs
cycle/detergent/softener selection into /course/vs/0's shared options[]
array, and that settling time regularly outlasted the fixed window --
same device, same integration, but /washer/vs/0's temperature/spin
fields (plain flags) confirmed instantly while these didn't. The short
window expired before the confirm poll (or the device itself) caught
up, so a stale read landed unprotected and reverted the optimistic
value, only to self-correct again once a later poll saw the real
change -- reading to the user as the write reverting and then
reapplying itself a few seconds later.

Size the window to always outlast the PUT and the confirming refresh's
poll combined, as issues #17/#53 also needed, but without that fix's
early-release mechanism (reverted previously for its own races around
overlapping writes) -- just hold the guard for the full window and let
it expire on its own.
2026-07-24 03:07:37 +00:00
Marc Billow cf18cc3ecc Merge pull request #59 from mbillow/claude/device-support-issue-56-shy2vg
Add Samsung air purifier support (ARTIK051_TVTL-class, issue #56)
2026-07-23 18:50:06 -05:00
Marc Billow 61e6035a36 Merge pull request #58 from mbillow/claude/climate-state-freshness-s5v2bk
fix: apply optimistic writes to their actual target href, not the bound href (issues #17, #53)
2026-07-23 18:47:48 -05:00
Marc Billow 7b155aabc2 Merge remote-tracking branch 'origin/main' into claude/device-support-issue-56-shy2vg
# Conflicts:
#	tests/test_golden_regression.py
2026-07-23 23:47:42 +00:00
Marc Billow 788b8f98a1 Merge pull request #57 from mbillow/claude/issue-55-triage-db1omk
Fix wall oven device-type detection (issue #55)
2026-07-23 18:44:57 -05:00
Marc Billow 83bd158049 fix: apply the optimistic write to its actual target href, not the bound href (issues #17, #53)
An independent review turned up the real bug behind the climate lag:
async_send_command applied the optimistic value and settle guard to
bound_entity.href, but write_fn's path_segs -- the resource actually
POSTed to -- can point somewhere else entirely. The AC's composite
climate entity is bound to /mode/vs/0, yet a power/temperature/fan/
swing/preset command writes to its own sibling resource (/power/0,
/temperature/desired/0, ...), which is also what climate.py reads the
displayed state from. The optimistic value landed on /mode/vs/0
instead, so the resource the entity actually shows stayed stale until
the next unrelated read of it -- surviving the earlier optimistic-apply
fix (issue #27), which applied to the same wrong href.

Derive the write's target from path_segs and apply/guard/log against
that instead. Reverts the previous commit's settle-window-sizing
change on this branch: that was chasing a real but speculative edge
case (a confirm poll slower than the settle window) that a follow-up
review couldn't confirm matches the reported symptom, and its
early-release mechanism had its own races (a debounced refresh that
hadn't actually run yet, overlapping writes to the same href) for
marginal benefit once this fix lands. Simpler to drop it than carry
that risk for a case not in evidence.

Added a coordinator-level regression test against the real AC CLIMATE
capability (not just a synthetic mismatched-path descriptor) sending a
power command and asserting the optimistic value lands on /power/0,
not /mode/vs/0.
2026-07-23 22:42:04 +00:00
Marc Billow 618443db7b Use generic names for shared start/stop/pause entities
The /operational/state/vs/0 href (and its start/pause/stop buttons and
"cycle active" sensor) is shared across the dryer/dishwasher/oven/washer
families, but the entity names hardcoded laundry vocabulary ("Start
cycle", "Cycle active") that doesn't fit an oven's bake/roast session.
Rename to generic "Start"/"Pause"/"Stop"/"Running".

Also fold oven.py's duplicate stop button into a single STOP_BUTTON
constant in operational.py -- both wrote the identical state='Ready'
RMW, so there was no reason for two copies to maintain.
2026-07-23 22:38:48 +00:00
Marc Billow 3ed277a33b Fix wall oven device-type detection (issue #55)
Ovens with modelNum like TP1X_DA-KS-OVEN-0107X report no oneUiVersion
and don't match any consumer-prefix or other fallback token in
for_device_by_model, so the device came back as "unknown" and every
resource fell through to the global capability registry instead of
oven.py's own -- explaining the unbound /connected/vs/0 href, the
unrelated-looking entities, and the non-functional controls reported
in the issue. Add a '-OVEN-' modelNum token fallback, mirroring the
existing '-RANGE-' fallback from issue #44.
2026-07-23 22:38:44 +00:00
Marc Billow 42a80fffe6 Simplify air purifier support by reusing existing helpers
Rewires /mode/vs/0's packed-options parsing (display_light/operating_mode/
blooming_level) onto laundry.py's existing option_value/replace_in_options/
bool_option_exists/bool_option_value instead of hand-rolled reimplementations,
hoists the duplicated int-conversion helper into common.py (shared by
range_hood.py too), collapses the five near-identical AIR_QUALITY sensors
into a table-driven loop, extracts the /consumable/vs/0 item lookup into a
named helper, and moves the humidity ignore list into capabilities/
air_purifier.py's own COVERAGE list to match the airconditioner/range_hood
convention of keeping by_type files as pure composition.

No behavior change; golden state keys and all existing tests are unaffected.
2026-07-23 22:36:47 +00:00
Marc Billow b121d24966 Add Samsung air purifier support (ARTIK051_TVTL-class, issue #56)
Adds a by_type registry for the AX60R5080WD/SE air purifier family, verified
against two independent diagnostics dumps (issue #56 and its comment). Binds
power, alarms, energy, diagnosis (reusing dishwasher.DIAGNOSIS), the dust/
fine-dust/super-fine-dust/odor/clean-level sensors off /sensors/vs/0, filter
progress, a device-active diagnostic, and a display-light switch parsed out
of /mode/vs/0's packed options list.

Fan speed/direction (/airflow/0, /airflow/vs/0) and two other /mode/vs/0
tokens (Comode_*, Blooming_*) are exposed as read-only diagnostics rather
than full controls -- neither dump has a supported-values list to confirm
their write contracts, so they're left for a follow-up once that's
clarified in the issue thread.

Hoists range_hood's items[]-sensor-value helper into common.py
(sensor_item_value) since air_purifier now reads the same /sensors/vs/0
shape.
2026-07-23 22:12:15 +00:00
Marc Billow 615c5d5aa9 fix: size the write-settle window to outlast its own confirm poll (issues #17, #53)
mark_write_pending's window was a fixed few seconds, started before the
PUT and the confirming /device/0 refresh that follows it -- but that
refresh is a full summary poll, which can legitimately take far longer
than that on these AC devices. The window routinely expired while the
confirm poll was still in flight, so a stale read (the device's own
resource tree hadn't caught up to the instant physical change yet)
landed unprotected and reverted the optimistic write, with nothing to
correct it again until the next scheduled summary poll. That's the
20-60s lag both issues report even after the earlier optimistic-apply
fix.

Size the window to cover the PUT and confirm-poll timeouts combined,
and release it as soon as that round trip actually completes (success
or failure) instead of leaving it open for the rest of a now much
longer window.
2026-07-23 22:11:26 +00:00
Marc Billow 30e814f242 Merge pull request #23 from themaanda/contrib/cooktop-range-hood
Add cooktop and range hood support
2026-07-22 22:41:10 -05:00
Marc Billow 13f1214790 Merge branch 'main' into contrib/cooktop-range-hood 2026-07-22 22:39:19 -05:00
Marc Billow 4d21f3b8f2 Merge pull request #51 from mbillow/claude/release-notes-version-bump-1obhfb
Bump version to 0.10.1
2026-07-22 22:31:42 -05:00
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
themaanda f7db568a43 Merge remote-tracking branch 'upstream/main' into contrib/cooktop-range-hood
# Conflicts:
#	custom_components/localthings/coordinator.py
#	custom_components/localthings/registry/by_type/__init__.py
2026-07-22 21:59:23 -05:00
themaanda 62caf769f2 Address cooktop and range hood review 2026-07-22 21:54:01 -05:00
themaanda e7daa21931 Merge remote-tracking branch 'upstream/main' into contrib/cooktop-range-hood
# Conflicts:
#	README.md
#	custom_components/localthings/registry/by_type/__init__.py
#	tests/test_by_type.py
#	tests/test_golden_regression.py
2026-07-22 21:53:45 -05: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
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
themaanda c64f58aaa5 Add cooktop and range hood support 2026-07-21 16:57:45 -05:00
82 changed files with 6883 additions and 317 deletions
+8 -3
View File
@@ -17,8 +17,12 @@ Your state stays on your LAN: HA talks to the appliance over a direct DTLS sessi
| Type | Registry |
|---|---|
| Air conditioner | `by_type/airconditioner.py` |
| Air purifier | `by_type/air_purifier.py` |
| Dryer | `by_type/dryer.py` |
| Oven | `by_type/oven.py` |
| Cooktop (read-only burner status) | `by_type/cooktop.py` |
| Range hood | `by_type/range_hood.py` |
| Range | `by_type/range.py` |
| Dishwasher | `by_type/dishwasher.py` |
| Refrigerator | `by_type/refrigerator.py` |
| Washer | `by_type/washer.py` |
@@ -100,7 +104,7 @@ custom_components/localthings/
diagnostics.py Redacted diagnostics download (device state + coverage metadata)
const.py Domain, config keys, probe ports
entity.py Base entity wiring capability registry -> HA entity
sensor.py / binary_sensor.py / switch.py / number.py / select.py / button.py / time.py / climate.py
sensor.py / binary_sensor.py / switch.py / number.py / select.py / button.py / time.py / fan.py / climate.py
One module per HA platform
strings.json / translations/ Config-flow copy + entity state translations
registry/
@@ -112,7 +116,8 @@ custom_components/localthings/
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)
cooktop, range_hood, 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
@@ -138,7 +143,7 @@ support for hardware the maintainers don't have.
1. Get a capture of the appliance's `/device/0` response. The easiest way: add the device to HA (type detection failing is fine) and pull its Diagnostics download from Settings > Devices & Services > the device > the menu > Download diagnostics — it already contains a redacted dump of the device's resources.
2. Reuse existing `Capability` objects from `registry/capabilities/` wherever the resource matches one already declared. Most `common.py` capabilities (power, kids lock, remote control, alarms, energy/water meters) are shared verbatim across families; add new ones only for resources unique to the new type.
3. Create `registry/by_type/<name>.py` with a `DeviceRegistry(name=..., capabilities=_build([...]))`. Use `pattern_capabilities` instead of `capabilities` for any resource whose `href` isn't fixed (for example per-compartment fridge resources); see `refrigerator.py` for the pattern.
4. Register it in `_REGISTRY_BY_KEY` in `registry/by_type/__init__.py`, keyed on the lowercased, space/hyphen-to-underscore-converted suffix of the device's `oneUiVersion` string (see `_type_key()` in that file for the exact transform). If the device never reports `oneUiVersion` — confirmed true for washers — add its consumer-model prefix to `_CONSUMER_PREFIX_TO_KEY` instead, so `for_device_by_model()` can route it.
4. Register it in `_REGISTRY_BY_KEY` in `registry/by_type/__init__.py`, keyed on the lowercased, space/hyphen-to-underscore-converted suffix of the device's `oneUiVersion` string (see `_type_key()` in that file for the exact transform). If the device never reports `oneUiVersion`, add its consumer-model prefix to `_CONSUMER_PREFIX_TO_KEY` so `for_device_by_model()` can route it. If it also omits `/information/vs/0` (as the verified NA9300K cooktop does), add a distinctive, conservative resource-signature rule to `for_device_by_resources()`.
5. Add golden-file coverage in `tests/` against a captured `/device/0` dump for the new type.
No config-flow changes are needed. Device-type detection and entity wiring are fully driven by the registry.
+45 -7
View File
@@ -13,9 +13,10 @@ snapshot via `coordinator.resource(href)` -- the same cross-resource read that
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.
`(path_segs, body)`, and `async_send_command` POSTs to those path_segs and
applies the optimistic value/settle guard to that same href -- not the bound
`/mode/vs/0` href -- so one descriptor drives writes to, and gets fresh state
back for, power, mode, temperature and wind resources alike.
"""
from __future__ import annotations
@@ -41,6 +42,7 @@ from .registry.capabilities.airconditioner import (
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,
@@ -125,6 +127,22 @@ def _num(value):
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."""
@@ -172,24 +190,41 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
# -- 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):
return _num(self._rep(TEMP_CURRENT_HREF).get('temperature'))
v = _num(self._rep(TEMP_CURRENT_HREF).get('temperature'))
if v is None:
v = _num(self._temps_vs().get('x.com.samsung.da.current'))
return v
@property
def target_temperature(self):
return _num(self._rep(TEMP_DESIRED_HREF).get('temperature'))
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')
return r if (isinstance(r, (list, tuple)) and len(r) == 2) else None
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:
@@ -203,7 +238,10 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
@property
def target_temperature_step(self) -> float:
return _num(self._rep(TEMP_CONTROL_HREF).get('increment')) or 1.0
return (_num(self._rep(TEMP_CONTROL_HREF).get('increment'))
or _num(self._rep(TEMP_CONTROL_HREF).get('x.com.samsung.da.increment'))
or _num(self._temps_vs().get('x.com.samsung.da.increment'))
or 1.0)
# -- hvac mode ----------------------------------------------------------
+41 -2
View File
@@ -12,6 +12,7 @@ from typing import Any
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.selector import (
TextSelector,
@@ -24,6 +25,7 @@ from .const import (
CONF_HOST, CONF_PORT,
CONF_CA_CERT_PEM, CONF_CA_KEY_PEM,
CONF_LEAF_CERT_PEM, CONF_LEAF_KEY_PEM,
CONF_BYPASS_REMOTE_CONTROL,
PROBE_PORT_RANGE, PREFERRED_PROBE_PORTS, LIVENESS_PROBE_TIMEOUT_S,
PROBE_GET_TIMEOUT_S,
)
@@ -195,7 +197,9 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict:
import cbor2
from smartthings_local.protocol.dtls_session import DtlsCoapSession
from .registry.batch import parse_device0_batch
from .registry.by_type import for_device, for_device_by_model
from .registry.by_type import (
for_device, for_device_by_model, for_device_by_resources,
)
_LOGGER.debug("Fetching Samsung cloud UUID from %s", _SAMSUNG_CLOUD_HOST)
try:
@@ -266,7 +270,7 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict:
) or for_device_by_model(
info_resource.get('x.com.samsung.da.modelNum', ''),
info_resource.get('x.com.samsung.da.description', ''),
)
) or for_device_by_resources(resources)
return {
"port": port,
"serial": serial,
@@ -298,6 +302,13 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self._ca_key_pem: str = ""
self._pending_info: dict | None = None
@staticmethod
@callback
def async_get_options_flow(
config_entry: config_entries.ConfigEntry,
) -> LocalThingsOptionsFlow:
return LocalThingsOptionsFlow()
def _create_entry(self, info: dict) -> FlowResult:
return self.async_create_entry(
title=f"Samsung Appliance ({self._host})",
@@ -382,3 +393,31 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"one_ui_version": self._pending_info["one_ui_version"] or "(none reported)",
},
)
class LocalThingsOptionsFlow(config_entries.OptionsFlow):
"""Per-device override of the remote-control-off write block (issue
#54). The block exists because most devices reject writes outright
while remote control is off and a clear error beats a silent
device-side rejection -- but not every model actually enforces that,
so this lets a user who's confirmed their device accepts writes anyway
turn the block off for just that device rather than it being
hardcoded on for everyone."""
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
if user_input is not None:
return self.async_create_entry(data=user_input)
return self.async_show_form(
step_id="init",
data_schema=vol.Schema({
vol.Required(
CONF_BYPASS_REMOTE_CONTROL,
default=self.config_entry.options.get(
CONF_BYPASS_REMOTE_CONTROL, False
),
): bool,
}),
)
+14 -1
View File
@@ -1,6 +1,9 @@
DOMAIN = "localthings"
PLATFORMS = ["sensor", "binary_sensor", "switch", "number", "select", "button", "time", "climate"]
PLATFORMS = [
"sensor", "binary_sensor", "switch", "number", "select", "button",
"time", "climate", "fan",
]
CONF_HOST = "host"
CONF_PORT = "port"
@@ -9,6 +12,16 @@ CONF_CA_KEY_PEM = "ca_key_pem"
CONF_LEAF_CERT_PEM = "leaf_cert_pem"
CONF_LEAF_KEY_PEM = "leaf_key_pem"
# Options-flow key (entry.options, not entry.data): lets a user override the
# device-wide remote-control-off write block for a specific device (issue
# #54). Some devices report remote control off yet still accept certain
# writes (e.g. default detergent/softener dosing on a washer, applied even
# to the built-in programs) -- the block exists to give a clear error
# instead of a silent device-side rejection, but that assumption doesn't
# hold for every model. Defaults to False (block stays on) everywhere it's
# read, so devices this doesn't apply to see no behavior change.
CONF_BYPASS_REMOTE_CONTROL = "bypass_remote_control_lock"
# 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
+103 -9
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
@@ -20,7 +21,8 @@ from smartthings_local.protocol.dtls_session import DtlsCoapSession
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.by_type import for_device, for_device_by_model, for_device_by_resources
from .registry.capabilities.common import remote_control_enabled
from .registry.discovery import discover, BoundEntity
from .registry import CAPABILITIES
from .registry.adapter import flatten
@@ -29,13 +31,19 @@ from .observe import ObserveManager, MODE_OBSERVE, MODE_POLL, GRACE_PERIOD_S
from .const import (
DOMAIN, CONF_HOST, CONF_PORT, CONF_LEAF_CERT_PEM, CONF_LEAF_KEY_PEM,
DEVICE_SUPPORT_ISSUE_URL, SUMMARY_INTERVAL_S,
CONF_BYPASS_REMOTE_CONTROL, DEVICE_SUPPORT_ISSUE_URL, SUMMARY_INTERVAL_S,
)
_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
@@ -69,6 +77,14 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# fine session shouldn't tear down a working OBSERVE subscription.
_POLL_TIMEOUT_LIMIT: int = 3
# Timeouts for the two network round trips a write triggers: the PUT
# itself (_do_put), then the confirming full /device/0 summary poll
# async_send_command requests right after (_poll_once). Named here
# (rather than left as inline literals) so the write-settle window
# below can be sized to always outlast both — see async_send_command.
_POST_TIMEOUT_S: float = 8.0
_POLL_TIMEOUT_S: float = 35.0
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
# Per-device logger (module logger scoped to this device's host) so
# every log line — including the base DataUpdateCoordinator's own
@@ -203,7 +219,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# would have succeeded, not a dead session. 35s gives a slow
# blockwise transfer room to actually finish instead of
# generating a TimeoutError every cycle.
code, payload = sess.get(_SEED_PATH, timeout=35.0)
code, payload = sess.get(_SEED_PATH, timeout=self._POLL_TIMEOUT_S)
except TimeoutError:
raise
except Exception as e:
@@ -287,6 +303,8 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
info.get('x.com.samsung.da.modelNum', ''),
info.get('x.com.samsung.da.description', ''),
)
if reg is None:
reg = for_device_by_resources(resources)
unbound: list[str] = []
if reg is not None:
self._log.debug("device type: %s (oneUiVersion=%r)", reg.name, one_ui)
@@ -306,7 +324,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self.device_serial = serial
ident = self._identity
device_type = reg.name.title() if reg else 'Appliance'
device_type = reg.name.replace('_', ' ').title() if reg else 'Appliance'
model_num = info.get('x.com.samsung.da.modelNum', '')
model = model_num.split('|', 1)[0] if model_num else (ident.model if ident else '')
name = f"Samsung {device_type} ({model})" if model else f"Samsung {device_type}"
@@ -509,30 +527,106 @@ 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 -- unless the user has
opted this device out of it via CONF_BYPASS_REMOTE_CONTROL (issue
#54: some devices accept certain writes, e.g. a washer's default
dosing levels, even while reporting remote control off, so the
block's assumption doesn't hold for every model)."""
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()
bypass_remote_control = self._entry.options.get(CONF_BYPASS_REMOTE_CONTROL, False)
if not bypass_remote_control and 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
# The write's actual target, not necessarily bound_entity.href. Most
# descriptors write to the same resource they're bound to, but a
# composite entity -- the AC's ClimateDesc, bound to /mode/vs/0 --
# drives writes to several sibling resources via path_segs
# (/power/0, /temperature/desired/0, /wind/strength/vs/0, ...) that
# write_fn picks per payload (see airconditioner._climate_write).
# Applying the optimistic value and settle guard below to
# bound_entity.href instead of this target protected the wrong
# resource: /mode/vs/0 got the (nonsensical, wrong-shaped) optimistic
# merge while the resource the climate entity actually displays from
# (e.g. /power/0) never got one, so HA kept showing the pre-write
# state until the next real read of that resource -- the 20-60s lag
# in issues #17/#53, which survived the earlier optimistic-apply fix
# (issue #27) because that fix applied to the wrong href too.
write_href = '/' + '/'.join(path_segs)
# 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).
#
# settle_s must outlast the PUT and the async_request_refresh()
# below combined, not just DEFAULT_SETTLE_S's fixed few seconds --
# that refresh is a full /device/0 summary poll, which
# _POLL_TIMEOUT_S itself admits can legitimately take tens of
# seconds on these devices (see _poll_once), and some writes settle
# on the device itself well after that: issue #9's washer packs
# cycle/detergent/softener selection into the same /course/vs/0
# options[] array, and picking a new value there visibly needs a
# few seconds of internal validation/dispenser movement before the
# device's own state agrees -- while /washer/vs/0's temperature/
# spin fields (plain flags, no device-side settling) confirm
# instantly on the same device. A short fixed window expired while
# the confirm poll was still in flight (or before the device had
# caught up internally), so that stale read landed unprotected and
# reverted the optimistic value, self-correcting again only once a
# later poll finally saw the real change -- read by the user as the
# write "reverting, then re-applying itself" a few seconds later.
#
# An earlier attempt at this also released the guard early, right
# after the confirming refresh completed, to avoid shutting out
# unrelated real updates (another automation, the physical remote)
# for the rest of settle_s. That was reverted: releasing the guard
# the moment one round trip finishes doesn't mean the device has
# actually caught up (exactly the slow-settling case above), and it
# introduced its own races around overlapping writes to the same
# href. Simpler and safer to just hold the guard for the full,
# generously-sized window and let it expire on its own.
self._observe.apply(write_href, body, source='optimistic')
self._observe.mark_write_pending(
write_href, settle_s=self._POST_TIMEOUT_S + self._POLL_TIMEOUT_S
)
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)
code, _ = sess.post(path_segs, cbor2.dumps(body), timeout=self._POST_TIMEOUT_S)
self._log.info("PUT %s → code %#04x", write_href, code)
try:
await self.hass.async_add_executor_job(_do_put)
except Exception as e:
self._log.error("command failed for %s: %s", href, e)
self._log.error("command failed for %s: %s", write_href, e)
else:
await self.async_request_refresh()
+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
+122
View File
@@ -0,0 +1,122 @@
"""Fan platform for Samsung range hoods."""
from __future__ import annotations
from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util.percentage import (
ordered_list_item_to_percentage,
percentage_to_ordered_list_item,
)
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
from .registry.entities import FanDesc
POWER_HREF = '/power/0'
POWER_VS_HREF = '/power/vs/0'
_FAN_SPEED_FIELD = 'x.com.samsung.da.hood.fanSpeed'
_SUPPORTED_FAN_SPEED_FIELD = 'x.com.samsung.da.hood.supportedFanSpeed'
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(
LocalThingsRangeHoodFan(coordinator, bound)
for bound in coordinator.bound
if isinstance(bound.desc, FanDesc) and _is_included(bound, coordinator)
)
class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
"""A hood fan combining sibling power and fan-speed resources."""
_enable_turn_on_off_backwards_compatibility = False
_attr_supported_features = (
FanEntityFeature.SET_SPEED
| FanEntityFeature.TURN_ON
| FanEntityFeature.TURN_OFF
)
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
super().__init__(coordinator, bound)
self._attr_name = None
def _rep(self, href: str) -> dict:
return self.coordinator.resource(href) or {}
def _all_speed_codes(self) -> list[str]:
rep = self._rep(self._bound.href)
return [str(value) for value in rep.get(_SUPPORTED_FAN_SPEED_FIELD, ())]
def _active_speed_codes(self) -> list[str]:
# Power is carried by the separate /power resource. fanSpeed retains
# the selected setting while power is off (as the lamp's `current`
# field does), so every advertised code is an active ordered speed.
return self._all_speed_codes()
def _power_payload(self, enabled: bool) -> tuple[str, bool, str]:
"""Target whichever power resource this hood actually exposes."""
resources = self.coordinator.last_resources
target = POWER_HREF if POWER_HREF in resources else POWER_VS_HREF
return 'power', enabled, target
@property
def is_on(self) -> bool:
rep = self._rep(POWER_HREF)
if 'value' in rep:
return bool(rep.get('value'))
return str(
self._rep(POWER_VS_HREF).get('x.com.samsung.da.power', '')
).lower() == 'on'
@property
def speed_count(self) -> int:
return len(self._active_speed_codes())
@property
def percentage(self) -> int | None:
if not self.is_on:
return 0
codes = self._active_speed_codes()
current = str(self._rep(self._bound.href).get(_FAN_SPEED_FIELD, ''))
if not codes or current not in codes:
return None
return ordered_list_item_to_percentage(codes, current)
async def async_turn_on(
self, percentage: int | None = None, preset_mode: str | None = None,
**kwargs,
) -> None:
await self.coordinator.async_send_command(
self._bound, self._power_payload(True),
)
if percentage is not None:
await self.async_set_percentage(percentage)
async def async_turn_off(self, **kwargs) -> None:
await self.coordinator.async_send_command(
self._bound, self._power_payload(False),
)
async def async_set_percentage(self, percentage: int) -> None:
if percentage <= 0:
await self.async_turn_off()
return
codes = self._active_speed_codes()
if not codes:
return
if not self.is_on:
await self.coordinator.async_send_command(
self._bound, self._power_payload(True),
)
code = percentage_to_ordered_list_item(codes, percentage)
await self.coordinator.async_send_command(self._bound, ('speed', code))
+1 -1
View File
@@ -12,5 +12,5 @@
"pyOpenSSL>=23.0",
"smartthings-local>=0.1.0"
],
"version": "0.8.0"
"version": "0.11.0"
}
+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)
+42 -3
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,49 @@ 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."""
if self._is_settling(href):
"""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.
`source == 'optimistic'` always bypasses the settle gate below,
even while this href is already settling from an earlier write.
Several independent selects can share one href -- issue #9's washer
packs cycle, detergent, and softener settings onto the same
/course/vs/0 -- so a second, different write to the same href
while the first's settle window is still open (up to
_POST_TIMEOUT_S + _POLL_TIMEOUT_S, tens of seconds -- see
coordinator.async_send_command) is an expected, normal sequence,
not a stale echo of the first write. Gating it the same as a
poll/sweep/observe update would silently drop the user's own
second selection from the cache until the first write's guard
happened to expire, i.e. the exact symptom this guard exists to
prevent, just relocated to whichever write loses the race.
"""
if source != 'optimistic' and self._is_settling(href):
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,17 +2,29 @@
from typing import Optional
from ._base import DeviceRegistry
from . import airconditioner, dishwasher, dryer, oven, refrigerator, washer
from . import (
air_purifier, airconditioner, cooktop, dishwasher, dryer, oven,
range as _range, range_hood, refrigerator, washer,
)
__all__ = ['DeviceRegistry', '_type_key', 'for_device', 'for_device_by_model']
__all__ = [
'DeviceRegistry', '_type_key', 'for_device', 'for_device_by_model',
'for_device_by_resources',
]
_REGISTRY_BY_KEY: dict[str, DeviceRegistry] = {
'air_purifier': air_purifier.REGISTRY,
'airpurifier': air_purifier.REGISTRY,
'airconditioner': airconditioner.REGISTRY,
'air_conditioner': airconditioner.REGISTRY,
'cooktop': cooktop.REGISTRY,
'dishwasher': dishwasher.REGISTRY,
'dryer': dryer.REGISTRY,
'oven': oven.REGISTRY,
'hood': range_hood.REGISTRY,
'range': _range.REGISTRY,
'range_hood': range_hood.REGISTRY,
'refrigerator': refrigerator.REGISTRY,
'washer': washer.REGISTRY,
}
@@ -96,4 +108,57 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi
# 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'
# Air purifiers (e.g. ARTIK051_TVTL_18K, issue #56) report no
# oneUiVersion either, and carry the '_TVTL_' board-family token.
if key is None and '_TVTL_' in (model_num or ''):
key = 'air_purifier'
model_identity = f'{model_num} {description}'.upper()
if key is None and ('_COOKTOP' in model_identity or '_GB_CT_' in model_identity):
key = 'cooktop'
if key is None and model_identity.startswith('AHD-'):
key = 'range_hood'
# Range/cooktop-oven combos (e.g. TP1X_DA-KS-RANGE-0102X, issue #44) --
# like the RAC/PRAC air conditioners above, these report no oneUiVersion
# and don't match the washer/dryer/dishwasher consumer-prefix map either.
if key is None and '-RANGE-' in (model_num or '').upper():
key = 'range'
# Wall ovens (e.g. TP1X_DA-KS-OVEN-0107X, issue #55) -- same board-family
# naming as the range combo above, minus the burners; also reports no
# oneUiVersion and doesn't match the washer/dryer/dishwasher prefix map.
if key is None and '-OVEN-' in (model_num or '').upper():
key = 'oven'
return _REGISTRY_BY_KEY.get(key) if key else None
def for_device_by_resources(resources: dict[str, dict]) -> Optional[DeviceRegistry]:
"""Detect a device family from a distinctive local-resource signature.
Some newer cooktops omit both ``oneUiVersion`` and
``/information/vs/0``. Their mode resource still identifies them: it
contains a DeviceType option and multiple per-burner OperationState
options. Require both shapes so an oven's unrelated ``/mode/vs/0`` is
not misclassified.
"""
mode = resources.get('/mode/vs/0', {})
options = mode.get('x.com.samsung.da.options') or ()
has_device_type = any(
isinstance(option, str) and option.startswith('DeviceType_')
for option in options
)
operation_states = sum(
1 for option in options
if isinstance(option, str) and option.startswith('OperationState')
)
if has_device_type and operation_states >= 2:
return _REGISTRY_BY_KEY['cooktop']
if (
'/hood/fanspeed/vs/0' in resources
and '/hood/lamp/vs/0' in resources
):
return _REGISTRY_BY_KEY['range_hood']
return None
@@ -0,0 +1,25 @@
"""Air-purifier device registry (Samsung ARTIK051_TVTL-class, issue #56).
Reports no oneUiVersion; resolved via for_device_by_model's '_TVTL_' modelNum
token (see registry.py). Reuses dishwasher.DIAGNOSIS for /diagnosis/vs/0
(identical field/write contract).
"""
from ..capabilities import air_purifier, common, dishwasher, ignored
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='air_purifier',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
dishwasher.DIAGNOSIS,
air_purifier.AIR_QUALITY,
air_purifier.FILTER,
air_purifier.DEVICE_ACTIVE,
air_purifier.AIRFLOW_GENERIC,
air_purifier.AIRFLOW_VS_FALLBACK,
air_purifier.MODE,
*air_purifier.COVERAGE,
]),
)
@@ -3,27 +3,28 @@
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 deliberately does NOT include the common POWER caps -- on/off is
the climate entity's HVACMode.OFF / TURN_ON/OFF.
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 common.ALARMS + common.ENERGY_METER, fridge.FIRMWARE_UPDATE (as every
registry does), and dishwasher.DIAGNOSIS for /diagnosis/vs/0.
Reuses dishwasher.DIAGNOSIS for /diagnosis/vs/0.
"""
from ..capabilities import airconditioner, common, dishwasher, fridge, ignored
from ..capabilities import airconditioner, common, dishwasher, ignored
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='airconditioner',
capabilities=_build([
*ignored.IGNORED,
common.ALARMS,
common.ENERGY_METER,
fridge.FIRMWARE_UPDATE,
*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,
]),
)
@@ -0,0 +1,17 @@
"""Cooktop device registry."""
from ..capabilities import common, cooktop, ignored
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='cooktop',
capabilities=_build([
*ignored.IGNORED,
cooktop.COOKTOP_POWER,
cooktop.COOKTOP_MODE,
cooktop.COOKTOP_CONNECTED,
cooktop.PAIRED_HOOD_STATUS,
common.FIRMWARE_UPDATE,
]),
)
@@ -1,19 +1,13 @@
"""Dishwasher device registry."""
from ..capabilities import common, dishwasher, 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_GENERIC,
common.POWER_VS_FALLBACK,
common.KIDS_LOCK_GENERIC,
common.KIDS_LOCK_VS_FALLBACK,
common.REMOTE_CONTROL_GENERIC,
common.REMOTE_CONTROL_VS_FALLBACK,
common.ALARMS,
common.ENERGY_METER,
*common.UNIVERSAL,
*common.POWER,
common.WATER_METER,
common.WATER_FILTER,
operational.OPERATIONAL_STATE,
@@ -25,6 +19,5 @@ REGISTRY = DeviceRegistry(
laundry.DOOR_LED,
laundry.SOUND_MODE,
laundry.SOUND_VOLUME,
fridge.FIRMWARE_UPDATE,
]),
)
@@ -9,21 +9,15 @@ 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_GENERIC,
common.POWER_VS_FALLBACK,
common.KIDS_LOCK_GENERIC,
common.KIDS_LOCK_VS_FALLBACK,
common.REMOTE_CONTROL_GENERIC,
common.REMOTE_CONTROL_VS_FALLBACK,
common.ALARMS,
common.ENERGY_METER,
*common.UNIVERSAL,
*common.POWER,
operational.OPERATIONAL_STATE,
laundry.DOOR_LED,
laundry.SOUND_MODE,
@@ -32,6 +26,5 @@ REGISTRY = DeviceRegistry(
dryer.DRYER_SETTINGS,
dryer.DRYER_COURSE,
dryer.DRYER_DIAGNOSIS,
fridge.FIRMWARE_UPDATE,
]),
)
@@ -1,24 +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_GENERIC,
common.POWER_VS_FALLBACK,
common.KIDS_LOCK_GENERIC,
common.KIDS_LOCK_VS_FALLBACK,
common.REMOTE_CONTROL_GENERIC,
common.REMOTE_CONTROL_VS_FALLBACK,
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,
]),
)
@@ -0,0 +1,22 @@
"""Range-hood device registry."""
from ..capabilities import common, ignored, range_hood
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='range_hood',
capabilities=_build([
*ignored.IGNORED,
range_hood.HOOD_ALARMS,
common.ENERGY_METER,
common.FIRMWARE_UPDATE,
range_hood.HOOD_FAN,
range_hood.HOOD_LAMP,
range_hood.HOOD_FILTER,
range_hood.AIR_QUALITY,
range_hood.AIR_LEVEL_CHECK,
range_hood.AUTO_VENTILATION,
*range_hood.COVERAGE,
]),
)
@@ -6,16 +6,10 @@ REGISTRY = DeviceRegistry(
name='refrigerator',
capabilities=_build([
*ignored.IGNORED,
common.POWER_GENERIC,
common.POWER_VS_FALLBACK,
*common.UNIVERSAL,
*common.POWER,
fridge.STATUS_LOCK,
fridge.DOOR_ALERT,
common.KIDS_LOCK_GENERIC,
common.KIDS_LOCK_VS_FALLBACK,
common.REMOTE_CONTROL_GENERIC,
common.REMOTE_CONTROL_VS_FALLBACK,
common.ALARMS,
common.ENERGY_METER,
common.WATER_FILTER,
dishwasher.DIAGNOSIS,
fridge.ICEMAKER_NIGHTTIME,
@@ -28,11 +22,9 @@ REGISTRY = DeviceRegistry(
fridge.SABBATH,
fridge.BEVERAGE_ZONE,
fridge.PANTRY_ZONE,
fridge.FIRMWARE_UPDATE,
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,
@@ -1,26 +1,19 @@
"""Washer device registry."""
from ..capabilities import common, dishwasher, fridge, ignored, laundry, operational, washer
from ..capabilities import common, dishwasher, ignored, laundry, operational, washer
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='washer',
capabilities=_build([
*ignored.IGNORED,
common.POWER_GENERIC,
common.POWER_VS_FALLBACK,
common.KIDS_LOCK_GENERIC,
common.KIDS_LOCK_VS_FALLBACK,
common.REMOTE_CONTROL_GENERIC,
common.REMOTE_CONTROL_VS_FALLBACK,
*common.UNIVERSAL,
*common.POWER,
washer.WASHER_SETTINGS,
washer.WASHER_COURSE,
laundry.BUZZER_SOUND,
laundry.JOB_BEGINNING_STATUS,
common.ALARMS,
common.ENERGY_METER,
common.WATER_METER,
operational.OPERATIONAL_STATE,
dishwasher.DIAGNOSIS,
fridge.FIRMWARE_UPDATE,
]),
)
@@ -1,4 +1,7 @@
from . import common, dishwasher, fridge, ignored, laundry, operational, oven
from . import (
common, cooktop, dishwasher, fridge, ignored, laundry, operational, oven,
range_hood,
)
from ..capability import Capability
@@ -0,0 +1,164 @@
"""Capabilities for the Samsung ARTIK051_TVTL-class air purifier family
(model AX60R5080WD/SE, issue #56 -- verified against two independent
diagnostics dumps of the same internal model).
Power, kids-lock, remote-control, alarms, and the energy meter are the shared
common.py capabilities (this family exposes the standard /power/0+/power/vs/0
pair and /alarms/vs/0, /energy/consumption/vs/0). /diagnosis/vs/0 reuses
dishwasher.DIAGNOSIS -- identical field/write contract
(x.com.samsung.da.diagnosisStart, 'Ready' on both dumps).
Two things are deliberately left as raw, unwritable diagnostic sensors rather
than modeled as real controls, per the "don't guess" rule:
/airflow/0, /airflow/vs/0 -- OCF-standard + vendor pair for fan speed/
direction, both zeroed/'Off' on every dump seen (device was off in both).
No supportedSpeed/supportedModes list is present anywhere in either dump
to confirm the valid range, so a write-capable fan/select isn't safe to
build yet -- see the issue #56 request for a running-state dump.
/mode/vs/0's x.com.samsung.da.options array packs multiple independent
'<Prefix>_<value>' flags into one list -- the same packed-list/RMW
contract laundry.py's option_value/replace_in_options already model for
/course/vs/0's options[] (reused directly below, just against this
family's own href). Of the tokens seen:
Light_On / Light_Off -- read as a plain on/off flag; MODE below
models it as a real switch, RMW-
replacing just that one list entry.
Comode_Off -- never seen non-'Off' on these dumps;
likely the fan operating mode the issue
describes (Auto/Sleep/1/2/3), but
unconfirmed -- exposed read-only.
Blooming_0 / Blooming_6 -- meaning unconfirmed; exposed read-only.
OptionCode_60282 -- opaque, unchanged across both dumps;
not modeled (same treatment as
range_hood's OptionCode_* token on the
same href).
"""
from ..capability import Capability
from ..entities import BinarySensorDesc, SensorDesc, SwitchDesc
from .common import int_or_none, sensor_item_value
from .laundry import bool_option_exists, bool_option_value, option_value, replace_in_options
_AIR_QUALITY_SENSORS = (
('dust', 'Dust', 'mdi:blur', 'Dust'),
('fine_dust', 'Fine dust', 'mdi:blur', 'FineDust'),
('super_fine_dust', 'Super fine dust', 'mdi:blur', 'SuperFineDust'),
('odor', 'Odor', 'mdi:scent', 'Odor'),
('clean_level', 'Clean level', 'mdi:air-filter', 'CleanLevel'),
)
AIR_QUALITY = Capability(
href='/sensors/vs/0',
poll_tier='warm',
entities=tuple(
SensorDesc(key=key, field='x.com.samsung.da.items', name=name, icon=icon,
value_fn=lambda items, t=sensor_type: sensor_item_value(items, t))
for key, name, icon, sensor_type in _AIR_QUALITY_SENSORS
),
)
def _consumable_state(items, name):
"""Read a `/consumable/vs/0`-style items[] entry -- {name, state} pairs,
unlike AIR_QUALITY's {type, value} shape above."""
for item in items or ():
if isinstance(item, dict) and item.get('x.com.samsung.da.name') == name:
return item.get('x.com.samsung.da.state')
return None
# FilterProgress is a raw 0-100 percentage in both dumps (100 and 62); which
# end of that scale means "replace me" isn't confirmed from the dump alone,
# so the entity is named after the raw field rather than asserting a
# direction (see issue #56 follow-up questions).
FILTER = Capability(
href='/consumable/vs/0',
poll_tier='cold',
entities=(
SensorDesc(key='filter_progress', field='x.com.samsung.da.items',
name='Filter progress', unit='%', state_class='measurement',
icon='mdi:air-filter', entity_category='diagnostic',
value_fn=lambda items: int_or_none(
_consumable_state(items, 'FilterProgress'))),
),
)
DEVICE_ACTIVE = Capability(
href='/devicespecificinfo/vs/0',
poll_tier='cold',
entities=(
BinarySensorDesc(key='device_active', field='x.com.samsung.da.deviceActive',
name='Device active', icon='mdi:check-network-outline',
entity_category='diagnostic',
value_fn=lambda v: bool(v)),
),
)
# OCF-native / vendor pair for fan speed+direction -- see module docstring for
# why these are read-only for now.
AIRFLOW_GENERIC = Capability(
href='/airflow/0',
poll_tier='warm',
entities=(
SensorDesc(key='fan_speed_level', field='speed',
name='Fan speed level', icon='mdi:fan',
state_class='measurement', entity_category='diagnostic'),
SensorDesc(key='fan_direction', field='direction',
name='Fan direction', icon='mdi:rotate-3d-variant',
entity_category='diagnostic'),
),
)
AIRFLOW_VS_FALLBACK = Capability(
href='/airflow/vs/0',
match_fn=lambda rep, resources: '/airflow/0' not in resources,
poll_tier='warm',
entities=(
SensorDesc(key='fan_speed_level', field='x.com.samsung.da.speedLevel',
name='Fan speed level', icon='mdi:fan',
state_class='measurement', entity_category='diagnostic',
value_fn=int_or_none),
SensorDesc(key='fan_direction', field='x.com.samsung.da.direction',
name='Fan direction', icon='mdi:rotate-3d-variant',
entity_category='diagnostic'),
),
)
def _light_write(payload, rep, href=None):
opts = list(rep.get('x.com.samsung.da.options') or [])
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': replace_in_options(opts, 'Light', payload),
}
MODE = Capability(
href='/mode/vs/0',
poll_tier='warm',
entities=(
SwitchDesc(key='display_light', name='Display light', icon='mdi:led-on',
entity_category='config',
rep_fn=bool_option_value('Light'),
exists_fn=bool_option_exists('Light'),
write_fn=_light_write),
# Read-only pending issue #56 follow-up -- see module docstring.
SensorDesc(key='operating_mode', name='Operating mode', icon='mdi:fan',
entity_category='diagnostic',
rep_fn=lambda rep: option_value(rep.get('x.com.samsung.da.options'), 'Comode'),
exists_fn=bool_option_exists('Comode')),
SensorDesc(key='blooming_level', name='Blooming level', icon='mdi:flower',
entity_category='diagnostic',
rep_fn=lambda rep: option_value(rep.get('x.com.samsung.da.options'), 'Blooming'),
exists_fn=bool_option_exists('Blooming')),
),
)
# /humidity/0 and /humidity/vs/0 are empty {} on both dumps this family has
# been verified against -- covered here (not globally, per ignored.py's
# module docstring) since those hrefs collide with fridge/AC schemas
# elsewhere. Same two hrefs and reasoning as airconditioner.py's _AC_IGNORED.
COVERAGE = [
Capability(href='/humidity/0'),
Capability(href='/humidity/vs/0'),
]
@@ -15,7 +15,7 @@ different schema (see capabilities/__init__.py). They live only in the AC
by_type registry.
"""
from ..capability import Capability
from ..entities import ClimateDesc, SensorDesc, SwitchDesc
from ..entities import BinarySensorDesc, ClimateDesc, SensorDesc, SwitchDesc
# ---------------------------------------------------------------------------
# Canonical AC resource hrefs. The climate entity (climate.py) binds the
@@ -34,10 +34,12 @@ 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_WIND_STRENGTH, HREF_WIND_DIRECTION, HREF_CONVENIENT,
HREF_TEMP_CONTROL, HREF_TEMPS_VS, HREF_WIND_STRENGTH, HREF_WIND_DIRECTION,
HREF_CONVENIENT,
]
@@ -146,6 +148,57 @@ AIR_FILTER = Capability(
),
)
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.
@@ -153,10 +206,18 @@ AIR_FILTER = Capability(
# (/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 = [
# Vendor superset that duplicates the OCF /temperature/current+desired pair.
'/temperatures/vs/0',
# 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',
@@ -164,7 +225,29 @@ _AC_IGNORED = [
'/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) for h in (CLIMATE_CONSUMED_HREFS + _AC_IGNORED)]
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):
@@ -21,6 +23,13 @@ def _num(v):
return None
def int_or_none(v):
try:
return int(v)
except (TypeError, ValueError):
return None
def clamp_power(v):
n = _num(v)
return 0.0 if (n is not None and n < 0) else n
@@ -56,6 +65,26 @@ def _active_alarm_codes(items):
return ', '.join(codes) if codes else 'none'
def sensor_item_value(items, sensor_type, index=0):
"""Pull one reading out of a `/sensors/vs/0`-style items[] list -- each
item is `{type, value: [...]}`; `index` picks which slot of a possibly
multi-value reading to read (index 0 is the raw measurement on every
family seen so far). Shared by range_hood.AIR_QUALITY and
air_purifier.AIR_QUALITY, which read the same resource shape."""
for item in items or ():
if not isinstance(item, dict):
continue
if item.get('x.com.samsung.da.type') != sensor_type:
continue
values = item.get('x.com.samsung.da.value') or ()
if index < len(values):
try:
return int(values[index])
except (TypeError, ValueError):
return None
return None
# OCF-native / vendor '-vs' fallback pairs for power, kids-lock, remote control.
#
# These three controls exist as both a standard OCF resource (/power/0,
@@ -117,8 +146,30 @@ KIDS_LOCK_VS_FALLBACK = 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',
@@ -129,6 +180,7 @@ REMOTE_CONTROL_GENERIC = Capability(
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',
@@ -231,3 +283,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,
)
@@ -0,0 +1,195 @@
"""Read-only capabilities for Samsung cooktops.
The first verified device is an NA9300K-class five-burner gas cooktop. Its
local OCF API reports burner state as strings embedded in the
``x.com.samsung.da.options`` array on ``/mode/vs/0``. Heat-producing controls
are intentionally not exposed: the local write contract is unverified and a
cooktop must not be remotely ignited by an automation.
"""
import re
from ..capability import Capability
from ..entities import BinarySensorDesc, SensorDesc
_INACTIVE_OPERATION_STATES = {'Off', 'Ready'}
def _option_value(options, prefix):
"""Return the value from the first ``<prefix>_<value>`` option."""
marker = prefix + '_'
for option in options or ():
if isinstance(option, str) and option.startswith(marker):
return option[len(marker):]
return None
def _operation_slots(options) -> tuple[int, ...]:
"""Return the numeric burner slots advertised in an options array."""
slots = set()
for option in options or ():
if not isinstance(option, str):
continue
match = re.match(r'^OperationState(\d+)_', option)
if match:
slots.add(int(match.group(1)))
return tuple(sorted(slots))
def _any_burner_active(options):
"""True when any advertised burner slot is not idle."""
states = [
_option_value(options, f'OperationState{slot}')
for slot in _operation_slots(options)
]
states = [state for state in states if state is not None]
return any(state not in _INACTIVE_OPERATION_STATES for state in states)
def _int_or_none(value):
try:
return int(value)
except (TypeError, ValueError):
return None
COOKTOP_POWER = Capability(
href='/power/vs/0',
poll_tier='hot',
entities=(
BinarySensorDesc(
key='power_state',
field='x.com.samsung.da.power',
name='Power state',
device_class='power',
icon='mdi:stove',
value_fn=lambda value: str(value).lower() == 'on',
),
),
)
# The verified NA9300K exposes physical slots 0, 1, 3, 4, and 5. Declare a
# generous static superset so variants with other layouts are not silently
# omitted; exists_fn hides every slot the live options array does not report.
# Replace this bound with data-driven entity generation when #31 lands.
_SUPPORTED_OPERATION_SLOTS = tuple(range(8))
COOKTOP_MODE = Capability(
href='/mode/vs/0',
poll_tier='hot',
entities=(
BinarySensorDesc(
key='any_burner_active',
field='x.com.samsung.da.options',
name='Any burner active',
device_class='running',
icon='mdi:fire',
value_fn=_any_burner_active,
),
*(
SensorDesc(
key=f'burner_{slot}_state',
field='x.com.samsung.da.options',
name=f'Burner {slot} state',
icon='mdi:gas-burner',
value_fn=lambda options, slot=slot: _option_value(
options, f'OperationState{slot}'
),
exists_fn=lambda rep, resources, slot=slot: (
not rep or _option_value(
rep.get('x.com.samsung.da.options'),
f'OperationState{slot}',
) is not None
),
)
for slot in _SUPPORTED_OPERATION_SLOTS
),
SensorDesc(
key='main_timer_state',
field='x.com.samsung.da.options',
name='Timer state',
icon='mdi:timer-outline',
value_fn=lambda options: _option_value(options, 'MainTimerState'),
),
SensorDesc(
key='main_timer_current',
field='x.com.samsung.da.options',
name='Timer current value',
icon='mdi:timer-sand',
enabled_default=False,
value_fn=lambda options: _int_or_none(
_option_value(options, 'MainTimerCurrent')
),
),
),
)
COOKTOP_CONNECTED = Capability(
href='/connected/vs/0',
poll_tier='warm',
entities=(
BinarySensorDesc(
key='cloud_connected',
field='x.com.samsung.da.connected',
name='Cloud connected',
device_class='connectivity',
entity_category='diagnostic',
value_fn=lambda value: str(value).lower() == 'on',
),
),
)
PAIRED_HOOD_STATUS = Capability(
href='/bluetooth/hood/status/vs/0',
poll_tier='hot',
entities=(
BinarySensorDesc(
key='paired_hood_connected',
field='connectionState',
name='Paired hood connected',
device_class='connectivity',
value_fn=lambda value: str(value).lower() == 'connected',
),
BinarySensorDesc(
key='paired_hood_power',
field='power',
name='Paired hood power',
device_class='running',
value_fn=lambda value: str(value).lower() == 'on',
),
SensorDesc(
key='paired_hood_fan_speed',
field='fanSpeed',
name='Paired hood fan speed',
icon='mdi:fan',
value_fn=_int_or_none,
),
BinarySensorDesc(
key='paired_hood_light',
field='lampState',
name='Paired hood light',
device_class='light',
value_fn=lambda value: str(value).lower() == 'on',
),
SensorDesc(
key='paired_hood_model',
field='micomModelId',
name='Paired hood model',
icon='mdi:information-outline',
entity_category='diagnostic',
enabled_default=False,
),
SensorDesc(
key='paired_hood_firmware',
field='firmwareVersion',
name='Paired hood firmware',
icon='mdi:chip',
entity_category='diagnostic',
enabled_default=False,
),
),
)
@@ -8,7 +8,7 @@ wash, auto release dry) are read locally here.
"""
from ..capability import Capability
from ..entities import ButtonDesc, SelectDesc, SensorDesc, SwitchDesc
from .laundry import cycle_select, option_value, replace_in_options
from .laundry import bool_option_switch, cycle_select
# ---------------------------------------------------------------------------
# /dishwasher/vs/0 — cycle wash/dry settings
@@ -35,49 +35,20 @@ DISHWASHER_SETTINGS = Capability(
# ---------------------------------------------------------------------------
# /course/vs/0 — cycle selection (shared laundry.cycle_select) plus the
# dishwasher-only StormWashZone / AutoDoorRelease toggles that ride in the
# same options array. Course display names live in translations under
# 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).
# ---------------------------------------------------------------------------
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=(
cycle_select(translation_key='dishwasher_cycle', icon='mdi:dishwasher'),
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),
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),
),
)
@@ -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',
@@ -228,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)
# ---------------------------------------------------------------------------
@@ -580,25 +565,6 @@ FLEX_ZONE = Capability(
),
)
# ---------------------------------------------------------------------------
# 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)
# ---------------------------------------------------------------------------
@@ -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.
@@ -217,6 +217,59 @@ def cycle_select(*, translation_key, icon):
)
# ---------------------------------------------------------------------------
# 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
@@ -70,6 +70,15 @@ def _finish_time(remaining_str):
return None
# Shared by dryer/dishwasher/oven/washer -- oven.py imports this directly
# rather than keeping its own copy, since both wrote the identical
# state='Ready' RMW.
STOP_BUTTON = ButtonDesc(key='stop', field='', name='Stop', payload='Ready',
icon='mdi:stop',
write_fn=lambda p, rep, href=None: (
['operational', 'state', 'vs', '0'],
{'x.com.samsung.da.state': p}))
OPERATIONAL_STATE = Capability(
href='/operational/state/vs/0',
poll_tier='hot',
@@ -83,8 +92,12 @@ OPERATIONAL_STATE = Capability(
# Harmless for non-oven appliances — just an extra bool in state.
# Samsung firmware keeps state='Run' after progress reaches 'Finish',
# so we also gate on progress to avoid a stuck 'Running' indication.
# Named 'Running' rather than 'Cycle active' -- this href (and the
# start/pause/stop buttons below) is shared across the dryer/
# dishwasher/oven/washer families, and 'cycle' is laundry-specific
# vocabulary that doesn't fit an oven's bake/roast/etc.
BinarySensorDesc(key='cycle_active', device_class='running',
name='Cycle active',
name='Running',
rep_fn=lambda rep: (
_SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) == 'active'
and rep.get('x.com.samsung.da.progress') != 'Finish'
@@ -119,20 +132,16 @@ OPERATIONAL_STATE = Capability(
write_fn=lambda p, rep, href=None: (
['operational', 'state', 'vs', '0'],
{_delay_field(rep): _format_delay(p)})),
ButtonDesc(key='start', field='', name='Start cycle', payload='Run',
ButtonDesc(key='start', field='', name='Start', payload='Run',
icon='mdi:play',
write_fn=lambda p, rep, href=None: (
['operational', 'state', 'vs', '0'],
{'x.com.samsung.da.state': p})),
ButtonDesc(key='pause', field='', name='Pause cycle', payload='Pause',
ButtonDesc(key='pause', field='', name='Pause', payload='Pause',
icon='mdi:pause',
write_fn=lambda p, rep, href=None: (
['operational', 'state', 'vs', '0'],
{'x.com.samsung.da.state': p})),
ButtonDesc(key='stop', field='', name='Stop cycle', payload='Ready',
icon='mdi:stop',
write_fn=lambda p, rep, href=None: (
['operational', 'state', 'vs', '0'],
{'x.com.samsung.da.state': p})),
STOP_BUTTON,
),
)
@@ -26,10 +26,10 @@ from datetime import datetime, timezone, timedelta
from ..capability import Capability
from ..entities import (
BinarySensorDesc, ButtonDesc, NumberDesc, SelectDesc, SensorDesc,
SwitchDesc,
BinarySensorDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc,
)
from .common import normalize_temp_unit
from .operational import STOP_BUTTON
# ---------------------------------------------------------------------------
# Constants
@@ -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:
@@ -156,12 +166,6 @@ def _cook_time_write(p, rep, href=None):
}
def _stop_write(p, rep, href=None):
return ['operational', 'state', 'vs', '0'], {
'x.com.samsung.da.state': 'Ready',
}
def _oven_mode_write(p, rep, href=None):
if p not in _OVEN_MODES:
return None
@@ -227,7 +231,7 @@ OVEN_OPERATIONAL_STATE = Capability(
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',
name='Running', device_class='running',
value_fn=lambda v: _SAMSUNG_STATE_TO_OCF.get(v) == 'active'),
SensorDesc(key='progress_percentage',
field='x.com.samsung.da.progressPercentage',
@@ -244,8 +248,7 @@ OVEN_OPERATIONAL_STATE = Capability(
name='Cook time', unit='min', native_min=0, native_max=1439,
step=1.0, icon='mdi:timer', value_fn=_op_minutes,
write_fn=_cook_time_write),
ButtonDesc(key='stop', field='', name='Stop cycle', icon='mdi:stop',
payload='Stop', write_fn=_stop_write),
STOP_BUTTON,
),
)
@@ -263,14 +266,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',
@@ -280,6 +293,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),
@@ -314,6 +330,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'),
),
)
@@ -0,0 +1,288 @@
"""Capabilities for Samsung AHD-WW-TP1-22 range hoods.
The verified device exposes its fan, two-level work lamp, washable-filter
status, and particulate sensors as distinct local OCF resources. Fan power
and speed are combined into one HA fan entity by ``fan.py``; lamp power and
brightness remain separate controls because the device advertises them as two
independent fields.
"""
from datetime import datetime, timezone
from ..capability import Capability
from ..entities import (
BinarySensorDesc,
FanDesc,
SelectDesc,
SensorDesc,
SwitchDesc,
)
from .common import int_or_none, sensor_item_value
def _timestamp(value):
try:
return datetime.fromtimestamp(float(value), tz=timezone.utc)
except (TypeError, ValueError, OSError):
return None
def _active_alarm_codes(items):
"""Discard the hood firmware's retained/deleted ``ErrorCode_OFF`` row.
Unlike ``common._alarm_codes``, the hood retains a deleted alarm row in
its live representation, so this family-specific helper also checks state.
"""
codes = []
for item in items or ():
if not isinstance(item, dict):
continue
if str(item.get('x.com.samsung.da.state', '')).lower() == 'deleted':
continue
code = item.get('x.com.samsung.da.code')
if code and str(code).lower() != 'errorcode_off':
codes.append(code)
return ', '.join(codes) if codes else 'none'
HOOD_ALARMS = Capability(
href='/alarms/vs/0',
poll_tier='hot',
entities=(
SensorDesc(
key='alarm_code',
field='x.com.samsung.da.items',
name='Alarm code',
icon='mdi:alert',
entity_category='diagnostic',
value_fn=_active_alarm_codes,
),
),
)
def _hood_fan_write(payload, rep, href=None):
kind, value, *args = payload
if kind == 'power':
power_href = args[0] if args else '/power/0'
if power_href == '/power/0':
return ['power', '0'], {'value': bool(value)}
if power_href == '/power/vs/0':
return ['power', 'vs', '0'], {
'x.com.samsung.da.power': 'On' if value else 'Off',
}
return None
if kind == 'speed':
value = str(value)
supported = [
str(code)
for code in rep.get('x.com.samsung.da.hood.supportedFanSpeed', ())
]
if value not in supported:
return None
return ['hood', 'fanspeed', 'vs', '0'], {
'x.com.samsung.da.hood.fanSpeed': value,
}
return None
HOOD_FAN = Capability(
href='/hood/fanspeed/vs/0',
poll_tier='hot',
entities=(
FanDesc(
key='fan',
field='x.com.samsung.da.hood.fanSpeed',
name=None,
write_fn=_hood_fan_write,
),
BinarySensorDesc(
key='automatic_operation',
field='x.com.samsung.da.hood.autoOperation',
name='Automatic operation',
icon='mdi:fan-auto',
entity_category='diagnostic',
value_fn=lambda value: str(value).lower() == 'on',
),
),
)
def _lamp_level_write(value, rep, href=None):
code = str(value)
supported = [str(level) for level in rep.get('x.com.samsung.lamp.range', ())]
if code not in supported:
return None
return ['hood', 'lamp', 'vs', '0'], {
'x.com.samsung.lamp.current': code,
}
HOOD_LAMP = Capability(
href='/hood/lamp/vs/0',
poll_tier='hot',
entities=(
SwitchDesc(
key='lamp',
field='x.com.samsung.lamp.power',
name='Lamp',
icon='mdi:range-hood',
value_fn=lambda value: str(value).lower() == 'on',
write_fn=lambda payload, rep, href=None: (
['hood', 'lamp', 'vs', '0'],
{'x.com.samsung.lamp.power': 'On' if payload == 'On' else 'Off'},
),
),
SelectDesc(
key='lamp_brightness',
field='x.com.samsung.lamp.current',
name='Lamp brightness',
icon='mdi:brightness-6',
translation_key='range_hood_lamp_brightness',
options_field='x.com.samsung.lamp.range',
write_fn=_lamp_level_write,
),
),
)
HOOD_FILTER = Capability(
href='/filter/hoodfilter/vs/0',
poll_tier='cold',
entities=(
SensorDesc(
key='hood_filter_usage',
field='x.com.samsung.da.filterUsage',
name='Filter usage',
unit='%',
state_class='measurement',
icon='mdi:air-filter',
entity_category='diagnostic',
value_fn=int_or_none,
),
SensorDesc(
key='hood_filter_status',
field='x.com.samsung.da.filterStatus',
name='Filter status',
icon='mdi:air-filter',
entity_category='diagnostic',
),
SensorDesc(
key='hood_filter_capacity',
field='x.com.samsung.da.filterCapacity',
name='Filter capacity',
unit='h',
icon='mdi:timer-outline',
entity_category='diagnostic',
enabled_default=False,
value_fn=int_or_none,
),
),
)
AIR_QUALITY = Capability(
href='/sensors/vs/0',
poll_tier='warm',
entities=(
SensorDesc(
key='clean_level',
field='x.com.samsung.da.items',
name='Clean level',
icon='mdi:air-filter',
value_fn=lambda items: sensor_item_value(items, 'CleanLevel'),
),
SensorDesc(
key='dust',
field='x.com.samsung.da.items',
name='Dust',
value_fn=lambda items: sensor_item_value(items, 'Dust'),
),
SensorDesc(
key='fine_dust',
field='x.com.samsung.da.items',
name='Fine dust',
value_fn=lambda items: sensor_item_value(items, 'FineDust'),
),
SensorDesc(
key='super_fine_dust',
field='x.com.samsung.da.items',
name='Super fine dust',
value_fn=lambda items: sensor_item_value(items, 'SuperFineDust'),
),
),
)
AIR_LEVEL_CHECK = Capability(
href='/airlevelcheck/vs/0',
poll_tier='warm',
entities=(
BinarySensorDesc(
key='periodic_air_sensing',
field='x.com.samsung.da.periodicSensingActivationState',
name='Periodic air sensing',
icon='mdi:radar',
entity_category='diagnostic',
value_fn=lambda value: str(value).lower() == 'on',
),
SensorDesc(
key='air_sensing_state',
field='x.com.samsung.da.sensingState',
name='Air sensing state',
icon='mdi:radar',
entity_category='diagnostic',
),
SensorDesc(
key='last_air_sensing_time',
field='x.com.samsung.da.lastSensingTime',
name='Last air sensing time',
device_class='timestamp',
entity_category='diagnostic',
value_fn=_timestamp,
),
SensorDesc(
key='last_air_sensing_level',
field='x.com.samsung.da.lastSensingLevel',
name='Last air sensing level',
icon='mdi:air-filter',
entity_category='diagnostic',
),
SensorDesc(
key='automatic_ventilation_state',
field='x.com.samsung.da.autoExeState',
name='Automatic ventilation state',
icon='mdi:fan-auto',
entity_category='diagnostic',
),
),
)
AUTO_VENTILATION = Capability(
href='/autoventilation/vs/0',
poll_tier='warm',
entities=(
SensorDesc(
key='auto_ventilation_action',
field='action',
name='Auto ventilation action',
icon='mdi:fan-auto',
),
),
)
# Resource plumbing and opaque feature-negotiation fields that are specific to
# this family. Bare capabilities mark them covered without creating entities.
COVERAGE = [
Capability(href=href)
for href in (
'/power/0',
'/power/vs/0',
'/mode/vs/0',
'/personality/presence/vs/0',
'/availablecontrolsets/vs/0',
'/da/softreset/vs/0',
)
]
@@ -17,10 +17,13 @@ from datetime import datetime, timezone
from ..capability import Capability
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc
from .laundry import cycle_select, hex_pairs, option_value, replace_in_options
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
@@ -35,6 +38,13 @@ from .laundry import cycle_select, hex_pairs, option_value, replace_in_options
# 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
@@ -226,9 +236,58 @@ def _dosing_low(prefix):
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(
@@ -281,11 +340,17 @@ WASHER_COURSE = Capability(
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')),
_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:
@@ -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
@@ -96,6 +111,13 @@ class ClimateDesc(SamsungEntityDescription):
write_fn: WriteFn = None
@dataclass(frozen=True, kw_only=True)
class FanDesc(SamsungEntityDescription):
# Composite fan entity: reads power from /power/0 and speed/support data
# from its bound href. Payloads are (kind, value), like ClimateDesc.
write_fn: WriteFn = None
PLATFORM_OF: dict[type, str] = {
SensorDesc: 'sensor',
BinarySensorDesc: 'binary_sensor',
@@ -105,4 +127,5 @@ PLATFORM_OF: dict[type, str] = {
NumberDesc: 'number',
TimeDesc: 'time',
ClimateDesc: 'climate',
FanDesc: 'fan',
}
+23 -1
View File
@@ -16,6 +16,12 @@
"100": "High"
}
},
"range_hood_lamp_brightness": {
"state": {
"1": "Level 1",
"2": "Level 2"
}
},
"ice_type": {
"state": {
"off": "Off",
@@ -89,7 +95,12 @@
"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": {
@@ -235,6 +246,17 @@
"already_configured": "This device is already configured."
}
},
"options": {
"step": {
"init": {
"title": "Local Things Options",
"description": "Some devices accept certain writes (e.g. default detergent/softener dosing on a washer) even while reporting remote control off. By default, LocalThings blocks every write with a clear error whenever a device reports remote control off, rather than letting the device silently reject it. Only enable this if you've confirmed writes actually work on this device with remote control off -- otherwise you'll trade that clear error for a silent failure.",
"data": {
"bypass_remote_control_lock": "Allow writes even when remote control is reported off"
}
}
}
},
"issues": {
"device_gap": {
"title": "Incomplete capability coverage for {device_name}",
@@ -16,6 +16,12 @@
"100": "High"
}
},
"range_hood_lamp_brightness": {
"state": {
"1": "Level 1",
"2": "Level 2"
}
},
"ice_type": {
"state": {
"off": "Off",
@@ -89,7 +95,12 @@
"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": {
@@ -235,6 +246,17 @@
"already_configured": "This device is already configured."
}
},
"options": {
"step": {
"init": {
"title": "Local Things Options",
"description": "Some devices accept certain writes (e.g. default detergent/softener dosing on a washer) even while reporting remote control off. By default, LocalThings blocks every write with a clear error whenever a device reports remote control off, rather than letting the device silently reject it. Only enable this if you've confirmed writes actually work on this device with remote control off -- otherwise you'll trade that clear error for a silent failure.",
"data": {
"bypass_remote_control_lock": "Allow writes even when remote control is reported off"
}
}
}
},
"issues": {
"device_gap": {
"title": "Incomplete capability coverage for {device_name}",
+200
View File
@@ -0,0 +1,200 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/airflow/0",
"rep": {
"speed": 0,
"direction": "Off"
}
},
{
"href": "/airflow/vs/0",
"rep": {
"x.com.samsung.da.speedLevel": "0",
"x.com.samsung.da.direction": "Off"
}
},
{
"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-23T15:56:55",
"x.com.samsung.da.state": "Deleted"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "FilterAlarm",
"x.com.samsung.da.triggeredTime": "2026-07-23T15:56:55",
"x.com.samsung.da.state": "Created"
}
]
}
},
{
"href": "/configuration/vs/0",
"rep": {}
},
{
"href": "/consumable/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.state": "100",
"x.com.samsung.da.name": "FilterProgress"
}
]
}
},
{
"href": "/devicespecificinfo/vs/0",
"rep": {
"x.com.samsung.da.deviceActive": true
}
},
{
"href": "/diagnosis/vs/0",
"rep": {
"x.com.samsung.da.diagnosisStart": "Ready"
}
},
{
"href": "/energy/consumption/0",
"rep": {}
},
{
"href": "/energy/consumption/vs/0",
"rep": {}
},
{
"href": "/humidity/0",
"rep": {}
},
{
"href": "/humidity/vs/0",
"rep": {}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "ARTIK051_TVTL_18K|10193941|7000023C001111C40100000000000000",
"x.com.samsung.da.description": "ARTIK051_TVTL_18K",
"x.com.samsung.da.serialNum": "**REDACTED**",
"x.com.samsung.da.otnDUID": "**REDACTED**",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02059A230513",
"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": "19071703,19071706",
"x.com.samsung.da.newVersionAvailable": "0"
}
]
}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.options": [
"Comode_Off",
"Blooming_0",
"Light_On",
"OptionCode_60282"
]
}
},
{
"href": "/power/0",
"rep": {
"value": false
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "Off"
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.longnotisubscription": "true",
"x.com.samsung.da.periodicnotisubscription": "false"
}
},
{
"href": "/sensors/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Sensor for Dust",
"x.com.samsung.da.type": "Dust",
"x.com.samsung.da.value": [
"11",
"0"
]
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Sensor for FineDust",
"x.com.samsung.da.type": "FineDust",
"x.com.samsung.da.value": [
"9",
"0"
]
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Sensor for Odor",
"x.com.samsung.da.type": "Odor",
"x.com.samsung.da.value": [
"0"
]
},
{
"x.com.samsung.da.id": "3",
"x.com.samsung.da.description": "Sensor for CleanLevel",
"x.com.samsung.da.type": "CleanLevel",
"x.com.samsung.da.value": [
"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": [
"5",
"0"
]
}
]
}
}
]
}
@@ -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"
]
}
}
]
}
+73
View File
@@ -0,0 +1,73 @@
{
"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"
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "On"
}
},
{
"href": "/mode/vs/0",
"rep": {
"rt": [
"x.com.samsung.da.mode"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"x.com.samsung.da.options": [
"DeviceType_NA9300K-/AA1",
"MainTimerCurrent_0",
"MainTimerSet_0",
"MainTimerState_Ready",
"OperationState0_Ready",
"OperationState1_Ready",
"OperationState3_Ready",
"OperationState4_Ready",
"OperationState5_Ready"
]
}
},
{
"href": "/connected/vs/0",
"rep": {
"x.com.samsung.da.connected": "On"
}
},
{
"href": "/configuration/vs/0",
"rep": {}
},
{
"href": "/bluetooth/hood/status/vs/0",
"rep": {
"connectionState": "disconnected",
"micomModelId": "",
"firmwareVersion": "",
"power": "off",
"fanSpeed": 0,
"lampState": "off",
"timer": {}
}
}
]
}
+25
View File
@@ -0,0 +1,25 @@
{
"state_keys": [
"alarm_code",
"blooming_level",
"clean_level",
"device_active",
"diagnosis_status",
"display_light",
"dust",
"energy_kwh",
"energy_last_month_kwh",
"energy_saved_kwh",
"energy_this_month_kwh",
"fan_direction",
"fan_speed_level",
"filter_progress",
"fine_dust",
"odor",
"operating_mode",
"power_energy_kwh",
"power_switch",
"power_watts",
"super_fine_dust"
]
}
@@ -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"
]
}
+21
View File
@@ -0,0 +1,21 @@
{
"state_keys": [
"any_burner_active",
"burner_0_state",
"burner_1_state",
"burner_3_state",
"burner_4_state",
"burner_5_state",
"cloud_connected",
"firmware_update",
"main_timer_current",
"main_timer_state",
"paired_hood_connected",
"paired_hood_fan_speed",
"paired_hood_firmware",
"paired_hood_light",
"paired_hood_model",
"paired_hood_power",
"power_state"
]
}
+25
View File
@@ -0,0 +1,25 @@
{
"state_keys": [
"alarm_code",
"child_lock",
"cloud_connected",
"cook_time",
"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"
]
}
+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"
]
}
+25
View File
@@ -0,0 +1,25 @@
{
"state_keys": [
"air_sensing_state",
"alarm_code",
"auto_ventilation_action",
"automatic_operation",
"automatic_ventilation_state",
"clean_level",
"energy_kwh",
"energy_saved_kwh",
"fan",
"firmware_update",
"hood_filter_capacity",
"hood_filter_status",
"hood_filter_usage",
"lamp",
"lamp_brightness",
"last_air_sensing_level",
"last_air_sensing_time",
"periodic_air_sensing",
"dust",
"fine_dust",
"super_fine_dust"
]
}
+4
View File
@@ -1,5 +1,6 @@
{
"state_keys": [
"ai_energy_level",
"alarm_code",
"auto_door_opener",
"autofill",
@@ -39,11 +40,13 @@
"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",
@@ -83,6 +86,7 @@
"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",
@@ -1,5 +1,6 @@
{
"state_keys": [
"ai_energy_level",
"alarm_code",
"autofill",
"cooler_setpoint",
@@ -28,6 +29,7 @@
"rapid_freezing",
"rapid_fridge",
"sabbath_mode",
"selfcheck_error",
"selfcheck_result",
"selfcheck_status"
]
+4
View File
@@ -1,6 +1,8 @@
{
"state_keys": [
"ai_energy_level",
"alarm_code",
"bubble_soak",
"buzzer_sound",
"child_lock",
"cycle",
@@ -14,9 +16,11 @@
"energy_saved_kwh",
"finish_time",
"firmware_update",
"intensive",
"job_beginning_status",
"machine_state",
"power_switch",
"pre_wash",
"progress",
"progress_percentage",
"remote_control",
+3
View File
@@ -1,6 +1,7 @@
{
"state_keys": [
"alarm_code",
"bubble_soak",
"child_lock",
"cycle",
"cycle_active",
@@ -12,9 +13,11 @@
"energy_kwh",
"finish_time",
"firmware_update",
"intensive",
"job_beginning_status",
"machine_state",
"power_switch",
"pre_wash",
"progress",
"progress_percentage",
"remote_control",
+289
View File
@@ -0,0 +1,289 @@
{
"meta": {
"model": "TP1X_DA-KS-OVEN-0107X (NV7000BS/ET5)",
"device_type": "oven",
"source": "issue #55 diagnostics (scrubbed)",
"note": "Wall oven reports no oneUiVersion; resolved via the '-OVEN-' modelNum token fallback in for_device_by_model, mirroring the '-RANGE-' fallback added for issue #44."
},
"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-22-OVEN-1",
"versions": [
"40250221"
],
"visVersion": "250221"
},
{
"type": "Micom",
"modelId": "07134046004140472641",
"versions": [
"23011700",
"23122000"
],
"visVersion": "231220"
},
{
"type": "Micom",
"modelId": "071360127441FFFFFFFF",
"versions": [
"21123000",
"FFFFFFFF"
],
"visVersion": "211230"
}
]
}
},
{
"href": "/connected/vs/0",
"rep": {
"x.com.samsung.da.connected": "On"
}
},
{
"href": "/doors/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Door",
"x.com.samsung.da.openState": "Close"
}
],
"rt": [
"x.com.samsung.da.doors"
],
"if": [
"oic.if.baseline",
"oic.if.s"
]
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "TP1X_DA-KS-OVEN-0107X|40460041|50030018001611020A00000000000000",
"x.com.samsung.da.description": "NV7000BS/ET5",
"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": "25022100",
"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": "04600A23011700, 04726A23122000",
"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": "01274A21123000",
"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": "KV1",
"x.com.samsung.da.diagMinVersion": "3.0",
"x.com.samsung.da.diagTsId": "DA01"
}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"Autocook",
"Convection",
"TopHeatPluseConvection",
"Conventional",
"LargeGrill",
"SmallGrill",
"BottomHeatPluseConvection",
"PlateWarm",
"KeepWarm",
"Bottom",
"EcoConvection",
"FanGrill",
"Defrost",
"SteamClean"
],
"x.com.samsung.da.modes": [
"NoOperation"
],
"x.com.samsung.da.options": [
"DeviceType_NV7000BS/ET5",
"UpperTimerCurrent_0",
"UpperTimerSet_0",
"UpperTimerState_Ready",
"UpperLamp_Off",
"Sound_On"
],
"x.com.samsung.da.defaultMode": "Convection",
"rt": [
"x.com.samsung.da.mode"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/operational/state/vs/0",
"rep": {
"x.com.samsung.da.state": "Ready",
"x.com.samsung.da.operationTime": "00:00:00",
"x.com.samsung.da.remainingTime": "00:00:00",
"x.com.samsung.da.progressPercentage": "0",
"rt": [
"x.com.samsung.da.operation"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/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-23T15:26:07"
}
]
}
},
{
"href": "/oven/vs/0",
"rep": {
"x.com.samsung.da.state": "Ready",
"x.com.samsung.da.recipe": "00000000000000",
"rt": [
"x.com.samsung.da.oven"
],
"if": [
"oic.if.baseline",
"oic.if.s"
]
}
},
{
"href": "/temperatures/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Temperature",
"x.com.samsung.da.desired": "0",
"x.com.samsung.da.current": "0",
"x.com.samsung.da.increment": "5",
"x.com.samsung.da.unit": "Celsius"
}
],
"rt": [
"x.com.samsung.da.temperatures"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "Europe/Rome",
"offset": "+02:00",
"DST": "ON"
}
},
{
"href": "/configuration/vs/0",
"rep": {}
},
{
"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": "true",
"rt": [
"x.com.samsung.da.configuration"
],
"if": [
"oic.if.baseline",
"oic.if.s"
]
}
},
{
"href": "/wirelessinfo/vs/0",
"rep": {
"macaddressWiFi": "**REDACTED**",
"macaddressBLE": "**REDACTED**"
}
},
{
"href": "/quickcontrol/info/vs/0",
"rep": {
"supportedVersion": "1.0"
}
}
]
}
+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"
}
}
]
}
+199
View File
@@ -0,0 +1,199 @@
{
"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": "TEST-DEVICE-ID",
"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": "/airlevelcheck/vs/0",
"rep": {
"x.com.samsung.da.periodicSensingActivationState": "Off",
"x.com.samsung.da.periodicSensingInterval": "3600",
"x.com.samsung.da.startSensingOnce": "Off",
"x.com.samsung.da.sensingState": "NonProcessing",
"x.com.samsung.da.lastSensingTime": "1784651378",
"x.com.samsung.da.lastSensingLevel": "Kr2",
"x.com.samsung.da.autoExeState": "Sensing",
"x.com.samsung.da.supportedAutoExeState": ["Airpurify", "Alarm"],
"x.com.samsung.da.periodicSensingSkipStatus": "Off",
"x.com.samsung.da.periodicSensingSkipTime": "00000000"
}
},
{
"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-21T20:18:51",
"x.com.samsung.da.state": "Deleted"
}
]
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.cumulativePower": "165620",
"x.com.samsung.da.cumulativeSavedPower": "0",
"x.com.samsung.da.cumulativeDate": "1784647130",
"x.com.samsung.da.cumulativeDateUTC": "1784665130",
"x.com.samsung.da.cumulativeUnit": "Wh"
}
},
{"href": "/energy/consumption/0", "rep": {}},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.options": ["OptionCode_19322", "UpdateAllow_NotAllowed"]
}
},
{
"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": ["2"]
},
{
"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": ["18", "1"]
},
{
"x.com.samsung.da.id": "3",
"x.com.samsung.da.description": "Sensor for FineDust",
"x.com.samsung.da.type": "FineDust",
"x.com.samsung.da.value": ["14", "1"]
},
{
"x.com.samsung.da.id": "4",
"x.com.samsung.da.description": "Sensor for SuperFineDust",
"x.com.samsung.da.type": "SuperFineDust",
"x.com.samsung.da.value": ["9", "1"]
}
]
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "AHD-WW-TP1-22-COMMON|20136141|7800006B001713C44D00090001030000",
"x.com.samsung.da.description": "AHD-WW-TP1-22-COMMON",
"x.com.samsung.da.serialNum": "TEST-HOOD-SERIAL",
"x.com.samsung.da.diagProtocolType": "WIFI_HTTPS",
"x.com.samsung.da.diagSetupid": "KH0"
}
},
{
"href": "/file/information/vs/0",
"rep": {"x.com.samsung.timeoffset": "-05:00"}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "0000000000",
"x.com.samsung.da.airconOptionList": ["DR"],
"x.com.samsung.da.countryCode": ""
}
},
{
"href": "/drlc/0",
"rep": {
"DRLevel": 0,
"start": "1970-01-01T00:00:00Z",
"durationminutes": 0,
"override": false
}
},
{
"href": "/drlc/vs/0",
"rep": {
"x.com.samsung.da.drlcLevel": "0",
"x.com.samsung.da.durationminutes": "0",
"x.com.samsung.da.start": "1970-01-01T00:00:00Z",
"x.com.samsung.da.override": "Off",
"x.com.samsung.da.realSaving": "Off"
}
},
{
"href": "/availablecontrolsets/vs/0",
"rep": {
"x.com.samsung.da.sets": "0000000000012000000000000000",
"x.com.samsung.da.id": "HOOD",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/da/softreset/vs/0",
"rep": {"x.com.samsung.da.softwarereset": "false"}
},
{
"href": "/filter/hoodfilter/vs/0",
"rep": {
"x.com.samsung.da.filterUsage": "100",
"x.com.samsung.da.filterUsageResolution": "1",
"x.com.samsung.da.filterStatus": "wash",
"x.com.samsung.da.filterCapacity": "60",
"x.com.samsung.da.filterCapacityUnit": "Hour",
"x.com.samsung.da.filterResetType": ["replaceable", "washable"]
}
},
{
"href": "/hood/fanspeed/vs/0",
"rep": {
"x.com.samsung.da.hood.autoOperation": "Off",
"x.com.samsung.da.hood.fanSpeed": "14",
"x.com.samsung.da.hood.supportedFanSpeed": ["14", "15", "16", "17"]
}
},
{
"href": "/hood/lamp/vs/0",
"rep": {
"x.com.samsung.lamp.power": "Off",
"x.com.samsung.lamp.current": "2",
"x.com.samsung.lamp.range": ["1", "2"]
}
},
{"href": "/autoventilation/vs/0", "rep": {"action": "Start"}},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "",
"x.com.samsung.da.newVersionAvailable": "false"
}
}
]
}
+2 -1
View File
@@ -279,7 +279,8 @@
"rep": {
"aiLevel": "1",
"supportedAiLevel": [
"1"
"1",
"2"
],
"href": "/energy/ailevel/vs/0"
}
+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": {
+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):
+44 -1
View File
@@ -9,7 +9,8 @@ from homeassistant.data_entry_flow import FlowResultType
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.localthings.const import (
CONF_CA_CERT_PEM, CONF_CA_KEY_PEM, CONF_HOST, CONF_PORT, DOMAIN,
CONF_BYPASS_REMOTE_CONTROL, CONF_CA_CERT_PEM, CONF_CA_KEY_PEM,
CONF_HOST, CONF_PORT, DOMAIN,
)
from .conftest import (
@@ -276,3 +277,45 @@ def test_probe_marks_washer_as_recognized(monkeypatch):
) is not None
)
assert recognized is True
async def test_options_flow_default_is_off(hass: HomeAssistant) -> None:
"""The bypass defaults to False, so devices that never touch this
option see no change in the remote-control write block."""
entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id=f'localthings_{MOCK_SERIAL}')
entry.add_to_hass(hass)
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result['type'] == FlowResultType.FORM
assert result['step_id'] == 'init'
assert result['data_schema']({})[CONF_BYPASS_REMOTE_CONTROL] is False
async def test_options_flow_can_enable_bypass(hass: HomeAssistant) -> None:
"""Submitting the form with the toggle on stores it in entry.options,
where coordinator.async_send_command reads it (issue #54)."""
entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id=f'localthings_{MOCK_SERIAL}')
entry.add_to_hass(hass)
result = await hass.config_entries.options.async_init(entry.entry_id)
result = await hass.config_entries.options.async_configure(
result['flow_id'], user_input={CONF_BYPASS_REMOTE_CONTROL: True}
)
assert result['type'] == FlowResultType.CREATE_ENTRY
assert entry.options[CONF_BYPASS_REMOTE_CONTROL] is True
async def test_options_flow_reflects_previously_saved_value(hass: HomeAssistant) -> None:
"""Reopening the form shows the currently-saved choice as the default,
not always False."""
entry = MockConfigEntry(
domain=DOMAIN, data=ENTRY_DATA, unique_id=f'localthings_{MOCK_SERIAL}',
options={CONF_BYPASS_REMOTE_CONTROL: True},
)
entry.add_to_hass(hass)
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result['data_schema']({})[CONF_BYPASS_REMOTE_CONTROL] is True
+414 -90
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,
CONF_BYPASS_REMOTE_CONTROL, 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
@@ -134,6 +136,31 @@ def test_run_discovery_detects_washer_via_model_fallback(
assert coordinator.device_type_name == 'washer'
def test_run_discovery_detects_cooktop_via_resource_signature(
hass: HomeAssistant, mock_entry
) -> None:
"""NA9300K omits oneUiVersion and information but has burner options."""
from tests.conftest import _load_device
coordinator = LocalThingsCoordinator(hass, mock_entry)
coordinator._run_discovery(_load_device('cooktop'))
assert coordinator.device_type_name == 'cooktop'
assert coordinator._unbound_hrefs == []
def test_run_discovery_detects_range_hood_via_model(
hass: HomeAssistant, mock_entry
) -> None:
from tests.conftest import _load_device
coordinator = LocalThingsCoordinator(hass, mock_entry)
coordinator._run_discovery(_load_device('range_hood'))
assert coordinator.device_type_name == 'range_hood'
assert coordinator._unbound_hrefs == []
def test_update_coverage_gap_issue_creates_issue_for_unknown_type(
hass: HomeAssistant, mock_entry
) -> None:
@@ -260,21 +287,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 +324,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 +341,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 +379,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 +418,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 +444,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 +479,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 +525,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 +552,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 +574,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 +614,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
@@ -676,7 +643,13 @@ async def test_write_marks_href_pending_before_post(
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
) -> None:
"""async_send_command must call mark_write_pending before POSTing so a
slow-to-settle device can't immediately revert the optimistic write."""
slow-to-settle device can't immediately revert the optimistic write --
and it must guard the write's actual target href (write_fn's own
path_segs), not bound_entity.href. Those differ for a composite entity
like the AC's ClimateDesc (bound to /mode/vs/0, but writing power/temp/
fan/swing/preset to their own sibling hrefs) -- guarding the bound href
protected the wrong resource and left the one the entity actually
displays from unprotected (issues #17/#53)."""
from custom_components.localthings.registry.discovery import BoundEntity
from custom_components.localthings.registry.entities import NumberDesc
@@ -695,4 +668,355 @@ async def test_write_marks_href_pending_before_post(
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
assert coordinator._observe._settle_until.get('/some/path') is not None
assert coordinator._observe._settle_until.get('/test/vs/0') is 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 at the write's actual
target href (write_fn's path_segs) -- not bound_entity.href, which
differs for a composite entity like the AC's ClimateDesc -- and stay put
against a stale echo for the rest of the settle window there.
Applying the optimistic value to bound_entity.href instead is exactly
how issues #17/#53 survived the original optimistic-apply fix (issue
#27): the bound href got a harmless (if nonsensical) optimistic merge,
while the resource the entity actually reads from for its displayed
state never got one and stayed stale until the next real read of it."""
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 at the write's real
# target, not the bound href.
assert coordinator._cache.get('/some/path') == {'value': 5}
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('/some/path', {'value': 0}, source='observe')
assert applied is False
assert coordinator._cache.get('/some/path') == {'value': 5}
async def test_climate_power_write_applies_to_its_own_href_not_bound_href(
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
) -> None:
"""Regression for issues #17/#53, using the real AC capability. The
composite climate entity binds /mode/vs/0 (airconditioner.CLIMATE.href),
but a power command's write_fn (airconditioner._climate_write) targets
the sibling /power/0 -- the href climate.py's `_is_on()` actually reads.
The optimistic value and settle guard must land there, not on the bound
/mode/vs/0, or the entity never sees the write and shows stale state
until the next unrelated read of /power/0."""
from custom_components.localthings.registry.capabilities import airconditioner
from custom_components.localthings.registry.discovery import BoundEntity
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]
bound = BoundEntity(
href=airconditioner.CLIMATE.href,
capability=coordinator.bound[0].capability,
desc=airconditioner.CLIMATE.entities[0],
)
with patch.object(fake, 'subscribe'):
fake.post = lambda *a, **k: (0x44, b'')
await coordinator.async_send_command(bound, ('power', True))
assert coordinator._cache.get('/power/0') == {'value': True}
assert coordinator._cache.get(airconditioner.CLIMATE.href) != {'value': True}
assert coordinator._observe._settle_until.get('/power/0') is not None
async def test_send_command_survives_stale_confirm_poll(
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session, fridge_resources,
) -> None:
"""The settle window must outlast the confirm poll async_send_command
triggers via async_request_refresh(), not just DEFAULT_SETTLE_S's fixed
few seconds (issue #9). Some devices settle a write internally slower
than that refresh's own round trip -- issue #9's washer packs cycle/
detergent/softener selection into /course/vs/0's shared options[]
array, which visibly takes a few seconds of validation/dispenser
movement to catch up, unlike /washer/vs/0's plain temperature/spin
fields on the same device, which confirm instantly. A confirm poll that
lands before the device has caught up is stale, and a settle window
sized only to the fixed default (rather than the PUT+poll round trip)
expires before that poll even returns, so the stale read lands
unprotected and reverts the optimistic value -- self-correcting again
only once a later poll finally sees the real change. That reads to a
user as the write reverting, then reapplying itself, a few seconds
later."""
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 (['test', 'vs', '0'], {'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)
def _stale_confirm_poll():
# Stand-in for the write's own confirming /device/0 poll racing
# against a stale read for the same href -- the device's internal
# state hasn't caught up to the write yet, even though it will a
# little later.
coordinator._observe.apply('/test/vs/0', {'value': 0}, source='poll')
return fridge_resources
with (
patch.object(fake, 'subscribe'),
patch.object(LocalThingsCoordinator, '_poll_once', side_effect=_stale_confirm_poll),
):
fake.post = lambda *a, **k: (0x44, b'')
await coordinator.async_send_command(bound, 5)
# The optimistic value is visible right away and survived the stale
# confirm-poll race triggered by the refresh above.
assert coordinator._cache.get('/test/vs/0') == {'value': 5}
# The settle window is still open afterward -- sized to outlast the
# whole PUT + confirm-poll round trip, not released the moment that
# round trip happens to finish.
applied = coordinator._observe.apply('/test/vs/0', {'value': 0}, source='observe')
assert applied is False
assert coordinator._cache.get('/test/vs/0') == {'value': 5}
async def test_second_write_to_same_href_lands_during_first_writes_settle_window(
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session,
) -> None:
"""Regression for issue #9: /course/vs/0 backs several independent
washer selects (cycle, detergent quantity, softener quantity, ...)
sharing one href. The settle window is now sized to tens of seconds
(see test_send_command_survives_stale_confirm_poll), which makes a
second, different write to that href landing while the first write's
window is still open a routine occurrence -- e.g. a user picking a
cycle and then adjusting detergent quantity within the same minute --
not a rare edge case. The second write's own optimistic value must
still show up immediately; a guard meant to protect optimistic writes
from stale device echoes must not itself suppress a later one."""
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]
desc_a = NumberDesc(key='cycle', field='cycle',
write_fn=lambda p, rep, href: (['test', 'vs', '0'], {'cycle': p}))
desc_b = NumberDesc(key='detergent', field='detergent',
write_fn=lambda p, rep, href: (['test', 'vs', '0'], {'detergent': p}))
bound_a = BoundEntity(href='/test/vs/0', capability=coordinator.bound[0].capability, desc=desc_a)
bound_b = BoundEntity(href='/test/vs/0', capability=coordinator.bound[0].capability, desc=desc_b)
with patch.object(fake, 'subscribe'):
fake.post = lambda *a, **k: (0x44, b'')
await coordinator.async_send_command(bound_a, 'Eco')
assert coordinator._cache.get('/test/vs/0') == {'cycle': 'Eco'}
# Still well within the first write's settle window.
await coordinator.async_send_command(bound_b, 'High')
assert coordinator._cache.get('/test/vs/0') == {'cycle': 'Eco', 'detergent': 'High'}
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._cache.get('/some/path') == {'value': 5}
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')
async def test_send_command_bypasses_remote_control_when_option_enabled(
hass: HomeAssistant, mock_coordinator_observe_session
) -> None:
"""Issue #54: some devices accept certain writes even while reporting
remote control off, so a user who's confirmed that for their device can
opt it out of the block entirely via the options flow
(CONF_BYPASS_REMOTE_CONTROL, entry.options -- not entry.data)."""
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.localthings.registry.discovery import BoundEntity
from custom_components.localthings.registry.entities import NumberDesc
entry = MockConfigEntry(
domain=DOMAIN, data=ENTRY_DATA, unique_id=f'localthings_{MOCK_SERIAL}',
options={CONF_BYPASS_REMOTE_CONTROL: True},
)
entry.add_to_hass(hass)
fake = mock_coordinator_observe_session
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][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)
with patch.object(fake, 'subscribe'):
fake.post = lambda *a, **k: (0x44, b'')
await coordinator.async_send_command(bound, 5)
assert coordinator._cache.get('/some/path') == {'value': 5}
+46
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')
@@ -45,6 +67,30 @@ def test_apply_drops_update_during_settle_window():
assert mgr.cache.get('/oven/vs/0') == {'a': 1}
def test_apply_optimistic_bypasses_an_in_progress_settle_window():
"""Regression for issue #9: /course/vs/0 backs several independent
washer selects (cycle, detergent quantity, softener quantity, ...).
Picking a second one while the first's settle window is still open
(now sized to tens of seconds -- see coordinator._POST_TIMEOUT_S/
_POLL_TIMEOUT_S) is a normal sequence, not a stale echo of the first
write, and must land in the cache immediately -- not get silently
dropped by a guard that exists to protect optimistic writes, not
suppress them."""
mgr = _manager()
mgr.cache.apply_rep('/course/vs/0', {'Course': '1C', 'Detergent': '1'}, source='seed')
mgr.mark_write_pending('/course/vs/0', settle_s=30.0)
result = mgr.apply('/course/vs/0', {'Detergent': '2'}, source='optimistic')
assert result is True
assert mgr.cache.get('/course/vs/0') == {'Course': '1C', 'Detergent': '2'}
# A poll/sweep/observe update racing in right behind it is still
# gated -- the second write's own guard (re-armed by mark_write_pending,
# not exercised directly here) is what protects it going forward.
assert mgr.apply('/course/vs/0', {'Detergent': '1'}, source='poll') is False
def test_apply_accepts_update_after_settle_window_elapses():
mgr = _manager()
mgr.mark_write_pending('/oven/vs/0', settle_s=0.05)
+108
View File
@@ -0,0 +1,108 @@
"""Tests for the ARTIK051_TVTL_18K air-purifier profile (issue #56)."""
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 air_purifier
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
def _purifier():
resources = _load_device('air_purifier')
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 = _purifier()
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
return flatten(bound, resources)
def test_model_resolves_to_air_purifier_registry():
reg, _ = _purifier()
assert reg is not None
assert reg.name == 'air_purifier'
def test_no_unbound_hrefs():
"""Every resource in the issue #56 dump binds or is ignored -- clears the
coverage-gap repair."""
reg, resources = _purifier()
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', 'alarm_code', 'dust', 'fine_dust', 'super_fine_dust',
'odor', 'clean_level', 'filter_progress', 'device_active',
'diagnosis_status', 'fan_speed_level', 'fan_direction',
'display_light', 'operating_mode', 'blooming_level',
):
assert key in state, key
def test_air_quality_sensor_values():
"""Dust/FineDust/SuperFineDust/Odor/CleanLevel read index 0 of each
items[] entry's value list (the raw measurement, per common.sensor_item_value)."""
state = _state()
assert state['dust'] == 11
assert state['fine_dust'] == 9
assert state['super_fine_dust'] == 5
assert state['odor'] == 0
assert state['clean_level'] == 0
def test_filter_progress_reads_named_consumable_item():
assert _state()['filter_progress'] == 100
def test_diagnosis_reuses_dishwasher_capability():
"""/diagnosis/vs/0 has the identical field/write contract as
dishwasher.DIAGNOSIS, so the by_type registry reuses it directly."""
from custom_components.localthings.registry.capabilities import dishwasher
reg, _ = _purifier()
assert dishwasher.DIAGNOSIS in reg.capabilities['/diagnosis/vs/0']
def test_light_switch_write_contract():
"""The display-light switch RMW-replaces only the 'Light_*' entry in the
packed /mode/vs/0 options list (via laundry.replace_in_options), leaving
the other flags and the list order untouched."""
desc = next(e for e in air_purifier.MODE.entities if e.key == 'display_light')
rep = {'x.com.samsung.da.options': [
'Comode_Off', 'Blooming_0', 'Light_On', 'OptionCode_60282',
]}
assert desc.rep_fn(rep) is True
assert desc.write_fn('Off', rep) == (
['mode', 'vs', '0'],
{'x.com.samsung.da.options': [
'Comode_Off', 'Blooming_0', 'Light_Off', 'OptionCode_60282',
]},
)
def test_mode_tokens_are_read_only_diagnostics():
"""Comode_/Blooming_ tokens surface as raw diagnostic sensors rather than
a select/control -- their valid value ranges aren't confirmed yet (see
the air_purifier.py module docstring and the issue #56 follow-up)."""
operating_mode = next(e for e in air_purifier.MODE.entities if e.key == 'operating_mode')
blooming = next(e for e in air_purifier.MODE.entities if e.key == 'blooming_level')
rep = {'x.com.samsung.da.options': ['Comode_Off', 'Blooming_6']}
assert operating_mode.rep_fn(rep) == 'Off'
assert blooming.rep_fn(rep) == '6'
assert not hasattr(operating_mode, 'write_fn')
def test_airflow_vs_fallback_only_binds_without_generic():
"""/airflow/vs/0 is a match_fn fallback -- it must not bind when the
OCF-standard /airflow/0 is also present (both are on every dump seen)."""
assert air_purifier.AIRFLOW_VS_FALLBACK.match_fn(
{}, {'/airflow/0': {'speed': 0, 'direction': 'Off'}},
) is False
assert air_purifier.AIRFLOW_VS_FALLBACK.match_fn({}, {}) is True
+128 -1
View File
@@ -6,7 +6,7 @@ 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_by_model
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
@@ -23,6 +23,22 @@ def _ac():
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
@@ -106,3 +122,114 @@ def test_climate_consumed_hrefs_declared_as_coverage():
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
+76
View File
@@ -41,6 +41,16 @@ class TestForDevice:
assert isinstance(registry, DeviceRegistry)
assert registry.name == 'refrigerator'
def test_for_device_returns_cooktop_registry(self):
registry = for_device('7.0 Cooktop')
assert registry is not None
assert registry.name == 'cooktop'
def test_for_device_returns_range_hood_registry(self):
registry = for_device('7.0 Range Hood')
assert registry is not None
assert registry.name == 'range_hood'
class TestDeviceRegistries:
"""Tests for device registries themselves."""
@@ -180,6 +190,25 @@ class TestForDeviceByModel:
assert reg is not None
assert reg.name == 'airconditioner'
def test_cooktop_via_legacy_model_description(self):
"""Older cooktops identify themselves as ARTIK051_GLOBAL_COOKTOP."""
from custom_components.localthings.registry.by_type import for_device_by_model
reg = for_device_by_model(
'ARTIK051_GB_CT_001|40424141|50000204001211000200000000000000',
'ARTIK051_GLOBAL_COOKTOP',
)
assert reg is not None
assert reg.name == 'cooktop'
def test_range_hood_via_ahd_model(self):
from custom_components.localthings.registry.by_type import for_device_by_model
reg = for_device_by_model(
'AHD-WW-TP1-22-COMMON|20136141|7800006B001713C44D00090001030000',
'AHD-WW-TP1-22-COMMON',
)
assert reg is not None
assert reg.name == 'range_hood'
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
@@ -200,6 +229,18 @@ class TestForDeviceByModel:
assert reg is not None
assert reg.name == 'washer'
def test_oven_via_oven_token(self):
"""Issue #55: a wall oven (NV7000BS/ET5) reports no oneUiVersion and
an unrecognized consumer token ('NV'); it falls back to the '-OVEN-'
token in modelNum, mirroring the '-RANGE-' fallback for issue #44."""
from custom_components.localthings.registry.by_type import for_device_by_model
reg = for_device_by_model(
'TP1X_DA-KS-OVEN-0107X|40460041|50030018001611020A00000000000000',
'NV7000BS/ET5',
)
assert reg is not None
assert reg.name == 'oven'
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')
@@ -208,3 +249,38 @@ class TestForDeviceByModel:
def test_empty_inputs_return_none(self):
from custom_components.localthings.registry.by_type import for_device_by_model
assert for_device_by_model('', '') is None
class TestForDeviceByResources:
def test_na9300k_without_one_ui_or_information_is_cooktop(self):
from custom_components.localthings.registry.by_type import for_device_by_resources
from tests.conftest import _load_device
reg = for_device_by_resources(_load_device('cooktop'))
assert reg is not None
assert reg.name == 'cooktop'
def test_unrelated_mode_options_are_not_cooktop(self):
from custom_components.localthings.registry.by_type import for_device_by_resources
resources = {
'/mode/vs/0': {
'x.com.samsung.da.options': [
'DeviceType_SOME_OVEN',
'UpperLamp_Off',
],
},
}
assert for_device_by_resources(resources) is None
def test_hood_resource_signature(self):
from custom_components.localthings.registry.by_type import for_device_by_resources
resources = {
'/hood/fanspeed/vs/0': {},
'/hood/lamp/vs/0': {},
}
reg = for_device_by_resources(resources)
assert reg is not None
assert reg.name == 'range_hood'
@@ -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']}) == {}
+207
View File
@@ -84,6 +84,14 @@ class TestRemoteControlFallback:
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
@@ -130,3 +138,202 @@ class TestEnergyMeter:
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
+77
View File
@@ -0,0 +1,77 @@
"""Tests for the read-only cooktop capability profile."""
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import cooktop
from custom_components.localthings.registry.capabilities.cooktop import (
COOKTOP_MODE,
)
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
def _state(resources):
bound = discover(
resources,
cooktop.REGISTRY.capabilities,
cooktop.REGISTRY.pattern_capabilities,
)
return flatten(bound, resources)
def test_real_cooktop_fixture_has_expected_idle_state():
state = _state(_load_device('cooktop'))
assert state['power_state'] is True
assert state['any_burner_active'] is False
assert state['burner_0_state'] == 'Ready'
assert state['burner_5_state'] == 'Ready'
assert state['main_timer_state'] == 'Ready'
assert state['main_timer_current'] == 0
assert state['cloud_connected'] is True
assert state['paired_hood_connected'] is False
assert state['paired_hood_power'] is False
assert state['paired_hood_fan_speed'] == 0
assert state['paired_hood_light'] is False
def test_run_operation_marks_any_burner_active():
resources = _load_device('cooktop')
options = resources['/mode/vs/0']['x.com.samsung.da.options']
options[options.index('OperationState3_Ready')] = 'OperationState3_Run'
state = _state(resources)
assert state['burner_3_state'] == 'Run'
assert state['any_burner_active'] is True
def test_cooktop_profile_has_no_write_functions():
"""A local integration must never expose unverified remote ignition."""
descriptions = [
desc
for capabilities in cooktop.REGISTRY.capabilities.values()
for capability in capabilities
for desc in capability.entities
]
assert descriptions
assert all(not hasattr(desc, 'write_fn') or desc.write_fn is None
for desc in descriptions)
def test_static_slot_superset_has_headroom_for_other_layouts():
keys = {desc.key for desc in COOKTOP_MODE.entities}
assert {f'burner_{slot}_state' for slot in range(8)} <= keys
def test_variant_slot_outside_na9300k_layout_is_discovered():
resources = _load_device('cooktop')
resources['/mode/vs/0']['x.com.samsung.da.options'].append(
'OperationState7_Ready',
)
state = _state(resources)
assert state['burner_7_state'] == 'Ready'
assert 'burner_2_state' not in state
+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(
+2 -1
View File
@@ -1,6 +1,6 @@
from custom_components.localthings.registry.entities import (
SensorDesc, BinarySensorDesc, SelectDesc, SwitchDesc, ButtonDesc,
NumberDesc, TimeDesc, ClimateDesc, PLATFORM_OF,
NumberDesc, TimeDesc, ClimateDesc, FanDesc, PLATFORM_OF,
)
@@ -28,6 +28,7 @@ def test_platform_mapping_covers_all_subclasses():
assert PLATFORM_OF[NumberDesc] == 'number'
assert PLATFORM_OF[TimeDesc] == 'time'
assert PLATFORM_OF[ClimateDesc] == 'climate'
assert PLATFORM_OF[FanDesc] == 'fan'
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'
+30
View File
@@ -171,6 +171,7 @@ class TestTp1xNativeDuplicateResources:
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:
@@ -228,3 +229,32 @@ class TestArtik051AndTp2xFixturesHaveCompleteCoverage:
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'
+124 -1
View File
@@ -7,7 +7,9 @@ GOLDEN = Path(__file__).parent / 'fixtures' / 'golden'
def _new_state_keys(name, resources):
from custom_components.localthings.registry.by_type import for_device, for_device_by_model
from custom_components.localthings.registry.by_type import (
for_device, for_device_by_model, for_device_by_resources,
)
from custom_components.localthings.registry.discovery import discover
from custom_components.localthings.registry.adapter import flatten
otn = resources.get('/otninformation/vs/0', {})
@@ -19,6 +21,8 @@ def _new_state_keys(name, resources):
info.get('x.com.samsung.da.modelNum', ''),
info.get('x.com.samsung.da.description', ''),
)
if reg is None:
reg = for_device_by_resources(resources)
if reg is None:
from custom_components.localthings.registry.registry import CAPABILITIES
caps, pats = CAPABILITIES, []
@@ -81,6 +85,18 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner():
)
def test_registry_reproduces_golden_state_keys_for_cooktop():
from tests.conftest import _load_device
resources = _load_device('cooktop')
golden = json.loads((GOLDEN / 'cooktop.json').read_text())
state_keys = _new_state_keys('cooktop', resources)
assert set(state_keys) == set(golden['state_keys']), (
f"state_keys mismatch:\n"
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
)
def test_registry_reproduces_golden_state_keys_for_tp1x_ref_21k_us():
from tests.conftest import _load_device
resources = _load_device('refrigerator_tp1x_ref_21k_us')
@@ -95,6 +111,18 @@ def test_registry_reproduces_golden_state_keys_for_tp1x_ref_21k_us():
)
def test_registry_reproduces_golden_state_keys_for_range_hood():
from tests.conftest import _load_device
resources = _load_device('range_hood')
golden = json.loads((GOLDEN / 'range_hood.json').read_text())
state_keys = _new_state_keys('range_hood', 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
@@ -153,6 +181,101 @@ def test_registry_reproduces_golden_state_keys_for_tp2x_ref_20k():
)
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_registry_reproduces_golden_state_keys_for_air_purifier():
"""ARTIK051_TVTL_18K (issue #56) -- reports no oneUiVersion; resolved via
the '_TVTL_' modelNum token fallback in for_device_by_model."""
from tests.conftest import _load_device
resources = _load_device('air_purifier')
golden = json.loads((GOLDEN / 'air_purifier.json').read_text())
state_keys = _new_state_keys('air_purifier', 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_oven():
"""Wall oven (model TP1X_DA-KS-OVEN-0107X, issue #55) -- reports no
oneUiVersion; resolved via the '-OVEN-' modelNum token fallback in
for_device_by_model, mirroring the '-RANGE-' fallback added for
issue #44. Before that fallback existed the device type came back
'unknown' and every href fell through to the global CAPABILITIES
registry instead of the oven family's own."""
from tests.conftest import _load_device
resources = _load_device('oven')
golden = json.loads((GOLDEN / 'oven.json').read_text())
state_keys = _new_state_keys('oven', 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 = {
+62
View File
@@ -1,8 +1,30 @@
"""Unit tests for oven-family capabilities."""
from custom_components.localthings.registry.by_type import for_device_by_model
from custom_components.localthings.registry.capabilities import oven
from custom_components.localthings.registry.discovery import discover
# ---------------------------------------------------------------------------
# Device-type detection + full-dump coverage (issue #55)
# ---------------------------------------------------------------------------
def test_oven_fixture_resolves_and_has_no_unbound_hrefs():
"""The issue #55 dump previously came back device_type='unknown' with
/connected/vs/0 unbound -- resolving via the '-OVEN-' modelNum token
fallback must leave every href in the oven registry bound or ignored."""
from tests.conftest import _load_device
resources = _load_device('oven')
info = resources['/information/vs/0']
reg = for_device_by_model(
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'])
assert reg is not None
assert reg.name == 'oven'
unbound = []
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
assert unbound == []
# ---------------------------------------------------------------------------
# OVEN_SETPOINT — NumberDesc with RMW write semantics
# ---------------------------------------------------------------------------
@@ -53,6 +75,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
+116
View File
@@ -0,0 +1,116 @@
"""Tests for the AHD-WW-TP1-22 range-hood profile."""
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_hood
from custom_components.localthings.registry.discovery import discover
from custom_components.localthings.registry.entities import FanDesc
from tests.conftest import _load_device
def _hood():
resources = _load_device('range_hood')
info = resources['/information/vs/0']
registry = for_device_by_model(
info['x.com.samsung.da.modelNum'],
info['x.com.samsung.da.description'],
)
return registry, resources
def _state():
registry, resources = _hood()
bound = discover(
resources, registry.capabilities, registry.pattern_capabilities,
)
return flatten(bound, resources)
def test_ahd_model_resolves_to_range_hood_registry():
registry, _ = _hood()
assert registry is not None
assert registry.name == 'range_hood'
def test_range_hood_has_no_unbound_hrefs():
registry, resources = _hood()
unbound = []
discover(
resources,
registry.capabilities,
registry.pattern_capabilities,
log=unbound.append,
)
assert unbound == []
def test_range_hood_fixture_values():
state = _state()
assert state['fan'] == '14'
assert state['alarm_code'] == 'none'
assert state['automatic_operation'] is False
assert state['lamp'] is False
assert state['lamp_brightness'] == '2'
assert state['hood_filter_usage'] == 100
assert state['hood_filter_status'] == 'wash'
assert state['energy_kwh'] == 165.62
assert state['clean_level'] == 2
assert state['dust'] == 18
assert state['fine_dust'] == 14
assert state['super_fine_dust'] == 9
assert state['periodic_air_sensing'] is False
assert state['air_sensing_state'] == 'NonProcessing'
assert state['last_air_sensing_level'] == 'Kr2'
def test_one_composite_fan_is_bound():
registry, resources = _hood()
bound = discover(
resources, registry.capabilities, registry.pattern_capabilities,
)
fans = [entity for entity in bound if isinstance(entity.desc, FanDesc)]
assert len(fans) == 1
assert fans[0].href == '/hood/fanspeed/vs/0'
def test_fan_write_contract():
desc = range_hood.HOOD_FAN.entities[0]
rep = {
'x.com.samsung.da.hood.supportedFanSpeed': ['14', '15', '16', '17'],
}
assert desc.write_fn(('power', True), rep) == (
['power', '0'], {'value': True},
)
assert desc.write_fn(('power', False), rep) == (
['power', '0'], {'value': False},
)
assert desc.write_fn(('power', True, '/power/vs/0'), rep) == (
['power', 'vs', '0'], {'x.com.samsung.da.power': 'On'},
)
assert desc.write_fn(('power', False, '/power/vs/0'), rep) == (
['power', 'vs', '0'], {'x.com.samsung.da.power': 'Off'},
)
assert desc.write_fn(('power', True, '/not-a-power-resource'), rep) is None
assert desc.write_fn(('speed', '16'), rep) == (
['hood', 'fanspeed', 'vs', '0'],
{'x.com.samsung.da.hood.fanSpeed': '16'},
)
assert desc.write_fn(('speed', '99'), rep) is None
def test_lamp_write_contract():
lamp, brightness = range_hood.HOOD_LAMP.entities
rep = {'x.com.samsung.lamp.range': ['1', '2']}
assert lamp.write_fn('On', rep) == (
['hood', 'lamp', 'vs', '0'], {'x.com.samsung.lamp.power': 'On'},
)
assert lamp.write_fn('Off', rep) == (
['hood', 'lamp', 'vs', '0'], {'x.com.samsung.lamp.power': 'Off'},
)
assert brightness.write_fn('1', rep) == (
['hood', 'lamp', 'vs', '0'], {'x.com.samsung.lamp.current': '1'},
)
assert brightness.write_fn('2', rep) == (
['hood', 'lamp', 'vs', '0'], {'x.com.samsung.lamp.current': '2'},
)
assert brightness.write_fn('Unsupported', rep) is None
+88
View File
@@ -0,0 +1,88 @@
"""HA fan-entity mapping tests for the range hood."""
from custom_components.localthings.fan import LocalThingsRangeHoodFan
from custom_components.localthings.registry.by_type import range_hood
from custom_components.localthings.registry.discovery import discover
from custom_components.localthings.registry.entities import FanDesc
from tests.conftest import _load_device
class _FakeCoordinator:
device_serial = 'TEST-HOOD-SERIAL'
device_info = {}
data = {}
def __init__(self, resources):
self.last_resources = resources
self.commands = []
def resource(self, href):
return self.last_resources.get(href, {})
async def async_send_command(self, bound, payload):
self.commands.append((bound, payload))
def _entity(resources, coordinator=None):
bound = discover(
resources,
range_hood.REGISTRY.capabilities,
range_hood.REGISTRY.pattern_capabilities,
)
fan_bound = next(item for item in bound if isinstance(item.desc, FanDesc))
return LocalThingsRangeHoodFan(
coordinator or _FakeCoordinator(resources), fan_bound,
)
def test_power_off_maps_to_zero_percent_and_four_retained_speeds():
entity = _entity(_load_device('range_hood'))
assert entity.is_on is False
assert entity.speed_count == 4
assert entity.percentage == 0
def test_active_codes_map_to_ordered_percentages():
resources = _load_device('range_hood')
resources['/power/0']['value'] = True
resources['/hood/fanspeed/vs/0'][
'x.com.samsung.da.hood.fanSpeed'
] = '14'
assert _entity(resources).percentage == 25
resources['/hood/fanspeed/vs/0'][
'x.com.samsung.da.hood.fanSpeed'
] = '15'
assert _entity(resources).percentage == 50
resources['/hood/fanspeed/vs/0'][
'x.com.samsung.da.hood.fanSpeed'
] = '16'
assert _entity(resources).percentage == 75
resources['/hood/fanspeed/vs/0'][
'x.com.samsung.da.hood.fanSpeed'
] = '17'
assert _entity(resources).percentage == 100
async def test_power_write_prefers_standard_resource_when_both_exist():
resources = _load_device('range_hood')
coordinator = _FakeCoordinator(resources)
entity = _entity(resources, coordinator)
await entity.async_turn_on()
assert coordinator.commands[-1][1] == ('power', True, '/power/0')
async def test_power_write_falls_back_to_vendor_resource():
resources = _load_device('range_hood')
resources.pop('/power/0')
coordinator = _FakeCoordinator(resources)
entity = _entity(resources, coordinator)
await entity.async_turn_off()
assert coordinator.commands[-1][1] == ('power', False, '/power/vs/0')
+155
View File
@@ -251,6 +251,161 @@ class TestDetergentSoftenerDosing:
assert self._desc('detergent_low').exists_fn(rep, {}) is True
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."""
@staticmethod
def _desc(key):
return next(e for e in washer.WASHER_COURSE.entities if e.key == key)
@staticmethod
def _keys():
return ('bubble_soak', 'pre_wash', 'intensive')
@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
# 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 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."""
@staticmethod
def _desc(key):
return next(e for e in washer.WASHER_COURSE.entities if e.key == key)
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_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 TestAiEnergyLevel:
"""Issue #40 -- /energy/ailevel/vs/0 was unbound on a plain washer.
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 TestFlexWashAndComboFixturesHaveCompleteCoverage:
"""FlexWash (issue #19, previously unrecognized entirely) and
washer/dryer combo (issue #22, dry_level) dumps must both resolve to