RBC_Test_Sim_Train 0.1.0 build a98ee69
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
30RTE_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 import saf_train_link
175from simcore.rbc_messages import MessageCatalog
176from simcore.status_report import StatusReporter
177
178from . import rbc_ertms_wire as ertms_codec
179from . import ertms_adapters
180from . import site_data
181
182CATALOG = MessageCatalog(
183 os.path.join(os.path.dirname(os.path.abspath(__file__)), "message_catalog.json"),
184 ertms_codec=ertms_codec,
185 ertms_encoders=ertms_adapters.ENCODERS,
186 ertms_flatteners=ertms_adapters.FLATTENERS,
187)
188
189# RBC_TRAIN_ENVELOPE_WIRE_SIZE (common_config.h) - the Train relay
190# channel's own physical slot size, wider than the flat scheme's own
191# ENVELOPE_SIZE (96) to also fit a real Subset-026 ertms message
192# (RBC_MSG_ERTMS_ENVELOPE, rbc_wire_types.h). GP's own receive side
193# requires an EXACT match to whatever it requested to read (a genuine
194# wire-protocol-violation check, not framing trivia - see
195# rte_posix_backend_netlink.c's own backend_read()), so EVERY send on
196# this channel - flat scheme included, not just the new ertms one - must
197# be padded to this exact size, not just the "real" content length.
198TRAIN_WIRE_SIZE = 512
199
200
201def _pad(frame):
202 """Zero-pads @frame up to TRAIN_WIRE_SIZE - both wire families
203 already put their own real length in their own header (l_message),
204 so the receiving side reads exactly the meaningful part regardless
205 of this trailing padding; this padding exists purely to satisfy the
206 transport's own exact-datagram-size expectation on this channel."""
207 if len(frame) > TRAIN_WIRE_SIZE:
208 raise ValueError(f"encoded frame is {len(frame)} bytes, exceeds TRAIN_WIRE_SIZE={TRAIN_WIRE_SIZE}")
209 return frame + bytes(TRAIN_WIRE_SIZE - len(frame))
210
211
212# This one sim process can host up to this many DISTINCT nid_engine
213# values over its lifetime (a soft capacity guard, not a pre-sized
214# roster - see main()'s own doc on why there is no roster at all now).
215# Purely a sanity bound on handle_connect_train() - nowhere near GP's
216# own current RTE_EXAMPLE_MAX_TRAINS=2U (common_config.h); this sim
217# is deliberately more permissive than today's RBC-side cap, so it's
218# never itself the limiting factor.
219MAX_TRAINS = 100
220
221
223 """A train's own starting LRBG before any real position report has
224 set one - purely a plausible default so an early sendTrainMessage/136
225 isn't sending nid_lrbg=0. Same "train 1 near the west end, everything
226 else near the east end" heuristic this file always used, just now a
227 per-train function instead of a single value precomputed from a
228 static roster's first entry (there is no roster to read "first
229 entry" from any more)."""
230 return 1001 if tid == 1 else 1009
231
232
233def main():
234 config = sim_config.load()
235 site_tag = sim_config.require(config, "siteTag")
236 period = float(config.get("simPeriodSeconds", 2.0))
237 control_host = sim_config.require(config, "controlServer", "bindHost")
238 control_port = sim_config.require(config, "controlServer", "port")
239
240 # "[%(levelname)s] " prefix on every line - on direct request: the
241 # test env's own merged-log/live-stream listener
242 # (ContainerLogAttachListener.py) used to have to GUESS a level for
243 # every line (defaulting to INFO when none was detectable), since
244 # this format string used to strip Python's own real level
245 # information entirely before it ever reached stdout/the log file -
246 # log.warning()/log.error() calls were textually indistinguishable
247 # from log.info() ones. This emits the sim's own REAL level as data,
248 # collected (not reconstructed) by the test env from here on.
249 logging.basicConfig(level=logging.INFO, format="[%(levelname)s] %(message)s", stream=sys.stdout)
250 log = logging.getLogger("train")
251
252 # A "commServer" config block routes the RBC link through the
253 # RBC_Test_CommServer access-point (Platform_Protocol_SafeComm path): one TCP link, with
254 # RBC_Test_CommServer owning the WEST/EAST dual-homing. Absent -> the direct
255 # dual-homed UDP link to each site's role-C gateway, as before.
256 link = _safecomm_link_from_config(log, config, TRAIN_WIRE_SIZE, sim_config)
257 if link is not None:
258 log.info("train link: via RBC_Test_CommServer (Platform_Protocol_SafeComm path)")
259 else:
260 targets = {
261 "WEST": (sim_config.require(config, "rbcWest", "host"), sim_config.require(config, "rbcWest", "port")),
262 "EAST": (sim_config.require(config, "rbcEast", "host"), sim_config.require(config, "rbcEast", "port")),
263 }
264 link = DualHomedLink(log, targets, TRAIN_WIRE_SIZE)
265 # Subset-037 SaF on the ERTMS traffic (SAFE_COMM_INTEGRATION.dox plan steps 2/3): the same two variables that turn
266 # it on in RBC_GP's A/B processes. Unset (the default) leaves the link exactly as before.
267 saf_rbc_id = os.environ.get("RTE_SAFECOMM_TRAIN_RBC_ID", "").strip()
268 if saf_rbc_id:
269 keys_path = os.environ.get("RTE_SAFECOMM_TRAIN_KEYS_FILE", "").strip()
270 if not keys_path:
271 log.error("[SAF] RTE_SAFECOMM_TRAIN_RBC_ID set without RTE_SAFECOMM_TRAIN_KEYS_FILE - refusing to start")
272 sys.exit(2)
273 link = saf_train_link.SafTrainLink(link, log, int(saf_rbc_id, 0), saf_train_link.load_keys_file(keys_path),
274 TRAIN_WIRE_SIZE)
275 log.info("train link: ERTMS traffic protected by Subset-037 SaF (rbc_etcs_id=%s)", saf_rbc_id)
276 was_connected = {site: False for site in SITES}
277 # Per-train autonomous-walk state (ADR-036: one process, many trains -
278 # up to MAX_TRAINS). Deliberately starts EMPTY, not pre-sized from any
279 # config roster: this process is connected to the RBC (transport-wise)
280 # from the moment it starts, but hosts NO train until a test explicitly
281 # names one via connectTrain - "no default trains" (per direct
282 # instruction) - a train's own state is created lazily, the first time
283 # it's actually named, by _ensure_train() below.
284 state = {}
285 online_site = {"value": None} # heuristic - see this file's own "Online-site tracking" doc above
286
287 # Train lifecycle is test-managed, not process-managed: this TCP
288 # transport connects (and reconnects-forever) on its own the moment
289 # the process starts, same as always, but NEITHER the P0 handshake
290 # NOR the autonomous cyclic M136 reporting fires until an explicit
291 # connect (CONNECT / connectTrain, below) sets this true - a test
292 # that never connects this train leaves it genuinely absent from the
293 # RBC (no session ever created there - see ab_gp_train.c's own
294 # RBC_MSG_P0/RBC_MSG_M136 handling), not just quiet on this sim's
295 # own side. disconnectTrain (below) sets it back to false AND sends
296 # RBC_MSG_TRAIN_DISCONNECT so the RBC-side session is actually
297 # removed, not just starved of further traffic.
298 # Per-train connect state (nid_engine -> bool). Was a single shared
299 # {"value": bool} until this fix - found live via a real symptom: a
300 # test that only ever calls `Connect Train ${TRAIN_1_NID_ENGINE}`
301 # (e.g. 12_ertms_shunting_mode_procedure.robot) was ALSO producing
302 # autonomous M136 traffic for train 2, because activeTrains=2 means
303 # this one sim process manages BOTH train 1 and train 2 (ADR-036
304 # multiplexing), and the single shared flag being set true by
305 # connecting train 1 made the autonomous-report loop below (which
306 # iterates every train in train_ids) start reporting for train 2 as
307 # well - a train that specific test never connected at all. Symmetric
308 # bug on the other end too: disconnecting train 1 was silently
309 # stopping train 2's reporting as well. This file's own header doc
310 # already documented per-train semantics ("connectTrain: ... starts
311 # THIS train's autonomous reporting") - the implementation just never
312 # actually matched that doc until now.
313 connected_to_rbc = {}
314
315 # Real Subset-026 mission-phase tracking (new, per the "critical
316 # architecture issue" request): "IDLE" (default / after a fresh
317 # connect or M155) means active-mission - the existing 2s Packet-0
318 # cadence below applies. "POST_EOM" (set when this sim itself sends
319 # M150/M156 via handle_send_message) means the train has finished its
320 # mission but stayed transport-connected - a SEPARATE 6s Packet-1
321 # heartbeat cadence applies instead (real Subset-026 Packet 1 is the
322 # degraded/no-longer-actively-tracked LRBG-orientation variant, a
323 # good fit for a stationary train no longer under active MA). Purely
324 # local bookkeeping for logging/cadence-selection - the RBC's own
325 # ab_gp_ertms_session_state_t (ab_gp_proc_dispatch.c's handle_msg_136)
326 # stays the sole authority on what a heartbeat is actually allowed to
327 # do session-state-wise.
328 # Also starts empty (see `state`'s own doc just above) - populated the
329 # same lazy way, and read everywhere via .get(tid, ...) already, so an
330 # unconnected/never-seen train simply behaves as "IDLE" with no
331 # explicit entry needed.
332 ertms_post_eom = {}
333 HEARTBEAT_PERIOD_S = 6.0
334 next_heartbeat = {}
335
336 # Resolved once, not per (train, site) iteration below - and NEVER
337 # falls back to a real ERTMS message name (e.g. "M155"): TRAIN_CONNECT/
338 # P0 are a low-level transport-connect signal, not a session-init
339 # request - those are different messages with different meaning, not
340 # interchangeable names for the same thing. If neither exists in a
341 # catalog, that is a real configuration problem worth failing loudly
342 # on, not silently sending the wrong message instead (found live:
343 # the previous fallback chain would have done exactly that).
344 _connect_msg_name = "TRAIN_CONNECT" if "TRAIN_CONNECT" in CATALOG.message_names() else (
345 "P0" if "P0" in CATALOG.message_names() else None)
346 if _connect_msg_name is None:
347 log.error("[IO] [%s] [internal] [CONFIG] [catalog has neither TRAIN_CONNECT nor P0 - "
348 "train connect handshake cannot be sent]", f"train-{site_tag.lower()}")
349
350 def send_p0_now(target_nid):
351 """Sends the connect handshake for exactly ONE train, @target_nid -
352 no implicit "every train" fan-out any more (there is no roster to
353 fan out over - see main()'s own "no default trains" doc). The
354 legacy plain CONNECT command (handle_connect below) now passes a
355 single hardcoded nid_engine explicitly, same as connectTrain
356 already did."""
357 if _connect_msg_name is None:
358 return
359 for site in SITES:
360 s = link.sockets[site]
361 if s is not None:
362 try:
363 s.sendall(_pad(CATALOG.build_envelope(_connect_msg_name, target_nid, {})))
364 log.info("[IO] [Train %d] [RBC %s] [%s] [ERTMS Train connection handshake sent] [train_id=%d]",
365 target_nid, site.title(), _connect_msg_name, target_nid)
366 except OSError:
367 pass
368
369 # Most-recently-received message per (nid_engine, message name) -
370 # safeAPITestEnv/doc/design/DESIGN.md section 4.2. This sim only ever
371 # sees its own train_id on the wire, so nid_engine here is always
372 # train_id, but keyed by it anyway (not a bare dict-by-message) for
373 # the same forward-looking reason SIM_INTERFACES.md gives for
374 # getMessage always taking nidEngine explicitly.
375 last_received = {}
376
377 # Per-train SS026-variable store, {nid_engine: {field_name: value}} -
378 # persists across calls until a test overwrites it (setTrainVariable),
379 # NOT cleared after a send the way the old Set Message/Set Packet
380 # builder pattern was. sendTrainMessage compiles a message by pulling
381 # whatever fields THAT message's own catalog entry declares
382 # (CATALOG.fields_for()) out of this store at send time - a variable
383 # nobody has set yet is simply absent (build_envelope() defaults an
384 # unset field to 0, same as the plain sendMessage command already
385 # does). Field names here are this catalog's own (message_catalog.json
386 # "fields" keys, e.g. "d_lrbg"/"cycle") - the same simplified,
387 # flat-encodable vocabulary "messages" uses, not yet the real SS026
388 # UPPER_CASE variable names in "_subset026Reference" (those aren't
389 # encodable on today's wire protocol at all - see that key's own doc).
390 train_vars = {}
391
392 def _reset_train(tid):
393 state[tid] = {"position_m": 0, "cycle": 0}
394 train_vars[tid] = {
395 "nid_lrbg": _default_lrbg(tid),
396 "d_lrbg": 0,
397 "v_train": 0,
398 "m_mode": 0,
399 "m_level": 2,
400 }
401 last_received.pop(tid, None)
402
403 def _ensure_train(tid):
404 """Lazily creates @tid's own state/train_vars entries the first
405 time it's actually named (connectTrain, or the legacy REPORT/
406 CONNECT commands) - see main()'s own "no default trains" doc.
407 Idempotent - safe to call on every touch, not just the first."""
408 state.setdefault(tid, {"position_m": 0, "cycle": 0})
409 train_vars.setdefault(tid, {
410 "nid_lrbg": _default_lrbg(tid),
411 "d_lrbg": 0,
412 "v_train": 0,
413 "m_mode": 0,
414 "m_level": 2,
415 })
416
417 # Read fresh from the real RailML file (site_data.py) rather than
418 # hardcoded here - was, by direct comparison, a byte-for-byte
419 # duplicate of that file's own <baliseGroup> data. See site_data.py's
420 # own doc for the fallback/logging posture if the file can't be read
421 # (e.g. a Docker image without the sibling RBC_SA checkout).
422 BALISE_NAMES = site_data.balise_names()
423
424 def get_balise_name(nid):
425 if not nid:
426 return ""
427 return BALISE_NAMES.get(int(nid), f"BG_{nid}")
428
429 def send_position_report(tid, d_lrbg, nid_lrbg=None):
430 """Sends this train's cyclic position report as a REAL Subset-026
431 Msg 136 / Packet 0 (CATALOG's own "M136" ertms entry -
432 ab_gp_ertms_train_message_codec.c on the RBC side), not the old
433 flat scheme's own "136" kind - switched this session, on direct
434 request, for every caller of this shared function (the autonomous
435 cyclic loop below AND the REPORT/handle_report command), so there
436 is exactly one M136-sending code path, not two. GP's own real
437 Msg 136 handler (handle_msg_136(), ab_gp_proc_dispatch.c) already
438 works standalone (no prior Msg 155 session handshake required) and
439 already replies with a real Msg 24 - see that function's own doc.
440 "cycle" stays purely local bookkeeping (this sim's own REPORT-
441 command/logging counter) - real Msg 136 has no counter field of
442 its own to carry it on the wire (message_catalog.json's own "M136"
443 reference entry doc: "cycle... is a project-invented counter with
444 no real-message-136 equivalent")."""
445 _ensure_train(tid)
446 st = state[tid]
447 st["cycle"] += 1
448 st["position_m"] = d_lrbg
449 vars_for_train = train_vars.setdefault(tid, {})
450 vars_for_train["d_lrbg"] = d_lrbg
451 if nid_lrbg is not None:
452 vars_for_train["nid_lrbg"] = nid_lrbg
453
454 cur_lrbg = vars_for_train.get("nid_lrbg", _default_lrbg(tid))
455 cur_v = vars_for_train.get("v_train", 0)
456 cur_mode = vars_for_train.get("m_mode", 0)
457 cur_level = vars_for_train.get("m_level", 2)
458
459 # Full real Packet 0 field set (ertms_adapters.py's own
460 # _position_report_from_fields() supplies the same defaults for
461 # q_scale/q_dirlrbg/q_dlrbg/l_doubtover/l_doubtunder when a
462 # sendMessage/sendTrainMessage command builds M136 instead of this
463 # autonomous path) - nid_c defaults to 0 (this sim never models a
464 # real multi-country NID_C), nid_bg is this sim's own flat
465 # "nid_lrbg" balise-ID convention.
466 wire_fields = {
467 "q_scale": vars_for_train.get("q_scale", ertms_codec.Q_SCALE_1M),
468 "nid_c": vars_for_train.get("nid_c", 0),
469 "nid_bg": cur_lrbg,
470 "d_lrbg": d_lrbg,
471 "q_dirlrbg": vars_for_train.get("q_dirlrbg", ertms_codec.Q_DIRLRBG_NOMINAL),
472 "q_dlrbg": vars_for_train.get("q_dlrbg", ertms_codec.Q_DLRBG_NOMINAL),
473 "l_doubtover": vars_for_train.get("l_doubtover", 0),
474 "l_doubtunder": vars_for_train.get("l_doubtunder", 0),
475 "v_train": cur_v,
476 "m_mode": cur_mode,
477 "m_level": cur_level,
478 }
479 link.send_all(_pad(CATALOG.build_envelope("M136", tid, wire_fields)))
480
481 # Returned for this function's own two callers (REPORT/handle_report
482 # and the cyclic loop) to log - "cycle"/"nid_lrbg" kept as the
483 # names those callers/get_balise_name() already expect, alongside
484 # every real wire field actually sent.
485 payload = dict(wire_fields)
486 payload["cycle"] = st["cycle"]
487 payload["nid_lrbg"] = cur_lrbg
488 return payload
489
490 def handle_report(args):
491 # Legacy, non-nidEngine-aware command - always targets train 1
492 # (there is no roster/"first train" any more to derive this from;
493 # see main()'s own "no default trains" doc). Use REPORT's own
494 # structured equivalent, sendTrainMessage with an explicit
495 # nidEngine, for anything beyond train 1.
496 tid = 1
497 d_lrbg = int(args[0])
498 nid_lrbg = int(args[1]) if len(args) > 1 else None
499 payload = send_position_report(tid, d_lrbg, nid_lrbg)
500 fields_str = " ".join(f"{k}={v}" for k, v in sorted(payload.items()))
501 bname = get_balise_name(payload.get("nid_lrbg"))
502 balise_tag = f" balise={bname}" if bname else ""
503 log.info("[IO] [Train %d] [RBC West, RBC East] [M136] [ERTMS Train position report commanded] "
504 "[train_id=%d] [P0] [%s%s]",
505 tid, tid, fields_str, balise_tag)
506 return "OK\n"
507
508 def handle_ping(_args):
509 return "PONG\n"
510
511 def handle_connect(_args):
512 # Legacy, non-nidEngine-aware command - connects train 1 only
513 # (there is no roster to fan out over any more; see main()'s own
514 # "no default trains" doc). Use connectTrain for any other train.
515 tid = 1
516 _ensure_train(tid)
517 connected_to_rbc[tid] = True
518 link.ensure_connected()
519 send_p0_now(tid)
520 return "OK\n"
521
522 def handle_disconnect(_args):
523 for site in SITES:
524 link.disconnect(site)
525 return "OK\n"
526
527 def handle_disconnect_with_online(_args):
528 site = online_site["value"]
529 if site is None:
530 return "ERR no site currently believed online\n"
531 link.disconnect(site)
532 return "OK\n"
533
534 def handle_disconnect_with_standby(_args):
535 site = online_site["value"]
536 if site is None:
537 return "ERR no site currently believed online\n"
538 standby = "EAST" if site == "WEST" else "WEST"
539 link.disconnect(standby)
540 return "OK\n"
541
542 def handle_send_message(request):
543 nid_engine = request["nidEngine"]
544 message = request["message"]
545 fields = request.get("fields", {})
546 link.send_all(_pad(CATALOG.build_envelope(message, nid_engine, fields)))
547 fields_str = " ".join(f"{k}={v}" for k, v in sorted(fields.items()))
548 log.info("[IO] [Train %d] [RBC West, RBC East] [%s] [ERTMS message sent] [train_id=%d %s]",
549 nid_engine, message, nid_engine, fields_str)
550 # Real mission-phase tracking (see this file's own ertms_post_eom
551 # doc): M150 (End of Mission) or M156 (Terminate Session) flip this
552 # train to the post-EoM 6s Packet-1 heartbeat cadence; M155
553 # (Initiate Session) - a fresh mission starting - flips it back.
554 if message in ("M150", "M156"):
555 ertms_post_eom[nid_engine] = True
556 next_heartbeat[nid_engine] = time.monotonic() + HEARTBEAT_PERIOD_S
557 elif message == "M155":
558 ertms_post_eom[nid_engine] = False
559 return {"status": "OK"}
560
561 def handle_get_message(request):
562 nid_engine = request["nidEngine"]
563 message = str(request["message"])
564 packets = last_received.get(nid_engine, {}).get(message)
565 # "24"/"15"/"M15" are legacy bridges to the flat scheme's own
566 # combined M24_M15 answer (kind=RBC_MSG_M24_M15) - none of them
567 # is a real message_catalog.json name. "M24" deliberately does
568 # NOT get this fallback any more: it is now a real, distinct
569 # ertms wireFormat message name (real Subset-026 Msg 24) that can
570 # genuinely arrive later than this call - falling back to the
571 # flat scheme's own unrelated answer here would make a caller
572 # waiting for the REAL Msg 24 (Wait For Train Message's own
573 # retry loop) see an immediate, wrong "success" instead of
574 # actually waiting for it.
575 if packets is None and message in ("24", "15", "M15"):
576 packets = last_received.get(nid_engine, {}).get("PositionReportAck")
577 if packets is None:
578 return {"status": "ERR", "reason": f"no {message} received yet for nidEngine {nid_engine}"}
579 result_packets = {message: packets}
580 try:
581 for pkt_name in CATALOG.packets_for(message):
582 result_packets[pkt_name] = packets
583 except Exception:
584 pass
585 if "msg_type" in packets:
586 result_packets["PositionReportAck"] = packets
587 result_packets["M24_M15"] = packets
588 result_packets["24"] = packets
589 result_packets[str(packets["msg_type"])] = packets
590 result_packets[f"M{packets['msg_type']}"] = packets
591 return {"status": "OK", "message": message, "packets": result_packets}
592
593 def handle_connect_train(request):
594 nid_engine = request["nidEngine"]
595 # MAX_TRAINS is a capacity guard on DISTINCT trains ever named,
596 # not on currently-connected count - re-connecting an
597 # already-known nid_engine never counts against it.
598 if nid_engine not in state and len(state) >= MAX_TRAINS:
599 return {"status": "ERR",
600 "reason": f"this sim already hosts {MAX_TRAINS} distinct trains (MAX_TRAINS)"}
601 _reset_train(nid_engine)
602 # Optional starting position override, on direct request - a test
603 # that needs a train to start somewhere other than
604 # _default_lrbg()'s own west/east heuristic (e.g. right at a
605 # specific route's own start balise) can now say so explicitly,
606 # instead of connecting then separately calling Set Train
607 # Variable/sendTrainMessage before the first real M136. Both
608 # optional and independent - either, neither, or both. Sets
609 # state[nid_engine]["position_m"] too (not just train_vars),
610 # since that field - not train_vars["d_lrbg"] - is what the
611 # cyclic position-report loop below actually advances from; see
612 # send_position_report()'s own doc for why both need to agree.
613 if "nidLrbg" in request and request["nidLrbg"] is not None:
614 train_vars[nid_engine]["nid_lrbg"] = int(request["nidLrbg"])
615 if "dLrbg" in request and request["dLrbg"] is not None:
616 d_lrbg = int(request["dLrbg"])
617 train_vars[nid_engine]["d_lrbg"] = d_lrbg
618 state[nid_engine]["position_m"] = d_lrbg
619 connected_to_rbc[nid_engine] = True
620 link.ensure_connected()
621 send_p0_now(nid_engine)
622 # A fresh connect always starts a train back in active-mission
623 # phase (not post-EoM) - matches session->in_use being reset fresh
624 # RBC-side on a genuine (re)connect.
625 ertms_post_eom[nid_engine] = False
626 log.info("[GENERAL] [Train %d] [internal] [CONNECT] [Train connected to RBC] [train_id=%d]",
627 nid_engine, nid_engine)
628 return {"status": "OK"}
629
630 def handle_disconnect_train(request):
631 nid_engine = request["nidEngine"]
632 for site in SITES:
633 s = link.sockets[site]
634 if s is not None:
635 try:
636 s.sendall(_pad(CATALOG.build_envelope("TRAIN_DISCONNECT", nid_engine, {})))
637 except OSError:
638 pass
639 connected_to_rbc[nid_engine] = False
640 ertms_post_eom[nid_engine] = False
641 _reset_train(nid_engine)
642 log.info("[IO] [Train %d] [RBC West, RBC East] [TRAIN_DISCONNECT] [ERTMS Train disconnected from RBC] [train_id=%d]",
643 nid_engine, nid_engine)
644 return {"status": "OK"}
645
646 def handle_set_train_variable(request):
647 nid_engine = request["nidEngine"]
648 variable = request["variable"]
649 value = request["value"]
650 train_vars.setdefault(nid_engine, {})[variable] = value
651 # d_lrbg is genuinely two places at once: the variable store here
652 # (what an explicit Send Train Message pulls fields from) AND
653 # state[nid_engine]["position_m"] (what the autonomous cyclic M136
654 # reporter below self-increments from every `period` seconds,
655 # entirely independently of this store). A real bug, found live:
656 # without this, Set Train Variable ... d_lrbg <N> had no visible
657 # effect on the next periodic report at all - the cyclic reporter
658 # just kept incrementing from its own prior position_m, and only
659 # an immediately-following explicit Send Train Message call
660 # (handle_send_train_message()'s own identical position_m write)
661 # ever bridged the two, racing against the next autonomous tick.
662 # Keeping both in sync here means Set Train Variable alone is
663 # enough, same as every other variable.
664 if variable == "d_lrbg":
665 state.setdefault(nid_engine, {"position_m": 0, "cycle": 0})["position_m"] = value
666 log.info("[GENERAL] [Train %d] [internal] [VAR_STORE] [Train variable set] [train_id=%d %s=%s]",
667 nid_engine, nid_engine, variable, value)
668 return {"status": "OK"}
669
670 def handle_send_train_message(request):
671 nonlocal next_report
672 nid_engine = request["nidEngine"]
673 message = request["message"]
674 packet = request.get("packet", message)
675 stored = train_vars.get(nid_engine, {})
676 try:
677 # ertms-format messages (M129/M132/...) get the WHOLE stored
678 # var set, unfiltered - a real, found-live bug otherwise:
679 # their own catalog "fields" list is Subset-026 documentation
680 # text ("Train data (Packet type 11)"), not real snake_case
681 # variable names, so filtering against it (the flat-message
682 # path below) always produced an EMPTY fields dict - e.g.
683 # Send Train Message M129 silently sent l_train=0/v_maxtrain=0
684 # regardless of what Set Train Variable had actually stored,
685 # which then failed GP's own train-data validation
686 # (ab_gp_proc_train_data_validate()) silently, leaving
687 # ctx->train_data.valid permanently false and the config-
688 # carrying Msg 24 (Packet 3/57/58) never sent. ertms encoders
689 # already do their own fields.get(key, default) extraction
690 # (ertms_adapters.py), so passing the raw store is safe - it's
691 # exactly what those lambdas expect.
692 if CATALOG.is_ertms(message):
693 fields = dict(stored)
694 else:
695 field_specs = CATALOG.fields_for(message, packet if packet != message else None)
696 fields = {name: stored[name] for name in field_specs if name in stored}
697 except Exception as e:
698 return {"status": "ERR", "reason": str(e)}
699 if message == "136" or message == "PositionReport" or message == "M136":
700 train_st = state.setdefault(nid_engine, {"position_m": 0, "cycle": 0})
701 if "cycle" not in fields:
702 train_st["cycle"] += 1
703 fields["cycle"] = train_st["cycle"]
704 if "nid_lrbg" not in fields:
705 fields["nid_lrbg"] = _default_lrbg(nid_engine)
706 if "d_lrbg" in fields:
707 train_st["position_m"] = fields["d_lrbg"]
708 # Per direct request: the autonomous cyclic report still fires
709 # every `period` (simPeriodSeconds) seconds, but a Robot-
710 # triggered explicit send (this handler) sends immediately AND
711 # restarts that timer from now - without this, an explicit
712 # send could be followed by a redundant autonomous report just
713 # a fraction of a period later, instead of a clean full period
714 # after the explicit one.
715 next_report = time.monotonic() + period
716 if message in ("132", "M132", "146", "M146"):
717 last_received.get(nid_engine, {}).pop("M24_CONFIG", None)
718 if message in ("M150", "M156", "150", "156"):
719 ertms_post_eom[nid_engine] = True
720 next_heartbeat[nid_engine] = time.monotonic() + HEARTBEAT_PERIOD_S
721 elif message in ("M155", "155"):
722 ertms_post_eom[nid_engine] = False
723 link.send_all(_pad(CATALOG.build_envelope(message, nid_engine, fields)))
724 fields_str = " ".join(f"{k}={v}" for k, v in sorted(fields.items()))
725 msg_tag = f"M{message}" if message.isdigit() else message
726 log.info("[IO] [%s] [c-west,c-east] [%s] [ERTMS message sent from stored variables] [train_id=%d %s]",
727 f"train-{site_tag.lower()}", msg_tag, nid_engine, fields_str)
728 return {"status": "OK"}
729
730 def handle_clear_messages(_request):
731 last_received.clear()
732 return {"status": "OK"}
733
734 def handle_cleanup(_request):
735 """Comprehensive one-call reset of every train this sim has ever
736 named, for every EVER-connected train (RBC_MSG_TRAIN_DISCONNECT,
737 the same wire action disconnectTrain sends, so the RBC side's own
738 per-train session/db state resets too - the same signal
739 ab_ga_ctc.c's on-change cache now relies on, see RBC_Template/ISSUES.md)
740 followed by a full local reset (_reset_train() - train_vars,
741 position/cycle, message history) plus ertms_post_eom/
742 next_heartbeat, which disconnectTrain's own per-call version
743 already covers per train but this does in one round trip for all
744 of them. Deliberately does NOT touch the underlying TCP link
745 (link.sockets) - only application-level state, so a test suite's
746 own [Teardown] can call this without paying a reconnect cost.
747 Per direct request: sims shall forget historical data between
748 tests; the connection itself can stay up."""
749 for tid in list(state.keys()):
750 if connected_to_rbc.get(tid):
751 for site in SITES:
752 s = link.sockets[site]
753 if s is not None:
754 try:
755 s.sendall(_pad(CATALOG.build_envelope("TRAIN_DISCONNECT", tid, {})))
756 except OSError:
757 pass
758 connected_to_rbc[tid] = False
759 ertms_post_eom[tid] = False
760 next_heartbeat.pop(tid, None)
761 _reset_train(tid)
762 last_received.clear()
763 log.info("[GENERAL] [%s] [internal] [CLEANUP] [All train state reset, link kept up] []",
764 f"train-{site_tag.lower()}")
765 return {"status": "OK"}
766
767 control = ControlServer(
768 log,
769 control_port,
770 {
771 "REPORT": handle_report,
772 "PING": handle_ping,
773 "CONNECT": handle_connect,
774 "DISCONNECT": handle_disconnect,
775 "DISCONNECTWITHONLINE": handle_disconnect_with_online,
776 "DISCONNECTWITHSTANDBY": handle_disconnect_with_standby,
777 },
778 json_handlers={
779 "sendMessage": handle_send_message,
780 "getMessage": handle_get_message,
781 "connectTrain": handle_connect_train,
782 "disconnectTrain": handle_disconnect_train,
783 "setTrainVariable": handle_set_train_variable,
784 "sendTrainMessage": handle_send_train_message,
785 "clearMessages": handle_clear_messages,
786 "cleanup": handle_cleanup,
787 },
788 bind_host=control_host,
789 )
790
791 running = True
792
793 def _stop(signum, _frame):
794 nonlocal running
795 log.info("[GENERAL] [%s] [internal] [SIGNAL] [Train sim shutting down] [signal=%d]",
796 f"train-{site_tag.lower()}", signum)
797 running = False
798
799 signal.signal(signal.SIGTERM, _stop)
800 signal.signal(signal.SIGINT, _stop)
801
802 log.info("[GENERAL] [%s] [internal] [INIT] [Train sim starting] "
803 "[no trains connected yet - up to %d supported, see connectTrain period=%.1fs]",
804 f"train-{site_tag.lower()}", MAX_TRAINS, period)
805 next_report = 0.0
806
807 # ADR-038: own-perspective status - startup + every 5s, to both sites.
808 status = StatusReporter(log, link, f"SIM-TRAIN/{site_tag}", f"train-{site_tag.lower()}",
809 frame_size=TRAIN_WIRE_SIZE)
810 status.emit({"trains": sum(1 for v in connected_to_rbc.values() if v)})
811
812 while running:
813 link.ensure_connected()
814 for site in SITES:
815 now_connected = link.sockets[site] is not None
816 # Re-sends P0 on a RECONNECT after a transient socket drop
817 # (was_connected[site] false -> true) once this train has
818 # already been explicitly connected - the initial connect
819 # itself is handled synchronously by handle_connect/
820 # handle_connect_train's own send_p0_now() call, not here.
821 if now_connected and not was_connected[site]:
822 s = link.sockets[site]
823 for tid in list(connected_to_rbc.keys()):
824 if not connected_to_rbc.get(tid):
825 continue
826 try:
827 s.sendall(_pad(CATALOG.build_envelope("P0", tid, {})))
828 log.info("[IO] [Train %d] [RBC %s] [P0] [ERTMS Train connection handshake sent] [train_id=%d]",
829 tid, site.title(), tid)
830 except OSError:
831 pass
832 was_connected[site] = now_connected
833
834 control.poll(timeout=0.0)
835 status.tick({"trains": sum(1 for v in connected_to_rbc.values() if v)})
836
837 now = time.monotonic()
838 if now >= next_report:
839 for tid in list(connected_to_rbc.keys()):
840 if not connected_to_rbc.get(tid):
841 continue
842 # Post-EoM trains stop the flat/legacy simPeriodSeconds
843 # cadence entirely - they get their own HEARTBEAT_PERIOD_S
844 # real-Subset-026 Packet-1 heartbeat below instead, not
845 # both at once.
846 if ertms_post_eom.get(tid):
847 continue
848 cur_vars = train_vars.get(tid, {})
849 cur_speed = cur_vars.get("v_train", 0)
850 if cur_speed > 0:
851 delta_m = int(round((cur_speed * 1000.0 / 3600.0) * period))
852 new_position = state[tid]["position_m"] + max(1, delta_m)
853 else:
854 new_position = state[tid]["position_m"]
855 payload = send_position_report(tid, new_position)
856 fields_str = " ".join(f"{k}={v}" for k, v in sorted(payload.items()))
857 bname = get_balise_name(payload.get("nid_lrbg"))
858 balise_tag = f" balise={bname}" if bname else ""
859 # Msg 136 carries Packet 0 OR Packet 1, never both (Subset-026
860 # 8.6.4 - see ab_gp_ertms_train_messages.h's own rbc_msg_136_
861 # position_report_s doc) - this cyclic report is the Packet 0
862 # (plain position report) variant, tagged [P0] so the message
863 # tag [M136] stays one consistent name across both variants
864 # instead of a second ad-hoc tag (was [M136_P1] below for the
865 # other variant, now [M136] ... [P1] to match).
866 log.info("[IO] [Train %d] [RBC West, RBC East] [M136] [ERTMS Train periodic position report] "
867 "[train_id=%d] [P0] [%s%s]",
868 tid, tid, fields_str, balise_tag)
869 next_report = now + period
870
871 # Post-EoM idle heartbeat (real Subset-026 Msg 136 / Packet 1,
872 # 6s cadence) - see this file's own ertms_post_eom doc. Sent
873 # directly through rbc_ertms_wire.py + MessageCatalog's new
874 # build_ertms_envelope_raw(), NOT via CATALOG.build_envelope(),
875 # since "M136_P1" is deliberately not a message_catalog.json
876 # entry (that file must stay unmodified) - this is this sim's own
877 # internally-driven cadence, not something a Robot test commands
878 # by name.
879 for tid in list(connected_to_rbc.keys()):
880 if connected_to_rbc.get(tid) and ertms_post_eom.get(tid) and now >= next_heartbeat.get(tid, 0.0):
881 cur_vars = train_vars.get(tid, {})
882 cur_lrbg_c = cur_vars.get("nid_c", 0)
883 cur_lrbg_bg = cur_vars.get("nid_lrbg", _default_lrbg(tid))
884 packet1_report = {
885 "nid_c": cur_lrbg_c, "nid_bg": cur_lrbg_bg,
886 "prv_nid_c": cur_lrbg_c, "prv_nid_bg": cur_lrbg_bg,
887 "d_lrbg": state[tid]["position_m"],
888 "v_train": cur_vars.get("v_train", 0),
889 "m_mode": cur_vars.get("m_mode", 0),
890 "m_level": cur_vars.get("m_level", 2),
891 }
892 codec_bytes = ertms_codec.encode_msg_136_packet1(tid, packet1_report)
893 link.send_all(_pad(CATALOG.build_ertms_envelope_raw(codec_bytes, tid)))
894 bname = get_balise_name(cur_lrbg_bg)
895 balise_tag = f" balise={bname}" if bname else ""
896 # Same [M136] message tag as the Packet 0 cyclic report
897 # above, now tagged [P1] instead of a separate [M136_P1]
898 # message name - Msg 136 is one message with two mutually
899 # exclusive packet variants, not two different messages.
900 log.info("[IO] [Train %d] [RBC West, RBC East] [M136] [ERTMS post-EoM idle heartbeat sent] "
901 "[train_id=%d] [P1] [d_lrbg=%d nid_lrbg=%d%s]",
902 tid, tid, state[tid]["position_m"], cur_lrbg_bg, balise_tag)
903 next_heartbeat[tid] = now + HEARTBEAT_PERIOD_S
904
905 for site, data in link.poll_recv(timeout=0.2):
906 decoded = CATALOG.decode_to_message(data)
907 if decoded is None:
908 log.warning("[Train 1] [internal] [MALFORMED] [Malformed envelope dropped] [site=%s]", site)
909 continue
910 nid_engine, message, fields = decoded
911 if message == "M24":
912 if fields.get("packetsPresent"):
913 last_received.setdefault(nid_engine, {})["M24_CONFIG"] = fields
914 last_received.setdefault(nid_engine, {})["M24"] = fields
915 elif "M24_CONFIG" not in last_received.get(nid_engine, {}):
916 last_received.setdefault(nid_engine, {})["M24"] = fields
917 else:
918 last_received.setdefault(nid_engine, {})[message] = fields
919 online_site["value"] = site
920 fields_str = " ".join(f"{k}={v}" for k, v in sorted(fields.items()))
921 target_train_label = f"Train {nid_engine}"
922 rbc_label = f"RBC {site.title()}"
923 if message == "PositionReportAck":
924 msg_type_name = f"M{fields.get('msg_type', '24')}"
925 log.info("[IO] [%s] [%s] [%s] [ERTMS Position report ack received] [nid_engine=%d %s]",
926 rbc_label, target_train_label, msg_type_name, nid_engine, fields_str)
927 elif message in ("3", "M3"):
928 # Per direct request: log each Subset-026 packet on its own
929 # line with its own real fields/values, not one flattened
930 # summary line - rbc_ertms_wire.py's own decode_msg_3()
931 # doc: Packet 15 (Movement Authority) is always present;
932 # Packets 21 (Gradient Profile), 27 (Static Speed Profile),
933 # 5 (Linking Information) are optional, only logged when
934 # this particular grant actually carried them.
935 packets_present = fields.get("packetsPresent") or ["P15"]
936 log.info("[IO] [%s] [%s] [M3] [ERTMS Movement Authority received] [nid_engine=%d packets=%s]",
937 rbc_label, target_train_label, nid_engine, ",".join(packets_present))
938 log.info("[IO] [%s] [%s] [P15] [Movement Authority packet] "
939 "[nid_engine=%d ma_seq=%s ma_length=%s v_loa=%s t_loa_s=%s]",
940 rbc_label, target_train_label, nid_engine,
941 fields.get("ma_seq"), fields.get("ma_length"), fields.get("v_loa"), fields.get("t_loa_s"))
942 if "P21" in packets_present:
943 log.info("[IO] [%s] [%s] [P21] [Gradient Profile packet] "
944 "[nid_engine=%d segment_count=%s segments=%s]",
945 rbc_label, target_train_label, nid_engine,
946 fields.get("gradient_segment_count"), fields.get("gradient_segments"))
947 if "P27" in packets_present:
948 log.info("[IO] [%s] [%s] [P27] [Static Speed Profile packet] "
949 "[nid_engine=%d segment_count=%s segments=%s]",
950 rbc_label, target_train_label, nid_engine,
951 fields.get("ssp_segment_count"), fields.get("ssp_segments"))
952 if "P5" in packets_present:
953 log.info("[IO] [%s] [%s] [P5] [Linking Information packet] "
954 "[nid_engine=%d link_count=%s links=%s]",
955 rbc_label, target_train_label, nid_engine,
956 fields.get("linking_link_count"), fields.get("linking_links"))
957 # Ack as the train the MA was actually addressed to.
958 link.send_all(_pad(CATALOG.build_envelope("146", nid_engine, {"ma_seq": fields["ma_seq"]})))
959 log.info("[IO] [%s] [%s] [M146] [ERTMS Movement Authority Ack sent] [train_id=%d ma_seq=%d]",
960 target_train_label, rbc_label, nid_engine, fields["ma_seq"])
961 else:
962 msg_tag = message if message.startswith("M") else (f"M{message}" if message.isdigit() else message)
963 log.info("[IO] [%s] [%s] [%s] [ERTMS message received from RBC] [nid_engine=%d %s]",
964 rbc_label, target_train_label, msg_tag, nid_engine, fields_str)
965
966 log.info("stopped after %d cycle(s)", sum(st["cycle"] for st in state.values()))
967
968
969if __name__ == "__main__":
970 main()