Repository navigation
Expand file tree
/
Copy pathApus.Engine.RobotAPI.pas
More file actions
363 lines (334 loc) · 10.8 KB
/
Copy pathApus.Engine.RobotAPI.pas
File metadata and controls
363 lines (334 loc) · 10.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
// Robot API: file-based protocol for external automation
// See robot_api_protocol.md for specification
//
// Copyright (C) 2026 Apus Software (www.apus-software.com)
// This file is licensed under the terms of BSD-3 license (see license.txt)
// This file is a part of the Apus Game Engine (http://apus-software.com/engine/)
{$R-}
unit Apus.Engine.RobotAPI;
interface
uses Apus.Core, Apus.Types;
type
TRobotRequest=record
id:String8;
cmd:String8;
params:TNameValueList;
// Identity of this request inside the process (unique, increasing): a handler that
// postpones itself keys its own state by it, IDs are chosen by the client.
serial:int64;
// How many times the handler was called for this request before: 0 on the first
// call, side effects (queueing input etc.) belong there - a pending request is retried.
attempt:integer;
function Param(const key:String8):String8;
end;
// handler returns true=OK (body=response), false=ERROR (body=error message)
TRobotCommandHandler=function(const req:TRobotRequest; out body:String8):boolean;
const
// A handler that can't answer yet returns true with this token as the body:
// the request is kept and retried on the next frame, nothing is written out.
PENDING_TOKEN='@PENDING@';
// Same, and the requests after this one (in this batch and in later ones) are not
// run until it is answered: for commands with effects that the next commands must see
// (input simulation).
PENDING_ORDERED_TOKEN='@PENDING-ORDERED@';
procedure InitRobotAPI;
// Main loop thread, after a frame is presented. Checks the input file at most every
// 100..500 ms; immediate - check now (tests).
procedure PollRobotAPI(immediate:boolean=false);
procedure DoneRobotAPI;
procedure RegisterRobotCommand(const name:String8; handler:TRobotCommandHandler);
// Called by DoneRobotAPI after the pending requests are dropped unanswered: a handler
// that keeps state for postponed requests releases it here.
procedure RegisterRobotShutdownHandler(handler:TProcedure);
var
robotAPIEnabled:boolean = {$IFDEF DEBUG}true{$ELSE}false{$ENDIF};
implementation
uses SysUtils, Apus.Conv, Apus.Strings, Apus.Log, Apus.Files, Apus.EventMan;
const
INPUT_FILE = 'robot_in.txt';
OUTPUT_FILE = 'robot_out.txt';
UTF8_BOM=#$EF#$BB#$BF;
SLOW_INTERVAL = 500; // ms between checks in slow mode
FAST_INTERVAL = 100; // ms between checks in fast mode
FAST_TIMEOUT = 5000; // ms of inactivity before returning to slow mode
type
TCommandEntry=record
name:String8;
handler:TRobotCommandHandler;
end;
TRequestArray=array of TRobotRequest;
var
commands:array of TCommandEntry;
commandCount:integer;
shutdownHandlers:array of TProcedure;
lastSerial:int64;
pendingRequests:TRequestArray;
batchResponse:String8; // accumulated responses: written out when nothing is pending anymore
lastCheckTime:int64;
lastActivityTime:int64;
fastMode:boolean;
initialized:boolean;
startTime:TDateTime; // process start (unit initialization), to tell files left by a previous run
function TRobotRequest.Param(const key:String8):String8;
begin
result:=params.Item[key];
end;
procedure RegisterRobotShutdownHandler(handler:TProcedure);
var
n:integer;
begin
n:=length(shutdownHandlers);
SetLength(shutdownHandlers,n+1);
shutdownHandlers[n]:=handler;
end;
procedure RegisterRobotCommand(const name:String8; handler:TRobotCommandHandler);
begin
if commandCount>=length(commands) then
SetLength(commands,commandCount+16);
commands[commandCount].name:=name.ToLower;
commands[commandCount].handler:=handler;
inc(commandCount);
end;
function GetCheckInterval:integer;
begin
if fastMode then result:=FAST_INTERVAL
else result:=SLOW_INTERVAL;
end;
function NormalizeHeaderKey(st:String8):String8;
begin
result:=st.Trim;
if UTF8.HasBOM(result) then
Delete(result,1,3);
// Be tolerant to editors/encodings that inject leading control/BOM-like bytes.
while (length(result)>0) and not (result[1] in ['A'..'Z','a'..'z']) do
Delete(result,1,1);
result:=result.ToLower;
end;
// Parse input file into array of requests
function ParseRequests(const content:String8):TRequestArray;
var
lines:Strings8;
i,reqCount:integer;
line:String8;
nv:TNameValue;
key:String8;
cur:TRobotRequest;
begin
SetLength(result,0);
reqCount:=0;
lines:=content.SplitLines;
cur.id:='';
cur.cmd:='';
SetLength(cur.params.items,0);
for i:=0 to high(lines) do begin
line:=lines[i].Trim;
if (i=0) and line.StartsWith(UTF8_BOM) then
line:=line.Substr(4,length(line)-3).Trim;
if (line='---') or (line='===') then begin
if cur.cmd<>'' then begin
if reqCount>=length(result) then
SetLength(result,reqCount+8);
inc(lastSerial);
cur.serial:=lastSerial;
cur.attempt:=0;
result[reqCount]:=cur;
inc(reqCount);
end;
cur.id:='';
cur.cmd:='';
SetLength(cur.params.items,0);
continue;
end;
if line.IndexOf(':')<1 then continue;
nv.InitFrom(line,':');
key:=NormalizeHeaderKey(nv.name);
if key='id' then
cur.id:=nv.value
else if key='cmd' then
cur.cmd:=nv.value.ToLower
else
cur.params.Add(nv);
end;
SetLength(result,reqCount);
end;
function HandleRequest(const req:TRobotRequest; out pending,ordered:boolean):String8;
var
i:integer;
body:String8;
ok:boolean;
begin
pending:=false;
ordered:=false;
if req.id='' then begin
result:='ID: '+req.id+LineBreak+'STATUS: ERROR'+LineBreak+'MSG: ID parameter required'+LineBreak+'==='+LineBreak;
exit;
end;
for i:=0 to commandCount-1 do
if commands[i].name=req.cmd then begin
Log.Info('RoboReq: '+commands[i].name);
ok:=commands[i].handler(req,body);
if ok then begin
if body.Trim.StartsWith(PENDING_TOKEN) then begin
pending:=true;
result:='';
exit;
end;
if body.Trim.StartsWith(PENDING_ORDERED_TOKEN) then begin
pending:=true;
ordered:=true;
result:='';
exit;
end;
result:='ID: '+req.id+LineBreak+'STATUS: OK'+LineBreak+body+'==='+LineBreak
end else begin
result:='ID: '+req.id+LineBreak+'STATUS: ERROR'+LineBreak+'MSG: '+body+LineBreak+'==='+LineBreak;
Log.Warn('RoboReq FAIL: '+body);
end;
exit;
end;
result:='ID: '+req.id+LineBreak+'STATUS: ERROR'+LineBreak+'MSG: unknown command: '+req.cmd+LineBreak+'==='+LineBreak;
end;
procedure ProcessInputFile;
var
content,response:String8;
requests,newPending,allRequests:TRequestArray;
i,requestCount,newCount,pendingCount:integer;
isPending,isOrdered,blocked:boolean;
begin
SetLength(requests,0);
if Files.Exists(INPUT_FILE) then begin
content:=Files.LoadAsString(INPUT_FILE);
if content.IndexOf('===')>0 then begin
Log.Msg('RobotAPI: processing input file');
lastActivityTime:=CoreTime.Ticks;
fastMode:=true;
requests:=ParseRequests(content);
Files.Delete(INPUT_FILE);
end;
end;
newCount:=length(requests);
pendingCount:=length(pendingRequests);
requestCount:=newCount+pendingCount;
if (requestCount=0) and (batchResponse='') then exit; // retry a response whose file was temporarily locked
SetLength(allRequests,requestCount);
for i:=0 to pendingCount-1 do
allRequests[i]:=pendingRequests[i];
for i:=0 to newCount-1 do
allRequests[pendingCount+i]:=requests[i];
SetLength(newPending,0);
response:='';
blocked:=false; // an ordered request is pending: the following ones wait unhandled
for i:=0 to high(allRequests) do begin
isPending:=true;
if not blocked then begin
response:=response+HandleRequest(allRequests[i],isPending,isOrdered);
inc(allRequests[i].attempt);
blocked:=isPending and isOrdered;
end;
if isPending then begin
SetLength(newPending,length(newPending)+1);
newPending[high(newPending)]:=allRequests[i];
end;
end;
pendingRequests:=newPending;
// Answers are written as a whole batch: a command that postponed itself would
// otherwise let the next frame overwrite the answers already given here.
batchResponse:=batchResponse+response;
if (batchResponse<>'') and (length(pendingRequests)=0) then begin
Files.Save(OUTPUT_FILE,batchResponse);
batchResponse:='';
end;
if length(pendingRequests)>0 then begin
fastMode:=true;
lastActivityTime:=CoreTime.Ticks;
end;
Log.Msg('RobotAPI: processed %d request(s), pending=%d',[requestCount,length(pendingRequests)]);
end;
// --- Built-in commands ---
function CmdSignal(const req:TRobotRequest; out body:String8):boolean;
var
event:String8;
tag:NativeInt;
begin
event:=req.Param('EVENT');
if event='' then begin
body:='EVENT parameter required';
exit(false);
end;
tag:=Conv.ToInt(req.Param('TAG'));
Signal(event,tag);
body:='';
result:=true;
end;
// Files left by a previous run must not be taken for requests of this one: the old answer
// is removed, and the input is dropped if it was written before this process started.
procedure DropStaleFiles;
const
TIME_SLACK = 3/86400; // 3 s: file timestamps may be rounded to 2 s
var
info:TFileInfo;
begin
if Files.Exists(OUTPUT_FILE) then begin
Files.Delete(OUTPUT_FILE);
Log.Msg('RobotAPI: removed %s left by a previous run',[OUTPUT_FILE]);
end;
if Files.GetFileInfo(INPUT_FILE,info) and (info.timestamp<startTime-TIME_SLACK) then begin
Files.Delete(INPUT_FILE);
Log.Msg('RobotAPI: removed %s left by a previous run',[INPUT_FILE]);
end;
end;
procedure InitRobotAPI;
begin
if not robotAPIEnabled then exit;
try
DropStaleFiles;
except
on e:Exception do Log.Warn('RobotAPI: cannot clean up old files: '+String8(e.Message));
end;
RegisterRobotCommand('signal',@CmdSignal);
fastMode:=false;
lastCheckTime:=0;
lastActivityTime:=0;
SetLength(pendingRequests,0);
batchResponse:='';
initialized:=true;
Log.Msg('RobotAPI: initialized (%d commands registered)',[commandCount]);
end;
procedure PollRobotAPI(immediate:boolean=false);
var
now:int64;
begin
if not initialized then exit;
now:=CoreTime.Ticks;
if not immediate and (now-lastCheckTime<GetCheckInterval) then exit;
lastCheckTime:=now;
if fastMode and (lastActivityTime>0) and (now-lastActivityTime>FAST_TIMEOUT) then begin
fastMode:=false;
Log.Msg('RobotAPI: switching to slow polling');
end;
try
ProcessInputFile;
except
on e:Exception do
Log.Warn('RobotAPI: error processing input: '+String8(e.Message));
end;
end;
procedure DoneRobotAPI;
var
i:integer;
begin
if not initialized then exit;
initialized:=false;
SetLength(pendingRequests,0);
batchResponse:='';
for i:=0 to high(shutdownHandlers) do
try
shutdownHandlers[i]();
except
on e:Exception do Log.Warn('RobotAPI: shutdown handler failed: '+String8(e.Message));
end;
Log.Msg('RobotAPI: shutdown');
end;
initialization
startTime:=Now;
end.