SafeAPI RBC 2oo2 Test Environment
End-to-end Robot Framework test suite + Docker Compose stack for the safeAPIRBC2oo2 system
Loading...
Searching...
No Matches
Robot Framework <-> Sim Message Interface: Design

Status: implemented - RbcMessages.py (robot/supportFunctions/), each sim's own sendMessage/getMessage control-port handlers (control_server.py/rbc_messages.py, SimCore), and robot/rbc_scenario/ all use this interface for real. It replaced today's log-scraping verification approach (Container Log Should Contain, Train Log Should Contain, etc., still used by containers/ and fault_injection/ - see section 6) for train/IL/CTC message content assertions. See TEST_CASE_TEMPLATE.md and SIM_INTERFACES.md for the rest of this design, and robot/rbc_scenario/train_connections_tests/train_connects_and_reports_position.robot onward for real usage.

1. Why change what exists today

Every current assertion works by regex/substring-matching a sim's or an RBC container's own free-text log output (Should Contain ${log} Movement Authority - length=500m). This has three real problems:

  1. A wording change silently breaks tests. The log line and the test assertion are two independent copies of the same fact, with nothing forcing them to agree. Nobody notices until a test fails for a reason that has nothing to do with the thing it was actually testing.
  2. Only whatever the log author chose to print is checkable. If a message has five fields and the log line only mentions one, the other four are unverifiable without a source change on the sim side just to add a log line.
  3. No way to assert on absence, timing between two specific messages, or a specific field's exact value without writing a bespoke regex for that one case.

The fix: sims decode every RBC envelope they see anyway (they need to, to log about it) - expose that already-decoded structured data through the sim's own control port instead of only ever printing it as text.

2. Scope: what "message" and "packet" mean here

Real ETCS (Subset-026) messages are a message ID plus a variable list of packets, each packet its own ID and field set. This project's own wire protocol (rbc_wire_types.h / rbc_wire.py, ADR-029) is not that - rbc_envelope_t is one flat, fixed-size struct with a kind tag (P0, M136, M24_M15, ROUTE_ADD, M3, M146, CTC_CONNECTED, CTC_MA_GRANTED, CTC_MA_EXTENDED) and every field that any kind might need, unused ones zeroed. There is no packet-within-message nesting on the wire today.

This design still uses "message" and "packet" as two distinct concepts, deliberately kept distinct even though they collapse 1:1 today:

  • Message = one rbc_envelope_t / one kind value (e.g. M136, M3).
  • Packet = a named field-group within a message. Today, every message has exactly one packet, itself named after the message (M136's packet is M136, carrying d_lrbg/cycle; M3's packet is M3, carrying ma_seq/ma_length). See SIM_INTERFACES.md for the exact field table per message/packet today.

Keeping the two concepts distinct even though they coincide today means that if the wire protocol ever grows real multi-packet messages (a separate, much bigger ADR-level change to rbc_wire_types.h/rbc_wire.c and the Python mirror - not in scope here), the Robot-facing keyword contract (Send Train Message, Verify Packet, ...) does not need to change - only the sim's own decode step would start returning more than one packet per message.

3. Architecture

Robot test case
| Set Message / Set Packet (build up a message, field by field)
| Send Train Message (flush + send)
| Wait Train Message (poll for a reply, cache it)
| Verify Packet (assert a field of the cached message)
v
RbcMessages.py (new Robot Framework library, Python - see section 5)
| JSON request/response, one line each, over the EXISTING
| line-based TCP control port (control_server.py) - same
| transport every sim already has, just a new command pair on it
v
sims/train_sim.py (or il_sim.py / ctc_sim.py)
| translates the JSON request into a real rbc_envelope_t datagram
| (dual_link.py, rbc_wire.py - unchanged) sent to whichever site
| is ONLINE, and decodes every envelope it receives back into the
| SAME JSON shape for query replies
v
Real RBC wire traffic (UDP, rbc_envelope_t, ADR-029) - unchanged

Nothing below the sim's own control-port handler changes: dual_link.py, rbc_wire.py, and the real UDP wire protocol are exactly as they are today. This design only adds a second command pair (sendMessage/getMessage) alongside each sim's existing plain-text commands (ADD_ROUTE, REPORT, PING) on the same port.

4. The sim-side protocol

One JSON object per line (matches control_server.py's existing "one command per line" framing - see that file's own header), sent and replied to over the same Telnet-style connection Robot already opens for ADD_ROUTE/PING today.

4.1 sendMessage - test commands the sim to transmit a message

Request:

{"cmd": "sendMessage", "nidEngine": 1, "message": "M136", "fields": {"d_lrbg": 500, "cycle": 12}}

Reply:

{"status": "OK"}

or, if the sim's own send failed (mirrors what ADD_ROUTE's Should Contain OK already checks today):

{"status": "ERR", "reason": "<human-readable detail>"}

nidEngine is this message's train_id (see SIM_INTERFACES.md for why "NID_ENGINE" rather than a fresh made-up field name - it is deliberately the real ETCS term, since a train's own running number is exactly what it names). fields carries only the fields that message kind actually uses (see the per-message field tables, SIM_INTERFACES.md) - anything else is rejected with ERR, not silently ignored, so a typo'd field name fails loudly instead of being dropped on the floor.

4.2 getMessage - test asks what the sim has actually received

Request:

{"cmd": "getMessage", "nidEngine": 1, "message": "M3"}

Reply, if that message has been seen at least once since the sim started:

{"status": "OK", "message": "M3", "packets": {"M3": {"ma_seq": 1, "ma_length": 500}}}

Reply, if never seen:

{"status": "ERR", "reason": "no M3 received yet for nidEngine 1"}

getMessage always returns the most recently received instance of that message kind for that nidEngine - not a queue, not a history. Rationale: Wait Train Message (section 5) is meant to be polled repeatedly inside a retry loop until the next expected message shows up; a queue would need its own separate "have I already consumed this one" bookkeeping on the Robot side for no real benefit at this project's current scale. If a test genuinely needs "did this happen exactly twice" rather than "did this eventually happen with these field values", that is a distinct, not-yet-needed feature (an explicit, opt-in history buffer) - flag it if a real test needs it rather than building it speculatively now.

Each sim keeps this as an in-memory Python dict, {nid_engine: {message: packets_dict}}, updated every time it decodes a real envelope from the wire (the same decode step that already drives today's log lines) - authoritative because the sim is the thing actually doing the decoding, not a copy of a copy.

4.3 Why JSON, why request/response, not a custom bracket syntax or a push channel

  • JSON, not {MessageID [field value]}-style custom syntax: Python's json module is already available with zero new dependencies, Robot Framework's Collections library already handles nested dict/list results well, and a custom bracket grammar would need its own hand-written parser on both ends for no benefit over a format both sides already have a library for.
  • Request/response (poll), not an async push/event channel: matches control_server.py's existing one-command-per-line, synchronous-reply model exactly (see that file's own doc) - no new transport, no new threading, no new "what if the event arrives while Robot isn't listening" question to answer. Wait Train Message gets the same "eventually true" behavior an event channel would give, via an ordinary Wait Until Keyword Succeeds-style retry loop polling getMessage, at the cost of a poll interval's worth of latency - entirely acceptable for a test harness, not for a real safety-relevant RBC (this stays purely on the test-sim side, per this whole sims/ tree's own "not part of the RBC's own wire protocol" boundary, control_server.py's own doc).

5. Robot Framework side: a Python library, not more .resource dict wrangling

robot/supportFunctions/*.resource today is pure Robot Framework keyword syntax calling the standard Telnet/Process/OperatingSystem libraries. Building Set Message/Set Packet/nested trainMessages dictionary bookkeeping directly in Robot syntax works (Robot has Create Dictionary/Set To Dictionary/Get From Dictionary, Collections library) but gets genuinely awkward for anything nested more than one level deep, and gives poor failure messages compared to a real assertion in Python.

Recommendation: a new Robot Framework library (Python module, not a .resource keyword file) - supportFunctions/RbcMessages.py - holding the message-builder state, the JSON request/response calls, and the verify assertions as plain Python. Robot Framework auto-converts a Python snake_case function name into a Title Case With Spaces keyword name (e.g. def send_train_message(...) is callable from a .robot/.resource file as Send Train Message) - this is a standard, well-known Robot Framework library-authoring convention, not a new pattern being invented for this project. It resolves three naming questions from the earlier discussion at once, each layer speaking its own idiom natively:

Layer Convention Example
Robot-visible keyword Title Case With Spaces (RF idiom) Send Train Message
Python implementation snake_case (Python idiom) def send_train_message(...)
JSON wire field/command camelCase (JSON/REST idiom) "cmd": "sendMessage", "nidEngine"

5.1 Keyword catalog

  • Set Message ${message} - starts building a new message of the given kind (e.g. M136), clearing any previous in-progress message for this call chain.
  • Set Packet ${packet} ${field} ${value} - sets one field within the packet currently being built. Repeatable - call once per field. (Today, ${packet} is always the same name as ${message} - see section 2 - but the keyword still takes it explicitly so the contract does not need to change if that stops being true later.)
  • Send Train Message ${nid_engine} / Send IL Message ${nid_engine} / Send CTC Message ${nid_engine} - flushes the message built by the Set Message/Set Packet calls above, sends it to the given sim (by nid_engine -> which train/IL/ctc instance and site, see SIM_INTERFACES.md) via sendMessage, and clears the builder state ready for the next message. Fails the test immediately (not just returns false) if the sim itself reports ERR.
  • Wait Train Message ${nid_engine} ${message} ${timeout}=20s ${poll}=1s - polls getMessage until it succeeds or ${timeout} elapses (same shape as today's Wait Until Keyword Succeeds calls in rbc_scenario/), and on success stores the result into the library's own trainMessages structure (trainMessages[nid_engine] [message] = {packet: {field: value}}) so later Verify Packet calls in the same test do not need to re-query.
  • Verify Packet ${nid_engine} ${message} ${packet} ${field} ${expected} - looks up the cached value from the most recent Wait Train Message/Send Train Message for that nid_engine/message, and fails with a clear message ("M3.ma_length for nidEngine 1: expected 500, got 1000") if it does not match ${expected}. Never itself talks to the sim - always reads the cache populated by Wait Train Message, so a test can verify several fields of the same received message without several round trips, and so a test's intent ("wait for the message" vs "check its fields") stays visible as two separate, separately-named steps rather than one keyword doing both silently.

5.2 Worked example (illustrative Robot syntax, this design's own keywords)

Set Message M136
Set Packet M136 d_lrbg 500
Set Packet M136 cycle 12
Send Train Message ${TRAIN_1_NID_ENGINE}
Wait Train Message ${TRAIN_1_NID_ENGINE} M3
Verify Packet ${TRAIN_1_NID_ENGINE} M3 M3 ma_length 500
Verify Packet ${TRAIN_1_NID_ENGINE} M3 M3 ma_seq 1

6. What does NOT change

  • dual_link.py, rbc_wire.py, and the real RBC UDP wire protocol - untouched. This is purely a test-harness-facing addition.
  • control_server.py's existing plain-text commands (ADD_ROUTE, REPORT, PING) - kept as-is; sendMessage/getMessage are new commands added alongside them on the same port, not a replacement. (Whether to eventually re-express ADD_ROUTE/REPORT themselves in terms of sendMessage once this exists is a later simplification, not part of this design.)
  • robot/containers/ - those tests check container/process health (All Containers Are Running, No Container Is Crash Looping) and have nothing to do with RBC message content; out of scope.

7. Open items for implementation time (not scope-blocking for this design)

  • Exact Python exception -> Robot Framework failure mapping in RbcMessages.py (a malformed reply, a connection drop mid-query, etc.) - should fail the keyword clearly, not raise an unhandled Python exception that Robot reports as a generic library error.
  • Whether Send Train Message's own sendMessage should itself retry on a transient send failure, or leave that to the calling test case (existing precedent, ADR-029 section 2.6: a lost one-time RBC event has no safe automatic retry on the production side - this control channel is a different, test-only link without that constraint, so a bounded retry inside Send Train Message itself is likely fine, but should be a deliberate choice, not an accident).