Train sim (ADR-029) - a real, dual-homed UDP client. Connects to BOTH
sites' C (c-west and c-east) at once and keeps both sockets open for the
whole run, so a site failover is invisible from this process's own point
of view (see simcore.dual_link's own header) - it sends its own
P0/136/146 to both sites unconditionally and simply acts on whichever
site actually answers (the STANDBY site's own C has nothing to forward
back, since its A/B pair isn't computing decisions - see monitor_c.c).
Message names/keys are this project's own (message_catalog.json, right
next to this script) - real Subset-026 message numbers where a real
counterpart exists ("3" = real message 3 Movement Authority, "146" =
real message 146 Acknowledgement - see that file's own
"_subset026Reference" for the real spec text), and clearly-labeled
project-specific names where none does ("P0" handshake -
neither corresponds to any real Subset-026 message). These are still
encoded with today's simplified flat fields, not the real nested
Subset-026 packet structure - see message_catalog.json's own per-message
comments and root CLAUDE.md's Subset-026 note for what that gap actually
is. Msg 136 (Train Position Report) is the one exception, switched this
session on direct request: this sim's autonomous cyclic reporting AND
the REPORT/sendTrainMessage commands all send the REAL Subset-026 Msg
136/Packet 0 now (rbc_ertms_wire.py's own "M136" encoder, "ertms"
wireFormat via message_catalog.json's implicit "M"-prefixed-name
default), not a flat kind - see send_position_report()'s own doc.
Configuration: one JSON file (common/sim_config.py, SIM_CONFIG_PATH env
var, default /app/config.json) supplies everything instance-specific -
trainIndex (0 or 1, selects which of this project's
SAFEAPI_EXAMPLE_MAX_TRAINS train slots this instance is - train_id is
trainIndex + 1), siteTag (display-only, kept for log-prefix consistency
with every other role - not a site selector, this sim talks to both
sites regardless), rbcWest/rbcEast (host+port to reach each site's C),
controlServer (bindHost+port for the command channel below), and
simPeriodSeconds. See sims/train/config/*.json for the actual files and
docker-compose.yml's own `volumes:` entries for how each container gets
its own. Message vocabulary (which messages/fields this sim may send or
receive) comes from message_catalog.json, right next to this script -
safeAPITestEnv/doc/design/SIM_INTERFACES.md documents the same table in
prose.
Train lifecycle is test-managed, not process-managed: the TCP transport
itself connects (and reconnects-forever, dual_link.py's own convention)
the moment this process starts, same as always, but NEITHER the P0
handshake NOR the autonomous cyclic "136" position reporting fires until
a test explicitly connects this train (CONNECT / the connectTrain JSON
command below) - a test that never connects a given train leaves it
genuinely absent from the RBC (ab_gp_train.c's own RBC_MSG_P0/
RBC_MSG_M136 handling never creates that train's session at all), not
just quiet on this sim's own side. Once connected, position reporting
keeps running autonomously (a real train reports position continuously,
not on command) until disconnectTrain (below) - a test harness
(safeAPITestEnv/robot/) can also drive specific position reports on
demand via the command channel (common/control_server.py) regardless of
connection state, e.g. to deterministically hit the D_LRBG single-
channel-fault value without waiting on the random walk.
Commands (controlServer.port, config file - see common/control_server.py):
REPORT <d_lrbg> Sends one message "136" (position report) immediately
with the given position (does not wait for the next
autonomous tick; also resets the autonomous random
walk to continue from this value). Works regardless
of connection state (an explicit test-driven send,
not the autonomous loop) - a test using this without
ever connecting the train is exercising the RBC's own
documented lenient "M136 arriving before/without a
P0" tolerance. Replies "OK\\n".
PING Replies "PONG\\n".
CONNECT Explicitly connects this train to the RBC: sends P0
now (not waiting for the next reconnect edge) and
starts autonomous "136" reporting. Replies "OK\\n".
See the structured connectTrain/disconnectTrain
commands below for the nidEngine-explicit,
multi-train-aware equivalents this file's own test
suite actually uses.
DISCONNECT Closes BOTH site connections right now (simulating
the train losing radio contact with the RBC
entirely) - reconnects automatically afterward, same
as any other transport-level disconnect. Deliberately
does NOT touch connected_to_rbc or send
RBC_MSG_TRAIN_DISCONNECT - this simulates losing
radio contact, not a formal disconnect; the RBC-side
session is untouched and autonomous reporting resumes
the moment the socket reconnects. Replies "OK\\n".
DISCONNECTWITHONLINE
Closes the connection to whichever site this sim
currently believes is ONLINE (see "online-site
tracking" below) - simulating losing contact with
the active RBC specifically. Replies "OK\\n", or
"ERR <reason>\\n" if no site has been heard from yet
(nothing to call "online").
DISCONNECTWITHSTANDBY
Same as DISCONNECTWITHONLINE but for the OTHER site
(whichever this sim does NOT currently believe is
ONLINE) - simulating losing contact with the standby
RBC specifically, while the active one stays
reachable. Same ERR case as DISCONNECTWITHONLINE.
Online-site tracking: this sim has no explicit ONLINE/STANDBY signal
from the wire (the RBC side doesn't tell a train which of A/B/site is
currently active) - "online" here is inferred as whichever site most
recently sent this sim ANY decodable message (updated in the main
receive loop, below), the same de-facto signal the sim already uses
operationally (only the ONLINE site's own C ever answers - this file's
own header). Purely a heuristic for the two DISCONNECTWITH* commands
above; not itself sent anywhere or asserted on by anything else.
Structured JSON commands (safeAPITestEnv/doc/design/DESIGN.md,
SIM_INTERFACES.md - same port, see common/control_server.py's own doc
for how the two command styles share one line-based channel):
sendMessage {"cmd":"sendMessage","nidEngine":N,"message":"136","fields":{...}}
Sends one message NOW (any message this train can
originate - P0/136/146, message_catalog.json) with the
given fields; unset fields default to 0. Does NOT touch
the autonomous cycle counter/random-walk state the way
REPORT does - a purely explicit, one-shot send.
getMessage {"cmd":"getMessage","nidEngine":N,"message":"3"}
Returns the most recently received message of that kind
for that nidEngine ("PositionReportAck" or "3" - the
only kinds this sim ever receives), or an ERR if none
has arrived yet.
connectTrain {"cmd":"connectTrain","nidEngine":N,"nidLrbg":M,"dLrbg":D}
Explicitly connects train N to the RBC: sends P0 now and
starts this train's autonomous "136" reporting (see this
file's own "Train lifecycle" paragraph above) - the
nidEngine-explicit structured equivalent of the plain
CONNECT command, and what safeAPITestEnv/robot/'s own
"Connect Train" keyword actually calls. "nidLrbg"/"dLrbg"
are both optional (either, neither, or both) - override
this train's starting balise/position instead of
_default_lrbg()'s own west/east heuristic, applied
before the first real "136" - see handle_connect_train()'s
own doc for why both state["position_m"] and
train_vars["d_lrbg"] get set. Replies {"status":"OK"}.
disconnectTrain
{"cmd":"disconnectTrain","nidEngine":N}
Sends RBC_MSG_TRAIN_DISCONNECT (rbc_wire_types.h) for
train N and stops its autonomous reporting - the RBC-side
session is actually removed (ab_gp_train.c releases every
route the train held, then removes it), not just starved
of traffic. Does NOT close the TCP transport (see
DISCONNECT above for that, a different, radio-silence
concept) - "Disconnect Train" in the Robot library.
Replies {"status":"OK"}.
setTrainVariable
{"cmd":"setTrainVariable","nidEngine":N,"variable":"d_lrbg","value":123}
Stores one SS026-style field for this train (nid_lrbg/
d_lrbg/v_train/m_mode/m_level), persisting until
overwritten - sendTrainMessage (below) pulls whatever a
given message's own catalog entry declares out of this
store at send time. Replies {"status":"OK"}.
sendTrainMessage
{"cmd":"sendTrainMessage","nidEngine":N,"message":"136"}
Sends message N NOW, built from whatever setTrainVariable
has stored for this train (message_catalog.json's own
field list for that message) - an explicit, one-shot send
like sendMessage above, but from the persistent variable
store rather than an inline fields dict. Works regardless
of connection state, same as REPORT/sendMessage. Replies
{"status":"OK"} or {"status":"ERR","reason":"..."}.