@codesys/mcp-toolkit 2.0.1 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +194 -160
- package/LICENSE +21 -21
- package/README.md +293 -269
- package/dist/bridge/bridge.d.ts +12 -0
- package/dist/bridge/bridge.js +126 -3
- package/dist/bridge/bridge.js.map +1 -1
- package/dist/cli.js +20 -4
- package/dist/cli.js.map +1 -1
- package/dist/codesys/devices.d.ts +2 -0
- package/dist/codesys/devices.js +5 -1
- package/dist/codesys/devices.js.map +1 -1
- package/dist/codesys/install.js +3 -2
- package/dist/codesys/install.js.map +1 -1
- package/dist/server.d.ts +1 -1
- package/dist/server.js +10 -10
- package/dist/tools.d.ts +2 -0
- package/dist/tools.js +38 -7
- package/dist/tools.js.map +1 -1
- package/package.json +67 -67
- package/watcher/mcp_compat.py +175 -147
- package/watcher/mcp_ops.py +6 -0
- package/watcher/mcp_sim.py +8 -0
- package/watcher/watcher.py +326 -255
package/watcher/watcher.py
CHANGED
|
@@ -1,255 +1,326 @@
|
|
|
1
|
-
# -*- coding: utf-8 -*-
|
|
2
|
-
# codesys-mcp-toolkit v2 watcher.
|
|
3
|
-
#
|
|
4
|
-
# Started by the MCP server as: CODESYS.exe --profile="..." --noUI --runscript="<dir>/watcher.py"
|
|
5
|
-
# Environment (inherited from the server):
|
|
6
|
-
# CODESYS_MCP_DIR session folder (requests/, results/, heartbeat.json, ready.json, watcher.log)
|
|
7
|
-
# CODESYS_MCP_SCRIPTS folder holding this file and its modules
|
|
8
|
-
# CODESYS_MCP_PARENT_PID server PID; the watcher exits when it is gone
|
|
9
|
-
#
|
|
10
|
-
# Protocol: the server writes requests/<id>.json ({"id","op","args","deadline_ms"}) via tmp+rename.
|
|
11
|
-
# The watcher handles one request at a time and writes results/<id>.json
|
|
12
|
-
# ({"id","ok","result"} or {"id","ok":false,"error":{"code","message","detail"}}).
|
|
13
|
-
# Requests are JSON op names + arguments; no code is ever generated or exec'd.
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
import
|
|
20
|
-
|
|
21
|
-
import
|
|
22
|
-
import
|
|
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
|
-
try:
|
|
83
|
-
se.system.
|
|
84
|
-
return "
|
|
85
|
-
except Exception
|
|
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
|
-
reported
|
|
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
|
-
log(
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
if not
|
|
239
|
-
log("
|
|
240
|
-
break
|
|
241
|
-
|
|
242
|
-
if
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# codesys-mcp-toolkit v2 watcher.
|
|
3
|
+
#
|
|
4
|
+
# Started by the MCP server as: CODESYS.exe --profile="..." --noUI --runscript="<dir>/watcher.py"
|
|
5
|
+
# Environment (inherited from the server):
|
|
6
|
+
# CODESYS_MCP_DIR session folder (requests/, results/, heartbeat.json, ready.json, watcher.log)
|
|
7
|
+
# CODESYS_MCP_SCRIPTS folder holding this file and its modules
|
|
8
|
+
# CODESYS_MCP_PARENT_PID server PID; the watcher exits when it is gone
|
|
9
|
+
#
|
|
10
|
+
# Protocol: the server writes requests/<id>.json ({"id","op","args","deadline_ms"}) via tmp+rename.
|
|
11
|
+
# The watcher handles one request at a time and writes results/<id>.json
|
|
12
|
+
# ({"id","ok","result"} or {"id","ok":false,"error":{"code","message","detail"}}).
|
|
13
|
+
# Requests are JSON op names + arguments; no code is ever generated or exec'd.
|
|
14
|
+
# Attach mode (CODESYS_MCP_ATTACH=1, see start_attach.py written by the server): run from
|
|
15
|
+
# the user's open window; a UI timer polls for requests instead of a blocking loop. The
|
|
16
|
+
# launcher runs only this file (the toolkit's own); requests still never become code.
|
|
17
|
+
#
|
|
18
|
+
# Keep this file ASCII-only (IronPython 2.7 inside CODESYS).
|
|
19
|
+
from __future__ import print_function
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import sys
|
|
24
|
+
import time
|
|
25
|
+
import traceback
|
|
26
|
+
|
|
27
|
+
SCRIPTS = os.environ.get("CODESYS_MCP_SCRIPTS")
|
|
28
|
+
if SCRIPTS and SCRIPTS not in sys.path:
|
|
29
|
+
sys.path.insert(0, SCRIPTS)
|
|
30
|
+
|
|
31
|
+
from mcp_compat import current_pid, now, pid_alive, read_json, remove_file, retry_fs, to_text, write_json_atomic # noqa: E402
|
|
32
|
+
|
|
33
|
+
SESSION = os.environ["CODESYS_MCP_DIR"]
|
|
34
|
+
PARENT_PID = int(os.environ.get("CODESYS_MCP_PARENT_PID") or "0")
|
|
35
|
+
REQ_DIR = os.path.join(SESSION, "requests")
|
|
36
|
+
RES_DIR = os.path.join(SESSION, "results")
|
|
37
|
+
HEARTBEAT = os.path.join(SESSION, "heartbeat.json")
|
|
38
|
+
READY = os.path.join(SESSION, "ready.json")
|
|
39
|
+
LOG = os.path.join(SESSION, "watcher.log")
|
|
40
|
+
|
|
41
|
+
DIALOG = os.path.join(SESSION, "dialog.json")
|
|
42
|
+
|
|
43
|
+
PID = current_pid()
|
|
44
|
+
STATE = {"count": 0, "last_beat": 0.0, "busy_since": None, "op": None}
|
|
45
|
+
|
|
46
|
+
# A dialog must stay up this long before the server is told, so brief windows don't count.
|
|
47
|
+
DIALOG_GRACE_S = 10.0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def log(msg):
|
|
51
|
+
try:
|
|
52
|
+
f = open(LOG, "a")
|
|
53
|
+
try:
|
|
54
|
+
f.write("%s %s\n" % (time.strftime("%H:%M:%S"), to_text(msg).encode("ascii", "replace").decode("ascii")))
|
|
55
|
+
finally:
|
|
56
|
+
f.close()
|
|
57
|
+
except Exception:
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def beat(state, op=None, rid=None, deadline_ms=None):
|
|
62
|
+
# A heartbeat that cannot be written (file held open past the retries) must not
|
|
63
|
+
# kill the watcher: log it and go on. If it keeps failing, the server sees a
|
|
64
|
+
# stale heartbeat and reports the watcher as hung, which is the right signal.
|
|
65
|
+
t = now()
|
|
66
|
+
try:
|
|
67
|
+
write_json_atomic(HEARTBEAT, {
|
|
68
|
+
"pid": PID, "ts": t, "state": state, "op": op, "id": rid,
|
|
69
|
+
"deadline": (t + deadline_ms / 1000.0) if deadline_ms else None,
|
|
70
|
+
"count": STATE["count"],
|
|
71
|
+
})
|
|
72
|
+
except Exception as e:
|
|
73
|
+
if t - STATE.get("last_beat_err", 0.0) > 30.0:
|
|
74
|
+
STATE["last_beat_err"] = t
|
|
75
|
+
log("heartbeat write failed (%s): %s\n%s" % (state, to_text(e), to_text(traceback.format_exc())))
|
|
76
|
+
STATE["last_beat"] = t
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def setup_prompts(se):
|
|
80
|
+
# Never block on a modal dialog: suppress with defaults and log them.
|
|
81
|
+
# SP21 only has the old prompt_handling; newer versions have script_prompt_handling.
|
|
82
|
+
try:
|
|
83
|
+
se.system.script_prompt_handling = se.ScriptPromptHandling.LogPrompts
|
|
84
|
+
return "script_prompt_handling=LogPrompts"
|
|
85
|
+
except Exception:
|
|
86
|
+
pass
|
|
87
|
+
try:
|
|
88
|
+
se.system.prompt_handling = se.PromptHandling.LogSimplePrompts
|
|
89
|
+
return "prompt_handling=LogSimplePrompts"
|
|
90
|
+
except Exception as e:
|
|
91
|
+
return "prompt handling not set: %s" % e
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def scripting_version():
|
|
95
|
+
# In CODESYS, sys.version ends in "ScriptEngine.plugin 4.2.0.0".
|
|
96
|
+
m = re.search(r"ScriptEngine\.plugin\s+([0-9.]+)", sys.version)
|
|
97
|
+
return m.group(1) if m else None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def find_dialog():
|
|
101
|
+
# A session without a UI has no open forms and no main window (checked on SP21 and
|
|
102
|
+
# SP22), so a modal form or a main window means CODESYS is waiting for an answer.
|
|
103
|
+
from System.Windows.Forms import Application
|
|
104
|
+
from System.Diagnostics import Process
|
|
105
|
+
for f in Application.OpenForms:
|
|
106
|
+
if f.Modal:
|
|
107
|
+
return to_text(f.Text) or "(untitled)"
|
|
108
|
+
return to_text(Process.GetCurrentProcess().MainWindowTitle) or None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def watch_dialogs():
|
|
112
|
+
# Runs beside the request loop. A dialog blocks that loop for good and nobody can
|
|
113
|
+
# answer it in a session without a window, so tell the server (dialog.json).
|
|
114
|
+
import clr
|
|
115
|
+
clr.AddReference("System.Windows.Forms")
|
|
116
|
+
title, since, reported, failed = None, 0.0, False, False
|
|
117
|
+
while True:
|
|
118
|
+
time.sleep(1.0)
|
|
119
|
+
busy = STATE["busy_since"]
|
|
120
|
+
current = None
|
|
121
|
+
if busy is not None and now() - busy > 2.0:
|
|
122
|
+
try:
|
|
123
|
+
current = find_dialog()
|
|
124
|
+
except Exception as e: # e.g. the form list changed while it was read
|
|
125
|
+
if not failed:
|
|
126
|
+
log("dialog check failed: %s" % to_text(e))
|
|
127
|
+
failed = True
|
|
128
|
+
if current != title:
|
|
129
|
+
title, since = current, now()
|
|
130
|
+
if current:
|
|
131
|
+
log("dialog up during %s: %s" % (STATE["op"], current))
|
|
132
|
+
elif reported:
|
|
133
|
+
remove_file(DIALOG)
|
|
134
|
+
reported = False
|
|
135
|
+
elif current and not reported and now() - since >= DIALOG_GRACE_S:
|
|
136
|
+
write_json_atomic(DIALOG, {"title": current, "op": STATE["op"]})
|
|
137
|
+
reported = True
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def start_dialog_watch():
|
|
141
|
+
import threading
|
|
142
|
+
|
|
143
|
+
def run():
|
|
144
|
+
try:
|
|
145
|
+
watch_dialogs()
|
|
146
|
+
except BaseException as e:
|
|
147
|
+
log("dialog watch stopped: %s" % to_text(e))
|
|
148
|
+
t = threading.Thread(target=run)
|
|
149
|
+
t.daemon = True
|
|
150
|
+
t.start()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def sleep_ms(se, ms):
|
|
154
|
+
delay = getattr(getattr(se, "system", None), "delay", None)
|
|
155
|
+
if delay is not None and not hasattr(se, "FAKE"):
|
|
156
|
+
try:
|
|
157
|
+
delay(ms)
|
|
158
|
+
return
|
|
159
|
+
except Exception:
|
|
160
|
+
pass
|
|
161
|
+
time.sleep(ms / 1000.0)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def next_request():
|
|
165
|
+
try:
|
|
166
|
+
names = sorted(n for n in os.listdir(REQ_DIR) if n.endswith(".json"))
|
|
167
|
+
except OSError:
|
|
168
|
+
return None
|
|
169
|
+
return names[0] if names else None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def handle(ops_mod, fname):
|
|
173
|
+
path = os.path.join(REQ_DIR, fname)
|
|
174
|
+
rid = fname[:-5]
|
|
175
|
+
try:
|
|
176
|
+
# Another process (an antivirus scan, the indexer) can hold a new file for a moment.
|
|
177
|
+
req = retry_fs(read_json, path)
|
|
178
|
+
except Exception as e:
|
|
179
|
+
remove_file(path)
|
|
180
|
+
write_json_atomic(os.path.join(RES_DIR, rid + ".json"), {
|
|
181
|
+
"id": rid, "ok": False, "error": {"code": "bad_request", "message": "Unreadable request: %s" % e}})
|
|
182
|
+
return True
|
|
183
|
+
remove_file(path)
|
|
184
|
+
rid = req.get("id") or rid
|
|
185
|
+
op = req.get("op")
|
|
186
|
+
args = req.get("args") or {}
|
|
187
|
+
STATE["count"] += 1
|
|
188
|
+
beat("busy", op, rid, req.get("deadline_ms"))
|
|
189
|
+
started = now()
|
|
190
|
+
STATE["op"] = op
|
|
191
|
+
STATE["busy_since"] = started
|
|
192
|
+
stop = False
|
|
193
|
+
if op == "shutdown":
|
|
194
|
+
res = {"id": rid, "ok": True, "result": {"bye": True}}
|
|
195
|
+
stop = True
|
|
196
|
+
else:
|
|
197
|
+
fn = ops_mod.OPS.get(op)
|
|
198
|
+
if fn is None:
|
|
199
|
+
res = {"id": rid, "ok": False, "error": {"code": "unknown_op", "message": "Unknown op: %s" % op}}
|
|
200
|
+
else:
|
|
201
|
+
try:
|
|
202
|
+
res = {"id": rid, "ok": True, "result": fn(args)}
|
|
203
|
+
except ops_mod.OpError as e:
|
|
204
|
+
res = {"id": rid, "ok": False, "error": {"code": e.code, "message": to_text(e.message)}}
|
|
205
|
+
except BaseException as e: # IronPython: KeyboardInterrupt is not an Exception
|
|
206
|
+
res = {"id": rid, "ok": False, "error": {
|
|
207
|
+
"code": "codesys_error", "message": to_text(e) or e.__class__.__name__,
|
|
208
|
+
"detail": to_text(traceback.format_exc())}}
|
|
209
|
+
STATE["busy_since"] = None
|
|
210
|
+
res["ms"] = int((now() - started) * 1000)
|
|
211
|
+
# Go idle before publishing the result: once the server sees the result it may
|
|
212
|
+
# read the heartbeat right away, and it must not find this op still "busy".
|
|
213
|
+
beat("idle")
|
|
214
|
+
write_json_atomic(os.path.join(RES_DIR, rid + ".json"), res)
|
|
215
|
+
log("%s %s %s %dms" % (rid, op, "ok" if res.get("ok") else res["error"]["code"], res["ms"]))
|
|
216
|
+
return not stop
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def main():
|
|
220
|
+
for d in (REQ_DIR, RES_DIR):
|
|
221
|
+
if not os.path.isdir(d):
|
|
222
|
+
os.makedirs(d)
|
|
223
|
+
log("watcher starting pid=%s parent=%s python=%s" % (PID, PARENT_PID, sys.version.split()[0]))
|
|
224
|
+
import scriptengine as se
|
|
225
|
+
import mcp_ops
|
|
226
|
+
import mcp_sim # noqa: F401 (adds its operations to mcp_ops.OPS)
|
|
227
|
+
prompts = setup_prompts(se)
|
|
228
|
+
log(prompts)
|
|
229
|
+
if not hasattr(se, "FAKE"):
|
|
230
|
+
start_dialog_watch()
|
|
231
|
+
beat("idle")
|
|
232
|
+
write_json_atomic(READY, {"pid": PID, "ts": now(), "python": sys.version.split()[0], "prompts": prompts, "scripting": scripting_version()})
|
|
233
|
+
last_parent_check = 0.0
|
|
234
|
+
while True:
|
|
235
|
+
t = now()
|
|
236
|
+
if PARENT_PID and t - last_parent_check > 2.0:
|
|
237
|
+
last_parent_check = t
|
|
238
|
+
if not pid_alive(PARENT_PID):
|
|
239
|
+
log("parent %s gone; exiting" % PARENT_PID)
|
|
240
|
+
break
|
|
241
|
+
fname = next_request()
|
|
242
|
+
if fname:
|
|
243
|
+
if not handle(mcp_ops, fname):
|
|
244
|
+
log("shutdown requested")
|
|
245
|
+
break
|
|
246
|
+
continue
|
|
247
|
+
if t - STATE["last_beat"] > 1.0:
|
|
248
|
+
beat("idle")
|
|
249
|
+
sleep_ms(se, 40)
|
|
250
|
+
beat("stopped")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def main_attach():
|
|
254
|
+
# Attach mode: the user's own window runs this script (Tools > Scripting > Execute
|
|
255
|
+
# Script File). A blocking loop would freeze their window, so a WinForms timer polls
|
|
256
|
+
# for requests on the UI thread and the script returns at once. The window is only
|
|
257
|
+
# busy while a request runs. No dialog watch: a window and dialogs are expected here.
|
|
258
|
+
for d in (REQ_DIR, RES_DIR):
|
|
259
|
+
if not os.path.isdir(d):
|
|
260
|
+
os.makedirs(d)
|
|
261
|
+
log("attach watcher starting pid=%s python=%s" % (PID, sys.version.split()[0]))
|
|
262
|
+
# This runs on the user's UI thread: never block it for seconds on a busy file.
|
|
263
|
+
import mcp_compat
|
|
264
|
+
mcp_compat.FS_RETRY_S = 0.3
|
|
265
|
+
import clr
|
|
266
|
+
clr.AddReference("System.Windows.Forms")
|
|
267
|
+
from System.Windows.Forms import Timer
|
|
268
|
+
import __builtin__
|
|
269
|
+
import scriptengine as se
|
|
270
|
+
import mcp_ops
|
|
271
|
+
import mcp_sim # noqa: F401 (adds its operations to mcp_ops.OPS)
|
|
272
|
+
prompts = setup_prompts(se)
|
|
273
|
+
log(prompts)
|
|
274
|
+
old = getattr(__builtin__, "_mcp_attach_timer", None)
|
|
275
|
+
if old is not None:
|
|
276
|
+
old.Stop() # started twice: the new script takes over
|
|
277
|
+
stop_flag = os.path.join(SESSION, "stop.flag")
|
|
278
|
+
busy = {"in_tick": False}
|
|
279
|
+
|
|
280
|
+
def on_tick(sender, args):
|
|
281
|
+
# Handling a request can pump messages (waits inside operations), which fires
|
|
282
|
+
# another tick: ignore it until the request is done.
|
|
283
|
+
if busy["in_tick"]:
|
|
284
|
+
return
|
|
285
|
+
busy["in_tick"] = True
|
|
286
|
+
try:
|
|
287
|
+
if os.path.exists(stop_flag):
|
|
288
|
+
remove_file(stop_flag)
|
|
289
|
+
sender.Stop()
|
|
290
|
+
beat("stopped")
|
|
291
|
+
log("stop.flag found; attach watcher stopped")
|
|
292
|
+
return
|
|
293
|
+
fname = next_request()
|
|
294
|
+
if fname:
|
|
295
|
+
if not handle(mcp_ops, fname):
|
|
296
|
+
sender.Stop()
|
|
297
|
+
beat("stopped")
|
|
298
|
+
log("shutdown requested")
|
|
299
|
+
elif now() - STATE["last_beat"] > 1.0:
|
|
300
|
+
beat("idle")
|
|
301
|
+
except BaseException as e:
|
|
302
|
+
log("tick failed: %s" % to_text(traceback.format_exc()))
|
|
303
|
+
finally:
|
|
304
|
+
busy["in_tick"] = False
|
|
305
|
+
|
|
306
|
+
timer = Timer()
|
|
307
|
+
timer.Interval = 100
|
|
308
|
+
timer.Tick += on_tick
|
|
309
|
+
timer.Start()
|
|
310
|
+
__builtin__._mcp_attach_timer = timer # keep it alive after this script returns
|
|
311
|
+
beat("idle")
|
|
312
|
+
write_json_atomic(READY, {"pid": PID, "ts": now(), "python": sys.version.split()[0], "prompts": prompts, "scripting": scripting_version(), "attach": True})
|
|
313
|
+
log("attach watcher running")
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
try:
|
|
317
|
+
if os.environ.get("CODESYS_MCP_ATTACH") == "1":
|
|
318
|
+
main_attach()
|
|
319
|
+
else:
|
|
320
|
+
main()
|
|
321
|
+
except BaseException:
|
|
322
|
+
log("watcher crashed: " + traceback.format_exc())
|
|
323
|
+
try:
|
|
324
|
+
beat("crashed")
|
|
325
|
+
except Exception:
|
|
326
|
+
pass
|