diff --git a/custom_components/sws12500/battery_sensors_def.py b/custom_components/sws12500/battery_sensors_def.py index d106b19..9fa0cde 100644 --- a/custom_components/sws12500/battery_sensors_def.py +++ b/custom_components/sws12500/battery_sensors_def.py @@ -1,7 +1,7 @@ """Battery sensors templates. -We create a sensor tempate here. -Actualy loaded senors are gated in coordinator. +We create a sensor template here. +Actually loaded sensors are gated in coordinator. """ from __future__ import annotations diff --git a/custom_components/sws12500/config_flow.py b/custom_components/sws12500/config_flow.py index e070b8c..c27694b 100644 --- a/custom_components/sws12500/config_flow.py +++ b/custom_components/sws12500/config_flow.py @@ -10,7 +10,6 @@ from yarl import URL from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.core import callback -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import selector from homeassistant.helpers.network import get_url @@ -43,14 +42,6 @@ from .const import ( _PASSWORD_SELECTOR = selector.TextSelector(selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)) -class CannotConnect(HomeAssistantError): - """We can not connect. - not used in push mechanism.""" - - -class InvalidAuth(HomeAssistantError): - """Invalid auth exception.""" - - class ConfigOptionsFlowHandler(OptionsFlow): """Handle WeatherStation ConfigFlow.""" @@ -63,7 +54,6 @@ class ConfigOptionsFlowHandler(OptionsFlow): self.windy_data_schema = {} self.user_data: dict[str, Any] = {} self.user_data_schema = {} - self.sensors: dict[str, Any] = {} self.migrate_schema = {} self.pocasi_cz: dict[str, Any] = {} self.pocasi_cz_schema = {} @@ -91,12 +81,6 @@ class ConfigOptionsFlowHandler(OptionsFlow): vol.Optional(LEGACY_ENABLED, default=self.user_data.get(LEGACY_ENABLED, True)): bool, } - self.sensors = { - SENSORS_TO_LOAD: ( - entry_data.get(SENSORS_TO_LOAD) if isinstance(entry_data.get(SENSORS_TO_LOAD), list) else [] - ) - } - 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, ""), @@ -157,7 +141,7 @@ class ConfigOptionsFlowHandler(OptionsFlow): """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 credantials empty. + For an Ecowitt-only setup, the user can turn the legacy endpoint off and leave credentials empty. """ errors: dict[str, str] = {} @@ -322,16 +306,24 @@ class ConfigOptionsFlowHandler(OptionsFlow): return self.async_create_entry(title=DOMAIN, data=user_input) def retain_data(self, data: dict[str, Any]) -> dict[str, Any]: - """Retain user_data.""" + """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.sensors, **self.ecowitt, **self.wslink_addon_port, **dict(data), + SENSORS_TO_LOAD: discovered if isinstance(discovered, list) else [], } diff --git a/custom_components/sws12500/const.py b/custom_components/sws12500/const.py index 548f873..6e9aec9 100644 --- a/custom_components/sws12500/const.py +++ b/custom_components/sws12500/const.py @@ -112,9 +112,9 @@ PURGE_DATA: Final = [ "dailyrainin", ] -"""NOTE: These are sensors that should be available with PWS protocol acording to https://support.weather.com/s/article/PWS-Upload-Protocol?language=en_US: +"""NOTE: These are sensors that should be available with PWS protocol according to https://support.weather.com/s/article/PWS-Upload-Protocol?language=en_US: -I have no option to test, if it will work correctly. So their implementatnion will be in future releases. +I have no option to test, if it will work correctly. So their implementation will be in future releases. leafwetness - [%] + for sensor 2 use leafwetness2 @@ -189,9 +189,7 @@ WSLINK_ADDON_PORT: Final = "WSLINK_ADDON_PORT" ECOWITT: Final = "ecowitt" ECOWITT_WEBHOOK_ID: Final = "ecowitt_webhook_id" ECOWITT_ENABLED: Final = "ecowitt_enabled" -ECOWITT_URL: Final = "/weather/ecowitt" ECOWITT_URL_PREFIX: Final = "/weatherhub" -ECOWITT_META_KEYS: Final = {"passkey", "stationtype", "model", "freq"} REMAP_ECOWITT_COMPAT: dict[str, str] = { "tempf": OUTSIDE_TEMP, @@ -225,11 +223,11 @@ REMAP_ECOWITT_COMPAT: dict[str, str] = { POCASI_CZ_API_KEY = "POCASI_CZ_API_KEY" POCASI_CZ_API_ID = "POCASI_CZ_API_ID" POCASI_CZ_SEND_INTERVAL = "POCASI_SEND_INTERVAL" -POCASI_CZ_ENABLED = "pocasi_enabled_chcekbox" +POCASI_CZ_ENABLED = "pocasi_enabled_checkbox" POCASI_CZ_LOGGER_ENABLED = "pocasi_logger_checkbox" POCASI_INVALID_KEY: Final = "Pocasi Meteo refused to accept data. Invalid ID/Key combination?" POCASI_CZ_SUCCESS: Final = "Successfully sent data to Pocasi Meteo" -POCASI_CZ_UNEXPECTED: Final = "Pocasti Meteo responded unexpectedly 3 times in row. Resendig is now disabled!" +POCASI_CZ_UNEXPECTED: Final = "Pocasi Meteo responded unexpectedly 3 times in row. Resending is now disabled!" WINDY_STATION_ID = "WINDY_STATION_ID" WINDY_STATION_PW = "WINDY_STATION_PWD" @@ -243,13 +241,6 @@ WINDY_SUCCESS: Final = "Windy successfully sent data and data was successfully i WINDY_UNEXPECTED: Final = "Windy responded unexpectedly 3 times in a row. Send to Windy is now disabled!" -PURGE_DATA_POCAS: Final = [ - "ID", - "PASSWORD", - "action", - "rtfreq", -] - REMAP_WSLINK_ITEMS: dict[str, str] = { "intem": INDOOR_TEMP, @@ -308,10 +299,10 @@ REMAP_WSLINK_ITEMS: dict[str, str] = { # # 'inbat' indoor battery level (1 normal, 0 low) # 't1bat': outdoor battery level (1 normal, 0 low) -# 't234c1bat': CH2 battery level (1 normal, 0 low) CH2 in integration is CH1 in WSLin +# 't234c1bat': CH2 battery level (1 normal, 0 low) CH2 in integration is CH1 in WSLink # # In the following there are sensors that should be available by WSLink. -# We need to compare them to PWS API to make sure, we have the same intarnal +# We need to compare them to PWS API to make sure, we have the same internal # representation of same sensors. ### TODO: These are sensors, that should be supported in WSLink API according to their API documentation: @@ -360,38 +351,12 @@ REMAP_WSLINK_ITEMS: dict[str, str] = { # &t10cn= CO2 sensor connection (Connected=1, No connect=0) integer # &t11co= CO concentration integer ppm # &t11bat= CO sensor battery level (0~5) remark: 5 is full integer -# &t11cn= CO sensor connection (Connected=1, No connect=0) integero +# &t11cn= CO sensor connection (Connected=1, No connect=0) integer # -DISABLED_BY_DEFAULT: Final = [ - CH2_TEMP, - CH2_HUMIDITY, - CH2_BATTERY, - CH3_TEMP, - CH3_HUMIDITY, - CH3_BATTERY, - CH4_TEMP, - CH4_HUMIDITY, - CH4_BATTERY, - CH5_TEMP, - CH5_HUMIDITY, - CH5_BATTERY, - CH6_TEMP, - CH6_HUMIDITY, - CH6_BATTERY, - CH7_TEMP, - CH7_HUMIDITY, - CH7_BATTERY, - CH8_TEMP, - CH8_HUMIDITY, - CH8_BATTERY, - OUTSIDE_BATTERY, - WBGT_TEMP, -] - -# Station reports batteries as 0/1 (low/normal) for most of sensors. -# Batteries reported as 0-5 level are stored in `BATTERY_NON_BINARY` tuple +# Station reports batteries as 0/1 (low/normal). Sensors reporting a 0-5 level +# (e.g. T9_BATTERY) are plain sensors, not binary ones. BATTERY_LIST: Final[tuple[str, ...]] = ( OUTSIDE_BATTERY, INDOOR_BATTERY, @@ -404,7 +369,6 @@ BATTERY_LIST: Final[tuple[str, ...]] = ( CH8_BATTERY, ) -BATTERY_NON_BINARY: Final[tuple[str, ...]] = (T9_BATTERY,) CONNECTION_GATED_SENSORS: Final[dict[str, list[str]]] = { # Multi-channel temp/humidity probes (CH2 - CH8) @@ -487,9 +451,3 @@ class UnitOfBat(StrEnum): NORMAL = "normal" UNKNOWN = "drained" - -BATTERY_LEVEL: list[UnitOfBat] = [ - UnitOfBat.LOW, - UnitOfBat.NORMAL, - UnitOfBat.UNKNOWN, -] diff --git a/custom_components/sws12500/coordinator.py b/custom_components/sws12500/coordinator.py index d069fb7..39367e9 100644 --- a/custom_components/sws12500/coordinator.py +++ b/custom_components/sws12500/coordinator.py @@ -46,7 +46,6 @@ from .ecowitt import EcowittBridge from .health_coordinator import HealthCoordinator from .pocasti_cz import PocasiPush from .sensor import add_new_sensors -from .staleness import update_stale_sensors_issue from .utils import ( anonymize, check_disabled, @@ -218,7 +217,6 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator): now = dt_util.utcnow() for key in mapped_data: self.config.runtime_data.last_seen[key] = now - update_stale_sensors_issue(self.hass, self.config) if health: health.update_ingress_result( @@ -313,7 +311,6 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator): now = dt_util.utcnow() for key in remaped_items: self.config.runtime_data.last_seen[key] = now - update_stale_sensors_issue(self.hass, self.config) if health: health.update_ingress_result( diff --git a/custom_components/sws12500/ecowitt.py b/custom_components/sws12500/ecowitt.py index 1427765..2918d82 100644 --- a/custom_components/sws12500/ecowitt.py +++ b/custom_components/sws12500/ecowitt.py @@ -16,7 +16,12 @@ import logging from typing import Any, Final from aioecowitt import EcoWittListener, EcoWittSensor, EcoWittSensorTypes -from aioecowitt.sensor import SENSOR_MAP + +try: + # Internal to aioecowitt; see `_build_unit_twins` for why this is tolerated. + from aioecowitt.sensor import SENSOR_MAP +except ImportError: # pragma: no cover - defensive, module moved upstream + SENSOR_MAP = {} from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorStateClass from homeassistant.core import HomeAssistant, callback @@ -42,10 +47,25 @@ def _build_unit_twins() -> dict[str, frozenset[str]]: aioecowitt exposes both metric and imperial sensors for many readings (e.g. `tempc`/`tempf`, `rainratemm`/`rainratein`), recognisable by a shared display name. + + `SENSOR_MAP` is aioecowitt-internal (not part of its public API), so an upstream + rename must not take the whole integration down with an ImportError/AttributeError + at module import. Degrading to "no twins known" only costs us the duplicate-unit + dedup in `_on_new_sensor`. """ + try: + sensor_map = SENSOR_MAP.items() + except AttributeError: # pragma: no cover - defensive, shape changed upstream + _LOGGER.warning("aioecowitt SENSOR_MAP is not a mapping; unit-variant dedup disabled") + return {} + by_name: dict[str, set[str]] = {} - for key, meta in SENSOR_MAP.items(): - by_name.setdefault(meta.name, set()).add(key) + for key, meta in sensor_map: + name = getattr(meta, "name", None) + if name is None: # pragma: no cover - defensive + continue + by_name.setdefault(name, set()).add(key) + twins: dict[str, frozenset[str]] = {} for keys in by_name.values(): if len(keys) > 1: @@ -210,7 +230,7 @@ class EcowittBridge: """Bridge between HA webhook and aioecowitt parsing. We do not run EcoWittListener.start() - this would start separate HTTP server. - Instead we are calling listener.process_data() manualy from our webhook handler + Instead we are calling listener.process_data() manually from our webhook handler and we are just using parsing/discovery logic. """ @@ -249,8 +269,8 @@ class EcowittBridge: """Process raw Ecowitt POST payload. Returns: - Dict of internal sensor keys -> values (fro mapped senors). - Unmapped sensors are handeled via _on_new_sensor callback. + Dict of internal sensor keys -> values (for mapped sensors). + Unmapped sensors are handled via _on_new_sensor callback. """ @@ -269,7 +289,7 @@ class EcowittBridge: def _on_new_sensor(self, sensor: EcoWittSensor) -> None: """Call me by aioecowitt when a new sensor is discovered. - If the senosor does not have internal mapping, + If the sensor does not have internal mapping, create native Ecowitt entity. """ @@ -313,16 +333,17 @@ class EcowittBridge: @property def unmapped_sensor(self) -> dict[str, EcoWittSensor]: - """Return al sensors that don't have an internal mapping.""" + """Return all sensors that don't have an internal mapping.""" return {key: sensor for key, sensor in self._listener.sensors.items() if sensor.key not in _MAPPED_ECOWITT_KEYS} @property def all_sensors(self) -> dict[str, EcoWittSensor]: - """Return all discovered sensors.""" + """Return every sensor aioecowitt has parsed so far.""" return self._listener.sensors + class EcoWittNativeSensor(SensorEntity): """Sensor entity for Ecowitt sensors without internal mapping. diff --git a/custom_components/sws12500/health_coordinator.py b/custom_components/sws12500/health_coordinator.py index a41a051..cfbeba9 100644 --- a/custom_components/sws12500/health_coordinator.py +++ b/custom_components/sws12500/health_coordinator.py @@ -366,7 +366,7 @@ class HealthCoordinator(DataUpdateCoordinator): - whether the request was rejected before processing """ - # We do not want to proccess health requests + # We do not want to process health requests if request.path == HEALTH_URL: return diff --git a/custom_components/sws12500/legacy.py b/custom_components/sws12500/legacy.py index 4caaaa6..142007e 100644 --- a/custom_components/sws12500/legacy.py +++ b/custom_components/sws12500/legacy.py @@ -1,8 +1,8 @@ """Legacy battery sensor deprecation. The integration used to expose battery state as regular SensorEntity instance -(unique_id == bettery key), they have been migrated to BinarySensorEntity (uniqui_id == `key`_binary). Old entity-registry entries from -pre-migration installs orphan. This module raises a Repairs issue so user can celan them up. +(unique_id == battery key), they have been migrated to BinarySensorEntity (unique_id == `key`_binary). Old entity-registry entries from +pre-migration installs orphan. This module raises a Repairs issue so user can clean them up. """ from __future__ import annotations @@ -33,17 +33,17 @@ LEGACY_BATTERY_KEYS: Final[frozenset[str]] = frozenset( def _legacy_battery_issue_id(entry: SWSConfigEntry) -> str: - """Return Repairs issue id fpr this config entry.""" + """Return Repairs issue id for this config entry.""" return f"legacy_battery_sensor_deprecation_{entry.entry_id}" @callback -def _orphan_legacy_battery_etries(hass: HomeAssistant, entry: SWSConfigEntry) -> list[str]: +def _orphan_legacy_battery_entries(hass: HomeAssistant, entry: SWSConfigEntry) -> list[str]: """Return entity_ids of legacy battery sensors still present in entity registry. Old non-binary battery entities have: - - domian == "sensor" - - unique_id matches a LEGACY_BATTERY_KESY entry (without `_binary` suffix) + - domain == "sensor" + - unique_id matches a LEGACY_BATTERY_KEYS entry (without `_binary` suffix) """ ent_reg = er.async_get(hass) return [ @@ -55,10 +55,10 @@ def _orphan_legacy_battery_etries(hass: HomeAssistant, entry: SWSConfigEntry) -> @callback def update_legacy_battery_issue(hass: HomeAssistant, entry: SWSConfigEntry) -> None: - """Create or clear a Repairs issue for orphan legacy battery snesors.""" + """Create or clear a Repairs issue for orphan legacy battery sensors.""" issue_id = _legacy_battery_issue_id(entry=entry) - orphans = _orphan_legacy_battery_etries(hass, entry) + orphans = _orphan_legacy_battery_entries(hass, entry) if orphans: ir.async_create_issue( diff --git a/custom_components/sws12500/pocasti_cz.py b/custom_components/sws12500/pocasti_cz.py index 90a8c32..ae7710a 100644 --- a/custom_components/sws12500/pocasti_cz.py +++ b/custom_components/sws12500/pocasti_cz.py @@ -7,7 +7,7 @@ import logging from typing import Any, Literal from aiohttp import ClientError -from py_typecheck.core import checked +from py_typecheck.core import checked_or from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -43,7 +43,6 @@ class PocasiPush: """Init.""" self.hass = hass self.config = config - self.enabled: bool = self.config.options.get(POCASI_CZ_ENABLED, False) self.last_status: str = "disabled" if not self.enabled else "idle" self.last_error: str | None = None self.last_attempt_at: str | None = None @@ -55,6 +54,16 @@ class PocasiPush: self.log = self.config.options.get(POCASI_CZ_LOGGER_ENABLED) self.invalid_response_count = 0 + @property + def enabled(self) -> bool: + """Whether forwarding is currently on, read live from the options. + + Toggling this option does not reload the entry (see `update_listener`), so a + cached copy would leave the diagnostics sensor reporting a stale value until + the next push - or forever, since a disabled forwarder is never called again. + """ + return checked_or(self.config.options.get(POCASI_CZ_ENABLED), bool, False) + def verify_response(self, status: int, body: str) -> PocasiResult: """Classify a send by its HTTP status. @@ -76,7 +85,6 @@ class PocasiPush: async def _disable_pocasi(self, reason: str) -> None: """Turn resending off and persist it, so it survives a restart.""" - self.enabled = False self.last_error = reason if not await update_options(self.hass, self.config, POCASI_CZ_ENABLED, False): @@ -86,17 +94,18 @@ class PocasiPush: """Pushes weather data to server.""" _data = data.copy() - self.enabled = self.config.options.get(POCASI_CZ_ENABLED, False) self.last_attempt_at = dt_util.utcnow().isoformat() self.last_error = None - if (_api_id := checked(self.config.options.get(POCASI_CZ_API_ID), str)) is None: + # An empty string is still a `str`, so `checked` alone would let unconfigured + # credentials through and send a request that can only ever be rejected. + if not (_api_id := checked_or(self.config.options.get(POCASI_CZ_API_ID), str, "")): _LOGGER.error("No API ID is provided for Pocasi Meteo. Check your configuration.") self.last_status = "config_error" self.last_error = "Missing API ID." return - if (_api_key := checked(self.config.options.get(POCASI_CZ_API_KEY), str)) is None: + if not (_api_key := checked_or(self.config.options.get(POCASI_CZ_API_KEY), str, "")): _LOGGER.error("No API Key is provided for Pocasi Meteo. Check your configuration.") self.last_status = "config_error" self.last_error = "Missing API key." @@ -112,7 +121,7 @@ class PocasiPush: if self.next_update > dt_util.utcnow(): self.last_status = "rate_limited_local" _LOGGER.debug( - "Triggered update interval limit of %s seconds. Next possilbe update is set to: %s", + "Triggered update interval limit of %s seconds. Next possible update is set to: %s", self._interval, self.next_update, ) @@ -163,7 +172,7 @@ class PocasiPush: self.last_error = f"Unexpected HTTP status {http_status} from Pocasi Meteo." self.invalid_response_count += 1 _LOGGER.warning( - "Unexpected HTTP status %s from Pocasi Meteo. Retries before disabling resend: %s", + "Unexpected HTTP status %s from Pocasi Meteo. Rentries before disabling resend: %s", http_status, POCASI_CZ_MAX_RETRIES - self.invalid_response_count, ) diff --git a/custom_components/sws12500/routes.py b/custom_components/sws12500/routes.py index 4925509..a0fdbe6 100644 --- a/custom_components/sws12500/routes.py +++ b/custom_components/sws12500/routes.py @@ -93,7 +93,7 @@ class Routes: def set_ecowitt_enabled(self, url_path: str, handler: Handler, enabled: bool) -> None: """Enable or disable the Ecowitt sticky route. - switch_route() does not involves sticky routes, so we need another + switch_route() does not involve sticky routes, so we need another method for Ecowitt state at reload. """ @@ -229,5 +229,5 @@ async def unregistered(request: Request) -> Response: a clear error message when the station pushes to the wrong endpoint. """ _ = request - _LOGGER.debug("Received data to unregistred or disabled webhook.") - return Response(text="Unregistred webhook. Check your settings.", status=400) + _LOGGER.debug("Received data to unregistered or disabled webhook.") + return Response(text="Unregistered webhook. Check your settings.", status=400) diff --git a/custom_components/sws12500/strings.json b/custom_components/sws12500/strings.json index 5520bde..23218fe 100644 --- a/custom_components/sws12500/strings.json +++ b/custom_components/sws12500/strings.json @@ -142,18 +142,6 @@ "data_description": { "WSLINK_ADDON_PORT": "The external TCP port the WSLink proxy add-on listens on (default 443)." } - }, - "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.", - "data": { - "sensor_to_migrate": "Sensor to migrate", - "trigger_action": "Trigger migration" - }, - "data_description": { - "sensor_to_migrate": "Select the correct sensor for statistics migration.\nThe sensor values will be preserved, they will not be recalculated, only the unit in the long-term statistics will be changed.", - "trigger_action": "Trigger the sensor statistics migration after checking." - } } } }, @@ -475,7 +463,7 @@ "state": { "normal": "OK", "low": "Low", - "unknown": "Unknown / drained out" + "drained": "Unknown / drained out" } }, "ch2_battery": { @@ -483,7 +471,7 @@ "state": { "normal": "OK", "low": "Low", - "unknown": "Unknown / drained out" + "drained": "Unknown / drained out" } }, "ch3_battery": { @@ -491,7 +479,7 @@ "state": { "normal": "OK", "low": "Low", - "unknown": "Unknown / drained out" + "drained": "Unknown / drained out" } }, "ch4_battery": { @@ -499,7 +487,7 @@ "state": { "normal": "OK", "low": "Low", - "unknown": "Unknown / drained out" + "drained": "Unknown / drained out" } }, "ch5_battery": { @@ -507,7 +495,7 @@ "state": { "normal": "OK", "low": "Low", - "unknown": "Unknown / drained out" + "drained": "Unknown / drained out" } }, "ch6_battery": { @@ -515,7 +503,7 @@ "state": { "normal": "OK", "low": "Low", - "unknown": "Unknown / drained out" + "drained": "Unknown / drained out" } }, "ch7_battery": { @@ -523,7 +511,7 @@ "state": { "normal": "OK", "low": "Low", - "unknown": "Unknown / drained out" + "drained": "Unknown / drained out" } }, "ch8_battery": { @@ -531,7 +519,7 @@ "state": { "normal": "OK", "low": "Low", - "unknown": "Unknown / drained out" + "drained": "Unknown / drained out" } }, "indoor_battery": { @@ -539,7 +527,7 @@ "state": { "normal": "OK", "low": "Low", - "unknown": "Unknown / drained out" + "drained": "Unknown / drained out" } } } diff --git a/custom_components/sws12500/translations/cs.json b/custom_components/sws12500/translations/cs.json index 411674d..7bcd8ec 100644 --- a/custom_components/sws12500/translations/cs.json +++ b/custom_components/sws12500/translations/cs.json @@ -67,8 +67,7 @@ "windy": "Nastavení pro přeposílání dat na Windy", "pocasi": "Nastavení pro přeposlání dat na Počasí Meteo CZ", "ecowitt": "Nastavení pro stanice Ecowitt", - "wslink_port_setup": "Nastavení portu WSLink Addonu", - "migration": "Migrace statistiky senzoru" + "wslink_port_setup": "Nastavení portu WSLink Addonu" } }, "basic": { @@ -143,18 +142,6 @@ "data_description": { "WSLINK_ADDON_PORT": "Zadejte port, tak jak jej máte nastavený ve WSLink Addonu." } - }, - "migration": { - "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ů.", - "data": { - "sensor_to_migrate": "Senzor pro migraci", - "trigger_action": "Spustit migraci" - }, - "data_description": { - "sensor_to_migrate": "Vyberte správný senzor pri migraci statistiky. \n Hodnoty senzoru budou zachovány, nepřepočítají se, pouze se změní jednotka v dlouhodobé statistice. ", - "trigger_action": "Po zaškrtnutí se spustí migrace statistiky senzoru." - } } } }, @@ -476,7 +463,7 @@ "state": { "low": "Nízká", "normal": "Normální", - "unknown": "Neznámá / zcela vybitá" + "drained": "Neznámá / zcela vybitá" } }, "indoor_battery": { @@ -492,7 +479,7 @@ "state": { "low": "Nízká", "normal": "Normální", - "unknown": "Neznámá / zcela vybitá" + "drained": "Neznámá / zcela vybitá" } }, "ch3_battery": { @@ -500,7 +487,7 @@ "state": { "low": "Nízká", "normal": "Normální", - "unknown": "Neznámá / zcela vybitá" + "drained": "Neznámá / zcela vybitá" } }, "ch4_battery": { @@ -508,7 +495,7 @@ "state": { "low": "Nízká", "normal": "Normální", - "unknown": "Neznámá / zcela vybitá" + "drained": "Neznámá / zcela vybitá" } }, "ch5_battery": { @@ -516,7 +503,7 @@ "state": { "low": "Nízká", "normal": "Normální", - "unknown": "Neznámá / zcela vybitá" + "drained": "Neznámá / zcela vybitá" } }, "ch6_battery": { @@ -524,7 +511,7 @@ "state": { "low": "Nízká", "normal": "Normální", - "unknown": "Neznámá / zcela vybitá" + "drained": "Neznámá / zcela vybitá" } }, "ch7_battery": { @@ -532,7 +519,7 @@ "state": { "low": "Nízká", "normal": "Normální", - "unknown": "Neznámá / zcela vybitá" + "drained": "Neznámá / zcela vybitá" } }, "ch8_battery": { @@ -540,7 +527,7 @@ "state": { "low": "Nízká", "normal": "Normální", - "unknown": "Neznámá / zcela vybitá" + "drained": "Neznámá / zcela vybitá" } } } diff --git a/custom_components/sws12500/translations/en.json b/custom_components/sws12500/translations/en.json index 5520bde..23218fe 100644 --- a/custom_components/sws12500/translations/en.json +++ b/custom_components/sws12500/translations/en.json @@ -142,18 +142,6 @@ "data_description": { "WSLINK_ADDON_PORT": "The external TCP port the WSLink proxy add-on listens on (default 443)." } - }, - "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.", - "data": { - "sensor_to_migrate": "Sensor to migrate", - "trigger_action": "Trigger migration" - }, - "data_description": { - "sensor_to_migrate": "Select the correct sensor for statistics migration.\nThe sensor values will be preserved, they will not be recalculated, only the unit in the long-term statistics will be changed.", - "trigger_action": "Trigger the sensor statistics migration after checking." - } } } }, @@ -475,7 +463,7 @@ "state": { "normal": "OK", "low": "Low", - "unknown": "Unknown / drained out" + "drained": "Unknown / drained out" } }, "ch2_battery": { @@ -483,7 +471,7 @@ "state": { "normal": "OK", "low": "Low", - "unknown": "Unknown / drained out" + "drained": "Unknown / drained out" } }, "ch3_battery": { @@ -491,7 +479,7 @@ "state": { "normal": "OK", "low": "Low", - "unknown": "Unknown / drained out" + "drained": "Unknown / drained out" } }, "ch4_battery": { @@ -499,7 +487,7 @@ "state": { "normal": "OK", "low": "Low", - "unknown": "Unknown / drained out" + "drained": "Unknown / drained out" } }, "ch5_battery": { @@ -507,7 +495,7 @@ "state": { "normal": "OK", "low": "Low", - "unknown": "Unknown / drained out" + "drained": "Unknown / drained out" } }, "ch6_battery": { @@ -515,7 +503,7 @@ "state": { "normal": "OK", "low": "Low", - "unknown": "Unknown / drained out" + "drained": "Unknown / drained out" } }, "ch7_battery": { @@ -523,7 +511,7 @@ "state": { "normal": "OK", "low": "Low", - "unknown": "Unknown / drained out" + "drained": "Unknown / drained out" } }, "ch8_battery": { @@ -531,7 +519,7 @@ "state": { "normal": "OK", "low": "Low", - "unknown": "Unknown / drained out" + "drained": "Unknown / drained out" } }, "indoor_battery": { @@ -539,7 +527,7 @@ "state": { "normal": "OK", "low": "Low", - "unknown": "Unknown / drained out" + "drained": "Unknown / drained out" } } } diff --git a/custom_components/sws12500/utils.py b/custom_components/sws12500/utils.py index a600690..017fe63 100644 --- a/custom_components/sws12500/utils.py +++ b/custom_components/sws12500/utils.py @@ -170,7 +170,6 @@ def check_disabled(items: dict[str, str], config_entry: ConfigEntry) -> list[str log = checked_or(config_entry.options.get(DEV_DBG), bool, False) - entityFound: bool = False _loaded_sensors: list[str] = loaded_sensors(config_entry) missing_sensors: list[str] = [] @@ -180,11 +179,10 @@ def check_disabled(items: dict[str, str], config_entry: ConfigEntry) -> list[str if item not in _loaded_sensors: missing_sensors.append(item) - entityFound = True if log: _LOGGER.info("Add sensor (%s) to loading queue", item) - return missing_sensors if entityFound else None + return missing_sensors or None def wind_dir_to_text(deg: float | str | None) -> UnitOfDir | None: @@ -235,18 +233,6 @@ def battery_level(battery: int | str | None) -> UnitOfBat: return level_map.get(vi, UnitOfBat.UNKNOWN) -def battery_level_to_icon(battery: UnitOfBat) -> str: - """Return battery level in icon representation. - - Returns str - """ - - icons = { - UnitOfBat.LOW: "mdi:battery-low", - UnitOfBat.NORMAL: "mdi:battery", - } - - return icons.get(battery, "mdi:battery-unknown") def fahrenheit_to_celsius(fahrenheit: float) -> float: @@ -382,17 +368,27 @@ def chill_index(data: dict[str, str | float | int], convert: bool = False) -> fl ) -def voc_level_to_text(value: str | None) -> VOCLevel | None: - """Map 1-5 VOC level to text state.""" - if value in (None, ""): +def voc_level_to_text(value: Any) -> VOCLevel | None: + """Map the 1-5 VOC level to a text state. + + Goes through `to_int` like every other value_fn: a bare `int()` raises on a garbage + payload value, which `WeatherSensor.native_value` then logs with a full traceback on + every push. + """ + level = to_int(value) + if level is None: return None - return VOC_LEVEL_MAP.get(int(value)) + return VOC_LEVEL_MAP.get(level) -def battery_5step_to_pct(value: str) -> int | None: - """Convert 0-5 battery steps to percentage.""" +def battery_5step_to_pct(value: Any) -> int | None: + """Convert the 0-5 battery step to a percentage. - if value in (None, ""): + Out-of-range steps are clamped so the reading stays valid for a battery + device class (see `voc_level_to_text` for why `to_int` is used). + """ + step = to_int(value) + if step is None: return None - return round(int(value) / 5 * 100) + return round(min(max(step, 0), 5) / 5 * 100) diff --git a/custom_components/sws12500/windy_func.py b/custom_components/sws12500/windy_func.py index c0b6c94..576f5c8 100644 --- a/custom_components/sws12500/windy_func.py +++ b/custom_components/sws12500/windy_func.py @@ -7,7 +7,7 @@ import logging from aiohttp.client import ClientResponse from aiohttp.client_exceptions import ClientError -from py_typecheck import checked +from py_typecheck import checked_or from homeassistant.components import persistent_notification from homeassistant.config_entries import ConfigEntry @@ -81,7 +81,6 @@ class WindyPush: """Init.""" self.hass = hass self.config = config - self.enabled: bool = self.config.options.get(WINDY_ENABLED, False) self.last_status: str = "disabled" if not self.enabled else "idle" self.last_error: str | None = None self.last_attempt_at: str | None = None @@ -94,10 +93,20 @@ class WindyPush: self.log: bool = self.config.options.get(WINDY_LOGGER_ENABLED, False) - # Lets chcek if Windy server is responding right. - # Otherwise, try 3 times and then disable resending. + # Lets check if Windy server is responding right. + # Otherwise, try WINDY_MAX_RETRIES times and then disable resending. self.invalid_response_count: int = 0 + @property + def enabled(self) -> bool: + """Whether forwarding is currently on, read live from the options. + + Toggling this option does not reload the entry (see `update_listener`), so a + cached copy would leave the diagnostics sensor reporting a stale value until + the next push - or forever, since a disabled forwarder is never called again. + """ + return checked_or(self.config.options.get(WINDY_ENABLED), bool, False) + # Refactored responses verification. # # We now comply to API at https://stations.windy.com/api-reference @@ -150,15 +159,18 @@ class WindyPush: return indata async def _disable_windy(self, reason: str) -> None: - """Disable Windy resending.""" - self.enabled = False + """Disable Windy resending. + + `enabled` reads the option back, so persisting it here is what actually turns + forwarding off. + """ self.last_status = "disabled" self.last_error = reason if not await update_options(self.hass, self.config, WINDY_ENABLED, False): _LOGGER.debug("Failed to set Windy options to false.") - persistent_notification.create(self.hass, reason, "Windy resending disabled.") + persistent_notification.async_create(self.hass, reason, "Windy resending disabled.") async def push_data_to_windy(self, data: dict[str, str], wslink: bool = False) -> bool: """Pushes weather data do Windy stations. @@ -170,19 +182,20 @@ class WindyPush: """ # First check if we have valid credentials, before any data manipulation. - self.enabled = self.config.options.get(WINDY_ENABLED, False) self.last_attempt_at = dt_util.utcnow().isoformat() self.last_error = None - if (windy_station_id := checked(self.config.options.get(WINDY_STATION_ID), str)) is None: - _LOGGER.error("Windy API key is not provided! Check your configuration.") + # An empty string is still a `str`, so `checked` alone would let unconfigured + # credentials through and send a request that can only ever be rejected. + if not (windy_station_id := checked_or(self.config.options.get(WINDY_STATION_ID), str, "")): + _LOGGER.error("Windy station ID is not provided! Check your configuration.") self.last_status = "config_error" await self._disable_windy( - "Windy API key is not provided. Resending is disabled for now. Reconfigure your integration." + "Windy station ID is not provided. Resending is disabled for now. Reconfigure your integration." ) return False - if (windy_station_pw := checked(self.config.options.get(WINDY_STATION_PW), str)) is None: + if not (windy_station_pw := checked_or(self.config.options.get(WINDY_STATION_PW), str, "")): _LOGGER.error("Windy station password is missing! Check your configuration.") self.last_status = "config_error" await self._disable_windy( @@ -238,7 +251,7 @@ class WindyPush: # log despite of settings _LOGGER.error( - "%s Max retries before disable resend function: %s", + "%s Max rentries before disable resend function: %s", WINDY_NOT_INSERTED, (WINDY_MAX_RETRIES - self.invalid_response_count), ) @@ -255,7 +268,7 @@ class WindyPush: self.last_status = "duplicate" self.last_error = "Duplicate payload detected by Windy server." _LOGGER.critical( - "Duplicate payload detected by Windy server. Will try again later. Max retries before disabling resend function: %s", + "Duplicate payload detected by Windy server. Will try again later. Max rentries before disabling resend function: %s", (WINDY_MAX_RETRIES - self.invalid_response_count), ) self.invalid_response_count += 1 @@ -283,12 +296,15 @@ class WindyPush: self.invalid_response_count += 1 if self.log: _LOGGER.debug( - "Unexpected response from Windy. Max retries before disabling resend function: %s", + "Unexpected response from Windy. Max rentries before disabling resend function: %s", (WINDY_MAX_RETRIES - self.invalid_response_count), ) finally: - if self.invalid_response_count >= 3: - _LOGGER.critical("Invalid response from Windy 3 times. Disabling resend option.") + if self.invalid_response_count >= WINDY_MAX_RETRIES: + _LOGGER.critical( + "Invalid response from Windy %s times. Disabling resend option.", + WINDY_MAX_RETRIES, + ) await self._disable_windy( reason="Unable to send data to Windy (3 times). Disabling resend option for now. Please check your Windy configuration and enable this feature afterwards." ) @@ -299,7 +315,7 @@ class WindyPush: # attributes; str(ex) could embed the request URL. self.last_error = type(ex).__name__ _LOGGER.critical( - "Invalid response from Windy: %s. Will try again later, max retries before disabling resend function: %s", + "Invalid response from Windy: %s. Will try again later, max rentries before disabling resend function: %s", str(ex), (WINDY_MAX_RETRIES - self.invalid_response_count), ) diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 6192b6f..db0a058 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -20,6 +20,7 @@ from custom_components.sws12500.const import ( POCASI_CZ_LOGGER_ENABLED, POCASI_CZ_SEND_INTERVAL, POCASI_CZ_SEND_MINIMUM, + SENSORS_TO_LOAD, WINDY_ENABLED, WINDY_LOGGER_ENABLED, WINDY_STATION_ID, @@ -463,3 +464,49 @@ async def test_config_flow_ecowitt_initial_setup(hass, enable_custom_integration assert done["type"] == "create_entry" assert done["data"][ECOWITT_ENABLED] is True assert done["data"][LEGACY_ENABLED] is False + + +@pytest.mark.asyncio +async def test_options_flow_does_not_roll_back_concurrent_autodiscovery( + hass, + enable_custom_integrations, +) -> None: + """Auto-discovery that lands while the dialog is open must survive the submit. + + The options flow snapshots the entry when a step opens, but the webhook handler + appends to SENSORS_TO_LOAD independently. Writing back the snapshot would silently + drop any sensor discovered in between. + """ + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + options={ + API_ID: "station", + API_KEY: "secret", + LEGACY_ENABLED: True, + SENSORS_TO_LOAD: ["outside_temp"], + }, + ) + entry.add_to_hass(hass) + + init = await hass.config_entries.options.async_init(entry.entry_id) + await hass.config_entries.options.async_configure(init["flow_id"], user_input={"next_step_id": "basic"}) + + # The station starts reporting a new field while the form is on screen. + hass.config_entries.async_update_entry( + entry, + options={**entry.options, SENSORS_TO_LOAD: ["outside_temp", "wind_gust"]}, + ) + + done = await hass.config_entries.options.async_configure( + init["flow_id"], + user_input={ + API_ID: "station", + API_KEY: "secret", + WSLINK: False, + LEGACY_ENABLED: True, + }, + ) + + assert done["type"] == "create_entry" + assert done["data"][SENSORS_TO_LOAD] == ["outside_temp", "wind_gust"] diff --git a/tests/test_pocasi_push.py b/tests/test_pocasi_push.py index 8eb67b4..0dbfab9 100644 --- a/tests/test_pocasi_push.py +++ b/tests/test_pocasi_push.py @@ -80,6 +80,21 @@ def _make_entry( return entry + +def _write_through_update_options(entry: Any) -> AsyncMock: + """Mock `update_options` that really mutates the entry, like the real helper. + + `PocasiPush.enabled` reads the option back, so a mock that only records the call + would leave `enabled` reporting the pre-disable value. + """ + + async def _apply(_hass, _entry, key, value): + entry.options[key] = value + return True + + return AsyncMock(side_effect=_apply) + + @pytest.fixture def hass(): # Minimal hass-like object; we patch client session retrieval. @@ -206,7 +221,7 @@ async def test_push_data_to_server_auth_error_disables_feature(monkeypatch, hass ) monkeypatch.setattr("custom_components.sws12500.pocasti_cz.anonymize", lambda d: d) - update_options = AsyncMock(return_value=True) + update_options = _write_through_update_options(entry) monkeypatch.setattr( "custom_components.sws12500.pocasti_cz.update_options", update_options ) @@ -265,7 +280,7 @@ async def test_push_data_to_server_server_error_disables_after_max_retries(monke ) monkeypatch.setattr("custom_components.sws12500.pocasti_cz.anonymize", lambda d: d) - update_options = AsyncMock(return_value=True) + update_options = _write_through_update_options(entry) monkeypatch.setattr( "custom_components.sws12500.pocasti_cz.update_options", update_options ) @@ -292,7 +307,7 @@ async def test_push_data_to_server_client_error_increments_and_disables_after_th entry = _make_entry() pp = PocasiPush(hass, entry) - update_options = AsyncMock(return_value=True) + update_options = _write_through_update_options(entry) monkeypatch.setattr( "custom_components.sws12500.pocasti_cz.update_options", update_options ) @@ -369,6 +384,57 @@ async def test_disable_pocasi_logs_when_option_write_fails(monkeypatch, hass): await pp._disable_pocasi("because") - assert pp.enabled is False + # `enabled` mirrors the persisted option: if the write failed, forwarding is still + # on as far as the config is concerned, and the failure is logged instead. + assert pp.enabled is True assert pp.last_error == "because" dbg.assert_called() + + +# --------------------------------------------------------------------------- +# Live `enabled` and empty-credential rejection +# --------------------------------------------------------------------------- + + +def test_enabled_reads_options_live(hass): + """Toggling the option is visible immediately - no reload, no cached copy. + + `update_listener` deliberately skips the reload when only this flag changes, so a + value cached in __init__ would leave the diagnostics sensor permanently stale. + """ + entry = _make_entry() + pp = PocasiPush(hass, entry) + assert pp.enabled is True + + entry.options[POCASI_CZ_ENABLED] = False + assert pp.enabled is False + + entry.options[POCASI_CZ_ENABLED] = True + assert pp.enabled is True + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("api_id", "api_key"), + [("", "key"), ("id", ""), ("", "")], + ids=["empty-id", "empty-key", "both-empty"], +) +async def test_empty_credentials_never_reach_the_network(monkeypatch, hass, api_id, api_key): + """An empty string is still a `str`, so it must be rejected explicitly. + + Otherwise a blank configuration sends a request that can only ever be refused. + """ + entry = _make_entry(api_id=api_id, api_key=api_key) + pp = PocasiPush(hass, entry) + pp.next_update = dt_util.utcnow() - timedelta(seconds=1) + + session = _FakeSession(response=_FakeResponse("OK")) + monkeypatch.setattr( + "custom_components.sws12500.pocasti_cz.async_get_clientsession", + lambda _h: session, + ) + + await pp.push_data_to_server({"x": 1}, "WU") + + assert session.calls == [] + assert pp.last_status == "config_error" diff --git a/tests/test_received_ecowitt.py b/tests/test_received_ecowitt.py index 15a2808..7d0b512 100644 --- a/tests/test_received_ecowitt.py +++ b/tests/test_received_ecowitt.py @@ -182,7 +182,7 @@ async def test_received_ecowitt_success_full_pipeline_with_health_autodiscovery_ - process_payload returns a mapped dict - check_disabled returns new keys -> autodiscovery (update_options, add_new_binary_sensors, add_new_sensors) - - async_set_updated_data + last_seen + update_stale_sensors_issue + - async_set_updated_data + last_seen - health.update_ingress_result(accepted) + windy + pocasi forwarding - health.update_forwarding - dev log via anonymize @@ -223,11 +223,6 @@ async def test_received_ecowitt_success_full_pipeline_with_health_autodiscovery_ "custom_components.sws12500.coordinator.add_new_binary_sensors", add_new_binary_sensors, ) - update_stale = MagicMock() - monkeypatch.setattr( - "custom_components.sws12500.coordinator.update_stale_sensors_issue", - update_stale, - ) coordinator.windy.push_data_to_windy = AsyncMock() coordinator.pocasi.push_data_to_server = AsyncMock() @@ -261,7 +256,6 @@ async def test_received_ecowitt_success_full_pipeline_with_health_autodiscovery_ # Coordinator data + staleness + last_seen. coordinator.async_set_updated_data.assert_called_once_with(mapped) - update_stale.assert_called_once() assert "outside_temp" in entry.runtime_data.last_seen # Forwarding: windy receives the raw data dict + False, pocasi receives "WU". @@ -314,11 +308,6 @@ async def test_received_ecowitt_success_no_health_no_autodiscovery_no_forwarding "custom_components.sws12500.coordinator.check_disabled", lambda _mapped, _config: [], ) - update_stale = MagicMock() - monkeypatch.setattr( - "custom_components.sws12500.coordinator.update_stale_sensors_issue", - update_stale, - ) coordinator.windy.push_data_to_windy = AsyncMock() coordinator.pocasi.push_data_to_server = AsyncMock() @@ -329,7 +318,6 @@ async def test_received_ecowitt_success_no_health_no_autodiscovery_no_forwarding assert resp.status == 200 coordinator.async_set_updated_data.assert_called_once_with(mapped) - update_stale.assert_called_once() coordinator.windy.push_data_to_windy.assert_not_awaited() coordinator.pocasi.push_data_to_server.assert_not_awaited() @@ -361,9 +349,6 @@ async def test_received_ecowitt_autodiscovery_extends_with_loaded_sensors(hass, monkeypatch.setattr( "custom_components.sws12500.coordinator.add_new_binary_sensors", MagicMock() ) - monkeypatch.setattr( - "custom_components.sws12500.coordinator.update_stale_sensors_issue", MagicMock() - ) coordinator.async_set_updated_data = MagicMock() request = _EcowittRequestStub(match_info={"webhook_id": "hook"}) @@ -392,9 +377,6 @@ async def test_health_coordinator_attribute_error_returns_none(hass, monkeypatch "custom_components.sws12500.coordinator.check_disabled", lambda _mapped, _config: [], ) - monkeypatch.setattr( - "custom_components.sws12500.coordinator.update_stale_sensors_issue", MagicMock() - ) coordinator.async_set_updated_data = MagicMock() request = _EcowittRequestStub(match_info={"webhook_id": "hook"}) @@ -410,11 +392,6 @@ async def test_received_ecowitt_empty_mapped_skips_update_block(hass, monkeypatc coordinator.ecowitt_bridge.process_payload = AsyncMock(return_value={}) - update_stale = MagicMock() - monkeypatch.setattr( - "custom_components.sws12500.coordinator.update_stale_sensors_issue", - update_stale, - ) coordinator.async_set_updated_data = MagicMock() request = _EcowittRequestStub(match_info={"webhook_id": "hook"}) @@ -422,7 +399,6 @@ async def test_received_ecowitt_empty_mapped_skips_update_block(hass, monkeypatc assert resp.status == 200 coordinator.async_set_updated_data.assert_not_called() - update_stale.assert_not_called() # --------------------------------------------------------------------------- diff --git a/tests/test_t9_air_quality.py b/tests/test_t9_air_quality.py index 4c1f380..06e834e 100644 --- a/tests/test_t9_air_quality.py +++ b/tests/test_t9_air_quality.py @@ -4,7 +4,7 @@ Covers what was added for the WSLink ``t9hcho`` / ``t9voclv`` / ``t9bat`` / ``t9cn`` parameters: - the new constants (``REMAP_WSLINK_ITEMS``, ``CONNECTION_GATED_SENSORS``, - ``BATTERY_NON_BINARY``, ``VOCLevel`` / ``VOC_LEVEL_MAP``) + ``VOCLevel`` / ``VOC_LEVEL_MAP``) - the ``utils.voc_level_to_text`` and ``utils.battery_5step_to_pct`` helpers - the connection gating in ``utils.remap_wslink_items`` - the new ``SENSOR_TYPES_WSLINK`` entity descriptions @@ -20,7 +20,6 @@ import pytest from custom_components.sws12500.const import ( BATTERY_LIST, - BATTERY_NON_BINARY, CONNECTION_GATED_SENSORS, HCHO, OUTSIDE_TEMP, @@ -76,7 +75,6 @@ def test_connection_gated_sensors_definition() -> None: def test_t9_battery_is_non_binary_only() -> None: - assert BATTERY_NON_BINARY == (T9_BATTERY,) # the 0-5 / percentage battery must not be treated as a binary low/normal one assert T9_BATTERY not in BATTERY_LIST diff --git a/tests/test_utils_more.py b/tests/test_utils_more.py index 353dbc2..cad8d75 100644 --- a/tests/test_utils_more.py +++ b/tests/test_utils_more.py @@ -19,8 +19,8 @@ from custom_components.sws12500.const import ( ) from custom_components.sws12500.utils import ( anonymize, + battery_5step_to_pct, battery_level, - battery_level_to_icon, celsius_to_fahrenheit, check_disabled, chill_index, @@ -32,6 +32,7 @@ from custom_components.sws12500.utils import ( translated_notification, translations, update_options, + voc_level_to_text, wind_dir_to_text, ) @@ -253,11 +254,6 @@ def test_battery_level_handles_none_empty_invalid_and_known_values(): assert battery_level("2") == UnitOfBat.UNKNOWN -def test_battery_level_to_icon_maps_all_and_unknown(): - assert battery_level_to_icon(UnitOfBat.LOW) == "mdi:battery-low" - assert battery_level_to_icon(UnitOfBat.NORMAL) == "mdi:battery" - assert battery_level_to_icon(UnitOfBat.UNKNOWN) == "mdi:battery-unknown" - def test_temperature_conversions_round_trip(): # Use a value that is exactly representable in binary-ish floats @@ -362,3 +358,36 @@ def test_chill_index_returns_temp_when_not_cold_or_not_windy(): def test_chill_index_convert_from_celsius_path(): out = chill_index({OUTSIDE_TEMP: "5", WIND_SPEED: "10"}, convert=True) assert out is not None + + +# --------------------------------------------------------------------------- +# Converters must degrade to None, not raise +# +# `WeatherSensor.native_value` catches value_fn exceptions and logs them with +# `_LOGGER.exception`, so a bare int() on a garbage payload value produced a full +# traceback on *every* push rather than a quiet `unknown` state. +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("bad", ["", None, "n/a", "--", "1.5.2", [], {}]) +def test_voc_level_to_text_rejects_garbage(bad): + assert voc_level_to_text(bad) is None + + +@pytest.mark.parametrize("bad", ["", None, "n/a", "--", object()]) +def test_battery_5step_to_pct_rejects_garbage(bad): + assert battery_5step_to_pct(bad) is None + + +@pytest.mark.parametrize( + ("value", "expected"), + [(0, 0), ("0", 0), (1, 20), ("3", 60), (5, 100), ("5.0", 100)], +) +def test_battery_5step_to_pct_maps_the_scale(value, expected): + assert battery_5step_to_pct(value) == expected + + +@pytest.mark.parametrize(("value", "expected"), [(-3, 0), (9, 100)]) +def test_battery_5step_to_pct_clamps_out_of_range(value, expected): + """Out-of-range steps stay a valid battery percentage.""" + assert battery_5step_to_pct(value) == expected diff --git a/tests/test_windy_more.py b/tests/test_windy_more.py index d57716c..d643d4c 100644 --- a/tests/test_windy_more.py +++ b/tests/test_windy_more.py @@ -107,7 +107,7 @@ async def test_push_duplicate_third_strike_disables(monkeypatch, hass): "custom_components.sws12500.windy_func.update_options", update_options ) monkeypatch.setattr( - "custom_components.sws12500.windy_func.persistent_notification.create", + "custom_components.sws12500.windy_func.persistent_notification.async_create", MagicMock(), ) monkeypatch.setattr( diff --git a/tests/test_windy_push.py b/tests/test_windy_push.py index 2f13a51..890118c 100644 --- a/tests/test_windy_push.py +++ b/tests/test_windy_push.py @@ -225,7 +225,7 @@ async def test_push_data_to_windy_missing_station_id_returns_false(monkeypatch, "custom_components.sws12500.windy_func.update_options", update_options ) monkeypatch.setattr( - "custom_components.sws12500.windy_func.persistent_notification.create", + "custom_components.sws12500.windy_func.persistent_notification.async_create", MagicMock(), ) @@ -252,7 +252,7 @@ async def test_push_data_to_windy_missing_station_pw_returns_false(monkeypatch, "custom_components.sws12500.windy_func.update_options", update_options ) monkeypatch.setattr( - "custom_components.sws12500.windy_func.persistent_notification.create", + "custom_components.sws12500.windy_func.persistent_notification.async_create", MagicMock(), ) @@ -281,7 +281,7 @@ async def test_push_data_to_windy_invalid_api_key_disables_windy(monkeypatch, ha "custom_components.sws12500.windy_func.update_options", update_options ) monkeypatch.setattr( - "custom_components.sws12500.windy_func.persistent_notification.create", + "custom_components.sws12500.windy_func.persistent_notification.async_create", MagicMock(), ) @@ -314,7 +314,7 @@ async def test_push_data_to_windy_invalid_api_key_update_options_failure_logs_de dbg = MagicMock() monkeypatch.setattr("custom_components.sws12500.windy_func._LOGGER.debug", dbg) monkeypatch.setattr( - "custom_components.sws12500.windy_func.persistent_notification.create", + "custom_components.sws12500.windy_func.persistent_notification.async_create", MagicMock(), ) @@ -416,7 +416,7 @@ async def test_push_data_to_windy_client_error_increments_and_disables_after_thr crit = MagicMock() monkeypatch.setattr("custom_components.sws12500.windy_func._LOGGER.critical", crit) monkeypatch.setattr( - "custom_components.sws12500.windy_func.persistent_notification.create", + "custom_components.sws12500.windy_func.persistent_notification.async_create", MagicMock(), ) @@ -462,7 +462,7 @@ async def test_push_data_to_windy_client_error_disable_failure_logs_debug( dbg = MagicMock() monkeypatch.setattr("custom_components.sws12500.windy_func._LOGGER.debug", dbg) monkeypatch.setattr( - "custom_components.sws12500.windy_func.persistent_notification.create", + "custom_components.sws12500.windy_func.persistent_notification.async_create", MagicMock(), )