Compare commits

..

3 Commits

Author SHA1 Message Date
Lukas Svoboda 714b8c3a73
Merge 2827eac158 into 7582f1e9b4 2026-07-25 22:01:19 +00:00
SchiZzA 2827eac158
fix(document): Refresh runtime route docs 2026-07-26 00:01:15 +02:00
SchiZzA 1bb8864426
fix(review): Disable stale routes and validate credentials 2026-07-25 23:42:17 +02:00
19 changed files with 187 additions and 540 deletions

View File

@ -3,16 +3,15 @@
Architecture overview
---------------------
This integration is *push-based*: the weather station calls our HTTP endpoint and we
receive a query payload. We do not poll the station.
receive an HTTP payload. We do not poll the station.
Key building blocks:
- `WeatherDataUpdateCoordinator` acts as an in-memory "data bus" for the latest payload.
On each webhook request we call `async_set_updated_data(...)` and all `CoordinatorEntity`
sensors get notified and update their states.
- `hass.data[DOMAIN][entry_id]` is a per-entry *dict* that stores runtime state
(coordinator instance, options snapshot, and sensor platform callbacks). Keeping this
structure consistent is critical; mixing different value types under the same key can
break listener wiring and make the UI appear "frozen".
- `entry.runtime_data` stores per-entry runtime state (coordinator instance, options
snapshot, and sensor platform callbacks). Shared aiohttp route registrations stay
under `hass.data[DOMAIN]["routes"]` because they must survive a config-entry reload.
Auto-discovery
--------------
@ -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

View File

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

View File

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

View File

@ -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.

View File

@ -3,10 +3,10 @@
Why this dispatcher exists
--------------------------
Home Assistant registers aiohttp routes on startup. Re-registering or removing routes at runtime
is awkward and error-prone (and can raise if routes already exist). This integration supports two
different push endpoints (legacy WU-style vs WSLink). To allow switching between them without
touching the aiohttp router, we register both routes once and use this in-process dispatcher to
decide which one is currently enabled.
is awkward and error-prone (and can raise if routes already exist). This integration supports
multiple station push endpoints. To allow switching between them without touching the aiohttp
router, we register routes once and use this in-process dispatcher to decide which one is
currently enabled.
Important note:
- Each route stores a *bound method* handler (e.g. `coordinator.received_data`). That means the
@ -63,6 +63,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()

View File

@ -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,

View File

@ -4,6 +4,7 @@
"valid_credentials_api": "Provide valid API ID.",
"valid_credentials_key": "Provide valid API KEY.",
"valid_credentials_match": "API ID and API KEY should not be the same.",
"ecowitt_webhook_required": "Provide a webhook ID before enabling Ecowitt data.",
"protocols_mutually_exclusive": "The legacy (PWS/WSLink) and Ecowitt endpoints cannot be enabled at the same time - they feed the same sensor entities, which would mix up units and blank readings. Disable one of them first."
},
"step": {
@ -50,6 +51,7 @@
"valid_credentials_api": "Provide valid API ID.",
"valid_credentials_key": "Provide valid API KEY.",
"valid_credentials_match": "API ID and API KEY should not be the same.",
"ecowitt_webhook_required": "Provide a webhook ID before enabling Ecowitt data.",
"windy_id_required": "Windy API ID is required if you want to enable this function.",
"windy_pw_required": "Windy API password is required if you want to enable this function.",
"windy_key_required": "Windy station ID and password are required if you want to enable this function.",

View File

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

View File

@ -4,6 +4,7 @@
"valid_credentials_api": "Vyplňte platné API ID.",
"valid_credentials_key": "Vyplňte platný API KEY.",
"valid_credentials_match": "API ID a API KEY nesmějí být stejné!",
"ecowitt_webhook_required": "Před zapnutím dat Ecowitt zadejte webhook ID.",
"protocols_mutually_exclusive": "Starý endpoint (PWS/WSLink) a Ecowitt nelze zapnout současně plní stejné entity senzorů, což by pomíchalo jednotky a mazalo naměřené hodnoty. Nejdřív jeden z nich vypni."
},
"step": {
@ -50,6 +51,7 @@
"valid_credentials_api": "Vyplňte platné API ID",
"valid_credentials_key": "Vyplňte platný API KEY",
"valid_credentials_match": "API ID a API KEY nesmějí být stejné!",
"ecowitt_webhook_required": "Před zapnutím dat Ecowitt zadejte webhook ID.",
"windy_id_required": "Je vyžadováno Windy ID, pokud chcete aktivovat přeposílání dat na Windy",
"windy_pw_required": "Je vyžadován Windy KEY, pokud chcete aktivovat přeposílání dat na Windy",
"windy_key_required": "Pro aktivaci je vyžadováno Windy ID i heslo stanice.",

View File

@ -4,6 +4,7 @@
"valid_credentials_api": "Provide valid API ID.",
"valid_credentials_key": "Provide valid API KEY.",
"valid_credentials_match": "API ID and API KEY should not be the same.",
"ecowitt_webhook_required": "Provide a webhook ID before enabling Ecowitt data.",
"protocols_mutually_exclusive": "The legacy (PWS/WSLink) and Ecowitt endpoints cannot be enabled at the same time - they feed the same sensor entities, which would mix up units and blank readings. Disable one of them first."
},
"step": {
@ -50,6 +51,7 @@
"valid_credentials_api": "Provide valid API ID.",
"valid_credentials_key": "Provide valid API KEY.",
"valid_credentials_match": "API ID and API KEY should not be the same.",
"ecowitt_webhook_required": "Provide a webhook ID before enabling Ecowitt data.",
"windy_id_required": "Windy API ID is required if you want to enable this function.",
"windy_pw_required": "Windy API password is required if you want to enable this function.",
"windy_key_required": "Windy station ID and password are required if you want to enable this function.",

View File

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

View File

@ -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.

View File

@ -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.

View File

@ -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={

View File

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

View File

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

View File

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

View File

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

View File

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