diff --git a/custom_components/sws12500/__init__.py b/custom_components/sws12500/__init__.py index 16f3e79..f71f779 100644 --- a/custom_components/sws12500/__init__.py +++ b/custom_components/sws12500/__init__.py @@ -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) diff --git a/custom_components/sws12500/config_flow.py b/custom_components/sws12500/config_flow.py index 9356fbb..1e3c7c2 100644 --- a/custom_components/sws12500/config_flow.py +++ b/custom_components/sws12500/config_flow.py @@ -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) diff --git a/custom_components/sws12500/routes.py b/custom_components/sws12500/routes.py index 5e58cfa..bab33aa 100644 --- a/custom_components/sws12500/routes.py +++ b/custom_components/sws12500/routes.py @@ -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: diff --git a/custom_components/sws12500/translations/cs.json b/custom_components/sws12500/translations/cs.json index 2eb9b65..014eea5 100644 --- a/custom_components/sws12500/translations/cs.json +++ b/custom_components/sws12500/translations/cs.json @@ -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." } }, diff --git a/custom_components/sws12500/translations/en.json b/custom_components/sws12500/translations/en.json index 4a3d5d6..b958eb5 100644 --- a/custom_components/sws12500/translations/en.json +++ b/custom_components/sws12500/translations/en.json @@ -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": { diff --git a/custom_components/sws12500/windy_func.py b/custom_components/sws12500/windy_func.py index e2fba0c..db45ecb 100644 --- a/custom_components/sws12500/windy_func.py +++ b/custom_components/sws12500/windy_func.py @@ -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)