diff --git a/custom_components/sws12500/__init__.py b/custom_components/sws12500/__init__.py index 931e411..f79746f 100644 --- a/custom_components/sws12500/__init__.py +++ b/custom_components/sws12500/__init__.py @@ -46,6 +46,7 @@ from .const import ( ECOWITT_URL_PREFIX, HEALTH_URL, POCASI_CZ_ENABLED, + POCASI_CZ_ENABLED_LEGACY, SENSORS_TO_LOAD, WINDY_ENABLED, WSLINK, @@ -61,6 +62,34 @@ from .staleness import update_stale_sensors_issue _LOGGER = logging.getLogger(__name__) PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.BINARY_SENSOR] +# Keep in sync with `ConfigFlowHandler.VERSION`. +CONFIG_ENTRY_VERSION: int = 2 + + +async def async_migrate_entry(hass: HomeAssistant, entry: SWSConfigEntry) -> bool: + """Migrate an old config entry. + + Version 2 renames the Pocasi Meteo enable flag from the misspelled + `pocasi_enabled_chcekbox` to `pocasi_enabled_checkbox`. Without moving the persisted + value, forwarding would silently switch off for every existing install. + """ + + if entry.version > CONFIG_ENTRY_VERSION: + # Downgrades are not supported - the entry was written by a newer version. + return False + + if entry.version < 2: + options = dict(entry.options) + if POCASI_CZ_ENABLED_LEGACY in options: + legacy_value = options.pop(POCASI_CZ_ENABLED_LEGACY) + # Never clobber an already-correct key (both may exist after a partial upgrade). + options.setdefault(POCASI_CZ_ENABLED, legacy_value) + _LOGGER.debug("Migrated Pocasi Meteo enable flag to %s.", POCASI_CZ_ENABLED) + + hass.config_entries.async_update_entry(entry, options=options, version=2) + + return True + def register_path( hass: HomeAssistant, diff --git a/custom_components/sws12500/config_flow.py b/custom_components/sws12500/config_flow.py index c27694b..3485db6 100644 --- a/custom_components/sws12500/config_flow.py +++ b/custom_components/sws12500/config_flow.py @@ -337,7 +337,7 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN): vol.Optional(DEV_DBG): bool, } - VERSION = 1 + VERSION = 2 async def async_step_user(self, user_input: Any = None): """Handle the initial step.""" diff --git a/custom_components/sws12500/const.py b/custom_components/sws12500/const.py index 6e9aec9..ec40092 100644 --- a/custom_components/sws12500/const.py +++ b/custom_components/sws12500/const.py @@ -224,10 +224,14 @@ 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_checkbox" +# Misspelled key used by config entries created before version 2 (see `async_migrate_entry`). +POCASI_CZ_ENABLED_LEGACY: Final = "pocasi_enabled_chcekbox" 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 = "Pocasi Meteo responded unexpectedly 3 times in row. Resending is now disabled!" +POCASI_CZ_UNEXPECTED: Final = ( + f"Pocasi Meteo responded unexpectedly {POCASI_CZ_MAX_RETRIES} times in row. Resending is now disabled!" +) WINDY_STATION_ID = "WINDY_STATION_ID" WINDY_STATION_PW = "WINDY_STATION_PWD" @@ -238,7 +242,9 @@ WINDY_INVALID_KEY: Final = ( "Windy API KEY is invalid. Send data to Windy is now disabled. Check your API KEY and try again." ) WINDY_SUCCESS: Final = "Windy successfully sent data and data was successfully inserted by Windy API" -WINDY_UNEXPECTED: Final = "Windy responded unexpectedly 3 times in a row. Send to Windy is now disabled!" +WINDY_UNEXPECTED: Final = ( + f"Windy responded unexpectedly {WINDY_MAX_RETRIES} times in a row. Send to Windy is now disabled!" +) diff --git a/custom_components/sws12500/pocasti_cz.py b/custom_components/sws12500/pocasti_cz.py index ae7710a..ef9c54b 100644 --- a/custom_components/sws12500/pocasti_cz.py +++ b/custom_components/sws12500/pocasti_cz.py @@ -172,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. Rentries before disabling resend: %s", + "Unexpected HTTP status %s from Pocasi Meteo. Retries before disabling resend: %s", http_status, POCASI_CZ_MAX_RETRIES - self.invalid_response_count, ) diff --git a/custom_components/sws12500/strings.json b/custom_components/sws12500/strings.json index 23218fe..6f83c9d 100644 --- a/custom_components/sws12500/strings.json +++ b/custom_components/sws12500/strings.json @@ -110,14 +110,14 @@ "POCASI_CZ_API_ID": "ID from your Pocasi Meteo APP", "POCASI_CZ_API_KEY": "Key from your Pocasi Meteo APP", "POCASI_SEND_INTERVAL": "Resend interval in seconds", - "pocasi_enabled_chcekbox": "Enable resending data to Pocasi Meteo", + "pocasi_enabled_checkbox": "Enable resending data to Pocasi Meteo", "pocasi_logger_checkbox": "Log data and responses" }, "data_description": { "POCASI_CZ_API_ID": "You can obtain your ID in Pocasi Meteo App", "POCASI_CZ_API_KEY": "You can obtain your KEY in Pocasi Meteo App", "POCASI_SEND_INTERVAL": "Resend interval in seconds (minimum 12s, default 30s)", - "pocasi_enabled_chcekbox": "Enables resending data to Pocasi Meteo", + "pocasi_enabled_checkbox": "Enables resending data to Pocasi Meteo", "pocasi_logger_checkbox": "Enable only if you want to send debbug data to the developer" } }, diff --git a/custom_components/sws12500/translations/cs.json b/custom_components/sws12500/translations/cs.json index 7bcd8ec..70b4076 100644 --- a/custom_components/sws12500/translations/cs.json +++ b/custom_components/sws12500/translations/cs.json @@ -110,14 +110,14 @@ "POCASI_CZ_API_ID": "ID účtu na Počasí Meteo", "POCASI_CZ_API_KEY": "Klíč (Key) k účtu Počasí Meteo", "POCASI_SEND_INTERVAL": "Interval v sekundách", - "pocasi_enabled_chcekbox": "Povolit přeposílání dat na server Počasí Meteo", + "pocasi_enabled_checkbox": "Povolit přeposílání dat na server Počasí Meteo", "pocasi_logger_checkbox": "Logovat data a odpovědi z Počasí Meteo" }, "data_description": { "POCASI_CZ_API_ID": "ID získáte ve své aplikaci Počasí Meteo", "POCASI_CZ_API_KEY": "Klíč (Key) získáte ve své aplikaci Počasí Meteo", "POCASI_SEND_INTERVAL": "Interval v jakém se mají data na server přeposílat (minimum 12s, defaultně 30s)", - "pocasi_enabled_chcekbox": "Zapne přeposílání data na server Počasí Meteo", + "pocasi_enabled_checkbox": "Zapne přeposílání data na server Počasí Meteo", "pocasi_logger_checkbox": "Zapnout pouze v případě, že chcete zaslat ladící informace vývojáři." } }, diff --git a/custom_components/sws12500/translations/en.json b/custom_components/sws12500/translations/en.json index 23218fe..6f83c9d 100644 --- a/custom_components/sws12500/translations/en.json +++ b/custom_components/sws12500/translations/en.json @@ -110,14 +110,14 @@ "POCASI_CZ_API_ID": "ID from your Pocasi Meteo APP", "POCASI_CZ_API_KEY": "Key from your Pocasi Meteo APP", "POCASI_SEND_INTERVAL": "Resend interval in seconds", - "pocasi_enabled_chcekbox": "Enable resending data to Pocasi Meteo", + "pocasi_enabled_checkbox": "Enable resending data to Pocasi Meteo", "pocasi_logger_checkbox": "Log data and responses" }, "data_description": { "POCASI_CZ_API_ID": "You can obtain your ID in Pocasi Meteo App", "POCASI_CZ_API_KEY": "You can obtain your KEY in Pocasi Meteo App", "POCASI_SEND_INTERVAL": "Resend interval in seconds (minimum 12s, default 30s)", - "pocasi_enabled_chcekbox": "Enables resending data to Pocasi Meteo", + "pocasi_enabled_checkbox": "Enables resending data to Pocasi Meteo", "pocasi_logger_checkbox": "Enable only if you want to send debbug data to the developer" } }, diff --git a/custom_components/sws12500/windy_func.py b/custom_components/sws12500/windy_func.py index 576f5c8..b983389 100644 --- a/custom_components/sws12500/windy_func.py +++ b/custom_components/sws12500/windy_func.py @@ -163,8 +163,10 @@ class WindyPush: `enabled` reads the option back, so persisting it here is what actually turns forwarding off. + + `last_status` is deliberately left to the caller so the diagnostics sensor keeps + the specific reason (`config_error`, `auth_error`, ...) instead of a generic one. """ - self.last_status = "disabled" self.last_error = reason if not await update_options(self.hass, self.config, WINDY_ENABLED, False): @@ -251,7 +253,7 @@ class WindyPush: # log despite of settings _LOGGER.error( - "%s Max rentries before disable resend function: %s", + "%s Max retries before disable resend function: %s", WINDY_NOT_INSERTED, (WINDY_MAX_RETRIES - self.invalid_response_count), ) @@ -268,7 +270,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 rentries before disabling resend function: %s", + "Duplicate payload detected by Windy server. Will try again later. Max retries before disabling resend function: %s", (WINDY_MAX_RETRIES - self.invalid_response_count), ) self.invalid_response_count += 1 @@ -296,7 +298,7 @@ class WindyPush: self.invalid_response_count += 1 if self.log: _LOGGER.debug( - "Unexpected response from Windy. Max rentries before disabling resend function: %s", + "Unexpected response from Windy. Max retries before disabling resend function: %s", (WINDY_MAX_RETRIES - self.invalid_response_count), ) finally: @@ -306,7 +308,7 @@ class WindyPush: 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." + reason=f"Unable to send data to Windy ({WINDY_MAX_RETRIES} times). Disabling resend option for now. Please check your Windy configuration and enable this feature afterwards." ) except ClientError as ex: @@ -315,14 +317,16 @@ 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 rentries before disabling resend function: %s", + "Invalid response from Windy: %s. Will try again later, max retries before disabling resend function: %s", str(ex), (WINDY_MAX_RETRIES - self.invalid_response_count), ) self.invalid_response_count += 1 if self.invalid_response_count >= WINDY_MAX_RETRIES: _LOGGER.critical(WINDY_UNEXPECTED) - await self._disable_windy(reason="Invalid response from Windy 3 times. Disabling resending option.") + await self._disable_windy( + reason=f"Invalid response from Windy {WINDY_MAX_RETRIES} times. Disabling resending option." + ) self.last_update = dt_util.utcnow() self.next_update = self.last_update + timed(minutes=5) diff --git a/tests/test_const.py b/tests/test_const.py index 7ad9732..0503b2f 100644 --- a/tests/test_const.py +++ b/tests/test_const.py @@ -1,4 +1,13 @@ -from custom_components.sws12500.const import DEFAULT_URL, DOMAIN, WINDY_URL, WSLINK_URL +from custom_components.sws12500.const import ( + DEFAULT_URL, + DOMAIN, + POCASI_CZ_MAX_RETRIES, + POCASI_CZ_UNEXPECTED, + WINDY_MAX_RETRIES, + WINDY_UNEXPECTED, + WINDY_URL, + WSLINK_URL, +) def test_const_values(): @@ -6,3 +15,9 @@ def test_const_values(): assert DEFAULT_URL == "/weatherstation/updateweatherstation.php" assert WSLINK_URL == "/data/upload.php" assert WINDY_URL == "https://stations.windy.com/api/v2/observation/update" + + +def test_retry_counts_in_user_facing_messages(): + """The retry count in the notification texts must follow the constants.""" + assert f"{WINDY_MAX_RETRIES} times" in WINDY_UNEXPECTED + assert f"{POCASI_CZ_MAX_RETRIES} times" in POCASI_CZ_UNEXPECTED diff --git a/tests/test_init.py b/tests/test_init.py index 5e8fc01..56b9861 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -20,8 +20,19 @@ from unittest.mock import AsyncMock, MagicMock import pytest from pytest_homeassistant_custom_component.common import MockConfigEntry -from custom_components.sws12500 import WeatherDataUpdateCoordinator, async_setup_entry -from custom_components.sws12500.const import DOMAIN +from custom_components.sws12500 import ( + CONFIG_ENTRY_VERSION, + WeatherDataUpdateCoordinator, + async_migrate_entry, + async_setup_entry, +) +from custom_components.sws12500.config_flow import ConfigFlowHandler +from custom_components.sws12500.const import ( + DOMAIN, + POCASI_CZ_ENABLED, + POCASI_CZ_ENABLED_LEGACY, + WINDY_ENABLED, +) from custom_components.sws12500.data import SWSRuntimeData from homeassistant.util import dt as dt_util @@ -149,3 +160,83 @@ async def test_check_stale_callback_runs_update( captured["cb"](dt_util.utcnow()) stale.assert_called_once_with(hass, config_entry) + + +def test_config_flow_version_matches_migration_target() -> None: + """The flow version and the migration target must not drift apart.""" + assert ConfigFlowHandler.VERSION == CONFIG_ENTRY_VERSION + + +async def test_migrate_moves_legacy_pocasi_key(hass) -> None: + """A v1 entry carrying the misspelled key migrates to v2 keeping the value.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + options={POCASI_CZ_ENABLED_LEGACY: True, WINDY_ENABLED: True}, + version=1, + ) + entry.add_to_hass(hass) + + assert await async_migrate_entry(hass, entry) is True + + assert entry.version == 2 + assert entry.options[POCASI_CZ_ENABLED] is True + assert POCASI_CZ_ENABLED_LEGACY not in entry.options + # Unrelated options survive untouched. + assert entry.options[WINDY_ENABLED] is True + + +async def test_migrate_without_pocasi_option(hass) -> None: + """An entry that never had the Pocasi option migrates cleanly.""" + entry = MockConfigEntry(domain=DOMAIN, data={}, options={WINDY_ENABLED: False}, version=1) + entry.add_to_hass(hass) + + assert await async_migrate_entry(hass, entry) is True + + assert entry.version == 2 + assert POCASI_CZ_ENABLED not in entry.options + assert entry.options[WINDY_ENABLED] is False + + +async def test_migrate_does_not_clobber_correct_key(hass) -> None: + """When both keys are present, the already-correct one wins.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + options={POCASI_CZ_ENABLED_LEGACY: False, POCASI_CZ_ENABLED: True}, + version=1, + ) + entry.add_to_hass(hass) + + assert await async_migrate_entry(hass, entry) is True + + assert entry.options[POCASI_CZ_ENABLED] is True + assert POCASI_CZ_ENABLED_LEGACY not in entry.options + + +async def test_migrate_is_idempotent(hass) -> None: + """Running the migration twice leaves the entry unchanged.""" + entry = MockConfigEntry( + domain=DOMAIN, data={}, options={POCASI_CZ_ENABLED_LEGACY: True}, version=1 + ) + entry.add_to_hass(hass) + + assert await async_migrate_entry(hass, entry) is True + first = dict(entry.options) + + assert await async_migrate_entry(hass, entry) is True + + assert entry.version == 2 + assert dict(entry.options) == first + assert entry.options[POCASI_CZ_ENABLED] is True + + +async def test_migrate_refuses_future_version(hass) -> None: + """An entry written by a newer version is not downgraded.""" + entry = MockConfigEntry( + domain=DOMAIN, data={}, options={}, version=CONFIG_ENTRY_VERSION + 1 + ) + entry.add_to_hass(hass) + + assert await async_migrate_entry(hass, entry) is False + assert entry.version == CONFIG_ENTRY_VERSION + 1 diff --git a/tests/test_windy_push.py b/tests/test_windy_push.py index 890118c..6a6d7db 100644 --- a/tests/test_windy_push.py +++ b/tests/test_windy_push.py @@ -232,6 +232,8 @@ async def test_push_data_to_windy_missing_station_id_returns_false(monkeypatch, ok = await wp.push_data_to_windy({"a": "b"}) assert ok is False assert session.calls == [] + # Disabling must not overwrite the specific reason the diagnostics sensor reports. + assert wp.last_status == "config_error" @pytest.mark.asyncio @@ -259,6 +261,7 @@ async def test_push_data_to_windy_missing_station_pw_returns_false(monkeypatch, ok = await wp.push_data_to_windy({"a": "b"}) assert ok is False assert session.calls == [] + assert wp.last_status == "config_error" @pytest.mark.asyncio @@ -288,6 +291,7 @@ async def test_push_data_to_windy_invalid_api_key_disables_windy(monkeypatch, ha ok = await wp.push_data_to_windy({"a": "b"}) assert ok is True update_options.assert_awaited_once_with(hass, entry, WINDY_ENABLED, False) + assert wp.last_status == "auth_error" @pytest.mark.asyncio