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 errorsecowitt_support
parent
cc1afaa218
commit
95663fd78b
|
|
@ -197,6 +197,8 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
|
|||
_wslink: bool = checked_or(self.config.options.get(WSLINK), bool, False)
|
||||
|
||||
# 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.
|
||||
get_data = webdata.query
|
||||
post_data = await webdata.post()
|
||||
|
|
@ -341,28 +343,52 @@ def register_path(
|
|||
|
||||
_wslink: bool = checked_or(config.options.get(WSLINK), bool, False)
|
||||
|
||||
# Create internal route dispatcher with provided urls
|
||||
routes: Routes = Routes()
|
||||
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)
|
||||
# Load registred routes
|
||||
routes: Routes | None = config.options.get("routes", None)
|
||||
|
||||
# Register webhooks in HomeAssistant with dispatcher
|
||||
try:
|
||||
_ = 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)
|
||||
if not isinstance(routes, Routes):
|
||||
routes = Routes()
|
||||
|
||||
# Save initialised routes
|
||||
hass_data["routes"] = routes
|
||||
# Register webhooks in HomeAssistant with dispatcher
|
||||
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:
|
||||
_LOGGER.critical(
|
||||
"Routes cannot be added. Integration will not work as expected. %s", Ex
|
||||
# Save initialised routes
|
||||
hass_data["routes"] = routes
|
||||
|
||||
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:
|
||||
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:
|
||||
|
|
@ -428,7 +454,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||
|
||||
if routes:
|
||||
_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())
|
||||
else:
|
||||
routes_enabled = register_path(hass, coordinator, coordinator_health, entry)
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ class InvalidAuth(HomeAssistantError):
|
|||
class ConfigOptionsFlowHandler(OptionsFlow):
|
||||
"""Handle WeatherStation ConfigFlow."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
def __init__(self, config_entry: ConfigEntry) -> None:
|
||||
"""Initialize flow."""
|
||||
super().__init__()
|
||||
|
||||
|
|
@ -66,16 +66,12 @@ class ConfigOptionsFlowHandler(OptionsFlow):
|
|||
self.ecowitt: dict[str, Any] = {}
|
||||
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):
|
||||
"""Get entry data."""
|
||||
|
||||
self.user_data = {
|
||||
API_ID: self.config_entry.options.get(API_ID),
|
||||
API_KEY: self.config_entry.options.get(API_KEY),
|
||||
API_ID: self.config_entry.options.get(API_ID, ""),
|
||||
API_KEY: self.config_entry.options.get(API_KEY, ""),
|
||||
WSLINK: self.config_entry.options.get(WSLINK, 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] = {}):
|
||||
"""Manage the options - show menu first."""
|
||||
_ = user_input
|
||||
return self.async_show_menu(
|
||||
step_id="init", menu_options=["basic", "ecowitt", "windy", "pocasi"]
|
||||
)
|
||||
|
|
@ -356,4 +353,4 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
|||
@callback
|
||||
def async_get_options_flow(config_entry: ConfigEntry) -> ConfigOptionsFlowHandler:
|
||||
"""Get the options flow for this handler."""
|
||||
return ConfigOptionsFlowHandler()
|
||||
return ConfigOptionsFlowHandler(config_entry=config_entry)
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ from collections.abc import Awaitable, Callable
|
|||
from dataclasses import dataclass, field
|
||||
import logging
|
||||
|
||||
from aiohttp.web import Request, Response
|
||||
from aiohttp.web import AbstractRoute, Request, Response
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -35,10 +35,16 @@ class RouteInfo:
|
|||
"""
|
||||
|
||||
url_path: str
|
||||
route: AbstractRoute
|
||||
handler: Handler
|
||||
enabled: bool = False
|
||||
|
||||
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:
|
||||
"""Simple route dispatcher.
|
||||
|
|
@ -54,41 +60,61 @@ class Routes:
|
|||
|
||||
async def dispatch(self, request: Request) -> Response:
|
||||
"""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:
|
||||
_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)
|
||||
handler = info.handler if info.enabled else info.fallback
|
||||
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.
|
||||
|
||||
This is called when options change (e.g. WSLink toggle). The aiohttp router stays
|
||||
untouched; we only flip which internal handler is active.
|
||||
"""
|
||||
for path, info in self.routes.items():
|
||||
info.enabled = path == url_path
|
||||
for route in self.routes.values():
|
||||
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(
|
||||
self, url_path: str, handler: Handler, *, enabled: bool = False
|
||||
self,
|
||||
url_path: str,
|
||||
route: AbstractRoute,
|
||||
handler: Handler,
|
||||
*,
|
||||
enabled: bool = False,
|
||||
) -> None:
|
||||
"""Register a route in the dispatcher.
|
||||
|
||||
This does not register anything in aiohttp. It only stores routing metadata that
|
||||
`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)
|
||||
|
||||
def show_enabled(self) -> str:
|
||||
"""Return a human-readable description of the currently enabled route."""
|
||||
for url, route in self.routes.items():
|
||||
if route.enabled:
|
||||
return (
|
||||
f"Dispatcher enabled for URL: {url}, with handler: {route.handler}"
|
||||
)
|
||||
return "No routes is enabled."
|
||||
|
||||
enabled_routes = {
|
||||
f"Dispatcher enabled for ({route.route.method}):{route.url_path}, with handler: {route.handler}"
|
||||
for route in self.routes.values()
|
||||
if route.enabled
|
||||
}
|
||||
return ", ".join(
|
||||
sorted(enabled_routes) if enabled_routes else "No routes are enabled."
|
||||
)
|
||||
|
||||
|
||||
async def unregistered(request: Request) -> Response:
|
||||
|
|
|
|||
|
|
@ -29,7 +29,8 @@
|
|||
"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é!",
|
||||
"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_key_required": "Klíč k účtu Počasí Meteo je povinný.",
|
||||
"pocasi_send_minimum": "Minimální interval pro přeposílání je 12 sekund."
|
||||
|
|
@ -73,7 +74,7 @@
|
|||
},
|
||||
"data_description": {
|
||||
"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."
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -29,7 +29,8 @@
|
|||
"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.",
|
||||
"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": {
|
||||
"init": {
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@
|
|||
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
import re
|
||||
|
||||
from aiohttp.client_exceptions import ClientError
|
||||
from homeassistant.components import persistent_notification
|
||||
from py_typecheck import checked
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
|
|
@ -62,6 +64,9 @@ class WindyPush:
|
|||
self.next_update: datetime = datetime.now() + timed(minutes=1)
|
||||
|
||||
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
|
||||
|
||||
def verify_windy_response(
|
||||
|
|
@ -110,6 +115,14 @@ class WindyPush:
|
|||
|
||||
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(
|
||||
self, data: dict[str, str], wslink: bool = False
|
||||
) -> bool:
|
||||
|
|
@ -121,6 +134,27 @@ class WindyPush:
|
|||
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:
|
||||
_LOGGER.info(
|
||||
"Windy last update = %s, next update at: %s",
|
||||
|
|
@ -139,21 +173,7 @@ class WindyPush:
|
|||
|
||||
if wslink:
|
||||
# WSLink -> Windy params
|
||||
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
|
||||
purged_data = self._covert_wslink_to_pws(purged_data)
|
||||
|
||||
request_url = f"{WINDY_URL}"
|
||||
|
||||
|
|
@ -180,13 +200,9 @@ class WindyPush:
|
|||
except WindyApiKeyError:
|
||||
# log despite of settings
|
||||
_LOGGER.critical(WINDY_INVALID_KEY)
|
||||
|
||||
if not (
|
||||
await update_options(
|
||||
self.hass, self.config, WINDY_ENABLED, False
|
||||
)
|
||||
):
|
||||
_LOGGER.debug("Failed to set Windy option to false.")
|
||||
await self._disable_windy(
|
||||
reason="Windy server refused your API key. Resending is disabled for now. Reconfigure your Windy settings."
|
||||
)
|
||||
|
||||
except WindySuccess:
|
||||
if self.log:
|
||||
|
|
@ -200,11 +216,9 @@ class WindyPush:
|
|||
self.invalid_response_count += 1
|
||||
if self.invalid_response_count > 3:
|
||||
_LOGGER.critical(WINDY_UNEXPECTED)
|
||||
if not await update_options(
|
||||
self.hass, self.config, WINDY_ENABLED, False
|
||||
):
|
||||
_LOGGER.debug("Failed to set Windy options to false.")
|
||||
|
||||
await self._disable_windy(
|
||||
reason="Invalid response from Windy 3 times. Disabling resending option."
|
||||
)
|
||||
self.last_update = datetime.now()
|
||||
self.next_update = self.last_update + timed(minutes=5)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue