From 9288ae4a648ac58c29c8d0c574230ef650b0deb9 Mon Sep 17 00:00:00 2001 From: SchiZzA Date: Wed, 27 May 2026 17:27:10 +0200 Subject: [PATCH] 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. --- custom_components/sws12500/config_flow.py | 66 ++++++-- custom_components/sws12500/const.py | 2 + custom_components/sws12500/sensors_common.py | 4 +- custom_components/sws12500/sensors_wslink.py | 41 ++++- custom_components/sws12500/utils.py | 162 ++++++++++++++++--- 5 files changed, 237 insertions(+), 38 deletions(-) diff --git a/custom_components/sws12500/config_flow.py b/custom_components/sws12500/config_flow.py index bc391a7..415c97e 100644 --- a/custom_components/sws12500/config_flow.py +++ b/custom_components/sws12500/config_flow.py @@ -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: diff --git a/custom_components/sws12500/const.py b/custom_components/sws12500/const.py index 266826e..dc5dd9e 100644 --- a/custom_components/sws12500/const.py +++ b/custom_components/sws12500/const.py @@ -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", diff --git a/custom_components/sws12500/sensors_common.py b/custom_components/sws12500/sensors_common.py index 66a4994..84f6731 100644 --- a/custom_components/sws12500/sensors_common.py +++ b/custom_components/sws12500/sensors_common.py @@ -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 diff --git a/custom_components/sws12500/sensors_wslink.py b/custom_components/sws12500/sensors_wslink.py index d1056d7..d2b3f2c 100644 --- a/custom_components/sws12500/sensors_wslink.py +++ b/custom_components/sws12500/sensors_wslink.py @@ -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, diff --git a/custom_components/sws12500/utils.py b/custom_components/sws12500/utils.py index 035be3b..000af91 100644 --- a/custom_components/sws12500/utils.py +++ b/custom_components/sws12500/utils.py @@ -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