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
parent
4b85d2e393
commit
624de521c4
71
README.md
71
README.md
|
|
@ -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 1–5 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** CH1–CH7 | 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 CH1–CH7 | 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 0–5 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 1–5 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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Reference in New Issue