feat(airconditioner): add beep, tropical night, filter hours/threshold, air-quality, sw/fw version

Layer the ARTIK051_PRAC additive entities onto the upstream registry on a
fresh branch (additive-only; the round-1 display-light/mute-once/WindFree
work was independently shipped upstream and is not redone here).

New entities, all driven by single-token option_write or item reads:
- beep (switch): Volume_Mute/Volume_100 option token; single-token merge --
  a full options RMW reverts on ARTIK051_PRAC. Cloud counterpart:
  samsungce.airConditionerAudioFeedback (on/off only; level is cloud-only).
- tropical_night_mode (number 0-16): Sleep_<N> option token. Cloud:
  custom.airConditionerTropicalNightMode.
- air_filter_usage_hours / air_filter_threshold (duration sensors, h):
  raw filterUsage count and filterDesiredUsage alarm threshold. Threshold
  SET is cloud-only (samsungce.dustFilterAlarm); local read-only.
- clean_level / odor / dust / fine_dust (diagnostic sensors): /sensors/vs/0
  items[]. No unit advertised on the resource, so no device_class until a
  populated reading + unit is observed (don't-guess rule). SuperFineDust
  intentionally not modeled.
- software_version / firmware_version (diagnostic sensors):
  /information/vs/0 items[]. The href is globally ignored as identity
  plumbing; the AC registry drops that ignore so INFO is the sole cap on it.

Translations: en.json + nl.json (every-language-mirrors-english invariant).
Golden regression: regenerated the 8 AC fixtures + dehumidifier (reuses
AIR_FILTER) for the new state keys.

608 tests pass.
This commit is contained in:
blka committed 2026-07-27 14:43:07 +02:00
1 parent ef23f5ec32
commit 3da01f80d0
14 files changed
+483 -15

No files matched your search

@@ -12,16 +12,23 @@ Reuses dishwasher.DIAGNOSIS for /diagnosis/vs/0.
from ..capabilities import airconditioner, common, dishwasher, ignored
from ._base import DeviceRegistry, _build
# /information/vs/0 is globally ignored (serial/model identity plumbing), but
# the AC exposes Software/Firmware version in its items[] -- model those here
# and drop the no-entity coverage entry so INFO is the sole cap on the href.
_AC_IGNORED = [c for c in ignored.IGNORED if c.href != '/information/vs/0']
REGISTRY = DeviceRegistry(
name='airconditioner',
capabilities=_build([
*ignored.IGNORED,
*_AC_IGNORED,
*common.UNIVERSAL,
dishwasher.DIAGNOSIS,
airconditioner.CLIMATE,
airconditioner.AIR_PURIFY,
airconditioner.AUTO_CLEAN,
airconditioner.AIR_FILTER,
airconditioner.AIR_QUALITY,
airconditioner.INFO,
airconditioner.DISPLAY_LIGHT,
airconditioner.MUTE_ONCE,
airconditioner.CURRENT_LIMIT,
@@ -15,10 +15,98 @@ different schema (see capabilities/__init__.py). They live only in the AC
by_type registry.
"""
from ..capability import Capability
from ..entities import BinarySensorDesc, ClimateDesc, SensorDesc, SwitchDesc
from ..entities import (
BinarySensorDesc, ClimateDesc, NumberDesc, SensorDesc, SwitchDesc,
)
from .common import normalize_temp_unit
from .laundry import option_write
def _int(v):
try:
return int(v)
except (TypeError, ValueError):
return None
def _option_token(rep, prefix):
"""Return the `<prefix>_<value>` token from /mode/vs/0 options, else None."""
for o in _mode_options(rep):
if isinstance(o, str) and o.startswith(prefix + '_'):
return o
return None
def _beep_on(rep):
"""Beep on/off from the `Volume_*` option token: Volume_Mute = off,
Volume_100 (and any non-Mute) = on. None when no Volume_ slot."""
tok = _option_token(rep, 'Volume')
if tok is None:
return None
return tok != 'Volume_Mute'
def _beep_write(payload, rep, href=None):
"""Toggle beep via a single-token /mode/vs/0 options write (option_write's
one-token merge -- a full options RMW reverts on ARTIK051_PRAC)."""
if payload not in ('On', 'Off'):
return None
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': option_write('Volume', '100' if payload == 'On' else 'Mute'),
}
def _tropical_night_value(rep):
"""Tropical night mode level (0-16) from the `Sleep_<N>` option token."""
tok = _option_token(rep, 'Sleep')
if tok is None or '_' not in tok:
return None
return _int(tok.split('_', 1)[1])
def _tropical_night_write(value, rep, href=None):
"""Set tropical night level via a single-token `Sleep_<N>` options write.
Samsung cloud counterpart: custom.airConditionerTropicalNightMode (0-16)."""
try:
level = int(round(float(value)))
except (TypeError, ValueError):
return None
if not 0 <= level <= 16:
return None
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': option_write('Sleep', str(level)),
}
def _info_item_number(items, type_):
"""x.com.samsung.da.number of the /information/vs/0 item with the given
x.com.samsung.da.type ('Software'/'Firmware'), else None."""
for it in (items or []):
if isinstance(it, dict) and it.get('x.com.samsung.da.type') == type_:
return it.get('x.com.samsung.da.number')
return None
def _sensor_item_value(items, type_):
"""First value of the /sensors/vs/0 item with the given
x.com.samsung.da.type, as a diagnostic scalar string. The resource exposes
no unit, so no device_class is set until a populated reading + unit is
observed (the 'don't guess' rule)."""
for it in (items or []):
if isinstance(it, dict) and it.get('x.com.samsung.da.type') == type_:
v = it.get('x.com.samsung.da.value')
if isinstance(v, list) and v:
return str(v[0])
return None
return None
def _has_sensor_type(type_):
def fn(rep, resources):
return any(isinstance(i, dict) and i.get('x.com.samsung.da.type') == type_
for i in (rep.get('x.com.samsung.da.items') or []))
return fn
# ---------------------------------------------------------------------------
# Canonical AC resource hrefs. The climate entity (climate.py) binds the
# primary HREF_MODE via CLIMATE below and reads the CLIMATE_CONSUMED_HREFS
@@ -37,6 +125,7 @@ HREF_WIND_STRENGTH = '/wind/strength/vs/0' # fan_mode
HREF_WIND_DIRECTION = '/wind/direction/vs/0' # swing_mode
HREF_CONVENIENT = '/mode/convenient/vs/0' # preset_mode
HREF_TEMPS_VS = '/temperatures/vs/0' # vendor temp fallback (items[] array)
HREF_INFORMATION = '/information/vs/0' # model/serial + Software/Firmware version items
CLIMATE_CONSUMED_HREFS = [
HREF_POWER, HREF_POWER_VS, HREF_TEMP_CURRENT, HREF_TEMP_DESIRED,
@@ -201,6 +290,19 @@ CLIMATE = Capability(
exists_fn=_has_display_light_option,
write_fn=_display_light_write,
icon='mdi:led-on', entity_category='config'),
# Beep on/off from the `Volume_*` option token (Volume_Mute/Volume_100).
# Single-token option_write; a full options RMW reverts on ARTIK051_PRAC.
SwitchDesc(key='beep', rep_fn=_beep_on,
exists_fn=lambda rep, resources: _option_token(rep, 'Volume') is not None,
write_fn=_beep_write,
icon='mdi:volume-high', entity_category='config'),
# Tropical night mode level (0-16) from the `Sleep_<N>` option token.
# Single-token option_write. Cloud: custom.airConditionerTropicalNightMode.
NumberDesc(key='tropical_night_mode', rep_fn=_tropical_night_value,
exists_fn=lambda rep, resources: _option_token(rep, 'Sleep') is not None,
write_fn=_tropical_night_write,
native_min=0, native_max=16, step=1,
icon='mdi:weather-night', entity_category='config'),
),
)
@@ -239,6 +341,17 @@ AIR_FILTER = Capability(
SensorDesc(key='air_filter_usage', rep_fn=_filter_usage_percent,
unit='%', state_class='measurement',
icon='mdi:air-filter', entity_category='diagnostic'),
SensorDesc(key='air_filter_usage_hours',
field='x.com.samsung.da.filterUsage',
device_class='duration',
state_class='measurement', unit='h',
icon='mdi:air-filter', entity_category='diagnostic',
value_fn=_int),
SensorDesc(key='air_filter_threshold',
field='x.com.samsung.da.filterDesiredUsage',
device_class='duration',
unit='h', icon='mdi:alarm', entity_category='diagnostic',
value_fn=_int),
SensorDesc(key='air_filter_status', field='x.com.samsung.da.filterStatus',
device_class='enum',
options=('normal', 'wash', 'replace'),
@@ -341,6 +454,43 @@ HUMIDITY = Capability(
),
)
# /sensors/vs/0 items[] carry live air-quality readings (CleanLevel, Odor, Dust,
# FineDust). The resource exposes no unit, so these are diagnostic scalars with
# no device_class until a populated reading + unit is observed. Removed from
# _AC_IGNORED below so AIR_QUALITY is the sole cap on the href.
AIR_QUALITY = Capability(
href='/sensors/vs/0',
poll_tier='cold',
entities=tuple(
SensorDesc(key=key, field='x.com.samsung.da.items',
icon=icon, entity_category='diagnostic',
exists_fn=_has_sensor_type(type_),
value_fn=lambda items, t=type_: _sensor_item_value(items, t))
for key, icon, type_ in (
('clean_level', 'mdi:broom', 'CleanLevel'),
('odor', 'mdi:weather-windy', 'Odor'),
('dust', 'mdi:cloud', 'Dust'),
('fine_dust', 'mdi:cloud-outline', 'FineDust'),
)
),
)
# Software/Firmware version from /information/vs/0 items[] (the href is
# globally ignored as identity plumbing; the AC registry drops that entry so
# INFO is the sole cap on it). Diagnostic, read-only.
INFO = Capability(
href=HREF_INFORMATION,
poll_tier='cold',
entities=(
SensorDesc(key='software_version', field='x.com.samsung.da.items',
icon='mdi:package-variant', entity_category='diagnostic',
value_fn=lambda items: _info_item_number(items, 'Software')),
SensorDesc(key='firmware_version', field='x.com.samsung.da.items',
icon='mdi:chip', entity_category='diagnostic',
value_fn=lambda items: _info_item_number(items, 'Firmware')),
),
)
# ---------------------------------------------------------------------------
# AC-scoped coverage: the CLIMATE_CONSUMED_HREFS above (read by the climate
# entity) plus vendor duplicates / all-zero-ambiguous / plumbing resources.
@@ -360,9 +510,6 @@ HUMIDITY = Capability(
# of waiting on the summary sweep.
# ---------------------------------------------------------------------------
_AC_IGNORED = [
# All-zero and ambiguously encoded on this model (2-value arrays); the
# 'don't guess' rule -- leave unmodeled rather than invent entities.
'/sensors/vs/0',
# Stuck at "0" on every dump seen -- HUMIDITY above reads the vendor
# resource's usable fivepercentHumidity field instead; this OCF-standard
# one has no corresponding live value confirmed yet.
@@ -149,6 +149,9 @@
},
"target_humidity": {
"name": "Target humidity"
},
"tropical_night_mode": {
"name": "Tropical night mode"
}
},
"select": {
@@ -478,6 +481,18 @@
"air_filter_usage": {
"name": "Filter usage"
},
"air_filter_usage_hours": {
"name": "Filter usage hours"
},
"air_filter_threshold": {
"name": "Filter alarm threshold"
},
"software_version": {
"name": "Software version"
},
"firmware_version": {
"name": "Firmware version"
},
"air_sensing_state": {
"name": "Air sensing state"
},
@@ -729,6 +744,9 @@
"auto_clean": {
"name": "Auto clean"
},
"beep": {
"name": "Beep"
},
"auto_door_opener": {
"name": "Auto door opener"
},
@@ -149,6 +149,9 @@
},
"target_humidity": {
"name": "Doelvochtigheid"
},
"tropical_night_mode": {
"name": "Tropische nachtmodus"
}
},
"select": {
@@ -478,6 +481,18 @@
"air_filter_usage": {
"name": "Filterverbruik"
},
"air_filter_usage_hours": {
"name": "Filterverbruik (uren)"
},
"air_filter_threshold": {
"name": "Filteralarmdrempel"
},
"software_version": {
"name": "Softwareversie"
},
"firmware_version": {
"name": "Firmwareversie"
},
"air_sensing_state": {
"name": "Status luchtmeting"
},
@@ -729,6 +744,9 @@
"auto_clean": {
"name": "Automatisch reinigen"
},
"beep": {
"name": "Piep"
},
"auto_door_opener": {
"name": "Automatische deuropener"
},