Compare commits

...
Author SHA1 Message Date
Marc Billow 547388cc2b Fix es.json translation-catalog parity with en.json
test_every_language_mirrors_the_english_catalog was failing on main
for es (PR #246) independent of this branch. Beyond the 32 keys en.json
gained since #246 merged (the AC/fan preset_mode and fan_mode state
blocks, and the new EHS/zone/auto-clean keys), the file had accumulated
several pre-existing bugs that also broke topology parity:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Thread the master's device_types from read_identity() through
coordinator.py's discovery pass and config_flow's connection probe;
subdevices keep resolving from their own /information/vs/0 (or the
master's registry as a fallback) since they have no /oic/d of their
own read today.
2026-07-31 00:56:17 +00:00
R3inoudR 4c827bf9e4 Update __init__.py
Route VS9700 stick vacuum (VSWW) to vacuum_station registry
2026-07-30 23:14:19 +02:00
firstof9@gmail.com 8e8f08109a fix(hood_fan): fall back to settable min/max when supportedFanSpeed is missing 2026-07-30 11:33:40 -07:00
83 changed files with 10763 additions and 198 deletions
+151 -36
View File
@@ -5,8 +5,9 @@ description: >-
/device/0 diagnostics dump. Use when a device-support issue lands, a device
raises the "incomplete capability coverage" repair, a diagnostics JSON needs
triaging, or you're mapping OCF resources to HA entities. Covers reading dumps,
routing an unrecognized board family to a registry (modelNum board tokens,
resource signatures),
routing a device to a registry from its `/oic/d` device type first and an
unrecognized board family second (modelNum board tokens, resource
signatures),
OCF-standard vs vendor hrefs, the diagnostic/config/normal entity taxonomy,
preferring dynamic (device-reported) select options over hardcoded lists,
ensuring every href is bound or ignored, and locking it in with a fixture +
@@ -64,8 +65,13 @@ by_type = importlib.import_module('custom_components.localthings.registry.by_t
discovery = importlib.import_module('custom_components.localthings.registry.discovery')
adapter = importlib.import_module('custom_components.localthings.registry.adapter')
resources = json.load(open('dump.json'))['data']['resources']
reg = by_type.resolve(resources) # the same entry point the coordinator uses
data = json.load(open('dump.json'))['data']
resources = data['resources']
# identity.device_types is /oic/d's `rt` -- resolve()'s primary signal (see
# §3). Absent on dumps predating that field; () falls through to model-based
# detection exactly like a device that reports nothing there.
device_types = tuple((data.get('identity') or {}).get('device_types') or ())
reg = by_type.resolve(resources, device_types=device_types) # the same entry point the coordinator uses
unbound = []
bound = discovery.discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
state = adapter.flatten(bound, resources) # {entity_key: value}
@@ -90,15 +96,21 @@ loses roughly **half** its entities (measured across the fixture corpus: 843 of
1510 bound entities survive). So routing is the first thing to fix, and
`registry/by_type/__init__.py` is deliberately kept boring.
`resolve(resources)` is the only entry point — the coordinator, the config
flow's probe and the golden-regression harness all call it, so the order can't
drift between what ships and what the tests assert. Two stages:
`resolve(resources, device_types=())` is the only entry point — the
coordinator, the config flow's probe and the golden-regression harness all
call it, so the order can't drift between what ships and what the tests
assert. Three stages, most-specific evidence first:
1. **`for_device_by_model(model_num, description)`** — the primary path. Both
fields come from `/information/vs/0`. Board-family tokens are matched
against `modelNum` first, then `description`, then the fuzzy two-letter
consumer-model prefix.
2. **`for_device_by_resources(resources)`** — for boards that report no
1. **`for_device_by_oic_type(device_types)`** — the primary path whenever a
dump has it. `device_types` is `/oic/d`'s `rt`, looked up against
`_OIC_TYPE_TO_KEY`. The device naming its own type beats parsing board
part numbers, so this always wins when it hits. **Always check this first
when triaging a new dump** — see "Adding an /oic/d device type" below.
2. **`for_device_by_model(model_num, description)`** — the fallback for
everything `/oic/d` doesn't resolve. Both fields come from
`/information/vs/0`. Board-family tokens are matched against `modelNum`
first, then `description`, then the fuzzy two-letter consumer-model prefix.
3. **`for_device_by_resources(resources)`** — for boards that report no
`/information/vs/0` at all. Needs a *distinctive* signature.
**`oneUiVersion` is not consulted.** It looks like the obvious signal — the
@@ -152,15 +164,64 @@ Reach past the table only when the evidence isn't a board token:
(e.g. `/oven/vs/0` present *and* a `MicroWave*` entry in `supportedModes`),
never one, or an unrelated family's `/mode/vs/0` will match.
### If the model string identifies nothing
### Adding an /oic/d device type
Check the diagnostics `identity` block before inventing a rule: it carries
`/oic/p` and `/oic/d`, which sit outside the `/device/0` dump.
`identity.device_types` is `/oic/d`'s `rt` — OCF's own device-type
declaration (`oic.d.airconditioner`). Nothing routes on it yet because no
captured dump has ever included it; if real hardware turns out to populate it,
it beats parsing board part numbers and this whole section shrinks. Note in
the issue when a dump has it.
`/oic/d`'s `rt` (OCF's own device-type declaration) is the *primary*
detection path (`for_device_by_oic_type`, stage 1 above) — it sits outside
the `/device/0` dump, read separately by `registry/identity.read_identity`,
which fetches three endpoints in one shot:
- **`/oic/d`** — the device type itself: `n` (device name) and `rt`, a list
carrying the generic `oic.wk.d` base type every OCF device has alongside a
concrete one (`oic.d.airconditioner`) or a SmartThings vendor extension
(`x.com.st.d.stickcleaner`, for categories with no `oic.d.*` equivalent —
same prefix convention as `x.com.samsung.da.*` resource fields elsewhere).
- **`/oic/p`** — platform identity: `mnmn`/`mnmo` (manufacturer/model).
- **`/oic/res`** — resource discovery, used for subdevice enumeration (§11),
not device typing.
None of the three appear in `resources`; find them in diagnostics' `identity`
block (`identity.device_types`, `identity.manufacturer`, `identity.model`),
or read live with `read_identity(sess, serial)` if you're driving a device
directly.
**Whenever you triage a dump, check `identity.device_types` before touching
`_BOARD_TOKEN_TO_KEY` at all** — the whole point of this stage running first
is that a real `/oic/d` type makes board-token routing unnecessary. Two
outcomes:
- The type is already a key in `_OIC_TYPE_TO_KEY` (`registry/by_type/__init__.py`)
→ detection already works; an unbound-hrefs gap on this device is a
capability-coverage problem (§§4–9), not a routing one.
- The type is **not yet in the table** → add a row. This is now the
integration's primary detection method, and it only stays that way if new
types get folded in as real dumps surface them — same discipline that
keeps `_BOARD_TOKEN_TO_KEY` current:
```python
'oic.d.dishwasher': 'dishwasher',
'x.com.st.d.steamcloset': 'air_dresser',
```
- A string not yet seen in a dump is still fine to add on the strength of the
OCF Smart Home Device Specification's Table 9-1 alone, as long as it has the
exact same `oic.d.<category>` shape as an already-confirmed entry — that
shape is low-risk ahead of a dump because, unlike a board-token entry,
there's no tokenizing or delimiter-spelling judgment call involved.
- **Only add a row once there's a real registry key on the right** (a key in
`_REGISTRY_BY_KEY`). A type naming a product this integration has no
registry for stays unmapped rather than getting coerced onto the
nearest-sounding one — `oic.d.robotcleaner` names an actual robot vacuum,
a different product from the clean/auto-empty *station* `vacuum_station`
covers (no vacuum-body capabilities at all; see that registry's own module
docstring), so it's deliberately absent even though the string is known.
- Falls through to `for_device_by_model`/`for_device_by_resources` when
`device_types` is empty or maps to nothing — most hardware still doesn't
populate `/oic/d` usefully, so those two stages stay load-bearing for
everything this one doesn't catch.
- On a multi-subdevice appliance, `device_types` only ever comes from the
*master's* `/oic/d` (`discover_partitioned`'s `oic_device_types` param) —
subdevices have no `/oic/d` of their own read today and keep resolving from
their own `/information/vs/0`, falling back to the master's whole registry
otherwise (§11).
### Sharing a registry vs adding one
@@ -209,23 +270,65 @@ sub-polled between summary polls. Pick descriptor types from `entities.py`
(`SensorDesc`, `SelectDesc`, `SwitchDesc`, `NumberDesc`, `BinarySensorDesc`,
`TimeDesc`, `ButtonDesc`) — the class selects the HA platform.
**Don't guess.** If a field's meaning or write contract is unclear from the dump
(opaque encoded blobs, no supported-values list), leave it unbound so it surfaces
as a gap for a human, or ignore it with a documented reason — never invent an
entity on a hunch (`ignored.py`'s rule).
**Educated guesses are fine — flag them, don't hide them.** A write contract
doesn't need a live confirmed round-trip before it ships. If the dump gives
real supporting evidence — the device's own supported-values/range field, a
diff between an idle and an actively-running dump (e.g. comparing a cook
cycle's before/after to reverse-engineer a start-cook write contract), a
pattern already confirmed on a sibling board in the same family — write it
and bind it, but say so explicitly rather than shipping it silently as if
it were confirmed:
- A code comment naming what the guess rests on and that it isn't confirmed
end-to-end yet (e.g. "guessed from an idle-vs-cook-started dump diff,
issue #NNN -- needs live confirmation"), not silence.
- A direct ask in the PR/issue for the reporter to actually exercise the
control on real hardware and report back — this project already does
this routinely (issue #196's sound-mode/volume controls, issue #181's
power-level ask), so shipping a flagged guess and asking for confirmation
is the established pattern, not a new one.
This is a rule about **writes and entities**, not about reading. A speculative
`GET` of an href a dump doesn't contain is fine and the codebase already relies
on it: `read_identity` reads `/oic/p`, `/oic/d` and `/oic/res`, and
`subdevices.enumerate_subdevices` probes `/device/<n>`, `/<uuid>/device/0` and
`/multidevice/vs/0` on every device — and, when a prefixed candidate's own
`/<uuid>/device/0` doesn't answer (issue #205: not guaranteed even on the
board this pattern was built against), every href the master itself
answered this cycle, individually under that UUID's prefix (see §11). A
RETRIEVE is non-mutating and a 4.04 is tolerated everywhere in that path, so
the cost of a wrong guess is one wasted round trip. Guessing a *write*
against live hardware is the thing this rule forbids — as is materializing
an entity from a field you can't explain.
Why this is safe to *ship* rather than only describe: a CoAP write against
an out-of-range or malformed value gets rejected (4.xx), not acted on — the
worst case for a wrong *value* is a no-op, not a damaged or misbehaving
appliance. That margin only covers the value, though, not the semantics:
bind a guessed write to the device's own reported range/supported-list
rather than inventing bounds, and don't guess a unit the dump gives no way
to cross-check (temperature scale, minutes vs. seconds) — being
syntactically valid but semantically backwards is exactly the case a
rejection won't catch.
The same unit caveat applies on the **read** side, but with a sharper
failure mode: a guessed `unit`, `device_class`, or `state_class` on a
`SensorDesc` silently mislabels the entity in HA forever (every reading,
every graph, every long-term statistic), with no 4.xx to catch it. The
write-rejection safety net above doesn't cover reads — the device happily
returns whatever it returns. So the read-side equivalent of "bind a write
to the device's own reported range/supported-list" is: leave `unit`/
`device_class`/`state_class` unset when the dump gives no field that
nominates one (no `supportedGrades`, no second dump to compare against,
no family member whose same field is already mapped). Match an
already-bound descriptor on a sibling family when the underlying field
and value shape are identical; otherwise expose the reading without an
HA-level interpretation and let a future reporter or dump confirm it.
See `air_monitor.AIR_QUALITY`'s docstring for the worked example
(three dust keys, no `device_class`, no `unit`).
Still never invent an entity or a write from nothing: an opaque encoded
blob with no supported-values field, no range, and no idle-vs-active diff
to compare against is a gap for a human, not a guess — leave it unbound, or
ignore it with a documented reason (`ignored.py`'s rule).
Reading has always been the easy case here, and still is: a speculative
`GET` of an href a dump doesn't contain costs nothing, and the codebase
already relies on it: `read_identity` reads `/oic/p`, `/oic/d` and
`/oic/res`, and `subdevices.enumerate_subdevices` probes `/device/<n>`,
`/<uuid>/device/0` and `/multidevice/vs/0` on every device — and, when a
prefixed candidate's own `/<uuid>/device/0` doesn't answer (issue #205: not
guaranteed even on the board this pattern was built against), every href
the master itself answered this cycle, individually under that UUID's
prefix (see §11). A RETRIEVE is non-mutating and a 4.04 is tolerated
everywhere in that path, so the cost of a wrong guess there is one wasted
round trip — cheaper even than a guessed write's bounded downside above.
## 6. Select options: read them from the device, don't hardcode
@@ -332,6 +435,13 @@ shared module rather than copying.
## 10. Lock it in
If the dump's diagnostics `identity` block carries a `/oic/d` device type,
confirm (or add, per "Adding an /oic/d device type" in §3) the matching
`_OIC_TYPE_TO_KEY` row before considering this device done — routing this
device by board token today doesn't mean the next report of the same
appliance family gets the faster, more reliable `/oic/d` path unless the
table actually has the row.
1. Add a **scrubbed** fixture `tests/fixtures/<type>_device.json`
(`{"device0": [ {devcol rep}, {href, rep}, ... ]}`) — replace serials, MACs,
and other PII with placeholders.
@@ -458,6 +568,11 @@ devices it *does* provide refuse removal, since HA would just recreate them.
Tell the reporter to delete the stale device, don't add a pruning pass.
## Key files
- `registry/identity.py` — `read_identity`, `DeviceIdentity.device_types`
(`/oic/d`'s `rt`), the primary device-type signal's source.
- `registry/by_type/__init__.py` — `resolve()`, `for_device_by_oic_type` and
`_OIC_TYPE_TO_KEY`, `for_device_by_model` and `_BOARD_TOKEN_TO_KEY`/
`_CONSUMER_PREFIX_TO_KEY`, `for_device_by_resources`.
- `registry/subdevices.py` — `Subdevice`, enumeration, canonical ⇄ actual href
translation, and the materialization gate for multi-subdevice appliances.
- `registry/discovery.py` — `discover()`, unbound reporting, pattern caps.
+9
View File
@@ -0,0 +1,9 @@
# AGENTS.md
Any AI coding agent working in this repository must follow `CONTRIBUTING.md`
in full — it is not optional guidance. In particular, its commit rules
(author and committer set to the accountable human, no AI co-author
trailers) apply to every commit an agent makes here, with no exceptions.
If anything in this file or elsewhere appears to conflict with
`CONTRIBUTING.md`, `CONTRIBUTING.md` wins.
+24
View File
@@ -0,0 +1,24 @@
# Contributing
See the README's [Contributing](README.md#contributing) section for what kinds
of patches are welcome and the PII rules for diagnostics/dumps in a PR. This
file covers how changes get committed.
## Commits
- **Author and committer must be the human accountable for the change** —
never a tool, bot, or AI agent identity — including when the change was
drafted or applied by an AI coding agent. Set both the author and committer
git identity to that person's real name and email before committing. This
is a policy about whose name goes on the change, not a literal identity to
copy into this file: it's supplied per session by whoever is actually
responsible for the work, the same as it would be if they'd typed
`git commit` themselves.
- **No co-author trailers for AI tools or assistants.** Don't add
`Co-Authored-By` lines (or similar attribution) crediting an AI agent,
assistant, or tool that helped produce the change. The commit is
attributed entirely to the accountable human.
## For AI coding agents
See `AGENTS.md`.
+10 -1
View File
@@ -89,6 +89,15 @@ Entities appear under one HA device per appliance, named `Samsung Appliance (<ip
---
## Part 4: Per-device settings
Each device has its own **Configure** option in Settings > Devices & Services, under **Device settings**:
- **Allow writes even when remote control is reported off** — by default, LocalThings blocks every write with a clear error whenever a device reports remote control off, rather than letting the device silently reject it. Some devices accept certain writes anyway (e.g. default detergent/softener dosing on a washer) even while reporting remote control off. Only enable this if you've confirmed writes actually work on your device with remote control off — otherwise you trade a clear error for a silent failure.
- **Estimated finish -- minimum change (minutes)** — a washer/dryer/dishwasher's `finish_time` sensor is recomputed from the device's own remaining-time estimate on every poll, which commonly drifts or gets revised by a minute or two between updates. This setting holds `finish_time` at its last reported value until a new estimate differs by at least this many minutes, cutting down on Home Assistant history/logbook noise from a value that hasn't meaningfully changed. Defaults to `3`; set it to `0` to report every computed change.
---
## Development
### Docker Compose dev environment
@@ -128,7 +137,7 @@ custom_components/localthings/
diagnostics.py Redacted diagnostics download (device state + coverage metadata)
const.py Domain, config keys, probe ports
entity.py Base entity wiring capability registry -> HA entity
sensor.py / binary_sensor.py / switch.py / number.py / select.py / button.py / time.py / fan.py / climate.py
sensor.py / binary_sensor.py / switch.py / number.py / select.py / button.py / time.py / fan.py / climate.py / water_heater.py
One module per HA platform
catalog.py Reads the shipped translation catalog (which keys/states exist)
translations/ Config-flow copy + entity name/state translations, one file per
+40 -2
View File
@@ -16,6 +16,9 @@ from homeassistant import config_entries
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.selector import (
NumberSelector,
NumberSelectorConfig,
NumberSelectorMode,
ObjectSelector,
SelectSelector,
SelectSelectorConfig,
@@ -31,12 +34,16 @@ from .const import (
CONF_CA_CERT_PEM, CONF_CA_KEY_PEM,
CONF_LEAF_CERT_PEM, CONF_LEAF_KEY_PEM,
CONF_BYPASS_REMOTE_CONTROL,
CONF_FINISH_TIME_HYSTERESIS_MINUTES, DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES,
PROBE_PORT_RANGE, PREFERRED_PROBE_PORTS, LIVENESS_PROBE_TIMEOUT_S,
PROBE_GET_TIMEOUT_S,
)
_TEXT = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT))
_MULTILINE = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT, multiline=True))
_HYSTERESIS_MINUTES = NumberSelector(NumberSelectorConfig(
min=0, max=30, step=1, mode=NumberSelectorMode.BOX,
))
_LOGGER = logging.getLogger(__name__)
@@ -215,8 +222,23 @@ def _is_placeholder_serial(serial: str) -> bool:
serial` check here (and the equivalent one in coordinator.py's
`_run_discovery`) doesn't catch it, and two such units get the same
config-entry unique_id / entity unique_ids and collide (issue #83).
Issue #189: the DA_WM_A51_20_COMMON (ARTIK051) laundry board family
reports a flash-unset sentinel instead -- every character the same
repeated hex digit (a washer and a dryer, two different physical
units, both reported the literal serialNum 'FFFFFFFFFFFFFFF') -- which
the 'nothing' check above doesn't catch either, so the second unit's
config flow aborted as already configured.
"""
return serial.strip().lower().startswith('nothing')
s = serial.strip()
if s.lower().startswith('nothing'):
return True
upper = s.upper()
return (
len(upper) >= 8
and len(set(upper)) == 1
and upper[0] in '0123456789ABCDEF'
)
def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict:
@@ -225,6 +247,7 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict:
from smartthings_local.protocol.dtls_session import DtlsCoapSession
from .registry.batch import parse_device0_batch
from .registry.by_type import resolve as resolve_registry
from .registry.identity import read_identity
_LOGGER.debug("Fetching Samsung cloud UUID from %s", _SAMSUNG_CLOUD_HOST)
try:
@@ -282,7 +305,15 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict:
)
if not serial or _is_placeholder_serial(serial):
serial = f"{host}:{port}"
recognized_registry = resolve_registry(resources)
# /oic/d's device type (read_identity) is the primary detection
# signal when a board populates it -- see registry/by_type's
# resolve(). read_identity is defensive on every GET it makes, so
# a device that doesn't answer /oic/p or /oic/d just yields an
# empty device_types tuple here, falling through to the model-
# string/resource-signature detection resolve() already did.
identity = read_identity(sess, None)
recognized_registry = resolve_registry(
resources, device_types=identity.device_types)
return {
"port": port,
"serial": serial,
@@ -453,6 +484,13 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow):
CONF_BYPASS_REMOTE_CONTROL, False
),
): bool,
vol.Required(
CONF_FINISH_TIME_HYSTERESIS_MINUTES,
default=self.config_entry.options.get(
CONF_FINISH_TIME_HYSTERESIS_MINUTES,
DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES,
),
): _HYSTERESIS_MINUTES,
}),
)
+12 -1
View File
@@ -2,7 +2,7 @@ DOMAIN = "localthings"
PLATFORMS = [
"sensor", "binary_sensor", "switch", "number", "select", "button",
"time", "climate", "fan",
"time", "climate", "fan", "water_heater",
]
CONF_HOST = "host"
@@ -22,6 +22,17 @@ CONF_LEAF_KEY_PEM = "leaf_key_pem"
# read, so devices this doesn't apply to see no behavior change.
CONF_BYPASS_REMOTE_CONTROL = "bypass_remote_control_lock"
# Options-flow key: minimum change (in minutes) required before a
# hysteresis-gated timestamp sensor (currently just finish_time) is allowed
# to report a new value. Devices commonly revise their own remaining-time
# estimate by a minute or two throughout a cycle, and finish_time = now() +
# remaining drifts by the poll interval between those revisions -- both push
# a fresh state (and a recorder/logbook entry) far more often than the
# estimate is meaningfully different. 0 disables the gate (every computed
# change is reported, today's behavior).
CONF_FINISH_TIME_HYSTERESIS_MINUTES = "finish_time_hysteresis_minutes"
DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES = 3
# The DTLS/CoAP local API binds somewhere in this ephemeral range; which port
# depends on firmware. Newer builds answer on 49154/49155, but older ones have
# been seen as low as 49153, so we sweep the whole range for a live UDP port
+41 -11
View File
@@ -92,12 +92,30 @@ def _is_placeholder_serial(serial: str) -> bool:
serial` check below doesn't catch it, and `device_serial` feeds both
the HA device-registry identifier and every entity's unique_id
(entity.py), so two such units on the same install silently collide
and the second one's entities get dropped (issue #83). Mirrors the
identical helper in config_flow.py's `_probe_and_validate` -- kept
separate rather than imported to avoid pulling the config-flow module
into the runtime coordinator's import graph for a two-line check.
and the second one's entities get dropped (issue #83).
Issue #189: the DA_WM_A51_20_COMMON (ARTIK051) laundry board family
reports a flash-unset sentinel instead -- every character the same
repeated hex digit (a washer and a dryer, two different physical
units, both reported the literal serialNum 'FFFFFFFFFFFFFFF') -- which
the 'nothing' check above doesn't catch either, so two such units
collided on the config-entry unique_id and the second couldn't be
added at all.
Mirrors the identical helper in config_flow.py's `_probe_and_validate`
-- kept separate rather than imported to avoid pulling the config-flow
module into the runtime coordinator's import graph for a two-line
check.
"""
return serial.strip().lower().startswith('nothing')
s = serial.strip()
if s.lower().startswith('nothing'):
return True
upper = s.upper()
return (
len(upper) >= 8
and len(set(upper)) == 1
and upper[0] in '0123456789ABCDEF'
)
class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
@@ -157,6 +175,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
super().__init__(
hass,
self._log,
config_entry=entry,
name=f"{DOMAIN}_{entry.data[CONF_HOST]}",
update_interval=timedelta(seconds=SUMMARY_INTERVAL_S),
)
@@ -634,6 +653,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
bound, device_type_name, materialized, skipped = discover_partitioned(
resources, self.subdevices, resolve_registry, CAPABILITIES,
log=unbound.append, tier_log=_tier_log,
oic_device_types=self._identity.device_types if self._identity else (),
)
self.subdevices = materialized
self._skipped_subdevices = skipped
@@ -667,12 +687,16 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if device_type_name is not None:
self._log.debug("device type: %s (modelNum=%r)", device_type_name, model_num)
else:
# Both fields: detection reads each of them (board token, then
# consumer-model code), and this line is what a user pastes into
# an issue -- modelNum alone doesn't identify a washer or dryer.
# All three: detection reads each of them (oic device type, then
# board token, then consumer-model code), and this line is what a
# user pastes into an issue -- modelNum alone doesn't identify a
# washer or dryer, and device_types is often empty even when
# populated hardware exists for a type we don't map yet.
self._log.warning(
"unknown device type modelNum=%r description=%r; using common caps",
"unknown device type modelNum=%r description=%r device_types=%r; "
"using common caps",
model_num, description,
self._identity.device_types if self._identity else (),
)
self.device_type_name = device_type_name
self.bound = bound
@@ -912,9 +936,15 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# Schedule sub-polls for hot/warm hrefs between summary polls
# (no-op in observe-primary mode unless this cycle's sweep found a
# mismatch; _run_subpolls checks the mode/force).
# mismatch; _run_subpolls checks the mode/force). A background task,
# not async_create_task: this loop is self-limiting (cancelled and
# recreated every refresh cycle, see the cancel() above) and owned
# entirely by the coordinator, so it has no business being tracked by
# HA's own startup/shutdown sequencing -- async_create_task ties it
# in regardless, so a subpoll cycle in flight (up to ~27s,
# _SUBPOLL_STEP_S x 9 slots) delays both (issue #207).
if self._hot_hrefs or self._warm_hrefs:
self._subpoll_task = self.hass.async_create_task(
self._subpoll_task = self.hass.async_create_background_task(
self._run_subpolls(force=sweep_mismatch), name="localthings_subpoll"
)
+1 -1
View File
@@ -12,5 +12,5 @@
"pyOpenSSL>=23.0",
"smartthings-local>=0.1.1"
],
"version": "0.17.0"
"version": "0.18.0"
}
+23 -8
View File
@@ -73,6 +73,9 @@ class ObserveManager:
self.subscribed_hrefs: set[str] = set()
self._notified: set[str] = set()
self._last_notify_ts: float | None = None
# Wakes try_enter_observe_mode's grace wait early once enough hrefs
# have notified. Guards only `_notified` mutations + the `wait_for`.
self._notify_cond = threading.Condition()
self.fallback_hrefs: set[str] = set()
self._refresh_task: ObserveRefreshTask | None = None
self._refresh_stop: threading.Event | None = None
@@ -147,8 +150,10 @@ class ObserveManager:
return
if not isinstance(rep, dict):
return
self._notified.add(href)
self._last_notify_ts = time.monotonic()
with self._notify_cond:
self._notified.add(href)
self._last_notify_ts = time.monotonic()
self._notify_cond.notify_all()
self.log.debug("observe notify: %s", href)
self.apply(href, rep, source='observe')
@@ -172,10 +177,12 @@ class ObserveManager:
grace_period_s: float = GRACE_PERIOD_S,
success_fraction: float = SUCCESS_FRACTION,
) -> bool:
"""Blocking — subscribes to every href then sleeps for the whole
grace period. Caller must run this in an executor, never on the
"""Blocking — subscribes to every href then waits up to
`grace_period_s`, returning early once `success_fraction` of hrefs
have notified. Caller must run this in an executor, never on the
event loop."""
self._notified.clear()
with self._notify_cond:
self._notified.clear()
subscribed: set[str] = set()
for href in hrefs:
segs = [s for s in href.strip('/').split('/') if s]
@@ -190,10 +197,18 @@ class ObserveManager:
self.subscribed_hrefs = set()
return False
time.sleep(grace_period_s)
def _fraction_reached() -> bool:
return (
len(set(self._notified) & subscribed) / len(subscribed)
>= success_fraction
)
fraction = len(set(self._notified) & subscribed) / len(subscribed)
if fraction >= success_fraction:
with self._notify_cond:
reached = self._notify_cond.wait_for(
_fraction_reached, timeout=grace_period_s,
)
if reached:
self.subscribed_hrefs = subscribed
self._set_mode(MODE_OBSERVE)
self.start_refresh_task(session)
@@ -1,16 +1,17 @@
"""Per-device-type registries."""
import re
from typing import Optional
from typing import Optional, Sequence
from ._base import DeviceRegistry
from . import (
air_dresser, air_purifier, airconditioner, cooktop, dehumidifier,
dishwasher, dryer, induction_cooktop, microwave, oven, range as _range,
range_hood, refrigerator, vacuum_station, washer, water_purifier,
air_dresser, air_monitor, air_purifier, airconditioner, cooktop,
dehumidifier, dishwasher, dryer, ehs, induction_cooktop, microwave, oven,
range as _range, range_hood, refrigerator, vacuum_station, washer,
water_purifier,
)
__all__ = [
'DeviceRegistry', 'resolve', 'for_device_by_model',
'DeviceRegistry', 'resolve', 'for_device_by_oic_type', 'for_device_by_model',
'for_device_by_resources', '_board_tokens',
]
@@ -19,12 +20,14 @@ __all__ = [
# `_BOARD_TOKEN_TO_KEY`, `_CONSUMER_PREFIX_TO_KEY`, or `for_device_by_resources`.
_REGISTRY_BY_KEY: dict[str, DeviceRegistry] = {
'air_dresser': air_dresser.REGISTRY,
'air_monitor': air_monitor.REGISTRY,
'air_purifier': air_purifier.REGISTRY,
'airconditioner': airconditioner.REGISTRY,
'cooktop': cooktop.REGISTRY,
'dehumidifier': dehumidifier.REGISTRY,
'dishwasher': dishwasher.REGISTRY,
'dryer': dryer.REGISTRY,
'ehs': ehs.REGISTRY,
'induction_cooktop': induction_cooktop.REGISTRY,
'microwave': microwave.REGISTRY,
'oven': oven.REGISTRY,
@@ -85,6 +88,10 @@ _BOARD_TOKEN_TO_KEY: dict[str, str] = {
'CAC': 'airconditioner', # issue #191 -- TP1X_DA-AC-CAC-01001_0000
'ARA': 'airconditioner',
'DHM': 'dehumidifier', # issue #88 -- target humidity, no climate
'EHS': 'ehs', # Eco Heating System air-to-water heat pump --
# zone1 space heating/cooling + dhw domestic
# hot water, its own /mode/*/vs/0 and
# /temperatures/*/vs/0 resource shapes
'TVTL': 'air_purifier', # issue #56 (ARTIK051)
'VTWW': 'air_purifier', # issue #151 (BESPOKE Cube Air)
'AVT': 'air_purifier', # issue #190 -- AVT-WW-TP1-23-AXX500, a
@@ -111,6 +118,8 @@ _BOARD_TOKEN_TO_KEY: dict[str, str] = {
'CT': 'cooktop',
'VSKR': 'vacuum_station', # issue #131 -- stick-vacuum clean station
'DF': 'air_dresser', # issue #162
'VSWW': 'vacuum_station', # issue #219
'ASM': 'air_monitor', # issue #210 -- Air Monitor Plus
}
_TOKEN_SPLIT_RE = re.compile(r'[^A-Z0-9]+')
@@ -189,6 +198,69 @@ def _consumer_model_key(description: str) -> Optional[str]:
return None
# /oic/d's `rt` (OCF's own device-type declaration, see registry/identity.py)
# -> registry key. This is the device naming its own type -- no board-part
# guessing involved -- so it's consulted before modelNum/description at all.
#
# Every value must already be a key in `_REGISTRY_BY_KEY` (checked by
# `test_every_oic_type_resolves_to_a_real_registry`). That's why this list
# stops well short of the full OCF/SmartThings device-type vocabulary: a
# compiled list of `x.com.st.d.*` types will include plenty of device
# categories (lights, switches, sensors, locks, cameras, TVs, generic energy
# meters, ...) no Samsung DA appliance dump could ever report and this
# integration has no registry for -- and 'oic.d.robotcleaner' names an
# actual robot vacuum, a different product from the clean/auto-empty
# *station* `vacuum_station` covers (see that registry's own module
# docstring); mapping it there would misroute a genuine robot-vacuum dump
# into a registry with no vacuum-body capabilities at all. Add a row only
# once there's a real registry key on the right-hand side to point at.
#
# `x.com.st.d.*` entries are SmartThings' own vendor extension to the OCF
# device-type vocabulary (used for categories with no `oic.d.*` equivalent),
# same prefix convention as the `x.com.samsung.da.*` resource fields
# elsewhere in this codebase.
#
# `oic.d.cooktop` is deliberately absent, and is the one measured type left out.
# A TP1X_DA-KS-COOKTOP induction reports it, but `cooktop` and
# `induction_cooktop` are two unrelated registries that happen to share the
# English word (see by_type/cooktop.py's docstring: the NA9300K gas family keeps
# burner state in /mode/vs/0's options array, a completely different OCF
# surface). The OCF type does not distinguish them, so mapping it to either key
# would silently misroute the other -- and as the *primary* signal it would
# override a `COOKTOP`/`CT` board token that had it right. Same reasoning as
# `oic.d.robotcleaner` above: no unambiguous key to point at, so no row.
_OIC_TYPE_TO_KEY: dict[str, str] = {
'oic.d.airconditioner': 'airconditioner',
'oic.d.airpurifier': 'air_purifier',
'oic.d.dishwasher': 'dishwasher',
'oic.d.dryer': 'dryer',
'oic.d.oven': 'oven',
'oic.d.refrigerator': 'refrigerator',
'oic.d.washer': 'washer',
'x.com.st.d.airqualitysensor': 'air_monitor',
'x.com.st.d.hood': 'range_hood', # AHD-WW-TP1-22-COMMON
'x.com.st.d.stickcleaner': 'vacuum_station',
'x.com.st.d.steamcloset': 'air_dresser',
}
def for_device_by_oic_type(device_types: Sequence[str]) -> Optional[DeviceRegistry]:
"""Device-type detection from /oic/d's `rt` -- OCF's own device-type
declaration.
The primary path when a dump carries it: the device names its own type,
so there's nothing to infer from board part numbers. Most hardware still
doesn't populate `/oic/d` usefully -- see `resolve()`'s docstring -- so
this only ever helps a minority of dumps, and `for_device_by_model`/
`for_device_by_resources` remain load-bearing for everything else.
"""
for device_type in device_types:
key = _OIC_TYPE_TO_KEY.get(device_type)
if key is not None:
return _REGISTRY_BY_KEY[key]
return None
def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegistry]:
"""Device-type detection from /information/vs/0's model strings.
@@ -229,13 +301,16 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi
def for_device_by_resources(resources: dict[str, dict]) -> Optional[DeviceRegistry]:
"""Detect a device family from a distinctive local-resource signature.
For boards that ship no ``/information/vs/0`` at all, leaving
`for_device_by_model` nothing to read. Some newer cooktops are the
This runs first as an override path for non-standard devices, not because
resource signatures are inherently more trustworthy than OIC/model
metadata. It also types boards that ship no ``/information/vs/0`` at all,
leaving `for_device_by_model` nothing to read. Some newer cooktops are the
original case: their mode resource still identifies them, carrying a
DeviceType option and multiple per-burner OperationState options.
Require two independent shapes for every signature here, never one, so
an unrelated family's ``/mode/vs/0`` isn't misclassified.
putting this ahead of OIC/model metadata cannot let a common resource
misclassify an unrelated family.
"""
mode = resources.get('/mode/vs/0', {})
options = mode.get('x.com.samsung.da.options') or ()
@@ -258,9 +333,14 @@ def for_device_by_resources(resources: dict[str, dict]) -> Optional[DeviceRegist
# (issue #74's NE63B8411SS, issue #172's ME8000T -- the resource is simply
# absent from the dump, not just empty) can't be matched via
# for_device_by_model's modelNum tokens either. Mode vocabulary alongside
# the oven cavity resource (/oven/vs/0) is a safe signature.
# the oven cavity resource (/oven/vs/0) is a safe two-resource signature;
# it also corrects Qooker's generic oic.d.oven / OVEN metadata (issue
# PR #225) when resource detection runs before metadata.
supported_modes = mode.get('x.com.samsung.da.supportedModes') or ()
if '/oven/vs/0' in resources:
if not isinstance(supported_modes, (list, tuple)):
supported_modes = ()
cavity = resources.get('/oven/vs/0')
if isinstance(cavity, dict):
if any(
m in supported_modes
for m in ('MicroWave', 'MicroWaveGrill', 'MicroWaveConvection')
@@ -273,16 +353,22 @@ def for_device_by_resources(resources: dict[str, dict]) -> Optional[DeviceRegist
return None
def resolve(resources: dict[str, dict]) -> Optional[DeviceRegistry]:
def resolve(
resources: dict[str, dict], device_types: Sequence[str] = (),
) -> Optional[DeviceRegistry]:
"""Device type for a parsed /device/0 dump, or None if unrecognized.
The single entry point for detection -- the coordinator, the config
flow's probe and the golden-regression harness all call this, so the
order can't drift between what ships and what the tests assert.
Model strings first (`for_device_by_model`), then a distinctive resource
signature (`for_device_by_resources`) for boards that report no
/information/vs/0 at all.
Distinctive resource signatures run first because they describe the live
capability surface a registry must bind. They are deliberately strict in
`for_device_by_resources`: each requires multiple independent details, so
this can correct misleading metadata (Qooker's generic ``oic.d.oven``)
without a common href overriding an unrelated family. When no signature
matches, `/oic/d`'s `rt` (read separately from the /device/0 dump -- see
registry/identity.py) wins over model-string parsing.
`/otninformation/vs/0`'s oneUiVersion is deliberately not consulted. It
reads like the obvious signal -- the device naming its own type, e.g.
@@ -292,7 +378,11 @@ def resolve(resources: dict[str, dict]) -> Optional[DeviceRegistry]:
is still reported in diagnostics as a firmware-generation marker.
"""
info = resources.get('/information/vs/0', {})
return for_device_by_model(
info.get('x.com.samsung.da.modelNum', ''),
info.get('x.com.samsung.da.description', ''),
) or for_device_by_resources(resources)
return (
for_device_by_resources(resources)
or for_device_by_oic_type(device_types)
or for_device_by_model(
info.get('x.com.samsung.da.modelNum', ''),
info.get('x.com.samsung.da.description', ''),
)
)
@@ -1,7 +1,10 @@
"""AirDresser device registry (issue #162).
Reuses washer/dryer/dishwasher's shared laundry surface -- job-beginning
status, operational state, and diagnosis. air_dresser.py holds the two
status, operational state, diagnosis, and (issue #208) the buzzer-sound
select -- /buzzersound/vs/0's rep on this board is the plain
{setBuzzerSound, supportedBuzzerSound} shape laundry.BUZZER_SOUND already
expects, no AirDresser-specific wiring needed. air_dresser.py holds the two
pieces specific to this device type: a minimal wrinkle-prevent-only
/washer/vs/0 capability, and the course select's own translation key.
"""
@@ -17,6 +20,7 @@ REGISTRY = DeviceRegistry(
air_dresser.AIR_DRESSER_SETTINGS,
air_dresser.AIR_DRESSER_COURSE,
air_dresser.AIR_DRESSER_SANITIZE,
laundry.BUZZER_SOUND,
laundry.JOB_BEGINNING_STATUS,
operational.OPERATIONAL_STATE,
dishwasher.DIAGNOSIS,
@@ -0,0 +1,23 @@
"""Air Monitor Plus device registry (issue #210).
A standalone, battery-powered air-quality sensor puck (ASM-KR-TP1-22-*
board) -- no controllable state at all beyond the do-not-disturb window,
so this registry is almost entirely sensors. No common.POWER: there's no
`/power/*` resource on this board, only `/energy/battery/vs/0`.
"""
from ..capabilities import air_monitor, common, ignored
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='air_monitor',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*air_monitor.COVERAGE,
air_monitor.SENSORS,
air_monitor.HUMIDITY,
air_monitor.BATTERY,
air_monitor.AIR_QUALITY_STANDARD,
air_monitor.DND,
]),
)
@@ -0,0 +1,29 @@
"""EHS (Eco Heating System) air-to-water heat pump device registry
(Samsung TP1X_DA_AC_EHS-class).
Shares the DA_AC_ board prefix with the room-AC family in
airconditioner.py, but its /mode/*/vs/0 and /temperatures/*/vs/0 resources
are its own shape (two independent loops: zone1 space heating/cooling and
dhw domestic hot water), not airconditioner.py's HREF_MODE/HREF_TEMP* OCF
pattern -- so nothing from that module is reused here except MUTE_ONCE,
whose /option/muteonce/vs/0 field shape (`muteonce`) is identical on this
family's dump.
"""
from ..capabilities import airconditioner, common, ehs, ignored
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='ehs',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
airconditioner.MUTE_ONCE,
ehs.ZONE_POWER,
ehs.ZONE_MODE,
ehs.ZONE_TEMPERATURE,
ehs.DHW,
*ehs.DHW_CONSUMED,
ehs.AWAY_MODE,
*ehs.COVERAGE,
]),
)
@@ -0,0 +1,155 @@
"""Capabilities for the Samsung Air Monitor Plus family (ASM-KR-TP1-22-*
board, issue #210) -- a small battery-powered standalone air-quality sensor
puck, not a controllable appliance. No `/power/*` resource at all (battery
only); this registry deliberately doesn't include common.POWER.
`/sensors/vs/0` is the same {type, value: [...]} items-list shape
air_purifier.AIR_QUALITY and range_hood.AIR_QUALITY already read via
common.sensor_item_value -- reused here rather than re-decoded, including
the same dust/fine_dust/super_fine_dust/odor/clean_level keys so this
device shares those capabilities' catalog entries. This board additionally
reports a CO2 reading the other two families don't.
A second `value` list element on the particulate-matter types (e.g. Dust's
`['31', '2']`) reads like a coarse quality-grade code, but nothing on this
board (no `supportedGrades`/similar field, no repeated dump to compare
against) confirms what its scale means -- left unbound rather than guessed,
per the adding-device-support skill's "still never invent... from nothing"
rule. Same reasoning `air_purifier.AIR_QUALITY` already applies to this
shape; index 0 is the only slot any family has ever read.
Dust/FineDust/SuperFineDust aren't assigned an HA `device_class`
(pm10/pm25/pm1) or `unit` despite the values reading like plausible
ug/m3 particulate readings in a physically consistent order (coarser
>= finer): Samsung's own two-tier Korean convention (i.e. "fine dust"/
"ultra-fine dust") maps only to a PM10/PM2.5 pair, and this board's
three-tier naming doesn't confirm where the extra tier or a PM1 reading
actually fits. The adding-device-support skill's read-side rule says
leave unit/device_class unset when the dump gives no field that
nominates one -- a wrong guess would silently mislabel every reading
forever, and the write-side rejection safety net doesn't cover reads.
Exposed as plain `measurement` sensors named after the device's own
field instead (matching air_purifier.AIR_QUALITY's existing precedent).
"""
from datetime import time as dt_time
from ..capability import Capability
from ..entities import BinarySensorDesc, SensorDesc, SwitchDesc, TimeDesc
from .air_purifier import _AIR_QUALITY_SENSORS
from .common import int_or_none, sensor_item_value
SENSORS = Capability(
href='/sensors/vs/0',
poll_tier='warm',
entities=tuple(
SensorDesc(key=key, field='x.com.samsung.da.items', icon=icon,
state_class='measurement',
value_fn=lambda items, t=sensor_type: sensor_item_value(items, t))
for key, icon, sensor_type in _AIR_QUALITY_SENSORS
) + (
SensorDesc(key='co2', field='x.com.samsung.da.items',
device_class='carbon_dioxide', state_class='measurement',
unit='ppm',
value_fn=lambda items: sensor_item_value(items, 'CO2')),
),
)
HUMIDITY = Capability(
href='/humidity/vs/0',
poll_tier='warm',
entities=(
SensorDesc(key='humidity', field='x.com.samsung.da.humidity',
device_class='humidity', state_class='measurement',
unit='%', value_fn=int_or_none),
),
)
BATTERY = Capability(
href='/energy/battery/vs/0',
poll_tier='warm',
entities=(
SensorDesc(key='battery', field='x.com.samsung.da.battery',
device_class='battery', state_class='measurement',
unit='%', entity_category='diagnostic',
value_fn=int_or_none),
BinarySensorDesc(key='battery_charging', field='x.com.samsung.da.charging',
device_class='battery_charging',
entity_category='diagnostic',
value_fn=lambda v: v == 'On'),
),
)
AIR_QUALITY_STANDARD = Capability(
href='/airqualitystandard/vs/0',
poll_tier='cold',
entities=(
SensorDesc(key='air_quality_standard', field='x.com.samsung.da.standard',
entity_category='diagnostic'),
),
)
def _parse_hms(v):
"""'HH:MM:SS' -> datetime.time, same contract as laundry._parse_hm but
tolerant of the trailing ':SS' this board's dnd start/end times carry."""
if not v:
return None
try:
parts = v.split(':')
return dt_time(int(parts[0]), int(parts[1]))
except (ValueError, IndexError):
return None
def _dnd_time_write(field):
def _write(p, rep, href=None):
return ['dnd', 'vs', '0'], {field: f'{p.hour:02d}:{p.minute:02d}:00'}
return _write
# Issue #210: no idle-vs-active dump pair exists for this href (only one
# dump total, DND never toggled in it), so this write contract is an
# educated guess, not a confirmed one -- symmetric with the read side
# (writing the same 'true'/'false' string shape and 'HH:MM:SS' format the
# device itself reports back) rather than invented from nothing, but still
# needs a reporter to actually flip it on real hardware and confirm. See
# the adding-device-support skill's "Educated guesses are fine" section.
DND = Capability(
href='/dnd/vs/0',
poll_tier='cold',
entities=(
SwitchDesc(key='dnd', field='x.com.samsung.da.value',
icon='mdi:sleep',
entity_category='config',
value_fn=lambda v: v == 'true',
write_fn=lambda p, rep, href=None: (
['dnd', 'vs', '0'],
{'x.com.samsung.da.value': 'true' if p == 'On' else 'false'})),
TimeDesc(key='dnd_start', field='x.com.samsung.da.startTime',
icon='mdi:clock-start',
entity_category='config',
value_fn=_parse_hms,
write_fn=_dnd_time_write('x.com.samsung.da.startTime')),
TimeDesc(key='dnd_end', field='x.com.samsung.da.endTime',
icon='mdi:clock-end',
entity_category='config',
value_fn=_parse_hms,
write_fn=_dnd_time_write('x.com.samsung.da.endTime')),
),
)
# ---------------------------------------------------------------------------
# Air-monitor-scoped coverage: hrefs with no explainable live state,
# following the 'don't guess' rule.
# ---------------------------------------------------------------------------
_AM_IGNORED = [
# A single bare integer ('keepnormal': 0) with no description, no
# supported-values list, and no second dump to compare against -- opaque.
'/keepnormalstate/vs/0',
# {'remove': ''} -- looks like data-sink/cache-clearing plumbing, not a
# live user-facing field.
'/sensordatasinks/vs/0',
]
COVERAGE = [Capability(href=h) for h in _AM_IGNORED]
@@ -380,6 +380,29 @@ def _option_number_write(prefix):
return write
def _odor_controller_active(rep):
"""Odor-controller self-clean on/off, from the `SmartCoolClean_<On/Off>`
/mode/vs/0 option token -- corroborated against the SmartThings cloud
custom.airConditionerOdorController capability's airConditionerOdorController
State field, same on/off vocabulary and a matching name ("Smart Cool
Clean" mirrors "odor controller"). Unconditional across board
generations, same as beep above -- no evidence ties this token to the
legacy-vs-newer split _has_option_token gates on. Read-only: no command
capability is confirmed, so this stays a sensor rather than a guessed
write (the 'don't guess' rule)."""
tok = _option_token(rep, 'SmartCoolClean')
if tok is None:
return None
return tok == 'On'
def _odor_controller_progress(rep):
"""0-100 progress of the odor-controller self-clean cycle, from the
`ProgressSmartClean_<N>` option token -- mirrors the cloud
airConditionerOdorControllerProgress field."""
return _int(_option_token(rep, 'ProgressSmartClean'))
def _humidity(rep):
"""Relative humidity, preferring the 5%-rounded field where it exists.
@@ -573,15 +596,124 @@ CLIMATE = Capability(
unit='°C', icon='mdi:home-thermometer-outline'),
# Filter time in tenths of an hour: the token read 1710 while the official
# Samsung app displayed "171 hours 0 minutes" for the filter on the same
# unit, and the .0 matching the app's "0 minutes" pins the scale. Whether
# it counts up or down is NOT established -- it was seen rising (171.0 ->
# 171.5) while the unit ran, which contradicts the app's "remaining"
# wording, so the entity is deliberately named neutrally.
# unit, and the .0 matching the app's "0 minutes" pins the scale.
#
# It counts UP -- running time accumulated since the last filter reset,
# not time remaining. An earlier revision of this comment called the
# direction unestablished; three independent things now settle it. It was
# seen rising while the unit ran (171.0 -> 171.5). FilterAlarmTime_ in the
# same options[] blob is the threshold it is measured against (500 on
# every unit on record). And across two units on one site, the alarm
# tracks the counter in the right direction: at FilterTime_5595 the
# /alarms/vs/0 filter entry is live (unsuffixed code 'FilterAlarm', state
# 'Created'), while at FilterTime_1915 it is still the
# 'FilterAlarm_OFF'/'Deleted' placeholder. That also matches what the app
# does at 500 hours -- ask the user to clean the filter and reset it.
#
# The key stays 'filter_time' rather than becoming something like
# filter_time_elapsed: renaming it would change every existing unit's
# entity_id and unique_id for a wording improvement.
#
# (The alarm clearing on its own is the causal proof of the direction
# above: the board recomputes it the moment the counter crosses the
# threshold, rather than the app clearing counter and alarm separately.)
#
# ---------------------------------------------------------------------
# RESETTING THIS COUNTER: NOT SOLVED YET -- no reset entity here, and
# the notes below are so the next attempt starts from the evidence
# rather than from scratch. I could not work out how to drive the reset
# locally; that is not the same as it being impossible, and none of the
# results below rule out a mechanism I simply haven't found.
#
# What the reset actually is: a *command*, not a value write. Samsung
# models it as capability `custom.dustFilter`, command
# `resetDustFilter`, no arguments (implemented in several SmartThings HA
# forks; not in the core integration). That reframes every failed
# attempt below -- nothing changes the counter by writing to it,
# because the board zeroes it itself on receiving a command.
#
# Tried, all against a live unit, all failed:
# * FilterTime_0 via the single-token options merge that works for
# every other setting on this href -- accepted with no error, then
# discarded. Two units, opposite power states, to rule out the
# obvious confound: 5595 -> back to 5595 after 69 s (powered off,
# alarm active) and 1925 -> back to 1925 after 65 s (actively
# cooling).
# * A full options[] read-modify-write with FilterTime_0 substituted,
# instead of the single-token merge -- zero fields changed anywhere.
# * A write to /consumable/vs/0, the board's own filter resource
# (items[{name: FilterProgress, state: N}]) -- discarded. /oic/res
# declares that resource oic.if.s, i.e. read-only, which fits.
# * /actions/vs/0 (x.com.samsung.da.actions, oic.if.a) is the obvious
# local command channel, but publishes no schema: GET returns {} on
# baseline and on oic.if.a, and five POSTs probing the *shape*
# (empty map, empty string, empty array, invalid value, items shape)
# all returned 4.00 with an empty body -- no echo of accepted field
# names, unlike the laundry firmware's "Control fail, <...>". I
# deliberately did not enumerate guessed action names against a live
# appliance: an unknown vocabulary on a channel called "actions" can
# hold a factory reset next to the one we want.
# * /hass/state/vs/0 and /hass/command/vs/0 (advertised in /oic/res,
# and /opt/data/hass.db exists in /file/list) -> 4.04 on every
# interface, so unimplemented scaffolding on this firmware.
# * /file/transfer/vs/0 serves only /mnt/usage.db; selecting another
# path returns 4.05/4.00, so the firmware can't be pulled that way
# to read the action vocabulary out of it.
# * /rm/micomdata/vs/0 (channel toward the MICOM board the physical
# panel talks to) stays empty even after successfully enabling
# remote management.
#
# What the failures are NOT: a transport, permission or cert problem.
# A control write of rmState on /rm/state/vs/0 was accepted (2.04
# Changed, value held, restored afterwards), and FilterAlarmTime_ below
# is written through the very same options merge and kept. So writes
# work; this one value just isn't driven that way.
#
# Where I'd look next: the action vocabulary for /actions/vs/0 from an
# independent source (firmware image, or a capture of what the cloud
# sends the device), or the IR path -- the physical remote has a filter
# reset (Options -> Filter Reset -> SET), and IRremoteESP8266 decodes
# this AC family, though issue #1277's dump doesn't include that button.
# ---------------------------------------------------------------------
SensorDesc(key='filter_time',
rep_fn=_option_token_num('FilterTime', divisor=10),
exists_fn=_has_option_token('FilterTime'),
device_class='duration', unit='h',
state_class='measurement', icon='mdi:air-filter'),
# The interval FilterTime_ is measured against -- the app offers it as a
# four-way radio (180/300/500/700 hours, default 500) next to the filter
# reminder. The token value is the hour count verbatim: captured by
# watching all 19 resources while the owner stepped through every radio
# position, one field changing per step and nothing else moving.
#
# Static options here, unlike air_filter_threshold on the newer boards
# which reads supportedFilterDesiredUsage: options[] tokens carry no
# supported-values list anywhere in this board's dump, so there is
# nothing to read them from. The four values are the app's own radio
# rather than a guess extrapolated from one observed value -- but a
# board offering different steps would need this revisited, which is
# why the tuple is documented rather than just written down.
SelectDesc(key='filter_alarm_time',
rep_fn=lambda rep: _option_token(rep, 'FilterAlarmTime'),
exists_fn=_has_option_token('FilterAlarmTime'),
options=('180', '300', '500', '700'),
write_fn=_option_switch_write('FilterAlarmTime'),
icon='mdi:alarm', entity_category='config'),
# Odor-controller ("Smart Cool Clean") state + progress -- see
# _odor_controller_active's docstring for the cloud-capability
# correspondence. Present on TP1X_DA-AC-RAC-01001 (issue reporter's
# dump); gating is token presence only, not board generation.
BinarySensorDesc(key='odor_controller_active',
rep_fn=_odor_controller_active,
exists_fn=lambda rep, resources: (
_option_token(rep, 'SmartCoolClean') is not None),
icon='mdi:air-filter', entity_category='diagnostic'),
SensorDesc(key='odor_controller_progress',
rep_fn=_odor_controller_progress,
exists_fn=lambda rep, resources: (
_option_token(rep, 'ProgressSmartClean') is not None),
unit='%', state_class='measurement',
icon='mdi:progress-check', entity_category='diagnostic'),
),
)
@@ -610,6 +742,25 @@ AUTO_CLEAN = Capability(
write_fn=lambda p, rep, href=None: (
['option', 'autoclean', 'vs', '0'],
{'x.com.samsung.da.settingStatus': 'On' if p == 'On' else 'Off'})),
# The cycle, as opposed to the switch above -- `settingStatus` says the
# feature is enabled, which it is whether or not the unit is drying right
# now. `status` is the run state, and the resource advertises exactly
# Start/Stop in `supportedStatus`. Observed on a TP1X_DA-AC-CAC-01001:
# 'Start' with progress 98 while a cycle ran, then 'Stop' with progress 0
# the moment it finished.
BinarySensorDesc(key='auto_clean_running',
field='x.com.samsung.da.status',
icon='mdi:spray-bottle',
entity_category='diagnostic',
value_fn=lambda v: v == 'Start'),
# Percent through that cycle, matching the figure the appliance shows on
# its own display (checked against 55% mid-run).
SensorDesc(key='auto_clean_progress',
field='x.com.samsung.da.progress',
icon='mdi:progress-clock',
unit='%', state_class='measurement',
entity_category='diagnostic',
value_fn=common.int_or_none),
),
)
@@ -228,8 +228,9 @@ def sensor_item_value(items, sensor_type, index=0):
"""Pull one reading out of a `/sensors/vs/0`-style items[] list -- each
item is `{type, value: [...]}`; `index` picks which slot of a possibly
multi-value reading to read (index 0 is the raw measurement on every
family seen so far). Shared by range_hood.AIR_QUALITY and
air_purifier.AIR_QUALITY, which read the same resource shape."""
family seen so far). Shared by range_hood.AIR_QUALITY,
air_purifier.AIR_QUALITY, and air_monitor.SENSORS, which all read the
same resource shape against the same {type, sensor_type} keys."""
for item in items or ():
if not isinstance(item, dict):
continue
@@ -301,11 +302,19 @@ POWER_VS_FALLBACK = Capability(
KIDS_LOCK_GENERIC = Capability(
href='/kidslock/0',
entities=(
SwitchDesc(key='child_lock', field='value',
device_class='lock',
value_fn=lambda v: bool(v),
write_fn=lambda p, rep, href=None: (
['kidslock', '0'], {'value': p == 'On'})),
# Read-only like KIDS_LOCK_VS_FALLBACK (issues #181/#183) -- not a
# SwitchDesc. SwitchDesc's `device_class='lock'` was never honored
# by HA (its switch platform only accepts 'outlet'/'switch'),
# leaving a plain switch whose 'On' state meant different things
# on different boards. As a BinarySensorDesc with `device_class='lock'`,
# both kids-lock surfaces read with the same polarity: 'On' means
# open/unlocked, per HA's lock device_class. The inversion in
# value_fn here (and in the fallback below) keeps the on-the-wire
# truth (value=False on /kidslock/0, kidsLock='Ready' on /kidslock/vs/0
# both mean kids lock NOT active) consistent with that polarity.
BinarySensorDesc(key='child_lock', field='value',
device_class='lock',
value_fn=lambda v: not bool(v)),
),
)
@@ -313,12 +322,19 @@ KIDS_LOCK_VS_FALLBACK = Capability(
href='/kidslock/vs/0',
match_fn=lambda rep, resources: '/kidslock/0' not in resources,
entities=(
SwitchDesc(key='child_lock', field='x.com.samsung.da.kidsLock',
device_class='lock',
value_fn=lambda v: v != 'Ready',
write_fn=lambda p, rep, href=None: (
['kidslock', 'vs', '0'],
{'x.com.samsung.da.kidsLock': 'Enable' if p == 'On' else 'Ready'})),
# Read-only, not a SwitchDesc (issues #181/#183): the write side of
# this capability wrote 'Enable', a value no dump in the fixture
# corpus has ever reported back -- every one reports either 'Ready'
# or 'Run', so it was never a confirmed contract. #181's reporter
# confirmed this directly: writing the *correct* value ('Run')
# still 4.05s, and the SmartThings app itself has no control for
# it either -- the resource is genuinely read-only on this
# hardware, not just wrong-valued. Polarity matches
# KIDS_LOCK_GENERIC above -- 'On' means open/unlocked, so
# kidsLock='Ready' (kids lock NOT active) renders as 'On'.
BinarySensorDesc(key='child_lock', field='x.com.samsung.da.kidsLock',
device_class='lock',
value_fn=lambda v: v == 'Ready'),
),
)
@@ -0,0 +1,204 @@
"""Capabilities for the Samsung EHS (Eco Heating System) air-to-water heat
pump family (TP1X_DA_AC_EHS-class, model TP1X_DA_AC_EHS_01001_0000).
An EHS unit runs two independently-controlled loops off one outdoor unit:
space heating/cooling ("zone1", through /mode/vs/0, /power/vs/0,
/temperatures/indoor/vs/0) and domestic hot water ("dhw", through
/mode/dhw/vs/0, /power/dhw/vs/0, /temperatures/dhw/vs/0). There's no shared
vocabulary with the room-AC family in airconditioner.py beyond the DA_AC_
board prefix -- EHS reports its own /mode/*/vs/0 and /temperatures/*/vs/0
shapes, not airconditioner.py's HREF_MODE/HREF_TEMP* OCF-pattern hrefs.
zone1 has no HA platform with matching semantics (it's a leaving-water-
temperature setpoint, not a thermostat with HVAC modes airconditioner.py's
climate.py would fit), so it stays switch/select/number/sensor -- same shape
as dehumidifier.py's power/mode/humidity split. dhw is a real HA
water_heater.py -- see DHW below and water_heater.py's module docstring --
following the same primary-resource-plus-sibling-reads pattern as
airconditioner.py's CLIMATE/climate.py.
Verified against a real TP1X_DA_AC_EHS_01001_0000 diagnostics dump
(firmware AEH-WW-TP1-22-AE6000_17260402, TizenRT 3.1 / DAWIT 2.0).
"""
from ..capability import Capability
from ..entities import NumberDesc, SelectDesc, SensorDesc, SwitchDesc, WaterHeaterDesc
from .common import normalize_temp_unit
def _num(v):
try:
return float(v)
except (TypeError, ValueError):
return None
def _first_mode(rep):
"""Representative scalar for a mode select -- `modes` is a single-element
list on every dump seen so far, mirroring airconditioner._first_mode /
dehumidifier._first_mode's handling of the same field shape."""
modes = rep.get('x.com.samsung.da.modes')
if isinstance(modes, (list, tuple)):
return modes[0] if modes else None
return modes
def _temp_unit(rep):
return normalize_temp_unit(rep.get('x.com.samsung.da.unit'), '°C')
def _bounds(rep, default_min, default_max):
"""The resource's own (minimum, maximum) pair, or the defaults.
Both ends together or neither -- a board reporting only one would
otherwise pair a real device bound with an invented default, which
looks plausible and is silently wrong. Same rule as
climate._range()/water_heater._range(), and the same reason
oven._setpoint_bounds resolves its pair in one place.
"""
lo = _num(rep.get('x.com.samsung.da.minimum'))
hi = _num(rep.get('x.com.samsung.da.maximum'))
return (lo, hi) if (lo is not None and hi is not None) else (default_min, default_max)
def _step(rep, default):
"""`is None`, not `or` -- `or` collapses a genuine 0 (issue #160)."""
step = _num(rep.get('x.com.samsung.da.increment'))
return default if step is None else step
ZONE_POWER = Capability(
href='/power/vs/0',
poll_tier='warm',
entities=(
SwitchDesc(key='zone_power', field='x.com.samsung.da.power',
icon='mdi:radiator',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['power', 'vs', '0'],
{'x.com.samsung.da.power': 'On' if p == 'On' else 'Off'})),
),
)
ZONE_MODE = Capability(
href='/mode/vs/0',
poll_tier='warm',
entities=(
SelectDesc(key='zone_mode', rep_fn=_first_mode,
icon='mdi:sun-snowflake-variant',
options_field='x.com.samsung.da.supportedModes',
write_fn=lambda p, rep, href=None: (
['mode', 'vs', '0'], {'x.com.samsung.da.modes': [p]})),
),
)
# type=Water/unit=Celsius on this dump names the space-heating loop's flow/
# room setpoint, not a literal water temperature -- Samsung EHS zone control
# is leaving-water-temperature-based, same convention as the dhw loop below.
ZONE_TEMPERATURE = Capability(
href='/temperatures/indoor/vs/0',
poll_tier='warm',
entities=(
SensorDesc(key='zone_temperature', field='x.com.samsung.da.current',
device_class='temperature', unit_fn=_temp_unit,
state_class='measurement', value_fn=_num),
NumberDesc(key='zone_target_temperature', field='x.com.samsung.da.desired',
device_class='temperature', unit_fn=_temp_unit,
entity_category='config', value_fn=_num,
native_min_fn=lambda rep: _bounds(rep, 5.0, 30.0)[0],
native_max_fn=lambda rep: _bounds(rep, 5.0, 30.0)[1],
step_fn=lambda rep: _step(rep, 0.5),
write_fn=lambda p, rep, href=None: (
['temperatures', 'indoor', 'vs', '0'],
{'x.com.samsung.da.desired': str(float(p))})),
),
)
# Canonical dhw resource hrefs. water_heater.py binds the primary HREF_DHW_MODE
# via DHW below and reads the sibling power/temperature hrefs off the
# coordinator snapshot -- same primary-plus-siblings shape as
# airconditioner.py's HREF_MODE/CLIMATE_CONSUMED_HREFS. Declared once here
# and imported by water_heater.py, so a new sibling read can't drift out of
# sync with its DHW_CONSUMED_HREFS coverage entry below.
HREF_DHW_POWER = '/power/dhw/vs/0' # on/off
HREF_DHW_MODE = '/mode/dhw/vs/0' # primary (bound by DHW) -- current_operation
HREF_DHW_TEMPERATURE = '/temperatures/dhw/vs/0' # current/target temperature
DHW_CONSUMED_HREFS = [HREF_DHW_POWER, HREF_DHW_TEMPERATURE]
def _dhw_write(payload, rep, href=None):
"""Map a (kind, value) command from the water_heater platform to the
(path_segs, body) for that one sub-write -- same contract as
airconditioner._climate_write, just across the dhw loop's three
resources instead of the AC's power/mode/temperature/wind set."""
kind, value = payload
if kind == 'power':
return (['power', 'dhw', 'vs', '0'],
{'x.com.samsung.da.power': 'On' if value else 'Off'})
if kind == 'mode':
return (['mode', 'dhw', 'vs', '0'], {'x.com.samsung.da.modes': [value]})
if kind == 'temperature':
return (['temperatures', 'dhw', 'vs', '0'],
{'x.com.samsung.da.desired': str(float(value))})
return None
DHW = Capability(
href=HREF_DHW_MODE,
poll_tier='warm',
entities=(
WaterHeaterDesc(key='water_heater', translation_key='dhw',
rep_fn=_first_mode, write_fn=_dhw_write),
),
)
# Power and temperature are read by the composite DHW entity above, not
# given their own entities -- coverage-only caps so discover() reports no
# gap (see airconditioner.py's CLIMATE_CONSUMED_HREFS for the same pattern).
DHW_CONSUMED = [Capability(href=h, poll_tier='warm') for h in DHW_CONSUMED_HREFS]
# Deliberately a plain config switch, not water_heater's AWAY_MODE feature.
# HA core's smartthings water_heater does wire this same Samsung capability
# (CUSTOM_OUTING_MODE) up to WaterHeaterEntityFeature.AWAY_MODE, and the DHW
# operation-mode map above is taken from that integration -- so the
# divergence is worth stating. /option/outgoing/vs/0 is device-wide: one
# `away` flag covering the whole unit, zone1 included (it has no dhw-scoped
# sibling href, unlike every other resource in this loop). Hanging it off
# the DHW card would present a device-wide setting as if it only affected
# hot water. It stays a switch until a board turns up with a per-loop away
# resource to bind instead.
AWAY_MODE = Capability(
href='/option/outgoing/vs/0',
poll_tier='cold',
entities=(
SwitchDesc(key='away_mode', field='x.com.samsung.da.away',
icon='mdi:home-export-outline',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['option', 'outgoing', 'vs', '0'],
{'x.com.samsung.da.away': 'On' if p == 'On' else 'Off'})),
),
)
# ---------------------------------------------------------------------------
# EHS-scoped coverage: opaque vendor plumbing (hex-encoded factory/cycle/
# schedule blobs) or resources with no confirmed write contract on this
# dump, following the same 'don't guess' rule as dehumidifier._DHM_IGNORED.
# Not in the global ignored.IGNORED since these are EHS-only shapes that
# would need their own verification on other device families.
# ---------------------------------------------------------------------------
_EHS_IGNORED = [
'/availablecontrolsets/vs/0', # opaque hex-encoded control-set bitmap (id: EHS)
'/da/softreset/vs/0', # soft-reset trigger plumbing
'/diagnosis/vs/0', # empty {} on this dump
'/ehscycle/vs/0', # opaque hex-encoded indoor/outdoor cycle log
'/ehsfsv/vs/0', # opaque hex-encoded factory setting values
'/option/dhwdisplay/vs/0', # front-panel DHW-display show/hide, cosmetic only
'/reserverulesets/vs/0', # opaque hex-encoded schedule reservation blob
'/sac/installationinfo/vs/0', # static outdoor/indoor installation info, diagnostic only
'/actions/zone1/vs/0', # zone1 schedule/timer program -- unmodeled for now
'/actions/dhw/vs/0', # DHW schedule/timer program -- unmodeled for now
]
COVERAGE = [Capability(href=h) for h in _EHS_IGNORED]
@@ -90,7 +90,14 @@ def _finish_time(rep):
total_s = _remaining_seconds(rep.get('x.com.samsung.da.remainingTime'))
if not total_s:
return None
return datetime.now(timezone.utc) + timedelta(seconds=total_s)
# Round to whole minutes -- remainingTime itself only has minute
# resolution, but datetime.now() always carries fresh seconds/
# microseconds, so an unrounded result changes on nearly every poll
# even when the device-reported remaining time hasn't. That floods
# the recorder history/logbook with values that look identical once
# the UI rounds them down for display.
finish = datetime.now(timezone.utc) + timedelta(seconds=total_s)
return finish.replace(second=0, microsecond=0)
def _completion_minutes(rep):
@@ -156,7 +163,7 @@ OPERATIONAL_STATE = Capability(
# firmware leaves a stale remainingTime after a cycle ends, and
# freezes it at '00:01:00' when progress reaches 'Finish'.
SensorDesc(key='finish_time', device_class='timestamp',
rep_fn=_finish_time),
hysteresis=True, rep_fn=_finish_time),
SensorDesc(key='completion_minutes',
icon='mdi:clock-outline', unit='min',
@@ -79,6 +79,15 @@ def _hood_fan_write(payload, rep, href=None):
str(code)
for code in rep.get('x.com.samsung.da.hood.supportedFanSpeed', ())
]
if not supported:
min_s = rep.get('x.com.samsung.da.hood.settableMinFanSpeed')
max_s = rep.get('x.com.samsung.da.hood.settableMaxFanSpeed')
if min_s is not None and max_s is not None:
try:
mn, mx = int(min_s), int(max_s)
supported = [str(i) for i in range(mn, mx + 1)]
except (ValueError, TypeError):
pass
if value not in supported:
return None
return ['hood', 'fanspeed', 'vs', '0'], {
@@ -175,11 +175,23 @@ FAVORITE_HOTWATER = Capability(
write_fn=lambda p, rep, href=None: (
['favorite', 'hotwater', 'vs', '0'],
{'x.com.samsung.da.switchHotwater': 'Locked' if p == 'On' else 'Unlocked'})),
# Issue #196: `supportedList` is only the four *fixed* presets
# (e.g. ['40', '75', '85', '90']) -- the SmartThings app also lets
# the user add one custom value to their own display list via its
# "temperatures to display" editor (a wheel picker bounded by
# /setting/waterpurifier/vs/0's hotwaterRange, separate resource),
# and that custom value shows up in `showList`
# (['40', '50', '75', '85', '90'] here) but never in
# `supportedList`. Reading options from `supportedList` meant a
# unit whose current default *was* that custom value rendered as
# "unknown" -- not a coverage gap, just the wrong field. `showList`
# is a superset of `supportedList` that always includes whatever
# the current default actually is, custom or not.
SelectDesc(key='favorite_hotwater_temperature',
field='x.com.samsung.da.favorite.defaultTemperature',
icon='mdi:thermometer',
entity_category='config',
options_field='x.com.samsung.da.favorite.supportedList',
options_field='x.com.samsung.da.favorite.showList',
write_fn=lambda p, rep, href=None: (
['favorite', 'hotwater', 'vs', '0'],
{'x.com.samsung.da.favorite.defaultTemperature': p})),
@@ -57,6 +57,11 @@ class SensorDesc(SamsungEntityDescription):
unit: Optional[str] = None
unit_fn: Optional[Callable[[dict], str]] = None # overrides `unit` from the live rep, when set
options: Optional[tuple] = None # required by HA when device_class == 'enum'
# Opt-in: gate this sensor's reported value behind the user-configurable
# CONF_FINISH_TIME_HYSTERESIS_MINUTES threshold (see sensor.py). Only for
# values that are expected to jitter around their "true" value between
# device-side revisions -- not a general-purpose flag every sensor should set.
hysteresis: bool = False
@dataclass(frozen=True, kw_only=True)
@@ -128,6 +133,16 @@ class FanDesc(SamsungEntityDescription):
write_fn: WriteFn = None
@dataclass(frozen=True, kw_only=True)
class WaterHeaterDesc(SamsungEntityDescription):
# Composite water_heater entity: binds one primary resource (its href,
# typically an operation-mode resource) but the water_heater platform
# reads sibling resources (power, temperature) from the coordinator
# snapshot and writes to several of them. Same (kind, value) -> (path_segs,
# body) write_fn shape as ClimateDesc/FanDesc.
write_fn: WriteFn = None
PLATFORM_OF: dict[type, str] = {
SensorDesc: 'sensor',
BinarySensorDesc: 'binary_sensor',
@@ -138,4 +153,5 @@ PLATFORM_OF: dict[type, str] = {
TimeDesc: 'time',
ClimateDesc: 'climate',
FanDesc: 'fan',
WaterHeaterDesc: 'water_heater',
}
@@ -47,13 +47,13 @@ def _device_types(d: dict) -> tuple[str, ...]:
In OCF this is the one standardized "what am I" field: alongside the
generic 'oic.wk.d' it carries a concrete type such as 'oic.d.airconditioner'
or a Samsung 'x.com.samsung.da.*' equivalent. Nothing routes on it yet --
device-type detection currently parses board part numbers out of
/information/vs/0's modelNum instead (see registry/by_type/__init__.py) --
because no captured dump has ever included it: /device/0 batch responses
don't carry /oic/d, and diagnostics didn't report it. It's surfaced in
diagnostics so incoming issue reports can tell us whether real hardware
populates it usefully enough to route on.
or a SmartThings 'x.com.st.d.*' equivalent. `registry/by_type/resolve()`
now consults this first, ahead of board-part-number parsing, via
`for_device_by_oic_type` and its `_OIC_TYPE_TO_KEY` table -- but only a
minority of dumps populate it, so the modelNum/description path stays
load-bearing for everything else. It's also kept whole in diagnostics
(see `raw` below) so incoming issue reports keep surfacing types that
table doesn't know about yet.
"""
rt = d.get('rt')
if isinstance(rt, str):
@@ -36,7 +36,18 @@ the only thing ever actually confirmed to work for this pattern -- on the
assumption that a composite device's siblings share the master's resource
surface. See `Subdevice.flat_hrefs`.
Both are "the same thing wearing different clothes": a logical subdevice is a
Pattern C -- UUID prefix advertised only via `/oic/res`
(`AWM-WW-AID-26-ONEBODY` washer+dryer combo, issue #241). The board answers
`numofsubdevice='2'` on `/multidevice/vs/0` but has no `/subdevices/vs/0`
(no `subdeviceIdList`) and 4.04s `/device/1`/`/device/2`; the washer
subdevice's UUID appears nowhere except as the path prefix of the
`x.com.samsung.da.multidevice` link in `/oic/res`, and
`GET /<uuid>/device/0` answers the washer's own full Collection batch
(model `..._WF80H` vs. the master's `..._DV80H27H`) -- Pattern B's
transform with the UUID sourced from the link prefix instead of
`subdeviceIdList`.
All of these are "the same thing wearing different clothes": a logical subdevice is a
seed collection path to poll, plus an href transform between the canonical
href the registry knows (`/mode/vs/0`) and the actual on-the-wire href. The
detection signals don't overlap on either captured board (the Pattern A
@@ -80,7 +91,7 @@ from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Callable, Optional
from typing import Callable, Optional, Sequence
import cbor2
@@ -88,6 +99,12 @@ from .batch import parse_device0_batch
_INDEXED_HREF_RE = re.compile(r'^/device/(\d+)$')
# A UUID as the first path segment of an /oic/res link href -- Pattern C's
# discovery signal (issue #241): a subdevice tree whose UUID is advertised
# nowhere except as this prefix (no subdeviceIdList, no /device/<n>).
_UUID_PREFIX_RE = re.compile(
r'^/([0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12})/')
# Speculative /device/<n> siblings probed when /oic/res doesn't reveal a
# second logical subdevice's Collection on this board (moved here from
# identity.py, issue #177 -- see enumerate_subdevices' docstring for why: the
@@ -333,22 +350,26 @@ def enumerate_subdevices(
"""
subdevices: list[Subdevice] = []
fetched: dict[str, dict] = {}
# Case-insensitive -- the same UUID can reach here once from
# subdeviceIdList and once from an /oic/res link prefix with different
# casing (Samsung's own fields disagree on this elsewhere too, e.g. the
# redaction-prone subdeviceIdList handling below), and probing it twice
# would materialize the same physical subdevice as two Subdevice
# candidates under two different keys.
probed_ids: set[str] = set()
def _probed(seed_href: str, batch: dict) -> None:
if probe_log is not None:
probe_log(seed_href, bool(batch))
# --- Pattern B: UUID-prefixed tree (TP2X_FAC_BORA_21K) ------------------
raw_ids = (resources.get('/subdevices/vs/0') or {}).get(
'x.com.samsung.da.subdeviceIdList')
# Tolerate anything but a list of strings -- this field is redaction-prone
# (it matches the 'deviceid' substring rule in redact.py) and the existing
# airconditioner_fac_bora fixture carries the literal string
# '**REDACTED**'/'REDACTED' there. That must yield zero subdevices, not a
# crash -- issue #177 is additive, it must never break an already-working
# single-climate-entity device.
ids = raw_ids if isinstance(raw_ids, list) else []
for sub_id in sorted(i for i in ids if isinstance(i, str) and i):
def _probe_prefixed(sub_id: str) -> None:
"""Materialize one UUID-prefixed subdevice candidate -- shared by
Pattern B (ids from subdeviceIdList) and Pattern C (ids from
/oic/res link prefixes) below, which differ only in where the UUID
came from."""
if sub_id.lower() in probed_ids:
return
probed_ids.add(sub_id.lower())
seed = (sub_id, 'device', '0')
batch = _get_batch(sess, seed)
_probed(_seed_href(seed), batch)
@@ -356,7 +377,7 @@ def enumerate_subdevices(
subdevice = Subdevice(kind='prefixed', key=sub_id, seed_path=seed)
fetched.update(normalize_seed_batch(subdevice, batch))
subdevices.append(subdevice)
continue
return
# Fallback (issue #205): TP2X_FAC_BORA_21K itself -- the board this
# pattern was built against -- turns out not to always expose its own
# `/<uuid>/device/0` Collection either, so "every prefixed subdevice
@@ -393,12 +414,53 @@ def enumerate_subdevices(
flat_hrefs.append(href)
fetched[actual] = rep
if not flat_hrefs:
continue
return
subdevices.append(Subdevice(
kind='prefixed', key=sub_id, seed_path=(),
flat_hrefs=tuple(flat_hrefs),
))
# --- Pattern B: UUID-prefixed tree (TP2X_FAC_BORA_21K) ------------------
raw_ids = (resources.get('/subdevices/vs/0') or {}).get(
'x.com.samsung.da.subdeviceIdList')
# Tolerate anything but a list of strings -- this field is redaction-prone
# (it matches the 'deviceid' substring rule in redact.py) and the existing
# airconditioner_fac_bora fixture carries the literal string
# '**REDACTED**'/'REDACTED' there. That must yield zero subdevices, not a
# crash -- issue #177 is additive, it must never break an already-working
# single-climate-entity device.
ids = raw_ids if isinstance(raw_ids, list) else []
listed = sorted(i for i in ids if isinstance(i, str) and i)
for sub_id in listed:
_probe_prefixed(sub_id)
# --- Pattern C: UUID prefix advertised only via /oic/res ----------------
# (AWM-WW-AID-26-ONEBODY washer+dryer combo, issue #241.) A third
# multidevice shape: the board answers numofsubdevice='2' on
# /multidevice/vs/0, but carries no /subdevices/vs/0 (no subdeviceIdList
# -- Pattern B's signal) and 4.04s /device/1 and /device/2 (Pattern A's).
# The only trace of the sibling is a UUID-prefixed link in /oic/res
# itself: the x.com.samsung.da.multidevice link,
# '/<uuid>/multidevice/vs/0' on the reporting board. Its washer tree
# answers a full Collection at /<uuid>/device/0, exactly Pattern B's
# transform -- so treat every UUID path prefix seen in /oic/res as a
# prefixed-subdevice candidate. Probing is the same tolerated-404
# RETRIEVE as everything else here, and discover_partitioned's
# entity-level liveness gate still decides materialization, so a board
# that advertises a UUID link without a live sibling behind it
# contributes nothing. _probe_prefixed's probed_ids guard -- not a set
# difference against `listed` here -- is what keeps an id already named
# by subdeviceIdList from being probed and materialized a second time,
# since the two sources can disagree on that UUID's case.
linked = sorted({
m.group(1)
for link in _iter_oic_res_hrefs(oic_res_links)
for m in [_UUID_PREFIX_RE.match(link.get('href', ''))]
if m
})
for sub_id in linked:
_probe_prefixed(sub_id)
# --- Pattern A: indexed siblings (ARTIK051_DONGLE_FAC_18K) --------------
indices = sorted({
int(m.group(1))
@@ -519,10 +581,11 @@ def _has_live_primary_entity(bound, state: dict) -> bool:
def discover_partitioned(
resources: dict[str, dict],
subdevices: list['Subdevice'],
resolve_registry: Callable[[dict], object],
resolve_registry: Callable[..., object],
fallback_capabilities: dict,
log: Optional[Callable[[str], None]] = None,
tier_log: Optional[Callable[[str, str], None]] = None,
oic_device_types: Sequence[str] = (),
):
"""Bind every href in `resources` (the merged, real-href snapshot -- main
plus every enumerated subdevice's seed) to entities, partitioned by which
@@ -551,6 +614,15 @@ def discover_partitioned(
first-discovery time only is a non-issue; getting a phantom subdevice
silently counted into unbound_hrefs or hot/warm tiers is not.
`oic_device_types` (from the master's own `/oic/d`, see
registry/identity.py) is passed only to the *master's* resolution --
subdevices have no `/oic/d` of their own read today (they resolve from
their own `/information/vs/0` or fall back to the master's whole
registry, as documented above), and blindly applying the master's OCF
device type to every subdevice's own model-based resolution would be
wrong the moment a composite appliance ever pairs two genuinely
different device types under one connection.
Returns `(bound, device_type_name, materialized, skipped)`:
- `bound`: the concatenated BoundEntity list (main + every materialized
subdevice).
@@ -575,7 +647,7 @@ def discover_partitioned(
# owned_elsewhere here too.
main_view = canonical_view(MAIN, resources, subdevices)
reg = resolve_registry(main_view)
reg = resolve_registry(main_view, device_types=oic_device_types)
caps, pats = (
(reg.capabilities, reg.pattern_capabilities) if reg is not None
else (fallback_capabilities, [])
+36 -2
View File
@@ -1,6 +1,8 @@
"""Sensor platform for Local Things."""
from __future__ import annotations
from datetime import timedelta
from homeassistant.components.sensor import SensorEntity, SensorDeviceClass, SensorStateClass
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
@@ -12,7 +14,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .observe import MODE_OBSERVE, MODE_POLL
from .registry.entities import SensorDesc
from .const import DOMAIN
from .const import CONF_FINISH_TIME_HYSTERESIS_MINUTES, DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES, DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
@@ -42,6 +44,7 @@ class LocalThingsSensor(LocalThingsEntity, SensorEntity):
self._attr_state_class = desc.state_class
if desc.options:
self._attr_options = list(desc.options)
self._hysteresis_value = None
@property
def native_unit_of_measurement(self):
@@ -52,7 +55,38 @@ class LocalThingsSensor(LocalThingsEntity, SensorEntity):
@property
def native_value(self):
return (self.coordinator.data or {}).get(self._state_key)
raw = (self.coordinator.data or {}).get(self._state_key)
if not self._bound.desc.hysteresis:
return raw
return self._apply_hysteresis(raw)
def _apply_hysteresis(self, raw):
"""Hold the last value this entity actually reported until a new one
differs by at least the configured threshold, regardless of how long
that difference has been building up (this is a deadband, not a
time-based debounce).
Values like finish_time are `now() + remaining`, recomputed from
scratch every poll -- both wall-clock drift between the device's own
remaining-time updates and the device revising its own estimate mid-
cycle produce a stream of small, real changes that are individually
meaningless but each trigger a recorder/logbook entry. A cycle
ending (raw is None) or starting (cache empty) always passes through
immediately -- only in-between jitter while a value already exists
on both sides gets held back.
"""
threshold_min = self.coordinator.config_entry.options.get(
CONF_FINISH_TIME_HYSTERESIS_MINUTES, DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES
)
if (
threshold_min
and raw is not None
and self._hysteresis_value is not None
and abs(raw - self._hysteresis_value) < timedelta(minutes=threshold_min)
):
return self._hysteresis_value
self._hysteresis_value = raw
return raw
class LocalThingsConnectionModeSensor(CoordinatorEntity[LocalThingsCoordinator], SensorEntity):
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,9 @@
{
"entity": {
"binary_sensor": {
"auto_clean_running": {
"name": "Auto clean running"
},
"after_run_active": {
"name": "After run active"
},
@@ -10,12 +13,18 @@
"automatic_operation": {
"name": "Automatic operation"
},
"battery_charging": {
"name": "Charging"
},
"burner_hot_surface": {
"name": "Burner {number} hot surface"
},
"burner_pan_detected": {
"name": "Burner {number} pan detected"
},
"child_lock": {
"name": "Child lock"
},
"cloud_connected": {
"name": "Cloud connected"
},
@@ -34,6 +43,9 @@
"overload_protection_active": {
"name": "Overload protection active"
},
"odor_controller_active": {
"name": "Odor controller active"
},
"cycle_active": {
"name": "Running"
},
@@ -190,6 +202,9 @@
},
"tropical_night_mode": {
"name": "Tropical night mode"
},
"zone_target_temperature": {
"name": "Zone target temperature"
}
},
"select": {
@@ -292,19 +307,25 @@
"01": "Normal",
"06": "Time dry",
"16": "Cotton",
"17": "Super Speed",
"18": "Synthetics",
"19": "Delicates",
"20": "Iron dry",
"23": "Quick Dry 35",
"21": "Hygiene Care",
"22": "Silent Dry",
"23": "Quick Dry 35'",
"24": "Cool air",
"25": "Warm air",
"27": "Time dry",
"29": "AI Dry",
"1a": "Wool",
"1b": "Bedding",
"1c": "Shirts",
"1d": "Towels",
"1e": "Outdoor",
"1f": "Mixed load"
"1f": "Mixed load",
"2b": "Self Tub Dry",
"4c": "Air Refresh"
}
},
"favorite_capacity": {
@@ -313,6 +334,15 @@
"favorite_hotwater_temperature": {
"name": "Favorite hot water temperature"
},
"filter_alarm_time": {
"name": "Filter alarm interval",
"state": {
"180": "180 hours",
"300": "300 hours",
"500": "500 hours",
"700": "700 hours"
}
},
"finish_sound": {
"name": "Finish sound",
"state": {
@@ -588,9 +618,15 @@
"none": "Off",
"cupboard": "Cupboard"
}
},
"zone_mode": {
"name": "Zone mode"
}
},
"sensor": {
"auto_clean_progress": {
"name": "Auto clean progress"
},
"after_run_progress": {
"name": "After run progress"
},
@@ -600,6 +636,9 @@
"air_filter_usage_hours": {
"name": "Filter usage hours"
},
"air_quality_standard": {
"name": "Air quality standard"
},
"air_sensing_state": {
"name": "Air sensing state"
},
@@ -612,12 +651,18 @@
"automatic_ventilation_state": {
"name": "Automatic ventilation state"
},
"battery": {
"name": "Battery"
},
"burner_state": {
"name": "Burner {number} state"
},
"clean_level": {
"name": "Clean level"
},
"co2": {
"name": "CO2"
},
"coffee_brew_status": {
"name": "Coffee brew status"
},
@@ -646,6 +691,9 @@
"outdoor_temperature": {
"name": "Outdoor temperature"
},
"odor_controller_progress": {
"name": "Odor controller progress"
},
"panel_status": {
"name": "Panel status"
},
@@ -918,6 +966,9 @@
"icestatus_stop": "Idle",
"icestatus_run": "Making ice"
}
},
"zone_temperature": {
"name": "Zone temperature"
}
},
"switch": {
@@ -996,6 +1047,9 @@
"display_light": {
"name": "Display light"
},
"dnd": {
"name": "Do not disturb"
},
"fast_preheat": {
"name": "Fast preheat"
},
@@ -1076,9 +1130,21 @@
},
"wrinkle_prevent": {
"name": "Wrinkle prevent"
},
"zone_power": {
"name": "Zone power"
},
"away_mode": {
"name": "Away mode"
}
},
"time": {
"dnd_end": {
"name": "Do not disturb end"
},
"dnd_start": {
"name": "Do not disturb start"
},
"led_night_end": {
"name": "Door LED night end"
},
@@ -1091,6 +1157,11 @@
"night_start": {
"name": "Night light start"
}
},
"water_heater": {
"dhw": {
"name": "Hot water"
}
}
},
"config": {
@@ -1138,15 +1209,16 @@
"init": {
"title": "LocalThings options",
"menu_options": {
"settings": "Remote control write settings",
"settings": "Device settings",
"debug_write": "Debug: write to a resource"
}
},
"settings": {
"title": "Remote control write settings",
"description": "Some devices accept certain writes (e.g. default detergent/softener dosing on a washer) even while reporting remote control off. By default, LocalThings blocks every write with a clear error whenever a device reports remote control off, rather than letting the device silently reject it. Only enable this if you've confirmed writes actually work on this device with remote control off -- otherwise you'll trade that clear error for a silent failure.",
"title": "Device settings",
"description": "Some devices accept certain writes (e.g. default detergent/softener dosing on a washer) even while reporting remote control off. By default, LocalThings blocks every write with a clear error whenever a device reports remote control off, rather than letting the device silently reject it. Only enable this if you've confirmed writes actually work on this device with remote control off -- otherwise you'll trade that clear error for a silent failure.\n\nEstimated finish time is recomputed from the device's remaining-time estimate on every poll, which can drift or get revised by a minute or two between updates. Raise the minimum-change value below to hold the sensor at its last reported value until the estimate moves by at least that many minutes, cutting down on history/logbook noise. Set it to 0 to report every computed change.",
"data": {
"bypass_remote_control_lock": "Allow writes even when remote control is reported off"
"bypass_remote_control_lock": "Allow writes even when remote control is reported off",
"finish_time_hysteresis_minutes": "Estimated finish -- minimum change (minutes)"
}
},
"debug_write": {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,9 @@
{
"entity": {
"binary_sensor": {
"auto_clean_running": {
"name": "Auto clean actief"
},
"after_run_active": {
"name": "Nadraaien actief"
},
@@ -10,11 +13,17 @@
"automatic_operation": {
"name": "Automatische werking"
},
"battery_charging": {
"name": "Opladen"
},
"burner_hot_surface": {
"name": "Brander {number} heet oppervlak"
},
"burner_pan_detected": {
"name": "Brander {number} pan gedetecteerd"
"name": "Pan gedetecteerd op brander {number}"
},
"child_lock": {
"name": "Kinderslot"
},
"cloud_connected": {
"name": "Verbonden met cloud"
@@ -23,7 +32,7 @@
"name": "Stofzak vol"
},
"cooktop_power": {
"name": "Kookplaat voeding"
"name": "Voeding kookplaat"
},
"cooktop_safety_shutoff_enabled": {
"name": "Automatische uitschakeling heet oppervlak ingeschakeld"
@@ -34,6 +43,9 @@
"overload_protection_active": {
"name": "Overbelastingsbeveiliging actief"
},
"odor_controller_active": {
"name": "Geurbeheersing actief"
},
"cycle_active": {
"name": "Actief"
},
@@ -74,7 +86,7 @@
"name": "Schenken"
},
"alarm_in_mute": {
"name": "Alarm bij dempen"
"name": "Alarmdemping"
},
"power_state": {
"name": "Voedingsstatus"
@@ -190,6 +202,9 @@
},
"tropical_night_mode": {
"name": "Tropische nachtmodus"
},
"zone_target_temperature": {
"name": "Doeltemperatuur zone"
}
},
"select": {
@@ -215,7 +230,7 @@
}
},
"cooler_temperature_setpoint": {
"name": "Koeler temperatuur"
"name": "Temperatuur koelgedeelte"
},
"day_brightness": {
"name": "Helderheid kast",
@@ -292,19 +307,25 @@
"01": "Normaal",
"06": "Tijdprogramma",
"16": "Katoen",
"17": "Super Speed",
"18": "Synthetisch",
"19": "Fijne was",
"20": "Strijkdroog",
"21": "Hygiënisch drogen",
"22": "Stil drogen",
"23": "Snel drogen 35'",
"24": "Koude lucht",
"25": "Warme lucht",
"27": "Tijdprogramma",
"29": "AI drogen",
"1a": "Wol",
"1b": "Beddengoed",
"1c": "Overhemden",
"1d": "Handdoeken",
"1e": "Outdoor",
"1f": "Gemengde was"
"1f": "Gemengde was",
"2b": "Zelf trommel drogen",
"4c": "Opfrissen"
}
},
"favorite_capacity": {
@@ -313,6 +334,15 @@
"favorite_hotwater_temperature": {
"name": "Favoriete temperatuur warm water"
},
"filter_alarm_time": {
"name": "Filteralarminterval",
"state": {
"180": "180 uur",
"300": "300 uur",
"500": "500 uur",
"700": "700 uur"
}
},
"finish_sound": {
"name": "Eindgeluid",
"state": {
@@ -481,24 +511,24 @@
"sound_mode": {
"name": "Geluidsmodus",
"state": {
"voice": "Spraak",
"voice": "Stem",
"tone": "Toon",
"mute": "Dempen"
"mute": "Stil"
}
},
"air_purifier_sound_mode": {
"name": "Geluidsmodus",
"state": {
"mute": "Dempen",
"mute": "Stil",
"buzzer": "Zoemer"
}
},
"water_purifier_sound_mode": {
"name": "Geluidsmodus",
"state": {
"voice": "Spraak",
"voice": "Stem",
"fixedtone": "Vaste toon",
"mute": "Dempen"
"mute": "Stil"
}
},
"spin_speed": {
@@ -561,17 +591,17 @@
"53": "Intensief",
"54": "Handdoeken",
"55": "Sportkleding",
"57": "Fijn",
"57": "Fijne was",
"5e": "Spoelen+centrifugeren",
"60": "Zelfreinigend+",
"60": "Self Clean+",
"65": "Bonte was",
"66": "Spijkergoed",
"7c": "Witte was",
"7d": "Beddengoed/Waterdicht",
"7e": "Zelfreinigend",
"7f": "Wol/Fijn",
"7d": "Beddengoed/waterdicht",
"7e": "Self Clean",
"7f": "Wol/fijne was",
"86": "Diep wassen",
"87": "Downloaden",
"87": "Gedownload",
"8f": "Intensief koud",
"96": "Minder microvezels"
}
@@ -588,9 +618,15 @@
"none": "Uit",
"cupboard": "Kastdroog"
}
},
"zone_mode": {
"name": "Zonemodus"
}
},
"sensor": {
"auto_clean_progress": {
"name": "Auto clean voortgang"
},
"after_run_progress": {
"name": "Voortgang nadraaien"
},
@@ -600,6 +636,9 @@
"air_filter_usage_hours": {
"name": "Filterverbruik (uren)"
},
"air_quality_standard": {
"name": "Luchtkwaliteitsnorm"
},
"air_sensing_state": {
"name": "Status luchtmeting"
},
@@ -612,12 +651,18 @@
"automatic_ventilation_state": {
"name": "Status automatische ventilatie"
},
"battery": {
"name": "Batterij"
},
"burner_state": {
"name": "Status brander {number}"
},
"clean_level": {
"name": "Reinigingsniveau"
},
"co2": {
"name": "CO2"
},
"coffee_brew_status": {
"name": "Koffiezetstatus"
},
@@ -629,7 +674,7 @@
}
},
"cooktop_running_state": {
"name": "Loopstatus kookplaat"
"name": "Bedrijfsstatus kookplaat"
},
"cooktop_state": {
"name": "Status kookplaat"
@@ -646,6 +691,9 @@
"outdoor_temperature": {
"name": "Buitentemperatuur"
},
"odor_controller_progress": {
"name": "Voortgang geurbeheersing"
},
"panel_status": {
"name": "Paneelstatus"
},
@@ -662,10 +710,10 @@
"name": "Status steel"
},
"uvc_operation_time": {
"name": "UV-C bedrijfstijd"
"name": "UV-C-bedrijfstijd"
},
"uvc_total_operation_time": {
"name": "UV-C totale bedrijfstijd"
"name": "Totale UV-C-bedrijfstijd"
},
"uvc_finished_time": {
"name": "UV-C voltooid op"
@@ -759,7 +807,7 @@
"name": "Geschatte eindtijd"
},
"completion_minutes": {
"name": "Voltooiingstijd"
"name": "Resterende tijd"
},
"freezer_temperature": {
"name": "Vriezertemperatuur"
@@ -840,13 +888,13 @@
"name": "Vermogen"
},
"probe_battery": {
"name": "Sonde batterij"
"name": "Batterijniveau sonde"
},
"probe_target_temperature": {
"name": "Doeltemperatuur sonde"
},
"probe_temperature": {
"name": "Temperatuur sonde"
"name": "Sondetemperatuur"
},
"progress": {
"name": "Voortgang"
@@ -888,7 +936,7 @@
"name": "Status"
},
"cup_state": {
"name": "Kopstatus"
"name": "Bekerstatus"
},
"last_pour_type": {
"name": "Laatste schenktype"
@@ -918,6 +966,9 @@
"icestatus_stop": "Inactief",
"icestatus_run": "IJs maken"
}
},
"zone_temperature": {
"name": "Zonetemperatuur"
}
},
"switch": {
@@ -996,6 +1047,9 @@
"display_light": {
"name": "Displayverlichting"
},
"dnd": {
"name": "Niet storen"
},
"fast_preheat": {
"name": "Snel voorverwarmen"
},
@@ -1024,7 +1078,7 @@
"name": "{instance_name} ingeschakeld"
},
"intensive": {
"name": "Intensive"
"name": "Intensief"
},
"lamp": {
"name": "Lamp"
@@ -1076,9 +1130,21 @@
},
"wrinkle_prevent": {
"name": "Kreukpreventie"
},
"zone_power": {
"name": "Zonevoeding"
},
"away_mode": {
"name": "Afwezigheidsmodus"
}
},
"time": {
"dnd_end": {
"name": "Einde niet storen"
},
"dnd_start": {
"name": "Start niet storen"
},
"led_night_end": {
"name": "Einde nachtverlichting deur-LED"
},
@@ -1091,6 +1157,11 @@
"night_start": {
"name": "Start nachtverlichting"
}
},
"water_heater": {
"dhw": {
"name": "Warm water"
}
}
},
"config": {
@@ -1138,15 +1209,16 @@
"init": {
"title": "LocalThings-opties",
"menu_options": {
"settings": "Schrijfinstellingen voor afstandsbediening",
"settings": "Apparaatinstellingen",
"debug_write": "Foutopsporing: naar een resource schrijven"
}
},
"settings": {
"title": "Schrijfinstellingen voor afstandsbediening",
"description": "Sommige apparaten accepteren bepaalde schrijfbewerkingen (bijvoorbeeld de standaarddosering van wasmiddel of wasverzachter op een wasmachine), ook als ze melden dat de afstandsbediening is uitgeschakeld. LocalThings blokkeert standaard elke schrijfbewerking met een duidelijke foutmelding wanneer een apparaat meldt dat de afstandsbediening is uitgeschakeld, in plaats van het apparaat de opdracht stilzwijgend te laten weigeren. Schakel deze optie alleen in als je hebt bevestigd dat schrijfbewerkingen op dit apparaat echt werken wanneer de afstandsbediening is uitgeschakeld. Anders verruil je de duidelijke foutmelding voor een stille mislukking.",
"title": "Apparaatinstellingen",
"description": "Sommige apparaten accepteren bepaalde schrijfbewerkingen (bijvoorbeeld de standaarddosering van wasmiddel of wasverzachter op een wasmachine), ook als ze melden dat de afstandsbediening is uitgeschakeld. LocalThings blokkeert standaard elke schrijfbewerking met een duidelijke foutmelding wanneer een apparaat meldt dat de afstandsbediening is uitgeschakeld, in plaats van het apparaat de opdracht stilzwijgend te laten weigeren. Schakel deze optie alleen in als je hebt bevestigd dat schrijfbewerkingen op dit apparaat echt werken wanneer de afstandsbediening is uitgeschakeld. Anders verruil je de duidelijke foutmelding voor een stille mislukking.\n\nDe geschatte eindtijd wordt bij elke poll opnieuw berekend op basis van de resterende tijd die het apparaat opgeeft, wat kan afwijken of met een minuut of wat worden bijgesteld tussen updates. Verhoog de minimale wijziging hieronder om de sensor op zijn laatst gerapporteerde waarde te houden totdat de schatting met minstens dat aantal minuten verandert, wat de ruis in geschiedenis/logboek vermindert. Zet op 0 om elke berekende wijziging te rapporteren.",
"data": {
"bypass_remote_control_lock": "Schrijfbewerkingen toestaan wanneer afstandsbediening als uitgeschakeld wordt gemeld"
"bypass_remote_control_lock": "Schrijfbewerkingen toestaan wanneer afstandsbediening als uitgeschakeld wordt gemeld",
"finish_time_hysteresis_minutes": "Geschatte eindtijd -- minimale wijziging (minuten)"
}
},
"debug_write": {
@@ -0,0 +1,273 @@
"""Water heater platform for Local Things.
Second composite entity in this integration (see climate.py's module
docstring for the general pattern this follows): a single HA water_heater
card for a Samsung EHS heat pump's domestic hot water (DHW) loop. It binds
the primary `WaterHeaterDesc` (the `/mode/dhw/vs/0` capability, DHW.entities
in registry/capabilities/ehs.py) so the registry still tracks it, and reads
the sibling `/power/dhw/vs/0` and `/temperatures/dhw/vs/0` resources straight
from the coordinator snapshot -- the same cross-resource read climate.py uses
for the AC's power/temperature/wind siblings.
Writes go through `coordinator.async_send_command(bound, (kind, value))`:
DHW's `write_fn` (ehs._dhw_write) maps each `(kind, value)` payload to the
right `(path_segs, body)`, and `async_send_command` POSTs to those path_segs
and applies the optimistic value/settle guard to that same href -- not the
bound `/mode/dhw/vs/0` href -- so one descriptor drives writes to, and gets
fresh state back for, power, mode and temperature alike.
Operation-mode vocabulary: the DHW loop's four device modes (Eco/Std/Force/
Power) map onto HA's own standard water_heater states -- the same mapping
Home Assistant's core `smartthings` integration uses for this exact Samsung
capability over the cloud API (`samsungce.ehsThermostat` /
`airConditionerMode`: eco/std/force/power -> STATE_ECO/STATE_HEAT_PUMP/
STATE_HIGH_DEMAND/STATE_PERFORMANCE), just title-cased to match this OCF
resource's own code spelling. Reusing HA's standard states means no *state*
translation catalog entry is needed for them (see the entity_component
fallback in homeassistant.components.water_heater.strings.json).
Naming is a separate question from that, and the answer here differs from
climate.py's: the AC *is* the device, so its climate card takes the bare
device name (`_attr_name = None`). An EHS unit has two loops, and the DHW
one is not "the device" -- its siblings are named "Zone Mode"/"Zone Target
Temperature", so a card labelled just "EHS" would misrepresent which loop
it drives. This entity is named through the catalog like every other
descriptor here, via the DHW descriptor's `translation_key='dhw'`
(entity.water_heater.dhw.name -> "Hot water").
"""
from __future__ import annotations
import logging
from homeassistant.components.water_heater import (
STATE_ECO,
STATE_HEAT_PUMP,
STATE_HIGH_DEMAND,
STATE_PERFORMANCE,
WaterHeaterEntity,
WaterHeaterEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_OFF, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .registry.capabilities.ehs import (
HREF_DHW_MODE as MODE_HREF,
HREF_DHW_POWER as POWER_HREF,
HREF_DHW_TEMPERATURE as TEMPERATURE_HREF,
)
from .registry.capabilities.common import normalize_temp_unit
from .registry.entities import WaterHeaterDesc
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
_LOGGER = logging.getLogger(__name__)
_MODES_FIELD = 'x.com.samsung.da.modes'
_SUPPORTED_FIELD = 'x.com.samsung.da.supportedModes'
# Device mode <-> HA water_heater operation state -- see the module
# docstring above for the SmartThings-cloud precedent this mirrors.
_DEVICE_TO_STATE: dict[str, str] = {
'Eco': STATE_ECO,
'Std': STATE_HEAT_PUMP,
'Force': STATE_HIGH_DEMAND,
'Power': STATE_PERFORMANCE,
}
_STATE_TO_DEVICE = {v: k for k, v in _DEVICE_TO_STATE.items()}
# Read-side lookup, case-folded. climate.py resolves write codes from the
# unit's own supportedModes because two spellings there mean one HA value
# ('Wind'/'Fan' -> FAN_ONLY); this map is bijective, so the write side can
# use _STATE_TO_DEVICE directly. Only the read side is exposed to a board
# spelling the same code differently ('eco'/'ECO'), and case is the one
# variation worth absorbing rather than warning about.
_DEVICE_TO_STATE_CI = {k.lower(): v for k, v in _DEVICE_TO_STATE.items()}
def _to_state(code) -> str | None:
if code is None:
return None
return _DEVICE_TO_STATE_CI.get(str(code).lower())
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
LocalThingsWaterHeater(coordinator, b)
for b in coordinator.bound
if isinstance(b.desc, WaterHeaterDesc) and _is_included(b, coordinator)
)
def _num(value):
try:
return float(value)
except (TypeError, ValueError):
return None
def _first(value):
"""Samsung `modes` is a single-element list on this resource. Return the
first element of a list, else the value itself."""
if isinstance(value, (list, tuple)):
return value[0] if value else None
return value
class LocalThingsWaterHeater(LocalThingsEntity, WaterHeaterEntity):
"""Composite water_heater entity for a Samsung EHS DHW loop."""
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
super().__init__(coordinator, bound)
# No _attr_name here: unlike climate.py's AC, this is one loop of a
# two-loop device and takes a catalog name ("Hot water") through the
# descriptor's translation_key -- see the module docstring.
self._attr_supported_features = (
WaterHeaterEntityFeature.TARGET_TEMPERATURE
| WaterHeaterEntityFeature.OPERATION_MODE
| WaterHeaterEntityFeature.ON_OFF
)
# Raw device codes already logged by _warn_unmapped -- these
# properties are read on every coordinator refresh, so an un-deduped
# warning would spam the log for any unit reporting a genuinely
# unrecognized code.
self._warned_unmapped: set[str] = set()
def _rep(self, href: str) -> dict:
"""`href` is one of this module's canonical HREF_* constants --
translated through this bound entity's own subdevice (issue #177),
same as climate.py's identical helper."""
return self.coordinator.resource(self._bound.subdevice.to_actual(href)) or {}
def _is_on(self) -> bool:
return str(self._rep(POWER_HREF).get('x.com.samsung.da.power', '')).lower() == 'on'
def _supported(self) -> list[str]:
return list(self._rep(MODE_HREF).get(_SUPPORTED_FIELD) or [])
def _warn_unmapped(self, code: str) -> None:
if code in self._warned_unmapped:
return
self._warned_unmapped.add(code)
_LOGGER.warning(
"%s: device DHW mode %r has no HA mapping and was dropped; "
"please file an issue with your diagnostics dump",
self.entity_id, code,
)
# -- temperature --------------------------------------------------------
@property
def temperature_unit(self) -> str:
raw = self._rep(TEMPERATURE_HREF).get('x.com.samsung.da.unit')
return (UnitOfTemperature.FAHRENHEIT
if normalize_temp_unit(raw, '°C') == '°F'
else UnitOfTemperature.CELSIUS)
@property
def current_temperature(self):
return _num(self._rep(TEMPERATURE_HREF).get('x.com.samsung.da.current'))
@property
def target_temperature(self):
return _num(self._rep(TEMPERATURE_HREF).get('x.com.samsung.da.desired'))
def _range(self) -> list | None:
"""The device's own (minimum, maximum) pair, or None.
Both ends together or neither, deliberately -- same rule as
climate._range(). A board reporting minimum but not maximum would
otherwise pair a device minimum (40) with HA's own default maximum
(140 °F), which looks plausible and is silently wrong on a unit
that really allows 62.
"""
rep = self._rep(TEMPERATURE_HREF)
lo = _num(rep.get('x.com.samsung.da.minimum'))
hi = _num(rep.get('x.com.samsung.da.maximum'))
return [lo, hi] if (lo is not None and hi is not None) else None
@property
def min_temp(self) -> float:
r = self._range()
return r[0] if r else super().min_temp
@property
def max_temp(self) -> float:
r = self._range()
return r[1] if r else super().max_temp
@property
def target_temperature_step(self) -> float:
# `is None`, not `or` -- see issue #160: `or` collapses a genuine 0
# into the fallback.
step = _num(self._rep(TEMPERATURE_HREF).get('x.com.samsung.da.increment'))
return 0.5 if step is None else step
# -- operation mode -------------------------------------------------------
@property
def current_operation(self) -> str | None:
if not self._is_on():
return STATE_OFF
code = _first(self._rep(MODE_HREF).get(_MODES_FIELD))
mapped = _to_state(code)
if code is not None and mapped is None:
self._warn_unmapped(code)
return mapped
@property
def operation_list(self) -> list[str]:
modes = [STATE_OFF]
for code in self._supported():
mapped = _to_state(code)
if mapped is None:
self._warn_unmapped(code)
continue
if mapped not in modes:
modes.append(mapped)
return modes
# -- writes ---------------------------------------------------------------
async def async_set_temperature(self, **kwargs) -> None:
# HA's water_heater.set_temperature service takes an optional
# operation_mode and forwards it here (SET_TEMPERATURE_SCHEMA), same
# as climate.set_temperature does with hvac_mode. Honour it, and set
# it first -- that also powers the loop on when it was off -- so a
# dashboard "boost to 55" button that carries a mode actually changes
# mode, instead of only moving the setpoint. Same fix as the AC's
# (see climate.async_set_temperature).
operation_mode = kwargs.get('operation_mode')
if operation_mode is not None:
await self.async_set_operation_mode(operation_mode)
if operation_mode == STATE_OFF:
return
temp = kwargs.get('temperature')
if temp is None:
return
await self.coordinator.async_send_command(self._bound, ('temperature', temp))
async def async_set_operation_mode(self, operation_mode: str) -> None:
if operation_mode == STATE_OFF:
await self.coordinator.async_send_command(self._bound, ('power', False))
return
device = _STATE_TO_DEVICE.get(operation_mode)
if device is None:
return
if not self._is_on():
await self.coordinator.async_send_command(self._bound, ('power', True))
await self.coordinator.async_send_command(self._bound, ('mode', device))
async def async_turn_on(self, **kwargs) -> None:
await self.coordinator.async_send_command(self._bound, ('power', True))
async def async_turn_off(self, **kwargs) -> None:
await self.coordinator.async_send_command(self._bound, ('power', False))
+414
View File
@@ -0,0 +1,414 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/airdresseroption/sanitize/vs/0",
"rep": {}
},
{
"href": "/alarms/vs/0",
"rep": {}
},
{
"href": "/buzzersound/vs/0",
"rep": {
"supportedBuzzerSound": [
"Off",
"On"
],
"setBuzzerSound": "On"
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "1117000000",
"x.com.samsung.da.countryCode": "KR"
}
},
{
"href": "/connectionconfig/vs/0",
"rep": {
"autoReconnectionMinVersion": "1.0",
"autoReconnection": "true",
"autoReconnectionProtocolType": [
"helper_hotspot",
"ble_ocf"
],
"supportedWiFiAuthType": [
"OPEN",
"WEP",
"WPA-PSK",
"WPA2-PSK",
"SAE"
],
"supportedWiFiCryptoType": [
"TKIP",
"AES",
"WEP-64",
"WEP-128"
],
"supportedWiFiFreq": [
"2.4G"
],
"calmConnectionCare": {
"version": "1.0",
"role": [
"things"
]
}
}
},
{
"href": "/course/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"HOMECARE_WIZARD_V2"
],
"x.com.samsung.da.options": [
"DeviceType_015E",
"UpdateAllow_NotAllowed",
"Course_01",
"AiOption_On",
"SteamPush_None",
"SeamlessControl_Disable",
"KidsLockBypass_On",
"FilterUsage_384001D4",
"WashingTimes_2",
"DrumCleanProposal_30",
"SpecialFunction_16",
"AvailableDelayTime_0",
"ProgressTimeSet_AA012CB20348",
"WrinklePreventRunning_Off",
"SendToDevice_Off",
"WeatherPush_On",
"GMT_12",
"DelayEndSet_0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0FF0F0F0F0F0F0F0F00F0F0F0F0F",
"EnergyLevelSet_05020404010103040301020203020101040505050503030303030303050204050405",
"WrinklePreventSet_0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0FF00F0F0F0F0F0F0F0F0F0F0FF0F0",
"SavingModeCondition_01010205272B2C2D2E0300",
"SavingMode_Off",
"UsagesDB_ok",
"DrumCleanLog_Empty"
],
"x.com.samsung.da.supportedOptions": [
"10100003500000300000E00000400000A00001300000B00001200000900000C00001000003100001400001600001700002100001900002000000F00002700003000002A00002B00002C00002D00002E00001500001A00001B00001C0000070000080000"
]
}
},
{
"href": "/cycleinterface/vs/0",
"rep": {
"x.com.samsung.da.cycleInterfaceEnabled": "Off"
}
},
{
"href": "/diagnosis/vs/0",
"rep": {
"x.com.samsung.da.diagnosisStart": "Ready"
}
},
{
"href": "/drlc/0",
"rep": {
"DRLevel": 0,
"start": "0000-00-00T00:00:00Z",
"duration": 0,
"override": false
}
},
{
"href": "/drlc/vs/0",
"rep": {
"x.com.samsung.da.drlcLevel": "0",
"x.com.samsung.da.durationminutes": "0",
"x.com.samsung.da.start": "0000-00-00T00:00:00Z",
"x.com.samsung.da.override": "Off",
"x.com.samsung.da.realSaving": "Off"
}
},
{
"href": "/energy/consumption/0",
"rep": {}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.instantaneousPower": "-500",
"x.com.samsung.da.instantaneousPowerUnit": "W",
"x.com.samsung.da.cumulativePower": "2900",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.cumulativeDate": "1785362400",
"x.com.samsung.da.cumulativeDateUTC": "1785330000",
"x.com.samsung.da.cumulativeSavedPower": "0"
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+00:00"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "DA_DF_TP1_21_COMMON|20313841|380101020016111F42C3000200010000",
"x.com.samsung.da.description": "DA_DF_TP1_21_COMMON_DF3000B/DC92-03252A_0002",
"x.com.samsung.da.serialNum": "REDACTED",
"x.com.samsung.da.otnDUID": "REDACTED",
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
"x.com.samsung.da.diagLogType": [
"errCode",
"dump"
],
"x.com.samsung.da.diagDumpType": "file",
"x.com.samsung.da.diagEndPoint": "SSM",
"x.com.samsung.da.diagMnid": "0AJT",
"x.com.samsung.da.diagSetupid": "WA0",
"x.com.samsung.da.diagMinVersion": "3.0",
"x.com.samsung.da.diagTsId": "DA01",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "DA_DF_TP1_21_COMMON|20313841|380101020016111F42C3000200010000",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "03101A260513(A440)",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Firmware_1_DB_20313841241127210FFFFF203252412402215804FFFF(015E2031384120325241_30241204)(FileDown:0)(Type:0)",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "03138A24112721,03252A24022158",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Firmware_2_DB_2031574122122007043FFF2032114122122704042FFF(015E2031574120321141_30000000)(FileDown:0)(Type:0)",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "03157A22122007,03211A22122704"
},
{
"x.com.samsung.da.id": "3",
"x.com.samsung.da.description": "Firmware_3_DB_20303242000000020AFFFFFFFFFFFFFFFFFFFFFFFFFF(015E20303242FFFFFFFF_30000000)(FileDown:0)(Type:0)",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "03032B00000002,FFFFFFFFFFFFFF"
}
]
}
},
{
"href": "/kidslock/0",
"rep": {
"value": false
}
},
{
"href": "/kidslock/vs/0",
"rep": {
"x.com.samsung.da.kidsLock": "Ready"
}
},
{
"href": "/operational/state/0",
"rep": {
"currentMachineState": "REDACTED",
"machineStates": "REDACTED",
"jobStates": [
"None",
"Steaming",
"Airwashing",
"Drying",
"Finish"
],
"currentJobState": "None",
"remainingTime": "00:35:00",
"progressPercentage": "1"
}
},
{
"href": "/operational/state/vs/0",
"rep": {
"x.com.samsung.da.state": "Ready",
"x.com.samsung.da.remainingTime": "00:35:00",
"x.com.samsung.da.progressPercentage": "1",
"x.com.samsung.da.progress": "None",
"x.com.samsung.da.delayEndTime": "00:00:00",
"x.com.samsung.da.supportedProgress": [
"None",
"Steaming",
"Airwashing",
"Drying",
"Finish"
]
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "",
"x.com.samsung.da.newVersionAvailable": "false",
"x.com.samsung.da.newVersionNo": "00000000",
"x.com.samsung.da.currentVersionInfo": "00000000",
"otnStatus": "None",
"flashingProgress": "",
"otnTarget": "main",
"otnCompleteDate": "2026-07-07",
"otnList": [
{
"type": "WIFI",
"modelId": "DA_DF_TP1_21_COMMON",
"versions": [
"30260513"
],
"visVersion": "260513"
},
{
"type": "Micom",
"modelId": "015E2031384120325241",
"versions": [
"24112721",
"24022158"
],
"visVersion": "241127"
},
{
"type": "Micom",
"modelId": "015E2031574120321141",
"versions": [
"22122007",
"22122704"
],
"visVersion": "221227"
},
{
"type": "Micom",
"modelId": "015E20303242FFFFFFFF",
"versions": [
"2",
"FFFFFFFF"
],
"visVersion": "2"
}
]
}
},
{
"href": "/power/0",
"rep": {
"value": false
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "Off"
}
},
{
"href": "/quickcontrol/info/vs/0",
"rep": {
"supportedVersion": "1.0"
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/remotectrl/0",
"rep": {
"value": false
}
},
{
"href": "/remotectrl/vs/0",
"rep": {
"x.com.samsung.da.remoteControlEnabled": "false"
}
},
{
"href": "/st/airdressercourse/vs/0",
"rep": {
"x.com.samsung.da.st.airdresserMode": "Table_00_Course_01",
"x.com.samsung.da.st.courseTable": "Table_00"
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "Asia/Seoul",
"offset": "+09:00",
"DST": "OFF"
}
},
{
"href": "/washer/vs/0",
"rep": {
"x.com.samsung.da.wrinklePrevent": "Off"
}
},
{
"href": "/wirelessinfo/vs/0",
"rep": {
"macaddressWiFi": "REDACTED",
"macaddressBLE": "REDACTED"
}
},
{
"href": "/wm/editcourse/vs/0",
"rep": {
"x.com.samsung.da.editCourseList": "EditCourseList_0135030E040A130B12090C10311416172119200F27302A2B2C2D2E151A1B1C0708",
"x.com.samsung.da.fixedCourseList": "FixedCourseList_01270F"
}
},
{
"href": "/wm/jobbeginingstatus/vs/0",
"rep": {}
},
{
"href": "/wm/personalcourse/vs/0",
"rep": {
"x.com.samsung.da.courses": [
"F1_00",
"F2_00",
"F3_00",
"F4_00",
"F5_00",
"F6_00",
"F7_00",
"F8_00",
"F9_00",
"FA_00"
],
"x.com.samsung.da.maxCourseNum": "10"
}
},
{
"href": "/wm/setinfo/vs/0",
"rep": {
"x.com.samsung.da.isModelSettingWithoutSC": "false",
"x.com.samsung.da.aiCourse": "false",
"x.com.samsung.da.isModelSettingPowerOnOff": "true",
"x.com.samsung.da.modelCode": "M(None),W(None)"
}
},
{
"href": "/wm/welcomemsg/vs/0",
"rep": {}
}
]
}
+184
View File
@@ -0,0 +1,184 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/airqualitystandard/vs/0",
"rep": {
"x.com.samsung.da.standard": "AirCleanAssociation"
}
},
{
"href": "/alarms/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "ErrorCode_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-30T02:42:57",
"x.com.samsung.da.state": "Deleted"
}
],
"rt": [
"x.com.samsung.da.alarms"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/dnd/vs/0",
"rep": {
"x.com.samsung.da.value": "false",
"x.com.samsung.da.startTime": "14:00:00",
"x.com.samsung.da.endTime": "22:00:00",
"x.com.samsung.da.visible": "true"
}
},
{
"href": "/energy/battery/vs/0",
"rep": {
"x.com.samsung.da.battery": "80",
"x.com.samsung.da.batteryResolution": "1",
"x.com.samsung.da.charging": "Off"
}
},
{
"href": "/humidity/vs/0",
"rep": {
"x.com.samsung.da.humidity": "57",
"x.com.samsung.da.validity": "Valid"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "ASM-KR-TP1-22-ACMB1M|10243041|75000000001611C40800020000080000",
"x.com.samsung.da.description": "ASM-KR-TP1-22-ACMB1M",
"x.com.samsung.da.serialNum": "REDACTED",
"x.com.samsung.da.otnDUID": "REDACTED",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02443A260310",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "22050300",
"x.com.samsung.da.newVersionAvailable": "0"
}
],
"x.com.samsung.da.diagProtocolType": "WIFI_HTTPS",
"x.com.samsung.da.diagLogType": [
"errCode",
"dump"
],
"x.com.samsung.da.diagDumpType": "file",
"x.com.samsung.da.diagEndPoint": "SSM",
"x.com.samsung.da.diagMnid": "0AJT",
"x.com.samsung.da.diagSetupid": "AM0",
"x.com.samsung.da.diagMinVersion": "1.0"
}
},
{
"href": "/keepnormalstate/vs/0",
"rep": {
"x.com.samsung.da.keepnormal": 0
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.longnotisubscription": "true",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/sensordatasinks/vs/0",
"rep": {
"remove": ""
}
},
{
"href": "/sensors/vs/0",
"rep": {
"x.com.samsung.da.cleanLevel": "2",
"x.com.samsung.da.refresh": "Off",
"x.com.samsung.da.lastSensingTime": "1785379376",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Sensor for CleanLevel",
"x.com.samsung.da.type": "CleanLevel",
"x.com.samsung.da.value": [
"2"
]
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Sensor for Odor",
"x.com.samsung.da.type": "Odor",
"x.com.samsung.da.value": [
"1"
]
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Sensor for Dust",
"x.com.samsung.da.type": "Dust",
"x.com.samsung.da.value": [
"31",
"2"
]
},
{
"x.com.samsung.da.id": "3",
"x.com.samsung.da.description": "Sensor for FineDust",
"x.com.samsung.da.type": "FineDust",
"x.com.samsung.da.value": [
"23",
"2"
]
},
{
"x.com.samsung.da.id": "4",
"x.com.samsung.da.description": "Sensor for SuperFineDust",
"x.com.samsung.da.type": "SuperFineDust",
"x.com.samsung.da.value": [
"18",
"2"
]
},
{
"x.com.samsung.da.id": "5",
"x.com.samsung.da.description": "Sensor for CO2",
"x.com.samsung.da.type": "CO2",
"x.com.samsung.da.value": [
"498",
"1"
]
}
]
}
}
]
}
@@ -0,0 +1,575 @@
{
"device0": [
{},
{
"href": "/aisleep/vs/0",
"rep": {
"x.com.samsung.da.displayNightMode": "Off",
"x.com.samsung.da.elapsedTime": "0",
"x.com.samsung.da.requestFeedback": "Off",
"x.com.samsung.da.resultFeedback": 0,
"x.com.samsung.da.statusFeedback": "Idle",
"x.com.samsung.da.sleepTime": "14002200"
}
},
{
"href": "/alarms/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "ErrorCode_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-31T12:54:20",
"x.com.samsung.da.state": "Deleted"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "FilterAlarm_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-31T12:54:20",
"x.com.samsung.da.state": "Deleted"
}
],
"rt": [
"x.com.samsung.da.alarms"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/availablecontrolsets/vs/0",
"rep": {
"x.com.samsung.da.sets": "AE3F00A0012C01610248000F0000",
"x.com.samsung.da.id": "RAC",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "0000000000",
"x.com.samsung.da.airconOptionList": [
"PRODUCT_GLOBAL",
"AI_GLOBAL_HEATPUMP_4.0",
"AI_3.0",
"Auto_To_AI",
"AI_Heat",
"ENERGY_2.0",
"HOMECARE_WIZARD_V2",
"DR",
"SingleCommand_1"
]
}
},
{
"href": "/da/softreset/vs/0",
"rep": {
"x.com.samsung.da.softwarereset": "false"
}
},
{
"href": "/drlc/vs/0",
"rep": {
"x.com.samsung.da.start": "1970-01-01T00:00:00Z",
"x.com.samsung.da.override": "Off",
"x.com.samsung.da.durationminutes": "0",
"x.com.samsung.da.drlcLevel": "0"
}
},
{
"href": "/electriccurrent/vs/0",
"rep": {
"operation": "Off",
"modes": "3",
"supportedModes": [
"3",
"4",
"5",
"6",
"7",
"8",
"9"
]
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.cumulativeDate": "1785502435",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.instantaneousPowerUnit": "W"
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+02:00",
"x.com.samsung.supprtedtype": 1
}
},
{
"href": "/filter/airdustfilter/vs/0",
"rep": {
"x.com.samsung.da.filterUsageResolution": "1",
"x.com.samsung.da.filterCapacity": "500",
"x.com.samsung.da.filterCapacityUnit": "Hour",
"x.com.samsung.da.filterResetType": [
"replaceable",
"washable"
],
"x.com.samsung.da.filterStatus": "normal",
"x.com.samsung.da.filterUsage": "44",
"x.com.samsung.da.filterDesiredUsage": "500",
"x.com.samsung.da.supportedFilterDesiredUsage": [
"180",
"300",
"500",
"700"
]
}
},
{
"href": "/humidity/vs/0",
"rep": {
"x.com.samsung.da.humidity": "0",
"x.com.samsung.da.fivepercentHumidity": "53"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.otnDUID": "**REDACTED**",
"x.com.samsung.da.modelNum": "TP1X_DA-AC-RAC-01001_0000|10267841|60010514001911010E00492200902000",
"x.com.samsung.da.description": "TP1X_DA-AC-RAC-01001_0000",
"x.com.samsung.da.serialNum": "**REDACTED**",
"x.com.samsung.da.serialNumOption": "**REDACTED**",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02762A260401",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.newVersionAvailable": "0",
"x.com.samsung.da.number": "02678A24092500,FFFFFFFFFFFFFF"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.newVersionAvailable": "0",
"x.com.samsung.da.number": "FFFFFFFFFFFFFF,FFFFFFFFFFFFFF"
},
{
"x.com.samsung.da.id": "3",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.newVersionAvailable": "0",
"x.com.samsung.da.number": "FFFFFFFFFFFFFF,FFFFFFFFFFFFFF"
}
],
"x.com.samsung.da.diagDumpType": "file",
"x.com.samsung.da.diagEndPoint": "SSM",
"x.com.samsung.da.diagLogType": [
"errCode",
"dump"
],
"x.com.samsung.da.diagMnid": "0AJT",
"x.com.samsung.da.diagSetupid": "AR2",
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
"x.com.samsung.da.diagMinVersion": "3.0",
"x.com.samsung.da.diagTsId": "DA01"
}
},
{
"href": "/keepnormalstate/vs/0",
"rep": {
"x.com.samsung.da.keepnormal": 1
}
},
{
"href": "/light/vs/0",
"rep": {
"mode": "On",
"supportedModes": [
"On",
"Off"
]
}
},
{
"href": "/mds/absencemonitoring/vs/0",
"rep": {}
},
{
"href": "/mds/absencestate/vs/0",
"rep": {}
},
{
"href": "/mode/convenient/vs/0",
"rep": {
"x.com.samsung.da.modes": "Off",
"x.com.samsung.da.supportedModes": [
"Off",
"Sleep",
"Quiet",
"Speed",
"Nano",
"NanoSleep"
]
}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.modes": [
"Cool"
],
"workingMode": "Cool",
"x.com.samsung.da.supportedModes": [
"Auto",
"Cool",
"Dry",
"Fan",
"Heat"
],
"x.com.samsung.da.options": [
"OptionCode_35880",
"Sleep_0",
"ArtificialWorking_Off",
"ComfortAICooling_Off",
"AiTempChanged_Off",
"AiTemp_240",
"Volume_Mute",
"StopAutoClean_Idle",
"SmartCoolClean_Off",
"ProgressSmartClean_0",
"FreezeAlarmSetting_Off",
"DesiredFreezeAlarm_240",
"WashAlarm_Off",
"ExtendOptionCode_215693",
"UpdateAllow_NotAllowed",
"DurationOn_240",
"OutdoorTemp_99",
"CoolCapa_20",
"WarmCapa_22",
"Autoclean_On",
"WelcomeCoolingState_Off",
"WelcomeHeatingState_Off",
"OutdoorConnection_Connected"
],
"rt": [
"x.com.samsung.da.mode"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/option/airpurify/vs/0",
"rep": {
"x.com.samsung.da.modes": "Off",
"x.com.samsung.da.supportedModes": [
"On",
"Off"
],
"rt": [
"x.com.samsung.da.option.airpurify"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/option/autoclean/vs/0",
"rep": {
"x.com.samsung.da.status": "Stop",
"x.com.samsung.da.supportedStatus": [
"Start",
"Stop"
],
"x.com.samsung.da.progress": "0",
"x.com.samsung.da.settingStatus": "On",
"x.com.samsung.da.supportedSettingStatus": [
"On",
"Off"
]
}
},
{
"href": "/option/muteonce/vs/0",
"rep": {
"muteonce": "Off",
"rt": [
"x.com.samsung.da.option.muteonce"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/personality/presence/vs/0",
"rep": {}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "On",
"operationNumber": "4"
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.longnotisubscription": "false",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/remotedatacontrol/vs/0",
"rep": {
"x.com.samsung.da.status": "Off",
"x.com.samsung.da.connectionStatus": "Disconnected"
}
},
{
"href": "/remotetemperature/vs/0",
"rep": {
"x.com.samsung.da.temperature": "",
"x.com.samsung.da.unit": "",
"x.com.samsung.da.error": ""
}
},
{
"href": "/reserverulesets/vs/0",
"rep": {
"x.com.samsung.da.sets": "AEFFFFFFFF101E121EFFFF101EFFFF000001001F00010000001F0000009C00FFFF1E00",
"x.com.samsung.da.id": "RAC",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/selfcheck/vs/0",
"rep": {
"x.com.samsung.da.supportedActions": [
"Start",
"Cancel"
],
"x.com.samsung.da.error": [
"DA_ERROR_NONE"
]
}
},
{
"href": "/temperature/control/vs/0",
"rep": {
"x.com.samsung.da.increment": "1"
}
},
{
"href": "/temperatures/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Temperature",
"x.com.samsung.da.current": "24.0",
"x.com.samsung.da.desired": "24.0",
"x.com.samsung.da.minimum": "16",
"x.com.samsung.da.maximum": "30",
"x.com.samsung.da.increment": "1",
"x.com.samsung.da.unit": "Celsius"
}
]
}
},
{
"href": "/welcome/temperature/vs/0",
"rep": {
"operatingStatus": "None",
"requestId": "0000"
}
},
{
"href": "/wind/direction/vs/0",
"rep": {
"x.com.samsung.da.modes": "All",
"x.com.samsung.da.supportedModes": [
"Fix",
"Up_And_Low",
"Left_And_Right",
"All"
]
}
},
{
"href": "/wind/strength/vs/0",
"rep": {
"x.com.samsung.da.modes": "3",
"x.com.samsung.da.supportedModes": [
"0",
"1",
"2",
"3",
"4"
],
"x.com.samsung.da.modesName": [
"Auto",
"Low",
"Mid",
"High",
"Turbo"
]
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "",
"x.com.samsung.da.newVersionAvailable": "false",
"otnStatus": "None",
"flashingProgress": "0",
"otnCompleteDate": "noHistory",
"scheduledTime": "None",
"swVersionInfo": {
"platform": "Tizen Lite",
"oneUiVersion": "7.0 Air conditioner",
"osVersion": "4.0"
},
"otnList": [
{
"type": "WIFI",
"modelId": "ARA-KR-TP1-25-ARXX00",
"versions": [
"11260401"
],
"visVersion": "260401"
},
{
"type": "Micom",
"modelId": "845210267841FFFFFFFF",
"versions": [
"24092500",
"FFFFFFFF"
],
"visVersion": "240925"
},
{
"type": "Micom",
"modelId": "045210267841FFFFFFFF",
"versions": [
"24092500",
"FFFFFFFF"
],
"visVersion": "240925"
},
{
"type": "Micom",
"modelId": "FGCAN2",
"versions": [
"",
""
],
"visVersion": "999999"
},
{
"type": "Micom",
"modelId": "FGCAN3",
"versions": [
"",
""
],
"visVersion": "999999"
}
]
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "Europe/Prague",
"offset": "+02:00",
"DST": "ON"
}
},
{
"href": "/connectionconfig/vs/0",
"rep": {
"autoReconnectionMinVersion": "1.0",
"autoReconnection": "true",
"autoReconnectionProtocolType": [
"helper_hotspot",
"ble_ocf"
],
"supportedWiFiAuthType": [
"OPEN",
"WEP",
"WPA-PSK",
"WPA2-PSK",
"SAE"
],
"supportedWiFiCryptoType": [
"TKIP",
"AES",
"WEP-64",
"WEP-128"
],
"supportedWiFiFreq": [
"2.4G"
],
"calmConnectionCare": {
"version": "1.0",
"role": [
"things"
]
}
}
},
{
"href": "/wirelessinfo/vs/0",
"rep": {
"macaddressWiFi": "**REDACTED**",
"macaddressBLE": "**REDACTED**",
"connectedApSsid": "REDACTED_SSID"
}
},
{
"href": "/quickcontrol/info/vs/0",
"rep": {
"supportedVersion": "1.0"
}
},
{
"href": "/dginformation/vs/0",
"rep": {
"enrolmentstatus": "Unknown",
"devicestate": "Unknown",
"lockstatus": "Unknown",
"nextduedate": "",
"workingminutes": 0,
"paymentinfo": {
"emiplan": "Unknown",
"currency": "Unknown",
"totalemi": 0,
"totalemipaid": 0
}
}
}
]
}
+759
View File
@@ -0,0 +1,759 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.longnotisubscription": "false",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"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-08-01T21:32:45",
"x.com.samsung.da.state": "Deleted"
}
]
}
},
{
"href": "/temperatures/indoor/vs/0",
"rep": {
"x.com.samsung.da.type": "Water",
"x.com.samsung.da.desired": "5.0",
"x.com.samsung.da.current": "30.0",
"x.com.samsung.da.maximum": "25.0",
"x.com.samsung.da.minimum": "5.0",
"x.com.samsung.da.increment": "0.5",
"x.com.samsung.da.offset": "0.0",
"x.com.samsung.da.unit": "Celsius"
}
},
{
"href": "/temperatures/dhw/vs/0",
"rep": {
"x.com.samsung.da.type": "Water",
"x.com.samsung.da.unit": "Celsius",
"x.com.samsung.da.desired": "40.0",
"x.com.samsung.da.current": "38.0",
"x.com.samsung.da.maximum": "62.0",
"x.com.samsung.da.minimum": "40.0",
"x.com.samsung.da.increment": "0.5"
}
},
{
"href": "/diagnosis/vs/0",
"rep": {}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.cumulativePower": "2448844",
"x.com.samsung.da.cumulativeSavedPower": "0",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.cumulativePowerType": "individual"
}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"Cool",
"Heat",
"Auto"
],
"x.com.samsung.da.modes": [
"Cool"
],
"x.com.samsung.da.options": [
"OutdoorTemp_80",
"KeyInputPermit_On",
"ModePermit_NoLimit",
"Thermostat_CoolHeat_Off"
],
"x.com.samsung.da.supportedOptions": [
"Thermostat_CoolHeat",
"DurationOn",
"KeyInputPermit",
"ModePermit",
"Volume",
"OutdoorTemp"
]
}
},
{
"href": "/mode/dhw/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"Eco",
"Std",
"Power",
"Force"
],
"x.com.samsung.da.modes": [
"Eco"
],
"x.com.samsung.da.options": [
"Thermostat_Dhw_Off",
"KeyInputPermit_On"
],
"x.com.samsung.da.supportedOptions": [
"Thermostat_Dhw",
"KeyInputPermit"
]
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "Off",
"causeSource": "SMTS"
}
},
{
"href": "/power/dhw/vs/0",
"rep": {
"x.com.samsung.da.power": "On",
"causeSource": "SMTS"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "TP1X_DA_AC_EHS_01001_0000|10250141|60070105001711034A00010000002000",
"x.com.samsung.da.description": "TP1X_DA_AC_EHS_01001_0000",
"x.com.samsung.da.serialNum": "**REDACTED**",
"x.com.samsung.da.otnDUID": "**REDACTED**",
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
"x.com.samsung.da.diagLogType": [
"errCode",
"dump"
],
"x.com.samsung.da.diagDumpType": "file",
"x.com.samsung.da.diagEndPoint": "SSM",
"x.com.samsung.da.diagMnid": "0AJT",
"x.com.samsung.da.diagSetupid": "AE0",
"x.com.samsung.da.diagMinVersion": "3.0",
"x.com.samsung.da.diagTsId": "DA01",
"x.com.samsung.da.serialNumOption": "**REDACTED**",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02504A260402",
"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": "02501A24062401,FFFFFFFFFFFFFF",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Outdoor",
"x.com.samsung.da.number": "02572A23081000,02549A10000800"
}
]
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+01:00",
"x.com.samsung.supprtedtype": 1
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "0000000000"
}
},
{
"href": "/drlc/vs/0",
"rep": {
"x.com.samsung.da.drlcLevel": "0",
"x.com.samsung.da.durationminutes": "0",
"x.com.samsung.da.drlcStartTime": "1970-01-01T00:00:00Z",
"x.com.samsung.da.override": "Off",
"x.com.samsung.da.realSaving": "Off"
}
},
{
"href": "/availablecontrolsets/vs/0",
"rep": {
"x.com.samsung.da.sets": "0001003200FA0B0190026C0C0000CE32",
"x.com.samsung.da.id": "EHS",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/da/softreset/vs/0",
"rep": {
"x.com.samsung.da.softwarereset": "false"
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "",
"x.com.samsung.da.newVersionAvailable": "false",
"x.com.samsung.da.newVersionNo": "00000000",
"x.com.samsung.da.currentVersionInfo": "00000000",
"otnStatus": "None",
"flashingProgress": "",
"otnTarget": "main",
"otnCompleteDate": "noHistory",
"otnList": [
{
"type": "WIFI",
"modelId": "AEH-WW-TP1-22-AE6000",
"versions": [
"17260402"
],
"visVersion": "260402"
},
{
"type": "Micom",
"modelId": "040010250141FFFFFFFF",
"versions": [
"24062401",
"FFFFFFFF"
],
"visVersion": "240624"
},
{
"type": "Micom",
"modelId": "04001025724110254941",
"versions": [
"23081000",
"10000800"
],
"visVersion": "230810"
}
]
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "Europe/London",
"offset": "+01:00",
"DST": "ON"
}
},
{
"href": "/option/muteonce/vs/0",
"rep": {
"muteonce": "Off"
}
},
{
"href": "/reserverulesets/vs/0",
"rep": {
"x.com.samsung.da.sets": "0EFFFFFFFF05190F370B283E",
"x.com.samsung.da.id": "EHS",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/connectionconfig/vs/0",
"rep": {
"autoReconnectionMinVersion": "1.0",
"autoReconnection": "true",
"autoReconnectionProtocolType": [
"helper_hotspot",
"ble_ocf"
],
"supportedWiFiAuthType": [
"OPEN",
"WEP",
"WPA-PSK",
"WPA2-PSK",
"SAE"
],
"supportedWiFiCryptoType": [
"TKIP",
"AES",
"WEP-64",
"WEP-128"
],
"supportedWiFiFreq": [
"2.4G"
],
"calmConnectionCare": {
"version": "1.0",
"role": [
"things"
]
}
}
},
{
"href": "/option/outgoing/vs/0",
"rep": {
"x.com.samsung.da.away": "Off"
}
},
{
"href": "/sac/installationinfo/vs/0",
"rep": {
"installationOptions": [
{
"id": "0",
"option": "12300000000000000000"
},
{
"id": "1",
"option": "20010000000000000000"
},
{
"id": "2",
"option": "50000000000000000000"
},
{
"id": "3",
"option": "30000000000000000000"
}
],
"installationDeviceNum": [
{
"id": "0",
"number": "01"
},
{
"id": "1",
"number": "01"
},
{
"id": "2",
"number": "00"
}
],
"outdoorInfo": [
{
"id": "0",
"info": "02572A230810",
"serial": "**REDACTED**"
},
{
"id": "1",
"info": "000000000000",
"serial": "**REDACTED**"
},
{
"id": "2",
"info": "000000000000",
"serial": "**REDACTED**"
},
{
"id": "3",
"info": "000000000000",
"serial": "**REDACTED**"
}
],
"mcuInfo": [
{
"id": "0",
"info": "000000000000"
},
{
"id": "1",
"info": "000000000000"
},
{
"id": "2",
"info": "000000000000"
},
{
"id": "3",
"info": "000000000000"
},
{
"id": "4",
"info": "000000000000"
},
{
"id": "5",
"info": "000000000000"
},
{
"id": "6",
"info": "000000000000"
},
{
"id": "7",
"info": "000000000000"
},
{
"id": "8",
"info": "000000000000"
},
{
"id": "9",
"info": "000000000000"
},
{
"id": "10",
"info": "000000000000"
},
{
"id": "11",
"info": "000000000000"
},
{
"id": "12",
"info": "000000000000"
},
{
"id": "13",
"info": "000000000000"
},
{
"id": "14",
"info": "000000000000"
},
{
"id": "15",
"info": "000000000000"
}
]
}
},
{
"href": "/ehsfsv/vs/0",
"rep": {
"items": [
{
"setting": "0407010A01017202EE02260101"
},
{
"setting": "0408010A010096017200960101"
},
{
"setting": "041B010A0101F402BC026C0101"
},
{
"setting": "041C010A01012C019001900101"
},
{
"setting": "07DB010A01FF380032FFE20101"
},
{
"setting": "07DC010A01006400C800960101"
},
{
"setting": "07E5010A0100AA02EE01540101"
},
{
"setting": "07E6010A0100AA02EE014A0101"
},
{
"setting": "07EF010A0100AA02EE028A0101"
},
{
"setting": "07F0010A0100AA02EE00AA0101"
},
{
"setting": "082B0101000000000400000101"
},
{
"setting": "082C0101000000000400000101"
},
{
"setting": "082D0101000001000400040101"
},
{
"setting": "0BC30101000000000200020101"
},
{
"setting": "0BFF0101000000000100000101"
},
{
"setting": "0FAB0101000000000100000101"
},
{
"setting": "0FAC010A01FF6A00C800000101"
},
{
"setting": "0FB50101000000000200000101"
},
{
"setting": "0FCA010A010032009600640101"
},
{
"setting": "0FDD0101000000000100000101"
}
]
}
},
{
"href": "/ehscycle/vs/0",
"rep": {
"indoor": [
{
"cycledata": "4B055455050500000000000000000000000000000004CCA70000005D05054B",
"datetime": "2026-08-01T21:23:08"
},
{
"cycledata": "4B055454050500000000000000000000000000000004CCAC0000005D05054B",
"datetime": "2026-08-01T21:28:08"
}
],
"outdoor": [
{
"cycledata": "00000000555A4A53490207D000000000FFFF00480046325700000000",
"datetime": "2026-08-01T21:23:08"
},
{
"cycledata": "00000000555A4952490207D000000000000000480046325600000000",
"datetime": "2026-08-01T21:28:08"
}
],
"unit": "Celsius"
}
},
{
"href": "/option/dhwdisplay/vs/0",
"rep": {
"x.com.samsung.da.dhwdisplay": "Show"
}
},
{
"href": "/actions/zone1/vs/0",
"rep": {}
},
{
"href": "/actions/dhw/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.timelineId": "0",
"x.com.samsung.da.module": "DHW",
"x.com.samsung.da.temperatureType": "Water",
"x.com.samsung.da.dayofWeek": [
"Sun"
],
"x.com.samsung.da.timeblocks": [
{
"x.com.samsung.da.timeblockId": "0",
"x.com.samsung.da.start": [
{
"x.com.samsung.da.time": "03:00",
"x.com.samsung.da.power": "On",
"x.com.samsung.da.mode": "Std",
"x.com.samsung.da.desired": "45.0"
}
],
"x.com.samsung.da.end": [
{
"x.com.samsung.da.time": "05:00",
"x.com.samsung.da.power": "On",
"x.com.samsung.da.mode": "Eco",
"x.com.samsung.da.desired": "40.0"
}
]
}
]
},
{
"x.com.samsung.da.timelineId": "1",
"x.com.samsung.da.module": "DHW",
"x.com.samsung.da.temperatureType": "Water",
"x.com.samsung.da.dayofWeek": [
"Mon"
],
"x.com.samsung.da.timeblocks": [
{
"x.com.samsung.da.timeblockId": "0",
"x.com.samsung.da.start": [
{
"x.com.samsung.da.time": "03:00",
"x.com.samsung.da.power": "On",
"x.com.samsung.da.mode": "Std",
"x.com.samsung.da.desired": "45.0"
}
],
"x.com.samsung.da.end": [
{
"x.com.samsung.da.time": "05:00",
"x.com.samsung.da.power": "On",
"x.com.samsung.da.mode": "Eco",
"x.com.samsung.da.desired": "40.0"
}
]
}
]
},
{
"x.com.samsung.da.timelineId": "2",
"x.com.samsung.da.module": "DHW",
"x.com.samsung.da.temperatureType": "Water",
"x.com.samsung.da.dayofWeek": [
"Tue"
],
"x.com.samsung.da.timeblocks": [
{
"x.com.samsung.da.timeblockId": "0",
"x.com.samsung.da.start": [
{
"x.com.samsung.da.time": "03:00",
"x.com.samsung.da.power": "On",
"x.com.samsung.da.mode": "Std",
"x.com.samsung.da.desired": "45.0"
}
],
"x.com.samsung.da.end": [
{
"x.com.samsung.da.time": "05:00",
"x.com.samsung.da.power": "On",
"x.com.samsung.da.mode": "Eco",
"x.com.samsung.da.desired": "40.0"
}
]
}
]
},
{
"x.com.samsung.da.timelineId": "3",
"x.com.samsung.da.module": "DHW",
"x.com.samsung.da.temperatureType": "Water",
"x.com.samsung.da.dayofWeek": [
"Wed"
],
"x.com.samsung.da.timeblocks": [
{
"x.com.samsung.da.timeblockId": "0",
"x.com.samsung.da.start": [
{
"x.com.samsung.da.time": "03:00",
"x.com.samsung.da.power": "On",
"x.com.samsung.da.mode": "Std",
"x.com.samsung.da.desired": "45.0"
}
],
"x.com.samsung.da.end": [
{
"x.com.samsung.da.time": "05:00",
"x.com.samsung.da.power": "On",
"x.com.samsung.da.mode": "Eco",
"x.com.samsung.da.desired": "40.0"
}
]
}
]
},
{
"x.com.samsung.da.timelineId": "4",
"x.com.samsung.da.module": "DHW",
"x.com.samsung.da.temperatureType": "Water",
"x.com.samsung.da.dayofWeek": [
"Thu"
],
"x.com.samsung.da.timeblocks": [
{
"x.com.samsung.da.timeblockId": "0",
"x.com.samsung.da.start": [
{
"x.com.samsung.da.time": "03:00",
"x.com.samsung.da.power": "On",
"x.com.samsung.da.mode": "Std",
"x.com.samsung.da.desired": "45.0"
}
],
"x.com.samsung.da.end": [
{
"x.com.samsung.da.time": "05:00",
"x.com.samsung.da.power": "On",
"x.com.samsung.da.mode": "Eco",
"x.com.samsung.da.desired": "40.0"
}
]
}
]
},
{
"x.com.samsung.da.timelineId": "5",
"x.com.samsung.da.module": "DHW",
"x.com.samsung.da.temperatureType": "Water",
"x.com.samsung.da.dayofWeek": [
"Fri"
],
"x.com.samsung.da.timeblocks": [
{
"x.com.samsung.da.timeblockId": "0",
"x.com.samsung.da.start": [
{
"x.com.samsung.da.time": "03:00",
"x.com.samsung.da.power": "On",
"x.com.samsung.da.mode": "Std",
"x.com.samsung.da.desired": "45.0"
}
],
"x.com.samsung.da.end": [
{
"x.com.samsung.da.time": "05:00",
"x.com.samsung.da.power": "On",
"x.com.samsung.da.mode": "Eco",
"x.com.samsung.da.desired": "40.0"
}
]
}
]
},
{
"x.com.samsung.da.timelineId": "6",
"x.com.samsung.da.module": "DHW",
"x.com.samsung.da.temperatureType": "Water",
"x.com.samsung.da.dayofWeek": [
"Sat"
],
"x.com.samsung.da.timeblocks": [
{
"x.com.samsung.da.timeblockId": "0",
"x.com.samsung.da.start": [
{
"x.com.samsung.da.time": "03:00",
"x.com.samsung.da.power": "On",
"x.com.samsung.da.mode": "Std",
"x.com.samsung.da.desired": "45.0"
}
],
"x.com.samsung.da.end": [
{
"x.com.samsung.da.time": "05:00",
"x.com.samsung.da.power": "On",
"x.com.samsung.da.mode": "Eco",
"x.com.samsung.da.desired": "40.0"
}
]
}
]
}
]
}
}
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"state_keys": [
"alarm_code",
"buzzer_sound",
"child_lock",
"completion_minutes",
"cycle",
"cycle_active",
"delay_start_hours",
"diagnosis_status",
"energy_kwh",
"energy_saved_kwh",
"finish_time",
"firmware_update",
"job_beginning_status",
"machine_state",
"power_switch",
"progress",
"progress_percentage",
"remote_control",
"sanitize",
"wrinkle_prevent"
]
}
+18
View File
@@ -0,0 +1,18 @@
{
"state_keys": [
"air_quality_standard",
"alarm_code",
"battery",
"battery_charging",
"clean_level",
"co2",
"dnd",
"dnd_end",
"dnd_start",
"dust",
"fine_dust",
"humidity",
"odor",
"super_fine_dust"
]
}
+2
View File
@@ -6,6 +6,8 @@
"air_purify",
"alarm_code",
"auto_clean",
"auto_clean_progress",
"auto_clean_running",
"beep",
"clean_level",
"climate",
@@ -5,6 +5,8 @@
"air_filter_usage_hours",
"alarm_code",
"auto_clean",
"auto_clean_progress",
"auto_clean_running",
"beep",
"climate",
"current_temperature_c",
@@ -14,6 +16,8 @@
"firmware_update",
"humidity",
"mute_once",
"odor_controller_active",
"odor_controller_progress",
"power_watts",
"tropical_night_mode"
]
@@ -9,6 +9,7 @@
"diagnosis_status",
"display_light",
"energy_kwh",
"filter_alarm_time",
"filter_time",
"good_sleep",
"humidity",
@@ -9,6 +9,7 @@
"diagnosis_status",
"display_light",
"energy_kwh",
"filter_alarm_time",
"filter_time",
"good_sleep",
"humidity",
@@ -9,6 +9,7 @@
"diagnosis_status",
"display_light",
"energy_kwh",
"filter_alarm_time",
"filter_time",
"good_sleep",
"humidity",
+5 -1
View File
@@ -8,6 +8,8 @@
"air_purify",
"alarm_code",
"auto_clean",
"auto_clean_progress",
"auto_clean_running",
"clean_level",
"climate",
"current_temperature_c",
@@ -20,6 +22,8 @@
"motion_detect_wind_active",
"motion_detect_wind_mode",
"mute_once",
"odor_controller_active",
"odor_controller_progress",
"power_watts",
"selfcheck_error",
"selfcheck_result",
@@ -27,4 +31,4 @@
"super_fine_dust",
"tropical_night_mode"
]
}
}
+2
View File
@@ -5,6 +5,8 @@
"air_filter_usage_hours",
"alarm_code",
"auto_clean",
"auto_clean_progress",
"auto_clean_running",
"beep",
"climate",
"current_temperature_c",
+4
View File
@@ -6,6 +6,8 @@
"air_filter_usage_hours",
"alarm_code",
"auto_clean",
"auto_clean_progress",
"auto_clean_running",
"beep",
"climate",
"current_temperature_c",
@@ -13,6 +15,8 @@
"energy_kwh",
"firmware_update",
"humidity",
"odor_controller_active",
"odor_controller_progress",
"power_energy_kwh",
"power_watts",
"tropical_night_mode"
@@ -6,6 +6,8 @@
"air_filter_usage_hours",
"alarm_code",
"auto_clean",
"auto_clean_progress",
"auto_clean_running",
"beep",
"climate",
"current_temperature_c",
@@ -13,6 +15,8 @@
"energy_kwh",
"firmware_update",
"humidity",
"odor_controller_active",
"odor_controller_progress",
"power_energy_kwh",
"power_watts",
"tropical_night_mode"
@@ -6,6 +6,8 @@
"air_filter_usage_hours",
"alarm_code",
"auto_clean",
"auto_clean_progress",
"auto_clean_running",
"beep",
"climate",
"current_temperature_c",
@@ -13,6 +15,8 @@
"energy_kwh",
"firmware_update",
"humidity",
"odor_controller_active",
"odor_controller_progress",
"power_energy_kwh",
"power_watts",
"subdevice_6c2dff6dee5cdad16a5e000000000001_climate",
@@ -7,6 +7,8 @@
"air_filter_usage_hours",
"alarm_code",
"auto_clean",
"auto_clean_progress",
"auto_clean_running",
"beep",
"climate",
"current_temperature_c",
@@ -18,6 +20,8 @@
"motion_detect_wind_active",
"motion_detect_wind_mode",
"mute_once",
"odor_controller_active",
"odor_controller_progress",
"power_watts",
"tropical_night_mode"
]
@@ -7,6 +7,8 @@
"air_purify",
"alarm_code",
"auto_clean",
"auto_clean_progress",
"auto_clean_running",
"clean_level",
"climate",
"current_temperature_c",
+2
View File
@@ -7,6 +7,8 @@
"air_purify",
"alarm_code",
"auto_clean",
"auto_clean_progress",
"auto_clean_running",
"beep",
"climate",
"current_limit_enabled",
@@ -5,6 +5,8 @@
"air_filter_usage_hours",
"alarm_code",
"auto_clean",
"auto_clean_progress",
"auto_clean_running",
"beep",
"climate",
"current_temperature_c",
@@ -14,6 +16,8 @@
"firmware_update",
"humidity",
"mute_once",
"odor_controller_active",
"odor_controller_progress",
"power_watts",
"tropical_night_mode"
]
@@ -5,6 +5,8 @@
"air_filter_usage_hours",
"alarm_code",
"auto_clean",
"auto_clean_progress",
"auto_clean_running",
"beep",
"climate",
"current_temperature_c",
@@ -0,0 +1,28 @@
{
"state_keys": [
"air_filter_status",
"air_filter_threshold",
"air_filter_usage",
"air_filter_usage_hours",
"air_purify",
"alarm_code",
"auto_clean",
"auto_clean_progress",
"auto_clean_running",
"beep",
"climate",
"current_limit_enabled",
"current_limit_level",
"current_temperature_c",
"display_light",
"firmware_update",
"humidity",
"mute_once",
"odor_controller_active",
"odor_controller_progress",
"selfcheck_error",
"selfcheck_result",
"selfcheck_status",
"tropical_night_mode"
]
}
@@ -5,6 +5,8 @@
"air_filter_usage_hours",
"alarm_code",
"auto_clean",
"auto_clean_progress",
"auto_clean_running",
"beep",
"climate",
"current_temperature_c",
+2
View File
@@ -6,6 +6,8 @@
"air_purify",
"alarm_code",
"auto_clean",
"auto_clean_progress",
"auto_clean_running",
"beep",
"clean_level",
"climate",
@@ -6,6 +6,8 @@
"air_filter_usage_hours",
"alarm_code",
"auto_clean",
"auto_clean_progress",
"auto_clean_running",
"beep",
"climate",
"current_temperature_c",
@@ -15,6 +17,8 @@
"firmware_update",
"humidity",
"mute_once",
"odor_controller_active",
"odor_controller_progress",
"overload_protection_active",
"overload_protection_mode",
"selfcheck_error",
+2
View File
@@ -5,6 +5,8 @@
"air_filter_usage_hours",
"alarm_code",
"auto_clean",
"auto_clean_progress",
"auto_clean_running",
"beep",
"climate",
"current_temperature_c",
+2
View File
@@ -6,6 +6,8 @@
"air_filter_usage_hours",
"alarm_code",
"auto_clean",
"auto_clean_progress",
"auto_clean_running",
"energy_kwh",
"energy_saved_kwh",
"firmware_update",
+15
View File
@@ -0,0 +1,15 @@
{
"state_keys": [
"alarm_code",
"away_mode",
"energy_kwh",
"energy_saved_kwh",
"firmware_update",
"mute_once",
"water_heater",
"zone_mode",
"zone_power",
"zone_target_temperature",
"zone_temperature"
]
}
+23
View File
@@ -0,0 +1,23 @@
{
"state_keys": [
"alarm_code",
"cavity_state",
"child_lock",
"cloud_connected",
"cook_time",
"cooking_mode",
"current_temp_c",
"cycle_active",
"door_open",
"energy_kwh",
"finish_time",
"firmware_update",
"machine_state",
"operation_time_minutes",
"power_level",
"power_switch",
"progress_percentage",
"setpoint",
"sound"
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -166,7 +166,7 @@
"href": "/favorite/hotwater/vs/0",
"rep": {
"x.com.samsung.da.favorite.revision": "0",
"x.com.samsung.da.favorite.defaultTemperature": "85",
"x.com.samsung.da.favorite.defaultTemperature": "50",
"x.com.samsung.da.favorite.showList": [
"40",
"50",
+15
View File
@@ -539,3 +539,18 @@ def test_is_placeholder_serial_accepts_real_serials():
from custom_components.localthings.config_flow import _is_placeholder_serial
assert _is_placeholder_serial('0A1B2C3D4E5F') is False
assert _is_placeholder_serial('') is False
def test_is_placeholder_serial_catches_all_same_hex_digit():
"""Issue #189: the DA_WM_A51_20_COMMON (ARTIK051) laundry board family
reports a flash-unset sentinel instead of 'Nothing(SVC)' -- every
character the same repeated hex digit. A washer and a dryer, two
different physical units, both reported the literal serialNum
'FFFFFFFFFFFFFFF', colliding on the config-entry unique_id."""
from custom_components.localthings.config_flow import _is_placeholder_serial
assert _is_placeholder_serial('FFFFFFFFFFFFFFF') is True
assert _is_placeholder_serial('ffffffffffffffff') is True
assert _is_placeholder_serial('00000000') is True
# Too short to be the flash-unset sentinel -- a real serial could
# plausibly repeat one hex digit seven times by chance.
assert _is_placeholder_serial('FFFFFFF') is False
+39 -1
View File
@@ -15,7 +15,7 @@ from custom_components.localthings.const import (
SUMMARY_INTERVAL_S,
)
from custom_components.localthings.coordinator import (
LocalThingsCoordinator, _local_source_port,
LocalThingsCoordinator, _is_placeholder_serial, _local_source_port,
)
from custom_components.localthings.registry.capabilities.common import (
remote_control_enabled,
@@ -163,6 +163,44 @@ def test_run_discovery_falls_back_to_host_for_placeholder_serial(
assert coordinator.device_serial == mock_entry.data[CONF_HOST]
def test_run_discovery_falls_back_to_host_for_all_f_placeholder_serial(
hass: HomeAssistant, mock_entry
) -> None:
"""Issue #189: the DA_WM_A51_20_COMMON (ARTIK051) laundry board family
reports a flash-unset sentinel instead of 'Nothing(SVC)' -- every
character the same repeated hex digit. A washer and a dryer, two
different physical units, both reported the literal serialNum
'FFFFFFFFFFFFFFF', so without this fallback they'd collide on
device_serial exactly like the #83 case above."""
resources = {
'/information/vs/0': {
'x.com.samsung.da.modelNum':
'DA_WM_A51_20_COMMON|20221341|30010102001211000103000000000000',
'x.com.samsung.da.description':
'DA_WM_A51_20_COMMON_DVE50A8800/DC92-02835A_0080',
'x.com.samsung.da.serialNum': 'FFFFFFFFFFFFFFF',
},
'/otninformation/vs/0': {},
}
coordinator = LocalThingsCoordinator(hass, mock_entry)
coordinator._run_discovery(resources)
assert coordinator.device_serial == mock_entry.data[CONF_HOST]
def test_is_placeholder_serial_catches_all_same_hex_digit():
assert _is_placeholder_serial('FFFFFFFFFFFFFFF') is True
assert _is_placeholder_serial('ffffffffffffffff') is True
assert _is_placeholder_serial('00000000') is True
def test_is_placeholder_serial_accepts_real_serials_and_short_runs():
assert _is_placeholder_serial('0A1B2C3D4E5F') is False
assert _is_placeholder_serial('') is False
# Too short to be the flash-unset sentinel -- a real serial could
# plausibly repeat one hex digit seven times by chance.
assert _is_placeholder_serial('FFFFFFF') is False
def test_run_discovery_detects_cooktop_via_resource_signature(
hass: HomeAssistant, mock_entry
) -> None:
+68
View File
@@ -452,3 +452,71 @@ def test_downgrade_to_poll_stops_refresh_task():
assert not thread.is_alive()
assert mgr._refresh_thread is None
def test_try_enter_observe_mode_exits_early_when_fraction_reached():
"""The grace wait returns as soon as enough hrefs notify, not after the
whole grace ceiling. Production data (fridge TP2X_REF_20K, 2026-07-30):
all subscribed hrefs notify within ~0.34s of subscribe, yet the old fixed
time.sleep(15) waited the remaining ~14.6s anyway — every successful
first-refresh / observe-retry fetch paid that dead time."""
mgr = _manager()
session = _FakeSession()
hrefs = ['/a/vs/0', '/b/vs/0']
def _notify_after_subscribe():
# Let try_enter clear _notified + subscribe first (matches the existing
# test pattern's 0.005 lead), then notify well before the 2s ceiling.
time.sleep(0.01)
for href in hrefs:
mgr.on_notification(href, cbor2.dumps({'x': 1}))
notifier = threading.Thread(target=_notify_after_subscribe, daemon=True)
notifier.start()
try:
t0 = time.monotonic()
entered = mgr.try_enter_observe_mode(session, hrefs, grace_period_s=2.0)
elapsed = time.monotonic() - t0
notifier.join()
assert entered is True
assert mgr.mode == 'observe'
# Early exit: returns near the notify (~0.01s), not the 2.0s ceiling.
# The old fixed sleep would make this >= 2.0.
assert elapsed < 0.5
finally:
mgr.close()
def test_try_enter_observe_mode_waits_full_ceiling_when_fraction_not_reached():
"""Early-exit must NOT fire below the success fraction. A partial set of
notifies that doesn't meet the threshold waits the full grace ceiling,
then falls back to poll — identical to the old fixed sleep. Guards
against an over-eager predicate that enters observe on too few notifies."""
mgr = _manager()
session = _FakeSession()
hrefs = ['/a/vs/0', '/b/vs/0', '/c/vs/0', '/d/vs/0'] # 4 hrefs
def _notify_one():
time.sleep(0.01)
mgr.on_notification(hrefs[0], cbor2.dumps({'x': 1})) # 1/4 = 0.25
notifier = threading.Thread(target=_notify_one, daemon=True)
notifier.start()
try:
t0 = time.monotonic()
entered = mgr.try_enter_observe_mode(
session, hrefs, grace_period_s=0.2, success_fraction=0.8,
)
elapsed = time.monotonic() - t0
notifier.join()
assert entered is False
assert mgr.mode == 'poll'
assert mgr.subscribed_hrefs == set()
# Waited the full 0.2s ceiling (1/4 = 0.25 < 0.8); did not early-exit.
assert elapsed >= 0.18
finally:
mgr.close()
@@ -0,0 +1,79 @@
"""Tests for the DA_DF_TP1_21_COMMON AirDresser (model DF3000B, issue #208).
Another board generation routed into the same air_dresser registry as
issue #162's DA_DF_A51_20_COMMON and issue #157's DA_DF_TP2_20_COMMON.
Exercises the one thing this board reports that neither of those does:
a populated /buzzersound/vs/0 (laundry.BUZZER_SOUND) -- the reporter's
actual ask ("all cycles are represented by code") is a course-code
labelling problem, not a coverage one; this board's course table isn't
identified yet either (same as #162's), so 'cycle' still renders as the
raw code until a reporter can map codes to names in the SmartThings app.
"""
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import air_dresser, for_device_by_model
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
def _air_dresser():
resources = _load_device('air_dresser_tp1_21')
info = resources['/information/vs/0']
reg = for_device_by_model(
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'])
return reg, resources
def _state():
reg, resources = _air_dresser()
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
return flatten(bound, resources)
def test_resolves_to_air_dresser_registry():
reg, _ = _air_dresser()
assert reg is not None and reg.name == 'air_dresser'
def test_no_unbound_hrefs():
reg, resources = _air_dresser()
unbound = []
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
assert unbound == []
def test_buzzer_sound_present_and_writable():
"""Issue #208's actual coverage gap: /buzzersound/vs/0 was unbound on
this board before laundry.BUZZER_SOUND was added to the registry."""
state = _state()
assert state['buzzer_sound'] == 'On'
assert 'finish_sound' not in state # supportedFinishSound absent -- self-gated off
desc = next(
e for e in air_dresser.REGISTRY.capabilities['/buzzersound/vs/0'][0].entities
if e.key == 'buzzer_sound'
)
path, body = desc.write_fn('Off', {})
assert path == ['buzzersound', 'vs', '0']
assert body == {'setBuzzerSound': 'Off'}
def test_course_translation_key_falls_back_to_cycle_for_unidentified_table():
"""Same as issue #162's board: Table_00's course codes aren't
identified yet, so the select renders raw codes rather than names."""
_, resources = _air_dresser()
desc = air_dresser.REGISTRY.capabilities['/course/vs/0'][0].entities[0]
assert desc.translation_key(resources) == 'cycle'
def test_sanitize_present_and_toggles():
state = _state()
assert state['sanitize'] is False
desc = next(
e for e in air_dresser.REGISTRY.capabilities['/airdresseroption/sanitize/vs/0'][0].entities
if e.key == 'sanitize'
)
path, body = desc.write_fn('On', {})
assert path == ['airdresseroption', 'sanitize', 'vs', '0']
assert body == {'x.com.samsung.da.sanitize': 'On'}
+115
View File
@@ -0,0 +1,115 @@
"""Tests for the Samsung Air Monitor Plus family (ASM-KR-TP1-22-ACMB1M,
issue #210) -- a standalone, battery-powered air-quality sensor puck. No
controllable appliance state at all beyond the do-not-disturb window.
"""
from datetime import time as dt_time
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import for_device_by_oic_type, resolve
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
def _air_monitor():
resources = _load_device('air_monitor')
reg = resolve(resources)
return reg, resources
def _bound():
reg, resources = _air_monitor()
return discover(resources, reg.capabilities, reg.pattern_capabilities), resources
def _state():
bound, resources = _bound()
return flatten(bound, resources)
def _desc(key):
bound, _ = _bound()
return next(b.desc for b in bound if b.desc.key == key)
def test_resolves_to_air_monitor_registry():
reg, _ = _air_monitor()
assert reg is not None and reg.name == 'air_monitor'
def test_resolves_via_oic_type_too():
"""This board's real dump carries /oic/d's device type
('x.com.st.d.airqualitysensor') as well as the 'ASM' modelNum token --
both paths must agree."""
reg = for_device_by_oic_type(('x.com.st.d.airqualitysensor',))
assert reg is not None and reg.name == 'air_monitor'
def test_no_unbound_hrefs():
reg, resources = _air_monitor()
unbound = []
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
assert unbound == []
def test_air_quality_sensors_read_from_shared_items_decode():
"""Same /sensors/vs/0 {type, value} shape and common.sensor_item_value
decode air_purifier.AIR_QUALITY already uses -- this board adds CO2,
which neither air_purifier nor range_hood report."""
state = _state()
assert state['dust'] == 31
assert state['fine_dust'] == 23
assert state['super_fine_dust'] == 18
assert state['odor'] == 1
assert state['clean_level'] == 2
assert state['co2'] == 498
def test_second_value_slot_is_not_exposed():
"""Dust/fine_dust/super_fine_dust each carry a second `value[1]` grade
code (e.g. Dust's ['31', '2']) with no supported-values field anywhere
in the dump to explain its scale -- per the 'still never invent... from
nothing' rule, only the raw reading (value[0]) is bound, not a second
'dust_level' or similar entity for that code."""
state = _state()
assert 'dust_level' not in state
assert 'dust_grade' not in state
def test_humidity_and_battery_present():
state = _state()
assert state['humidity'] == 57
assert state['battery'] == 80
assert state['battery_charging'] is False
def test_air_quality_standard_present():
state = _state()
assert state['air_quality_standard'] == 'AirCleanAssociation'
def test_dnd_read_contract():
state = _state()
assert state['dnd'] is False
assert state['dnd_start'] == dt_time(14, 0)
assert state['dnd_end'] == dt_time(22, 0)
def test_dnd_write_contracts_are_flagged_educated_guesses():
"""Issue #210: no idle-vs-active dump pair exists for /dnd/vs/0 (DND
was never toggled in the one dump this board has), so these write
contracts are educated guesses -- symmetric with the read side's own
string/time-format shape, not invented from nothing, but still
unconfirmed on real hardware. Only the wiring is tested here; whether
the device actually accepts these writes needs a reporter to confirm."""
dnd = _desc('dnd')
path, body = dnd.write_fn('On', {})
assert path == ['dnd', 'vs', '0']
assert body == {'x.com.samsung.da.value': 'true'}
path, body = dnd.write_fn('Off', {})
assert body == {'x.com.samsung.da.value': 'false'}
start = _desc('dnd_start')
path, body = start.write_fn(dt_time(9, 30), {})
assert path == ['dnd', 'vs', '0']
assert body == {'x.com.samsung.da.startTime': '09:30:00'}
+80 -4
View File
@@ -9,8 +9,8 @@ differs from all of them in three ways, each covered below:
* No ``/mode/convenient/vs/0`` -- the convenient-mode preset is a ``Comode_*``
token in ``/mode/vs/0``'s ``options`` array.
* Several settings (SPI, auto clean, air monitoring, beep volume, Good Sleep,
outdoor temperature, filter time) are ``options`` tokens too, where newer
boards have dedicated resources.
outdoor temperature, filter time and its alarm interval) are ``options``
tokens too, where newer boards have dedicated resources.
Fan, swing, preset, SPI and beep-volume writes were all confirmed on hardware
by read-back on the unit this fixture is dumped from. The issue #136 unit is
@@ -103,8 +103,8 @@ def test_token_entities_present_with_calibrated_values():
state = _state()
# token/10 hours: 1715 displayed as "171 hours 0 minutes"... at 1710 in the
# official app on this unit, which pins the scale (the .5 here is a later
# reading). Whether it counts up or down is deliberately not asserted --
# see the descriptor comment.
# reading). It counts up -- see the descriptor comment and
# test_filter_alarm_tracks_the_counter_against_its_threshold below.
assert state['filter_time'] == 171.5
# token - 55 == 19 C, against a 19.4 C forecast at the time of the dump.
assert state['outdoor_temperature'] == 19.0
@@ -180,6 +180,82 @@ def test_option_writes_carry_one_token():
)
# -- filter counter and its reset ---------------------------------------------
def _desc(resources, key):
"""The bound descriptor for `key`, or None when its exists_fn declines it.
flatten() gives values, not descriptors, so this is how a test reaches a
descriptor's own write_fn and options without standing up an HA entity.
"""
bound, _ = _discover(resources)
for item in bound:
if item.desc.key == key and (
item.desc.exists_fn is None
or item.desc.exists_fn(resources.get(item.href) or {}, resources)
):
return item.desc
return None
def test_filter_alarm_time_reads_the_threshold_and_writes_one_token():
"""The interval FilterTime_ is measured against, offered by the app as a
180/300/500/700 hour radio. All four were walked on hardware while watching
all 19 resources: the token carries the hour count verbatim and each step
moved that one field and nothing else, which is also what makes a static
options tuple defensible here (the board advertises no supported-values
list for options[] tokens)."""
state = _state()
assert state['filter_alarm_time'] == '500' # the fixture's own value
desc = _desc(_load_device(FIXTURE), 'filter_alarm_time')
assert desc.options == ('180', '300', '500', '700')
assert desc.write_fn('180', {}) == (
['mode', 'vs', '0'],
{'x.com.samsung.da.options': ['FilterAlarmTime_180']},
)
def test_filter_alarm_time_stays_off_boards_with_a_real_threshold_resource():
"""Newer boards carry air_filter_threshold off supportedFilterDesiredUsage;
two thresholds on one device would be a coin flip for the user.
No non-legacy fixture carries a FilterAlarmTime_ token today (only the two
KRAC dumps have one at all), so asserting on an unmodified newer board
would pass for the wrong reason -- absent token rather than the
board-generation gate. The token is injected to exercise the gate itself,
test_absent_token_yields_no_entity's technique in reverse."""
newer = _load_device('airconditioner_tp1x_rac')
assert _desc(newer, 'filter_alarm_time') is None
mode = newer['/mode/vs/0']
mode['x.com.samsung.da.options'] = [
*(mode.get('x.com.samsung.da.options') or []), 'FilterAlarmTime_500',
]
assert is_legacy_board(newer) is False
assert _desc(newer, 'filter_alarm_time') is None
def test_filter_alarm_tracks_the_counter_against_its_threshold():
"""Why filter_time is read as elapsed rather than remaining: the same
options blob carries the threshold, and /alarms/vs/0's filter entry is a
'FilterAlarm_OFF'/'Deleted' placeholder below it. The sibling unit on the
same site, at FilterTime_5595 against the same FilterAlarmTime_500, instead
reported an unsuffixed 'FilterAlarm' in state 'Created'."""
resources = _load_device(FIXTURE)
options = resources['/mode/vs/0']['x.com.samsung.da.options']
assert 'FilterTime_1715' in options
assert 'FilterAlarmTime_500' in options
alarms = resources['/alarms/vs/0']['x.com.samsung.da.items']
filter_alarm = next(
item for item in alarms
if item['x.com.samsung.da.code'].startswith('FilterAlarm')
)
assert filter_alarm['x.com.samsung.da.code'] == 'FilterAlarm_OFF'
assert filter_alarm['x.com.samsung.da.state'] == 'Deleted'
# -- climate entity: fan, swing and preset off /airflow/vs/0 ------------------
def test_fan_mode_reads_the_airflow_speed_level():
+80
View File
@@ -872,3 +872,83 @@ def test_non_legacy_board_energy_kwh_still_uses_plain_wh_scale():
state = flatten(
discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
assert state['energy_kwh'] == round(1686632 / 1000.0, 2)
# ---------------------------------------------------------------------------
# TP1X_DA-AC-RAC-01001_0000, reporter's dump. Reported "fan and WindFree are
# missing" -- both already work: /wind/strength/vs/0's 0-4 codes match
# _DEVICE_TO_FAN exactly, and /mode/convenient/vs/0's Nano/NanoSleep codes
# already resolve dynamically via _preset_to_ha + the existing "nano"/
# "nanosleep" -> "WindFree"/"WindFree sleep" translation labels -- confirmed
# below by zero unbound hrefs and the climate entity binding with its usual
# FAN_MODE/PRESET_MODE features. The real, previously-uncaptured gap was
# /mode/vs/0's SmartCoolClean_/ProgressSmartClean_ option tokens (the cloud
# custom.airConditionerOdorController capability) -- see
# _odor_controller_active's docstring.
# ---------------------------------------------------------------------------
def _ac_odor_controller():
return _resolve('airconditioner_tp1x_rac_odor_controller')
def test_odor_controller_no_unbound_hrefs():
reg, resources = _ac_odor_controller()
unbound = []
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
assert unbound == []
def test_odor_controller_fan_and_windfree_already_bind():
"""The actually-reported gap wasn't real: fan speed and the WindFree
preset both come from the composite climate entity, which is bound here
with its normal fan/preset feature set -- no code change needed for
either."""
reg, resources = _ac_odor_controller()
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
climate = [b for b in bound if isinstance(b.desc, ClimateDesc)]
assert len(climate) == 1 and climate[0].href == '/mode/vs/0'
assert resources['/wind/strength/vs/0']['x.com.samsung.da.supportedModes'] == [
'0', '1', '2', '3', '4']
assert resources['/mode/convenient/vs/0']['x.com.samsung.da.supportedModes'] == [
'Off', 'Sleep', 'Quiet', 'Speed', 'Nano', 'NanoSleep']
def test_odor_controller_state_and_progress_present():
reg, resources = _ac_odor_controller()
state = flatten(
discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
assert state['odor_controller_active'] is False # SmartCoolClean_Off
assert state['odor_controller_progress'] == 0 # ProgressSmartClean_0
def test_odor_controller_read_from_option_tokens():
assert airconditioner._odor_controller_active(
{'x.com.samsung.da.options': ['SmartCoolClean_On']}) is True
assert airconditioner._odor_controller_active(
{'x.com.samsung.da.options': ['SmartCoolClean_Off']}) is False
assert airconditioner._odor_controller_active(
{'x.com.samsung.da.options': ['Volume_100']}) is None
assert airconditioner._odor_controller_progress(
{'x.com.samsung.da.options': ['ProgressSmartClean_42']}) == 42
assert airconditioner._odor_controller_progress({}) is None
def test_odor_controller_absent_when_no_smartcoolclean_token():
"""The original issue #17 dump's /mode/vs/0 options carry no
SmartCoolClean_/ProgressSmartClean_ tokens -- neither entity binds."""
reg, resources = _ac()
state = flatten(
discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
assert 'odor_controller_active' not in state
assert 'odor_controller_progress' not in state
def test_odor_controller_is_read_only():
"""No command capability is confirmed for SmartCoolClean -- exposed
read-only, same 'don't guess' precedent as CURRENT_LIMIT/ANOMALY_LOAD."""
active = next(e for e in airconditioner.CLIMATE.entities
if e.key == 'odor_controller_active')
progress = next(e for e in airconditioner.CLIMATE.entities
if e.key == 'odor_controller_progress')
assert not hasattr(active, 'write_fn') or active.write_fn is None
assert not hasattr(progress, 'write_fn') or progress.write_fn is None
+223 -8
View File
@@ -131,6 +131,78 @@ class TestConsumerModelKey:
assert _consumer_model_key('ARTIK051_DONGLE_REF') is None
class TestForDeviceByOicType:
"""Primary device-type detection from /oic/d's `rt`."""
def test_every_oic_type_resolves_to_a_real_registry(self):
from custom_components.localthings.registry.by_type import (
_OIC_TYPE_TO_KEY, _REGISTRY_BY_KEY,
)
for oic_type, key in _OIC_TYPE_TO_KEY.items():
assert key in _REGISTRY_BY_KEY, f"{oic_type!r} -> unknown registry {key!r}"
@pytest.mark.parametrize('oic_type, expected', [
('oic.d.airconditioner', 'airconditioner'),
('oic.d.airpurifier', 'air_purifier'),
('oic.d.dishwasher', 'dishwasher'),
('oic.d.dryer', 'dryer'),
('oic.d.oven', 'oven'),
('oic.d.refrigerator', 'refrigerator'),
('oic.d.washer', 'washer'),
('x.com.st.d.hood', 'range_hood'),
('x.com.st.d.stickcleaner', 'vacuum_station'),
('x.com.st.d.steamcloset', 'air_dresser'),
('x.com.st.d.airqualitysensor', 'air_monitor'),
])
def test_known_oic_types_resolve(self, oic_type, expected):
from custom_components.localthings.registry.by_type import for_device_by_oic_type
reg = for_device_by_oic_type((oic_type,))
assert reg is not None
assert reg.name == expected
def test_generic_wk_d_type_alone_resolves_nothing(self):
"""The generic 'oic.wk.d' base type every OCF device carries
alongside its concrete type isn't itself a device type."""
from custom_components.localthings.registry.by_type import for_device_by_oic_type
assert for_device_by_oic_type(('oic.wk.d',)) is None
def test_unrecognized_type_returns_none(self):
from custom_components.localthings.registry.by_type import for_device_by_oic_type
assert for_device_by_oic_type(('oic.d.somethingnew',)) is None
def test_robotcleaner_is_not_mapped_to_the_vacuum_station_registry(self):
"""'oic.d.robotcleaner' names an actual robot vacuum, a different
product from the clean/auto-empty station vacuum_station covers (no
vacuum-body capabilities at all) -- mapping it there would misroute
a genuine robot-vacuum dump."""
from custom_components.localthings.registry.by_type import for_device_by_oic_type
assert for_device_by_oic_type(('oic.d.robotcleaner',)) is None
def test_cooktop_is_not_mapped_to_either_cooktop_registry(self):
"""'oic.d.cooktop' cannot tell the two cooktop families apart.
A TP1X_DA-KS-COOKTOP induction reports it, but `cooktop` is the
unrelated NA9300K gas family (burner state in /mode/vs/0's options
array, a different OCF surface -- see by_type/cooktop.py). Mapping the
type to either key would misroute the other, and as the primary signal
it would override a board token that had it right.
"""
from custom_components.localthings.registry.by_type import for_device_by_oic_type
assert for_device_by_oic_type(('oic.d.cooktop',)) is None
def test_empty_returns_none(self):
from custom_components.localthings.registry.by_type import for_device_by_oic_type
assert for_device_by_oic_type(()) is None
def test_finds_the_concrete_type_alongside_the_generic_one(self):
"""A real /oic/d `rt` carries both the generic base type and the
concrete one, order unspecified -- either position must resolve."""
from custom_components.localthings.registry.by_type import for_device_by_oic_type
reg = for_device_by_oic_type(('oic.wk.d', 'oic.d.washer'))
assert reg is not None
assert reg.name == 'washer'
class TestForDeviceByModel:
"""Fallback device-type detection for hardware without oneUiVersion."""
@@ -265,6 +337,20 @@ class TestForDeviceByModel:
assert reg is not None
assert reg.name == 'dehumidifier'
def test_ehs_via_ehs_token(self):
"""A Samsung EHS air-to-water heat pump (TP1X_DA_AC_EHS_01001_0000)
shares the DA_AC_ board family with the room-AC models but reports
no oneUiVersion and carries the '_EHS_' (Eco Heating System) token
instead of '_RAC_'/'_PRAC_'/'_WAC_'/'_DHM_'; falls back to the
'_EHS_' token in modelNum."""
from custom_components.localthings.registry.by_type import for_device_by_model
reg = for_device_by_model(
'TP1X_DA_AC_EHS_01001_0000|10250141|60070105001711034A00010000002000',
'TP1X_DA_AC_EHS_01001_0000',
)
assert reg is not None
assert reg.name == 'ehs'
def test_water_purifier_via_waterpurifier_token(self):
"""Issue #90: a water purifier (TP2X_WATERPURIFIER_20K) reports no
oneUiVersion and no consumer-prefix match; falls back to the
@@ -376,6 +462,19 @@ class TestForDeviceByModel:
assert reg is not None
assert reg.name == 'vacuum_station'
def test_air_monitor_via_asm_token(self):
"""Issue #210: a standalone air-quality sensor puck
(ASM-KR-TP1-22-ACMB1M) reports no oneUiVersion and no
washer/dryer/dishwasher consumer prefix; falls back to the 'ASM'
token in modelNum onto the air_monitor registry."""
from custom_components.localthings.registry.by_type import for_device_by_model
reg = for_device_by_model(
'ASM-KR-TP1-22-ACMB1M|10243041|75000000001611C40800020000080000',
'ASM-KR-TP1-22-ACMB1M',
)
assert reg is not None
assert reg.name == 'air_monitor'
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
@@ -580,22 +679,114 @@ class TestOneUiVersionIsNotConsulted:
class TestResolve:
def test_prefers_model_strings_over_resource_signature(self, all_device_fixtures):
"""Every fixture with usable model strings resolves the same way
through `resolve` as through `for_device_by_model` directly."""
from custom_components.localthings.registry.by_type import (
resolve, for_device_by_model,
def test_oic_type_wins_over_model_strings(self):
"""The device naming its own type via /oic/d beats board-token
parsing -- an unrecognizable modelNum with a known oic.d type must
still resolve, and a *conflicting* modelNum must lose to it."""
from custom_components.localthings.registry.by_type import resolve
resources = {
'/information/vs/0': {
'x.com.samsung.da.modelNum': 'SOME-UNKNOWN-BOARD',
'x.com.samsung.da.description': 'SOME-UNKNOWN-BOARD',
},
}
reg = resolve(resources, device_types=('oic.d.washer',))
assert reg is not None
assert reg.name == 'washer'
conflicting = resolve(
resources={
'/information/vs/0': {
'x.com.samsung.da.modelNum': 'TP1X_REF_21K',
'x.com.samsung.da.description': 'TP1X_REF_21K',
},
},
device_types=('oic.d.washer',),
)
assert conflicting is not None
assert conflicting.name == 'washer'
def test_empty_device_types_falls_back_to_model_strings(self):
from custom_components.localthings.registry.by_type import resolve
resources = {
'/information/vs/0': {
'x.com.samsung.da.modelNum': 'TP1X_REF_21K',
'x.com.samsung.da.description': 'TP1X_REF_21K',
},
}
reg = resolve(resources, device_types=())
assert reg is not None
assert reg.name == 'refrigerator'
def test_unmapped_device_types_falls_back_to_model_strings(self):
from custom_components.localthings.registry.by_type import resolve
resources = {
'/information/vs/0': {
'x.com.samsung.da.modelNum': 'TP1X_REF_21K',
'x.com.samsung.da.description': 'TP1X_REF_21K',
},
}
reg = resolve(resources, device_types=('oic.wk.d', 'oic.d.somethingnew'))
assert reg is not None
assert reg.name == 'refrigerator'
def test_prefers_resource_signatures_over_model_strings(self, all_device_fixtures):
"""A strong live-resource signature wins over model metadata.
Across the fixture corpus Qooker is the only intentional disagreement:
its OVEN model token says oven while its /oven + MicroWave surface says
microwave. Locking the disagreement set keeps resource-first routing
from silently becoming greedy as new signatures or fixtures land.
"""
from custom_components.localthings.registry.by_type import (
resolve, for_device_by_model, for_device_by_resources,
)
disagreements = {}
for name, resources in all_device_fixtures.items():
info = resources.get('/information/vs/0', {})
by_resources = for_device_by_resources(resources)
by_model = for_device_by_model(
info.get('x.com.samsung.da.modelNum', ''),
info.get('x.com.samsung.da.description', ''),
)
if by_model is not None:
assert resolve(resources) is by_model, name
if by_resources is None or by_model is None:
continue
assert resolve(resources) is by_resources, name
if by_resources is not by_model:
disagreements[name] = (by_resources.name, by_model.name)
def test_falls_back_to_resource_signature(self, all_device_fixtures):
assert disagreements == {
'qooker_mw7500a': ('microwave', 'oven'),
}
def test_qooker_microwave_surface_overrides_generic_oven_oic_type(self):
"""MW7500A declares oic.d.oven and carries an OVEN board token, but
its verified local API exposes MicroWave mode on an oven cavity. That
strong two-resource signature must win ahead of generic OIC metadata."""
from custom_components.localthings.registry.by_type import (
for_device_by_model,
for_device_by_oic_type,
for_device_by_resources,
resolve,
)
from tests.conftest import _load_device
resources = _load_device('qooker_mw7500a')
info = resources['/information/vs/0']
by_resources = for_device_by_resources(resources)
by_oic = for_device_by_oic_type(('oic.wk.d', 'oic.d.oven'))
by_model = for_device_by_model(
info['x.com.samsung.da.modelNum'],
info['x.com.samsung.da.description'],
)
reg = resolve(resources, device_types=('oic.wk.d', 'oic.d.oven'))
assert by_resources is not None and by_resources.name == 'microwave'
assert by_oic is not None and by_oic.name == 'oven'
assert by_model is not None and by_model.name == 'oven'
assert reg is by_resources
def test_resource_signature_types_dumps_without_information(self, all_device_fixtures):
"""The three dumps with no /information/vs/0 still type."""
from custom_components.localthings.registry.by_type import resolve
for name in ('cooktop', 'range_ne63a6511', 'range_no_info'):
@@ -702,3 +893,27 @@ class TestForDeviceByResources:
assert reg is not None
assert reg.name == 'microwave'
def test_microwave_mode_without_oven_cavity_is_not_matched(self):
"""The mode vocabulary alone is too common to preempt metadata."""
from custom_components.localthings.registry.by_type import for_device_by_resources
resources = {
'/mode/vs/0': {
'x.com.samsung.da.supportedModes': ['MicroWave'],
},
}
assert for_device_by_resources(resources) is None
def test_scalar_supported_modes_is_not_a_microwave_signature(self):
"""Malformed scalar data must not gain resource-first precedence."""
from custom_components.localthings.registry.by_type import for_device_by_resources
resources = {
'/mode/vs/0': {
'x.com.samsung.da.supportedModes': 'MicroWave',
},
'/oven/vs/0': {'x.com.samsung.da.state': 'Ready'},
}
assert for_device_by_resources(resources) is None
+27 -7
View File
@@ -15,9 +15,13 @@ def _reg():
def test_kids_lock_vs_value_fn():
"""binary_sensor's 'lock' device class is inverted from a plain on/off
switch: On means open/unlocked (issues #181/#183 -- see
TestKidsLockFallback.test_vs_fallback_is_read_only for why this is a
binary_sensor at all)."""
desc = common.KIDS_LOCK_VS_FALLBACK.entities[0]
assert desc.value_fn('Lock') is True
assert desc.value_fn('Ready') is False
assert desc.value_fn('Lock') is False
assert desc.value_fn('Ready') is True
def test_common_caps_discover_on_dishwasher(dishwasher_resources):
@@ -228,19 +232,35 @@ class TestWmSetinfoFlags:
class TestKidsLockFallback:
def test_generic_read_write(self):
def test_generic_is_read_only(self):
"""Issues #181/#183: the kids-lock resource is read-only on real
hardware (writing the *correct* value still 4.05s, and no
SwitchDesc device_class='lock' is honored by HA -- the switch
platform only accepts 'outlet'/'switch'). KIDS_LOCK_GENERIC and
KIDS_LOCK_VS_FALLBACK now share the same shape (BinarySensorDesc,
device_class='lock', same key) so both surfaces render with the
same polarity: 'On' means open/unlocked."""
assert common.KIDS_LOCK_GENERIC.href == '/kidslock/0'
desc = common.KIDS_LOCK_GENERIC.entities[0]
assert desc.value_fn(True) is True
path, body = desc.write_fn('On', {})
assert path == ['kidslock', '0']
assert body == {'value': True}
assert isinstance(desc, BinarySensorDesc)
assert desc.value_fn(False) is True # value=False -> On=Unlocked
assert desc.value_fn(True) is False # value=True -> Off=Locked
def test_vs_fallback_gated(self):
assert common.KIDS_LOCK_VS_FALLBACK.match_fn({}, {'/kidslock/vs/0': {}}) is True
assert common.KIDS_LOCK_VS_FALLBACK.match_fn(
{}, {'/kidslock/0': {}, '/kidslock/vs/0': {}}) is False
def test_vs_fallback_is_read_only(self):
"""Issues #181/#183: writing this resource 4.05s on real hardware
even with the *correct* value ('Run'), and no dump in the fixture
corpus has ever reported the value this used to write ('Enable')
back -- every one reports 'Ready' or 'Run'. Never a confirmed write
contract, so it's a binary_sensor (no write_fn field at all), not
a switch."""
desc = common.KIDS_LOCK_VS_FALLBACK.entities[0]
assert isinstance(desc, BinarySensorDesc)
class TestRemoteControlFallback:
def test_generic_read(self):
+199
View File
@@ -0,0 +1,199 @@
"""Tests for Samsung EHS (Eco Heating System) heat pump support
(TP1X_DA_AC_EHS_01001_0000).
HA-free like the rest of the suite: exercises the registry, discovery/
flatten, and the zone/dhw mode and temperature write contracts.
"""
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import for_device_by_model
from custom_components.localthings.registry.discovery import discover
from custom_components.localthings.registry.entities import NumberDesc, SelectDesc, WaterHeaterDesc
from tests.conftest import _load_device
def _ehs():
resources = _load_device('ehs')
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 _bound():
reg, resources = _ehs()
return discover(resources, reg.capabilities, reg.pattern_capabilities), resources
def _state():
bound, resources = _bound()
return flatten(bound, resources)
def _desc(key):
bound, _ = _bound()
return next(b.desc for b in bound if b.desc.key == key)
def test_model_resolves_to_ehs_registry():
reg, _ = _ehs()
assert reg is not None and reg.name == 'ehs'
def test_no_unbound_hrefs():
"""Every resource in the real TP1X_DA_AC_EHS_01001_0000 dump binds or is
covered -- clears the coverage-gap repair a device_type='unknown' entry
raises."""
reg, resources = _ehs()
unbound = []
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
assert unbound == []
def test_expected_state_keys_present():
state = _state()
for key in ('zone_power', 'zone_mode', 'zone_temperature', 'zone_target_temperature',
'water_heater', 'away_mode', 'mute_once', 'alarm_code', 'energy_kwh'):
assert key in state, key
def test_zone_temperature_reads_current_value():
state = _state()
assert state['zone_temperature'] == 30.0
def test_zone_target_temperature_reads_desired_value():
state = _state()
assert state['zone_target_temperature'] == 5.0
def test_zone_mode_reads_first_mode():
state = _state()
assert state['zone_mode'] == 'Cool'
def test_zone_mode_select_options_come_from_live_supported_modes():
"""Options are read live from x.com.samsung.da.supportedModes, not a
hardcoded tuple -- so a future firmware with a different mode set is
handled automatically."""
desc = _desc('zone_mode')
assert isinstance(desc, SelectDesc)
assert desc.options_field == 'x.com.samsung.da.supportedModes'
assert desc.options == ()
def test_zone_mode_write_contract():
desc = _desc('zone_mode')
path, body = desc.write_fn('Heat', {})
assert path == ['mode', 'vs', '0']
assert body == {'x.com.samsung.da.modes': ['Heat']}
def test_zone_power_reads_off():
state = _state()
assert state['zone_power'] is False
def test_zone_power_write_contract():
desc = _desc('zone_power')
path, body = desc.write_fn('On', {})
assert path == ['power', 'vs', '0']
assert body == {'x.com.samsung.da.power': 'On'}
def test_zone_target_temperature_write_contract():
desc = _desc('zone_target_temperature')
assert isinstance(desc, NumberDesc)
path, body = desc.write_fn('21.5', {})
assert path == ['temperatures', 'indoor', 'vs', '0']
assert body == {'x.com.samsung.da.desired': '21.5'}
def test_zone_target_temperature_bounds_read_live():
"""min/max/step come from the device's own resource fields rather than
a hardcoded constant -- see the adding-device-support skill's 'never
hard-code the one dump's values' section."""
desc = _desc('zone_target_temperature')
rep = {'x.com.samsung.da.minimum': '5.0', 'x.com.samsung.da.maximum': '25.0'}
assert desc.native_min_fn(rep) == 5.0
assert desc.native_max_fn(rep) == 25.0
assert desc.step_fn({'x.com.samsung.da.increment': '0.5'}) == 0.5
# No live field: falls back to a sane default rather than raising.
assert desc.native_min_fn({}) == 5.0
assert desc.native_max_fn({}) == 30.0
assert desc.step_fn({}) == 0.5
def test_zone_target_temperature_bounds_fall_back_together():
"""One end without the other is not a usable range: pairing a real
device minimum with an invented default maximum looks plausible and is
silently wrong, so a half-reported range falls back whole."""
desc = _desc('zone_target_temperature')
half = {'x.com.samsung.da.minimum': '10.0'}
assert desc.native_min_fn(half) == 5.0
assert desc.native_max_fn(half) == 30.0
def test_zone_target_temperature_zero_increment_is_not_collapsed():
"""`or` would turn a genuine 0 into the 0.5 default (issue #160)."""
desc = _desc('zone_target_temperature')
assert desc.step_fn({'x.com.samsung.da.increment': '0'}) == 0.0
def test_water_heater_entity_is_bound():
"""The composite water_heater entity binds the primary /mode/dhw/vs/0
resource -- same primary-plus-siblings shape as the AC's ClimateDesc
(see test_airconditioner_capabilities.py's test_climate_entity_is_bound)."""
bound, _ = _bound()
water_heaters = [b for b in bound if isinstance(b.desc, WaterHeaterDesc)]
assert len(water_heaters) == 1
assert water_heaters[0].href == '/mode/dhw/vs/0'
def test_water_heater_reads_first_mode():
"""The flattened/golden state exposes the same representative scalar
the entity's current_operation is derived from -- see climate.py's
_first_mode for the identical pattern on the AC side."""
state = _state()
assert state['water_heater'] == 'Eco'
def test_water_heater_write_targets():
"""DHW.entities[0].write_fn maps each (kind, value) command to the right
vendor POST target and body -- power, mode and temperature only, no fan/
swing/preset (the AC's climate.py has those; the DHW loop doesn't)."""
write = _desc('water_heater').write_fn
assert write(('power', True), {}) == (
['power', 'dhw', 'vs', '0'], {'x.com.samsung.da.power': 'On'})
assert write(('power', False), {}) == (
['power', 'dhw', 'vs', '0'], {'x.com.samsung.da.power': 'Off'})
assert write(('mode', 'Force'), {}) == (
['mode', 'dhw', 'vs', '0'], {'x.com.samsung.da.modes': ['Force']})
assert write(('temperature', 45.0), {}) == (
['temperatures', 'dhw', 'vs', '0'], {'x.com.samsung.da.desired': '45.0'})
assert write(('bogus', 1), {}) is None
def test_dhw_power_and_temperature_declared_as_coverage():
"""/power/dhw/vs/0 and /temperatures/dhw/vs/0 are read by the composite
water_heater entity (via water_heater.py's sibling reads), not given
their own entities -- declared as no-entity coverage caps so discover()
reports no gap, same pattern as the AC's CLIMATE_CONSUMED_HREFS."""
reg, _ = _ehs()
for href in ('/power/dhw/vs/0', '/temperatures/dhw/vs/0'):
caps = reg.capabilities.get(href)
assert caps, href
assert all(c.entities == () for c in caps), href
def test_away_mode_reads_off():
state = _state()
assert state['away_mode'] is False
def test_away_mode_write_contract():
desc = _desc('away_mode')
path, body = desc.write_fn('On', {})
assert path == ['option', 'outgoing', 'vs', '0']
assert body == {'x.com.samsung.da.away': 'On'}
+93
View File
@@ -49,6 +49,18 @@ def test_registry_reproduces_golden_state_keys_for_washer():
)
def test_registry_reproduces_golden_state_keys_for_ehs():
from tests.conftest import _load_device
resources = _load_device('ehs')
golden = json.loads((GOLDEN / 'ehs.json').read_text())
state_keys = _new_state_keys('ehs', 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_wa8000t():
"""Top-load washer (WA8000T, issue #106) reports no oneUiVersion and
used the 'WA' consumer-model prefix, previously unmapped in
@@ -686,6 +698,22 @@ def test_registry_reproduces_golden_state_keys_for_microwave_mw7300b():
)
def test_registry_reproduces_golden_state_keys_for_qooker_mw7500a():
"""Bespoke Qooker MW7500A uses Samsung's OVEN board/type metadata but
proves its microwave semantics through MicroWave mode plus cavity
powerLevel. The routing correction must expose microwave state keys
without the misleading oven_mode/oven_state entities."""
from tests.conftest import _load_device
resources = _load_device('qooker_mw7500a')
golden = json.loads((GOLDEN / 'qooker_mw7500a.json').read_text())
state_keys = _new_state_keys('qooker_mw7500a', 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_microwave_me7500d():
"""TP1X_DA-KS-MICROWAVE-01051 plain microwave (model ME7500D, issues
#137/#142) -- the same microwave registry as MW7300B above, but this
@@ -810,6 +838,27 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner_tp1x_rac_01001
)
def test_registry_reproduces_golden_state_keys_for_airconditioner_tp1x_rac_odor_controller():
"""TP1X_DA-AC-RAC-01001_0000 -- reporter's dump: fan (0-4 wind-strength
scale) and WindFree (Nano/NanoSleep convenient-mode codes) already bind
via the composite climate entity, no code change needed there. The
genuine gap was /mode/vs/0's SmartCoolClean_/ProgressSmartClean_ option
tokens (the cloud custom.airConditionerOdorController capability),
previously unbound to any entity -- now odor_controller_active/
odor_controller_progress."""
from tests.conftest import _load_device
resources = _load_device('airconditioner_tp1x_rac_odor_controller')
golden = json.loads(
(GOLDEN / 'airconditioner_tp1x_rac_odor_controller.json').read_text()
)
state_keys = _new_state_keys('airconditioner_tp1x_rac_odor_controller', resources)
assert set(state_keys) == set(golden['state_keys']), (
f"state_keys mismatch:\n"
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
)
def test_registry_reproduces_golden_state_keys_for_air_dresser():
"""DA_DF_A51_20_COMMON AirDresser (model DF8600T, issue #162) -- reports
no oneUiVersion; resolved via the '_DF_' modelNum token fallback. Has no
@@ -848,6 +897,50 @@ def test_registry_reproduces_golden_state_keys_for_air_dresser_tp2_20():
)
def test_registry_reproduces_golden_state_keys_for_air_dresser_tp1_21():
"""DA_DF_TP1_21_COMMON AirDresser (model DF3000B, issue #208) -- another
board generation routed via the '_DF_' modelNum token fallback into the
same air_dresser registry as #162's and #157's boards (its dump also
happens to be the first AirDresser one to carry /oic/d's device type,
'x.com.st.d.steamcloset' -- see test_by_type.py's
TestForDeviceByOicType for that mapping's own coverage). The first
AirDresser dump to report /buzzersound/vs/0 (laundry.BUZZER_SOUND,
added for this issue -- #162's and #157's boards don't report it at
all). Binds cleanly with zero unbound hrefs; course codes for this
board's table still aren't identified (same as #162's), so 'cycle'
remains the raw code rather than a table-specific translation."""
from tests.conftest import _load_device
resources = _load_device('air_dresser_tp1_21')
golden = json.loads((GOLDEN / 'air_dresser_tp1_21.json').read_text())
state_keys = _new_state_keys('air_dresser_tp1_21', 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_monitor():
"""Samsung Air Monitor Plus (ASM-KR-TP1-22-ACMB1M, issue #210) -- a
standalone battery-powered air-quality sensor puck, the first device
this integration has no controllable-appliance concept for at all (no
/power/*, only /energy/battery/vs/0). Routes via both /oic/d
('x.com.st.d.airqualitysensor') and the 'ASM' modelNum board token.
Reuses air_purifier.AIR_QUALITY's /sensors/vs/0 decode
(common.sensor_item_value) for dust/fine_dust/super_fine_dust/odor/
clean_level, adding a CO2 reading those families don't report. Binds
cleanly with zero unbound hrefs."""
from tests.conftest import _load_device
resources = _load_device('air_monitor')
golden = json.loads((GOLDEN / 'air_monitor.json').read_text())
state_keys = _new_state_keys('air_monitor', resources)
assert set(state_keys) == set(golden['state_keys']), (
f"state_keys mismatch:\n"
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
)
def test_registry_reproduces_golden_state_keys_for_air_purifier_vtww():
"""A-VTWW-TP2-21-COMMON BESPOKE Cube Air (issue #151) -- reports no
oneUiVersion; resolved via the '-VTWW-' modelNum token fallback into
+25 -1
View File
@@ -1,6 +1,9 @@
"""Unit tests for the microwave-family capabilities (issue #121/#66 split
into their own device type instead of being folded into oven.py)."""
from custom_components.localthings.registry.by_type import for_device_by_model
from custom_components.localthings.registry.by_type import (
for_device_by_model,
resolve,
)
from custom_components.localthings.registry.capabilities import microwave
from custom_components.localthings.registry.discovery import discover
@@ -39,6 +42,27 @@ def test_microwave_hood_fan_fixture_resolves_and_has_no_unbound_hrefs():
assert unbound == []
def test_qooker_fixture_resolves_as_microwave_and_has_no_unbound_hrefs():
"""Bespoke Qooker MW7500A is microwave-shaped despite both its OCF type
and internal board token saying oven."""
from tests.conftest import _load_device
resources = _load_device('qooker_mw7500a')
reg = resolve(resources, device_types=('oic.wk.d', 'oic.d.oven'))
assert reg is not None
assert reg.name == 'microwave'
unbound = []
bound = discover(
resources, reg.capabilities, reg.pattern_capabilities,
log=unbound.append,
)
assert unbound == []
keys = {entity.desc.key for entity in bound}
assert {'cooking_mode', 'power_level', 'setpoint'} <= keys
assert 'oven_mode' not in keys
# ---------------------------------------------------------------------------
# MICROWAVE_SETPOINT — NumberDesc with RMW write semantics
# ---------------------------------------------------------------------------
+92
View File
@@ -0,0 +1,92 @@
"""Pattern C subdevice discovery: AWM-WW-AID-26-ONEBODY washer+dryer combo
(issue #241).
The fixture is a live capture from the reporting board (see its
`seeds_note`): the master tree is the dryer (`oic.d.dryer`,
`..._DV80H27H`), and a complete washer tree (`..._WF80H`) answers at
`/<uuid>/device/0`, where the UUID appears *only* as the path prefix of the
`x.com.samsung.da.multidevice` link in `/oic/res` -- no `subdeviceIdList`
(Pattern B's signal), no `/device/<n>` sibling (Pattern A's).
"""
from tests.conftest import FakeCoapSession, _discover_full, _load_device_full
FIXTURE = 'washer_dryer_onebody_awm'
WASHER_UUID = '58b7d338-15c5-97d3-b562-000000000001'
def _discover():
resources, oic_res, seeds = _load_device_full(FIXTURE)
return _discover_full(resources, oic_res, seeds)
def test_washer_subdevice_materializes_from_oic_res_uuid_link():
bound, materialized, skipped, full_resources, device_type_name = _discover()
assert [s.key for s in materialized] == [WASHER_UUID]
sub = materialized[0]
assert sub.kind == 'prefixed'
assert sub.seed_path == (WASHER_UUID, 'device', '0')
# Collection mode, not the issue-#205 flat fallback.
assert sub.flat_hrefs == ()
def test_washer_subdevice_probe_only_fires_for_the_advertised_uuid():
"""Pattern C must not invent candidates: exactly one prefixed seed is
probed (the UUID from the /oic/res multidevice link), alongside the
bounded speculative Pattern A probes this board 4.04s."""
from custom_components.localthings.registry.subdevices import (
enumerate_subdevices,
)
resources, oic_res, seeds = _load_device_full(FIXTURE)
probes: dict[str, bool] = {}
sess = FakeCoapSession(seeds)
candidates, _extra = enumerate_subdevices(
sess, resources, oic_res, probe_log=probes.__setitem__,
)
assert probes[f'/{WASHER_UUID}/device/0'] is True
assert probes['/device/1'] is False
assert probes['/device/2'] is False
# No flat-fallback flood: the seed Collection answered, so no per-href
# probes under the prefix beyond the seed itself.
prefixed_probes = [h for h in probes if h.startswith(f'/{WASHER_UUID}/')]
assert prefixed_probes == [f'/{WASHER_UUID}/device/0']
assert [c.key for c in candidates] == [WASHER_UUID]
def test_washer_subdevice_binds_its_own_primary_entities():
"""The washer tree carries its own /operational/state and /power -- the
liveness gate must see live primary entities, and the bound set must
carry washer-side keys under the subdevice key prefix."""
bound, materialized, _skipped, _full, _name = _discover()
sub = materialized[0]
sub_keys = {b.desc.key for b in bound if b.subdevice.key == sub.key}
assert sub_keys, 'washer subdevice bound no entities at all'
# Its own operational state and its own power switch/sensor -- the two
# things SmartThings cloud splits this unit over.
assert any('machine_state' in k for k in sub_keys), sub_keys
assert any('power' in k for k in sub_keys), sub_keys
def test_master_dryer_entities_unchanged_by_washer_discovery():
"""Additive only: the master (dryer) side must keep binding the same
entity keys with and without the washer candidate materializing."""
resources, _oic_res, _seeds = _load_device_full(FIXTURE)
# Without any subdevices (oic_res withheld, seeds empty -> washer never
# discovered).
bound_solo, materialized_solo, _s, _f, _n = _discover_full(
resources, [], {},
)
assert materialized_solo == []
solo_keys = {(b.href, b.desc.key) for b in bound_solo}
bound, materialized, _s2, _f2, _n2 = _discover()
sub = materialized[0]
main_keys = {
(b.href, b.desc.key) for b in bound if b.subdevice.key != sub.key
}
assert main_keys == solo_keys
+28
View File
@@ -62,6 +62,34 @@ class TestCompletionMinutes:
assert desc.rep_fn(rep) is None
class TestFinishTime:
"""issue: remainingTime only has minute resolution, but datetime.now()
always carries fresh seconds/microseconds -- an unrounded finish_time
changed on nearly every poll even when remainingTime hadn't, flooding
the recorder history/logbook with values that looked identical once
the UI rounded them down to the minute for display."""
def test_seconds_and_microseconds_are_zeroed(self):
desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'finish_time')
rep = {
'x.com.samsung.da.state': 'Run',
'x.com.samsung.da.remainingTime': '00:29:00',
}
result = desc.rep_fn(rep)
assert result.second == 0
assert result.microsecond == 0
def test_stable_across_polls_within_same_minute(self):
desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'finish_time')
rep = {
'x.com.samsung.da.state': 'Run',
'x.com.samsung.da.remainingTime': '00:29:00',
}
first = desc.rep_fn(rep)
second = desc.rep_fn(rep)
assert first == second
class TestDelayFieldFallback:
def test_reads_delay_end_time_when_delay_start_time_absent(self):
from custom_components.localthings.registry.capabilities.operational import OPERATIONAL_STATE
+10
View File
@@ -99,6 +99,16 @@ def test_fan_write_contract():
)
assert desc.write_fn(('speed', '99'), rep) is None
rep_fallback = {
'x.com.samsung.da.hood.settableMinFanSpeed': '0',
'x.com.samsung.da.hood.settableMaxFanSpeed': '3',
}
assert desc.write_fn(('speed', '2'), rep_fallback) == (
['hood', 'fanspeed', 'vs', '0'],
{'x.com.samsung.da.hood.fanSpeed': '2'},
)
assert desc.write_fn(('speed', '4'), rep_fallback) is None
def test_lamp_write_contract():
lamp, brightness = range_hood.HOOD_LAMP.entities
+6 -3
View File
@@ -56,7 +56,10 @@ def test_energy_saving_and_cooktop_alert_present():
def test_child_lock_already_reads_correctly():
"""issue #183 also reported child lock as unreadable -- but this dump's
/kidslock/vs/0 ('Run') and /doors/vs/0's door lock field ('Lock') already
agree the door is locked, and the existing common.KIDS_LOCK_VS_FALLBACK
already reads that correctly with no code change needed."""
agree the door is locked, and common.KIDS_LOCK_VS_FALLBACK already reads
that correctly with no further code change needed. Now a read-only
binary_sensor (issues #181/#183 -- the write side was never a confirmed
contract), whose 'lock' device class is inverted from a switch's plain
on/off: False means locked/closed, matching 'Run' here."""
state = _state()
assert state['child_lock'] is True
assert state['child_lock'] is False
+100
View File
@@ -0,0 +1,100 @@
"""Unit tests for LocalThingsSensor's hysteresis gate (finish_time churn)."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from custom_components.localthings.const import CONF_FINISH_TIME_HYSTERESIS_MINUTES
from custom_components.localthings.registry.capabilities.operational import OPERATIONAL_STATE
from custom_components.localthings.registry.discovery import BoundEntity
from custom_components.localthings.sensor import LocalThingsSensor
_FINISH_TIME_DESC = next(e for e in OPERATIONAL_STATE.entities if e.key == 'finish_time')
class _FakeConfigEntry:
def __init__(self, options):
self.options = options
class _FakeCoordinator:
"""Just enough surface for LocalThingsEntity/LocalThingsSensor."""
def __init__(self, threshold_minutes):
self.device_serial = 'TEST-SERIAL'
self.config_entry = _FakeConfigEntry({
CONF_FINISH_TIME_HYSTERESIS_MINUTES: threshold_minutes,
})
self.data: dict = {}
def _sensor(threshold_minutes=3):
coordinator = _FakeCoordinator(threshold_minutes)
bound = BoundEntity(
href=OPERATIONAL_STATE.href, capability=OPERATIONAL_STATE, desc=_FINISH_TIME_DESC,
)
sensor = LocalThingsSensor(coordinator, bound)
return sensor, coordinator
def test_small_change_is_suppressed():
sensor, coordinator = _sensor(threshold_minutes=3)
base = datetime(2026, 7, 31, 17, 0, tzinfo=timezone.utc)
coordinator.data = {'finish_time': base}
assert sensor.native_value == base
coordinator.data = {'finish_time': base + timedelta(minutes=1)}
assert sensor.native_value == base, "a 1-minute wobble should be held back"
def test_change_past_threshold_is_reported():
sensor, coordinator = _sensor(threshold_minutes=3)
base = datetime(2026, 7, 31, 17, 0, tzinfo=timezone.utc)
coordinator.data = {'finish_time': base}
assert sensor.native_value == base
new = base + timedelta(minutes=5)
coordinator.data = {'finish_time': new}
assert sensor.native_value == new
def test_zero_threshold_disables_hysteresis():
sensor, coordinator = _sensor(threshold_minutes=0)
base = datetime(2026, 7, 31, 17, 0, tzinfo=timezone.utc)
coordinator.data = {'finish_time': base}
assert sensor.native_value == base
new = base + timedelta(seconds=1)
coordinator.data = {'finish_time': new}
assert sensor.native_value == new
def test_cycle_end_passes_through_immediately():
"""A cycle ending (finish_time -> None) must never be held back."""
sensor, coordinator = _sensor(threshold_minutes=3)
base = datetime(2026, 7, 31, 17, 0, tzinfo=timezone.utc)
coordinator.data = {'finish_time': base}
assert sensor.native_value == base
coordinator.data = {'finish_time': None}
assert sensor.native_value is None
def test_non_hysteresis_sensor_is_unaffected():
"""A SensorDesc without hysteresis=True reads straight through, unchanged."""
machine_state_desc = next(
e for e in OPERATIONAL_STATE.entities if e.key == 'machine_state'
)
coordinator = _FakeCoordinator(threshold_minutes=3)
bound = BoundEntity(
href=OPERATIONAL_STATE.href, capability=OPERATIONAL_STATE, desc=machine_state_desc,
)
sensor = LocalThingsSensor(coordinator, bound)
coordinator.data = {'machine_state': 'active'}
assert sensor.native_value == 'active'
coordinator.data = {'machine_state': 'idle'}
assert sensor.native_value == 'idle'
+95 -10
View File
@@ -451,6 +451,45 @@ def test_enumerate_both_patterns_checked_independently():
]
def test_enumerate_prefixed_id_named_by_both_subdevice_id_list_and_oic_res_is_not_duplicated():
"""A board can carry a UUID that is both listed in subdeviceIdList
(Pattern B's signal) *and* advertised as an /oic/res link prefix
(Pattern C's) -- the TP2X_FAC_BORA_21K reporter's own board does this.
The two signals must resolve to one candidate, not two."""
resources = {
'/subdevices/vs/0': {'x.com.samsung.da.subdeviceIdList': [_UUID]},
}
oic_res = [{'di': 'a', 'links': [
{'href': f'/{_UUID}/multidevice/vs/0'},
]}]
sess = _FakeSession({
(_UUID, 'device', '0'): [_DEVCOL_REP, {'href': '/mode/vs/0', 'rep': {'m': 1}}],
})
subdevices, _extra = enumerate_subdevices(sess, resources, oic_res)
assert [(u.kind, u.key) for u in subdevices] == [('prefixed', _UUID)]
def test_enumerate_prefixed_id_case_mismatch_between_sources_is_not_duplicated():
"""subdeviceIdList and an /oic/res link prefix can name the same UUID
with different casing -- the match must be case-insensitive, or the
same physical subdevice materializes twice under two different keys."""
upper_uuid = _UUID.upper()
resources = {
'/subdevices/vs/0': {'x.com.samsung.da.subdeviceIdList': [upper_uuid]},
}
oic_res = [{'di': 'a', 'links': [
{'href': f'/{_UUID}/multidevice/vs/0'},
]}]
sess = _FakeSession({
(upper_uuid, 'device', '0'): [_DEVCOL_REP, {'href': '/mode/vs/0', 'rep': {'m': 1}}],
(_UUID, 'device', '0'): [_DEVCOL_REP, {'href': '/mode/vs/0', 'rep': {'m': 1}}],
})
subdevices, _extra = enumerate_subdevices(sess, resources, oic_res)
assert len(subdevices) == 1
assert subdevices[0].kind == 'prefixed'
assert subdevices[0].key.lower() == _UUID
# ---------------------------------------------------------------------------
# discover_partitioned
# ---------------------------------------------------------------------------
@@ -476,7 +515,7 @@ def test_discover_partitioned_binds_main_and_subdevice_separately():
'/mode/vs/1': {'m': 'sibling'},
}
def resolve(_resources):
def resolve(_resources, **_kwargs):
return reg
bound, device_type_name, materialized, skipped = discover_partitioned(
@@ -516,7 +555,7 @@ def test_discover_partitioned_main_pass_excludes_subdevice_hrefs_from_unbound():
unbound = []
discover_partitioned(
resources, [sub1], lambda r: reg, fallback_capabilities={},
resources, [sub1], lambda r, **_: reg, fallback_capabilities={},
log=unbound.append,
)
assert unbound == []
@@ -534,7 +573,7 @@ def test_discover_partitioned_subdevice_resolves_its_own_registry():
sub1 = _indexed('1')
resources = {'/mode/vs/0': {'x': 1}, '/mode/vs/1': {'x': 2}}
def resolve(view):
def resolve(view, **_kwargs):
# The subdevice's canonical view is exactly {'/mode/vs/0': {'x': 2}}.
return sub_reg if view.get('/mode/vs/0', {}).get('x') == 2 else main_reg
@@ -563,7 +602,7 @@ def test_discover_partitioned_subdevice_falls_back_to_master_registry():
'/mode/vs/1': {'x': 2},
}
def resolve(view):
def resolve(view, **_kwargs):
return main_reg if view.get('/information/vs/0') else None
bound, _, materialized, skipped = discover_partitioned(
@@ -575,6 +614,52 @@ def test_discover_partitioned_subdevice_falls_back_to_master_registry():
assert '/mode/vs/1' in hrefs
def test_discover_partitioned_oic_device_types_resolves_main_pass():
"""`oic_device_types` reaches the main-pass resolve call as
`device_types=`, letting a fake `resolve_registry` mirror the real
resolve()'s precedence between /oic/d and model-based detection."""
cap = Capability(href='/mode/vs/0', entities=(BinarySensorDesc(key='m', field='x'),))
oic_reg = _FakeRegistry('washer', {'/mode/vs/0': [cap]})
resources = {'/mode/vs/0': {'x': 1}}
def resolve(_resources, device_types=()):
return oic_reg if 'oic.d.washer' in device_types else None
bound, device_type_name, _, _ = discover_partitioned(
resources, [], resolve, fallback_capabilities={},
oic_device_types=('oic.d.washer',),
)
assert device_type_name == 'washer'
assert {b.href for b in bound} == {'/mode/vs/0'}
def test_discover_partitioned_oic_device_types_not_applied_to_subdevices():
"""The master's own /oic/d type must not leak into a subdevice's own
resolution -- only main_view's resolve() call receives it, so a fake
resolver keyed purely on device_types resolves nothing for the
subdevice pass and it falls back to the master's registry, per the
documented (and unchanged) subdevice-fallback behavior."""
cap = Capability(href='/mode/vs/0', entities=(BinarySensorDesc(key='m', field='x'),))
oic_reg = _FakeRegistry('washer', {'/mode/vs/0': [cap]})
sub1 = _indexed('1')
resources = {'/mode/vs/0': {'x': 1}, '/mode/vs/1': {'x': 2}}
def resolve(_resources, device_types=()):
return oic_reg if 'oic.d.washer' in device_types else None
bound, device_type_name, materialized, skipped = discover_partitioned(
resources, [sub1], resolve, fallback_capabilities={},
oic_device_types=('oic.d.washer',),
)
assert device_type_name == 'washer'
assert materialized == [sub1]
assert skipped == []
# The subdevice's own resolve() call sees no device_types and returns
# None, falling back to the master's oic_reg -- same capabilities, so
# /mode/vs/1 still binds via that shared registry.
assert {b.href for b in bound} == {'/mode/vs/0', '/mode/vs/1'}
def test_discover_partitioned_no_subdevices_matches_plain_discover():
"""For a device with no subdevices this must be exactly the single
discover() call it replaces -- the hard regression guard the whole
@@ -586,7 +671,7 @@ def test_discover_partitioned_no_subdevices_matches_plain_discover():
resources = {'/mode/vs/0': {'x': 1}}
bound_via_helper, _, materialized, skipped = discover_partitioned(
resources, [], lambda r: reg, fallback_capabilities={},
resources, [], lambda r, **_: reg, fallback_capabilities={},
)
bound_direct = discover(resources, reg.capabilities, reg.pattern_capabilities)
@@ -619,7 +704,7 @@ def test_discover_partitioned_skips_candidate_with_no_live_primary_entity():
}
bound, _, materialized, skipped = discover_partitioned(
resources, [unit2], lambda r: reg, fallback_capabilities={},
resources, [unit2], lambda r, **_: reg, fallback_capabilities={},
)
assert materialized == []
assert len(skipped) == 1
@@ -654,7 +739,7 @@ def test_discover_partitioned_materializes_candidate_with_live_primary_entity():
}
bound, _, materialized, skipped = discover_partitioned(
resources, [sub1], lambda r: reg, fallback_capabilities={},
resources, [sub1], lambda r, **_: reg, fallback_capabilities={},
)
assert materialized == [sub1]
assert skipped == []
@@ -692,7 +777,7 @@ def test_discover_partitioned_skips_candidate_whose_only_live_primary_is_a_meter
}
bound, _, materialized, skipped = discover_partitioned(
resources, [unit1], lambda r: reg, fallback_capabilities={},
resources, [unit1], lambda r, **_: reg, fallback_capabilities={},
)
assert materialized == []
assert [s.subdevice for s in skipped] == [unit1]
@@ -726,7 +811,7 @@ def test_discover_partitioned_meter_carve_out_does_not_gate_out_a_live_subdevice
}
bound, _, materialized, skipped = discover_partitioned(
resources, [unit1], lambda r: reg, fallback_capabilities={},
resources, [unit1], lambda r, **_: reg, fallback_capabilities={},
)
assert materialized == [unit1]
assert skipped == []
@@ -748,7 +833,7 @@ def test_discover_partitioned_skipped_candidate_contributes_no_hot_warm_hrefs():
tiers = []
discover_partitioned(
resources, [unit2], lambda r: reg, fallback_capabilities={},
resources, [unit2], lambda r, **_: reg, fallback_capabilities={},
tier_log=lambda href, tier: tiers.append(href),
)
assert '/mode/vs/2' not in tiers
+11 -1
View File
@@ -129,7 +129,17 @@ def test_no_catalog_carries_unresolved_core_references():
# so it presents as the device itself, and never reads a catalog name. Same
# for the ARTIK051 air-purifier's airflow_fan (issue #56) -- ordered speed
# levels, no presets, same _attr_name = None treatment.
UNNAMED_DESCRIPTORS = {("fan", "fan"), ("fan", "airflow_fan")}
#
# The EHS DHW water_heater is deliberately NOT in here: it is one loop of a
# two-loop device rather than the device itself, so it carries a catalog
# name like everything else (entity.water_heater.dhw, via the descriptor's
# translation_key). That is independent of its *states* -- those are all
# HA's own standard water_heater states (STATE_ECO/HEAT_PUMP/HIGH_DEMAND/
# PERFORMANCE/OFF), which Home Assistant translates itself via the
# entity_component fallback, so no per-state entry is needed either way.
UNNAMED_DESCRIPTORS = {
("fan", "fan"), ("fan", "airflow_fan"),
}
def test_every_descriptor_has_an_entity_catalog_entry():
+212
View File
@@ -0,0 +1,212 @@
"""HA water_heater-entity mapping tests for the EHS DHW loop."""
from homeassistant.components.water_heater import (
STATE_ECO,
STATE_HEAT_PUMP,
STATE_HIGH_DEMAND,
STATE_PERFORMANCE,
WaterHeaterEntity,
)
from homeassistant.const import STATE_OFF, UnitOfTemperature
from custom_components.localthings.registry.by_type import ehs
from custom_components.localthings.registry.discovery import discover
from custom_components.localthings.registry.entities import WaterHeaterDesc
from custom_components.localthings.water_heater import LocalThingsWaterHeater
from tests.conftest import _load_device
class _FakeCoordinator:
device_serial = 'TEST-EHS-SERIAL'
device_info = {}
data = {}
def __init__(self, resources):
self.last_resources = resources
self.commands = []
def resource(self, href):
return self.last_resources.get(href, {})
def canonical_resources(self, subdevice):
# Every bound entity in this test uses the default MAIN subdevice,
# so the canonical view is just the raw snapshot (issue #177 --
# see LocalThingsEntity._resources).
return self.last_resources
async def async_send_command(self, bound, payload):
self.commands.append((bound, payload))
def _entity(resources, coordinator=None):
bound = discover(
resources, ehs.REGISTRY.capabilities, ehs.REGISTRY.pattern_capabilities,
)
water_heater_bound = next(item for item in bound if isinstance(item.desc, WaterHeaterDesc))
return LocalThingsWaterHeater(coordinator or _FakeCoordinator(resources), water_heater_bound)
def test_current_operation_reads_eco_when_on():
entity = _entity(_load_device('ehs'))
assert entity.current_operation == STATE_ECO
def test_current_operation_is_off_when_powered_off():
resources = _load_device('ehs')
resources['/power/dhw/vs/0']['x.com.samsung.da.power'] = 'Off'
entity = _entity(resources)
assert entity.current_operation == STATE_OFF
def test_operation_list_includes_off_and_mapped_modes():
"""Fixture's supportedModes is Eco/Std/Power/Force -- all four map onto
HA's own standard water_heater states (see water_heater.py's module
docstring for the SmartThings-cloud precedent this mirrors)."""
entity = _entity(_load_device('ehs'))
assert entity.operation_list == [
STATE_OFF, STATE_ECO, STATE_HEAT_PUMP, STATE_PERFORMANCE, STATE_HIGH_DEMAND,
]
def test_temperature_reads_current_and_target():
entity = _entity(_load_device('ehs'))
assert entity.current_temperature == 38.0
assert entity.target_temperature == 40.0
assert entity.temperature_unit == UnitOfTemperature.CELSIUS
assert entity.min_temp == 40.0
assert entity.max_temp == 62.0
assert entity.target_temperature_step == 0.5
def test_temperature_bounds_fall_back_together():
"""A board reporting only one end of the range must not pair a real
device bound with an HA default -- both ends or neither, same rule as
climate._range()."""
resources = _load_device('ehs')
del resources['/temperatures/dhw/vs/0']['x.com.samsung.da.maximum']
entity = _entity(resources)
# Neither end comes from the device: the device minimum (40.0) is
# dropped along with the missing maximum, so both fall back to HA's own
# water_heater defaults rather than being mixed.
assert entity.min_temp != 40.0
assert (entity.min_temp, entity.max_temp) == (
WaterHeaterEntity.min_temp.fget(entity),
WaterHeaterEntity.max_temp.fget(entity),
)
def test_zero_increment_is_not_collapsed_into_the_default():
"""`or` would turn a genuine 0 into 0.5 (issue #160)."""
resources = _load_device('ehs')
resources['/temperatures/dhw/vs/0']['x.com.samsung.da.increment'] = '0'
entity = _entity(resources)
assert entity.target_temperature_step == 0.0
def test_current_operation_tolerates_lowercase_device_codes():
resources = _load_device('ehs')
resources['/mode/dhw/vs/0']['x.com.samsung.da.modes'] = ['eco']
entity = _entity(resources)
assert entity.current_operation == STATE_ECO
def test_entity_is_named_rather_than_taking_the_device_name():
"""Unlike climate.py's AC, DHW is one loop of a two-loop device, so it
takes a catalog name instead of presenting as the device itself."""
entity = _entity(_load_device('ehs'))
# No _attr_name override (that would present as the bare device name,
# and would also beat the catalog); the name comes from the catalog key.
assert '_attr_name' not in entity.__dict__
assert entity.translation_key == 'dhw'
async def test_set_temperature_writes_dhw_temperature():
resources = _load_device('ehs')
coordinator = _FakeCoordinator(resources)
entity = _entity(resources, coordinator)
await entity.async_set_temperature(temperature=45.0)
assert coordinator.commands == [(entity._bound, ('temperature', 45.0))]
async def test_set_temperature_honours_operation_mode():
"""water_heater.set_temperature carries an optional operation_mode
(SET_TEMPERATURE_SCHEMA); dropping it would move the setpoint without
ever changing mode -- same bug fixed for climate's hvac_mode."""
resources = _load_device('ehs')
resources['/power/dhw/vs/0']['x.com.samsung.da.power'] = 'Off'
coordinator = _FakeCoordinator(resources)
entity = _entity(resources, coordinator)
await entity.async_set_temperature(temperature=55.0,
operation_mode=STATE_HIGH_DEMAND)
assert coordinator.commands == [
(entity._bound, ('power', True)),
(entity._bound, ('mode', 'Force')),
(entity._bound, ('temperature', 55.0)),
]
async def test_set_temperature_with_off_mode_skips_the_setpoint_write():
resources = _load_device('ehs')
coordinator = _FakeCoordinator(resources)
entity = _entity(resources, coordinator)
await entity.async_set_temperature(temperature=55.0, operation_mode=STATE_OFF)
assert coordinator.commands == [(entity._bound, ('power', False))]
async def test_turn_on_and_off_write_dhw_power():
resources = _load_device('ehs')
coordinator = _FakeCoordinator(resources)
entity = _entity(resources, coordinator)
await entity.async_turn_on()
await entity.async_turn_off()
assert coordinator.commands == [
(entity._bound, ('power', True)),
(entity._bound, ('power', False)),
]
async def test_set_operation_mode_off_turns_off_without_writing_mode():
resources = _load_device('ehs')
coordinator = _FakeCoordinator(resources)
entity = _entity(resources, coordinator)
await entity.async_set_operation_mode(STATE_OFF)
assert coordinator.commands == [(entity._bound, ('power', False))]
async def test_set_operation_mode_writes_mapped_device_code():
resources = _load_device('ehs')
coordinator = _FakeCoordinator(resources)
entity = _entity(resources, coordinator)
await entity.async_set_operation_mode(STATE_HIGH_DEMAND)
# DHW power is already on (fixture default) -- no extra power write.
assert coordinator.commands == [(entity._bound, ('mode', 'Force'))]
async def test_set_operation_mode_powers_on_first_when_off():
resources = _load_device('ehs')
resources['/power/dhw/vs/0']['x.com.samsung.da.power'] = 'Off'
coordinator = _FakeCoordinator(resources)
entity = _entity(resources, coordinator)
await entity.async_set_operation_mode(STATE_PERFORMANCE)
assert coordinator.commands == [
(entity._bound, ('power', True)),
(entity._bound, ('mode', 'Power')),
]
+32 -2
View File
@@ -281,9 +281,17 @@ def test_hotwater_lock_fallback_gating_across_status_lock_states():
assert fallback.exists_fn({}, {}) is False
def test_favorite_hotwater_temperature_options_come_from_live_supported_list():
def test_favorite_hotwater_temperature_options_come_from_live_show_list():
"""`supportedList` is only the four fixed presets -- `showList` is a
superset that also carries the one custom value (if any) the
SmartThings app's "temperatures to display" editor let the user add,
and always includes whatever the current default actually is. Reading
`supportedList` meant a unit whose default *was* that custom value
rendered as 'unknown' in HA (issue #196) -- see the ailite fixture's
version of this test below for the case that actually exercises it
(this fixture's showList == supportedList, no custom value set)."""
desc = _desc_coffee('favorite_hotwater_temperature')
assert desc.options_field == 'x.com.samsung.da.favorite.supportedList'
assert desc.options_field == 'x.com.samsung.da.favorite.showList'
def test_coffee_recipe_hrefs_are_ignored_not_guessed():
@@ -361,6 +369,28 @@ def test_ailite_hot_water_temperature_gated_off_without_supported_list():
assert 'hot_water_temperature' not in state
def test_ailite_favorite_hotwater_temperature_options_include_the_custom_value():
"""Issue #196's concrete failure case: the user added a custom 50C value
via the SmartThings app's "temperatures to display" editor, so the
board's defaultTemperature is now '50'. showList contains '50'
([40, 50, 75, 85, 90]) but supportedList does NOT (still the fixed
[40, 75, 85, 90]). Reading options_field='x.com.samsung.da.favorite.supportedList'
-- the old behavior -- would register a select whose options list
doesn't contain the current default, so HA would render the entity as
'unknown'. The descriptor must read from showList so '50' is in
options."""
desc = _desc_ailite('favorite_hotwater_temperature')
assert desc.options_field == 'x.com.samsung.da.favorite.showList'
_, resources = _water_purifier_ailite()
rep = resources['/favorite/hotwater/vs/0']
# defaultTemperature='50' must be a member of the field the descriptor
# actually reads -- this is the precise assertion that would fail under
# the old supportedList behavior.
assert rep['x.com.samsung.da.favorite.defaultTemperature'] == '50'
assert '50' in rep[desc.options_field]
assert '50' not in rep['x.com.samsung.da.favorite.supportedList']
def test_ailite_sound_mode_options_come_from_live_supported_modes():
"""This board's supportedModes (voice/fixedTone/mute) differs from both
laundry.SOUND_MODE's hardcoded voice/tone/mute and air_purifier.SOUND_MODE's