Add health diagnostics coordinator and routing snapshot
Track ingress/forwarding status, expose detailed health sensors and translations, and include redacted diagnostics data.ecowitt_support
parent
39b16afcbc
commit
63660006ea
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue