air_purifier: share has_sensor_type and disable co2 by default

Hoist airconditioner._has_sensor_type to common.has_sensor_type next to
sensor_item_value, add the issue #127 stub-rep carve-out, and match the
AC family's enabled_default=False on the purifier CO2 entity.
This commit is contained in:
JayChickenK committed 2026-08-19 18:27:01 +02:00
1 parent 296ba18cba
commit 171316bd95
7 files changed
+82 -41

No files matched your search

@@ -34,7 +34,13 @@ from ..entities import (
SwitchDesc,
TimeDesc,
)
from .common import epoch_to_utc, filter_usage_percent, int_or_none, sensor_item_value
from .common import (
epoch_to_utc,
filter_usage_percent,
has_sensor_type,
int_or_none,
sensor_item_value,
)
from .laundry import bool_option_exists, bool_option_value, option_value, option_write
# Newer TP1X_DA-AC-AIR-class boards (issue #130) report fan modes directly
@@ -51,18 +57,6 @@ def _has_top_level_modes(rep, resources):
return isinstance(rep.get("x.com.samsung.da.supportedModes"), (list, tuple))
def _has_sensor_type(type_):
"""True when /sensors/vs/0's items[] lists an item of this 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
# Columns: key, icon, device item type, state_class, device_class, unit.
# state_class is what makes Home Assistant keep long-term statistics --
# without one, a reading is only in the short-term recorder history and
@@ -134,7 +128,8 @@ AIR_QUALITY = Capability(
# CO2 (issue #387) -- same field/shape air_monitor.SENSORS already
# models with device_class='carbon_dioxide'/unit='ppm'. Gated on the
# type being listed so boards that don't report it (every current
# fixture) don't grow an empty entity.
# fixture) don't grow an empty entity. Disabled by default for the
# same reason as airconditioner.AIR_QUALITY (issue #166).
SensorDesc(
key="co2",
field="x.com.samsung.da.items",
@@ -142,7 +137,8 @@ AIR_QUALITY = Capability(
device_class="carbon_dioxide",
state_class="measurement",
unit="ppm",
exists_fn=_has_sensor_type("CO2"),
exists_fn=has_sensor_type("CO2"),
enabled_default=False,
value_fn=lambda items: sensor_item_value(items, "CO2"),
),
),
@@ -23,7 +23,7 @@ from ..entities import (
SwitchDesc,
)
from . import common
from .common import filter_usage_hours, filter_usage_percent, normalize_temp_unit
from .common import filter_usage_hours, filter_usage_percent, has_sensor_type, normalize_temp_unit
from .laundry import option_write
@@ -119,26 +119,6 @@ def _sensor_item_value(items, type_):
return None
def _has_sensor_type(type_):
"""True when /sensors/vs/0's items[] lists an item of this type.
This only proves the type is *listed*, not that the reading is real:
issue #166 (ARTIK051_PRAC_20K) lists all five types with permanent-zero
values on units the reporter confirmed don't have the hardware. So
entities gated on this stay disabled by default (see AIR_QUALITY) rather
than existence-gated further, to avoid silently dropping real readings
on hardware not yet seen.
"""
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. climate.py binds HREF_MODE and reads the
# CLIMATE_CONSUMED_HREFS siblings off the coordinator snapshot; declared once
# here so climate.py and the coverage list below can't drift out of sync.
@@ -1570,7 +1550,7 @@ WINDSLEEP = Capability(
# /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
# _sensor_item_value). All disabled by default: _has_sensor_type only proves
# _sensor_item_value). All disabled by default: has_sensor_type only proves
# the item type is listed, not that the sensor is real (see its docstring).
AIR_QUALITY = Capability(
href="/sensors/vs/0",
@@ -1582,7 +1562,7 @@ AIR_QUALITY = Capability(
icon="mdi:broom",
entity_category="diagnostic",
state_class="measurement",
exists_fn=_has_sensor_type("CleanLevel"),
exists_fn=has_sensor_type("CleanLevel"),
enabled_default=False,
value_fn=lambda items: _int(_sensor_item_value(items, "CleanLevel")),
),
@@ -1592,7 +1572,7 @@ AIR_QUALITY = Capability(
field="x.com.samsung.da.items",
icon=icon,
entity_category="diagnostic",
exists_fn=_has_sensor_type(type_),
exists_fn=has_sensor_type(type_),
enabled_default=False,
value_fn=lambda items, t=type_: _sensor_item_value(items, t),
)
@@ -1618,7 +1598,7 @@ AIR_QUALITY = Capability(
device_class="carbon_dioxide",
state_class="measurement",
unit="ppm",
exists_fn=_has_sensor_type("CO2"),
exists_fn=has_sensor_type("CO2"),
enabled_default=False,
value_fn=lambda items: _int(_sensor_item_value(items, "CO2")),
),
@@ -299,6 +299,30 @@ def sensor_item_value(items, sensor_type, index=0):
return None
def has_sensor_type(type_):
"""True when /sensors/vs/0's items[] lists an item of this type.
This only proves the type is *listed*, not that the reading is real:
issue #166 (ARTIK051_PRAC_20K) lists all five types with permanent-zero
values on units the reporter confirmed don't have the hardware. So
entities gated on this stay disabled by default rather than
existence-gated further, to avoid silently dropping real readings on
hardware not yet seen.
is_stub_rep(rep) keeps the stub carve-out (see entity._is_included /
ENERGY_METER, issue #127): an explicit exists_fn otherwise bypasses it
and would drop the entity when /device/0 returns a not-yet-fetched stub.
"""
def fn(rep, resources):
return is_stub_rep(rep) or 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
# OCF-native / vendor '-vs' fallback pairs for power, kids-lock, remote
# control: each exists as both a standard OCF resource (/power/0,
# oic.r.switch.binary, plain boolean 'value') and a Samsung vendor
@@ -110,6 +110,7 @@ def test_co2_matches_air_monitor_mapping():
assert desc.unit == "ppm"
assert desc.state_class == "measurement"
assert desc.exists_fn is not None
assert desc.enabled_default is False
assert (
desc.value_fn([{"x.com.samsung.da.type": "CO2", "x.com.samsung.da.value": ["498"]}]) == 498
)
+4
View File
@@ -91,10 +91,14 @@ def test_co2_reads_when_the_items_list_includes_the_type():
desc = next(e for e in air_purifier.AIR_QUALITY.entities if e.key == "co2")
assert desc.exists_fn is not None
assert desc.enabled_default is False
assert desc.exists_fn({"x.com.samsung.da.items": []}, {}) is False
assert (
desc.exists_fn({"x.com.samsung.da.items": [{"x.com.samsung.da.type": "CO2"}]}, {}) is True
)
# /device/0 stub must not drop co2 while its field-gated siblings still
# register -- platforms enumerate bound once (issue #127).
assert desc.exists_fn({"href": "/sensors/vs/0"}, {}) is True
def test_filter_progress_reads_named_consumable_item():
+12 -1
View File
@@ -986,7 +986,7 @@ def test_air_quality_disabled_by_default():
SuperFineDust readings with no such scalar, so requiring it would
silently drop real readings on hardware this repo hasn't seen yet on an
AC. These stay bound whenever the item type is listed (see
_has_sensor_type) and disabled by default instead, same precedent as
has_sensor_type) and disabled by default instead, same precedent as
fridge.rack_count / cooktop.paired_hood_model / tropical_night_mode --
units that do have the sensor can enable it themselves."""
for key in ("clean_level", "odor", "dust", "fine_dust", "super_fine_dust"):
@@ -994,6 +994,17 @@ def test_air_quality_disabled_by_default():
assert desc.enabled_default is False, key
def test_air_quality_included_on_stub_rep():
"""exists_fn would otherwise drop every gated sensor when /device/0
returns a not-yet-fetched stub, while field-gated siblings still
register (issue #127)."""
stub = {"href": "/sensors/vs/0"}
for key in ("clean_level", "odor", "dust", "fine_dust", "super_fine_dust", "co2"):
desc = next(e for e in airconditioner.AIR_QUALITY.entities if e.key == key)
assert desc.exists_fn is not None
assert desc.exists_fn(stub, {}) is True, key
def test_air_quality_absent_when_no_sensor_items():
"""A board whose /sensors/vs/0 carries an empty items[] (the cool-only
RAC variant) binds no air-quality entities -- exists_fn gates each on its
+25
View File
@@ -417,6 +417,31 @@ class TestEnergyMeter:
assert pw.exists_fn({"x.com.samsung.da.cumulativePower": "5"}, {}) is False
# ---------------------------------------------------------------------------
# has_sensor_type. Presence gate for /sensors/vs/0 items[] types, shared by
# airconditioner.AIR_QUALITY and air_purifier.AIR_QUALITY. The stub carve-out
# is the same contract ENERGY_METER documents for issue #127.
# ---------------------------------------------------------------------------
class TestHasSensorType:
def test_true_when_type_is_listed(self):
fn = common.has_sensor_type("CO2")
assert fn({"x.com.samsung.da.items": [{"x.com.samsung.da.type": "CO2"}]}, {}) is True
def test_false_when_type_is_absent(self):
fn = common.has_sensor_type("CO2")
assert fn({"x.com.samsung.da.items": [{"x.com.samsung.da.type": "Dust"}]}, {}) is False
assert fn({"x.com.samsung.da.items": []}, {}) is False
assert fn({}, {}) is False
def test_true_on_stub_rep(self):
"""A true stub -- /device/0's {"href": "..."} "not fetched yet"
marker -- must keep the entity so sub-polls can populate it."""
fn = common.has_sensor_type("CO2")
assert fn({"href": "/sensors/vs/0"}, {}) is True
# ---------------------------------------------------------------------------
# AI energy-saving level. '0' is off; supportedAiLevel lists the additional
# level(s) on offer. A single-entry list (issue #21 fridge, issue #40 washer)