From 603adccb9fa4c1b52f7964a57ac589681c2fce1d Mon Sep 17 00:00:00 2001 From: Bjorkan Date: Fri, 5 Jun 2026 10:38:17 +0200 Subject: [PATCH] Emit UTC Z-suffixed timestamps for Community MQTT observer payloads --- app/fanout/community_mqtt.py | 18 ++++++++++++------ tests/test_community_mqtt.py | 18 +++++++++++++----- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/app/fanout/community_mqtt.py b/app/fanout/community_mqtt.py index 2e04f58..42d1943 100644 --- a/app/fanout/community_mqtt.py +++ b/app/fanout/community_mqtt.py @@ -15,7 +15,7 @@ import json import logging import ssl import time -from datetime import datetime +from datetime import UTC, datetime from typing import Any, Protocol import aiomqtt @@ -45,6 +45,12 @@ _STATS_MIN_CACHE_SECS = 60 # Don't re-fetch stats within 60s _ROUTE_MAP = {0: "F", 1: "F", 2: "D", 3: "T"} +def _format_utc_timestamp(dt: datetime | None = None) -> str: + """Return an ISO-8601 UTC timestamp accepted by community observers.""" + current = dt.astimezone(UTC) if dt is not None else datetime.now(UTC) + return current.isoformat().replace("+00:00", "Z") + + class CommunityMqttSettings(Protocol): """Attributes expected on the settings object for the community MQTT publisher.""" @@ -152,9 +158,9 @@ def _format_raw_packet(data: dict[str, Any], device_name: str, public_key_hex: s if route == "U": return None - # Reference format uses local "now" timestamp and derived time/date fields. - current_time = datetime.now() - ts_str = current_time.isoformat() + # Community observers clamp zone-less local timestamps; publish explicit UTC. + current_time = datetime.now(UTC) + ts_str = _format_utc_timestamp(current_time) # Keep numeric telemetry numeric so downstream analyzers can ingest it. # Preserve the existing "Unknown" fallback for missing values. @@ -314,7 +320,7 @@ class CommunityMqttPublisher(BaseMqttPublisher): offline_payload = json.dumps( { "status": "offline", - "timestamp": datetime.now().isoformat(), + "timestamp": _format_utc_timestamp(), "origin": device_name or "MeshCore Device", "origin_id": pubkey_hex, } @@ -478,7 +484,7 @@ class CommunityMqttPublisher(BaseMqttPublisher): status_topic = _build_status_topic(settings, pubkey_hex) payload: dict[str, Any] = { "status": "online", - "timestamp": datetime.now().isoformat(), + "timestamp": _format_utc_timestamp(), "origin": device_name or "MeshCore Device", "origin_id": pubkey_hex, "model": device_info.get("model", "unknown"), diff --git a/tests/test_community_mqtt.py b/tests/test_community_mqtt.py index 966bb20..eb4b7b3 100644 --- a/tests/test_community_mqtt.py +++ b/tests/test_community_mqtt.py @@ -3,6 +3,7 @@ import json import time from contextlib import asynccontextmanager +from datetime import UTC, datetime from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch @@ -76,6 +77,13 @@ def _make_community_settings(**overrides) -> SimpleNamespace: return SimpleNamespace(**defaults) +def _assert_utc_z_timestamp(value: str) -> None: + assert value.endswith("Z") + assert "+00:00" not in value + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + assert parsed.tzinfo == UTC + + class TestBase64UrlEncode: def test_encodes_without_padding(self): result = _base64url_encode(b"\x00\x01\x02") @@ -219,12 +227,11 @@ class TestPacketFormatConversion: assert result["direction"] == "rx" assert result["len"] == "3" - def test_timestamp_is_iso8601(self): + def test_timestamp_is_utc_z_iso8601(self): data = {"timestamp": 1700000000, "data": "0100AA", "snr": None, "rssi": None} result = _format_raw_packet(data, "Node", "AA" * 32) assert result is not None - assert result["timestamp"] - assert "T" in result["timestamp"] + _assert_utc_z_timestamp(result["timestamp"]) def test_snr_rssi_unknown_when_none(self): data = {"timestamp": 0, "data": "0100AA", "snr": None, "rssi": None} @@ -734,7 +741,7 @@ class TestLwtAndStatusPublish: assert payload["status"] == "offline" assert payload["origin"] == "TestNode" assert payload["origin_id"] == pubkey_hex - assert "timestamp" in payload + _assert_utc_z_timestamp(payload["timestamp"]) assert "client" not in payload assert kwargs["transport"] == "websockets" assert kwargs["websocket_path"] == "/" @@ -884,7 +891,7 @@ class TestLwtAndStatusPublish: assert payload["origin"] == "TestNode" assert payload["origin_id"] == pubkey_hex assert "client" not in payload - assert "timestamp" in payload + _assert_utc_z_timestamp(payload["timestamp"]) assert payload["model"] == "T-Deck" assert payload["firmware_version"] == "v2.2.2 (Build: 2025-01-15)" assert payload["radio"] == "915.0,250.0,10,8" @@ -1393,6 +1400,7 @@ class TestPublishStatus: assert payload["origin"] == "TestNode" assert payload["origin_id"] == pubkey_hex assert "client" not in payload + _assert_utc_z_timestamp(payload["timestamp"]) assert payload["model"] == "T-Deck" assert payload["firmware_version"] == "v2.2.2 (Build: 2025-01-15)" assert payload["radio"] == "915.0,250.0,10,8"