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
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
51 changed files with 6704 additions and 83 deletions
+1 -1
View File
@@ -137,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
+1 -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"
+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)
@@ -5,7 +5,7 @@ from typing import Optional, Sequence
from ._base import DeviceRegistry
from . import (
air_dresser, air_monitor, air_purifier, airconditioner, cooktop,
dehumidifier, dishwasher, dryer, induction_cooktop, microwave, oven,
dehumidifier, dishwasher, dryer, ehs, induction_cooktop, microwave, oven,
range as _range, range_hood, refrigerator, vacuum_station, washer,
water_purifier,
)
@@ -27,6 +27,7 @@ _REGISTRY_BY_KEY: dict[str, DeviceRegistry] = {
'dehumidifier': dehumidifier.REGISTRY,
'dishwasher': dishwasher.REGISTRY,
'dryer': dryer.REGISTRY,
'ehs': ehs.REGISTRY,
'induction_cooktop': induction_cooktop.REGISTRY,
'microwave': microwave.REGISTRY,
'oven': oven.REGISTRY,
@@ -87,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
@@ -214,6 +219,16 @@ def _consumer_model_key(description: str) -> Optional[str]:
# 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',
@@ -223,6 +238,7 @@ _OIC_TYPE_TO_KEY: dict[str, str] = {
'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',
}
@@ -285,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 ()
@@ -314,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')
@@ -338,13 +362,13 @@ def resolve(
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.
`device_types` (/oic/d's `rt`, read separately from the /device/0 dump --
see registry/identity.py) is the primary signal when present: the device
naming its own type beats parsing board part numbers. Falls back to model
strings (`for_device_by_model`), then a distinctive resource signature
(`for_device_by_resources`) for boards that report no /information/vs/0
at all -- both unchanged from before /oic/d was ever consulted, since most
hardware still doesn't populate it usefully.
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.
@@ -355,10 +379,10 @@ def resolve(
"""
info = resources.get('/information/vs/0', {})
return (
for_device_by_oic_type(device_types)
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', ''),
)
or for_device_by_resources(resources)
)
@@ -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,
]),
)
@@ -596,15 +596,109 @@ 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
@@ -648,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),
),
)
@@ -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]
@@ -133,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',
@@ -143,4 +153,5 @@ PLATFORM_OF: dict[type, str] = {
TimeDesc: 'time',
ClimateDesc: 'climate',
FanDesc: 'fan',
WaterHeaterDesc: 'water_heater',
}
@@ -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
@@ -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))
@@ -1,6 +1,9 @@
{
"entity": {
"binary_sensor": {
"auto_clean_running": {
"name": "Automatické čištění probíhá"
},
"after_run_active": {
"name": "Dosoušení aktivní"
},
@@ -199,6 +202,9 @@
},
"tropical_night_mode": {
"name": "Tropický noční režim"
},
"zone_target_temperature": {
"name": "Požadovaná teplota zóny"
}
},
"select": {
@@ -328,6 +334,15 @@
"favorite_hotwater_temperature": {
"name": "Oblíbená teplota horké vody"
},
"filter_alarm_time": {
"name": "Interval alarmu filtru",
"state": {
"180": "180 hodin",
"300": "300 hodin",
"500": "500 hodin",
"700": "700 hodin"
}
},
"finish_sound": {
"name": "Zvuk dokončení",
"state": {
@@ -603,9 +618,15 @@
"none": "Vypnuto",
"cupboard": "Do skříně"
}
},
"zone_mode": {
"name": "Režim zóny"
}
},
"sensor": {
"auto_clean_progress": {
"name": "Průběh automatického čištění"
},
"after_run_progress": {
"name": "Průběh dosoušení"
},
@@ -945,6 +966,9 @@
"icestatus_stop": "Nečinný",
"icestatus_run": "Vyrábí led"
}
},
"zone_temperature": {
"name": "Teplota zóny"
}
},
"switch": {
@@ -1106,6 +1130,12 @@
},
"wrinkle_prevent": {
"name": "Ochrana proti pomačkání"
},
"zone_power": {
"name": "Napájení zóny"
},
"away_mode": {
"name": "Režim nepřítomnosti"
}
},
"time": {
@@ -1127,6 +1157,11 @@
"night_start": {
"name": "Začátek nočního osvětlení"
}
},
"water_heater": {
"dhw": {
"name": "Teplá voda"
}
}
},
"config": {
@@ -1,6 +1,9 @@
{
"entity": {
"binary_sensor": {
"auto_clean_running": {
"name": "Auto clean running"
},
"after_run_active": {
"name": "After run active"
},
@@ -199,6 +202,9 @@
},
"tropical_night_mode": {
"name": "Tropical night mode"
},
"zone_target_temperature": {
"name": "Zone target temperature"
}
},
"select": {
@@ -328,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": {
@@ -603,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"
},
@@ -945,6 +966,9 @@
"icestatus_stop": "Idle",
"icestatus_run": "Making ice"
}
},
"zone_temperature": {
"name": "Zone temperature"
}
},
"switch": {
@@ -1106,6 +1130,12 @@
},
"wrinkle_prevent": {
"name": "Wrinkle prevent"
},
"zone_power": {
"name": "Zone power"
},
"away_mode": {
"name": "Away mode"
}
},
"time": {
@@ -1127,6 +1157,11 @@
"night_start": {
"name": "Night light start"
}
},
"water_heater": {
"dhw": {
"name": "Hot water"
}
}
},
"config": {
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"
},
@@ -199,6 +202,9 @@
},
"tropical_night_mode": {
"name": "Tropische nachtmodus"
},
"zone_target_temperature": {
"name": "Doeltemperatuur zone"
}
},
"select": {
@@ -328,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": {
@@ -603,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"
},
@@ -945,6 +966,9 @@
"icestatus_stop": "Inactief",
"icestatus_run": "IJs maken"
}
},
"zone_temperature": {
"name": "Zonetemperatuur"
}
},
"switch": {
@@ -1106,6 +1130,12 @@
},
"wrinkle_prevent": {
"name": "Kreukpreventie"
},
"zone_power": {
"name": "Zonevoeding"
},
"away_mode": {
"name": "Afwezigheidsmodus"
}
},
"time": {
@@ -1127,6 +1157,11 @@
"night_start": {
"name": "Start nachtverlichting"
}
},
"water_heater": {
"dhw": {
"name": "Warm water"
}
}
},
"config": {
@@ -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))
+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"
}
]
}
]
}
]
}
}
]
}
+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",
@@ -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",
+3 -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",
@@ -29,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",
+2
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",
@@ -6,6 +6,8 @@
"air_filter_usage_hours",
"alarm_code",
"auto_clean",
"auto_clean_progress",
"auto_clean_running",
"beep",
"climate",
"current_temperature_c",
@@ -6,6 +6,8 @@
"air_filter_usage_hours",
"alarm_code",
"auto_clean",
"auto_clean_progress",
"auto_clean_running",
"beep",
"climate",
"current_temperature_c",
@@ -7,6 +7,8 @@
"air_filter_usage_hours",
"alarm_code",
"auto_clean",
"auto_clean_progress",
"auto_clean_running",
"beep",
"climate",
"current_temperature_c",
@@ -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",
@@ -5,6 +5,8 @@
"air_filter_usage_hours",
"alarm_code",
"auto_clean",
"auto_clean_progress",
"auto_clean_running",
"beep",
"climate",
"current_temperature_c",
@@ -1,26 +1,28 @@
{
"state_keys": [
"air_filter_status",
"air_filter_threshold",
"air_filter_usage",
"air_filter_usage_hours",
"air_purify",
"alarm_code",
"auto_clean",
"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"
]
}
{
"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",
+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
+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()
+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():
+99 -7
View File
@@ -149,6 +149,7 @@ class TestForDeviceByOicType:
('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'),
@@ -177,6 +178,18 @@ class TestForDeviceByOicType:
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
@@ -324,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
@@ -703,22 +730,63 @@ class TestResolve:
assert reg is not None
assert reg.name == 'refrigerator'
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."""
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,
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'):
@@ -825,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
+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'}
+28
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
+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
+39
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
# ---------------------------------------------------------------------------
+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')),
]