Merge pull request #114 from mbillow/claude/pr91-finish
Add cool-only global RAC support: WindFree, Auto mode, and display light
This commit is contained in:
@@ -26,6 +26,7 @@ from homeassistant.components.climate import (
|
||||
ClimateEntity,
|
||||
ClimateEntityFeature,
|
||||
HVACMode,
|
||||
PRESET_NONE,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import UnitOfTemperature
|
||||
@@ -66,8 +67,22 @@ _SUPPORTED_FIELD = 'x.com.samsung.da.supportedModes'
|
||||
_DEVICE_TO_HVAC: dict[str, HVACMode] = {
|
||||
'Cool': HVACMode.COOL,
|
||||
'Dry': HVACMode.DRY,
|
||||
# Fan-only is spelled 'Wind' on some boards (e.g. TP1X_DA-AC-RAC-01001) and
|
||||
# 'Fan' on others (e.g. TP1X_DA-AC-RAC-01011); both map to FAN_ONLY. The
|
||||
# reverse write can't rely on this map alone (two codes, one HA value) --
|
||||
# _device_code_for_hvac() resolves the code from the unit's own
|
||||
# supportedModes, so this is only a fallback for a unit reporting no
|
||||
# supportedModes at all. 'Fan' is listed first so the {v: k} reverse
|
||||
# comprehension below has 'Wind' win that fallback (last-key-wins),
|
||||
# preserving the original single-spelling behavior rather than silently
|
||||
# flipping it when 'Fan' was added.
|
||||
'Fan': HVACMode.FAN_ONLY,
|
||||
'Wind': HVACMode.FAN_ONLY,
|
||||
'Auto': HVACMode.HEAT_COOL,
|
||||
# The device's 'Auto' is a single-setpoint "device decides" mode -> HA
|
||||
# HVACMode.AUTO (renders "Auto"). Not HEAT_COOL: that renders "Heat/cool"
|
||||
# and implies a two-setpoint heat+cool range these single-setpoint units
|
||||
# (including cool-only models) don't have.
|
||||
'Auto': HVACMode.AUTO,
|
||||
'Heat': HVACMode.HEAT,
|
||||
}
|
||||
_HVAC_TO_DEVICE = {v: k for k, v in _DEVICE_TO_HVAC.items()}
|
||||
@@ -77,11 +92,11 @@ _HVAC_TO_DEVICE = {v: k for k, v in _DEVICE_TO_HVAC.items()}
|
||||
# like Cool/Dry/Heat, it's an AI overlay on top of the device's own 'Auto'
|
||||
# behavior -- confirmed by this unit reporting both 'Auto' and 'AIComfort' as
|
||||
# separate, mutually-exclusive entries in /mode/vs/0's supportedModes. Modeled
|
||||
# the idiomatic HA way instead: hvac_mode reports AUTO (same as plain 'Auto'
|
||||
# would if it were ever mapped there) and a dedicated 'ai_comfort' preset
|
||||
# carries the distinction a bare hvac_mode can't. Not reachable via
|
||||
# async_set_hvac_mode -- entered/left only through the preset, since there's
|
||||
# no dedicated HVACMode value for it to write back to.
|
||||
# the idiomatic HA way instead: hvac_mode reports AUTO (same as the plain
|
||||
# 'Auto' code maps to) and a dedicated 'ai_comfort' preset carries the
|
||||
# distinction a bare hvac_mode can't. Not reachable via async_set_hvac_mode --
|
||||
# entered/left only through the preset, since there's no dedicated HVACMode
|
||||
# value for it to write back to.
|
||||
_AI_COMFORT_MODE = 'AIComfort'
|
||||
PRESET_AI_COMFORT = 'ai_comfort'
|
||||
|
||||
@@ -105,16 +120,18 @@ _DEVICE_TO_SWING: dict[str, str] = {
|
||||
}
|
||||
_SWING_TO_DEVICE = {v: k for k, v in _DEVICE_TO_SWING.items()}
|
||||
|
||||
# Preset (convenient mode): Off/Sleep map onto HA standard presets; Quiet/Smart/
|
||||
# Speed are custom (translated).
|
||||
_DEVICE_TO_PRESET: dict[str, str] = {
|
||||
'Off': 'none',
|
||||
'Sleep': 'sleep',
|
||||
'Quiet': 'quiet',
|
||||
'Smart': 'smart',
|
||||
'Speed': 'speed',
|
||||
}
|
||||
_PRESET_TO_DEVICE = {v: k for k, v in _DEVICE_TO_PRESET.items()}
|
||||
# Preset (convenient mode): resolved dynamically from the device's own
|
||||
# /mode/convenient/vs/0 supportedModes -- no per-model table. The device 'Off'
|
||||
# code maps to HA's PRESET_NONE ("no preset active"); every other code is
|
||||
# exposed as its lowercased self and labelled in translations
|
||||
# (entity.climate.airconditioner.state_attributes.preset_mode.state.<code>),
|
||||
# so any board's convenient modes surface without code changes, and an
|
||||
# unlabelled code just renders as its raw value until a label is added.
|
||||
# (Samsung's WindFree still-air cooling shows up here as the 'Nano'/
|
||||
# 'NanoSleep' codes on cool-only global RAC boards -- that's just a
|
||||
# translation label, not a hard-coded mode.)
|
||||
def _preset_to_ha(code) -> str:
|
||||
return PRESET_NONE if code == 'Off' else str(code).lower()
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -194,11 +211,15 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
return self.coordinator.resource(href) or {}
|
||||
|
||||
def _is_on(self) -> bool:
|
||||
rep = self._rep(POWER_HREF)
|
||||
if 'value' in rep:
|
||||
return bool(rep.get('value'))
|
||||
vs = self._rep(POWER_VS_HREF)
|
||||
return str(vs.get('x.com.samsung.da.power', '')).lower() == 'on'
|
||||
# Prefer the vendor /power/vs/0 (present on every observed board and
|
||||
# the resource writes target -- see airconditioner._climate_write).
|
||||
# The OCF /power/0 is absent on many boards and a stale mirror on
|
||||
# some, so reading it first showed pre-write state after a power
|
||||
# toggle (issue #53: "can turn on but not off").
|
||||
power = self._rep(POWER_VS_HREF).get('x.com.samsung.da.power')
|
||||
if power is not None:
|
||||
return str(power).lower() == 'on'
|
||||
return bool(self._rep(POWER_HREF).get('value'))
|
||||
|
||||
def _supported(self, href: str) -> list[str]:
|
||||
return list(self._rep(href).get(_SUPPORTED_FIELD) or [])
|
||||
@@ -234,6 +255,15 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
|
||||
# -- temperature --------------------------------------------------------
|
||||
|
||||
def _ocf_temp_authoritative(self) -> bool:
|
||||
"""True when the OCF /temperature/{current,desired}/0 pair is the
|
||||
authoritative temperature channel -- signalled by
|
||||
/temperature/current/0 being present. Those boards honour reads/
|
||||
writes on /temperature/desired/0 and ignore the vendor
|
||||
/temperatures/vs/0; boards without the pair (only a desired stub, or
|
||||
nothing) are the reverse. Confirmed on live units of both kinds."""
|
||||
return bool(self._rep(TEMP_CURRENT_HREF))
|
||||
|
||||
def _temps_vs(self) -> dict:
|
||||
"""Vendor `/temperatures/vs/0` items[0] (empty {} when absent)."""
|
||||
return _temps_vs_item(self._rep(TEMPS_VS_HREF))
|
||||
@@ -256,10 +286,14 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
|
||||
@property
|
||||
def target_temperature(self):
|
||||
v = _num(self._rep(TEMP_DESIRED_HREF).get('temperature'))
|
||||
if v is None:
|
||||
v = _num(self._temps_vs().get('x.com.samsung.da.desired'))
|
||||
return v
|
||||
# Read from the same channel writes go to (see async_set_temperature):
|
||||
# OCF /temperature/desired/0 on boards with the full OCF pair, vendor
|
||||
# /temperatures/vs/0 otherwise -- with the other as fallback.
|
||||
ocf = _num(self._rep(TEMP_DESIRED_HREF).get('temperature'))
|
||||
vs = _num(self._temps_vs().get('x.com.samsung.da.desired'))
|
||||
if self._ocf_temp_authoritative():
|
||||
return ocf if ocf is not None else vs
|
||||
return vs if vs is not None else ocf
|
||||
|
||||
def _range(self) -> list | None:
|
||||
r = self._rep(TEMP_DESIRED_HREF).get('range')
|
||||
@@ -336,27 +370,42 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
def preset_mode(self):
|
||||
if _first(self._rep(MODE_HREF).get(_MODES_FIELD)) == _AI_COMFORT_MODE:
|
||||
return PRESET_AI_COMFORT
|
||||
return self._read_mode(CONVENIENT_HREF, _DEVICE_TO_PRESET)
|
||||
code = _first(self._rep(CONVENIENT_HREF).get(_MODES_FIELD))
|
||||
return _preset_to_ha(code) if code is not None else None
|
||||
|
||||
@property
|
||||
def preset_modes(self) -> list[str]:
|
||||
modes = self._read_modes(CONVENIENT_HREF, _DEVICE_TO_PRESET)
|
||||
modes = [_preset_to_ha(c) for c in self._supported(CONVENIENT_HREF)]
|
||||
if _AI_COMFORT_MODE in self._supported(MODE_HREF):
|
||||
modes.append(PRESET_AI_COMFORT)
|
||||
return modes
|
||||
|
||||
# -- writes -------------------------------------------------------------
|
||||
|
||||
def _device_code_for_hvac(self, hvac_mode: HVACMode):
|
||||
"""Device mode code for an HA hvac_mode, chosen from this unit's own
|
||||
supportedModes -- fan-only is 'Wind' on some boards and 'Fan' on
|
||||
others, so the reverse map alone can't pick the code this unit
|
||||
accepts."""
|
||||
for code in self._supported(MODE_HREF):
|
||||
if _DEVICE_TO_HVAC.get(code) == hvac_mode:
|
||||
return code
|
||||
return _HVAC_TO_DEVICE.get(hvac_mode)
|
||||
|
||||
async def async_set_temperature(self, **kwargs) -> None:
|
||||
temp = kwargs.get('temperature')
|
||||
if temp is not None:
|
||||
await self.coordinator.async_send_command(self._bound, ('temperature', temp))
|
||||
if temp is None:
|
||||
return
|
||||
# OCF-pair boards write /temperature/desired/0; vendor boards write
|
||||
# /temperatures/vs/0 (see airconditioner._climate_write).
|
||||
kind = 'temperature_ocf' if self._ocf_temp_authoritative() else 'temperature'
|
||||
await self.coordinator.async_send_command(self._bound, (kind, temp))
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
if hvac_mode == HVACMode.OFF:
|
||||
await self.coordinator.async_send_command(self._bound, ('power', False))
|
||||
return
|
||||
device = _HVAC_TO_DEVICE.get(hvac_mode)
|
||||
device = self._device_code_for_hvac(hvac_mode)
|
||||
if device is None:
|
||||
return
|
||||
if not self._is_on():
|
||||
@@ -388,4 +437,9 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
# /mode/convenient/vs/0 with Quiet/Smart/Speed/Sleep.
|
||||
await self.coordinator.async_send_command(self._bound, ('mode', _AI_COMFORT_MODE))
|
||||
return
|
||||
await self._set_mapped('preset', _PRESET_TO_DEVICE, preset_mode)
|
||||
# Reverse-resolve against the unit's own supportedModes (codes aren't
|
||||
# a fixed transform of the HA value -- e.g. 'NanoSleep' -> 'nanosleep').
|
||||
for code in self._supported(CONVENIENT_HREF):
|
||||
if _preset_to_ha(code) == preset_mode:
|
||||
await self.coordinator.async_send_command(self._bound, ('preset', code))
|
||||
return
|
||||
|
||||
@@ -23,6 +23,7 @@ from smartthings_local.ocf.state_cache import StateCache
|
||||
from .registry.batch import parse_device0_batch
|
||||
from .registry.by_type import for_device, for_device_by_model, for_device_by_resources
|
||||
from .registry.capabilities.common import (
|
||||
merge_items_field,
|
||||
merge_options_field,
|
||||
remote_control_enabled,
|
||||
remote_control_required_for_write,
|
||||
@@ -641,23 +642,35 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
# introduced its own races around overlapping writes to the same
|
||||
# href. Simpler and safer to just hold the guard for the full,
|
||||
# generously-sized window and let it expire on its own.
|
||||
# write_fn bodies that touch x.com.samsung.da.options carry only the
|
||||
# changed token(s) now (issue #54: confirmed sufficient on the wire --
|
||||
# the device merges by prefix itself), not the whole packed array.
|
||||
# observe.apply()'s field-level {**cached, **rep} merge doesn't know
|
||||
# that -- handed the bare token list, it would replace the cached
|
||||
# field outright and wipe every sibling option for the rest of the
|
||||
# settle window. Pre-merge it here the same way the device does, so
|
||||
# the optimistic cache entry stays complete; the minimal `body` below
|
||||
# is still exactly what goes out over the wire.
|
||||
# write_fn bodies that touch x.com.samsung.da.options or
|
||||
# x.com.samsung.da.items carry only the changed token(s)/item now
|
||||
# (issue #54 for options; the AC vendor temperature write for items --
|
||||
# confirmed sufficient on the wire, the device merges the rest itself),
|
||||
# not the whole packed array. observe.apply()'s field-level
|
||||
# {**cached, **rep} merge doesn't know that -- handed the bare
|
||||
# partial value, it would replace the cached field outright and wipe
|
||||
# every sibling option/item for the rest of the settle window.
|
||||
# Pre-merge it here the same way the device does, so the optimistic
|
||||
# cache entry stays complete; the minimal `body` below is still
|
||||
# exactly what goes out over the wire.
|
||||
optimistic_body = body
|
||||
new_options = body.get('x.com.samsung.da.options')
|
||||
if isinstance(new_options, list):
|
||||
cached_options = (self._cache.get(write_href) or {}).get('x.com.samsung.da.options')
|
||||
optimistic_body = {
|
||||
**body,
|
||||
**optimistic_body,
|
||||
'x.com.samsung.da.options': merge_options_field(cached_options, new_options),
|
||||
}
|
||||
# Same fact, items[] shape (e.g. airconditioner._climate_write's vendor
|
||||
# temperature write, which now carries only {id, desired} -- see that
|
||||
# module for the write-side half of this).
|
||||
new_items = body.get('x.com.samsung.da.items')
|
||||
if isinstance(new_items, list):
|
||||
cached_items = (self._cache.get(write_href) or {}).get('x.com.samsung.da.items')
|
||||
optimistic_body = {
|
||||
**optimistic_body,
|
||||
'x.com.samsung.da.items': merge_items_field(cached_items, new_items),
|
||||
}
|
||||
self._observe.apply(write_href, optimistic_body, source='optimistic')
|
||||
self._observe.mark_write_pending(
|
||||
write_href, settle_s=self._POST_TIMEOUT_S + self._POLL_TIMEOUT_S
|
||||
|
||||
@@ -160,10 +160,18 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi
|
||||
# a modelNum carrying the '_PRAC_' (Package Room Air Conditioner) token.
|
||||
if key is None and '_PRAC_' in (model_num or ''):
|
||||
key = 'airconditioner'
|
||||
# Older/simpler RAC boards (e.g. TP2X_RAC_20K, issue #37) use the plain
|
||||
# '_RAC_' token instead -- distinct from '_PRAC_' above (no overlap: the
|
||||
# 'P' sits between the underscore and 'RAC' in that token).
|
||||
if key is None and '_RAC_' in (model_num or ''):
|
||||
# Other RAC boards carry a bare 'RAC' (Room Air Conditioner) token in the
|
||||
# modelNum, in one of two spellings: the underscore form '_RAC_' (e.g.
|
||||
# TP2X_RAC_20K, issue #37) or the hyphenated form '-RAC-' (e.g.
|
||||
# TP1X_DA-AC-RAC-01001, a cool-only global variant, issue #91). Both are
|
||||
# distinct from '_PRAC_' above ('P' sits before 'RAC' with no delimiter)
|
||||
# and from range ('-RANGE-') / oven ('-OVEN-') tokens. Most TP1X boards
|
||||
# self-report oneUiVersion and resolve via for_device() upstream of this
|
||||
# fallback; the hyphenated match is what rescues variants whose
|
||||
# /otninformation/vs/0 ships no swVersionInfo block at all, so
|
||||
# oneUiVersion is empty.
|
||||
if key is None and ('_RAC_' in (model_num or '')
|
||||
or '-RAC-' in (model_num or '').upper()):
|
||||
key = 'airconditioner'
|
||||
# System air conditioners (multi-indoor-unit commercial installs, e.g.
|
||||
# A-CAWW-TP2-20-COMMON, issue #52) report no oneUiVersion either and
|
||||
|
||||
@@ -15,6 +15,13 @@ issue #56's follow-up (five diagnostics dumps captured with the physical unit
|
||||
set to Auto/Sleep/Low/Medium/High):
|
||||
Light_On / Light_Off -- a plain on/off flag; MODE below models it as a
|
||||
real switch, RMW-replacing just that one entry.
|
||||
NOT the same polarity as the AC family's own
|
||||
Light_On/Light_Off token on its own /mode/vs/0
|
||||
(airconditioner._display_light_on) -- that one is
|
||||
confirmed inverted (Light_Off means the panel is
|
||||
lit) on live hardware. Same token name, same
|
||||
resource name, different device type and
|
||||
opposite meaning -- don't unify them.
|
||||
Comode_Off -- read 'Off' on *every* one of the five dumps,
|
||||
including High/Low/Medium/Auto -- confirms this
|
||||
is NOT the fan-speed selector (ruling out the
|
||||
|
||||
@@ -17,6 +17,7 @@ by_type registry.
|
||||
from ..capability import Capability
|
||||
from ..entities import BinarySensorDesc, ClimateDesc, SensorDesc, SwitchDesc
|
||||
from .common import normalize_temp_unit
|
||||
from .laundry import option_write
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Canonical AC resource hrefs. The climate entity (climate.py) binds the
|
||||
@@ -91,21 +92,87 @@ def _first_mode(rep):
|
||||
return modes
|
||||
|
||||
|
||||
def _mode_options(rep):
|
||||
opts = rep.get('x.com.samsung.da.options')
|
||||
return opts if isinstance(opts, (list, tuple)) else ()
|
||||
|
||||
|
||||
def _has_display_light_option(rep, resources):
|
||||
"""True when the panel light state is carried inside /mode/vs/0's options
|
||||
blob (a `Light_On`/`Light_Off` token) rather than a dedicated /light/vs/0
|
||||
switch. The two encodings are mutually exclusive across observed boards:
|
||||
models exposing the /light/vs/0 switch (bound by DISPLAY_LIGHT below)
|
||||
carry no Light_* option, so this entity only materialises on the boards
|
||||
that would otherwise have no display-light entity at all."""
|
||||
return any(isinstance(o, str) and o.startswith('Light_')
|
||||
for o in _mode_options(rep))
|
||||
|
||||
|
||||
def _display_light_on(rep):
|
||||
"""Panel display light state from /mode/vs/0's options blob. The token is
|
||||
INVERTED relative to its name -- confirmed by a live toggle test: with the
|
||||
panel lit the option reads `Light_Off`, and with it dark it reads
|
||||
`Light_On` (the flag really encodes "night/display-off mode active"). So
|
||||
`Light_Off` -> light on, `Light_On` -> light off. Read-only from here; the
|
||||
write below uses the device's own single-token merge (see option_write)."""
|
||||
for o in _mode_options(rep):
|
||||
if isinstance(o, str) and o.startswith('Light_'):
|
||||
return o == 'Light_Off'
|
||||
return None
|
||||
|
||||
|
||||
def _display_light_write(payload, rep, href=None):
|
||||
"""Toggle the panel light via a single-token /mode/vs/0 options write
|
||||
('SingleCommand_1' is advertised in this family's /configuration/vs/0
|
||||
airconOptionList; option_write's one-token merge is the same mechanism
|
||||
air_purifier.py uses for its own Light switch). Polarity is inverted (see
|
||||
_display_light_on): switching the lamp ON writes 'Light_Off', OFF writes
|
||||
'Light_On'."""
|
||||
token = 'Off' if payload == 'On' else 'On'
|
||||
return (['mode', 'vs', '0'],
|
||||
{'x.com.samsung.da.options': option_write('Light', token)})
|
||||
|
||||
|
||||
def _climate_write(payload, rep, href=None):
|
||||
"""Map a (kind, value) command from the climate platform to the
|
||||
(path_segs, body) for that one sub-write. `value` is already the raw device
|
||||
code (the platform maps HA<->device). async_send_command POSTs to path_segs,
|
||||
so a single desc drives writes across power/mode/temperature/wind resources.
|
||||
Read-modify-write safe: each write sends only its own field, leaving the
|
||||
resource's other fields (e.g. /mode/vs/0's opaque `options` blob) untouched.
|
||||
so a single desc drives writes across the power/mode/temperature/wind
|
||||
resources.
|
||||
|
||||
Power goes to the vendor `/power/vs/0` (the OCF `/power/0` is absent on
|
||||
most boards and a non-authoritative mirror where present -- vendor works
|
||||
on every board). Temperature is board-dependent, so the platform picks the
|
||||
channel and sends `temperature_ocf` (-> OCF `/temperature/desired/0`,
|
||||
boards with the full OCF current+desired pair) or `temperature` (-> vendor
|
||||
`/temperatures/vs/0`, boards without it). Mode/fan/swing/preset are always
|
||||
the vendor `/x/vs/0` resources.
|
||||
|
||||
Each write sends only its own field(s), leaving the resource's other
|
||||
fields (e.g. /mode/vs/0's opaque `options` blob, or the vendor temperature
|
||||
item's current/minimum/maximum/unit) untouched -- the device merges the
|
||||
rest itself, same contract as the options[] array (see
|
||||
common.merge_items_field / merge_options_field, which keep the
|
||||
coordinator's optimistic cache complete for the settle window instead of
|
||||
this write echoing those fields back).
|
||||
"""
|
||||
kind, value = payload
|
||||
if kind == 'power':
|
||||
return (['power', '0'], {'value': bool(value)})
|
||||
return (['power', 'vs', '0'],
|
||||
{'x.com.samsung.da.power': 'On' if value else 'Off'})
|
||||
if kind == 'mode':
|
||||
return (['mode', 'vs', '0'], {'x.com.samsung.da.modes': [value]})
|
||||
if kind == 'temperature_ocf':
|
||||
return (['temperature', 'desired', '0'],
|
||||
{'temperature': int(round(float(value)))})
|
||||
if kind == 'temperature':
|
||||
return (['temperature', 'desired', '0'], {'temperature': int(round(float(value)))})
|
||||
# Vendor items[] array; only one item observed on every AC dump, id
|
||||
# '0'. See the docstring above for why this doesn't echo current/
|
||||
# minimum/maximum/unit back at the unit.
|
||||
return (['temperatures', 'vs', '0'],
|
||||
{'x.com.samsung.da.items': [
|
||||
{'x.com.samsung.da.id': '0',
|
||||
'x.com.samsung.da.desired': str(int(round(float(value))))}]})
|
||||
if kind == 'fan':
|
||||
return (['wind', 'strength', 'vs', '0'], {'x.com.samsung.da.modes': value})
|
||||
if kind == 'swing':
|
||||
@@ -121,6 +188,19 @@ CLIMATE = Capability(
|
||||
entities=(
|
||||
ClimateDesc(key='climate', translation_key='airconditioner',
|
||||
rep_fn=_first_mode, write_fn=_climate_write),
|
||||
# Display (panel) light switch, only on boards that encode it in
|
||||
# /mode/vs/0's options instead of a /light/vs/0 switch (see
|
||||
# _has_display_light_option). Shares the /mode/vs/0 href with the
|
||||
# climate entity above -- same Capability, so no multi-cap
|
||||
# discriminator is needed. /mode/vs/0 is OBSERVE-subscribed, so state
|
||||
# updates on push; writes go through the single-token merge in
|
||||
# _display_light_write. Shares the switch.display_light translation
|
||||
# with DISPLAY_LIGHT (the /light/vs/0 switch on other boards) --
|
||||
# mutually exclusive per href, so only one ever binds for a given unit.
|
||||
SwitchDesc(key='display_light', rep_fn=_display_light_on,
|
||||
exists_fn=_has_display_light_option,
|
||||
write_fn=_display_light_write,
|
||||
icon='mdi:led-on', entity_category='config'),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -304,7 +384,14 @@ _AC_IGNORED = [
|
||||
'/mds/absencemonitoring/vs/0', # motion-detection sensor plumbing (empty here)
|
||||
'/mds/absencestate/vs/0', # motion-detection state (empty here)
|
||||
'/remotedatacontrol/vs/0', # remote data-control session status
|
||||
'/remotedeviceinfo/vs/0', # remote paired-device id list (empty didList here)
|
||||
'/remotetemperature/vs/0', # external temp-sensor feed (unset on this unit)
|
||||
# Manual airflow-step position (supportedModes Off/80/60/40/Power).
|
||||
# Overlaps the /wind/direction swing control already on the climate card,
|
||||
# and the meaning of the numeric steps vs. 'Power' isn't documented in the
|
||||
# dump -- ignored per the 'don't guess' rule rather than modeled as a
|
||||
# select whose write could confuse live HVAC hardware.
|
||||
'/stepcontrol/vs/0',
|
||||
'/reserverulesets/vs/0', # opaque hex-encoded schedule reservation blob
|
||||
'/welcome/temperature/vs/0', # welcome-cooling plumbing
|
||||
# System-AC-only (multi-indoor-unit commercial installs, e.g.
|
||||
|
||||
@@ -104,6 +104,34 @@ def merge_options_field(cached, new_tokens):
|
||||
return merged
|
||||
|
||||
|
||||
def merge_items_field(cached, new_items):
|
||||
"""Merge a partial x.com.samsung.da.items[]-style write (matched by
|
||||
x.com.samsung.da.id) into a cached items array -- the read-side
|
||||
counterpart of merge_options_field above, for the items[] shape instead
|
||||
of the packed options[] shape.
|
||||
|
||||
Confirmed on hardware that a write only needs to carry the array item
|
||||
with the changed id plus the field(s) being changed; the device merges
|
||||
the rest itself (same fact as the options[] case, different array --
|
||||
see airconditioner._climate_write's vendor temperature write). Fields
|
||||
within the matched item are merged, not replaced outright, so a
|
||||
setpoint-only write doesn't wipe current/minimum/maximum/unit from the
|
||||
optimistic cache entry for the settle window. An id with no match in
|
||||
`cached` is appended."""
|
||||
merged = [dict(i) if isinstance(i, dict) else i for i in (cached or [])]
|
||||
for new_item in new_items or ():
|
||||
if not isinstance(new_item, dict):
|
||||
continue
|
||||
item_id = new_item.get('x.com.samsung.da.id')
|
||||
for i, existing in enumerate(merged):
|
||||
if isinstance(existing, dict) and existing.get('x.com.samsung.da.id') == item_id:
|
||||
merged[i] = {**existing, **new_item}
|
||||
break
|
||||
else:
|
||||
merged.append(new_item)
|
||||
return merged
|
||||
|
||||
|
||||
# /wm/setinfo/vs/0 -- laundry-family firmware capability flags. Present on
|
||||
# washers, dryers, and dishwashers; absent on fridge/oven/AC. Static for the
|
||||
# life of a given board, so reading them from the /device/0 seed (no dedicated
|
||||
|
||||
@@ -116,7 +116,13 @@
|
||||
"ai_comfort": "AI Comfort",
|
||||
"quiet": "Quiet",
|
||||
"smart": "Smart",
|
||||
"speed": "Speed"
|
||||
"speed": "Speed",
|
||||
"nano": "WindFree",
|
||||
"nanosleep": "WindFree sleep",
|
||||
"longwind": "Long wind",
|
||||
"motionindirect": "Motion indirect",
|
||||
"motiondirect": "Motion direct",
|
||||
"drycomfort": "Dry comfort"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,7 +116,13 @@
|
||||
"ai_comfort": "AI-comfort",
|
||||
"quiet": "Stil",
|
||||
"smart": "Slim",
|
||||
"speed": "Snel"
|
||||
"speed": "Snel",
|
||||
"nano": "WindFree",
|
||||
"nanosleep": "WindFree-slaap",
|
||||
"longwind": "Lange wind",
|
||||
"motionindirect": "Beweging indirect",
|
||||
"motiondirect": "Beweging direct",
|
||||
"drycomfort": "Droog comfort"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,515 @@
|
||||
{
|
||||
"meta": {
|
||||
"model": "TP1X_DA-AC-RAC-01001_0000",
|
||||
"device_type": "airconditioner",
|
||||
"source": "user diagnostics (scrubbed)",
|
||||
"note": "Cool-only global TP1X RAC variant (airconOptionList AI_RAC_GLOBAL_COOLONLY_3.0). Unlike the other TP1X_DA-AC-RAC dumps, its /otninformation/vs/0 ships no swVersionInfo block, so oneUiVersion is empty and detection must fall back to the hyphenated '-RAC-' modelNum token in for_device_by_model. Adds two hrefs absent from the other AC dumps -- /stepcontrol/vs/0 (manual airflow step) and /remotedeviceinfo/vs/0 (empty paired-device list), both ignored -- and exposes Samsung WindFree via the convenient-mode codes Nano/NanoSleep (in place of the Smart code on other boards; confirmed by an off-vs-on dump diff)."
|
||||
},
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/personality/presence/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "",
|
||||
"x.com.samsung.da.deviceId": "**REDACTED**",
|
||||
"x.com.samsung.da.value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/realtimenotiforclient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.timeforshortnoti": "0",
|
||||
"x.com.samsung.da.longnotisubscription": "false",
|
||||
"x.com.samsung.da.periodicnotisubscription": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/filter/airdustfilter/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.filterUsage": "100",
|
||||
"x.com.samsung.da.filterUsageResolution": "1",
|
||||
"x.com.samsung.da.filterDesiredUsage": "500",
|
||||
"x.com.samsung.da.filterStatus": "wash",
|
||||
"x.com.samsung.da.filterCapacity": "500",
|
||||
"x.com.samsung.da.filterCapacityUnit": "Hour",
|
||||
"x.com.samsung.da.filterResetType": [
|
||||
"replaceable",
|
||||
"washable"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/control/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.increment": "1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/convenient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "Off",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Off",
|
||||
"Sleep",
|
||||
"Quiet",
|
||||
"Speed",
|
||||
"Nano",
|
||||
"NanoSleep"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/autoclean/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.status": "Stop",
|
||||
"x.com.samsung.da.settingStatus": "Off",
|
||||
"x.com.samsung.da.progress": "0",
|
||||
"x.com.samsung.da.supportedStatus": [
|
||||
"Start",
|
||||
"Stop"
|
||||
],
|
||||
"x.com.samsung.da.supportedSettingStatus": [
|
||||
"On",
|
||||
"Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wind/strength/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "0",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"0",
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4"
|
||||
],
|
||||
"x.com.samsung.da.modesName": [
|
||||
"Auto",
|
||||
"Low",
|
||||
"Mid",
|
||||
"High",
|
||||
"Turbo"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wind/direction/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "Fix",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Fix",
|
||||
"Up_And_Low",
|
||||
"Left_And_Right",
|
||||
"All"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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-07-26T15:24:44",
|
||||
"x.com.samsung.da.state": "Deleted"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Alarm",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "FilterAlarm",
|
||||
"x.com.samsung.da.triggeredTime": "2026-07-26T15:24:44",
|
||||
"x.com.samsung.da.state": "Created"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperatures/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Temperature",
|
||||
"x.com.samsung.da.desired": "25.0",
|
||||
"x.com.samsung.da.current": "20.0",
|
||||
"x.com.samsung.da.maximum": "30",
|
||||
"x.com.samsung.da.minimum": "16",
|
||||
"x.com.samsung.da.increment": "1.0",
|
||||
"x.com.samsung.da.unit": "Celsius"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/current/0",
|
||||
"rep": {
|
||||
"range": [
|
||||
16,
|
||||
30
|
||||
],
|
||||
"units": "C",
|
||||
"temperature": 20.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/desired/0",
|
||||
"rep": {
|
||||
"range": [
|
||||
16,
|
||||
30
|
||||
],
|
||||
"units": "C",
|
||||
"temperature": 25.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.instantaneousPower": "0.000000",
|
||||
"x.com.samsung.da.cumulativePower": "445938",
|
||||
"x.com.samsung.da.cumulativeSavedPower": "81528",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/0",
|
||||
"rep": {
|
||||
"power": 0.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Auto",
|
||||
"Cool",
|
||||
"Dry",
|
||||
"Wind"
|
||||
],
|
||||
"x.com.samsung.da.modes": [
|
||||
"Auto"
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"Sleep_0",
|
||||
"ArtificialWorking_Off",
|
||||
"ComfortAICooling_Off",
|
||||
"AiTempChanged_Off",
|
||||
"AiTemp_240",
|
||||
"OutdoorTemp_84",
|
||||
"CoolCapa_35",
|
||||
"WarmCapa_0",
|
||||
"Light_Off",
|
||||
"Volume_Mute",
|
||||
"StopAutoClean_Idle",
|
||||
"Autoclean_Off",
|
||||
"DiagnosisAI_Off",
|
||||
"ProgressDiagnosisAI_0",
|
||||
"ResultDiagnosisAI_Normal",
|
||||
"OptionCode_52344",
|
||||
"ExtendOptionCode_230029",
|
||||
"RacInfo_None",
|
||||
"UpdateAllow_NotAllowed",
|
||||
"DurationOn_0",
|
||||
"WelcomeCoolingState_Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "Off",
|
||||
"causeSource": "DEFT",
|
||||
"operationNumber": "0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/0",
|
||||
"rep": {
|
||||
"value": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/sensors/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "TP1X_DA-AC-RAC-01001_0000|10247441|60010523001811014E00002200D00000",
|
||||
"x.com.samsung.da.description": "TP1X_DA-AC-RAC-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": "AR2",
|
||||
"x.com.samsung.da.diagMinVersion": "3.0",
|
||||
"x.com.samsung.da.diagTsId": "DA01",
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Software",
|
||||
"x.com.samsung.da.number": "02646A260327",
|
||||
"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": "102474A23112200",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "102702A24041900,102579A10000200",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "-03:00",
|
||||
"x.com.samsung.supprtedtype": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "0000000000",
|
||||
"x.com.samsung.da.airconOptionList": [
|
||||
"SingleCommand_1",
|
||||
"DR",
|
||||
"HOMECARE_WIZARD_V2",
|
||||
"PRODUCT_GLOBAL",
|
||||
"AI_RAC_GLOBAL_COOLONLY_3.0",
|
||||
"AI_3.0",
|
||||
"Auto_To_AI"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/0",
|
||||
"rep": {
|
||||
"humidity": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.humidity": "0",
|
||||
"x.com.samsung.da.fivepercentHumidity": "66"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/drlc/0",
|
||||
"rep": {
|
||||
"DRLevel": 1,
|
||||
"start": "2026-07-26T12:01:41Z",
|
||||
"duration": 23,
|
||||
"override": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/drlc/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.drlcLevel": "1",
|
||||
"x.com.samsung.da.duration": "23:59:00",
|
||||
"x.com.samsung.da.drlcStartTime": "2026-07-26T12:01:41Z",
|
||||
"x.com.samsung.da.override": "Off",
|
||||
"x.com.samsung.da.realSaving": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/availablecontrolsets/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.sets": "000000A0012C0161024904000000",
|
||||
"x.com.samsung.da.id": "RAC",
|
||||
"x.com.samsung.da.version": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/stepcontrol/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "Off",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Off",
|
||||
"80",
|
||||
"60",
|
||||
"40",
|
||||
"Power"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/keepnormalstate/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.keepnormal": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/remotedatacontrol/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.status": "Off",
|
||||
"x.com.samsung.da.connectionStatus": "Disconnected"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/remotetemperature/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.temperature": "",
|
||||
"x.com.samsung.da.unit": "",
|
||||
"x.com.samsung.da.error": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/remotedeviceinfo/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.didList": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "ARA-WW-TP1-24-ARXX00",
|
||||
"versions": [
|
||||
"11260327"
|
||||
],
|
||||
"visVersion": "260327"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "045210247441FFFFFFFF",
|
||||
"versions": [
|
||||
"23112200",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "231122"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "04521027024110257941",
|
||||
"versions": [
|
||||
"24041900",
|
||||
"10000200"
|
||||
],
|
||||
"visVersion": "240419"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "/timezone/vs/0",
|
||||
"rep": {
|
||||
"timezoneid": "America/Sao_Paulo",
|
||||
"offset": "-03:00",
|
||||
"DST": "OFF"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/muteonce/vs/0",
|
||||
"rep": {
|
||||
"muteonce": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/aisleep/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.displayNightMode": "Off",
|
||||
"x.com.samsung.da.elapsedTime": "0",
|
||||
"x.com.samsung.da.requestFeedback": "Off",
|
||||
"x.com.samsung.da.resultFeedback": "0",
|
||||
"x.com.samsung.da.statusFeedback": "Idle",
|
||||
"x.com.samsung.da.sleepTime": "14002200"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wirelessinfo/vs/0",
|
||||
"rep": {
|
||||
"macaddressWiFi": "**REDACTED**",
|
||||
"macaddressBLE": "**REDACTED**",
|
||||
"connectedApSsid": "**REDACTED**"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/quickcontrol/info/vs/0",
|
||||
"rep": {
|
||||
"supportedVersion": "1.0"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+1
@@ -8,6 +8,7 @@
|
||||
"climate",
|
||||
"current_temperature_c",
|
||||
"diagnosis_status",
|
||||
"display_light",
|
||||
"energy_kwh",
|
||||
"energy_saved_kwh",
|
||||
"humidity",
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"climate",
|
||||
"current_temperature_c",
|
||||
"diagnosis_status",
|
||||
"display_light",
|
||||
"energy_kwh",
|
||||
"energy_saved_kwh",
|
||||
"firmware_update",
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"air_filter_status",
|
||||
"air_filter_usage",
|
||||
"alarm_code",
|
||||
"auto_clean",
|
||||
"climate",
|
||||
"current_temperature_c",
|
||||
"display_light",
|
||||
"energy_kwh",
|
||||
"energy_saved_kwh",
|
||||
"firmware_update",
|
||||
"humidity",
|
||||
"mute_once",
|
||||
"power_watts"
|
||||
]
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
"auto_clean",
|
||||
"climate",
|
||||
"current_temperature_c",
|
||||
"display_light",
|
||||
"energy_kwh",
|
||||
"firmware_update",
|
||||
"humidity",
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"climate",
|
||||
"current_temperature_c",
|
||||
"diagnosis_status",
|
||||
"display_light",
|
||||
"energy_kwh",
|
||||
"humidity",
|
||||
"power_watts"
|
||||
|
||||
@@ -746,10 +746,10 @@ async def test_climate_power_write_applies_to_its_own_href_not_bound_href(
|
||||
"""Regression for issues #17/#53, using the real AC capability. The
|
||||
composite climate entity binds /mode/vs/0 (airconditioner.CLIMATE.href),
|
||||
but a power command's write_fn (airconditioner._climate_write) targets
|
||||
the sibling /power/0 -- the href climate.py's `_is_on()` actually reads.
|
||||
the sibling /power/vs/0 -- the href climate.py's `_is_on()` actually reads.
|
||||
The optimistic value and settle guard must land there, not on the bound
|
||||
/mode/vs/0, or the entity never sees the write and shows stale state
|
||||
until the next unrelated read of /power/0."""
|
||||
until the next unrelated read of /power/vs/0."""
|
||||
from custom_components.localthings.registry.capabilities import airconditioner
|
||||
from custom_components.localthings.registry.discovery import BoundEntity
|
||||
|
||||
@@ -768,9 +768,11 @@ async def test_climate_power_write_applies_to_its_own_href_not_bound_href(
|
||||
fake.post = lambda *a, **k: (0x44, b'')
|
||||
await coordinator.async_send_command(bound, ('power', True))
|
||||
|
||||
assert coordinator._cache.get('/power/0') == {'value': True}
|
||||
assert coordinator._cache.get(airconditioner.CLIMATE.href) != {'value': True}
|
||||
assert coordinator._observe._settle_until.get('/power/0') is not None
|
||||
assert (coordinator._cache.get('/power/vs/0') or {}).get(
|
||||
'x.com.samsung.da.power') == 'On'
|
||||
assert 'x.com.samsung.da.power' not in (
|
||||
coordinator._cache.get(airconditioner.CLIMATE.href) or {})
|
||||
assert coordinator._observe._settle_until.get('/power/vs/0') is not None
|
||||
|
||||
|
||||
async def test_send_command_survives_stale_confirm_poll(
|
||||
|
||||
@@ -93,15 +93,30 @@ def test_air_filter_usage_is_percentage_of_capacity():
|
||||
|
||||
|
||||
def test_climate_write_targets():
|
||||
"""The CLIMATE write_fn maps each (kind, value) command to the right OCF
|
||||
POST target and body. `value` is already the raw device code."""
|
||||
"""The CLIMATE write_fn maps each (kind, value) command to the right vendor
|
||||
POST target and body. `value` is already the raw device code. Power and
|
||||
temperature target the vendor /power/vs/0 and /temperatures/vs/0 (the OCF
|
||||
/power/0 is absent on most boards and a non-authoritative mirror where
|
||||
present; /temperature/desired/0 is only written via the temperature_ocf
|
||||
kind, on boards that have the OCF pair)."""
|
||||
write = airconditioner.CLIMATE.entities[0].write_fn
|
||||
assert write(('power', True), {}) == (['power', '0'], {'value': True})
|
||||
assert write(('power', False), {}) == (['power', '0'], {'value': False})
|
||||
assert write(('power', True), {}) == (
|
||||
['power', 'vs', '0'], {'x.com.samsung.da.power': 'On'})
|
||||
assert write(('power', False), {}) == (
|
||||
['power', 'vs', '0'], {'x.com.samsung.da.power': 'Off'})
|
||||
assert write(('mode', 'Heat'), {}) == (
|
||||
['mode', 'vs', '0'], {'x.com.samsung.da.modes': ['Heat']})
|
||||
assert write(('temperature', 23.6), {}) == (
|
||||
# OCF-pair boards: temperature_ocf -> /temperature/desired/0.
|
||||
assert write(('temperature_ocf', 23.6), {}) == (
|
||||
['temperature', 'desired', '0'], {'temperature': 24})
|
||||
# Vendor boards: temperature -> /temperatures/vs/0, carrying only the id
|
||||
# and the changed field -- the device merges current/min/max/unit itself
|
||||
# (see common.merge_items_field, wired into async_send_command, for the
|
||||
# read-side half that keeps the optimistic cache complete).
|
||||
assert write(('temperature', 22), {}) == (
|
||||
['temperatures', 'vs', '0'],
|
||||
{'x.com.samsung.da.items': [
|
||||
{'x.com.samsung.da.id': '0', 'x.com.samsung.da.desired': '22'}]})
|
||||
assert write(('fan', '2'), {}) == (
|
||||
['wind', 'strength', 'vs', '0'], {'x.com.samsung.da.modes': '2'})
|
||||
assert write(('swing', 'All'), {}) == (
|
||||
@@ -261,6 +276,90 @@ def test_current_limit_is_read_only():
|
||||
assert getattr(desc, 'write_fn', None) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TP1X_DA-AC-RAC-01001 cool-only global variant (issue #91). Same modelNum as
|
||||
# the issue #38 board above, but its /otninformation/vs/0 ships no
|
||||
# swVersionInfo block, so oneUiVersion is empty and detection must fall back
|
||||
# to the hyphenated '-RAC-' modelNum token (the older '_RAC_' underscore match
|
||||
# doesn't fire on this DA-AC-RAC spelling). Adds /stepcontrol/vs/0 and
|
||||
# /remotedeviceinfo/vs/0 (both ignored) and exposes the WindFree preset via
|
||||
# the Nano/NanoSleep convenient-mode codes. Its panel light is carried inside
|
||||
# /mode/vs/0's options blob instead of a dedicated /light/vs/0 switch.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_tp1x_rac_coolonly_resolves_via_hyphenated_model_fallback():
|
||||
"""Empty oneUiVersion -> resolved by the '-RAC-' modelNum token, not
|
||||
for_device(). Guards the regression where this unit loaded as 'unknown'."""
|
||||
resources = _load_device('airconditioner_tp1x_rac_coolonly')
|
||||
otn = resources.get('/otninformation/vs/0', {})
|
||||
assert otn.get('swVersionInfo', {}).get('oneUiVersion', '') == ''
|
||||
reg, _ = _resolve('airconditioner_tp1x_rac_coolonly')
|
||||
assert reg is not None and reg.name == 'airconditioner'
|
||||
|
||||
|
||||
def test_tp1x_rac_coolonly_no_unbound_hrefs():
|
||||
"""Every resource binds or is ignored -- including the two hrefs unique
|
||||
to this dump (/stepcontrol/vs/0, /remotedeviceinfo/vs/0). Clears the gap
|
||||
repair."""
|
||||
reg, resources = _resolve('airconditioner_tp1x_rac_coolonly')
|
||||
unbound = []
|
||||
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
||||
assert unbound == []
|
||||
|
||||
|
||||
def test_tp1x_rac_coolonly_stray_hrefs_ignored():
|
||||
ignored_hrefs = {cap.href for cap in airconditioner.COVERAGE}
|
||||
assert '/stepcontrol/vs/0' in ignored_hrefs
|
||||
assert '/remotedeviceinfo/vs/0' in ignored_hrefs
|
||||
|
||||
|
||||
def test_tp1x_rac_coolonly_climate_bound():
|
||||
reg, resources = _resolve('airconditioner_tp1x_rac_coolonly')
|
||||
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
|
||||
climate = [b for b in bound if isinstance(b.desc, ClimateDesc)]
|
||||
assert len(climate) == 1 and climate[0].href == '/mode/vs/0'
|
||||
|
||||
|
||||
def test_tp1x_rac_coolonly_display_light_from_mode_options():
|
||||
"""This board has no /light/vs/0 switch; the panel light lives in
|
||||
/mode/vs/0's options and surfaces as a display_light switch. The token is
|
||||
inverted vs its name (confirmed by a live toggle test): with the panel
|
||||
lit the option reads `Light_Off`, and with it dark it reads `Light_On`."""
|
||||
reg, resources = _resolve('airconditioner_tp1x_rac_coolonly')
|
||||
state = flatten(discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
|
||||
assert state.get('display_light') is True
|
||||
|
||||
|
||||
def test_display_light_option_parsing_and_gating():
|
||||
# Inverted token: Light_Off -> panel lit (on), Light_On -> panel dark (off).
|
||||
lit = {'x.com.samsung.da.options': ['CoolCapa_35', 'Light_Off', 'Volume_Mute']}
|
||||
dark = {'x.com.samsung.da.options': ['Light_On']}
|
||||
absent = {'x.com.samsung.da.options': ['Volume_Mute']}
|
||||
assert airconditioner._display_light_on(lit) is True
|
||||
assert airconditioner._display_light_on(dark) is False
|
||||
assert airconditioner._display_light_on(absent) is None
|
||||
assert airconditioner._has_display_light_option(lit, {}) is True
|
||||
assert airconditioner._has_display_light_option(absent, {}) is False
|
||||
|
||||
|
||||
def test_mode_options_display_light_write_is_inverted_single_token():
|
||||
"""Turning the lamp ON writes the inverted 'Light_Off' token as a
|
||||
single-element options list (single-token merge); OFF writes 'Light_On'."""
|
||||
sw = next(e for e in airconditioner.CLIMATE.entities if e.key == 'display_light')
|
||||
assert sw.write_fn('On', {}) == (
|
||||
['mode', 'vs', '0'], {'x.com.samsung.da.options': ['Light_Off']})
|
||||
assert sw.write_fn('Off', {}) == (
|
||||
['mode', 'vs', '0'], {'x.com.samsung.da.options': ['Light_On']})
|
||||
|
||||
|
||||
def test_light_switch_board_gates_out_mode_options_light():
|
||||
"""Boards with a real /light/vs/0 switch carry no Light_* option, so the
|
||||
mode-options display-light entity doesn't double up (mutually exclusive
|
||||
encodings)."""
|
||||
reg, resources = _resolve('airconditioner_tp1x_rac')
|
||||
assert airconditioner._has_display_light_option(resources['/mode/vs/0'], resources) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WindFree unit (issue #75): same ARTIK051_PRAC_20K modelNum family as the
|
||||
# original issue #17 fixture, but its /mode/convenient/vs/0 additionally
|
||||
|
||||
@@ -8,13 +8,17 @@ from homeassistant.components.climate import HVACMode
|
||||
|
||||
from custom_components.localthings.climate import (
|
||||
_AI_COMFORT_MODE, _DEVICE_TO_HVAC, _HVAC_TO_DEVICE, PRESET_AI_COMFORT,
|
||||
_preset_to_ha,
|
||||
)
|
||||
|
||||
|
||||
def test_auto_still_maps_to_heat_cool():
|
||||
"""'Auto' is unchanged -- AIComfort is handled separately, not folded
|
||||
into this map."""
|
||||
assert _DEVICE_TO_HVAC['Auto'] == HVACMode.HEAT_COOL
|
||||
def test_auto_maps_to_hvac_auto():
|
||||
"""The device's 'Auto' is a single-setpoint "device decides" mode -> HA
|
||||
HVACMode.AUTO, not HEAT_COOL (issue #91 review): HEAT_COOL implies a
|
||||
two-setpoint heat+cool range these single-setpoint units (including
|
||||
cool-only models) don't have. AIComfort is handled separately, not
|
||||
folded into this map."""
|
||||
assert _DEVICE_TO_HVAC['Auto'] == HVACMode.AUTO
|
||||
|
||||
|
||||
def test_aicomfort_not_in_flat_hvac_map():
|
||||
@@ -24,10 +28,13 @@ def test_aicomfort_not_in_flat_hvac_map():
|
||||
assert _AI_COMFORT_MODE not in _DEVICE_TO_HVAC
|
||||
|
||||
|
||||
def test_hvac_auto_not_writable_via_hvac_mode():
|
||||
"""HVACMode.AUTO has no _DEVICE_TO_HVAC entry, so it's unreachable via
|
||||
async_set_hvac_mode -- entered/left only through the ai_comfort preset."""
|
||||
assert HVACMode.AUTO not in _HVAC_TO_DEVICE
|
||||
def test_hvac_auto_writes_back_to_plain_auto_not_aicomfort():
|
||||
"""HVACMode.AUTO is reachable via async_set_hvac_mode -- it writes the
|
||||
device's plain 'Auto' code. AIComfort stays reachable only through the
|
||||
ai_comfort preset, since it isn't a flat _DEVICE_TO_HVAC entry (see
|
||||
test_aicomfort_not_in_flat_hvac_map) and so can never win the reverse
|
||||
{v: k} dict even though both map to HVACMode.AUTO conceptually."""
|
||||
assert _HVAC_TO_DEVICE[HVACMode.AUTO] == 'Auto'
|
||||
|
||||
|
||||
def test_fan_only_still_reachable_via_wind():
|
||||
@@ -36,5 +43,34 @@ def test_fan_only_still_reachable_via_wind():
|
||||
assert _DEVICE_TO_HVAC['Wind'] == HVACMode.FAN_ONLY
|
||||
|
||||
|
||||
def test_fan_only_still_reachable_via_fan():
|
||||
"""'Fan' (e.g. TP1X_DA-AC-RAC-01011) is a second FAN_ONLY spelling
|
||||
alongside 'Wind' -- issue #91."""
|
||||
assert _DEVICE_TO_HVAC['Fan'] == HVACMode.FAN_ONLY
|
||||
|
||||
|
||||
def test_fan_only_reverse_fallback_prefers_wind():
|
||||
"""_device_code_for_hvac() resolves FAN_ONLY from a unit's own
|
||||
supportedModes first, so _HVAC_TO_DEVICE is only a fallback for a unit
|
||||
reporting no supportedModes at all. That fallback must stay 'Wind' (the
|
||||
original single spelling, predating 'Fan') rather than silently
|
||||
flipping to whichever of the two duplicate-value entries happens to
|
||||
come last in _DEVICE_TO_HVAC."""
|
||||
assert _HVAC_TO_DEVICE[HVACMode.FAN_ONLY] == 'Wind'
|
||||
|
||||
|
||||
def test_preset_ai_comfort_constant():
|
||||
assert PRESET_AI_COMFORT == 'ai_comfort'
|
||||
|
||||
|
||||
def test_preset_to_ha_off_maps_to_preset_none():
|
||||
from homeassistant.components.climate import PRESET_NONE
|
||||
assert _preset_to_ha('Off') == PRESET_NONE
|
||||
|
||||
|
||||
def test_preset_to_ha_lowercases_other_codes():
|
||||
"""Every other device code is exposed as its lowercased self -- resolved
|
||||
dynamically, not via a per-model table (issue #91)."""
|
||||
assert _preset_to_ha('Sleep') == 'sleep'
|
||||
assert _preset_to_ha('NanoSleep') == 'nanosleep'
|
||||
assert _preset_to_ha('MotionIndirect') == 'motionindirect'
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
test_climate_temperature_fallback.py).
|
||||
|
||||
The preset side of issue #75 (WindFree/motion convenient modes not
|
||||
surfacing) is intentionally not addressed here: PR #91 replaces
|
||||
climate.py's static _DEVICE_TO_PRESET table with a generic resolver that
|
||||
reads any device preset code straight off the unit's own supportedModes,
|
||||
which covers WindFree/motion generically instead of a per-model dict --
|
||||
duplicating that here would just conflict with it.
|
||||
surfacing) is intentionally not addressed here: climate.py's
|
||||
_preset_to_ha() (issue #91) reads any device preset code straight off the
|
||||
unit's own supportedModes, replacing the old static _DEVICE_TO_PRESET
|
||||
table with a generic resolver that covers WindFree/motion generically
|
||||
instead of a per-model dict -- duplicating that here would just conflict
|
||||
with it.
|
||||
"""
|
||||
from custom_components.localthings.climate import _DEVICE_TO_SWING, _SWING_TO_DEVICE
|
||||
|
||||
|
||||
@@ -59,6 +59,42 @@ class TestMergeOptionsField:
|
||||
assert common.merge_options_field(cached, ['nounderscore']) == ['Course_16']
|
||||
|
||||
|
||||
class TestMergeItemsField:
|
||||
"""merge_items_field() is the items[]-array counterpart of
|
||||
merge_options_field above (issue #91 review feedback): a vendor
|
||||
x.com.samsung.da.items[] write only needs to carry the item id plus the
|
||||
field(s) being changed, so the coordinator uses this to keep its
|
||||
optimistic cache entry complete (current/minimum/maximum/unit still
|
||||
present) without waiting on a real poll."""
|
||||
|
||||
def test_merges_fields_into_matching_id(self):
|
||||
cached = [{'x.com.samsung.da.id': '0', 'x.com.samsung.da.current': '20.0',
|
||||
'x.com.samsung.da.maximum': '30', 'x.com.samsung.da.minimum': '16'}]
|
||||
merged = common.merge_items_field(
|
||||
cached, [{'x.com.samsung.da.id': '0', 'x.com.samsung.da.desired': '22'}])
|
||||
assert merged == [{'x.com.samsung.da.id': '0', 'x.com.samsung.da.current': '20.0',
|
||||
'x.com.samsung.da.maximum': '30', 'x.com.samsung.da.minimum': '16',
|
||||
'x.com.samsung.da.desired': '22'}]
|
||||
|
||||
def test_appends_when_id_absent(self):
|
||||
cached = [{'x.com.samsung.da.id': '0', 'x.com.samsung.da.current': '20.0'}]
|
||||
merged = common.merge_items_field(
|
||||
cached, [{'x.com.samsung.da.id': '1', 'x.com.samsung.da.desired': '22'}])
|
||||
assert merged == [
|
||||
{'x.com.samsung.da.id': '0', 'x.com.samsung.da.current': '20.0'},
|
||||
{'x.com.samsung.da.id': '1', 'x.com.samsung.da.desired': '22'},
|
||||
]
|
||||
|
||||
def test_handles_missing_cache(self):
|
||||
assert common.merge_items_field(
|
||||
None, [{'x.com.samsung.da.id': '0', 'x.com.samsung.da.desired': '22'}]
|
||||
) == [{'x.com.samsung.da.id': '0', 'x.com.samsung.da.desired': '22'}]
|
||||
|
||||
def test_ignores_malformed_new_items(self):
|
||||
cached = [{'x.com.samsung.da.id': '0', 'x.com.samsung.da.current': '20.0'}]
|
||||
assert common.merge_items_field(cached, ['not-a-dict']) == cached
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OCF-native / vendor '-vs' fallback pairs (power, kids-lock, remote control).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -381,6 +381,21 @@ def test_registry_reproduces_golden_state_keys_for_tp1x_rac():
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_tp1x_rac_coolonly():
|
||||
"""TP1X_DA-AC-RAC-01001 cool-only global variant (issue #91) whose
|
||||
/otninformation/vs/0 ships no swVersionInfo block -- resolves via the
|
||||
hyphenated '-RAC-' modelNum fallback rather than for_device()."""
|
||||
from tests.conftest import _load_device
|
||||
resources = _load_device('airconditioner_tp1x_rac_coolonly')
|
||||
golden = json.loads((GOLDEN / 'airconditioner_tp1x_rac_coolonly.json').read_text())
|
||||
state_keys = _new_state_keys('airconditioner_tp1x_rac_coolonly', 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_airconditioner_windfree():
|
||||
"""ARTIK051_PRAC_20K, WindFree-capable unit (issue #75) -- same modelNum
|
||||
family as the original issue #17 fixture, but its /mode/convenient/vs/0
|
||||
|
||||
@@ -168,4 +168,43 @@ def test_all_entity_state_translation_keys_are_lowercase():
|
||||
for translation in platform.values():
|
||||
for state_key in translation.get("state", {}):
|
||||
assert state_key == state_key.lower()
|
||||
|
||||
|
||||
|
||||
def test_every_ac_convenient_mode_code_has_a_preset_label():
|
||||
"""issue #91 review feedback #3: AC preset resolution is fully dynamic
|
||||
(climate._preset_to_ha), so every fixture's /mode/convenient/vs/0
|
||||
supportedModes code surfaces as a preset -- an unlabelled one falls back
|
||||
to its raw device code in the UI. Cheap guard against repeating that gap:
|
||||
every non-'Off' code across every AC fixture must either resolve to one
|
||||
of HA's own auto-localized standard presets or have an explicit label in
|
||||
en.json.
|
||||
"""
|
||||
from homeassistant.components.climate.const import (
|
||||
PRESET_ACTIVITY, PRESET_AWAY, PRESET_BOOST, PRESET_COMFORT,
|
||||
PRESET_ECO, PRESET_HOME, PRESET_SLEEP,
|
||||
)
|
||||
standard = {PRESET_ACTIVITY, PRESET_AWAY, PRESET_BOOST, PRESET_COMFORT,
|
||||
PRESET_ECO, PRESET_HOME, PRESET_SLEEP}
|
||||
preset_labels = set(
|
||||
_load("en")["entity"]["climate"]["airconditioner"]["state_attributes"]
|
||||
["preset_mode"]["state"]
|
||||
)
|
||||
fixtures_dir = Path(__file__).parent / "fixtures"
|
||||
missing = []
|
||||
for path in sorted(fixtures_dir.glob("airconditioner*_device.json")):
|
||||
dump = json.loads(path.read_text())
|
||||
conv = next(
|
||||
(item for item in dump.get("device0", [])
|
||||
if item.get("href") == "/mode/convenient/vs/0"), None,
|
||||
)
|
||||
if not conv:
|
||||
continue
|
||||
for code in conv["rep"].get("x.com.samsung.da.supportedModes", []):
|
||||
if code == "Off":
|
||||
continue
|
||||
label = code.lower()
|
||||
if label in standard or label in preset_labels:
|
||||
continue
|
||||
missing.append((path.name, code))
|
||||
assert missing == []
|
||||
|
||||
|
||||
Reference in New Issue
Block a user