Compare commits

...
Author SHA1 Message Date
Marc Billow 5ee72bebb8 Apply issue #56 field reports: filter direction, drop Blooming_*
Incorporates confirmed answers from the issue thread:
- FilterProgress counts down as the filter wears (100 = fresh), so the
  sensor is renamed filter_progress -> filter_life to match the direction
  instead of implying "usage" like the AC/range_hood filterUsage sensors.
- Blooming_* has no corresponding SmartThings app setting, so it's dropped
  entirely rather than kept as an unexplained diagnostic.
- OptionCode_60282 and the missing humidity sensor are confirmed correct
  as already modeled (not user-facing / genuinely absent on this model).

Five running-state diagnostics dumps (Auto/Sleep/Low/Medium/High) rule out
the original guess that Comode_* is the fan-speed selector -- it reads
'Off' on every one of them. /airflow's speed field doesn't map monotonically
to the five settings either (0 for both Auto and High, 3 for Low/Medium/
Sleep), and all five dumps were captured within about three minutes of each
other -- one poll cycle at this integration's 30s summary interval -- so
fan-speed modeling stays unbuilt pending a cleaner capture.
2026-07-23 23:56:11 +00: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 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 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
themaanda c64f58aaa5 Add cooktop and range hood support 2026-07-21 16:57:45 -05:00
68 changed files with 4469 additions and 326 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.
+4 -2
View File
@@ -195,7 +195,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 +268,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,
+4 -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"
+39 -4
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
@@ -36,6 +38,12 @@ _LOGGER = logging.getLogger(__name__)
_SEED_PATH = ['device', '0']
_REMOTE_CONTROL_DISABLED_MESSAGE = (
"Remote control is turned off on this device. Check your appliance's "
"manual for how to enable remote control before Home Assistant can "
"control it."
)
class _NoOpDescriptor:
"""StateCache requires a descriptor with an on_observation hook. This
@@ -287,6 +295,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 +316,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,24 +519,49 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
async def async_send_command(self, bound_entity: BoundEntity,
payload: Any) -> None:
"""Write a value to the device. Fire-and-forget style."""
"""Write a value to the device. Fire-and-forget style.
A description-level validate_fn (currently SwitchDesc only) runs
here rather than per-platform, so rejecting a write with a
user-facing message -- as opposed to write_fn's silent no-op below
-- is available to every platform for free. The remote-control
check runs first and applies to every platform unconditionally,
ahead of any description-specific validate_fn."""
desc = bound_entity.desc
write_fn = getattr(desc, 'write_fn', None)
if write_fn is None:
return
href = bound_entity.href
rep = self._cache.get(href or '') or {}
resources = self._cache.snapshot()
if not remote_control_enabled(resources):
raise ServiceValidationError(_REMOTE_CONTROL_DISABLED_MESSAGE)
validate_fn = getattr(desc, 'validate_fn', None)
if validate_fn is not None:
error = validate_fn(payload, rep, resources)
if error:
raise ServiceValidationError(error)
result = write_fn(payload, rep, href)
if result is None:
self._log.warning("write_fn rejected payload %r for %s", payload, href)
return
path_segs, body = result
# Apply the write optimistically before starting the settle guard,
# not after -- mark_write_pending gates every source (poll, sweep,
# observe) through the same apply(), itself included, so flipping
# this order would have the guard drop the one update it exists to
# protect. Without an optimistic value in the cache for it to hold
# onto, the settle window was just delaying the real device
# confirmation for a few seconds on every write, which read exactly
# like the write being silently reverted (issue #27).
self._observe.apply(href, body, source='optimistic')
self._observe.mark_write_pending(href)
def _do_put():
sess = self._session
if sess is None:
raise RuntimeError("no session")
self._observe.mark_write_pending(href)
code, _ = sess.post(path_segs, cbor2.dumps(body), timeout=8.0)
self._log.info("PUT %s → code %#04x", href, code)
+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.9.0"
"version": "0.10.1"
}
+15
View File
@@ -58,6 +58,9 @@ class LocalThingsNumber(LocalThingsEntity, NumberEntity):
@property
def native_min_value(self) -> float:
desc: NumberDesc = self._bound.desc
if desc.native_min_fn is not None:
return desc.native_min_fn(self.coordinator.resource(self._bound.href))
r = self._range_from_resource()
if r is not None:
return float(r[0])
@@ -67,6 +70,9 @@ class LocalThingsNumber(LocalThingsEntity, NumberEntity):
@property
def native_max_value(self) -> float:
desc: NumberDesc = self._bound.desc
if desc.native_max_fn is not None:
return desc.native_max_fn(self.coordinator.resource(self._bound.href))
r = self._range_from_resource()
if r is not None:
return float(r[1])
@@ -74,6 +80,15 @@ class LocalThingsNumber(LocalThingsEntity, NumberEntity):
return self._attr_native_max_value
return super().native_max_value
@property
def native_step(self) -> float:
desc: NumberDesc = self._bound.desc
if desc.step_fn is not None:
return desc.step_fn(self.coordinator.resource(self._bound.href))
if hasattr(self, '_attr_native_step'):
return self._attr_native_step
return super().native_step
@property
def native_value(self):
return (self.coordinator.data or {}).get(self._state_key)
+27 -2
View File
@@ -69,6 +69,7 @@ class ObserveManager:
self.last_mode_change_wall = time.time()
self._settle_until: dict[str, float] = {}
self._settle_lock = threading.Lock()
self._cache_lock = threading.Lock()
self.subscribed_hrefs: set[str] = set()
self._notified: set[str] = set()
self._last_notify_ts: float | None = None
@@ -92,11 +93,35 @@ class ObserveManager:
return True
def apply(self, href: str, rep: dict, source: str) -> bool:
"""Gate a StateCache.apply_rep call through the write-settle guard."""
"""Gate a StateCache.apply_rep call through the write-settle guard.
Merges the incoming rep onto whatever's already cached for this
href rather than handing it to StateCache.apply_rep verbatim --
apply_rep does a full replace, and Samsung devices don't always
repeat every field on every update (issue #27: a /mode/vs/0
OBSERVE notify -- and, on at least one Bespoke fridge, even the
/device/0 sweep entry for that href -- can carry just `modes`,
omitting `supportedOptions`/`supportedModes` entirely). A full
replace would silently wipe fields a select entity's
exists_fn/options_field gates on the moment one partial update
comes through, even though nothing about the device's actual
supported options changed.
`apply()` is the sole path StateCache mutations flow through in
this component (poll, sweep, and OBSERVE notify all funnel here),
so `_cache_lock` serializes the read-then-write across those
threads (DTLS reader for notifies, executor threads for poll/
sweep) -- without it, two concurrent updates for the same href
could each read the same prior rep and the second writer would
silently lose the first's fields, reintroducing the exact bug
this merge fixes.
"""
if self._is_settling(href):
self.log.debug("dropping %s update for %s (settling)", source, href)
return False
return self.cache.apply_rep(href, rep, source=source)
with self._cache_lock:
merged = {**(self.cache.get(href) or {}), **rep}
return self.cache.apply_rep(href, merged, source=source)
def on_notification(self, href: str, payload: bytes) -> None:
"""Wired as DtlsCoapSession.on_notification. Runs on the DTLS
@@ -2,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,
}
@@ -101,4 +113,52 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi
# '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,23 +3,20 @@
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,
fridge.SELF_CHECK,
*common.UNIVERSAL,
dishwasher.DIAGNOSIS,
airconditioner.CLIMATE,
airconditioner.AIR_PURIFY,
@@ -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,21 +6,14 @@ 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,
fridge.FLEX_ZONE,
fridge.AI_ENERGY_LEVEL,
fridge.REFRIGERATION,
fridge.AUTOFILL,
fridge.WELCOME_LIGHTING,
@@ -29,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,170 @@
"""Capabilities for the Samsung ARTIK051_TVTL-class air purifier family
(model AX60R5080WD/SE, issue #56).
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).
/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). Per issue #56's follow-up (five diagnostics dumps captured with
the physical unit set to Auto/Sleep/Low/Medium/High):
Light_On / Light_Off -- a plain on/off flag; MODE below models it as a
real switch, RMW-replacing just that one entry.
Comode_Off -- read 'Off' on *every* one of the five dumps,
including High/Low/Medium/Auto -- confirms this
is NOT the fan-speed selector (ruling out the
original guess); exposed read-only since its
actual purpose is still unconfirmed.
OptionCode_60282 -- confirmed opaque/not user-facing in the
SmartThings app; not modeled (same treatment as
range_hood's OptionCode_* token on the same
href).
Blooming_* -- confirmed to have no corresponding SmartThings
app setting; dropped entirely rather than kept
as an unexplained diagnostic (it did track 1:1
with Sleep mode across the five dumps -- 0 in
Sleep, 6 otherwise -- so it's plausibly an
automatic side effect of sleep mode, e.g. a
display-dimming level, but that's still a guess).
/airflow/0 and /airflow/vs/0's `speed` still isn't modeled as a real
fan-speed control: across the same five dumps it read 0 for both Auto *and*
High, and 3 for Low/Medium/*and* Sleep -- not a monotonic mapping to any
selectable level, and the dumps were all captured within about three minutes
of each other (only one poll cycle apart at this integration's 30s summary
interval), so the values may not have settled after each change before the
diagnostics snapshot was taken. Exposed read-only pending a confirmed,
stable capture -- see the issue #56 discussion for what's needed.
"""
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 0-100 percentage counting down as the filter wears --
# confirmed via issue #56: the SmartThings app shows "Filter needs changing"
# once this reaches the low end, so 100 means a fresh filter, not "100% worn".
# Named 'Filter life' (matching the direction) rather than the 'Filter usage'
# convention used elsewhere in this codebase (AC/range_hood's filterUsage
# counts up instead), so the two aren't confused.
FILTER = Capability(
href='/consumable/vs/0',
poll_tier='cold',
entities=(
SensorDesc(key='filter_life', field='x.com.samsung.da.items',
name='Filter life', 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 -- confirmed NOT the fan-speed selector (see module
# docstring), actual purpose still unconfirmed.
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')),
),
)
# /humidity/0 and /humidity/vs/0 are empty {} on every dump seen, and issue
# #56 confirms this model has no humidity sensor at all (dust/odor only) --
# 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'),
]
@@ -206,6 +206,16 @@ CURRENT_LIMIT = 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 = [
# All-zero and ambiguously encoded on this model (2-value arrays); the
@@ -219,8 +229,9 @@ _AC_IGNORED = [
# 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 and fridge.SELF_CHECK above/in the by_type registry,
# both of which have a confirmed, cleanly modelable contract.
# 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
@@ -235,4 +246,8 @@ _AC_IGNORED = [
]
# 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,36 +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'),
# 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})),
),
)
# ---------------------------------------------------------------------------
# Refrigeration modes (rapid cooling)
# ---------------------------------------------------------------------------
@@ -588,52 +565,6 @@ FLEX_ZONE = Capability(
),
)
# ---------------------------------------------------------------------------
# AI energy-saving level
#
# supportedAiLevel is a single-entry list ('1' only) on some hardware, where
# a select would offer no real choice — gated to >1 supported level.
# ---------------------------------------------------------------------------
AI_ENERGY_LEVEL = Capability(
href='/energy/ailevel/vs/0',
poll_tier='cold',
entities=(
# Only levels '1'/'2' have translated labels in strings.json/translations;
# a higher supportedAiLevel entry renders as its raw value until added.
SelectDesc(key='ai_energy_level', field='aiLevel',
name='AI energy level', icon='mdi:leaf',
translation_key='ai_energy_level',
entity_category='config',
options_field='supportedAiLevel',
exists_fn=lambda rep, resources: (
not rep or (
isinstance((sl := rep.get('supportedAiLevel')), (list, tuple))
and len(sl) > 1)),
write_fn=lambda p, rep, href=None: (
['energy', 'ailevel', 'vs', '0'], {'aiLevel': p})),
),
)
# ---------------------------------------------------------------------------
# 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)
# ---------------------------------------------------------------------------
@@ -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',
}
+12 -7
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": {
@@ -143,12 +154,6 @@
"180": "3 hr",
"240": "4 hr"
}
},
"ai_energy_level": {
"state": {
"1": "Level 1",
"2": "Level 2"
}
}
},
"sensor": {
@@ -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": {
@@ -143,12 +154,6 @@
"180": "3 hr",
"240": "4 hr"
}
},
"ai_energy_level": {
"state": {
"1": "Level 1",
"2": "Level 2"
}
}
},
"sensor": {
+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"
]
}
]
}
}
]
}
+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": {}
}
}
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"state_keys": [
"alarm_code",
"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_life",
"fine_dust",
"odor",
"operating_mode",
"power_energy_kwh",
"power_switch",
"power_watts",
"super_fine_dust"
]
}
+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"
]
}
@@ -1,5 +1,6 @@
{
"state_keys": [
"ai_energy_level",
"alarm_code",
"autofill",
"cooler_setpoint",
+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"
}
}
]
}
+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": {
+201
View File
@@ -7,12 +7,16 @@ from unittest.mock import AsyncMock, patch
import cbor2
import pytest
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import issue_registry as ir
from custom_components.localthings.const import (
CONF_HOST, DOMAIN, SUMMARY_INTERVAL_S,
)
from custom_components.localthings.coordinator import LocalThingsCoordinator
from custom_components.localthings.registry.capabilities.common import (
remote_control_enabled,
)
from custom_components.localthings.observe import MODE_OBSERVE, MODE_POLL, PUSH_HEALTH_WINDOW_S
from .conftest import ENTRY_DATA, MOCK_SERIAL
@@ -132,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:
@@ -634,3 +663,175 @@ async def test_write_marks_href_pending_before_post(
await coordinator.async_send_command(bound, 5)
assert coordinator._observe._settle_until.get('/test/vs/0') is not None
async def test_send_command_applies_write_optimistically_before_settling(
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
) -> None:
"""The cache must reflect a write immediately, and stay put against a
stale echo for the rest of the settle window -- otherwise
mark_write_pending has nothing to protect and just delays the real
device confirmation instead, which reads to a user as the write being
silently reverted (issue #27)."""
from custom_components.localthings.registry.discovery import BoundEntity
from custom_components.localthings.registry.entities import NumberDesc
fake = mock_coordinator_observe_session
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
def _write_fn(payload, rep, href):
return (['some', 'path'], {'value': payload})
desc = NumberDesc(key='test', field='value', write_fn=_write_fn)
bound = BoundEntity(href='/test/vs/0', capability=coordinator.bound[0].capability, desc=desc)
with patch.object(fake, 'subscribe'):
fake.post = lambda *a, **k: (0x44, b'')
await coordinator.async_send_command(bound, 5)
# The optimistic value is visible right away, without waiting for a
# device response.
assert coordinator._cache.get('/test/vs/0') == {'value': 5}
# A stale update racing in behind the write (e.g. a poll/notify that
# was already in flight before the PUT) must not clobber it while the
# settle window is open.
applied = coordinator._observe.apply('/test/vs/0', {'value': 0}, source='observe')
assert applied is False
assert coordinator._cache.get('/test/vs/0') == {'value': 5}
class TestRemoteControlEnabled:
"""remote_control_enabled (registry/capabilities/common.py) is the
single source of truth for the /remotectrl on/off signal, shared by
the Smart Control binary_sensor descriptors and the coordinator's
write guard alike."""
def test_vs_fallback_href_true_is_enabled(self):
resources = {'/remotectrl/vs/0': {'x.com.samsung.da.remoteControlEnabled': 'true'}}
assert remote_control_enabled(resources) is True
def test_vs_fallback_href_false_is_disabled(self):
resources = {'/remotectrl/vs/0': {'x.com.samsung.da.remoteControlEnabled': 'false'}}
assert remote_control_enabled(resources) is False
def test_generic_href_true_is_enabled(self):
resources = {'/remotectrl/0': {'value': True}}
assert remote_control_enabled(resources) is True
def test_generic_href_false_is_disabled(self):
resources = {'/remotectrl/0': {'value': False}}
assert remote_control_enabled(resources) is False
def test_generic_href_wins_when_both_present(self):
resources = {
'/remotectrl/0': {'value': True},
'/remotectrl/vs/0': {'x.com.samsung.da.remoteControlEnabled': 'false'},
}
assert remote_control_enabled(resources) is True
def test_assumes_enabled_when_capability_absent(self):
assert remote_control_enabled({}) is True
async def test_send_command_blocked_when_remote_control_disabled(
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
) -> None:
"""A device reporting remote control off must reject every write with
a user-facing message, ahead of write_fn's silent-no-op path and any
description-level validate_fn."""
from custom_components.localthings.registry.discovery import BoundEntity
from custom_components.localthings.registry.entities import NumberDesc
fake = mock_coordinator_observe_session
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
coordinator._cache.apply_rep(
'/remotectrl/vs/0',
{'x.com.samsung.da.remoteControlEnabled': 'false'},
source='test',
)
def _write_fn(payload, rep, href):
return (['some', 'path'], {'value': payload})
desc = NumberDesc(key='test', field='value', write_fn=_write_fn)
bound = BoundEntity(href='/test/vs/0', capability=coordinator.bound[0].capability, desc=desc)
posted = False
def _post(*a, **k):
nonlocal posted
posted = True
return (0x44, b'')
with patch.object(fake, 'subscribe'):
fake.post = _post
with pytest.raises(ServiceValidationError, match="Remote control is turned off"):
await coordinator.async_send_command(bound, 5)
assert posted is False
async def test_send_command_allowed_when_remote_control_enabled(
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
) -> None:
"""The same write goes through once remote control reports enabled."""
from custom_components.localthings.registry.discovery import BoundEntity
from custom_components.localthings.registry.entities import NumberDesc
fake = mock_coordinator_observe_session
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
coordinator._cache.apply_rep(
'/remotectrl/vs/0',
{'x.com.samsung.da.remoteControlEnabled': 'true'},
source='test',
)
def _write_fn(payload, rep, href):
return (['some', 'path'], {'value': payload})
desc = NumberDesc(key='test', field='value', write_fn=_write_fn)
bound = BoundEntity(href='/test/vs/0', capability=coordinator.bound[0].capability, desc=desc)
with patch.object(fake, 'subscribe'):
fake.post = lambda *a, **k: (0x44, b'')
await coordinator.async_send_command(bound, 5)
assert coordinator._observe._settle_until.get('/test/vs/0') is not None
async def test_send_command_remote_control_check_precedes_validate_fn(
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
) -> None:
"""When both a disabled remote-control state and a failing validate_fn
apply to the same write, the remote-control message wins -- it's the
more actionable of the two and is checked first."""
from custom_components.localthings.registry.discovery import BoundEntity
from custom_components.localthings.registry.entities import SwitchDesc
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
coordinator._cache.apply_rep(
'/remotectrl/vs/0',
{'x.com.samsung.da.remoteControlEnabled': 'false'},
source='test',
)
def _write_fn(payload, rep, href=None):
return (['some', 'path'], {'value': payload})
def _validate_fn(payload, rep, resources):
return "always rejected"
desc = SwitchDesc(key='test', field='value', write_fn=_write_fn, validate_fn=_validate_fn)
bound = BoundEntity(href='/test/vs/0', capability=coordinator.bound[0].capability, desc=desc)
with pytest.raises(ServiceValidationError, match="Remote control is turned off"):
await coordinator.async_send_command(bound, 'On')
+22
View File
@@ -34,6 +34,28 @@ def test_apply_writes_through_when_not_settling():
assert mgr.cache.get('/oven/vs/0') == {'a': 1}
def test_apply_merges_partial_update_onto_prior_rep():
"""Regression test for issue #27: a Bespoke fridge's /mode/vs/0 notify
(and even a later sweep entry for that href) can carry only `modes`,
omitting `supportedOptions` entirely. A full replace would wipe
`supportedOptions` from the cache the moment that partial update
arrives, even though the device's supported options didn't change --
which is exactly what made the flex-zone select disappear."""
mgr = _manager()
full = {
'x.com.samsung.da.modes': ['CVN_CONVERTIBLE_ZONE', 'CV_FDR_MEAT'],
'x.com.samsung.da.supportedOptions': ['CV_FDR_WINE', 'CV_FDR_MEAT'],
}
mgr.apply('/mode/vs/0', full, source='poll')
partial = {'x.com.samsung.da.modes': ['CVN_CONVERTIBLE_ZONE', 'WATERFILTER_ENABLE']}
mgr.apply('/mode/vs/0', partial, source='observe')
cached = mgr.cache.get('/mode/vs/0')
assert cached['x.com.samsung.da.modes'] == ['CVN_CONVERTIBLE_ZONE', 'WATERFILTER_ENABLE']
assert cached['x.com.samsung.da.supportedOptions'] == ['CV_FDR_WINE', 'CV_FDR_MEAT']
def test_apply_drops_update_during_settle_window():
mgr = _manager()
mgr.cache.apply_rep('/oven/vs/0', {'a': 1}, source='seed')
+117
View File
@@ -0,0 +1,117 @@
"""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_life', 'device_active',
'diagnosis_status', 'fan_speed_level', 'fan_direction',
'display_light', 'operating_mode',
):
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_life_reads_named_consumable_item():
"""FilterProgress is confirmed (issue #56) to count down as the filter
wears -- 100 means fresh, not "100% used"."""
assert _state()['filter_life'] == 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', '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', 'Light_Off', 'OptionCode_60282',
]},
)
def test_operating_mode_is_a_read_only_diagnostic():
"""Comode_* surfaces as a raw diagnostic sensor rather than a select/
control -- issue #56's five running-state dumps confirmed it reads 'Off'
regardless of the device's actual fan setting, ruling out the original
guess that it was the fan-speed selector; its real purpose is still
unconfirmed (see the air_purifier.py module docstring)."""
operating_mode = next(e for e in air_purifier.MODE.entities if e.key == 'operating_mode')
rep = {'x.com.samsung.da.options': ['Comode_Off']}
assert operating_mode.rep_fn(rep) == 'Off'
assert not hasattr(operating_mode, 'write_fn')
def test_blooming_not_modeled():
"""Confirmed (issue #56) to have no corresponding SmartThings app
setting -- dropped entirely rather than kept as an unexplained
diagnostic."""
assert not any(e.key == 'blooming_level' for e in air_purifier.MODE.entities)
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
+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'
+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'
+8 -75
View File
@@ -231,84 +231,17 @@ class TestArtik051AndTp2xFixturesHaveCompleteCoverage:
assert state['flex_zone_mode'] == 'CV_FDR_BEVERAGE'
class TestSelfCheckError:
"""Self-check diagnostic error list -- surfaced on hardware that reports
x.com.samsung.da.error, joined into a single display string."""
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.
def _desc(self):
return next(e for e in fridge.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
class TestAiEnergyLevel:
"""AI energy-saving level select on /energy/ailevel/vs/0 -- only
exposed when the device actually offers more than one level; hardware
that only ever reports a single supported level keeps this ignored
(see capabilities/ignored.py)."""
def test_href(self):
assert fridge.AI_ENERGY_LEVEL.href == '/energy/ailevel/vs/0'
def test_hidden_with_single_supported_level(self):
desc = fridge.AI_ENERGY_LEVEL.entities[0]
assert desc.exists_fn({'aiLevel': '1', 'supportedAiLevel': ['1']}, {}) is False
def test_shown_with_multiple_supported_levels(self):
desc = fridge.AI_ENERGY_LEVEL.entities[0]
assert desc.exists_fn(
{'aiLevel': '1', 'supportedAiLevel': ['1', '2']}, {}) is True
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 = fridge.AI_ENERGY_LEVEL.entities[0]
assert desc.exists_fn({}, {}) is True
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 5-char string would otherwise wrongly pass `> 1`."""
desc = fridge.AI_ENERGY_LEVEL.entities[0]
assert desc.exists_fn(
{'aiLevel': '1', 'supportedAiLevel': '12'}, {}) is False
def test_write(self):
desc = fridge.AI_ENERGY_LEVEL.entities[0]
path, body = desc.write_fn('2', {})
assert path == ['energy', 'ailevel', 'vs', '0']
assert body == {'aiLevel': '2'}
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):
"""refrigerator_device.json's supportedAiLevel was extended to two
entries (['1', '2']) specifically to exercise this select -- the
real captured TP1X_REF_21K_US dump only ever reports one entry, so
this capability is synthetic-fixture-only for now."""
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
+78 -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
@@ -199,6 +227,55 @@ def test_registry_reproduces_golden_state_keys_for_tp1x_rac():
)
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