Merge pull request #390 from JayChickenK/air-purifier-co2
air_purifier: expose CO2 when the device lists it
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -54,8 +54,8 @@ def test_no_unbound_hrefs():
|
||||
|
||||
def test_air_quality_sensors_read_from_shared_items_decode():
|
||||
"""Same /sensors/vs/0 {type, value} shape and common.sensor_item_value
|
||||
decode air_purifier.AIR_QUALITY already uses -- this board adds CO2,
|
||||
which neither air_purifier nor range_hood report."""
|
||||
decode air_purifier.AIR_QUALITY already uses -- this board lists CO2
|
||||
unconditionally, unlike the exists_fn-gated purifier descriptor."""
|
||||
state = _state()
|
||||
assert state["dust"] == 31
|
||||
assert state["fine_dust"] == 23
|
||||
|
||||
@@ -96,6 +96,26 @@ def test_air_monitor_takes_the_pm_labels_but_not_the_state_class():
|
||||
assert (desc.device_class, desc.unit) == (None, None), key
|
||||
|
||||
|
||||
def test_co2_is_not_in_the_shared_tuple():
|
||||
"""air_monitor already has its own CO2 SensorDesc. Putting CO2 in
|
||||
_AIR_QUALITY_SENSORS would create a second entity with the same key."""
|
||||
assert all(row[0] != "co2" for row in air_purifier._AIR_QUALITY_SENSORS)
|
||||
|
||||
|
||||
def test_co2_matches_air_monitor_mapping():
|
||||
"""ppm / carbon_dioxide is the same contract air_monitor.SENSORS already
|
||||
ships for this field (issue #387), not a unit guess."""
|
||||
desc = _desc("co2")
|
||||
assert desc.device_class == "carbon_dioxide"
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
def test_every_air_quality_sensor_still_reads_a_plain_int():
|
||||
"""A state_class is only honoured for a numeric state, so the value
|
||||
contract this depends on is asserted here too."""
|
||||
|
||||
@@ -69,6 +69,36 @@ def test_air_quality_sensor_values():
|
||||
assert state["super_fine_dust"] == 5
|
||||
assert state["odor"] == 0
|
||||
assert state["clean_level"] == 0
|
||||
assert "co2" not in state
|
||||
|
||||
|
||||
def test_co2_reads_when_the_items_list_includes_the_type():
|
||||
"""Issue #387 -- same {type, value} shape air_monitor.SENSORS already
|
||||
models. Gated so boards that don't list CO2 don't grow an empty entity."""
|
||||
reg, resources = _purifier()
|
||||
resources = {
|
||||
**resources,
|
||||
"/sensors/vs/0": {
|
||||
**resources["/sensors/vs/0"],
|
||||
"x.com.samsung.da.items": [
|
||||
*resources["/sensors/vs/0"]["x.com.samsung.da.items"],
|
||||
{"x.com.samsung.da.type": "CO2", "x.com.samsung.da.value": ["612"]},
|
||||
],
|
||||
},
|
||||
}
|
||||
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
|
||||
assert flatten(bound, resources)["co2"] == 612
|
||||
|
||||
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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user