Compare commits
3 Commits
08e9891925
...
714b8c3a73
| Author | SHA1 | Date |
|---|---|---|
|
|
714b8c3a73 | |
|
|
2827eac158 | |
|
|
1bb8864426 |
|
|
@ -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
|
||||
--------------
|
||||
|
|
@ -99,9 +98,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 +159,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 +195,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
|
||||
|
|
@ -270,4 +271,13 @@ 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:
|
||||
domain_data = hass.data.get(DOMAIN)
|
||||
routes = domain_data.get("routes") if isinstance(domain_data, dict) else None
|
||||
if isinstance(routes, Routes):
|
||||
routes.deactivate()
|
||||
setattr(entry, "runtime_data", None)
|
||||
|
||||
return unload_ok
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@ import voluptuous as vol
|
|||
from yarl import URL
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import selector
|
||||
from homeassistant.helpers.network import NoURLAvailableError, get_url
|
||||
from homeassistant.helpers.network import get_url
|
||||
|
||||
from .conflicts import ERROR_MUTUALLY_EXCLUSIVE
|
||||
from .const import (
|
||||
|
|
@ -42,29 +42,13 @@ from .const import (
|
|||
_PASSWORD_SELECTOR = selector.TextSelector(selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD))
|
||||
|
||||
|
||||
def _is_invalid_credential(value: Any) -> bool:
|
||||
"""Return True when a PWS/WSLink credential is unusable.
|
||||
|
||||
Blank (or whitespace-only) values are as useless as the placeholder strings in
|
||||
`INVALID_CREDENTIALS`: `_validate_credentials` rejects every incoming packet if
|
||||
either option is empty, so the entry would be created but never receive data.
|
||||
"""
|
||||
return not isinstance(value, str) or not value.strip() or value in INVALID_CREDENTIALS
|
||||
def _is_empty(value: Any) -> bool:
|
||||
return not isinstance(value, str) or value == ""
|
||||
|
||||
|
||||
def _ha_url_placeholders(hass: HomeAssistant) -> tuple[str, str]:
|
||||
"""Return (host, port) of the Home Assistant URL for the Ecowitt instructions.
|
||||
|
||||
`get_url` raises `NoURLAvailableError` when HA cannot resolve any of its own
|
||||
URLs. These values are shown as setup instructions only, so fall back to a
|
||||
placeholder instead of letting the config flow abort.
|
||||
"""
|
||||
try:
|
||||
url: URL = URL(get_url(hass))
|
||||
except NoURLAvailableError:
|
||||
return "UNKNOWN", "UNKNOWN"
|
||||
|
||||
return url.host or "UNKNOWN", str(url.port)
|
||||
def _validate_ecowitt_webhook(user_input: dict[str, Any], errors: dict[str, str]) -> None:
|
||||
if user_input.get(ECOWITT_ENABLED) and _is_empty(user_input.get(ECOWITT_WEBHOOK_ID, "")):
|
||||
errors[ECOWITT_WEBHOOK_ID] = "ecowitt_webhook_required"
|
||||
|
||||
|
||||
class ConfigOptionsFlowHandler(OptionsFlow):
|
||||
|
|
@ -185,9 +169,9 @@ class ConfigOptionsFlowHandler(OptionsFlow):
|
|||
# legacy one while Ecowitt is active would corrupt those entities.
|
||||
if self.ecowitt.get(ECOWITT_ENABLED):
|
||||
errors["base"] = ERROR_MUTUALLY_EXCLUSIVE
|
||||
elif _is_invalid_credential(user_input.get(API_ID)):
|
||||
elif user_input[API_ID] in INVALID_CREDENTIALS or _is_empty(user_input.get(API_ID, "")):
|
||||
errors[API_ID] = "valid_credentials_api"
|
||||
elif _is_invalid_credential(user_input.get(API_KEY)):
|
||||
elif user_input[API_KEY] in INVALID_CREDENTIALS or _is_empty(user_input.get(API_KEY, "")):
|
||||
errors[API_KEY] = "valid_credentials_key"
|
||||
elif user_input[API_KEY] == user_input[API_ID]:
|
||||
errors["base"] = "valid_credentials_match"
|
||||
|
|
@ -276,14 +260,16 @@ 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)
|
||||
url: URL = URL(get_url(self.hass))
|
||||
host = url.host or "UNKNOWN"
|
||||
|
||||
ecowitt_schema = {
|
||||
vol.Required(
|
||||
|
|
@ -301,7 +287,7 @@ class ConfigOptionsFlowHandler(OptionsFlow):
|
|||
data_schema=vol.Schema(ecowitt_schema),
|
||||
description_placeholders={
|
||||
"url": host,
|
||||
"port": port,
|
||||
"port": str(url.port),
|
||||
"webhook_id": webhook,
|
||||
},
|
||||
errors=errors,
|
||||
|
|
@ -382,9 +368,9 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
|||
if user_input is None:
|
||||
return self.async_show_form(step_id="pws", data_schema=vol.Schema(self.pws_schema), errors=errors)
|
||||
|
||||
if _is_invalid_credential(user_input.get(API_ID)):
|
||||
if user_input[API_ID] in INVALID_CREDENTIALS or _is_empty(user_input.get(API_ID, "")):
|
||||
errors[API_ID] = "valid_credentials_api"
|
||||
elif _is_invalid_credential(user_input.get(API_KEY)):
|
||||
elif user_input[API_KEY] in INVALID_CREDENTIALS or _is_empty(user_input.get(API_KEY, "")):
|
||||
errors[API_KEY] = "valid_credentials_key"
|
||||
elif user_input[API_KEY] == user_input[API_ID]:
|
||||
errors["base"] = "valid_credentials_match"
|
||||
|
|
@ -405,9 +391,24 @@ 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] = {}
|
||||
|
||||
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_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)
|
||||
url: URL = URL(get_url(self.hass))
|
||||
host = url.host or "UNKNOWN"
|
||||
|
||||
ecowitt_schema = {
|
||||
vol.Required(ECOWITT_WEBHOOK_ID, default=webhook): str,
|
||||
|
|
@ -419,19 +420,11 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
|||
data_schema=vol.Schema(ecowitt_schema),
|
||||
description_placeholders={
|
||||
"url": host,
|
||||
"port": port,
|
||||
"port": str(url.port),
|
||||
"webhook_id": webhook,
|
||||
},
|
||||
errors=errors,
|
||||
)
|
||||
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)
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -180,10 +180,7 @@ class PocasiPush:
|
|||
_LOGGER.critical(POCASI_CZ_UNEXPECTED)
|
||||
await self._disable_pocasi(POCASI_CZ_UNEXPECTED)
|
||||
|
||||
# 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:
|
||||
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,16 @@ 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."""
|
||||
self.active = False
|
||||
self._ingress_observer = None
|
||||
|
||||
def _resolve_route(self, request: Request) -> RouteInfo | None:
|
||||
"""Find the matching RouteInfo for a request.
|
||||
|
|
@ -141,6 +151,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 +212,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 +226,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 +234,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()
|
||||
|
|
|
|||
|
|
@ -69,15 +69,7 @@ from .const import (
|
|||
VOCLevel,
|
||||
)
|
||||
from .sensors_common import WeatherSensorEntityDescription
|
||||
from .utils import (
|
||||
battery_level,
|
||||
to_float,
|
||||
to_int,
|
||||
voc_level_to_text,
|
||||
wind_dir_to_text,
|
||||
wslink_chill_index,
|
||||
wslink_heat_index,
|
||||
)
|
||||
from .utils import battery_level, to_float, to_int, voc_level_to_text, wind_dir_to_text
|
||||
|
||||
SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
|
||||
WeatherSensorEntityDescription(
|
||||
|
|
@ -465,9 +457,6 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
|
|||
replacement_entity_key=CH8_BATTERY,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
# `value_from_data_fn` takes precedence in `WeatherSensor.native_value`, so these
|
||||
# use the station's own t1heat/t1chill when present and compute them otherwise -
|
||||
# stations that do not report them used to leave both entities Unavailable forever.
|
||||
WeatherSensorEntityDescription(
|
||||
key=HEAT_INDEX,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
|
|
@ -477,7 +466,7 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
|
|||
suggested_display_precision=2,
|
||||
icon="mdi:weather-sunny",
|
||||
translation_key=HEAT_INDEX,
|
||||
value_from_data_fn=wslink_heat_index,
|
||||
value_fn=to_float,
|
||||
),
|
||||
WeatherSensorEntityDescription(
|
||||
key=CHILL_INDEX,
|
||||
|
|
@ -488,7 +477,7 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
|
|||
suggested_display_precision=2,
|
||||
icon="mdi:weather-sunny",
|
||||
translation_key=CHILL_INDEX,
|
||||
value_from_data_fn=wslink_chill_index,
|
||||
value_fn=to_float,
|
||||
),
|
||||
WeatherSensorEntityDescription(
|
||||
key=OUTSIDE_BATTERY,
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ from __future__ import annotations
|
|||
|
||||
import logging
|
||||
import math
|
||||
from typing import Any, Final
|
||||
from typing import Any
|
||||
|
||||
from py_typecheck.core import checked_or
|
||||
|
||||
|
|
@ -26,10 +26,8 @@ from homeassistant.helpers.translation import async_get_translations
|
|||
|
||||
from .const import (
|
||||
AZIMUT,
|
||||
CHILL_INDEX,
|
||||
CONNECTION_GATED_SENSORS,
|
||||
DEV_DBG,
|
||||
HEAT_INDEX,
|
||||
OUTSIDE_HUMIDITY,
|
||||
OUTSIDE_TEMP,
|
||||
REMAP_ITEMS,
|
||||
|
|
@ -394,56 +392,3 @@ def battery_5step_to_pct(value: Any) -> int | None:
|
|||
return None
|
||||
|
||||
return round(min(max(step, 0), 5) / 5 * 100)
|
||||
|
||||
|
||||
# The NWS wind-chill formula is defined for mph; WSLink reports wind in m/s.
|
||||
_MS_TO_MPH: Final = 2.236936
|
||||
|
||||
|
||||
def wslink_heat_index(data: dict[str, Any]) -> float | None:
|
||||
"""Heat index for a WSLink payload, in Celsius.
|
||||
|
||||
Prefers the station's own `t1heat` reading. Not every WSLink station sends one,
|
||||
and without a fallback the entity - which `_auto_enable_derived_sensors` creates
|
||||
as soon as temperature and humidity arrive - stays Unavailable forever.
|
||||
|
||||
`heat_index` takes Celsius via `convert=True` but always returns Fahrenheit, so
|
||||
the result is converted back to match this entity's native Celsius unit.
|
||||
"""
|
||||
if (reported := to_float(data.get(HEAT_INDEX))) is not None:
|
||||
return reported
|
||||
|
||||
# Guard here rather than letting heat_index log an error per push: a payload
|
||||
# without these fields is normal, not a fault.
|
||||
if to_float(data.get(OUTSIDE_TEMP)) is None or to_float(data.get(OUTSIDE_HUMIDITY)) is None:
|
||||
return None
|
||||
|
||||
value_f = heat_index(data, convert=True)
|
||||
return None if value_f is None else round(fahrenheit_to_celsius(value_f), 2)
|
||||
|
||||
|
||||
def wslink_chill_index(data: dict[str, Any]) -> float | None:
|
||||
"""Wind chill for a WSLink payload, in Celsius.
|
||||
|
||||
Prefers the station's own `t1chill` reading; see `wslink_heat_index` for why a
|
||||
fallback is needed.
|
||||
|
||||
`chill_index` converts the temperature but *not* the wind speed, and its formula
|
||||
expects mph, so the m/s reading is converted here before the Fahrenheit result is
|
||||
turned back into Celsius.
|
||||
"""
|
||||
if (reported := to_float(data.get(CHILL_INDEX))) is not None:
|
||||
return reported
|
||||
|
||||
temp_c = to_float(data.get(OUTSIDE_TEMP))
|
||||
wind_ms = to_float(data.get(WIND_SPEED))
|
||||
if temp_c is None or wind_ms is None:
|
||||
return None
|
||||
|
||||
value_f = chill_index(
|
||||
{
|
||||
OUTSIDE_TEMP: celsius_to_fahrenheit(temp_c),
|
||||
WIND_SPEED: wind_ms * _MS_TO_MPH,
|
||||
}
|
||||
)
|
||||
return None if value_f is None else round(fahrenheit_to_celsius(value_f), 2)
|
||||
|
|
|
|||
|
|
@ -311,10 +311,7 @@ class WindyPush:
|
|||
reason=f"Unable to send data to Windy ({WINDY_MAX_RETRIES} times). Disabling resend option for now. Please check your Windy configuration and enable this feature afterwards."
|
||||
)
|
||||
|
||||
# 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:
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -81,35 +81,13 @@ def test_level_descriptions_generated_from_battery_non_binary() -> None:
|
|||
|
||||
|
||||
def test_level_batteries_are_not_also_plain_sensors() -> None:
|
||||
"""A 0-5 battery must not be hand-defined on top of its generated description.
|
||||
|
||||
`BATTERY_LEVEL_SENSORS` is spliced into `SENSOR_TYPES_WSLINK`, so one occurrence
|
||||
there is the generated one and is expected; `SENSOR_TYPES_WEATHER_API` does not
|
||||
splice them in, so zero is expected there. "At most once" is therefore the
|
||||
invariant that holds for both - a second entry is a duplicate entity.
|
||||
|
||||
Complete *absence* of a description is caught by
|
||||
`test_level_descriptions_generated_from_battery_non_binary`, which pins the
|
||||
generated set to `BATTERY_NON_BINARY` exactly.
|
||||
"""
|
||||
"""A 0-5 battery must appear exactly once in each platform's description set."""
|
||||
for sensor_types in (SENSOR_TYPES_WSLINK, SENSOR_TYPES_WEATHER_API):
|
||||
keys = [desc.key for desc in sensor_types]
|
||||
for key in BATTERY_NON_BINARY:
|
||||
assert keys.count(key) <= 1, f"{key} defined more than once"
|
||||
|
||||
|
||||
def test_level_batteries_reach_the_sensor_platform() -> None:
|
||||
"""Every 0-5 battery must actually be spliced into the WSLink platform set.
|
||||
|
||||
The generated tuple existing is not enough - if the `*BATTERY_LEVEL_SENSORS`
|
||||
splice were dropped, the classification tests would still pass while the
|
||||
percentage entities silently disappeared.
|
||||
"""
|
||||
keys = [desc.key for desc in SENSOR_TYPES_WSLINK]
|
||||
for key in BATTERY_NON_BINARY:
|
||||
assert key in keys, f"{key} is classified as a 0-5 battery but has no sensor description"
|
||||
|
||||
|
||||
def test_binary_batteries_are_not_plain_sensors_too() -> None:
|
||||
"""0/1 batteries belong to the binary platform only.
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ from custom_components.sws12500.const import (
|
|||
WSLINK_ADDON_PORT,
|
||||
)
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.helpers.network import NoURLAvailableError
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -128,81 +127,6 @@ async def test_config_flow_user_invalid_credentials_api_key(
|
|||
assert result2["errors"][API_KEY] == "valid_credentials_key"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("blank", ["", " "])
|
||||
@pytest.mark.parametrize("field", [API_ID, API_KEY])
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_flow_user_rejects_blank_credentials(
|
||||
hass, enable_custom_integrations, field, blank
|
||||
) -> None:
|
||||
"""Blank PWS credentials must not create an entry.
|
||||
|
||||
`INVALID_CREDENTIALS` holds placeholder strings only, so an empty (or
|
||||
whitespace-only) value used to pass validation. The entry was created but
|
||||
`_validate_credentials` then rejected every incoming packet, leaving the
|
||||
integration permanently without data.
|
||||
"""
|
||||
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"}
|
||||
)
|
||||
assert form["step_id"] == "pws"
|
||||
|
||||
user_input = {
|
||||
API_ID: "ok_id",
|
||||
API_KEY: "ok_key",
|
||||
WSLINK: False,
|
||||
DEV_DBG: False,
|
||||
}
|
||||
user_input[field] = blank
|
||||
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
form["flow_id"], user_input=user_input
|
||||
)
|
||||
assert result2["type"] == "form"
|
||||
assert result2["step_id"] == "pws"
|
||||
expected = "valid_credentials_api" if field is API_ID else "valid_credentials_key"
|
||||
assert result2["errors"][field] == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("blank", ["", " "])
|
||||
@pytest.mark.parametrize("field", [API_ID, API_KEY])
|
||||
@pytest.mark.asyncio
|
||||
async def test_options_flow_basic_rejects_blank_credentials(
|
||||
hass, enable_custom_integrations, field, blank
|
||||
) -> None:
|
||||
"""Same blank-credential rule applies when the legacy endpoint is re-enabled."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={},
|
||||
options={LEGACY_ENABLED: False, ECOWITT_ENABLED: False},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
init = await hass.config_entries.options.async_init(entry.entry_id)
|
||||
form = await hass.config_entries.options.async_configure(
|
||||
init["flow_id"], user_input={"next_step_id": "basic"}
|
||||
)
|
||||
assert form["step_id"] == "basic"
|
||||
|
||||
user_input = {
|
||||
API_ID: "ok_id",
|
||||
API_KEY: "ok_key",
|
||||
WSLINK: False,
|
||||
DEV_DBG: False,
|
||||
LEGACY_ENABLED: True,
|
||||
}
|
||||
user_input[field] = blank
|
||||
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
init["flow_id"], user_input=user_input
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
expected = "valid_credentials_api" if field is API_ID else "valid_credentials_key"
|
||||
assert result["errors"][field] == expected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_flow_user_invalid_credentials_match(
|
||||
hass, enable_custom_integrations
|
||||
|
|
@ -233,6 +157,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,72 +428,18 @@ async def test_options_flow_ecowitt_uses_get_url_placeholders_and_webhook_defaul
|
|||
assert placeholders["port"] == "8123"
|
||||
assert placeholders["webhook_id"] # generated
|
||||
|
||||
done = await hass.config_entries.options.async_configure(
|
||||
bad = await hass.config_entries.options.async_configure(
|
||||
init["flow_id"],
|
||||
user_input={
|
||||
ECOWITT_WEBHOOK_ID: placeholders["webhook_id"],
|
||||
ECOWITT_WEBHOOK_ID: "",
|
||||
ECOWITT_ENABLED: True,
|
||||
},
|
||||
)
|
||||
assert done["type"] == "create_entry"
|
||||
assert done["data"][ECOWITT_ENABLED] is True
|
||||
assert bad["type"] == "form"
|
||||
assert bad["errors"][ECOWITT_WEBHOOK_ID] == "ecowitt_webhook_required"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_options_flow_ecowitt_survives_no_url_available(
|
||||
hass, enable_custom_integrations
|
||||
) -> None:
|
||||
"""`get_url` raising must not abort the Ecowitt options step.
|
||||
|
||||
The host/port are shown as setup instructions only. HA can fail to resolve any
|
||||
of its own URLs (no internal/external URL configured), and letting that escape
|
||||
made the Ecowitt setup unreachable.
|
||||
"""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={},
|
||||
options={ECOWITT_WEBHOOK_ID: "", ECOWITT_ENABLED: False, LEGACY_ENABLED: False},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
init = await hass.config_entries.options.async_init(entry.entry_id)
|
||||
with patch(
|
||||
"custom_components.sws12500.config_flow.get_url",
|
||||
side_effect=NoURLAvailableError,
|
||||
):
|
||||
form = await hass.config_entries.options.async_configure(
|
||||
init["flow_id"], user_input={"next_step_id": "ecowitt"}
|
||||
)
|
||||
assert form["type"] == "form"
|
||||
placeholders = form.get("description_placeholders") or {}
|
||||
assert placeholders["url"] == "UNKNOWN"
|
||||
assert placeholders["port"] == "UNKNOWN"
|
||||
assert placeholders["webhook_id"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_flow_ecowitt_survives_no_url_available(
|
||||
hass, enable_custom_integrations
|
||||
) -> None:
|
||||
"""The initial Ecowitt step stays usable when HA cannot resolve its own URL."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
with patch(
|
||||
"custom_components.sws12500.config_flow.get_url",
|
||||
side_effect=NoURLAvailableError,
|
||||
):
|
||||
form = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={"next_step_id": "ecowitt"}
|
||||
)
|
||||
assert form["type"] == "form"
|
||||
placeholders = form.get("description_placeholders") or {}
|
||||
assert placeholders["url"] == "UNKNOWN"
|
||||
assert placeholders["port"] == "UNKNOWN"
|
||||
|
||||
# The step is still completable without a resolvable URL.
|
||||
done = await hass.config_entries.flow.async_configure(
|
||||
form["flow_id"],
|
||||
done = await hass.config_entries.options.async_configure(
|
||||
init["flow_id"],
|
||||
user_input={
|
||||
ECOWITT_WEBHOOK_ID: placeholders["webhook_id"],
|
||||
ECOWITT_ENABLED: True,
|
||||
|
|
@ -594,6 +493,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={
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -369,6 +369,35 @@ 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_unload_entry_returns_false_on_failure(hass_with_http):
|
||||
entry = MockConfigEntry(domain=DOMAIN, data={}, options={API_ID: "id", API_KEY: "key"})
|
||||
|
|
|
|||
|
|
@ -338,40 +338,6 @@ 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
|
||||
):
|
||||
"""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.
|
||||
"""
|
||||
entry = _make_entry()
|
||||
pp = PocasiPush(hass, entry)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.pocasti_cz.update_options",
|
||||
_write_through_update_options(entry),
|
||||
)
|
||||
monkeypatch.setattr("custom_components.sws12500.pocasti_cz._LOGGER.critical", MagicMock())
|
||||
|
||||
session = _FakeSession(exc=TimeoutError("timed out"))
|
||||
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({"x": 1}, "WU")
|
||||
|
||||
assert pp.last_status == "client_error"
|
||||
assert pp.last_error == "TimeoutError"
|
||||
assert pp.invalid_response_count == 1
|
||||
assert pp.enabled is True
|
||||
|
||||
|
||||
def test_verify_response_logs_debug_when_logger_enabled(monkeypatch, hass):
|
||||
entry = _make_entry(logger=True)
|
||||
pp = PocasiPush(hass, entry)
|
||||
|
|
|
|||
|
|
@ -449,43 +449,6 @@ 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
|
||||
):
|
||||
"""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.
|
||||
"""
|
||||
entry = _make_entry()
|
||||
wp = WindyPush(hass, entry)
|
||||
wp.next_update = dt_util.utcnow() - timedelta(seconds=1)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.windy_func.update_options", AsyncMock(return_value=True)
|
||||
)
|
||||
monkeypatch.setattr("custom_components.sws12500.windy_func._LOGGER.critical", MagicMock())
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.windy_func.persistent_notification.async_create",
|
||||
MagicMock(),
|
||||
)
|
||||
|
||||
session = _FakeSession(exc=TimeoutError("timed out"))
|
||||
monkeypatch.setattr(
|
||||
"custom_components.sws12500.windy_func.async_get_clientsession",
|
||||
lambda _h: session,
|
||||
)
|
||||
|
||||
ok = await wp.push_data_to_windy({"a": "b"})
|
||||
|
||||
assert ok is True
|
||||
assert wp.last_status == "client_error"
|
||||
assert wp.last_error == "TimeoutError"
|
||||
assert wp.invalid_response_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_push_data_to_windy_client_error_disable_failure_logs_debug(
|
||||
monkeypatch, hass
|
||||
|
|
|
|||
|
|
@ -1,153 +0,0 @@
|
|||
"""Heat / wind-chill indices must work on WSLink too.
|
||||
|
||||
`_auto_enable_derived_sensors` creates both entities as soon as temperature and
|
||||
humidity (resp. wind speed) arrive, but the WSLink descriptions only ever read the
|
||||
station's own ``t1heat`` / ``t1chill``. A station that does not report those left both
|
||||
entities Unavailable forever, while the WU/PWS platform computed them.
|
||||
|
||||
The payload is metric, the NWS formulas are Fahrenheit-based (and mph for wind chill),
|
||||
and the WSLink entities are declared in Celsius - so the conversions matter as much as
|
||||
the fallback itself.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.sws12500.const import (
|
||||
CHILL_INDEX,
|
||||
HEAT_INDEX,
|
||||
OUTSIDE_HUMIDITY,
|
||||
OUTSIDE_TEMP,
|
||||
WIND_SPEED,
|
||||
)
|
||||
from custom_components.sws12500.sensors_wslink import SENSOR_TYPES_WSLINK
|
||||
from custom_components.sws12500.utils import (
|
||||
celsius_to_fahrenheit,
|
||||
chill_index,
|
||||
fahrenheit_to_celsius,
|
||||
heat_index,
|
||||
wslink_chill_index,
|
||||
wslink_heat_index,
|
||||
)
|
||||
|
||||
# The remapped form of a real payload from a station that sends neither t1heat nor
|
||||
# t1chill (t1tem/t1hum/t1ws in metric units).
|
||||
LIVE_PAYLOAD: dict[str, str] = {
|
||||
OUTSIDE_TEMP: "6.2",
|
||||
OUTSIDE_HUMIDITY: "40",
|
||||
WIND_SPEED: "30.6",
|
||||
}
|
||||
|
||||
|
||||
def _description(key: str):
|
||||
return next(desc for desc in SENSOR_TYPES_WSLINK if desc.key == key)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fallback
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_indices_are_computed_when_the_station_omits_them() -> None:
|
||||
"""The reported bug: both entities stayed empty for this exact payload."""
|
||||
assert wslink_heat_index(LIVE_PAYLOAD) is not None
|
||||
assert wslink_chill_index(LIVE_PAYLOAD) is not None
|
||||
|
||||
|
||||
def test_computed_values_are_celsius_and_plausible() -> None:
|
||||
"""6.2 C at 40% RH with strong wind: heat index near ambient, chill below it."""
|
||||
heat = wslink_heat_index(LIVE_PAYLOAD)
|
||||
chill = wslink_chill_index(LIVE_PAYLOAD)
|
||||
assert heat is not None and chill is not None
|
||||
|
||||
# Sanity-check the magnitude - a Fahrenheit result leaking through would be ~39/28.
|
||||
assert -10 < heat < 15, heat
|
||||
assert -10 < chill < 15, chill
|
||||
# Wind chill must be colder than the still-air temperature at 30.6 m/s.
|
||||
assert chill < 6.2
|
||||
|
||||
|
||||
def test_wind_is_converted_to_mph() -> None:
|
||||
"""`chill_index` converts temperature but not wind, and its formula needs mph.
|
||||
|
||||
Feeding m/s straight in understates the wind and yields a warmer chill.
|
||||
"""
|
||||
correct = wslink_chill_index(LIVE_PAYLOAD)
|
||||
|
||||
naive_f = chill_index(
|
||||
{OUTSIDE_TEMP: celsius_to_fahrenheit(6.2), WIND_SPEED: 30.6}, # m/s, unconverted
|
||||
)
|
||||
assert naive_f is not None
|
||||
naive = round(fahrenheit_to_celsius(naive_f), 2)
|
||||
|
||||
assert correct is not None
|
||||
assert correct < naive, f"unconverted wind gives {naive}, converted gives {correct}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The station's own reading still wins
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("fn", "key"),
|
||||
[(wslink_heat_index, HEAT_INDEX), (wslink_chill_index, CHILL_INDEX)],
|
||||
ids=["heat", "chill"],
|
||||
)
|
||||
def test_station_reported_value_takes_precedence(fn, key) -> None:
|
||||
"""t1heat / t1chill are already Celsius and must be passed through untouched."""
|
||||
assert fn({**LIVE_PAYLOAD, key: "3.5"}) == 3.5
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("fn", "key"),
|
||||
[(wslink_heat_index, HEAT_INDEX), (wslink_chill_index, CHILL_INDEX)],
|
||||
ids=["heat", "chill"],
|
||||
)
|
||||
def test_empty_reported_value_falls_back_to_computing(fn, key) -> None:
|
||||
"""The station sends "" for absent fields; that must not shadow the fallback."""
|
||||
assert fn({**LIVE_PAYLOAD, key: ""}) is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Missing inputs stay quiet
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("missing", [OUTSIDE_TEMP, OUTSIDE_HUMIDITY])
|
||||
def test_heat_index_needs_temp_and_humidity(missing) -> None:
|
||||
payload = {k: v for k, v in LIVE_PAYLOAD.items() if k != missing}
|
||||
assert wslink_heat_index(payload) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("missing", [OUTSIDE_TEMP, WIND_SPEED])
|
||||
def test_chill_index_needs_temp_and_wind(missing) -> None:
|
||||
payload = {k: v for k, v in LIVE_PAYLOAD.items() if k != missing}
|
||||
assert wslink_chill_index(payload) is None
|
||||
|
||||
|
||||
def test_no_inputs_at_all_is_none() -> None:
|
||||
assert wslink_heat_index({}) is None
|
||||
assert wslink_chill_index({}) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Wiring
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("key", "expected"), [(HEAT_INDEX, wslink_heat_index), (CHILL_INDEX, wslink_chill_index)])
|
||||
def test_descriptions_use_the_wslink_helpers(key, expected) -> None:
|
||||
"""`value_from_data_fn` wins in WeatherSensor.native_value, so it must be set."""
|
||||
desc = _description(key)
|
||||
assert desc.value_from_data_fn is expected
|
||||
|
||||
|
||||
def test_wu_platform_still_computes_in_fahrenheit() -> None:
|
||||
"""The WU descriptions are Fahrenheit-native; they must keep the raw helpers."""
|
||||
from custom_components.sws12500.sensors_weather import SENSOR_TYPES_WEATHER_API
|
||||
|
||||
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
|
||||
Loading…
Reference in New Issue