no-mistakes(review): Migrate Pocasi option key, fix typos and Windy status

ecowitt_support
SchiZzA 2026-07-25 22:12:55 +02:00
parent 5e4b02fd6e
commit 1b69737408
No known key found for this signature in database
11 changed files with 169 additions and 20 deletions

View File

@ -46,6 +46,7 @@ from .const import (
ECOWITT_URL_PREFIX, ECOWITT_URL_PREFIX,
HEALTH_URL, HEALTH_URL,
POCASI_CZ_ENABLED, POCASI_CZ_ENABLED,
POCASI_CZ_ENABLED_LEGACY,
SENSORS_TO_LOAD, SENSORS_TO_LOAD,
WINDY_ENABLED, WINDY_ENABLED,
WSLINK, WSLINK,
@ -61,6 +62,34 @@ from .staleness import update_stale_sensors_issue
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.BINARY_SENSOR] 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( def register_path(
hass: HomeAssistant, hass: HomeAssistant,

View File

@ -337,7 +337,7 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
vol.Optional(DEV_DBG): bool, vol.Optional(DEV_DBG): bool,
} }
VERSION = 1 VERSION = 2
async def async_step_user(self, user_input: Any = None): async def async_step_user(self, user_input: Any = None):
"""Handle the initial step.""" """Handle the initial step."""

View File

@ -224,10 +224,14 @@ POCASI_CZ_API_KEY = "POCASI_CZ_API_KEY"
POCASI_CZ_API_ID = "POCASI_CZ_API_ID" POCASI_CZ_API_ID = "POCASI_CZ_API_ID"
POCASI_CZ_SEND_INTERVAL = "POCASI_SEND_INTERVAL" POCASI_CZ_SEND_INTERVAL = "POCASI_SEND_INTERVAL"
POCASI_CZ_ENABLED = "pocasi_enabled_checkbox" 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_CZ_LOGGER_ENABLED = "pocasi_logger_checkbox"
POCASI_INVALID_KEY: Final = "Pocasi Meteo refused to accept data. Invalid ID/Key combination?" 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_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_ID = "WINDY_STATION_ID"
WINDY_STATION_PW = "WINDY_STATION_PWD" 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 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_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!"
)

View File

@ -172,7 +172,7 @@ class PocasiPush:
self.last_error = f"Unexpected HTTP status {http_status} from Pocasi Meteo." self.last_error = f"Unexpected HTTP status {http_status} from Pocasi Meteo."
self.invalid_response_count += 1 self.invalid_response_count += 1
_LOGGER.warning( _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, http_status,
POCASI_CZ_MAX_RETRIES - self.invalid_response_count, POCASI_CZ_MAX_RETRIES - self.invalid_response_count,
) )

View File

@ -110,14 +110,14 @@
"POCASI_CZ_API_ID": "ID from your Pocasi Meteo APP", "POCASI_CZ_API_ID": "ID from your Pocasi Meteo APP",
"POCASI_CZ_API_KEY": "Key from your Pocasi Meteo APP", "POCASI_CZ_API_KEY": "Key from your Pocasi Meteo APP",
"POCASI_SEND_INTERVAL": "Resend interval in seconds", "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" "pocasi_logger_checkbox": "Log data and responses"
}, },
"data_description": { "data_description": {
"POCASI_CZ_API_ID": "You can obtain your ID in Pocasi Meteo App", "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_CZ_API_KEY": "You can obtain your KEY in Pocasi Meteo App",
"POCASI_SEND_INTERVAL": "Resend interval in seconds (minimum 12s, default 30s)", "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" "pocasi_logger_checkbox": "Enable only if you want to send debbug data to the developer"
} }
}, },

View File

@ -110,14 +110,14 @@
"POCASI_CZ_API_ID": "ID účtu na Počasí Meteo", "POCASI_CZ_API_ID": "ID účtu na Počasí Meteo",
"POCASI_CZ_API_KEY": "Klíč (Key) k účtu Počasí Meteo", "POCASI_CZ_API_KEY": "Klíč (Key) k účtu Počasí Meteo",
"POCASI_SEND_INTERVAL": "Interval v sekundách", "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" "pocasi_logger_checkbox": "Logovat data a odpovědi z Počasí Meteo"
}, },
"data_description": { "data_description": {
"POCASI_CZ_API_ID": "ID získáte ve své aplikaci Počasí Meteo", "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_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_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." "pocasi_logger_checkbox": "Zapnout pouze v případě, že chcete zaslat ladící informace vývojáři."
} }
}, },

View File

@ -110,14 +110,14 @@
"POCASI_CZ_API_ID": "ID from your Pocasi Meteo APP", "POCASI_CZ_API_ID": "ID from your Pocasi Meteo APP",
"POCASI_CZ_API_KEY": "Key from your Pocasi Meteo APP", "POCASI_CZ_API_KEY": "Key from your Pocasi Meteo APP",
"POCASI_SEND_INTERVAL": "Resend interval in seconds", "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" "pocasi_logger_checkbox": "Log data and responses"
}, },
"data_description": { "data_description": {
"POCASI_CZ_API_ID": "You can obtain your ID in Pocasi Meteo App", "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_CZ_API_KEY": "You can obtain your KEY in Pocasi Meteo App",
"POCASI_SEND_INTERVAL": "Resend interval in seconds (minimum 12s, default 30s)", "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" "pocasi_logger_checkbox": "Enable only if you want to send debbug data to the developer"
} }
}, },

View File

@ -163,8 +163,10 @@ class WindyPush:
`enabled` reads the option back, so persisting it here is what actually turns `enabled` reads the option back, so persisting it here is what actually turns
forwarding off. 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 self.last_error = reason
if not await update_options(self.hass, self.config, WINDY_ENABLED, False): if not await update_options(self.hass, self.config, WINDY_ENABLED, False):
@ -251,7 +253,7 @@ class WindyPush:
# log despite of settings # log despite of settings
_LOGGER.error( _LOGGER.error(
"%s Max rentries before disable resend function: %s", "%s Max retries before disable resend function: %s",
WINDY_NOT_INSERTED, WINDY_NOT_INSERTED,
(WINDY_MAX_RETRIES - self.invalid_response_count), (WINDY_MAX_RETRIES - self.invalid_response_count),
) )
@ -268,7 +270,7 @@ class WindyPush:
self.last_status = "duplicate" self.last_status = "duplicate"
self.last_error = "Duplicate payload detected by Windy server." self.last_error = "Duplicate payload detected by Windy server."
_LOGGER.critical( _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), (WINDY_MAX_RETRIES - self.invalid_response_count),
) )
self.invalid_response_count += 1 self.invalid_response_count += 1
@ -296,7 +298,7 @@ class WindyPush:
self.invalid_response_count += 1 self.invalid_response_count += 1
if self.log: if self.log:
_LOGGER.debug( _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), (WINDY_MAX_RETRIES - self.invalid_response_count),
) )
finally: finally:
@ -306,7 +308,7 @@ class WindyPush:
WINDY_MAX_RETRIES, WINDY_MAX_RETRIES,
) )
await self._disable_windy( 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: except ClientError as ex:
@ -315,14 +317,16 @@ class WindyPush:
# attributes; str(ex) could embed the request URL. # attributes; str(ex) could embed the request URL.
self.last_error = type(ex).__name__ self.last_error = type(ex).__name__
_LOGGER.critical( _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), str(ex),
(WINDY_MAX_RETRIES - self.invalid_response_count), (WINDY_MAX_RETRIES - self.invalid_response_count),
) )
self.invalid_response_count += 1 self.invalid_response_count += 1
if self.invalid_response_count >= WINDY_MAX_RETRIES: if self.invalid_response_count >= WINDY_MAX_RETRIES:
_LOGGER.critical(WINDY_UNEXPECTED) _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.last_update = dt_util.utcnow()
self.next_update = self.last_update + timed(minutes=5) self.next_update = self.last_update + timed(minutes=5)

View File

@ -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(): def test_const_values():
@ -6,3 +15,9 @@ def test_const_values():
assert DEFAULT_URL == "/weatherstation/updateweatherstation.php" assert DEFAULT_URL == "/weatherstation/updateweatherstation.php"
assert WSLINK_URL == "/data/upload.php" assert WSLINK_URL == "/data/upload.php"
assert WINDY_URL == "https://stations.windy.com/api/v2/observation/update" 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

View File

@ -20,8 +20,19 @@ from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
from pytest_homeassistant_custom_component.common import MockConfigEntry from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.sws12500 import WeatherDataUpdateCoordinator, async_setup_entry from custom_components.sws12500 import (
from custom_components.sws12500.const import DOMAIN 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 custom_components.sws12500.data import SWSRuntimeData
from homeassistant.util import dt as dt_util 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()) captured["cb"](dt_util.utcnow())
stale.assert_called_once_with(hass, config_entry) 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

View File

@ -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"}) ok = await wp.push_data_to_windy({"a": "b"})
assert ok is False assert ok is False
assert session.calls == [] assert session.calls == []
# Disabling must not overwrite the specific reason the diagnostics sensor reports.
assert wp.last_status == "config_error"
@pytest.mark.asyncio @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"}) ok = await wp.push_data_to_windy({"a": "b"})
assert ok is False assert ok is False
assert session.calls == [] assert session.calls == []
assert wp.last_status == "config_error"
@pytest.mark.asyncio @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"}) ok = await wp.push_data_to_windy({"a": "b"})
assert ok is True assert ok is True
update_options.assert_awaited_once_with(hass, entry, WINDY_ENABLED, False) update_options.assert_awaited_once_with(hass, entry, WINDY_ENABLED, False)
assert wp.last_status == "auth_error"
@pytest.mark.asyncio @pytest.mark.asyncio