SimCore
Shared transport-layer plumbing for the ADR-029 Train/IL/CTC RBC simulators
Toggle main menu visibility
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
2
built on SimCore a real TCP/IP control port, with its own dedicated
3
command table: `ControlServer` takes @handlers/@json_handlers as
4
constructor arguments rather than owning any commands itself, so
5
Train/IL/CTC each wire up only the commands that make sense for that
6
sim (e.g. Train: REPORT/PING + sendMessage/getMessage; IL: ADD_ROUTE/
7
PING + sendMessage; CTC: PING + getMessage only - CTC is receive-only in
8
this scenario) - no sim sees another sim's commands, and SimCore itself
9
defines none. Lets Robot Framework (safeAPITestEnv/robot/) drive a
10
Train/IL sim's real, test-relevant actions on demand (e.g. "add a route
11
now", "report this position now") instead of only ever firing on a
12
fixed wall-clock schedule. Robot talks to this with its own built-in
13
Telnet library (no extra Python dependency needed) - see
14
safeAPITestEnv/robot/ for the keywords that use it.
15
16
Owns its own small selectors.DefaultSelector, polled non-blockingly
17
(timeout=0) once per iteration of the sim's own main loop, right
18
alongside that loop's own dual_link.py poll - a second cheap select()
19
syscall per iteration, not a separate thread. One accepted control
20
connection is served at a time per sim, which is all a test harness
21
needs.
22
23
Two command styles share this one port/line-framing, distinguished by
24
the 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
41
Neither style is part of the RBC's own rbc_envelope_t wire protocol -
42
this whole channel is kept deliberately separate (different port,
43
different framing) so a malformed/foreign line here can never be
44
confused for real RBC traffic.
45
"""
46
47
import
json
48
import
selectors
49
import
socket
50
51
52
class
_ControlClient
:
53
def
__init__
(self, conn, addr):
54
self.
conn
= conn
55
self.
addr
= addr
56
self.
buf
= b
""
57
58
59
class
ControlServer
:
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
simcore.control_server.ControlServer
Definition
control_server.py:59
simcore.control_server.ControlServer._listen
_listen
Definition
control_server.py:85
simcore.control_server.ControlServer.log
log
Definition
control_server.py:81
simcore.control_server.ControlServer.poll
poll(self, timeout=0.0)
Definition
control_server.py:94
simcore.control_server.ControlServer._close_client
_close_client(self, client)
Definition
control_server.py:176
simcore.control_server.ControlServer.handlers
handlers
Definition
control_server.py:83
simcore.control_server.ControlServer.__init__
__init__(self, log, port, handlers, json_handlers=None, bind_host="0.0.0.0")
Definition
control_server.py:60
simcore.control_server.ControlServer.json_handlers
json_handlers
Definition
control_server.py:84
simcore.control_server.ControlServer.sel
sel
Definition
control_server.py:82
simcore.control_server.ControlServer._handle_json_line
_handle_json_line(self, line)
Definition
control_server.py:155
simcore.control_server.ControlServer._on_accept
_on_accept(self, _mask)
Definition
control_server.py:105
simcore.control_server.ControlServer._on_data
_on_data(self, client, _mask)
Definition
control_server.py:115
simcore.control_server.ControlServer._handle_line
_handle_line(self, client, line)
Definition
control_server.py:129
simcore.control_server._ControlClient
Definition
control_server.py:52
simcore.control_server._ControlClient.buf
str buf
Definition
control_server.py:56
simcore.control_server._ControlClient.__init__
__init__(self, conn, addr)
Definition
control_server.py:53
simcore.control_server._ControlClient.addr
addr
Definition
control_server.py:55
simcore.control_server._ControlClient.conn
conn
Definition
control_server.py:54
src
simcore
control_server.py
Generated by
1.18.0