diff --git a/README.md b/README.md index a68029c..ec8bc45 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/custom_components/localthings/const.py b/custom_components/localthings/const.py index 6c0eca7..3e2341b 100644 --- a/custom_components/localthings/const.py +++ b/custom_components/localthings/const.py @@ -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" diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index df1dc02..a887d1b 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -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 diff --git a/custom_components/localthings/registry/by_type/ehs.py b/custom_components/localthings/registry/by_type/ehs.py new file mode 100644 index 0000000..863cb8f --- /dev/null +++ b/custom_components/localthings/registry/by_type/ehs.py @@ -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, + ]), +) diff --git a/custom_components/localthings/registry/capabilities/ehs.py b/custom_components/localthings/registry/capabilities/ehs.py new file mode 100644 index 0000000..bbde863 --- /dev/null +++ b/custom_components/localthings/registry/capabilities/ehs.py @@ -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] diff --git a/custom_components/localthings/registry/entities.py b/custom_components/localthings/registry/entities.py index aed65d1..b086fe1 100644 --- a/custom_components/localthings/registry/entities.py +++ b/custom_components/localthings/registry/entities.py @@ -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', } diff --git a/custom_components/localthings/translations/cs.json b/custom_components/localthings/translations/cs.json index e26e4f7..732049b 100644 --- a/custom_components/localthings/translations/cs.json +++ b/custom_components/localthings/translations/cs.json @@ -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": { diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 50c86a9..9a9ba06 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -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": { diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index 7d2948a..d65dcf5 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -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": { diff --git a/custom_components/localthings/water_heater.py b/custom_components/localthings/water_heater.py new file mode 100644 index 0000000..4896ede --- /dev/null +++ b/custom_components/localthings/water_heater.py @@ -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)) diff --git a/tests/fixtures/ehs_device.json b/tests/fixtures/ehs_device.json new file mode 100644 index 0000000..57167f4 --- /dev/null +++ b/tests/fixtures/ehs_device.json @@ -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" + } + ] + } + ] + } + ] + } + } + ] +} diff --git a/tests/fixtures/golden/ehs.json b/tests/fixtures/golden/ehs.json new file mode 100644 index 0000000..ec3a736 --- /dev/null +++ b/tests/fixtures/golden/ehs.json @@ -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" + ] +} diff --git a/tests/test_by_type.py b/tests/test_by_type.py index 05d24c0..ce29aa4 100644 --- a/tests/test_by_type.py +++ b/tests/test_by_type.py @@ -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 diff --git a/tests/test_ehs_capabilities.py b/tests/test_ehs_capabilities.py new file mode 100644 index 0000000..7afd883 --- /dev/null +++ b/tests/test_ehs_capabilities.py @@ -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'} diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 898c67f..a05a74b 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -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 diff --git a/tests/test_translations.py b/tests/test_translations.py index 94c879a..6ccc147 100644 --- a/tests/test_translations.py +++ b/tests/test_translations.py @@ -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(): diff --git a/tests/test_water_heater_ehs.py b/tests/test_water_heater_ehs.py new file mode 100644 index 0000000..cae7ee5 --- /dev/null +++ b/tests/test_water_heater_ehs.py @@ -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')), + ]