SimCore
Shared transport-layer plumbing for the ADR-029 Train/IL/CTC RBC simulators
Loading...
Searching...
No Matches
rbc_messages.py
Go to the documentation of this file.
1"""Structured message layer on top of rbc_wire.py's raw envelope codec
2(safeAPITestEnv/doc/design/DESIGN.md) - translates between the JSON
3sendMessage/getMessage control-port commands (control_server.py) and
4real rbc_envelope_t bytes (rbc_wire.py). rbc_wire.py itself is
5deliberately untouched by this module (DESIGN.md section 6 - "what does
6NOT change") - this is a new layer above it, not a replacement: every
7sim still sends/receives the exact same wire bytes it always has, this
8module only adds a named, field-validated way to build/decode them.
9
10The message/packet/field vocabulary itself is DATA, not code - loaded
11from each sim's own message_catalog.json (sims/train/, sims/il/,
12sims/ctc/ - NOT one shared catalog, deliberately: a sim's own catalog
13only lists the messages THAT sim actually sends/receives, e.g. il's
14never mentions M3 at all since IL neither sends nor receives it - see
15each catalog file's own header). Adding/adjusting a message's fields or
16allowed values is a JSON edit, not a Python change.
17
18MessageCatalog is a class, not a module-level singleton, specifically
19so each sim can load its OWN catalog file independently - see this
20module's previous, single-shared-catalog version's own history if
21comparing; that shape did not allow one sim's catalog to differ from
22another's, which is exactly what per-sim catalogs need.
23
24**Two coexisting wire families, per-message "wireFormat"** (added
25alongside the original flat scheme, not replacing it - the existing
2608-18 Robot suite still depends on the flat family working exactly as
27before): a message with no "wireFormat" key (every pre-existing catalog
28entry) or `"wireFormat": "flat"` uses the original path (rbc_wire.py,
29kind-based, always ENVELOPE_SIZE bytes, generic and shared - SimCore
30owns the shape, no sim-specific knowledge needed). `"wireFormat": "ertms"`
31uses a real Subset-026 codec instead - genuinely variable length,
32message-specific shapes (Packet 15/21/27 for an MA, Packet 3/57/58/65/66
33for a General Message, etc.), which is Subset-026-the-Train-RBC-air-gap-
34protocol specifically - IL and CTC never speak it in this codebase (same
35"a sim's own catalog only lists the messages THAT sim uses" principle
36above), so unlike rbc_wire.py this is NOT something SimCore can own
37generically. **This module stays sim-agnostic**: it does not import any
38ertms codec itself - a caller whose OWN catalog declares "ertms"
39messages must inject two things at construction time:
40 - `ertms_codec`: the codec module (e.g. TrainRBCSim's own
41 rbc_ertms_wire.py) - exposes decode(buf) -> (nid_message, decoded_dict).
42 - `ertms_encoders`: {catalog_message_name: (nid_engine, fields_dict,
43 t_train) -> bytes} - one small adapter per message this sim sends,
44 mapping ITS OWN flat {field: int} fields dict (the same shape every
45 JSON sendMessage command already uses for the flat scheme) onto the
46 codec's own per-message-shaped encode_msg_*() signature.
47 - `ertms_flatteners`: {nid_message: decoded_dict -> flat_fields_dict} -
48 one small adapter per message this sim receives, flattening the
49 codec's own rich nested decode() output into the same flat
50 {field: value} shape decode_to_message() already returns for the
51 flat scheme.
52Both default to empty/None - a catalog with no "ertms" messages (IL's,
53CTC's) never needs any of this. Wire bytes for both families are wrapped
54identically by one shared outer kind, declared once per catalog file as
55a top-level `"_ertmsWrapperKind"` key (RBC_MSG_ERTMS_ENVELOPE=22 today -
56moved from 21 after a real collision with a catalog's own flat
57TRAIN_CONNECT=21, see __init__()'s own comment on the default below) -
58kept catalog-data-driven like every other kind number this module
59touches, never hardcoded here (see this module's own precedent for
60`kind`/`nidMessage` above), read once at load time, absent/None if a
61catalog defines no "ertms" messages at all.
62"""
63
64import json
65import os
66
67from . import rbc_wire as _wire
68
69
70class UnknownMessageError(ValueError):
71 """@message (sendMessage/getMessage) is not in this catalog."""
72
73
74class UnknownFieldError(ValueError):
75 """@fields (sendMessage) names a field that message's own packet
76 does not define in this catalog."""
77
78
79class InvalidFieldValueError(ValueError):
80 """@fields (sendMessage) gives a field a value outside its catalog-
81 defined allowed range/allowedValues."""
82
83
84class WrongDirectionError(ValueError):
85 """build_envelope() was asked to build a message this catalog marks
86 "direction": "receive" - this sim is not the one that originates
87 it (e.g. asking the Train sim to build an M3, which only the RBC
88 itself ever sends)."""
89
90
92 def __init__(self, catalog_path, ertms_codec=None, ertms_encoders=None, ertms_flatteners=None):
93 """@param catalog_path: path to this sim's own message_catalog.json.
94 Callers should pass an absolute path built from their own
95 __file__ (e.g. os.path.join(os.path.dirname(os.path.abspath(
96 __file__)), "message_catalog.json")) rather than a bare relative
97 name, since a relative path here would otherwise depend on the
98 process's own current working directory at startup, not on
99 where the catalog file actually lives on disk.
100 @param ertms_codec, ertms_encoders, ertms_flatteners: only needed
101 if this catalog declares any "wireFormat": "ertms" message - see
102 this module's own header doc for what each one is. Omitted
103 (None/{}) for a catalog with no ertms messages (raises at load
104 time below if that combination is wrong either way)."""
105 path = catalog_path
106 ertms_encoders = ertms_encoders or {}
107 ertms_flatteners = ertms_flatteners or {}
108 with open(path, "r", encoding="utf-8") as f:
109 raw = json.load(f)
110 messages = {}
111 packets_catalog = {}
112 has_ertms_messages = False
113 import re
114 for name, spec in raw.get("messages", {}).items():
115 if name.startswith("P") and (name[1:].isdigit() or name in (
116 "P0", "P1", "P2", "P11", "P12", "P15", "P65", "P66", "P68", "P72", "P131", "P136"
117 )):
118 packets_catalog[name] = spec
119 continue
120
121 wire_format = spec.get("wireFormat")
122 if wire_format is None:
123 if "kind" in spec and not name.startswith("M"):
124 wire_format = "flat"
125 else:
126 wire_format = "ertms"
127
128 if wire_format not in ("flat", "ertms"):
129 raise ValueError(f"{path}: {name!r} has wireFormat {wire_format!r} - must be \"flat\" or \"ertms\"")
130
131 direction = spec.get("direction")
132 if not direction:
133 digits = re.findall(r'\d+', name)
134 if digits:
135 num = int(digits[0])
136 direction = "send" if num >= 120 else "receive"
137 elif name in ("TRAIN_CONNECT", "TRAIN_DISCONNECT"):
138 direction = "send"
139 else:
140 direction = "send"
141
142 if direction not in ("send", "receive"):
143 raise ValueError(f"{path}: {name!r} has direction {direction!r} - must be \"send\" or \"receive\"")
144
145 if wire_format == "ertms":
146 nid_message = spec.get("nidMessage")
147 if nid_message is None:
148 digits = re.findall(r'\d+', name)
149 if digits:
150 nid_message = int(digits[0])
151 else:
152 nid_message = 0
153 if not isinstance(nid_message, int) or isinstance(nid_message, bool) or not (0 <= nid_message <= 255):
154 raise ValueError(f"{path}: {name!r} (wireFormat=ertms) nidMessage={nid_message!r} "
155 "is not a plausible wire byte (0-255) - required, not optional, for ertms messages")
156
157 raw_fields = spec.get("fields", {})
158 if isinstance(raw_fields, list):
159 all_fields = {f: {} for f in raw_fields}
160 elif isinstance(raw_fields, dict):
161 all_fields = raw_fields
162 else:
163 all_fields = {}
164
165 has_ertms_messages = True
166 msg_entry = {
167 "kind": None,
168 "nid_message": nid_message,
169 "direction": direction,
170 "packets": {},
171 "fields": all_fields,
172 "wire_format": "ertms",
173 }
174 messages[name] = msg_entry
175 if name.startswith("M") and name[1:].isdigit():
176 messages[name[1:]] = msg_entry
177 elif name.isdigit():
178 messages[f"M{name}"] = msg_entry
179 continue
180
181 packets = spec.get("packets", {})
182 if not packets:
183 raise ValueError(
184 f"{path}: {name!r} must define at least one packet in 'packets'"
185 )
186 kind = spec["kind"]
187 if not isinstance(kind, int) or isinstance(kind, bool) or not (0 <= kind <= 255):
188 raise ValueError(f"{path}: {name!r} kind={kind!r} is not a plausible wire kind byte (0-255)")
189 nid_message = spec.get("nidMessage", 0)
190 if not isinstance(nid_message, int) or isinstance(nid_message, bool) or not (0 <= nid_message <= 255):
191 raise ValueError(f"{path}: {name!r} nidMessage={nid_message!r} is not a plausible wire byte (0-255)")
192
193 all_fields = {}
194 for pkt_name, pkt_spec in packets.items():
195 if isinstance(pkt_spec, dict) and "fields" in pkt_spec:
196 all_fields.update(pkt_spec["fields"])
197
198 msg_entry = {
199 "kind": kind,
200 "nid_message": nid_message,
201 "direction": direction,
202 "packets": packets,
203 "fields": all_fields,
204 "wire_format": "flat",
205 }
206 messages[name] = msg_entry
207 if name == "TRAIN_CONNECT":
208 messages["P0"] = msg_entry
209 elif name == "P0":
210 messages["TRAIN_CONNECT"] = msg_entry
211
212 ertms_wrapper_kind = raw.get("_ertmsWrapperKind")
213 if has_ertms_messages and ertms_wrapper_kind is None:
214 # RBC_MSG_ERTMS_ENVELOPE (rbc_wire_types.h, both C-side copies)
215 # - deliberately 22, NOT 21: TrainRBCSim's own
216 # message_catalog.json defines a flat "TRAIN_CONNECT"/"P0"
217 # message with kind=21 (added independently, after this value
218 # already claimed 21) - a real, found-live collision (a plain
219 # connect handshake was being routed into the ERTMS decoder
220 # and rejected as malformed). Keep this default numerically
221 # synced with the C-side enum if it ever moves again.
222 ertms_wrapper_kind = 22
223 if ertms_wrapper_kind is not None:
224 if not isinstance(ertms_wrapper_kind, int) or isinstance(ertms_wrapper_kind, bool) \
225 or not (0 <= ertms_wrapper_kind <= 255):
226 raise ValueError(f"{path}: _ertmsWrapperKind={ertms_wrapper_kind!r} is not a plausible wire byte (0-255)")
227 if has_ertms_messages and ertms_codec is None:
228 raise ValueError(f"{path}: has wireFormat=ertms message(s) but no ertms_codec was injected "
229 "(MessageCatalog(catalog_path, ertms_codec=..., ...) - see this module's own doc)")
230
231 self._path = path
232 self._messages = messages
233 self._packets = packets_catalog
234 self._ertms_codec = ertms_codec
235 self._ertms_encoders = ertms_encoders
236 self._ertms_flatteners = ertms_flatteners
237 self._ertms_wrapper_kind = ertms_wrapper_kind
238 self._name_by_kind = {spec["kind"]: name for name, spec in messages.items() if spec["wire_format"] == "flat"}
239 # Deliberately excludes the bare-digit alias keys (e.g. "24") this
240 # loader also registers for every "M<nid>"-named ertms message
241 # (see the aliasing just above) - a real, found-live bug: without
242 # this filter, a dict comprehension over ALL of messages.items()
243 # picks whichever of the two keys for the same nid_message was
244 # inserted LAST (Python dict insertion order), which is always
245 # the bare-digit alias (registered immediately after its own
246 # canonical name in the loop above) - so decode_to_message() was
247 # silently returning "24" instead of "M24" for every incoming
248 # real Subset-026 Msg 24, even though callers only ever look it
249 # up by the canonical "M24" name (message_catalog.json's own
250 # documented convention, and the only form a Robot test would
251 # sensibly request). Restricting this reverse map to "M"-prefixed
252 # keys makes the canonical name win unconditionally, regardless
253 # of dict insertion order.
254 self._name_by_nid_message_ertms = {spec["nid_message"]: name for name, spec in messages.items()
255 if (spec["wire_format"] == "ertms") and name.startswith("M")}
256
257 def message_names(self):
258 """@return every message name in this catalog, sorted."""
259 return sorted(self._messages)
260
261 def is_ertms(self, message):
262 """@return True if @message is a "wireFormat": "ertms" catalog
263 entry, False if "flat". Lets a caller decide whether
264 fields_for(message)'s own field-NAME list is safe to use as a
265 real, populated-variable-store filter (true for flat messages,
266 whose "fields" are this project's own snake_case variable names)
267 or is purely Subset-026 documentation text unrelated to any
268 variable a caller would actually have stored (true for ertms
269 messages - see message_catalog.json's own field lists for e.g.
270 "M129"/"M132": ["NID_MESSAGE", "L_MESSAGE", ..., "Train data
271 (Packet type 11)"], never matching real stored field names like
272 "l_train"/"v_maxtrain"). Raises UnknownMessageError if @message
273 is not in this catalog at all."""
274 if message not in self._messages:
275 raise UnknownMessageError(f"unknown message {message!r} - known: {self.message_names()}")
276 return self._messages[message]["wire_format"] == "ertms"
277
278 def build_ertms_envelope_raw(self, ertms_codec_bytes, train_id):
279 """@return the wire datagram wrapping already-encoded ertms_codec
280 bytes with this catalog's own _ertmsWrapperKind+train_id prefix -
281 the same 2-byte prefix build_envelope() applies for a catalog-
282 registered "wireFormat": "ertms" message, exposed for a sim's own
283 internally-driven sends (e.g. a periodic heartbeat) that have no
284 catalog entry of their own name to look up an encoder by. Raises
285 if this catalog has no ertms wrapper kind configured at all."""
286 if self._ertms_wrapper_kind is None:
287 raise ValueError(f"{self._path} has no _ertmsWrapperKind configured - "
288 "cannot build a raw ertms envelope")
289 return bytes([self._ertms_wrapper_kind, train_id & 0xFF]) + ertms_codec_bytes
290
291 def packet_names(self):
292 """@return every packet name in this catalog, sorted."""
293 return sorted(self._packets)
294
295 def packets_for(self, message):
296 """@return dict of packet_name -> packet_spec for @message."""
297 if message not in self._messages:
298 raise UnknownMessageError(f"unknown message {message!r} - known: {self.message_names()}")
299 return self._messages[message]["packets"]
300
301 def fields_for(self, message, packet=None):
302 """@return {field_name: spec_dict} for @message. If packet is specified,
303 returns fields for that specific packet; otherwise returns all fields for the message."""
304 if message not in self._messages:
305 raise UnknownMessageError(f"unknown message {message!r} - known: {self.message_names()}")
306 if packet is not None:
307 packets = self._messages[message]["packets"]
308 if packet in packets:
309 return packets[packet].get("fields", {})
310 if packet == message:
311 return self._messages[message]["fields"]
312 raise UnknownFieldError(f"message {message!r} has no packet {packet!r} - known: {list(packets)}")
313 return self._messages[message]["fields"]
314
315 def build_envelope(self, message, train_id, fields):
316 """@return the raw wire datagram for @message/@train_id/@fields (a
317 dict of field name -> int, may omit fields to leave them 0).
318 Raises UnknownMessageError/WrongDirectionError/UnknownFieldError/
319 InvalidFieldValueError on a bad request - a typo'd name, a
320 message this sim does not originate, or an out-of-catalog value
321 (e.g. msg_type=99, not 24 or 15) must fail loudly here, not be
322 silently sent onto the wire as garbage.
323
324 For a "wireFormat": "ertms" message, delegates to this catalog's
325 own injected ertms_encoders[message] adapter (byte length is
326 genuinely message-specific, not this method's own concern the
327 way the flat scheme's l_message computation below is) and wraps
328 the result with the shared 2-byte _ertmsWrapperKind+train_id
329 prefix - see this module's own header doc."""
330 if message not in self._messages:
331 raise UnknownMessageError(f"unknown message {message!r} - known: {self.message_names()}")
332 if self._messages[message]["direction"] != "send":
333 raise WrongDirectionError(f"{message} is direction=receive in {self._path} - this sim does not send it")
334 if self._messages[message]["wire_format"] == "ertms":
335 encoder = self._ertms_encoders.get(message) or self._ertms_encoders.get(message.lstrip("M")) or self._ertms_encoders.get(f"M{message}")
336 if encoder is None:
337 raise ValueError(f"no ertms_encoders[{message!r}] was injected for ERTMS send message")
338 codec_bytes = encoder(train_id, fields, fields.get("t_train", 0))
339 return bytes([self._ertms_wrapper_kind, train_id & 0xFF]) + codec_bytes
340
341 field_specs = self.fields_for(message)
342 unknown = set(fields) - set(field_specs)
343 if unknown:
344 raise UnknownFieldError(
345 f"{message} does not have field(s) {sorted(unknown)} - allowed: {sorted(field_specs)}"
346 )
347 for field, value in fields.items():
348 _validate_value(message, field, field_specs[field], value)
349 # REQ-RBC-001 (revised): l_message is genuinely calculated here,
350 # from this message's OWN catalog field list, not hand-set by a
351 # caller - HEADER(12) + BASE_CONTENT(24) is every existing
352 # message's own shape (unchanged), plus whichever kind-specific
353 # extra content this message's catalog fields actually declare -
354 # mirrors rbc_wire.c's own content_size_for_kind(), the C side's
355 # equally-hardcoded (not catalog-driven there) equivalent. At
356 # most one of these applies per message today (each catalog
357 # message maps to exactly one kind, which has exactly one extra-
358 # content shape - see rbc_wire_types.h's own doc).
359 extra_content = 0
360 if "t_train_ack" in field_specs:
361 extra_content = 4 # RBC_MSG_M146's own extra content (rbc_wire.c's RBC_WIRE_CONTENT_SIZE_M146)
362 elif "nid_lrbg" in field_specs:
363 # RBC_MSG_M136's own extra content (rbc_wire.c's RBC_WIRE_CONTENT_SIZE_M136) - the
364 # (unused, always-zero for this kind) t_train_ack slot(4) it sits after on the wire,
365 # PLUS the 10 real Packet 0 fields(40) - see rbc_wire.c's own comment: fields are
366 # always written at the same fixed offsets for every kind, so M136's own fields start
367 # right AFTER t_train_ack's slot, not instead of it.
368 extra_content = 4 + 40
369 elif ("start_signal" in field_specs) or ("end_signal" in field_specs):
370 # RBC_MSG_ROUTE_ADD/_ROUTE_RELEASE/_TRAIN_POSITION_IN_ROUTE's own extra content
371 # (IL<->RBC route-identity pass) - these fields sit at offsets 80-95, past the
372 # (unused-for-these-kinds) t_train_ack(4)+M136 block(40) - so the whole gap up to
373 # them (44) plus their own 16 bytes is "extra content" the same way M136's own
374 # block is, even though these kinds never touch t_train_ack/M136's own fields.
375 extra_content = 4 + 40 + 16
376 else:
377 extra_content = 0
378 l_message = 12 + 24 + extra_content
379 return _wire.encode(
380 self._messages[message]["kind"],
381 train_id,
382 nid_message=self._messages[message]["nid_message"],
383 l_message=l_message,
384 **fields,
385 )
386
387 def decode_to_message(self, data):
388 """@return (train_id, message_name, fields_dict) for a raw
389 received datagram, or None if malformed/unrecognized by THIS
390 catalog (mirrors rbc_wire.decode()'s own contract -
391 REQ-RBC-002's Python side) - including a structurally valid
392 envelope of a kind this sim's own catalog simply does not list
393 (e.g. a ROUTE_ADD arriving at the Train sim, which should never
394 happen but is treated the same as any other unrecognized kind,
395 not a crash). fields_dict holds only the fields THIS message's
396 own packet defines, not the whole flat envelope.
397
398 First checks data[0] against this catalog's own _ertmsWrapperKind
399 (if any "ertms" messages are declared) and routes to the injected
400 ertms_codec + ertms_flatteners for that family; falls through to
401 the original flat-scheme path (_wire.decode()) otherwise - a
402 single call site works for either family, same as build_envelope()."""
403 if (self._ertms_wrapper_kind is not None) and (len(data) >= 2) and (data[0] == self._ertms_wrapper_kind):
404 train_id = data[1]
405 try:
406 nid_message, decoded = self._ertms_codec.decode(data[2:])
407 except Exception:
408 # ErtmsDecodeError (or any other decode failure) - same
409 # "malformed/unrecognized is None, not a crash" contract
410 # the flat branch below already has for _wire.decode().
411 return None
412 name = self._name_by_nid_message_ertms.get(nid_message)
413 if name is None:
414 return None
415 flattener = self._ertms_flatteners.get(nid_message)
416 fields = flattener(decoded) if flattener is not None else decoded
417 return train_id, name, fields
418
419 env = _wire.decode(data)
420 if env is None:
421 return None
422 name = self._name_by_kind.get(env["kind"])
423 if name is None:
424 return None
425 fields = {field: env[field] for field in self._messages[name]["fields"]}
426 return env["train_id"], name, fields
427
428
429def _validate_value(message, field, spec, value):
430 if not isinstance(value, int) or isinstance(value, bool):
431 raise InvalidFieldValueError(f"{message}.{field}: value {value!r} is not an integer")
432 allowed_values = spec.get("allowedValues")
433 if allowed_values is not None:
434 if value not in allowed_values:
435 raise InvalidFieldValueError(f"{message}.{field}: {value} not in allowed values {allowed_values}")
436 return
437 lo, hi = spec.get("min"), spec.get("max")
438 if lo is not None and value < lo:
439 raise InvalidFieldValueError(f"{message}.{field}: {value} < minimum {lo}")
440 if hi is not None and value > hi:
441 raise InvalidFieldValueError(f"{message}.{field}: {value} > maximum {hi}")
fields_for(self, message, packet=None)
build_ertms_envelope_raw(self, ertms_codec_bytes, train_id)
__init__(self, catalog_path, ertms_codec=None, ertms_encoders=None, ertms_flatteners=None)
build_envelope(self, message, train_id, fields)
_validate_value(message, field, spec, value)