@codesys/mcp-toolkit 2.1.0 → 2.1.2
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 +202 -181
- package/LICENSE +21 -21
- 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/server.d.ts +1 -1
- package/dist/server.js +1 -1
- package/package.json +1 -1
- package/watcher/mcp_compat.py +175 -175
- package/watcher/mcp_ops.py +11 -1
- package/watcher/watcher.py +326 -326
package/watcher/mcp_compat.py
CHANGED
|
@@ -1,175 +1,175 @@
|
|
|
1
|
-
# -*- coding: utf-8 -*-
|
|
2
|
-
# Python 2.7 (IronPython, inside CODESYS) / Python 3 (tests) compatibility.
|
|
3
|
-
# Keep this file ASCII-only.
|
|
4
|
-
from __future__ import print_function
|
|
5
|
-
|
|
6
|
-
import codecs
|
|
7
|
-
import json
|
|
8
|
-
import os
|
|
9
|
-
import sys
|
|
10
|
-
import time
|
|
11
|
-
|
|
12
|
-
PY2 = sys.version_info[0] == 2
|
|
13
|
-
|
|
14
|
-
if PY2:
|
|
15
|
-
text_type = unicode # noqa: F821
|
|
16
|
-
string_types = (str, unicode) # noqa: F821
|
|
17
|
-
else:
|
|
18
|
-
text_type = str
|
|
19
|
-
string_types = (str,)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def to_text(value):
|
|
23
|
-
"""Best-effort conversion of anything (including .NET strings/enums) to text."""
|
|
24
|
-
if value is None:
|
|
25
|
-
return None
|
|
26
|
-
if isinstance(value, text_type):
|
|
27
|
-
return value
|
|
28
|
-
if PY2 and isinstance(value, str):
|
|
29
|
-
for enc in ("utf-8", "mbcs", "latin-1"):
|
|
30
|
-
try:
|
|
31
|
-
return value.decode(enc)
|
|
32
|
-
except Exception:
|
|
33
|
-
continue
|
|
34
|
-
try:
|
|
35
|
-
return text_type(value)
|
|
36
|
-
except Exception:
|
|
37
|
-
return repr(value)
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def read_json(path):
|
|
41
|
-
f = codecs.open(path, "r", "utf-8-sig")
|
|
42
|
-
try:
|
|
43
|
-
return json.loads(f.read())
|
|
44
|
-
finally:
|
|
45
|
-
f.close()
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def _replace(src, dst):
|
|
49
|
-
if hasattr(os, "replace"):
|
|
50
|
-
os.replace(src, dst)
|
|
51
|
-
return
|
|
52
|
-
try:
|
|
53
|
-
import System.IO # IronPython
|
|
54
|
-
if System.IO.File.Exists(dst):
|
|
55
|
-
System.IO.File.Replace(src, dst, None)
|
|
56
|
-
else:
|
|
57
|
-
System.IO.File.Move(src, dst)
|
|
58
|
-
return
|
|
59
|
-
except ImportError:
|
|
60
|
-
pass
|
|
61
|
-
if os.path.exists(dst):
|
|
62
|
-
os.remove(dst)
|
|
63
|
-
os.rename(src, dst)
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
FS_RETRY_S = 3.0
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
def retry_fs(fn, *args):
|
|
70
|
-
"""Run a file operation, retrying while Windows reports the file as in use.
|
|
71
|
-
|
|
72
|
-
On Windows, replacing or removing a file fails with "access denied" (WinError 5,
|
|
73
|
-
or IOException in .NET) while another process holds it open: the server polling
|
|
74
|
-
heartbeat.json, an antivirus scan, the search indexer. That lasts milliseconds,
|
|
75
|
-
so retry for up to FS_RETRY_S before giving up and raising the last error.
|
|
76
|
-
"""
|
|
77
|
-
deadline = time.time() + FS_RETRY_S
|
|
78
|
-
delay = 0.005
|
|
79
|
-
while True:
|
|
80
|
-
try:
|
|
81
|
-
return fn(*args)
|
|
82
|
-
except Exception:
|
|
83
|
-
if time.time() + delay > deadline:
|
|
84
|
-
raise
|
|
85
|
-
time.sleep(delay)
|
|
86
|
-
delay = min(delay * 2, 0.1)
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
_TMP_SEQ = [0]
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
def _write_text_net(path, data):
|
|
93
|
-
"""Write with .NET (IronPython only): the handle is closed when this returns."""
|
|
94
|
-
import System.IO
|
|
95
|
-
import System.Text
|
|
96
|
-
System.IO.File.WriteAllText(path, data, System.Text.UTF8Encoding(False))
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
def write_json_atomic(path, obj):
|
|
100
|
-
data = to_text(json.dumps(obj, ensure_ascii=False, indent=None, separators=(",", ":")))
|
|
101
|
-
# A fresh temp name each time: inside a Machine Expert window the process has been seen
|
|
102
|
-
# holding on to a temp file (no delete sharing), so renaming that one name failed for good.
|
|
103
|
-
_TMP_SEQ[0] += 1
|
|
104
|
-
tmp = "%s.%d.tmp" % (path, _TMP_SEQ[0])
|
|
105
|
-
try:
|
|
106
|
-
try:
|
|
107
|
-
_write_text_net(tmp, data)
|
|
108
|
-
except ImportError:
|
|
109
|
-
f = retry_fs(codecs.open, tmp, "w", "utf-8")
|
|
110
|
-
try:
|
|
111
|
-
f.write(data)
|
|
112
|
-
finally:
|
|
113
|
-
f.close()
|
|
114
|
-
retry_fs(_replace, tmp, path)
|
|
115
|
-
except Exception as first:
|
|
116
|
-
# Last resort: write the target itself (not atomic; readers retry on a bad read).
|
|
117
|
-
try:
|
|
118
|
-
os.remove(tmp)
|
|
119
|
-
except Exception:
|
|
120
|
-
pass
|
|
121
|
-
try:
|
|
122
|
-
import System.IO # noqa: F401 (IronPython only)
|
|
123
|
-
except ImportError:
|
|
124
|
-
raise first
|
|
125
|
-
_write_text_net(path, data)
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
def remove_file(path):
|
|
129
|
-
retry_fs(os.remove, path)
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
def current_pid():
|
|
133
|
-
try:
|
|
134
|
-
import System.Diagnostics # IronPython
|
|
135
|
-
return int(System.Diagnostics.Process.GetCurrentProcess().Id)
|
|
136
|
-
except ImportError:
|
|
137
|
-
return os.getpid()
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
def pid_alive(pid):
|
|
141
|
-
if not pid:
|
|
142
|
-
return True
|
|
143
|
-
try:
|
|
144
|
-
import System.Diagnostics # IronPython
|
|
145
|
-
try:
|
|
146
|
-
p = System.Diagnostics.Process.GetProcessById(int(pid))
|
|
147
|
-
return not p.HasExited
|
|
148
|
-
except Exception:
|
|
149
|
-
return False
|
|
150
|
-
except ImportError:
|
|
151
|
-
pass
|
|
152
|
-
if os.name == "nt":
|
|
153
|
-
import ctypes
|
|
154
|
-
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
|
155
|
-
STILL_ACTIVE = 259
|
|
156
|
-
k32 = ctypes.windll.kernel32
|
|
157
|
-
h = k32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, int(pid))
|
|
158
|
-
if not h:
|
|
159
|
-
return False
|
|
160
|
-
try:
|
|
161
|
-
code = ctypes.c_ulong()
|
|
162
|
-
if not k32.GetExitCodeProcess(h, ctypes.byref(code)):
|
|
163
|
-
return False
|
|
164
|
-
return code.value == STILL_ACTIVE
|
|
165
|
-
finally:
|
|
166
|
-
k32.CloseHandle(h)
|
|
167
|
-
try:
|
|
168
|
-
os.kill(int(pid), 0)
|
|
169
|
-
return True
|
|
170
|
-
except OSError:
|
|
171
|
-
return False
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
def now():
|
|
175
|
-
return time.time()
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# Python 2.7 (IronPython, inside CODESYS) / Python 3 (tests) compatibility.
|
|
3
|
+
# Keep this file ASCII-only.
|
|
4
|
+
from __future__ import print_function
|
|
5
|
+
|
|
6
|
+
import codecs
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
PY2 = sys.version_info[0] == 2
|
|
13
|
+
|
|
14
|
+
if PY2:
|
|
15
|
+
text_type = unicode # noqa: F821
|
|
16
|
+
string_types = (str, unicode) # noqa: F821
|
|
17
|
+
else:
|
|
18
|
+
text_type = str
|
|
19
|
+
string_types = (str,)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def to_text(value):
|
|
23
|
+
"""Best-effort conversion of anything (including .NET strings/enums) to text."""
|
|
24
|
+
if value is None:
|
|
25
|
+
return None
|
|
26
|
+
if isinstance(value, text_type):
|
|
27
|
+
return value
|
|
28
|
+
if PY2 and isinstance(value, str):
|
|
29
|
+
for enc in ("utf-8", "mbcs", "latin-1"):
|
|
30
|
+
try:
|
|
31
|
+
return value.decode(enc)
|
|
32
|
+
except Exception:
|
|
33
|
+
continue
|
|
34
|
+
try:
|
|
35
|
+
return text_type(value)
|
|
36
|
+
except Exception:
|
|
37
|
+
return repr(value)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def read_json(path):
|
|
41
|
+
f = codecs.open(path, "r", "utf-8-sig")
|
|
42
|
+
try:
|
|
43
|
+
return json.loads(f.read())
|
|
44
|
+
finally:
|
|
45
|
+
f.close()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _replace(src, dst):
|
|
49
|
+
if hasattr(os, "replace"):
|
|
50
|
+
os.replace(src, dst)
|
|
51
|
+
return
|
|
52
|
+
try:
|
|
53
|
+
import System.IO # IronPython
|
|
54
|
+
if System.IO.File.Exists(dst):
|
|
55
|
+
System.IO.File.Replace(src, dst, None)
|
|
56
|
+
else:
|
|
57
|
+
System.IO.File.Move(src, dst)
|
|
58
|
+
return
|
|
59
|
+
except ImportError:
|
|
60
|
+
pass
|
|
61
|
+
if os.path.exists(dst):
|
|
62
|
+
os.remove(dst)
|
|
63
|
+
os.rename(src, dst)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
FS_RETRY_S = 3.0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def retry_fs(fn, *args):
|
|
70
|
+
"""Run a file operation, retrying while Windows reports the file as in use.
|
|
71
|
+
|
|
72
|
+
On Windows, replacing or removing a file fails with "access denied" (WinError 5,
|
|
73
|
+
or IOException in .NET) while another process holds it open: the server polling
|
|
74
|
+
heartbeat.json, an antivirus scan, the search indexer. That lasts milliseconds,
|
|
75
|
+
so retry for up to FS_RETRY_S before giving up and raising the last error.
|
|
76
|
+
"""
|
|
77
|
+
deadline = time.time() + FS_RETRY_S
|
|
78
|
+
delay = 0.005
|
|
79
|
+
while True:
|
|
80
|
+
try:
|
|
81
|
+
return fn(*args)
|
|
82
|
+
except Exception:
|
|
83
|
+
if time.time() + delay > deadline:
|
|
84
|
+
raise
|
|
85
|
+
time.sleep(delay)
|
|
86
|
+
delay = min(delay * 2, 0.1)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
_TMP_SEQ = [0]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _write_text_net(path, data):
|
|
93
|
+
"""Write with .NET (IronPython only): the handle is closed when this returns."""
|
|
94
|
+
import System.IO
|
|
95
|
+
import System.Text
|
|
96
|
+
System.IO.File.WriteAllText(path, data, System.Text.UTF8Encoding(False))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def write_json_atomic(path, obj):
|
|
100
|
+
data = to_text(json.dumps(obj, ensure_ascii=False, indent=None, separators=(",", ":")))
|
|
101
|
+
# A fresh temp name each time: inside a Machine Expert window the process has been seen
|
|
102
|
+
# holding on to a temp file (no delete sharing), so renaming that one name failed for good.
|
|
103
|
+
_TMP_SEQ[0] += 1
|
|
104
|
+
tmp = "%s.%d.tmp" % (path, _TMP_SEQ[0])
|
|
105
|
+
try:
|
|
106
|
+
try:
|
|
107
|
+
_write_text_net(tmp, data)
|
|
108
|
+
except ImportError:
|
|
109
|
+
f = retry_fs(codecs.open, tmp, "w", "utf-8")
|
|
110
|
+
try:
|
|
111
|
+
f.write(data)
|
|
112
|
+
finally:
|
|
113
|
+
f.close()
|
|
114
|
+
retry_fs(_replace, tmp, path)
|
|
115
|
+
except Exception as first:
|
|
116
|
+
# Last resort: write the target itself (not atomic; readers retry on a bad read).
|
|
117
|
+
try:
|
|
118
|
+
os.remove(tmp)
|
|
119
|
+
except Exception:
|
|
120
|
+
pass
|
|
121
|
+
try:
|
|
122
|
+
import System.IO # noqa: F401 (IronPython only)
|
|
123
|
+
except ImportError:
|
|
124
|
+
raise first
|
|
125
|
+
_write_text_net(path, data)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def remove_file(path):
|
|
129
|
+
retry_fs(os.remove, path)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def current_pid():
|
|
133
|
+
try:
|
|
134
|
+
import System.Diagnostics # IronPython
|
|
135
|
+
return int(System.Diagnostics.Process.GetCurrentProcess().Id)
|
|
136
|
+
except ImportError:
|
|
137
|
+
return os.getpid()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def pid_alive(pid):
|
|
141
|
+
if not pid:
|
|
142
|
+
return True
|
|
143
|
+
try:
|
|
144
|
+
import System.Diagnostics # IronPython
|
|
145
|
+
try:
|
|
146
|
+
p = System.Diagnostics.Process.GetProcessById(int(pid))
|
|
147
|
+
return not p.HasExited
|
|
148
|
+
except Exception:
|
|
149
|
+
return False
|
|
150
|
+
except ImportError:
|
|
151
|
+
pass
|
|
152
|
+
if os.name == "nt":
|
|
153
|
+
import ctypes
|
|
154
|
+
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
|
155
|
+
STILL_ACTIVE = 259
|
|
156
|
+
k32 = ctypes.windll.kernel32
|
|
157
|
+
h = k32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, int(pid))
|
|
158
|
+
if not h:
|
|
159
|
+
return False
|
|
160
|
+
try:
|
|
161
|
+
code = ctypes.c_ulong()
|
|
162
|
+
if not k32.GetExitCodeProcess(h, ctypes.byref(code)):
|
|
163
|
+
return False
|
|
164
|
+
return code.value == STILL_ACTIVE
|
|
165
|
+
finally:
|
|
166
|
+
k32.CloseHandle(h)
|
|
167
|
+
try:
|
|
168
|
+
os.kill(int(pid), 0)
|
|
169
|
+
return True
|
|
170
|
+
except OSError:
|
|
171
|
+
return False
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def now():
|
|
175
|
+
return time.time()
|
package/watcher/mcp_ops.py
CHANGED
|
@@ -10,7 +10,7 @@ import sys
|
|
|
10
10
|
|
|
11
11
|
import scriptengine as se
|
|
12
12
|
|
|
13
|
-
from mcp_compat import PY2, to_text
|
|
13
|
+
from mcp_compat import PY2, string_types, to_text
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
class OpError(Exception):
|
|
@@ -838,6 +838,15 @@ def op_devices_list(args):
|
|
|
838
838
|
nm = name_of(ch)
|
|
839
839
|
if _safe(lambda: ch.is_device, False):
|
|
840
840
|
ident = _safe(lambda: ch.get_device_identification())
|
|
841
|
+
# A module (a device that exists only inside its owner, such as Machine Expert's
|
|
842
|
+
# local I/O and embedded functions) reports its owner's type and id; its module
|
|
843
|
+
# id is what tells it apart. get_module_identification() returns that id as a
|
|
844
|
+
# string, empty for a device that isn't a module (CODESYS and Machine Expert 2.3,
|
|
845
|
+
# Scripting 4.1). Schneider's DeviceIdDto carries it as module_id instead.
|
|
846
|
+
mod = _safe(lambda: ch.get_module_identification())
|
|
847
|
+
if mod is not None and not isinstance(mod, string_types):
|
|
848
|
+
mod = _safe(lambda: mod.module_id)
|
|
849
|
+
module = (to_text(mod) or "").strip() if mod is not None else ""
|
|
841
850
|
info = {
|
|
842
851
|
"path": "/".join(names + [nm]),
|
|
843
852
|
"name": nm,
|
|
@@ -845,6 +854,7 @@ def op_devices_list(args):
|
|
|
845
854
|
"type": _safe(lambda: int(ident.type)),
|
|
846
855
|
"id": to_text(_safe(lambda: ident.id)),
|
|
847
856
|
"version": to_text(_safe(lambda: ident.version)),
|
|
857
|
+
"module": module or None,
|
|
848
858
|
"enabled": _safe(lambda: bool(ch.is_enabled())),
|
|
849
859
|
}
|
|
850
860
|
if args.get("io", True):
|