RBC_Test_Sim_Core 0.1.0 build 51fd23e
Shared transport-layer plumbing for the ADR-029 Train/IL/CTC RBC simulators
Loading...
Searching...
No Matches
saf_train_link.py
Go to the documentation of this file.
1"""SafTrainLink - Subset-037 SaF protection of a train sim's ERTMS traffic (the train = SaF initiator).
2
3Python side of RBC_GP's train-link SaF termination (``ab_gp_saf_train.c``; root
4``docs/architecture/SAFE_COMM_INTEGRATION.dox`` plan steps 2/3). Wraps either transport a train sim uses
5(``DualHomedLink`` or ``SafeCommLink``) and keeps their ``ensure_connected()`` / ``send_all()`` / ``poll_recv()``
6surface, so ``train_sim`` only swaps the object:
7
8* An outgoing ``RBC_MSG_ERTMS_ENVELOPE`` (kind 22: kind, train_id, codec bytes, zero padding) becomes one Sa-DATA
9 frame of that train's SaF connection, carried in an ``RBC_MSG_SAF_ENVELOPE`` (kind 28: kind, train_id, 2-byte
10 big-endian frame length, frame, zero padding). The PDU is exactly the codec bytes (``l_message`` long).
11* The first ERTMS send for a train starts its handshake (AU1); PDUs queue until AR arrives, then go out in order.
12* An incoming kind-28 frame is verified; a Sa-DATA PDU is handed to the caller as an ordinary kind-22 frame, so the
13 catalog decoder and everything above it are unchanged.
14* Anything else (the flat P0/M136/TRAIN_CONNECT scheme) passes through untouched - the RBC side protects only the
15 ERTMS envelope too.
16* A handshake past ``t_connect_ms`` or a DATA connection silent past ``t_supervision_ms`` is dropped; the next ERTMS
17 send starts a new handshake (this is also how a site failover is recovered: the newly ONLINE site has no session).
18* A Sa-DISCONNECT from the RBC (it answers a Sa-DATA frame for a connection it no longer holds - e.g. its processes
19 restarted) drops the session at once: the PDUs sent in the last ``resend_window_ms`` were swallowed by the dead
20 session, so they are queued again and a new handshake starts immediately (a real train's application would redo
21 whatever the lost messages carried, e.g. Start of Mission).
22
23Keys: ``keys`` maps a train's ETCS identity (its nid_engine) to its 24-byte KMAC - the same test-only key file the
24RBC reads through ``RTE_SAFECOMM_TRAIN_KEYS_FILE``.
25"""
26import collections
27import time
28
29from . import saf
30
31ERTMS_KIND = 22
32SAF_KIND = 28
33_SAF_HEADER = 4
34ETY_ENGINE = 2 # Subset-037-2 Table 8
35
36
38 """Parse ``<etcs_id> <48 hex>`` lines (``#`` comments, blank lines ignored) into {etcs_id: kmac}."""
39 keys = {}
40 with open(path, encoding="ascii") as fh:
41 for lineno, raw in enumerate(fh, 1):
42 line = raw.split("#", 1)[0].strip()
43 if not line:
44 continue
45 parts = line.split()
46 if len(parts) != 2 or len(parts[1]) != 2 * saf.KEY_LEN:
47 raise ValueError(f"{path}:{lineno}: expected '<etcs_id> <48 hex chars>'")
48 keys[int(parts[0], 0) & 0xFFFFFF] = bytes.fromhex(parts[1])
49 return keys
50
51
52def encode_envelope(train_id, frame, wire_size):
53 if not frame or len(frame) > wire_size - _SAF_HEADER:
54 raise ValueError(f"SaF frame of {len(frame)} bytes does not fit a {wire_size}-byte envelope")
55 env = bytes([SAF_KIND, train_id & 0xFF]) + len(frame).to_bytes(2, "big") + bytes(frame)
56 return env + bytes(wire_size - len(env))
57
58
60 """(train_id, frame) of a kind-28 envelope, or None if malformed."""
61 if len(env) < _SAF_HEADER or env[0] != SAF_KIND:
62 return None
63 length = int.from_bytes(env[2:4], "big")
64 if length == 0 or _SAF_HEADER + length > len(env):
65 return None
66 return env[1], bytes(env[_SAF_HEADER:_SAF_HEADER + length])
67
68
70 def __init__(self, inner, log, rbc_etcs_id, keys, wire_size, *, t_connect_ms=10000, t_supervision_ms=20000,
71 clock=None, rng=None, resend_window_ms=3000):
72 self._inner = inner
73 self._log = log
74 self._rbc_id = rbc_etcs_id & 0xFFFFFF
75 self._keys = dict(keys)
76 self._wire_size = wire_size
77 self._t_connect_ms = t_connect_ms
78 self._t_supervision_ms = t_supervision_ms
79 self._clock = clock
80 self._rng = rng
81 self._sessions = {} # train_id -> SafSession
82 self._queues = {} # train_id -> [pdu, ...] waiting for the handshake
83 self._recent = {} # train_id -> deque of (sent_ms, pdu) sent in DATA, for a resend after a Sa-DISCONNECT
84 self._resend_window_ms = resend_window_ms
85
86 def __getattr__(self, name):
87 # sockets / disconnect / connected_sites and anything else belong to the wrapped transport
88 return getattr(self._inner, name)
89
90 # -- transport surface -------------------------------------------------------------------------------------
93 self._supervise()
94
95 def send_all(self, payload):
96 if len(payload) < 5 or payload[0] != ERTMS_KIND:
97 self._inner.send_all(payload)
98 return
99 tid = payload[1]
100 codec = bytes(payload[2:])
101 l_message = int.from_bytes(codec[1:3], "big")
102 if l_message == 0 or l_message > len(codec):
103 self._log.warning("[SAF] [Train %d] ERTMS frame with bad l_message=%d not sent", tid, l_message)
104 return
105 pdu = codec[:l_message]
106 sess = self._sessions.get(tid)
107 if sess is not None and sess.state == saf.DATA:
108 self._send_pdu(tid, sess, pdu)
109 return
110 self._queues.setdefault(tid, []).append(pdu)
111 if sess is None or sess.state == saf.IDLE:
112 self._start_handshake(tid)
113
114 def poll_recv(self, timeout=0.0):
115 for site, data in self._inner.poll_recv(timeout):
116 if not data or data[0] != SAF_KIND:
117 yield site, data
118 continue
119 decoded = decode_envelope(data)
120 if decoded is None:
121 self._log.warning("[SAF] malformed SaF envelope from %s dropped", site)
122 continue
123 tid, frame = decoded
124 pdu = self._on_frame(tid, frame)
125 if pdu is not None:
126 env = bytes([ERTMS_KIND, tid]) + pdu
127 yield site, env + bytes(max(0, self._wire_size - len(env)))
128
129 # -- internals ----------------------------------------------------------------------------------------------
130 def _new_session(self, tid):
131 kmac = self._keys.get(tid)
132 if kmac is None:
133 return None
134 kwargs = {"ety": ETY_ENGINE, "t_connect_ms": self._t_connect_ms, "t_supervision_ms": self._t_supervision_ms}
135 if self._clock is not None:
136 kwargs["clock"] = self._clock
137 if self._rng is not None:
138 kwargs["rng"] = self._rng
139 return saf.SafSession("initiator", tid, self._rbc_id, kmac, self._raw_send_unused, self._raw_recv_unused,
140 **kwargs)
141
142 @staticmethod
143 def _raw_send_unused(_frame):
144 raise RuntimeError("frame-driven session: frames are sent by SafTrainLink")
145
146 @staticmethod
148 raise TimeoutError
149
150 def _send_frame(self, tid, frame):
151 self._inner.send_all(encode_envelope(tid, frame, self._wire_size))
152
153 def _now_ms(self):
154 return self._clock() if self._clock is not None else int(time.monotonic() * 1000)
155
156 def _send_pdu(self, tid, sess, pdu):
157 self._send_frame(tid, sess.build_frame(pdu))
158 sess.seq_tx = (sess.seq_tx + 1) & 0xFFFFFFFF
159 recent = self._recent.setdefault(tid, collections.deque())
160 now = self._now_ms()
161 recent.append((now, pdu))
162 while recent and now - recent[0][0] > self._resend_window_ms:
163 recent.popleft()
164
165 def _start_handshake(self, tid):
166 sess = self._new_session(tid)
167 if sess is None:
168 self._log.error("[SAF] [Train %d] no KMAC for ETCS id %d - ERTMS traffic not sent", tid, tid)
169 self._queues.pop(tid, None)
170 return
171 self._sessions[tid] = sess
172 self._send_frame(tid, sess.initiator_start())
173 self._log.info("[SAF] [Train %d] AU1 sent (rbc_etcs_id=%d)", tid, self._rbc_id)
174
175 def _on_frame(self, tid, frame):
176 sess = self._sessions.get(tid)
177 if sess is None:
178 self._log.warning("[SAF] [Train %d] SaF frame with no session dropped", tid)
179 return None
180 try:
181 if sess.state in (saf.WAIT_AU2, saf.WAIT_AR):
182 reply = sess.initiator_on_frame(frame)
183 if reply is not None:
184 self._send_frame(tid, reply)
185 if sess.state == saf.DATA:
186 self._log.info("[SAF] [Train %d] safe connection established", tid)
187 for pdu in self._queues.pop(tid, []):
188 self._send_pdu(tid, sess, pdu)
189 return None
190 return sess.accept_frame(frame)
191 except saf.SafError as err:
192 if err.status == "PEER_DISCONNECT":
193 now = self._now_ms()
194 lost = [pdu for sent, pdu in self._recent.pop(tid, ()) if now - sent <= self._resend_window_ms]
195 self._queues[tid] = lost + self._queues.get(tid, [])
196 self._log.info("[SAF] [Train %d] released by the RBC - reconnecting, %d recent message(s) queued "
197 "for resend", tid, len(lost))
198 else:
199 self._log.warning("[SAF] [Train %d] connection dropped: %s", tid, err)
200 if self._queues.get(tid):
201 self._start_handshake(tid)
202 return None
203
204 def _supervise(self):
205 for tid, sess in list(self._sessions.items()):
206 if sess.handshake_expired():
207 self._log.warning("[SAF] [Train %d] handshake timed out - retrying", tid)
208 sess.state = saf.IDLE
209 if self._queues.get(tid):
210 self._start_handshake(tid)
211 elif sess.state == saf.DATA:
212 try:
213 sess.supervise()
214 except saf.SafError:
215 self._log.warning("[SAF] [Train %d] supervision expired - link lost, will re-handshake", tid)