Merge pull request #390 from JayChickenK/air-purifier-co2

air_purifier: expose CO2 when the device lists it
This commit is contained in:
Marc Billow authored and GitHub committed 2026-08-19 20:35:00 -05:00
commit 16dfcd4e50
9 files changed
+155 -40

No files matched your search

@@ -7,8 +7,9 @@ only); this registry deliberately doesn't include common.POWER.
air_purifier.AIR_QUALITY and range_hood.AIR_QUALITY already read via
common.sensor_item_value -- reused here rather than re-decoded, including
the same dust/fine_dust/super_fine_dust/odor/clean_level keys so this
device shares those capabilities' catalog entries. This board additionally
reports a CO2 reading the other two families don't.
device shares those capabilities' catalog entries. This board reports a
CO2 reading; air_purifier.AIR_QUALITY now models the same type when a
purifier lists it (issue #387).
A second `value` list element on the particulate-matter types (Dust's
`['31', '2']`) is the device's own graded air-quality level for that
@@ -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
@@ -106,17 +112,35 @@ _AIR_QUALITY_SENSORS = (
AIR_QUALITY = Capability(
href="/sensors/vs/0",
poll_tier="warm",
entities=tuple(
entities=(
*(
SensorDesc(
key=key,
field="x.com.samsung.da.items",
icon=icon,
state_class=state_class,
device_class=device_class,
unit=unit,
value_fn=lambda items, t=sensor_type: sensor_item_value(items, t),
)
for key, icon, sensor_type, state_class, device_class, unit in _AIR_QUALITY_SENSORS
),
# 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. Disabled by default for the
# same reason as airconditioner.AIR_QUALITY (issue #166).
SensorDesc(
key=key,
key="co2",
field="x.com.samsung.da.items",
icon=icon,
state_class=state_class,
device_class=device_class,
unit=unit,
value_fn=lambda items, t=sensor_type: sensor_item_value(items, t),
)
for key, icon, sensor_type, state_class, device_class, unit in _AIR_QUALITY_SENSORS
icon="mdi:molecule-co2",
device_class="carbon_dioxide",
state_class="measurement",
unit="ppm",
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