Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions aiopnsense/_typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ async def _safe_list_post(
self, path: str, payload: MutableMapping[str, Any] | None = None
) -> list: ...

async def _get_resolved_opnsense_timezone(
self, datetime_str: str | None = None
) -> tzinfo | None: ...

async def _get_opnsense_timezone(self, datetime_str: str | None = None) -> tzinfo: ...

async def get_host_firmware_version(self) -> str | None: ...
Expand Down
29 changes: 28 additions & 1 deletion aiopnsense/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import asyncio
from collections.abc import Callable, MutableMapping
from datetime import UTC, datetime
from datetime import UTC, datetime, tzinfo
from functools import wraps
import ipaddress
import logging
Expand Down Expand Up @@ -294,6 +294,33 @@ def timestamp_to_datetime(timestamp: int | None) -> datetime | None:
return utc_datetime.astimezone()


def normalize_datetime(value: object, default_tz: tzinfo | None) -> str | None:
"""Return a timezone-aware ISO 8601 datetime string.

Args:
value (object): Raw datetime value to normalize.
default_tz (tzinfo | None): Timezone assigned when ``value`` is naive.
When ``None``, naive values are returned as ``None`` so callers can
distinguish missing or unresolvable timezones.

Returns:
str | None: ISO 8601 timestamp including a UTC offset, or ``None``
when the value is missing or malformed.
"""
if not isinstance(value, str):
return None
try:
parsed_date = datetime.fromisoformat(value)
except ValueError:
_LOGGER.debug("Failed to parse datetime: %s", value)
return None
if parsed_date.tzinfo is None:
if default_tz is None:
return None
parsed_date = parsed_date.replace(tzinfo=default_tz)
return parsed_date.isoformat()


def try_to_int(value: Any | None, retval: int | None = None) -> int | None:
"""Convert a value to ``int`` and return a fallback on conversion failure.

Expand Down
51 changes: 18 additions & 33 deletions aiopnsense/speedtest.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from typing import Any

from ._typing import AiopnsenseClientProtocol
from .helpers import _LOGGER, _log_errors, try_to_float, try_to_int
from .helpers import _LOGGER, _log_errors, dict_get, normalize_datetime, try_to_float, try_to_int

SPEEDTEST_SHOW_LOG_ENDPOINT = "/api/speedtest/service/showlog"
SPEEDTEST_SHOW_STAT_ENDPOINT = "/api/speedtest/service/showstat"
Expand Down Expand Up @@ -53,18 +53,14 @@ async def get_speedtest(self) -> dict[str, Any]:
show_stat = {}

server_id = latest_result.get("server_id")
if not isinstance(server_id, str):
server_id = None
server_name = latest_result.get("server")
if not isinstance(server_name, str):
server_name = None
date = latest_result.get("date") if isinstance(latest_result.get("date"), str) else None
opnsense_tz = await self._get_resolved_opnsense_timezone()
Comment thread
Snuffy2 marked this conversation as resolved.
date = normalize_datetime(latest_result.get("date"), opnsense_tz)
url = latest_result.get("url") if isinstance(latest_result.get("url"), str) else None

samples = try_to_int(show_stat.get("samples"))
period = show_stat.get("period", {})
oldest = period.get("oldest") if isinstance(period, MutableMapping) else None
youngest = period.get("youngest") if isinstance(period, MutableMapping) else None
samples = try_to_int(dict_get(show_stat, "samples"))
oldest = normalize_datetime(dict_get(show_stat, "period.oldest"), opnsense_tz)
youngest = normalize_datetime(dict_get(show_stat, "period.youngest"), opnsense_tz)
Comment thread
Snuffy2 marked this conversation as resolved.

output: dict[str, Any] = {
"available": True,
Expand All @@ -73,8 +69,6 @@ async def get_speedtest(self) -> dict[str, Any]:
}
for metric in ("download", "upload", "latency"):
recent_value = try_to_float(latest_result.get(metric))
stat_metric = show_stat.get(metric, {})

output["last"][metric] = {
"value": recent_value,
"date": date,
Expand All @@ -83,15 +77,9 @@ async def get_speedtest(self) -> dict[str, Any]:
"url": url,
}
output["average"][metric] = {
"value": try_to_float(
stat_metric.get("avg") if isinstance(stat_metric, MutableMapping) else None
),
"min": try_to_float(
stat_metric.get("min") if isinstance(stat_metric, MutableMapping) else None
),
"max": try_to_float(
stat_metric.get("max") if isinstance(stat_metric, MutableMapping) else None
),
"value": try_to_float(dict_get(show_stat, f"{metric}.avg")),
"min": try_to_float(dict_get(show_stat, f"{metric}.min")),
"max": try_to_float(dict_get(show_stat, f"{metric}.max")),
"oldest": oldest,
"youngest": youngest,
"samples": samples,
Expand All @@ -115,19 +103,16 @@ def _parse_showlog_latest(self, show_log: object) -> dict[str, Any]:
if not isinstance(latest, list) or len(latest) < 9:
return {}

raw_server_id = latest[2].strip() if isinstance(latest[2], str) else latest[2]
if isinstance(raw_server_id, bool) or not isinstance(raw_server_id, int | str):
server_id = (
str(latest[2]).strip()
if isinstance(latest[2], int | str) and not isinstance(latest[2], bool)
else None
)
if server_id == "":
server_id = None
else:
server_id = str(raw_server_id)
if not server_id:
server_id = None

raw_server = latest[3].strip() if isinstance(latest[3], str) else latest[3]
if not isinstance(raw_server, str):
server = None
else:
server = raw_server or None
raw_server = latest[3]
server = raw_server.strip() if isinstance(raw_server, str) else None
server = server or None
return {
"date": latest[0],
"server_id": server_id,
Expand Down
88 changes: 61 additions & 27 deletions aiopnsense/system.py
Original file line number Diff line number Diff line change
Expand Up @@ -330,49 +330,83 @@ def _get_local_timezone(self) -> tzinfo:
"""
return timezone(datetime.now().astimezone().utcoffset() or timedelta())

async def _get_opnsense_timezone(self, datetime_str: str | None = None) -> tzinfo:
"""Resolve timezone information from OPNsense system time data.
def _parse_opnsense_tz(self, datetime_str: str | None) -> tzinfo | None:
"""Parse a timezone from a system timestamp string.

Args:
datetime_str (str | None, optional): Datetime string parsed from API output.
datetime_str (str | None): Raw timestamp value from the system
endpoint.

Returns:
tzinfo: Resolved timezone object for OPNsense system data.
tzinfo | None: Parsed timezone when available; ``None`` when the
string is missing, naive, or cannot be parsed.
"""
if not datetime_str:
return None

try:
with warnings.catch_warnings():
warnings.simplefilter("error", UnknownTimezoneWarning)
parsed_time = parse(datetime_str, tzinfos=AMBIGUOUS_TZINFOS)
if parsed_time.tzinfo is not None:
return parsed_time.tzinfo
Comment thread
Snuffy2 marked this conversation as resolved.
_LOGGER.debug("No timezone data in OPNsense datetime '%s'", datetime_str)
except (ValueError, TypeError, ParserError, UnknownTimezoneWarning) as err:
_LOGGER.debug(
"Failed to parse OPNsense timezone from datetime '%s': %s: %s",
datetime_str,
type(err).__name__,
err,
)
return None

async def _get_resolved_opnsense_timezone(
self, datetime_str: str | None = None
) -> tzinfo | None:
"""Resolve OPNsense timezone only when it can be determined.

Args:
datetime_str (str | None, optional): Datetime string from OPNsense to
parse instead of reading the system-time endpoint.

Returns:
tzinfo | None: Resolved firewall timezone, or ``None`` when the
timezone cannot be resolved.
"""
if datetime_str is None:
if not await self._is_get_endpoint_available(SYSTEM_TIME_ENDPOINT):
_LOGGER.debug("System time endpoint unavailable for timezone resolution")
return self._get_local_timezone()
return None
try:
datetime_raw = (await self._safe_dict_get(SYSTEM_TIME_ENDPOINT)).get("datetime")
datetime_payload = await self._safe_dict_get(SYSTEM_TIME_ENDPOINT)
except (OPNsenseError, aiohttp.ClientError, TimeoutError) as err:
_LOGGER.debug(
"Failed to fetch OPNsense system time for timezone resolution: %s: %s",
type(err).__name__,
err,
)
return self._get_local_timezone()
datetime_str = datetime_raw if isinstance(datetime_raw, str) else None
return None
datetime_value = (
datetime_payload.get("datetime")
if isinstance(datetime_payload, MutableMapping)
else None
)
datetime_str = datetime_value if isinstance(datetime_value, str) else None

if datetime_str:
try:
with warnings.catch_warnings():
warnings.simplefilter("error", UnknownTimezoneWarning)
parsed_time = parse(datetime_str, tzinfos=AMBIGUOUS_TZINFOS)
if parsed_time.tzinfo is not None:
return parsed_time.tzinfo
_LOGGER.debug(
"No timezone data in OPNsense datetime '%s', using local fallback",
datetime_str,
)
except (ValueError, TypeError, ParserError, UnknownTimezoneWarning) as err:
_LOGGER.debug(
"Failed to parse OPNsense timezone from datetime '%s': %s: %s",
datetime_str,
type(err).__name__,
err,
)
return self._get_local_timezone()
return self._parse_opnsense_tz(datetime_str)

async def _get_opnsense_timezone(self, datetime_str: str | None = None) -> tzinfo:
"""Resolve timezone information from OPNsense system time data.

Args:
datetime_str (str | None, optional): Datetime string parsed from API output.

Returns:
tzinfo: Resolved timezone object for OPNsense system data.
"""
return (
await self._get_resolved_opnsense_timezone(datetime_str) or self._get_local_timezone()
)

@_log_errors
async def get_device_unique_id(self, expected_id: str | None = None) -> str | None:
Expand Down
36 changes: 35 additions & 1 deletion tests/test_helpers.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Tests for `aiopnsense.helpers` utility and decorator helpers."""

from collections.abc import Callable
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta, timezone
import inspect
import logging
from unittest.mock import MagicMock
Expand Down Expand Up @@ -89,6 +89,40 @@ def test_timestamp_to_datetime() -> None:
assert aiopnsense_helpers.timestamp_to_datetime(None) is None


@pytest.mark.parametrize(
("value", "default_tz", "expected"),
[
("2026-03-14T03:09:45", timezone(timedelta(hours=-4)), "2026-03-14T03:09:45-04:00"),
("2023-01-22 00:29:00", timezone(timedelta(hours=-4)), "2023-01-22T00:29:00-04:00"),
("2026-03-14T03:09:45+01:30", timezone(timedelta(hours=-4)), "2026-03-14T03:09:45+01:30"),
("2026-03-14T03:09:45", None, None),
("2026-03-14T03:09:45+01:30", None, "2026-03-14T03:09:45+01:30"),
("not-a-date", timezone(timedelta(hours=-4)), None),
(12345, timezone(timedelta(hours=-4)), None),
],
)
def test_normalize_datetime(
value: object, default_tz: timezone | None, expected: str | None
) -> None:
"""Normalize naive and aware datetimes while rejecting malformed values.

Args:
value (object): Raw datetime value under test.
default_tz (timezone | None): Fallback timezone for naive values.
expected (str | None): Expected timezone-aware ISO result.

Returns:
None: This test validates helper output via assertions.
"""
assert (
aiopnsense_helpers.normalize_datetime(
value,
default_tz,
)
== expected
)


@pytest.mark.parametrize(
("firmware_version", "expected"),
[
Expand Down
Loading
Loading