Skip to content

Commit bbdc3a8

Browse files
committed
Distrust a >120s-off ECU RTC and skip Redis on car-mode boot
The car's battery-backed ECU DS3231 RTC can be set wrong — most often an exactly-3600s EST/EDT DST error — and the car LAN has no NTP to ever correct it. _handle_ecu_timestamp used to trust the ECU unconditionally, so every frame would be stamped an hour off. Trust the local system clock instead when it disagrees with the ECU by more than _MAX_ECU_SYSTEM_DISAGREEMENT_S (120s), and log a warning naming the likely DS3231 cause so future regressions are visible. Separately, the car mode used to call redis_utils.get_sync_client() during TelemetryNode.__init__, which blocks ~25s on connection-refused retries to 127.0.0.1:6379 (the car LAN has no Redis), preventing the WebSocket on :9080 from ever binding. main.py:start_car_services now passes redis_client=None and __init__ honors that on role=car without attempting a connection; base role still auto-connects on None as before. Adds TestCarModeClockAndRedis (5 cases) covering both the disagreement guard and the car-skip-Redis path. Full telemetry-ci test set: 81/81 pass.
1 parent d33b0e6 commit bbdc3a8

3 files changed

Lines changed: 140 additions & 9 deletions

File tree

universal-telemetry-software/main.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,10 @@ async def _run():
8787
# sets a default loop; asyncio.gather() then returns a Future instead of a
8888
# coroutine, causing asyncio.run() to raise ValueError.
8989
direct_queue: asyncio.Queue = asyncio.Queue(maxsize=2000)
90-
node = TelemetryNode(can_event=can_event, telemetry_event=telemetry_event)
90+
# Pass redis_client=None so the car never blocks trying to connect to a
91+
# Redis that isn't there — Redis only runs on the base. All Redis paths
92+
# in TelemetryNode (safe_publish, _redis_ok, health reads) handle None.
93+
node = TelemetryNode(can_event=can_event, telemetry_event=telemetry_event, redis_client=None)
9194
node.direct_queue = direct_queue
9295
await asyncio.gather(
9396
node.start(),

universal-telemetry-software/src/data.py

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -52,21 +52,37 @@ def unpack(cls, binary_data):
5252
# NOTE: 2026-03-22 is today's date at time of writing.
5353
_SYSTEM_CLOCK_TRUST_EPOCH = 1742601600.0 # 2026-03-22 00:00:00 UTC
5454

55+
# When the system clock is itself trustworthy (past _SYSTEM_CLOCK_TRUST_EPOCH and kept
56+
# correct by the base station's /set-time injection or NTP), distrust an ECU RTC that
57+
# disagrees with it by more than this many seconds. The ECU's battery-backed DS3231 can
58+
# be set wrong — most often an exactly-3600s DST (EST vs EDT) error — and the car LAN has
59+
# no NTP to ever correct it, so without this guard every frame would be stamped an hour
60+
# off. 120s is far above normal RTC-vs-clock jitter but well below the 1h failure mode.
61+
_MAX_ECU_SYSTEM_DISAGREEMENT_S = 120.0
62+
5563
class TelemetryNode:
56-
def __init__(self, can_event=None, telemetry_event=None):
64+
def __init__(self, can_event=None, telemetry_event=None, redis_client=None):
65+
# redis_client: pass an existing connected sync client (base), or None
66+
# to skip Redis entirely (car — no Redis on the car LAN, and blocking
67+
# here on a missing 127.0.0.1:6379 would prevent the WebSocket from ever
68+
# binding). When None, all publish/health reads degrade gracefully.
5769
self.buffer = deque()
5870
self.seq_num = 0
5971
self.received_messages = {} # seq_num -> message_batch
6072
self.role = os.getenv("ROLE", "auto")
6173
self.has_can = False
6274
self.can_event = can_event # multiprocessing.Event signalled on each CAN RX
6375
self.telemetry_event = telemetry_event # heartbeat for LED status
64-
self.redis_client = redis_utils.get_sync_client(REDIS_URL)
65-
self.direct_queue: asyncio.Queue | None = None # set by main.py for car mode (no Redis)
76+
# On the car (no Redis available), pass redis_client=None explicitly to skip
77+
# the connect attempt — see main.py:start_car_services. On the base, the
78+
# explicit None is treated as "auto-connect" (the normal path).
79+
if redis_client is None and self.role != "car":
80+
redis_client = redis_utils.get_sync_client(REDIS_URL)
81+
self.redis_client = redis_client # None on the car, possibly None on a Redis-less base
6682
# ECU clock sync — offset between ECU RTC and local monotonic clock
6783
self._clock_offset: float | None = None # epoch_sec - monotonic at last sync
6884
self._last_ecu_sync: float = 0.0 # monotonic time of last valid ECU 1999
69-
self._sync_source: str = "none" # "ecu_rtc" | "system_clock" | "override"
85+
self._sync_source: str = "none" # "ecu_rtc" | "system_clock" | "system_clock_override"
7086
self._base_to_car_offset: float | None = None # Offset between base station time and car's 1970 time
7187
self._last_raw_car_time: float | None = None # Raw timestamp of last message
7288
self._car_internal_jump: float | None = None # Cumulative jump from 1970 to 2026+ internal car time
@@ -90,23 +106,50 @@ def publish(self, channel, data):
90106
pass # drop under backpressure rather than block the CAN reader
91107

92108
def _handle_ecu_timestamp(self, data: bytes) -> None:
93-
"""Extract epoch_ms from ECU VCU_Timestamp message and update clock offset."""
109+
"""Extract epoch_ms from the ECU VCU_Timestamp message and update the clock offset.
110+
111+
The ECU's battery-backed DS3231 RTC is normally authoritative. But it can be set
112+
wrong (most often an exactly-1h DST / EST-vs-EDT error) and the car LAN has no NTP
113+
to ever correct it, so a bad ECU clock would silently stamp every frame an hour off.
114+
Guard against that: when the local system clock is itself trustworthy (kept correct
115+
by the base station's /set-time injection or NTP) and the ECU disagrees with it by
116+
more than _MAX_ECU_SYSTEM_DISAGREEMENT_S, distrust the ECU and use the system clock.
117+
"""
94118
if len(data) < 8:
95119
return
96120
epoch_ms = struct.unpack_from("<q", data)[0] # int64 little-endian
97121
if epoch_ms <= 0:
98122
return
123+
epoch_s = epoch_ms / 1000.0
99124
mono = time.monotonic()
100-
self._clock_offset = epoch_ms / 1000.0 - mono
125+
sys_time = time.time()
126+
127+
# Distrust an ECU clock that is far from a trustworthy system clock.
128+
if (sys_time > _SYSTEM_CLOCK_TRUST_EPOCH
129+
and abs(epoch_s - sys_time) > _MAX_ECU_SYSTEM_DISAGREEMENT_S):
130+
if self._sync_source != "system_clock_override":
131+
logger.warning(
132+
f"ECU time ({epoch_s:.0f}) disagrees with system clock ({sys_time:.0f}) "
133+
f"by {epoch_s - sys_time:+.0f}s — distrusting ECU, using system clock. "
134+
"Fix the ECU DS3231 RTC (likely a 1h DST/EST-vs-EDT error)."
135+
)
136+
self._clock_offset = sys_time - mono
137+
self._last_ecu_sync = mono
138+
self._sync_source = "system_clock_override"
139+
return
140+
141+
self._clock_offset = epoch_s - mono
101142
self._last_ecu_sync = mono
102143
self._sync_source = "ecu_rtc"
103-
logger.info(f"ECU time sync: epoch={epoch_ms/1000:.3f} offset={self._clock_offset:+.3f}s")
144+
logger.info(f"ECU time sync: epoch={epoch_s:.3f} offset={self._clock_offset:+.3f}s")
104145

105146
def _try_auto_sync(self) -> None:
106147
"""Check fallback time sources when no ECU sync has arrived yet.
107148
108149
Priority:
109-
1. ECU RTC (handled by _handle_ecu_timestamp, always wins)
150+
1. ECU RTC (handled by _handle_ecu_timestamp) — wins unless it disagrees with a
151+
trustworthy system clock by more than _MAX_ECU_SYSTEM_DISAGREEMENT_S, in which
152+
case _handle_ecu_timestamp falls back to the system clock.
110153
2. RPi system clock past _SYSTEM_CLOCK_TRUST_EPOCH — covers NTP-synced RPis
111154
and RPis whose clock was manually set via POST /set-time on the status page.
112155
"""

universal-telemetry-software/tests/test_car_mode.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@
2929

3030
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
3131
import src.websocket_bridge as wb
32+
import struct
33+
import time as _time
34+
from src import data as _data_mod
3235
from .test_helpers import WebSocketHelper, RedisHelper
3336

3437
logging.basicConfig(level=logging.INFO)
@@ -497,3 +500,85 @@ async def test_hex_string_data_rejected(self, car_ws_server):
497500

498501
if __name__ == "__main__":
499502
pytest.main([__file__, "-v", "-s"])
503+
504+
505+
# ---------------------------------------------------------------------------
506+
# Car-mode boot path + ECU clock guard
507+
#
508+
# Regression coverage for two car-specific failure modes that previously
509+
# could only be caught on the real car:
510+
# 1. TelemetryNode.__init__ must accept redis_client=None and not attempt
511+
# to connect to 127.0.0.1:6379 — the car LAN has no Redis and blocking
512+
# on retries prevents the WebSocket on :9080 from ever binding.
513+
# 2. _handle_ecu_timestamp must distrust an ECU RTC that disagrees with a
514+
# trustworthy system clock by more than _MAX_ECU_SYSTEM_DISAGREEMENT_S,
515+
# to catch a stuck/wrong ECU DS3231 (the classic exactly-1h DST error).
516+
# ---------------------------------------------------------------------------
517+
518+
class TestCarModeClockAndRedis:
519+
THRESHOLD = _data_mod._MAX_ECU_SYSTEM_DISAGREEMENT_S
520+
521+
def _make_node(self):
522+
"""Build a TelemetryNode without going through __init__ (skips Redis connect)."""
523+
n = object.__new__(_data_mod.TelemetryNode)
524+
n._clock_offset = None
525+
n._last_ecu_sync = 0.0
526+
n._sync_source = "none"
527+
return n
528+
529+
def _ecu_bytes(self, epoch_s: float) -> bytes:
530+
return struct.pack("<q", int(epoch_s * 1000))
531+
532+
def test_init_does_not_connect_to_redis_when_redis_client_none(self, monkeypatch):
533+
"""TelemetryNode(redis_client=None) must not call get_sync_client.
534+
535+
On the car, Redis is unreachable; if __init__ tries to connect, it blocks
536+
~25s on retries and the WebSocket never binds on :9080.
537+
"""
538+
called = {"n": 0}
539+
def _spy(url):
540+
called["n"] += 1
541+
return None
542+
monkeypatch.setattr(_data_mod.redis_utils, "get_sync_client", _spy)
543+
544+
# role=car should not even attempt a Redis connection
545+
with monkeypatch.context() as m:
546+
m.setenv("ROLE", "car")
547+
_data_mod.TelemetryNode(redis_client=None)
548+
assert called["n"] == 0, "TelemetryNode(car) called redis get_sync_client — car will hang on boot"
549+
550+
# role=base WITHOUT an explicit override still attempts a connection
551+
# (this is the normal base path; we just confirm the car differs from base).
552+
with monkeypatch.context() as m:
553+
m.setenv("ROLE", "base")
554+
_data_mod.TelemetryNode(redis_client=None)
555+
assert called["n"] == 1, "TelemetryNode(base) should still try Redis"
556+
557+
def test_ecu_within_tolerance_is_trusted(self):
558+
n = self._make_node()
559+
now = _time.time()
560+
n._handle_ecu_timestamp(self._ecu_bytes(now - 60)) # 60s slow, within 120s tol
561+
assert n._sync_source == "ecu_rtc"
562+
# Corrected time tracks the actual ECU time, not the system clock
563+
assert abs(n._corrected_time() - (now - 60)) < 5
564+
565+
def test_ecu_off_by_one_hour_is_overridden(self):
566+
n = self._make_node()
567+
now = _time.time()
568+
n._handle_ecu_timestamp(self._ecu_bytes(now - 3600)) # the classic DST/EST error
569+
assert n._sync_source == "system_clock_override"
570+
# Corrected time snaps to real system time
571+
assert abs(n._corrected_time() - now) < 5
572+
573+
def test_ecu_just_outside_tolerance_is_overridden(self):
574+
n = self._make_node()
575+
now = _time.time()
576+
n._handle_ecu_timestamp(self._ecu_bytes(now - (self.THRESHOLD + 10)))
577+
assert n._sync_source == "system_clock_override"
578+
assert abs(n._corrected_time() - now) < 5
579+
580+
def test_ecu_agreeing_with_system_clock_is_trusted(self):
581+
n = self._make_node()
582+
now = _time.time()
583+
n._handle_ecu_timestamp(self._ecu_bytes(now))
584+
assert n._sync_source == "ecu_rtc"

0 commit comments

Comments
 (0)