diff --git a/custom_components/sws12500/__init__.py b/custom_components/sws12500/__init__.py index fec487a..7c6a43f 100644 --- a/custom_components/sws12500/__init__.py +++ b/custom_components/sws12500/__init__.py @@ -26,16 +26,13 @@ With a high-frequency push source (webhook), a reload at the wrong moment can le period where no entities are subscribed, causing stale states until another full reload/restart. """ -from asyncio import timeout import logging from typing import Any -from aiohttp import ClientConnectionError import aiohttp.web from aiohttp.web_exceptions import HTTPUnauthorized from py_typecheck import checked, checked_or -from homeassistant.components.network import async_get_source_ip from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -44,8 +41,6 @@ from homeassistant.exceptions import ( InvalidStateError, PlatformNotReady, ) -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.network import get_url from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( @@ -61,6 +56,7 @@ from .const import ( WSLINK_URL, ) from .data import ENTRY_COORDINATOR, ENTRY_HEALTH_COORD, ENTRY_LAST_OPTIONS +from .health_coordinator import HealthCoordinator from .pocasti_cz import PocasiPush from .routes import Routes from .utils import ( @@ -83,76 +79,6 @@ class IncorrectDataError(InvalidStateError): """Invalid exception.""" -"""Helper coordinator for health status endpoint. - -This is separate from the main `WeatherDataUpdateCoordinator` -Coordinator checks the WSLink Addon reachability and returns basic health info. - -Serves health status for diagnostic sensors and the integration health page in HA UI. -""" - - -class HealthCoordinator(DataUpdateCoordinator): - """Coordinator for health status of integration. - - This coordinator will listen on `/station/health`. - """ - - # TODO Add update interval and periodic checks for WSLink Addon reachability, so that health status is always up-to-date even without incoming station pushes. - - def __init__(self, hass: HomeAssistant, config: ConfigEntry) -> None: - """Initialize coordinator for health status.""" - - self.hass: HomeAssistant = hass - self.config: ConfigEntry = config - self.data: dict[str, str] = {} - - super().__init__(hass, logger=_LOGGER, name=DOMAIN) - - async def health_status(self, _: aiohttp.web.Request) -> aiohttp.web.Response: - """Handle and inform of integration status. - - Note: aiohttp route handlers must accept the incoming Request. - """ - - session = async_get_clientsession(self.hass, False) - - # Keep this endpoint lightweight and always available. - url = get_url(self.hass) - ip = await async_get_source_ip(self.hass) - - request_url = f"https://{ip}" - - try: - async with timeout(5), session.get(request_url) as response: - if checked(response.status, int) == 200: - resp = await response.text() - else: - resp = {"error": f"Unexpected status code {response.status}"} - except ClientConnectionError: - resp = {"error": "Connection error, WSLink addon is unreachable."} - - data = { - "Integration status": "ok", - "HomeAssistant source_ip": str(ip), - "HomeAssistant base_url": url, - "WSLink Addon response": resp, - } - - self.async_set_updated_data(data) - - # TODO Remove this response, as it is intentded to tests only. - return aiohttp.web.json_response( - { - "Integration status": "ok", - "HomeAssistant source_ip": str(ip), - "HomeAssistant base_url": url, - "WSLink Addon response": resp, - }, - status=200, - ) - - # NOTE: # We intentionally avoid importing the sensor platform module at import-time here. # Home Assistant can import modules in different orders; keeping imports acyclic @@ -182,6 +108,16 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator): self.pocasi: PocasiPush = PocasiPush(hass, config) super().__init__(hass, _LOGGER, name=DOMAIN) + def _health_coordinator(self) -> HealthCoordinator | None: + """Return the health coordinator for this config entry.""" + if (data := checked(self.hass.data.get(DOMAIN), dict[str, Any])) is None: + return None + if (entry := checked(data.get(self.config.entry_id), dict[str, Any])) is None: + return None + + coordinator = entry.get(ENTRY_HEALTH_COORD) + return coordinator if isinstance(coordinator, HealthCoordinator) else None + async def received_data(self, webdata: aiohttp.web.Request) -> aiohttp.web.Response: """Handle incoming webhook payload from the station. @@ -206,13 +142,30 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator): # normalize incoming data to dict[str, Any] data: dict[str, Any] = {**dict(get_data), **dict(post_data)} + # Get health data coordinator + health = self._health_coordinator() + # Validate auth keys (different parameter names depending on endpoint mode). if not _wslink and ("ID" not in data or "PASSWORD" not in data): _LOGGER.error("Invalid request. No security data provided!") + if health: + health.update_ingress_result( + webdata, + accepted=False, + authorized=False, + reason="missing_credentials", + ) raise HTTPUnauthorized if _wslink and ("wsid" not in data or "wspw" not in data): _LOGGER.error("Invalid request. No security data provided!") + if health: + health.update_ingress_result( + webdata, + accepted=False, + authorized=False, + reason="missing_credentials", + ) raise HTTPUnauthorized id_data: str = "" @@ -230,30 +183,37 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator): if (_id := checked(self.config.options.get(API_ID), str)) is None: _LOGGER.error("We don't have API ID set! Update your config!") + if health: + health.update_ingress_result( + webdata, + accepted=False, + authorized=None, + reason="config_missing_api_id", + ) raise IncorrectDataError if (_key := checked(self.config.options.get(API_KEY), str)) is None: _LOGGER.error("We don't have API KEY set! Update your config!") + if health: + health.update_ingress_result( + webdata, + accepted=False, + authorized=None, + reason="config_missing_api_key", + ) raise IncorrectDataError if id_data != _id or key_data != _key: _LOGGER.error("Unauthorised access!") + if health: + health.update_ingress_result( + webdata, + accepted=False, + authorized=False, + reason="unauthorized", + ) raise HTTPUnauthorized - # Optional forwarding to external services. This is kept here (in the webhook handler) - # to avoid additional background polling tasks. - - _windy_enabled = checked_or(self.config.options.get(WINDY_ENABLED), bool, False) - _pocasi_enabled = checked_or( - self.config.options.get(POCASI_CZ_ENABLED), bool, False - ) - - if _windy_enabled: - await self.windy.push_data_to_windy(data, _wslink) - - if _pocasi_enabled: - await self.pocasi.push_data_to_server(data, "WSLINK" if _wslink else "WU") - # Convert raw payload keys to our internal sensor keys (stable identifiers). remaped_items: dict[str, str] = ( remap_wslink_items(data) if _wslink else remap_items(data) @@ -322,6 +282,30 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator): # Fan-out update: notify all subscribed entities. self.async_set_updated_data(remaped_items) + if health: + health.update_ingress_result( + webdata, + accepted=True, + authorized=True, + reason="accepted", + ) + + # Optional forwarding to external services. This is kept here (in the webhook handler) + # to avoid additional background polling tasks. + + _windy_enabled = checked_or(self.config.options.get(WINDY_ENABLED), bool, False) + _pocasi_enabled = checked_or( + self.config.options.get(POCASI_CZ_ENABLED), bool, False + ) + + if _windy_enabled: + await self.windy.push_data_to_windy(data, _wslink) + + if _pocasi_enabled: + await self.pocasi.push_data_to_server(data, "WSLINK" if _wslink else "WU") + + if health: + health.update_forwarding(self.windy, self.pocasi) # Optional dev logging (keep it lightweight to avoid log spam under high-frequency updates). if self.config.options.get("dev_debug_checkbox"): @@ -350,10 +334,11 @@ def register_path( _wslink: bool = checked_or(config.options.get(WSLINK), bool, False) # Load registred routes - routes: Routes | None = config.options.get("routes", None) + routes: Routes | None = hass_data.get("routes", None) if not isinstance(routes, Routes): routes = Routes() + routes.set_ingress_observer(coordinator_h.record_dispatch) # Register webhooks in HomeAssistant with dispatcher try: @@ -389,10 +374,16 @@ def register_path( routes.add_route( WSLINK_URL, _wslink_get_route, coordinator.received_data, enabled=_wslink ) + # Make health route `sticky` so it will not change upon updating options. routes.add_route( - HEALTH_URL, _health_route, coordinator_h.health_status, enabled=True + HEALTH_URL, + _health_route, + coordinator_h.health_status, + enabled=True, + sticky=True, ) else: + routes.set_ingress_observer(coordinator_h.record_dispatch) _LOGGER.info("We have already registered routes: %s", routes.show_enabled()) return True @@ -461,6 +452,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: routes.switch_route( coordinator.received_data, DEFAULT_URL if not _wslink else WSLINK_URL ) + routes.set_ingress_observer(coordinator_health.record_dispatch) + coordinator_health.update_routing(routes) _LOGGER.debug("%s", routes.show_enabled()) else: routes_enabled = register_path(hass, coordinator, coordinator_health, entry) @@ -468,6 +461,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not routes_enabled: _LOGGER.error("Fatal: path not registered!") raise PlatformNotReady + routes = hass_data.get("routes", None) + if isinstance(routes, Routes): + coordinator_health.update_routing(routes) + + await coordinator_health.async_config_entry_first_refresh() + coordinator_health.update_forwarding(coordinator.windy, coordinator.pocasi) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/custom_components/sws12500/config_flow.py b/custom_components/sws12500/config_flow.py index 1e3c7c2..42949d9 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, config_entry: ConfigEntry) -> None: + def __init__(self) -> None: """Initialize flow.""" super().__init__() @@ -353,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(config_entry=config_entry) + return ConfigOptionsFlowHandler() diff --git a/custom_components/sws12500/const.py b/custom_components/sws12500/const.py index 9e3a754..b1a27f1 100644 --- a/custom_components/sws12500/const.py +++ b/custom_components/sws12500/const.py @@ -3,8 +3,6 @@ from enum import StrEnum from typing import Final -from .channels import * - # Integration specific constants. DOMAIN = "sws12500" DATABASE_PATH = "/config/home-assistant_v2.db" @@ -32,6 +30,61 @@ INVALID_CREDENTIALS: Final = [ ] +# Sensor constants +BARO_PRESSURE: Final = "baro_pressure" +OUTSIDE_TEMP: Final = "outside_temp" +DEW_POINT: Final = "dew_point" +OUTSIDE_HUMIDITY: Final = "outside_humidity" +OUTSIDE_CONNECTION: Final = "outside_connection" +OUTSIDE_BATTERY: Final = "outside_battery" +WIND_SPEED: Final = "wind_speed" +WIND_GUST: Final = "wind_gust" +WIND_DIR: Final = "wind_dir" +WIND_AZIMUT: Final = "wind_azimut" +RAIN: Final = "rain" +HOURLY_RAIN: Final = "hourly_rain" +WEEKLY_RAIN: Final = "weekly_rain" +MONTHLY_RAIN: Final = "monthly_rain" +YEARLY_RAIN: Final = "yearly_rain" +DAILY_RAIN: Final = "daily_rain" +SOLAR_RADIATION: Final = "solar_radiation" +INDOOR_TEMP: Final = "indoor_temp" +INDOOR_HUMIDITY: Final = "indoor_humidity" +INDOOR_BATTERY: Final = "indoor_battery" +UV: Final = "uv" +CH2_TEMP: Final = "ch2_temp" +CH2_HUMIDITY: Final = "ch2_humidity" +CH2_CONNECTION: Final = "ch2_connection" +CH2_BATTERY: Final = "ch2_battery" +CH3_TEMP: Final = "ch3_temp" +CH3_HUMIDITY: Final = "ch3_humidity" +CH3_CONNECTION: Final = "ch3_connection" +CH3_BATTERY: Final = "ch3_battery" +CH4_TEMP: Final = "ch4_temp" +CH4_HUMIDITY: Final = "ch4_humidity" +CH4_CONNECTION: Final = "ch4_connection" +CH4_BATTERY: Final = "ch4_battery" +CH5_TEMP: Final = "ch5_temp" +CH5_HUMIDITY: Final = "ch5_humidity" +CH5_CONNECTION: Final = "ch5_connection" +CH5_BATTERY: Final = "ch5_battery" +CH6_TEMP: Final = "ch6_temp" +CH6_HUMIDITY: Final = "ch6_humidity" +CH6_CONNECTION: Final = "ch6_connection" +CH6_BATTERY: Final = "ch6_battery" +CH7_TEMP: Final = "ch7_temp" +CH7_HUMIDITY: Final = "ch7_humidity" +CH7_CONNECTION: Final = "ch7_connection" +CH7_BATTERY: Final = "ch7_battery" +CH8_TEMP: Final = "ch8_temp" +CH8_HUMIDITY: Final = "ch8_humidity" +CH8_CONNECTION: Final = "ch8_connection" +CH8_BATTERY: Final = "ch8_battery" +HEAT_INDEX: Final = "heat_index" +CHILL_INDEX: Final = "chill_index" +WBGT_TEMP: Final = "wbgt_temp" + + # Health specific constants HEALTH_URL = "/station/health" @@ -53,7 +106,7 @@ PURGE_DATA: Final = [ ] REMAP_ITEMS: dict[str, str] = { - "baromin": .channels.BARO_PRESSURE, + "baromin": BARO_PRESSURE, "tempf": OUTSIDE_TEMP, "dewptf": DEW_POINT, "humidity": OUTSIDE_HUMIDITY, @@ -74,10 +127,11 @@ REMAP_ITEMS: dict[str, str] = { "soilmoisture3": CH4_HUMIDITY, "soiltemp4f": CH5_TEMP, "soilmoisture4": CH5_HUMIDITY, + "soiltemp5f": CH6_TEMP, + "soilmoisture5": CH6_HUMIDITY, } - WSLINK_URL = "/data/upload.php" WINDY_URL = "https://stations.windy.com/api/v2/observation/update" @@ -86,9 +140,6 @@ POCASI_CZ_URL: Final = "http://ms.pocasimeteo.cz" POCASI_CZ_SEND_MINIMUM: Final = 12 # minimal time to resend data - - - WSLINK: Final = "wslink" WINDY_MAX_RETRIES: Final = 3 @@ -208,7 +259,6 @@ WINDY_UNEXPECTED: Final = ( ) - PURGE_DATA_POCAS: Final = [ "ID", "PASSWORD", @@ -217,9 +267,6 @@ PURGE_DATA_POCAS: Final = [ ] - - - """NOTE: These are sensors that should be available with PWS protocol acording to https://support.weather.com/s/article/PWS-Upload-Protocol?language=en_US: I have no option to test, if it will work correctly. So their implementatnion will be in future releases. diff --git a/custom_components/sws12500/data.py b/custom_components/sws12500/data.py index b84d2c7..e7673ac 100644 --- a/custom_components/sws12500/data.py +++ b/custom_components/sws12500/data.py @@ -18,3 +18,4 @@ ENTRY_ADD_ENTITIES: Final[str] = "async_add_entities" ENTRY_DESCRIPTIONS: Final[str] = "sensor_descriptions" ENTRY_LAST_OPTIONS: Final[str] = "last_options" ENTRY_HEALTH_COORD: Final[str] = "coord_h" +ENTRY_HEALTH_DATA: Final[str] = "health_data" diff --git a/custom_components/sws12500/diagnostics.py b/custom_components/sws12500/diagnostics.py new file mode 100644 index 0000000..72b2742 --- /dev/null +++ b/custom_components/sws12500/diagnostics.py @@ -0,0 +1,63 @@ +"""Diagnostics support for the SWS12500 integration.""" + +from __future__ import annotations + +from copy import deepcopy +from typing import Any + +from py_typecheck import checked, checked_or + +from homeassistant.components.diagnostics import ( + async_redact_data, # pyright: ignore[reportUnknownVariableType] +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import ( + API_ID, + API_KEY, + DOMAIN, + POCASI_CZ_API_ID, + POCASI_CZ_API_KEY, + WINDY_STATION_ID, + WINDY_STATION_PW, +) +from .data import ENTRY_HEALTH_COORD, ENTRY_HEALTH_DATA + +TO_REDACT = { + API_ID, + API_KEY, + POCASI_CZ_API_ID, + POCASI_CZ_API_KEY, + WINDY_STATION_ID, + WINDY_STATION_PW, + "ID", + "PASSWORD", + "wsid", + "wspw", +} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + data = checked_or(hass.data.get(DOMAIN), dict[str, Any], {}) + + if (entry_data := checked(data.get(entry.entry_id), dict[str, Any])) is None: + entry_data = {} + + health_data = checked(entry_data.get(ENTRY_HEALTH_DATA), dict[str, Any]) + if health_data is None: + coordinator = entry_data.get(ENTRY_HEALTH_COORD) + health_data = getattr(coordinator, "data", None) + + return { + "entry_data": async_redact_data(dict(entry.data), TO_REDACT), + "entry_options": async_redact_data(dict(entry.options), TO_REDACT), + "health_data": async_redact_data( + deepcopy(health_data) if health_data else {}, + TO_REDACT, + ), + } diff --git a/custom_components/sws12500/health_coordinator.py b/custom_components/sws12500/health_coordinator.py new file mode 100644 index 0000000..b5e6a4a --- /dev/null +++ b/custom_components/sws12500/health_coordinator.py @@ -0,0 +1,344 @@ +"""Health and diagnostics coordinator for the SWS12500 integration. + +This module owns the integration's runtime health model. The intent is to keep +all support/debug state in one place so it can be surfaced consistently via: + +- diagnostic entities (`health_sensor.py`) +- diagnostics download (`diagnostics.py`) +- the `/station/health` HTTP endpoint + +The coordinator is intentionally separate from the weather data coordinator. +Weather payload handling is push-based, while health metadata is lightweight +polling plus event-driven updates (route dispatch, ingress result, forwarding). +""" + +from __future__ import annotations + +from asyncio import timeout +from copy import deepcopy +from datetime import timedelta +import logging +from typing import Any + +import aiohttp +from aiohttp import ClientConnectionError +import aiohttp.web +from py_typecheck import checked, checked_or + +from homeassistant.components.network import async_get_source_ip +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.network import get_url +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.util import dt as dt_util + +from .const import ( + DEFAULT_URL, + DOMAIN, + HEALTH_URL, + POCASI_CZ_ENABLED, + WINDY_ENABLED, + WSLINK, + WSLINK_URL, +) +from .data import ENTRY_HEALTH_DATA +from .pocasti_cz import PocasiPush +from .routes import Routes +from .windy_func import WindyPush + +_LOGGER = logging.getLogger(__name__) + + +def _protocol_name(wslink_enabled: bool) -> str: + """Return the configured protocol name.""" + return "wslink" if wslink_enabled else "wu" + + +def _protocol_from_path(path: str) -> str: + """Infer an ingress protocol label from a request path.""" + if path == WSLINK_URL: + return "wslink" + if path == DEFAULT_URL: + return "wu" + if path == HEALTH_URL: + return "health" + return "unknown" + + +def _empty_forwarding_state(enabled: bool) -> dict[str, Any]: + """Build the default forwarding status payload.""" + return { + "enabled": enabled, + "last_status": "disabled" if not enabled else "idle", + "last_error": None, + "last_attempt_at": None, + } + + +def _default_health_data(config: ConfigEntry) -> dict[str, Any]: + """Build the default health/debug payload for this config entry.""" + configured_protocol = _protocol_name( + checked_or(config.options.get(WSLINK), bool, False) + ) + return { + "integration_status": f"online_{configured_protocol}", + "configured_protocol": configured_protocol, + "active_protocol": configured_protocol, + "addon": { + "online": False, + "health_endpoint": "/healthz", + "info_endpoint": "/status/internal", + "name": None, + "version": None, + "listen_port": None, + "tls": None, + "upstream_ha_port": None, + "paths": { + "wslink": WSLINK_URL, + "wu": DEFAULT_URL, + }, + "raw_status": None, + }, + "routes": { + "wu_enabled": False, + "wslink_enabled": False, + "health_enabled": False, + "snapshot": {}, + }, + "last_ingress": { + "time": None, + "protocol": "unknown", + "path": None, + "method": None, + "route_enabled": False, + "accepted": False, + "authorized": None, + "reason": "no_data", + }, + "forwarding": { + "windy": _empty_forwarding_state( + checked_or(config.options.get(WINDY_ENABLED), bool, False) + ), + "pocasi": _empty_forwarding_state( + checked_or(config.options.get(POCASI_CZ_ENABLED), bool, False) + ), + }, + } + + +class HealthCoordinator(DataUpdateCoordinator): + """Maintain the integration health snapshot. + + The coordinator combines: + - periodic add-on reachability checks + - live ingress observations from the HTTP dispatcher + - ingress processing results from the main webhook handler + - forwarding status from Windy/Pocasi helpers + + All of that is stored as one structured JSON-like dict in `self.data`. + """ + + def __init__(self, hass: HomeAssistant, config: ConfigEntry) -> None: + """Initialize the health coordinator.""" + self.hass: HomeAssistant = hass + self.config: ConfigEntry = config + + super().__init__( + hass, + logger=_LOGGER, + name=f"{DOMAIN}_health", + update_interval=timedelta(minutes=1), + ) + + self.data: dict[str, Any] = _default_health_data(config) + + def _store_runtime_health(self, data: dict[str, Any]) -> None: + """Persist the latest health payload into entry runtime storage.""" + + if (domain := checked(self.hass.data.get(DOMAIN), dict[str, Any])) is None: + return + + if (entry := checked(domain.get(self.config.entry_id), dict[str, Any])) is None: + return + + entry[ENTRY_HEALTH_DATA] = deepcopy(data) + + def _commit(self, data: dict[str, Any]) -> dict[str, Any]: + """Publish a new health snapshot.""" + self.async_set_updated_data(data) + self._store_runtime_health(data) + return data + + def _refresh_summary(self, data: dict[str, Any]) -> None: + """Derive top-level integration status from the detailed health payload.""" + + configured_protocol = data.get("configured_protocol", "wu") + ingress = data.get("last_ingress", {}) + last_protocol = ingress.get("protocol", "unknown") + accepted = bool(ingress.get("accepted")) + reason = ingress.get("reason") + + if (reason in {"route_disabled", "route_not_registered", "unauthorized"}) or ( + last_protocol in {"wu", "wslink"} and last_protocol != configured_protocol + ): + integration_status = "degraded" + elif accepted and last_protocol in {"wu", "wslink"}: + integration_status = f"online_{last_protocol}" + else: + integration_status = "online_idle" + + data["integration_status"] = integration_status + data["active_protocol"] = ( + last_protocol + if accepted and last_protocol in {"wu", "wslink"} + else configured_protocol + ) + + async def _async_update_data(self) -> dict[str, Any]: + """Refresh add-on health metadata from the WSLink proxy.""" + session = async_get_clientsession(self.hass, False) + url = get_url(self.hass) + ip = await async_get_source_ip(self.hass) + + health_url = f"https://{ip}/healthz" + info_url = f"https://{ip}/status/internal" + + data = deepcopy(self.data) + addon = data["addon"] + addon["health_url"] = health_url + addon["info_url"] = info_url + addon["home_assistant_url"] = url + addon["home_assistant_source_ip"] = str(ip) + addon["online"] = False + + try: + async with timeout(5), session.get(health_url) as response: + addon["online"] = checked(response.status, int) == 200 + except ClientConnectionError: + addon["online"] = False + + raw_status: dict[str, Any] | None = None + if addon["online"]: + try: + async with timeout(5), session.get(info_url) as info_response: + if checked(info_response.status, int) == 200: + raw_status = await info_response.json(content_type=None) + except (ClientConnectionError, aiohttp.ContentTypeError, ValueError): + raw_status = None + + addon["raw_status"] = raw_status + if raw_status: + addon["name"] = raw_status.get("addon") + addon["version"] = raw_status.get("version") + addon["listen_port"] = raw_status.get("listen", {}).get("port") + addon["tls"] = raw_status.get("listen", {}).get("tls") + addon["upstream_ha_port"] = raw_status.get("upstream", {}).get("ha_port") + addon["paths"] = { + "wslink": raw_status.get("paths", {}).get("wslink", WSLINK_URL), + "wu": raw_status.get("paths", {}).get("wu", DEFAULT_URL), + } + + self._refresh_summary(data) + return self._commit(data) + + def update_routing(self, routes: Routes | None) -> None: + """Store the currently enabled routes for diagnostics.""" + data = deepcopy(self.data) + data["configured_protocol"] = _protocol_name( + checked_or(self.config.options.get(WSLINK), bool, False) + ) + if routes is not None: + data["routes"] = { + "wu_enabled": routes.path_enabled(DEFAULT_URL), + "wslink_enabled": routes.path_enabled(WSLINK_URL), + "health_enabled": routes.path_enabled(HEALTH_URL), + "snapshot": routes.snapshot(), + } + self._refresh_summary(data) + self._commit(data) + + def record_dispatch( + self, request: aiohttp.web.Request, route_enabled: bool, reason: str | None + ) -> None: + """Record every ingress observed by the dispatcher. + + This runs before the actual webhook handler. It lets diagnostics answer: + - which endpoint the station is calling + - whether the route was enabled + - whether the request was rejected before processing + """ + + # We do not want to proccess health requests + if request.path == HEALTH_URL: + return + + data = deepcopy(self.data) + data["last_ingress"] = { + "time": dt_util.utcnow().isoformat(), + "protocol": _protocol_from_path(request.path), + "path": request.path, + "method": request.method, + "route_enabled": route_enabled, + "accepted": False, + "authorized": None, + "reason": reason or "pending", + } + self._refresh_summary(data) + self._commit(data) + + def update_ingress_result( + self, + request: aiohttp.web.Request, + *, + accepted: bool, + authorized: bool | None, + reason: str | None = None, + ) -> None: + """Store the final processing result of a webhook request.""" + data = deepcopy(self.data) + ingress = data.get("last_ingress", {}) + ingress.update( + { + "time": dt_util.utcnow().isoformat(), + "protocol": _protocol_from_path(request.path), + "path": request.path, + "method": request.method, + "accepted": accepted, + "authorized": authorized, + "reason": reason or ("accepted" if accepted else "rejected"), + } + ) + data["last_ingress"] = ingress + self._refresh_summary(data) + self._commit(data) + + def update_forwarding(self, windy: WindyPush, pocasi: PocasiPush) -> None: + """Store forwarding subsystem statuses for diagnostics.""" + data = deepcopy(self.data) + + data["forwarding"] = { + "windy": { + "enabled": windy.enabled, + "last_status": windy.last_status, + "last_error": windy.last_error, + "last_attempt_at": windy.last_attempt_at, + }, + "pocasi": { + "enabled": pocasi.enabled, + "last_status": pocasi.last_status, + "last_error": pocasi.last_error, + "last_attempt_at": pocasi.last_attempt_at, + }, + } + self._refresh_summary(data) + self._commit(data) + + async def health_status(self, _: aiohttp.web.Request) -> aiohttp.web.Response: + """Serve the current health snapshot over HTTP. + + The endpoint forces one refresh before returning so that the caller sees + a reasonably fresh add-on status. + """ + await self.async_request_refresh() + return aiohttp.web.json_response(self.data, status=200) diff --git a/custom_components/sws12500/health_sensor.py b/custom_components/sws12500/health_sensor.py index 2e12ae1..52f6209 100644 --- a/custom_components/sws12500/health_sensor.py +++ b/custom_components/sws12500/health_sensor.py @@ -1,15 +1,19 @@ -"""Health diagnostic sensor for SWS-12500. - -Home Assistant only auto-loads standard platform modules (e.g. `sensor.py`). -This file is a helper module and must be wired from `sensor.py`. -""" +"""Health diagnostic sensors for SWS-12500.""" from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass from functools import cached_property from typing import Any, cast -from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from py_typecheck import checked, checked_or + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant @@ -17,35 +21,178 @@ from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import dt as dt_util from .const import DOMAIN from .data import ENTRY_HEALTH_COORD +@dataclass(frozen=True, kw_only=True) class HealthSensorEntityDescription(SensorEntityDescription): """Description for health diagnostic sensors.""" + data_path: tuple[str, ...] + value_fn: Callable[[Any], Any] | None = None + + +def _resolve_path(data: dict[str, Any], path: tuple[str, ...]) -> Any: + """Resolve a nested path from a dictionary.""" + current: Any = data + for key in path: + if checked(current, dict[str, Any]) is None: + return None + current = current.get(key) + return current + + +def _on_off(value: Any) -> str: + """Render a boolean-ish value as `on` / `off`.""" + return "on" if bool(value) else "off" + + +def _accepted_state(value: Any) -> str: + """Render ingress acceptance state.""" + return "accepted" if bool(value) else "rejected" + + +def _authorized_state(value: Any) -> str: + """Render ingress authorization state.""" + if value is None: + return "unknown" + return "authorized" if bool(value) else "unauthorized" + + +def _timestamp_or_none(value: Any) -> Any: + """Convert ISO timestamp string to datetime for HA rendering.""" + if not isinstance(value, str): + return None + return dt_util.parse_datetime(value) + HEALTH_SENSOR_DESCRIPTIONS: tuple[HealthSensorEntityDescription, ...] = ( HealthSensorEntityDescription( - key="Integration status", - name="Integration status", + key="integration_health", + translation_key="integration_health", icon="mdi:heart-pulse", + data_path=("integration_status",), ), HealthSensorEntityDescription( - key="HomeAssistant source_ip", - name="Home Assistant source IP", - icon="mdi:ip", + key="active_protocol", + translation_key="active_protocol", + icon="mdi:swap-horizontal", + data_path=("active_protocol",), ), HealthSensorEntityDescription( - key="HomeAssistant base_url", - name="Home Assistant base URL", - icon="mdi:link-variant", - ), - HealthSensorEntityDescription( - key="WSLink Addon response", - name="WSLink Addon response", + key="wslink_addon_status", + translation_key="wslink_addon_status", icon="mdi:server-network", + data_path=("addon", "online"), + value_fn=lambda value: "online" if value else "offline", + ), + HealthSensorEntityDescription( + key="wslink_addon_name", + translation_key="wslink_addon_name", + icon="mdi:package-variant-closed", + data_path=("addon", "name"), + ), + HealthSensorEntityDescription( + key="wslink_addon_version", + translation_key="wslink_addon_version", + icon="mdi:label-outline", + data_path=("addon", "version"), + ), + HealthSensorEntityDescription( + key="wslink_addon_listen_port", + translation_key="wslink_addon_listen_port", + icon="mdi:lan-connect", + data_path=("addon", "listen_port"), + ), + HealthSensorEntityDescription( + key="wslink_upstream_ha_port", + translation_key="wslink_upstream_ha_port", + icon="mdi:transit-connection-variant", + data_path=("addon", "upstream_ha_port"), + ), + HealthSensorEntityDescription( + key="route_wu_enabled", + translation_key="route_wu_enabled", + icon="mdi:transit-connection-horizontal", + data_path=("routes", "wu_enabled"), + value_fn=_on_off, + ), + HealthSensorEntityDescription( + key="route_wslink_enabled", + translation_key="route_wslink_enabled", + icon="mdi:transit-connection-horizontal", + data_path=("routes", "wslink_enabled"), + value_fn=_on_off, + ), + HealthSensorEntityDescription( + key="last_ingress_time", + translation_key="last_ingress_time", + icon="mdi:clock-outline", + device_class=SensorDeviceClass.TIMESTAMP, + data_path=("last_ingress", "time"), + value_fn=_timestamp_or_none, + ), + HealthSensorEntityDescription( + key="last_ingress_protocol", + translation_key="last_ingress_protocol", + icon="mdi:download-network", + data_path=("last_ingress", "protocol"), + ), + HealthSensorEntityDescription( + key="last_ingress_route_enabled", + translation_key="last_ingress_route_enabled", + icon="mdi:check-network", + data_path=("last_ingress", "route_enabled"), + value_fn=_on_off, + ), + HealthSensorEntityDescription( + key="last_ingress_accepted", + translation_key="last_ingress_accepted", + icon="mdi:check-decagram", + data_path=("last_ingress", "accepted"), + value_fn=_accepted_state, + ), + HealthSensorEntityDescription( + key="last_ingress_authorized", + translation_key="last_ingress_authorized", + icon="mdi:key", + data_path=("last_ingress", "authorized"), + value_fn=_authorized_state, + ), + HealthSensorEntityDescription( + key="last_ingress_reason", + translation_key="last_ingress_reason", + icon="mdi:message-alert-outline", + data_path=("last_ingress", "reason"), + ), + HealthSensorEntityDescription( + key="forward_windy_enabled", + translation_key="forward_windy_enabled", + icon="mdi:weather-windy", + data_path=("forwarding", "windy", "enabled"), + value_fn=_on_off, + ), + HealthSensorEntityDescription( + key="forward_windy_status", + translation_key="forward_windy_status", + icon="mdi:weather-windy", + data_path=("forwarding", "windy", "last_status"), + ), + HealthSensorEntityDescription( + key="forward_pocasi_enabled", + translation_key="forward_pocasi_enabled", + icon="mdi:cloud-upload-outline", + data_path=("forwarding", "pocasi", "enabled"), + value_fn=_on_off, + ), + HealthSensorEntityDescription( + key="forward_pocasi_status", + translation_key="forward_pocasi_status", + icon="mdi:cloud-upload-outline", + data_path=("forwarding", "pocasi", "last_status"), ), ) @@ -55,30 +202,23 @@ async def async_setup_entry( entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up the health diagnostic sensor.""" + """Set up health diagnostic sensors.""" - domain_data_any = hass.data.get(DOMAIN) - if not isinstance(domain_data_any, dict): + if (data := checked(hass.data.get(DOMAIN), dict[str, Any])) is None: return - domain_data = cast("dict[str, Any]", domain_data_any) - entry_data_any = domain_data.get(entry.entry_id) - if not isinstance(entry_data_any, dict): + if (entry_data := checked(data.get(entry.entry_id), dict[str, Any])) is None: return - entry_data = cast("dict[str, Any]", entry_data_any) - coordinator_any = entry_data.get(ENTRY_HEALTH_COORD) - if coordinator_any is None: + coordinator = entry_data.get(ENTRY_HEALTH_COORD) + if coordinator is None: return entities = [ - HealthDiagnosticSensor( - coordinator=coordinator_any, entry=entry, description=description - ) + HealthDiagnosticSensor(coordinator=coordinator, description=description) for description in HEALTH_SENSOR_DESCRIPTIONS ] async_add_entities(entities) - # async_add_entities([HealthDiagnosticSensor(coordinator_any, entry)]) class HealthDiagnosticSensor( # pyright: ignore[reportIncompatibleVariableOverride] @@ -92,33 +232,33 @@ class HealthDiagnosticSensor( # pyright: ignore[reportIncompatibleVariableOverr def __init__( self, coordinator: Any, - entry: ConfigEntry, description: HealthSensorEntityDescription, ) -> None: """Initialize the sensor.""" super().__init__(coordinator) - + self.entity_description = description self._attr_entity_category = EntityCategory.DIAGNOSTIC self._attr_unique_id = f"{description.key}_health" - # self._attr_name = description.name - # self._attr_icon = "mdi:heart-pulse" @property - def native_value(self) -> str | None: # pyright: ignore[reportIncompatibleVariableOverride] - """Return a compact health state.""" + def native_value(self) -> Any: # pyright: ignore[reportIncompatibleVariableOverride] + """Return the current diagnostic value.""" - data = cast("dict[str, Any]", getattr(self.coordinator, "data", {}) or {}) - value = data.get("Integration status") - return cast("str | None", value) + data = checked_or(self.coordinator.data, dict[str, Any], {}) + + description = cast("HealthSensorEntityDescription", self.entity_description) + value = _resolve_path(data, description.data_path) + if description.value_fn is not None: + return description.value_fn(value) + return value @property def extra_state_attributes(self) -> dict[str, Any] | None: # pyright: ignore[reportIncompatibleVariableOverride] - """Return detailed health diagnostics as attributes.""" - - data_any = getattr(self.coordinator, "data", None) - if not isinstance(data_any, dict): + """Expose the full health JSON on the main health sensor for debugging.""" + if self.entity_description.key != "integration_health": return None - return cast("dict[str, Any]", data_any) + + return checked_or(self.coordinator.data, dict[str, Any], None) @cached_property def device_info(self) -> DeviceInfo: diff --git a/custom_components/sws12500/pocasti_cz.py b/custom_components/sws12500/pocasti_cz.py index 9291c42..322468f 100644 --- a/custom_components/sws12500/pocasti_cz.py +++ b/custom_components/sws12500/pocasti_cz.py @@ -48,6 +48,10 @@ class PocasiPush: """Init.""" self.hass = hass self.config = config + self.enabled: bool = self.config.options.get(POCASI_CZ_ENABLED, False) + self.last_status: str = "disabled" if not self.enabled else "idle" + self.last_error: str | None = None + self.last_attempt_at: str | None = None self._interval = int(self.config.options.get(POCASI_CZ_SEND_INTERVAL, 30)) self.last_update = datetime.now() @@ -76,11 +80,16 @@ class PocasiPush: """Pushes weather data to server.""" _data = data.copy() + self.enabled = self.config.options.get(POCASI_CZ_ENABLED, False) + self.last_attempt_at = datetime.now().isoformat() + self.last_error = None if (_api_id := checked(self.config.options.get(POCASI_CZ_API_ID), str)) is None: _LOGGER.error( "No API ID is provided for Pocasi Meteo. Check your configuration." ) + self.last_status = "config_error" + self.last_error = "Missing API ID." return if ( @@ -89,6 +98,8 @@ class PocasiPush: _LOGGER.error( "No API Key is provided for Pocasi Meteo. Check your configuration." ) + self.last_status = "config_error" + self.last_error = "Missing API key." return if self.log: @@ -99,6 +110,7 @@ class PocasiPush: ) if self.next_update > datetime.now(): + self.last_status = "rate_limited_local" _LOGGER.debug( "Triggered update interval limit of %s seconds. Next possilbe update is set to: %s", self._interval, @@ -132,19 +144,29 @@ class PocasiPush: except PocasiApiKeyError: # log despite of settings + self.last_status = "auth_error" + self.last_error = POCASI_INVALID_KEY + self.enabled = False _LOGGER.critical(POCASI_INVALID_KEY) await update_options( self.hass, self.config, POCASI_CZ_ENABLED, False ) except PocasiSuccess: + self.last_status = "ok" + self.last_error = None if self.log: _LOGGER.info(POCASI_CZ_SUCCESS) + else: + self.last_status = "ok" except ClientError as ex: + self.last_status = "client_error" + self.last_error = str(ex) _LOGGER.critical("Invalid response from Pocasi Meteo: %s", str(ex)) self.invalid_response_count += 1 if self.invalid_response_count > 3: _LOGGER.critical(POCASI_CZ_UNEXPECTED) + self.enabled = False await update_options(self.hass, self.config, POCASI_CZ_ENABLED, False) self.last_update = datetime.now() diff --git a/custom_components/sws12500/routes.py b/custom_components/sws12500/routes.py index 95f7aee..0d3d838 100644 --- a/custom_components/sws12500/routes.py +++ b/custom_components/sws12500/routes.py @@ -18,12 +18,14 @@ Important note: from collections.abc import Awaitable, Callable from dataclasses import dataclass, field import logging +from typing import Any from aiohttp.web import AbstractRoute, Request, Response _LOGGER = logging.getLogger(__name__) Handler = Callable[[Request], Awaitable[Response]] +IngressObserver = Callable[[Request, bool, str | None], None] @dataclass @@ -38,6 +40,7 @@ class RouteInfo: route: AbstractRoute handler: Handler enabled: bool = False + sticky: bool = False fallback: Handler = field(default_factory=lambda: unregistered) @@ -57,6 +60,11 @@ class Routes: def __init__(self) -> None: """Initialize dispatcher storage.""" self.routes: dict[str, RouteInfo] = {} + self._ingress_observer: IngressObserver | None = None + + def set_ingress_observer(self, observer: IngressObserver | None) -> None: + """Set a callback notified for every incoming dispatcher request.""" + self._ingress_observer = observer async def dispatch(self, request: Request) -> Response: """Dispatch incoming request to either the enabled handler or a fallback.""" @@ -66,17 +74,30 @@ class Routes: _LOGGER.debug( "Route (%s):%s is not registered!", request.method, request.path ) + if self._ingress_observer is not None: + self._ingress_observer(request, False, "route_not_registered") return await unregistered(request) + + if self._ingress_observer is not None: + self._ingress_observer( + request, + info.enabled, + None if info.enabled else "route_disabled", + ) + handler = info.handler if info.enabled else info.fallback return await handler(request) def switch_route(self, handler: Handler, url_path: str) -> None: - """Enable exactly one route and disable all others. + """Enable routes based on URL, disable all others. Leave sticky routes enabled. This is called when options change (e.g. WSLink toggle). The aiohttp router stays untouched; we only flip which internal handler is active. """ for route in self.routes.values(): + if route.sticky: + continue + if route.url_path == url_path: _LOGGER.info( "New coordinator to route: (%s):%s", @@ -96,6 +117,7 @@ class Routes: handler: Handler, *, enabled: bool = False, + sticky: bool = False, ) -> None: """Register a route in the dispatcher. @@ -104,7 +126,7 @@ class Routes: """ key = f"{route.method}:{url_path}" self.routes[key] = RouteInfo( - url_path, route=route, handler=handler, enabled=enabled + url_path, route=route, handler=handler, enabled=enabled, sticky=sticky ) _LOGGER.debug("Registered dispatcher for route (%s):%s", route.method, url_path) @@ -120,6 +142,24 @@ class Routes: return "No routes are enabled." return ", ".join(sorted(enabled_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 + ) + + def snapshot(self) -> dict[str, Any]: + """Return a compact routing snapshot for diagnostics.""" + return { + key: { + "path": route.url_path, + "method": route.route.method, + "enabled": route.enabled, + "sticky": route.sticky, + } + for key, route in self.routes.items() + } + async def unregistered(request: Request) -> Response: """Fallback response for unknown/disabled routes. diff --git a/custom_components/sws12500/translations/cs.json b/custom_components/sws12500/translations/cs.json index 014eea5..7649fd2 100644 --- a/custom_components/sws12500/translations/cs.json +++ b/custom_components/sws12500/translations/cs.json @@ -124,6 +124,101 @@ }, "entity": { "sensor": { + "integration_health": { + "name": "Stav integrace", + "state": { + "online_wu": "Online PWS/WU", + "online_wslink": "Online WSLink", + "online_idle": "Čekám na data", + "degraded": "Degradovaný", + "error": "Nefunkční" + } + }, + "active_protocol": { + "name": "Aktivní protokol", + "state": { + "wu": "PWS/WU", + "wslink": "WSLink API" + } + }, + "wslink_addon_status": { + "name": "Stav WSLink Addonu", + "state": { + "online": "Běží", + "offline": "Vypnutý" + } + }, + "wslink_addon_name": { + "name": "Název WSLink Addonu" + }, + "wslink_addon_version": { + "name": "Verze WSLink Addonu" + }, + "wslink_addon_listen_port": { + "name": "Port WSLink Addonu" + }, + "wslink_upstream_ha_port": { + "name": "Port upstream HA WSLink Addonu" + }, + "route_wu_enabled": { + "name": "Protokol PWS/WU" + }, + "route_wslink_enabled": { + "name": "Protokol WSLink" + }, + "last_ingress_time": { + "name": "Poslední přístup" + }, + "last_ingress_protocol": { + "name": "Protokol posledního přístupu", + "state": { + "wu": "PWS/WU", + "wslink": "WSLink API" + } + }, + "last_ingress_route_enabled": { + "name": "Trasa posledního přístupu povolena" + }, + "last_ingress_accepted": { + "name": "Poslední přístup", + "state": { + "accepted": "Prijat", + "rejected": "Odmítnut" + } + }, + "last_ingress_authorized": { + "name": "Autorizace posledního přístupu", + "state": { + "authorized": "Autorizován", + "unauthorized": "Neautorizován", + "unknown": "Neznámý" + } + }, + "last_ingress_reason": { + "name": "Zpráva přístupu" + }, + "forward_windy_enabled": { + "name": "Přeposílání na Windy" + }, + "forward_windy_status": { + "name": "Stav přeposílání na Windy", + "state": { + "disabled": "Vypnuto", + "idle": "Čekám na odeslání", + "ok": "Ok" + } + }, + "forward_pocasi_enabled": { + "name": "Přeposílání na Počasí Meteo" + }, + "forward_pocasi_status": { + "name": "Stav přeposílání na Počasí Meteo", + "state": { + "disabled": "Vypnuto", + "idle": "Čekám na odeslání", + "ok": "Ok" + } + }, "indoor_temp": { "name": "Vnitřní teplota" }, diff --git a/custom_components/sws12500/translations/en.json b/custom_components/sws12500/translations/en.json index ddcdb32..76ab3f5 100644 --- a/custom_components/sws12500/translations/en.json +++ b/custom_components/sws12500/translations/en.json @@ -118,6 +118,101 @@ }, "entity": { "sensor": { + "integration_health": { + "name": "Integration status", + "state": { + "online_wu": "Online PWS/WU", + "online_wslink": "Online WSLink", + "online_idle": "Waiting for data", + "degraded": "Degraded", + "error": "Error" + } + }, + "active_protocol": { + "name": "Active protocol", + "state": { + "wu": "PWS/WU", + "wslink": "WSLink API" + } + }, + "wslink_addon_status": { + "name": "WSLink Addon Status", + "state": { + "online": "Running", + "offline": "Offline" + } + }, + "wslink_addon_name": { + "name": "WSLink Addon Name" + }, + "wslink_addon_version": { + "name": "WSLink Addon Version" + }, + "wslink_addon_listen_port": { + "name": "WSLink Addon Listen Port" + }, + "wslink_upstream_ha_port": { + "name": "WSLink Addon Upstream HA Port" + }, + "route_wu_enabled": { + "name": "PWS/WU Protocol" + }, + "route_wslink_enabled": { + "name": "WSLink Protocol" + }, + "last_ingress_time": { + "name": "Last access time" + }, + "last_ingress_protocol": { + "name": "Last access protocol", + "state": { + "wu": "PWS/WU", + "wslink": "WSLink API" + } + }, + "last_ingress_route_enabled": { + "name": "Last ingress route enabled" + }, + "last_ingress_accepted": { + "name": "Last access", + "state": { + "accepted": "Accepted", + "rejected": "Rejected" + } + }, + "last_ingress_authorized": { + "name": "Last access authorization", + "state": { + "authorized": "Authorized", + "unauthorized": "Unauthorized", + "unknown": "Unknown" + } + }, + "last_ingress_reason": { + "name": "Last access reason" + }, + "forward_windy_enabled": { + "name": "Forwarding to Windy" + }, + "forward_windy_status": { + "name": "Forwarding status to Windy", + "state": { + "disabled": "Disabled", + "idle": "Waiting to send", + "ok": "Ok" + } + }, + "forward_pocasi_enabled": { + "name": "Forwarding to Počasí Meteo" + }, + "forward_pocasi_status": { + "name": "Forwarding status to Počasí Meteo", + "state": { + "disabled": "Disabled", + "idle": "Waiting to send", + "ok": "Ok" + } + }, "indoor_temp": { "name": "Indoor temperature" }, diff --git a/custom_components/sws12500/windy_func.py b/custom_components/sws12500/windy_func.py index 09b4488..23ea60f 100644 --- a/custom_components/sws12500/windy_func.py +++ b/custom_components/sws12500/windy_func.py @@ -78,6 +78,10 @@ class WindyPush: """Init.""" self.hass = hass self.config = config + self.enabled: bool = self.config.options.get(WINDY_ENABLED, False) + self.last_status: str = "disabled" if not self.enabled else "idle" + self.last_error: str | None = None + self.last_attempt_at: str | None = None """ lets wait for 1 minute to get initial data from station and then try to push first data to Windy @@ -142,6 +146,9 @@ class WindyPush: async def _disable_windy(self, reason: str) -> None: """Disable Windy resending.""" + self.enabled = False + self.last_status = "disabled" + self.last_error = reason if not await update_options(self.hass, self.config, WINDY_ENABLED, False): _LOGGER.debug("Failed to set Windy options to false.") @@ -160,10 +167,15 @@ class WindyPush: """ # First check if we have valid credentials, before any data manipulation. + self.enabled = self.config.options.get(WINDY_ENABLED, False) + self.last_attempt_at = datetime.now().isoformat() + self.last_error = None + 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.") + self.last_status = "config_error" await self._disable_windy( "Windy API key is not provided. Resending is disabled for now. Reconfigure your integration." ) @@ -175,6 +187,7 @@ class WindyPush: _LOGGER.error( "Windy station password is missing! Check your configuration." ) + self.last_status = "config_error" await self._disable_windy( "Windy password is not provided. Resending is disabled for now. Reconfigure your integration." ) @@ -188,6 +201,7 @@ class WindyPush: ) if self.next_update > datetime.now(): + self.last_status = "rate_limited_local" return False purged_data = data.copy() @@ -218,6 +232,8 @@ class WindyPush: try: self.verify_windy_response(response=resp) except WindyNotInserted: + self.last_status = "not_inserted" + self.last_error = WINDY_NOT_INSERTED self.invalid_response_count += 1 # log despite of settings @@ -229,23 +245,39 @@ class WindyPush: except WindyPasswordMissing: # log despite of settings + self.last_status = "auth_error" + self.last_error = WINDY_INVALID_KEY _LOGGER.critical(WINDY_INVALID_KEY) await self._disable_windy( reason="Windy password is missing in payload or Authorization header. Resending is disabled for now. Reconfigure your Windy settings." ) except WindyDuplicatePayloadDetected: + self.last_status = "duplicate" + self.last_error = "Duplicate payload detected by Windy server." _LOGGER.critical( "Duplicate payload detected by Windy server. Will try again later. Max retries before disabling resend function: %s", (WINDY_MAX_RETRIES - self.invalid_response_count), ) self.invalid_response_count += 1 + except WindyRateLimitExceeded: + # log despite of settings + self.last_status = "rate_limited_remote" + self.last_error = "Windy rate limit exceeded." + _LOGGER.critical( + "Windy responded with WindyRateLimitExceeded, this should happend only on restarting Home Assistant when we lost track of last send time. Pause resend for next 5 minutes." + ) + self.next_update = datetime.now() + timedelta(minutes=5) except WindySuccess: # reset invalid_response_count self.invalid_response_count = 0 + self.last_status = "ok" + self.last_error = None if self.log: _LOGGER.info(WINDY_SUCCESS) else: + self.last_status = "unexpected_response" + self.last_error = "Unexpected response from Windy." if self.log: self.invalid_response_count += 1 _LOGGER.debug( @@ -262,6 +294,8 @@ class WindyPush: ) except ClientError as ex: + self.last_status = "client_error" + self.last_error = str(ex) _LOGGER.critical( "Invalid response from Windy: %s. Will try again later, max retries before disabling resend function: %s", str(ex),