Extract usable parts of PR #316 (System Fresh Air Ventilator support)

PR #316 (fork stale by several months, most of its ~2200-line diff was drift
against main rather than real changes) proposed device support for the
Samsung System Fresh Air Ventilator (ACA-KR-TP2-21-AN9000). Extracted what
holds up, adapted to this project's conventions, and left out what doesn't:

Extracted:
- ventilation_mode select on CLIMATE's own href, gated via
  _is_ventilation_mode_device so it can only ever bind on a device whose
  entire supportedModes set is Purification/Ventilation/SmartVentilation --
  verified against every real AC fixture in the corpus to confirm it can't
  false-positive on an actual air conditioner's climate card.
- WINDFREE / WINDSLEEP switches on their own dedicated hrefs.
- A CO2 sensor on AIR_QUALITY, matching air_monitor.SENSORS' already-bound
  device_class='carbon_dioxide'/unit='ppm' descriptor for the same field
  shape rather than guessing fresh.
- HEPA_FILTER / DEVICE_ACTIVE reuse from air_purifier.py.
- Removing /airlevelcheck/vs/0 from _AC_IGNORED and binding
  air_purifier.AIR_LEVEL_CHECK in its place: the PR's claim that this
  project's old "scheduler plumbing" description was wrong turned out to
  be independently verifiable against two of our own existing fixtures
  (airconditioner_cac and airconditioner_tp1x_da_ac_rac_01011 both already
  carry real, populated periodicSensingActivationState/autoExeState
  values), so this benefits existing users, not just the one new device.

Left out:
- Unit/device_class ('ug/m3', pm10/pm25/pm1) on the existing dust/
  fine_dust/super_fine_dust sensors, sourced from an unverified third-party
  screenshot description. air_monitor.py already has an explicit, reasoned
  rejection of this exact mapping for the exact same three fields:
  Samsung's PM10/PM2.5 convention doesn't confirm where a third tier or a
  PM1 reading fits, and a wrong guess mislabels the reading forever.
- A standalone common.POWER switch -- contradicts this registry's own
  documented design (power is deliberately the climate entity's job) and
  would affect every AC user, not just this device.
- Promoting wind/swing to independent selects for every AC user -- a UX
  opinion, not a coverage necessity, and out of scope for this device's
  own support.
- A model-name diagnostic sensor -- /information/vs/0 is already covered
  via the global ignore list, so this wasn't closing an actual gap.

No raw diagnostics dump for this model was ever attached to PR #316, so
there's no fixture for it here (fabricating one would violate this
project's fixture-integrity rule) -- see
tests/test_airconditioner_ventilation_windfree.py's module docstring.
This commit is contained in:
Marc Billow
2026-08-07 16:38:09 +00:00
parent 8551974719
commit a7dc1db8ff
13 changed files with 361 additions and 1 deletions
@@ -58,6 +58,16 @@ REGISTRY = DeviceRegistry(
airconditioner.ENERGY_SAVING,
airconditioner.EDGE_LIGHTING,
airconditioner.LIGHT_STATEFUL,
# System Fresh Air Ventilator (PR #316, ACA-KR-TP2-21-AN9000):
# WINDFREE/WINDSLEEP are this device's own hrefs; HEPA_FILTER/
# DEVICE_ACTIVE reuse air_purifier.py's identical shapes.
# AIR_LEVEL_CHECK is not this-device-specific -- see its
# removal from _AC_IGNORED above.
airconditioner.WINDFREE,
airconditioner.WINDSLEEP,
air_purifier.HEPA_FILTER,
air_purifier.DEVICE_ACTIVE,
air_purifier.AIR_LEVEL_CHECK,
*airconditioner.COVERAGE,
]
),
@@ -206,6 +206,28 @@ def _mode_options(rep):
return opts if isinstance(opts, (list, tuple)) else ()
# Samsung's "System Fresh Air Ventilator" (PR #316, model
# ACA-KR-TP2-21-AN9000, vid DA-AC-DIFFUSER-01001) self-reports oic.d.
# airconditioner and routes through this same CLIMATE capability, but its
# /mode/vs/0 supportedModes are Purification/Ventilation/SmartVentilation --
# none of which climate.py's HVAC-mode table knows, so hvac_mode collapses
# to a single stuck value with no way to tell the three apart. Gated to
# devices whose *entire* supported-mode set is this vocabulary, so it can't
# false-positive on a real AC's Cool/Heat/Dry list.
_VENTILATION_MODE_VALUES = frozenset(("Purification", "Ventilation", "SmartVentilation"))
def _is_ventilation_mode_device(rep, resources):
supported = rep.get("x.com.samsung.da.supportedModes")
if not isinstance(supported, (list, tuple)) or not supported:
return False
return set(supported) <= _VENTILATION_MODE_VALUES
def _ventilation_mode_write(payload, rep, href=None):
return ["mode", "vs", "0"], {"x.com.samsung.da.modes": [payload]}
def _has_display_light_option(rep, resources):
"""True when the panel light lives in /mode/vs/0's `Light_*` option
token rather than a dedicated /light/vs/0 switch -- the two encodings
@@ -532,6 +554,17 @@ CLIMATE = Capability(
rep_fn=_first_mode,
write_fn=_climate_write,
),
# Purification/Ventilation/SmartVentilation mode select (PR #316) --
# _is_ventilation_mode_device gates this to devices using that
# vocabulary exclusively, so a real AC's climate card is unaffected.
SelectDesc(
key="ventilation_mode",
rep_fn=_first_mode,
exists_fn=_is_ventilation_mode_device,
options_field="x.com.samsung.da.supportedModes",
icon="mdi:air-filter",
write_fn=_ventilation_mode_write,
),
# Panel light switch for boards that encode it in /mode/vs/0's options
# instead of a dedicated /light/vs/0 (see _has_display_light_option).
# Shares the switch.display_light translation key with DISPLAY_LIGHT
@@ -1364,6 +1397,47 @@ LIGHT_STATEFUL = Capability(
),
)
# Wind-Free / Wind-Sleep mode toggles (PR #316, ACA-KR-TP2-21-AN9000). Each
# on its own dedicated href, so unlike ventilation_mode above these need no
# device gating -- absent on every other family's dump. Write contract
# extrapolated from this file's other plain On/Off options-array fields
# (AIR_PURIFY, AUTO_CLEAN); not confirmed live.
WINDFREE = Capability(
href="/modeoption/windfree/vs/0",
poll_tier="warm",
entities=(
SwitchDesc(
key="windfree",
field="x.com.samsung.da.windfree",
icon="mdi:leaf",
entity_category="config",
value_fn=lambda v: v == "On",
write_fn=lambda p, rep, href=None: (
["modeoption", "windfree", "vs", "0"],
{"x.com.samsung.da.windfree": "On" if p == "On" else "Off"},
),
),
),
)
WINDSLEEP = Capability(
href="/modeoption/windsleep/vs/0",
poll_tier="warm",
entities=(
SwitchDesc(
key="windsleep",
field="x.com.samsung.da.windsleep",
icon="mdi:sleep",
entity_category="config",
value_fn=lambda v: v == "On",
write_fn=lambda p, rep, href=None: (
["modeoption", "windsleep", "vs", "0"],
{"x.com.samsung.da.windsleep": "On" if p == "On" else "Off"},
),
),
),
)
# /sensors/vs/0 items[] carry live air-quality readings. CleanLevel is
# corroborated as numeric by a top-level x.com.samsung.da.cleanLevel scalar,
# so it's a measurement; the others stay string diagnostics (see
@@ -1400,6 +1474,27 @@ AIR_QUALITY = Capability(
("super_fine_dust", "mdi:weather-fog", "SuperFineDust"),
)
),
# CO2 (PR #316, ACA-KR-TP2-21-AN9000) -- a type this file's other AC
# families don't report. Same field/shape air_monitor.SENSORS
# already models with device_class='carbon_dioxide'/unit='ppm', so
# this matches that descriptor rather than guessing fresh -- unlike
# the pm10/pm25/pm1 mapping air_monitor.py's own docstring
# deliberately rejects for the three dust-type keys above (Samsung's
# two-tier PM10/PM2.5 convention doesn't confirm where a third tier
# or PM1 fits), ppm for a field literally named CO2 isn't a guess of
# that kind.
SensorDesc(
key="co2",
field="x.com.samsung.da.items",
icon="mdi:molecule-co2",
entity_category="diagnostic",
device_class="carbon_dioxide",
state_class="measurement",
unit="ppm",
exists_fn=_has_sensor_type("CO2"),
enabled_default=False,
value_fn=lambda items: _int(_sensor_item_value(items, "CO2")),
),
),
)
@@ -1419,7 +1514,12 @@ _AC_IGNORED = [
# state or documented write contract. /option/muteonce/vs/0 and
# /selfcheck/vs/0 are deliberately NOT here -- see MUTE_ONCE above and
# common.SELF_CHECK, both of which have a confirmed, modelable contract.
"/airlevelcheck/vs/0", # periodic air-quality sensing scheduler plumbing
# /airlevelcheck/vs/0 is deliberately NOT here either (PR #316):
# despite this list's old description of it as "scheduler plumbing",
# both the CAC and TP1X_DA_AC_RAC_01011 fixtures already carry real,
# populated periodicSensingActivationState/autoExeState values here --
# the AI-Purify feature air_purifier.AIR_LEVEL_CHECK already models,
# reused below rather than reinvented.
"/aisleep/vs/0", # AI-sleep feedback state (no actionable control)
"/availablecontrolsets/vs/0", # opaque hex-encoded control-set bitmap
"/da/softreset/vs/0", # soft-reset trigger plumbing
@@ -692,6 +692,14 @@
"high": "Vysoký",
"low": "Nízký"
}
},
"ventilation_mode": {
"name": "Režim",
"state": {
"purification": "Čištění",
"ventilation": "Větrání",
"smartventilation": "Chytré větrání"
}
}
},
"sensor": {
@@ -1246,6 +1254,12 @@
},
"indicator_light": {
"name": "Kontrolka"
},
"windfree": {
"name": "Režim Wind-Free"
},
"windsleep": {
"name": "Noční režim"
}
},
"time": {
@@ -692,6 +692,14 @@
"high": "High",
"low": "Low"
}
},
"ventilation_mode": {
"name": "Mode",
"state": {
"purification": "Purification",
"ventilation": "Ventilation",
"smartventilation": "Smart Ventilation"
}
}
},
"sensor": {
@@ -1246,6 +1254,12 @@
},
"indicator_light": {
"name": "Indicator light"
},
"windfree": {
"name": "Wind-Free mode"
},
"windsleep": {
"name": "Sleep mode"
}
},
"time": {
@@ -817,6 +817,14 @@
"high": "Alto",
"low": "Bajo"
}
},
"ventilation_mode": {
"name": "Modo",
"state": {
"purification": "Purificación",
"ventilation": "Ventilación",
"smartventilation": "Ventilación inteligente"
}
}
},
"sensor": {
@@ -1371,6 +1379,12 @@
},
"indicator_light": {
"name": "Luz indicadora"
},
"windfree": {
"name": "Modo Wind-Free"
},
"windsleep": {
"name": "Modo nocturno"
}
},
"time": {
@@ -692,6 +692,14 @@
"high": "Alta",
"low": "Bassa"
}
},
"ventilation_mode": {
"name": "Modalità",
"state": {
"purification": "Purificazione",
"ventilation": "Ventilazione",
"smartventilation": "Ventilazione intelligente"
}
}
},
"sensor": {
@@ -1246,6 +1254,12 @@
},
"indicator_light": {
"name": "Spia luminosa"
},
"windfree": {
"name": "Modalità Wind-Free"
},
"windsleep": {
"name": "Modalità notte"
}
},
"time": {
@@ -692,6 +692,14 @@
"high": "높음",
"low": "낮음"
}
},
"ventilation_mode": {
"name": "모드",
"state": {
"purification": "청정",
"ventilation": "환기",
"smartventilation": "스마트환기"
}
}
},
"sensor": {
@@ -1246,6 +1254,12 @@
},
"indicator_light": {
"name": "표시등"
},
"windfree": {
"name": "무풍 모드"
},
"windsleep": {
"name": "취침 모드"
}
},
"time": {
@@ -692,6 +692,14 @@
"high": "Hoog",
"low": "Laag"
}
},
"ventilation_mode": {
"name": "Modus",
"state": {
"purification": "Zuivering",
"ventilation": "Ventilatie",
"smartventilation": "Slimme ventilatie"
}
}
},
"sensor": {
@@ -1246,6 +1254,12 @@
},
"indicator_light": {
"name": "Indicatorlampje"
},
"windfree": {
"name": "Wind-Free modus"
},
"windsleep": {
"name": "Slaapmodus"
}
},
"time": {
+9
View File
@@ -10,6 +10,7 @@
"air_filter_usage",
"air_filter_usage_hours",
"air_purify",
"air_sensing_state",
"alarm_code",
"auto_clean",
"auto_clean_progress",
@@ -28,15 +29,23 @@
"humidity",
"indicator_light",
"indicator_light_mode",
"last_air_sensing_level",
"last_air_sensing_time",
"motion_detect_wind_active",
"motion_detect_wind_mode",
"mute_once",
"odor_controller_active",
"odor_controller_progress",
"periodic_air_sensing",
"periodic_sensing_skip_status",
"power_watts",
"selfcheck_error",
"selfcheck_result",
"selfcheck_status",
"sensing_interval",
"sensing_mode",
"sensing_skip_end",
"sensing_skip_start",
"sound_mode",
"sound_output",
"sound_volume",
@@ -5,6 +5,7 @@
"air_filter_usage",
"air_filter_usage_hours",
"air_purify",
"air_sensing_state",
"alarm_code",
"auto_clean",
"auto_clean_progress",
@@ -19,10 +20,18 @@
"fine_dust",
"firmware_update",
"humidity",
"last_air_sensing_level",
"last_air_sensing_time",
"mute_once",
"periodic_air_sensing",
"periodic_sensing_skip_status",
"selfcheck_error",
"selfcheck_result",
"selfcheck_status",
"sensing_interval",
"sensing_mode",
"sensing_skip_end",
"sensing_skip_start",
"super_fine_dust",
"tropical_night_mode"
]
+22
View File
@@ -33,6 +33,13 @@ until issue #270 (TP1X_FAC_TIME_23K) added real capabilities for both --
this board's own live filterUsage/filterStatus data on the PM1 filter binds
through the same exists_fn-gated entities #270's dump (which has neither
field) leaves empty.
/airlevelcheck/vs/0 was never on this unbound list (this fixture's rep
already carried a full set of periodicSensing*/autoExeState fields), but
until PR #316 it was globally ignored by airconditioner.py's own
_AC_IGNORED as "scheduler plumbing" -- this fixture's own populated values
were the proof that description was wrong. air_purifier.AIR_LEVEL_CHECK
now covers it (see test_airlevelcheck_binds_real_ai_purify_state below).
"""
from custom_components.localthings.registry.adapter import flatten
@@ -85,6 +92,21 @@ def test_mds_absenceclean_shares_csi_absenceclean_key():
assert state["absence_clean"] is False
def test_airlevelcheck_binds_real_ai_purify_state():
"""This fixture's /airlevelcheck/vs/0 has real, populated values --
periodic_air_sensing on, sensing_mode 'Alarm' -- confirming
air_purifier.AIR_LEVEL_CHECK binds real AI-Purify state here rather
than the inert plumbing _AC_IGNORED used to describe."""
resources = _resources()
reg = _reg(resources)
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
state = flatten(bound, resources)
assert state["periodic_air_sensing"] is True
assert state["sensing_mode"] == "Alarm"
assert state["sensing_interval"] == 30 # 1800s
assert state["air_sensing_state"] == "NonProcessing"
def test_non_legacy_board_uses_the_generic_energy_scale():
"""This board reports /wind/strength/vs/0 (not /airflow/vs/0), so
is_legacy_board() is False and it must use the plain wh_to_kwh scale,
+13
View File
@@ -221,6 +221,19 @@ def test_tp1x_climate_entity_is_bound():
assert len(climate) == 1 and climate[0].href == "/mode/vs/0"
def test_tp1x_airlevelcheck_binds_real_ai_purify_state():
"""This fixture's /airlevelcheck/vs/0 has real, populated
periodicSensing*/autoExeState values too (PR #316's finding that
_AC_IGNORED's old "scheduler plumbing" description was wrong wasn't
specific to one board) -- air_purifier.AIR_LEVEL_CHECK now covers it."""
reg, resources = _ac_tp1x()
state = flatten(discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
assert state["periodic_air_sensing"] is True
assert state["sensing_mode"] == "Alarm"
assert state["sensing_interval"] == 30 # 1800s
assert state["air_sensing_state"] == "NonProcessing"
def test_tp2x_rac_20k_model_resolves_via_model_fallback():
"""TP2X_RAC_20K (issue #37) reports no oneUiVersion and no '_PRAC_' token
-- resolved via the '_RAC_' modelNum fallback added for this device."""
@@ -0,0 +1,113 @@
"""Tests for the ventilation-mode/Wind-Free/Wind-Sleep additions extracted
from PR #316 (Samsung "System Fresh Air Ventilator", model
ACA-KR-TP2-21-AN9000, vid DA-AC-DIFFUSER-01001).
No raw diagnostics dump for this model was available -- PR #316 never
attached one, only Korean code comments describing field shapes the
contributor said they observed. Per this project's fixture-integrity rule
(a fixture must record what hardware actually did, not a third party's
prose about it), there's no `airconditioner_*_device.json` fixture for
this model here. These tests instead exercise the gating logic directly
against hand-built reps matching those quoted shapes, clearly distinct
from this suite's fixture-backed tests, and check the new gate doesn't
false-positive against every real AC fixture already in the corpus.
If a real diagnostics dump for this model ever surfaces (tracked as a
follow-up device-support issue), replace this file with a proper
fixture + golden + capability test per the usual workflow, and drop the
disclaimer above.
"""
import glob
import json
import os
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import for_device_by_model
from custom_components.localthings.registry.capabilities.airconditioner import (
WINDFREE,
WINDSLEEP,
_is_ventilation_mode_device,
)
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
_FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "fixtures")
def _all_airconditioner_fixture_names():
names = []
for path in glob.glob(os.path.join(_FIXTURES_DIR, "airconditioner*_device.json")):
name = os.path.basename(path)[: -len("_device.json")]
with open(path) as f:
info = json.load(f)
rep = next(
(e["rep"] for e in info.get("device0", []) if e.get("href") == "/information/vs/0"),
None,
)
if rep is not None: # for_device_by_model needs /information/vs/0
names.append(name)
return names
def test_ventilation_mode_gate_never_false_positives_on_real_ac_fixtures():
"""None of the real air-conditioner fixtures in this corpus use the
Purification/Ventilation/SmartVentilation vocabulary -- confirms
_is_ventilation_mode_device can't turn a real AC's climate card into
this select."""
for name in _all_airconditioner_fixture_names():
resources = _load_device(name)
reg = for_device_by_model(
resources["/information/vs/0"]["x.com.samsung.da.modelNum"],
resources["/information/vs/0"]["x.com.samsung.da.description"],
)
if reg is None or reg.name != "airconditioner":
continue
mode_rep = resources.get("/mode/vs/0")
if not mode_rep:
continue
assert _is_ventilation_mode_device(mode_rep, resources) is False, name
def test_ventilation_mode_gate_matches_diffuser_shape():
"""PR #316: supportedModes exactly {Purification, Ventilation,
SmartVentilation} -- the vocabulary that makes climate.py's hvac_mode
collapse to one stuck value with no way to tell the three apart."""
rep = {
"x.com.samsung.da.modes": ["Purification"],
"x.com.samsung.da.supportedModes": ["Purification", "Ventilation", "SmartVentilation"],
}
assert _is_ventilation_mode_device(rep, {}) is True
def test_ventilation_mode_gate_rejects_partial_overlap():
"""A real AC reporting an unrelated mode alongside one of these three
words (coincidence, not this device) must not gate in -- the check is
'subset of', not 'intersects'."""
rep = {"x.com.samsung.da.supportedModes": ["Ventilation", "Cool", "Heat"]}
assert _is_ventilation_mode_device(rep, {}) is False
def _bind(capability, href, rep):
resources = {href: rep}
bound = discover(resources, {href: [capability]}, [])
return flatten(bound, resources)
def test_windfree_and_windsleep_read_their_own_hrefs():
"""PR #316's quoted rep shape: {'x.com.samsung.da.windfree': 'On'/'Off',
'x.com.samsung.da.displaycondition': 'normal'} (displaycondition is a
read-only UI-visibility flag, deliberately not modeled)."""
windfree_state = _bind(
WINDFREE,
"/modeoption/windfree/vs/0",
{"x.com.samsung.da.windfree": "On", "x.com.samsung.da.displaycondition": "normal"},
)
assert windfree_state["windfree"] is True
windsleep_state = _bind(
WINDSLEEP,
"/modeoption/windsleep/vs/0",
{"x.com.samsung.da.windsleep": "Off", "x.com.samsung.da.displaycondition": "normal"},
)
assert windsleep_state["windsleep"] is False