232 lines
7.8 KiB
Python
232 lines
7.8 KiB
Python
"""Tests for the T9 air-quality (HCHO / VOC) sensor support.
|
|
|
|
Covers what was added for the WSLink ``t9hcho`` / ``t9voclv`` / ``t9bat`` /
|
|
``t9cn`` parameters:
|
|
|
|
- the new constants (``REMAP_WSLINK_ITEMS``, ``CONNECTION_GATED_SENSORS``,
|
|
``BATTERY_NON_BINARY``, ``VOCLevel`` / ``VOC_LEVEL_MAP``)
|
|
- the ``utils.voc_level_to_text`` and ``utils.battery_5step_to_pct`` helpers
|
|
- the connection gating in ``utils.remap_wslink_items``
|
|
- the new ``SENSOR_TYPES_WSLINK`` entity descriptions
|
|
- the ``hcho`` / ``voc`` / ``t9_battery`` entries in the translation files
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from custom_components.sws12500.const import (
|
|
BATTERY_LIST,
|
|
BATTERY_NON_BINARY,
|
|
CONNECTION_GATED_SENSORS,
|
|
HCHO,
|
|
OUTSIDE_TEMP,
|
|
REMAP_WSLINK_ITEMS,
|
|
T9_BATTERY,
|
|
VOC,
|
|
VOC_LEVEL_MAP,
|
|
VOCLevel,
|
|
)
|
|
from custom_components.sws12500.sensors_wslink import SENSOR_TYPES_WSLINK
|
|
from custom_components.sws12500.utils import battery_5step_to_pct, remap_wslink_items, voc_level_to_text
|
|
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
|
|
from homeassistant.const import CONCENTRATION_PARTS_PER_BILLION, PERCENTAGE
|
|
|
|
# Realistic WSLink payload taken from an issue report: the station sends every
|
|
# parameter, even for channels with no sensor connected (``*cn == "0"``).
|
|
ISSUE_PAYLOAD = {
|
|
"rbar": "1013.3",
|
|
"intem": "25.0",
|
|
"inhum": "44",
|
|
"t1cn": "1",
|
|
"t1tem": "11.3",
|
|
"t1hum": "92",
|
|
"t234c1cn": "1",
|
|
"t234c2cn": "1",
|
|
"t234c3cn": "0",
|
|
"t8cn": "0",
|
|
"t9cn": "1",
|
|
"t9hcho": "57",
|
|
"t9voclv": "5",
|
|
"t9bat": "5",
|
|
"t10cn": "0",
|
|
"t11cn": "0",
|
|
"apiver": "1.00",
|
|
}
|
|
|
|
|
|
# --- constants -------------------------------------------------------------
|
|
|
|
|
|
def test_t9_keys_are_remapped() -> None:
|
|
assert REMAP_WSLINK_ITEMS["t9hcho"] == HCHO
|
|
assert REMAP_WSLINK_ITEMS["t9voclv"] == VOC
|
|
assert REMAP_WSLINK_ITEMS["t9bat"] == T9_BATTERY
|
|
# t9cn is intentionally NOT remapped - it is only used as a gating flag.
|
|
assert "t9cn" not in REMAP_WSLINK_ITEMS
|
|
|
|
|
|
def test_connection_gated_sensors_definition() -> None:
|
|
assert CONNECTION_GATED_SENSORS == {"t9cn": [HCHO, VOC, T9_BATTERY]}
|
|
|
|
|
|
def test_t9_battery_is_non_binary_only() -> None:
|
|
assert BATTERY_NON_BINARY == [T9_BATTERY]
|
|
# the 0-5 / percentage battery must not be treated as a binary low/normal one
|
|
assert T9_BATTERY not in BATTERY_LIST
|
|
|
|
|
|
def test_voc_level_map_is_complete_and_ordered() -> None:
|
|
# 1 == highest VOC reading (worst air) ... 5 == lowest VOC reading (best air)
|
|
assert set(VOC_LEVEL_MAP) == {1, 2, 3, 4, 5}
|
|
assert set(VOC_LEVEL_MAP.values()) == set(VOCLevel)
|
|
assert VOC_LEVEL_MAP[1] is VOCLevel.UNHEALTHY
|
|
assert VOC_LEVEL_MAP[5] is VOCLevel.EXCELENT
|
|
assert [member.value for member in VOCLevel] == [
|
|
"unhealthy",
|
|
"poor",
|
|
"moderate",
|
|
"good",
|
|
"excellent",
|
|
]
|
|
|
|
|
|
# --- voc_level_to_text -----------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize("empty", [None, ""])
|
|
def test_voc_level_to_text_handles_empty(empty) -> None:
|
|
assert voc_level_to_text(empty) is None
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("raw", "expected"),
|
|
[
|
|
("1", VOCLevel.UNHEALTHY),
|
|
("2", VOCLevel.POOR),
|
|
("3", VOCLevel.MODERATE),
|
|
("4", VOCLevel.GOOD),
|
|
("5", VOCLevel.EXCELENT),
|
|
(3, VOCLevel.MODERATE),
|
|
],
|
|
)
|
|
def test_voc_level_to_text_maps_known_levels(raw, expected) -> None:
|
|
assert voc_level_to_text(raw) == expected
|
|
|
|
|
|
@pytest.mark.parametrize("raw", ["0", "6", 0, 6])
|
|
def test_voc_level_to_text_out_of_range_is_none(raw) -> None:
|
|
assert voc_level_to_text(raw) is None
|
|
|
|
|
|
# --- battery_5step_to_pct --------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize("empty", [None, ""])
|
|
def test_battery_5step_to_pct_handles_empty(empty) -> None:
|
|
assert battery_5step_to_pct(empty) is None
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("raw", "expected"),
|
|
[("0", 0), ("1", 20), ("2", 40), ("3", 60), ("4", 80), ("5", 100), (5, 100)],
|
|
)
|
|
def test_battery_5step_to_pct_scales_to_percentage(raw, expected) -> None:
|
|
assert battery_5step_to_pct(raw) == expected
|
|
|
|
|
|
# --- remap_wslink_items connection gating ----------------------------------
|
|
|
|
|
|
def test_remap_keeps_t9_group_when_connected() -> None:
|
|
out = remap_wslink_items({"t9cn": "1", "t9hcho": "57", "t9voclv": "5", "t9bat": "5", "t1tem": "11.3"})
|
|
assert out[HCHO] == "57"
|
|
assert out[VOC] == "5"
|
|
assert out[T9_BATTERY] == "5"
|
|
assert out[OUTSIDE_TEMP] == "11.3"
|
|
|
|
|
|
@pytest.mark.parametrize("conn", [{"t9cn": "0"}, {}], ids=["disconnected", "absent"])
|
|
def test_remap_drops_t9_group_when_disconnected_or_absent(conn) -> None:
|
|
out = remap_wslink_items({**conn, "t9hcho": "57", "t9voclv": "5", "t9bat": "5", "t1tem": "11.3"})
|
|
assert HCHO not in out
|
|
assert VOC not in out
|
|
assert T9_BATTERY not in out
|
|
# unrelated sensors are untouched by the gating
|
|
assert out[OUTSIDE_TEMP] == "11.3"
|
|
|
|
|
|
def test_remap_issue_payload_exposes_t9_when_connected() -> None:
|
|
out = remap_wslink_items(ISSUE_PAYLOAD)
|
|
# t9cn == "1" -> the T9 sensors are exposed
|
|
assert out[HCHO] == "57"
|
|
assert out[VOC] == "5"
|
|
assert out[T9_BATTERY] == "5"
|
|
# connection flags never leak into the sensor data
|
|
assert "t9cn" not in out
|
|
assert "t9_conn" not in out
|
|
|
|
|
|
# --- sensor entity descriptions -------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def wslink_descriptions():
|
|
return {description.key: description for description in SENSOR_TYPES_WSLINK}
|
|
|
|
|
|
def test_hcho_entity_description(wslink_descriptions) -> None:
|
|
description = wslink_descriptions[HCHO]
|
|
assert description.translation_key == HCHO
|
|
assert description.device_class is SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS
|
|
assert description.native_unit_of_measurement == CONCENTRATION_PARTS_PER_BILLION
|
|
assert description.state_class is SensorStateClass.MEASUREMENT
|
|
# value_fn is a pass-through (typing.cast is a no-op at runtime; HA coerces the str)
|
|
assert description.value_fn("57") == "57"
|
|
|
|
|
|
def test_voc_entity_description(wslink_descriptions) -> None:
|
|
description = wslink_descriptions[VOC]
|
|
assert description.translation_key == VOC
|
|
assert description.device_class is SensorDeviceClass.ENUM
|
|
assert description.options == list(VOCLevel)
|
|
# ENUM sensors must not declare a state_class
|
|
assert description.state_class is None
|
|
assert description.value_fn("1") == VOCLevel.UNHEALTHY
|
|
assert description.value_fn("5") == "excellent"
|
|
assert description.value_fn(None) is None
|
|
|
|
|
|
def test_t9_battery_entity_description(wslink_descriptions) -> None:
|
|
description = wslink_descriptions[T9_BATTERY]
|
|
assert description.translation_key == T9_BATTERY
|
|
assert description.device_class is SensorDeviceClass.BATTERY
|
|
assert description.native_unit_of_measurement == PERCENTAGE
|
|
assert description.state_class is SensorStateClass.MEASUREMENT
|
|
assert description.suggested_display_precision == 0
|
|
# no explicit icon -> HA renders the battery icon from the device class + %
|
|
assert description.icon is None
|
|
assert description.value_fn("5") == 100
|
|
assert description.value_fn("0") == 0
|
|
assert description.value_fn(None) is None
|
|
|
|
|
|
# --- translation files -----------------------------------------------------
|
|
|
|
_TRANSLATIONS_DIR = Path(__file__).resolve().parents[1] / "custom_components" / "sws12500" / "translations"
|
|
|
|
|
|
@pytest.mark.parametrize("filename", ["en.json", "cs.json"])
|
|
def test_translation_files_have_t9_entries(filename) -> None:
|
|
sensors = json.loads((_TRANSLATIONS_DIR / filename).read_text(encoding="utf-8"))["entity"]["sensor"]
|
|
|
|
assert sensors["hcho"]["name"]
|
|
assert sensors["t9_battery"]["name"]
|
|
|
|
voc = sensors["voc"]
|
|
assert voc["name"]
|
|
assert set(voc["state"]) == {member.value for member in VOCLevel}
|