Stabilize webhook routing and config updates

- Register aiohttp webhook routes once and switch the active dispatcher handler on option changes
- Make the internal route registry method-aware (GET/POST) and improve enabled-route logging
- Fix OptionsFlow initialization by passing the config entry and using safe defaults for credentials
- Harden Windy resend by validating credentials early, auto-disabling the feature on invalid responses, and notifying the user
- Update translations for Windy credential validation errors
ecowitt_support
SchiZzA 2026-03-01 12:48:23 +01:00
parent cc1afaa218
commit 95663fd78b
No known key found for this signature in database
6 changed files with 137 additions and 70 deletions

View File

@ -197,6 +197,8 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
_wslink: bool = checked_or(self.config.options.get(WSLINK), bool, False) _wslink: bool = checked_or(self.config.options.get(WSLINK), bool, False)
# Incoming station payload is delivered as query params. # Incoming station payload is delivered as query params.
# Some stations posts data in body, so we need to contracts those data.
#
# We copy it to a plain dict so it can be passed around safely. # We copy it to a plain dict so it can be passed around safely.
get_data = webdata.query get_data = webdata.query
post_data = await webdata.post() post_data = await webdata.post()
@ -341,28 +343,52 @@ def register_path(
_wslink: bool = checked_or(config.options.get(WSLINK), bool, False) _wslink: bool = checked_or(config.options.get(WSLINK), bool, False)
# Create internal route dispatcher with provided urls # Load registred routes
routes: Routes = Routes() routes: Routes | None = config.options.get("routes", None)
routes.add_route(DEFAULT_URL, coordinator.received_data, enabled=not _wslink)
routes.add_route(WSLINK_URL, coordinator.received_data, enabled=_wslink)
routes.add_route(HEALTH_URL, coordinator_h.health_status, enabled=True)
# Register webhooks in HomeAssistant with dispatcher if not isinstance(routes, Routes):
try: routes = Routes()
_ = hass.http.app.router.add_get(DEFAULT_URL, routes.dispatch)
_ = hass.http.app.router.add_post(WSLINK_URL, routes.dispatch)
_ = hass.http.app.router.add_get(HEALTH_URL, routes.dispatch)
# Save initialised routes # Register webhooks in HomeAssistant with dispatcher
hass_data["routes"] = routes try:
_default_route = hass.http.app.router.add_get(
DEFAULT_URL, routes.dispatch, name="_default_route"
)
_wslink_post_route = hass.http.app.router.add_post(
WSLINK_URL, routes.dispatch, name="_wslink_post_route"
)
_wslink_get_route = hass.http.app.router.add_get(
WSLINK_URL, routes.dispatch, name="_wslink_get_route"
)
_health_route = hass.http.app.router.add_get(
HEALTH_URL, routes.dispatch, name="_health_route"
)
except RuntimeError as Ex: # Save initialised routes
_LOGGER.critical( hass_data["routes"] = routes
"Routes cannot be added. Integration will not work as expected. %s", Ex
except RuntimeError as Ex:
_LOGGER.critical(
"Routes cannot be added. Integration will not work as expected. %s", Ex
)
raise ConfigEntryNotReady from Ex
# Finally create internal route dispatcher with provided urls, while we have webhooks registered.
routes.add_route(
DEFAULT_URL, _default_route, coordinator.received_data, enabled=not _wslink
)
routes.add_route(
WSLINK_URL, _wslink_post_route, coordinator.received_data, enabled=_wslink
)
routes.add_route(
WSLINK_URL, _wslink_get_route, coordinator.received_data, enabled=_wslink
)
routes.add_route(
HEALTH_URL, _health_route, coordinator_h.health_status, enabled=True
) )
raise ConfigEntryNotReady from Ex
else: else:
return True _LOGGER.info("We have already registered routes: %s", routes.show_enabled())
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
@ -428,7 +454,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if routes: if routes:
_LOGGER.debug("We have routes registered, will try to switch dispatcher.") _LOGGER.debug("We have routes registered, will try to switch dispatcher.")
routes.switch_route(DEFAULT_URL if not _wslink else WSLINK_URL) routes.switch_route(
coordinator.received_data, DEFAULT_URL if not _wslink else WSLINK_URL
)
_LOGGER.debug("%s", routes.show_enabled()) _LOGGER.debug("%s", routes.show_enabled())
else: else:
routes_enabled = register_path(hass, coordinator, coordinator_health, entry) routes_enabled = register_path(hass, coordinator, coordinator_health, entry)

View File

@ -51,7 +51,7 @@ class InvalidAuth(HomeAssistantError):
class ConfigOptionsFlowHandler(OptionsFlow): class ConfigOptionsFlowHandler(OptionsFlow):
"""Handle WeatherStation ConfigFlow.""" """Handle WeatherStation ConfigFlow."""
def __init__(self) -> None: def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize flow.""" """Initialize flow."""
super().__init__() super().__init__()
@ -66,16 +66,12 @@ class ConfigOptionsFlowHandler(OptionsFlow):
self.ecowitt: dict[str, Any] = {} self.ecowitt: dict[str, Any] = {}
self.ecowitt_schema = {} self.ecowitt_schema = {}
# @property
# def config_entry(self) -> ConfigEntry:
# return self.hass.config_entries.async_get_entry(self.handler)
async def _get_entry_data(self): async def _get_entry_data(self):
"""Get entry data.""" """Get entry data."""
self.user_data = { self.user_data = {
API_ID: self.config_entry.options.get(API_ID), API_ID: self.config_entry.options.get(API_ID, ""),
API_KEY: self.config_entry.options.get(API_KEY), API_KEY: self.config_entry.options.get(API_KEY, ""),
WSLINK: self.config_entry.options.get(WSLINK, False), WSLINK: self.config_entry.options.get(WSLINK, False),
DEV_DBG: self.config_entry.options.get(DEV_DBG, False), DEV_DBG: self.config_entry.options.get(DEV_DBG, False),
} }
@ -159,6 +155,7 @@ class ConfigOptionsFlowHandler(OptionsFlow):
async def async_step_init(self, user_input: dict[str, Any] = {}): async def async_step_init(self, user_input: dict[str, Any] = {}):
"""Manage the options - show menu first.""" """Manage the options - show menu first."""
_ = user_input
return self.async_show_menu( return self.async_show_menu(
step_id="init", menu_options=["basic", "ecowitt", "windy", "pocasi"] step_id="init", menu_options=["basic", "ecowitt", "windy", "pocasi"]
) )
@ -356,4 +353,4 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
@callback @callback
def async_get_options_flow(config_entry: ConfigEntry) -> ConfigOptionsFlowHandler: def async_get_options_flow(config_entry: ConfigEntry) -> ConfigOptionsFlowHandler:
"""Get the options flow for this handler.""" """Get the options flow for this handler."""
return ConfigOptionsFlowHandler() return ConfigOptionsFlowHandler(config_entry=config_entry)

View File

@ -19,7 +19,7 @@ from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field from dataclasses import dataclass, field
import logging import logging
from aiohttp.web import Request, Response from aiohttp.web import AbstractRoute, Request, Response
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -35,10 +35,16 @@ class RouteInfo:
""" """
url_path: str url_path: str
route: AbstractRoute
handler: Handler handler: Handler
enabled: bool = False enabled: bool = False
fallback: Handler = field(default_factory=lambda: unregistered) fallback: Handler = field(default_factory=lambda: unregistered)
def __str__(self):
"""Return string representation."""
return f"RouteInfo(url_path={self.url_path}, route={self.route}, handler={self.handler}, enabled={self.enabled}, fallback={self.fallback})"
class Routes: class Routes:
"""Simple route dispatcher. """Simple route dispatcher.
@ -54,41 +60,61 @@ class Routes:
async def dispatch(self, request: Request) -> Response: async def dispatch(self, request: Request) -> Response:
"""Dispatch incoming request to either the enabled handler or a fallback.""" """Dispatch incoming request to either the enabled handler or a fallback."""
info = self.routes.get(request.path) key = f"{request.method}:{request.path}"
info = self.routes.get(key)
if not info: if not info:
_LOGGER.debug("Route %s is not registered!", request.path) _LOGGER.debug(
"Route (%s):%s is not registered!", request.method, request.path
)
return await unregistered(request) return await unregistered(request)
handler = info.handler if info.enabled else info.fallback handler = info.handler if info.enabled else info.fallback
return await handler(request) return await handler(request)
def switch_route(self, url_path: str) -> None: def switch_route(self, handler: Handler, url_path: str) -> None:
"""Enable exactly one route and disable all others. """Enable exactly one route and disable all others.
This is called when options change (e.g. WSLink toggle). The aiohttp router stays This is called when options change (e.g. WSLink toggle). The aiohttp router stays
untouched; we only flip which internal handler is active. untouched; we only flip which internal handler is active.
""" """
for path, info in self.routes.items(): for route in self.routes.values():
info.enabled = path == url_path if route.url_path == url_path:
_LOGGER.info("New coordinator to route: %s", route.url_path)
route.enabled = True
route.handler = handler
else:
route.enabled = False
route.handler = unregistered
def add_route( def add_route(
self, url_path: str, handler: Handler, *, enabled: bool = False self,
url_path: str,
route: AbstractRoute,
handler: Handler,
*,
enabled: bool = False,
) -> None: ) -> None:
"""Register a route in the dispatcher. """Register a route in the dispatcher.
This does not register anything in aiohttp. It only stores routing metadata that This does not register anything in aiohttp. It only stores routing metadata that
`dispatch` uses after aiohttp has routed the request by path. `dispatch` uses after aiohttp has routed the request by path.
""" """
self.routes[url_path] = RouteInfo(url_path, handler, enabled=enabled) key = f"{route.method}:{url_path}"
self.routes[key] = RouteInfo(
url_path, route=route, handler=handler, enabled=enabled
)
_LOGGER.debug("Registered dispatcher for route %s", url_path) _LOGGER.debug("Registered dispatcher for route %s", url_path)
def show_enabled(self) -> str: def show_enabled(self) -> str:
"""Return a human-readable description of the currently enabled route.""" """Return a human-readable description of the currently enabled route."""
for url, route in self.routes.items():
if route.enabled: enabled_routes = {
return ( f"Dispatcher enabled for ({route.route.method}):{route.url_path}, with handler: {route.handler}"
f"Dispatcher enabled for URL: {url}, with handler: {route.handler}" for route in self.routes.values()
) if route.enabled
return "No routes is enabled." }
return ", ".join(
sorted(enabled_routes) if enabled_routes else "No routes are enabled."
)
async def unregistered(request: Request) -> Response: async def unregistered(request: Request) -> Response:

View File

@ -29,7 +29,8 @@
"valid_credentials_api": "Vyplňte platné API ID", "valid_credentials_api": "Vyplňte platné API ID",
"valid_credentials_key": "Vyplňte platný API KEY", "valid_credentials_key": "Vyplňte platný API KEY",
"valid_credentials_match": "API ID a API KEY nesmějí být stejné!", "valid_credentials_match": "API ID a API KEY nesmějí být stejné!",
"windy_key_required": "Je vyžadován Windy API key, pokud chcete aktivovat přeposílání dat na Windy", "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",
"pocasi_id_required": "Je vyžadován Počasí ID, pokud chcete aktivovat přeposílání dat na Počasí Meteo CZ", "pocasi_id_required": "Je vyžadován Počasí ID, pokud chcete aktivovat přeposílání dat na Počasí Meteo CZ",
"pocasi_key_required": "Klíč k účtu Počasí Meteo je povinný.", "pocasi_key_required": "Klíč k účtu Počasí Meteo je povinný.",
"pocasi_send_minimum": "Minimální interval pro přeposílání je 12 sekund." "pocasi_send_minimum": "Minimální interval pro přeposílání je 12 sekund."
@ -73,7 +74,7 @@
}, },
"data_description": { "data_description": {
"WINDY_STATION_ID": "ID stanice získaný z https://stations.windy.com/station", "WINDY_STATION_ID": "ID stanice získaný z https://stations.windy.com/station",
"WINDY_STATION_PWD": "Heslo stanice získané z https://stations.windy.com/station", "WINDY_STATION_PWD": "Heslo stanice získané z https://stations.windy.com/station",
"windy_logger_checkbox": "Zapnout pouze v případě, že chcete poslat ladící informace vývojáři." "windy_logger_checkbox": "Zapnout pouze v případě, že chcete poslat ladící informace vývojáři."
} }
}, },

View File

@ -29,7 +29,8 @@
"valid_credentials_api": "Provide valid API ID.", "valid_credentials_api": "Provide valid API ID.",
"valid_credentials_key": "Provide valid API KEY.", "valid_credentials_key": "Provide valid API KEY.",
"valid_credentials_match": "API ID and API KEY should not be the same.", "valid_credentials_match": "API ID and API KEY should not be the same.",
"windy_key_required": "Windy API key is required if you want to enable this function." "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."
}, },
"step": { "step": {
"init": { "init": {

View File

@ -2,8 +2,10 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
import logging import logging
import re
from aiohttp.client_exceptions import ClientError from aiohttp.client_exceptions import ClientError
from homeassistant.components import persistent_notification
from py_typecheck import checked from py_typecheck import checked
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@ -62,6 +64,9 @@ class WindyPush:
self.next_update: datetime = datetime.now() + timed(minutes=1) self.next_update: datetime = datetime.now() + timed(minutes=1)
self.log: bool = self.config.options.get(WINDY_LOGGER_ENABLED, False) self.log: bool = self.config.options.get(WINDY_LOGGER_ENABLED, False)
# Lets chcek if Windy server is responding right.
# Otherwise, try 3 times and then disable resending, as we might have bad credentials.
self.invalid_response_count: int = 0 self.invalid_response_count: int = 0
def verify_windy_response( def verify_windy_response(
@ -110,6 +115,14 @@ class WindyPush:
return indata return indata
async def _disable_windy(self, reason: str) -> None:
"""Disable Windy resending."""
if not await update_options(self.hass, self.config, WINDY_ENABLED, False):
_LOGGER.debug("Failed to set Windy options to false.")
persistent_notification.create(self.hass, reason, "Windy resending disabled.")
async def push_data_to_windy( async def push_data_to_windy(
self, data: dict[str, str], wslink: bool = False self, data: dict[str, str], wslink: bool = False
) -> bool: ) -> bool:
@ -121,6 +134,27 @@ class WindyPush:
from station. But we need to do some clean up. from station. But we need to do some clean up.
""" """
# First check if we have valid credentials, before any data manipulation.
if (
windy_station_id := checked(self.config.options.get(WINDY_STATION_ID), str)
) is None:
_LOGGER.error("Windy API key is not provided! Check your configuration.")
await self._disable_windy(
"Windy API key is not provided. Resending is disabled for now. Reconfigure your integration."
)
return False
if (
windy_station_pw := checked(self.config.options.get(WINDY_STATION_PW), str)
) is None:
_LOGGER.error(
"Windy station password is missing! Check your configuration."
)
await self._disable_windy(
"Windy password is not provided. Resending is disabled for now. Reconfigure your integration."
)
return False
if self.log: if self.log:
_LOGGER.info( _LOGGER.info(
"Windy last update = %s, next update at: %s", "Windy last update = %s, next update at: %s",
@ -139,21 +173,7 @@ class WindyPush:
if wslink: if wslink:
# WSLink -> Windy params # WSLink -> Windy params
self._covert_wslink_to_pws(purged_data) purged_data = self._covert_wslink_to_pws(purged_data)
if (
windy_station_id := checked(self.config.options.get(WINDY_STATION_ID), str)
) is None:
_LOGGER.error("Windy API key is not provided! Check your configuration.")
return False
if (
windy_station_pw := checked(self.config.options.get(WINDY_STATION_PW), str)
) is None:
_LOGGER.error(
"Windy station password is missing! Check your configuration."
)
return False
request_url = f"{WINDY_URL}" request_url = f"{WINDY_URL}"
@ -180,13 +200,9 @@ class WindyPush:
except WindyApiKeyError: except WindyApiKeyError:
# log despite of settings # log despite of settings
_LOGGER.critical(WINDY_INVALID_KEY) _LOGGER.critical(WINDY_INVALID_KEY)
await self._disable_windy(
if not ( reason="Windy server refused your API key. Resending is disabled for now. Reconfigure your Windy settings."
await update_options( )
self.hass, self.config, WINDY_ENABLED, False
)
):
_LOGGER.debug("Failed to set Windy option to false.")
except WindySuccess: except WindySuccess:
if self.log: if self.log:
@ -200,11 +216,9 @@ class WindyPush:
self.invalid_response_count += 1 self.invalid_response_count += 1
if self.invalid_response_count > 3: if self.invalid_response_count > 3:
_LOGGER.critical(WINDY_UNEXPECTED) _LOGGER.critical(WINDY_UNEXPECTED)
if not await update_options( await self._disable_windy(
self.hass, self.config, WINDY_ENABLED, False reason="Invalid response from Windy 3 times. Disabling resending option."
): )
_LOGGER.debug("Failed to set Windy options to false.")
self.last_update = datetime.now() self.last_update = datetime.now()
self.next_update = self.last_update + timed(minutes=5) self.next_update = self.last_update + timed(minutes=5)