Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
547388cc2b | ||
|
|
81b83f4175 | ||
|
|
c5f37ea280 | ||
|
|
b4550cc4b1 | ||
|
|
42a1812967 | ||
|
|
e926905517 | ||
|
|
6bcf8f3bec | ||
|
|
9737684c9f | ||
|
|
6efee761d9 | ||
|
|
e309d3e7b8 | ||
|
|
34b2291bef | ||
|
|
5c275753d4 | ||
|
|
57eb3e80c7 | ||
|
|
d544644e0e | ||
|
|
f9cd857ced | ||
|
|
93744e80f4 | ||
|
|
a74b92f758 | ||
|
|
d639aa693f | ||
|
|
3c6c7f246f | ||
|
|
0d0844ea7f | ||
|
|
618fc5fa51 | ||
|
|
9ee0dbb8f9 | ||
|
|
f19abc7936 |
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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))
|
||||
Vendored
+759
@@ -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
@@ -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
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"air_filter_usage_hours",
|
||||
"alarm_code",
|
||||
"auto_clean",
|
||||
"auto_clean_progress",
|
||||
"auto_clean_running",
|
||||
"beep",
|
||||
"climate",
|
||||
"current_temperature_c",
|
||||
|
||||
+2
@@ -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",
|
||||
|
||||
Vendored
+15
@@ -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
@@ -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"
|
||||
]
|
||||
}
|
||||
+183
File diff suppressed because one or more lines are too long
+1364
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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'}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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')),
|
||||
]
|
||||
Reference in New Issue
Block a user