Compare commits

...

4 Commits

Author SHA1 Message Date
SchiZzA 7d1494f29b
Removed extended debugging info from sensors. Added descriptive method on route info. 2026-03-01 17:32:36 +01:00
SchiZzA 01058a07b4
Add WSLink support for additional sensor channels
- Extend constants and WSLink key remapping for channels 3–8 (temp, humidity, battery and connection)
- Add new WSLink sensor entity descriptions for the extra channel readings
- Update English translations for the newly added channel sensors and battery states
2026-03-01 16:56:46 +01:00
SchiZzA f06f8b31ae
Align Windy resend with Stations API response handling
- Add WINDY_MAX_RETRIES constant and use it consistently when deciding to disable resending
- Refactor Windy response verification to rely on HTTP status codes per stations.windy.com API
- Improve error handling for missing password, duplicate payloads and rate limiting
- Enhance retry logging and disable Windy resend via persistent notification on repeated failures
2026-03-01 13:51:17 +01:00
SchiZzA 95663fd78b
Stabilize webhook routing and config updates
- Register aiohttp webhook routes once and switch the active dispatcher handler on option changes
- Make the internal route registry method-aware (GET/POST) and improve enabled-route logging
- Fix OptionsFlow initialization by passing the config entry and using safe defaults for credentials
- Harden Windy resend by validating credentials early, auto-disabling the feature on invalid responses, and notifying the user
- Update translations for Windy credential validation errors
2026-03-01 12:48:23 +01:00
9 changed files with 495 additions and 116 deletions

View File

@ -197,6 +197,8 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
_wslink: bool = checked_or(self.config.options.get(WSLINK), bool, False) _wslink: bool = checked_or(self.config.options.get(WSLINK), bool, False)
# Incoming station payload is delivered as query params. # Incoming station payload is delivered as query params.
# Some stations posts data in body, so we need to contracts those data.
#
# We copy it to a plain dict so it can be passed around safely. # We copy it to a plain dict so it can be passed around safely.
get_data = webdata.query get_data = webdata.query
post_data = await webdata.post() post_data = await webdata.post()
@ -341,28 +343,52 @@ def register_path(
_wslink: bool = checked_or(config.options.get(WSLINK), bool, False) _wslink: bool = checked_or(config.options.get(WSLINK), bool, False)
# Create internal route dispatcher with provided urls # Load registred routes
routes: Routes = Routes() routes: Routes | None = config.options.get("routes", None)
routes.add_route(DEFAULT_URL, coordinator.received_data, enabled=not _wslink)
routes.add_route(WSLINK_URL, coordinator.received_data, enabled=_wslink)
routes.add_route(HEALTH_URL, coordinator_h.health_status, enabled=True)
# Register webhooks in HomeAssistant with dispatcher if not isinstance(routes, Routes):
try: routes = Routes()
_ = hass.http.app.router.add_get(DEFAULT_URL, routes.dispatch)
_ = hass.http.app.router.add_post(WSLINK_URL, routes.dispatch)
_ = hass.http.app.router.add_get(HEALTH_URL, routes.dispatch)
# Save initialised routes # Register webhooks in HomeAssistant with dispatcher
hass_data["routes"] = routes try:
_default_route = hass.http.app.router.add_get(
DEFAULT_URL, routes.dispatch, name="_default_route"
)
_wslink_post_route = hass.http.app.router.add_post(
WSLINK_URL, routes.dispatch, name="_wslink_post_route"
)
_wslink_get_route = hass.http.app.router.add_get(
WSLINK_URL, routes.dispatch, name="_wslink_get_route"
)
_health_route = hass.http.app.router.add_get(
HEALTH_URL, routes.dispatch, name="_health_route"
)
except RuntimeError as Ex: # Save initialised routes
_LOGGER.critical( hass_data["routes"] = routes
"Routes cannot be added. Integration will not work as expected. %s", Ex
except RuntimeError as Ex:
_LOGGER.critical(
"Routes cannot be added. Integration will not work as expected. %s", Ex
)
raise ConfigEntryNotReady from Ex
# Finally create internal route dispatcher with provided urls, while we have webhooks registered.
routes.add_route(
DEFAULT_URL, _default_route, coordinator.received_data, enabled=not _wslink
)
routes.add_route(
WSLINK_URL, _wslink_post_route, coordinator.received_data, enabled=_wslink
)
routes.add_route(
WSLINK_URL, _wslink_get_route, coordinator.received_data, enabled=_wslink
)
routes.add_route(
HEALTH_URL, _health_route, coordinator_h.health_status, enabled=True
) )
raise ConfigEntryNotReady from Ex
else: else:
return True _LOGGER.info("We have already registered routes: %s", routes.show_enabled())
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
@ -428,7 +454,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if routes: if routes:
_LOGGER.debug("We have routes registered, will try to switch dispatcher.") _LOGGER.debug("We have routes registered, will try to switch dispatcher.")
routes.switch_route(DEFAULT_URL if not _wslink else WSLINK_URL) routes.switch_route(
coordinator.received_data, DEFAULT_URL if not _wslink else WSLINK_URL
)
_LOGGER.debug("%s", routes.show_enabled()) _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)

View File

@ -51,7 +51,7 @@ class InvalidAuth(HomeAssistantError):
class ConfigOptionsFlowHandler(OptionsFlow): class ConfigOptionsFlowHandler(OptionsFlow):
"""Handle WeatherStation ConfigFlow.""" """Handle WeatherStation ConfigFlow."""
def __init__(self) -> None: def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize flow.""" """Initialize flow."""
super().__init__() super().__init__()
@ -66,16 +66,12 @@ class ConfigOptionsFlowHandler(OptionsFlow):
self.ecowitt: dict[str, Any] = {} self.ecowitt: dict[str, Any] = {}
self.ecowitt_schema = {} self.ecowitt_schema = {}
# @property
# def config_entry(self) -> ConfigEntry:
# return self.hass.config_entries.async_get_entry(self.handler)
async def _get_entry_data(self): async def _get_entry_data(self):
"""Get entry data.""" """Get entry data."""
self.user_data = { self.user_data = {
API_ID: self.config_entry.options.get(API_ID), API_ID: self.config_entry.options.get(API_ID, ""),
API_KEY: self.config_entry.options.get(API_KEY), API_KEY: self.config_entry.options.get(API_KEY, ""),
WSLINK: self.config_entry.options.get(WSLINK, False), WSLINK: self.config_entry.options.get(WSLINK, False),
DEV_DBG: self.config_entry.options.get(DEV_DBG, False), DEV_DBG: self.config_entry.options.get(DEV_DBG, False),
} }
@ -159,6 +155,7 @@ class ConfigOptionsFlowHandler(OptionsFlow):
async def async_step_init(self, user_input: dict[str, Any] = {}): async def async_step_init(self, user_input: dict[str, Any] = {}):
"""Manage the options - show menu first.""" """Manage the options - show menu first."""
_ = user_input
return self.async_show_menu( return self.async_show_menu(
step_id="init", menu_options=["basic", "ecowitt", "windy", "pocasi"] step_id="init", menu_options=["basic", "ecowitt", "windy", "pocasi"]
) )
@ -356,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() return ConfigOptionsFlowHandler(config_entry=config_entry)

View File

@ -25,6 +25,8 @@ SENSOR_TO_MIGRATE: Final = "sensor_to_migrate"
DEV_DBG: Final = "dev_debug_checkbox" DEV_DBG: Final = "dev_debug_checkbox"
WSLINK: Final = "wslink" WSLINK: Final = "wslink"
WINDY_MAX_RETRIES: Final = 3
__all__ = [ __all__ = [
"DOMAIN", "DOMAIN",
"DEFAULT_URL", "DEFAULT_URL",
@ -198,9 +200,27 @@ CH2_BATTERY: Final = "ch2_battery"
CH3_TEMP: Final = "ch3_temp" CH3_TEMP: Final = "ch3_temp"
CH3_HUMIDITY: Final = "ch3_humidity" CH3_HUMIDITY: Final = "ch3_humidity"
CH3_CONNECTION: Final = "ch3_connection" CH3_CONNECTION: Final = "ch3_connection"
CH3_BATTERY: Final = "ch3_battery"
CH4_TEMP: Final = "ch4_temp" CH4_TEMP: Final = "ch4_temp"
CH4_HUMIDITY: Final = "ch4_humidity" CH4_HUMIDITY: Final = "ch4_humidity"
CH4_CONNECTION: Final = "ch4_connection" 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" HEAT_INDEX: Final = "heat_index"
CHILL_INDEX: Final = "chill_index" CHILL_INDEX: Final = "chill_index"
WBGT_TEMP: Final = "wbgt_temp" WBGT_TEMP: Final = "wbgt_temp"
@ -226,6 +246,8 @@ REMAP_ITEMS: dict[str, str] = {
"soilmoisture2": CH3_HUMIDITY, "soilmoisture2": CH3_HUMIDITY,
"soiltemp3f": CH4_TEMP, "soiltemp3f": CH4_TEMP,
"soilmoisture3": CH4_HUMIDITY, "soilmoisture3": CH4_HUMIDITY,
"soiltemp4f": CH5_TEMP,
"soilmoisture4": CH5_HUMIDITY,
} }
REMAP_WSLINK_ITEMS: dict[str, str] = { REMAP_WSLINK_ITEMS: dict[str, str] = {
@ -247,6 +269,11 @@ REMAP_WSLINK_ITEMS: dict[str, str] = {
"t1cn": OUTSIDE_CONNECTION, "t1cn": OUTSIDE_CONNECTION,
"t234c1cn": CH2_CONNECTION, "t234c1cn": CH2_CONNECTION,
"t234c2cn": CH3_CONNECTION, "t234c2cn": CH3_CONNECTION,
"t234c3cn": CH4_CONNECTION,
"t234c4cn": CH5_CONNECTION,
"t234c5cn": CH6_CONNECTION,
"t234c6cn": CH7_CONNECTION,
"t234c7cn": CH8_CONNECTION,
"t1chill": CHILL_INDEX, "t1chill": CHILL_INDEX,
"t1heat": HEAT_INDEX, "t1heat": HEAT_INDEX,
"t1rainhr": HOURLY_RAIN, "t1rainhr": HOURLY_RAIN,
@ -255,9 +282,25 @@ REMAP_WSLINK_ITEMS: dict[str, str] = {
"t1rainyr": YEARLY_RAIN, "t1rainyr": YEARLY_RAIN,
"t234c2tem": CH3_TEMP, "t234c2tem": CH3_TEMP,
"t234c2hum": CH3_HUMIDITY, "t234c2hum": CH3_HUMIDITY,
"t234c3tem": CH4_TEMP,
"t234c3hum": CH4_HUMIDITY,
"t234c4tem": CH5_TEMP,
"t234c4hum": CH5_HUMIDITY,
"t234c5tem": CH6_TEMP,
"t234c5hum": CH6_HUMIDITY,
"t234c6tem": CH7_TEMP,
"t234c6hum": CH7_HUMIDITY,
"t234c7tem": CH8_TEMP,
"t234c7hum": CH8_HUMIDITY,
"t1bat": OUTSIDE_BATTERY, "t1bat": OUTSIDE_BATTERY,
"inbat": INDOOR_BATTERY, "inbat": INDOOR_BATTERY,
"t234c1bat": CH2_BATTERY, "t234c1bat": CH2_BATTERY,
"t234c2bat": CH3_BATTERY,
"t234c3bat": CH4_BATTERY,
"t234c4bat": CH5_BATTERY,
"t234c5bat": CH6_BATTERY,
"t234c6bat": CH7_BATTERY,
"t234c7bat": CH8_BATTERY,
"t1wbgt": WBGT_TEMP, "t1wbgt": WBGT_TEMP,
} }
@ -274,8 +317,22 @@ DISABLED_BY_DEFAULT: Final = [
CH2_BATTERY, CH2_BATTERY,
CH3_TEMP, CH3_TEMP,
CH3_HUMIDITY, CH3_HUMIDITY,
CH3_BATTERY,
CH4_TEMP, CH4_TEMP,
CH4_HUMIDITY, CH4_HUMIDITY,
CH4_BATTERY,
CH5_TEMP,
CH5_HUMIDITY,
CH5_BATTERY,
CH6_TEMP,
CH6_HUMIDITY,
CH6_BATTERY,
CH7_TEMP,
CH7_HUMIDITY,
CH7_BATTERY,
CH8_TEMP,
CH8_HUMIDITY,
CH8_BATTERY,
OUTSIDE_BATTERY, OUTSIDE_BATTERY,
WBGT_TEMP, WBGT_TEMP,
] ]
@ -284,6 +341,13 @@ BATTERY_LIST = [
OUTSIDE_BATTERY, OUTSIDE_BATTERY,
INDOOR_BATTERY, INDOOR_BATTERY,
CH2_BATTERY, CH2_BATTERY,
CH2_BATTERY,
CH3_BATTERY,
CH4_BATTERY,
CH5_BATTERY,
CH6_BATTERY,
CH7_BATTERY,
CH8_BATTERY,
] ]

View File

@ -19,7 +19,7 @@ from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field from dataclasses import dataclass, field
import logging import logging
from aiohttp.web import Request, Response from aiohttp.web import AbstractRoute, Request, Response
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -35,10 +35,16 @@ class RouteInfo:
""" """
url_path: str url_path: str
route: AbstractRoute
handler: Handler handler: Handler
enabled: bool = False enabled: bool = False
fallback: Handler = field(default_factory=lambda: unregistered) fallback: Handler = field(default_factory=lambda: unregistered)
def __str__(self):
"""Return string representation."""
return f"RouteInfo(url_path={self.url_path}, route={self.route}, handler={self.handler}, enabled={self.enabled}, fallback={self.fallback})"
class Routes: class Routes:
"""Simple route dispatcher. """Simple route dispatcher.
@ -54,41 +60,65 @@ class Routes:
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."""
info = self.routes.get(request.path) key = f"{request.method}:{request.path}"
info = self.routes.get(key)
if not info: if not info:
_LOGGER.debug("Route %s is not registered!", request.path) _LOGGER.debug(
"Route (%s):%s is not registered!", request.method, request.path
)
return await unregistered(request) return await unregistered(request)
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, url_path: str) -> None: def switch_route(self, handler: Handler, url_path: str) -> None:
"""Enable exactly one route and disable all others. """Enable exactly one route and disable all others.
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 path, info in self.routes.items(): for route in self.routes.values():
info.enabled = path == url_path if route.url_path == url_path:
_LOGGER.info(
"New coordinator to route: (%s):%s",
route.route.method,
route.url_path,
)
route.enabled = True
route.handler = handler
else:
route.enabled = False
route.handler = unregistered
def add_route( def add_route(
self, url_path: str, handler: Handler, *, enabled: bool = False self,
url_path: str,
route: AbstractRoute,
handler: Handler,
*,
enabled: bool = False,
) -> None: ) -> None:
"""Register a route in the dispatcher. """Register a route in the dispatcher.
This does not register anything in aiohttp. It only stores routing metadata that This does not register anything in aiohttp. It only stores routing metadata that
`dispatch` uses after aiohttp has routed the request by path. `dispatch` uses after aiohttp has routed the request by path.
""" """
self.routes[url_path] = RouteInfo(url_path, handler, enabled=enabled) key = f"{route.method}:{url_path}"
_LOGGER.debug("Registered dispatcher for route %s", url_path) self.routes[key] = RouteInfo(
url_path, route=route, handler=handler, enabled=enabled
)
_LOGGER.debug("Registered dispatcher for route (%s):%s", route.method, url_path)
def show_enabled(self) -> str: def show_enabled(self) -> str:
"""Return a human-readable description of the currently enabled route.""" """Return a human-readable description of the currently enabled route."""
for url, route in self.routes.items():
if route.enabled: enabled_routes = {
return ( f"Dispatcher enabled for ({route.route.method}):{route.url_path}, with handler: {route.handler}"
f"Dispatcher enabled for URL: {url}, with handler: {route.handler}" for route in self.routes.values()
) if route.enabled
return "No routes is enabled." }
return ", ".join(
sorted(enabled_routes) if enabled_routes else "No routes are enabled."
)
async def unregistered(request: Request) -> Response: async def unregistered(request: Request) -> Response:

View File

@ -233,15 +233,6 @@ class WeatherSensor( # pyright: ignore[reportIncompatibleVariableOverride]
description = cast("WeatherSensorEntityDescription", self.entity_description) description = cast("WeatherSensorEntityDescription", self.entity_description)
if self._dev_log:
_LOGGER.debug(
"native_value start: key=%s, has_value_from_data_fn=%s, has_value_fn=%s, data_keys=%s",
key,
description.value_from_data_fn is not None,
description.value_fn is not None,
sorted(data),
)
if description.value_from_data_fn is not None: if description.value_from_data_fn is not None:
try: try:
value = description.value_from_data_fn(data) value = description.value_from_data_fn(data)
@ -250,12 +241,7 @@ class WeatherSensor( # pyright: ignore[reportIncompatibleVariableOverride]
"native_value compute failed via value_from_data_fn for key=%s", key "native_value compute failed via value_from_data_fn for key=%s", key
) )
return None return None
if self._dev_log:
_LOGGER.debug(
"native_value computed via value_from_data_fn: key=%s -> %s",
key,
value,
)
return value return value
raw = data.get(key) raw = data.get(key)
@ -277,14 +263,6 @@ class WeatherSensor( # pyright: ignore[reportIncompatibleVariableOverride]
) )
return None return None
if self._dev_log:
_LOGGER.debug(
"native_value computed via value_fn: key=%s raw=%s -> %s",
key,
raw,
value,
)
return value return value
@property @property

View File

@ -20,8 +20,24 @@ from .const import (
CH2_BATTERY, CH2_BATTERY,
CH2_HUMIDITY, CH2_HUMIDITY,
CH2_TEMP, CH2_TEMP,
CH3_BATTERY,
CH3_HUMIDITY, CH3_HUMIDITY,
CH3_TEMP, CH3_TEMP,
CH4_BATTERY,
CH4_HUMIDITY,
CH4_TEMP,
CH5_BATTERY,
CH5_HUMIDITY,
CH5_TEMP,
CH6_BATTERY,
CH6_HUMIDITY,
CH6_TEMP,
CH7_BATTERY,
CH7_HUMIDITY,
CH7_TEMP,
CH8_BATTERY,
CH8_HUMIDITY,
CH8_TEMP,
CHILL_INDEX, CHILL_INDEX,
DAILY_RAIN, DAILY_RAIN,
DEW_POINT, DEW_POINT,
@ -269,6 +285,149 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
translation_key=CH3_HUMIDITY, translation_key=CH3_HUMIDITY,
value_fn=lambda data: cast("int", data), value_fn=lambda data: cast("int", data),
), ),
WeatherSensorEntityDescription(
key=CH4_TEMP,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.TEMPERATURE,
suggested_unit_of_measurement=UnitOfTemperature.CELSIUS,
icon="mdi:weather-sunny",
translation_key=CH4_TEMP,
value_fn=lambda data: cast("float", data),
),
WeatherSensorEntityDescription(
key=CH4_HUMIDITY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.HUMIDITY,
icon="mdi:weather-sunny",
translation_key=CH4_HUMIDITY,
value_fn=lambda data: cast("int", data),
),
WeatherSensorEntityDescription(
key=CH5_TEMP,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.TEMPERATURE,
suggested_unit_of_measurement=UnitOfTemperature.CELSIUS,
icon="mdi:weather-sunny",
translation_key=CH5_TEMP,
value_fn=lambda data: cast("float", data),
),
WeatherSensorEntityDescription(
key=CH5_HUMIDITY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.HUMIDITY,
icon="mdi:weather-sunny",
translation_key=CH5_HUMIDITY,
value_fn=lambda data: cast("int", data),
),
WeatherSensorEntityDescription(
key=CH6_TEMP,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.TEMPERATURE,
suggested_unit_of_measurement=UnitOfTemperature.CELSIUS,
icon="mdi:weather-sunny",
translation_key=CH6_TEMP,
value_fn=lambda data: cast("float", data),
),
WeatherSensorEntityDescription(
key=CH6_HUMIDITY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.HUMIDITY,
icon="mdi:weather-sunny",
translation_key=CH6_HUMIDITY,
value_fn=lambda data: cast("int", data),
),
WeatherSensorEntityDescription(
key=CH7_TEMP,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.TEMPERATURE,
suggested_unit_of_measurement=UnitOfTemperature.CELSIUS,
icon="mdi:weather-sunny",
translation_key=CH7_TEMP,
value_fn=lambda data: cast("float", data),
),
WeatherSensorEntityDescription(
key=CH7_HUMIDITY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.HUMIDITY,
icon="mdi:weather-sunny",
translation_key=CH7_HUMIDITY,
value_fn=lambda data: cast("int", data),
),
WeatherSensorEntityDescription(
key=CH8_TEMP,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.TEMPERATURE,
suggested_unit_of_measurement=UnitOfTemperature.CELSIUS,
icon="mdi:weather-sunny",
translation_key=CH8_TEMP,
value_fn=lambda data: cast("float", data),
),
WeatherSensorEntityDescription(
key=CH8_HUMIDITY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.HUMIDITY,
icon="mdi:weather-sunny",
translation_key=CH8_HUMIDITY,
value_fn=lambda data: cast("int", data),
),
WeatherSensorEntityDescription(
key=HEAT_INDEX,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.ENUM,
value_fn=lambda data: data,
),
WeatherSensorEntityDescription(
key=CH3_BATTERY,
translation_key=CH3_BATTERY,
icon="mdi:battery-unknown",
device_class=SensorDeviceClass.ENUM,
value_fn=lambda data: data,
),
WeatherSensorEntityDescription(
key=CH4_BATTERY,
translation_key=CH4_BATTERY,
icon="mdi:battery-unknown",
device_class=SensorDeviceClass.ENUM,
value_fn=lambda data: data,
),
WeatherSensorEntityDescription(
key=CH5_BATTERY,
translation_key=CH5_BATTERY,
icon="mdi:battery-unknown",
device_class=SensorDeviceClass.ENUM,
value_fn=lambda data: data,
),
WeatherSensorEntityDescription(
key=CH6_BATTERY,
translation_key=CH6_BATTERY,
icon="mdi:battery-unknown",
device_class=SensorDeviceClass.ENUM,
value_fn=lambda data: data,
),
WeatherSensorEntityDescription(
key=CH7_BATTERY,
translation_key=CH7_BATTERY,
icon="mdi:battery-unknown",
device_class=SensorDeviceClass.ENUM,
value_fn=lambda data: data,
),
WeatherSensorEntityDescription(
key=CH8_BATTERY,
translation_key=CH8_BATTERY,
icon="mdi:battery-unknown",
device_class=SensorDeviceClass.ENUM,
value_fn=lambda data: data,
),
WeatherSensorEntityDescription( WeatherSensorEntityDescription(
key=HEAT_INDEX, key=HEAT_INDEX,
native_unit_of_measurement=UnitOfTemperature.CELSIUS, native_unit_of_measurement=UnitOfTemperature.CELSIUS,

View File

@ -29,7 +29,8 @@
"valid_credentials_api": "Vyplňte platné API ID", "valid_credentials_api": "Vyplňte platné API ID",
"valid_credentials_key": "Vyplňte platný API KEY", "valid_credentials_key": "Vyplňte platný API KEY",
"valid_credentials_match": "API ID a API KEY nesmějí být stejné!", "valid_credentials_match": "API ID a API KEY nesmějí být stejné!",
"windy_key_required": "Je vyžadován Windy API key, pokud chcete aktivovat přeposílání dat na Windy", "windy_id_required": "Je vyžadováno Windy ID, pokud chcete aktivovat přeposílání dat na Windy",
"windy_pw_required": "Je vyžadován Windy KEY, pokud chcete aktivovat přeposílání dat na Windy",
"pocasi_id_required": "Je vyžadován Počasí ID, pokud chcete aktivovat přeposílání dat na Počasí Meteo CZ", "pocasi_id_required": "Je vyžadován Počasí ID, pokud chcete aktivovat přeposílání dat na Počasí Meteo CZ",
"pocasi_key_required": "Klíč k účtu Počasí Meteo je povinný.", "pocasi_key_required": "Klíč k účtu Počasí Meteo je povinný.",
"pocasi_send_minimum": "Minimální interval pro přeposílání je 12 sekund." "pocasi_send_minimum": "Minimální interval pro přeposílání je 12 sekund."
@ -73,7 +74,7 @@
}, },
"data_description": { "data_description": {
"WINDY_STATION_ID": "ID stanice získaný z https://stations.windy.com/station", "WINDY_STATION_ID": "ID stanice získaný z https://stations.windy.com/station",
"WINDY_STATION_PWD": "Heslo stanice získané z https://stations.windy.com/station", "WINDY_STATION_PWD": "Heslo stanice získané z https://stations.windy.com/station",
"windy_logger_checkbox": "Zapnout pouze v případě, že chcete poslat ladící informace vývojáři." "windy_logger_checkbox": "Zapnout pouze v případě, že chcete poslat ladící informace vývojáři."
} }
}, },

View File

@ -29,7 +29,8 @@
"valid_credentials_api": "Provide valid API ID.", "valid_credentials_api": "Provide valid API ID.",
"valid_credentials_key": "Provide valid API KEY.", "valid_credentials_key": "Provide valid API KEY.",
"valid_credentials_match": "API ID and API KEY should not be the same.", "valid_credentials_match": "API ID and API KEY should not be the same.",
"windy_key_required": "Windy API key is required if you want to enable this function." "windy_id_required": "Windy API ID is required if you want to enable this function.",
"windy_pw_required": "Windy API password is required if you want to enable this function."
}, },
"step": { "step": {
"init": { "init": {
@ -174,6 +175,30 @@
"ch4_humidity": { "ch4_humidity": {
"name": "Channel 4 humidity" "name": "Channel 4 humidity"
}, },
"ch5_temp": {
"name": "Channel 5 temperature"
},
"ch5_humidity": {
"name": "Channel 5 humidity"
},
"ch6_temp": {
"name": "Channel 6 temperature"
},
"ch6_humidity": {
"name": "Channel 6 humidity"
},
"ch7_temp": {
"name": "Channel 7 temperature"
},
"ch7_humidity": {
"name": "Channel 7 humidity"
},
"ch8_temp": {
"name": "Channel 8 temperature"
},
"ch8_humidity": {
"name": "Channel 8 humidity"
},
"heat_index": { "heat_index": {
"name": "Apparent temperature" "name": "Apparent temperature"
}, },
@ -232,6 +257,54 @@
"unknown": "Unknown / drained out" "unknown": "Unknown / drained out"
} }
}, },
"ch3_battery": {
"name": "Channel 3 battery level",
"state": {
"normal": "OK",
"low": "Low",
"unknown": "Unknown / drained out"
}
},
"ch4_battery": {
"name": "Channel 4 battery level",
"state": {
"normal": "OK",
"low": "Low",
"unknown": "Unknown / drained out"
}
},
"ch5_battery": {
"name": "Channel 5 battery level",
"state": {
"normal": "OK",
"low": "Low",
"unknown": "Unknown / drained out"
}
},
"ch6_battery": {
"name": "Channel 6 battery level",
"state": {
"normal": "OK",
"low": "Low",
"unknown": "Unknown / drained out"
}
},
"ch7_battery": {
"name": "Channel 7 battery level",
"state": {
"normal": "OK",
"low": "Low",
"unknown": "Unknown / drained out"
}
},
"ch8_battery": {
"name": "Channel 8 battery level",
"state": {
"normal": "OK",
"low": "Low",
"unknown": "Unknown / drained out"
}
},
"indoor_battery": { "indoor_battery": {
"name": "Console battery level", "name": "Console battery level",
"state": { "state": {

View File

@ -3,9 +3,11 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
import logging import logging
from aiohttp.client import ClientResponse
from aiohttp.client_exceptions import ClientError from aiohttp.client_exceptions import ClientError
from py_typecheck import checked from py_typecheck import checked
from homeassistant.components import persistent_notification
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
@ -15,6 +17,7 @@ from .const import (
WINDY_ENABLED, WINDY_ENABLED,
WINDY_INVALID_KEY, WINDY_INVALID_KEY,
WINDY_LOGGER_ENABLED, WINDY_LOGGER_ENABLED,
WINDY_MAX_RETRIES,
WINDY_NOT_INSERTED, WINDY_NOT_INSERTED,
WINDY_STATION_ID, WINDY_STATION_ID,
WINDY_STATION_PW, WINDY_STATION_PW,
@ -28,15 +31,36 @@ _LOGGER = logging.getLogger(__name__)
class WindyNotInserted(Exception): class WindyNotInserted(Exception):
"""NotInserted state.""" """NotInserted state.
Possible variants are:
- station password is invalid
- station password does not match the station
- payload failed validation
"""
class WindySuccess(Exception): class WindySuccess(Exception):
"""WindySucces state.""" """WindySucces state."""
class WindyApiKeyError(Exception): class WindyPasswordMissing(Exception):
"""Windy API Key error.""" """Windy password is missing in query or Authorization header.
This should not happend, while we are checking if we have password set and do exits early.
"""
class WindyDuplicatePayloadDetected(Exception):
"""Duplicate payload detected."""
class WindyRateLimitExceeded(Exception):
"""Rate limit exceeded. Minimum interval is 5 minutes.
This should not happend in runnig integration.
Might be seen, if restart of HomeAssistant occured and we are not aware of previous update.
"""
def timed(minutes: int): def timed(minutes: int):
@ -62,28 +86,34 @@ class WindyPush:
self.next_update: datetime = datetime.now() + timed(minutes=1) self.next_update: datetime = datetime.now() + timed(minutes=1)
self.log: bool = self.config.options.get(WINDY_LOGGER_ENABLED, False) self.log: bool = self.config.options.get(WINDY_LOGGER_ENABLED, False)
# Lets chcek if Windy server is responding right.
# Otherwise, try 3 times and then disable resending.
self.invalid_response_count: int = 0 self.invalid_response_count: int = 0
def verify_windy_response( # Refactored responses verification.
self, #
response: str, # We now comply to API at https://stations.windy.com/api-reference
): def verify_windy_response(self, response: ClientResponse):
"""Verify answer form Windy.""" """Verify answer form Windy."""
if self.log: if self.log and response:
_LOGGER.info("Windy response raw response: %s", response) _LOGGER.info("Windy raw response: %s", response.text)
if "NOTICE" in response: if response.status == 200:
raise WindyNotInserted
if "SUCCESS" in response:
raise WindySuccess raise WindySuccess
if "Invalid API key" in response: if response.status == 400:
raise WindyApiKeyError raise WindyNotInserted
if "Unauthorized" in response: if response.status == 401:
raise WindyApiKeyError raise WindyPasswordMissing
if response.status == 409:
raise WindyDuplicatePayloadDetected
if response.status == 429:
raise WindyRateLimitExceeded
def _covert_wslink_to_pws(self, indata: dict[str, str]) -> dict[str, str]: def _covert_wslink_to_pws(self, indata: dict[str, str]) -> dict[str, str]:
"""Convert WSLink API data to Windy API data protocol.""" """Convert WSLink API data to Windy API data protocol."""
@ -110,6 +140,14 @@ class WindyPush:
return indata return indata
async def _disable_windy(self, reason: str) -> None:
"""Disable Windy resending."""
if not await update_options(self.hass, self.config, WINDY_ENABLED, False):
_LOGGER.debug("Failed to set Windy options to false.")
persistent_notification.create(self.hass, reason, "Windy resending disabled.")
async def push_data_to_windy( async def push_data_to_windy(
self, data: dict[str, str], wslink: bool = False self, data: dict[str, str], wslink: bool = False
) -> bool: ) -> bool:
@ -121,6 +159,27 @@ class WindyPush:
from station. But we need to do some clean up. from station. But we need to do some clean up.
""" """
# First check if we have valid credentials, before any data manipulation.
if (
windy_station_id := checked(self.config.options.get(WINDY_STATION_ID), str)
) is None:
_LOGGER.error("Windy API key is not provided! Check your configuration.")
await self._disable_windy(
"Windy API key is not provided. Resending is disabled for now. Reconfigure your integration."
)
return False
if (
windy_station_pw := checked(self.config.options.get(WINDY_STATION_PW), str)
) is None:
_LOGGER.error(
"Windy station password is missing! Check your configuration."
)
await self._disable_windy(
"Windy password is not provided. Resending is disabled for now. Reconfigure your integration."
)
return False
if self.log: if self.log:
_LOGGER.info( _LOGGER.info(
"Windy last update = %s, next update at: %s", "Windy last update = %s, next update at: %s",
@ -139,21 +198,7 @@ class WindyPush:
if wslink: if wslink:
# WSLink -> Windy params # WSLink -> Windy params
self._covert_wslink_to_pws(purged_data) purged_data = self._covert_wslink_to_pws(purged_data)
if (
windy_station_id := checked(self.config.options.get(WINDY_STATION_ID), str)
) is None:
_LOGGER.error("Windy API key is not provided! Check your configuration.")
return False
if (
windy_station_pw := checked(self.config.options.get(WINDY_STATION_PW), str)
) is None:
_LOGGER.error(
"Windy station password is missing! Check your configuration."
)
return False
request_url = f"{WINDY_URL}" request_url = f"{WINDY_URL}"
@ -170,23 +215,25 @@ class WindyPush:
async with session.get( async with session.get(
request_url, params=purged_data, headers=headers request_url, params=purged_data, headers=headers
) as resp: ) as resp:
status = await resp.text()
try: try:
self.verify_windy_response(status) self.verify_windy_response(response=resp)
except WindyNotInserted: except WindyNotInserted:
# log despite of settings # log despite of settings
_LOGGER.error(WINDY_NOT_INSERTED) _LOGGER.error(WINDY_NOT_INSERTED)
self.invalid_response_count += 1
except WindyApiKeyError: except WindyPasswordMissing:
# log despite of settings # log despite of settings
_LOGGER.critical(WINDY_INVALID_KEY) _LOGGER.critical(WINDY_INVALID_KEY)
await self._disable_windy(
if not ( reason="Windy password is missing in payload or Authorization header. Resending is disabled for now. Reconfigure your Windy settings."
await update_options( )
self.hass, self.config, WINDY_ENABLED, False except WindyDuplicatePayloadDetected:
) _LOGGER.critical(
): "Duplicate payload detected by Windy server. Will try again later. Max retries before disabling resend function: %s",
_LOGGER.debug("Failed to set Windy option to false.") (WINDY_MAX_RETRIES - self.invalid_response_count),
)
self.invalid_response_count += 1
except WindySuccess: except WindySuccess:
if self.log: if self.log:
@ -196,15 +243,17 @@ class WindyPush:
_LOGGER.debug(WINDY_NOT_INSERTED) _LOGGER.debug(WINDY_NOT_INSERTED)
except ClientError as ex: except ClientError as ex:
_LOGGER.critical("Invalid response from Windy: %s", str(ex)) _LOGGER.critical(
"Invalid response from Windy: %s. Will try again later, max retries before disabling resend function: %s",
str(ex),
(WINDY_MAX_RETRIES - self.invalid_response_count),
)
self.invalid_response_count += 1 self.invalid_response_count += 1
if self.invalid_response_count > 3: if self.invalid_response_count >= WINDY_MAX_RETRIES:
_LOGGER.critical(WINDY_UNEXPECTED) _LOGGER.critical(WINDY_UNEXPECTED)
if not await update_options( await self._disable_windy(
self.hass, self.config, WINDY_ENABLED, False reason="Invalid response from Windy 3 times. Disabling resending option."
): )
_LOGGER.debug("Failed to set Windy options to false.")
self.last_update = datetime.now() self.last_update = datetime.now()
self.next_update = self.last_update + timed(minutes=5) self.next_update = self.last_update + timed(minutes=5)