@@ -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+
5563class 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 """
0 commit comments