fix(packet_capture): publish UTC in every packet time field (#278)

The published packet payload mixed clocks: "timestamp" was UTC ISO 8601
with a Z suffix, but the "time" and "date" fields beside it were rendered
from a naive datetime.now(), i.e. the host's local wall clock. A consumer
reading those two off a bot in a non-UTC zone sees a skew of exactly that
zone's UTC offset and flags the observer's clock as wrong.

Local time was never the intended reading. The original script took time
and date off the firmware log line, and mctomqtt sets the device clock
from calendar.timegm(), so upstream both fields are already UTC.

All three now render one aware UTC instant, so they cannot disagree with
each other or straddle a second boundary. _utc_iso_timestamp() takes an
optional instant to make that possible; its no-arg behavior is unchanged.
This commit is contained in:
agessaman
2026-09-09 22:22:26 -07:00
parent 9ddaf6d3cc
commit 1f87707ade
3 changed files with 112 additions and 5 deletions
+8
View File
@@ -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
@@ -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()
@@ -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"