Compare commits

..

No commits in common. "7d1494f29b0069fbad7be7a5fdb6811bef2d62eb" and "cc1afaa2182da0d33cd46849588ff0dc03be7ac1" have entirely different histories.

9 changed files with 115 additions and 494 deletions

View File

@ -197,8 +197,6 @@ 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()
@ -343,52 +341,28 @@ 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 # Create internal route dispatcher with provided urls
routes: Routes | None = config.options.get("routes", None) routes: Routes = Routes()
routes.add_route(DEFAULT_URL, coordinator.received_data, enabled=not _wslink)
routes.add_route(WSLINK_URL, coordinator.received_data, enabled=_wslink)
routes.add_route(HEALTH_URL, coordinator_h.health_status, enabled=True)
if not isinstance(routes, Routes): # Register webhooks in HomeAssistant with dispatcher
routes = Routes() try:
_ = hass.http.app.router.add_get(DEFAULT_URL, routes.dispatch)
_ = hass.http.app.router.add_post(WSLINK_URL, routes.dispatch)
_ = hass.http.app.router.add_get(HEALTH_URL, routes.dispatch)
# Register webhooks in HomeAssistant with dispatcher # Save initialised routes
try: hass_data["routes"] = routes
_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"
)
# Save initialised routes except RuntimeError as Ex:
hass_data["routes"] = routes _LOGGER.critical(
"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:
_LOGGER.info("We have already registered routes: %s", routes.show_enabled()) return True
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
@ -454,9 +428,7 @@ 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( routes.switch_route(DEFAULT_URL if not _wslink else WSLINK_URL)
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, config_entry: ConfigEntry) -> None: def __init__(self) -> None:
"""Initialize flow.""" """Initialize flow."""
super().__init__() super().__init__()
@ -66,12 +66,16 @@ 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),
} }
@ -155,7 +159,6 @@ 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"]
) )
@ -353,4 +356,4 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
@callback @callback
def async_get_options_flow(config_entry: ConfigEntry) -> ConfigOptionsFlowHandler: def async_get_options_flow(config_entry: ConfigEntry) -> ConfigOptionsFlowHandler:
"""Get the options flow for this handler.""" """Get the options flow for this handler."""
return ConfigOptionsFlowHandler(config_entry=config_entry) return ConfigOptionsFlowHandler()

View File

@ -25,8 +25,6 @@ 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",
@ -200,27 +198,9 @@ 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"
@ -246,8 +226,6 @@ 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] = {
@ -269,11 +247,6 @@ 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,
@ -282,25 +255,9 @@ 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,
} }
@ -317,22 +274,8 @@ 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,
] ]
@ -341,13 +284,6 @@ 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 AbstractRoute, Request, Response from aiohttp.web import Request, Response
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -35,16 +35,10 @@ 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.
@ -60,65 +54,41 @@ 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."""
key = f"{request.method}:{request.path}" info = self.routes.get(request.path)
info = self.routes.get(key)
if not info: if not info:
_LOGGER.debug( _LOGGER.debug("Route %s is not registered!", request.path)
"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, handler: Handler, url_path: str) -> None: def switch_route(self, 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 route in self.routes.values(): for path, info in self.routes.items():
if route.url_path == url_path: info.enabled = 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, self, url_path: str, handler: Handler, *, enabled: bool = False
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.
""" """
key = f"{route.method}:{url_path}" self.routes[url_path] = RouteInfo(url_path, handler, enabled=enabled)
self.routes[key] = RouteInfo( _LOGGER.debug("Registered dispatcher for route %s", url_path)
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():
enabled_routes = { if route.enabled:
f"Dispatcher enabled for ({route.route.method}):{route.url_path}, with handler: {route.handler}" return (
for route in self.routes.values() f"Dispatcher enabled for URL: {url}, with handler: {route.handler}"
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,6 +233,15 @@ 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)
@ -241,7 +250,12 @@ 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)
@ -263,6 +277,14 @@ 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,24 +20,8 @@ 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,
@ -285,149 +269,6 @@ 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,8 +29,7 @@
"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_id_required": "Je vyžadováno Windy ID, pokud chcete aktivovat přeposílání dat na Windy", "windy_key_required": "Je vyžadován Windy API key, 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."
@ -74,7 +73,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,8 +29,7 @@
"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_id_required": "Windy API ID is required if you want to enable this function.", "windy_key_required": "Windy API key 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": {
@ -175,30 +174,6 @@
"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"
}, },
@ -257,54 +232,6 @@
"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,11 +3,9 @@
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
@ -17,7 +15,6 @@ 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,
@ -31,36 +28,15 @@ _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 WindyPasswordMissing(Exception): class WindyApiKeyError(Exception):
"""Windy password is missing in query or Authorization header. """Windy API Key error."""
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):
@ -86,34 +62,28 @@ 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
# Refactored responses verification. def verify_windy_response(
# self,
# We now comply to API at https://stations.windy.com/api-reference response: str,
def verify_windy_response(self, response: ClientResponse): ):
"""Verify answer form Windy.""" """Verify answer form Windy."""
if self.log and response: if self.log:
_LOGGER.info("Windy raw response: %s", response.text) _LOGGER.info("Windy response raw response: %s", response)
if response.status == 200: if "NOTICE" in response:
raise WindySuccess
if response.status == 400:
raise WindyNotInserted raise WindyNotInserted
if response.status == 401: if "SUCCESS" in response:
raise WindyPasswordMissing raise WindySuccess
if response.status == 409: if "Invalid API key" in response:
raise WindyDuplicatePayloadDetected raise WindyApiKeyError
if response.status == 429: if "Unauthorized" in response:
raise WindyRateLimitExceeded raise WindyApiKeyError
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."""
@ -140,14 +110,6 @@ 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:
@ -159,27 +121,6 @@ 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",
@ -198,7 +139,21 @@ class WindyPush:
if wslink: if wslink:
# WSLink -> Windy params # WSLink -> Windy params
purged_data = self._covert_wslink_to_pws(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}"
@ -215,25 +170,23 @@ 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(response=resp) self.verify_windy_response(status)
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 WindyPasswordMissing: except WindyApiKeyError:
# log despite of settings # log despite of settings
_LOGGER.critical(WINDY_INVALID_KEY) _LOGGER.critical(WINDY_INVALID_KEY)
await self._disable_windy(
reason="Windy password is missing in payload or Authorization header. Resending is disabled for now. Reconfigure your Windy settings." if not (
) await update_options(
except WindyDuplicatePayloadDetected: self.hass, self.config, WINDY_ENABLED, False
_LOGGER.critical( )
"Duplicate payload detected by Windy server. Will try again later. Max retries before disabling resend function: %s", ):
(WINDY_MAX_RETRIES - self.invalid_response_count), _LOGGER.debug("Failed to set Windy option to false.")
)
self.invalid_response_count += 1
except WindySuccess: except WindySuccess:
if self.log: if self.log:
@ -243,17 +196,15 @@ class WindyPush:
_LOGGER.debug(WINDY_NOT_INSERTED) _LOGGER.debug(WINDY_NOT_INSERTED)
except ClientError as ex: except ClientError as ex:
_LOGGER.critical( _LOGGER.critical("Invalid response from Windy: %s", str(ex))
"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 >= WINDY_MAX_RETRIES: if self.invalid_response_count > 3:
_LOGGER.critical(WINDY_UNEXPECTED) _LOGGER.critical(WINDY_UNEXPECTED)
await self._disable_windy( if not await update_options(
reason="Invalid response from Windy 3 times. Disabling resending option." self.hass, self.config, WINDY_ENABLED, False
) ):
_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)