diff --git a/README.md b/README.md index 08d53f9..3ad23af 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,10 @@ A few details worth knowing: 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`. +- **There are two 1-hour lightning counters.** The API defines both `t5lsf` ("strike + count last 1 hours") and `t5ls1htc` ("count total of during 1 hour") without saying how + they differ, so both get an entity and both carry the parameter name, rather than the + integration guessing which one your firmware fills in. - **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 diff --git a/custom_components/sws12500/binary_sensor.py b/custom_components/sws12500/binary_sensor.py index f7e16aa..42e5870 100644 --- a/custom_components/sws12500/binary_sensor.py +++ b/custom_components/sws12500/binary_sensor.py @@ -1,6 +1,7 @@ """Binary sensor platform for SWS12500. -Exposes low-battery warnings as binary sensors. +Exposes low-battery warnings and water-leak state as binary sensors; the raw value +that means `on` differs per family and comes from the entity description. Auto-discovery adds entities without reloading the entry, using callbacks stored on `runtime_data`. """ diff --git a/custom_components/sws12500/const.py b/custom_components/sws12500/const.py index c3effed..b236a90 100644 --- a/custom_components/sws12500/const.py +++ b/custom_components/sws12500/const.py @@ -438,16 +438,6 @@ REMAP_WSLINK_ITEMS: dict[str, str] = { "t11bat": T11_BATTERY, } -# NOTE: Add more sensors -# -# 'inbat' indoor battery level (1 normal, 0 low) -# 't1bat': outdoor battery level (1 normal, 0 low) -# 't234c1bat': CH2 battery level (1 normal, 0 low) CH2 in integration is CH1 in WSLink -# -# In the following there are sensors that should be available by WSLink. -# We need to compare them to PWS API to make sure, we have the same internal -# representation of same sensors. - ### WSLink API v0.6 coverage # # Every upload parameter the API document defines is now handled: base console, diff --git a/tests/test_wslink_e2e_upload.py b/tests/test_wslink_e2e_upload.py new file mode 100644 index 0000000..4f59b65 --- /dev/null +++ b/tests/test_wslink_e2e_upload.py @@ -0,0 +1,450 @@ +"""End-to-end proof that a full WSLink v0.6 upload becomes Home Assistant entities. + +Every other WSLink test in this suite inspects tables or drives the coordinator with a +request stub. This one is the user's view: a real Home Assistant instance with the +integration set up, a real HTTP request to `/data/upload.php` carrying all 107 upload +parameters from API.pdf v0.6, and then the entity registry / state machine as the +frontend would show them - name, state, unit, device class, state class. + +It exists so the three things that fail silently can be seen rather than reasoned about: + +- a 0-5 battery published as `%` instead of a "low battery" flag (and the reverse), +- a leak sensor whose `1` means wet while its battery's `0` means low, +- a soil probe on a channel reporting soil moisture rather than air humidity. + +Set ``SWS_E2E_REPORT`` to a file path to also dump the resulting entity table there. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.sws12500.const import ( + API_ID, + API_KEY, + CHANNEL_TYPES, + DOMAIN, + WIND_GUST, + WIND_SPEED, + WIND_SPEED_AVG10, + WSLINK, + WSLINK_URL, +) +from custom_components.sws12500.sensors_wslink import SENSOR_TYPES_WSLINK +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_UNIT_OF_MEASUREMENT, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_MILLION, + PERCENTAGE, + STATE_OFF, + STATE_ON, + UnitOfLength, + UnitOfPressure, + UnitOfSpeed, + UnitOfTime, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + +# Credentials, timestamp and API version are not readings. +ENVELOPE = ("wsid", "wspw", "datetime", "apiver") + +STATION_ID = "e2e-station" +STATION_PW = "e2e-secret" + +# A complete WSLink v0.6 upload. Values are plausible readings, chosen so that a +# mis-classification is visible in the rendered state rather than hidden by a +# coincidence: every level battery is 3 (of 5) and every binary battery is 0 (low), +# so a swapped convention shows up as "60 %" where "on/low" belongs, or vice versa. +FULL_PAYLOAD: dict[str, str] = { + # --- credentials / envelope ------------------------------------------- + "wsid": STATION_ID, + "wspw": STATION_PW, + "datetime": "2026-07-26 12:00:00", + "apiver": "0.6", + # --- base console ------------------------------------------------------ + "rbar": "1013.2", + "abar": "998.7", + "intem": "22.4", + "inhum": "45", + "inbat": "1", + # --- Type1 outdoor ----------------------------------------------------- + "t1tem": "18.6", + "t1hum": "62", + "t1feels": "17.9", + "t1chill": "18.1", + "t1heat": "18.9", + "t1dew": "11.2", + "t1wdir": "225", + "t1ws": "3.4", + "t1ws10mav": "2.8", + "t1wgust": "7.1", + "t1rainra": "0.4", + "t1rainhr": "1.2", + "t1raindy": "5.6", + "t1rainwy": "12.8", + "t1rainmth": "44.2", + "t1rainyr": "512.9", + "t1uvi": "4", + "t1solrad": "612.5", + "t1wbgt": "21.3", + "t1bat": "0", # (Normal=1, Low battery=0) -> low + "t1cn": "1", + # --- Type2/3/4 channels CH1-CH7 (CH1 is the integration's CH2) --------- + # Channel 3 (integration CH4) carries a soil probe (tp=4). + **{ + k: v + for ch in range(1, 8) + for k, v in { + f"t234c{ch}tem": f"{15 + ch}.5", + f"t234c{ch}hum": f"{50 + ch}", + f"t234c{ch}bat": "0", # (Normal=1, Low battery=0) -> low + f"t234c{ch}cn": "1", + f"t234c{ch}tp": "4" if ch == 3 else "2", + }.items() + }, + # --- Type5 lightning --------------------------------------------------- + "t5lst": "17", + "t5lskm": "12", + "t5lsf": "3", + "t5ls5mtc": "1", + "t5ls30mtc": "2", + "t5ls1htc": "3", + "t5ls1dtc": "9", + "t5lsbat": "0", # (Normal=1, Low battery=0) -> low + "t5lscn": "1", + # --- Type6 water leak CH1-CH7 ----------------------------------------- + # CH2 is wet (wls=1); every leak battery is low (bat=0) - opposite conventions + # inside one family. + **{ + k: v + for ch in range(1, 8) + for k, v in { + f"t6c{ch}wls": "1" if ch == 2 else "0", + f"t6c{ch}bat": "0", + f"t6c{ch}cn": "1", + }.items() + }, + # --- Type8 particulate matter ----------------------------------------- + "t8pm25": "12", + "t8pm10": "21", + "t8pm25ai": "51", + "t8pm10ai": "34", + "t8bat": "3", # (0~5) remark: 5 is full -> 60 % + "t8cn": "1", + # --- Type9 HCHO / VOC -------------------------------------------------- + "t9hcho": "18", + "t9voclv": "4", + "t9bat": "3", + "t9cn": "1", + # --- Type10 CO2 -------------------------------------------------------- + "t10co2": "812", + "t10bat": "3", + "t10cn": "1", + # --- Type11 CO --------------------------------------------------------- + "t11co": "6", + "t11bat": "3", + "t11cn": "1", +} + + +async def _setup_wslink_entry(hass: HomeAssistant) -> MockConfigEntry: + """Set up the integration exactly as a configured WSLink installation.""" + assert await async_setup_component(hass, "http", {"http": {}}) + entry = MockConfigEntry( + domain=DOMAIN, + title="Sencor SWS 12500", + data={}, + options={ + API_ID: STATION_ID, + API_KEY: STATION_PW, + WSLINK: True, + }, + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + return entry + + +def _entity_table(hass: HomeAssistant, entity_reg: er.EntityRegistry) -> list[dict[str, str]]: + """Render every entity of the integration the way the frontend shows it.""" + rows: list[dict[str, str]] = [] + for reg_entry in er.async_entries_for_config_entry( + entity_reg, next(iter(hass.config_entries.async_entries(DOMAIN))).entry_id + ): + state = hass.states.get(reg_entry.entity_id) + if state is None: + continue + rows.append( + { + "entity_id": reg_entry.entity_id, + "name": str(state.attributes.get("friendly_name", "")), + "state": state.state, + "unit": str(state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) or ""), + "device_class": str(state.attributes.get(ATTR_DEVICE_CLASS) or ""), + "state_class": str(state.attributes.get("state_class") or ""), + } + ) + return sorted(rows, key=lambda row: row["entity_id"]) + + +def _row(rows: list[dict[str, str]], entity_id: str) -> dict[str, str]: + """Return the rendered row for one entity, or fail listing what does exist.""" + for row in rows: + if row["entity_id"] == entity_id: + return row + raise AssertionError(f"{entity_id} was never created; entities: {[r['entity_id'] for r in rows]}") + + +@pytest.fixture +async def wslink_report( + hass: HomeAssistant, + enable_custom_integrations, + hass_client_no_auth, + entity_registry: er.EntityRegistry, +): + """Push the full API payload twice and return the resulting entity table. + + Twice on purpose: the first upload is auto-discovery (it creates the entities), + the second is the steady state a running station produces, and it is the one whose + values the entities must be showing. + """ + await _setup_wslink_entry(hass) + client = await hass_client_no_auth() + + for _ in range(2): + response = await client.get(WSLINK_URL, params=FULL_PAYLOAD) + assert response.status == 200 + assert await response.text() == "OK" + await hass.async_block_till_done() + + return _entity_table(hass, entity_registry) + + +SENSOR = "sensor.weather_station_sws_12500_" +BINARY = "binary_sensor.weather_station_sws_12500_" + + +async def test_full_wslink_upload_creates_every_new_family(wslink_report) -> None: + """A complete v0.6 upload must surface all the newly implemented families.""" + for entity_id in ( + # Type5 lightning + SENSOR + "time_since_last_lightning_strike", + SENSOR + "lightning_distance", + SENSOR + "lightning_strikes_last_hour_t5lsf", + SENSOR + "lightning_strikes_5_minutes", + SENSOR + "lightning_strikes_30_minutes", + SENSOR + "lightning_strike_total_1_hour_t5ls1htc", + SENSOR + "lightning_strikes_1_day", + BINARY + "lightning_sensor_battery", + # Type8 particulate matter + SENSOR + "pm2_5", + SENSOR + "pm10", + SENSOR + "pm2_5_aqi", + SENSOR + "pm10_aqi", + SENSOR + "pm_sensor_battery", + # Type10 CO2 / Type11 CO + SENSOR + "co2", + SENSOR + "co2_sensor_battery", + SENSOR + "co", + SENSOR + "co_sensor_battery", + # Parameters added to existing families + SENSOR + "absolute_pressure", + SENSOR + "feels_like", + SENSOR + "wind_speed_10_min_average", + ): + _row(wslink_report, entity_id) + + # Type6 water leak: seven channels, each with its own battery. + for ch in range(1, 8): + _row(wslink_report, f"{BINARY}water_leak_ch{ch}") + _row(wslink_report, f"{BINARY}water_leak_ch{ch}_battery") + + +async def test_device_classes_match_ha_documentation(wslink_report) -> None: + """The classes the change verified against developers.home-assistant.io.""" + expected = { + SENSOR + "pm2_5": SensorDeviceClass.PM25, + SENSOR + "pm10": SensorDeviceClass.PM10, + SENSOR + "pm2_5_aqi": SensorDeviceClass.AQI, + SENSOR + "pm10_aqi": SensorDeviceClass.AQI, + SENSOR + "co2": SensorDeviceClass.CO2, + SENSOR + "co": SensorDeviceClass.CO, + SENSOR + "lightning_distance": SensorDeviceClass.DISTANCE, + SENSOR + "absolute_pressure": SensorDeviceClass.ATMOSPHERIC_PRESSURE, + SENSOR + "barometric_pressure": SensorDeviceClass.ATMOSPHERIC_PRESSURE, + SENSOR + "wind_speed_10_min_average": SensorDeviceClass.WIND_SPEED, + SENSOR + "feels_like": SensorDeviceClass.TEMPERATURE, + # HA documents no formaldehyde class, so HCHO keeps VOC-parts. + SENSOR + "formaldehyde_hcho": SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS, + } + for entity_id, device_class in expected.items(): + row = _row(wslink_report, entity_id) + assert row["device_class"] == device_class.value, row + + # AQI is unitless; the concentrations carry the units HA documents for their class. + assert _row(wslink_report, SENSOR + "pm2_5_aqi")["unit"] == "" + assert _row(wslink_report, SENSOR + "pm10_aqi")["unit"] == "" + assert _row(wslink_report, SENSOR + "pm2_5")["unit"] == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER + assert _row(wslink_report, SENSOR + "pm10")["unit"] == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER + assert _row(wslink_report, SENSOR + "co2")["unit"] == CONCENTRATION_PARTS_PER_MILLION + assert _row(wslink_report, SENSOR + "co")["unit"] == CONCENTRATION_PARTS_PER_MILLION + assert _row(wslink_report, SENSOR + "lightning_distance")["unit"] == UnitOfLength.KILOMETERS + assert _row(wslink_report, SENSOR + "absolute_pressure")["unit"] == UnitOfPressure.HPA + + +async def test_native_wind_speed_unit_is_metres_per_second() -> None: + """The state renders in the user's unit system; the reading itself is m/s.""" + by_key = {desc.key: desc for desc in SENSOR_TYPES_WSLINK} + for key in (WIND_SPEED, WIND_SPEED_AVG10, WIND_GUST): + assert by_key[key].native_unit_of_measurement == UnitOfSpeed.METERS_PER_SECOND + + +async def test_batteries_use_the_convention_the_api_documents(wslink_report) -> None: + """0/1 batteries stay flags; 0-5 batteries become percentages.""" + # Documented "(Normal=1, Low battery=0)". Sent as 0, so the flag reads "on" (low) + # and carries no unit - a level battery misread as a flag would show "on" for 1-5. + for entity_id in ( + BINARY + "outside_battery", + BINARY + "lightning_sensor_battery", + BINARY + "channel_2_battery", + BINARY + "water_leak_ch1_battery", + ): + row = _row(wslink_report, entity_id) + assert row["device_class"] == BinarySensorDeviceClass.BATTERY.value, row + assert row["state"] == STATE_ON, f"{entity_id} should read as low battery: {row}" + assert row["unit"] == "", f"{entity_id} must not carry a percentage unit: {row}" + + # inbat has no documented scale and is read as 0/1 (sent 1 = normal -> "off"). + console = _row(wslink_report, BINARY + "console_battery") + assert console["device_class"] == BinarySensorDeviceClass.BATTERY.value, console + assert console["state"] == STATE_OFF, console + + # Documented "(0~5) remark: 5 is full". Sent as 3, so 60 % - a flag misread as a + # level would show "on"/"off" instead. + for entity_id in ( + SENSOR + "pm_sensor_battery", + SENSOR + "hcho_voc_sensor_battery", + SENSOR + "co2_sensor_battery", + SENSOR + "co_sensor_battery", + ): + row = _row(wslink_report, entity_id) + assert row["device_class"] == SensorDeviceClass.BATTERY.value, row + assert row["unit"] == PERCENTAGE, row + assert row["state"] == "60", f"{entity_id} should render 3 of 5 as 60 %: {row}" + + +async def test_water_leak_and_its_battery_read_opposite_conventions(wslink_report) -> None: + """Leak 1 = wet, battery 0 = low, in the same family and the same entity class.""" + wet = _row(wslink_report, BINARY + "water_leak_ch2") + assert wet["device_class"] == BinarySensorDeviceClass.MOISTURE.value, wet + assert wet["state"] == STATE_ON, f"t6c2wls=1 means wet: {wet}" + + dry = _row(wslink_report, BINARY + "water_leak_ch1") + assert dry["device_class"] == BinarySensorDeviceClass.MOISTURE.value, dry + assert dry["state"] == STATE_OFF, f"t6c1wls=0 means dry: {dry}" + + # Same payload digit, opposite meaning: the CH1 battery was sent 0 and is low. + assert _row(wslink_report, BINARY + "water_leak_ch1_battery")["state"] == STATE_ON + + +async def test_soil_probe_channel_reports_moisture_not_humidity(wslink_report) -> None: + """`t234c3tp=4` is a soil probe, so integration CH4 humidity is soil moisture.""" + soil = _row(wslink_report, SENSOR + "channel_4_humidity") + assert soil["device_class"] == SensorDeviceClass.MOISTURE.value, soil + + air = _row(wslink_report, SENSOR + "channel_2_humidity") + assert air["device_class"] == SensorDeviceClass.HUMIDITY.value, air + + +async def test_probe_types_are_persisted_so_the_class_survives_a_restart( + hass: HomeAssistant, wslink_report, entity_registry: er.EntityRegistry +) -> None: + """HA records the device class in the entity registry, so the type must persist. + + The types are stored in options rather than runtime state because entities are + created during entry setup, before the first payload of the next run arrives. + """ + entry = next(iter(hass.config_entries.async_entries(DOMAIN))) + stored = entry.options.get(CHANNEL_TYPES, {}) + assert stored.get("t234c3tp") == "4", stored + assert stored.get("t234c1tp") == "2", stored + + # Reload with no payload at all: the soil channel must still be soil moisture. + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + rows = _entity_table(hass, entity_registry) + assert _row(rows, SENSOR + "channel_4_humidity")["device_class"] == SensorDeviceClass.MOISTURE.value + assert _row(rows, SENSOR + "channel_2_humidity")["device_class"] == SensorDeviceClass.HUMIDITY.value + + +async def test_lightning_time_since_last_strike_reads_as_minutes(wslink_report) -> None: + """`t5lst` has no documented unit; 17 is minutes and 9999 means "nothing recorded".""" + row = _row(wslink_report, SENSOR + "time_since_last_lightning_strike") + assert row["unit"] == UnitOfTime.MINUTES, row + assert row["state"] == "17", row + + +async def test_t1cn_gating_drops_a_disconnected_probe_but_absence_does_not( + hass: HomeAssistant, + enable_custom_integrations, + hass_client_no_auth, + entity_registry: er.EntityRegistry, +) -> None: + """The behavioural change existing installations feel. + + `t1cn=0` (explicitly disconnected) must stop publishing outdoor readings; a + firmware that never sends `t1cn` says nothing, so its readings must be kept. + """ + await _setup_wslink_entry(hass) + client = await hass_client_no_auth() + + assert (await client.get(WSLINK_URL, params=FULL_PAYLOAD)).status == 200 + await hass.async_block_till_done() + assert _row(_entity_table(hass, entity_registry), SENSOR + "outside_temperature")["state"] == "18.6" + + # Explicitly disconnected -> the stale reading must not be published. + disconnected = {**FULL_PAYLOAD, "t1cn": "0", "t1tem": "99.9", "t1ws": "88.8"} + assert (await client.get(WSLINK_URL, params=disconnected)).status == 200 + await hass.async_block_till_done() + rows = _entity_table(hass, entity_registry) + assert _row(rows, SENSOR + "outside_temperature")["state"] != "99.9" + assert _row(rows, SENSOR + "wind_speed")["state"] != "88.8" + + # Firmware that omits t1cn gives no information -> keep the readings. + absent = {k: v for k, v in FULL_PAYLOAD.items() if k != "t1cn"} + absent["t1tem"] = "20.1" + assert (await client.get(WSLINK_URL, params=absent)).status == 200 + await hass.async_block_till_done() + assert _row(_entity_table(hass, entity_registry), SENSOR + "outside_temperature")["state"] == "20.1" + + +async def test_write_entity_report(wslink_report) -> None: + """Dump the rendered entity table when SWS_E2E_REPORT names a file.""" + target = os.environ.get("SWS_E2E_REPORT") + if not target: + pytest.skip("SWS_E2E_REPORT not set") + + rows = wslink_report + widths = {col: max(len(col), *(len(row[col]) for row in rows)) for col in rows[0]} + header = " | ".join(col.ljust(widths[col]) for col in rows[0]) + lines = [ + f"WSLink API v0.6 - upload of all {len(FULL_PAYLOAD) - len(ENVELOPE)} parameters " + f"(plus {len(ENVELOPE)} envelope fields) to {WSLINK_URL}", + f"{len(rows)} entities created and populated in Home Assistant", + "", + header, + "-+-".join("-" * widths[col] for col in rows[0]), + *(" | ".join(row[col].ljust(widths[col]) for col in row) for row in rows), + ] + Path(target).write_text("\n".join(lines) + "\n", encoding="utf-8")