Add health diagnostics coordinator and routing snapshot

Track ingress/forwarding status, expose detailed health sensors and translations, and include redacted diagnostics data.
ecowitt_support
SchiZzA 2026-03-14 17:39:52 +01:00
parent 39b16afcbc
commit 63660006ea
No known key found for this signature in database
12 changed files with 1031 additions and 151 deletions

View File

@ -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. period where no entities are subscribed, causing stale states until another full reload/restart.
""" """
from asyncio import timeout
import logging import logging
from typing import Any from typing import Any
from aiohttp import ClientConnectionError
import aiohttp.web import aiohttp.web
from aiohttp.web_exceptions import HTTPUnauthorized from aiohttp.web_exceptions import HTTPUnauthorized
from py_typecheck import checked, checked_or from py_typecheck import checked, checked_or
from homeassistant.components.network import async_get_source_ip
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@ -44,8 +41,6 @@ from homeassistant.exceptions import (
InvalidStateError, InvalidStateError,
PlatformNotReady, 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 homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import ( from .const import (
@ -61,6 +56,7 @@ from .const import (
WSLINK_URL, WSLINK_URL,
) )
from .data import ENTRY_COORDINATOR, ENTRY_HEALTH_COORD, ENTRY_LAST_OPTIONS from .data import ENTRY_COORDINATOR, ENTRY_HEALTH_COORD, ENTRY_LAST_OPTIONS
from .health_coordinator import HealthCoordinator
from .pocasti_cz import PocasiPush from .pocasti_cz import PocasiPush
from .routes import Routes from .routes import Routes
from .utils import ( from .utils import (
@ -83,76 +79,6 @@ class IncorrectDataError(InvalidStateError):
"""Invalid exception.""" """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: # NOTE:
# We intentionally avoid importing the sensor platform module at import-time here. # We intentionally avoid importing the sensor platform module at import-time here.
# Home Assistant can import modules in different orders; keeping imports acyclic # Home Assistant can import modules in different orders; keeping imports acyclic
@ -182,6 +108,16 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
self.pocasi: PocasiPush = PocasiPush(hass, config) self.pocasi: PocasiPush = PocasiPush(hass, config)
super().__init__(hass, _LOGGER, name=DOMAIN) 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: async def received_data(self, webdata: aiohttp.web.Request) -> aiohttp.web.Response:
"""Handle incoming webhook payload from the station. """Handle incoming webhook payload from the station.
@ -206,13 +142,30 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
# normalize incoming data to dict[str, Any] # normalize incoming data to dict[str, Any]
data: dict[str, Any] = {**dict(get_data), **dict(post_data)} 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). # Validate auth keys (different parameter names depending on endpoint mode).
if not _wslink and ("ID" not in data or "PASSWORD" not in data): if not _wslink and ("ID" not in data or "PASSWORD" not in data):
_LOGGER.error("Invalid request. No security data provided!") _LOGGER.error("Invalid request. No security data provided!")
if health:
health.update_ingress_result(
webdata,
accepted=False,
authorized=False,
reason="missing_credentials",
)
raise HTTPUnauthorized raise HTTPUnauthorized
if _wslink and ("wsid" not in data or "wspw" not in data): if _wslink and ("wsid" not in data or "wspw" not in data):
_LOGGER.error("Invalid request. No security data provided!") _LOGGER.error("Invalid request. No security data provided!")
if health:
health.update_ingress_result(
webdata,
accepted=False,
authorized=False,
reason="missing_credentials",
)
raise HTTPUnauthorized raise HTTPUnauthorized
id_data: str = "" id_data: str = ""
@ -230,30 +183,37 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
if (_id := checked(self.config.options.get(API_ID), str)) is None: if (_id := checked(self.config.options.get(API_ID), str)) is None:
_LOGGER.error("We don't have API ID set! Update your config!") _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 raise IncorrectDataError
if (_key := checked(self.config.options.get(API_KEY), str)) is None: if (_key := checked(self.config.options.get(API_KEY), str)) is None:
_LOGGER.error("We don't have API KEY set! Update your config!") _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 raise IncorrectDataError
if id_data != _id or key_data != _key: if id_data != _id or key_data != _key:
_LOGGER.error("Unauthorised access!") _LOGGER.error("Unauthorised access!")
if health:
health.update_ingress_result(
webdata,
accepted=False,
authorized=False,
reason="unauthorized",
)
raise HTTPUnauthorized 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). # Convert raw payload keys to our internal sensor keys (stable identifiers).
remaped_items: dict[str, str] = ( remaped_items: dict[str, str] = (
remap_wslink_items(data) if _wslink else remap_items(data) remap_wslink_items(data) if _wslink else remap_items(data)
@ -322,6 +282,30 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
# Fan-out update: notify all subscribed entities. # Fan-out update: notify all subscribed entities.
self.async_set_updated_data(remaped_items) 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). # Optional dev logging (keep it lightweight to avoid log spam under high-frequency updates).
if self.config.options.get("dev_debug_checkbox"): 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) _wslink: bool = checked_or(config.options.get(WSLINK), bool, False)
# Load registred routes # Load registred routes
routes: Routes | None = config.options.get("routes", None) routes: Routes | None = hass_data.get("routes", None)
if not isinstance(routes, Routes): if not isinstance(routes, Routes):
routes = Routes() routes = Routes()
routes.set_ingress_observer(coordinator_h.record_dispatch)
# Register webhooks in HomeAssistant with dispatcher # Register webhooks in HomeAssistant with dispatcher
try: try:
@ -389,10 +374,16 @@ def register_path(
routes.add_route( routes.add_route(
WSLINK_URL, _wslink_get_route, coordinator.received_data, enabled=_wslink 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( 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: else:
routes.set_ingress_observer(coordinator_h.record_dispatch)
_LOGGER.info("We have already registered routes: %s", routes.show_enabled()) _LOGGER.info("We have already registered routes: %s", routes.show_enabled())
return True return True
@ -461,6 +452,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
routes.switch_route( routes.switch_route(
coordinator.received_data, DEFAULT_URL if not _wslink else WSLINK_URL 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()) _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)
@ -468,6 +461,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if not routes_enabled: if not routes_enabled:
_LOGGER.error("Fatal: path not registered!") _LOGGER.error("Fatal: path not registered!")
raise PlatformNotReady 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) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

View File

@ -51,7 +51,7 @@ class InvalidAuth(HomeAssistantError):
class ConfigOptionsFlowHandler(OptionsFlow): class ConfigOptionsFlowHandler(OptionsFlow):
"""Handle WeatherStation ConfigFlow.""" """Handle WeatherStation ConfigFlow."""
def __init__(self, config_entry: ConfigEntry) -> None: def __init__(self) -> None:
"""Initialize flow.""" """Initialize flow."""
super().__init__() super().__init__()
@ -353,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(config_entry=config_entry) return ConfigOptionsFlowHandler()

View File

@ -3,8 +3,6 @@
from enum import StrEnum from enum import StrEnum
from typing import Final from typing import Final
from .channels import *
# Integration specific constants. # Integration specific constants.
DOMAIN = "sws12500" DOMAIN = "sws12500"
DATABASE_PATH = "/config/home-assistant_v2.db" 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 specific constants
HEALTH_URL = "/station/health" HEALTH_URL = "/station/health"
@ -53,7 +106,7 @@ PURGE_DATA: Final = [
] ]
REMAP_ITEMS: dict[str, str] = { REMAP_ITEMS: dict[str, str] = {
"baromin": .channels.BARO_PRESSURE, "baromin": BARO_PRESSURE,
"tempf": OUTSIDE_TEMP, "tempf": OUTSIDE_TEMP,
"dewptf": DEW_POINT, "dewptf": DEW_POINT,
"humidity": OUTSIDE_HUMIDITY, "humidity": OUTSIDE_HUMIDITY,
@ -74,10 +127,11 @@ REMAP_ITEMS: dict[str, str] = {
"soilmoisture3": CH4_HUMIDITY, "soilmoisture3": CH4_HUMIDITY,
"soiltemp4f": CH5_TEMP, "soiltemp4f": CH5_TEMP,
"soilmoisture4": CH5_HUMIDITY, "soilmoisture4": CH5_HUMIDITY,
"soiltemp5f": CH6_TEMP,
"soilmoisture5": CH6_HUMIDITY,
} }
WSLINK_URL = "/data/upload.php" WSLINK_URL = "/data/upload.php"
WINDY_URL = "https://stations.windy.com/api/v2/observation/update" 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 POCASI_CZ_SEND_MINIMUM: Final = 12 # minimal time to resend data
WSLINK: Final = "wslink" WSLINK: Final = "wslink"
WINDY_MAX_RETRIES: Final = 3 WINDY_MAX_RETRIES: Final = 3
@ -208,7 +259,6 @@ WINDY_UNEXPECTED: Final = (
) )
PURGE_DATA_POCAS: Final = [ PURGE_DATA_POCAS: Final = [
"ID", "ID",
"PASSWORD", "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: """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. I have no option to test, if it will work correctly. So their implementatnion will be in future releases.

View File

@ -18,3 +18,4 @@ ENTRY_ADD_ENTITIES: Final[str] = "async_add_entities"
ENTRY_DESCRIPTIONS: Final[str] = "sensor_descriptions" ENTRY_DESCRIPTIONS: Final[str] = "sensor_descriptions"
ENTRY_LAST_OPTIONS: Final[str] = "last_options" ENTRY_LAST_OPTIONS: Final[str] = "last_options"
ENTRY_HEALTH_COORD: Final[str] = "coord_h" ENTRY_HEALTH_COORD: Final[str] = "coord_h"
ENTRY_HEALTH_DATA: Final[str] = "health_data"

View File

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

View File

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

View File

@ -1,15 +1,19 @@
"""Health diagnostic sensor for SWS-12500. """Health diagnostic sensors 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`.
"""
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from functools import cached_property from functools import cached_property
from typing import Any, cast 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.config_entries import ConfigEntry
from homeassistant.const import EntityCategory from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant 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 import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
from .const import DOMAIN from .const import DOMAIN
from .data import ENTRY_HEALTH_COORD from .data import ENTRY_HEALTH_COORD
@dataclass(frozen=True, kw_only=True)
class HealthSensorEntityDescription(SensorEntityDescription): class HealthSensorEntityDescription(SensorEntityDescription):
"""Description for health diagnostic sensors.""" """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, ...] = ( HEALTH_SENSOR_DESCRIPTIONS: tuple[HealthSensorEntityDescription, ...] = (
HealthSensorEntityDescription( HealthSensorEntityDescription(
key="Integration status", key="integration_health",
name="Integration status", translation_key="integration_health",
icon="mdi:heart-pulse", icon="mdi:heart-pulse",
data_path=("integration_status",),
), ),
HealthSensorEntityDescription( HealthSensorEntityDescription(
key="HomeAssistant source_ip", key="active_protocol",
name="Home Assistant source IP", translation_key="active_protocol",
icon="mdi:ip", icon="mdi:swap-horizontal",
data_path=("active_protocol",),
), ),
HealthSensorEntityDescription( HealthSensorEntityDescription(
key="HomeAssistant base_url", key="wslink_addon_status",
name="Home Assistant base URL", translation_key="wslink_addon_status",
icon="mdi:link-variant",
),
HealthSensorEntityDescription(
key="WSLink Addon response",
name="WSLink Addon response",
icon="mdi:server-network", 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, entry: ConfigEntry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up the health diagnostic sensor.""" """Set up health diagnostic sensors."""
domain_data_any = hass.data.get(DOMAIN) if (data := checked(hass.data.get(DOMAIN), dict[str, Any])) is None:
if not isinstance(domain_data_any, dict):
return return
domain_data = cast("dict[str, Any]", domain_data_any)
entry_data_any = domain_data.get(entry.entry_id) if (entry_data := checked(data.get(entry.entry_id), dict[str, Any])) is None:
if not isinstance(entry_data_any, dict):
return return
entry_data = cast("dict[str, Any]", entry_data_any)
coordinator_any = entry_data.get(ENTRY_HEALTH_COORD) coordinator = entry_data.get(ENTRY_HEALTH_COORD)
if coordinator_any is None: if coordinator is None:
return return
entities = [ entities = [
HealthDiagnosticSensor( HealthDiagnosticSensor(coordinator=coordinator, description=description)
coordinator=coordinator_any, entry=entry, description=description
)
for description in HEALTH_SENSOR_DESCRIPTIONS for description in HEALTH_SENSOR_DESCRIPTIONS
] ]
async_add_entities(entities) async_add_entities(entities)
# async_add_entities([HealthDiagnosticSensor(coordinator_any, entry)])
class HealthDiagnosticSensor( # pyright: ignore[reportIncompatibleVariableOverride] class HealthDiagnosticSensor( # pyright: ignore[reportIncompatibleVariableOverride]
@ -92,33 +232,33 @@ class HealthDiagnosticSensor( # pyright: ignore[reportIncompatibleVariableOverr
def __init__( def __init__(
self, self,
coordinator: Any, coordinator: Any,
entry: ConfigEntry,
description: HealthSensorEntityDescription, description: HealthSensorEntityDescription,
) -> None: ) -> None:
"""Initialize the sensor.""" """Initialize the sensor."""
super().__init__(coordinator) super().__init__(coordinator)
self.entity_description = description
self._attr_entity_category = EntityCategory.DIAGNOSTIC self._attr_entity_category = EntityCategory.DIAGNOSTIC
self._attr_unique_id = f"{description.key}_health" self._attr_unique_id = f"{description.key}_health"
# self._attr_name = description.name
# self._attr_icon = "mdi:heart-pulse"
@property @property
def native_value(self) -> str | None: # pyright: ignore[reportIncompatibleVariableOverride] def native_value(self) -> Any: # pyright: ignore[reportIncompatibleVariableOverride]
"""Return a compact health state.""" """Return the current diagnostic value."""
data = cast("dict[str, Any]", getattr(self.coordinator, "data", {}) or {}) data = checked_or(self.coordinator.data, dict[str, Any], {})
value = data.get("Integration status")
return cast("str | None", value) 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 @property
def extra_state_attributes(self) -> dict[str, Any] | None: # pyright: ignore[reportIncompatibleVariableOverride] def extra_state_attributes(self) -> dict[str, Any] | None: # pyright: ignore[reportIncompatibleVariableOverride]
"""Return detailed health diagnostics as attributes.""" """Expose the full health JSON on the main health sensor for debugging."""
if self.entity_description.key != "integration_health":
data_any = getattr(self.coordinator, "data", None)
if not isinstance(data_any, dict):
return None return None
return cast("dict[str, Any]", data_any)
return checked_or(self.coordinator.data, dict[str, Any], None)
@cached_property @cached_property
def device_info(self) -> DeviceInfo: def device_info(self) -> DeviceInfo:

View File

@ -48,6 +48,10 @@ class PocasiPush:
"""Init.""" """Init."""
self.hass = hass self.hass = hass
self.config = config 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._interval = int(self.config.options.get(POCASI_CZ_SEND_INTERVAL, 30))
self.last_update = datetime.now() self.last_update = datetime.now()
@ -76,11 +80,16 @@ class PocasiPush:
"""Pushes weather data to server.""" """Pushes weather data to server."""
_data = data.copy() _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: if (_api_id := checked(self.config.options.get(POCASI_CZ_API_ID), str)) is None:
_LOGGER.error( _LOGGER.error(
"No API ID is provided for Pocasi Meteo. Check your configuration." "No API ID is provided for Pocasi Meteo. Check your configuration."
) )
self.last_status = "config_error"
self.last_error = "Missing API ID."
return return
if ( if (
@ -89,6 +98,8 @@ class PocasiPush:
_LOGGER.error( _LOGGER.error(
"No API Key is provided for Pocasi Meteo. Check your configuration." "No API Key is provided for Pocasi Meteo. Check your configuration."
) )
self.last_status = "config_error"
self.last_error = "Missing API key."
return return
if self.log: if self.log:
@ -99,6 +110,7 @@ class PocasiPush:
) )
if self.next_update > datetime.now(): if self.next_update > datetime.now():
self.last_status = "rate_limited_local"
_LOGGER.debug( _LOGGER.debug(
"Triggered update interval limit of %s seconds. Next possilbe update is set to: %s", "Triggered update interval limit of %s seconds. Next possilbe update is set to: %s",
self._interval, self._interval,
@ -132,19 +144,29 @@ class PocasiPush:
except PocasiApiKeyError: except PocasiApiKeyError:
# log despite of settings # log despite of settings
self.last_status = "auth_error"
self.last_error = POCASI_INVALID_KEY
self.enabled = False
_LOGGER.critical(POCASI_INVALID_KEY) _LOGGER.critical(POCASI_INVALID_KEY)
await update_options( await update_options(
self.hass, self.config, POCASI_CZ_ENABLED, False self.hass, self.config, POCASI_CZ_ENABLED, False
) )
except PocasiSuccess: except PocasiSuccess:
self.last_status = "ok"
self.last_error = None
if self.log: if self.log:
_LOGGER.info(POCASI_CZ_SUCCESS) _LOGGER.info(POCASI_CZ_SUCCESS)
else:
self.last_status = "ok"
except ClientError as ex: except ClientError as ex:
self.last_status = "client_error"
self.last_error = str(ex)
_LOGGER.critical("Invalid response from Pocasi Meteo: %s", str(ex)) _LOGGER.critical("Invalid response from Pocasi Meteo: %s", str(ex))
self.invalid_response_count += 1 self.invalid_response_count += 1
if self.invalid_response_count > 3: if self.invalid_response_count > 3:
_LOGGER.critical(POCASI_CZ_UNEXPECTED) _LOGGER.critical(POCASI_CZ_UNEXPECTED)
self.enabled = False
await update_options(self.hass, self.config, POCASI_CZ_ENABLED, False) await update_options(self.hass, self.config, POCASI_CZ_ENABLED, False)
self.last_update = datetime.now() self.last_update = datetime.now()

View File

@ -18,12 +18,14 @@ Important note:
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field from dataclasses import dataclass, field
import logging import logging
from typing import Any
from aiohttp.web import AbstractRoute, Request, Response from aiohttp.web import AbstractRoute, Request, Response
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
Handler = Callable[[Request], Awaitable[Response]] Handler = Callable[[Request], Awaitable[Response]]
IngressObserver = Callable[[Request, bool, str | None], None]
@dataclass @dataclass
@ -38,6 +40,7 @@ class RouteInfo:
route: AbstractRoute route: AbstractRoute
handler: Handler handler: Handler
enabled: bool = False enabled: bool = False
sticky: bool = False
fallback: Handler = field(default_factory=lambda: unregistered) fallback: Handler = field(default_factory=lambda: unregistered)
@ -57,6 +60,11 @@ class Routes:
def __init__(self) -> None: def __init__(self) -> None:
"""Initialize dispatcher storage.""" """Initialize dispatcher storage."""
self.routes: dict[str, RouteInfo] = {} 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: 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."""
@ -66,17 +74,30 @@ class Routes:
_LOGGER.debug( _LOGGER.debug(
"Route (%s):%s is not registered!", request.method, request.path "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) 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 handler = info.handler if info.enabled else info.fallback
return await handler(request) return await handler(request)
def switch_route(self, handler: Handler, url_path: str) -> None: 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 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 route in self.routes.values(): for route in self.routes.values():
if route.sticky:
continue
if route.url_path == url_path: if route.url_path == url_path:
_LOGGER.info( _LOGGER.info(
"New coordinator to route: (%s):%s", "New coordinator to route: (%s):%s",
@ -96,6 +117,7 @@ class Routes:
handler: Handler, handler: Handler,
*, *,
enabled: bool = False, enabled: bool = False,
sticky: bool = False,
) -> None: ) -> None:
"""Register a route in the dispatcher. """Register a route in the dispatcher.
@ -104,7 +126,7 @@ class Routes:
""" """
key = f"{route.method}:{url_path}" key = f"{route.method}:{url_path}"
self.routes[key] = RouteInfo( 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) _LOGGER.debug("Registered dispatcher for route (%s):%s", route.method, url_path)
@ -120,6 +142,24 @@ class Routes:
return "No routes are enabled." return "No routes are enabled."
return ", ".join(sorted(enabled_routes)) 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: async def unregistered(request: Request) -> Response:
"""Fallback response for unknown/disabled routes. """Fallback response for unknown/disabled routes.

View File

@ -124,6 +124,101 @@
}, },
"entity": { "entity": {
"sensor": { "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": { "indoor_temp": {
"name": "Vnitřní teplota" "name": "Vnitřní teplota"
}, },

View File

@ -118,6 +118,101 @@
}, },
"entity": { "entity": {
"sensor": { "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": { "indoor_temp": {
"name": "Indoor temperature" "name": "Indoor temperature"
}, },

View File

@ -78,6 +78,10 @@ class WindyPush:
"""Init.""" """Init."""
self.hass = hass self.hass = hass
self.config = config 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 """ lets wait for 1 minute to get initial data from station
and then try to push first data to Windy and then try to push first data to Windy
@ -142,6 +146,9 @@ class WindyPush:
async def _disable_windy(self, reason: str) -> None: async def _disable_windy(self, reason: str) -> None:
"""Disable Windy resending.""" """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): if not await update_options(self.hass, self.config, WINDY_ENABLED, False):
_LOGGER.debug("Failed to set Windy options to 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. # 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 ( if (
windy_station_id := checked(self.config.options.get(WINDY_STATION_ID), str) windy_station_id := checked(self.config.options.get(WINDY_STATION_ID), str)
) is None: ) is None:
_LOGGER.error("Windy API key is not provided! Check your configuration.") _LOGGER.error("Windy API key is not provided! Check your configuration.")
self.last_status = "config_error"
await self._disable_windy( await self._disable_windy(
"Windy API key is not provided. Resending is disabled for now. Reconfigure your integration." "Windy API key is not provided. Resending is disabled for now. Reconfigure your integration."
) )
@ -175,6 +187,7 @@ class WindyPush:
_LOGGER.error( _LOGGER.error(
"Windy station password is missing! Check your configuration." "Windy station password is missing! Check your configuration."
) )
self.last_status = "config_error"
await self._disable_windy( await self._disable_windy(
"Windy password is not provided. Resending is disabled for now. Reconfigure your integration." "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(): if self.next_update > datetime.now():
self.last_status = "rate_limited_local"
return False return False
purged_data = data.copy() purged_data = data.copy()
@ -218,6 +232,8 @@ class WindyPush:
try: try:
self.verify_windy_response(response=resp) self.verify_windy_response(response=resp)
except WindyNotInserted: except WindyNotInserted:
self.last_status = "not_inserted"
self.last_error = WINDY_NOT_INSERTED
self.invalid_response_count += 1 self.invalid_response_count += 1
# log despite of settings # log despite of settings
@ -229,23 +245,39 @@ class WindyPush:
except WindyPasswordMissing: except WindyPasswordMissing:
# log despite of settings # log despite of settings
self.last_status = "auth_error"
self.last_error = WINDY_INVALID_KEY
_LOGGER.critical(WINDY_INVALID_KEY) _LOGGER.critical(WINDY_INVALID_KEY)
await self._disable_windy( await self._disable_windy(
reason="Windy password is missing in payload or Authorization header. Resending is disabled for now. Reconfigure your Windy settings." reason="Windy password is missing in payload or Authorization header. Resending is disabled for now. Reconfigure your Windy settings."
) )
except WindyDuplicatePayloadDetected: except WindyDuplicatePayloadDetected:
self.last_status = "duplicate"
self.last_error = "Duplicate payload detected by Windy server."
_LOGGER.critical( _LOGGER.critical(
"Duplicate payload detected by Windy server. Will try again later. Max retries before disabling resend function: %s", "Duplicate payload detected by Windy server. Will try again later. Max retries before disabling resend function: %s",
(WINDY_MAX_RETRIES - self.invalid_response_count), (WINDY_MAX_RETRIES - self.invalid_response_count),
) )
self.invalid_response_count += 1 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: except WindySuccess:
# reset invalid_response_count # reset invalid_response_count
self.invalid_response_count = 0 self.invalid_response_count = 0
self.last_status = "ok"
self.last_error = None
if self.log: if self.log:
_LOGGER.info(WINDY_SUCCESS) _LOGGER.info(WINDY_SUCCESS)
else: else:
self.last_status = "unexpected_response"
self.last_error = "Unexpected response from Windy."
if self.log: if self.log:
self.invalid_response_count += 1 self.invalid_response_count += 1
_LOGGER.debug( _LOGGER.debug(
@ -262,6 +294,8 @@ class WindyPush:
) )
except ClientError as ex: except ClientError as ex:
self.last_status = "client_error"
self.last_error = str(ex)
_LOGGER.critical( _LOGGER.critical(
"Invalid response from Windy: %s. Will try again later, max retries before disabling resend function: %s", "Invalid response from Windy: %s. Will try again later, max retries before disabling resend function: %s",
str(ex), str(ex),