fix(review): persist WSLink probe types and harden flag parsing

ecowitt_support
SchiZzA 2026-07-26 15:41:30 +02:00
parent 624de521c4
commit d2ae26eab5
No known key found for this signature in database
20 changed files with 340 additions and 58 deletions

View File

@ -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** CH1CH7 | temperature and humidity per channel, battery | | **Type2/3/4** CH2CH8 (API CH1CH7) | temperature and humidity per channel, battery |
| **Type5** lightning | distance, time since the last strike, strike counts over 5 min / 30 min / 1 h / 1 day, battery | | **Type5** lightning | distance, time since the last strike, strike counts over 5 min / 30 min / 1 h / 1 day, battery |
| **Type6** water leak CH1CH7 | leak state per channel, battery | | **Type6** water leak CH1CH7 | 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 05 level instead and `battery` sensors. The PM, air-quality, CO₂ and CO probes report a 05 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 15 index, where 1 is worst. `good`, `excellent`) from the station's 15 index, where 1 is worst.
- **Heat index and wind chill** are taken from the station when it sends them, and computed - **Heat index and wind chill** are taken from the station when it sends them, and computed

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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