fix(review): persist WSLink probe types and harden flag parsing
parent
624de521c4
commit
d2ae26eab5
18
README.md
18
README.md
|
|
@ -119,9 +119,9 @@ helpers.
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Console | indoor temperature and humidity, relative **and absolute** barometric pressure, console battery |
|
| 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 |
|
| **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 |
|
| **Type2/3/4** CH2–CH8 (API 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 |
|
| **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 |
|
| **Type6** water leak CH1–CH7 | leak state per channel (entities `leak_ch1` – `leak_ch7`), battery |
|
||||||
| **Type8** particulate matter | PM2.5, PM10 and their AQI values, battery |
|
| **Type8** particulate matter | PM2.5, PM10 and their AQI values, battery |
|
||||||
| **Type9** air quality | formaldehyde (HCHO) in ppb, VOC level, battery |
|
| **Type9** air quality | formaldehyde (HCHO) in ppb, VOC level, battery |
|
||||||
| **Type10** | CO₂ concentration, battery |
|
| **Type10** | CO₂ concentration, battery |
|
||||||
|
|
@ -129,6 +129,10 @@ helpers.
|
||||||
|
|
||||||
A few details worth knowing:
|
A few details worth knowing:
|
||||||
|
|
||||||
|
- **Channel numbering differs between the two multi-channel families.** For historical
|
||||||
|
reasons the temperature/humidity channels are numbered one higher than the API numbers
|
||||||
|
them, so WSLink `CH1` is the `ch2_*` entity ("Channel 2 temperature") and WSLink `CH7`
|
||||||
|
is `ch8_*`. The water leak channels are not shifted: WSLink `CH1` is `leak_ch1`.
|
||||||
- **Batteries come in two kinds, and they are not interchangeable.** The outdoor, channel,
|
- **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
|
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
|
`battery` sensors. The PM, air-quality, CO₂ and CO probes report a 0–5 level instead and
|
||||||
|
|
@ -137,12 +141,14 @@ A few details worth knowing:
|
||||||
"not low" for everything above empty.
|
"not low" for everything above empty.
|
||||||
- **Probes are gated on their connection flag.** A probe the station reports as
|
- **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
|
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
|
station that does not send a connection flag, or sends it empty, is never gated — only an
|
||||||
is not evidence of a disconnection.
|
explicit "not connected" drops readings, because a missing flag is not evidence of a
|
||||||
|
disconnection.
|
||||||
- **A soil probe is not a humidity sensor.** WSLink reports what kind of probe sits on each
|
- **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
|
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
|
class instead of `humidity`. The reported probe types are remembered across restarts, and
|
||||||
the physical probe on a channel afterwards means reinstalling the integration.
|
the class is resolved when the entity is created; swapping the physical probe on a channel
|
||||||
|
therefore takes effect on the next restart, once the station has reported the new type.
|
||||||
- **VOC level** is reported as a named air-quality level (`unhealthy`, `poor`, `moderate`,
|
- **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.
|
`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
|
- **Heat index and wind chill** are taken from the station when it sends them, and computed
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ from homeassistant.helpers.event import async_track_time_interval
|
||||||
|
|
||||||
from .conflicts import effective_protocols, update_protocol_conflict_issue
|
from .conflicts import effective_protocols, update_protocol_conflict_issue
|
||||||
from .const import (
|
from .const import (
|
||||||
|
CHANNEL_TYPES,
|
||||||
DEFAULT_URL,
|
DEFAULT_URL,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
ECOWITT_URL_PREFIX,
|
ECOWITT_URL_PREFIX,
|
||||||
|
|
@ -235,7 +236,9 @@ async def update_listener(hass: HomeAssistant, entry: SWSConfigEntry):
|
||||||
"""Handle config entry option updates.
|
"""Handle config entry option updates.
|
||||||
|
|
||||||
We skip reloading when only live-read options change:
|
We skip reloading when only live-read options change:
|
||||||
- `SENSORS_TO_LOAD` (auto-discovery updates it as new payload fields appear), and
|
- `SENSORS_TO_LOAD` (auto-discovery updates it as new payload fields appear),
|
||||||
|
- `CHANNEL_TYPES` (the webhook handler persists the probe types it is told about,
|
||||||
|
and they are only read when an entity is created), and
|
||||||
- the forwarding enable flags (`WINDY_ENABLED`/`POCASI_CZ_ENABLED`), which the
|
- the forwarding enable flags (`WINDY_ENABLED`/`POCASI_CZ_ENABLED`), which the
|
||||||
forwarders read on every push - so a forwarder that auto-disables itself from the
|
forwarders read on every push - so a forwarder that auto-disables itself from the
|
||||||
hot path no longer triggers a disruptive reload.
|
hot path no longer triggers a disruptive reload.
|
||||||
|
|
@ -254,7 +257,7 @@ async def update_listener(hass: HomeAssistant, entry: SWSConfigEntry):
|
||||||
|
|
||||||
runtime.last_options = new_options
|
runtime.last_options = new_options
|
||||||
|
|
||||||
if changed_keys and changed_keys <= {SENSORS_TO_LOAD, WINDY_ENABLED, POCASI_CZ_ENABLED}:
|
if changed_keys and changed_keys <= {SENSORS_TO_LOAD, CHANNEL_TYPES, WINDY_ENABLED, POCASI_CZ_ENABLED}:
|
||||||
_LOGGER.debug("Options updated (%s); skipping reload.", ", ".join(sorted(changed_keys)))
|
_LOGGER.debug("Options updated (%s); skipping reload.", ", ".join(sorted(changed_keys)))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
from .data import build_device_info
|
from .data import build_device_info
|
||||||
from .sensors_common import WSBinarySensorEntityDescription
|
from .sensors_common import WSBinarySensorEntityDescription
|
||||||
|
from .utils import to_int
|
||||||
|
|
||||||
|
|
||||||
class WSBinarySensor( # pyright: ignore[reportIncompatibleVariableOverride]
|
class WSBinarySensor( # pyright: ignore[reportIncompatibleVariableOverride]
|
||||||
|
|
@ -53,12 +54,10 @@ class WSBinarySensor( # pyright: ignore[reportIncompatibleVariableOverride]
|
||||||
data = checked_or(self.coordinator.data, dict[str, Any], {})
|
data = checked_or(self.coordinator.data, dict[str, Any], {})
|
||||||
raw: Any = data.get(self.entity_description.key)
|
raw: Any = data.get(self.entity_description.key)
|
||||||
|
|
||||||
if raw is None or raw == "":
|
# `to_int` rather than a bare `int`: the station sometimes sends integer
|
||||||
return None
|
# fields as decimals, and a leak reported as "1.0" must still read as wet.
|
||||||
|
value = to_int(raw)
|
||||||
try:
|
if value is None:
|
||||||
value = int(raw)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return value == self.entity_description.on_value
|
return value == self.entity_description.on_value
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ from .conflicts import ERROR_MUTUALLY_EXCLUSIVE
|
||||||
from .const import (
|
from .const import (
|
||||||
API_ID,
|
API_ID,
|
||||||
API_KEY,
|
API_KEY,
|
||||||
|
CHANNEL_TYPES,
|
||||||
DEV_DBG,
|
DEV_DBG,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
ECOWITT_ENABLED,
|
ECOWITT_ENABLED,
|
||||||
|
|
@ -344,15 +345,17 @@ class ConfigOptionsFlowHandler(OptionsFlow):
|
||||||
def retain_data(self, data: dict[str, Any]) -> dict[str, Any]:
|
def retain_data(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Merge the submitted step over every other section's current values.
|
"""Merge the submitted step over every other section's current values.
|
||||||
|
|
||||||
`SENSORS_TO_LOAD` is re-read here rather than taken from the `_get_entry_data`
|
`SENSORS_TO_LOAD` and `CHANNEL_TYPES` are re-read here rather than taken from
|
||||||
snapshot: auto-discovery appends to it from the webhook handler, so a dialog
|
the `_get_entry_data` snapshot: the webhook handler writes both, so a dialog
|
||||||
left open while the station reports a new field would otherwise roll that
|
left open while the station reports a new field - or a new probe type - would
|
||||||
discovery back on submit.
|
otherwise roll that back on submit. Losing the probe types would silently turn
|
||||||
|
every soil channel back into an air-humidity entity.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
discovered = self.config_entry.options.get(SENSORS_TO_LOAD)
|
discovered = self.config_entry.options.get(SENSORS_TO_LOAD)
|
||||||
|
probe_types = self.config_entry.options.get(CHANNEL_TYPES)
|
||||||
|
|
||||||
return {
|
retained: dict[str, Any] = {
|
||||||
**self.user_data,
|
**self.user_data,
|
||||||
**self.windy_data,
|
**self.windy_data,
|
||||||
**self.pocasi_cz,
|
**self.pocasi_cz,
|
||||||
|
|
@ -362,6 +365,11 @@ class ConfigOptionsFlowHandler(OptionsFlow):
|
||||||
SENSORS_TO_LOAD: discovered if isinstance(discovered, list) else [],
|
SENSORS_TO_LOAD: discovered if isinstance(discovered, list) else [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if isinstance(probe_types, dict) and probe_types:
|
||||||
|
retained[CHANNEL_TYPES] = probe_types
|
||||||
|
|
||||||
|
return retained
|
||||||
|
|
||||||
|
|
||||||
class ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
class ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||||
"""Handle a config flow for Sencor SWS 12500 Weather Station."""
|
"""Handle a config flow for Sencor SWS 12500 Weather Station."""
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,11 @@ API_ID = "API_ID"
|
||||||
|
|
||||||
SENSORS_TO_LOAD: Final = "sensors_to_load"
|
SENSORS_TO_LOAD: Final = "sensors_to_load"
|
||||||
|
|
||||||
|
# Option key holding the raw `t234cXtp` probe types last reported by the station.
|
||||||
|
# Persisted rather than kept in runtime state because the channel humidity entities
|
||||||
|
# are created during entry setup, before any payload arrives.
|
||||||
|
CHANNEL_TYPES: Final = "channel_types"
|
||||||
|
|
||||||
INVALID_CREDENTIALS: Final = [
|
INVALID_CREDENTIALS: Final = [
|
||||||
"API",
|
"API",
|
||||||
"API_ID",
|
"API_ID",
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ from .const import (
|
||||||
API_ID,
|
API_ID,
|
||||||
API_KEY,
|
API_KEY,
|
||||||
CH_HUMIDITY_TYPE_PARAM,
|
CH_HUMIDITY_TYPE_PARAM,
|
||||||
|
CHANNEL_TYPES,
|
||||||
DEV_DBG,
|
DEV_DBG,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
ECOWITT_ENABLED,
|
ECOWITT_ENABLED,
|
||||||
|
|
@ -49,6 +50,7 @@ from .pocasti_cz import PocasiPush
|
||||||
from .sensor import add_new_sensors
|
from .sensor import add_new_sensors
|
||||||
from .utils import (
|
from .utils import (
|
||||||
anonymize,
|
anonymize,
|
||||||
|
channel_types,
|
||||||
check_disabled,
|
check_disabled,
|
||||||
loaded_sensors,
|
loaded_sensors,
|
||||||
remap_items,
|
remap_items,
|
||||||
|
|
@ -249,6 +251,27 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
|
||||||
|
|
||||||
return aiohttp.web.Response(body="OK", status=200)
|
return aiohttp.web.Response(body="OK", status=200)
|
||||||
|
|
||||||
|
async def _persist_channel_types(self, data: dict[str, Any]) -> None:
|
||||||
|
"""Store the reported `t234cXtp` probe types in the config entry options.
|
||||||
|
|
||||||
|
Entities are created during entry setup, before the first payload arrives, so
|
||||||
|
the probe type that decides a channel's humidity device class has to outlive
|
||||||
|
the current session - runtime state resets on every reload.
|
||||||
|
|
||||||
|
Merged rather than replaced: a payload that omits some `tp` parameters says
|
||||||
|
nothing about the channels it left out. Written only when something actually
|
||||||
|
changed, and `update_listener` skips the reload for this key.
|
||||||
|
"""
|
||||||
|
|
||||||
|
reported = {param: str(data[param]) for param in CH_HUMIDITY_TYPE_PARAM.values() if param in data}
|
||||||
|
if not reported:
|
||||||
|
return
|
||||||
|
|
||||||
|
stored = channel_types(self.config)
|
||||||
|
merged = {**stored, **reported}
|
||||||
|
if merged != stored:
|
||||||
|
await update_options(self.hass, self.config, CHANNEL_TYPES, merged)
|
||||||
|
|
||||||
async def received_data(self, webdata: aiohttp.web.Request) -> aiohttp.web.Response:
|
async def received_data(self, webdata: aiohttp.web.Request) -> aiohttp.web.Response:
|
||||||
"""Handle incoming webhook payload from the station.
|
"""Handle incoming webhook payload from the station.
|
||||||
|
|
||||||
|
|
@ -281,9 +304,7 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
|
||||||
# The probe type per channel is not a reading, so it is not remapped - but a
|
# 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.
|
# sensor created later needs it to tell soil moisture from air humidity.
|
||||||
if _wslink:
|
if _wslink:
|
||||||
self.config.runtime_data.channel_types = {
|
await self._persist_channel_types(data)
|
||||||
param: data[param] for param in CH_HUMIDITY_TYPE_PARAM.values() if param in data
|
|
||||||
}
|
|
||||||
|
|
||||||
if sensors := check_disabled(remaped_items, self.config):
|
if sensors := check_disabled(remaped_items, self.config):
|
||||||
# Resolve each sensor's display name once (the previous comprehension
|
# Resolve each sensor's display name once (the previous comprehension
|
||||||
|
|
|
||||||
|
|
@ -61,11 +61,6 @@ class SWSRuntimeData:
|
||||||
# Ecowitt station model (e.g. "GW1000"), learned from the first Ecowitt payload.
|
# Ecowitt station model (e.g. "GW1000"), learned from the first Ecowitt payload.
|
||||||
ecowitt_model: str | None = None
|
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 alias for typed ConfigEntry
|
||||||
type SWSConfigEntry = ConfigEntry[SWSRuntimeData]
|
type SWSConfigEntry = ConfigEntry[SWSRuntimeData]
|
||||||
|
|
|
||||||
|
|
@ -70,9 +70,15 @@ class Routes:
|
||||||
self.active = True
|
self.active = True
|
||||||
|
|
||||||
def deactivate(self) -> None:
|
def deactivate(self) -> None:
|
||||||
"""Stop registered routes from dispatching to config-entry handlers."""
|
"""Stop registered routes from dispatching to config-entry handlers.
|
||||||
|
|
||||||
|
The ingress observer is deliberately kept: a payload that arrives while the
|
||||||
|
entry is unloaded is exactly the kind of thing diagnostics needs to record,
|
||||||
|
and `HealthCoordinator.record_dispatch` is safe to call then - it only
|
||||||
|
publishes a snapshot to (no) listeners and tolerates missing runtime data.
|
||||||
|
The next setup repoints it at the new coordinator.
|
||||||
|
"""
|
||||||
self.active = False
|
self.active = False
|
||||||
self._ingress_observer = None
|
|
||||||
|
|
||||||
def _resolve_route(self, request: Request) -> RouteInfo | None:
|
def _resolve_route(self, request: Request) -> RouteInfo | None:
|
||||||
"""Find the matching RouteInfo for a request.
|
"""Find the matching RouteInfo for a request.
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ from .data import SWSConfigEntry, build_device_info
|
||||||
from .sensors_common import WeatherSensorEntityDescription
|
from .sensors_common import WeatherSensorEntityDescription
|
||||||
from .sensors_weather import SENSOR_TYPES_WEATHER_API
|
from .sensors_weather import SENSOR_TYPES_WEATHER_API
|
||||||
from .sensors_wslink import SENSOR_TYPES_WSLINK
|
from .sensors_wslink import SENSOR_TYPES_WSLINK
|
||||||
from .utils import channel_humidity_device_class
|
from .utils import channel_humidity_device_class, channel_types
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .coordinator import WeatherDataUpdateCoordinator
|
from .coordinator import WeatherDataUpdateCoordinator
|
||||||
|
|
@ -176,10 +176,11 @@ class WeatherSensor( # pyright: ignore[reportIncompatibleVariableOverride]
|
||||||
|
|
||||||
# A multi-channel humidity reading is soil moisture when the probe says so.
|
# A multi-channel humidity reading is soil moisture when the probe says so.
|
||||||
# Resolved once here rather than in the (frozen, shared) description, because
|
# Resolved once here rather than in the (frozen, shared) description, because
|
||||||
# it depends on which probe the user actually plugged into that channel.
|
# it depends on which probe the user actually plugged into that channel. The
|
||||||
runtime = getattr(coordinator.config, "runtime_data", None)
|
# probe types come from options, so the class survives a restart - entities are
|
||||||
channel_types = getattr(runtime, "channel_types", None) or {}
|
# created during entry setup, before the first payload arrives.
|
||||||
if (device_class := channel_humidity_device_class(channel_types, description.key)) is not None:
|
probe_types = channel_types(coordinator.config)
|
||||||
|
if (device_class := channel_humidity_device_class(probe_types, description.key)) is not None:
|
||||||
self._attr_device_class = device_class
|
self._attr_device_class = device_class
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
|
||||||
|
|
@ -593,7 +593,7 @@
|
||||||
"name": "Lightning distance"
|
"name": "Lightning distance"
|
||||||
},
|
},
|
||||||
"lightning_strikes_1h": {
|
"lightning_strikes_1h": {
|
||||||
"name": "Lightning strikes (last hour)"
|
"name": "Lightning strikes, last hour (t5lsf)"
|
||||||
},
|
},
|
||||||
"lightning_count_5m": {
|
"lightning_count_5m": {
|
||||||
"name": "Lightning strikes (5 minutes)"
|
"name": "Lightning strikes (5 minutes)"
|
||||||
|
|
@ -602,7 +602,7 @@
|
||||||
"name": "Lightning strikes (30 minutes)"
|
"name": "Lightning strikes (30 minutes)"
|
||||||
},
|
},
|
||||||
"lightning_count_1h": {
|
"lightning_count_1h": {
|
||||||
"name": "Lightning strikes (1 hour)"
|
"name": "Lightning strike total, 1 hour (t5ls1htc)"
|
||||||
},
|
},
|
||||||
"lightning_count_1d": {
|
"lightning_count_1d": {
|
||||||
"name": "Lightning strikes (1 day)"
|
"name": "Lightning strikes (1 day)"
|
||||||
|
|
|
||||||
|
|
@ -593,7 +593,7 @@
|
||||||
"name": "Vzdálenost blesku"
|
"name": "Vzdálenost blesku"
|
||||||
},
|
},
|
||||||
"lightning_strikes_1h": {
|
"lightning_strikes_1h": {
|
||||||
"name": "Blesky (poslední hodina)"
|
"name": "Blesky, poslední hodina (t5lsf)"
|
||||||
},
|
},
|
||||||
"lightning_count_5m": {
|
"lightning_count_5m": {
|
||||||
"name": "Blesky (5 minut)"
|
"name": "Blesky (5 minut)"
|
||||||
|
|
@ -602,7 +602,7 @@
|
||||||
"name": "Blesky (30 minut)"
|
"name": "Blesky (30 minut)"
|
||||||
},
|
},
|
||||||
"lightning_count_1h": {
|
"lightning_count_1h": {
|
||||||
"name": "Blesky (1 hodina)"
|
"name": "Blesky celkem, 1 hodina (t5ls1htc)"
|
||||||
},
|
},
|
||||||
"lightning_count_1d": {
|
"lightning_count_1d": {
|
||||||
"name": "Blesky (1 den)"
|
"name": "Blesky (1 den)"
|
||||||
|
|
|
||||||
|
|
@ -593,7 +593,7 @@
|
||||||
"name": "Lightning distance"
|
"name": "Lightning distance"
|
||||||
},
|
},
|
||||||
"lightning_strikes_1h": {
|
"lightning_strikes_1h": {
|
||||||
"name": "Lightning strikes (last hour)"
|
"name": "Lightning strikes, last hour (t5lsf)"
|
||||||
},
|
},
|
||||||
"lightning_count_5m": {
|
"lightning_count_5m": {
|
||||||
"name": "Lightning strikes (5 minutes)"
|
"name": "Lightning strikes (5 minutes)"
|
||||||
|
|
@ -602,7 +602,7 @@
|
||||||
"name": "Lightning strikes (30 minutes)"
|
"name": "Lightning strikes (30 minutes)"
|
||||||
},
|
},
|
||||||
"lightning_count_1h": {
|
"lightning_count_1h": {
|
||||||
"name": "Lightning strikes (1 hour)"
|
"name": "Lightning strike total, 1 hour (t5ls1htc)"
|
||||||
},
|
},
|
||||||
"lightning_count_1d": {
|
"lightning_count_1d": {
|
||||||
"name": "Lightning strikes (1 day)"
|
"name": "Lightning strikes (1 day)"
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ from .const import (
|
||||||
AZIMUT,
|
AZIMUT,
|
||||||
CH_HUMIDITY_TYPE_PARAM,
|
CH_HUMIDITY_TYPE_PARAM,
|
||||||
CH_TYPE_SOIL,
|
CH_TYPE_SOIL,
|
||||||
|
CHANNEL_TYPES,
|
||||||
CHILL_INDEX,
|
CHILL_INDEX,
|
||||||
CONNECTION_GATED_SENSORS,
|
CONNECTION_GATED_SENSORS,
|
||||||
DEV_DBG,
|
DEV_DBG,
|
||||||
|
|
@ -145,8 +146,10 @@ def remap_wslink_items(entities: dict[str, str]) -> dict[str, str]:
|
||||||
# firmware does not report one, which is not evidence of a disconnection -
|
# 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
|
# 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`.
|
# omits the flag, up to and including the main outdoor sensor gated by `t1cn`.
|
||||||
connection = entities.get(conn_key)
|
# A blank value carries no information either, and `to_int` accepts the
|
||||||
if connection is not None and str(connection) != "1":
|
# decimal spelling the station sometimes uses for integer fields.
|
||||||
|
connection = to_int(entities.get(conn_key))
|
||||||
|
if connection is not None and connection != 1:
|
||||||
for key in gated:
|
for key in gated:
|
||||||
items.pop(key, None)
|
items.pop(key, None)
|
||||||
|
|
||||||
|
|
@ -162,6 +165,19 @@ def loaded_sensors(config_entry: ConfigEntry) -> list[str]:
|
||||||
return config_entry.options.get(SENSORS_TO_LOAD) or []
|
return config_entry.options.get(SENSORS_TO_LOAD) or []
|
||||||
|
|
||||||
|
|
||||||
|
def channel_types(config_entry: ConfigEntry) -> dict[str, str]:
|
||||||
|
"""Return the persisted `t234cXtp` probe types for this config entry.
|
||||||
|
|
||||||
|
The types decide whether a channel's humidity reading is air humidity or soil
|
||||||
|
moisture. They live in options rather than runtime state because entities are
|
||||||
|
created during entry setup, long before the first payload arrives.
|
||||||
|
"""
|
||||||
|
stored = config_entry.options.get(CHANNEL_TYPES)
|
||||||
|
if not isinstance(stored, dict):
|
||||||
|
return {}
|
||||||
|
return {str(param): str(value) for param, value in stored.items()}
|
||||||
|
|
||||||
|
|
||||||
def check_disabled(items: dict[str, str], config_entry: ConfigEntry) -> list[str] | None:
|
def check_disabled(items: dict[str, str], config_entry: ConfigEntry) -> list[str] | None:
|
||||||
"""Detect payload fields that are not enabled yet (auto-discovery).
|
"""Detect payload fields that are not enabled yet (auto-discovery).
|
||||||
|
|
||||||
|
|
@ -492,26 +508,28 @@ def lightning_minutes(value: Any) -> int | None:
|
||||||
return minutes
|
return minutes
|
||||||
|
|
||||||
|
|
||||||
def channel_humidity_device_class(raw_payload: dict[str, Any], key: str) -> SensorDeviceClass | None:
|
def channel_humidity_device_class(probe_types: dict[str, Any], key: str) -> SensorDeviceClass | None:
|
||||||
"""Device class for a multi-channel humidity reading, from the probe type.
|
"""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
|
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
|
probe (type 4) measures soil moisture, not air humidity, so it needs
|
||||||
`SensorDeviceClass.MOISTURE` rather than `HUMIDITY`.
|
`SensorDeviceClass.MOISTURE` rather than `HUMIDITY`.
|
||||||
|
|
||||||
Returns None when the channel is not one of the multi-channel ones, or when the
|
`probe_types` are the persisted types from `channel_types`, not a live payload:
|
||||||
station did not report a type - the description's own device class then applies.
|
the entity is created during entry setup, before any payload arrives, so the
|
||||||
|
types have to survive a restart for the class to stay stable.
|
||||||
|
|
||||||
This is resolved once, when the entity is created: Home Assistant records the
|
Returns None when the channel is not one of the multi-channel ones, or when the
|
||||||
device class in the entity registry, and swapping it underneath a live entity
|
station has never reported a type - the description's own device class then applies.
|
||||||
would rewrite its meaning. Replacing the physical probe therefore needs the
|
|
||||||
integration reinstalled, which is the intended trade-off.
|
This is resolved once, when the entity is created; swapping the physical probe on
|
||||||
|
a channel takes effect on the next restart, once the station reports the new type.
|
||||||
"""
|
"""
|
||||||
param = CH_HUMIDITY_TYPE_PARAM.get(key)
|
param = CH_HUMIDITY_TYPE_PARAM.get(key)
|
||||||
if param is None:
|
if param is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
channel_type = to_int(raw_payload.get(param))
|
channel_type = to_int(probe_types.get(param))
|
||||||
if channel_type is None:
|
if channel_type is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -185,8 +185,10 @@ def test_add_new_adds_new_known_keys(hass):
|
||||||
[
|
[
|
||||||
("0", True), # low battery -> on
|
("0", True), # low battery -> on
|
||||||
(0, True),
|
(0, True),
|
||||||
|
("0.0", True), # the station also spells integers as decimals
|
||||||
("1", False), # battery OK -> off
|
("1", False), # battery OK -> off
|
||||||
(1, False),
|
(1, False),
|
||||||
|
("1.0", False),
|
||||||
(None, None), # missing
|
(None, None), # missing
|
||||||
("", None), # empty string
|
("", None), # empty string
|
||||||
("x", None), # non-int
|
("x", None), # non-int
|
||||||
|
|
@ -201,6 +203,19 @@ def test_is_on_value_mapping(raw, expected):
|
||||||
assert sensor.unique_id == f"{OUTSIDE_BATTERY}_binary"
|
assert sensor.unique_id == f"{OUTSIDE_BATTERY}_binary"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw", ["1", "1.0", 1])
|
||||||
|
def test_a_decimal_formatted_leak_still_reads_as_wet(raw):
|
||||||
|
"""A missed alarm, not a cosmetic gap: `1.0` must not render as `unknown`."""
|
||||||
|
from custom_components.sws12500.battery_sensors_def import LEAK_BINARY_SENSORS
|
||||||
|
from custom_components.sws12500.const import LEAK_CH
|
||||||
|
|
||||||
|
desc = next(d for d in LEAK_BINARY_SENSORS if d.key == LEAK_CH[0])
|
||||||
|
coordinator = _CoordinatorStub({LEAK_CH[0]: raw})
|
||||||
|
sensor = WSBinarySensor(coordinator, desc)
|
||||||
|
|
||||||
|
assert sensor.is_on is True
|
||||||
|
|
||||||
|
|
||||||
def test_is_on_key_absent_returns_none():
|
def test_is_on_key_absent_returns_none():
|
||||||
coordinator = _CoordinatorStub({}) # key not present at all
|
coordinator = _CoordinatorStub({}) # key not present at all
|
||||||
sensor = WSBinarySensor(coordinator, _desc_for(OUTSIDE_BATTERY))
|
sensor = WSBinarySensor(coordinator, _desc_for(OUTSIDE_BATTERY))
|
||||||
|
|
|
||||||
|
|
@ -699,3 +699,44 @@ async def test_options_flow_does_not_roll_back_concurrent_autodiscovery(
|
||||||
|
|
||||||
assert done["type"] == "create_entry"
|
assert done["type"] == "create_entry"
|
||||||
assert done["data"][SENSORS_TO_LOAD] == ["outside_temp", "wind_gust"]
|
assert done["data"][SENSORS_TO_LOAD] == ["outside_temp", "wind_gust"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_options_flow_keeps_the_learned_probe_types(
|
||||||
|
hass,
|
||||||
|
enable_custom_integrations,
|
||||||
|
) -> None:
|
||||||
|
"""Probe types are written from the hot path, like SENSORS_TO_LOAD.
|
||||||
|
|
||||||
|
Dropping them on submit would silently turn every soil channel back into an
|
||||||
|
air-humidity entity on the next restart.
|
||||||
|
"""
|
||||||
|
from custom_components.sws12500.const import CHANNEL_TYPES
|
||||||
|
|
||||||
|
entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
data={},
|
||||||
|
options={
|
||||||
|
API_ID: "station",
|
||||||
|
API_KEY: "secret",
|
||||||
|
LEGACY_ENABLED: True,
|
||||||
|
CHANNEL_TYPES: {"t234c1tp": "4"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
init = await hass.config_entries.options.async_init(entry.entry_id)
|
||||||
|
await hass.config_entries.options.async_configure(init["flow_id"], user_input={"next_step_id": "basic"})
|
||||||
|
|
||||||
|
done = await hass.config_entries.options.async_configure(
|
||||||
|
init["flow_id"],
|
||||||
|
user_input={
|
||||||
|
API_ID: "station",
|
||||||
|
API_KEY: "secret",
|
||||||
|
WSLINK: True,
|
||||||
|
LEGACY_ENABLED: True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert done["type"] == "create_entry"
|
||||||
|
assert done["data"][CHANNEL_TYPES] == {"t234c1tp": "4"}
|
||||||
|
|
|
||||||
|
|
@ -309,6 +309,33 @@ async def test_update_listener_skips_reload_when_only_sensors_to_load_changes(
|
||||||
assert entry.runtime_data.last_options == dict(entry.options)
|
assert entry.runtime_data.last_options == dict(entry.options)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_listener_skips_reload_when_only_probe_types_change(
|
||||||
|
hass_with_http,
|
||||||
|
):
|
||||||
|
"""The webhook handler writes these on every new probe type it is told about.
|
||||||
|
|
||||||
|
Reloading a push-based integration there would drop coordinator listeners.
|
||||||
|
"""
|
||||||
|
from custom_components.sws12500.const import CHANNEL_TYPES
|
||||||
|
|
||||||
|
entry = _entry_with_runtime(
|
||||||
|
hass_with_http,
|
||||||
|
options={API_ID: "id", API_KEY: "key", CHANNEL_TYPES: {"t234c1tp": "2"}},
|
||||||
|
)
|
||||||
|
|
||||||
|
hass_with_http.config_entries.async_reload = AsyncMock()
|
||||||
|
|
||||||
|
hass_with_http.config_entries.async_update_entry(
|
||||||
|
entry, options={**dict(entry.options), CHANNEL_TYPES: {"t234c1tp": "4"}}
|
||||||
|
)
|
||||||
|
|
||||||
|
await update_listener(hass_with_http, entry)
|
||||||
|
|
||||||
|
hass_with_http.config_entries.async_reload.assert_not_awaited()
|
||||||
|
assert entry.runtime_data.last_options == dict(entry.options)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_update_listener_triggers_reload_when_other_option_changes(
|
async def test_update_listener_triggers_reload_when_other_option_changes(
|
||||||
hass_with_http,
|
hass_with_http,
|
||||||
|
|
|
||||||
|
|
@ -531,3 +531,86 @@ async def test_received_data_empty_incoming_credentials_raises_unauthorized(hass
|
||||||
with pytest.raises(HTTPUnauthorized):
|
with pytest.raises(HTTPUnauthorized):
|
||||||
await coordinator.received_data(_RequestStub(query={"ID": "", "PASSWORD": ""})) # type: ignore[arg-type]
|
await coordinator.received_data(_RequestStub(query={"ID": "", "PASSWORD": ""})) # type: ignore[arg-type]
|
||||||
entry.runtime_data.health_coordinator.update_ingress_result.assert_called_once()
|
entry.runtime_data.health_coordinator.update_ingress_result.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
# --- probe types are persisted, not held in runtime state -------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _wslink_coordinator(hass, monkeypatch, entry):
|
||||||
|
"""Coordinator wired for a WSLink push, with option writes captured in `entry.options`."""
|
||||||
|
coordinator = WeatherDataUpdateCoordinator(hass, entry)
|
||||||
|
coordinator.async_set_updated_data = MagicMock()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"custom_components.sws12500.coordinator.check_disabled",
|
||||||
|
lambda _remaped_items, _config: [],
|
||||||
|
)
|
||||||
|
|
||||||
|
writes: list[tuple[str, Any]] = []
|
||||||
|
|
||||||
|
async def _update_options(_hass, config_entry, update_key, update_value):
|
||||||
|
writes.append((update_key, update_value))
|
||||||
|
config_entry.options = {**config_entry.options, update_key: update_value}
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr("custom_components.sws12500.coordinator.update_options", _update_options)
|
||||||
|
return coordinator, writes
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_wslink_payload_persists_the_reported_probe_types(hass, monkeypatch):
|
||||||
|
"""Entities are created before the first payload, so runtime state cannot carry these."""
|
||||||
|
from custom_components.sws12500.const import CHANNEL_TYPES
|
||||||
|
|
||||||
|
entry = _make_entry(wslink=True)
|
||||||
|
coordinator, writes = _wslink_coordinator(hass, monkeypatch, entry)
|
||||||
|
|
||||||
|
request = _RequestStub(query={"wsid": "id", "wspw": "key", "t234c1tp": "4", "t234c1hum": "31"})
|
||||||
|
await coordinator.received_data(request) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
assert writes == [(CHANNEL_TYPES, {"t234c1tp": "4"})]
|
||||||
|
assert entry.options[CHANNEL_TYPES] == {"t234c1tp": "4"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_probe_types_are_merged_not_replaced(hass, monkeypatch):
|
||||||
|
"""A payload that omits a `tp` parameter says nothing about that channel."""
|
||||||
|
from custom_components.sws12500.const import CHANNEL_TYPES
|
||||||
|
|
||||||
|
entry = _make_entry(wslink=True)
|
||||||
|
entry.options[CHANNEL_TYPES] = {"t234c1tp": "4"}
|
||||||
|
coordinator, _writes = _wslink_coordinator(hass, monkeypatch, entry)
|
||||||
|
|
||||||
|
request = _RequestStub(query={"wsid": "id", "wspw": "key", "t234c2tp": "2"})
|
||||||
|
await coordinator.received_data(request) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
assert entry.options[CHANNEL_TYPES] == {"t234c1tp": "4", "t234c2tp": "2"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unchanged_probe_types_are_not_rewritten(hass, monkeypatch):
|
||||||
|
"""Rewriting options on every push would churn the config entry for nothing."""
|
||||||
|
from custom_components.sws12500.const import CHANNEL_TYPES
|
||||||
|
|
||||||
|
entry = _make_entry(wslink=True)
|
||||||
|
entry.options[CHANNEL_TYPES] = {"t234c1tp": "4"}
|
||||||
|
coordinator, writes = _wslink_coordinator(hass, monkeypatch, entry)
|
||||||
|
|
||||||
|
request = _RequestStub(query={"wsid": "id", "wspw": "key", "t234c1tp": "4"})
|
||||||
|
await coordinator.received_data(request) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
assert writes == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_legacy_payload_never_touches_probe_types(hass, monkeypatch):
|
||||||
|
from custom_components.sws12500.const import CHANNEL_TYPES
|
||||||
|
|
||||||
|
entry = _make_entry(wslink=False)
|
||||||
|
coordinator, writes = _wslink_coordinator(hass, monkeypatch, entry)
|
||||||
|
|
||||||
|
request = _RequestStub(query={"ID": "id", "PASSWORD": "key", "t234c1tp": "4"})
|
||||||
|
await coordinator.received_data(request) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
assert writes == []
|
||||||
|
assert CHANNEL_TYPES not in entry.options
|
||||||
|
|
|
||||||
|
|
@ -97,8 +97,12 @@ async def test_deactivated_dispatcher_reports_unloaded() -> None:
|
||||||
|
|
||||||
response = await routes.dispatch(_RequestStub(method="GET", path="/x"))
|
response = await routes.dispatch(_RequestStub(method="GET", path="/x"))
|
||||||
assert response.status == 503
|
assert response.status == 503
|
||||||
# The observer is cleared on deactivate, so nothing is recorded for a dead entry.
|
# The observer survives deactivate: an ingress arriving while the entry is
|
||||||
observer.assert_not_called()
|
# unloaded is exactly what diagnostics needs to report.
|
||||||
|
observer.assert_called_once()
|
||||||
|
_request, route_enabled, reason = observer.call_args.args
|
||||||
|
assert route_enabled is False
|
||||||
|
assert reason == "integration_unloaded"
|
||||||
|
|
||||||
|
|
||||||
def test_show_enabled_reports_nothing_while_deactivated() -> None:
|
def test_show_enabled_reports_nothing_while_deactivated() -> None:
|
||||||
|
|
|
||||||
|
|
@ -241,14 +241,11 @@ def test_channel_humidity_entity_takes_its_class_from_the_probe(channel_type, ex
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
from custom_components.sws12500.const import CH2_HUMIDITY
|
from custom_components.sws12500.const import CH2_HUMIDITY, CHANNEL_TYPES
|
||||||
from custom_components.sws12500.sensor import WeatherSensor
|
from custom_components.sws12500.sensor import WeatherSensor
|
||||||
|
|
||||||
coordinator = MagicMock()
|
coordinator = MagicMock()
|
||||||
coordinator.config = SimpleNamespace(
|
coordinator.config = SimpleNamespace(options={CHANNEL_TYPES: {"t234c1tp": channel_type}})
|
||||||
options={},
|
|
||||||
runtime_data=SimpleNamespace(channel_types={"t234c1tp": channel_type}),
|
|
||||||
)
|
|
||||||
|
|
||||||
sensor = WeatherSensor(_wslink_desc(CH2_HUMIDITY), coordinator)
|
sensor = WeatherSensor(_wslink_desc(CH2_HUMIDITY), coordinator)
|
||||||
assert sensor.device_class == expected
|
assert sensor.device_class == expected
|
||||||
|
|
@ -263,7 +260,30 @@ def test_channel_humidity_entity_falls_back_to_the_description() -> None:
|
||||||
from custom_components.sws12500.sensor import WeatherSensor
|
from custom_components.sws12500.sensor import WeatherSensor
|
||||||
|
|
||||||
coordinator = MagicMock()
|
coordinator = MagicMock()
|
||||||
coordinator.config = SimpleNamespace(options={}, runtime_data=SimpleNamespace(channel_types={}))
|
coordinator.config = SimpleNamespace(options={})
|
||||||
|
|
||||||
sensor = WeatherSensor(_wslink_desc(CH2_HUMIDITY), coordinator)
|
sensor = WeatherSensor(_wslink_desc(CH2_HUMIDITY), coordinator)
|
||||||
assert sensor.device_class == "humidity"
|
assert sensor.device_class == "humidity"
|
||||||
|
|
||||||
|
|
||||||
|
def test_soil_channel_class_survives_a_restart() -> None:
|
||||||
|
"""The restart case: entities are built from options before any payload arrives.
|
||||||
|
|
||||||
|
Runtime state is empty at this point, so the probe type must come from the
|
||||||
|
persisted options or the soil channel silently reverts to air humidity.
|
||||||
|
"""
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from custom_components.sws12500.const import CH2_HUMIDITY, CHANNEL_TYPES
|
||||||
|
from custom_components.sws12500.sensor import WeatherSensor
|
||||||
|
|
||||||
|
coordinator = MagicMock()
|
||||||
|
coordinator.data = {}
|
||||||
|
coordinator.config = SimpleNamespace(
|
||||||
|
options={CHANNEL_TYPES: {"t234c1tp": "4"}},
|
||||||
|
runtime_data=SimpleNamespace(),
|
||||||
|
)
|
||||||
|
|
||||||
|
sensor = WeatherSensor(_wslink_desc(CH2_HUMIDITY), coordinator)
|
||||||
|
assert sensor.device_class == "moisture"
|
||||||
|
|
|
||||||
|
|
@ -220,6 +220,36 @@ def test_outdoor_readings_survive_a_firmware_that_omits_t1cn() -> None:
|
||||||
assert out[WIND_SPEED] == "4.2"
|
assert out[WIND_SPEED] == "4.2"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("blank", ["", " ", "n/a"])
|
||||||
|
def test_a_flag_that_carries_no_value_is_not_a_disconnection(blank) -> None:
|
||||||
|
"""A key present but empty says nothing; treating it as 0 blanks the whole probe."""
|
||||||
|
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": blank})
|
||||||
|
assert out[OUTSIDE_TEMP] == "11.3"
|
||||||
|
assert out[WIND_SPEED] == "4.2"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("connected", ["1", "1.0", 1, 1.0])
|
||||||
|
def test_a_decimal_formatted_connection_flag_still_means_connected(connected) -> None:
|
||||||
|
"""The station sometimes spells integer fields as decimals - see `to_int`."""
|
||||||
|
from custom_components.sws12500.const import OUTSIDE_TEMP
|
||||||
|
from custom_components.sws12500.utils import remap_wslink_items
|
||||||
|
|
||||||
|
out = remap_wslink_items({**OUTDOOR_PAYLOAD, "t1cn": connected})
|
||||||
|
assert out[OUTSIDE_TEMP] == "11.3"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("disconnected", ["0", "0.0", 0])
|
||||||
|
def test_a_decimal_formatted_zero_still_means_disconnected(disconnected) -> 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": disconnected})
|
||||||
|
assert OUTSIDE_TEMP not in out
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Probe type decides the humidity device class
|
# Probe type decides the humidity device class
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue