Merge pull request #253 from pookey/feat/ehs-water-heater

Add a water_heater platform for the EHS DHW loop
This commit is contained in:
Marc Billow
2026-08-02 17:32:22 -04:00
committed by GitHub
17 changed files with 1807 additions and 4 deletions
+1 -1
View File
@@ -137,7 +137,7 @@ custom_components/localthings/
diagnostics.py Redacted diagnostics download (device state + coverage metadata)
const.py Domain, config keys, probe ports
entity.py Base entity wiring capability registry -> HA entity
sensor.py / binary_sensor.py / switch.py / number.py / select.py / button.py / time.py / fan.py / climate.py
sensor.py / binary_sensor.py / switch.py / number.py / select.py / button.py / time.py / fan.py / climate.py / water_heater.py
One module per HA platform
catalog.py Reads the shipped translation catalog (which keys/states exist)
translations/ Config-flow copy + entity name/state translations, one file per
+1 -1
View File
@@ -2,7 +2,7 @@ DOMAIN = "localthings"
PLATFORMS = [
"sensor", "binary_sensor", "switch", "number", "select", "button",
"time", "climate", "fan",
"time", "climate", "fan", "water_heater",
]
CONF_HOST = "host"
@@ -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
@@ -0,0 +1,29 @@
"""EHS (Eco Heating System) air-to-water heat pump device registry
(Samsung TP1X_DA_AC_EHS-class).
Shares the DA_AC_ board prefix with the room-AC family in
airconditioner.py, but its /mode/*/vs/0 and /temperatures/*/vs/0 resources
are its own shape (two independent loops: zone1 space heating/cooling and
dhw domestic hot water), not airconditioner.py's HREF_MODE/HREF_TEMP* OCF
pattern -- so nothing from that module is reused here except MUTE_ONCE,
whose /option/muteonce/vs/0 field shape (`muteonce`) is identical on this
family's dump.
"""
from ..capabilities import airconditioner, common, ehs, ignored
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='ehs',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
airconditioner.MUTE_ONCE,
ehs.ZONE_POWER,
ehs.ZONE_MODE,
ehs.ZONE_TEMPERATURE,
ehs.DHW,
*ehs.DHW_CONSUMED,
ehs.AWAY_MODE,
*ehs.COVERAGE,
]),
)
@@ -0,0 +1,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',
}
@@ -202,6 +202,9 @@
},
"tropical_night_mode": {
"name": "Tropický noční režim"
},
"zone_target_temperature": {
"name": "Požadovaná teplota zóny"
}
},
"select": {
@@ -615,6 +618,9 @@
"none": "Vypnuto",
"cupboard": "Do skříně"
}
},
"zone_mode": {
"name": "Režim zóny"
}
},
"sensor": {
@@ -960,6 +966,9 @@
"icestatus_stop": "Nečinný",
"icestatus_run": "Vyrábí led"
}
},
"zone_temperature": {
"name": "Teplota zóny"
}
},
"switch": {
@@ -1121,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": {
@@ -1142,6 +1157,11 @@
"night_start": {
"name": "Začátek nočního osvětlení"
}
},
"water_heater": {
"dhw": {
"name": "Teplá voda"
}
}
},
"config": {
@@ -202,6 +202,9 @@
},
"tropical_night_mode": {
"name": "Tropical night mode"
},
"zone_target_temperature": {
"name": "Zone target temperature"
}
},
"select": {
@@ -615,6 +618,9 @@
"none": "Off",
"cupboard": "Cupboard"
}
},
"zone_mode": {
"name": "Zone mode"
}
},
"sensor": {
@@ -960,6 +966,9 @@
"icestatus_stop": "Idle",
"icestatus_run": "Making ice"
}
},
"zone_temperature": {
"name": "Zone temperature"
}
},
"switch": {
@@ -1121,6 +1130,12 @@
},
"wrinkle_prevent": {
"name": "Wrinkle prevent"
},
"zone_power": {
"name": "Zone power"
},
"away_mode": {
"name": "Away mode"
}
},
"time": {
@@ -1142,6 +1157,11 @@
"night_start": {
"name": "Night light start"
}
},
"water_heater": {
"dhw": {
"name": "Hot water"
}
}
},
"config": {
@@ -202,6 +202,9 @@
},
"tropical_night_mode": {
"name": "Tropische nachtmodus"
},
"zone_target_temperature": {
"name": "Doeltemperatuur zone"
}
},
"select": {
@@ -615,6 +618,9 @@
"none": "Uit",
"cupboard": "Kastdroog"
}
},
"zone_mode": {
"name": "Zonemodus"
}
},
"sensor": {
@@ -960,6 +966,9 @@
"icestatus_stop": "Inactief",
"icestatus_run": "IJs maken"
}
},
"zone_temperature": {
"name": "Zonetemperatuur"
}
},
"switch": {
@@ -1121,6 +1130,12 @@
},
"wrinkle_prevent": {
"name": "Kreukpreventie"
},
"zone_power": {
"name": "Zonevoeding"
},
"away_mode": {
"name": "Afwezigheidsmodus"
}
},
"time": {
@@ -1142,6 +1157,11 @@
"night_start": {
"name": "Start nachtverlichting"
}
},
"water_heater": {
"dhw": {
"name": "Warm water"
}
}
},
"config": {
@@ -0,0 +1,273 @@
"""Water heater platform for Local Things.
Second composite entity in this integration (see climate.py's module
docstring for the general pattern this follows): a single HA water_heater
card for a Samsung EHS heat pump's domestic hot water (DHW) loop. It binds
the primary `WaterHeaterDesc` (the `/mode/dhw/vs/0` capability, DHW.entities
in registry/capabilities/ehs.py) so the registry still tracks it, and reads
the sibling `/power/dhw/vs/0` and `/temperatures/dhw/vs/0` resources straight
from the coordinator snapshot -- the same cross-resource read climate.py uses
for the AC's power/temperature/wind siblings.
Writes go through `coordinator.async_send_command(bound, (kind, value))`:
DHW's `write_fn` (ehs._dhw_write) maps each `(kind, value)` payload to the
right `(path_segs, body)`, and `async_send_command` POSTs to those path_segs
and applies the optimistic value/settle guard to that same href -- not the
bound `/mode/dhw/vs/0` href -- so one descriptor drives writes to, and gets
fresh state back for, power, mode and temperature alike.
Operation-mode vocabulary: the DHW loop's four device modes (Eco/Std/Force/
Power) map onto HA's own standard water_heater states -- the same mapping
Home Assistant's core `smartthings` integration uses for this exact Samsung
capability over the cloud API (`samsungce.ehsThermostat` /
`airConditionerMode`: eco/std/force/power -> STATE_ECO/STATE_HEAT_PUMP/
STATE_HIGH_DEMAND/STATE_PERFORMANCE), just title-cased to match this OCF
resource's own code spelling. Reusing HA's standard states means no *state*
translation catalog entry is needed for them (see the entity_component
fallback in homeassistant.components.water_heater.strings.json).
Naming is a separate question from that, and the answer here differs from
climate.py's: the AC *is* the device, so its climate card takes the bare
device name (`_attr_name = None`). An EHS unit has two loops, and the DHW
one is not "the device" -- its siblings are named "Zone Mode"/"Zone Target
Temperature", so a card labelled just "EHS" would misrepresent which loop
it drives. This entity is named through the catalog like every other
descriptor here, via the DHW descriptor's `translation_key='dhw'`
(entity.water_heater.dhw.name -> "Hot water").
"""
from __future__ import annotations
import logging
from homeassistant.components.water_heater import (
STATE_ECO,
STATE_HEAT_PUMP,
STATE_HIGH_DEMAND,
STATE_PERFORMANCE,
WaterHeaterEntity,
WaterHeaterEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_OFF, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .registry.capabilities.ehs import (
HREF_DHW_MODE as MODE_HREF,
HREF_DHW_POWER as POWER_HREF,
HREF_DHW_TEMPERATURE as TEMPERATURE_HREF,
)
from .registry.capabilities.common import normalize_temp_unit
from .registry.entities import WaterHeaterDesc
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
_LOGGER = logging.getLogger(__name__)
_MODES_FIELD = 'x.com.samsung.da.modes'
_SUPPORTED_FIELD = 'x.com.samsung.da.supportedModes'
# Device mode <-> HA water_heater operation state -- see the module
# docstring above for the SmartThings-cloud precedent this mirrors.
_DEVICE_TO_STATE: dict[str, str] = {
'Eco': STATE_ECO,
'Std': STATE_HEAT_PUMP,
'Force': STATE_HIGH_DEMAND,
'Power': STATE_PERFORMANCE,
}
_STATE_TO_DEVICE = {v: k for k, v in _DEVICE_TO_STATE.items()}
# Read-side lookup, case-folded. climate.py resolves write codes from the
# unit's own supportedModes because two spellings there mean one HA value
# ('Wind'/'Fan' -> FAN_ONLY); this map is bijective, so the write side can
# use _STATE_TO_DEVICE directly. Only the read side is exposed to a board
# spelling the same code differently ('eco'/'ECO'), and case is the one
# variation worth absorbing rather than warning about.
_DEVICE_TO_STATE_CI = {k.lower(): v for k, v in _DEVICE_TO_STATE.items()}
def _to_state(code) -> str | None:
if code is None:
return None
return _DEVICE_TO_STATE_CI.get(str(code).lower())
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
LocalThingsWaterHeater(coordinator, b)
for b in coordinator.bound
if isinstance(b.desc, WaterHeaterDesc) and _is_included(b, coordinator)
)
def _num(value):
try:
return float(value)
except (TypeError, ValueError):
return None
def _first(value):
"""Samsung `modes` is a single-element list on this resource. Return the
first element of a list, else the value itself."""
if isinstance(value, (list, tuple)):
return value[0] if value else None
return value
class LocalThingsWaterHeater(LocalThingsEntity, WaterHeaterEntity):
"""Composite water_heater entity for a Samsung EHS DHW loop."""
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
super().__init__(coordinator, bound)
# No _attr_name here: unlike climate.py's AC, this is one loop of a
# two-loop device and takes a catalog name ("Hot water") through the
# descriptor's translation_key -- see the module docstring.
self._attr_supported_features = (
WaterHeaterEntityFeature.TARGET_TEMPERATURE
| WaterHeaterEntityFeature.OPERATION_MODE
| WaterHeaterEntityFeature.ON_OFF
)
# Raw device codes already logged by _warn_unmapped -- these
# properties are read on every coordinator refresh, so an un-deduped
# warning would spam the log for any unit reporting a genuinely
# unrecognized code.
self._warned_unmapped: set[str] = set()
def _rep(self, href: str) -> dict:
"""`href` is one of this module's canonical HREF_* constants --
translated through this bound entity's own subdevice (issue #177),
same as climate.py's identical helper."""
return self.coordinator.resource(self._bound.subdevice.to_actual(href)) or {}
def _is_on(self) -> bool:
return str(self._rep(POWER_HREF).get('x.com.samsung.da.power', '')).lower() == 'on'
def _supported(self) -> list[str]:
return list(self._rep(MODE_HREF).get(_SUPPORTED_FIELD) or [])
def _warn_unmapped(self, code: str) -> None:
if code in self._warned_unmapped:
return
self._warned_unmapped.add(code)
_LOGGER.warning(
"%s: device DHW mode %r has no HA mapping and was dropped; "
"please file an issue with your diagnostics dump",
self.entity_id, code,
)
# -- temperature --------------------------------------------------------
@property
def temperature_unit(self) -> str:
raw = self._rep(TEMPERATURE_HREF).get('x.com.samsung.da.unit')
return (UnitOfTemperature.FAHRENHEIT
if normalize_temp_unit(raw, '°C') == '°F'
else UnitOfTemperature.CELSIUS)
@property
def current_temperature(self):
return _num(self._rep(TEMPERATURE_HREF).get('x.com.samsung.da.current'))
@property
def target_temperature(self):
return _num(self._rep(TEMPERATURE_HREF).get('x.com.samsung.da.desired'))
def _range(self) -> list | None:
"""The device's own (minimum, maximum) pair, or None.
Both ends together or neither, deliberately -- same rule as
climate._range(). A board reporting minimum but not maximum would
otherwise pair a device minimum (40) with HA's own default maximum
(140 °F), which looks plausible and is silently wrong on a unit
that really allows 62.
"""
rep = self._rep(TEMPERATURE_HREF)
lo = _num(rep.get('x.com.samsung.da.minimum'))
hi = _num(rep.get('x.com.samsung.da.maximum'))
return [lo, hi] if (lo is not None and hi is not None) else None
@property
def min_temp(self) -> float:
r = self._range()
return r[0] if r else super().min_temp
@property
def max_temp(self) -> float:
r = self._range()
return r[1] if r else super().max_temp
@property
def target_temperature_step(self) -> float:
# `is None`, not `or` -- see issue #160: `or` collapses a genuine 0
# into the fallback.
step = _num(self._rep(TEMPERATURE_HREF).get('x.com.samsung.da.increment'))
return 0.5 if step is None else step
# -- operation mode -------------------------------------------------------
@property
def current_operation(self) -> str | None:
if not self._is_on():
return STATE_OFF
code = _first(self._rep(MODE_HREF).get(_MODES_FIELD))
mapped = _to_state(code)
if code is not None and mapped is None:
self._warn_unmapped(code)
return mapped
@property
def operation_list(self) -> list[str]:
modes = [STATE_OFF]
for code in self._supported():
mapped = _to_state(code)
if mapped is None:
self._warn_unmapped(code)
continue
if mapped not in modes:
modes.append(mapped)
return modes
# -- writes ---------------------------------------------------------------
async def async_set_temperature(self, **kwargs) -> None:
# HA's water_heater.set_temperature service takes an optional
# operation_mode and forwards it here (SET_TEMPERATURE_SCHEMA), same
# as climate.set_temperature does with hvac_mode. Honour it, and set
# it first -- that also powers the loop on when it was off -- so a
# dashboard "boost to 55" button that carries a mode actually changes
# mode, instead of only moving the setpoint. Same fix as the AC's
# (see climate.async_set_temperature).
operation_mode = kwargs.get('operation_mode')
if operation_mode is not None:
await self.async_set_operation_mode(operation_mode)
if operation_mode == STATE_OFF:
return
temp = kwargs.get('temperature')
if temp is None:
return
await self.coordinator.async_send_command(self._bound, ('temperature', temp))
async def async_set_operation_mode(self, operation_mode: str) -> None:
if operation_mode == STATE_OFF:
await self.coordinator.async_send_command(self._bound, ('power', False))
return
device = _STATE_TO_DEVICE.get(operation_mode)
if device is None:
return
if not self._is_on():
await self.coordinator.async_send_command(self._bound, ('power', True))
await self.coordinator.async_send_command(self._bound, ('mode', device))
async def async_turn_on(self, **kwargs) -> None:
await self.coordinator.async_send_command(self._bound, ('power', True))
async def async_turn_off(self, **kwargs) -> None:
await self.coordinator.async_send_command(self._bound, ('power', False))
+759
View File
@@ -0,0 +1,759 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.longnotisubscription": "false",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/alarms/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "ErrorCode_OFF",
"x.com.samsung.da.triggeredTime": "2026-08-01T21:32:45",
"x.com.samsung.da.state": "Deleted"
}
]
}
},
{
"href": "/temperatures/indoor/vs/0",
"rep": {
"x.com.samsung.da.type": "Water",
"x.com.samsung.da.desired": "5.0",
"x.com.samsung.da.current": "30.0",
"x.com.samsung.da.maximum": "25.0",
"x.com.samsung.da.minimum": "5.0",
"x.com.samsung.da.increment": "0.5",
"x.com.samsung.da.offset": "0.0",
"x.com.samsung.da.unit": "Celsius"
}
},
{
"href": "/temperatures/dhw/vs/0",
"rep": {
"x.com.samsung.da.type": "Water",
"x.com.samsung.da.unit": "Celsius",
"x.com.samsung.da.desired": "40.0",
"x.com.samsung.da.current": "38.0",
"x.com.samsung.da.maximum": "62.0",
"x.com.samsung.da.minimum": "40.0",
"x.com.samsung.da.increment": "0.5"
}
},
{
"href": "/diagnosis/vs/0",
"rep": {}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.cumulativePower": "2448844",
"x.com.samsung.da.cumulativeSavedPower": "0",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.cumulativePowerType": "individual"
}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"Cool",
"Heat",
"Auto"
],
"x.com.samsung.da.modes": [
"Cool"
],
"x.com.samsung.da.options": [
"OutdoorTemp_80",
"KeyInputPermit_On",
"ModePermit_NoLimit",
"Thermostat_CoolHeat_Off"
],
"x.com.samsung.da.supportedOptions": [
"Thermostat_CoolHeat",
"DurationOn",
"KeyInputPermit",
"ModePermit",
"Volume",
"OutdoorTemp"
]
}
},
{
"href": "/mode/dhw/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"Eco",
"Std",
"Power",
"Force"
],
"x.com.samsung.da.modes": [
"Eco"
],
"x.com.samsung.da.options": [
"Thermostat_Dhw_Off",
"KeyInputPermit_On"
],
"x.com.samsung.da.supportedOptions": [
"Thermostat_Dhw",
"KeyInputPermit"
]
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "Off",
"causeSource": "SMTS"
}
},
{
"href": "/power/dhw/vs/0",
"rep": {
"x.com.samsung.da.power": "On",
"causeSource": "SMTS"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "TP1X_DA_AC_EHS_01001_0000|10250141|60070105001711034A00010000002000",
"x.com.samsung.da.description": "TP1X_DA_AC_EHS_01001_0000",
"x.com.samsung.da.serialNum": "**REDACTED**",
"x.com.samsung.da.otnDUID": "**REDACTED**",
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
"x.com.samsung.da.diagLogType": [
"errCode",
"dump"
],
"x.com.samsung.da.diagDumpType": "file",
"x.com.samsung.da.diagEndPoint": "SSM",
"x.com.samsung.da.diagMnid": "0AJT",
"x.com.samsung.da.diagSetupid": "AE0",
"x.com.samsung.da.diagMinVersion": "3.0",
"x.com.samsung.da.diagTsId": "DA01",
"x.com.samsung.da.serialNumOption": "**REDACTED**",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02504A260402",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "02501A24062401,FFFFFFFFFFFFFF",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Outdoor",
"x.com.samsung.da.number": "02572A23081000,02549A10000800"
}
]
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+01:00",
"x.com.samsung.supprtedtype": 1
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "0000000000"
}
},
{
"href": "/drlc/vs/0",
"rep": {
"x.com.samsung.da.drlcLevel": "0",
"x.com.samsung.da.durationminutes": "0",
"x.com.samsung.da.drlcStartTime": "1970-01-01T00:00:00Z",
"x.com.samsung.da.override": "Off",
"x.com.samsung.da.realSaving": "Off"
}
},
{
"href": "/availablecontrolsets/vs/0",
"rep": {
"x.com.samsung.da.sets": "0001003200FA0B0190026C0C0000CE32",
"x.com.samsung.da.id": "EHS",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/da/softreset/vs/0",
"rep": {
"x.com.samsung.da.softwarereset": "false"
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "",
"x.com.samsung.da.newVersionAvailable": "false",
"x.com.samsung.da.newVersionNo": "00000000",
"x.com.samsung.da.currentVersionInfo": "00000000",
"otnStatus": "None",
"flashingProgress": "",
"otnTarget": "main",
"otnCompleteDate": "noHistory",
"otnList": [
{
"type": "WIFI",
"modelId": "AEH-WW-TP1-22-AE6000",
"versions": [
"17260402"
],
"visVersion": "260402"
},
{
"type": "Micom",
"modelId": "040010250141FFFFFFFF",
"versions": [
"24062401",
"FFFFFFFF"
],
"visVersion": "240624"
},
{
"type": "Micom",
"modelId": "04001025724110254941",
"versions": [
"23081000",
"10000800"
],
"visVersion": "230810"
}
]
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "Europe/London",
"offset": "+01:00",
"DST": "ON"
}
},
{
"href": "/option/muteonce/vs/0",
"rep": {
"muteonce": "Off"
}
},
{
"href": "/reserverulesets/vs/0",
"rep": {
"x.com.samsung.da.sets": "0EFFFFFFFF05190F370B283E",
"x.com.samsung.da.id": "EHS",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/connectionconfig/vs/0",
"rep": {
"autoReconnectionMinVersion": "1.0",
"autoReconnection": "true",
"autoReconnectionProtocolType": [
"helper_hotspot",
"ble_ocf"
],
"supportedWiFiAuthType": [
"OPEN",
"WEP",
"WPA-PSK",
"WPA2-PSK",
"SAE"
],
"supportedWiFiCryptoType": [
"TKIP",
"AES",
"WEP-64",
"WEP-128"
],
"supportedWiFiFreq": [
"2.4G"
],
"calmConnectionCare": {
"version": "1.0",
"role": [
"things"
]
}
}
},
{
"href": "/option/outgoing/vs/0",
"rep": {
"x.com.samsung.da.away": "Off"
}
},
{
"href": "/sac/installationinfo/vs/0",
"rep": {
"installationOptions": [
{
"id": "0",
"option": "12300000000000000000"
},
{
"id": "1",
"option": "20010000000000000000"
},
{
"id": "2",
"option": "50000000000000000000"
},
{
"id": "3",
"option": "30000000000000000000"
}
],
"installationDeviceNum": [
{
"id": "0",
"number": "01"
},
{
"id": "1",
"number": "01"
},
{
"id": "2",
"number": "00"
}
],
"outdoorInfo": [
{
"id": "0",
"info": "02572A230810",
"serial": "**REDACTED**"
},
{
"id": "1",
"info": "000000000000",
"serial": "**REDACTED**"
},
{
"id": "2",
"info": "000000000000",
"serial": "**REDACTED**"
},
{
"id": "3",
"info": "000000000000",
"serial": "**REDACTED**"
}
],
"mcuInfo": [
{
"id": "0",
"info": "000000000000"
},
{
"id": "1",
"info": "000000000000"
},
{
"id": "2",
"info": "000000000000"
},
{
"id": "3",
"info": "000000000000"
},
{
"id": "4",
"info": "000000000000"
},
{
"id": "5",
"info": "000000000000"
},
{
"id": "6",
"info": "000000000000"
},
{
"id": "7",
"info": "000000000000"
},
{
"id": "8",
"info": "000000000000"
},
{
"id": "9",
"info": "000000000000"
},
{
"id": "10",
"info": "000000000000"
},
{
"id": "11",
"info": "000000000000"
},
{
"id": "12",
"info": "000000000000"
},
{
"id": "13",
"info": "000000000000"
},
{
"id": "14",
"info": "000000000000"
},
{
"id": "15",
"info": "000000000000"
}
]
}
},
{
"href": "/ehsfsv/vs/0",
"rep": {
"items": [
{
"setting": "0407010A01017202EE02260101"
},
{
"setting": "0408010A010096017200960101"
},
{
"setting": "041B010A0101F402BC026C0101"
},
{
"setting": "041C010A01012C019001900101"
},
{
"setting": "07DB010A01FF380032FFE20101"
},
{
"setting": "07DC010A01006400C800960101"
},
{
"setting": "07E5010A0100AA02EE01540101"
},
{
"setting": "07E6010A0100AA02EE014A0101"
},
{
"setting": "07EF010A0100AA02EE028A0101"
},
{
"setting": "07F0010A0100AA02EE00AA0101"
},
{
"setting": "082B0101000000000400000101"
},
{
"setting": "082C0101000000000400000101"
},
{
"setting": "082D0101000001000400040101"
},
{
"setting": "0BC30101000000000200020101"
},
{
"setting": "0BFF0101000000000100000101"
},
{
"setting": "0FAB0101000000000100000101"
},
{
"setting": "0FAC010A01FF6A00C800000101"
},
{
"setting": "0FB50101000000000200000101"
},
{
"setting": "0FCA010A010032009600640101"
},
{
"setting": "0FDD0101000000000100000101"
}
]
}
},
{
"href": "/ehscycle/vs/0",
"rep": {
"indoor": [
{
"cycledata": "4B055455050500000000000000000000000000000004CCA70000005D05054B",
"datetime": "2026-08-01T21:23:08"
},
{
"cycledata": "4B055454050500000000000000000000000000000004CCAC0000005D05054B",
"datetime": "2026-08-01T21:28:08"
}
],
"outdoor": [
{
"cycledata": "00000000555A4A53490207D000000000FFFF00480046325700000000",
"datetime": "2026-08-01T21:23:08"
},
{
"cycledata": "00000000555A4952490207D000000000000000480046325600000000",
"datetime": "2026-08-01T21:28:08"
}
],
"unit": "Celsius"
}
},
{
"href": "/option/dhwdisplay/vs/0",
"rep": {
"x.com.samsung.da.dhwdisplay": "Show"
}
},
{
"href": "/actions/zone1/vs/0",
"rep": {}
},
{
"href": "/actions/dhw/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.timelineId": "0",
"x.com.samsung.da.module": "DHW",
"x.com.samsung.da.temperatureType": "Water",
"x.com.samsung.da.dayofWeek": [
"Sun"
],
"x.com.samsung.da.timeblocks": [
{
"x.com.samsung.da.timeblockId": "0",
"x.com.samsung.da.start": [
{
"x.com.samsung.da.time": "03:00",
"x.com.samsung.da.power": "On",
"x.com.samsung.da.mode": "Std",
"x.com.samsung.da.desired": "45.0"
}
],
"x.com.samsung.da.end": [
{
"x.com.samsung.da.time": "05:00",
"x.com.samsung.da.power": "On",
"x.com.samsung.da.mode": "Eco",
"x.com.samsung.da.desired": "40.0"
}
]
}
]
},
{
"x.com.samsung.da.timelineId": "1",
"x.com.samsung.da.module": "DHW",
"x.com.samsung.da.temperatureType": "Water",
"x.com.samsung.da.dayofWeek": [
"Mon"
],
"x.com.samsung.da.timeblocks": [
{
"x.com.samsung.da.timeblockId": "0",
"x.com.samsung.da.start": [
{
"x.com.samsung.da.time": "03:00",
"x.com.samsung.da.power": "On",
"x.com.samsung.da.mode": "Std",
"x.com.samsung.da.desired": "45.0"
}
],
"x.com.samsung.da.end": [
{
"x.com.samsung.da.time": "05:00",
"x.com.samsung.da.power": "On",
"x.com.samsung.da.mode": "Eco",
"x.com.samsung.da.desired": "40.0"
}
]
}
]
},
{
"x.com.samsung.da.timelineId": "2",
"x.com.samsung.da.module": "DHW",
"x.com.samsung.da.temperatureType": "Water",
"x.com.samsung.da.dayofWeek": [
"Tue"
],
"x.com.samsung.da.timeblocks": [
{
"x.com.samsung.da.timeblockId": "0",
"x.com.samsung.da.start": [
{
"x.com.samsung.da.time": "03:00",
"x.com.samsung.da.power": "On",
"x.com.samsung.da.mode": "Std",
"x.com.samsung.da.desired": "45.0"
}
],
"x.com.samsung.da.end": [
{
"x.com.samsung.da.time": "05:00",
"x.com.samsung.da.power": "On",
"x.com.samsung.da.mode": "Eco",
"x.com.samsung.da.desired": "40.0"
}
]
}
]
},
{
"x.com.samsung.da.timelineId": "3",
"x.com.samsung.da.module": "DHW",
"x.com.samsung.da.temperatureType": "Water",
"x.com.samsung.da.dayofWeek": [
"Wed"
],
"x.com.samsung.da.timeblocks": [
{
"x.com.samsung.da.timeblockId": "0",
"x.com.samsung.da.start": [
{
"x.com.samsung.da.time": "03:00",
"x.com.samsung.da.power": "On",
"x.com.samsung.da.mode": "Std",
"x.com.samsung.da.desired": "45.0"
}
],
"x.com.samsung.da.end": [
{
"x.com.samsung.da.time": "05:00",
"x.com.samsung.da.power": "On",
"x.com.samsung.da.mode": "Eco",
"x.com.samsung.da.desired": "40.0"
}
]
}
]
},
{
"x.com.samsung.da.timelineId": "4",
"x.com.samsung.da.module": "DHW",
"x.com.samsung.da.temperatureType": "Water",
"x.com.samsung.da.dayofWeek": [
"Thu"
],
"x.com.samsung.da.timeblocks": [
{
"x.com.samsung.da.timeblockId": "0",
"x.com.samsung.da.start": [
{
"x.com.samsung.da.time": "03:00",
"x.com.samsung.da.power": "On",
"x.com.samsung.da.mode": "Std",
"x.com.samsung.da.desired": "45.0"
}
],
"x.com.samsung.da.end": [
{
"x.com.samsung.da.time": "05:00",
"x.com.samsung.da.power": "On",
"x.com.samsung.da.mode": "Eco",
"x.com.samsung.da.desired": "40.0"
}
]
}
]
},
{
"x.com.samsung.da.timelineId": "5",
"x.com.samsung.da.module": "DHW",
"x.com.samsung.da.temperatureType": "Water",
"x.com.samsung.da.dayofWeek": [
"Fri"
],
"x.com.samsung.da.timeblocks": [
{
"x.com.samsung.da.timeblockId": "0",
"x.com.samsung.da.start": [
{
"x.com.samsung.da.time": "03:00",
"x.com.samsung.da.power": "On",
"x.com.samsung.da.mode": "Std",
"x.com.samsung.da.desired": "45.0"
}
],
"x.com.samsung.da.end": [
{
"x.com.samsung.da.time": "05:00",
"x.com.samsung.da.power": "On",
"x.com.samsung.da.mode": "Eco",
"x.com.samsung.da.desired": "40.0"
}
]
}
]
},
{
"x.com.samsung.da.timelineId": "6",
"x.com.samsung.da.module": "DHW",
"x.com.samsung.da.temperatureType": "Water",
"x.com.samsung.da.dayofWeek": [
"Sat"
],
"x.com.samsung.da.timeblocks": [
{
"x.com.samsung.da.timeblockId": "0",
"x.com.samsung.da.start": [
{
"x.com.samsung.da.time": "03:00",
"x.com.samsung.da.power": "On",
"x.com.samsung.da.mode": "Std",
"x.com.samsung.da.desired": "45.0"
}
],
"x.com.samsung.da.end": [
{
"x.com.samsung.da.time": "05:00",
"x.com.samsung.da.power": "On",
"x.com.samsung.da.mode": "Eco",
"x.com.samsung.da.desired": "40.0"
}
]
}
]
}
]
}
}
]
}
+15
View File
@@ -0,0 +1,15 @@
{
"state_keys": [
"alarm_code",
"away_mode",
"energy_kwh",
"energy_saved_kwh",
"firmware_update",
"mute_once",
"water_heater",
"zone_mode",
"zone_power",
"zone_target_temperature",
"zone_temperature"
]
}
+14
View File
@@ -337,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
+199
View File
@@ -0,0 +1,199 @@
"""Tests for Samsung EHS (Eco Heating System) heat pump support
(TP1X_DA_AC_EHS_01001_0000).
HA-free like the rest of the suite: exercises the registry, discovery/
flatten, and the zone/dhw mode and temperature write contracts.
"""
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import for_device_by_model
from custom_components.localthings.registry.discovery import discover
from custom_components.localthings.registry.entities import NumberDesc, SelectDesc, WaterHeaterDesc
from tests.conftest import _load_device
def _ehs():
resources = _load_device('ehs')
info = resources['/information/vs/0']
reg = for_device_by_model(
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'],
)
return reg, resources
def _bound():
reg, resources = _ehs()
return discover(resources, reg.capabilities, reg.pattern_capabilities), resources
def _state():
bound, resources = _bound()
return flatten(bound, resources)
def _desc(key):
bound, _ = _bound()
return next(b.desc for b in bound if b.desc.key == key)
def test_model_resolves_to_ehs_registry():
reg, _ = _ehs()
assert reg is not None and reg.name == 'ehs'
def test_no_unbound_hrefs():
"""Every resource in the real TP1X_DA_AC_EHS_01001_0000 dump binds or is
covered -- clears the coverage-gap repair a device_type='unknown' entry
raises."""
reg, resources = _ehs()
unbound = []
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
assert unbound == []
def test_expected_state_keys_present():
state = _state()
for key in ('zone_power', 'zone_mode', 'zone_temperature', 'zone_target_temperature',
'water_heater', 'away_mode', 'mute_once', 'alarm_code', 'energy_kwh'):
assert key in state, key
def test_zone_temperature_reads_current_value():
state = _state()
assert state['zone_temperature'] == 30.0
def test_zone_target_temperature_reads_desired_value():
state = _state()
assert state['zone_target_temperature'] == 5.0
def test_zone_mode_reads_first_mode():
state = _state()
assert state['zone_mode'] == 'Cool'
def test_zone_mode_select_options_come_from_live_supported_modes():
"""Options are read live from x.com.samsung.da.supportedModes, not a
hardcoded tuple -- so a future firmware with a different mode set is
handled automatically."""
desc = _desc('zone_mode')
assert isinstance(desc, SelectDesc)
assert desc.options_field == 'x.com.samsung.da.supportedModes'
assert desc.options == ()
def test_zone_mode_write_contract():
desc = _desc('zone_mode')
path, body = desc.write_fn('Heat', {})
assert path == ['mode', 'vs', '0']
assert body == {'x.com.samsung.da.modes': ['Heat']}
def test_zone_power_reads_off():
state = _state()
assert state['zone_power'] is False
def test_zone_power_write_contract():
desc = _desc('zone_power')
path, body = desc.write_fn('On', {})
assert path == ['power', 'vs', '0']
assert body == {'x.com.samsung.da.power': 'On'}
def test_zone_target_temperature_write_contract():
desc = _desc('zone_target_temperature')
assert isinstance(desc, NumberDesc)
path, body = desc.write_fn('21.5', {})
assert path == ['temperatures', 'indoor', 'vs', '0']
assert body == {'x.com.samsung.da.desired': '21.5'}
def test_zone_target_temperature_bounds_read_live():
"""min/max/step come from the device's own resource fields rather than
a hardcoded constant -- see the adding-device-support skill's 'never
hard-code the one dump's values' section."""
desc = _desc('zone_target_temperature')
rep = {'x.com.samsung.da.minimum': '5.0', 'x.com.samsung.da.maximum': '25.0'}
assert desc.native_min_fn(rep) == 5.0
assert desc.native_max_fn(rep) == 25.0
assert desc.step_fn({'x.com.samsung.da.increment': '0.5'}) == 0.5
# No live field: falls back to a sane default rather than raising.
assert desc.native_min_fn({}) == 5.0
assert desc.native_max_fn({}) == 30.0
assert desc.step_fn({}) == 0.5
def test_zone_target_temperature_bounds_fall_back_together():
"""One end without the other is not a usable range: pairing a real
device minimum with an invented default maximum looks plausible and is
silently wrong, so a half-reported range falls back whole."""
desc = _desc('zone_target_temperature')
half = {'x.com.samsung.da.minimum': '10.0'}
assert desc.native_min_fn(half) == 5.0
assert desc.native_max_fn(half) == 30.0
def test_zone_target_temperature_zero_increment_is_not_collapsed():
"""`or` would turn a genuine 0 into the 0.5 default (issue #160)."""
desc = _desc('zone_target_temperature')
assert desc.step_fn({'x.com.samsung.da.increment': '0'}) == 0.0
def test_water_heater_entity_is_bound():
"""The composite water_heater entity binds the primary /mode/dhw/vs/0
resource -- same primary-plus-siblings shape as the AC's ClimateDesc
(see test_airconditioner_capabilities.py's test_climate_entity_is_bound)."""
bound, _ = _bound()
water_heaters = [b for b in bound if isinstance(b.desc, WaterHeaterDesc)]
assert len(water_heaters) == 1
assert water_heaters[0].href == '/mode/dhw/vs/0'
def test_water_heater_reads_first_mode():
"""The flattened/golden state exposes the same representative scalar
the entity's current_operation is derived from -- see climate.py's
_first_mode for the identical pattern on the AC side."""
state = _state()
assert state['water_heater'] == 'Eco'
def test_water_heater_write_targets():
"""DHW.entities[0].write_fn maps each (kind, value) command to the right
vendor POST target and body -- power, mode and temperature only, no fan/
swing/preset (the AC's climate.py has those; the DHW loop doesn't)."""
write = _desc('water_heater').write_fn
assert write(('power', True), {}) == (
['power', 'dhw', 'vs', '0'], {'x.com.samsung.da.power': 'On'})
assert write(('power', False), {}) == (
['power', 'dhw', 'vs', '0'], {'x.com.samsung.da.power': 'Off'})
assert write(('mode', 'Force'), {}) == (
['mode', 'dhw', 'vs', '0'], {'x.com.samsung.da.modes': ['Force']})
assert write(('temperature', 45.0), {}) == (
['temperatures', 'dhw', 'vs', '0'], {'x.com.samsung.da.desired': '45.0'})
assert write(('bogus', 1), {}) is None
def test_dhw_power_and_temperature_declared_as_coverage():
"""/power/dhw/vs/0 and /temperatures/dhw/vs/0 are read by the composite
water_heater entity (via water_heater.py's sibling reads), not given
their own entities -- declared as no-entity coverage caps so discover()
reports no gap, same pattern as the AC's CLIMATE_CONSUMED_HREFS."""
reg, _ = _ehs()
for href in ('/power/dhw/vs/0', '/temperatures/dhw/vs/0'):
caps = reg.capabilities.get(href)
assert caps, href
assert all(c.entities == () for c in caps), href
def test_away_mode_reads_off():
state = _state()
assert state['away_mode'] is False
def test_away_mode_write_contract():
desc = _desc('away_mode')
path, body = desc.write_fn('On', {})
assert path == ['option', 'outgoing', 'vs', '0']
assert body == {'x.com.samsung.da.away': 'On'}
+12
View File
@@ -49,6 +49,18 @@ def test_registry_reproduces_golden_state_keys_for_washer():
)
def test_registry_reproduces_golden_state_keys_for_ehs():
from tests.conftest import _load_device
resources = _load_device('ehs')
golden = json.loads((GOLDEN / 'ehs.json').read_text())
state_keys = _new_state_keys('ehs', resources)
assert set(state_keys) == set(golden['state_keys']), (
f"state_keys mismatch:\n"
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
)
def test_registry_reproduces_golden_state_keys_for_washer_wa8000t():
"""Top-load washer (WA8000T, issue #106) reports no oneUiVersion and
used the 'WA' consumer-model prefix, previously unmapped in
+11 -1
View File
@@ -129,7 +129,17 @@ def test_no_catalog_carries_unresolved_core_references():
# so it presents as the device itself, and never reads a catalog name. Same
# for the ARTIK051 air-purifier's airflow_fan (issue #56) -- ordered speed
# levels, no presets, same _attr_name = None treatment.
UNNAMED_DESCRIPTORS = {("fan", "fan"), ("fan", "airflow_fan")}
#
# The EHS DHW water_heater is deliberately NOT in here: it is one loop of a
# two-loop device rather than the device itself, so it carries a catalog
# name like everything else (entity.water_heater.dhw, via the descriptor's
# translation_key). That is independent of its *states* -- those are all
# HA's own standard water_heater states (STATE_ECO/HEAT_PUMP/HIGH_DEMAND/
# PERFORMANCE/OFF), which Home Assistant translates itself via the
# entity_component fallback, so no per-state entry is needed either way.
UNNAMED_DESCRIPTORS = {
("fan", "fan"), ("fan", "airflow_fan"),
}
def test_every_descriptor_has_an_entity_catalog_entry():
+212
View File
@@ -0,0 +1,212 @@
"""HA water_heater-entity mapping tests for the EHS DHW loop."""
from homeassistant.components.water_heater import (
STATE_ECO,
STATE_HEAT_PUMP,
STATE_HIGH_DEMAND,
STATE_PERFORMANCE,
WaterHeaterEntity,
)
from homeassistant.const import STATE_OFF, UnitOfTemperature
from custom_components.localthings.registry.by_type import ehs
from custom_components.localthings.registry.discovery import discover
from custom_components.localthings.registry.entities import WaterHeaterDesc
from custom_components.localthings.water_heater import LocalThingsWaterHeater
from tests.conftest import _load_device
class _FakeCoordinator:
device_serial = 'TEST-EHS-SERIAL'
device_info = {}
data = {}
def __init__(self, resources):
self.last_resources = resources
self.commands = []
def resource(self, href):
return self.last_resources.get(href, {})
def canonical_resources(self, subdevice):
# Every bound entity in this test uses the default MAIN subdevice,
# so the canonical view is just the raw snapshot (issue #177 --
# see LocalThingsEntity._resources).
return self.last_resources
async def async_send_command(self, bound, payload):
self.commands.append((bound, payload))
def _entity(resources, coordinator=None):
bound = discover(
resources, ehs.REGISTRY.capabilities, ehs.REGISTRY.pattern_capabilities,
)
water_heater_bound = next(item for item in bound if isinstance(item.desc, WaterHeaterDesc))
return LocalThingsWaterHeater(coordinator or _FakeCoordinator(resources), water_heater_bound)
def test_current_operation_reads_eco_when_on():
entity = _entity(_load_device('ehs'))
assert entity.current_operation == STATE_ECO
def test_current_operation_is_off_when_powered_off():
resources = _load_device('ehs')
resources['/power/dhw/vs/0']['x.com.samsung.da.power'] = 'Off'
entity = _entity(resources)
assert entity.current_operation == STATE_OFF
def test_operation_list_includes_off_and_mapped_modes():
"""Fixture's supportedModes is Eco/Std/Power/Force -- all four map onto
HA's own standard water_heater states (see water_heater.py's module
docstring for the SmartThings-cloud precedent this mirrors)."""
entity = _entity(_load_device('ehs'))
assert entity.operation_list == [
STATE_OFF, STATE_ECO, STATE_HEAT_PUMP, STATE_PERFORMANCE, STATE_HIGH_DEMAND,
]
def test_temperature_reads_current_and_target():
entity = _entity(_load_device('ehs'))
assert entity.current_temperature == 38.0
assert entity.target_temperature == 40.0
assert entity.temperature_unit == UnitOfTemperature.CELSIUS
assert entity.min_temp == 40.0
assert entity.max_temp == 62.0
assert entity.target_temperature_step == 0.5
def test_temperature_bounds_fall_back_together():
"""A board reporting only one end of the range must not pair a real
device bound with an HA default -- both ends or neither, same rule as
climate._range()."""
resources = _load_device('ehs')
del resources['/temperatures/dhw/vs/0']['x.com.samsung.da.maximum']
entity = _entity(resources)
# Neither end comes from the device: the device minimum (40.0) is
# dropped along with the missing maximum, so both fall back to HA's own
# water_heater defaults rather than being mixed.
assert entity.min_temp != 40.0
assert (entity.min_temp, entity.max_temp) == (
WaterHeaterEntity.min_temp.fget(entity),
WaterHeaterEntity.max_temp.fget(entity),
)
def test_zero_increment_is_not_collapsed_into_the_default():
"""`or` would turn a genuine 0 into 0.5 (issue #160)."""
resources = _load_device('ehs')
resources['/temperatures/dhw/vs/0']['x.com.samsung.da.increment'] = '0'
entity = _entity(resources)
assert entity.target_temperature_step == 0.0
def test_current_operation_tolerates_lowercase_device_codes():
resources = _load_device('ehs')
resources['/mode/dhw/vs/0']['x.com.samsung.da.modes'] = ['eco']
entity = _entity(resources)
assert entity.current_operation == STATE_ECO
def test_entity_is_named_rather_than_taking_the_device_name():
"""Unlike climate.py's AC, DHW is one loop of a two-loop device, so it
takes a catalog name instead of presenting as the device itself."""
entity = _entity(_load_device('ehs'))
# No _attr_name override (that would present as the bare device name,
# and would also beat the catalog); the name comes from the catalog key.
assert '_attr_name' not in entity.__dict__
assert entity.translation_key == 'dhw'
async def test_set_temperature_writes_dhw_temperature():
resources = _load_device('ehs')
coordinator = _FakeCoordinator(resources)
entity = _entity(resources, coordinator)
await entity.async_set_temperature(temperature=45.0)
assert coordinator.commands == [(entity._bound, ('temperature', 45.0))]
async def test_set_temperature_honours_operation_mode():
"""water_heater.set_temperature carries an optional operation_mode
(SET_TEMPERATURE_SCHEMA); dropping it would move the setpoint without
ever changing mode -- same bug fixed for climate's hvac_mode."""
resources = _load_device('ehs')
resources['/power/dhw/vs/0']['x.com.samsung.da.power'] = 'Off'
coordinator = _FakeCoordinator(resources)
entity = _entity(resources, coordinator)
await entity.async_set_temperature(temperature=55.0,
operation_mode=STATE_HIGH_DEMAND)
assert coordinator.commands == [
(entity._bound, ('power', True)),
(entity._bound, ('mode', 'Force')),
(entity._bound, ('temperature', 55.0)),
]
async def test_set_temperature_with_off_mode_skips_the_setpoint_write():
resources = _load_device('ehs')
coordinator = _FakeCoordinator(resources)
entity = _entity(resources, coordinator)
await entity.async_set_temperature(temperature=55.0, operation_mode=STATE_OFF)
assert coordinator.commands == [(entity._bound, ('power', False))]
async def test_turn_on_and_off_write_dhw_power():
resources = _load_device('ehs')
coordinator = _FakeCoordinator(resources)
entity = _entity(resources, coordinator)
await entity.async_turn_on()
await entity.async_turn_off()
assert coordinator.commands == [
(entity._bound, ('power', True)),
(entity._bound, ('power', False)),
]
async def test_set_operation_mode_off_turns_off_without_writing_mode():
resources = _load_device('ehs')
coordinator = _FakeCoordinator(resources)
entity = _entity(resources, coordinator)
await entity.async_set_operation_mode(STATE_OFF)
assert coordinator.commands == [(entity._bound, ('power', False))]
async def test_set_operation_mode_writes_mapped_device_code():
resources = _load_device('ehs')
coordinator = _FakeCoordinator(resources)
entity = _entity(resources, coordinator)
await entity.async_set_operation_mode(STATE_HIGH_DEMAND)
# DHW power is already on (fixture default) -- no extra power write.
assert coordinator.commands == [(entity._bound, ('mode', 'Force'))]
async def test_set_operation_mode_powers_on_first_when_off():
resources = _load_device('ehs')
resources['/power/dhw/vs/0']['x.com.samsung.da.power'] = 'Off'
coordinator = _FakeCoordinator(resources)
entity = _entity(resources, coordinator)
await entity.async_set_operation_mode(STATE_PERFORMANCE)
assert coordinator.commands == [
(entity._bound, ('power', True)),
(entity._bound, ('mode', 'Power')),
]