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