Pārlūkot izejas kodu

fix: reject non-IANA time zone ids before they hit the portal API (#23)

Terrorr's fresh v2.5.0 install (#21) hard-failed adding a station with
"Timeseries error code=20101 message=Illegal argument" after entering
"Europe" as the time zone. CONF_TIME_ZONE has always been a free-text
cv.string sent verbatim as the IOT-Time-Zone header on every call - a
continent name reads as plausible but isn't a real zoneinfo key, and the
portal's timeseries endpoint rejects it outright on the very first call,
with nothing in the error pointing at time zone as the cause.

Not a v2.5.0 regression: config_flow.py's handling of this field is
unchanged since before that release (only whitespace-trimming was added,
in #17). It just took a fresh install for someone to hit it.

Add is_valid_time_zone() (checked against zoneinfo.available_timezones())
and validate both the password and token setup steps before ever calling
the API, surfacing a same-form "invalid_time_zone" error instead of a
cryptic upstream one. Clarify the field hint to ask for a full IANA id.

Reauth doesn't collect time_zone (it's carried over from the existing
entry), so only the two initial setup steps needed the check.
Bob Ehlers 1 mēnesi atpakaļ
vecāks
revīzija
f16552e10f

+ 42 - 34
custom_components/solar_of_things/config_flow.py

@@ -36,7 +36,7 @@ from .const import (
     CONF_ACCESS_TOKEN_EXPIRES,
     CONF_REFRESH_TOKEN_EXPIRES,
 )
-from .util import normalise_config_fields
+from .util import normalise_config_fields, is_valid_time_zone
 
 _LOGGER = logging.getLogger(__name__)
 
@@ -139,30 +139,34 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
             # Trim before validating *and* before storing, so the value that is
             # checked is the same one that gets persisted.
             user_input = normalise_config_fields(user_input)
-            try:
-                info = await _validate_password_auth(self.hass, user_input)
-            except InvalidAuth:
-                errors["base"] = "invalid_auth"
-            except CannotConnect:
-                errors["base"] = "cannot_connect"
-            except Exception:
-                _LOGGER.exception("Unexpected exception in password step")
-                errors["base"] = "unknown"
+            time_zone = user_input.get(CONF_TIME_ZONE) or "Asia/Manila"
+            if not is_valid_time_zone(time_zone):
+                errors["base"] = "invalid_time_zone"
             else:
-                await self.async_set_unique_id(f"station_{user_input[CONF_STATION_ID]}")
-                self._abort_if_unique_id_configured()
-                entry_data = {
-                    CONF_USER_ID: user_input[CONF_USER_ID],
-                    CONF_PASSWORD: user_input[CONF_PASSWORD],
-                    CONF_STATION_ID: user_input[CONF_STATION_ID],
-                    CONF_DEVICE_ID: user_input.get(CONF_DEVICE_ID, ""),
-                    CONF_TIME_ZONE: user_input.get(CONF_TIME_ZONE, "Asia/Manila"),
-                    CONF_IOT_TOKEN: info[CONF_IOT_TOKEN],
-                    CONF_REFRESH_TOKEN: info[CONF_REFRESH_TOKEN],
-                    CONF_ACCESS_TOKEN_EXPIRES: info[CONF_ACCESS_TOKEN_EXPIRES],
-                    CONF_REFRESH_TOKEN_EXPIRES: info[CONF_REFRESH_TOKEN_EXPIRES],
-                }
-                return self.async_create_entry(title=info["title"], data=entry_data)
+                try:
+                    info = await _validate_password_auth(self.hass, user_input)
+                except InvalidAuth:
+                    errors["base"] = "invalid_auth"
+                except CannotConnect:
+                    errors["base"] = "cannot_connect"
+                except Exception:
+                    _LOGGER.exception("Unexpected exception in password step")
+                    errors["base"] = "unknown"
+                else:
+                    await self.async_set_unique_id(f"station_{user_input[CONF_STATION_ID]}")
+                    self._abort_if_unique_id_configured()
+                    entry_data = {
+                        CONF_USER_ID: user_input[CONF_USER_ID],
+                        CONF_PASSWORD: user_input[CONF_PASSWORD],
+                        CONF_STATION_ID: user_input[CONF_STATION_ID],
+                        CONF_DEVICE_ID: user_input.get(CONF_DEVICE_ID, ""),
+                        CONF_TIME_ZONE: time_zone,
+                        CONF_IOT_TOKEN: info[CONF_IOT_TOKEN],
+                        CONF_REFRESH_TOKEN: info[CONF_REFRESH_TOKEN],
+                        CONF_ACCESS_TOKEN_EXPIRES: info[CONF_ACCESS_TOKEN_EXPIRES],
+                        CONF_REFRESH_TOKEN_EXPIRES: info[CONF_REFRESH_TOKEN_EXPIRES],
+                    }
+                    return self.async_create_entry(title=info["title"], data=entry_data)
 
         return self.async_show_form(
             step_id="password",
@@ -190,17 +194,21 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
         if user_input is not None:
             # The entry is created from this same dict, so trim it up front.
             user_input = normalise_config_fields(user_input)
-            try:
-                info = await _validate_token_auth(self.hass, user_input)
-            except CannotConnect:
-                errors["base"] = "cannot_connect"
-            except Exception:
-                _LOGGER.exception("Unexpected exception in token step")
-                errors["base"] = "unknown"
+            time_zone = user_input.get(CONF_TIME_ZONE) or "Asia/Manila"
+            if not is_valid_time_zone(time_zone):
+                errors["base"] = "invalid_time_zone"
             else:
-                await self.async_set_unique_id(f"station_{user_input[CONF_STATION_ID]}")
-                self._abort_if_unique_id_configured()
-                return self.async_create_entry(title=info["title"], data=user_input)
+                try:
+                    info = await _validate_token_auth(self.hass, user_input)
+                except CannotConnect:
+                    errors["base"] = "cannot_connect"
+                except Exception:
+                    _LOGGER.exception("Unexpected exception in token step")
+                    errors["base"] = "unknown"
+                else:
+                    await self.async_set_unique_id(f"station_{user_input[CONF_STATION_ID]}")
+                    self._abort_if_unique_id_configured()
+                    return self.async_create_entry(title=info["title"], data=user_input)
 
         return self.async_show_form(
             step_id="token",

+ 4 - 3
custom_components/solar_of_things/strings.json

@@ -16,7 +16,7 @@
           "password": "Password",
           "station_id": "Station ID (required)",
           "device_id": "Device ID (optional — blank to auto-discover)",
-          "time_zone": "Time zone (e.g. Asia/Manila)"
+          "time_zone": "Time zone (full IANA id, e.g. Europe/Warsaw — not just \"Europe\")"
         }
       },
       "token": {
@@ -26,7 +26,7 @@
           "iot_token": "IOT Token",
           "station_id": "Station ID (required)",
           "device_id": "Device ID (optional — blank to auto-discover)",
-          "time_zone": "Time zone (e.g. Asia/Manila)"
+          "time_zone": "Time zone (full IANA id, e.g. Europe/Warsaw — not just \"Europe\")"
         }
       },
       "reauth_confirm": {
@@ -41,7 +41,8 @@
     "error": {
       "cannot_connect": "Cannot connect to the Solar of Things API. Check credentials, Station ID, and network access.",
       "invalid_auth": "Invalid user ID or password. Please try again.",
-      "unknown": "Unexpected error. Check Home Assistant logs for details."
+      "unknown": "Unexpected error. Check Home Assistant logs for details.",
+      "invalid_time_zone": "That's not a valid time zone id. Use a full IANA zone like \"Europe/Warsaw\" or \"America/New_York\" — not a continent or country name."
     },
     "abort": {
       "already_configured": "This station is already configured.",

+ 4 - 3
custom_components/solar_of_things/translations/en.json

@@ -16,7 +16,7 @@
           "password": "Password",
           "station_id": "Station ID (required)",
           "device_id": "Device ID (optional — blank to auto-discover)",
-          "time_zone": "Time zone (e.g. Asia/Manila)"
+          "time_zone": "Time zone (full IANA id, e.g. Europe/Warsaw — not just \"Europe\")"
         }
       },
       "token": {
@@ -26,7 +26,7 @@
           "iot_token": "IOT Token",
           "station_id": "Station ID (required)",
           "device_id": "Device ID (optional — blank to auto-discover)",
-          "time_zone": "Time zone (e.g. Asia/Manila)"
+          "time_zone": "Time zone (full IANA id, e.g. Europe/Warsaw — not just \"Europe\")"
         }
       },
       "reauth_confirm": {
@@ -41,7 +41,8 @@
     "error": {
       "cannot_connect": "Cannot connect to the Solar of Things API. Check credentials, Station ID, and network access.",
       "invalid_auth": "Invalid user ID or password. Please try again.",
-      "unknown": "Unexpected error. Check Home Assistant logs for details."
+      "unknown": "Unexpected error. Check Home Assistant logs for details.",
+      "invalid_time_zone": "That's not a valid time zone id. Use a full IANA zone like \"Europe/Warsaw\" or \"America/New_York\" — not a continent or country name."
     },
     "abort": {
       "already_configured": "This station is already configured.",

+ 24 - 0
custom_components/solar_of_things/util.py

@@ -7,6 +7,11 @@ from __future__ import annotations
 
 from typing import Any
 
+try:
+    from zoneinfo import available_timezones
+except ImportError:  # pragma: no cover - Python < 3.9, not a supported HA target
+    available_timezones = None  # type: ignore[assignment]
+
 from .const import WHITESPACE_SENSITIVE_FIELDS
 
 
@@ -31,3 +36,22 @@ def normalise_config_fields(data: dict[str, Any]) -> dict[str, Any]:
         if isinstance(value, str):
             cleaned[key] = value.strip()
     return cleaned
+
+
+def is_valid_time_zone(value: str) -> bool:
+    """Return whether *value* is a real IANA time zone id (e.g. "Europe/Warsaw").
+
+    The time-zone field is free text (issue #21): a continent name like
+    "Europe" or a bare offset reads as plausible to a user copying from a
+    dropdown elsewhere, but is not a zoneinfo key. Sent as-is in the
+    IOT-Time-Zone header, the Siseli portal's timeseries endpoint rejects it
+    outright with "Timeseries error code=20101 message=Illegal argument" —
+    on the very first API call, with nothing in the error pointing at time
+    zone as the cause. Catching this locally turns that into a same-form
+    validation error instead of a cryptic upstream failure during setup.
+    """
+    if not value:
+        return False
+    if available_timezones is None:  # pragma: no cover
+        return True
+    return value in available_timezones()

+ 24 - 1
tests/test_util.py

@@ -10,7 +10,7 @@ from __future__ import annotations
 import pytest
 
 from custom_components.solar_of_things.const import WHITESPACE_SENSITIVE_FIELDS
-from custom_components.solar_of_things.util import normalise_config_fields
+from custom_components.solar_of_things.util import is_valid_time_zone, normalise_config_fields
 
 
 def test_trims_pasted_station_and_device_ids() -> None:
@@ -70,3 +70,26 @@ def test_returns_a_copy_without_mutating_the_input() -> None:
 )
 def test_edge_cases(payload, expected) -> None:
     assert normalise_config_fields(payload) == expected
+
+
+@pytest.mark.parametrize(
+    "value",
+    ["Asia/Manila", "Europe/Warsaw", "America/New_York", "UTC"],
+)
+def test_valid_iana_zone_ids(value) -> None:
+    assert is_valid_time_zone(value) is True
+
+
+@pytest.mark.parametrize(
+    "value",
+    [
+        "Europe",  # issue #21 — a continent, not a zone
+        "America",
+        "GMT+2",
+        "PST",
+        "",
+        None,
+    ],
+)
+def test_rejects_non_iana_values(value) -> None:
+    assert is_valid_time_zone(value) is False