From 624de521c4ee03ff1741da43b4466d65a784d5f3 Mon Sep 17 00:00:00 2001 From: SchiZzA Date: Sun, 26 Jul 2026 15:20:07 +0200 Subject: [PATCH] feat(wslink): implement every sensor the WSLink API v0.6 defines Coverage went from 54 of 107 upload parameters to all 107, adding roughly 34 entities. New families: Type5 lightning, Type6 water leak (CH1-7), Type8 particulate matter, Type10 CO2 and Type11 CO, plus the parameters missing from existing families - absolute pressure, feels-like temperature, the 10-minute average wind speed, and the per-channel probe type. Device classes follow Home Assistant's own list (PM25, PM10, AQI, CO2, CO, DISTANCE, ATMOSPHERIC_PRESSURE, WIND_SPEED, and binary MOISTURE for leaks), so the readings work with statistics and unit conversion without template helpers. Batteries were the trap. The API annotates two incompatible conventions - "(Normal=1, Low battery=0)" and "(0~5) remark: 5 is full" - and mixing them up is silent, because a level battery read as a flag reports "not low" for every level above empty. Eight new binary batteries (t5lsbat, t6c1-7bat) and three new level batteries (t8bat, t10bat, t11bat) are classified from the document, and tests/test_wslink_api_coverage.py pins each one to the wording it came from. Water leak and battery use opposite conventions in the same family (leak 1 means wet, battery 0 means low), so `on_value` moved into the description and one entity class now serves both instead of two that differ by a comparison. `t1cn` was in the API but wired to nothing, so a disconnected outdoor probe kept publishing its last readings. Gating it required fixing the gate first: an absent connection flag was treated as "disconnected", which would have blanked temperature, wind and rain outright on any firmware that omits `t1cn`. Absence is now treated as no information, and only an explicit non-1 drops the readings. The probe type (`t234cXtp`) decides whether a channel's humidity reading is air humidity or soil moisture, which are different device classes. It is resolved once, when the entity is created, because Home Assistant records the device class in the entity registry; swapping the physical probe means reinstalling. Two parameters are handled with a documented caveat: `inbat` is the only battery whose scale the API does not state and stays a 0/1 flag, and `t5lst` has no unit (the vendor example uses 9999) so it is read as minutes with 9999 meaning "nothing recorded". Also replaces the now-stale TODO block in const.py - it listed exactly these sensors as unimplemented - and documents the complete coverage in the README. --- README.md | 71 +++-- custom_components/sws12500/battery_sensors.py | 36 ++- .../sws12500/battery_sensors_def.py | 49 ++- custom_components/sws12500/binary_sensor.py | 16 +- custom_components/sws12500/const.py | 209 ++++++++++--- custom_components/sws12500/coordinator.py | 8 + custom_components/sws12500/data.py | 5 + custom_components/sws12500/sensor.py | 9 + custom_components/sws12500/sensors_common.py | 20 ++ custom_components/sws12500/sensors_wslink.py | 169 +++++++++- custom_components/sws12500/strings.json | 102 ++++++ .../sws12500/translations/cs.json | 102 ++++++ .../sws12500/translations/en.json | 102 ++++++ custom_components/sws12500/utils.py | 50 ++- tests/test_battery_classification.py | 17 +- tests/test_binary_battery.py | 26 +- tests/test_routes_more.py | 28 ++ tests/test_sensor_platform.py | 49 +++ tests/test_t9_air_quality.py | 20 +- tests/test_wslink_api_coverage.py | 292 ++++++++++++++++++ 20 files changed, 1252 insertions(+), 128 deletions(-) create mode 100644 tests/test_wslink_api_coverage.py diff --git a/README.md b/README.md index 3068e98..9d2747d 100644 --- a/README.md +++ b/README.md @@ -105,30 +105,65 @@ Web server repo is reachable here: low battery (binary sensor is ``on``) - - ``1`` => battery OK (binary sensor is ``off``) + Battery: the station sends ``0`` for low, and ``BinarySensorDeviceClass.BATTERY`` + means ``on`` = low, so ``on_value`` is 0. + + Water leak: the station sends ``1`` for a leak, and + ``BinarySensorDeviceClass.MOISTURE`` means ``on`` = wet, so ``on_value`` is 1. """ + entity_description: WSBinarySensorEntityDescription # pyright: ignore[reportIncompatibleVariableOverride] + _attr_has_entity_name = True _attr_should_poll = False def __init__( self, coordinator: Any, - description: BinarySensorEntityDescription, + description: WSBinarySensorEntityDescription, ) -> None: - """Initialize the battery binary sensor.""" + """Initialize the binary sensor.""" super().__init__(coordinator) - self.entity_description = description + self.entity_description = description # pyright: ignore[reportIncompatibleVariableOverride] self._attr_unique_id = f"{description.key}_binary" @property def is_on(self) -> bool | None: # pyright: ignore[reportIncompatibleVariableOverride] - """Return low-battery state. - - ``True`` means low battery for ``BinarySensorDeviceClass.BATTERY``. - """ + """Return whether the reading matches this sensor's `on` value.""" data = checked_or(self.coordinator.data, dict[str, Any], {}) raw: Any = data.get(self.entity_description.key) @@ -57,7 +61,7 @@ class BatteryBinarySensor( # pyright: ignore[reportIncompatibleVariableOverride except (TypeError, ValueError): return None - return value == 0 + return value == self.entity_description.on_value @cached_property def device_info(self) -> DeviceInfo: diff --git a/custom_components/sws12500/battery_sensors_def.py b/custom_components/sws12500/battery_sensors_def.py index f067419..ba43b4b 100644 --- a/custom_components/sws12500/battery_sensors_def.py +++ b/custom_components/sws12500/battery_sensors_def.py @@ -1,39 +1,60 @@ -"""Battery sensor templates. +"""Generated entity descriptions for the WSLink binary and battery families. -Entity descriptions for both battery kinds are *generated* from the classification -tuples in `const`, so those tuples are the single source of truth: +Everything here is derived from the classification tuples in `const`, so those +tuples stay the single source of truth: - ``BATTERY_LIST`` -> 0/1 low/normal -> binary sensors - ``BATTERY_NON_BINARY`` -> 0-5 level -> percentage sensors +- ``LEAK_CH`` -> 1/0 leak/dry -> binary sensors -Generating both from one place is what prevents the conflict: a key listed in both -tuples would otherwise get a binary sensor (collapsing 0-5 into low/not-low and -throwing away four fifths of the reading) *and* a percentage sensor for the same -battery. `tests/test_battery_classification.py` fails if the tuples ever overlap or -if a `*_BATTERY` constant is left unclassified. +Generating them from one place is what prevents a key ending up with two +conflicting entities; `tests/test_battery_classification.py` fails if the battery +tuples ever overlap or if a `*_BATTERY` constant is left unclassified. -Which of these are actually loaded is gated in the coordinator by SENSORS_TO_LOAD. +Which of these are actually loaded is gated in the coordinator by SENSORS_TO_LOAD, +and per-probe by CONNECTION_GATED_SENSORS. """ from __future__ import annotations -from homeassistant.components.binary_sensor import BinarySensorDeviceClass, BinarySensorEntityDescription +from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.const import PERCENTAGE -from .const import BATTERY_LIST, BATTERY_NON_BINARY -from .sensors_common import WeatherSensorEntityDescription +from .const import BATTERY_LIST, BATTERY_NON_BINARY, LEAK_CH +from .sensors_common import WeatherSensorEntityDescription, WSBinarySensorEntityDescription from .utils import battery_5step_to_pct -BATTERY_BINARY_SENSORS: tuple[BinarySensorEntityDescription, ...] = tuple( - BinarySensorEntityDescription( +# The station reports 0 for a low battery, and BinarySensorDeviceClass.BATTERY +# defines `on` as "low" - hence on_value 0. +BATTERY_BINARY_SENSORS: tuple[WSBinarySensorEntityDescription, ...] = tuple( + WSBinarySensorEntityDescription( key=key, translation_key=key, device_class=BinarySensorDeviceClass.BATTERY, + on_value=0, ) for key in BATTERY_LIST ) +# The station reports 1 for a leak, and BinarySensorDeviceClass.MOISTURE defines +# `on` as "wet" - hence on_value 1. +LEAK_BINARY_SENSORS: tuple[WSBinarySensorEntityDescription, ...] = tuple( + WSBinarySensorEntityDescription( + key=key, + translation_key=key, + device_class=BinarySensorDeviceClass.MOISTURE, + on_value=1, + ) + for key in LEAK_CH +) + +# Every binary family the WSLink platform exposes. +WSLINK_BINARY_SENSORS: tuple[WSBinarySensorEntityDescription, ...] = ( + *BATTERY_BINARY_SENSORS, + *LEAK_BINARY_SENSORS, +) + BATTERY_LEVEL_SENSORS: tuple[WeatherSensorEntityDescription, ...] = tuple( WeatherSensorEntityDescription( key=key, diff --git a/custom_components/sws12500/binary_sensor.py b/custom_components/sws12500/binary_sensor.py index c432a99..f7e16aa 100644 --- a/custom_components/sws12500/binary_sensor.py +++ b/custom_components/sws12500/binary_sensor.py @@ -12,8 +12,8 @@ from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .battery_sensors import BatteryBinarySensor -from .battery_sensors_def import BATTERY_BINARY_SENSORS +from .battery_sensors import WSBinarySensor +from .battery_sensors_def import WSLINK_BINARY_SENSORS from .const import SENSORS_TO_LOAD from .data import SWSConfigEntry @@ -23,7 +23,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, entry: SWSConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: - """Set up battery binary sensors.""" + """Set up the binary sensors (battery and water leak).""" del hass @@ -32,16 +32,16 @@ async def async_setup_entry( # Persist platform callback + description map for dynamic entity creation. runtime.add_binary_entities = async_add_entities - runtime.binary_descriptions = {desc.key: desc for desc in BATTERY_BINARY_SENSORS} + runtime.binary_descriptions = {desc.key: desc for desc in WSLINK_BINARY_SENSORS} runtime.added_binary_keys = set() # Initial entities for battery keys that station already reports. # `SENSORS_TO_LOAD` accumulates all discovered keys across runs. loaded = set(entry.options.get(SENSORS_TO_LOAD, [])) - entities: list[BatteryBinarySensor] = [] - for desc in BATTERY_BINARY_SENSORS: + entities: list[WSBinarySensor] = [] + for desc in WSLINK_BINARY_SENSORS: if desc.key in loaded: - entities.append(BatteryBinarySensor(coordinator, desc)) + entities.append(WSBinarySensor(coordinator, desc)) runtime.added_binary_keys.add(desc.key) if entities: @@ -76,7 +76,7 @@ def add_new_binary_sensors(hass: HomeAssistant, entry: SWSConfigEntry, keys: lis if (desc := descriptions.get(key)) is None: continue - new_entities.append(BatteryBinarySensor(coordinator, desc)) + new_entities.append(WSBinarySensor(coordinator, desc)) added.add(key) if new_entities: diff --git a/custom_components/sws12500/const.py b/custom_components/sws12500/const.py index 6534788..23af8b5 100644 --- a/custom_components/sws12500/const.py +++ b/custom_components/sws12500/const.py @@ -87,6 +87,50 @@ VOC: Final = "voc" T9_BATTERY: Final = "t9_battery" # T9 sensors are HCHO and VOC T9_CONN: Final = "t9_conn" +# --- Base console (WSLink API v0.6, no sensor type) ------------------------- +ABS_PRESSURE: Final = "abs_pressure" # abar + +# --- Type1 outdoor ---------------------------------------------------------- +FEELS_LIKE: Final = "feels_like" # t1feels +WIND_SPEED_AVG10: Final = "wind_speed_avg10" # t1ws10mav + +# --- Type5 lightning -------------------------------------------------------- +# `t5lst` is an integer the API documents only as "Last Lightning strike time"; +# it is not an epoch (the vendor example shows 9999), so it is treated as minutes +# elapsed, with 9999 as the "no strike recorded" sentinel - see `lightning_minutes`. +LIGHTNING_LAST: Final = "lightning_last" # t5lst +LIGHTNING_DISTANCE: Final = "lightning_distance" # t5lskm +# The API lists both `t5lsf` ("strike count last 1 Hours") and `t5ls1htc` ("count +# total of during 1 Hour"). They overlap; both are exposed rather than guessing +# which one a given firmware populates. +LIGHTNING_STRIKES_1H: Final = "lightning_strikes_1h" # t5lsf +LIGHTNING_COUNT_5M: Final = "lightning_count_5m" # t5ls5mtc +LIGHTNING_COUNT_30M: Final = "lightning_count_30m" # t5ls30mtc +LIGHTNING_COUNT_1H: Final = "lightning_count_1h" # t5ls1htc +LIGHTNING_COUNT_1D: Final = "lightning_count_1d" # t5ls1dtc +LIGHTNING_BATTERY: Final = "lightning_battery" # t5lsbat (0/1) + +# --- Type6 water leak, CH1-7 ------------------------------------------------ +# Numbered as the API does (CH1-7). The temp/humidity channels use a legacy +# off-by-one (integration CH2 == WSLink CH1); that quirk is not repeated here. +LEAK_CH: Final[tuple[str, ...]] = tuple(f"leak_ch{ch}" for ch in range(1, 8)) +LEAK_CH_BATTERY: Final[tuple[str, ...]] = tuple(f"leak_ch{ch}_battery" for ch in range(1, 8)) + +# --- Type8 particulate matter ---------------------------------------------- +PM25: Final = "pm25" # t8pm25 +PM10: Final = "pm10" # t8pm10 +PM25_AQI: Final = "pm25_aqi" # t8pm25ai +PM10_AQI: Final = "pm10_aqi" # t8pm10ai +T8_BATTERY: Final = "t8_battery" # t8bat (0-5) + +# --- Type10 CO2 ------------------------------------------------------------- +CO2: Final = "co2" # t10co2 +T10_BATTERY: Final = "t10_battery" # t10bat (0-5) + +# --- Type11 CO -------------------------------------------------------------- +CO: Final = "co" # t11co +T11_BATTERY: Final = "t11_battery" # t11bat (0-5) + # Health specific constants HEALTH_URL = "/station/health" @@ -357,6 +401,35 @@ REMAP_WSLINK_ITEMS: dict[str, str] = { "t9hcho": HCHO, "t9voclv": VOC, "t9bat": T9_BATTERY, # T9 battery is 0-5, where 5 is full + # --- base console ----------------------------------------------------- + "abar": ABS_PRESSURE, + # --- Type1 outdoor ---------------------------------------------------- + "t1feels": FEELS_LIKE, + "t1ws10mav": WIND_SPEED_AVG10, + # --- Type5 lightning -------------------------------------------------- + "t5lst": LIGHTNING_LAST, + "t5lskm": LIGHTNING_DISTANCE, + "t5lsf": LIGHTNING_STRIKES_1H, + "t5ls5mtc": LIGHTNING_COUNT_5M, + "t5ls30mtc": LIGHTNING_COUNT_30M, + "t5ls1htc": LIGHTNING_COUNT_1H, + "t5ls1dtc": LIGHTNING_COUNT_1D, + "t5lsbat": LIGHTNING_BATTERY, + # --- Type6 water leak CH1-7 ------------------------------------------- + **{f"t6c{ch}wls": LEAK_CH[ch - 1] for ch in range(1, 8)}, + **{f"t6c{ch}bat": LEAK_CH_BATTERY[ch - 1] for ch in range(1, 8)}, + # --- Type8 particulate matter ----------------------------------------- + "t8pm25": PM25, + "t8pm10": PM10, + "t8pm25ai": PM25_AQI, + "t8pm10ai": PM10_AQI, + "t8bat": T8_BATTERY, + # --- Type10 CO2 ------------------------------------------------------- + "t10co2": CO2, + "t10bat": T10_BATTERY, + # --- Type11 CO -------------------------------------------------------- + "t11co": CO, + "t11bat": T11_BATTERY, } # NOTE: Add more sensors @@ -369,54 +442,21 @@ REMAP_WSLINK_ITEMS: dict[str, str] = { # We need to compare them to PWS API to make sure, we have the same internal # representation of same sensors. -### TODO: These are sensors, that should be supported in WSLink API according to their API documentation: -# &t5lst= Last Lightning strike time integer -# &t5lskm= Lightning distance integer km -# &t5lsf= Lightning strike count last 1 Hours integer -# &t5ls5mtc= Lightning count total of during 5 minutes integer -# &t5ls30mtc= Lightning count total of during 30 minutes integer -# &t5ls1htc= Lightning count total of during 1 Hour integer -# &t5ls1dtc= Lightning count total of during 1 day integer -# &t5lsbat= Lightning Sensor battery (Normal=1, Low battery=0) integer -# &t5lscn= Lightning Sensor connection (Connected=1, No connect=0) integer -# &t6c1wls= Water leak sensor CH1 (Leak=1, No leak=0) integer -# &t6c1bat= Water leak sensor CH1 battery (Normal=1, Low battery=0) integer -# &t6c1cn= Water leak sensor CH1 connection (Connected=1, No connect=0) integer -# &t6c2wls= Water leak sensor CH2 (Leak=1, No leak=0) integer -# &t6c2bat= Water leak sensor CH2 battery (Normal=1, Low battery=0) integer -# &t6c2cn= Water leak sensor CH2 connection (Connected=1, No connect=0) integer -# &t6c3wls= Water leak sensor CH3 (Leak=1, No leak=0) integer -# &t6c3bat= Water leak sensor CH3 battery (Normal=1, Low battery=0) integer -# &t6c3cn= Water leak sensor CH3 connection (Connected=1, No connect=0) integer -# &t6c4wls= Water leak sensor CH4 (Leak=1, No leak=0) integer -# &t6c4bat= Water leak sensor CH4 battery (Normal=1, Low battery=0) integer -# &t6c4cn= Water leak sensor CH4 connection (Connected=1, No connect=0) integer -# &t6c5wls= Water leak sensor CH5 (Leak=1, No leak=0) integer -# &t6c5bat= Water leak sensor CH5 battery (Normal=1, Low battery=0) integer -# &t6c5cn= Water leak sensor CH5 connection (Connected=1, No connect=0) integer -# &t6c6wls= Water leak sensor CH6 (Leak=1, No leak=0) integer -# &t6c6bat= Water leak sensor CH6 battery (Normal=1, Low battery=0) integer -# &t6c6cn= Water leak sensor CH6 connection (Connected=1, No connect=0) integer -# &t6c7wls= Water leak sensor CH7 (Leak=1, No leak=0) integer -# &t6c7bat= Water leak sensor CH7 battery (Normal=1, Low battery=0) integer -# &t6c7cn= Water leak sensor CH7 connection (Connected=1, No connect=0) integer -# &t8pm25= PM2.5 concentration integer ug/m3 -# &t8pm10= PM10 concentration integer ug/m3 -# &t8pm25ai= PM2.5 AQI integer -# &t8pm10ai = PM10 AQI integer -# &t8bat= PM sensor battery level (0~5) remark: 5 is full integer -# &t8cn= PM sensor connection (Connected=1, No connect=0) integer -# &t9hcho= HCHO concentration integer ppb -# &t9voclv= VOC level (1~5) 1 is the highest level, 5 is the lowest VOC level integer -# &t9bat= HCHO / VOC sensor battery level (0~5) remark: 5 is full integer -# &t9cn= HCHO / VOC sensor connection (Connected=1, No connect=0) integer -# &t10co2= CO2 concentration integer ppm -# &t10bat= CO2 sensor battery level (0~5) remark: 5 is full integer -# &t10cn= CO2 sensor connection (Connected=1, No connect=0) integer -# &t11co= CO concentration integer ppm -# &t11bat= CO sensor battery level (0~5) remark: 5 is full integer -# &t11cn= CO sensor connection (Connected=1, No connect=0) integer +### WSLink API v0.6 coverage # +# Every upload parameter the API document defines is now handled: base console, +# Type1 outdoor, Type2/3/4 channels, Type5 lightning, Type6 water leak, Type8 +# particulate matter, Type9 HCHO/VOC, Type10 CO2 and Type11 CO. +# +# `tests/test_wslink_api_coverage.py` pins that list, so a parameter cannot be +# dropped by a refactor and a future API revision fails the suite until its new +# parameters are mapped here. +# +# Two are handled with a documented caveat, because the API does not define them: +# - `inbat` is the only battery with no stated scale; it is read as 0/1 like the +# other wireless probes (see BATTERY_LIST). +# - `t5lst` has no unit and the vendor example uses 9999; it is read as minutes +# elapsed with 9999 meaning "nothing recorded" (see `lightning_minutes`). # How the station reports each battery decides which entity it becomes. The two tuples @@ -424,9 +464,12 @@ REMAP_WSLINK_ITEMS: dict[str, str] = { # both entity description sets from them, so a key cannot end up with two entities. # # They must stay disjoint, and every `*_BATTERY` constant must appear in exactly one of -# them; `tests/test_battery_classification.py` enforces both. That matters because the -# WSLink API has more of each kind still to be implemented (see the TODO block above): -# `t5lsbat` / `t6c1-7bat` are 0/1, while `t8bat` / `t10bat` / `t11bat` are 0-5. +# them; `tests/test_battery_classification.py` enforces both, and +# `tests/test_wslink_api_coverage.py` additionally pins each battery to the wording of +# the API document. That matters because getting it wrong is silent: a 0-5 battery read +# as a 0/1 flag reports "not low" for every level above empty, discarding most of the +# reading. The API annotates the difference explicitly - "(Normal=1, Low battery=0)" +# versus "(0~5) remark: 5 is full" - so a new battery only has to be read carefully. # Reported as 0/1 (low/normal) -> BinarySensorDeviceClass.BATTERY. BATTERY_LIST: Final[tuple[str, ...]] = ( @@ -439,13 +482,58 @@ BATTERY_LIST: Final[tuple[str, ...]] = ( CH6_BATTERY, CH7_BATTERY, CH8_BATTERY, + LIGHTNING_BATTERY, + *LEAK_CH_BATTERY, ) # Reported as a 0-5 level, 5 being full -> percentage SensorDeviceClass.BATTERY. -BATTERY_NON_BINARY: Final[tuple[str, ...]] = (T9_BATTERY,) +BATTERY_NON_BINARY: Final[tuple[str, ...]] = (T8_BATTERY, T9_BATTERY, T10_BATTERY, T11_BATTERY) + + +# Which raw `t234cXtp` parameter describes the probe behind each channel humidity +# reading. The API's compatible-sensor list maps the value to a probe kind: +# 2 = thermo-hygrometer, 3 = pool sensor, 4 = soil moisture & temperature +# A soil probe reports soil moisture, not air humidity, which is a different Home +# Assistant device class - see `channel_humidity_device_class`. +CH_HUMIDITY_TYPE_PARAM: Final[dict[str, str]] = { + CH2_HUMIDITY: "t234c1tp", + CH3_HUMIDITY: "t234c2tp", + CH4_HUMIDITY: "t234c3tp", + CH5_HUMIDITY: "t234c4tp", + CH6_HUMIDITY: "t234c5tp", + CH7_HUMIDITY: "t234c6tp", + CH8_HUMIDITY: "t234c7tp", +} + +# `t234cXtp` value for a soil moisture & temperature probe. +CH_TYPE_SOIL: Final = 4 CONNECTION_GATED_SENSORS: Final[dict[str, list[str]]] = { + # Main outdoor probe (Type1). A payload that omits `t1cn` is not gated at all - + # see `remap_wslink_items` - so this cannot blank a station that never reports it. + "t1cn": [ + OUTSIDE_TEMP, + OUTSIDE_HUMIDITY, + FEELS_LIKE, + CHILL_INDEX, + HEAT_INDEX, + DEW_POINT, + WIND_DIR, + WIND_SPEED, + WIND_SPEED_AVG10, + WIND_GUST, + RAIN, + HOURLY_RAIN, + DAILY_RAIN, + WEEKLY_RAIN, + MONTHLY_RAIN, + YEARLY_RAIN, + UV, + SOLAR_RADIATION, + WBGT_TEMP, + OUTSIDE_BATTERY, + ], # Multi-channel temp/humidity probes (CH2 - CH8) "t234c1cn": [CH2_TEMP, CH2_HUMIDITY, CH2_BATTERY], "t234c2cn": [CH3_TEMP, CH3_HUMIDITY, CH3_BATTERY], @@ -456,6 +544,25 @@ CONNECTION_GATED_SENSORS: Final[dict[str, list[str]]] = { "t234c7cn": [CH8_TEMP, CH8_HUMIDITY, CH8_BATTERY], # T9 HCHO/VOC probe "t9cn": [HCHO, VOC, T9_BATTERY], + # Lightning probe + "t5lscn": [ + LIGHTNING_LAST, + LIGHTNING_DISTANCE, + LIGHTNING_STRIKES_1H, + LIGHTNING_COUNT_5M, + LIGHTNING_COUNT_30M, + LIGHTNING_COUNT_1H, + LIGHTNING_COUNT_1D, + LIGHTNING_BATTERY, + ], + # Water leak probes CH1-7 + **{f"t6c{ch}cn": [LEAK_CH[ch - 1], LEAK_CH_BATTERY[ch - 1]] for ch in range(1, 8)}, + # Particulate matter probe + "t8cn": [PM25, PM10, PM25_AQI, PM10_AQI, T8_BATTERY], + # CO2 probe + "t10cn": [CO2, T10_BATTERY], + # CO probe + "t11cn": [CO, T11_BATTERY], } diff --git a/custom_components/sws12500/coordinator.py b/custom_components/sws12500/coordinator.py index 3ca6b63..2599127 100644 --- a/custom_components/sws12500/coordinator.py +++ b/custom_components/sws12500/coordinator.py @@ -32,6 +32,7 @@ from .binary_sensor import add_new_binary_sensors from .const import ( API_ID, API_KEY, + CH_HUMIDITY_TYPE_PARAM, DEV_DBG, DOMAIN, ECOWITT_ENABLED, @@ -277,6 +278,13 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator): remaped_items: dict[str, str] = remap_wslink_items(data) if _wslink else remap_items(data) + # The probe type per channel is not a reading, so it is not remapped - but a + # sensor created later needs it to tell soil moisture from air humidity. + if _wslink: + self.config.runtime_data.channel_types = { + param: data[param] for param in CH_HUMIDITY_TYPE_PARAM.values() if param in data + } + if sensors := check_disabled(remaped_items, self.config): # Resolve each sensor's display name once (the previous comprehension # awaited translations() twice per key). diff --git a/custom_components/sws12500/data.py b/custom_components/sws12500/data.py index 02f16c6..daceada 100644 --- a/custom_components/sws12500/data.py +++ b/custom_components/sws12500/data.py @@ -61,6 +61,11 @@ class SWSRuntimeData: # Ecowitt station model (e.g. "GW1000"), learned from the first Ecowitt payload. ecowitt_model: str | None = None + # Raw `t234cXtp` values from the last WSLink payload. They decide whether a + # channel's humidity reading is air humidity or soil moisture, and are read when + # the entity is created (see `utils.channel_humidity_device_class`). + channel_types: dict[str, str] = field(default_factory=dict) + # Type alias for typed ConfigEntry type SWSConfigEntry = ConfigEntry[SWSRuntimeData] diff --git a/custom_components/sws12500/sensor.py b/custom_components/sws12500/sensor.py index 5858276..2042f5a 100644 --- a/custom_components/sws12500/sensor.py +++ b/custom_components/sws12500/sensor.py @@ -45,6 +45,7 @@ from .data import SWSConfigEntry, build_device_info from .sensors_common import WeatherSensorEntityDescription from .sensors_weather import SENSOR_TYPES_WEATHER_API from .sensors_wslink import SENSOR_TYPES_WSLINK +from .utils import channel_humidity_device_class if TYPE_CHECKING: from .coordinator import WeatherDataUpdateCoordinator @@ -173,6 +174,14 @@ class WeatherSensor( # pyright: ignore[reportIncompatibleVariableOverride] self.entity_description = description # pyright: ignore[reportIncompatibleVariableOverride] type: ignore[assignment] self._dev_log = checked_or(coordinator.config.options.get(DEV_DBG), bool, False) + # A multi-channel humidity reading is soil moisture when the probe says so. + # Resolved once here rather than in the (frozen, shared) description, because + # it depends on which probe the user actually plugged into that channel. + runtime = getattr(coordinator.config, "runtime_data", None) + channel_types = getattr(runtime, "channel_types", None) or {} + if (device_class := channel_humidity_device_class(channel_types, description.key)) is not None: + self._attr_device_class = device_class + @property def native_value(self): # pyright: ignore[reportIncompatibleVariableOverride] """Return the current sensor state. diff --git a/custom_components/sws12500/sensors_common.py b/custom_components/sws12500/sensors_common.py index f5d0ea9..98cdeef 100644 --- a/custom_components/sws12500/sensors_common.py +++ b/custom_components/sws12500/sensors_common.py @@ -6,6 +6,7 @@ from collections.abc import Callable from dataclasses import dataclass from typing import Any +from homeassistant.components.binary_sensor import BinarySensorEntityDescription from homeassistant.components.sensor import SensorEntityDescription from .const import VOCLevel @@ -21,3 +22,22 @@ class WeatherSensorEntityDescription(SensorEntityDescription): deprecated: bool = False replacement_entity_domain: str | None = None replacement_entity_key: str | None = None + + +@dataclass(frozen=True, kw_only=True) +class WSBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describe a WSLink binary sensor. + + `on_value` is the raw payload value that means `on`, because the station's + conventions differ per family and so do Home Assistant's: + + - a battery reports ``0`` for low, and ``BinarySensorDeviceClass.BATTERY`` + defines ``on`` as "low" + - a water leak sensor reports ``1`` for a leak, and + ``BinarySensorDeviceClass.MOISTURE`` defines ``on`` as "wet" + + Keeping it in the description means one entity class serves both instead of + two near-identical ones that differ only in a comparison. + """ + + on_value: int diff --git a/custom_components/sws12500/sensors_wslink.py b/custom_components/sws12500/sensors_wslink.py index f5c8623..ab9ebd8 100644 --- a/custom_components/sws12500/sensors_wslink.py +++ b/custom_components/sws12500/sensors_wslink.py @@ -4,20 +4,25 @@ from __future__ import annotations from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, DEGREE, PERCENTAGE, UV_INDEX, UnitOfIrradiance, + UnitOfLength, UnitOfPrecipitationDepth, UnitOfPressure, UnitOfSpeed, UnitOfTemperature, + UnitOfTime, UnitOfVolumetricFlux, ) from .battery_sensors_def import BATTERY_LEVEL_SENSORS from .const import ( + ABS_PRESSURE, BARO_PRESSURE, CH2_BATTERY, CH2_HUMIDITY, @@ -41,18 +46,32 @@ from .const import ( CH8_HUMIDITY, CH8_TEMP, CHILL_INDEX, + CO, + CO2, DAILY_RAIN, DEW_POINT, + FEELS_LIKE, HCHO, HEAT_INDEX, HOURLY_RAIN, INDOOR_BATTERY, INDOOR_HUMIDITY, INDOOR_TEMP, + LIGHTNING_COUNT_1D, + LIGHTNING_COUNT_1H, + LIGHTNING_COUNT_5M, + LIGHTNING_COUNT_30M, + LIGHTNING_DISTANCE, + LIGHTNING_LAST, + LIGHTNING_STRIKES_1H, MONTHLY_RAIN, OUTSIDE_BATTERY, OUTSIDE_HUMIDITY, OUTSIDE_TEMP, + PM10, + PM10_AQI, + PM25, + PM25_AQI, RAIN, SOLAR_RADIATION, UV, @@ -63,6 +82,7 @@ from .const import ( WIND_DIR, WIND_GUST, WIND_SPEED, + WIND_SPEED_AVG10, YEARLY_RAIN, UnitOfBat, UnitOfDir, @@ -71,6 +91,7 @@ from .const import ( from .sensors_common import WeatherSensorEntityDescription from .utils import ( battery_level, + lightning_minutes, to_float, to_int, voc_level_to_text, @@ -553,7 +574,151 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = ( icon="mdi:air-filter", value_fn=voc_level_to_text, ), - # 0-5 level batteries are generated from BATTERY_NON_BINARY so the classification - # lives in exactly one place (see battery_sensors_def). + # --- Base console ------------------------------------------------------- + WeatherSensorEntityDescription( + key=ABS_PRESSURE, + native_unit_of_measurement=UnitOfPressure.HPA, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE, + icon="mdi:gauge", + translation_key=ABS_PRESSURE, + value_fn=to_float, + ), + # --- Type1 outdoor ------------------------------------------------------ + WeatherSensorEntityDescription( + key=FEELS_LIKE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + suggested_display_precision=2, + icon="mdi:thermometer", + translation_key=FEELS_LIKE, + value_fn=to_float, + ), + WeatherSensorEntityDescription( + key=WIND_SPEED_AVG10, + native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.WIND_SPEED, + suggested_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + icon="mdi:weather-windy", + translation_key=WIND_SPEED_AVG10, + value_fn=to_float, + ), + # --- Type5 lightning ---------------------------------------------------- + WeatherSensorEntityDescription( + key=LIGHTNING_LAST, + native_unit_of_measurement=UnitOfTime.MINUTES, + icon="mdi:flash-alert", + translation_key=LIGHTNING_LAST, + # No device class: the API does not define an epoch, so this cannot be a + # TIMESTAMP (which HA requires to be a tz-aware datetime). + value_fn=lightning_minutes, + ), + WeatherSensorEntityDescription( + key=LIGHTNING_DISTANCE, + native_unit_of_measurement=UnitOfLength.KILOMETERS, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DISTANCE, + icon="mdi:flash", + translation_key=LIGHTNING_DISTANCE, + value_fn=to_float, + ), + WeatherSensorEntityDescription( + key=LIGHTNING_STRIKES_1H, + # Rolling windows, not monotonic totals, so MEASUREMENT rather than + # TOTAL_INCREASING - the latter would make long-term statistics meaningless. + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:flash", + translation_key=LIGHTNING_STRIKES_1H, + value_fn=to_int, + ), + WeatherSensorEntityDescription( + key=LIGHTNING_COUNT_5M, + # Rolling windows, not monotonic totals, so MEASUREMENT rather than + # TOTAL_INCREASING - the latter would make long-term statistics meaningless. + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:flash", + translation_key=LIGHTNING_COUNT_5M, + value_fn=to_int, + ), + WeatherSensorEntityDescription( + key=LIGHTNING_COUNT_30M, + # Rolling windows, not monotonic totals, so MEASUREMENT rather than + # TOTAL_INCREASING - the latter would make long-term statistics meaningless. + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:flash", + translation_key=LIGHTNING_COUNT_30M, + value_fn=to_int, + ), + WeatherSensorEntityDescription( + key=LIGHTNING_COUNT_1H, + # Rolling windows, not monotonic totals, so MEASUREMENT rather than + # TOTAL_INCREASING - the latter would make long-term statistics meaningless. + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:flash", + translation_key=LIGHTNING_COUNT_1H, + value_fn=to_int, + ), + WeatherSensorEntityDescription( + key=LIGHTNING_COUNT_1D, + # Rolling windows, not monotonic totals, so MEASUREMENT rather than + # TOTAL_INCREASING - the latter would make long-term statistics meaningless. + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:flash", + translation_key=LIGHTNING_COUNT_1D, + value_fn=to_int, + ), + # --- Type8 particulate matter ------------------------------------------- + WeatherSensorEntityDescription( + key=PM25, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.PM25, + translation_key=PM25, + value_fn=to_int, + ), + WeatherSensorEntityDescription( + key=PM10, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.PM10, + translation_key=PM10, + value_fn=to_int, + ), + WeatherSensorEntityDescription( + key=PM25_AQI, + # SensorDeviceClass.AQI takes no unit of measurement. + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.AQI, + translation_key=PM25_AQI, + value_fn=to_int, + ), + WeatherSensorEntityDescription( + key=PM10_AQI, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.AQI, + translation_key=PM10_AQI, + value_fn=to_int, + ), + # --- Type10 CO2 --------------------------------------------------------- + WeatherSensorEntityDescription( + key=CO2, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CO2, + translation_key=CO2, + value_fn=to_int, + ), + # --- Type11 CO ---------------------------------------------------------- + WeatherSensorEntityDescription( + key=CO, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CO, + translation_key=CO, + value_fn=to_int, + ), + # 0-5 level batteries (t8/t9/t10/t11) are generated from BATTERY_NON_BINARY. *BATTERY_LEVEL_SENSORS, ) diff --git a/custom_components/sws12500/strings.json b/custom_components/sws12500/strings.json index 7edce2d..6a1efd8 100644 --- a/custom_components/sws12500/strings.json +++ b/custom_components/sws12500/strings.json @@ -175,6 +175,51 @@ }, "ch8_battery": { "name": "Channel 8 battery" + }, + "lightning_battery": { + "name": "Lightning sensor battery" + }, + "leak_ch1": { + "name": "Water leak CH1" + }, + "leak_ch2": { + "name": "Water leak CH2" + }, + "leak_ch3": { + "name": "Water leak CH3" + }, + "leak_ch4": { + "name": "Water leak CH4" + }, + "leak_ch5": { + "name": "Water leak CH5" + }, + "leak_ch6": { + "name": "Water leak CH6" + }, + "leak_ch7": { + "name": "Water leak CH7" + }, + "leak_ch1_battery": { + "name": "Water leak CH1 battery" + }, + "leak_ch2_battery": { + "name": "Water leak CH2 battery" + }, + "leak_ch3_battery": { + "name": "Water leak CH3 battery" + }, + "leak_ch4_battery": { + "name": "Water leak CH4 battery" + }, + "leak_ch5_battery": { + "name": "Water leak CH5 battery" + }, + "leak_ch6_battery": { + "name": "Water leak CH6 battery" + }, + "leak_ch7_battery": { + "name": "Water leak CH7 battery" } }, "sensor": { @@ -531,6 +576,63 @@ "low": "Low", "drained": "Unknown / drained out" } + }, + "abs_pressure": { + "name": "Absolute pressure" + }, + "feels_like": { + "name": "Feels like" + }, + "wind_speed_avg10": { + "name": "Wind speed (10 min average)" + }, + "lightning_last": { + "name": "Time since last lightning strike" + }, + "lightning_distance": { + "name": "Lightning distance" + }, + "lightning_strikes_1h": { + "name": "Lightning strikes (last hour)" + }, + "lightning_count_5m": { + "name": "Lightning strikes (5 minutes)" + }, + "lightning_count_30m": { + "name": "Lightning strikes (30 minutes)" + }, + "lightning_count_1h": { + "name": "Lightning strikes (1 hour)" + }, + "lightning_count_1d": { + "name": "Lightning strikes (1 day)" + }, + "pm25": { + "name": "PM2.5" + }, + "pm10": { + "name": "PM10" + }, + "pm25_aqi": { + "name": "PM2.5 AQI" + }, + "pm10_aqi": { + "name": "PM10 AQI" + }, + "t8_battery": { + "name": "PM sensor battery" + }, + "co2": { + "name": "CO2" + }, + "t10_battery": { + "name": "CO2 sensor battery" + }, + "co": { + "name": "CO" + }, + "t11_battery": { + "name": "CO sensor battery" } } }, diff --git a/custom_components/sws12500/translations/cs.json b/custom_components/sws12500/translations/cs.json index ecafe6f..3500e3b 100644 --- a/custom_components/sws12500/translations/cs.json +++ b/custom_components/sws12500/translations/cs.json @@ -175,6 +175,51 @@ }, "ch8_battery": { "name": "Baterie senzoru 8" + }, + "lightning_battery": { + "name": "Baterie čidla blesků" + }, + "leak_ch1": { + "name": "Zaplavení CH1" + }, + "leak_ch2": { + "name": "Zaplavení CH2" + }, + "leak_ch3": { + "name": "Zaplavení CH3" + }, + "leak_ch4": { + "name": "Zaplavení CH4" + }, + "leak_ch5": { + "name": "Zaplavení CH5" + }, + "leak_ch6": { + "name": "Zaplavení CH6" + }, + "leak_ch7": { + "name": "Zaplavení CH7" + }, + "leak_ch1_battery": { + "name": "Baterie čidla zaplavení CH1" + }, + "leak_ch2_battery": { + "name": "Baterie čidla zaplavení CH2" + }, + "leak_ch3_battery": { + "name": "Baterie čidla zaplavení CH3" + }, + "leak_ch4_battery": { + "name": "Baterie čidla zaplavení CH4" + }, + "leak_ch5_battery": { + "name": "Baterie čidla zaplavení CH5" + }, + "leak_ch6_battery": { + "name": "Baterie čidla zaplavení CH6" + }, + "leak_ch7_battery": { + "name": "Baterie čidla zaplavení CH7" } }, "sensor": { @@ -531,6 +576,63 @@ "normal": "Normální", "drained": "Neznámá / zcela vybitá" } + }, + "abs_pressure": { + "name": "Absolutní tlak" + }, + "feels_like": { + "name": "Pocitová teplota" + }, + "wind_speed_avg10": { + "name": "Rychlost větru (10min průměr)" + }, + "lightning_last": { + "name": "Čas od posledního blesku" + }, + "lightning_distance": { + "name": "Vzdálenost blesku" + }, + "lightning_strikes_1h": { + "name": "Blesky (poslední hodina)" + }, + "lightning_count_5m": { + "name": "Blesky (5 minut)" + }, + "lightning_count_30m": { + "name": "Blesky (30 minut)" + }, + "lightning_count_1h": { + "name": "Blesky (1 hodina)" + }, + "lightning_count_1d": { + "name": "Blesky (1 den)" + }, + "pm25": { + "name": "PM2,5" + }, + "pm10": { + "name": "PM10" + }, + "pm25_aqi": { + "name": "PM2,5 AQI" + }, + "pm10_aqi": { + "name": "PM10 AQI" + }, + "t8_battery": { + "name": "Baterie čidla PM" + }, + "co2": { + "name": "CO2" + }, + "t10_battery": { + "name": "Baterie čidla CO2" + }, + "co": { + "name": "CO" + }, + "t11_battery": { + "name": "Baterie čidla CO" } } }, diff --git a/custom_components/sws12500/translations/en.json b/custom_components/sws12500/translations/en.json index 7edce2d..6a1efd8 100644 --- a/custom_components/sws12500/translations/en.json +++ b/custom_components/sws12500/translations/en.json @@ -175,6 +175,51 @@ }, "ch8_battery": { "name": "Channel 8 battery" + }, + "lightning_battery": { + "name": "Lightning sensor battery" + }, + "leak_ch1": { + "name": "Water leak CH1" + }, + "leak_ch2": { + "name": "Water leak CH2" + }, + "leak_ch3": { + "name": "Water leak CH3" + }, + "leak_ch4": { + "name": "Water leak CH4" + }, + "leak_ch5": { + "name": "Water leak CH5" + }, + "leak_ch6": { + "name": "Water leak CH6" + }, + "leak_ch7": { + "name": "Water leak CH7" + }, + "leak_ch1_battery": { + "name": "Water leak CH1 battery" + }, + "leak_ch2_battery": { + "name": "Water leak CH2 battery" + }, + "leak_ch3_battery": { + "name": "Water leak CH3 battery" + }, + "leak_ch4_battery": { + "name": "Water leak CH4 battery" + }, + "leak_ch5_battery": { + "name": "Water leak CH5 battery" + }, + "leak_ch6_battery": { + "name": "Water leak CH6 battery" + }, + "leak_ch7_battery": { + "name": "Water leak CH7 battery" } }, "sensor": { @@ -531,6 +576,63 @@ "low": "Low", "drained": "Unknown / drained out" } + }, + "abs_pressure": { + "name": "Absolute pressure" + }, + "feels_like": { + "name": "Feels like" + }, + "wind_speed_avg10": { + "name": "Wind speed (10 min average)" + }, + "lightning_last": { + "name": "Time since last lightning strike" + }, + "lightning_distance": { + "name": "Lightning distance" + }, + "lightning_strikes_1h": { + "name": "Lightning strikes (last hour)" + }, + "lightning_count_5m": { + "name": "Lightning strikes (5 minutes)" + }, + "lightning_count_30m": { + "name": "Lightning strikes (30 minutes)" + }, + "lightning_count_1h": { + "name": "Lightning strikes (1 hour)" + }, + "lightning_count_1d": { + "name": "Lightning strikes (1 day)" + }, + "pm25": { + "name": "PM2.5" + }, + "pm10": { + "name": "PM10" + }, + "pm25_aqi": { + "name": "PM2.5 AQI" + }, + "pm10_aqi": { + "name": "PM10 AQI" + }, + "t8_battery": { + "name": "PM sensor battery" + }, + "co2": { + "name": "CO2" + }, + "t10_battery": { + "name": "CO2 sensor battery" + }, + "co": { + "name": "CO" + }, + "t11_battery": { + "name": "CO sensor battery" } } }, diff --git a/custom_components/sws12500/utils.py b/custom_components/sws12500/utils.py index 64ad87c..096780c 100644 --- a/custom_components/sws12500/utils.py +++ b/custom_components/sws12500/utils.py @@ -20,12 +20,15 @@ from typing import Any, Final from py_typecheck.core import checked_or from homeassistant.components import persistent_notification +from homeassistant.components.sensor import SensorDeviceClass from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.translation import async_get_translations from .const import ( AZIMUT, + CH_HUMIDITY_TYPE_PARAM, + CH_TYPE_SOIL, CHILL_INDEX, CONNECTION_GATED_SENSORS, DEV_DBG, @@ -138,7 +141,12 @@ def remap_wslink_items(entities: dict[str, str]) -> dict[str, str]: items[REMAP_WSLINK_ITEMS[item]] = value for conn_key, gated in CONNECTION_GATED_SENSORS.items(): - if str(entities.get(conn_key, "0")) != "1": + # Only an explicit "not connected" drops the readings. An absent flag means the + # firmware does not report one, which is not evidence of a disconnection - + # treating it as such would wipe out every reading of a probe whose firmware + # omits the flag, up to and including the main outdoor sensor gated by `t1cn`. + connection = entities.get(conn_key) + if connection is not None and str(connection) != "1": for key in gated: items.pop(key, None) @@ -468,3 +476,43 @@ def remap_ecowitt_to_windy(data: dict[str, Any]) -> dict[str, Any]: WU spelling. """ return {out_key: data[eco_key] for eco_key, out_key in REMAP_ECOWITT_TO_WINDY.items() if eco_key in data} + + +# The WSLink API documents `t5lst` only as "Last Lightning strike time" (integer) and +# its own example uses 9999, which is not a plausible epoch. It is read as minutes +# since the last strike, with 9999 meaning "nothing recorded". +LIGHTNING_NO_STRIKE: Final = 9999 + + +def lightning_minutes(value: Any) -> int | None: + """Minutes since the last lightning strike, or None when nothing was recorded.""" + minutes = to_int(value) + if minutes is None or minutes >= LIGHTNING_NO_STRIKE: + return None + return minutes + + +def channel_humidity_device_class(raw_payload: dict[str, Any], key: str) -> SensorDeviceClass | None: + """Device class for a multi-channel humidity reading, from the probe type. + + WSLink reports what kind of probe sits on each channel via `t234cXtp`. A soil + probe (type 4) measures soil moisture, not air humidity, so it needs + `SensorDeviceClass.MOISTURE` rather than `HUMIDITY`. + + Returns None when the channel is not one of the multi-channel ones, or when the + station did not report a type - the description's own device class then applies. + + This is resolved once, when the entity is created: Home Assistant records the + device class in the entity registry, and swapping it underneath a live entity + would rewrite its meaning. Replacing the physical probe therefore needs the + integration reinstalled, which is the intended trade-off. + """ + param = CH_HUMIDITY_TYPE_PARAM.get(key) + if param is None: + return None + + channel_type = to_int(raw_payload.get(param)) + if channel_type is None: + return None + + return SensorDeviceClass.MOISTURE if channel_type == CH_TYPE_SOIL else SensorDeviceClass.HUMIDITY diff --git a/tests/test_battery_classification.py b/tests/test_battery_classification.py index 99821ea..f2f210d 100644 --- a/tests/test_battery_classification.py +++ b/tests/test_battery_classification.py @@ -29,8 +29,21 @@ from custom_components.sws12500.sensors_wslink import SENSOR_TYPES_WSLINK def _all_battery_constants() -> set[str]: - """Every `*_BATTERY` value defined in const.py.""" - return {value for name, value in vars(const).items() if name.endswith("_BATTERY") and isinstance(value, str)} + """Every battery key defined in const.py. + + A `*_BATTERY` constant is either a single key (``T9_BATTERY``) or a tuple of them + for a multi-channel family (``LEAK_CH_BATTERY``); both shapes count, otherwise a + whole family could be added without the classification tests noticing. + """ + keys: set[str] = set() + for name, value in vars(const).items(): + if not name.endswith("_BATTERY"): + continue + if isinstance(value, str): + keys.add(value) + elif isinstance(value, tuple): + keys.update(v for v in value if isinstance(v, str)) + return keys def test_classifications_are_disjoint() -> None: diff --git a/tests/test_binary_battery.py b/tests/test_binary_battery.py index e8acf95..2e129e3 100644 --- a/tests/test_binary_battery.py +++ b/tests/test_binary_battery.py @@ -3,7 +3,7 @@ Covers: - `binary_sensor.async_setup_entry` (with and without battery keys in SENSORS_TO_LOAD) - `binary_sensor.add_new_binary_sensors` (no-op / dedupe / unknown / new) -- `battery_sensors.BatteryBinarySensor` (`is_on` value mapping + `device_info`) +- `battery_sensors.WSBinarySensor` (`is_on` value mapping + `device_info`) """ from __future__ import annotations @@ -13,8 +13,8 @@ from typing import Any import pytest -from custom_components.sws12500.battery_sensors import BatteryBinarySensor -from custom_components.sws12500.battery_sensors_def import BATTERY_BINARY_SENSORS +from custom_components.sws12500.battery_sensors import WSBinarySensor +from custom_components.sws12500.battery_sensors_def import BATTERY_BINARY_SENSORS, WSLINK_BINARY_SENSORS from custom_components.sws12500.binary_sensor import add_new_binary_sensors, async_setup_entry from custom_components.sws12500.const import CH2_BATTERY, DOMAIN, INDOOR_BATTERY, OUTSIDE_BATTERY, SENSORS_TO_LOAD from custom_components.sws12500.data import SWSRuntimeData @@ -80,10 +80,10 @@ async def test_setup_creates_entities_for_battery_keys(hass): # Callback + description map persisted for dynamic entity creation. assert runtime.add_binary_entities is add_entities - assert set(runtime.binary_descriptions.keys()) == {d.key for d in BATTERY_BINARY_SENSORS} + assert set(runtime.binary_descriptions.keys()) == {d.key for d in WSLINK_BINARY_SENSORS} # Entities created for the requested battery keys only. - assert all(isinstance(e, BatteryBinarySensor) for e in captured) + assert all(isinstance(e, WSBinarySensor) for e in captured) created_keys = {e.entity_description.key for e in captured} assert created_keys == {OUTSIDE_BATTERY, INDOOR_BATTERY} @@ -136,7 +136,7 @@ def test_add_new_ignores_already_added_keys(hass): entry, _coordinator, runtime = _make_entry() captured, add_entities = _capture_add_entities() runtime.add_binary_entities = add_entities - runtime.binary_descriptions = {d.key: d for d in BATTERY_BINARY_SENSORS} + runtime.binary_descriptions = {d.key: d for d in WSLINK_BINARY_SENSORS} runtime.added_binary_keys = {OUTSIDE_BATTERY} add_new_binary_sensors(hass, entry, [OUTSIDE_BATTERY]) @@ -150,7 +150,7 @@ def test_add_new_ignores_unknown_keys(hass): entry, _coordinator, runtime = _make_entry() captured, add_entities = _capture_add_entities() runtime.add_binary_entities = add_entities - runtime.binary_descriptions = {d.key: d for d in BATTERY_BINARY_SENSORS} + runtime.binary_descriptions = {d.key: d for d in WSLINK_BINARY_SENSORS} add_new_binary_sensors(hass, entry, ["totally_unknown_key"]) @@ -162,7 +162,7 @@ def test_add_new_adds_new_known_keys(hass): entry, coordinator, runtime = _make_entry() captured, add_entities = _capture_add_entities() runtime.add_binary_entities = add_entities - runtime.binary_descriptions = {d.key: d for d in BATTERY_BINARY_SENSORS} + runtime.binary_descriptions = {d.key: d for d in WSLINK_BINARY_SENSORS} # Mix of new known, unknown, and a key we'll mark already-added. runtime.added_binary_keys = {INDOOR_BATTERY} @@ -172,12 +172,12 @@ def test_add_new_adds_new_known_keys(hass): created_keys = {e.entity_description.key for e in captured} assert created_keys == {OUTSIDE_BATTERY, CH2_BATTERY} - assert all(isinstance(e, BatteryBinarySensor) for e in captured) + assert all(isinstance(e, WSBinarySensor) for e in captured) assert all(e.coordinator is coordinator for e in captured) assert runtime.added_binary_keys == {INDOOR_BATTERY, OUTSIDE_BATTERY, CH2_BATTERY} -# --- BatteryBinarySensor --------------------------------------------------- +# --- WSBinarySensor --------------------------------------------------- @pytest.mark.parametrize( @@ -195,7 +195,7 @@ def test_add_new_adds_new_known_keys(hass): ) def test_is_on_value_mapping(raw, expected): coordinator = _CoordinatorStub({OUTSIDE_BATTERY: raw}) - sensor = BatteryBinarySensor(coordinator, _desc_for(OUTSIDE_BATTERY)) + sensor = WSBinarySensor(coordinator, _desc_for(OUTSIDE_BATTERY)) assert sensor.is_on is expected assert sensor.unique_id == f"{OUTSIDE_BATTERY}_binary" @@ -203,14 +203,14 @@ def test_is_on_value_mapping(raw, expected): def test_is_on_key_absent_returns_none(): coordinator = _CoordinatorStub({}) # key not present at all - sensor = BatteryBinarySensor(coordinator, _desc_for(OUTSIDE_BATTERY)) + sensor = WSBinarySensor(coordinator, _desc_for(OUTSIDE_BATTERY)) assert sensor.is_on is None def test_device_info(): coordinator = _CoordinatorStub() - sensor = BatteryBinarySensor(coordinator, _desc_for(OUTSIDE_BATTERY)) + sensor = WSBinarySensor(coordinator, _desc_for(OUTSIDE_BATTERY)) info = sensor.device_info assert info["name"] == "Weather Station SWS 12500" diff --git a/tests/test_routes_more.py b/tests/test_routes_more.py index 3b43db8..e7e3069 100644 --- a/tests/test_routes_more.py +++ b/tests/test_routes_more.py @@ -80,3 +80,31 @@ async def test_dispatch_resolves_via_canonical_resource(routes: Routes) -> None: resp = await routes.dispatch(request) # type: ignore[arg-type] assert resp.status == 200 + + +async def test_deactivated_dispatcher_reports_unloaded() -> None: + """After unload the routes outlive the entry, so they must answer 503. + + Without this they would call a handler bound to a coordinator whose config entry + is gone. + """ + routes = Routes() + observer = MagicMock() + routes.set_ingress_observer(observer) + routes.add_route("/x", _RouteStub(method="GET"), _handler, enabled=True) + + routes.deactivate() + + response = await routes.dispatch(_RequestStub(method="GET", path="/x")) + assert response.status == 503 + # The observer is cleared on deactivate, so nothing is recorded for a dead entry. + observer.assert_not_called() + + +def test_show_enabled_reports_nothing_while_deactivated() -> None: + routes = Routes() + routes.add_route("/x", _RouteStub(method="GET"), _handler, enabled=True) + assert "Dispatcher enabled" in routes.show_enabled() + + routes.deactivate() + assert routes.show_enabled() == "No routes are enabled." diff --git a/tests/test_sensor_platform.py b/tests/test_sensor_platform.py index a67f7c5..e5be905 100644 --- a/tests/test_sensor_platform.py +++ b/tests/test_sensor_platform.py @@ -218,3 +218,52 @@ def test_add_new_sensors_noop_when_runtime_data_missing(): """add_new_sensors is a safe no-op when the entry is unloaded (no runtime_data).""" entry = SimpleNamespace(entry_id="x", options={}, runtime_data=None) add_new_sensors(None, entry, keys=["anything"]) # must not raise + + +# --------------------------------------------------------------------------- +# Probe type decides the humidity device class, once, at entity creation +# --------------------------------------------------------------------------- + + +def _wslink_desc(key: str): + from custom_components.sws12500.sensors_wslink import SENSOR_TYPES_WSLINK + + return next(d for d in SENSOR_TYPES_WSLINK if d.key == key) + + +@pytest.mark.parametrize( + ("channel_type", "expected"), + [("4", "moisture"), ("2", "humidity")], + ids=["soil-probe", "thermo-hygrometer"], +) +def test_channel_humidity_entity_takes_its_class_from_the_probe(channel_type, expected) -> None: + """A soil probe reports soil moisture, so the entity must not claim humidity.""" + from types import SimpleNamespace + from unittest.mock import MagicMock + + from custom_components.sws12500.const import CH2_HUMIDITY + from custom_components.sws12500.sensor import WeatherSensor + + coordinator = MagicMock() + coordinator.config = SimpleNamespace( + options={}, + runtime_data=SimpleNamespace(channel_types={"t234c1tp": channel_type}), + ) + + sensor = WeatherSensor(_wslink_desc(CH2_HUMIDITY), coordinator) + assert sensor.device_class == expected + + +def test_channel_humidity_entity_falls_back_to_the_description() -> None: + """No reported probe type leaves the description's own device class in place.""" + from types import SimpleNamespace + from unittest.mock import MagicMock + + from custom_components.sws12500.const import CH2_HUMIDITY + from custom_components.sws12500.sensor import WeatherSensor + + coordinator = MagicMock() + coordinator.config = SimpleNamespace(options={}, runtime_data=SimpleNamespace(channel_types={})) + + sensor = WeatherSensor(_wslink_desc(CH2_HUMIDITY), coordinator) + assert sensor.device_class == "humidity" diff --git a/tests/test_t9_air_quality.py b/tests/test_t9_air_quality.py index 06e834e..56271d5 100644 --- a/tests/test_t9_air_quality.py +++ b/tests/test_t9_air_quality.py @@ -149,9 +149,9 @@ def test_remap_keeps_t9_group_when_connected() -> None: 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"}) +def test_remap_drops_t9_group_when_disconnected() -> None: + """An explicit `t9cn=0` means the probe is gone, so its readings must not linger.""" + out = remap_wslink_items({"t9cn": "0", "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 @@ -159,6 +159,20 @@ def test_remap_drops_t9_group_when_disconnected_or_absent(conn) -> None: assert out[OUTSIDE_TEMP] == "11.3" +def test_remap_keeps_t9_group_when_the_flag_is_absent() -> None: + """A missing connection flag is no information, not a disconnection. + + Treating absence as "disconnected" would blank the readings of any probe whose + firmware simply does not send the flag - and with `t1cn` now gating the outdoor + probe, that would have meant losing temperature, wind and rain outright. + """ + out = remap_wslink_items({"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" + + def test_remap_issue_payload_exposes_t9_when_connected() -> None: out = remap_wslink_items(ISSUE_PAYLOAD) # t9cn == "1" -> the T9 sensors are exposed diff --git a/tests/test_wslink_api_coverage.py b/tests/test_wslink_api_coverage.py new file mode 100644 index 0000000..3d939f0 --- /dev/null +++ b/tests/test_wslink_api_coverage.py @@ -0,0 +1,292 @@ +"""The WSLink API v0.6 must be covered completely and classified correctly. + +`API.pdf` (WSLink data upload API, VERSION 0.6) is the contract. This file pins the +parameter list from that document so a future firmware or a refactor cannot quietly +drop a sensor, and - more importantly - so the two battery conventions cannot get +mixed up: + +- ``(Normal=1, Low battery=0)`` -> binary sensor +- ``(0~5) remark: 5 is full`` -> percentage sensor + +Getting that wrong is silent: a 0-5 battery read as binary reports "not low" for +levels 1-5 and throws four fifths of the reading away. +""" + +from __future__ import annotations + +import pytest + +from custom_components.sws12500.battery_sensors_def import ( + BATTERY_BINARY_SENSORS, + BATTERY_LEVEL_SENSORS, + LEAK_BINARY_SENSORS, +) +from custom_components.sws12500.const import ( + BATTERY_LIST, + BATTERY_NON_BINARY, + CONNECTION_GATED_SENSORS, + LEAK_CH, + LEAK_CH_BATTERY, + REMAP_WSLINK_ITEMS, +) +from custom_components.sws12500.sensors_wslink import SENSOR_TYPES_WSLINK + +# Every upload parameter in API.pdf v0.6, by sensor type. +API_BASE = ["rbar", "abar", "intem", "inhum", "inbat"] +API_TYPE1 = [ + "t1tem", "t1hum", "t1feels", "t1chill", "t1heat", "t1dew", "t1wdir", "t1ws", + "t1ws10mav", "t1wgust", "t1rainra", "t1rainhr", "t1raindy", "t1rainwy", + "t1rainmth", "t1rainyr", "t1uvi", "t1solrad", "t1wbgt", "t1bat", "t1cn", +] +API_TYPE234 = [f"t234c{ch}{part}" for ch in range(1, 8) for part in ("tem", "hum", "bat", "cn", "tp")] +API_TYPE5 = [ + "t5lst", "t5lskm", "t5lsf", "t5ls5mtc", "t5ls30mtc", "t5ls1htc", "t5ls1dtc", + "t5lsbat", "t5lscn", +] +API_TYPE6 = [f"t6c{ch}{part}" for ch in range(1, 8) for part in ("wls", "bat", "cn")] +API_TYPE8 = ["t8pm25", "t8pm10", "t8pm25ai", "t8pm10ai", "t8bat", "t8cn"] +API_TYPE9 = ["t9hcho", "t9voclv", "t9bat", "t9cn"] +API_TYPE10 = ["t10co2", "t10bat", "t10cn"] +API_TYPE11 = ["t11co", "t11bat", "t11cn"] + +# Parameters that are not readings: credentials, timestamp, API version, and the +# per-channel probe type (which selects a device class rather than becoming a sensor). +API_NON_READINGS = {"wsid", "wspw", "datetime", "apiver"} | {f"t234c{ch}tp" for ch in range(1, 8)} + +ALL_API_PARAMS = [ + *API_BASE, *API_TYPE1, *API_TYPE234, *API_TYPE5, + *API_TYPE6, *API_TYPE8, *API_TYPE9, *API_TYPE10, *API_TYPE11, +] + + +def _handled() -> set[str]: + """Raw parameters the integration does something with.""" + return set(REMAP_WSLINK_ITEMS) | set(CONNECTION_GATED_SENSORS) | API_NON_READINGS + + +# --------------------------------------------------------------------------- +# Coverage +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("name", "params"), + [ + ("base", API_BASE), + ("type1", API_TYPE1), + ("type2/3/4", API_TYPE234), + ("type5 lightning", API_TYPE5), + ("type6 water leak", API_TYPE6), + ("type8 PM", API_TYPE8), + ("type9 HCHO/VOC", API_TYPE9), + ("type10 CO2", API_TYPE10), + ("type11 CO", API_TYPE11), + ], +) +def test_every_api_parameter_is_handled(name, params) -> None: + """No upload parameter may be silently ignored.""" + unhandled = sorted(set(params) - _handled()) + assert not unhandled, f"{name}: unhandled WSLink parameters {unhandled}" + + +def test_no_invented_parameters() -> None: + """The remap table must not claim parameters the API does not define.""" + unknown = sorted(set(REMAP_WSLINK_ITEMS) - set(ALL_API_PARAMS)) + assert not unknown, f"not in API.pdf v0.6: {unknown}" + + +def test_gates_are_real_connection_parameters() -> None: + """Every gate key must be a `*cn` parameter from the document.""" + for gate in CONNECTION_GATED_SENSORS: + assert gate in ALL_API_PARAMS, f"{gate} is not an API parameter" + assert gate.endswith("cn"), f"{gate} is not a connection parameter" + + +def test_every_reading_becomes_an_entity() -> None: + """A remapped key with no entity would be dead weight in SENSORS_TO_LOAD.""" + sensor_keys = {d.key for d in SENSOR_TYPES_WSLINK} + binary_keys = {d.key for d in (*BATTERY_BINARY_SENSORS, *LEAK_BINARY_SENSORS)} + orphans = sorted(set(REMAP_WSLINK_ITEMS.values()) - sensor_keys - binary_keys) + assert not orphans, f"remapped but never shown: {orphans}" + + +# --------------------------------------------------------------------------- +# Battery conventions - the part that fails silently when wrong +# --------------------------------------------------------------------------- + +# Straight from API.pdf. Batteries documented "(Normal=1, Low battery=0)". +API_BINARY_BATTERIES = ["t1bat", *(f"t234c{ch}bat" for ch in range(1, 8)), "t5lsbat", *(f"t6c{ch}bat" for ch in range(1, 8))] +# Batteries documented "(0~5) remark: 5 is full". +API_LEVEL_BATTERIES = ["t8bat", "t9bat", "t10bat", "t11bat"] + + +@pytest.mark.parametrize("param", API_BINARY_BATTERIES) +def test_binary_batteries_are_classified_binary(param) -> None: + key = REMAP_WSLINK_ITEMS[param] + assert key in BATTERY_LIST, f"{param} is 0/1 in the API but is not a binary battery" + assert key not in BATTERY_NON_BINARY + + +@pytest.mark.parametrize("param", API_LEVEL_BATTERIES) +def test_level_batteries_are_classified_as_levels(param) -> None: + key = REMAP_WSLINK_ITEMS[param] + assert key in BATTERY_NON_BINARY, f"{param} is 0-5 in the API but is not a level battery" + assert key not in BATTERY_LIST + + +def test_every_api_battery_is_accounted_for() -> None: + """A new battery must land in exactly one of the two tuples.""" + from_api = {REMAP_WSLINK_ITEMS[p] for p in (*API_BINARY_BATTERIES, *API_LEVEL_BATTERIES)} + classified = set(BATTERY_LIST) | set(BATTERY_NON_BINARY) + assert from_api <= classified, f"unclassified: {sorted(from_api - classified)}" + + +def test_level_battery_scale_maps_five_to_full() -> None: + """0-5 with 5 = full, per the API's own remark.""" + for desc in BATTERY_LEVEL_SENSORS: + assert desc.value_fn is not None + assert desc.value_fn("5") == 100 + assert desc.value_fn("0") == 0 + + +# --------------------------------------------------------------------------- +# Water leak +# --------------------------------------------------------------------------- + + +def test_leak_sensors_cover_all_seven_channels() -> None: + assert len(LEAK_CH) == 7 + assert len(LEAK_CH_BATTERY) == 7 + assert {d.key for d in LEAK_BINARY_SENSORS} == set(LEAK_CH) + + +def test_leak_is_on_when_the_station_reports_one() -> None: + """`Leak=1, No leak=0`, and MOISTURE means `on` = wet - so on_value is 1. + + The batteries in the same family use the opposite convention, which is exactly + why `on_value` lives in the description. + """ + for desc in LEAK_BINARY_SENSORS: + assert desc.on_value == 1 + for desc in BATTERY_BINARY_SENSORS: + assert desc.on_value == 0 + + +def test_leak_channels_are_gated_by_their_own_connection() -> None: + for channel in range(1, 8): + gated = CONNECTION_GATED_SENSORS[f"t6c{channel}cn"] + assert LEAK_CH[channel - 1] in gated + assert LEAK_CH_BATTERY[channel - 1] in gated + + +# --------------------------------------------------------------------------- +# Outdoor probe gating +# +# `t1cn` was in the API but wired to nothing, so a disconnected outdoor probe kept +# publishing its last readings. Gating it is only safe because an *absent* flag no +# longer counts as "disconnected" - otherwise any firmware that omits `t1cn` would +# lose temperature, wind and rain in one go. +# --------------------------------------------------------------------------- + + +OUTDOOR_PAYLOAD = {"t1tem": "11.3", "t1ws": "4.2", "t1raindy": "0.8", "t1bat": "1"} + + +def test_disconnected_outdoor_probe_drops_its_readings() -> None: + from custom_components.sws12500.const import OUTSIDE_TEMP + from custom_components.sws12500.utils import remap_wslink_items + + out = remap_wslink_items({**OUTDOOR_PAYLOAD, "t1cn": "0"}) + assert OUTSIDE_TEMP not in out + assert not out, f"nothing from a disconnected probe should survive: {sorted(out)}" + + +def test_connected_outdoor_probe_keeps_its_readings() -> None: + from custom_components.sws12500.const import OUTSIDE_TEMP, WIND_SPEED + from custom_components.sws12500.utils import remap_wslink_items + + out = remap_wslink_items({**OUTDOOR_PAYLOAD, "t1cn": "1"}) + assert out[OUTSIDE_TEMP] == "11.3" + assert out[WIND_SPEED] == "4.2" + + +def test_outdoor_readings_survive_a_firmware_that_omits_t1cn() -> None: + """The regression this gating could have caused, pinned.""" + from custom_components.sws12500.const import OUTSIDE_TEMP, WIND_SPEED + from custom_components.sws12500.utils import remap_wslink_items + + out = remap_wslink_items(OUTDOOR_PAYLOAD) + assert out[OUTSIDE_TEMP] == "11.3" + assert out[WIND_SPEED] == "4.2" + + +# --------------------------------------------------------------------------- +# Probe type decides the humidity device class +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("channel_type", "expected"), + [("2", "humidity"), ("3", "humidity"), ("4", "moisture")], + ids=["thermo-hygrometer", "pool", "soil"], +) +def test_channel_humidity_device_class_follows_the_probe_type(channel_type, expected) -> None: + """Type 4 is a soil probe, so its `hum` reading is soil moisture, not humidity.""" + from custom_components.sws12500.const import CH2_HUMIDITY + from custom_components.sws12500.utils import channel_humidity_device_class + + resolved = channel_humidity_device_class({"t234c1tp": channel_type}, CH2_HUMIDITY) + assert resolved is not None + assert resolved.value == expected + + +def test_channel_humidity_device_class_is_none_without_a_type() -> None: + """No reported type means the description's own device class stands.""" + from custom_components.sws12500.const import CH2_HUMIDITY, OUTSIDE_HUMIDITY + from custom_components.sws12500.utils import channel_humidity_device_class + + assert channel_humidity_device_class({}, CH2_HUMIDITY) is None + # Outdoor humidity is not a multi-channel probe at all. + assert channel_humidity_device_class({"t234c1tp": "4"}, OUTSIDE_HUMIDITY) is None + + +def test_every_channel_has_its_own_type_parameter() -> None: + from custom_components.sws12500.const import CH_HUMIDITY_TYPE_PARAM + + assert len(CH_HUMIDITY_TYPE_PARAM) == 7 + assert set(CH_HUMIDITY_TYPE_PARAM.values()) == {f"t234c{ch}tp" for ch in range(1, 8)} + + +# --------------------------------------------------------------------------- +# `t5lst` sentinel +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize(("raw", "expected"), [("0", 0), ("7", 7), ("120", 120), ("9998", 9998)]) +def test_lightning_minutes_passes_real_readings_through(raw, expected) -> None: + from custom_components.sws12500.utils import lightning_minutes + + assert lightning_minutes(raw) == expected + + +@pytest.mark.parametrize("raw", ["9999", 9999, "10000", "", None, "n/a"]) +def test_lightning_minutes_reports_nothing_for_the_sentinel(raw) -> None: + """The API documents no unit and its own example uses 9999. + + Reading that as "9999 minutes since the last strike" would show a week-old strike + on a station that has never seen one. + """ + from custom_components.sws12500.utils import lightning_minutes + + assert lightning_minutes(raw) is None + + +def test_lightning_last_description_uses_the_sentinel_helper() -> None: + from custom_components.sws12500.const import LIGHTNING_LAST + from custom_components.sws12500.utils import lightning_minutes + + desc = next(d for d in SENSOR_TYPES_WSLINK if d.key == LIGHTNING_LAST) + assert desc.value_fn is lightning_minutes + # No device class: TIMESTAMP would need a tz-aware datetime, which the API + # does not provide. + assert desc.device_class is None