SimCore
Shared transport-layer plumbing for the ADR-029 Train/IL/CTC RBC simulators
Loading...
Searching...
No Matches
control_server.py
Go to the documentation of this file.
1"""Non-blocking line-based TCP command server (ADR-029) - gives any sim
2built on SimCore a real TCP/IP control port, with its own dedicated
3command table: `ControlServer` takes @handlers/@json_handlers as
4constructor arguments rather than owning any commands itself, so
5Train/IL/CTC each wire up only the commands that make sense for that
6sim (e.g. Train: REPORT/PING + sendMessage/getMessage; IL: ADD_ROUTE/
7PING + sendMessage; CTC: PING + getMessage only - CTC is receive-only in
8this scenario) - no sim sees another sim's commands, and SimCore itself
9defines none. Lets Robot Framework (safeAPITestEnv/robot/) drive a
10Train/IL sim's real, test-relevant actions on demand (e.g. "add a route
11now", "report this position now") instead of only ever firing on a
12fixed wall-clock schedule. Robot talks to this with its own built-in
13Telnet library (no extra Python dependency needed) - see
14safeAPITestEnv/robot/ for the keywords that use it.
15
16Owns its own small selectors.DefaultSelector, polled non-blockingly
17(timeout=0) once per iteration of the sim's own main loop, right
18alongside that loop's own dual_link.py poll - a second cheap select()
19syscall per iteration, not a separate thread. One accepted control
20connection is served at a time per sim, which is all a test harness
21needs.
22
23Two command styles share this one port/line-framing, distinguished by
24the line's own first character:
25
26- **Plain-text commands** (original ADR-029 protocol): one command per
27 line, ASCII, `NAME arg1 arg2\\n`. Unrecognized commands get
28 "ERR unknown command\\n"; a command's own handler decides its own
29 success reply (e.g. "OK\\n"). Still used by REPORT/ADD_ROUTE/PING -
30 unchanged.
31- **JSON commands** (safeAPITestEnv/doc/design/DESIGN.md - structured
32 sendMessage/getMessage): a line starting with `{` is parsed as one
33 JSON object, `{"cmd": "<name>", ...}`; the handler receives the whole
34 parsed dict and returns a dict, which is sent back JSON-encoded plus
35 a trailing newline. Kept as a genuinely separate dispatch path (not
36 merged into the plain-text one by, say, treating `{"cmd":...}` as a
37 single whitespace-free token) specifically because a JSON payload
38 routinely contains internal whitespace (`{"cmd": "sendMessage", ...}`)
39 that plain-text's own `line.split()` would incorrectly tokenize.
40
41Neither style is part of the RBC's own rbc_envelope_t wire protocol -
42this whole channel is kept deliberately separate (different port,
43different framing) so a malformed/foreign line here can never be
44confused for real RBC traffic.
45"""
46
47import json
48import selectors
49import socket
50
51
53 def __init__(self, conn, addr):
54 self.conn = conn
55 self.addr = addr
56 self.buf = b""
57
58
60 def __init__(self, log, port, handlers, json_handlers=None, bind_host="0.0.0.0"):
61 """@param bind_host: interface to listen on - "0.0.0.0" (every
62 interface, the previous hardcoded behavior) unless the
63 sim's own config file (sim_config.py) says otherwise.
64 @param handlers: {command_name: fn(args: list[str]) -> str}
65 - fn's return value is written back verbatim (the handler
66 is responsible for its own trailing newline). Command
67 names are matched case-insensitively (upper-cased before
68 lookup) - @handlers' own keys should be upper-case.
69 @param json_handlers: {cmd_name: fn(request: dict) -> dict} -
70 fn's return value is JSON-encoded and a trailing newline
71 appended automatically; fn should raise (any Exception)
72 rather than return an error dict itself - the caller
73 turns that into {"status": "ERR", "reason": str(exc)}
74 uniformly, same "a bad command must not crash the sim"
75 contract as the plain-text handlers below. Command names
76 (the JSON request's own "cmd" field) are matched
77 case-sensitively, unlike plain-text commands - a JSON
78 API's own field values are not conventionally
79 case-folded the way a human-typed line's leading word is.
80 """
81 self.log = log
82 self.sel = selectors.DefaultSelector()
83 self.handlers = handlers
84 self.json_handlers = json_handlers or {}
85 self._listen = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
86 self._listen.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
87 self._listen.bind((bind_host, port))
88 self._listen.listen(16)
89 self._listen.setblocking(False)
90 self.sel.register(self._listen, selectors.EVENT_READ, data=None)
91 all_commands = sorted(handlers) + sorted(self.json_handlers)
92 self.log.info("control server listening on :%d (commands: %s)", port, ", ".join(all_commands))
93
94 def poll(self, timeout=0.0):
95 """Call once per iteration of the sim's own main loop - dispatches
96 any ready accept/read event (non-blocking with @timeout=0, or a
97 small blocking budget if the caller wants this to double as its
98 own pacing tick)."""
99 for key, mask in self.sel.select(timeout=timeout):
100 if key.data is None:
101 self._on_accept(mask)
102 else:
103 self._on_data(key.data, mask)
104
105 def _on_accept(self, _mask):
106 try:
107 conn, addr = self._listen.accept()
108 except OSError:
109 return
110 conn.setblocking(False)
111 client = _ControlClient(conn, addr)
112 self.sel.register(conn, selectors.EVENT_READ, data=client)
113 self.log.info("control connection from %s", addr)
114
115 def _on_data(self, client, _mask):
116 try:
117 chunk = client.conn.recv(4096)
118 except OSError:
119 self._close_client(client)
120 return
121 if not chunk:
122 self._close_client(client)
123 return
124 client.buf += chunk
125 while b"\n" in client.buf:
126 line, client.buf = client.buf.split(b"\n", 1)
127 self._handle_line(client, line.decode("ascii", errors="replace").strip())
128
129 def _handle_line(self, client, line):
130 if not line:
131 return
132 if line.startswith("{"):
133 reply = self._handle_json_line(line)
134 encoding = "utf-8"
135 else:
136 parts = line.split()
137 name = parts[0].upper()
138 args = parts[1:]
139 handler = self.handlers.get(name)
140 if handler is None:
141 reply = "ERR unknown command\n"
142 else:
143 try:
144 reply = handler(args)
145 except Exception as exc: # noqa: BLE001 - a bad command must not crash the sim
146 self.log.warning("control command %s failed: %s", name, exc)
147 reply = f"ERR {exc}\n"
148 encoding = "ascii"
149 if client.conn is not None:
150 try:
151 client.conn.sendall(reply.encode(encoding, errors="replace"))
152 except OSError:
153 self._close_client(client)
154
155 def _handle_json_line(self, line):
156 """@return the JSON-encoded (plus trailing newline) reply string
157 for one JSON-command line - see this class's own doc for the
158 {"cmd": ...} request/{"status": "OK"|"ERR", ...} reply shape."""
159 try:
160 request = json.loads(line)
161 except json.JSONDecodeError as exc:
162 return json.dumps({"status": "ERR", "reason": f"malformed JSON: {exc}"}) + "\n"
163 if not isinstance(request, dict) or "cmd" not in request:
164 return json.dumps({"status": "ERR", "reason": "request must be a JSON object with a \"cmd\" field"}) + "\n"
165 name = request["cmd"]
166 handler = self.json_handlers.get(name)
167 if handler is None:
168 return json.dumps({"status": "ERR", "reason": f"unknown cmd {name!r}"}) + "\n"
169 try:
170 response = handler(request)
171 except Exception as exc: # noqa: BLE001 - a bad command must not crash the sim
172 self.log.warning("control command %s failed: %s", name, exc)
173 return json.dumps({"status": "ERR", "reason": str(exc)}) + "\n"
174 return json.dumps(response) + "\n"
175
176 def _close_client(self, client):
177 if client.conn is not None:
178 try:
179 self.sel.unregister(client.conn)
180 except (KeyError, ValueError):
181 pass
182 try:
183 client.conn.close()
184 except OSError:
185 pass
186 client.conn = None
__init__(self, log, port, handlers, json_handlers=None, bind_host="0.0.0.0")