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,
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,

View File

@ -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."""

View File

@ -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!"
)

View File

@ -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,
)

View File

@ -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"
}
},

View File

@ -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."
}
},

View File

@ -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"
}
},

View File

@ -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)

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():
@ -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

View File

@ -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

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"})
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