TrainRBCSim
ADR-029 Train (EVC) simulator — dual-homed TCP client speaking the RBC wire protocol
Loading...
Searching...
No Matches
train_sim.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""Train sim (ADR-029) - a real, dual-homed UDP client. Connects to BOTH
3sites' C (c-west and c-east) at once and keeps both sockets open for the
4whole run, so a site failover is invisible from this process's own point
5of view (see simcore.dual_link's own header) - it sends its own
6P0/136/146 to both sites unconditionally and simply acts on whichever
7site actually answers (the STANDBY site's own C has nothing to forward
8back, since its A/B pair isn't computing decisions - see monitor_c.c).
9
10Message names/keys are this project's own (message_catalog.json, right
11next to this script) - real Subset-026 message numbers where a real
12counterpart exists ("3" = real message 3 Movement Authority, "146" =
13real message 146 Acknowledgement - see that file's own
14"_subset026Reference" for the real spec text), and clearly-labeled
15project-specific names where none does ("P0" handshake -
16neither corresponds to any real Subset-026 message). These are still
17encoded with today's simplified flat fields, not the real nested
18Subset-026 packet structure - see message_catalog.json's own per-message
19comments and root CLAUDE.md's Subset-026 note for what that gap actually
20is. Msg 136 (Train Position Report) is the one exception, switched this
21session on direct request: this sim's autonomous cyclic reporting AND
22the REPORT/sendTrainMessage commands all send the REAL Subset-026 Msg
23136/Packet 0 now (rbc_ertms_wire.py's own "M136" encoder, "ertms"
24wireFormat via message_catalog.json's implicit "M"-prefixed-name
25default), not a flat kind - see send_position_report()'s own doc.
26
27Configuration: one JSON file (common/sim_config.py, SIM_CONFIG_PATH env
28var, default /app/config.json) supplies everything instance-specific -
29trainIndex (0 or 1, selects which of this project's
30SAFEAPI_EXAMPLE_MAX_TRAINS train slots this instance is - train_id is
31trainIndex + 1), siteTag (display-only, kept for log-prefix consistency
32with every other role - not a site selector, this sim talks to both
33sites regardless), rbcWest/rbcEast (host+port to reach each site's C),
34controlServer (bindHost+port for the command channel below), and
35simPeriodSeconds. See sims/train/config/*.json for the actual files and
36docker-compose.yml's own `volumes:` entries for how each container gets
37its own. Message vocabulary (which messages/fields this sim may send or
38receive) comes from message_catalog.json, right next to this script -
39safeAPITestEnv/doc/design/SIM_INTERFACES.md documents the same table in
40prose.
41
42Train lifecycle is test-managed, not process-managed: the TCP transport
43itself connects (and reconnects-forever, dual_link.py's own convention)
44the moment this process starts, same as always, but NEITHER the P0
45handshake NOR the autonomous cyclic "136" position reporting fires until
46a test explicitly connects this train (CONNECT / the connectTrain JSON
47command below) - a test that never connects a given train leaves it
48genuinely absent from the RBC (ab_gp_train.c's own RBC_MSG_P0/
49RBC_MSG_M136 handling never creates that train's session at all), not
50just quiet on this sim's own side. Once connected, position reporting
51keeps running autonomously (a real train reports position continuously,
52not on command) until disconnectTrain (below) - a test harness
53(safeAPITestEnv/robot/) can also drive specific position reports on
54demand via the command channel (common/control_server.py) regardless of
55connection state, e.g. to deterministically hit the D_LRBG single-
56channel-fault value without waiting on the random walk.
57
58Commands (controlServer.port, config file - see common/control_server.py):
59 REPORT <d_lrbg> Sends one message "136" (position report) immediately
60 with the given position (does not wait for the next
61 autonomous tick; also resets the autonomous random
62 walk to continue from this value). Works regardless
63 of connection state (an explicit test-driven send,
64 not the autonomous loop) - a test using this without
65 ever connecting the train is exercising the RBC's own
66 documented lenient "M136 arriving before/without a
67 P0" tolerance. Replies "OK\\n".
68 PING Replies "PONG\\n".
69 CONNECT Explicitly connects this train to the RBC: sends P0
70 now (not waiting for the next reconnect edge) and
71 starts autonomous "136" reporting. Replies "OK\\n".
72 See the structured connectTrain/disconnectTrain
73 commands below for the nidEngine-explicit,
74 multi-train-aware equivalents this file's own test
75 suite actually uses.
76 DISCONNECT Closes BOTH site connections right now (simulating
77 the train losing radio contact with the RBC
78 entirely) - reconnects automatically afterward, same
79 as any other transport-level disconnect. Deliberately
80 does NOT touch connected_to_rbc or send
81 RBC_MSG_TRAIN_DISCONNECT - this simulates losing
82 radio contact, not a formal disconnect; the RBC-side
83 session is untouched and autonomous reporting resumes
84 the moment the socket reconnects. Replies "OK\\n".
85 DISCONNECTWITHONLINE
86 Closes the connection to whichever site this sim
87 currently believes is ONLINE (see "online-site
88 tracking" below) - simulating losing contact with
89 the active RBC specifically. Replies "OK\\n", or
90 "ERR <reason>\\n" if no site has been heard from yet
91 (nothing to call "online").
92 DISCONNECTWITHSTANDBY
93 Same as DISCONNECTWITHONLINE but for the OTHER site
94 (whichever this sim does NOT currently believe is
95 ONLINE) - simulating losing contact with the standby
96 RBC specifically, while the active one stays
97 reachable. Same ERR case as DISCONNECTWITHONLINE.
98
99Online-site tracking: this sim has no explicit ONLINE/STANDBY signal
100from the wire (the RBC side doesn't tell a train which of A/B/site is
101currently active) - "online" here is inferred as whichever site most
102recently sent this sim ANY decodable message (updated in the main
103receive loop, below), the same de-facto signal the sim already uses
104operationally (only the ONLINE site's own C ever answers - this file's
105own header). Purely a heuristic for the two DISCONNECTWITH* commands
106above; not itself sent anywhere or asserted on by anything else.
107
108Structured JSON commands (safeAPITestEnv/doc/design/DESIGN.md,
109SIM_INTERFACES.md - same port, see common/control_server.py's own doc
110for how the two command styles share one line-based channel):
111 sendMessage {"cmd":"sendMessage","nidEngine":N,"message":"136","fields":{...}}
112 Sends one message NOW (any message this train can
113 originate - P0/136/146, message_catalog.json) with the
114 given fields; unset fields default to 0. Does NOT touch
115 the autonomous cycle counter/random-walk state the way
116 REPORT does - a purely explicit, one-shot send.
117 getMessage {"cmd":"getMessage","nidEngine":N,"message":"3"}
118 Returns the most recently received message of that kind
119 for that nidEngine ("PositionReportAck" or "3" - the
120 only kinds this sim ever receives), or an ERR if none
121 has arrived yet.
122 connectTrain {"cmd":"connectTrain","nidEngine":N,"nidLrbg":M,"dLrbg":D}
123 Explicitly connects train N to the RBC: sends P0 now and
124 starts this train's autonomous "136" reporting (see this
125 file's own "Train lifecycle" paragraph above) - the
126 nidEngine-explicit structured equivalent of the plain
127 CONNECT command, and what safeAPITestEnv/robot/'s own
128 "Connect Train" keyword actually calls. "nidLrbg"/"dLrbg"
129 are both optional (either, neither, or both) - override
130 this train's starting balise/position instead of
131 _default_lrbg()'s own west/east heuristic, applied
132 before the first real "136" - see handle_connect_train()'s
133 own doc for why both state["position_m"] and
134 train_vars["d_lrbg"] get set. Replies {"status":"OK"}.
135 disconnectTrain
136 {"cmd":"disconnectTrain","nidEngine":N}
137 Sends RBC_MSG_TRAIN_DISCONNECT (rbc_wire_types.h) for
138 train N and stops its autonomous reporting - the RBC-side
139 session is actually removed (ab_gp_train.c releases every
140 route the train held, then removes it), not just starved
141 of traffic. Does NOT close the TCP transport (see
142 DISCONNECT above for that, a different, radio-silence
143 concept) - "Disconnect Train" in the Robot library.
144 Replies {"status":"OK"}.
145 setTrainVariable
146 {"cmd":"setTrainVariable","nidEngine":N,"variable":"d_lrbg","value":123}
147 Stores one SS026-style field for this train (nid_lrbg/
148 d_lrbg/v_train/m_mode/m_level), persisting until
149 overwritten - sendTrainMessage (below) pulls whatever a
150 given message's own catalog entry declares out of this
151 store at send time. Replies {"status":"OK"}.
152 sendTrainMessage
153 {"cmd":"sendTrainMessage","nidEngine":N,"message":"136"}
154 Sends message N NOW, built from whatever setTrainVariable
155 has stored for this train (message_catalog.json's own
156 field list for that message) - an explicit, one-shot send
157 like sendMessage above, but from the persistent variable
158 store rather than an inline fields dict. Works regardless
159 of connection state, same as REPORT/sendMessage. Replies
160 {"status":"OK"} or {"status":"ERR","reason":"..."}.
161"""
162
163import logging
164import os
165import random
166import signal
167import sys
168import time
169
170from simcore import sim_config
171from simcore.control_server import ControlServer
172from simcore.dual_link import SITES, DualHomedLink
173from simcore.safecomm import link_from_config as _safecomm_link_from_config
174from simcore.rbc_messages import MessageCatalog
175from simcore.status_report import StatusReporter
176
177from . import rbc_ertms_wire as ertms_codec
178from . import ertms_adapters
179from . import site_data
180
181CATALOG = MessageCatalog(
182 os.path.join(os.path.dirname(os.path.abspath(__file__)), "message_catalog.json"),
183 ertms_codec=ertms_codec,
184 ertms_encoders=ertms_adapters.ENCODERS,
185 ertms_flatteners=ertms_adapters.FLATTENERS,
186)
187
188# RBC_TRAIN_ENVELOPE_WIRE_SIZE (common_config.h) - the Train relay
189# channel's own physical slot size, wider than the flat scheme's own
190# ENVELOPE_SIZE (96) to also fit a real Subset-026 ertms message
191# (RBC_MSG_ERTMS_ENVELOPE, rbc_wire_types.h). GP's own receive side
192# requires an EXACT match to whatever it requested to read (a genuine
193# wire-protocol-violation check, not framing trivia - see
194# sapi_posix_backend_netlink.c's own backend_read()), so EVERY send on
195# this channel - flat scheme included, not just the new ertms one - must
196# be padded to this exact size, not just the "real" content length.
197TRAIN_WIRE_SIZE = 512
198
199
200def _pad(frame):
201 """Zero-pads @frame up to TRAIN_WIRE_SIZE - both wire families
202 already put their own real length in their own header (l_message),
203 so the receiving side reads exactly the meaningful part regardless
204 of this trailing padding; this padding exists purely to satisfy the
205 transport's own exact-datagram-size expectation on this channel."""
206 if len(frame) > TRAIN_WIRE_SIZE:
207 raise ValueError(f"encoded frame is {len(frame)} bytes, exceeds TRAIN_WIRE_SIZE={TRAIN_WIRE_SIZE}")
208 return frame + bytes(TRAIN_WIRE_SIZE - len(frame))
209
210
211# This one sim process can host up to this many DISTINCT nid_engine
212# values over its lifetime (a soft capacity guard, not a pre-sized
213# roster - see main()'s own doc on why there is no roster at all now).
214# Purely a sanity bound on handle_connect_train() - nowhere near GP's
215# own current SAFEAPI_EXAMPLE_MAX_TRAINS=2U (common_config.h); this sim
216# is deliberately more permissive than today's RBC-side cap, so it's
217# never itself the limiting factor.
218MAX_TRAINS = 100
219
220
222 """A train's own starting LRBG before any real position report has
223 set one - purely a plausible default so an early sendTrainMessage/136
224 isn't sending nid_lrbg=0. Same "train 1 near the west end, everything
225 else near the east end" heuristic this file always used, just now a
226 per-train function instead of a single value precomputed from a
227 static roster's first entry (there is no roster to read "first
228 entry" from any more)."""
229 return 1001 if tid == 1 else 1009
230
231
232def main():
233 config = sim_config.load()
234 site_tag = sim_config.require(config, "siteTag")
235 period = float(config.get("simPeriodSeconds", 2.0))
236 control_host = sim_config.require(config, "controlServer", "bindHost")
237 control_port = sim_config.require(config, "controlServer", "port")
238
239 # "[%(levelname)s] " prefix on every line - on direct request: the
240 # test env's own merged-log/live-stream listener
241 # (ContainerLogAttachListener.py) used to have to GUESS a level for
242 # every line (defaulting to INFO when none was detectable), since
243 # this format string used to strip Python's own real level
244 # information entirely before it ever reached stdout/the log file -
245 # log.warning()/log.error() calls were textually indistinguishable
246 # from log.info() ones. This emits the sim's own REAL level as data,
247 # collected (not reconstructed) by the test env from here on.
248 logging.basicConfig(level=logging.INFO, format="[%(levelname)s] %(message)s", stream=sys.stdout)
249 log = logging.getLogger("train")
250
251 # A "commServer" config block routes the RBC link through the
252 # CommServer access-point (safeCommFreamwork path): one TCP link, with
253 # CommServer owning the WEST/EAST dual-homing. Absent -> the direct
254 # dual-homed UDP link to each site's role-C gateway, as before.
255 link = _safecomm_link_from_config(log, config, TRAIN_WIRE_SIZE, sim_config)
256 if link is not None:
257 log.info("train link: via CommServer (safeCommFreamwork path)")
258 else:
259 targets = {
260 "WEST": (sim_config.require(config, "rbcWest", "host"), sim_config.require(config, "rbcWest", "port")),
261 "EAST": (sim_config.require(config, "rbcEast", "host"), sim_config.require(config, "rbcEast", "port")),
262 }
263 link = DualHomedLink(log, targets, TRAIN_WIRE_SIZE)
264 was_connected = {site: False for site in SITES}
265 # Per-train autonomous-walk state (ADR-036: one process, many trains -
266 # up to MAX_TRAINS). Deliberately starts EMPTY, not pre-sized from any
267 # config roster: this process is connected to the RBC (transport-wise)
268 # from the moment it starts, but hosts NO train until a test explicitly
269 # names one via connectTrain - "no default trains" (per direct
270 # instruction) - a train's own state is created lazily, the first time
271 # it's actually named, by _ensure_train() below.
272 state = {}
273 online_site = {"value": None} # heuristic - see this file's own "Online-site tracking" doc above
274
275 # Train lifecycle is test-managed, not process-managed: this TCP
276 # transport connects (and reconnects-forever) on its own the moment
277 # the process starts, same as always, but NEITHER the P0 handshake
278 # NOR the autonomous cyclic M136 reporting fires until an explicit
279 # connect (CONNECT / connectTrain, below) sets this true - a test
280 # that never connects this train leaves it genuinely absent from the
281 # RBC (no session ever created there - see ab_gp_train.c's own
282 # RBC_MSG_P0/RBC_MSG_M136 handling), not just quiet on this sim's
283 # own side. disconnectTrain (below) sets it back to false AND sends
284 # RBC_MSG_TRAIN_DISCONNECT so the RBC-side session is actually
285 # removed, not just starved of further traffic.
286 # Per-train connect state (nid_engine -> bool). Was a single shared
287 # {"value": bool} until this fix - found live via a real symptom: a
288 # test that only ever calls `Connect Train ${TRAIN_1_NID_ENGINE}`
289 # (e.g. 12_ertms_shunting_mode_procedure.robot) was ALSO producing
290 # autonomous M136 traffic for train 2, because activeTrains=2 means
291 # this one sim process manages BOTH train 1 and train 2 (ADR-036
292 # multiplexing), and the single shared flag being set true by
293 # connecting train 1 made the autonomous-report loop below (which
294 # iterates every train in train_ids) start reporting for train 2 as
295 # well - a train that specific test never connected at all. Symmetric
296 # bug on the other end too: disconnecting train 1 was silently
297 # stopping train 2's reporting as well. This file's own header doc
298 # already documented per-train semantics ("connectTrain: ... starts
299 # THIS train's autonomous reporting") - the implementation just never
300 # actually matched that doc until now.
301 connected_to_rbc = {}
302
303 # Real Subset-026 mission-phase tracking (new, per the "critical
304 # architecture issue" request): "IDLE" (default / after a fresh
305 # connect or M155) means active-mission - the existing 2s Packet-0
306 # cadence below applies. "POST_EOM" (set when this sim itself sends
307 # M150/M156 via handle_send_message) means the train has finished its
308 # mission but stayed transport-connected - a SEPARATE 6s Packet-1
309 # heartbeat cadence applies instead (real Subset-026 Packet 1 is the
310 # degraded/no-longer-actively-tracked LRBG-orientation variant, a
311 # good fit for a stationary train no longer under active MA). Purely
312 # local bookkeeping for logging/cadence-selection - the RBC's own
313 # ab_gp_ertms_session_state_t (ab_gp_proc_dispatch.c's handle_msg_136)
314 # stays the sole authority on what a heartbeat is actually allowed to
315 # do session-state-wise.
316 # Also starts empty (see `state`'s own doc just above) - populated the
317 # same lazy way, and read everywhere via .get(tid, ...) already, so an
318 # unconnected/never-seen train simply behaves as "IDLE" with no
319 # explicit entry needed.
320 ertms_post_eom = {}
321 HEARTBEAT_PERIOD_S = 6.0
322 next_heartbeat = {}
323
324 # Resolved once, not per (train, site) iteration below - and NEVER
325 # falls back to a real ERTMS message name (e.g. "M155"): TRAIN_CONNECT/
326 # P0 are a low-level transport-connect signal, not a session-init
327 # request - those are different messages with different meaning, not
328 # interchangeable names for the same thing. If neither exists in a
329 # catalog, that is a real configuration problem worth failing loudly
330 # on, not silently sending the wrong message instead (found live:
331 # the previous fallback chain would have done exactly that).
332 _connect_msg_name = "TRAIN_CONNECT" if "TRAIN_CONNECT" in CATALOG.message_names() else (
333 "P0" if "P0" in CATALOG.message_names() else None)
334 if _connect_msg_name is None:
335 log.error("[IO] [%s] [internal] [CONFIG] [catalog has neither TRAIN_CONNECT nor P0 - "
336 "train connect handshake cannot be sent]", f"train-{site_tag.lower()}")
337
338 def send_p0_now(target_nid):
339 """Sends the connect handshake for exactly ONE train, @target_nid -
340 no implicit "every train" fan-out any more (there is no roster to
341 fan out over - see main()'s own "no default trains" doc). The
342 legacy plain CONNECT command (handle_connect below) now passes a
343 single hardcoded nid_engine explicitly, same as connectTrain
344 already did."""
345 if _connect_msg_name is None:
346 return
347 for site in SITES:
348 s = link.sockets[site]
349 if s is not None:
350 try:
351 s.sendall(_pad(CATALOG.build_envelope(_connect_msg_name, target_nid, {})))
352 log.info("[IO] [%s] [c-%s] [%s] [ERTMS Train connection handshake sent] [train_id=%d]",
353 f"train-{site_tag.lower()}", site.lower(), _connect_msg_name, target_nid)
354 except OSError:
355 pass
356
357 # Most-recently-received message per (nid_engine, message name) -
358 # safeAPITestEnv/doc/design/DESIGN.md section 4.2. This sim only ever
359 # sees its own train_id on the wire, so nid_engine here is always
360 # train_id, but keyed by it anyway (not a bare dict-by-message) for
361 # the same forward-looking reason SIM_INTERFACES.md gives for
362 # getMessage always taking nidEngine explicitly.
363 last_received = {}
364
365 # Per-train SS026-variable store, {nid_engine: {field_name: value}} -
366 # persists across calls until a test overwrites it (setTrainVariable),
367 # NOT cleared after a send the way the old Set Message/Set Packet
368 # builder pattern was. sendTrainMessage compiles a message by pulling
369 # whatever fields THAT message's own catalog entry declares
370 # (CATALOG.fields_for()) out of this store at send time - a variable
371 # nobody has set yet is simply absent (build_envelope() defaults an
372 # unset field to 0, same as the plain sendMessage command already
373 # does). Field names here are this catalog's own (message_catalog.json
374 # "fields" keys, e.g. "d_lrbg"/"cycle") - the same simplified,
375 # flat-encodable vocabulary "messages" uses, not yet the real SS026
376 # UPPER_CASE variable names in "_subset026Reference" (those aren't
377 # encodable on today's wire protocol at all - see that key's own doc).
378 train_vars = {}
379
380 def _reset_train(tid):
381 state[tid] = {"position_m": 0, "cycle": 0}
382 train_vars[tid] = {
383 "nid_lrbg": _default_lrbg(tid),
384 "d_lrbg": 0,
385 "v_train": 0,
386 "m_mode": 0,
387 "m_level": 2,
388 }
389 last_received.pop(tid, None)
390
391 def _ensure_train(tid):
392 """Lazily creates @tid's own state/train_vars entries the first
393 time it's actually named (connectTrain, or the legacy REPORT/
394 CONNECT commands) - see main()'s own "no default trains" doc.
395 Idempotent - safe to call on every touch, not just the first."""
396 state.setdefault(tid, {"position_m": 0, "cycle": 0})
397 train_vars.setdefault(tid, {
398 "nid_lrbg": _default_lrbg(tid),
399 "d_lrbg": 0,
400 "v_train": 0,
401 "m_mode": 0,
402 "m_level": 2,
403 })
404
405 # Read fresh from the real RailML file (site_data.py) rather than
406 # hardcoded here - was, by direct comparison, a byte-for-byte
407 # duplicate of that file's own <baliseGroup> data. See site_data.py's
408 # own doc for the fallback/logging posture if the file can't be read
409 # (e.g. a Docker image without the sibling safeAPIRBC2oo2SA checkout).
410 BALISE_NAMES = site_data.balise_names()
411
412 def get_balise_name(nid):
413 if not nid:
414 return ""
415 return BALISE_NAMES.get(int(nid), f"BG_{nid}")
416
417 def send_position_report(tid, d_lrbg, nid_lrbg=None):
418 """Sends this train's cyclic position report as a REAL Subset-026
419 Msg 136 / Packet 0 (CATALOG's own "M136" ertms entry -
420 ab_gp_ertms_train_message_codec.c on the RBC side), not the old
421 flat scheme's own "136" kind - switched this session, on direct
422 request, for every caller of this shared function (the autonomous
423 cyclic loop below AND the REPORT/handle_report command), so there
424 is exactly one M136-sending code path, not two. GP's own real
425 Msg 136 handler (handle_msg_136(), ab_gp_proc_dispatch.c) already
426 works standalone (no prior Msg 155 session handshake required) and
427 already replies with a real Msg 24 - see that function's own doc.
428 "cycle" stays purely local bookkeeping (this sim's own REPORT-
429 command/logging counter) - real Msg 136 has no counter field of
430 its own to carry it on the wire (message_catalog.json's own "M136"
431 reference entry doc: "cycle... is a project-invented counter with
432 no real-message-136 equivalent")."""
433 _ensure_train(tid)
434 st = state[tid]
435 st["cycle"] += 1
436 st["position_m"] = d_lrbg
437 vars_for_train = train_vars.setdefault(tid, {})
438 vars_for_train["d_lrbg"] = d_lrbg
439 if nid_lrbg is not None:
440 vars_for_train["nid_lrbg"] = nid_lrbg
441
442 cur_lrbg = vars_for_train.get("nid_lrbg", _default_lrbg(tid))
443 cur_v = vars_for_train.get("v_train", 0)
444 cur_mode = vars_for_train.get("m_mode", 0)
445 cur_level = vars_for_train.get("m_level", 2)
446
447 # Full real Packet 0 field set (ertms_adapters.py's own
448 # _position_report_from_fields() supplies the same defaults for
449 # q_scale/q_dirlrbg/q_dlrbg/l_doubtover/l_doubtunder when a
450 # sendMessage/sendTrainMessage command builds M136 instead of this
451 # autonomous path) - nid_c defaults to 0 (this sim never models a
452 # real multi-country NID_C), nid_bg is this sim's own flat
453 # "nid_lrbg" balise-ID convention.
454 wire_fields = {
455 "q_scale": vars_for_train.get("q_scale", ertms_codec.Q_SCALE_1M),
456 "nid_c": vars_for_train.get("nid_c", 0),
457 "nid_bg": cur_lrbg,
458 "d_lrbg": d_lrbg,
459 "q_dirlrbg": vars_for_train.get("q_dirlrbg", ertms_codec.Q_DIRLRBG_NOMINAL),
460 "q_dlrbg": vars_for_train.get("q_dlrbg", ertms_codec.Q_DLRBG_NOMINAL),
461 "l_doubtover": vars_for_train.get("l_doubtover", 0),
462 "l_doubtunder": vars_for_train.get("l_doubtunder", 0),
463 "v_train": cur_v,
464 "m_mode": cur_mode,
465 "m_level": cur_level,
466 }
467 link.send_all(_pad(CATALOG.build_envelope("M136", tid, wire_fields)))
468
469 # Returned for this function's own two callers (REPORT/handle_report
470 # and the cyclic loop) to log - "cycle"/"nid_lrbg" kept as the
471 # names those callers/get_balise_name() already expect, alongside
472 # every real wire field actually sent.
473 payload = dict(wire_fields)
474 payload["cycle"] = st["cycle"]
475 payload["nid_lrbg"] = cur_lrbg
476 return payload
477
478 def handle_report(args):
479 # Legacy, non-nidEngine-aware command - always targets train 1
480 # (there is no roster/"first train" any more to derive this from;
481 # see main()'s own "no default trains" doc). Use REPORT's own
482 # structured equivalent, sendTrainMessage with an explicit
483 # nidEngine, for anything beyond train 1.
484 tid = 1
485 d_lrbg = int(args[0])
486 nid_lrbg = int(args[1]) if len(args) > 1 else None
487 payload = send_position_report(tid, d_lrbg, nid_lrbg)
488 fields_str = " ".join(f"{k}={v}" for k, v in sorted(payload.items()))
489 bname = get_balise_name(payload.get("nid_lrbg"))
490 balise_tag = f" balise={bname}" if bname else ""
491 log.info("[IO] [%s] [c-west,c-east] [M136] [ERTMS Train position report commanded] "
492 "[train_id=%d] [P0] [%s%s]",
493 f"train-{site_tag.lower()}", tid, fields_str, balise_tag)
494 return "OK\n"
495
496 def handle_ping(_args):
497 return "PONG\n"
498
499 def handle_connect(_args):
500 # Legacy, non-nidEngine-aware command - connects train 1 only
501 # (there is no roster to fan out over any more; see main()'s own
502 # "no default trains" doc). Use connectTrain for any other train.
503 tid = 1
504 _ensure_train(tid)
505 connected_to_rbc[tid] = True
506 link.ensure_connected()
507 send_p0_now(tid)
508 return "OK\n"
509
510 def handle_disconnect(_args):
511 for site in SITES:
512 link.disconnect(site)
513 return "OK\n"
514
515 def handle_disconnect_with_online(_args):
516 site = online_site["value"]
517 if site is None:
518 return "ERR no site currently believed online\n"
519 link.disconnect(site)
520 return "OK\n"
521
522 def handle_disconnect_with_standby(_args):
523 site = online_site["value"]
524 if site is None:
525 return "ERR no site currently believed online\n"
526 standby = "EAST" if site == "WEST" else "WEST"
527 link.disconnect(standby)
528 return "OK\n"
529
530 def handle_send_message(request):
531 nid_engine = request["nidEngine"]
532 message = request["message"]
533 fields = request.get("fields", {})
534 link.send_all(_pad(CATALOG.build_envelope(message, nid_engine, fields)))
535 fields_str = " ".join(f"{k}={v}" for k, v in sorted(fields.items()))
536 log.info("[IO] [%s] [c-west,c-east] [%s] [ERTMS message sent] [train_id=%d %s]",
537 f"train-{site_tag.lower()}", message, nid_engine, fields_str)
538 # Real mission-phase tracking (see this file's own ertms_post_eom
539 # doc): M150 (End of Mission) or M156 (Terminate Session) flip this
540 # train to the post-EoM 6s Packet-1 heartbeat cadence; M155
541 # (Initiate Session) - a fresh mission starting - flips it back.
542 if message in ("M150", "M156"):
543 ertms_post_eom[nid_engine] = True
544 next_heartbeat[nid_engine] = time.monotonic() + HEARTBEAT_PERIOD_S
545 elif message == "M155":
546 ertms_post_eom[nid_engine] = False
547 return {"status": "OK"}
548
549 def handle_get_message(request):
550 nid_engine = request["nidEngine"]
551 message = str(request["message"])
552 packets = last_received.get(nid_engine, {}).get(message)
553 # "24"/"15"/"M15" are legacy bridges to the flat scheme's own
554 # combined M24_M15 answer (kind=RBC_MSG_M24_M15) - none of them
555 # is a real message_catalog.json name. "M24" deliberately does
556 # NOT get this fallback any more: it is now a real, distinct
557 # ertms wireFormat message name (real Subset-026 Msg 24) that can
558 # genuinely arrive later than this call - falling back to the
559 # flat scheme's own unrelated answer here would make a caller
560 # waiting for the REAL Msg 24 (Wait For Train Message's own
561 # retry loop) see an immediate, wrong "success" instead of
562 # actually waiting for it.
563 if packets is None and message in ("24", "15", "M15"):
564 packets = last_received.get(nid_engine, {}).get("PositionReportAck")
565 if packets is None:
566 return {"status": "ERR", "reason": f"no {message} received yet for nidEngine {nid_engine}"}
567 result_packets = {message: packets}
568 try:
569 for pkt_name in CATALOG.packets_for(message):
570 result_packets[pkt_name] = packets
571 except Exception:
572 pass
573 if "msg_type" in packets:
574 result_packets["PositionReportAck"] = packets
575 result_packets["M24_M15"] = packets
576 result_packets["24"] = packets
577 result_packets[str(packets["msg_type"])] = packets
578 result_packets[f"M{packets['msg_type']}"] = packets
579 return {"status": "OK", "message": message, "packets": result_packets}
580
581 def handle_connect_train(request):
582 nid_engine = request["nidEngine"]
583 # MAX_TRAINS is a capacity guard on DISTINCT trains ever named,
584 # not on currently-connected count - re-connecting an
585 # already-known nid_engine never counts against it.
586 if nid_engine not in state and len(state) >= MAX_TRAINS:
587 return {"status": "ERR",
588 "reason": f"this sim already hosts {MAX_TRAINS} distinct trains (MAX_TRAINS)"}
589 _reset_train(nid_engine)
590 # Optional starting position override, on direct request - a test
591 # that needs a train to start somewhere other than
592 # _default_lrbg()'s own west/east heuristic (e.g. right at a
593 # specific route's own start balise) can now say so explicitly,
594 # instead of connecting then separately calling Set Train
595 # Variable/sendTrainMessage before the first real M136. Both
596 # optional and independent - either, neither, or both. Sets
597 # state[nid_engine]["position_m"] too (not just train_vars),
598 # since that field - not train_vars["d_lrbg"] - is what the
599 # cyclic position-report loop below actually advances from; see
600 # send_position_report()'s own doc for why both need to agree.
601 if "nidLrbg" in request and request["nidLrbg"] is not None:
602 train_vars[nid_engine]["nid_lrbg"] = int(request["nidLrbg"])
603 if "dLrbg" in request and request["dLrbg"] is not None:
604 d_lrbg = int(request["dLrbg"])
605 train_vars[nid_engine]["d_lrbg"] = d_lrbg
606 state[nid_engine]["position_m"] = d_lrbg
607 connected_to_rbc[nid_engine] = True
608 link.ensure_connected()
609 send_p0_now(nid_engine)
610 # A fresh connect always starts a train back in active-mission
611 # phase (not post-EoM) - matches session->in_use being reset fresh
612 # RBC-side on a genuine (re)connect.
613 ertms_post_eom[nid_engine] = False
614 log.info("[GENERAL] [%s] [internal] [CONNECT] [Train connected to RBC] [train_id=%d]",
615 f"train-{site_tag.lower()}", nid_engine)
616 return {"status": "OK"}
617
618 def handle_disconnect_train(request):
619 nid_engine = request["nidEngine"]
620 for site in SITES:
621 s = link.sockets[site]
622 if s is not None:
623 try:
624 s.sendall(_pad(CATALOG.build_envelope("TRAIN_DISCONNECT", nid_engine, {})))
625 except OSError:
626 pass
627 connected_to_rbc[nid_engine] = False
628 ertms_post_eom[nid_engine] = False
629 _reset_train(nid_engine)
630 log.info("[IO] [%s] [c-west,c-east] [TRAIN_DISCONNECT] [ERTMS Train disconnected from RBC] [train_id=%d]",
631 f"train-{site_tag.lower()}", nid_engine)
632 return {"status": "OK"}
633
634 def handle_set_train_variable(request):
635 nid_engine = request["nidEngine"]
636 variable = request["variable"]
637 value = request["value"]
638 train_vars.setdefault(nid_engine, {})[variable] = value
639 # d_lrbg is genuinely two places at once: the variable store here
640 # (what an explicit Send Train Message pulls fields from) AND
641 # state[nid_engine]["position_m"] (what the autonomous cyclic M136
642 # reporter below self-increments from every `period` seconds,
643 # entirely independently of this store). A real bug, found live:
644 # without this, Set Train Variable ... d_lrbg <N> had no visible
645 # effect on the next periodic report at all - the cyclic reporter
646 # just kept incrementing from its own prior position_m, and only
647 # an immediately-following explicit Send Train Message call
648 # (handle_send_train_message()'s own identical position_m write)
649 # ever bridged the two, racing against the next autonomous tick.
650 # Keeping both in sync here means Set Train Variable alone is
651 # enough, same as every other variable.
652 if variable == "d_lrbg":
653 state.setdefault(nid_engine, {"position_m": 0, "cycle": 0})["position_m"] = value
654 log.info("[GENERAL] [%s] [internal] [VAR_STORE] [Train variable set] [train_id=%d %s=%s]",
655 f"train-{site_tag.lower()}", nid_engine, variable, value)
656 return {"status": "OK"}
657
658 def handle_send_train_message(request):
659 nid_engine = request["nidEngine"]
660 message = request["message"]
661 packet = request.get("packet", message)
662 stored = train_vars.get(nid_engine, {})
663 try:
664 # ertms-format messages (M129/M132/...) get the WHOLE stored
665 # var set, unfiltered - a real, found-live bug otherwise:
666 # their own catalog "fields" list is Subset-026 documentation
667 # text ("Train data (Packet type 11)"), not real snake_case
668 # variable names, so filtering against it (the flat-message
669 # path below) always produced an EMPTY fields dict - e.g.
670 # Send Train Message M129 silently sent l_train=0/v_maxtrain=0
671 # regardless of what Set Train Variable had actually stored,
672 # which then failed GP's own train-data validation
673 # (ab_gp_proc_train_data_validate()) silently, leaving
674 # ctx->train_data.valid permanently false and the config-
675 # carrying Msg 24 (Packet 3/57/58) never sent. ertms encoders
676 # already do their own fields.get(key, default) extraction
677 # (ertms_adapters.py), so passing the raw store is safe - it's
678 # exactly what those lambdas expect.
679 if CATALOG.is_ertms(message):
680 fields = dict(stored)
681 else:
682 field_specs = CATALOG.fields_for(message, packet if packet != message else None)
683 fields = {name: stored[name] for name in field_specs if name in stored}
684 except Exception as e:
685 return {"status": "ERR", "reason": str(e)}
686 if message == "136" or message == "PositionReport" or message == "M136":
687 train_st = state.setdefault(nid_engine, {"position_m": 0, "cycle": 0})
688 if "cycle" not in fields:
689 train_st["cycle"] += 1
690 fields["cycle"] = train_st["cycle"]
691 if "nid_lrbg" not in fields:
692 fields["nid_lrbg"] = _default_lrbg(nid_engine)
693 if "d_lrbg" in fields:
694 train_st["position_m"] = fields["d_lrbg"]
695 if message in ("132", "M132", "146", "M146"):
696 last_received.get(nid_engine, {}).pop("M24_CONFIG", None)
697 link.send_all(_pad(CATALOG.build_envelope(message, nid_engine, fields)))
698 fields_str = " ".join(f"{k}={v}" for k, v in sorted(fields.items()))
699 msg_tag = f"M{message}" if message.isdigit() else message
700 log.info("[IO] [%s] [c-west,c-east] [%s] [ERTMS message sent from stored variables] [train_id=%d %s]",
701 f"train-{site_tag.lower()}", msg_tag, nid_engine, fields_str)
702 return {"status": "OK"}
703
704 def handle_clear_messages(_request):
705 last_received.clear()
706 return {"status": "OK"}
707
708 control = ControlServer(
709 log,
710 control_port,
711 {
712 "REPORT": handle_report,
713 "PING": handle_ping,
714 "CONNECT": handle_connect,
715 "DISCONNECT": handle_disconnect,
716 "DISCONNECTWITHONLINE": handle_disconnect_with_online,
717 "DISCONNECTWITHSTANDBY": handle_disconnect_with_standby,
718 },
719 json_handlers={
720 "sendMessage": handle_send_message,
721 "getMessage": handle_get_message,
722 "connectTrain": handle_connect_train,
723 "disconnectTrain": handle_disconnect_train,
724 "setTrainVariable": handle_set_train_variable,
725 "sendTrainMessage": handle_send_train_message,
726 "clearMessages": handle_clear_messages,
727 },
728 bind_host=control_host,
729 )
730
731 running = True
732
733 def _stop(signum, _frame):
734 nonlocal running
735 log.info("[GENERAL] [%s] [internal] [SIGNAL] [Train sim shutting down] [signal=%d]",
736 f"train-{site_tag.lower()}", signum)
737 running = False
738
739 signal.signal(signal.SIGTERM, _stop)
740 signal.signal(signal.SIGINT, _stop)
741
742 log.info("[GENERAL] [%s] [internal] [INIT] [Train sim starting] "
743 "[no trains connected yet - up to %d supported, see connectTrain period=%.1fs]",
744 f"train-{site_tag.lower()}", MAX_TRAINS, period)
745 next_report = 0.0
746
747 # ADR-038: own-perspective status - startup + every 5s, to both sites.
748 status = StatusReporter(log, link, f"SIM-TRAIN/{site_tag}", f"train-{site_tag.lower()}",
749 frame_size=TRAIN_WIRE_SIZE)
750 status.emit({"trains": sum(1 for v in connected_to_rbc.values() if v)})
751
752 while running:
753 link.ensure_connected()
754 for site in SITES:
755 now_connected = link.sockets[site] is not None
756 # Re-sends P0 on a RECONNECT after a transient socket drop
757 # (was_connected[site] false -> true) once this train has
758 # already been explicitly connected - the initial connect
759 # itself is handled synchronously by handle_connect/
760 # handle_connect_train's own send_p0_now() call, not here.
761 if now_connected and not was_connected[site]:
762 s = link.sockets[site]
763 for tid in list(connected_to_rbc.keys()):
764 if not connected_to_rbc.get(tid):
765 continue
766 try:
767 s.sendall(_pad(CATALOG.build_envelope("P0", tid, {})))
768 log.info("[IO] [%s] [c-%s] [P0] [ERTMS Train connection handshake sent] [train_id=%d]",
769 f"train-{site_tag.lower()}", site.lower(), tid)
770 except OSError:
771 pass
772 was_connected[site] = now_connected
773
774 control.poll(timeout=0.0)
775 status.tick({"trains": sum(1 for v in connected_to_rbc.values() if v)})
776
777 now = time.monotonic()
778 if now >= next_report:
779 for tid in list(connected_to_rbc.keys()):
780 if not connected_to_rbc.get(tid):
781 continue
782 # Post-EoM trains stop the flat/legacy 2s cadence entirely -
783 # they get their own 6s real-Subset-026 Packet-1 heartbeat
784 # below instead, not both at once.
785 if ertms_post_eom.get(tid):
786 continue
787 cur_vars = train_vars.get(tid, {})
788 cur_speed = cur_vars.get("v_train", 0)
789 if cur_speed > 0:
790 delta_m = int(round((cur_speed * 1000.0 / 3600.0) * period))
791 new_position = state[tid]["position_m"] + max(1, delta_m)
792 else:
793 new_position = state[tid]["position_m"]
794 payload = send_position_report(tid, new_position)
795 fields_str = " ".join(f"{k}={v}" for k, v in sorted(payload.items()))
796 bname = get_balise_name(payload.get("nid_lrbg"))
797 balise_tag = f" balise={bname}" if bname else ""
798 # Msg 136 carries Packet 0 OR Packet 1, never both (Subset-026
799 # 8.6.4 - see ab_gp_ertms_train_messages.h's own rbc_msg_136_
800 # position_report_s doc) - this cyclic report is the Packet 0
801 # (plain position report) variant, tagged [P0] so the message
802 # tag [M136] stays one consistent name across both variants
803 # instead of a second ad-hoc tag (was [M136_P1] below for the
804 # other variant, now [M136] ... [P1] to match).
805 log.info("[IO] [%s] [c-west,c-east] [M136] [ERTMS Train periodic position report] "
806 "[train_id=%d] [P0] [%s%s]",
807 f"train-{site_tag.lower()}", tid, fields_str, balise_tag)
808 next_report = now + period
809
810 # Post-EoM idle heartbeat (real Subset-026 Msg 136 / Packet 1,
811 # 6s cadence) - see this file's own ertms_post_eom doc. Sent
812 # directly through rbc_ertms_wire.py + MessageCatalog's new
813 # build_ertms_envelope_raw(), NOT via CATALOG.build_envelope(),
814 # since "M136_P1" is deliberately not a message_catalog.json
815 # entry (that file must stay unmodified) - this is this sim's own
816 # internally-driven cadence, not something a Robot test commands
817 # by name.
818 for tid in list(connected_to_rbc.keys()):
819 if connected_to_rbc.get(tid) and ertms_post_eom.get(tid) and now >= next_heartbeat.get(tid, 0.0):
820 cur_vars = train_vars.get(tid, {})
821 cur_lrbg_c = cur_vars.get("nid_c", 0)
822 cur_lrbg_bg = cur_vars.get("nid_lrbg", _default_lrbg(tid))
823 packet1_report = {
824 "nid_c": cur_lrbg_c, "nid_bg": cur_lrbg_bg,
825 "prv_nid_c": cur_lrbg_c, "prv_nid_bg": cur_lrbg_bg,
826 "d_lrbg": state[tid]["position_m"],
827 "v_train": cur_vars.get("v_train", 0),
828 "m_mode": cur_vars.get("m_mode", 0),
829 "m_level": cur_vars.get("m_level", 2),
830 }
831 codec_bytes = ertms_codec.encode_msg_136_packet1(tid, packet1_report)
832 link.send_all(_pad(CATALOG.build_ertms_envelope_raw(codec_bytes, tid)))
833 bname = get_balise_name(cur_lrbg_bg)
834 balise_tag = f" balise={bname}" if bname else ""
835 # Same [M136] message tag as the Packet 0 cyclic report
836 # above, now tagged [P1] instead of a separate [M136_P1]
837 # message name - Msg 136 is one message with two mutually
838 # exclusive packet variants, not two different messages.
839 log.info("[IO] [%s] [c-west,c-east] [M136] [ERTMS post-EoM idle heartbeat sent] "
840 "[train_id=%d] [P1] [d_lrbg=%d nid_lrbg=%d%s]",
841 f"train-{site_tag.lower()}", tid, state[tid]["position_m"], cur_lrbg_bg, balise_tag)
842 next_heartbeat[tid] = now + HEARTBEAT_PERIOD_S
843
844 for site, data in link.poll_recv(timeout=0.2):
845 decoded = CATALOG.decode_to_message(data)
846 if decoded is None:
847 log.warning("[%s] [internal] [MALFORMED] [Malformed envelope dropped] [site=%s]",
848 f"train-{site_tag.lower()}", site)
849 continue
850 nid_engine, message, fields = decoded
851 if message == "M24":
852 if fields.get("packetsPresent"):
853 last_received.setdefault(nid_engine, {})["M24_CONFIG"] = fields
854 last_received.setdefault(nid_engine, {})["M24"] = fields
855 elif "M24_CONFIG" not in last_received.get(nid_engine, {}):
856 last_received.setdefault(nid_engine, {})["M24"] = fields
857 else:
858 last_received.setdefault(nid_engine, {})[message] = fields
859 online_site["value"] = site
860 fields_str = " ".join(f"{k}={v}" for k, v in sorted(fields.items()))
861 if message == "PositionReportAck":
862 msg_type_name = f"M{fields.get('msg_type', '24')}"
863 log.info("[IO] [c-%s] [%s] [%s] [ERTMS Position report ack received] [nid_engine=%d %s]",
864 site.lower(), f"train-{site_tag.lower()}", msg_type_name, nid_engine, fields_str)
865 elif message in ("3", "M3"):
866 log.info("[IO] [c-%s] [%s] [M3] [ERTMS Movement Authority received] [nid_engine=%d %s]",
867 site.lower(), f"train-{site_tag.lower()}", nid_engine, fields_str)
868 # Ack as the train the MA was actually addressed to.
869 link.send_all(_pad(CATALOG.build_envelope("146", nid_engine, {"ma_seq": fields["ma_seq"]})))
870 log.info("[IO] [%s] [c-%s] [M146] [ERTMS Movement Authority Ack sent] [train_id=%d ma_seq=%d]",
871 f"train-{site_tag.lower()}", site.lower(), nid_engine, fields["ma_seq"])
872 else:
873 msg_tag = message if message.startswith("M") else (f"M{message}" if message.isdigit() else message)
874 log.info("[IO] [c-%s] [%s] [%s] [ERTMS message received from RBC] [nid_engine=%d %s]",
875 site.lower(), f"train-{site_tag.lower()}", msg_tag, nid_engine, fields_str)
876
877 log.info("stopped after %d cycle(s)", sum(st["cycle"] for st in state.values()))
878
879
880if __name__ == "__main__":
881 main()