mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-09-16 21:02:35 +00:00
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:
@@ -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"
|
||||
Reference in New Issue
Block a user