SWS-12500-custom-component/custom_components/sws12500/config_flow.py

441 lines
16 KiB
Python

"""Config flow for Sencor SWS 12500 Weather Station integration."""
from __future__ import annotations
import secrets
from typing import Any
import voluptuous as vol
from yarl import URL
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import selector
from homeassistant.helpers.network import NoURLAvailableError, get_url
from .conflicts import ERROR_MUTUALLY_EXCLUSIVE
from .const import (
API_ID,
API_KEY,
DEV_DBG,
DOMAIN,
ECOWITT_ENABLED,
ECOWITT_WEBHOOK_ID,
INVALID_CREDENTIALS,
LEGACY_ENABLED,
POCASI_CZ_API_ID,
POCASI_CZ_API_KEY,
POCASI_CZ_ENABLED,
POCASI_CZ_LOGGER_ENABLED,
POCASI_CZ_SEND_INTERVAL,
POCASI_CZ_SEND_MINIMUM,
SENSORS_TO_LOAD,
WINDY_ENABLED,
WINDY_LOGGER_ENABLED,
WINDY_STATION_ID,
WINDY_STATION_PW,
WSLINK,
WSLINK_ADDON_PORT,
)
# Masked text input for secret fields (API keys / station passwords).
_PASSWORD_SELECTOR = selector.TextSelector(selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD))
def _is_invalid_credential(value: Any) -> bool:
"""Return True when a PWS/WSLink credential is unusable.
Blank (or whitespace-only) values are as useless as the placeholder strings in
`INVALID_CREDENTIALS`: `_validate_credentials` rejects every incoming packet if
either option is empty, so the entry would be created but never receive data.
"""
return not isinstance(value, str) or not value.strip() or value in INVALID_CREDENTIALS
def _ha_url_placeholders(hass: HomeAssistant) -> tuple[str, str]:
"""Return (host, port) of the Home Assistant URL for the Ecowitt instructions.
`get_url` raises `NoURLAvailableError` when HA cannot resolve any of its own
URLs. These values are shown as setup instructions only, so fall back to a
placeholder instead of letting the config flow abort.
"""
try:
url: URL = URL(get_url(hass))
except NoURLAvailableError:
return "UNKNOWN", "UNKNOWN"
return url.host or "UNKNOWN", str(url.port)
class ConfigOptionsFlowHandler(OptionsFlow):
"""Handle WeatherStation ConfigFlow."""
def __init__(self) -> None:
"""Initialize flow."""
super().__init__()
# self.config_entry = config_entry
self.windy_data: dict[str, Any] = {}
self.windy_data_schema = {}
self.user_data: dict[str, Any] = {}
self.user_data_schema = {}
self.migrate_schema = {}
self.pocasi_cz: dict[str, Any] = {}
self.pocasi_cz_schema = {}
self.ecowitt: dict[str, Any] = {}
self.ecowitt_schema = {}
self.wslink_addon_port: dict[str, int] = {}
self.wslink_addod_schema = {}
async def _get_entry_data(self):
"""Get entry data."""
self.user_data = {
API_ID: self.config_entry.options.get(API_ID, ""),
API_KEY: self.config_entry.options.get(API_KEY, ""),
LEGACY_ENABLED: self.config_entry.options.get(LEGACY_ENABLED, True),
WSLINK: self.config_entry.options.get(WSLINK, False),
DEV_DBG: self.config_entry.options.get(DEV_DBG, False),
}
self.user_data_schema = {
vol.Optional(API_ID, default=self.user_data.get(API_ID, "")): str,
vol.Optional(API_KEY, default=self.user_data.get(API_KEY, "")): _PASSWORD_SELECTOR,
vol.Optional(WSLINK, default=self.user_data.get(WSLINK, False)): bool,
vol.Optional(DEV_DBG, default=self.user_data.get(DEV_DBG, False)): bool,
vol.Optional(LEGACY_ENABLED, default=self.user_data.get(LEGACY_ENABLED, True)): bool,
}
self.windy_data = {
WINDY_STATION_ID: self.config_entry.options.get(WINDY_STATION_ID, ""),
WINDY_STATION_PW: self.config_entry.options.get(WINDY_STATION_PW, ""),
WINDY_LOGGER_ENABLED: self.config_entry.options.get(WINDY_LOGGER_ENABLED, False),
WINDY_ENABLED: self.config_entry.options.get(WINDY_ENABLED, False),
}
self.windy_data_schema = {
vol.Optional(WINDY_STATION_ID, default=self.windy_data.get(WINDY_STATION_ID, "")): str,
vol.Optional(
WINDY_STATION_PW,
default=self.windy_data.get(WINDY_STATION_PW, ""),
): _PASSWORD_SELECTOR,
vol.Optional(WINDY_ENABLED, default=self.windy_data[WINDY_ENABLED]): bool,
vol.Optional(
WINDY_LOGGER_ENABLED,
default=self.windy_data[WINDY_LOGGER_ENABLED],
): bool,
}
self.pocasi_cz = {
POCASI_CZ_API_ID: self.config_entry.options.get(POCASI_CZ_API_ID, ""),
POCASI_CZ_API_KEY: self.config_entry.options.get(POCASI_CZ_API_KEY, ""),
POCASI_CZ_ENABLED: self.config_entry.options.get(POCASI_CZ_ENABLED, False),
POCASI_CZ_LOGGER_ENABLED: self.config_entry.options.get(POCASI_CZ_LOGGER_ENABLED, False),
POCASI_CZ_SEND_INTERVAL: self.config_entry.options.get(POCASI_CZ_SEND_INTERVAL, 30),
}
self.pocasi_cz_schema = {
vol.Required(POCASI_CZ_API_ID, default=self.pocasi_cz.get(POCASI_CZ_API_ID)): str,
vol.Required(POCASI_CZ_API_KEY, default=self.pocasi_cz.get(POCASI_CZ_API_KEY)): _PASSWORD_SELECTOR,
vol.Required(
POCASI_CZ_SEND_INTERVAL,
default=self.pocasi_cz.get(POCASI_CZ_SEND_INTERVAL),
): int,
vol.Optional(POCASI_CZ_ENABLED, default=self.pocasi_cz.get(POCASI_CZ_ENABLED)): bool,
vol.Optional(
POCASI_CZ_LOGGER_ENABLED,
default=self.pocasi_cz.get(POCASI_CZ_LOGGER_ENABLED),
): bool,
}
self.ecowitt = {
ECOWITT_WEBHOOK_ID: self.config_entry.options.get(ECOWITT_WEBHOOK_ID, ""),
ECOWITT_ENABLED: self.config_entry.options.get(ECOWITT_ENABLED, False),
}
self.wslink_addon_port = {WSLINK_ADDON_PORT: self.config_entry.options.get(WSLINK_ADDON_PORT, 443)}
async def async_step_init(self, user_input: dict[str, Any] | None = None):
"""Manage the options - show menu first."""
_ = user_input
return self.async_show_menu(
step_id="init", menu_options=["basic", "wslink_port_setup", "ecowitt", "windy", "pocasi"]
)
async def async_step_basic(self, user_input: Any = None):
"""Manage basic options - PWS/WSLink credentials and legacy endpoint toggle.
API ID/KEY are required only when legacy (PWS/WSLINK) endpoint is enabled.
For an Ecowitt-only setup, the user can turn the legacy endpoint off and leave credentials empty.
"""
errors: dict[str, str] = {}
await self._get_entry_data()
if user_input is None:
return self.async_show_form(
step_id="basic",
data_schema=vol.Schema(self.user_data_schema),
errors=errors,
)
if user_input.get(LEGACY_ENABLED):
# Both endpoints remap onto the same internal sensor keys, so enabling the
# legacy one while Ecowitt is active would corrupt those entities.
if self.ecowitt.get(ECOWITT_ENABLED):
errors["base"] = ERROR_MUTUALLY_EXCLUSIVE
elif _is_invalid_credential(user_input.get(API_ID)):
errors[API_ID] = "valid_credentials_api"
elif _is_invalid_credential(user_input.get(API_KEY)):
errors[API_KEY] = "valid_credentials_key"
elif user_input[API_KEY] == user_input[API_ID]:
errors["base"] = "valid_credentials_match"
if not errors:
user_input = self.retain_data(user_input)
return self.async_create_entry(title=DOMAIN, data=user_input)
self.user_data = user_input
# we are ending with error msg, reshow form
return self.async_show_form(
step_id="basic",
data_schema=vol.Schema(self.user_data_schema),
errors=errors,
)
async def async_step_windy(self, user_input: Any = None):
"""Manage windy options."""
errors: dict[str, str] = {}
await self._get_entry_data()
if user_input is None:
return self.async_show_form(
step_id="windy",
data_schema=vol.Schema(self.windy_data_schema),
errors=errors,
)
if (user_input[WINDY_ENABLED] is True) and (
(user_input[WINDY_STATION_ID] == "") or (user_input[WINDY_STATION_PW] == "")
):
errors[WINDY_STATION_ID] = "windy_key_required"
return self.async_show_form(
step_id="windy",
data_schema=vol.Schema(self.windy_data_schema),
errors=errors,
)
user_input = self.retain_data(user_input)
return self.async_create_entry(title=DOMAIN, data=user_input)
async def async_step_pocasi(self, user_input: Any = None) -> ConfigFlowResult:
"""Handle the pocasi step."""
errors: dict[str, str] = {}
await self._get_entry_data()
if user_input is None:
return self.async_show_form(
step_id="pocasi",
data_schema=vol.Schema(self.pocasi_cz_schema),
errors=errors,
)
if user_input.get(POCASI_CZ_SEND_INTERVAL, 0) < POCASI_CZ_SEND_MINIMUM:
errors[POCASI_CZ_SEND_INTERVAL] = "pocasi_send_minimum"
if user_input.get(POCASI_CZ_ENABLED):
if user_input.get(POCASI_CZ_API_ID) == "":
errors[POCASI_CZ_API_ID] = "pocasi_id_required"
if user_input.get(POCASI_CZ_API_KEY) == "":
errors[POCASI_CZ_API_KEY] = "pocasi_key_required"
if len(errors) > 0:
return self.async_show_form(
step_id="pocasi",
data_schema=vol.Schema(self.pocasi_cz_schema),
errors=errors,
)
user_input = self.retain_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 not None:
# Both endpoints remap onto the same internal sensor keys, so enabling
# Ecowitt while the legacy endpoint is active would corrupt those entities.
if user_input.get(ECOWITT_ENABLED) and self.user_data.get(LEGACY_ENABLED):
errors["base"] = ERROR_MUTUALLY_EXCLUSIVE
else:
return self.async_create_entry(title=DOMAIN, data=self.retain_data(user_input))
host, port = _ha_url_placeholders(self.hass)
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": host,
"port": port,
"webhook_id": webhook,
},
errors=errors,
)
async def async_step_wslink_port_setup(self, user_input: Any = None) -> ConfigFlowResult:
"""WSLink Addon port setup."""
errors: dict[str, str] = {}
await self._get_entry_data()
if not (port := self.wslink_addon_port.get(WSLINK_ADDON_PORT)):
port = 443
wslink_port_schema = {
vol.Required(WSLINK_ADDON_PORT, default=port): int,
}
if user_input is None:
return self.async_show_form(
step_id="wslink_port_setup",
data_schema=vol.Schema(wslink_port_schema),
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]:
"""Merge the submitted step over every other section's current values.
`SENSORS_TO_LOAD` is re-read here rather than taken from the `_get_entry_data`
snapshot: auto-discovery appends to it from the webhook handler, so a dialog
left open while the station reports a new field would otherwise roll that
discovery back on submit.
"""
discovered = self.config_entry.options.get(SENSORS_TO_LOAD)
return {
**self.user_data,
**self.windy_data,
**self.pocasi_cz,
**self.ecowitt,
**self.wslink_addon_port,
**dict(data),
SENSORS_TO_LOAD: discovered if isinstance(discovered, list) else [],
}
class ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Sencor SWS 12500 Weather Station."""
pws_schema = {
vol.Required(API_ID): str,
vol.Required(API_KEY): _PASSWORD_SELECTOR,
vol.Optional(WSLINK): bool,
vol.Optional(DEV_DBG): bool,
}
VERSION = 2
async def async_step_user(self, user_input: Any = None):
"""Handle the initial step."""
await self.async_set_unique_id(DOMAIN)
self._abort_if_unique_id_configured()
return self.async_show_menu(
step_id="user",
menu_options=["pws", "ecowitt"],
)
async def async_step_pws(self, user_input: Any = None) -> ConfigFlowResult:
"""PWS/WSLink credentials setup."""
errors: dict[str, str] = {}
if user_input is None:
return self.async_show_form(step_id="pws", data_schema=vol.Schema(self.pws_schema), errors=errors)
if _is_invalid_credential(user_input.get(API_ID)):
errors[API_ID] = "valid_credentials_api"
elif _is_invalid_credential(user_input.get(API_KEY)):
errors[API_KEY] = "valid_credentials_key"
elif user_input[API_KEY] == user_input[API_ID]:
errors["base"] = "valid_credentials_match"
else:
options: dict[str, Any] = {
**user_input,
LEGACY_ENABLED: True,
ECOWITT_ENABLED: False,
}
return self.async_create_entry(title=DOMAIN, data=options, options=options)
return self.async_show_form(
step_id="pws",
data_schema=vol.Schema(self.pws_schema),
errors=errors,
)
async def async_step_ecowitt(self, user_input: Any = None) -> ConfigFlowResult:
"""Ecowitt stations setup."""
if user_input is None:
webhook = secrets.token_hex(8)
host, port = _ha_url_placeholders(self.hass)
ecowitt_schema = {
vol.Required(ECOWITT_WEBHOOK_ID, default=webhook): str,
vol.Optional(ECOWITT_ENABLED, default=True): bool,
}
return self.async_show_form(
step_id="ecowitt",
data_schema=vol.Schema(ecowitt_schema),
description_placeholders={
"url": host,
"port": port,
"webhook_id": webhook,
},
)
options: dict[str, Any] = {
**user_input,
LEGACY_ENABLED: False,
WSLINK: False,
API_ID: "",
API_KEY: "",
}
return self.async_create_entry(title=DOMAIN, data=options, options=options)
@staticmethod
@callback
def async_get_options_flow(config_entry: ConfigEntry) -> ConfigOptionsFlowHandler:
"""Get the options flow for this handler."""
return ConfigOptionsFlowHandler()