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.
ecowitt_support
SchiZzA 2026-07-26 15:20:07 +02:00
parent 4b85d2e393
commit 624de521c4
No known key found for this signature in database
20 changed files with 1252 additions and 128 deletions

View File

@ -105,30 +105,65 @@ Web server repo is reachable here: <https://github.com/schizza/test-station-serv
The integration auto-creates sensors as soon as the station first sends data for them — new
sensors trigger a notification in Home Assistant and are added to the entity list
automatically.
automatically. Only the fields your station actually sends are created, so an entry never
shows entities for probes you do not own.
Beyond the standard set (outdoor / indoor temperature and humidity, barometric pressure,
wind speed / direction / gust, rain rate and daily / weekly / monthly / yearly totals, dew
point, UV index, solar irradiance) the WSLink protocol also exposes:
### WSLink — complete API coverage
- additional channels **CH2** **CH8** for temperature and humidity
- **WBGT** index, **heat index**, **wind chill**
- sensor battery state for the outdoor, indoor and **CH2** **CH8** probes, exposed as
binary `battery` sensors (`normal` / `low`) — these used to be regular sensors, so on an
upgraded install the old entities are left behind; a `Repairs` issue lists them and
tells you how to remove them
- **Formaldehyde (HCHO)** in ppb and **VOC level** from the WH46 / 7-in-1 air-quality
combo sensor, reported as a named air-quality level (`unhealthy`, `poor`, `moderate`,
`good`, `excellent` — the station's 15 index, where 1 is worst)
- **HCHO/VOC sensor battery** reported as a percentage
**Every measurement the WSLink data upload API (v0.6) defines is implemented**, across all
of its sensor types. Readings are mapped to native Home Assistant device classes, so
statistics, unit conversion and the energy/weather dashboards work without any template
helpers.
HCHO / VOC entities are only created when the station reports the air-quality module as
connected (`t9cn = 1`), so they don't clutter the device when no such sensor is attached.
| Sensor type | Readings |
| --- | --- |
| Console | indoor temperature and humidity, relative **and absolute** barometric pressure, console battery |
| **Type1** outdoor | temperature, humidity, dew point, **feels-like**, heat index, wind chill, **WBGT**, wind direction / speed / **10-minute average** / gust, rain rate and hourly / daily / weekly / monthly / yearly totals, UV index, solar irradiance, battery |
| **Type2/3/4** CH1CH7 | temperature and humidity per channel, battery |
| **Type5** lightning | distance, time since the last strike, strike counts over 5 min / 30 min / 1 h / 1 day, battery |
| **Type6** water leak CH1CH7 | leak state per channel, battery |
| **Type8** particulate matter | PM2.5, PM10 and their AQI values, battery |
| **Type9** air quality | formaldehyde (HCHO) in ppb, VOC level, battery |
| **Type10** | CO₂ concentration, battery |
| **Type11** | CO concentration, battery |
A few details worth knowing:
- **Batteries come in two kinds, and they are not interchangeable.** The outdoor, channel,
lightning and water-leak probes report a plain `normal` / `low` flag and become binary
`battery` sensors. The PM, air-quality, CO₂ and CO probes report a 05 level instead and
become percentage sensors (5 = full). The mapping follows the API document literally and
is enforced by tests, because a level battery read as a flag would silently report
"not low" for everything above empty.
- **Probes are gated on their connection flag.** A probe the station reports as
disconnected has its readings dropped rather than left frozen at the last value. A
station that does not send a connection flag at all is never gated — absence of the flag
is not evidence of a disconnection.
- **A soil probe is not a humidity sensor.** WSLink reports what kind of probe sits on each
channel, so a soil moisture & temperature probe gets Home Assistant's `moisture` device
class instead of `humidity`. This is decided when the entity is first created; swapping
the physical probe on a channel afterwards means reinstalling the integration.
- **VOC level** is reported as a named air-quality level (`unhealthy`, `poor`, `moderate`,
`good`, `excellent`) from the station's 15 index, where 1 is worst.
- **Heat index and wind chill** are taken from the station when it sends them, and computed
from temperature, humidity and wind speed when it does not.
- Battery sensors used to be regular sensors. On an upgraded install the old entities are
left behind; a `Repairs` issue lists them and tells you how to remove them.
Two parameters are exposed with a caveat, because the API document does not define them
fully: the console battery (`inbat`) is the only battery whose scale is not documented and
is treated as a `normal` / `low` flag, and the time since the last lightning strike
(`t5lst`) is read as minutes, with the vendor's `9999` treated as "nothing recorded".
### Ecowitt
Ecowitt stations map onto the same standard set and additionally report whatever their own
firmware sends — absolute pressure, event / hourly / weekly / monthly / yearly / total /
24h rain, feels-like temperature, indoor dew point and CO₂ readings among them. Only the
fields your station actually sends are created.
24h rain, feels-like temperature, indoor dew point and CO₂ readings among them. Readings
the integration has no mapping for still get an entity, so a newer firmware does not need
an integration update to be usable.
### Diagnostics
The integration also creates diagnostic entities describing its own state — the active
protocol, whether each endpoint is registered, details of the last received payload, the

View File

@ -1,6 +1,8 @@
"""Battery binary sensor entities for SWS 12500.
"""Binary sensor entity for SWS 12500.
Expose low-batter warnings as binary sensors.
One class serves every binary family (battery, water leak). Which raw value means
`on` comes from the description's `on_value`, because the station's convention
differs per family - see `WSBinarySensorEntityDescription`.
"""
from __future__ import annotations
@ -10,42 +12,44 @@ from typing import Any
from py_typecheck import checked_or
from homeassistant.components.binary_sensor import BinarySensorEntity, BinarySensorEntityDescription
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .data import build_device_info
from .sensors_common import WSBinarySensorEntityDescription
class BatteryBinarySensor( # pyright: ignore[reportIncompatibleVariableOverride]
class WSBinarySensor( # pyright: ignore[reportIncompatibleVariableOverride]
CoordinatorEntity, BinarySensorEntity
):
"""Represent a low-battery binary sensor.
"""Represent a WSLink binary reading.
Station payload uses:
- ``0`` => 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:

View File

@ -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,

View File

@ -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:

View File

@ -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],
}

View File

@ -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).

View File

@ -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]

View File

@ -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.

View File

@ -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

View File

@ -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,
)

View File

@ -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"
}
}
},

View File

@ -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"
}
}
},

View File

@ -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"
}
}
},

View File

@ -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

View File

@ -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:

View File

@ -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"

View File

@ -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."

View File

@ -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"

View File

@ -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

View File

@ -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