diff --git a/CHANGELOG.md b/CHANGELOG.md index 69a8f82..498fe31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,14 @@ semantic versioning. ### Fixed +- Published packet payloads carry UTC in every time field, not just `timestamp` + (#278). `time` and `date` came from a local `datetime.now()` while the + `timestamp` beside them was UTC, so a consumer reading the pair off a bot in a + non-UTC zone saw a skew of exactly that zone's offset and flagged the observer's + clock as wrong. The original script took those two fields off the firmware log + line, which runs on the device's UTC clock, so a host-local reading was never + intended. All three fields now render one UTC instant. + - Weather output no longer leaks translation key paths into mesh broadcasts. The localization pass replaced several `dict.get(key, fallback)` lookups with bare `translate()` calls, and `Translator.translate` returns the dotted key path when diff --git a/modules/service_plugins/packet_capture_service.py b/modules/service_plugins/packet_capture_service.py index b561eeb..5fac5ec 100644 --- a/modules/service_plugins/packet_capture_service.py +++ b/modules/service_plugins/packet_capture_service.py @@ -850,9 +850,13 @@ class PacketCaptureService(BaseServicePlugin): return iat, iat + ttl @staticmethod - def _utc_iso_timestamp() -> str: - """UTC ISO 8601 timestamp with Z suffix for broad consumer compatibility.""" - return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + def _utc_iso_timestamp(moment: datetime | None = None) -> str: + """UTC ISO 8601 timestamp with Z suffix for broad consumer compatibility. + + Pass ``moment`` to render an instant the caller already has, so every + time field in one payload agrees instead of straddling a second boundary. + """ + return (moment or datetime.now(timezone.utc)).isoformat().replace("+00:00", "Z") @staticmethod def _disconnect_reason(rc: int) -> str: @@ -1190,8 +1194,12 @@ class PacketCaptureService(BaseServicePlugin): Returns: dict[str, Any]: Formatted packet dictionary. """ - current_time = datetime.now() - timestamp = self._utc_iso_timestamp() + # One UTC instant for the whole payload. "time"/"date" mirror "timestamp" + # because the original script read them off the firmware log line, which + # runs on the device's UTC clock — rendering them in the host's local + # zone here reads downstream as clock skew of exactly the UTC offset. + current_time = datetime.now(timezone.utc) + timestamp = self._utc_iso_timestamp(current_time) # Remove 0x prefix if present clean_raw_hex = raw_hex.replace("0x", "").upper() diff --git a/tests/unit/test_packet_capture_utc_payload_time.py b/tests/unit/test_packet_capture_utc_payload_time.py new file mode 100644 index 0000000..9460574 --- /dev/null +++ b/tests/unit/test_packet_capture_utc_payload_time.py @@ -0,0 +1,91 @@ +"""Published packet payloads carry UTC time fields, not host-local ones (issue #278).""" + +from __future__ import annotations + +import configparser +import logging +import os +import time +import types +from datetime import datetime, timezone +from unittest.mock import MagicMock + +import pytest + +from modules.service_plugins.packet_capture_service import PacketCaptureService + +LOGGER = logging.getLogger("test-packet-capture-utc-payload-time") +DEVICE_KEY = "cd" * 32 + +# A fixed UTC+5 zone: no DST, so the offset is nonzero no matter when CI runs. +NON_UTC_TZ = "Etc/GMT-5" + + +def build_service(): + """Build a minimal PacketCaptureService without running __init__.""" + config = configparser.ConfigParser() + config["Bot"] = {"bot_name": "BotNode"} + config["PacketCapture"] = {} + + bot = MagicMock() + bot.config = config + bot.meshcore = types.SimpleNamespace( + self_info={"name": "DeviceNode", "public_key": DEVICE_KEY} + ) + + service = object.__new__(PacketCaptureService) + service.bot = bot + service.logger = LOGGER + service.debug = False + service.decode_payloads = False + service.channel_key_store = None + return service + + +@pytest.fixture +def non_utc_timezone(): + """Run the test body under a non-UTC local zone, then restore the original.""" + original = os.environ.get("TZ") + os.environ["TZ"] = NON_UTC_TZ + time.tzset() + try: + yield + finally: + if original is None: + os.environ.pop("TZ", None) + else: + os.environ["TZ"] = original + time.tzset() + + +def format_packet(): + packet_info = { + "route_type": "FLOOD", + "payload_type": "ADVERT", + "payload_type_value": 4, + "payload_bytes": 1, + "path_len": 0, + "path_byte_length": 0, + "path": [], + "packet_hash": "0123456789ABCDEF", + "has_transport_codes": False, + } + return build_service()._format_packet_data("00AA", packet_info, {"snr": 5.0, "rssi": -90}) + + +@pytest.mark.skipif(not hasattr(time, "tzset"), reason="tzset is POSIX-only") +def test_packet_time_and_date_are_utc(non_utc_timezone): + result = format_packet() + + moment = datetime.fromisoformat(result["timestamp"].replace("Z", "+00:00")) + assert moment.utcoffset().total_seconds() == 0 + + # Pre-fix these were datetime.now() renderings, so under UTC+5 they ran five + # hours ahead of the "timestamp" they sit beside in the same payload. + assert result["time"] == moment.strftime("%H:%M:%S") + assert result["date"] == moment.strftime("%d/%m/%Y") + + +def test_utc_iso_timestamp_renders_a_supplied_instant(): + moment = datetime(2026, 3, 1, 4, 5, 6, tzinfo=timezone.utc) + assert PacketCaptureService._utc_iso_timestamp(moment) == "2026-03-01T04:05:06Z"