"""The WSLink API v0.6 must be covered completely and classified correctly. `API.pdf` (WSLink data upload API, VERSION 0.6) is the contract. This file pins the parameter list from that document so a future firmware or a refactor cannot quietly drop a sensor, and - more importantly - so the two battery conventions cannot get mixed up: - ``(Normal=1, Low battery=0)`` -> binary sensor - ``(0~5) remark: 5 is full`` -> percentage sensor Getting that wrong is silent: a 0-5 battery read as binary reports "not low" for levels 1-5 and throws four fifths of the reading away. """ from __future__ import annotations import pytest from custom_components.sws12500.battery_sensors_def import ( BATTERY_BINARY_SENSORS, BATTERY_LEVEL_SENSORS, LEAK_BINARY_SENSORS, ) from custom_components.sws12500.const import ( BATTERY_LIST, BATTERY_NON_BINARY, CONNECTION_GATED_SENSORS, LEAK_CH, LEAK_CH_BATTERY, REMAP_WSLINK_ITEMS, ) from custom_components.sws12500.sensors_wslink import SENSOR_TYPES_WSLINK # Every upload parameter in API.pdf v0.6, by sensor type. API_BASE = ["rbar", "abar", "intem", "inhum", "inbat"] API_TYPE1 = [ "t1tem", "t1hum", "t1feels", "t1chill", "t1heat", "t1dew", "t1wdir", "t1ws", "t1ws10mav", "t1wgust", "t1rainra", "t1rainhr", "t1raindy", "t1rainwy", "t1rainmth", "t1rainyr", "t1uvi", "t1solrad", "t1wbgt", "t1bat", "t1cn", ] API_TYPE234 = [f"t234c{ch}{part}" for ch in range(1, 8) for part in ("tem", "hum", "bat", "cn", "tp")] API_TYPE5 = [ "t5lst", "t5lskm", "t5lsf", "t5ls5mtc", "t5ls30mtc", "t5ls1htc", "t5ls1dtc", "t5lsbat", "t5lscn", ] API_TYPE6 = [f"t6c{ch}{part}" for ch in range(1, 8) for part in ("wls", "bat", "cn")] API_TYPE8 = ["t8pm25", "t8pm10", "t8pm25ai", "t8pm10ai", "t8bat", "t8cn"] API_TYPE9 = ["t9hcho", "t9voclv", "t9bat", "t9cn"] API_TYPE10 = ["t10co2", "t10bat", "t10cn"] API_TYPE11 = ["t11co", "t11bat", "t11cn"] # Parameters that are not readings: credentials, timestamp, API version, and the # per-channel probe type (which selects a device class rather than becoming a sensor). API_NON_READINGS = {"wsid", "wspw", "datetime", "apiver"} | {f"t234c{ch}tp" for ch in range(1, 8)} ALL_API_PARAMS = [ *API_BASE, *API_TYPE1, *API_TYPE234, *API_TYPE5, *API_TYPE6, *API_TYPE8, *API_TYPE9, *API_TYPE10, *API_TYPE11, ] def _handled() -> set[str]: """Raw parameters the integration does something with.""" return set(REMAP_WSLINK_ITEMS) | set(CONNECTION_GATED_SENSORS) | API_NON_READINGS # --------------------------------------------------------------------------- # Coverage # --------------------------------------------------------------------------- @pytest.mark.parametrize( ("name", "params"), [ ("base", API_BASE), ("type1", API_TYPE1), ("type2/3/4", API_TYPE234), ("type5 lightning", API_TYPE5), ("type6 water leak", API_TYPE6), ("type8 PM", API_TYPE8), ("type9 HCHO/VOC", API_TYPE9), ("type10 CO2", API_TYPE10), ("type11 CO", API_TYPE11), ], ) def test_every_api_parameter_is_handled(name, params) -> None: """No upload parameter may be silently ignored.""" unhandled = sorted(set(params) - _handled()) assert not unhandled, f"{name}: unhandled WSLink parameters {unhandled}" def test_no_invented_parameters() -> None: """The remap table must not claim parameters the API does not define.""" unknown = sorted(set(REMAP_WSLINK_ITEMS) - set(ALL_API_PARAMS)) assert not unknown, f"not in API.pdf v0.6: {unknown}" def test_gates_are_real_connection_parameters() -> None: """Every gate key must be a `*cn` parameter from the document.""" for gate in CONNECTION_GATED_SENSORS: assert gate in ALL_API_PARAMS, f"{gate} is not an API parameter" assert gate.endswith("cn"), f"{gate} is not a connection parameter" def test_every_reading_becomes_an_entity() -> None: """A remapped key with no entity would be dead weight in SENSORS_TO_LOAD.""" sensor_keys = {d.key for d in SENSOR_TYPES_WSLINK} binary_keys = {d.key for d in (*BATTERY_BINARY_SENSORS, *LEAK_BINARY_SENSORS)} orphans = sorted(set(REMAP_WSLINK_ITEMS.values()) - sensor_keys - binary_keys) assert not orphans, f"remapped but never shown: {orphans}" # --------------------------------------------------------------------------- # Battery conventions - the part that fails silently when wrong # --------------------------------------------------------------------------- # Straight from API.pdf. Batteries documented "(Normal=1, Low battery=0)". API_BINARY_BATTERIES = ["t1bat", *(f"t234c{ch}bat" for ch in range(1, 8)), "t5lsbat", *(f"t6c{ch}bat" for ch in range(1, 8))] # Batteries documented "(0~5) remark: 5 is full". API_LEVEL_BATTERIES = ["t8bat", "t9bat", "t10bat", "t11bat"] @pytest.mark.parametrize("param", API_BINARY_BATTERIES) def test_binary_batteries_are_classified_binary(param) -> None: key = REMAP_WSLINK_ITEMS[param] assert key in BATTERY_LIST, f"{param} is 0/1 in the API but is not a binary battery" assert key not in BATTERY_NON_BINARY @pytest.mark.parametrize("param", API_LEVEL_BATTERIES) def test_level_batteries_are_classified_as_levels(param) -> None: key = REMAP_WSLINK_ITEMS[param] assert key in BATTERY_NON_BINARY, f"{param} is 0-5 in the API but is not a level battery" assert key not in BATTERY_LIST def test_every_api_battery_is_accounted_for() -> None: """A new battery must land in exactly one of the two tuples.""" from_api = {REMAP_WSLINK_ITEMS[p] for p in (*API_BINARY_BATTERIES, *API_LEVEL_BATTERIES)} classified = set(BATTERY_LIST) | set(BATTERY_NON_BINARY) assert from_api <= classified, f"unclassified: {sorted(from_api - classified)}" def test_level_battery_scale_maps_five_to_full() -> None: """0-5 with 5 = full, per the API's own remark.""" for desc in BATTERY_LEVEL_SENSORS: assert desc.value_fn is not None assert desc.value_fn("5") == 100 assert desc.value_fn("0") == 0 # --------------------------------------------------------------------------- # Water leak # --------------------------------------------------------------------------- def test_leak_sensors_cover_all_seven_channels() -> None: assert len(LEAK_CH) == 7 assert len(LEAK_CH_BATTERY) == 7 assert {d.key for d in LEAK_BINARY_SENSORS} == set(LEAK_CH) def test_leak_is_on_when_the_station_reports_one() -> None: """`Leak=1, No leak=0`, and MOISTURE means `on` = wet - so on_value is 1. The batteries in the same family use the opposite convention, which is exactly why `on_value` lives in the description. """ for desc in LEAK_BINARY_SENSORS: assert desc.on_value == 1 for desc in BATTERY_BINARY_SENSORS: assert desc.on_value == 0 def test_leak_channels_are_gated_by_their_own_connection() -> None: for channel in range(1, 8): gated = CONNECTION_GATED_SENSORS[f"t6c{channel}cn"] assert LEAK_CH[channel - 1] in gated assert LEAK_CH_BATTERY[channel - 1] in gated # --------------------------------------------------------------------------- # Outdoor probe gating # # `t1cn` was in the API but wired to nothing, so a disconnected outdoor probe kept # publishing its last readings. Gating it is only safe because an *absent* flag no # longer counts as "disconnected" - otherwise any firmware that omits `t1cn` would # lose temperature, wind and rain in one go. # --------------------------------------------------------------------------- OUTDOOR_PAYLOAD = {"t1tem": "11.3", "t1ws": "4.2", "t1raindy": "0.8", "t1bat": "1"} def test_disconnected_outdoor_probe_drops_its_readings() -> None: from custom_components.sws12500.const import OUTSIDE_TEMP from custom_components.sws12500.utils import remap_wslink_items out = remap_wslink_items({**OUTDOOR_PAYLOAD, "t1cn": "0"}) assert OUTSIDE_TEMP not in out assert not out, f"nothing from a disconnected probe should survive: {sorted(out)}" def test_connected_outdoor_probe_keeps_its_readings() -> None: from custom_components.sws12500.const import OUTSIDE_TEMP, WIND_SPEED from custom_components.sws12500.utils import remap_wslink_items out = remap_wslink_items({**OUTDOOR_PAYLOAD, "t1cn": "1"}) assert out[OUTSIDE_TEMP] == "11.3" assert out[WIND_SPEED] == "4.2" def test_outdoor_readings_survive_a_firmware_that_omits_t1cn() -> None: """The regression this gating could have caused, pinned.""" from custom_components.sws12500.const import OUTSIDE_TEMP, WIND_SPEED from custom_components.sws12500.utils import remap_wslink_items out = remap_wslink_items(OUTDOOR_PAYLOAD) assert out[OUTSIDE_TEMP] == "11.3" assert out[WIND_SPEED] == "4.2" @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 # --------------------------------------------------------------------------- @pytest.mark.parametrize( ("channel_type", "expected"), [("2", "humidity"), ("3", "humidity"), ("4", "moisture")], ids=["thermo-hygrometer", "pool", "soil"], ) def test_channel_humidity_device_class_follows_the_probe_type(channel_type, expected) -> None: """Type 4 is a soil probe, so its `hum` reading is soil moisture, not humidity.""" from custom_components.sws12500.const import CH2_HUMIDITY from custom_components.sws12500.utils import channel_humidity_device_class resolved = channel_humidity_device_class({"t234c1tp": channel_type}, CH2_HUMIDITY) assert resolved is not None assert resolved.value == expected def test_channel_humidity_device_class_is_none_without_a_type() -> None: """No reported type means the description's own device class stands.""" from custom_components.sws12500.const import CH2_HUMIDITY, OUTSIDE_HUMIDITY from custom_components.sws12500.utils import channel_humidity_device_class assert channel_humidity_device_class({}, CH2_HUMIDITY) is None # Outdoor humidity is not a multi-channel probe at all. assert channel_humidity_device_class({"t234c1tp": "4"}, OUTSIDE_HUMIDITY) is None def test_every_channel_has_its_own_type_parameter() -> None: from custom_components.sws12500.const import CH_HUMIDITY_TYPE_PARAM assert len(CH_HUMIDITY_TYPE_PARAM) == 7 assert set(CH_HUMIDITY_TYPE_PARAM.values()) == {f"t234c{ch}tp" for ch in range(1, 8)} # --------------------------------------------------------------------------- # `t5lst` sentinel # --------------------------------------------------------------------------- @pytest.mark.parametrize(("raw", "expected"), [("0", 0), ("7", 7), ("120", 120), ("9998", 9998)]) def test_lightning_minutes_passes_real_readings_through(raw, expected) -> None: from custom_components.sws12500.utils import lightning_minutes assert lightning_minutes(raw) == expected @pytest.mark.parametrize("raw", ["9999", 9999, "10000", "", None, "n/a"]) def test_lightning_minutes_reports_nothing_for_the_sentinel(raw) -> None: """The API documents no unit and its own example uses 9999. Reading that as "9999 minutes since the last strike" would show a week-old strike on a station that has never seen one. """ from custom_components.sws12500.utils import lightning_minutes assert lightning_minutes(raw) is None def test_lightning_last_description_uses_the_sentinel_helper() -> None: from custom_components.sws12500.const import LIGHTNING_LAST from custom_components.sws12500.utils import lightning_minutes desc = next(d for d in SENSOR_TYPES_WSLINK if d.key == LIGHTNING_LAST) assert desc.value_fn is lightning_minutes # No device class: TIMESTAMP would need a tz-aware datetime, which the API # does not provide. assert desc.device_class is None