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")
249 logging.basicConfig(level=logging.INFO, format=
"[%(levelname)s] %(message)s", stream=sys.stdout)
250 log = logging.getLogger(
"train")
256 link = _safecomm_link_from_config(log, config, TRAIN_WIRE_SIZE, sim_config)
258 log.info(
"train link: via RBC_Test_CommServer (Platform_Protocol_SafeComm path)")
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")),
264 link = DualHomedLink(log, targets, TRAIN_WIRE_SIZE)
267 saf_rbc_id = os.environ.get(
"RTE_SAFECOMM_TRAIN_RBC_ID",
"").strip()
269 keys_path = os.environ.get(
"RTE_SAFECOMM_TRAIN_KEYS_FILE",
"").strip()
271 log.error(
"[SAF] RTE_SAFECOMM_TRAIN_RBC_ID set without RTE_SAFECOMM_TRAIN_KEYS_FILE - refusing to start")
273 link = saf_train_link.SafTrainLink(link, log, int(saf_rbc_id, 0), saf_train_link.load_keys_file(keys_path),
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}
285 online_site = {
"value":
None}
313 connected_to_rbc = {}
333 HEARTBEAT_PERIOD_S = 6.0
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()}")
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
357 if _connect_msg_name
is None:
360 s = link.sockets[site]
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)
392 def _reset_train(tid):
393 state[tid] = {
"position_m": 0,
"cycle": 0}
401 last_received.pop(tid,
None)
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, {
422 BALISE_NAMES = site_data.balise_names()
424 def get_balise_name(nid):
427 return BALISE_NAMES.get(int(nid), f
"BG_{nid}")
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")."""
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
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)
467 "q_scale": vars_for_train.get(
"q_scale", ertms_codec.Q_SCALE_1M),
468 "nid_c": vars_for_train.get(
"nid_c", 0),
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),
477 "m_level": cur_level,
479 link.send_all(
_pad(CATALOG.build_envelope(
"M136", tid, wire_fields)))
485 payload = dict(wire_fields)
486 payload[
"cycle"] = st[
"cycle"]
487 payload[
"nid_lrbg"] = cur_lrbg
490 def handle_report(args):
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)
508 def handle_ping(_args):
511 def handle_connect(_args):
517 connected_to_rbc[tid] =
True
518 link.ensure_connected()
522 def handle_disconnect(_args):
524 link.disconnect(site)
527 def handle_disconnect_with_online(_args):
528 site = online_site[
"value"]
530 return "ERR no site currently believed online\n"
531 link.disconnect(site)
534 def handle_disconnect_with_standby(_args):
535 site = online_site[
"value"]
537 return "ERR no site currently believed online\n"
538 standby =
"EAST" if site ==
"WEST" else "WEST"
539 link.disconnect(standby)
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)
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"}
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)
575 if packets
is None and message
in (
"24",
"15",
"M15"):
576 packets = last_received.get(nid_engine, {}).get(
"PositionReportAck")
578 return {
"status":
"ERR",
"reason": f
"no {message} received yet for nidEngine {nid_engine}"}
579 result_packets = {message: packets}
581 for pkt_name
in CATALOG.packets_for(message):
582 result_packets[pkt_name] = packets
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}
593 def handle_connect_train(request):
594 nid_engine = request[
"nidEngine"]
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)
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)
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"}
630 def handle_disconnect_train(request):
631 nid_engine = request[
"nidEngine"]
633 s = link.sockets[site]
636 s.sendall(
_pad(CATALOG.build_envelope(
"TRAIN_DISCONNECT", nid_engine, {})))
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"}
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
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"}
670 def handle_send_train_message(request):
672 nid_engine = request[
"nidEngine"]
673 message = request[
"message"]
674 packet = request.get(
"packet", message)
675 stored = train_vars.get(nid_engine, {})
692 if CATALOG.is_ertms(message):
693 fields = dict(stored)
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:
706 if "d_lrbg" in fields:
707 train_st[
"position_m"] = fields[
"d_lrbg"]
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"}
730 def handle_clear_messages(_request):
731 last_received.clear()
732 return {
"status":
"OK"}
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):
752 s = link.sockets[site]
755 s.sendall(
_pad(CATALOG.build_envelope(
"TRAIN_DISCONNECT", tid, {})))
758 connected_to_rbc[tid] =
False
759 ertms_post_eom[tid] =
False
760 next_heartbeat.pop(tid,
None)
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"}
767 control = ControlServer(
771 "REPORT": handle_report,
773 "CONNECT": handle_connect,
774 "DISCONNECT": handle_disconnect,
775 "DISCONNECTWITHONLINE": handle_disconnect_with_online,
776 "DISCONNECTWITHSTANDBY": handle_disconnect_with_standby,
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,
788 bind_host=control_host,
793 def _stop(signum, _frame):
795 log.info(
"[GENERAL] [%s] [internal] [SIGNAL] [Train sim shutting down] [signal=%d]",
796 f
"train-{site_tag.lower()}", signum)
799 signal.signal(signal.SIGTERM, _stop)
800 signal.signal(signal.SIGINT, _stop)
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)
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)})
813 link.ensure_connected()
815 now_connected = link.sockets[site]
is not None
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):
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)
832 was_connected[site] = now_connected
834 control.poll(timeout=0.0)
835 status.tick({
"trains": sum(1
for v
in connected_to_rbc.values()
if v)})
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):
846 if ertms_post_eom.get(tid):
848 cur_vars = train_vars.get(tid, {})
849 cur_speed = cur_vars.get(
"v_train", 0)
851 delta_m = int(round((cur_speed * 1000.0 / 3600.0) * period))
852 new_position = state[tid][
"position_m"] + max(1, delta_m)
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 ""
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
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)
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),
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 ""
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
905 for site, data
in link.poll_recv(timeout=0.2):
906 decoded = CATALOG.decode_to_message(data)
908 log.warning(
"[Train 1] [internal] [MALFORMED] [Malformed envelope dropped] [site=%s]", site)
910 nid_engine, message, fields = decoded
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
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"):
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"))
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"])
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)
966 log.info(
"stopped after %d cycle(s)", sum(st[
"cycle"]
for st
in state.values()))