Compare commits

...

16 Commits

Author SHA1 Message Date
Lukas Svoboda c8345e12bc
Merge 935953f20f into 7582f1e9b4 2026-07-26 14:29:32 +00:00
SchiZzA 935953f20f
fix(document): Drop stale WSLink TODO preamble, refresh binary sensor docs 2026-07-26 16:28:05 +02:00
SchiZzA 3cdaffc81f
fix(review): parse battery flags via to_int, release routes on removal 2026-07-26 15:53:03 +02:00
SchiZzA d2ae26eab5
fix(review): persist WSLink probe types and harden flag parsing 2026-07-26 15:41:30 +02:00
SchiZzA 624de521c4
feat(wslink): implement every sensor the WSLink API v0.6 defines
Coverage went from 54 of 107 upload parameters to all 107, adding roughly 34
entities. New families: Type5 lightning, Type6 water leak (CH1-7), Type8
particulate matter, Type10 CO2 and Type11 CO, plus the parameters missing from
existing families - absolute pressure, feels-like temperature, the 10-minute
average wind speed, and the per-channel probe type.

Device classes follow Home Assistant's own list (PM25, PM10, AQI, CO2, CO,
DISTANCE, ATMOSPHERIC_PRESSURE, WIND_SPEED, and binary MOISTURE for leaks), so
the readings work with statistics and unit conversion without template helpers.

Batteries were the trap. The API annotates two incompatible conventions -
"(Normal=1, Low battery=0)" and "(0~5) remark: 5 is full" - and mixing them up is
silent, because a level battery read as a flag reports "not low" for every level
above empty. Eight new binary batteries (t5lsbat, t6c1-7bat) and three new level
batteries (t8bat, t10bat, t11bat) are classified from the document, and
tests/test_wslink_api_coverage.py pins each one to the wording it came from.

Water leak and battery use opposite conventions in the same family (leak 1 means
wet, battery 0 means low), so `on_value` moved into the description and one
entity class now serves both instead of two that differ by a comparison.

`t1cn` was in the API but wired to nothing, so a disconnected outdoor probe kept
publishing its last readings. Gating it required fixing the gate first: an absent
connection flag was treated as "disconnected", which would have blanked
temperature, wind and rain outright on any firmware that omits `t1cn`. Absence is
now treated as no information, and only an explicit non-1 drops the readings.

The probe type (`t234cXtp`) decides whether a channel's humidity reading is air
humidity or soil moisture, which are different device classes. It is resolved
once, when the entity is created, because Home Assistant records the device class
in the entity registry; swapping the physical probe means reinstalling.

Two parameters are handled with a documented caveat: `inbat` is the only battery
whose scale the API does not state and stays a 0/1 flag, and `t5lst` has no unit
(the vendor example uses 9999) so it is read as minutes with 9999 meaning
"nothing recorded".

Also replaces the now-stale TODO block in const.py - it listed exactly these
sensors as unimplemented - and documents the complete coverage in the README.
2026-07-26 15:20:07 +02:00
SchiZzA 4b85d2e393
fix(lint): Track pytest config and add ruff CI lint job 2026-07-26 14:38:43 +02:00
SchiZzA 83ca9b7f67
fix(lint): Add repo ruff config matching Home Assistant core conventions 2026-07-26 13:54:57 +02:00
SchiZzA 56ea41c143
fix(document): Document Pocasi disable notification and timeout retry exclusion 2026-07-26 13:46:00 +02:00
SchiZzA c33f9454f7
fix(review): stop counting timeouts, notify on disable, drop WSLink indoor 2026-07-26 13:25:39 +02:00
SchiZzA 2c6b698ca9
fix(review): clamp heat index, unify Windy uv, bound forward timeouts 2026-07-26 13:13:05 +02:00
SchiZzA 4e4c3b8f7c
merge: reconcile with the cancelled run's pushed head
The cancelled run 01KYDJNXGJ pushed 1bb8864 and 2827eac, then the branch continued
from before them. Both were cherry-picked back onto the current head, so this merge
carries no new content - it only makes the branch a descendant of what the pipeline
gate still holds, so validation can run again.

config_flow.py keeps the branch version: it already contains both sides (the
blank/whitespace credential rejection and NoURLAvailableError fallback, plus the
webhook-id validation from the pipeline commit).

# Conflicts:
#	custom_components/sws12500/config_flow.py
2026-07-26 12:09:14 +02:00
SchiZzA 7cc3a99abb
fix(document): Refresh runtime route docs 2026-07-26 12:03:37 +02:00
SchiZzA 0dfdd6c9e9
fix(review): Disable stale routes and validate credentials 2026-07-26 12:03:36 +02:00
SchiZzA 2b266f33b4
feat(sws12500): forward Ecowitt payloads to Windy and Pocasi Meteo 2026-07-26 11:50:11 +02:00
SchiZzA 2827eac158
fix(document): Refresh runtime route docs 2026-07-26 00:01:15 +02:00
SchiZzA 1bb8864426
fix(review): Disable stale routes and validate credentials 2026-07-25 23:42:17 +02:00
41 changed files with 3639 additions and 275 deletions

View File

@ -16,3 +16,19 @@ jobs:
uses: "hacs/action@main"
with:
category: "integration"
# Separate job so a lint failure is distinguishable from HACS validation
# failing. Runs the tracked ruff.toml; formatting is deliberately not
# checked here, the repository has not been reformatted.
lint:
name: "Ruff"
runs-on: "ubuntu-latest"
steps:
- uses: "actions/checkout@v3"
- uses: "actions/setup-python@v5"
with:
python-version: "3.13"
- name: Install ruff
run: pip install ruff==0.16.0
- name: Ruff check
run: ruff check .

View File

@ -105,30 +105,75 @@ Web server repo is reachable here: <https://github.com/schizza/test-station-serv
The integration auto-creates sensors as soon as the station first sends data for them — new
sensors trigger a notification in Home Assistant and are added to the entity list
automatically.
automatically. Only the fields your station actually sends are created, so an entry never
shows entities for probes you do not own.
Beyond the standard set (outdoor / indoor temperature and humidity, barometric pressure,
wind speed / direction / gust, rain rate and daily / weekly / monthly / yearly totals, dew
point, UV index, solar irradiance) the WSLink protocol also exposes:
### WSLink — complete API coverage
- additional channels **CH2** **CH8** for temperature and humidity
- **WBGT** index, **heat index**, **wind chill**
- sensor battery state for the outdoor, indoor and **CH2** **CH8** probes, exposed as
binary `battery` sensors (`normal` / `low`) — these used to be regular sensors, so on an
upgraded install the old entities are left behind; a `Repairs` issue lists them and
tells you how to remove them
- **Formaldehyde (HCHO)** in ppb and **VOC level** from the WH46 / 7-in-1 air-quality
combo sensor, reported as a named air-quality level (`unhealthy`, `poor`, `moderate`,
`good`, `excellent` — the station's 15 index, where 1 is worst)
- **HCHO/VOC sensor battery** reported as a percentage
**Every measurement the WSLink data upload API (v0.6) defines is implemented**, across all
of its sensor types. Readings are mapped to native Home Assistant device classes, so
statistics, unit conversion and the energy/weather dashboards work without any template
helpers.
HCHO / VOC entities are only created when the station reports the air-quality module as
connected (`t9cn = 1`), so they don't clutter the device when no such sensor is attached.
| Sensor type | Readings |
| --- | --- |
| 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** CH2CH8 (API CH1CH7) | temperature and humidity per channel, battery |
| **Type5** lightning | distance, time since the last strike, strike counts over 5 min / 30 min / 1 h / 1 day, battery |
| **Type6** water leak CH1CH7 | leak state per channel (entities `leak_ch1` `leak_ch7`), battery |
| **Type8** particulate matter | PM2.5, PM10 and their AQI values, battery |
| **Type9** air quality | formaldehyde (HCHO) in ppb, VOC level, battery |
| **Type10** | CO₂ concentration, battery |
| **Type11** | CO concentration, battery |
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`.
- **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 05 level instead and
become percentage sensors (5 = full). The mapping follows the API document literally and
is enforced by tests, because a level battery read as a flag would silently report
"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, 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`. 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 15 index, where 1 is worst.
- **Heat index and wind chill** are taken from the station when it sends them, and computed
from temperature, humidity and wind speed when it does not.
- Battery sensors used to be regular sensors. On an upgraded install the old entities are
left behind; a `Repairs` issue lists them and tells you how to remove them.
Two parameters are exposed with a caveat, because the API document does not define them
fully: the console battery (`inbat`) is the only battery whose scale is not documented and
is treated as a `normal` / `low` flag, and the time since the last lightning strike
(`t5lst`) is read as minutes, with the vendor's `9999` treated as "nothing recorded".
### Ecowitt
Ecowitt stations map onto the same standard set and additionally report whatever their own
firmware sends — absolute pressure, event / hourly / weekly / monthly / yearly / total /
24h rain, feels-like temperature, indoor dew point and CO₂ readings among them. Only the
fields your station actually sends are created.
24h rain, feels-like temperature, indoor dew point and CO₂ readings among them. Readings
the integration has no mapping for still get an entity, so a newer firmware does not need
an integration update to be usable.
### Diagnostics
The integration also creates diagnostic entities describing its own state — the active
protocol, whether each endpoint is registered, details of the last received payload, the
@ -271,9 +316,9 @@ credentials and tick `Enable resending data to Windy` again.
- You are done.
As with Windy, three unexpected responses in a row switch resending off automatically and
log the reason. The `Forwarding to Počasí Meteo` and `Forwarding status to Počasí Meteo`
diagnostic sensors show the current state.
As with Windy, three unexpected responses in a row switch resending off automatically, log
the reason and raise a persistent notification. The `Forwarding to Počasí Meteo` and
`Forwarding status to Počasí Meteo` diagnostic sensors show the current state.
## WSLink notes

View File

@ -3,16 +3,15 @@
Architecture overview
---------------------
This integration is *push-based*: the weather station calls our HTTP endpoint and we
receive a query payload. We do not poll the station.
receive an HTTP payload. We do not poll the station.
Key building blocks:
- `WeatherDataUpdateCoordinator` acts as an in-memory "data bus" for the latest payload.
On each webhook request we call `async_set_updated_data(...)` and all `CoordinatorEntity`
sensors get notified and update their states.
- `hass.data[DOMAIN][entry_id]` is a per-entry *dict* that stores runtime state
(coordinator instance, options snapshot, and sensor platform callbacks). Keeping this
structure consistent is critical; mixing different value types under the same key can
break listener wiring and make the UI appear "frozen".
- `entry.runtime_data` stores per-entry runtime state (coordinator instance, options
snapshot, and sensor platform callbacks). Shared aiohttp route registrations stay
under `hass.data[DOMAIN]["routes"]` because they must survive a config-entry reload.
Auto-discovery
--------------
@ -41,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,
@ -66,6 +66,14 @@ PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.BINARY_SENSOR]
CONFIG_ENTRY_VERSION: int = 2
def _shared_routes(hass: HomeAssistant) -> Routes | None:
"""Return the route dispatcher shared across entry reloads, if it exists yet."""
domain_data = hass.data.get(DOMAIN)
routes = domain_data.get("routes") if isinstance(domain_data, dict) else None
return routes if isinstance(routes, Routes) else None
async def async_migrate_entry(hass: HomeAssistant, entry: SWSConfigEntry) -> bool:
"""Migrate an old config entry.
@ -99,9 +107,9 @@ def register_path(
) -> bool:
"""Register webhook paths.
We register both possible endpoints and use an internal dispatcher (`Routes`) to
enable exactly one of them. This lets us toggle WSLink mode without re-registering
routes on the aiohttp router.
We register the supported station endpoints and use an internal dispatcher
(`Routes`) to enable only the configured ingress path. This lets us toggle
protocols without re-registering routes on the aiohttp router.
"""
hass.data.setdefault(DOMAIN, {})
@ -160,6 +168,7 @@ def register_path(
sticky=True,
)
else:
routes.activate()
routes.set_ingress_observer(coordinator_h.record_dispatch)
_LOGGER.info("We have already registered routes: %s", routes.show_enabled())
return True
@ -195,6 +204,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SWSConfigEntry) -> bool:
if routes is not None:
_LOGGER.debug("We have routes registered, will try to switch dispatcher.")
routes.activate()
routes.switch_route(coordinator.received_data, DEFAULT_URL if not _wslink else WSLINK_URL, enabled=_legacy)
routes.set_ecowitt_enabled(_ecowitt_path, coordinator.received_ecowitt_data, _ecowitt_enabled)
# Rebind the sticky health route to the new coordinator so /station/health
@ -234,7 +244,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.
@ -253,7 +265,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
@ -270,4 +282,28 @@ async def async_unload_entry(hass: HomeAssistant, entry: SWSConfigEntry) -> bool
aiohttp routes stay registered and the dispatcher is re-wired on the next setup.
"""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
if (routes := _shared_routes(hass)) is not None:
routes.deactivate()
setattr(entry, "runtime_data", None)
return unload_ok
async def async_remove_entry(hass: HomeAssistant, entry: SWSConfigEntry) -> None:
"""Release what the shared dispatcher keeps across an unload.
Unload is deliberately not the end of the line - the dispatcher keeps the ingress
observer so a payload that lands mid-reload is still recorded, and the next setup
repoints it at the new coordinator. Removal has no next setup, so the references
have to go here or the deleted entry's coordinators stay reachable from
`hass.data[DOMAIN]["routes"]` for the rest of the process.
"""
del entry # single_config_entry: the shared dispatcher belongs to the one entry
if (routes := _shared_routes(hass)) is not None:
routes.release()
_LOGGER.debug("Config entry removed; dispatcher references released.")

View File

@ -1,6 +1,8 @@
"""Battery binary sensor entities for SWS 12500.
"""Binary sensor entity for SWS 12500.
Expose low-batter warnings as binary sensors.
One class serves every binary family (battery, water leak). Which raw value means
`on` comes from the description's `on_value`, because the station's convention
differs per family - see `WSBinarySensorEntityDescription`.
"""
from __future__ import annotations
@ -10,54 +12,55 @@ from typing import Any
from py_typecheck import checked_or
from homeassistant.components.binary_sensor import BinarySensorEntity, BinarySensorEntityDescription
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .data import build_device_info
from .sensors_common import WSBinarySensorEntityDescription
from .utils import to_int
class BatteryBinarySensor( # pyright: ignore[reportIncompatibleVariableOverride]
class WSBinarySensor( # pyright: ignore[reportIncompatibleVariableOverride]
CoordinatorEntity, BinarySensorEntity
):
"""Represent a low-battery binary sensor.
"""Represent a WSLink binary reading.
Station payload uses:
- ``0`` => low battery (binary sensor is ``on``)
- ``1`` => battery OK (binary sensor is ``off``)
Battery: the station sends ``0`` for low, and ``BinarySensorDeviceClass.BATTERY``
means ``on`` = low, so ``on_value`` is 0.
Water leak: the station sends ``1`` for a leak, and
``BinarySensorDeviceClass.MOISTURE`` means ``on`` = wet, so ``on_value`` is 1.
"""
entity_description: WSBinarySensorEntityDescription # pyright: ignore[reportIncompatibleVariableOverride]
_attr_has_entity_name = True
_attr_should_poll = False
def __init__(
self,
coordinator: Any,
description: BinarySensorEntityDescription,
description: WSBinarySensorEntityDescription,
) -> None:
"""Initialize the battery binary sensor."""
"""Initialize the binary sensor."""
super().__init__(coordinator)
self.entity_description = description
self.entity_description = description # pyright: ignore[reportIncompatibleVariableOverride]
self._attr_unique_id = f"{description.key}_binary"
@property
def is_on(self) -> bool | None: # pyright: ignore[reportIncompatibleVariableOverride]
"""Return low-battery state.
``True`` means low battery for ``BinarySensorDeviceClass.BATTERY``.
"""
"""Return whether the reading matches this sensor's `on` value."""
data = checked_or(self.coordinator.data, dict[str, Any], {})
raw: Any = data.get(self.entity_description.key)
if raw is None or raw == "":
# `to_int` rather than a bare `int`: the station sometimes sends integer
# 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
try:
value = int(raw)
except (TypeError, ValueError):
return None
return value == 0
return value == self.entity_description.on_value
@cached_property
def device_info(self) -> DeviceInfo:

View File

@ -1,39 +1,60 @@
"""Battery sensor templates.
"""Generated entity descriptions for the WSLink binary and battery families.
Entity descriptions for both battery kinds are *generated* from the classification
tuples in `const`, so those tuples are the single source of truth:
Everything here is derived from the classification tuples in `const`, so those
tuples stay the single source of truth:
- ``BATTERY_LIST`` -> 0/1 low/normal -> binary sensors
- ``BATTERY_NON_BINARY`` -> 0-5 level -> percentage sensors
- ``LEAK_CH`` -> 1/0 leak/dry -> binary sensors
Generating both from one place is what prevents the conflict: a key listed in both
tuples would otherwise get a binary sensor (collapsing 0-5 into low/not-low and
throwing away four fifths of the reading) *and* a percentage sensor for the same
battery. `tests/test_battery_classification.py` fails if the tuples ever overlap or
if a `*_BATTERY` constant is left unclassified.
Generating them from one place is what prevents a key ending up with two
conflicting entities; `tests/test_battery_classification.py` fails if the battery
tuples ever overlap or if a `*_BATTERY` constant is left unclassified.
Which of these are actually loaded is gated in the coordinator by SENSORS_TO_LOAD.
Which of these are actually loaded is gated in the coordinator by SENSORS_TO_LOAD,
and per-probe by CONNECTION_GATED_SENSORS.
"""
from __future__ import annotations
from homeassistant.components.binary_sensor import BinarySensorDeviceClass, BinarySensorEntityDescription
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
from homeassistant.const import PERCENTAGE
from .const import BATTERY_LIST, BATTERY_NON_BINARY
from .sensors_common import WeatherSensorEntityDescription
from .const import BATTERY_LIST, BATTERY_NON_BINARY, LEAK_CH
from .sensors_common import WeatherSensorEntityDescription, WSBinarySensorEntityDescription
from .utils import battery_5step_to_pct
BATTERY_BINARY_SENSORS: tuple[BinarySensorEntityDescription, ...] = tuple(
BinarySensorEntityDescription(
# The station reports 0 for a low battery, and BinarySensorDeviceClass.BATTERY
# defines `on` as "low" - hence on_value 0.
BATTERY_BINARY_SENSORS: tuple[WSBinarySensorEntityDescription, ...] = tuple(
WSBinarySensorEntityDescription(
key=key,
translation_key=key,
device_class=BinarySensorDeviceClass.BATTERY,
on_value=0,
)
for key in BATTERY_LIST
)
# The station reports 1 for a leak, and BinarySensorDeviceClass.MOISTURE defines
# `on` as "wet" - hence on_value 1.
LEAK_BINARY_SENSORS: tuple[WSBinarySensorEntityDescription, ...] = tuple(
WSBinarySensorEntityDescription(
key=key,
translation_key=key,
device_class=BinarySensorDeviceClass.MOISTURE,
on_value=1,
)
for key in LEAK_CH
)
# Every binary family the WSLink platform exposes.
WSLINK_BINARY_SENSORS: tuple[WSBinarySensorEntityDescription, ...] = (
*BATTERY_BINARY_SENSORS,
*LEAK_BINARY_SENSORS,
)
BATTERY_LEVEL_SENSORS: tuple[WeatherSensorEntityDescription, ...] = tuple(
WeatherSensorEntityDescription(
key=key,

View File

@ -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`.
"""
@ -12,8 +13,8 @@ from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .battery_sensors import BatteryBinarySensor
from .battery_sensors_def import BATTERY_BINARY_SENSORS
from .battery_sensors import WSBinarySensor
from .battery_sensors_def import WSLINK_BINARY_SENSORS
from .const import SENSORS_TO_LOAD
from .data import SWSConfigEntry
@ -23,7 +24,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant, entry: SWSConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up battery binary sensors."""
"""Set up the binary sensors (battery and water leak)."""
del hass
@ -32,16 +33,16 @@ async def async_setup_entry(
# Persist platform callback + description map for dynamic entity creation.
runtime.add_binary_entities = async_add_entities
runtime.binary_descriptions = {desc.key: desc for desc in BATTERY_BINARY_SENSORS}
runtime.binary_descriptions = {desc.key: desc for desc in WSLINK_BINARY_SENSORS}
runtime.added_binary_keys = set()
# Initial entities for battery keys that station already reports.
# `SENSORS_TO_LOAD` accumulates all discovered keys across runs.
loaded = set(entry.options.get(SENSORS_TO_LOAD, []))
entities: list[BatteryBinarySensor] = []
for desc in BATTERY_BINARY_SENSORS:
entities: list[WSBinarySensor] = []
for desc in WSLINK_BINARY_SENSORS:
if desc.key in loaded:
entities.append(BatteryBinarySensor(coordinator, desc))
entities.append(WSBinarySensor(coordinator, desc))
runtime.added_binary_keys.add(desc.key)
if entities:
@ -76,7 +77,7 @@ def add_new_binary_sensors(hass: HomeAssistant, entry: SWSConfigEntry, keys: lis
if (desc := descriptions.get(key)) is None:
continue
new_entities.append(BatteryBinarySensor(coordinator, desc))
new_entities.append(WSBinarySensor(coordinator, desc))
added.add(key)
if new_entities:

View File

@ -17,6 +17,7 @@ from .conflicts import ERROR_MUTUALLY_EXCLUSIVE
from .const import (
API_ID,
API_KEY,
CHANNEL_TYPES,
DEV_DBG,
DOMAIN,
ECOWITT_ENABLED,
@ -27,6 +28,7 @@ from .const import (
POCASI_CZ_API_KEY,
POCASI_CZ_ENABLED,
POCASI_CZ_LOGGER_ENABLED,
POCASI_CZ_SEND_DEFAULT,
POCASI_CZ_SEND_INTERVAL,
POCASI_CZ_SEND_MINIMUM,
SENSORS_TO_LOAD,
@ -67,6 +69,17 @@ def _ha_url_placeholders(hass: HomeAssistant) -> tuple[str, str]:
return url.host or "UNKNOWN", str(url.port)
def _validate_ecowitt_webhook(user_input: dict[str, Any], errors: dict[str, str]) -> None:
"""Reject enabling Ecowitt without a webhook id.
The id is the endpoint's only credential, so an empty one would leave
`/weatherhub/<id>` unauthenticated (`received_ecowitt_data` rejects it outright).
"""
webhook = user_input.get(ECOWITT_WEBHOOK_ID, "")
if user_input.get(ECOWITT_ENABLED) and (not isinstance(webhook, str) or not webhook.strip()):
errors[ECOWITT_WEBHOOK_ID] = "ecowitt_webhook_required"
class ConfigOptionsFlowHandler(OptionsFlow):
"""Handle WeatherStation ConfigFlow."""
@ -131,7 +144,9 @@ class ConfigOptionsFlowHandler(OptionsFlow):
POCASI_CZ_API_KEY: self.config_entry.options.get(POCASI_CZ_API_KEY, ""),
POCASI_CZ_ENABLED: self.config_entry.options.get(POCASI_CZ_ENABLED, False),
POCASI_CZ_LOGGER_ENABLED: self.config_entry.options.get(POCASI_CZ_LOGGER_ENABLED, False),
POCASI_CZ_SEND_INTERVAL: self.config_entry.options.get(POCASI_CZ_SEND_INTERVAL, 30),
POCASI_CZ_SEND_INTERVAL: self.config_entry.options.get(
POCASI_CZ_SEND_INTERVAL, POCASI_CZ_SEND_DEFAULT
),
}
self.pocasi_cz_schema = {
@ -276,11 +291,12 @@ class ConfigOptionsFlowHandler(OptionsFlow):
webhook = secrets.token_hex(8)
if user_input is not None:
_validate_ecowitt_webhook(user_input, errors)
# Both endpoints remap onto the same internal sensor keys, so enabling
# Ecowitt while the legacy endpoint is active would corrupt those entities.
if user_input.get(ECOWITT_ENABLED) and self.user_data.get(LEGACY_ENABLED):
errors["base"] = ERROR_MUTUALLY_EXCLUSIVE
else:
if not errors:
return self.async_create_entry(title=DOMAIN, data=self.retain_data(user_input))
host, port = _ha_url_placeholders(self.hass)
@ -332,15 +348,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,
@ -350,6 +368,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."""
@ -405,33 +428,39 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
async def async_step_ecowitt(self, user_input: Any = None) -> ConfigFlowResult:
"""Ecowitt stations setup."""
if user_input is None:
webhook = secrets.token_hex(8)
host, port = _ha_url_placeholders(self.hass)
errors: dict[str, str] = {}
ecowitt_schema = {
vol.Required(ECOWITT_WEBHOOK_ID, default=webhook): str,
vol.Optional(ECOWITT_ENABLED, default=True): bool,
}
if user_input is not None:
_validate_ecowitt_webhook(user_input, errors)
if not errors:
options: dict[str, Any] = {
**user_input,
LEGACY_ENABLED: False,
WSLINK: False,
API_ID: "",
API_KEY: "",
}
return self.async_show_form(
step_id="ecowitt",
data_schema=vol.Schema(ecowitt_schema),
description_placeholders={
"url": host,
"port": port,
"webhook_id": webhook,
},
)
options: dict[str, Any] = {
**user_input,
LEGACY_ENABLED: False,
WSLINK: False,
API_ID: "",
API_KEY: "",
return self.async_create_entry(title=DOMAIN, data=options, options=options)
webhook = user_input.get(ECOWITT_WEBHOOK_ID, "") if user_input is not None else secrets.token_hex(8)
host, port = _ha_url_placeholders(self.hass)
ecowitt_schema = {
vol.Required(ECOWITT_WEBHOOK_ID, default=webhook): str,
vol.Optional(ECOWITT_ENABLED, default=True): bool,
}
return self.async_create_entry(title=DOMAIN, data=options, options=options)
return self.async_show_form(
step_id="ecowitt",
data_schema=vol.Schema(ecowitt_schema),
description_placeholders={
"url": host,
"port": port,
"webhook_id": webhook,
},
errors=errors,
)
@staticmethod
@callback

View File

@ -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",
@ -87,6 +92,50 @@ VOC: Final = "voc"
T9_BATTERY: Final = "t9_battery" # T9 sensors are HCHO and VOC
T9_CONN: Final = "t9_conn"
# --- Base console (WSLink API v0.6, no sensor type) -------------------------
ABS_PRESSURE: Final = "abs_pressure" # abar
# --- Type1 outdoor ----------------------------------------------------------
FEELS_LIKE: Final = "feels_like" # t1feels
WIND_SPEED_AVG10: Final = "wind_speed_avg10" # t1ws10mav
# --- Type5 lightning --------------------------------------------------------
# `t5lst` is an integer the API documents only as "Last Lightning strike time";
# it is not an epoch (the vendor example shows 9999), so it is treated as minutes
# elapsed, with 9999 as the "no strike recorded" sentinel - see `lightning_minutes`.
LIGHTNING_LAST: Final = "lightning_last" # t5lst
LIGHTNING_DISTANCE: Final = "lightning_distance" # t5lskm
# The API lists both `t5lsf` ("strike count last 1 Hours") and `t5ls1htc` ("count
# total of during 1 Hour"). They overlap; both are exposed rather than guessing
# which one a given firmware populates.
LIGHTNING_STRIKES_1H: Final = "lightning_strikes_1h" # t5lsf
LIGHTNING_COUNT_5M: Final = "lightning_count_5m" # t5ls5mtc
LIGHTNING_COUNT_30M: Final = "lightning_count_30m" # t5ls30mtc
LIGHTNING_COUNT_1H: Final = "lightning_count_1h" # t5ls1htc
LIGHTNING_COUNT_1D: Final = "lightning_count_1d" # t5ls1dtc
LIGHTNING_BATTERY: Final = "lightning_battery" # t5lsbat (0/1)
# --- Type6 water leak, CH1-7 ------------------------------------------------
# Numbered as the API does (CH1-7). The temp/humidity channels use a legacy
# off-by-one (integration CH2 == WSLink CH1); that quirk is not repeated here.
LEAK_CH: Final[tuple[str, ...]] = tuple(f"leak_ch{ch}" for ch in range(1, 8))
LEAK_CH_BATTERY: Final[tuple[str, ...]] = tuple(f"leak_ch{ch}_battery" for ch in range(1, 8))
# --- Type8 particulate matter ----------------------------------------------
PM25: Final = "pm25" # t8pm25
PM10: Final = "pm10" # t8pm10
PM25_AQI: Final = "pm25_aqi" # t8pm25ai
PM10_AQI: Final = "pm10_aqi" # t8pm10ai
T8_BATTERY: Final = "t8_battery" # t8bat (0-5)
# --- Type10 CO2 -------------------------------------------------------------
CO2: Final = "co2" # t10co2
T10_BATTERY: Final = "t10_battery" # t10bat (0-5)
# --- Type11 CO --------------------------------------------------------------
CO: Final = "co" # t11co
T11_BATTERY: Final = "t11_battery" # t11bat (0-5)
# Health specific constants
HEALTH_URL = "/station/health"
@ -177,7 +226,19 @@ WINDY_URL = "https://stations.windy.com/api/v2/observation/update"
POCASI_CZ_URL: Final = "http://ms.pocasimeteo.cz"
POCASI_CZ_SEND_MINIMUM: Final = 12 # minimal time to resend data
POCASI_CZ_MAX_RETRIES: Final = 3 # failed sends in a row before resending is disabled
POCASI_CZ_SEND_DEFAULT: Final = 30 # used when the option is unset or unreadable
# Failed sends in a row before resending is disabled. Timeouts are excluded on purpose -
# a slow upstream is transient, see the TimeoutError branch in `PocasiPush`.
POCASI_CZ_MAX_RETRIES: Final = 3
# Pocasi Meteo accepts Ecowitt only as a POST in the Ecowitt protocol itself - the
# station payload is forwarded verbatim rather than translated to PWS. Per their
# integration notes the account credentials go in the query string, and the password
# parameter is `PAS` (not `PASS` / `PASSWORD` as on the PWS endpoint):
# ms.pocasimeteo.cz/ws_ecowitt/ws.php?ID=xxxx&PAS=yyyy
POCASI_CZ_ECOWITT_URL: Final = "/ws_ecowitt/ws.php"
POCASI_CZ_ECOWITT_ID_PARAM: Final = "ID"
POCASI_CZ_ECOWITT_PW_PARAM: Final = "PAS"
WSLINK: Final = "wslink"
@ -186,11 +247,58 @@ LEGACY_ENABLED: Final = "legacy_enabled"
WINDY_MAX_RETRIES: Final = 3
WSLINK_ADDON_PORT: Final = "WSLINK_ADDON_PORT"
# Forwarding to Windy / Pocasi Meteo is awaited inline in the station's own request
# path, so an upstream that accepts the connection and never answers would hold the
# webhook open. aiohttp's default is a 5 minute total, long past the point where the
# station itself gives up; cut the send short instead and retry on the next push.
FORWARD_TIMEOUT: Final = 15 # seconds
ECOWITT: Final = "ecowitt"
ECOWITT_WEBHOOK_ID: Final = "ecowitt_webhook_id"
ECOWITT_ENABLED: Final = "ecowitt_enabled"
ECOWITT_URL_PREFIX: Final = "/weatherhub"
# Ecowitt field names -> the field names Windy accepts.
#
# Windy has no Ecowitt endpoint, so an Ecowitt payload has to be translated before it
# can be forwarded there. The target vocabulary is mostly the PWS/WU one Windy already
# receives from a real PWS station, but not exactly: Windy documents the UV index as
# lowercase `uv` (https://stations.windy.com/api-reference) while WU spells it `UV`
# (see REMAP_ITEMS). This table follows Windy, which is its only consumer.
#
# Deliberately an allowlist, not a "strip the bad keys" denylist: the Ecowitt payload
# also carries device metadata (stationtype, model, freq, runtime, heap, interval) that
# is meaningless upstream, and a denylist would forward whatever fields a future
# firmware adds.
#
# This is the *full* mapping, not the set of fields that end up on the wire: five of
# the entries below (`dateutc`, `solarradiation`, `dailyrainin`, `indoortempf`,
# `indoorhumidity`) are in PURGE_DATA and are stripped by `push_data_to_windy` right
# after the conversion. They are listed so the translation itself stays complete and
# keeps working if the purge list ever changes.
#
# Multi-channel temp/humidity (temp1f..temp7f) are intentionally absent - the PWS
# protocol has no equivalent, and mapping them onto its soil fields would send wrong
# data.
REMAP_ECOWITT_TO_WINDY: Final[dict[str, str]] = {
# same name on both sides, listed so the allowlist is complete
"tempf": "tempf",
"humidity": "humidity",
"windspeedmph": "windspeedmph",
"windgustmph": "windgustmph",
"winddir": "winddir",
"dailyrainin": "dailyrainin",
"solarradiation": "solarradiation",
"dateutc": "dateutc",
"uv": "uv",
# renamed between the two protocols
"dewpointf": "dewptf",
"baromrelin": "baromin",
"tempinf": "indoortempf",
"humidityin": "indoorhumidity",
"hourlyrainin": "rainin", # WU `rainin` is the accumulation over the past hour
}
REMAP_ECOWITT_COMPAT: dict[str, str] = {
"tempf": OUTSIDE_TEMP,
"humidity": OUTSIDE_HUMIDITY,
@ -299,66 +407,52 @@ REMAP_WSLINK_ITEMS: dict[str, str] = {
"t9hcho": HCHO,
"t9voclv": VOC,
"t9bat": T9_BATTERY, # T9 battery is 0-5, where 5 is full
# --- base console -----------------------------------------------------
"abar": ABS_PRESSURE,
# --- Type1 outdoor ----------------------------------------------------
"t1feels": FEELS_LIKE,
"t1ws10mav": WIND_SPEED_AVG10,
# --- Type5 lightning --------------------------------------------------
"t5lst": LIGHTNING_LAST,
"t5lskm": LIGHTNING_DISTANCE,
"t5lsf": LIGHTNING_STRIKES_1H,
"t5ls5mtc": LIGHTNING_COUNT_5M,
"t5ls30mtc": LIGHTNING_COUNT_30M,
"t5ls1htc": LIGHTNING_COUNT_1H,
"t5ls1dtc": LIGHTNING_COUNT_1D,
"t5lsbat": LIGHTNING_BATTERY,
# --- Type6 water leak CH1-7 -------------------------------------------
**{f"t6c{ch}wls": LEAK_CH[ch - 1] for ch in range(1, 8)},
**{f"t6c{ch}bat": LEAK_CH_BATTERY[ch - 1] for ch in range(1, 8)},
# --- Type8 particulate matter -----------------------------------------
"t8pm25": PM25,
"t8pm10": PM10,
"t8pm25ai": PM25_AQI,
"t8pm10ai": PM10_AQI,
"t8bat": T8_BATTERY,
# --- Type10 CO2 -------------------------------------------------------
"t10co2": CO2,
"t10bat": T10_BATTERY,
# --- Type11 CO --------------------------------------------------------
"t11co": CO,
"t11bat": T11_BATTERY,
}
# NOTE: Add more sensors
### WSLink API v0.6 coverage
#
# '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
# Every upload parameter the API document defines is now handled: base console,
# Type1 outdoor, Type2/3/4 channels, Type5 lightning, Type6 water leak, Type8
# particulate matter, Type9 HCHO/VOC, Type10 CO2 and Type11 CO.
#
# 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.
### TODO: These are sensors, that should be supported in WSLink API according to their API documentation:
# &t5lst= Last Lightning strike time integer
# &t5lskm= Lightning distance integer km
# &t5lsf= Lightning strike count last 1 Hours integer
# &t5ls5mtc= Lightning count total of during 5 minutes integer
# &t5ls30mtc= Lightning count total of during 30 minutes integer
# &t5ls1htc= Lightning count total of during 1 Hour integer
# &t5ls1dtc= Lightning count total of during 1 day integer
# &t5lsbat= Lightning Sensor battery (Normal=1, Low battery=0) integer
# &t5lscn= Lightning Sensor connection (Connected=1, No connect=0) integer
# &t6c1wls= Water leak sensor CH1 (Leak=1, No leak=0) integer
# &t6c1bat= Water leak sensor CH1 battery (Normal=1, Low battery=0) integer
# &t6c1cn= Water leak sensor CH1 connection (Connected=1, No connect=0) integer
# &t6c2wls= Water leak sensor CH2 (Leak=1, No leak=0) integer
# &t6c2bat= Water leak sensor CH2 battery (Normal=1, Low battery=0) integer
# &t6c2cn= Water leak sensor CH2 connection (Connected=1, No connect=0) integer
# &t6c3wls= Water leak sensor CH3 (Leak=1, No leak=0) integer
# &t6c3bat= Water leak sensor CH3 battery (Normal=1, Low battery=0) integer
# &t6c3cn= Water leak sensor CH3 connection (Connected=1, No connect=0) integer
# &t6c4wls= Water leak sensor CH4 (Leak=1, No leak=0) integer
# &t6c4bat= Water leak sensor CH4 battery (Normal=1, Low battery=0) integer
# &t6c4cn= Water leak sensor CH4 connection (Connected=1, No connect=0) integer
# &t6c5wls= Water leak sensor CH5 (Leak=1, No leak=0) integer
# &t6c5bat= Water leak sensor CH5 battery (Normal=1, Low battery=0) integer
# &t6c5cn= Water leak sensor CH5 connection (Connected=1, No connect=0) integer
# &t6c6wls= Water leak sensor CH6 (Leak=1, No leak=0) integer
# &t6c6bat= Water leak sensor CH6 battery (Normal=1, Low battery=0) integer
# &t6c6cn= Water leak sensor CH6 connection (Connected=1, No connect=0) integer
# &t6c7wls= Water leak sensor CH7 (Leak=1, No leak=0) integer
# &t6c7bat= Water leak sensor CH7 battery (Normal=1, Low battery=0) integer
# &t6c7cn= Water leak sensor CH7 connection (Connected=1, No connect=0) integer
# &t8pm25= PM2.5 concentration integer ug/m3
# &t8pm10= PM10 concentration integer ug/m3
# &t8pm25ai= PM2.5 AQI integer
# &t8pm10ai = PM10 AQI integer
# &t8bat= PM sensor battery level (0~5) remark: 5 is full integer
# &t8cn= PM sensor connection (Connected=1, No connect=0) integer
# &t9hcho= HCHO concentration integer ppb
# &t9voclv= VOC level (1~5) 1 is the highest level, 5 is the lowest VOC level integer
# &t9bat= HCHO / VOC sensor battery level (0~5) remark: 5 is full integer
# &t9cn= HCHO / VOC sensor connection (Connected=1, No connect=0) integer
# &t10co2= CO2 concentration integer ppm
# &t10bat= CO2 sensor battery level (0~5) remark: 5 is full integer
# &t10cn= CO2 sensor connection (Connected=1, No connect=0) integer
# &t11co= CO concentration integer ppm
# &t11bat= CO sensor battery level (0~5) remark: 5 is full integer
# &t11cn= CO sensor connection (Connected=1, No connect=0) integer
# `tests/test_wslink_api_coverage.py` pins that list, so a parameter cannot be
# dropped by a refactor and a future API revision fails the suite until its new
# parameters are mapped here.
#
# Two are handled with a documented caveat, because the API does not define them:
# - `inbat` is the only battery with no stated scale; it is read as 0/1 like the
# other wireless probes (see BATTERY_LIST).
# - `t5lst` has no unit and the vendor example uses 9999; it is read as minutes
# elapsed with 9999 meaning "nothing recorded" (see `lightning_minutes`).
# How the station reports each battery decides which entity it becomes. The two tuples
@ -366,9 +460,12 @@ REMAP_WSLINK_ITEMS: dict[str, str] = {
# both entity description sets from them, so a key cannot end up with two entities.
#
# They must stay disjoint, and every `*_BATTERY` constant must appear in exactly one of
# them; `tests/test_battery_classification.py` enforces both. That matters because the
# WSLink API has more of each kind still to be implemented (see the TODO block above):
# `t5lsbat` / `t6c1-7bat` are 0/1, while `t8bat` / `t10bat` / `t11bat` are 0-5.
# them; `tests/test_battery_classification.py` enforces both, and
# `tests/test_wslink_api_coverage.py` additionally pins each battery to the wording of
# the API document. That matters because getting it wrong is silent: a 0-5 battery read
# as a 0/1 flag reports "not low" for every level above empty, discarding most of the
# reading. The API annotates the difference explicitly - "(Normal=1, Low battery=0)"
# versus "(0~5) remark: 5 is full" - so a new battery only has to be read carefully.
# Reported as 0/1 (low/normal) -> BinarySensorDeviceClass.BATTERY.
BATTERY_LIST: Final[tuple[str, ...]] = (
@ -381,13 +478,58 @@ BATTERY_LIST: Final[tuple[str, ...]] = (
CH6_BATTERY,
CH7_BATTERY,
CH8_BATTERY,
LIGHTNING_BATTERY,
*LEAK_CH_BATTERY,
)
# Reported as a 0-5 level, 5 being full -> percentage SensorDeviceClass.BATTERY.
BATTERY_NON_BINARY: Final[tuple[str, ...]] = (T9_BATTERY,)
BATTERY_NON_BINARY: Final[tuple[str, ...]] = (T8_BATTERY, T9_BATTERY, T10_BATTERY, T11_BATTERY)
# Which raw `t234cXtp` parameter describes the probe behind each channel humidity
# reading. The API's compatible-sensor list maps the value to a probe kind:
# 2 = thermo-hygrometer, 3 = pool sensor, 4 = soil moisture & temperature
# A soil probe reports soil moisture, not air humidity, which is a different Home
# Assistant device class - see `channel_humidity_device_class`.
CH_HUMIDITY_TYPE_PARAM: Final[dict[str, str]] = {
CH2_HUMIDITY: "t234c1tp",
CH3_HUMIDITY: "t234c2tp",
CH4_HUMIDITY: "t234c3tp",
CH5_HUMIDITY: "t234c4tp",
CH6_HUMIDITY: "t234c5tp",
CH7_HUMIDITY: "t234c6tp",
CH8_HUMIDITY: "t234c7tp",
}
# `t234cXtp` value for a soil moisture & temperature probe.
CH_TYPE_SOIL: Final = 4
CONNECTION_GATED_SENSORS: Final[dict[str, list[str]]] = {
# Main outdoor probe (Type1). A payload that omits `t1cn` is not gated at all -
# see `remap_wslink_items` - so this cannot blank a station that never reports it.
"t1cn": [
OUTSIDE_TEMP,
OUTSIDE_HUMIDITY,
FEELS_LIKE,
CHILL_INDEX,
HEAT_INDEX,
DEW_POINT,
WIND_DIR,
WIND_SPEED,
WIND_SPEED_AVG10,
WIND_GUST,
RAIN,
HOURLY_RAIN,
DAILY_RAIN,
WEEKLY_RAIN,
MONTHLY_RAIN,
YEARLY_RAIN,
UV,
SOLAR_RADIATION,
WBGT_TEMP,
OUTSIDE_BATTERY,
],
# Multi-channel temp/humidity probes (CH2 - CH8)
"t234c1cn": [CH2_TEMP, CH2_HUMIDITY, CH2_BATTERY],
"t234c2cn": [CH3_TEMP, CH3_HUMIDITY, CH3_BATTERY],
@ -398,6 +540,25 @@ CONNECTION_GATED_SENSORS: Final[dict[str, list[str]]] = {
"t234c7cn": [CH8_TEMP, CH8_HUMIDITY, CH8_BATTERY],
# T9 HCHO/VOC probe
"t9cn": [HCHO, VOC, T9_BATTERY],
# Lightning probe
"t5lscn": [
LIGHTNING_LAST,
LIGHTNING_DISTANCE,
LIGHTNING_STRIKES_1H,
LIGHTNING_COUNT_5M,
LIGHTNING_COUNT_30M,
LIGHTNING_COUNT_1H,
LIGHTNING_COUNT_1D,
LIGHTNING_BATTERY,
],
# Water leak probes CH1-7
**{f"t6c{ch}cn": [LEAK_CH[ch - 1], LEAK_CH_BATTERY[ch - 1]] for ch in range(1, 8)},
# Particulate matter probe
"t8cn": [PM25, PM10, PM25_AQI, PM10_AQI, T8_BATTERY],
# CO2 probe
"t10cn": [CO2, T10_BATTERY],
# CO probe
"t11cn": [CO, T11_BATTERY],
}

View File

@ -32,6 +32,8 @@ from .binary_sensor import add_new_binary_sensors
from .const import (
API_ID,
API_KEY,
CH_HUMIDITY_TYPE_PARAM,
CHANNEL_TYPES,
DEV_DBG,
DOMAIN,
ECOWITT_ENABLED,
@ -48,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,
@ -228,12 +231,17 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
_windy_enabled = checked_or(self.config.options.get(WINDY_ENABLED), bool, False)
_pocasi_enabled = checked_or(self.config.options.get(POCASI_CZ_ENABLED), bool, False)
if _windy_enabled:
await self.windy.push_data_to_windy(data, False)
# TODO: create ecowitt protocol to send full payload to Pocasi CZ
# The two services need the payload in different shapes. Windy has no Ecowitt
# endpoint and converts to PWS itself; Pocasi Meteo does, so it gets the station
# payload verbatim.
if _windy_enabled:
await self.windy.push_data_to_windy(data, "ecowitt")
# Pocasi Meteo does have an Ecowitt endpoint, so it gets the station payload
# verbatim instead.
if _pocasi_enabled:
await self.pocasi.push_data_to_server(data, "WU")
await self.pocasi.push_data_to_server(data, "ECOWITT")
if health:
health.update_forwarding(self.windy, self.pocasi)
@ -243,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.
@ -272,6 +301,11 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
remaped_items: dict[str, str] = remap_wslink_items(data) if _wslink else remap_items(data)
# 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:
await self._persist_channel_types(data)
if sensors := check_disabled(remaped_items, self.config):
# Resolve each sensor's display name once (the previous comprehension
# awaited translations() twice per key).
@ -324,7 +358,7 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
_pocasi_enabled = checked_or(self.config.options.get(POCASI_CZ_ENABLED), bool, False)
if _windy_enabled:
await self.windy.push_data_to_windy(data, _wslink)
await self.windy.push_data_to_windy(data, "wslink" if _wslink else "pws")
if _pocasi_enabled:
await self.pocasi.push_data_to_server(data, "WSLINK" if _wslink else "WU")

View File

@ -71,8 +71,8 @@ def _configured_protocol(config: SWSConfigEntry) -> str:
"""Return the primary configured protocol (wu / wslink / ecowitt).
The legacy PWS/WSLink endpoint takes precedence when enabled; otherwise an
Ecowitt-only setup reports "ecowitt". (Legacy and Ecowitt can be enabled at the
same time; this just labels the primary protocol for the summary.)
Ecowitt-only setup reports "ecowitt". If an old or externally edited config
has both flags enabled, legacy still wins because that is the effective route.
"""
if checked_or(config.options.get(LEGACY_ENABLED), bool, True):
return "wslink" if checked_or(config.options.get(WSLINK), bool, False) else "wu"
@ -235,8 +235,8 @@ class HealthCoordinator(DataUpdateCoordinator):
reason = ingress.get("reason")
# A WU vs WSLink mismatch means the station is misconfigured for the legacy
# endpoint. Ecowitt coexists with the legacy endpoint, so it never counts as a
# mismatch - it is a valid protocol whenever a payload arrives on its route.
# endpoint. Ecowitt has its own route and is considered valid only when an
# accepted Ecowitt payload arrives there.
legacy_mismatch = (
last_protocol in _LEGACY_PROTOCOLS
and configured_protocol in _LEGACY_PROTOCOLS

View File

@ -3,12 +3,14 @@
from __future__ import annotations
from datetime import timedelta
from functools import partial
import logging
from typing import Any, Literal
from aiohttp import ClientError
from aiohttp import ClientError, ClientTimeout
from py_typecheck.core import checked_or
from homeassistant.components import persistent_notification
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@ -16,11 +18,16 @@ from homeassistant.util import dt as dt_util
from .const import (
DEFAULT_URL,
FORWARD_TIMEOUT,
POCASI_CZ_API_ID,
POCASI_CZ_API_KEY,
POCASI_CZ_ECOWITT_ID_PARAM,
POCASI_CZ_ECOWITT_PW_PARAM,
POCASI_CZ_ECOWITT_URL,
POCASI_CZ_ENABLED,
POCASI_CZ_LOGGER_ENABLED,
POCASI_CZ_MAX_RETRIES,
POCASI_CZ_SEND_DEFAULT,
POCASI_CZ_SEND_INTERVAL,
POCASI_CZ_SUCCESS,
POCASI_CZ_UNEXPECTED,
@ -28,13 +35,16 @@ from .const import (
POCASI_INVALID_KEY,
WSLINK_URL,
)
from .utils import anonymize, update_options
from .utils import anonymize, to_int, update_options
_LOGGER = logging.getLogger(__name__)
# Outcome of a single send, derived from the HTTP status (see `verify_response`).
type PocasiResult = Literal["ok", "auth_error", "unexpected_response"]
# Which upstream protocol a payload is forwarded in.
type PocasiMode = Literal["WU", "WSLINK", "ECOWITT"]
class PocasiPush:
"""Forward station payloads to the Pocasi Meteo server."""
@ -46,7 +56,9 @@ class PocasiPush:
self.last_status: str = "disabled" if not self.enabled else "idle"
self.last_error: str | None = None
self.last_attempt_at: str | None = None
self._interval = int(self.config.options.get(POCASI_CZ_SEND_INTERVAL, 30))
# A stored interval that does not parse falls back to the default rather than
# raising here, which would take the whole config entry setup down.
self._interval = to_int(self.config.options.get(POCASI_CZ_SEND_INTERVAL)) or POCASI_CZ_SEND_DEFAULT
self.last_update = dt_util.utcnow()
self.next_update = dt_util.utcnow() + timedelta(seconds=self._interval)
@ -83,15 +95,27 @@ class PocasiPush:
return "unexpected_response"
async def _disable_pocasi(self, reason: str) -> None:
"""Turn resending off and persist it, so it survives a restart."""
"""Turn resending off and persist it, so it survives a restart.
Notifies the user as well - as `WindyPush._disable_windy` does. Forwarding
switching itself off is otherwise invisible until someone notices that data
stopped arriving upstream, since the only other trace is a log line.
"""
self.last_error = reason
if not await update_options(self.hass, self.config, POCASI_CZ_ENABLED, False):
_LOGGER.debug("Failed to set Pocasi Meteo options to false.")
async def push_data_to_server(self, data: dict[str, Any], mode: Literal["WU", "WSLINK"]):
"""Pushes weather data to server."""
persistent_notification.async_create(self.hass, reason, "Pocasi Meteo resending disabled.")
async def push_data_to_server(self, data: dict[str, Any], mode: PocasiMode):
"""Forward a station payload to Pocasi Meteo.
WU and WSLINK are GET requests carrying the readings as query parameters.
ECOWITT is a POST of the station's own payload with the credentials in the
query string - the server only accepts the Ecowitt protocol that way.
"""
_data = data.copy()
self.last_attempt_at = dt_util.utcnow().isoformat()
@ -130,26 +154,49 @@ class PocasiPush:
# Reserve the next send window before the await to avoid concurrent double-sends.
self.next_update = dt_util.utcnow() + timedelta(seconds=self._interval)
request_url: str = ""
if mode == "WSLINK":
_data["wsid"] = _api_id
_data["wspw"] = _api_key
request_url = f"{POCASI_CZ_URL}{WSLINK_URL}"
if mode == "WU":
_data["ID"] = _api_id
_data["PASSWORD"] = _api_key
request_url = f"{POCASI_CZ_URL}{DEFAULT_URL}"
session = async_get_clientsession(self.hass)
request_url: str = ""
params: dict[str, Any] = {}
# Home Assistant's shared session sets no timeout, so without an explicit one a
# stalled server would hold the station's webhook open for aiohttp's 5 minute
# default and the TimeoutError branch below could never run in time to answer
# the station.
timeout = ClientTimeout(total=FORWARD_TIMEOUT)
if mode == "ECOWITT":
# Pocasi Meteo takes Ecowitt only as a POST in the Ecowitt protocol itself,
# so the station payload is forwarded verbatim; only the credentials are
# ours, and they travel in the query string as ID / PAS.
request_url = f"{POCASI_CZ_URL}{POCASI_CZ_ECOWITT_URL}"
params = {
POCASI_CZ_ECOWITT_ID_PARAM: _api_id,
POCASI_CZ_ECOWITT_PW_PARAM: _api_key,
}
make_request = partial(session.post, request_url, params=params, data=_data, timeout=timeout)
else:
if mode == "WSLINK":
_data["wsid"] = _api_id
_data["wspw"] = _api_key
request_url = f"{POCASI_CZ_URL}{WSLINK_URL}"
else:
_data["ID"] = _api_id
_data["PASSWORD"] = _api_key
request_url = f"{POCASI_CZ_URL}{DEFAULT_URL}"
make_request = partial(session.get, request_url, params=_data, timeout=timeout)
_LOGGER.debug(
"Payload for Pocasi Meteo server: [mode=%s] [request_url=%s] = %s",
"Payload for Pocasi Meteo server: [mode=%s] [request_url=%s] [params=%s] = %s",
mode,
request_url,
anonymize(params),
anonymize(_data),
)
try:
async with session.get(request_url, params=_data) as resp:
# Built inside the try: a failure while creating the request must be handled
# like any other transport error, not escape into the webhook handler.
async with make_request() as resp:
result = self.verify_response(resp.status, await resp.text())
http_status = resp.status
@ -183,7 +230,20 @@ class PocasiPush:
# TimeoutError is not a ClientError: an `async_timeout`/`asyncio` timeout would
# otherwise escape into the webhook handler and answer the station with HTTP 500,
# even though the measured data was already stored.
except (ClientError, TimeoutError) as ex:
#
# It gets its own branch because it must not spend the retry budget: a slow
# upstream is transient and self-healing, so the next push just tries again,
# while `invalid_response_count` is meant for faults that will not fix
# themselves. Caught first on purpose - aiohttp's ServerTimeoutError is both a
# ClientError and a TimeoutError, and it belongs here.
except TimeoutError as ex:
self.last_status = "timeout"
self.last_error = type(ex).__name__
_LOGGER.warning(
"Pocasi Meteo did not answer within %s seconds. Will try again on the next push.",
FORWARD_TIMEOUT,
)
except ClientError as ex:
self.last_status = "client_error"
# Store only the exception class - last_error is surfaced via entity
# attributes; str(ex) could embed the request URL.

View File

@ -3,10 +3,10 @@
Why this dispatcher exists
--------------------------
Home Assistant registers aiohttp routes on startup. Re-registering or removing routes at runtime
is awkward and error-prone (and can raise if routes already exist). This integration supports two
different push endpoints (legacy WU-style vs WSLink). To allow switching between them without
touching the aiohttp router, we register both routes once and use this in-process dispatcher to
decide which one is currently enabled.
is awkward and error-prone (and can raise if routes already exist). This integration supports
multiple station push endpoints. To allow switching between them without touching the aiohttp
router, we register routes once and use this in-process dispatcher to decide which one is
currently enabled.
Important note:
- Each route stores a *bound method* handler (e.g. `coordinator.received_data`). That means the
@ -63,6 +63,39 @@ class Routes:
"""Initialize dispatcher storage."""
self.routes: dict[str, RouteInfo] = {}
self._ingress_observer: IngressObserver | None = None
self.active: bool = True
def activate(self) -> None:
"""Allow registered routes to dispatch to their configured handlers."""
self.active = True
def deactivate(self) -> None:
"""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
def release(self) -> None:
"""Drop every config-entry object the dispatcher still holds.
`deactivate()` deliberately keeps the ingress observer so a payload arriving
while the entry is unloaded is still recorded. Removal is the other case: the
entry is gone for good, and this dispatcher outlives it in `hass.data`, so
holding its coordinators - and through them the removed ConfigEntry - would
keep them alive for the rest of the process.
Enablement flags are left alone: a re-added entry rebinds every handler
through the usual setup path, and `rebind_handler` only touches sticky routes
that are still enabled.
"""
self._ingress_observer = None
for route in self.routes.values():
route.handler = unregistered
def _resolve_route(self, request: Request) -> RouteInfo | None:
"""Find the matching RouteInfo for a request.
@ -141,6 +174,12 @@ class Routes:
self._ingress_observer(request, False, "route_not_registered")
return await unregistered(request)
if not self.active:
_LOGGER.debug("Route (%s):%s received while integration is not loaded.", request.method, request.path)
if self._ingress_observer is not None:
self._ingress_observer(request, False, "integration_unloaded")
return Response(text="Integration is not loaded.", status=503)
if self._ingress_observer is not None:
self._ingress_observer(
request,
@ -196,6 +235,9 @@ class Routes:
def show_enabled(self) -> str:
"""Return a human-readable description of the currently enabled route."""
if not self.active:
return "No routes are enabled."
enabled_routes = {
f"Dispatcher enabled for ({route.route.method}):{route.url_path}, with handler: {route.handler}"
for route in self.routes.values()
@ -207,7 +249,7 @@ class Routes:
def path_enabled(self, url_path: str) -> bool:
"""Return whether any route registered for `url_path` is enabled."""
return any(route.enabled for route in self.routes.values() if route.url_path == url_path)
return self.active and any(route.enabled for route in self.routes.values() if route.url_path == url_path)
def snapshot(self) -> dict[str, Any]:
"""Return a compact routing snapshot for diagnostics."""
@ -215,7 +257,7 @@ class Routes:
key: {
"path": route.url_path,
"method": route.route.method,
"enabled": route.enabled,
"enabled": self.active and route.enabled,
"sticky": route.sticky,
}
for key, route in self.routes.items()

View File

@ -45,6 +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, channel_types
if TYPE_CHECKING:
from .coordinator import WeatherDataUpdateCoordinator
@ -173,6 +174,15 @@ class WeatherSensor( # pyright: ignore[reportIncompatibleVariableOverride]
self.entity_description = description # pyright: ignore[reportIncompatibleVariableOverride] type: ignore[assignment]
self._dev_log = checked_or(coordinator.config.options.get(DEV_DBG), bool, False)
# 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. 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
def native_value(self): # pyright: ignore[reportIncompatibleVariableOverride]
"""Return the current sensor state.

View File

@ -6,6 +6,7 @@ from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from homeassistant.components.binary_sensor import BinarySensorEntityDescription
from homeassistant.components.sensor import SensorEntityDescription
from .const import VOCLevel
@ -21,3 +22,22 @@ class WeatherSensorEntityDescription(SensorEntityDescription):
deprecated: bool = False
replacement_entity_domain: str | None = None
replacement_entity_key: str | None = None
@dataclass(frozen=True, kw_only=True)
class WSBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Describe a WSLink binary sensor.
`on_value` is the raw payload value that means `on`, because the station's
conventions differ per family and so do Home Assistant's:
- a battery reports ``0`` for low, and ``BinarySensorDeviceClass.BATTERY``
defines ``on`` as "low"
- a water leak sensor reports ``1`` for a leak, and
``BinarySensorDeviceClass.MOISTURE`` defines ``on`` as "wet"
Keeping it in the description means one entity class serves both instead of
two near-identical ones that differ only in a comparison.
"""
on_value: int

View File

@ -4,20 +4,25 @@ from __future__ import annotations
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
from homeassistant.const import (
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION,
DEGREE,
PERCENTAGE,
UV_INDEX,
UnitOfIrradiance,
UnitOfLength,
UnitOfPrecipitationDepth,
UnitOfPressure,
UnitOfSpeed,
UnitOfTemperature,
UnitOfTime,
UnitOfVolumetricFlux,
)
from .battery_sensors_def import BATTERY_LEVEL_SENSORS
from .const import (
ABS_PRESSURE,
BARO_PRESSURE,
CH2_BATTERY,
CH2_HUMIDITY,
@ -41,18 +46,32 @@ from .const import (
CH8_HUMIDITY,
CH8_TEMP,
CHILL_INDEX,
CO,
CO2,
DAILY_RAIN,
DEW_POINT,
FEELS_LIKE,
HCHO,
HEAT_INDEX,
HOURLY_RAIN,
INDOOR_BATTERY,
INDOOR_HUMIDITY,
INDOOR_TEMP,
LIGHTNING_COUNT_1D,
LIGHTNING_COUNT_1H,
LIGHTNING_COUNT_5M,
LIGHTNING_COUNT_30M,
LIGHTNING_DISTANCE,
LIGHTNING_LAST,
LIGHTNING_STRIKES_1H,
MONTHLY_RAIN,
OUTSIDE_BATTERY,
OUTSIDE_HUMIDITY,
OUTSIDE_TEMP,
PM10,
PM10_AQI,
PM25,
PM25_AQI,
RAIN,
SOLAR_RADIATION,
UV,
@ -63,6 +82,7 @@ from .const import (
WIND_DIR,
WIND_GUST,
WIND_SPEED,
WIND_SPEED_AVG10,
YEARLY_RAIN,
UnitOfBat,
UnitOfDir,
@ -71,6 +91,7 @@ from .const import (
from .sensors_common import WeatherSensorEntityDescription
from .utils import (
battery_level,
lightning_minutes,
to_float,
to_int,
voc_level_to_text,
@ -553,7 +574,151 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
icon="mdi:air-filter",
value_fn=voc_level_to_text,
),
# 0-5 level batteries are generated from BATTERY_NON_BINARY so the classification
# lives in exactly one place (see battery_sensors_def).
# --- Base console -------------------------------------------------------
WeatherSensorEntityDescription(
key=ABS_PRESSURE,
native_unit_of_measurement=UnitOfPressure.HPA,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE,
icon="mdi:gauge",
translation_key=ABS_PRESSURE,
value_fn=to_float,
),
# --- Type1 outdoor ------------------------------------------------------
WeatherSensorEntityDescription(
key=FEELS_LIKE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.TEMPERATURE,
suggested_display_precision=2,
icon="mdi:thermometer",
translation_key=FEELS_LIKE,
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=WIND_SPEED_AVG10,
native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.WIND_SPEED,
suggested_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
icon="mdi:weather-windy",
translation_key=WIND_SPEED_AVG10,
value_fn=to_float,
),
# --- Type5 lightning ----------------------------------------------------
WeatherSensorEntityDescription(
key=LIGHTNING_LAST,
native_unit_of_measurement=UnitOfTime.MINUTES,
icon="mdi:flash-alert",
translation_key=LIGHTNING_LAST,
# No device class: the API does not define an epoch, so this cannot be a
# TIMESTAMP (which HA requires to be a tz-aware datetime).
value_fn=lightning_minutes,
),
WeatherSensorEntityDescription(
key=LIGHTNING_DISTANCE,
native_unit_of_measurement=UnitOfLength.KILOMETERS,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.DISTANCE,
icon="mdi:flash",
translation_key=LIGHTNING_DISTANCE,
value_fn=to_float,
),
WeatherSensorEntityDescription(
key=LIGHTNING_STRIKES_1H,
# Rolling windows, not monotonic totals, so MEASUREMENT rather than
# TOTAL_INCREASING - the latter would make long-term statistics meaningless.
state_class=SensorStateClass.MEASUREMENT,
icon="mdi:flash",
translation_key=LIGHTNING_STRIKES_1H,
value_fn=to_int,
),
WeatherSensorEntityDescription(
key=LIGHTNING_COUNT_5M,
# Rolling windows, not monotonic totals, so MEASUREMENT rather than
# TOTAL_INCREASING - the latter would make long-term statistics meaningless.
state_class=SensorStateClass.MEASUREMENT,
icon="mdi:flash",
translation_key=LIGHTNING_COUNT_5M,
value_fn=to_int,
),
WeatherSensorEntityDescription(
key=LIGHTNING_COUNT_30M,
# Rolling windows, not monotonic totals, so MEASUREMENT rather than
# TOTAL_INCREASING - the latter would make long-term statistics meaningless.
state_class=SensorStateClass.MEASUREMENT,
icon="mdi:flash",
translation_key=LIGHTNING_COUNT_30M,
value_fn=to_int,
),
WeatherSensorEntityDescription(
key=LIGHTNING_COUNT_1H,
# Rolling windows, not monotonic totals, so MEASUREMENT rather than
# TOTAL_INCREASING - the latter would make long-term statistics meaningless.
state_class=SensorStateClass.MEASUREMENT,
icon="mdi:flash",
translation_key=LIGHTNING_COUNT_1H,
value_fn=to_int,
),
WeatherSensorEntityDescription(
key=LIGHTNING_COUNT_1D,
# Rolling windows, not monotonic totals, so MEASUREMENT rather than
# TOTAL_INCREASING - the latter would make long-term statistics meaningless.
state_class=SensorStateClass.MEASUREMENT,
icon="mdi:flash",
translation_key=LIGHTNING_COUNT_1D,
value_fn=to_int,
),
# --- Type8 particulate matter -------------------------------------------
WeatherSensorEntityDescription(
key=PM25,
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.PM25,
translation_key=PM25,
value_fn=to_int,
),
WeatherSensorEntityDescription(
key=PM10,
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.PM10,
translation_key=PM10,
value_fn=to_int,
),
WeatherSensorEntityDescription(
key=PM25_AQI,
# SensorDeviceClass.AQI takes no unit of measurement.
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.AQI,
translation_key=PM25_AQI,
value_fn=to_int,
),
WeatherSensorEntityDescription(
key=PM10_AQI,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.AQI,
translation_key=PM10_AQI,
value_fn=to_int,
),
# --- Type10 CO2 ---------------------------------------------------------
WeatherSensorEntityDescription(
key=CO2,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.CO2,
translation_key=CO2,
value_fn=to_int,
),
# --- Type11 CO ----------------------------------------------------------
WeatherSensorEntityDescription(
key=CO,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.CO,
translation_key=CO,
value_fn=to_int,
),
# 0-5 level batteries (t8/t9/t10/t11) are generated from BATTERY_NON_BINARY.
*BATTERY_LEVEL_SENSORS,
)

View File

@ -4,6 +4,7 @@
"valid_credentials_api": "Provide valid API ID.",
"valid_credentials_key": "Provide valid API KEY.",
"valid_credentials_match": "API ID and API KEY should not be the same.",
"ecowitt_webhook_required": "Provide a webhook ID before enabling Ecowitt data.",
"protocols_mutually_exclusive": "The legacy (PWS/WSLink) and Ecowitt endpoints cannot be enabled at the same time - they feed the same sensor entities, which would mix up units and blank readings. Disable one of them first."
},
"step": {
@ -50,6 +51,7 @@
"valid_credentials_api": "Provide valid API ID.",
"valid_credentials_key": "Provide valid API KEY.",
"valid_credentials_match": "API ID and API KEY should not be the same.",
"ecowitt_webhook_required": "Provide a webhook ID before enabling Ecowitt data.",
"windy_id_required": "Windy API ID is required if you want to enable this function.",
"windy_pw_required": "Windy API password is required if you want to enable this function.",
"windy_key_required": "Windy station ID and password are required if you want to enable this function.",
@ -173,6 +175,51 @@
},
"ch8_battery": {
"name": "Channel 8 battery"
},
"lightning_battery": {
"name": "Lightning sensor battery"
},
"leak_ch1": {
"name": "Water leak CH1"
},
"leak_ch2": {
"name": "Water leak CH2"
},
"leak_ch3": {
"name": "Water leak CH3"
},
"leak_ch4": {
"name": "Water leak CH4"
},
"leak_ch5": {
"name": "Water leak CH5"
},
"leak_ch6": {
"name": "Water leak CH6"
},
"leak_ch7": {
"name": "Water leak CH7"
},
"leak_ch1_battery": {
"name": "Water leak CH1 battery"
},
"leak_ch2_battery": {
"name": "Water leak CH2 battery"
},
"leak_ch3_battery": {
"name": "Water leak CH3 battery"
},
"leak_ch4_battery": {
"name": "Water leak CH4 battery"
},
"leak_ch5_battery": {
"name": "Water leak CH5 battery"
},
"leak_ch6_battery": {
"name": "Water leak CH6 battery"
},
"leak_ch7_battery": {
"name": "Water leak CH7 battery"
}
},
"sensor": {
@ -529,6 +576,63 @@
"low": "Low",
"drained": "Unknown / drained out"
}
},
"abs_pressure": {
"name": "Absolute pressure"
},
"feels_like": {
"name": "Feels like"
},
"wind_speed_avg10": {
"name": "Wind speed (10 min average)"
},
"lightning_last": {
"name": "Time since last lightning strike"
},
"lightning_distance": {
"name": "Lightning distance"
},
"lightning_strikes_1h": {
"name": "Lightning strikes, last hour (t5lsf)"
},
"lightning_count_5m": {
"name": "Lightning strikes (5 minutes)"
},
"lightning_count_30m": {
"name": "Lightning strikes (30 minutes)"
},
"lightning_count_1h": {
"name": "Lightning strike total, 1 hour (t5ls1htc)"
},
"lightning_count_1d": {
"name": "Lightning strikes (1 day)"
},
"pm25": {
"name": "PM2.5"
},
"pm10": {
"name": "PM10"
},
"pm25_aqi": {
"name": "PM2.5 AQI"
},
"pm10_aqi": {
"name": "PM10 AQI"
},
"t8_battery": {
"name": "PM sensor battery"
},
"co2": {
"name": "CO2"
},
"t10_battery": {
"name": "CO2 sensor battery"
},
"co": {
"name": "CO"
},
"t11_battery": {
"name": "CO sensor battery"
}
}
},

View File

@ -1 +0,0 @@
../dev/custom_components/sws12500

View File

@ -4,6 +4,7 @@
"valid_credentials_api": "Vyplňte platné API ID.",
"valid_credentials_key": "Vyplňte platný API KEY.",
"valid_credentials_match": "API ID a API KEY nesmějí být stejné!",
"ecowitt_webhook_required": "Před zapnutím dat Ecowitt zadejte webhook ID.",
"protocols_mutually_exclusive": "Starý endpoint (PWS/WSLink) a Ecowitt nelze zapnout současně plní stejné entity senzorů, což by pomíchalo jednotky a mazalo naměřené hodnoty. Nejdřív jeden z nich vypni."
},
"step": {
@ -50,6 +51,7 @@
"valid_credentials_api": "Vyplňte platné API ID",
"valid_credentials_key": "Vyplňte platný API KEY",
"valid_credentials_match": "API ID a API KEY nesmějí být stejné!",
"ecowitt_webhook_required": "Před zapnutím dat Ecowitt zadejte webhook ID.",
"windy_id_required": "Je vyžadováno Windy ID, pokud chcete aktivovat přeposílání dat na Windy",
"windy_pw_required": "Je vyžadován Windy KEY, pokud chcete aktivovat přeposílání dat na Windy",
"windy_key_required": "Pro aktivaci je vyžadováno Windy ID i heslo stanice.",
@ -173,6 +175,51 @@
},
"ch8_battery": {
"name": "Baterie senzoru 8"
},
"lightning_battery": {
"name": "Baterie čidla blesků"
},
"leak_ch1": {
"name": "Zaplavení CH1"
},
"leak_ch2": {
"name": "Zaplavení CH2"
},
"leak_ch3": {
"name": "Zaplavení CH3"
},
"leak_ch4": {
"name": "Zaplavení CH4"
},
"leak_ch5": {
"name": "Zaplavení CH5"
},
"leak_ch6": {
"name": "Zaplavení CH6"
},
"leak_ch7": {
"name": "Zaplavení CH7"
},
"leak_ch1_battery": {
"name": "Baterie čidla zaplavení CH1"
},
"leak_ch2_battery": {
"name": "Baterie čidla zaplavení CH2"
},
"leak_ch3_battery": {
"name": "Baterie čidla zaplavení CH3"
},
"leak_ch4_battery": {
"name": "Baterie čidla zaplavení CH4"
},
"leak_ch5_battery": {
"name": "Baterie čidla zaplavení CH5"
},
"leak_ch6_battery": {
"name": "Baterie čidla zaplavení CH6"
},
"leak_ch7_battery": {
"name": "Baterie čidla zaplavení CH7"
}
},
"sensor": {
@ -529,6 +576,63 @@
"normal": "Normální",
"drained": "Neznámá / zcela vybitá"
}
},
"abs_pressure": {
"name": "Absolutní tlak"
},
"feels_like": {
"name": "Pocitová teplota"
},
"wind_speed_avg10": {
"name": "Rychlost větru (10min průměr)"
},
"lightning_last": {
"name": "Čas od posledního blesku"
},
"lightning_distance": {
"name": "Vzdálenost blesku"
},
"lightning_strikes_1h": {
"name": "Blesky, poslední hodina (t5lsf)"
},
"lightning_count_5m": {
"name": "Blesky (5 minut)"
},
"lightning_count_30m": {
"name": "Blesky (30 minut)"
},
"lightning_count_1h": {
"name": "Blesky celkem, 1 hodina (t5ls1htc)"
},
"lightning_count_1d": {
"name": "Blesky (1 den)"
},
"pm25": {
"name": "PM2,5"
},
"pm10": {
"name": "PM10"
},
"pm25_aqi": {
"name": "PM2,5 AQI"
},
"pm10_aqi": {
"name": "PM10 AQI"
},
"t8_battery": {
"name": "Baterie čidla PM"
},
"co2": {
"name": "CO2"
},
"t10_battery": {
"name": "Baterie čidla CO2"
},
"co": {
"name": "CO"
},
"t11_battery": {
"name": "Baterie čidla CO"
}
}
},

View File

@ -4,6 +4,7 @@
"valid_credentials_api": "Provide valid API ID.",
"valid_credentials_key": "Provide valid API KEY.",
"valid_credentials_match": "API ID and API KEY should not be the same.",
"ecowitt_webhook_required": "Provide a webhook ID before enabling Ecowitt data.",
"protocols_mutually_exclusive": "The legacy (PWS/WSLink) and Ecowitt endpoints cannot be enabled at the same time - they feed the same sensor entities, which would mix up units and blank readings. Disable one of them first."
},
"step": {
@ -50,6 +51,7 @@
"valid_credentials_api": "Provide valid API ID.",
"valid_credentials_key": "Provide valid API KEY.",
"valid_credentials_match": "API ID and API KEY should not be the same.",
"ecowitt_webhook_required": "Provide a webhook ID before enabling Ecowitt data.",
"windy_id_required": "Windy API ID is required if you want to enable this function.",
"windy_pw_required": "Windy API password is required if you want to enable this function.",
"windy_key_required": "Windy station ID and password are required if you want to enable this function.",
@ -173,6 +175,51 @@
},
"ch8_battery": {
"name": "Channel 8 battery"
},
"lightning_battery": {
"name": "Lightning sensor battery"
},
"leak_ch1": {
"name": "Water leak CH1"
},
"leak_ch2": {
"name": "Water leak CH2"
},
"leak_ch3": {
"name": "Water leak CH3"
},
"leak_ch4": {
"name": "Water leak CH4"
},
"leak_ch5": {
"name": "Water leak CH5"
},
"leak_ch6": {
"name": "Water leak CH6"
},
"leak_ch7": {
"name": "Water leak CH7"
},
"leak_ch1_battery": {
"name": "Water leak CH1 battery"
},
"leak_ch2_battery": {
"name": "Water leak CH2 battery"
},
"leak_ch3_battery": {
"name": "Water leak CH3 battery"
},
"leak_ch4_battery": {
"name": "Water leak CH4 battery"
},
"leak_ch5_battery": {
"name": "Water leak CH5 battery"
},
"leak_ch6_battery": {
"name": "Water leak CH6 battery"
},
"leak_ch7_battery": {
"name": "Water leak CH7 battery"
}
},
"sensor": {
@ -529,6 +576,63 @@
"low": "Low",
"drained": "Unknown / drained out"
}
},
"abs_pressure": {
"name": "Absolute pressure"
},
"feels_like": {
"name": "Feels like"
},
"wind_speed_avg10": {
"name": "Wind speed (10 min average)"
},
"lightning_last": {
"name": "Time since last lightning strike"
},
"lightning_distance": {
"name": "Lightning distance"
},
"lightning_strikes_1h": {
"name": "Lightning strikes, last hour (t5lsf)"
},
"lightning_count_5m": {
"name": "Lightning strikes (5 minutes)"
},
"lightning_count_30m": {
"name": "Lightning strikes (30 minutes)"
},
"lightning_count_1h": {
"name": "Lightning strike total, 1 hour (t5ls1htc)"
},
"lightning_count_1d": {
"name": "Lightning strikes (1 day)"
},
"pm25": {
"name": "PM2.5"
},
"pm10": {
"name": "PM10"
},
"pm25_aqi": {
"name": "PM2.5 AQI"
},
"pm10_aqi": {
"name": "PM10 AQI"
},
"t8_battery": {
"name": "PM sensor battery"
},
"co2": {
"name": "CO2"
},
"t10_battery": {
"name": "CO2 sensor battery"
},
"co": {
"name": "CO"
},
"t11_battery": {
"name": "CO sensor battery"
}
}
},

View File

@ -20,18 +20,23 @@ from typing import Any, Final
from py_typecheck.core import checked_or
from homeassistant.components import persistent_notification
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.translation import async_get_translations
from .const import (
AZIMUT,
CH_HUMIDITY_TYPE_PARAM,
CH_TYPE_SOIL,
CHANNEL_TYPES,
CHILL_INDEX,
CONNECTION_GATED_SENSORS,
DEV_DBG,
HEAT_INDEX,
OUTSIDE_HUMIDITY,
OUTSIDE_TEMP,
REMAP_ECOWITT_TO_WINDY,
REMAP_ITEMS,
REMAP_WSLINK_ITEMS,
SENSORS_TO_LOAD,
@ -113,7 +118,8 @@ def anonymize(
- Keep all keys, but mask sensitive values.
- Do not raise on unexpected/missing keys.
"""
secrets = {"ID", "PASSWORD", "wsid", "wspw", "passkey", "PASSKEY"}
# `PAS` is Pocasi Meteo's Ecowitt password parameter (not a typo for PASS).
secrets = {"ID", "PASSWORD", "PAS", "wsid", "wspw", "passkey", "PASSKEY"}
return {k: ("***" if k in secrets else v) for k, v in data.items()}
@ -136,7 +142,14 @@ def remap_wslink_items(entities: dict[str, str]) -> dict[str, str]:
items[REMAP_WSLINK_ITEMS[item]] = value
for conn_key, gated in CONNECTION_GATED_SENSORS.items():
if str(entities.get(conn_key, "0")) != "1":
# Only an explicit "not connected" drops the readings. An absent flag means the
# 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`.
# 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)
@ -152,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).
@ -206,11 +232,12 @@ def wind_dir_to_text(deg: float | str | None) -> UnitOfDir | None:
return azimut
def battery_level(battery: int | str | None) -> UnitOfBat:
def battery_level(battery: Any) -> UnitOfBat:
"""Return battery level.
WSLink payload values often arrive as strings (e.g. "0"/"1"), so we accept
both ints and strings and coerce to int before mapping.
Goes through `to_int` like every other payload coercion: values arrive as
strings, sometimes spelled as decimals ("1.0"), and reading one of those as
"unknown" would contradict the binary battery entity fed by the same field.
Returns UnitOfBat
"""
@ -220,18 +247,10 @@ def battery_level(battery: int | str | None) -> UnitOfBat:
1: UnitOfBat.NORMAL,
}
if (battery is None) or (battery == ""):
vi = to_int(battery)
if vi is None:
return UnitOfBat.UNKNOWN
vi: int
if isinstance(battery, int):
vi = battery
else:
try:
vi = int(battery)
except ValueError:
return UnitOfBat.UNKNOWN
return level_map.get(vi, UnitOfBat.UNKNOWN)
@ -290,6 +309,11 @@ def heat_index(data: dict[str, int | float | str], convert: bool = False) -> flo
data: dict with temperature and humidity
convert: bool, convert received data from Celsius to Fahrenheit
The result is floored at the ambient temperature. The NWS step-1 average is only
meaningful once it reaches 80 F; below that it drifts under the air temperature
(6.2 C at 40% RH yields 3.9 C), and an entity called "Heat index" reading colder
than the air is wrong rather than merely imprecise. Always returns Fahrenheit.
"""
if (temp := to_float(data.get(OUTSIDE_TEMP))) is None:
_LOGGER.error(
@ -329,9 +353,9 @@ def heat_index(data: dict[str, int | float | str], convert: bool = False) -> flo
if rh > 80 and (80 <= temp <= 87):
adjustment = ((rh - 85) / 10) * ((87 - temp) / 5)
return round((full_index + adjustment if adjustment else full_index), 2)
return max(round((full_index + adjustment if adjustment else full_index), 2), temp)
return simple
return max(simple, temp)
def chill_index(data: dict[str, str | float | int], convert: bool = False) -> float | None:
@ -447,3 +471,59 @@ def wslink_chill_index(data: dict[str, Any]) -> float | None:
}
)
return None if value_f is None else round(fahrenheit_to_celsius(value_f), 2)
def remap_ecowitt_to_windy(data: dict[str, Any]) -> dict[str, Any]:
"""Translate an Ecowitt payload into the field names Windy accepts.
Needed for services that do not speak the Ecowitt protocol - Windy, unlike Pocasi
Meteo, has no Ecowitt endpoint, so fields such as `baromrelin`, `dewpointf` or
`tempinf` would simply not be understood.
Only fields listed in `REMAP_ECOWITT_TO_WINDY` are passed on; see the table there
for why that is an allowlist rather than a denylist, and where it departs from the
WU spelling.
"""
return {out_key: data[eco_key] for eco_key, out_key in REMAP_ECOWITT_TO_WINDY.items() if eco_key in data}
# The WSLink API documents `t5lst` only as "Last Lightning strike time" (integer) and
# its own example uses 9999, which is not a plausible epoch. It is read as minutes
# since the last strike, with 9999 meaning "nothing recorded".
LIGHTNING_NO_STRIKE: Final = 9999
def lightning_minutes(value: Any) -> int | None:
"""Minutes since the last lightning strike, or None when nothing was recorded."""
minutes = to_int(value)
if minutes is None or minutes >= LIGHTNING_NO_STRIKE:
return None
return minutes
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`.
`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.
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(probe_types.get(param))
if channel_type is None:
return None
return SensorDeviceClass.MOISTURE if channel_type == CH_TYPE_SOIL else SensorDeviceClass.HUMIDITY

View File

@ -4,8 +4,9 @@ from __future__ import annotations
from datetime import datetime, timedelta
import logging
from typing import Literal
from aiohttp.client import ClientResponse
from aiohttp.client import ClientResponse, ClientTimeout
from aiohttp.client_exceptions import ClientError
from py_typecheck import checked_or
@ -16,6 +17,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.util import dt as dt_util
from .const import (
FORWARD_TIMEOUT,
PURGE_DATA,
WINDY_ENABLED,
WINDY_INVALID_KEY,
@ -28,10 +30,13 @@ from .const import (
WINDY_UNEXPECTED,
WINDY_URL,
)
from .utils import update_options
from .utils import remap_ecowitt_to_windy, update_options
_LOGGER = logging.getLogger(__name__)
# Which protocol the payload arrived in; Windy itself always speaks PWS.
type WindySource = Literal["pws", "wslink", "ecowitt"]
class WindyNotInserted(Exception):
"""NotInserted state.
@ -133,6 +138,21 @@ class WindyPush:
if response.status == 429:
raise WindyRateLimitExceeded
def _to_pws(self, data: dict[str, str], source: WindySource) -> dict[str, str]:
"""Convert a station payload into the field names Windy understands.
Windy speaks the PWS/WU vocabulary, so every other protocol has to be mapped
onto it first. All three sources are dispatched here so the conversions stay
in one place instead of being applied by whoever happens to call us.
"""
if source == "wslink":
return self._covert_wslink_to_pws(data)
if source == "ecowitt":
# Windy has no Ecowitt endpoint; the shared table also drops the station
# metadata (PASSKEY, stationtype, model, ...) that means nothing upstream.
return remap_ecowitt_to_windy(data)
return data
def _covert_wslink_to_pws(self, indata: dict[str, str]) -> dict[str, str]:
"""Convert WSLink API data to Windy API data protocol."""
if "t1ws" in indata:
@ -156,6 +176,13 @@ class WindyPush:
if "t1solrad" in indata:
indata["solarradiation"] = indata.pop("t1solrad")
# Indoor readings mean nothing to Windy, and there is no PWS name to put them
# under: `indoortempf` is Fahrenheit while WSLink `intem` is Celsius. Dropping
# them outright keeps the payload unit-correct on its own terms, rather than
# relying on PURGE_DATA to discard a mislabelled value afterwards.
indata.pop("intem", None)
indata.pop("inhum", None)
return indata
async def _disable_windy(self, reason: str) -> None:
@ -174,7 +201,7 @@ class WindyPush:
persistent_notification.async_create(self.hass, reason, "Windy resending disabled.")
async def push_data_to_windy(self, data: dict[str, str], wslink: bool = False) -> bool:
async def push_data_to_windy(self, data: dict[str, str], source: WindySource = "pws") -> bool:
"""Pushes weather data do Windy stations.
Interval is 5 minutes, otherwise Windy would not accepts data.
@ -220,15 +247,12 @@ class WindyPush:
# webhook does not also pass the rate-limit check and double-send.
self.next_update = dt_util.utcnow() + timed(minutes=5)
purged_data = data.copy()
for purge in PURGE_DATA:
if purge in purged_data:
_ = purged_data.pop(purge)
if wslink:
# WSLink -> Windy params
purged_data = self._covert_wslink_to_pws(purged_data)
# Convert *before* purging. PURGE_DATA lists PWS field names, so a reading that
# only becomes a purge target after conversion - Ecowitt `tempinf` ->
# `indoortempf`, WSLink `t1solrad` -> `solarradiation` - would otherwise slip
# through to Windy unnoticed.
converted = self._to_pws(data.copy(), source)
purged_data = {key: value for key, value in converted.items() if key not in PURGE_DATA}
request_url = f"{WINDY_URL}"
@ -243,7 +267,16 @@ class WindyPush:
_LOGGER.info("Dataset for windy: %s", {**purged_data, "id": "***"})
session = async_get_clientsession(self.hass)
try:
async with session.get(request_url, params=purged_data, headers=headers) as resp:
# Home Assistant's shared session sets no timeout, so without an explicit
# one a stalled Windy would hold the station's webhook open for aiohttp's
# 5 minute default and the TimeoutError branch below could never run in
# time to answer the station.
async with session.get(
request_url,
params=purged_data,
headers=headers,
timeout=ClientTimeout(total=FORWARD_TIMEOUT),
) as resp:
try:
self.verify_windy_response(response=resp)
except WindyNotInserted:
@ -314,7 +347,20 @@ class WindyPush:
# TimeoutError is not a ClientError: an `async_timeout`/`asyncio` timeout would
# otherwise escape into the webhook handler and answer the station with HTTP 500,
# even though the measured data was already stored.
except (ClientError, TimeoutError) as ex:
#
# It gets its own branch because it must not spend the retry budget: a slow
# upstream is transient and self-healing, so the next push just tries again,
# while `invalid_response_count` is meant for faults that will not fix
# themselves. Caught first on purpose - aiohttp's ServerTimeoutError is both a
# ClientError and a TimeoutError, and it belongs here.
except TimeoutError as ex:
self.last_status = "timeout"
self.last_error = type(ex).__name__
_LOGGER.warning(
"Windy did not answer within %s seconds. Will try again on the next push.",
FORWARD_TIMEOUT,
)
except ClientError as ex:
self.last_status = "client_error"
# Store only the exception class - last_error is surfaced via entity
# attributes; str(ex) could embed the request URL.

15
pytest.ini Normal file
View File

@ -0,0 +1,15 @@
# Pytest configuration for the sws12500 Home Assistant custom component.
#
# The suite is written against these settings; without them a clean clone
# collects the async tests but never awaits them. Kept as a standalone
# pytest.ini for the same reason the ruff config lives in ruff.toml: one
# tool, one tracked file, no package metadata this repository does not need.
[pytest]
testpaths = tests
# Most async tests carry no @pytest.mark.asyncio marker, so pytest-asyncio's
# default strict mode would skip awaiting them. Auto mode treats every async
# test function as an asyncio test.
asyncio_mode = auto
asyncio_default_fixture_loop_scope = function

228
ruff.toml Normal file
View File

@ -0,0 +1,228 @@
# Ruff configuration for the sws12500 Home Assistant custom component.
#
# This mirrors the rule set Home Assistant core uses, because that is the
# convention this integration's code is written against. Core-only settings
# (entry-point scripts, the per-component relative-import exemptions, and the
# PLATFORM_SCHEMA import aliases for domains this integration does not touch)
# have been dropped; everything else is carried over verbatim so that the
# tooling in this repository describes the code that is already here.
required-version = ">=0.8.0"
line-length = 120
[lint]
select = [
"A001", # Variable {name} is shadowing a Python builtin
"ASYNC210", # Async functions should not call blocking HTTP methods
"ASYNC220", # Async functions should not create subprocesses with blocking methods
"ASYNC221", # Async functions should not run processes with blocking methods
"ASYNC222", # Async functions should not wait on processes with blocking methods
"ASYNC230", # Async functions should not open files with blocking methods like open
"ASYNC251", # Async functions should not call time.sleep
"B002", # Python does not support the unary prefix increment
"B005", # Using .strip() with multi-character strings is misleading
"B007", # Loop control variable {name} not used within loop body
"B014", # Exception handler with duplicate exception
"B015", # Pointless comparison. Did you mean to assign a value? Otherwise, prepend assert or remove it.
"B017", # pytest.raises(BaseException) should be considered evil
"B018", # Found useless attribute access. Either assign it to a variable or remove it.
"B023", # Function definition does not bind loop variable {name}
"B026", # Star-arg unpacking after a keyword argument is strongly discouraged
"B032", # Possible unintentional type annotation (using :). Did you mean to assign (using =)?
"B904", # Use raise from to specify exception cause
"B905", # zip() without an explicit strict= parameter
"BLE",
"C", # complexity
"COM818", # Trailing comma on bare tuple prohibited
"D", # docstrings
"DTZ003", # Use datetime.now(tz=) instead of datetime.utcnow()
"DTZ004", # Use datetime.fromtimestamp(ts, tz=) instead of datetime.utcfromtimestamp(ts)
"E", # pycodestyle
"F", # pyflakes/autoflake
"F541", # f-string without any placeholders
"FLY", # flynt
"FURB", # refurb
"G", # flake8-logging-format
"I", # isort
"INP", # flake8-no-pep420
"ISC", # flake8-implicit-str-concat
"ICN001", # import concentions; {name} should be imported as {asname}
"LOG", # flake8-logging
"N804", # First argument of a class method should be named cls
"N805", # First argument of a method should be named self
"N815", # Variable {name} in class scope should not be mixedCase
"PERF", # Perflint
"PGH", # pygrep-hooks
"PIE", # flake8-pie
"PL", # pylint
"PT", # flake8-pytest-style
"PTH", # flake8-pathlib
"PYI", # flake8-pyi
"RET", # flake8-return
"RSE", # flake8-raise
"RUF005", # Consider iterable unpacking instead of concatenation
"RUF006", # Store a reference to the return value of asyncio.create_task
"RUF010", # Use explicit conversion flag
"RUF013", # PEP 484 prohibits implicit Optional
"RUF017", # Avoid quadratic list summation
"RUF018", # Avoid assignment expressions in assert statements
"RUF019", # Unnecessary key check before dictionary access
# "RUF100", # Unused `noqa` directive; temporarily every now and then to clean them up
"S102", # Use of exec detected
"S103", # bad-file-permissions
"S108", # hardcoded-temp-file
"S306", # suspicious-mktemp-usage
"S307", # suspicious-eval-usage
"S313", # suspicious-xmlc-element-tree-usage
"S314", # suspicious-xml-element-tree-usage
"S315", # suspicious-xml-expat-reader-usage
"S316", # suspicious-xml-expat-builder-usage
"S317", # suspicious-xml-sax-usage
"S318", # suspicious-xml-mini-dom-usage
"S319", # suspicious-xml-pull-dom-usage
"S601", # paramiko-call
"S602", # subprocess-popen-with-shell-equals-true
"S604", # call-with-shell-equals-true
"S608", # hardcoded-sql-expression
"S609", # unix-command-wildcard-injection
"SIM", # flake8-simplify
"SLF", # flake8-self
"SLOT", # flake8-slots
"T100", # Trace found: {name} used
"T20", # flake8-print
"TC", # flake8-type-checking
"TID", # Tidy imports
"TRY", # tryceratops
"UP", # pyupgrade
"UP031", # Use format specifiers instead of percent format
"UP032", # Use f-string instead of `format` call
"W", # pycodestyle
]
ignore = [
"D202", # No blank lines allowed after function docstring
"D203", # 1 blank line required before class docstring
"D213", # Multi-line docstring summary should start at the second line
"D406", # Section name should end with a newline
"D407", # Section name underlining
"E501", # line too long
"PLC1901", # {existing} can be simplified to {replacement} as an empty string is falsey; too many false positives
"PLR0911", # Too many return statements ({returns} > {max_returns})
"PLR0912", # Too many branches ({branches} > {max_branches})
"PLR0913", # Too many arguments to function call ({c_args} > {max_args})
"PLR0915", # Too many statements ({statements} > {max_statements})
"PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable
"PLW2901", # Outer {outer_kind} variable {name} overwritten by inner {inner_kind} target
"PT011", # pytest.raises({exception}) is too broad, set the `match` parameter or use a more specific exception
"PT018", # Assertion should be broken down into multiple parts
"RUF001", # String contains ambiguous unicode character.
"RUF002", # Docstring contains ambiguous unicode character.
"RUF003", # Comment contains ambiguous unicode character.
"RUF015", # Prefer next(...) over single element slice
"SIM102", # Use a single if statement instead of nested if statements
"SIM103", # Return the condition {condition} directly
"SIM108", # Use ternary operator {contents} instead of if-else-block
"SIM115", # Use context handler for opening files
# Moving imports into type-checking blocks can mess with pytest.patch()
"TC001", # Move application import {} into a type-checking block
"TC002", # Move third-party import {} into a type-checking block
"TC003", # Move standard library import {} into a type-checking block
"TRY003", # Avoid specifying long messages outside the exception class
"TRY400", # Use `logging.exception` instead of `logging.error`
# UP038 (`X | Y` in `isinstance`) is in core's ignore list, but the rule was
# removed from ruff; listing it only produces a "has no effect" warning.
# May conflict with the formatter, https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules
"W191",
"E111",
"E114",
"E117",
"D206",
"D300",
"Q",
"COM812",
"COM819",
"ISC001",
# Disabled because ruff does not understand type of __all__ generated by a function
"PLE0605",
]
[lint.flake8-import-conventions.extend-aliases]
voluptuous = "vol"
"homeassistant.components.binary_sensor.PLATFORM_SCHEMA" = "BINARY_SENSOR_PLATFORM_SCHEMA"
"homeassistant.components.sensor.PLATFORM_SCHEMA" = "SENSOR_PLATFORM_SCHEMA"
"homeassistant.components.weather.PLATFORM_SCHEMA" = "WEATHER_PLATFORM_SCHEMA"
"homeassistant.core.DOMAIN" = "HOMEASSISTANT_DOMAIN"
"homeassistant.helpers.area_registry" = "ar"
"homeassistant.helpers.category_registry" = "cr"
"homeassistant.helpers.config_validation" = "cv"
"homeassistant.helpers.device_registry" = "dr"
"homeassistant.helpers.entity_registry" = "er"
"homeassistant.helpers.floor_registry" = "fr"
"homeassistant.helpers.issue_registry" = "ir"
"homeassistant.helpers.label_registry" = "lr"
"homeassistant.util.dt" = "dt_util"
[lint.flake8-pytest-style]
fixture-parentheses = false
mark-parentheses = false
[lint.flake8-tidy-imports.banned-api]
"async_timeout".msg = "use asyncio.timeout instead"
"pytz".msg = "use zoneinfo instead"
"tests".msg = "You should not import tests"
[lint.isort]
force-sort-within-sections = true
known-first-party = ["homeassistant"]
combine-as-imports = true
split-on-trailing-comma = false
[lint.per-file-ignores]
# Temporary, as in core: this integration has not migrated to pathlib.
"custom_components/**" = ["PTH"]
# The integration code satisfies the full rule set above. The test suite does
# not, and is relaxed here in two distinct ways.
#
# Deliberate, permanent exemptions - these are how this suite is written:
# D100, D103 - test modules and test functions carry their meaning in names
# INP001 - tests/ is intentionally not a package (no __init__.py)
# PLC0415 - function-local imports keep import-time patching honest
# PTH - as above
# SLF001 - tests assert directly on private helpers
#
# Deferred nits - genuine violations that exist in the tests today. They are
# suppressed rather than fixed to keep the introduction of this configuration a
# config-only change with no source edits. Remove each entry as it is cleaned
# up; none of them affect the integration code:
# B007, C420, D401, D413, I001, PLW0108, PT006, PYI034, SIM300, UP035, UP037
"tests/**" = [
"B007",
"C420",
"D100",
"D103",
"D401",
"D413",
"I001",
"INP001",
"PLC0415",
"PLW0108",
"PT006",
"PTH",
"PYI034",
"SIM300",
"SLF001",
"UP035",
"UP037",
]
[lint.mccabe]
max-complexity = 25
[lint.pydocstyle]
property-decorators = ["propcache.cached_property"]

View File

@ -29,8 +29,21 @@ from custom_components.sws12500.sensors_wslink import SENSOR_TYPES_WSLINK
def _all_battery_constants() -> set[str]:
"""Every `*_BATTERY` value defined in const.py."""
return {value for name, value in vars(const).items() if name.endswith("_BATTERY") and isinstance(value, str)}
"""Every battery key defined in const.py.
A `*_BATTERY` constant is either a single key (``T9_BATTERY``) or a tuple of them
for a multi-channel family (``LEAK_CH_BATTERY``); both shapes count, otherwise a
whole family could be added without the classification tests noticing.
"""
keys: set[str] = set()
for name, value in vars(const).items():
if not name.endswith("_BATTERY"):
continue
if isinstance(value, str):
keys.add(value)
elif isinstance(value, tuple):
keys.update(v for v in value if isinstance(v, str))
return keys
def test_classifications_are_disjoint() -> None:

View File

@ -3,7 +3,7 @@
Covers:
- `binary_sensor.async_setup_entry` (with and without battery keys in SENSORS_TO_LOAD)
- `binary_sensor.add_new_binary_sensors` (no-op / dedupe / unknown / new)
- `battery_sensors.BatteryBinarySensor` (`is_on` value mapping + `device_info`)
- `battery_sensors.WSBinarySensor` (`is_on` value mapping + `device_info`)
"""
from __future__ import annotations
@ -13,8 +13,8 @@ from typing import Any
import pytest
from custom_components.sws12500.battery_sensors import BatteryBinarySensor
from custom_components.sws12500.battery_sensors_def import BATTERY_BINARY_SENSORS
from custom_components.sws12500.battery_sensors import WSBinarySensor
from custom_components.sws12500.battery_sensors_def import BATTERY_BINARY_SENSORS, WSLINK_BINARY_SENSORS
from custom_components.sws12500.binary_sensor import add_new_binary_sensors, async_setup_entry
from custom_components.sws12500.const import CH2_BATTERY, DOMAIN, INDOOR_BATTERY, OUTSIDE_BATTERY, SENSORS_TO_LOAD
from custom_components.sws12500.data import SWSRuntimeData
@ -80,10 +80,10 @@ async def test_setup_creates_entities_for_battery_keys(hass):
# Callback + description map persisted for dynamic entity creation.
assert runtime.add_binary_entities is add_entities
assert set(runtime.binary_descriptions.keys()) == {d.key for d in BATTERY_BINARY_SENSORS}
assert set(runtime.binary_descriptions.keys()) == {d.key for d in WSLINK_BINARY_SENSORS}
# Entities created for the requested battery keys only.
assert all(isinstance(e, BatteryBinarySensor) for e in captured)
assert all(isinstance(e, WSBinarySensor) for e in captured)
created_keys = {e.entity_description.key for e in captured}
assert created_keys == {OUTSIDE_BATTERY, INDOOR_BATTERY}
@ -136,7 +136,7 @@ def test_add_new_ignores_already_added_keys(hass):
entry, _coordinator, runtime = _make_entry()
captured, add_entities = _capture_add_entities()
runtime.add_binary_entities = add_entities
runtime.binary_descriptions = {d.key: d for d in BATTERY_BINARY_SENSORS}
runtime.binary_descriptions = {d.key: d for d in WSLINK_BINARY_SENSORS}
runtime.added_binary_keys = {OUTSIDE_BATTERY}
add_new_binary_sensors(hass, entry, [OUTSIDE_BATTERY])
@ -150,7 +150,7 @@ def test_add_new_ignores_unknown_keys(hass):
entry, _coordinator, runtime = _make_entry()
captured, add_entities = _capture_add_entities()
runtime.add_binary_entities = add_entities
runtime.binary_descriptions = {d.key: d for d in BATTERY_BINARY_SENSORS}
runtime.binary_descriptions = {d.key: d for d in WSLINK_BINARY_SENSORS}
add_new_binary_sensors(hass, entry, ["totally_unknown_key"])
@ -162,7 +162,7 @@ def test_add_new_adds_new_known_keys(hass):
entry, coordinator, runtime = _make_entry()
captured, add_entities = _capture_add_entities()
runtime.add_binary_entities = add_entities
runtime.binary_descriptions = {d.key: d for d in BATTERY_BINARY_SENSORS}
runtime.binary_descriptions = {d.key: d for d in WSLINK_BINARY_SENSORS}
# Mix of new known, unknown, and a key we'll mark already-added.
runtime.added_binary_keys = {INDOOR_BATTERY}
@ -172,12 +172,12 @@ def test_add_new_adds_new_known_keys(hass):
created_keys = {e.entity_description.key for e in captured}
assert created_keys == {OUTSIDE_BATTERY, CH2_BATTERY}
assert all(isinstance(e, BatteryBinarySensor) for e in captured)
assert all(isinstance(e, WSBinarySensor) for e in captured)
assert all(e.coordinator is coordinator for e in captured)
assert runtime.added_binary_keys == {INDOOR_BATTERY, OUTSIDE_BATTERY, CH2_BATTERY}
# --- BatteryBinarySensor ---------------------------------------------------
# --- WSBinarySensor ---------------------------------------------------
@pytest.mark.parametrize(
@ -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
@ -195,22 +197,35 @@ def test_add_new_adds_new_known_keys(hass):
)
def test_is_on_value_mapping(raw, expected):
coordinator = _CoordinatorStub({OUTSIDE_BATTERY: raw})
sensor = BatteryBinarySensor(coordinator, _desc_for(OUTSIDE_BATTERY))
sensor = WSBinarySensor(coordinator, _desc_for(OUTSIDE_BATTERY))
assert sensor.is_on is 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 = BatteryBinarySensor(coordinator, _desc_for(OUTSIDE_BATTERY))
sensor = WSBinarySensor(coordinator, _desc_for(OUTSIDE_BATTERY))
assert sensor.is_on is None
def test_device_info():
coordinator = _CoordinatorStub()
sensor = BatteryBinarySensor(coordinator, _desc_for(OUTSIDE_BATTERY))
sensor = WSBinarySensor(coordinator, _desc_for(OUTSIDE_BATTERY))
info = sensor.device_info
assert info["name"] == "Weather Station SWS 12500"

View File

@ -233,6 +233,35 @@ async def test_config_flow_user_invalid_credentials_match(
assert result2["errors"]["base"] == "valid_credentials_match"
@pytest.mark.parametrize(
("user_input", "field", "error"),
[
({API_ID: "", API_KEY: "ok_key", WSLINK: False, DEV_DBG: False}, API_ID, "valid_credentials_api"),
({API_ID: "ok_id", API_KEY: "", WSLINK: False, DEV_DBG: False}, API_KEY, "valid_credentials_key"),
],
)
@pytest.mark.asyncio
async def test_config_flow_user_rejects_empty_pws_credentials(
hass,
enable_custom_integrations,
user_input,
field,
error,
) -> None:
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
form = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={"next_step_id": "pws"}
)
result2 = await hass.config_entries.flow.async_configure(form["flow_id"], user_input=user_input)
assert result2["type"] == "form"
assert result2["step_id"] == "pws"
assert result2["errors"][field] == error
@pytest.mark.asyncio
async def test_options_flow_init_menu(hass, enable_custom_integrations) -> None:
"""Options flow shows menu with expected steps."""
@ -475,6 +504,16 @@ async def test_options_flow_ecowitt_uses_get_url_placeholders_and_webhook_defaul
assert placeholders["port"] == "8123"
assert placeholders["webhook_id"] # generated
bad = await hass.config_entries.options.async_configure(
init["flow_id"],
user_input={
ECOWITT_WEBHOOK_ID: "",
ECOWITT_ENABLED: True,
},
)
assert bad["type"] == "form"
assert bad["errors"][ECOWITT_WEBHOOK_ID] == "ecowitt_webhook_required"
done = await hass.config_entries.options.async_configure(
init["flow_id"],
user_input={
@ -594,6 +633,16 @@ async def test_config_flow_ecowitt_initial_setup(hass, enable_custom_integration
assert placeholders["url"] == "example.local"
assert placeholders["webhook_id"]
bad = await hass.config_entries.flow.async_configure(
form["flow_id"],
user_input={
ECOWITT_WEBHOOK_ID: "",
ECOWITT_ENABLED: True,
},
)
assert bad["type"] == "form"
assert bad["errors"][ECOWITT_WEBHOOK_ID] == "ecowitt_webhook_required"
done = await hass.config_entries.flow.async_configure(
form["flow_id"],
user_input={
@ -650,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"}

View File

@ -5,7 +5,7 @@ These tests rely on `pytest-homeassistant-custom-component` to provide:
- `MockConfigEntry` helper for config entries
They validate that the integration can set up a config entry and that the
coordinator is created and stored in `hass.data`.
coordinator is stored on `entry.runtime_data`.
Note:
This integration registers aiohttp routes via `hass.http.app.router`. In this
@ -46,7 +46,7 @@ def config_entry() -> MockConfigEntry:
async def test_async_setup_entry_creates_runtime_state(
hass, config_entry: MockConfigEntry, monkeypatch
):
"""Setting up a config entry should succeed and populate hass.data."""
"""Setting up a config entry should succeed and populate runtime state."""
config_entry.add_to_hass(hass)
# `async_setup_entry` calls `register_path`, which needs `hass.http`.

View File

@ -24,6 +24,7 @@ from custom_components.sws12500.const import (
from custom_components.sws12500.coordinator import IncorrectDataError, WeatherDataUpdateCoordinator
from custom_components.sws12500.data import SWSRuntimeData
from custom_components.sws12500.health_coordinator import HealthCoordinator
from custom_components.sws12500.routes import unregistered
ECOWITT_PATH = ECOWITT_URL_PREFIX + "/{webhook_id}"
@ -309,6 +310,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,
@ -369,6 +397,96 @@ async def test_async_unload_entry_returns_true_on_success(hass_with_http):
hass_with_http.config_entries.async_unload_platforms.assert_awaited_once()
@pytest.mark.asyncio
async def test_async_unload_entry_deactivates_shared_routes(hass_with_http):
entry = MockConfigEntry(domain=DOMAIN, data={}, options={API_ID: "id", API_KEY: "key"})
entry.add_to_hass(hass_with_http)
coordinator = WeatherDataUpdateCoordinator(hass_with_http, entry)
coordinator_health = HealthCoordinator(hass_with_http, entry)
register_path(hass_with_http, coordinator, coordinator_health, entry)
entry.runtime_data = SWSRuntimeData(
coordinator=coordinator,
health_coordinator=coordinator_health,
last_options=dict(entry.options),
)
routes = hass_with_http.data[DOMAIN]["routes"]
assert routes.path_enabled(DEFAULT_URL) is True
hass_with_http.config_entries.async_unload_platforms = AsyncMock(return_value=True)
ok = await async_unload_entry(hass_with_http, entry)
assert ok is True
assert routes.path_enabled(DEFAULT_URL) is False
assert getattr(entry, "runtime_data", None) is None
response = await routes.dispatch(SimpleNamespace(method="GET", path=DEFAULT_URL))
assert response.status == 503
@pytest.mark.asyncio
async def test_async_remove_entry_releases_dispatcher_references(hass_with_http):
"""Unload keeps the observer on purpose; removal has to let it go.
The dispatcher outlives the entry in `hass.data`, so a removed entry whose
coordinators are still referenced there stays alive for the rest of the process.
"""
from custom_components.sws12500 import async_remove_entry
entry = MockConfigEntry(domain=DOMAIN, data={}, options={API_ID: "id", API_KEY: "key"})
entry.add_to_hass(hass_with_http)
coordinator = WeatherDataUpdateCoordinator(hass_with_http, entry)
coordinator_health = HealthCoordinator(hass_with_http, entry)
register_path(hass_with_http, coordinator, coordinator_health, entry)
routes = hass_with_http.data[DOMAIN]["routes"]
routes.deactivate()
# Unload deliberately keeps the observer so mid-reload ingress is still recorded.
assert routes._ingress_observer is not None
await async_remove_entry(hass_with_http, entry)
assert routes._ingress_observer is None
assert all(route.handler is unregistered for route in routes.routes.values())
@pytest.mark.asyncio
async def test_reload_after_unload_still_rebinds_the_observer(hass_with_http, monkeypatch):
"""Release must not be reached on the reload path."""
entry = MockConfigEntry(domain=DOMAIN, data={}, options={API_ID: "id", API_KEY: "key"})
entry.add_to_hass(hass_with_http)
coordinator = WeatherDataUpdateCoordinator(hass_with_http, entry)
coordinator_health = HealthCoordinator(hass_with_http, entry)
register_path(hass_with_http, coordinator, coordinator_health, entry)
routes = hass_with_http.data[DOMAIN]["routes"]
first_observer = routes._ingress_observer
_mock_health_first_refresh(monkeypatch)
monkeypatch.setattr(
hass_with_http.config_entries,
"async_unload_platforms",
AsyncMock(return_value=True),
)
monkeypatch.setattr(
hass_with_http.config_entries,
"async_forward_entry_setups",
AsyncMock(return_value=True),
)
await async_unload_entry(hass_with_http, entry)
assert await async_setup_entry(hass_with_http, entry) is True
assert routes.active is True
assert routes._ingress_observer is not None
assert routes._ingress_observer != first_observer
assert routes.path_enabled(HEALTH_URL) is True
@pytest.mark.asyncio
async def test_async_unload_entry_returns_false_on_failure(hass_with_http):
entry = MockConfigEntry(domain=DOMAIN, data={}, options={API_ID: "id", API_KEY: "key"})

View File

@ -0,0 +1,403 @@
"""Forwarding an Ecowitt payload to Pocasi Meteo.
Their integration notes are specific, and every detail here is load-bearing:
- Ecowitt is accepted **only as a POST** in the Ecowitt protocol; the payload is
forwarded verbatim rather than translated into PWS field names.
- The endpoint is ``/ws_ecowitt/ws.php`` (not the PWS one).
- The account credentials go in the **query string**, and the password parameter is
``PAS`` - not ``PASS`` or ``PASSWORD`` as on the PWS endpoint.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import timedelta
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock
import pytest
from custom_components.sws12500.const import (
DEFAULT_URL,
POCASI_CZ_API_ID,
POCASI_CZ_API_KEY,
POCASI_CZ_ECOWITT_URL,
POCASI_CZ_ENABLED,
POCASI_CZ_LOGGER_ENABLED,
POCASI_CZ_SEND_INTERVAL,
POCASI_CZ_URL,
)
from custom_components.sws12500.pocasti_cz import PocasiPush
from homeassistant.util import dt as dt_util
# A representative Ecowitt POST body, including the metadata a station really sends.
ECOWITT_PAYLOAD: dict[str, str] = {
"PASSKEY": "A1B2C3D4E5F6",
"stationtype": "GW1000B_V1.6.8",
"dateutc": "2026-07-26 01:09:02",
"tempinf": "53.4",
"humidityin": "76",
"baromrelin": "28.792",
"baromabsin": "28.792",
"tempf": "43.2",
"humidity": "40",
"dewpointf": "20.3",
"winddir": "119",
"windspeedmph": "6.9",
"windgustmph": "9.7",
"solarradiation": "7.0",
"uv": "0",
"dailyrainin": "3.55",
"hourlyrainin": "2.08",
"model": "GW1000",
"freq": "868M",
}
@dataclass(slots=True)
class _FakeResponse:
status: int = 200
text_value: str = ""
async def text(self) -> str:
return self.text_value
async def __aenter__(self) -> "_FakeResponse":
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
return None
@dataclass
class _RecordingSession:
"""Records how the request was made: verb, url, query params and body."""
response: _FakeResponse = field(default_factory=_FakeResponse)
calls: list[dict[str, Any]] = field(default_factory=list)
def get(self, url: str, *, params: dict[str, Any] | None = None, **kw: Any):
self.calls.append(
{
"verb": "GET",
"url": url,
"params": dict(params or {}),
"body": kw.get("data"),
"timeout": kw.get("timeout"),
}
)
return self.response
def post(self, url: str, *, params: dict[str, Any] | None = None, data: Any = None, **kw: Any):
self.calls.append(
{
"verb": "POST",
"url": url,
"params": dict(params or {}),
"body": data,
"timeout": kw.get("timeout"),
}
)
return self.response
def _make_entry(*, api_id: str = "myid", api_key: str = "mykey") -> Any:
return SimpleNamespace(
options={
POCASI_CZ_API_ID: api_id,
POCASI_CZ_API_KEY: api_key,
POCASI_CZ_ENABLED: True,
POCASI_CZ_LOGGER_ENABLED: False,
POCASI_CZ_SEND_INTERVAL: 30,
},
entry_id="entry",
)
@pytest.fixture
def hass():
return SimpleNamespace()
@pytest.fixture(autouse=True)
def notify(monkeypatch):
"""Capture the disable notification; the stub `hass` cannot serve the real one."""
created = MagicMock()
monkeypatch.setattr(
"custom_components.sws12500.pocasti_cz.persistent_notification.async_create", created
)
return created
@pytest.fixture
def pusher(monkeypatch, hass):
"""A PocasiPush wired to a recording session and allowed to send immediately."""
session = _RecordingSession()
monkeypatch.setattr(
"custom_components.sws12500.pocasti_cz.async_get_clientsession",
lambda _h: session,
)
monkeypatch.setattr("custom_components.sws12500.pocasti_cz.anonymize", lambda d: d)
pp = PocasiPush(hass, _make_entry())
pp.next_update = dt_util.utcnow() - timedelta(seconds=1)
return pp, session
# ---------------------------------------------------------------------------
# Request shape
# ---------------------------------------------------------------------------
async def test_ecowitt_is_posted_not_get(pusher) -> None:
"""Their server accepts the Ecowitt protocol only via POST."""
pp, session = pusher
await pp.push_data_to_server(ECOWITT_PAYLOAD, "ECOWITT")
assert len(session.calls) == 1
assert session.calls[0]["verb"] == "POST"
async def test_ecowitt_uses_its_own_endpoint(pusher) -> None:
pp, session = pusher
await pp.push_data_to_server(ECOWITT_PAYLOAD, "ECOWITT")
assert session.calls[0]["url"] == f"{POCASI_CZ_URL}{POCASI_CZ_ECOWITT_URL}"
assert session.calls[0]["url"] != f"{POCASI_CZ_URL}{DEFAULT_URL}"
async def test_credentials_go_in_the_query_string_as_id_and_pas(pusher) -> None:
"""`PAS`, not `PASS`/`PASSWORD` - the PWS spelling is rejected here."""
pp, session = pusher
await pp.push_data_to_server(ECOWITT_PAYLOAD, "ECOWITT")
params = session.calls[0]["params"]
assert params == {"ID": "myid", "PAS": "mykey"}
assert "PASS" not in params
assert "PASSWORD" not in params
async def test_station_payload_is_forwarded_verbatim(pusher) -> None:
"""A full forward: what the station sent is what the server receives."""
pp, session = pusher
await pp.push_data_to_server(ECOWITT_PAYLOAD, "ECOWITT")
assert session.calls[0]["body"] == ECOWITT_PAYLOAD
async def test_credentials_are_not_injected_into_the_body(pusher) -> None:
"""Only the query string carries our credentials; the body stays the station's."""
pp, session = pusher
await pp.push_data_to_server(ECOWITT_PAYLOAD, "ECOWITT")
body = session.calls[0]["body"]
for key in ("ID", "PAS", "PASSWORD", "wsid", "wspw"):
assert key not in body
async def test_ecowitt_post_is_bounded_by_a_timeout(pusher) -> None:
"""The POST is awaited inside the station's own request, so it must be bounded.
Home Assistant's shared session sets no timeout, leaving aiohttp's 5 minute
default - long past the point where the station gives up waiting for us.
"""
from custom_components.sws12500.const import FORWARD_TIMEOUT
pp, session = pusher
await pp.push_data_to_server(ECOWITT_PAYLOAD, "ECOWITT")
timeout = session.calls[0]["timeout"]
assert timeout is not None
assert timeout.total == FORWARD_TIMEOUT
async def test_caller_payload_is_not_mutated(pusher) -> None:
"""The same dict is handed to Windy and to the coordinator afterwards."""
pp, _ = pusher
original = dict(ECOWITT_PAYLOAD)
await pp.push_data_to_server(ECOWITT_PAYLOAD, "ECOWITT")
assert ECOWITT_PAYLOAD == original
# ---------------------------------------------------------------------------
# The PWS / WSLink modes keep their existing shape
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
("mode", "path", "id_key", "pw_key"),
[("WU", DEFAULT_URL, "ID", "PASSWORD"), ("WSLINK", "/data/upload.php", "wsid", "wspw")],
)
async def test_legacy_modes_still_use_get_with_inline_credentials(pusher, mode, path, id_key, pw_key) -> None:
pp, session = pusher
await pp.push_data_to_server({"tempf": "43.2"}, mode)
call = session.calls[0]
assert call["verb"] == "GET"
assert call["url"] == f"{POCASI_CZ_URL}{path}"
assert call["params"][id_key] == "myid"
assert call["params"][pw_key] == "mykey"
# ---------------------------------------------------------------------------
# Shared behaviour still applies to the new mode
# ---------------------------------------------------------------------------
async def test_ecowitt_respects_the_send_interval(monkeypatch, hass) -> None:
session = _RecordingSession()
monkeypatch.setattr(
"custom_components.sws12500.pocasti_cz.async_get_clientsession",
lambda _h: session,
)
pp = PocasiPush(hass, _make_entry())
pp.next_update = dt_util.utcnow() + timedelta(seconds=999)
await pp.push_data_to_server(ECOWITT_PAYLOAD, "ECOWITT")
assert session.calls == []
assert pp.last_status == "rate_limited_local"
@pytest.mark.parametrize("missing", ["api_id", "api_key"])
async def test_ecowitt_needs_credentials(monkeypatch, hass, missing) -> None:
session = _RecordingSession()
monkeypatch.setattr(
"custom_components.sws12500.pocasti_cz.async_get_clientsession",
lambda _h: session,
)
pp = PocasiPush(hass, _make_entry(**{missing: ""}))
pp.next_update = dt_util.utcnow() - timedelta(seconds=1)
await pp.push_data_to_server(ECOWITT_PAYLOAD, "ECOWITT")
assert session.calls == []
assert pp.last_status == "config_error"
async def test_ecowitt_auth_error_disables_forwarding(monkeypatch, hass) -> None:
session = _RecordingSession(response=_FakeResponse(status=401))
monkeypatch.setattr(
"custom_components.sws12500.pocasti_cz.async_get_clientsession",
lambda _h: session,
)
monkeypatch.setattr("custom_components.sws12500.pocasti_cz.anonymize", lambda d: d)
entry = _make_entry()
async def _apply(_hass, _entry, key, value):
entry.options[key] = value
return True
monkeypatch.setattr("custom_components.sws12500.pocasti_cz.update_options", MagicMock(side_effect=_apply))
pp = PocasiPush(hass, entry)
pp.next_update = dt_util.utcnow() - timedelta(seconds=1)
await pp.push_data_to_server(ECOWITT_PAYLOAD, "ECOWITT")
assert pp.last_status == "auth_error"
assert pp.enabled is False
# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
def test_pas_is_masked_for_logging() -> None:
"""`PAS` is a credential; the debug log must not print it."""
from custom_components.sws12500.utils import anonymize
assert anonymize({"ID": "myid", "PAS": "mykey"}) == {"ID": "***", "PAS": "***"}
# ---------------------------------------------------------------------------
# Windy takes the same payload translated to the field names it accepts
#
# Windy has no Ecowitt endpoint, so `baromrelin`, `dewpointf`, `tempinf`,
# `humidityin` and `hourlyrainin` would not be understood there.
# ---------------------------------------------------------------------------
def test_ecowitt_to_windy_renames_the_diverging_fields() -> None:
from custom_components.sws12500.utils import remap_ecowitt_to_windy
translated = remap_ecowitt_to_windy(ECOWITT_PAYLOAD)
assert translated["dewptf"] == "20.3"
assert translated["baromin"] == "28.792"
assert translated["indoortempf"] == "53.4"
assert translated["indoorhumidity"] == "76"
assert translated["rainin"] == "2.08" # WU `rainin` is the past hour
# The Ecowitt spellings must be gone, not merely duplicated.
for gone in ("baromrelin", "tempinf", "humidityin", "hourlyrainin", "dewpointf"):
assert gone not in translated
def test_ecowitt_to_windy_keeps_the_shared_names() -> None:
from custom_components.sws12500.utils import remap_ecowitt_to_windy
translated = remap_ecowitt_to_windy(ECOWITT_PAYLOAD)
for same in ("tempf", "humidity", "windspeedmph", "windgustmph", "winddir", "solarradiation", "dailyrainin"):
assert translated[same] == ECOWITT_PAYLOAD[same]
def test_ecowitt_to_windy_drops_device_metadata() -> None:
"""Allowlist: station metadata has no meaning upstream and must not be forwarded."""
from custom_components.sws12500.utils import remap_ecowitt_to_windy
translated = remap_ecowitt_to_windy(ECOWITT_PAYLOAD)
for junk in ("PASSKEY", "stationtype", "model", "freq", "baromabsin"):
assert junk not in translated
def test_ecowitt_to_windy_ignores_unknown_future_fields() -> None:
"""A denylist would forward whatever a future firmware starts sending."""
from custom_components.sws12500.utils import remap_ecowitt_to_windy
translated = remap_ecowitt_to_windy({**ECOWITT_PAYLOAD, "some_new_sensor_v9": "42"})
assert "some_new_sensor_v9" not in translated
def test_ecowitt_to_windy_handles_a_partial_payload() -> None:
from custom_components.sws12500.utils import remap_ecowitt_to_windy
assert remap_ecowitt_to_windy({"tempf": "43.2"}) == {"tempf": "43.2"}
assert remap_ecowitt_to_windy({}) == {}
def test_ecowitt_to_windy_keeps_the_windy_uv_spelling() -> None:
"""Windy documents the UV index as lowercase `uv`; WU spells it `UV`.
The WSLink converter already emits `uv` on the path that demonstrably works, so
this table has to follow Windy rather than WU - otherwise Ecowitt users silently
lose the UV index upstream. See `test_both_windy_converters_agree_on_uv`.
"""
from custom_components.sws12500.utils import remap_ecowitt_to_windy
translated = remap_ecowitt_to_windy(ECOWITT_PAYLOAD)
assert translated["uv"] == "0"
assert "UV" not in translated
def test_ecowitt_to_windy_produces_names_the_wu_pipeline_knows() -> None:
"""The translated names must be the ones a real PWS station sends.
That is what makes the Windy forward work: it is the already-proven path, so the
output is pinned against the integration's own WU parser rather than guessed. `uv`
is the documented exception - Windy's own spelling, see the table's comment.
"""
from custom_components.sws12500.const import REMAP_ITEMS
from custom_components.sws12500.utils import remap_ecowitt_to_windy
translated = remap_ecowitt_to_windy(ECOWITT_PAYLOAD)
unknown = set(translated) - set(REMAP_ITEMS) - {"dateutc", "uv"}
assert not unknown, f"field names neither protocol defines: {sorted(unknown)}"

View File

@ -49,8 +49,8 @@ class _FakeSession:
self._exc = exc
self.calls: list[dict[str, Any]] = []
def get(self, url: str, *, params: dict[str, Any] | None = None):
self.calls.append({"url": url, "params": dict(params or {})})
def get(self, url: str, *, params: dict[str, Any] | None = None, timeout: Any = None):
self.calls.append({"url": url, "params": dict(params or {}), "timeout": timeout})
if self._exc is not None:
raise self._exc
assert self._response is not None
@ -101,6 +101,36 @@ def hass():
return SimpleNamespace()
@pytest.fixture(autouse=True)
def notify(monkeypatch):
"""Capture the disable notification; the stub `hass` cannot serve the real one."""
created = MagicMock()
monkeypatch.setattr(
"custom_components.sws12500.pocasti_cz.persistent_notification.async_create", created
)
return created
@pytest.mark.parametrize("stored", ["", "abc", None, [], 0])
def test_unreadable_send_interval_falls_back_to_the_default(hass, stored):
"""A corrupted option must not take the whole entry setup down.
`PocasiPush` is constructed from the coordinator during `async_setup_entry`, so a
raise here means the integration fails to load rather than forwarding late.
"""
from custom_components.sws12500.const import POCASI_CZ_SEND_DEFAULT
entry = _make_entry()
entry.options[POCASI_CZ_SEND_INTERVAL] = stored
assert PocasiPush(hass, entry)._interval == POCASI_CZ_SEND_DEFAULT
def test_a_stored_send_interval_is_honoured(hass):
entry = _make_entry(interval=45)
assert PocasiPush(hass, entry)._interval == 45
@pytest.mark.asyncio
async def test_push_data_to_server_missing_api_id_returns_early(monkeypatch, hass):
entry = _make_entry(api_id=None, api_key="key")
@ -339,14 +369,15 @@ async def test_push_data_to_server_client_error_increments_and_disables_after_th
@pytest.mark.asyncio
async def test_push_data_to_server_timeout_is_handled_like_client_error(
monkeypatch, hass
):
async def test_push_data_to_server_timeout_is_caught_and_not_counted(monkeypatch, hass):
"""A network timeout must not escape into the webhook handler.
`TimeoutError` is not a subclass of `ClientError`, so it used to propagate out
of `push_data_to_server`, through the awaiting coordinator, and answer the
station with HTTP 500 - even though the measured data was already stored.
It must not spend the retry budget either: a slow upstream is transient, so the
next push simply tries again.
"""
entry = _make_entry()
pp = PocasiPush(hass, entry)
@ -366,12 +397,121 @@ async def test_push_data_to_server_timeout_is_handled_like_client_error(
pp.next_update = dt_util.utcnow() - timedelta(seconds=1)
await pp.push_data_to_server({"x": 1}, "WU")
assert pp.last_status == "client_error"
assert pp.last_status == "timeout"
assert pp.last_error == "TimeoutError"
assert pp.invalid_response_count == 1
assert pp.invalid_response_count == 0
assert pp.enabled is True
@pytest.mark.asyncio
async def test_repeated_timeouts_never_disable_pocasi(monkeypatch, hass, notify):
"""Two minutes of a merely slow server must not turn forwarding off for good.
With a 30 second send interval, counting timeouts would spend the whole retry
budget on a slowdown that recovers by itself.
"""
entry = _make_entry()
pp = PocasiPush(hass, entry)
update_options = _write_through_update_options(entry)
monkeypatch.setattr(
"custom_components.sws12500.pocasti_cz.update_options", update_options
)
session = _FakeSession(exc=TimeoutError("timed out"))
monkeypatch.setattr(
"custom_components.sws12500.pocasti_cz.async_get_clientsession",
lambda _h: session,
)
for _ in range(POCASI_CZ_MAX_RETRIES + 2):
pp.next_update = dt_util.utcnow() - timedelta(seconds=1)
await pp.push_data_to_server({"x": 1}, "WU")
assert pp.invalid_response_count == 0
assert pp.enabled is True
update_options.assert_not_awaited()
notify.assert_not_called()
@pytest.mark.asyncio
async def test_timeout_does_not_reset_an_existing_client_error_budget(monkeypatch, hass):
"""A timeout is ignored by the counter, not a substitute for a successful send."""
entry = _make_entry()
pp = PocasiPush(hass, entry)
pp.invalid_response_count = 2
pp.next_update = dt_util.utcnow() - timedelta(seconds=1)
session = _FakeSession(exc=TimeoutError("timed out"))
monkeypatch.setattr(
"custom_components.sws12500.pocasti_cz.async_get_clientsession",
lambda _h: session,
)
await pp.push_data_to_server({"x": 1}, "WU")
assert pp.invalid_response_count == 2
@pytest.mark.asyncio
async def test_disabling_pocasi_notifies_the_user(monkeypatch, hass, notify):
"""Forwarding switching itself off must be visible, not just a log line.
Windy already raises a persistent notification; without one here the user only
finds out when data stops arriving upstream.
"""
entry = _make_entry()
pp = PocasiPush(hass, entry)
pp.next_update = dt_util.utcnow() - timedelta(seconds=1)
session = _FakeSession(response=_FakeResponse("", status=401))
monkeypatch.setattr(
"custom_components.sws12500.pocasti_cz.async_get_clientsession",
lambda _h: session,
)
monkeypatch.setattr("custom_components.sws12500.pocasti_cz.anonymize", lambda d: d)
monkeypatch.setattr(
"custom_components.sws12500.pocasti_cz.update_options",
_write_through_update_options(entry),
)
monkeypatch.setattr("custom_components.sws12500.pocasti_cz._LOGGER.critical", MagicMock())
await pp.push_data_to_server({"x": 1}, "WU")
assert pp.enabled is False
notify.assert_called_once()
args = notify.call_args.args
assert args[0] is hass
assert POCASI_INVALID_KEY in args[1]
@pytest.mark.asyncio
async def test_push_data_to_server_bounds_the_request(monkeypatch, hass):
"""The send must carry an explicit timeout.
Home Assistant's shared session sets none, so aiohttp's 5 minute default would
hold the station's own webhook open long past the point where it gives up - and
the TimeoutError branch above could never run in time to matter.
"""
from custom_components.sws12500.const import FORWARD_TIMEOUT
pp = PocasiPush(hass, _make_entry())
session = _FakeSession(response=_FakeResponse(status=200, text_value="OK"))
monkeypatch.setattr(
"custom_components.sws12500.pocasti_cz.async_get_clientsession",
lambda _h: session,
)
pp.next_update = dt_util.utcnow() - timedelta(seconds=1)
await pp.push_data_to_server({"tempf": "68"}, "WU")
timeout = session.calls[0]["timeout"]
assert timeout is not None
assert timeout.total == FORWARD_TIMEOUT
assert 0 < timeout.total <= 30
def test_verify_response_logs_debug_when_logger_enabled(monkeypatch, hass):
entry = _make_entry(logger=True)
pp = PocasiPush(hass, entry)

View File

@ -218,7 +218,7 @@ async def test_received_data_forwards_to_windy_when_enabled(hass, monkeypatch):
coordinator.windy.push_data_to_windy.assert_awaited_once()
args, _kwargs = coordinator.windy.push_data_to_windy.await_args
assert isinstance(args[0], dict) # raw data dict
assert args[1] is False # wslink flag
assert args[1] == "pws" # protocol the payload arrived in
@pytest.mark.asyncio
@ -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

View File

@ -262,10 +262,11 @@ async def test_received_ecowitt_success_full_pipeline_with_health_autodiscovery_
coordinator.windy.push_data_to_windy.assert_awaited_once()
w_args, _ = coordinator.windy.push_data_to_windy.await_args
assert isinstance(w_args[0], dict)
assert w_args[1] is False
assert w_args[1] == "ecowitt"
coordinator.pocasi.push_data_to_server.assert_awaited_once()
p_args, _ = coordinator.pocasi.push_data_to_server.await_args
assert p_args[1] == "WU"
# Pocasi Meteo has a dedicated Ecowitt endpoint; the payload is forwarded as-is.
assert p_args[1] == "ECOWITT"
# Health branches.
health.update_ingress_result.assert_called_once()
@ -571,3 +572,39 @@ async def test_received_ecowitt_returns_503_when_runtime_data_missing(hass):
_EcowittRequestStub(match_info={"webhook_id": "x"})
) # type: ignore[arg-type]
assert resp.status == 503
async def test_each_forwarder_is_told_which_protocol_the_payload_is(hass, monkeypatch):
"""Routing contract: Windy converts to PWS itself, Pocasi wants Ecowitt verbatim.
Both forwarders get the untouched station payload; what differs is the protocol
they are told it arrived in. The conversion itself lives in `WindyPush._to_pws`
and is verified end to end in test_windy_push.py, not at this call site.
"""
entry = _make_entry(
ecowitt_enabled=True,
ecowitt_webhook_id="hook",
windy_enabled=True,
pocasi_enabled=True,
health=_make_health_stub(),
)
coordinator = WeatherDataUpdateCoordinator(hass, entry)
coordinator.ecowitt_bridge.process_payload = AsyncMock(return_value={"outside_temp": "68"})
monkeypatch.setattr("custom_components.sws12500.coordinator.check_disabled", lambda *_: None)
coordinator.async_set_updated_data = MagicMock()
coordinator.windy.push_data_to_windy = AsyncMock()
coordinator.pocasi.push_data_to_server = AsyncMock()
station_payload = {"PASSKEY": "ABC123", "model": "GW1000", "tempf": "68", "baromrelin": "29.9"}
request = _EcowittRequestStub(match_info={"webhook_id": "hook"}, post_data=dict(station_payload))
await coordinator.received_ecowitt_data(request) # type: ignore[arg-type]
windy_payload, windy_source = coordinator.windy.push_data_to_windy.await_args[0]
assert windy_source == "ecowitt"
assert windy_payload == station_payload
pocasi_payload, pocasi_mode = coordinator.pocasi.push_data_to_server.await_args[0]
assert pocasi_mode == "ECOWITT"
assert pocasi_payload == station_payload

View File

@ -80,3 +80,35 @@ async def test_dispatch_resolves_via_canonical_resource(routes: Routes) -> None:
resp = await routes.dispatch(request) # type: ignore[arg-type]
assert resp.status == 200
async def test_deactivated_dispatcher_reports_unloaded() -> None:
"""After unload the routes outlive the entry, so they must answer 503.
Without this they would call a handler bound to a coordinator whose config entry
is gone.
"""
routes = Routes()
observer = MagicMock()
routes.set_ingress_observer(observer)
routes.add_route("/x", _RouteStub(method="GET"), _handler, enabled=True)
routes.deactivate()
response = await routes.dispatch(_RequestStub(method="GET", path="/x"))
assert response.status == 503
# 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:
routes = Routes()
routes.add_route("/x", _RouteStub(method="GET"), _handler, enabled=True)
assert "Dispatcher enabled" in routes.show_enabled()
routes.deactivate()
assert routes.show_enabled() == "No routes are enabled."

View File

@ -218,3 +218,72 @@ def test_add_new_sensors_noop_when_runtime_data_missing():
"""add_new_sensors is a safe no-op when the entry is unloaded (no runtime_data)."""
entry = SimpleNamespace(entry_id="x", options={}, runtime_data=None)
add_new_sensors(None, entry, keys=["anything"]) # must not raise
# ---------------------------------------------------------------------------
# Probe type decides the humidity device class, once, at entity creation
# ---------------------------------------------------------------------------
def _wslink_desc(key: str):
from custom_components.sws12500.sensors_wslink import SENSOR_TYPES_WSLINK
return next(d for d in SENSOR_TYPES_WSLINK if d.key == key)
@pytest.mark.parametrize(
("channel_type", "expected"),
[("4", "moisture"), ("2", "humidity")],
ids=["soil-probe", "thermo-hygrometer"],
)
def test_channel_humidity_entity_takes_its_class_from_the_probe(channel_type, expected) -> None:
"""A soil probe reports soil moisture, so the entity must not claim 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.config = SimpleNamespace(options={CHANNEL_TYPES: {"t234c1tp": channel_type}})
sensor = WeatherSensor(_wslink_desc(CH2_HUMIDITY), coordinator)
assert sensor.device_class == expected
def test_channel_humidity_entity_falls_back_to_the_description() -> None:
"""No reported probe type leaves the description's own device class in place."""
from types import SimpleNamespace
from unittest.mock import MagicMock
from custom_components.sws12500.const import CH2_HUMIDITY
from custom_components.sws12500.sensor import WeatherSensor
coordinator = MagicMock()
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"

View File

@ -149,9 +149,9 @@ def test_remap_keeps_t9_group_when_connected() -> None:
assert out[OUTSIDE_TEMP] == "11.3"
@pytest.mark.parametrize("conn", [{"t9cn": "0"}, {}], ids=["disconnected", "absent"])
def test_remap_drops_t9_group_when_disconnected_or_absent(conn) -> None:
out = remap_wslink_items({**conn, "t9hcho": "57", "t9voclv": "5", "t9bat": "5", "t1tem": "11.3"})
def test_remap_drops_t9_group_when_disconnected() -> None:
"""An explicit `t9cn=0` means the probe is gone, so its readings must not linger."""
out = remap_wslink_items({"t9cn": "0", "t9hcho": "57", "t9voclv": "5", "t9bat": "5", "t1tem": "11.3"})
assert HCHO not in out
assert VOC not in out
assert T9_BATTERY not in out
@ -159,6 +159,20 @@ def test_remap_drops_t9_group_when_disconnected_or_absent(conn) -> None:
assert out[OUTSIDE_TEMP] == "11.3"
def test_remap_keeps_t9_group_when_the_flag_is_absent() -> None:
"""A missing connection flag is no information, not a disconnection.
Treating absence as "disconnected" would blank the readings of any probe whose
firmware simply does not send the flag - and with `t1cn` now gating the outdoor
probe, that would have meant losing temperature, wind and rain outright.
"""
out = remap_wslink_items({"t9hcho": "57", "t9voclv": "5", "t9bat": "5", "t1tem": "11.3"})
assert out[HCHO] == "57"
assert out[VOC] == "5"
assert out[T9_BATTERY] == "5"
assert out[OUTSIDE_TEMP] == "11.3"
def test_remap_issue_payload_exposes_t9_when_connected() -> None:
out = remap_wslink_items(ISSUE_PAYLOAD)
# t9cn == "1" -> the T9 sensors are exposed

View File

@ -254,6 +254,19 @@ def test_battery_level_handles_none_empty_invalid_and_known_values():
assert battery_level("2") == UnitOfBat.UNKNOWN
def test_battery_level_accepts_the_decimal_spelling():
"""The station sometimes sends integer fields as decimals.
Reading `1.0` as UNKNOWN would make the deprecated ENUM battery sensor contradict
the binary battery entity fed by the very same payload field.
"""
assert battery_level("1.0") == UnitOfBat.NORMAL
assert battery_level("0.0") == UnitOfBat.LOW
assert battery_level(1.0) == UnitOfBat.NORMAL
assert battery_level(" ") == UnitOfBat.UNKNOWN
assert battery_level([]) == UnitOfBat.UNKNOWN
def test_temperature_conversions_round_trip():
# Use a value that is exactly representable in binary-ish floats

View File

@ -30,7 +30,7 @@ class _FakeSession:
def __init__(self, response: _FakeResponse) -> None:
self._response = response
def get(self, url: str, *, params=None, headers=None):
def get(self, url: str, *, params=None, headers=None, timeout=None):
return self._response

View File

@ -13,6 +13,7 @@ from custom_components.sws12500.const import (
PURGE_DATA,
WINDY_ENABLED,
WINDY_LOGGER_ENABLED,
WINDY_MAX_RETRIES,
WINDY_STATION_ID,
WINDY_STATION_PW,
WINDY_UNEXPECTED,
@ -51,9 +52,15 @@ class _FakeSession:
*,
params: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
timeout: Any = None,
):
self.calls.append(
{"url": url, "params": dict(params or {}), "headers": dict(headers or {})}
{
"url": url,
"params": dict(params or {}),
"headers": dict(headers or {}),
"timeout": timeout,
}
)
if self._exc is not None:
raise self._exc
@ -109,6 +116,8 @@ def test_covert_wslink_to_pws_maps_keys(hass):
"t1rainhr": "8",
"t1uvi": "9",
"t1solrad": "10",
"intem": "21.5",
"inhum": "48",
"other": "keep",
}
out = wp._covert_wslink_to_pws(data)
@ -134,9 +143,16 @@ def test_covert_wslink_to_pws_maps_keys(hass):
"t1rainhr",
"t1uvi",
"t1solrad",
"intem",
"inhum",
):
assert k not in out
# Indoor readings are dropped, not renamed: WSLink sends Celsius, while the PWS
# `indoortempf` spelling promises Fahrenheit.
assert "indoortempf" not in out
assert "indoorhumidity" not in out
@pytest.mark.asyncio
async def test_push_data_to_windy_respects_initial_next_update(monkeypatch, hass):
@ -170,7 +186,7 @@ async def test_push_data_to_windy_purges_data_and_sets_auth(monkeypatch, hass):
data = {k: "x" for k in PURGE_DATA}
data.update({"keep": "1"})
ok = await wp.push_data_to_windy(data, wslink=False)
ok = await wp.push_data_to_windy(data, source="pws")
assert ok is True
assert len(session.calls) == 1
@ -199,7 +215,7 @@ async def test_push_data_to_windy_wslink_conversion_applied(monkeypatch, hass):
lambda _h: session,
)
ok = await wp.push_data_to_windy({"t1ws": "1", "t1tem": "2"}, wslink=True)
ok = await wp.push_data_to_windy({"t1ws": "1", "t1tem": "2"}, source="wslink")
assert ok is True
params = session.calls[0]["params"]
assert "wind" in params and params["wind"] == "1"
@ -450,14 +466,15 @@ async def test_push_data_to_windy_client_error_increments_and_disables_after_thr
@pytest.mark.asyncio
async def test_push_data_to_windy_timeout_is_handled_like_client_error(
monkeypatch, hass
):
async def test_push_data_to_windy_timeout_is_caught_and_not_counted(monkeypatch, hass):
"""A network timeout must not escape into the webhook handler.
`TimeoutError` is not a subclass of `ClientError`, so it used to propagate out
of `push_data_to_windy`, through the awaiting coordinator, and answer the
station with HTTP 500 - even though the measured data was already stored.
It must not spend the retry budget either: a slow upstream is transient, so the
next push simply tries again.
"""
entry = _make_entry()
wp = WindyPush(hass, entry)
@ -481,9 +498,61 @@ async def test_push_data_to_windy_timeout_is_handled_like_client_error(
ok = await wp.push_data_to_windy({"a": "b"})
assert ok is True
assert wp.last_status == "client_error"
assert wp.last_status == "timeout"
assert wp.last_error == "TimeoutError"
assert wp.invalid_response_count == 1
assert wp.invalid_response_count == 0
@pytest.mark.asyncio
async def test_repeated_timeouts_never_disable_windy(monkeypatch, hass):
"""15 minutes of a merely slow Windy must not turn forwarding off for good.
Bounding the request made this branch reachable; counting it would let a
transient slowdown burn the whole retry budget with nothing to recover it.
"""
entry = _make_entry()
wp = WindyPush(hass, entry)
update_options = AsyncMock(return_value=True)
monkeypatch.setattr("custom_components.sws12500.windy_func.update_options", update_options)
notify = MagicMock()
monkeypatch.setattr(
"custom_components.sws12500.windy_func.persistent_notification.async_create", notify
)
session = _FakeSession(exc=TimeoutError("timed out"))
monkeypatch.setattr(
"custom_components.sws12500.windy_func.async_get_clientsession",
lambda _h: session,
)
for _ in range(WINDY_MAX_RETRIES + 2):
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
assert await wp.push_data_to_windy({"a": "b"}) is True
assert wp.invalid_response_count == 0
assert wp.enabled is True
update_options.assert_not_awaited()
notify.assert_not_called()
@pytest.mark.asyncio
async def test_timeout_does_not_reset_an_existing_client_error_budget(monkeypatch, hass):
"""A timeout is ignored by the counter, not a substitute for a successful send."""
entry = _make_entry()
wp = WindyPush(hass, entry)
wp.invalid_response_count = 2
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
session = _FakeSession(exc=TimeoutError("timed out"))
monkeypatch.setattr(
"custom_components.sws12500.windy_func.async_get_clientsession",
lambda _h: session,
)
await wp.push_data_to_windy({"a": "b"})
assert wp.invalid_response_count == 2
@pytest.mark.asyncio
@ -517,3 +586,144 @@ async def test_push_data_to_windy_client_error_disable_failure_logs_debug(
assert ok is True
update_options.assert_awaited_once_with(hass, entry, WINDY_ENABLED, False)
dbg.assert_called()
@pytest.mark.asyncio
async def test_push_data_to_windy_ecowitt_conversion_applied(monkeypatch, hass):
"""End to end: an Ecowitt payload must reach Windy in the names it accepts.
Windy has no Ecowitt endpoint, so `baromrelin`, `dewpointf`, `tempinf`,
`humidityin` and `hourlyrainin` would not be understood there.
"""
entry = _make_entry()
wp = WindyPush(hass, entry)
wp.next_update = dt_util.utcnow() - timedelta(minutes=1)
session = _FakeSession(response=_FakeResponse(status=200, text_value="OK"))
monkeypatch.setattr(
"custom_components.sws12500.windy_func.async_get_clientsession",
lambda _h: session,
)
ok = await wp.push_data_to_windy(
{
"PASSKEY": "ABC123",
"stationtype": "GW1000B_V1.6.8",
"model": "GW1000",
"tempf": "68",
"baromrelin": "29.9",
"dewpointf": "50.1",
"uv": "3",
"hourlyrainin": "0.04",
},
source="ecowitt",
)
assert ok is True
params = session.calls[0]["params"]
assert params["tempf"] == "68"
assert params["baromin"] == "29.9"
assert params["dewptf"] == "50.1"
assert params["uv"] == "3"
assert params["rainin"] == "0.04"
# Ecowitt spellings gone, station metadata never forwarded.
for gone in ("baromrelin", "dewpointf", "hourlyrainin", "PASSKEY", "stationtype", "model"):
assert gone not in params
@pytest.mark.asyncio
async def test_push_data_to_windy_purges_after_converting(monkeypatch, hass):
"""PURGE_DATA lists PWS names, so it must be applied *after* the conversion.
Purging first would let a reading that only becomes a purge target once converted
(WSLink `t1solrad` -> `solarradiation`, Ecowitt `tempinf` -> `indoortempf`) reach
Windy anyway.
"""
entry = _make_entry()
wp = WindyPush(hass, entry)
wp.next_update = dt_util.utcnow() - timedelta(minutes=1)
session = _FakeSession(response=_FakeResponse(status=200, text_value="OK"))
monkeypatch.setattr(
"custom_components.sws12500.windy_func.async_get_clientsession",
lambda _h: session,
)
await wp.push_data_to_windy({"t1tem": "6.2", "t1solrad": "7"}, source="wslink")
assert "solarradiation" not in session.calls[0]["params"]
session.calls.clear()
wp.next_update = dt_util.utcnow() - timedelta(minutes=1)
await wp.push_data_to_windy({"tempf": "68", "tempinf": "70.2", "humidityin": "55"}, source="ecowitt")
params = session.calls[0]["params"]
assert "indoortempf" not in params
assert "indoorhumidity" not in params
@pytest.mark.asyncio
async def test_push_data_to_windy_drops_wslink_indoor_readings(monkeypatch, hass):
"""WSLink `intem`/`inhum` must not reach Windy under either spelling.
PURGE_DATA lists the PWS names, so these two used to slip through untouched and
Windy received a raw `intem`/`inhum` pair. The converter drops them instead, which
holds regardless of what PURGE_DATA contains.
"""
entry = _make_entry()
wp = WindyPush(hass, entry)
wp.next_update = dt_util.utcnow() - timedelta(minutes=1)
session = _FakeSession(response=_FakeResponse(status=200, text_value="OK"))
monkeypatch.setattr(
"custom_components.sws12500.windy_func.async_get_clientsession",
lambda _h: session,
)
await wp.push_data_to_windy({"t1tem": "6.2", "intem": "21.5", "inhum": "48"}, source="wslink")
params = session.calls[0]["params"]
assert params["temp"] == "6.2"
for gone in ("intem", "inhum", "indoortempf", "indoorhumidity"):
assert gone not in params
def test_both_windy_converters_agree_on_uv(hass):
"""One UV spelling for Windy, whichever protocol the station speaks.
Windy documents lowercase `uv`; WU spells it `UV`. When the two converters
disagreed, Ecowitt users silently lost the UV index upstream.
"""
from custom_components.sws12500.utils import remap_ecowitt_to_windy
wp = WindyPush(hass, _make_entry())
assert wp._covert_wslink_to_pws({"t1uvi": "3"}) == {"uv": "3"}
assert remap_ecowitt_to_windy({"uv": "3"}) == {"uv": "3"}
@pytest.mark.asyncio
async def test_push_data_to_windy_bounds_the_request(monkeypatch, hass):
"""The send must carry an explicit timeout.
Home Assistant's shared session sets none, so aiohttp's 5 minute default would
hold the station's own webhook open long past the point where it gives up - and
the TimeoutError branch could never run in time to matter.
"""
from custom_components.sws12500.const import FORWARD_TIMEOUT
entry = _make_entry()
wp = WindyPush(hass, entry)
wp.next_update = dt_util.utcnow() - timedelta(minutes=1)
session = _FakeSession(response=_FakeResponse(status=200, text_value="OK"))
monkeypatch.setattr(
"custom_components.sws12500.windy_func.async_get_clientsession",
lambda _h: session,
)
await wp.push_data_to_windy({"tempf": "68"})
timeout = session.calls[0]["timeout"]
assert timeout is not None
assert timeout.total == FORWARD_TIMEOUT
assert 0 < timeout.total <= 30

View File

@ -0,0 +1,322 @@
"""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

View File

@ -151,3 +151,35 @@ def test_wu_platform_still_computes_in_fahrenheit() -> None:
by_key = {d.key: d for d in SENSOR_TYPES_WEATHER_API}
assert by_key[HEAT_INDEX].value_from_data_fn is heat_index
assert by_key[CHILL_INDEX].value_from_data_fn is chill_index
# ---------------------------------------------------------------------------
# A heat index colder than the air is not a heat index
# ---------------------------------------------------------------------------
def test_cold_weather_heat_index_is_clamped_to_ambient() -> None:
"""The NWS step-1 average drifts below the air temperature in the cold.
Unclamped, this exact payload reports 3.92 C under a sensor named "Heat index"
while the station measures 6.2 C.
"""
assert wslink_heat_index(LIVE_PAYLOAD) == 6.2
def test_hot_weather_heat_index_is_left_alone() -> None:
"""The clamp must not touch the range the formula is actually defined for."""
hot = {OUTSIDE_TEMP: "32", OUTSIDE_HUMIDITY: "70"}
computed = wslink_heat_index(hot)
assert computed is not None
assert computed > 32, computed
def test_clamp_applies_to_the_wu_platform_too() -> None:
"""Both platforms share `heat_index`, so neither can drift from the other."""
assert heat_index({OUTSIDE_TEMP: "43.16", OUTSIDE_HUMIDITY: "40"}) == 43.16
assert heat_index({OUTSIDE_TEMP: "6.2", OUTSIDE_HUMIDITY: "40"}, convert=True) == pytest.approx(43.16)
hot_f = heat_index({OUTSIDE_TEMP: "90", OUTSIDE_HUMIDITY: "70"})
assert hot_f is not None and hot_f > 90

View File

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