|
1 | 1 | import asyncio |
| 2 | +import contextlib |
2 | 3 | import socket |
3 | 4 | import struct |
4 | 5 | import time |
|
14 | 15 | from src.config import ( |
15 | 16 | REMOTE_IP, UDP_PORT, TCP_PORT, |
16 | 17 | REDIS_URL, REDIS_CAN_CHANNEL, REDIS_UPLINK_CHANNEL, ENABLE_UPLINK, |
17 | | - REDIS_WS_CLIENTS_KEY, |
| 18 | + REDIS_WS_CLIENTS_KEY, REDIS_HEARTBEAT_CHANNEL, |
18 | 19 | ) |
19 | 20 | from src import redis_utils, utils |
20 | | -from src.heartbeat import run_heartbeat_writer |
| 21 | +from src.heartbeat import pump_pubsub_with_heartbeat, run_heartbeat_writer |
21 | 22 | from src.version import get_git_hash |
22 | 23 |
|
23 | 24 | BATCH_SIZE = 20 |
@@ -90,66 +91,6 @@ def publish(self, channel, data): |
90 | 91 | except asyncio.QueueFull: |
91 | 92 | pass # drop under backpressure rather than block the CAN reader |
92 | 93 |
|
93 | | - @staticmethod |
94 | | - async def _pump_pubsub_with_heartbeat(pubsub, redis_client, on_message, *, |
95 | | - stale_s: float = 5.0, |
96 | | - log=None): |
97 | | - """Drain a Redis pubsub, restarting it if the heartbeat goes stale. |
98 | | -
|
99 | | - Replaces the naive `async for message in pubsub.listen():` pattern that |
100 | | - silently goes dark when the TCP connection or subscription state is lost |
101 | | - after a car power-cycle. Uses non-blocking get_message(timeout=1.0) so we |
102 | | - get a chance to check the heartbeat key on every iteration. |
103 | | -
|
104 | | - `on_message` is an `async` callable invoked with the raw pubsub message |
105 | | - dict (whatever `get_message()` returns, typically |
106 | | - `{"type": "message", "channel": ..., "data": ...}`) for each non-None, |
107 | | - non-subscribe-confirmation message. Returns only on cancellation; outer |
108 | | - supervisor is expected to restart the task. |
109 | | - """ |
110 | | - from .config import REDIS_HEARTBEAT_KEY |
111 | | - _log = log or logger |
112 | | - last_hb_mono = 0.0 |
113 | | - while True: |
114 | | - try: |
115 | | - msg = await pubsub.get_message(timeout=1.0, ignore_subscribe_messages=True) |
116 | | - except asyncio.CancelledError: |
117 | | - raise |
118 | | - except Exception as e: |
119 | | - _log.warning(f"pubsub.get_message error, reconnecting: {e}") |
120 | | - await asyncio.sleep(0.5) |
121 | | - return # let the outer while-True re-subscribe |
122 | | - |
123 | | - if msg is not None: |
124 | | - try: |
125 | | - await on_message(msg) |
126 | | - except Exception as e: |
127 | | - _log.error(f"subscriber handler error: {e}") |
128 | | - last_hb_mono = time.monotonic() # data flowing — heartbeat check not needed |
129 | | - continue |
130 | | - |
131 | | - # No message this second — check heartbeat |
132 | | - try: |
133 | | - raw = await redis_client.get(REDIS_HEARTBEAT_KEY) if redis_client else None |
134 | | - except Exception as e: |
135 | | - _log.debug(f"heartbeat read failed: {e}") |
136 | | - raw = None |
137 | | - |
138 | | - if raw is None: |
139 | | - # First-time setup or Redis is down — don't reconnect, just wait. |
140 | | - if last_hb_mono == 0.0: |
141 | | - continue |
142 | | - # Heartbeat missing while we've been running: producer is gone or |
143 | | - # the key expired. Tear down so the outer loop re-subscribes. |
144 | | - if time.monotonic() - last_hb_mono > stale_s: |
145 | | - _log.warning("heartbeat stale, forcing pubsub reconnect") |
146 | | - return |
147 | | - else: |
148 | | - last_hb_mono = time.monotonic() |
149 | | - if time.monotonic() - last_hb_mono > stale_s: |
150 | | - _log.warning("heartbeat stale, forcing pubsub reconnect") |
151 | | - return |
152 | | - |
153 | 94 | def _handle_ecu_timestamp(self, data: bytes) -> None: |
154 | 95 | """Extract epoch_ms from ECU VCU_Timestamp message and update clock offset.""" |
155 | 96 | if len(data) < 8: |
@@ -609,8 +550,6 @@ async def uplink_receiver(): |
609 | 550 | throughput_listener_task(), |
610 | 551 | utils.heartbeat_coro(self.telemetry_event), |
611 | 552 | inject_heartbeat(), |
612 | | - # Car has no Redis — writer is a clean no-op when redis_client is None. |
613 | | - run_heartbeat_writer(None), |
614 | 553 | ] |
615 | 554 | if ENABLE_UPLINK: |
616 | 555 | tasks.append(uplink_receiver()) |
@@ -926,55 +865,63 @@ async def uplink_relay(): |
926 | 865 | uplink_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) |
927 | 866 | uplink_seq = 0 |
928 | 867 |
|
929 | | - try: |
930 | | - r = aioredis.from_url(REDIS_URL) |
931 | | - pubsub = r.pubsub() |
932 | | - await pubsub.subscribe(REDIS_UPLINK_CHANNEL) |
933 | | - logger.info(f"Subscribed to Redis channel: {REDIS_UPLINK_CHANNEL}") |
934 | | - |
935 | | - async def _relay(msg): |
936 | | - nonlocal uplink_seq |
937 | | - if msg['type'] != 'message': |
938 | | - return |
939 | | - try: |
940 | | - data = redis_utils.decode_message(msg['data']) |
941 | | - uplink_msg = json.loads(data) |
942 | | - |
943 | | - can_id = uplink_msg.get("canId") |
944 | | - can_data = uplink_msg.get("data", []) |
945 | | - ref = uplink_msg.get("ref", "unknown") |
| 868 | + async def _relay(msg): |
| 869 | + nonlocal uplink_seq |
| 870 | + if msg['type'] != 'message': |
| 871 | + return |
| 872 | + try: |
| 873 | + data = redis_utils.decode_message(msg['data']) |
| 874 | + uplink_msg = json.loads(data) |
946 | 875 |
|
947 | | - if can_id is None or not isinstance(can_id, int) or can_id < 0: |
948 | | - logger.warning(f"Uplink relay: invalid canId in ref={ref}") |
949 | | - return |
950 | | - if not isinstance(can_data, list) or len(can_data) < 1 or len(can_data) > 8: |
951 | | - logger.warning(f"Uplink relay: invalid data in ref={ref}") |
952 | | - return |
| 876 | + can_id = uplink_msg.get("canId") |
| 877 | + can_data = uplink_msg.get("data", []) |
| 878 | + ref = uplink_msg.get("ref", "unknown") |
953 | 879 |
|
954 | | - # Pack as uplink UDP packet: 0xCAFE + seq + count(1) + CAN message |
955 | | - uplink_seq += 1 |
956 | | - data_bytes = bytes(can_data) + b'\x00' * (8 - len(can_data)) |
957 | | - can_msg = CANMessage(time.time(), can_id, data_bytes) |
| 880 | + if can_id is None or not isinstance(can_id, int) or can_id < 0: |
| 881 | + logger.warning(f"Uplink relay: invalid canId in ref={ref}") |
| 882 | + return |
| 883 | + if not isinstance(can_data, list) or len(can_data) < 1 or len(can_data) > 8: |
| 884 | + logger.warning(f"Uplink relay: invalid data in ref={ref}") |
| 885 | + return |
958 | 886 |
|
959 | | - payload = UPLINK_MAGIC |
960 | | - payload += struct.pack("!QH", uplink_seq, 1) |
961 | | - payload += can_msg.pack() |
| 887 | + # Pack as uplink UDP packet: 0xCAFE + seq + count(1) + CAN message |
| 888 | + uplink_seq += 1 |
| 889 | + data_bytes = bytes(can_data) + b'\x00' * (8 - len(can_data)) |
| 890 | + can_msg = CANMessage(time.time(), can_id, data_bytes) |
962 | 891 |
|
963 | | - try: |
964 | | - uplink_sock.sendto(payload, (REMOTE_IP, UDP_PORT)) |
965 | | - logger.info(f"Uplink relayed to car: canId={can_id} ref={ref} seq={uplink_seq}") |
966 | | - except (PermissionError, OSError) as e: |
967 | | - logger.error(f"Uplink UDP send failed: {e}") |
| 892 | + payload = UPLINK_MAGIC |
| 893 | + payload += struct.pack("!QH", uplink_seq, 1) |
| 894 | + payload += can_msg.pack() |
968 | 895 |
|
969 | | - except Exception as e: |
970 | | - logger.error(f"Uplink relay error: {e}") |
| 896 | + try: |
| 897 | + uplink_sock.sendto(payload, (REMOTE_IP, UDP_PORT)) |
| 898 | + logger.info(f"Uplink relayed to car: canId={can_id} ref={ref} seq={uplink_seq}") |
| 899 | + except (PermissionError, OSError) as e: |
| 900 | + logger.error(f"Uplink UDP send failed: {e}") |
971 | 901 |
|
972 | | - await TelemetryNode._pump_pubsub_with_heartbeat( |
973 | | - pubsub, r, _relay, log=logger, |
974 | | - ) |
| 902 | + except Exception as e: |
| 903 | + logger.error(f"Uplink relay error: {e}") |
975 | 904 |
|
976 | | - except Exception as e: |
977 | | - logger.error(f"Uplink relay Redis error: {e}") |
| 905 | + # Reconnect loop: the pump returns whenever the pubsub connection |
| 906 | + # goes silent past HEARTBEAT_STALE_S, and we re-subscribe here. |
| 907 | + try: |
| 908 | + while True: |
| 909 | + r = None |
| 910 | + try: |
| 911 | + r = aioredis.from_url(REDIS_URL) |
| 912 | + pubsub = r.pubsub() |
| 913 | + await pubsub.subscribe(REDIS_UPLINK_CHANNEL, REDIS_HEARTBEAT_CHANNEL) |
| 914 | + logger.info(f"Subscribed to Redis channels: {REDIS_UPLINK_CHANNEL}, {REDIS_HEARTBEAT_CHANNEL}") |
| 915 | + await pump_pubsub_with_heartbeat(pubsub, _relay, log=logger) |
| 916 | + except asyncio.CancelledError: |
| 917 | + raise |
| 918 | + except Exception as e: |
| 919 | + logger.error(f"Uplink relay Redis error: {e}") |
| 920 | + await asyncio.sleep(1.0) |
| 921 | + finally: |
| 922 | + if r is not None: |
| 923 | + with contextlib.suppress(Exception): |
| 924 | + await r.aclose() |
978 | 925 | finally: |
979 | 926 | uplink_sock.close() |
980 | 927 |
|
@@ -1051,8 +998,8 @@ async def version_checker(): |
1051 | 998 | await asyncio.sleep(30.0) |
1052 | 999 |
|
1053 | 1000 | # Base mode has a real Redis server; create an async client for the |
1054 | | - # heartbeat writer. The writer writes telemetry:heartbeat every 1s so |
1055 | | - # pubsub subscribers can detect a half-dead producer and reconnect. |
| 1001 | + # heartbeat writer. The writer publishes on the heartbeat channel every |
| 1002 | + # 1s so pubsub subscribers can detect a dead subscription and reconnect. |
1056 | 1003 | _async_redis = aioredis.from_url(REDIS_URL) |
1057 | 1004 | tasks = [udp_receiver(), missing_reporter(), stats_publisher(), raw_csv_logger(), car_time_injector(), version_checker(), utils.heartbeat_coro(self.telemetry_event), run_heartbeat_writer(_async_redis)] |
1058 | 1005 | if ENABLE_UPLINK: |
|
0 commit comments