Compare commits
16 Commits
08e9891925
...
c8345e12bc
| Author | SHA1 | Date |
|---|---|---|
|
|
c8345e12bc | |
|
|
935953f20f | |
|
|
3cdaffc81f | |
|
|
d2ae26eab5 | |
|
|
624de521c4 | |
|
|
4b85d2e393 | |
|
|
83ca9b7f67 | |
|
|
56ea41c143 | |
|
|
c33f9454f7 | |
|
|
2c6b698ca9 | |
|
|
4e4c3b8f7c | |
|
|
7cc3a99abb | |
|
|
0dfdd6c9e9 | |
|
|
2b266f33b4 | |
|
|
2827eac158 | |
|
|
1bb8864426 |
|
|
@ -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 .
|
||||
|
|
|
|||
87
README.md
87
README.md
|
|
@ -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 1–5 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** CH2–CH8 (API CH1–CH7) | temperature and humidity per channel, battery |
|
||||
| **Type5** lightning | distance, time since the last strike, strike counts over 5 min / 30 min / 1 h / 1 day, battery |
|
||||
| **Type6** water leak CH1–CH7 | leak state per channel (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 0–5 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 1–5 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
../dev/custom_components/sws12500
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
|
|
|||
|
|
@ -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)}"
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
Loading…
Reference in New Issue