Add cool-only global RAC support: WindFree, Auto mode, display light
Finishes PR #91's contribution (pedroperosin) with the requested review changes applied, on our own branch: - Preset resolution is now fully dynamic, read from each unit's own /mode/convenient/vs/0 supportedModes instead of a static per-model table -- any board's convenient modes (including WindFree's Nano/ NanoSleep) surface without code changes. Unlabelled codes across existing fixtures (longwind, motionindirect, motiondirect, drycomfort) plus the two new WindFree ones are added to en.json/nl.json. - 'Auto' now maps to HVACMode.AUTO instead of HEAT_COOL: these are single-setpoint "device decides" units, not two-setpoint heat+cool ones. 'Fan' is added alongside 'Wind' as a second FAN_ONLY spelling; a new _device_code_for_hvac() picks the code from the unit's own supportedModes since the flat map can't disambiguate two device codes mapping to one HA value. - Detection gains a hyphenated '-RAC-' modelNum fallback (alongside the existing '_RAC_') for cool-only global RAC variants whose /otninformation/vs/0 ships no swVersionInfo block. - Adds a display_light switch sourced from /mode/vs/0's opaque options blob (inverted Light_On/Light_Off token) for boards with no dedicated /light/vs/0 switch. Write-path behavioural change (touches the path iterated on across #9/#17/#27/#38/#54): power now targets the vendor /power/vs/0 instead of the OCF /power/0, and _is_on() reads vendor-first. /power/0 is absent on several known AC boards, so the previous OCF-first read/write pair could report and act on stale state -- consistent with #53's "can turn on but not off". Target temperature now picks its channel (OCF pair vs. vendor items[]) based on which the unit actually reports, reading and writing the same one. The vendor temperature write and the new display-light write both carry only the changed field(s), not the whole resource -- confirmed sufficient on the wire, the device merges the rest itself. That requires the coordinator's optimistic cache to do the same merge on the read side so a setpoint change doesn't blank out current/min/max/unit for the settle window: common.py gains merge_items_field() next to the existing merge_options_field(), and async_send_command wires it in for any write touching x.com.samsung.da.items. New fixture/golden for the cool-only global RAC variant (TP1X_DA-AC-RAC- 01001, AI_RAC_GLOBAL_COOLONLY_3.0); display_light added to the goldens for boards that gain the new options-based switch. Regenerated against current main rather than copied from the original PR, since those goldens had already shifted (current_temperature_c/humidity from issue #75).
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,18 @@ _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.
|
||||
'Wind': HVACMode.FAN_ONLY,
|
||||
'Auto': HVACMode.HEAT_COOL,
|
||||
'Fan': HVACMode.FAN_ONLY,
|
||||
# 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 +88,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 +116,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 +207,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 +251,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 +282,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 +366,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 +433,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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user