Updated config flow
Updated config flow's initial setup. Ecowitt stations does not require `API_ID` and `API_KEY`, so we need to separate Ecowitt setup from PWS/WSLink setup.ecowitt_support
parent
7abfedc1ca
commit
9288ae4a64
|
|
@ -19,6 +19,7 @@ from .const import (
|
|||
ECOWITT_ENABLED,
|
||||
ECOWITT_WEBHOOK_ID,
|
||||
INVALID_CREDENTIALS,
|
||||
LEGACY_ENABLED,
|
||||
POCASI_CZ_API_ID,
|
||||
POCASI_CZ_API_KEY,
|
||||
POCASI_CZ_ENABLED,
|
||||
|
|
@ -317,7 +318,7 @@ class ConfigOptionsFlowHandler(OptionsFlow):
|
|||
class ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Sencor SWS 12500 Weather Station."""
|
||||
|
||||
data_schema = {
|
||||
pws_schema = {
|
||||
vol.Required(API_ID): str,
|
||||
vol.Required(API_KEY): str,
|
||||
vol.Optional(WSLINK): bool,
|
||||
|
|
@ -328,17 +329,23 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
|||
|
||||
async def async_step_user(self, user_input: Any = None):
|
||||
"""Handle the initial step."""
|
||||
if user_input is None:
|
||||
await self.async_set_unique_id(DOMAIN)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(self.data_schema),
|
||||
)
|
||||
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 user_input[API_ID] in INVALID_CREDENTIALS:
|
||||
errors[API_ID] = "valid_credentials_api"
|
||||
elif user_input[API_KEY] in INVALID_CREDENTIALS:
|
||||
|
|
@ -346,14 +353,51 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
|||
elif user_input[API_KEY] == user_input[API_ID]:
|
||||
errors["base"] = "valid_credentials_match"
|
||||
else:
|
||||
return self.async_create_entry(title=DOMAIN, data=user_input, options=user_input)
|
||||
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="user",
|
||||
data_schema=vol.Schema(self.data_schema),
|
||||
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)
|
||||
url: URL = URL(get_url(self.hass))
|
||||
host = url.host or "UNKNOWN"
|
||||
|
||||
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": str(url.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:
|
||||
|
|
|
|||
|
|
@ -145,6 +145,7 @@ POCASI_CZ_SEND_MINIMUM: Final = 12 # minimal time to resend data
|
|||
|
||||
|
||||
WSLINK: Final = "wslink"
|
||||
LEGACY_ENABLED: Final = "legacy_enabled"
|
||||
|
||||
WINDY_MAX_RETRIES: Final = 3
|
||||
WSLINK_ADDON_PORT: Final = "WSLINK_ADDON_PORT"
|
||||
|
|
@ -165,6 +166,7 @@ __all__ = [
|
|||
"SENSOR_TO_MIGRATE",
|
||||
"DEV_DBG",
|
||||
"WSLINK",
|
||||
"LEGACY_ENABLED",
|
||||
"ECOWITT",
|
||||
"ECOWITT_WEBHOOK_ID",
|
||||
"ECOWITT_ENABLED",
|
||||
|
|
|
|||
|
|
@ -6,12 +6,14 @@ from typing import Any
|
|||
|
||||
from homeassistant.components.sensor import SensorEntityDescription
|
||||
|
||||
from dev.custom_components.sws12500.const import VOCLevel
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class WeatherSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describe Weather Sensor entities."""
|
||||
|
||||
value_fn: Callable[[Any], int | float | str | None] | None = None
|
||||
value_fn: Callable[[Any], int | float | str | VOCLevel | None] | None = None
|
||||
value_from_data_fn: Callable[[dict[str, Any]], int | float | str | None] | None = None
|
||||
|
||||
deprecated: bool = False
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ from .const import (
|
|||
VOCLevel,
|
||||
)
|
||||
from .sensors_common import WeatherSensorEntityDescription
|
||||
from .utils import battery_level, to_float, to_int, wind_dir_to_text
|
||||
from .utils import battery_5step_to_pct, battery_level, to_float, to_int, voc_level_to_text, wind_dir_to_text
|
||||
|
||||
SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
|
||||
WeatherSensorEntityDescription(
|
||||
|
|
@ -530,7 +530,7 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
|
|||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
icon="mdi:molecule",
|
||||
value_fn=lambda data: cast("int", data),
|
||||
value_fn=to_int,
|
||||
),
|
||||
WeatherSensorEntityDescription(
|
||||
key=VOC,
|
||||
|
|
@ -538,7 +538,42 @@ SENSOR_TYPES_WSLINK: tuple[WeatherSensorEntityDescription, ...] = (
|
|||
device_class=SensorDeviceClass.ENUM,
|
||||
options=list(VOCLevel),
|
||||
icon="mdi:air-filter",
|
||||
value_fn=lambda data: cast("str", voc_level_to_text(data)),
|
||||
value_fn=voc_level_to_text(data),
|
||||
),
|
||||
WeatherSensorEntityDescription(
|
||||
key=T9_BATTERY,
|
||||
translation_key=T9_BATTERY,
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
value_fn=battery_5step_to_pct,
|
||||
),
|
||||
WeatherSensorEntityDescription(
|
||||
key=HCHO,
|
||||
translation_key=HCHO,
|
||||
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
icon="mdi:molecule",
|
||||
value_fn=to_int,
|
||||
),
|
||||
WeatherSensorEntityDescription(
|
||||
key=HCHO,
|
||||
translation_key=HCHO,
|
||||
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
icon="mdi:molecule",
|
||||
value_fn=to_int,
|
||||
),
|
||||
WeatherSensorEntityDescription(
|
||||
key=VOC,
|
||||
translation_key=VOC,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=list(VOCLevel),
|
||||
icon="mdi:air-filter",
|
||||
value_from_data_fn=voc_level_to_text(data),
|
||||
),
|
||||
WeatherSensorEntityDescription(
|
||||
key=T9_BATTERY,
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ from homeassistant.helpers.translation import async_get_translations
|
|||
from .const import (
|
||||
AZIMUT,
|
||||
CONNECTION_GATED_SENSORS,
|
||||
DATABASE_PATH,
|
||||
# DATABASE_PATH,
|
||||
DEV_DBG,
|
||||
OUTSIDE_HUMIDITY,
|
||||
OUTSIDE_TEMP,
|
||||
|
|
@ -122,23 +122,22 @@ def remap_items(entities: dict[str, str]) -> dict[str, str]:
|
|||
stable keys from `const.py` (e.g. "outside_temp", "outside_humidity"). This function produces
|
||||
a normalized dict that the rest of the integration can work with.
|
||||
"""
|
||||
return {
|
||||
REMAP_ITEMS[key]: value for key, value in entities.items() if key in REMAP_ITEMS
|
||||
}
|
||||
return {REMAP_ITEMS[key]: value for key, value in entities.items() if key in REMAP_ITEMS}
|
||||
|
||||
|
||||
def remap_wslink_items(entities: dict[str, str]) -> dict[str, str]:
|
||||
"""Remap WSLink payload field names into internal sensor keys.
|
||||
"""Remap items in query for WSLink API."""
|
||||
items: dict[str, str] = {}
|
||||
for item, value in entities.items():
|
||||
if item in REMAP_WSLINK_ITEMS:
|
||||
items[REMAP_WSLINK_ITEMS[item]] = value
|
||||
|
||||
WSLink uses a different naming scheme than the legacy endpoint (e.g. "t1tem", "t1ws").
|
||||
Just like `remap_items`, this function normalizes the payload to the integration's stable
|
||||
internal keys.
|
||||
"""
|
||||
return {
|
||||
REMAP_WSLINK_ITEMS[key]: value
|
||||
for key, value in entities.items()
|
||||
if key in REMAP_WSLINK_ITEMS
|
||||
}
|
||||
for conn_key, gated in CONNECTION_GATED_SENSORS.items():
|
||||
if str(entities.get(conn_key, "0")) != "1":
|
||||
for key in gated:
|
||||
items.pop(key, None)
|
||||
|
||||
return items
|
||||
|
||||
|
||||
def loaded_sensors(config_entry: ConfigEntry) -> list[str]:
|
||||
|
|
@ -150,9 +149,7 @@ def loaded_sensors(config_entry: ConfigEntry) -> list[str]:
|
|||
return config_entry.options.get(SENSORS_TO_LOAD) or []
|
||||
|
||||
|
||||
def check_disabled(
|
||||
items: dict[str, str], config_entry: ConfigEntry
|
||||
) -> list[str] | None:
|
||||
def check_disabled(items: dict[str, str], config_entry: ConfigEntry) -> list[str] | None:
|
||||
"""Detect payload fields that are not enabled yet (auto-discovery).
|
||||
|
||||
The integration supports "auto-discovery" of sensors: when the station starts sending a new
|
||||
|
|
@ -290,9 +287,7 @@ def to_float(val: Any) -> float | None:
|
|||
return v
|
||||
|
||||
|
||||
def heat_index(
|
||||
data: dict[str, int | float | str], convert: bool = False
|
||||
) -> float | None:
|
||||
def heat_index(data: dict[str, int | float | str], convert: bool = False) -> float | None:
|
||||
"""Calculate heat index from temperature.
|
||||
|
||||
data: dict with temperature and humidity
|
||||
|
|
@ -341,9 +336,7 @@ def heat_index(
|
|||
return simple
|
||||
|
||||
|
||||
def chill_index(
|
||||
data: dict[str, str | float | int], 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.
|
||||
|
||||
data: dict with temperature and wind speed
|
||||
|
|
@ -377,3 +370,126 @@ def chill_index(
|
|||
if temp < 50 and wind > 3
|
||||
else temp
|
||||
)
|
||||
|
||||
|
||||
def voc_level_to_text(value: str) -> VOCLevel | None:
|
||||
"""Map 1-5 VOC level to text state."""
|
||||
if value in (None, ""):
|
||||
return None
|
||||
return VOC_LEVEL_MAP.get(int(value))
|
||||
|
||||
|
||||
def battery_5step_to_pct(value: str) -> int | None:
|
||||
"""Convert 0-5 battery steps to percentage."""
|
||||
|
||||
if value in (None, ""):
|
||||
return None
|
||||
|
||||
return round(int(value) / 5 * 100)
|
||||
|
||||
|
||||
#
|
||||
# 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
|
||||
|
|
|
|||
Loading…
Reference in New Issue