SimCore
Shared transport-layer plumbing for the ADR-029 Train/IL/CTC RBC simulators
Loading...
Searching...
No Matches
safecomm.py
Go to the documentation of this file.
1"""SafeCommLink - the CommServer-facing client link for a sim (train).
2
3This is the Python client side of the safe-communication path introduced
4with the C ``safeCommFreamwork`` / ``CommServer`` product:
5
6 TrainSim --(this module, plain TCP)--> CommServer --> RBC role-C gateway
7
8CommServer owns the WEST/EAST dual-homing, so from the sim's point of view
9there is a single logical link. It is a drop-in alternative to
10``simcore.dual_link.DualHomedLink`` - same ``ensure_connected()`` /
11``send_all()`` / ``poll_recv()`` surface - selected by ``train_sim`` when
12the config carries a ``commServer`` block.
13
14Stub, matching the C side: the Safety Functional Module (authentication,
15per-message MAC, sequence numbering - ``safecomm::safety``) is
16NOT_IMPLEMENTED, so this link is a transparent byte pipe with fixed
17``message_size`` framing. When the SaF PDU layout lands in
18``safeCommFreamwork`` this module gets the matching wrapper/unwrapper,
19kept byte-identical the same way ``simcore.rbc_wire`` mirrors the C codec.
20"""
21
22import socket
23import time
24
25# CommServer hides which real site (WEST/EAST) answered - it owns the
26# dual-homing. The sim's per-site bookkeeping (was_connected, log tags,
27# `for site in SITES`) still expects a site name, so this link presents
28# everything under the WEST slot and leaves EAST permanently unused.
29_SITE = "WEST"
30_UNUSED_SITE = "EAST"
31
32# Reconnect policy: this is a CLIENT link - if CommServer restarts, its
33# host goes away, or the stream errors, the sim must recover on its own.
34# ensure_connected() is called once per sim cycle; it makes at most ONE
35# non-blocking connect() attempt per call and never spins/sleeps inside a
36# single call (a blocking retry loop here stalled the whole sim - control
37# port, status, other trains' M136 - whenever CommServer was down).
38_CONNECT_TIMEOUT_S = 0.75 # bound one connect() attempt
39_RECONNECT_MIN_INTERVAL_S = 1.0 # don't hammer connect() every ~0.3 s cycle
40
41
43 """Single TCP link to a CommServer, framed as fixed-size messages.
44 Auto-reconnecting: any send/recv error drops the socket, and the next
45 ensure_connected() re-dials (rate-limited)."""
46
47 def __init__(self, log, host, port, message_size):
48 self._log = log
49 self._host = host
50 self._port = int(port)
51 self.message_size = int(message_size)
52 self._sock = None
53 self._rx = bytearray()
54 self._last_attempt = 0.0
55 self._ever_connected = False
56
57 # -- lifecycle --------------------------------------------------------
59 """One non-blocking (re)connect attempt, rate-limited. Safe to call
60 every cycle - a no-op once connected. Returns True if connected."""
61 if self._sock is not None:
62 return True
63 now = time.monotonic()
64 if (now - self._last_attempt) < _RECONNECT_MIN_INTERVAL_S:
65 return False
66 self._last_attempt = now
67 try:
68 s = socket.create_connection((self._host, self._port), timeout=_CONNECT_TIMEOUT_S)
69 s.setblocking(False)
70 try:
71 s.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
72 s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
73 except OSError:
74 pass
75 self._sock = s
76 self._rx.clear()
77 level = self._log.info if not self._ever_connected else self._log.warning
78 level("SafeCommLink %s to CommServer %s:%d",
79 "connected" if not self._ever_connected else "RECONNECTED",
80 self._host, self._port)
81 self._ever_connected = True
82 return True
83 except OSError as exc:
84 # DEBUG, not WARNING: this fires every retry interval while
85 # CommServer is down and would otherwise flood the log.
86 self._log.debug("SafeCommLink connect %s:%d failed (%s) - will retry",
87 self._host, self._port, exc)
88 return False
89
90 @property
91 def sockets(self):
92 """DualHomedLink-compatible view: WEST slot carries the one TCP
93 socket, EAST slot is always None."""
94 return {_SITE: self._sock, _UNUSED_SITE: None}
95
96 def connected_sites(self):
97 return [_SITE] if self._sock is not None else []
98
99 def disconnect(self, site=None): # noqa: ARG002 - parity with DualHomedLink
100 self._close()
101
102 def _close(self, site=None): # noqa: ARG002
103 if self._sock is not None:
104 try:
105 self._sock.close()
106 except OSError:
107 pass
108 self._sock = None
109 self._rx.clear()
110
111 # -- I/O ------------------------------------------------------------
112 def _frame(self, payload):
113 b = bytes(payload)
114 if len(b) < self.message_size:
115 b = b + b"\x00" * (self.message_size - len(b))
116 elif len(b) > self.message_size:
117 b = b[: self.message_size]
118 return b
119
120 def send_all(self, payload):
121 """Send one message to the CommServer (padded/truncated to
122 ``message_size``). Silently drops if not connected - the caller's
123 own ``ensure_connected()`` loop re-establishes."""
124 if self._sock is None:
125 return
126 try:
127 self._sock.sendall(self._frame(payload))
128 except OSError as exc:
129 self._log.warning("SafeCommLink send failed (%s) - dropping link", exc)
130 self._close()
131
132 def poll_recv(self, timeout=0.0):
133 """Yield ``(site, message_bytes)`` for every whole framed message
134 available within ``timeout`` seconds."""
135 if self._sock is None:
136 return
137 end = time.monotonic() + max(0.0, timeout)
138 first = True
139 while first or time.monotonic() < end:
140 first = False
141 try:
142 chunk = self._sock.recv(4096)
143 except BlockingIOError:
144 if timeout <= 0.0:
145 break
146 time.sleep(0.005)
147 continue
148 except OSError as exc:
149 self._log.warning("SafeCommLink recv failed (%s) - dropping link", exc)
150 self._close()
151 return
152 if not chunk:
153 self._log.warning("SafeCommLink: CommServer closed the connection")
154 self._close()
155 return
156 self._rx.extend(chunk)
157 while len(self._rx) >= self.message_size:
158 msg = bytes(self._rx[: self.message_size])
159 del self._rx[: self.message_size]
160 yield _SITE, msg
161
162
163def link_from_config(log, config, message_size, sim_config):
164 """Return a SafeCommLink if the config has a ``commServer`` block,
165 else None (caller falls back to DualHomedLink)."""
166 block = config.get("commServer") if isinstance(config, dict) else None
167 if not block:
168 return None
169 host = sim_config.require(config, "commServer", "host")
170 port = sim_config.require(config, "commServer", "port")
171 return SafeCommLink(log, host, port, message_size)
link_from_config(log, config, message_size, sim_config)
Definition safecomm.py:163