Merge a3dc3d0d53 into 9f36ab5d4c
commit
5731827224
|
|
@ -1,14 +1,20 @@
|
||||||
"""The Sencor SWS 12500 Weather Station integration."""
|
"""The Sencor SWS 12500 Weather Station integration."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
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 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
|
||||||
from homeassistant.exceptions import InvalidStateError, PlatformNotReady
|
from homeassistant.exceptions import (
|
||||||
|
ConfigEntryNotReady,
|
||||||
|
InvalidStateError,
|
||||||
|
PlatformNotReady,
|
||||||
|
)
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
|
|
@ -24,7 +30,7 @@ from .const import (
|
||||||
WSLINK_URL,
|
WSLINK_URL,
|
||||||
)
|
)
|
||||||
from .pocasti_cz import PocasiPush
|
from .pocasti_cz import PocasiPush
|
||||||
from .routes import Routes, unregistred
|
from .routes import Routes
|
||||||
from .utils import (
|
from .utils import (
|
||||||
anonymize,
|
anonymize,
|
||||||
check_disabled,
|
check_disabled,
|
||||||
|
|
@ -50,19 +56,20 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, config: ConfigEntry) -> None:
|
def __init__(self, hass: HomeAssistant, config: ConfigEntry) -> None:
|
||||||
"""Init global updater."""
|
"""Init global updater."""
|
||||||
self.hass = hass
|
self.hass: HomeAssistant = hass
|
||||||
self.config = config
|
self.config: ConfigEntry = config
|
||||||
self.windy = WindyPush(hass, config)
|
self.windy: WindyPush = WindyPush(hass, config)
|
||||||
self.pocasi: PocasiPush = PocasiPush(hass, config)
|
self.pocasi: PocasiPush = PocasiPush(hass, config)
|
||||||
super().__init__(hass, _LOGGER, name=DOMAIN)
|
super().__init__(hass, _LOGGER, name=DOMAIN)
|
||||||
|
|
||||||
async def recieved_data(self, webdata):
|
async def recieved_data(self, webdata: aiohttp.web.Request) -> aiohttp.web.Response:
|
||||||
"""Handle incoming data query."""
|
"""Handle incoming data query."""
|
||||||
_wslink = self.config_entry.options.get(WSLINK)
|
|
||||||
data = webdata.query
|
|
||||||
|
|
||||||
response = None
|
_wslink: bool = checked_or(self.config.options.get(WSLINK), bool, False)
|
||||||
|
|
||||||
|
data: dict[str, Any] = dict(webdata.query)
|
||||||
|
|
||||||
|
# Check if station is sending auth data
|
||||||
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!")
|
||||||
raise HTTPUnauthorized
|
raise HTTPUnauthorized
|
||||||
|
|
@ -71,44 +78,67 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
|
||||||
_LOGGER.error("Invalid request. No security data provided!")
|
_LOGGER.error("Invalid request. No security data provided!")
|
||||||
raise HTTPUnauthorized
|
raise HTTPUnauthorized
|
||||||
|
|
||||||
if _wslink:
|
id_data: str = ""
|
||||||
id_data = data["wsid"]
|
key_data: str = ""
|
||||||
key_data = data["wspw"]
|
|
||||||
else:
|
|
||||||
id_data = data["ID"]
|
|
||||||
key_data = data["PASSWORD"]
|
|
||||||
|
|
||||||
_id = self.config_entry.options.get(API_ID)
|
if _wslink:
|
||||||
_key = self.config_entry.options.get(API_KEY)
|
id_data = data.get("wsid", "")
|
||||||
|
key_data = data.get("wspw", "")
|
||||||
|
else:
|
||||||
|
id_data = data.get("ID", "")
|
||||||
|
key_data = data.get("PASSWORD", "")
|
||||||
|
|
||||||
|
# Check if we have valid auth data in the integration
|
||||||
|
|
||||||
|
if (_id := checked(self.config.options.get(API_ID), str)) is None:
|
||||||
|
_LOGGER.error("We don't have API ID set! Update your config!")
|
||||||
|
raise IncorrectDataError
|
||||||
|
|
||||||
|
if (_key := checked(self.config.options.get(API_KEY), str)) is None:
|
||||||
|
_LOGGER.error("We don't have API KEY set! Update your config!")
|
||||||
|
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!")
|
||||||
raise HTTPUnauthorized
|
raise HTTPUnauthorized
|
||||||
|
|
||||||
if self.config_entry.options.get(WINDY_ENABLED):
|
if self.config.options.get(WINDY_ENABLED, False):
|
||||||
response = await self.windy.push_data_to_windy(data)
|
await self.windy.push_data_to_windy(data)
|
||||||
|
|
||||||
if self.config.options.get(POCASI_CZ_ENABLED):
|
if self.config.options.get(POCASI_CZ_ENABLED, False):
|
||||||
await self.pocasi.push_data_to_server(data, "WSLINK" if _wslink else "WU")
|
await self.pocasi.push_data_to_server(data, "WSLINK" if _wslink else "WU")
|
||||||
|
|
||||||
remaped_items = (
|
remaped_items: dict[str, str] = (
|
||||||
remap_wslink_items(data)
|
remap_wslink_items(data) if _wslink else remap_items(data)
|
||||||
if self.config_entry.options.get(WSLINK)
|
|
||||||
else remap_items(data)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if sensors := check_disabled(self.hass, remaped_items, self.config):
|
if sensors := check_disabled(remaped_items, self.config):
|
||||||
translate_sensors = [
|
if (
|
||||||
|
translate_sensors := checked(
|
||||||
|
[
|
||||||
await translations(
|
await translations(
|
||||||
self.hass, DOMAIN, f"sensor.{t_key}", key="name", category="entity"
|
self.hass,
|
||||||
|
DOMAIN,
|
||||||
|
f"sensor.{t_key}",
|
||||||
|
key="name",
|
||||||
|
category="entity",
|
||||||
)
|
)
|
||||||
for t_key in sensors
|
for t_key in sensors
|
||||||
if await translations(
|
if await translations(
|
||||||
self.hass, DOMAIN, f"sensor.{t_key}", key="name", category="entity"
|
self.hass,
|
||||||
|
DOMAIN,
|
||||||
|
f"sensor.{t_key}",
|
||||||
|
key="name",
|
||||||
|
category="entity",
|
||||||
)
|
)
|
||||||
is not None
|
is not None
|
||||||
]
|
],
|
||||||
human_readable = "\n".join(translate_sensors)
|
list[str],
|
||||||
|
)
|
||||||
|
) is not None:
|
||||||
|
human_readable: str = "\n".join(translate_sensors)
|
||||||
|
else:
|
||||||
|
human_readable = ""
|
||||||
|
|
||||||
await translated_notification(
|
await translated_notification(
|
||||||
self.hass,
|
self.hass,
|
||||||
|
|
@ -126,82 +156,42 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
|
||||||
if self.config_entry.options.get(DEV_DBG):
|
if self.config_entry.options.get(DEV_DBG):
|
||||||
_LOGGER.info("Dev log: %s", anonymize(data))
|
_LOGGER.info("Dev log: %s", anonymize(data))
|
||||||
|
|
||||||
response = response or "OK"
|
return aiohttp.web.Response(body="OK", status=200)
|
||||||
return aiohttp.web.Response(body=f"{response or 'OK'}", status=200)
|
|
||||||
|
|
||||||
|
|
||||||
def register_path(
|
def register_path(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
url_path: str,
|
|
||||||
coordinator: WeatherDataUpdateCoordinator,
|
coordinator: WeatherDataUpdateCoordinator,
|
||||||
config: ConfigEntry,
|
config: ConfigEntry,
|
||||||
):
|
) -> bool:
|
||||||
"""Register path to handle incoming data."""
|
"""Register paths to webhook."""
|
||||||
|
|
||||||
hass_data = hass.data.setdefault(DOMAIN, {})
|
hass.data.setdefault(DOMAIN, {})
|
||||||
debug = config.options.get(DEV_DBG)
|
if (hass_data := checked(hass.data[DOMAIN], dict[str, Any])) is None:
|
||||||
_wslink = config.options.get(WSLINK, False)
|
raise ConfigEntryNotReady
|
||||||
|
|
||||||
routes: Routes = hass_data.get("routes", Routes())
|
_wslink: bool = checked_or(config.options.get(WSLINK), bool, False)
|
||||||
|
|
||||||
if not routes.routes:
|
# Create internal route dispatcher with provided urls
|
||||||
routes = Routes()
|
routes: Routes = Routes()
|
||||||
_LOGGER.info("Routes not found, creating new routes")
|
routes.add_route(DEFAULT_URL, coordinator.recieved_data, enabled=not _wslink)
|
||||||
|
routes.add_route(WSLINK_URL, coordinator.recieved_data, enabled=_wslink)
|
||||||
if debug:
|
|
||||||
_LOGGER.debug("Enabled route is: %s, WSLink is %s", url_path, _wslink)
|
|
||||||
|
|
||||||
|
# Register webhooks in HomeAssistant with dispatcher
|
||||||
try:
|
try:
|
||||||
default_route = hass.http.app.router.add_get(
|
_ = hass.http.app.router.add_get(DEFAULT_URL, routes.dispatch)
|
||||||
DEFAULT_URL,
|
_ = hass.http.app.router.add_get(WSLINK_URL, routes.dispatch)
|
||||||
coordinator.recieved_data if not _wslink else unregistred,
|
|
||||||
name="weather_default_url",
|
|
||||||
)
|
|
||||||
if debug:
|
|
||||||
_LOGGER.debug("Default route: %s", default_route)
|
|
||||||
|
|
||||||
wslink_route = hass.http.app.router.add_get(
|
|
||||||
WSLINK_URL,
|
|
||||||
coordinator.recieved_data if _wslink else unregistred,
|
|
||||||
name="weather_wslink_url",
|
|
||||||
)
|
|
||||||
if debug:
|
|
||||||
_LOGGER.debug("WSLink route: %s", wslink_route)
|
|
||||||
|
|
||||||
routes.add_route(
|
|
||||||
DEFAULT_URL,
|
|
||||||
default_route,
|
|
||||||
coordinator.recieved_data if not _wslink else unregistred,
|
|
||||||
not _wslink,
|
|
||||||
)
|
|
||||||
routes.add_route(
|
|
||||||
WSLINK_URL, wslink_route, coordinator.recieved_data, _wslink
|
|
||||||
)
|
|
||||||
|
|
||||||
|
# Save initialised routes
|
||||||
hass_data["routes"] = routes
|
hass_data["routes"] = routes
|
||||||
|
|
||||||
except RuntimeError as Ex: # pylint: disable=(broad-except)
|
except RuntimeError as Ex:
|
||||||
if (
|
_LOGGER.critical(
|
||||||
"Added route will never be executed, method GET is already registered"
|
"Routes cannot be added. Integration will not work as expected. %s", Ex
|
||||||
in Ex.args
|
|
||||||
):
|
|
||||||
_LOGGER.info("Handler to URL (%s) already registred", url_path)
|
|
||||||
return False
|
|
||||||
|
|
||||||
_LOGGER.error("Unable to register URL handler! (%s)", Ex.args)
|
|
||||||
return False
|
|
||||||
|
|
||||||
_LOGGER.info(
|
|
||||||
"Registered path to handle weather data: %s",
|
|
||||||
routes.get_enabled(), # pylint: disable=used-before-assignment
|
|
||||||
)
|
)
|
||||||
|
raise ConfigEntryNotReady from Ex
|
||||||
if _wslink:
|
|
||||||
routes.switch_route(coordinator.recieved_data, WSLINK_URL)
|
|
||||||
else:
|
else:
|
||||||
routes.switch_route(coordinator.recieved_data, DEFAULT_URL)
|
return True
|
||||||
|
|
||||||
return routes
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
|
@ -212,22 +202,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
hass_data = hass.data.setdefault(DOMAIN, {})
|
hass_data = hass.data.setdefault(DOMAIN, {})
|
||||||
hass_data[entry.entry_id] = coordinator
|
hass_data[entry.entry_id] = coordinator
|
||||||
|
|
||||||
_wslink = entry.options.get(WSLINK)
|
routes: Routes | None = hass_data.get("routes", None)
|
||||||
debug = entry.options.get(DEV_DBG)
|
|
||||||
|
_wslink = checked_or(entry.options.get(WSLINK), bool, False)
|
||||||
|
|
||||||
if debug:
|
|
||||||
_LOGGER.debug("WS Link is %s", "enbled" if _wslink else "disabled")
|
_LOGGER.debug("WS Link is %s", "enbled" if _wslink else "disabled")
|
||||||
|
|
||||||
route = register_path(
|
if routes:
|
||||||
hass, DEFAULT_URL if not _wslink else WSLINK_URL, coordinator, entry
|
_LOGGER.debug("We have routes registered, will try to switch dispatcher.")
|
||||||
)
|
routes.switch_route(DEFAULT_URL if not _wslink else WSLINK_URL)
|
||||||
|
_LOGGER.debug("%s", routes.show_enabled())
|
||||||
|
else:
|
||||||
|
routes_enabled = register_path(hass, coordinator, entry)
|
||||||
|
|
||||||
if not route:
|
if not routes_enabled:
|
||||||
_LOGGER.error("Fatal: path not registered!")
|
_LOGGER.error("Fatal: path not registered!")
|
||||||
raise PlatformNotReady
|
raise PlatformNotReady
|
||||||
|
|
||||||
hass_data["route"] = route
|
|
||||||
|
|
||||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
|
|
||||||
entry.async_on_unload(entry.add_update_listener(update_listener))
|
entry.async_on_unload(entry.add_update_listener(update_listener))
|
||||||
|
|
@ -238,7 +229,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
async def update_listener(hass: HomeAssistant, entry: ConfigEntry):
|
async def update_listener(hass: HomeAssistant, entry: ConfigEntry):
|
||||||
"""Update setup listener."""
|
"""Update setup listener."""
|
||||||
|
|
||||||
await hass.config_entries.async_reload(entry.entry_id)
|
_ = await hass.config_entries.async_reload(entry.entry_id)
|
||||||
|
|
||||||
_LOGGER.info("Settings updated")
|
_LOGGER.info("Settings updated")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,28 @@
|
||||||
"""Config flow for Sencor SWS 12500 Weather Station integration."""
|
"""Config flow for Sencor SWS 12500 Weather Station integration."""
|
||||||
|
|
||||||
|
import secrets
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
from yarl import URL
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
|
from homeassistant.config_entries import (
|
||||||
|
ConfigEntry,
|
||||||
|
ConfigFlow,
|
||||||
|
ConfigFlowResult,
|
||||||
|
OptionsFlow,
|
||||||
|
)
|
||||||
from homeassistant.core import callback
|
from homeassistant.core import callback
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
from homeassistant.helpers.network import get_url
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
API_ID,
|
API_ID,
|
||||||
API_KEY,
|
API_KEY,
|
||||||
DEV_DBG,
|
DEV_DBG,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
|
ECOWITT_ENABLED,
|
||||||
|
ECOWITT_WEBHOOK_ID,
|
||||||
INVALID_CREDENTIALS,
|
INVALID_CREDENTIALS,
|
||||||
POCASI_CZ_API_ID,
|
POCASI_CZ_API_ID,
|
||||||
POCASI_CZ_API_KEY,
|
POCASI_CZ_API_KEY,
|
||||||
|
|
@ -51,10 +61,12 @@ class ConfigOptionsFlowHandler(OptionsFlow):
|
||||||
self.migrate_schema = {}
|
self.migrate_schema = {}
|
||||||
self.pocasi_cz: dict[str, Any] = {}
|
self.pocasi_cz: dict[str, Any] = {}
|
||||||
self.pocasi_cz_schema = {}
|
self.pocasi_cz_schema = {}
|
||||||
|
self.ecowitt: dict[str, Any] = {}
|
||||||
|
self.ecowitt_schema = {}
|
||||||
|
|
||||||
@property
|
# @property
|
||||||
def config_entry(self):
|
# def config_entry(self) -> ConfigEntry:
|
||||||
return self.hass.config_entries.async_get_entry(self.handler)
|
# 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."""
|
||||||
|
|
@ -133,15 +145,20 @@ class ConfigOptionsFlowHandler(OptionsFlow):
|
||||||
): bool,
|
): bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def async_step_init(self, user_input=None):
|
self.ecowitt = {
|
||||||
|
ECOWITT_WEBHOOK_ID: self.config_entry.options.get(ECOWITT_WEBHOOK_ID, ""),
|
||||||
|
ECOWITT_ENABLED: self.config_entry.options.get(ECOWITT_ENABLED, False),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def async_step_init(self, user_input: dict[str, Any] = {}):
|
||||||
"""Manage the options - show menu first."""
|
"""Manage the options - show menu first."""
|
||||||
return self.async_show_menu(
|
return self.async_show_menu(
|
||||||
step_id="init", menu_options=["basic", "windy", "pocasi"]
|
step_id="init", menu_options=["basic", "ecowitt", "windy", "pocasi"]
|
||||||
)
|
)
|
||||||
|
|
||||||
async def async_step_basic(self, user_input=None):
|
async def async_step_basic(self, user_input: Any = None):
|
||||||
"""Manage basic options - credentials."""
|
"""Manage basic options - credentials."""
|
||||||
errors = {}
|
errors: dict[str, str] = {}
|
||||||
|
|
||||||
await self._get_entry_data()
|
await self._get_entry_data()
|
||||||
|
|
||||||
|
|
@ -159,14 +176,7 @@ class ConfigOptionsFlowHandler(OptionsFlow):
|
||||||
elif user_input[API_KEY] == user_input[API_ID]:
|
elif user_input[API_KEY] == user_input[API_ID]:
|
||||||
errors["base"] = "valid_credentials_match"
|
errors["base"] = "valid_credentials_match"
|
||||||
else:
|
else:
|
||||||
# retain windy data
|
user_input = self.retain_data(user_input)
|
||||||
user_input.update(self.windy_data)
|
|
||||||
|
|
||||||
# retain sensors
|
|
||||||
user_input.update(self.sensors)
|
|
||||||
|
|
||||||
# retain pocasi data
|
|
||||||
user_input.update(self.pocasi_cz)
|
|
||||||
|
|
||||||
return self.async_create_entry(title=DOMAIN, data=user_input)
|
return self.async_create_entry(title=DOMAIN, data=user_input)
|
||||||
|
|
||||||
|
|
@ -179,9 +189,9 @@ class ConfigOptionsFlowHandler(OptionsFlow):
|
||||||
errors=errors,
|
errors=errors,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def async_step_windy(self, user_input=None):
|
async def async_step_windy(self, user_input: Any = None):
|
||||||
"""Manage windy options."""
|
"""Manage windy options."""
|
||||||
errors = {}
|
errors: dict[str, str] = {}
|
||||||
|
|
||||||
await self._get_entry_data()
|
await self._get_entry_data()
|
||||||
|
|
||||||
|
|
@ -200,22 +210,14 @@ class ConfigOptionsFlowHandler(OptionsFlow):
|
||||||
errors=errors,
|
errors=errors,
|
||||||
)
|
)
|
||||||
|
|
||||||
# retain user_data
|
user_input = self.retain_data(user_input)
|
||||||
user_input.update(self.user_data)
|
|
||||||
|
|
||||||
# retain senors
|
|
||||||
user_input.update(self.sensors)
|
|
||||||
|
|
||||||
# retain pocasi cz
|
|
||||||
|
|
||||||
user_input.update(self.pocasi_cz)
|
|
||||||
|
|
||||||
return self.async_create_entry(title=DOMAIN, data=user_input)
|
return self.async_create_entry(title=DOMAIN, data=user_input)
|
||||||
|
|
||||||
async def async_step_pocasi(self, user_input: Any = None) -> ConfigFlowResult:
|
async def async_step_pocasi(self, user_input: Any = None) -> ConfigFlowResult:
|
||||||
"""Handle the pocasi step."""
|
"""Handle the pocasi step."""
|
||||||
|
|
||||||
errors = {}
|
errors: dict[str, str] = {}
|
||||||
|
|
||||||
await self._get_entry_data()
|
await self._get_entry_data()
|
||||||
|
|
||||||
|
|
@ -241,17 +243,63 @@ class ConfigOptionsFlowHandler(OptionsFlow):
|
||||||
data_schema=vol.Schema(self.pocasi_cz_schema),
|
data_schema=vol.Schema(self.pocasi_cz_schema),
|
||||||
errors=errors,
|
errors=errors,
|
||||||
)
|
)
|
||||||
# retain user data
|
|
||||||
user_input.update(self.user_data)
|
|
||||||
|
|
||||||
# retain senors
|
user_input = self.retain_data(user_input)
|
||||||
user_input.update(self.sensors)
|
|
||||||
|
|
||||||
# retain windy
|
|
||||||
user_input.update(self.windy_data)
|
|
||||||
|
|
||||||
return self.async_create_entry(title=DOMAIN, data=user_input)
|
return self.async_create_entry(title=DOMAIN, data=user_input)
|
||||||
|
|
||||||
|
async def async_step_ecowitt(self, user_input: Any = None) -> ConfigFlowResult:
|
||||||
|
"""Ecowitt stations setup."""
|
||||||
|
|
||||||
|
errors: dict[str, str] = {}
|
||||||
|
await self._get_entry_data()
|
||||||
|
|
||||||
|
if not (webhook := self.ecowitt.get(ECOWITT_WEBHOOK_ID)):
|
||||||
|
webhook = secrets.token_hex(8)
|
||||||
|
|
||||||
|
if user_input is None:
|
||||||
|
url: URL = URL(get_url(self.hass))
|
||||||
|
|
||||||
|
if not url.host:
|
||||||
|
url.host = "UNKNOWN"
|
||||||
|
|
||||||
|
ecowitt_schema = {
|
||||||
|
vol.Required(
|
||||||
|
ECOWITT_WEBHOOK_ID,
|
||||||
|
default=webhook,
|
||||||
|
): str,
|
||||||
|
vol.Optional(
|
||||||
|
ECOWITT_ENABLED,
|
||||||
|
default=self.ecowitt.get(ECOWITT_ENABLED, False),
|
||||||
|
): bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="ecowitt",
|
||||||
|
data_schema=vol.Schema(ecowitt_schema),
|
||||||
|
description_placeholders={
|
||||||
|
"url": url.host,
|
||||||
|
"port": str(url.port),
|
||||||
|
"webhook_id": webhook,
|
||||||
|
},
|
||||||
|
errors=errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
user_input = self.retain_data(user_input)
|
||||||
|
return self.async_create_entry(title=DOMAIN, data=user_input)
|
||||||
|
|
||||||
|
def retain_data(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Retain user_data."""
|
||||||
|
|
||||||
|
return {
|
||||||
|
**self.user_data,
|
||||||
|
**self.windy_data,
|
||||||
|
**self.pocasi_cz,
|
||||||
|
**self.sensors,
|
||||||
|
**self.ecowitt,
|
||||||
|
**dict(data),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
class ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||||
"""Handle a config flow for Sencor SWS 12500 Weather Station."""
|
"""Handle a config flow for Sencor SWS 12500 Weather Station."""
|
||||||
|
|
@ -265,7 +313,7 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||||
|
|
||||||
VERSION = 1
|
VERSION = 1
|
||||||
|
|
||||||
async def async_step_user(self, user_input=None):
|
async def async_step_user(self, user_input: Any = None):
|
||||||
"""Handle the initial step."""
|
"""Handle the initial step."""
|
||||||
if user_input is None:
|
if user_input is None:
|
||||||
await self.async_set_unique_id(DOMAIN)
|
await self.async_set_unique_id(DOMAIN)
|
||||||
|
|
@ -276,7 +324,7 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||||
data_schema=vol.Schema(self.data_schema),
|
data_schema=vol.Schema(self.data_schema),
|
||||||
)
|
)
|
||||||
|
|
||||||
errors = {}
|
errors: dict[str, str] = {}
|
||||||
|
|
||||||
if user_input[API_ID] in INVALID_CREDENTIALS:
|
if user_input[API_ID] in INVALID_CREDENTIALS:
|
||||||
errors[API_ID] = "valid_credentials_api"
|
errors[API_ID] = "valid_credentials_api"
|
||||||
|
|
@ -297,6 +345,6 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@callback
|
@callback
|
||||||
def async_get_options_flow(config_entry) -> 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()
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ DATABASE_PATH = "/config/home-assistant_v2.db"
|
||||||
POCASI_CZ_URL: Final = "http://ms.pocasimeteo.cz"
|
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
|
||||||
|
|
||||||
|
|
||||||
ICON = "mdi:weather"
|
ICON = "mdi:weather"
|
||||||
|
|
||||||
API_KEY = "API_KEY"
|
API_KEY = "API_KEY"
|
||||||
|
|
@ -23,6 +24,10 @@ 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"
|
||||||
|
|
||||||
|
ECOWITT: Final = "ecowitt"
|
||||||
|
ECOWITT_WEBHOOK_ID: Final = "ecowitt_webhook_id"
|
||||||
|
ECOWITT_ENABLED: Final = "ecowitt_enabled"
|
||||||
|
|
||||||
POCASI_CZ_API_KEY = "POCASI_CZ_API_KEY"
|
POCASI_CZ_API_KEY = "POCASI_CZ_API_KEY"
|
||||||
POCASI_CZ_API_ID = "POCASI_CZ_API_ID"
|
POCASI_CZ_API_ID = "POCASI_CZ_API_ID"
|
||||||
POCASI_CZ_SEND_INTERVAL = "POCASI_SEND_INTERVAL"
|
POCASI_CZ_SEND_INTERVAL = "POCASI_SEND_INTERVAL"
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
"homekit": {},
|
"homekit": {},
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"issue_tracker": "https://github.com/schizza/SWS-12500-custom-component/issues",
|
"issue_tracker": "https://github.com/schizza/SWS-12500-custom-component/issues",
|
||||||
"requirements": [],
|
"requirements": ["typecheck-runtime==0.2.0"],
|
||||||
"ssdp": [],
|
"ssdp": [],
|
||||||
"version": "1.6.9",
|
"version": "1.6.9",
|
||||||
"zeroconf": []
|
"zeroconf": []
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import logging
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
from aiohttp import ClientError
|
from aiohttp import ClientError
|
||||||
|
from py_typecheck.core import checked
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
@ -75,8 +76,20 @@ class PocasiPush:
|
||||||
"""Pushes weather data to server."""
|
"""Pushes weather data to server."""
|
||||||
|
|
||||||
_data = data.copy()
|
_data = data.copy()
|
||||||
_api_id = self.config.options.get(POCASI_CZ_API_ID)
|
|
||||||
_api_key = self.config.options.get(POCASI_CZ_API_KEY)
|
if (_api_id := checked(self.config.options.get(POCASI_CZ_API_ID), str)) is None:
|
||||||
|
_LOGGER.error(
|
||||||
|
"No API ID is provided for Pocasi Meteo. Check your configuration."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if (
|
||||||
|
_api_key := checked(self.config.options.get(POCASI_CZ_API_KEY), str)
|
||||||
|
) is None:
|
||||||
|
_LOGGER.error(
|
||||||
|
"No API Key is provided for Pocasi Meteo. Check your configuration."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
if self.log:
|
if self.log:
|
||||||
_LOGGER.info(
|
_LOGGER.info(
|
||||||
|
|
@ -91,7 +104,7 @@ class PocasiPush:
|
||||||
self._interval,
|
self._interval,
|
||||||
self.next_update,
|
self.next_update,
|
||||||
)
|
)
|
||||||
return False
|
return
|
||||||
|
|
||||||
request_url: str = ""
|
request_url: str = ""
|
||||||
if mode == "WSLINK":
|
if mode == "WSLINK":
|
||||||
|
|
@ -139,5 +152,3 @@ class PocasiPush:
|
||||||
|
|
||||||
if self.log:
|
if self.log:
|
||||||
_LOGGER.info("Next update: %s", str(self.next_update))
|
_LOGGER.info("Next update: %s", str(self.next_update))
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
|
||||||
|
|
@ -1,77 +1,67 @@
|
||||||
"""Store routes info."""
|
"""Routes implementation."""
|
||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from logging import getLogger
|
import logging
|
||||||
|
|
||||||
from aiohttp.web import AbstractRoute, Response
|
from aiohttp.web import Request, Response
|
||||||
|
|
||||||
_LOGGER = getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
Handler = Callable[[Request], Awaitable[Response]]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Route:
|
class RouteInfo:
|
||||||
"""Store route info."""
|
"""Route struct."""
|
||||||
|
|
||||||
url_path: str
|
url_path: str
|
||||||
route: AbstractRoute
|
handler: Handler
|
||||||
handler: Callable
|
|
||||||
enabled: bool = False
|
enabled: bool = False
|
||||||
|
fallback: Handler = field(default_factory=lambda: unregistred)
|
||||||
def __str__(self):
|
|
||||||
"""Return string representation."""
|
|
||||||
return f"{self.url_path} -> {self.handler}"
|
|
||||||
|
|
||||||
|
|
||||||
class Routes:
|
class Routes:
|
||||||
"""Store routes info."""
|
"""Routes class."""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
"""Initialize routes."""
|
"""Init."""
|
||||||
self.routes = {}
|
self.routes: dict[str, RouteInfo] = {}
|
||||||
|
|
||||||
def switch_route(self, coordinator: Callable, url_path: str):
|
async def dispatch(self, request: Request) -> Response:
|
||||||
"""Switch route."""
|
"""Dispatch."""
|
||||||
|
info = self.routes.get(request.path)
|
||||||
|
if not info:
|
||||||
|
_LOGGER.debug("Route %s is not registered!", request.path)
|
||||||
|
return await unregistred(request)
|
||||||
|
handler = info.handler if info.enabled else info.fallback
|
||||||
|
return await handler(request)
|
||||||
|
|
||||||
for url, route in self.routes.items():
|
def switch_route(self, url_path: str) -> None:
|
||||||
if url == url_path:
|
"""Switch route to new handler."""
|
||||||
_LOGGER.info("New coordinator to route: %s", route.url_path)
|
for path, info in self.routes.items():
|
||||||
route.enabled = True
|
info.enabled = path == url_path
|
||||||
route.handler = coordinator
|
|
||||||
route.route._handler = coordinator # noqa: SLF001
|
|
||||||
else:
|
|
||||||
route.enabled = False
|
|
||||||
route.handler = unregistred
|
|
||||||
route.route._handler = unregistred # noqa: SLF001
|
|
||||||
|
|
||||||
def add_route(
|
def add_route(
|
||||||
self,
|
self, url_path: str, handler: Handler, *, enabled: bool = False
|
||||||
url_path: str,
|
) -> None:
|
||||||
route: AbstractRoute,
|
"""Add route to dispatcher."""
|
||||||
handler: Callable,
|
|
||||||
enabled: bool = False,
|
|
||||||
):
|
|
||||||
"""Add route."""
|
|
||||||
self.routes[url_path] = Route(url_path, route, handler, enabled)
|
|
||||||
|
|
||||||
def get_route(self, url_path: str) -> Route:
|
self.routes[url_path] = RouteInfo(url_path, handler, enabled=enabled)
|
||||||
"""Get route."""
|
_LOGGER.debug("Registered dispatcher for route %s", url_path)
|
||||||
return self.routes.get(url_path, Route)
|
|
||||||
|
|
||||||
def get_enabled(self) -> str:
|
def show_enabled(self) -> str:
|
||||||
"""Get enabled routes."""
|
"""Show info of enabled route."""
|
||||||
enabled_routes = [
|
for url, route in self.routes.items():
|
||||||
route.url_path for route in self.routes.values() if route.enabled
|
if route.enabled:
|
||||||
]
|
return (
|
||||||
return "".join(enabled_routes) if enabled_routes else "None"
|
f"Dispatcher enabled for URL: {url}, with handler: {route.handler}"
|
||||||
|
)
|
||||||
def __str__(self):
|
return "No routes is enabled."
|
||||||
"""Return string representation."""
|
|
||||||
return "\n".join([str(route) for route in self.routes.values()])
|
|
||||||
|
|
||||||
|
|
||||||
async def unregistred(*args, **kwargs):
|
async def unregistred(request: Request) -> Response:
|
||||||
"""Unregister path to handle incoming data."""
|
"""Return unregistred error."""
|
||||||
|
_ = request
|
||||||
_LOGGER.error("Recieved data to unregistred webhook. Check your settings")
|
_LOGGER.debug("Received data to unregistred or disabled webhook.")
|
||||||
return Response(body=f"{'Unregistred webhook.'}", status=404)
|
return Response(text="Unregistred webhook. Check your settings.", status=400)
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@
|
||||||
"basic": "Základní - přístupové údaje (přihlášení)",
|
"basic": "Základní - přístupové údaje (přihlášení)",
|
||||||
"windy": "Nastavení pro přeposílání dat na Windy",
|
"windy": "Nastavení pro přeposílání dat na Windy",
|
||||||
"pocasi": "Nastavení pro přeposlání dat na Počasí Meteo CZ",
|
"pocasi": "Nastavení pro přeposlání dat na Počasí Meteo CZ",
|
||||||
|
"ecowitt": "Nastavení pro stanice Ecowitt",
|
||||||
"migration": "Migrace statistiky senzoru"
|
"migration": "Migrace statistiky senzoru"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -92,6 +93,18 @@
|
||||||
"pocasi_logger_checkbox": "Zapnout pouze v případě, že chcete zaslat ladící informace vývojáři."
|
"pocasi_logger_checkbox": "Zapnout pouze v případě, že chcete zaslat ladící informace vývojáři."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"ecowitt": {
|
||||||
|
"description": "Nastavení pro Ecowitt",
|
||||||
|
"title": "Konfigurace pro stanice Ecowitt",
|
||||||
|
"data": {
|
||||||
|
"ecowitt_webhook_id": "Unikátní webhook ID",
|
||||||
|
"ecowitt_enabled": "Povolit data ze stanice Ecowitt"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"ecowitt_webhook_id": "Nastavení pro stanici: {url}:{port}/weatherhub/{webhook_id}",
|
||||||
|
"ecowitt_enabled": "Povolit přijímání dat ze stanic Ecowitt"
|
||||||
|
}
|
||||||
|
},
|
||||||
"migration": {
|
"migration": {
|
||||||
"title": "Migrace statistiky senzoru.",
|
"title": "Migrace statistiky senzoru.",
|
||||||
"description": "Pro správnou funkci dlouhodobé statistiky je nutné provést migraci jednotky senzoru v dlouhodobé statistice. Původní jednotka dlouhodobé statistiky pro denní úhrn srážek byla v mm/d, nicméně stanice zasílá pouze data v mm bez časového rozlišení.\n\n Senzor, který má být migrován je pro denní úhrn srážek. Pokud je v seznamu již správná hodnota u senzoru pro denní úhrn (mm), pak je již migrace hotová.\n\n Výsledek migrace pro senzor: {migration_status}, přepvedeno celkem {migration_count} řádků.",
|
"description": "Pro správnou funkci dlouhodobé statistiky je nutné provést migraci jednotky senzoru v dlouhodobé statistice. Původní jednotka dlouhodobé statistiky pro denní úhrn srážek byla v mm/d, nicméně stanice zasílá pouze data v mm bez časového rozlišení.\n\n Senzor, který má být migrován je pro denní úhrn srážek. Pokud je v seznamu již správná hodnota u senzoru pro denní úhrn (mm), pak je již migrace hotová.\n\n Výsledek migrace pro senzor: {migration_status}, přepvedeno celkem {migration_count} řádků.",
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,18 @@
|
||||||
"pocasi_logger_checkbox": "Enable only if you want to send debbug data to the developer"
|
"pocasi_logger_checkbox": "Enable only if you want to send debbug data to the developer"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"ecowitt": {
|
||||||
|
"description": "Nastavení pro Ecowitt",
|
||||||
|
"title": "Konfigurace pro stanice Ecowitt",
|
||||||
|
"data": {
|
||||||
|
"ecowitt_webhook_id": "Unikátní webhook ID",
|
||||||
|
"ecowitt_enabled": "Povolit data ze stanice Ecowitt"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"ecowitt_webhook_id": "Nastavení pro stanici: {url}:{port}/weatherhub/{webhook_id}",
|
||||||
|
"ecowitt_enabled": "Povolit přijímání dat ze stanic Ecowitt"
|
||||||
|
}
|
||||||
|
},
|
||||||
"migration": {
|
"migration": {
|
||||||
"title": "Statistic migration.",
|
"title": "Statistic migration.",
|
||||||
"description": "For the correct functioning of long-term statistics, it is necessary to migrate the sensor unit in the long-term statistics. The original unit of long-term statistics for daily precipitation was in mm/d, however, the station only sends data in mm without time differentiation.\n\n The sensor to be migrated is for daily precipitation. If the correct value is already in the list for the daily precipitation sensor (mm), then the migration is already complete.\n\n Migration result for the sensor: {migration_status}, a total of {migration_count} rows converted.",
|
"description": "For the correct functioning of long-term statistics, it is necessary to migrate the sensor unit in the long-term statistics. The original unit of long-term statistics for daily precipitation was in mm/d, however, the station only sends data in mm without time differentiation.\n\n The sensor to be migrated is for daily precipitation. If the correct value is already in the list for the daily precipitation sensor (mm), then the migration is already complete.\n\n Migration result for the sensor: {migration_status}, a total of {migration_count} rows converted.",
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,12 @@
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
from pathlib import Path
|
from multiprocessing import Value
|
||||||
import sqlite3
|
from typing import Any, cast
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
from py_typecheck import checked
|
||||||
|
from py_typecheck.core import checked_or
|
||||||
|
|
||||||
from homeassistant.components import persistent_notification
|
from homeassistant.components import persistent_notification
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
|
@ -15,7 +16,6 @@ from homeassistant.helpers.translation import async_get_translations
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
AZIMUT,
|
AZIMUT,
|
||||||
DATABASE_PATH,
|
|
||||||
DEV_DBG,
|
DEV_DBG,
|
||||||
OUTSIDE_HUMIDITY,
|
OUTSIDE_HUMIDITY,
|
||||||
OUTSIDE_TEMP,
|
OUTSIDE_TEMP,
|
||||||
|
|
@ -37,19 +37,19 @@ async def translations(
|
||||||
*,
|
*,
|
||||||
key: str = "message",
|
key: str = "message",
|
||||||
category: str = "notify",
|
category: str = "notify",
|
||||||
) -> str:
|
) -> str | None:
|
||||||
"""Get translated keys for domain."""
|
"""Get translated keys for domain."""
|
||||||
|
|
||||||
localize_key = f"component.{translation_domain}.{category}.{translation_key}.{key}"
|
localize_key = f"component.{translation_domain}.{category}.{translation_key}.{key}"
|
||||||
|
|
||||||
language = hass.config.language
|
language: str = hass.config.language
|
||||||
|
|
||||||
_translations = await async_get_translations(
|
_translations = await async_get_translations(
|
||||||
hass, language, category, [translation_domain]
|
hass, language, category, [translation_domain]
|
||||||
)
|
)
|
||||||
if localize_key in _translations:
|
if localize_key in _translations:
|
||||||
return _translations[localize_key]
|
return _translations[localize_key]
|
||||||
return ""
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def translated_notification(
|
async def translated_notification(
|
||||||
|
|
@ -70,7 +70,7 @@ async def translated_notification(
|
||||||
f"component.{translation_domain}.{category}.{translation_key}.title"
|
f"component.{translation_domain}.{category}.{translation_key}.title"
|
||||||
)
|
)
|
||||||
|
|
||||||
language = hass.config.language
|
language: str = cast("str", hass.config.language)
|
||||||
|
|
||||||
_translations = await async_get_translations(
|
_translations = await async_get_translations(
|
||||||
hass, language, category, [translation_domain]
|
hass, language, category, [translation_domain]
|
||||||
|
|
@ -91,7 +91,10 @@ async def translated_notification(
|
||||||
|
|
||||||
|
|
||||||
async def update_options(
|
async def update_options(
|
||||||
hass: HomeAssistant, entry: ConfigEntry, update_key, update_value
|
hass: HomeAssistant,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
update_key: str,
|
||||||
|
update_value: str | list[str] | bool,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Update config.options entry."""
|
"""Update config.options entry."""
|
||||||
conf = {**entry.options}
|
conf = {**entry.options}
|
||||||
|
|
@ -100,46 +103,43 @@ async def update_options(
|
||||||
return hass.config_entries.async_update_entry(entry, options=conf)
|
return hass.config_entries.async_update_entry(entry, options=conf)
|
||||||
|
|
||||||
|
|
||||||
def anonymize(data):
|
def anonymize(
|
||||||
|
data: dict[str, str | int | float | bool],
|
||||||
|
) -> dict[str, str | int | float | bool]:
|
||||||
"""Anoynimize recieved data."""
|
"""Anoynimize recieved data."""
|
||||||
|
anonym: dict[str, str] = {}
|
||||||
anonym = {}
|
return {
|
||||||
for k in data:
|
anonym[key]: value
|
||||||
if k not in {"ID", "PASSWORD", "wsid", "wspw"}:
|
for key, value in data.items()
|
||||||
anonym[k] = data[k]
|
if key not in {"ID", "PASSWORD", "wsid", "wspw"}
|
||||||
|
}
|
||||||
return anonym
|
|
||||||
|
|
||||||
|
|
||||||
def remap_items(entities):
|
def remap_items(entities: dict[str, str]) -> dict[str, str]:
|
||||||
"""Remap items in query."""
|
"""Remap items in query."""
|
||||||
items = {}
|
return {
|
||||||
for item in entities:
|
REMAP_ITEMS[key]: value for key, value in entities.items() if key in REMAP_ITEMS
|
||||||
if item in REMAP_ITEMS:
|
}
|
||||||
items[REMAP_ITEMS[item]] = entities[item]
|
|
||||||
|
|
||||||
return items
|
|
||||||
|
|
||||||
|
|
||||||
def remap_wslink_items(entities):
|
def remap_wslink_items(entities: dict[str, str]) -> dict[str, str]:
|
||||||
"""Remap items in query for WSLink API."""
|
"""Remap items in query for WSLink API."""
|
||||||
items = {}
|
return {
|
||||||
for item in entities:
|
REMAP_WSLINK_ITEMS[key]: value
|
||||||
if item in REMAP_WSLINK_ITEMS:
|
for key, value in entities.items()
|
||||||
items[REMAP_WSLINK_ITEMS[item]] = entities[item]
|
if key in REMAP_WSLINK_ITEMS
|
||||||
|
}
|
||||||
return items
|
|
||||||
|
|
||||||
|
|
||||||
def loaded_sensors(config_entry: ConfigEntry) -> list | None:
|
def loaded_sensors(config_entry: ConfigEntry) -> list[str]:
|
||||||
"""Get loaded sensors."""
|
"""Get loaded sensors."""
|
||||||
|
|
||||||
return config_entry.options.get(SENSORS_TO_LOAD) or []
|
return config_entry.options.get(SENSORS_TO_LOAD) or []
|
||||||
|
|
||||||
|
|
||||||
def check_disabled(
|
def check_disabled(
|
||||||
hass: HomeAssistant, items, config_entry: ConfigEntry
|
items: dict[str, str], config_entry: ConfigEntry
|
||||||
) -> list | None:
|
) -> list[str] | None:
|
||||||
"""Check if we have data for unloaded sensors.
|
"""Check if we have data for unloaded sensors.
|
||||||
|
|
||||||
If so, then add sensor to load queue.
|
If so, then add sensor to load queue.
|
||||||
|
|
@ -147,10 +147,11 @@ def check_disabled(
|
||||||
Returns list of found sensors or None
|
Returns list of found sensors or None
|
||||||
"""
|
"""
|
||||||
|
|
||||||
log: bool = config_entry.options.get(DEV_DBG, False)
|
log = checked_or(config_entry.options.get(DEV_DBG), bool, False)
|
||||||
|
|
||||||
entityFound: bool = False
|
entityFound: bool = False
|
||||||
_loaded_sensors = loaded_sensors(config_entry)
|
_loaded_sensors: list[str] = loaded_sensors(config_entry)
|
||||||
missing_sensors: list = []
|
missing_sensors: list[str] = []
|
||||||
|
|
||||||
for item in items:
|
for item in items:
|
||||||
if log:
|
if log:
|
||||||
|
|
@ -177,8 +178,8 @@ def wind_dir_to_text(deg: float) -> UnitOfDir | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def battery_level_to_text(battery: int) -> UnitOfBat:
|
def battery_level(battery: int) -> UnitOfBat:
|
||||||
"""Return battery level in text representation.
|
"""Return battery level.
|
||||||
|
|
||||||
Returns UnitOfBat
|
Returns UnitOfBat
|
||||||
"""
|
"""
|
||||||
|
|
@ -188,10 +189,10 @@ def battery_level_to_text(battery: int) -> UnitOfBat:
|
||||||
1: UnitOfBat.NORMAL,
|
1: UnitOfBat.NORMAL,
|
||||||
}
|
}
|
||||||
|
|
||||||
if battery is None:
|
if (v := checked(battery, int)) is None:
|
||||||
return UnitOfBat.UNKNOWN
|
return UnitOfBat.UNKNOWN
|
||||||
|
|
||||||
return level_map.get(int(battery), UnitOfBat.UNKNOWN)
|
return level_map.get(v, UnitOfBat.UNKNOWN)
|
||||||
|
|
||||||
|
|
||||||
def battery_level_to_icon(battery: UnitOfBat) -> str:
|
def battery_level_to_icon(battery: UnitOfBat) -> str:
|
||||||
|
|
@ -218,21 +219,40 @@ def celsius_to_fahrenheit(celsius: float) -> float:
|
||||||
return celsius * 9.0 / 5.0 + 32
|
return celsius * 9.0 / 5.0 + 32
|
||||||
|
|
||||||
|
|
||||||
def heat_index(data: Any, convert: bool = False) -> float | None:
|
def _to_float(val: Any) -> float | None:
|
||||||
|
"""Convert int or string to float."""
|
||||||
|
|
||||||
|
if not val:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
v = float(val)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def heat_index(
|
||||||
|
data: dict[str, int | float | str], convert: bool = False
|
||||||
|
) -> float | None:
|
||||||
"""Calculate heat index from temperature.
|
"""Calculate heat index from temperature.
|
||||||
|
|
||||||
data: dict with temperature and humidity
|
data: dict with temperature and humidity
|
||||||
convert: bool, convert recieved data from Celsius to Fahrenheit
|
convert: bool, convert recieved data from Celsius to Fahrenheit
|
||||||
"""
|
"""
|
||||||
|
if (temp := _to_float(data.get(OUTSIDE_TEMP))) is None:
|
||||||
temp = data.get(OUTSIDE_TEMP, None)
|
_LOGGER.error(
|
||||||
rh = data.get(OUTSIDE_HUMIDITY, None)
|
"We are missing/invalid OUTSIDE TEMP (%s), cannot calculate wind chill index.",
|
||||||
|
temp,
|
||||||
if not temp or not rh:
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
temp = float(temp)
|
if (rh := _to_float(data.get(OUTSIDE_HUMIDITY))) is None:
|
||||||
rh = float(rh)
|
_LOGGER.error(
|
||||||
|
"We are missing/invalid OUTSIDE HUMIDITY (%s), cannot calculate wind chill index.",
|
||||||
|
rh,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
adjustment = None
|
adjustment = None
|
||||||
|
|
||||||
|
|
@ -263,21 +283,30 @@ def heat_index(data: Any, convert: bool = False) -> float | None:
|
||||||
return simple
|
return simple
|
||||||
|
|
||||||
|
|
||||||
def chill_index(data: Any, convert: bool = False) -> float | None:
|
def chill_index(
|
||||||
|
data: dict[str, str | float | int], convert: bool = False
|
||||||
|
) -> float | None:
|
||||||
"""Calculate wind chill index from temperature and wind speed.
|
"""Calculate wind chill index from temperature and wind speed.
|
||||||
|
|
||||||
data: dict with temperature and wind speed
|
data: dict with temperature and wind speed
|
||||||
convert: bool, convert recieved data from Celsius to Fahrenheit
|
convert: bool, convert recieved data from Celsius to Fahrenheit
|
||||||
"""
|
"""
|
||||||
|
temp = _to_float(data.get(OUTSIDE_TEMP))
|
||||||
|
wind = _to_float(data.get(WIND_SPEED))
|
||||||
|
|
||||||
temp = data.get(OUTSIDE_TEMP, None)
|
if temp is None:
|
||||||
wind = data.get(WIND_SPEED, None)
|
_LOGGER.error(
|
||||||
|
"We are missing/invalid OUTSIDE TEMP (%s), cannot calculate wind chill index.",
|
||||||
if not temp or not wind:
|
temp,
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
temp = float(temp)
|
if wind is None:
|
||||||
wind = float(wind)
|
_LOGGER.error(
|
||||||
|
"We are missing/invalid WIND SPEED (%s), cannot calculate wind chill index.",
|
||||||
|
wind,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
if convert:
|
if convert:
|
||||||
temp = celsius_to_fahrenheit(temp)
|
temp = celsius_to_fahrenheit(temp)
|
||||||
|
|
@ -294,109 +323,3 @@ def chill_index(data: Any, convert: bool = False) -> float | None:
|
||||||
if temp < 50 and wind > 3
|
if temp < 50 and wind > 3
|
||||||
else temp
|
else temp
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def long_term_units_in_statistics_meta():
|
|
||||||
"""Get units in long term statitstics."""
|
|
||||||
sensor_units = []
|
|
||||||
if not Path(DATABASE_PATH).exists():
|
|
||||||
_LOGGER.error("Database file not found: %s", DATABASE_PATH)
|
|
||||||
return False
|
|
||||||
|
|
||||||
conn = sqlite3.connect(DATABASE_PATH)
|
|
||||||
db = conn.cursor()
|
|
||||||
|
|
||||||
try:
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
SELECT statistic_id, unit_of_measurement from statistics_meta
|
|
||||||
WHERE statistic_id LIKE 'sensor.weather_station_sws%'
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
rows = db.fetchall()
|
|
||||||
sensor_units = {
|
|
||||||
statistic_id: f"{statistic_id} ({unit})" for statistic_id, unit in rows
|
|
||||||
}
|
|
||||||
|
|
||||||
except sqlite3.Error as e:
|
|
||||||
_LOGGER.error("Error during data migration: %s", e)
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
return sensor_units
|
|
||||||
|
|
||||||
|
|
||||||
async def migrate_data(hass: HomeAssistant, sensor_id: str | None = None) -> int | bool:
|
|
||||||
"""Migrate data from mm/d to mm."""
|
|
||||||
|
|
||||||
_LOGGER.debug("Sensor %s is required for data migration", sensor_id)
|
|
||||||
updated_rows = 0
|
|
||||||
|
|
||||||
if not Path(DATABASE_PATH).exists():
|
|
||||||
_LOGGER.error("Database file not found: %s", DATABASE_PATH)
|
|
||||||
return False
|
|
||||||
|
|
||||||
conn = sqlite3.connect(DATABASE_PATH)
|
|
||||||
db = conn.cursor()
|
|
||||||
|
|
||||||
try:
|
|
||||||
_LOGGER.info(sensor_id)
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
UPDATE statistics_meta
|
|
||||||
SET unit_of_measurement = 'mm'
|
|
||||||
WHERE statistic_id = ?
|
|
||||||
AND unit_of_measurement = 'mm/d';
|
|
||||||
""",
|
|
||||||
(sensor_id,),
|
|
||||||
)
|
|
||||||
updated_rows = db.rowcount
|
|
||||||
conn.commit()
|
|
||||||
_LOGGER.info(
|
|
||||||
"Data migration completed successfully. Updated rows: %s for %s",
|
|
||||||
updated_rows,
|
|
||||||
sensor_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
except sqlite3.Error as e:
|
|
||||||
_LOGGER.error("Error during data migration: %s", e)
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
return updated_rows
|
|
||||||
|
|
||||||
|
|
||||||
def migrate_data_old(sensor_id: str | None = None):
|
|
||||||
"""Migrate data from mm/d to mm."""
|
|
||||||
updated_rows = 0
|
|
||||||
|
|
||||||
if not Path(DATABASE_PATH).exists():
|
|
||||||
_LOGGER.error("Database file not found: %s", DATABASE_PATH)
|
|
||||||
return False
|
|
||||||
|
|
||||||
conn = sqlite3.connect(DATABASE_PATH)
|
|
||||||
db = conn.cursor()
|
|
||||||
|
|
||||||
try:
|
|
||||||
_LOGGER.info(sensor_id)
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
UPDATE statistics_meta
|
|
||||||
SET unit_of_measurement = 'mm'
|
|
||||||
WHERE statistic_id = ?
|
|
||||||
AND unit_of_measurement = 'mm/d';
|
|
||||||
""",
|
|
||||||
(sensor_id,),
|
|
||||||
)
|
|
||||||
updated_rows = db.rowcount
|
|
||||||
conn.commit()
|
|
||||||
_LOGGER.info(
|
|
||||||
"Data migration completed successfully. Updated rows: %s for %s",
|
|
||||||
updated_rows,
|
|
||||||
sensor_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
except sqlite3.Error as e:
|
|
||||||
_LOGGER.error("Error during data migration: %s", e)
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
return updated_rows
|
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,10 @@
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
from aiohttp.client_exceptions import ClientError
|
from aiohttp.client_exceptions import ClientError
|
||||||
|
from py_typecheck.core import checked
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
@ -52,22 +54,22 @@ class WindyPush:
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, config: ConfigEntry) -> None:
|
def __init__(self, hass: HomeAssistant, config: ConfigEntry) -> None:
|
||||||
"""Init."""
|
"""Init."""
|
||||||
self.hass = hass
|
self.hass: Final = hass
|
||||||
self.config = config
|
self.config: Final = config
|
||||||
|
|
||||||
""" 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
|
||||||
"""
|
"""
|
||||||
self.last_update = datetime.now()
|
self.last_update: datetime = datetime.now()
|
||||||
self.next_update = datetime.now() + timed(minutes=1)
|
self.next_update: datetime = datetime.now() + timed(minutes=1)
|
||||||
|
|
||||||
self.log = self.config.options.get(WINDY_LOGGER_ENABLED)
|
self.log: bool = self.config.options.get(WINDY_LOGGER_ENABLED, False)
|
||||||
self.invalid_response_count = 0
|
self.invalid_response_count: int = 0
|
||||||
|
|
||||||
def verify_windy_response( # pylint: disable=useless-return
|
def verify_windy_response( # pylint: disable=useless-return
|
||||||
self,
|
self,
|
||||||
response: str,
|
response: str,
|
||||||
) -> WindyNotInserted | WindySuccess | WindyApiKeyError | None:
|
):
|
||||||
"""Verify answer form Windy."""
|
"""Verify answer form Windy."""
|
||||||
|
|
||||||
if self.log:
|
if self.log:
|
||||||
|
|
@ -85,9 +87,7 @@ class WindyPush:
|
||||||
if "Unauthorized" in response:
|
if "Unauthorized" in response:
|
||||||
raise WindyApiKeyError
|
raise WindyApiKeyError
|
||||||
|
|
||||||
return None
|
async def push_data_to_windy(self, data: dict[str, str]) -> bool:
|
||||||
|
|
||||||
async def push_data_to_windy(self, data):
|
|
||||||
"""Pushes weather data do Windy stations.
|
"""Pushes weather data do Windy stations.
|
||||||
|
|
||||||
Interval is 5 minutes, otherwise Windy would not accepts data.
|
Interval is 5 minutes, otherwise Windy would not accepts data.
|
||||||
|
|
@ -96,8 +96,6 @@ class WindyPush:
|
||||||
from station. But we need to do some clean up.
|
from station. But we need to do some clean up.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
text_for_test = None
|
|
||||||
|
|
||||||
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",
|
||||||
|
|
@ -112,13 +110,18 @@ class WindyPush:
|
||||||
|
|
||||||
for purge in PURGE_DATA:
|
for purge in PURGE_DATA:
|
||||||
if purge in purged_data:
|
if purge in purged_data:
|
||||||
purged_data.pop(purge)
|
_ = purged_data.pop(purge)
|
||||||
|
|
||||||
if "dewptf" in purged_data:
|
if "dewptf" in purged_data:
|
||||||
dewpoint = round(((float(purged_data.pop("dewptf")) - 32) / 1.8), 1)
|
dewpoint = round(((float(purged_data.pop("dewptf")) - 32) / 1.8), 1)
|
||||||
purged_data["dewpoint"] = str(dewpoint)
|
purged_data["dewpoint"] = str(dewpoint)
|
||||||
|
|
||||||
windy_api_key = self.config.options.get(WINDY_API_KEY)
|
if (
|
||||||
|
windy_api_key := checked(self.config.options.get(WINDY_API_KEY), str)
|
||||||
|
) is None:
|
||||||
|
_LOGGER.error("Windy API key is not provided! Check your configuration.")
|
||||||
|
return False
|
||||||
|
|
||||||
request_url = f"{WINDY_URL}{windy_api_key}"
|
request_url = f"{WINDY_URL}{windy_api_key}"
|
||||||
|
|
||||||
if self.log:
|
if self.log:
|
||||||
|
|
@ -133,27 +136,33 @@ class WindyPush:
|
||||||
# log despite of settings
|
# log despite of settings
|
||||||
_LOGGER.error(WINDY_NOT_INSERTED)
|
_LOGGER.error(WINDY_NOT_INSERTED)
|
||||||
|
|
||||||
text_for_test = WINDY_NOT_INSERTED
|
|
||||||
|
|
||||||
except WindyApiKeyError:
|
except WindyApiKeyError:
|
||||||
# log despite of settings
|
# log despite of settings
|
||||||
_LOGGER.critical(WINDY_INVALID_KEY)
|
_LOGGER.critical(WINDY_INVALID_KEY)
|
||||||
text_for_test = WINDY_INVALID_KEY
|
|
||||||
|
|
||||||
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 option to false.")
|
||||||
|
|
||||||
except WindySuccess:
|
except WindySuccess:
|
||||||
if self.log:
|
if self.log:
|
||||||
_LOGGER.info(WINDY_SUCCESS)
|
_LOGGER.info(WINDY_SUCCESS)
|
||||||
text_for_test = WINDY_SUCCESS
|
else:
|
||||||
|
if self.log:
|
||||||
|
_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", 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(WINDY_UNEXPECTED)
|
_LOGGER.critical(WINDY_UNEXPECTED)
|
||||||
text_for_test = WINDY_UNEXPECTED
|
if not await update_options(
|
||||||
await update_options(self.hass, self.config, WINDY_ENABLED, False)
|
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)
|
||||||
|
|
@ -161,6 +170,4 @@ class WindyPush:
|
||||||
if self.log:
|
if self.log:
|
||||||
_LOGGER.info("Next update: %s", str(self.next_update))
|
_LOGGER.info("Next update: %s", str(self.next_update))
|
||||||
|
|
||||||
if RESPONSE_FOR_TEST and text_for_test:
|
return True
|
||||||
return text_for_test
|
|
||||||
return None
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue