diff --git a/README.md b/README.md index 9d2747d..08d53f9 100644 --- a/README.md +++ b/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 diff --git a/custom_components/sws12500/__init__.py b/custom_components/sws12500/__init__.py index 1756a5f..6925cbb 100644 --- a/custom_components/sws12500/__init__.py +++ b/custom_components/sws12500/__init__.py @@ -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 diff --git a/custom_components/sws12500/battery_sensors.py b/custom_components/sws12500/battery_sensors.py index 008e1f3..ed044b0 100644 --- a/custom_components/sws12500/battery_sensors.py +++ b/custom_components/sws12500/battery_sensors.py @@ -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 diff --git a/custom_components/sws12500/config_flow.py b/custom_components/sws12500/config_flow.py index 4089449..9bb0af7 100644 --- a/custom_components/sws12500/config_flow.py +++ b/custom_components/sws12500/config_flow.py @@ -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.""" diff --git a/custom_components/sws12500/const.py b/custom_components/sws12500/const.py index 23af8b5..ae3a0b4 100644 --- a/custom_components/sws12500/const.py +++ b/custom_components/sws12500/const.py @@ -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", diff --git a/custom_components/sws12500/coordinator.py b/custom_components/sws12500/coordinator.py index 2599127..f4e1eb3 100644 --- a/custom_components/sws12500/coordinator.py +++ b/custom_components/sws12500/coordinator.py @@ -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 diff --git a/custom_components/sws12500/data.py b/custom_components/sws12500/data.py index daceada..02f16c6 100644 --- a/custom_components/sws12500/data.py +++ b/custom_components/sws12500/data.py @@ -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] diff --git a/custom_components/sws12500/routes.py b/custom_components/sws12500/routes.py index 6d53886..d4266fe 100644 --- a/custom_components/sws12500/routes.py +++ b/custom_components/sws12500/routes.py @@ -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. diff --git a/custom_components/sws12500/sensor.py b/custom_components/sws12500/sensor.py index 2042f5a..7207999 100644 --- a/custom_components/sws12500/sensor.py +++ b/custom_components/sws12500/sensor.py @@ -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 diff --git a/custom_components/sws12500/strings.json b/custom_components/sws12500/strings.json index 6a1efd8..74cdaad 100644 --- a/custom_components/sws12500/strings.json +++ b/custom_components/sws12500/strings.json @@ -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)" diff --git a/custom_components/sws12500/translations/cs.json b/custom_components/sws12500/translations/cs.json index 3500e3b..18998d8 100644 --- a/custom_components/sws12500/translations/cs.json +++ b/custom_components/sws12500/translations/cs.json @@ -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)" diff --git a/custom_components/sws12500/translations/en.json b/custom_components/sws12500/translations/en.json index 6a1efd8..74cdaad 100644 --- a/custom_components/sws12500/translations/en.json +++ b/custom_components/sws12500/translations/en.json @@ -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)" diff --git a/custom_components/sws12500/utils.py b/custom_components/sws12500/utils.py index 096780c..ace3718 100644 --- a/custom_components/sws12500/utils.py +++ b/custom_components/sws12500/utils.py @@ -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 diff --git a/tests/test_binary_battery.py b/tests/test_binary_battery.py index 2e129e3..119942b 100644 --- a/tests/test_binary_battery.py +++ b/tests/test_binary_battery.py @@ -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)) diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 746dcd1..a842cea 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -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"} diff --git a/tests/test_integration_lifecycle.py b/tests/test_integration_lifecycle.py index a1ded6b..88b3a3e 100644 --- a/tests/test_integration_lifecycle.py +++ b/tests/test_integration_lifecycle.py @@ -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, diff --git a/tests/test_received_data.py b/tests/test_received_data.py index 9cc8928..8ec4c77 100644 --- a/tests/test_received_data.py +++ b/tests/test_received_data.py @@ -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 diff --git a/tests/test_routes_more.py b/tests/test_routes_more.py index e7e3069..45a3f14 100644 --- a/tests/test_routes_more.py +++ b/tests/test_routes_more.py @@ -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: diff --git a/tests/test_sensor_platform.py b/tests/test_sensor_platform.py index e5be905..b0d7cbd 100644 --- a/tests/test_sensor_platform.py +++ b/tests/test_sensor_platform.py @@ -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" diff --git a/tests/test_wslink_api_coverage.py b/tests/test_wslink_api_coverage.py index 3d939f0..36152dd 100644 --- a/tests/test_wslink_api_coverage.py +++ b/tests/test_wslink_api_coverage.py @@ -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 # ---------------------------------------------------------------------------