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 |
|
||||
| **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 |
|
||||
| **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 |
|
||||
| **Type9** air quality | formaldehyde (HCHO) in ppb, VOC level, battery |
|
||||
| **Type10** | CO₂ concentration, battery |
|
||||
|
|
@ -129,6 +129,10 @@ helpers.
|
|||
|
||||
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,
|
||||
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
|
||||
|
|
@ -137,12 +141,14 @@ A few details worth knowing:
|
|||
"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.
|
||||
station that does not send a connection flag, or sends it empty, is never gated — only an
|
||||
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
|
||||
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.
|
||||
class instead of `humidity`. The reported probe types are remembered across restarts, and
|
||||
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`,
|
||||
`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
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ from homeassistant.helpers.event import async_track_time_interval
|
|||
|
||||
from .conflicts import effective_protocols, update_protocol_conflict_issue
|
||||
from .const import (
|
||||
CHANNEL_TYPES,
|
||||
DEFAULT_URL,
|
||||
DOMAIN,
|
||||
ECOWITT_URL_PREFIX,
|
||||
|
|
@ -235,7 +236,9 @@ async def update_listener(hass: HomeAssistant, entry: SWSConfigEntry):
|
|||
"""Handle config entry option updates.
|
||||
|
||||
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
|
||||
forwarders read on every push - so a forwarder that auto-disables itself from the
|
||||
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
|
||||
|
||||
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)))
|
||||
return
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
|||
|
||||
from .data import build_device_info
|
||||
from .sensors_common import WSBinarySensorEntityDescription
|
||||
from .utils import to_int
|
||||
|
||||
|
||||
class WSBinarySensor( # pyright: ignore[reportIncompatibleVariableOverride]
|
||||
|
|
@ -53,12 +54,10 @@ class WSBinarySensor( # pyright: ignore[reportIncompatibleVariableOverride]
|
|||
data = checked_or(self.coordinator.data, dict[str, Any], {})
|
||||
raw: Any = data.get(self.entity_description.key)
|
||||
|
||||
if raw is None or raw == "":
|
||||
return None
|
||||
|
||||
try:
|
||||
value = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
# `to_int` rather than a bare `int`: the station sometimes sends integer
|
||||
# fields as decimals, and a leak reported as "1.0" must still read as wet.
|
||||
value = to_int(raw)
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
return value == self.entity_description.on_value
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from .conflicts import ERROR_MUTUALLY_EXCLUSIVE
|
|||
from .const import (
|
||||
API_ID,
|
||||
API_KEY,
|
||||
CHANNEL_TYPES,
|
||||
DEV_DBG,
|
||||
DOMAIN,
|
||||
ECOWITT_ENABLED,
|
||||
|
|
@ -344,15 +345,17 @@ class ConfigOptionsFlowHandler(OptionsFlow):
|
|||
def retain_data(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""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`
|
||||
snapshot: auto-discovery appends to it from the webhook handler, so a dialog
|
||||
left open while the station reports a new field would otherwise roll that
|
||||
discovery back on submit.
|
||||
`SENSORS_TO_LOAD` and `CHANNEL_TYPES` are re-read here rather than taken from
|
||||
the `_get_entry_data` snapshot: the webhook handler writes both, so a dialog
|
||||
left open while the station reports a new field - or a new probe type - would
|
||||
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)
|
||||
probe_types = self.config_entry.options.get(CHANNEL_TYPES)
|
||||
|
||||
return {
|
||||
retained: dict[str, Any] = {
|
||||
**self.user_data,
|
||||
**self.windy_data,
|
||||
**self.pocasi_cz,
|
||||
|
|
@ -362,6 +365,11 @@ class ConfigOptionsFlowHandler(OptionsFlow):
|
|||
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):
|
||||
"""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"
|
||||
|
||||
# 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 = [
|
||||
"API",
|
||||
"API_ID",
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ from .const import (
|
|||
API_ID,
|
||||
API_KEY,
|
||||
CH_HUMIDITY_TYPE_PARAM,
|
||||
CHANNEL_TYPES,
|
||||
DEV_DBG,
|
||||
DOMAIN,
|
||||
ECOWITT_ENABLED,
|
||||
|
|
@ -49,6 +50,7 @@ from .pocasti_cz import PocasiPush
|
|||
from .sensor import add_new_sensors
|
||||
from .utils import (
|
||||
anonymize,
|
||||
channel_types,
|
||||
check_disabled,
|
||||
loaded_sensors,
|
||||
remap_items,
|
||||
|
|
@ -249,6 +251,27 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
|
|||
|
||||
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:
|
||||
"""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
|
||||
# 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
|
||||
}
|
||||
await self._persist_channel_types(data)
|
||||
|
||||
if sensors := check_disabled(remaped_items, self.config):
|
||||
# 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_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]
|
||||
|
|
|
|||
|
|
@ -70,9 +70,15 @@ class Routes:
|
|||
self.active = True
|
||||
|
||||
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._ingress_observer = None
|
||||
|
||||
def _resolve_route(self, request: Request) -> RouteInfo | None:
|
||||
"""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_weather import SENSOR_TYPES_WEATHER_API
|
||||
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:
|
||||
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.
|
||||
# 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:
|
||||
# it depends on which probe the user actually plugged into that channel. The
|
||||
# probe types come from options, so the class survives a restart - entities are
|
||||
# created during entry setup, before the first payload arrives.
|
||||
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
|
||||
|
||||
@property
|
||||
|
|
|
|||
|
|
@ -593,7 +593,7 @@
|
|||
"name": "Lightning distance"
|
||||
},
|
||||
"lightning_strikes_1h": {
|
||||
"name": "Lightning strikes (last hour)"
|
||||
"name": "Lightning strikes, last hour (t5lsf)"
|
||||
},
|
||||
"lightning_count_5m": {
|
||||
"name": "Lightning strikes (5 minutes)"
|
||||
|
|
@ -602,7 +602,7 @@
|
|||
"name": "Lightning strikes (30 minutes)"
|
||||
},
|
||||
"lightning_count_1h": {
|
||||
"name": "Lightning strikes (1 hour)"
|
||||
"name": "Lightning strike total, 1 hour (t5ls1htc)"
|
||||
},
|
||||
"lightning_count_1d": {
|
||||
"name": "Lightning strikes (1 day)"
|
||||
|
|
|
|||
|
|
@ -593,7 +593,7 @@
|
|||
"name": "Vzdálenost blesku"
|
||||
},
|
||||
"lightning_strikes_1h": {
|
||||
"name": "Blesky (poslední hodina)"
|
||||
"name": "Blesky, poslední hodina (t5lsf)"
|
||||
},
|
||||
"lightning_count_5m": {
|
||||
"name": "Blesky (5 minut)"
|
||||
|
|
@ -602,7 +602,7 @@
|
|||
"name": "Blesky (30 minut)"
|
||||
},
|
||||
"lightning_count_1h": {
|
||||
"name": "Blesky (1 hodina)"
|
||||
"name": "Blesky celkem, 1 hodina (t5ls1htc)"
|
||||
},
|
||||
"lightning_count_1d": {
|
||||
"name": "Blesky (1 den)"
|
||||
|
|
|
|||
|
|
@ -593,7 +593,7 @@
|
|||
"name": "Lightning distance"
|
||||
},
|
||||
"lightning_strikes_1h": {
|
||||
"name": "Lightning strikes (last hour)"
|
||||
"name": "Lightning strikes, last hour (t5lsf)"
|
||||
},
|
||||
"lightning_count_5m": {
|
||||
"name": "Lightning strikes (5 minutes)"
|
||||
|
|
@ -602,7 +602,7 @@
|
|||
"name": "Lightning strikes (30 minutes)"
|
||||
},
|
||||
"lightning_count_1h": {
|
||||
"name": "Lightning strikes (1 hour)"
|
||||
"name": "Lightning strike total, 1 hour (t5ls1htc)"
|
||||
},
|
||||
"lightning_count_1d": {
|
||||
"name": "Lightning strikes (1 day)"
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ from .const import (
|
|||
AZIMUT,
|
||||
CH_HUMIDITY_TYPE_PARAM,
|
||||
CH_TYPE_SOIL,
|
||||
CHANNEL_TYPES,
|
||||
CHILL_INDEX,
|
||||
CONNECTION_GATED_SENSORS,
|
||||
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 -
|
||||
# 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":
|
||||
# A blank value carries no information either, and `to_int` accepts the
|
||||
# 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:
|
||||
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 []
|
||||
|
||||
|
||||
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:
|
||||
"""Detect payload fields that are not enabled yet (auto-discovery).
|
||||
|
||||
|
|
@ -492,26 +508,28 @@ def lightning_minutes(value: Any) -> int | None:
|
|||
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.
|
||||
|
||||
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.
|
||||
`probe_types` are the persisted types from `channel_types`, not a live payload:
|
||||
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
|
||||
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.
|
||||
Returns None when the channel is not one of the multi-channel ones, or when the
|
||||
station has never reported a type - the description's own device class then applies.
|
||||
|
||||
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)
|
||||
if param is None:
|
||||
return None
|
||||
|
||||
channel_type = to_int(raw_payload.get(param))
|
||||
channel_type = to_int(probe_types.get(param))
|
||||
if channel_type is None:
|
||||
return None
|
||||
|
||||
|
|
|
|||
|
|
@ -185,8 +185,10 @@ def test_add_new_adds_new_known_keys(hass):
|
|||
[
|
||||
("0", True), # low battery -> on
|
||||
(0, True),
|
||||
("0.0", True), # the station also spells integers as decimals
|
||||
("1", False), # battery OK -> off
|
||||
(1, False),
|
||||
("1.0", False),
|
||||
(None, None), # missing
|
||||
("", None), # empty string
|
||||
("x", None), # non-int
|
||||
|
|
@ -201,6 +203,19 @@ def test_is_on_value_mapping(raw, expected):
|
|||
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():
|
||||
coordinator = _CoordinatorStub({}) # key not present at all
|
||||
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["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)
|
||||
|
||||
|
||||
@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
|
||||
async def test_update_listener_triggers_reload_when_other_option_changes(
|
||||
hass_with_http,
|
||||
|
|
|
|||
|
|
@ -531,3 +531,86 @@ async def test_received_data_empty_incoming_credentials_raises_unauthorized(hass
|
|||
with pytest.raises(HTTPUnauthorized):
|
||||
await coordinator.received_data(_RequestStub(query={"ID": "", "PASSWORD": ""})) # type: ignore[arg-type]
|
||||
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"))
|
||||
assert response.status == 503
|
||||
# The observer is cleared on deactivate, so nothing is recorded for a dead entry.
|
||||
observer.assert_not_called()
|
||||
# The observer survives deactivate: an ingress arriving while the entry is
|
||||
# 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:
|
||||
|
|
|
|||
|
|
@ -241,14 +241,11 @@ def test_channel_humidity_entity_takes_its_class_from_the_probe(channel_type, ex
|
|||
from types import SimpleNamespace
|
||||
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
|
||||
|
||||
coordinator = MagicMock()
|
||||
coordinator.config = SimpleNamespace(
|
||||
options={},
|
||||
runtime_data=SimpleNamespace(channel_types={"t234c1tp": channel_type}),
|
||||
)
|
||||
coordinator.config = SimpleNamespace(options={CHANNEL_TYPES: {"t234c1tp": channel_type}})
|
||||
|
||||
sensor = WeatherSensor(_wslink_desc(CH2_HUMIDITY), coordinator)
|
||||
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
|
||||
|
||||
coordinator = MagicMock()
|
||||
coordinator.config = SimpleNamespace(options={}, runtime_data=SimpleNamespace(channel_types={}))
|
||||
coordinator.config = SimpleNamespace(options={})
|
||||
|
||||
sensor = WeatherSensor(_wslink_desc(CH2_HUMIDITY), coordinator)
|
||||
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"
|
||||
|
||||
|
||||
@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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in New Issue