@codesys/mcp-toolkit 1.5.8 → 2.0.0-alpha.0

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.
Files changed (110) hide show
  1. package/CHANGELOG.md +153 -189
  2. package/LICENSE +21 -21
  3. package/README.md +224 -107
  4. package/dist/actions.d.ts +28 -0
  5. package/dist/actions.js +98 -0
  6. package/dist/actions.js.map +1 -0
  7. package/dist/bridge/bridge.d.ts +105 -0
  8. package/dist/bridge/bridge.js +400 -0
  9. package/dist/bridge/bridge.js.map +1 -0
  10. package/dist/cli.d.ts +2 -0
  11. package/dist/cli.js +196 -0
  12. package/dist/cli.js.map +1 -0
  13. package/dist/codesys/cache.d.ts +48 -0
  14. package/dist/codesys/cache.js +82 -0
  15. package/dist/codesys/cache.js.map +1 -0
  16. package/dist/codesys/changes.d.ts +57 -0
  17. package/dist/codesys/changes.js +133 -0
  18. package/dist/codesys/changes.js.map +1 -0
  19. package/dist/codesys/decl.d.ts +45 -0
  20. package/dist/codesys/decl.js +251 -0
  21. package/dist/codesys/decl.js.map +1 -0
  22. package/dist/codesys/devices.d.ts +34 -0
  23. package/dist/codesys/devices.js +82 -0
  24. package/dist/codesys/devices.js.map +1 -0
  25. package/dist/codesys/diff.d.ts +13 -0
  26. package/dist/codesys/diff.js +61 -0
  27. package/dist/codesys/diff.js.map +1 -0
  28. package/dist/codesys/install.d.ts +40 -0
  29. package/dist/codesys/install.js +144 -0
  30. package/dist/codesys/install.js.map +1 -0
  31. package/dist/codesys/library-browse.d.ts +19 -0
  32. package/dist/codesys/library-browse.js +57 -0
  33. package/dist/codesys/library-browse.js.map +1 -0
  34. package/dist/codesys/outline.d.ts +32 -0
  35. package/dist/codesys/outline.js +219 -0
  36. package/dist/codesys/outline.js.map +1 -0
  37. package/dist/codesys/simulation.d.ts +34 -0
  38. package/dist/codesys/simulation.js +57 -0
  39. package/dist/codesys/simulation.js.map +1 -0
  40. package/dist/codesys/st.d.ts +36 -0
  41. package/dist/codesys/st.js +69 -0
  42. package/dist/codesys/st.js.map +1 -0
  43. package/dist/daemon.d.ts +46 -0
  44. package/dist/daemon.js +173 -0
  45. package/dist/daemon.js.map +1 -0
  46. package/dist/driver.d.ts +25 -0
  47. package/dist/driver.js +33 -0
  48. package/dist/driver.js.map +1 -0
  49. package/dist/panel/sim-panel.html +357 -0
  50. package/dist/panel/viewer.html +126 -0
  51. package/dist/panel-types.d.ts +20 -0
  52. package/dist/panel-types.js +3 -0
  53. package/dist/panel-types.js.map +1 -0
  54. package/dist/panel.d.ts +27 -0
  55. package/dist/panel.js +208 -0
  56. package/dist/panel.js.map +1 -0
  57. package/dist/server.d.ts +5 -0
  58. package/dist/server.js +33 -974
  59. package/dist/server.js.map +1 -0
  60. package/dist/tools.d.ts +103 -0
  61. package/dist/tools.js +793 -0
  62. package/dist/tools.js.map +1 -0
  63. package/dist/viewer.d.ts +14 -0
  64. package/dist/viewer.js +125 -0
  65. package/dist/viewer.js.map +1 -0
  66. package/package.json +37 -21
  67. package/watcher/mcp_compat.py +147 -0
  68. package/watcher/mcp_ops.py +1178 -0
  69. package/watcher/mcp_sim.py +246 -0
  70. package/watcher/watcher.py +255 -0
  71. package/.claude/settings.local.json +0 -29
  72. package/CLAUDE.md +0 -26
  73. package/CONTRIBUTING.md +0 -72
  74. package/README.md.enhanced +0 -155
  75. package/codesys-mcp-toolkit-1.2.0.tgz +0 -0
  76. package/codesys-mcp-toolkit-1.2.1.tgz +0 -0
  77. package/codesys-mcp-toolkit-1.5.7.tgz +0 -0
  78. package/codesys-mcp-toolkit-1.5.8.tgz +0 -0
  79. package/dist/bin.js +0 -75
  80. package/dist/check_session_paths.js +0 -149
  81. package/dist/codesys_interop.js +0 -435
  82. package/dist/direct_session_command.js +0 -293
  83. package/dist/scriptLoader.js +0 -165
  84. package/dist/scripts/check_status.py +0 -21
  85. package/dist/scripts/compile_project.py +0 -65
  86. package/dist/scripts/create_method.py +0 -80
  87. package/dist/scripts/create_pou.py +0 -102
  88. package/dist/scripts/create_project.py +0 -116
  89. package/dist/scripts/create_property.py +0 -78
  90. package/dist/scripts/ensure_project_open.py +0 -154
  91. package/dist/scripts/find_object_by_path.py +0 -111
  92. package/dist/scripts/get_pou_code.py +0 -91
  93. package/dist/scripts/get_project_structure.py +0 -72
  94. package/dist/scripts/open_project.py +0 -25
  95. package/dist/scripts/save_project.py +0 -29
  96. package/dist/scripts/session_status.py +0 -281
  97. package/dist/scripts/set_pou_code.py +0 -109
  98. package/dist/scripts/start_session.py +0 -809
  99. package/dist/scripts/stop_session.py +0 -324
  100. package/dist/sessionManager.js +0 -798
  101. package/docs/configuration.md +0 -141
  102. package/docs/index.md +0 -26
  103. package/docs/installation.md +0 -84
  104. package/docs/session_management.md +0 -110
  105. package/examples/README.md +0 -78
  106. package/examples/sample_config.json +0 -32
  107. package/replacements_session_status.txt +0 -16
  108. package/scriptDoc.txt +0 -8679
  109. package/scripts/build.js +0 -25
  110. package/scripts/fix_fstrings.py +0 -76
@@ -0,0 +1,1178 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Operation handlers for the codesys-mcp-toolkit watcher.
3
+ # Runs inside CODESYS (IronPython 2.7) and under CPython 3 with a fake
4
+ # scriptengine in tests. Keep this file ASCII-only.
5
+ from __future__ import print_function
6
+
7
+ import os
8
+ import re
9
+ import sys
10
+
11
+ import scriptengine as se
12
+
13
+ from mcp_compat import PY2, to_text
14
+
15
+
16
+ class OpError(Exception):
17
+ def __init__(self, code, message):
18
+ Exception.__init__(self, message)
19
+ self.code = code
20
+ self.message = message
21
+
22
+
23
+ # Object type GUIDs (verified against CODESYS V3.5 SP21). Names are
24
+ # localised by CODESYS ("Taskkonfiguration"), so kinds come from GUIDs only.
25
+ GUID_KIND = {
26
+ "6f9dac99-8de1-4efc-8465-68ac443b7d08": "pou",
27
+ "f8a58466-d7f6-439f-bbb8-d4600e41d099": "method",
28
+ "5a3b8626-d3e9-4f37-98b5-66420063d91e": "property",
29
+ "792f2eb6-721e-4e64-ba20-bc98351056db": "accessor",
30
+ "8ac092e5-3128-4e26-9e7e-11016c6684f2": "action",
31
+ "2db5746d-d284-4425-9f7f-2663a34b0ebc": "dut",
32
+ "ffbfa93a-b94d-45fc-a329-229860183b1d": "gvl",
33
+ "6654496c-404d-479a-aad2-8551054e5f1e": "interface",
34
+ "f89f7675-27f1-46b3-8abb-b7da8e774ffd": "interface_method",
35
+ "ae1de277-a207-4a28-9efb-456c06bd52f3": "task_config",
36
+ "98a2708a-9b18-4f31-82ed-a1465b24fa2d": "task",
37
+ "413e2a7d-adb1-4d2c-be29-6ae6e4fab820": "task_call",
38
+ "adb5cb65-8e1d-4a00-b70a-375ea27582f3": "library_manager",
39
+ }
40
+
41
+ SCRIPT_MESSAGE_CATEGORY = "194b48a9-ab51-43ae-b9a9-51d3edaaddf3"
42
+
43
+ _LEAD = r"^\s*(?:(?:\{[^}]*\}|//[^\n]*\n|\(\*.*?\*\))\s*)*"
44
+ _HEADER_RE = re.compile(_LEAD + r"(PROGRAM|FUNCTION_BLOCK|FUNCTION|METHOD|PROPERTY|INTERFACE|TYPE|VAR_GLOBAL)\b", re.S | re.I)
45
+ _DUT_RE = re.compile(_LEAD + r"TYPE\s+\w+\s*(?:EXTENDS\s+[\w.]+\s*)?:\s*(STRUCT|UNION|\()?", re.S | re.I)
46
+
47
+
48
+ # ---------------------------------------------------------------- helpers
49
+
50
+ def _safe(fn, default=None):
51
+ try:
52
+ return fn()
53
+ except Exception:
54
+ return default
55
+
56
+
57
+ def guid_of(obj):
58
+ return (to_text(_safe(lambda: obj.guid)) or "").strip("{}").lower()
59
+
60
+
61
+ def name_of(obj):
62
+ return to_text(obj.get_name())
63
+
64
+
65
+ def children(obj):
66
+ return list(obj.get_children(False))
67
+
68
+
69
+ def decl_of(obj):
70
+ if _safe(lambda: obj.has_textual_declaration, False):
71
+ return to_text(obj.textual_declaration.text)
72
+ return None
73
+
74
+
75
+ def impl_of(obj):
76
+ if _safe(lambda: obj.has_textual_implementation, False):
77
+ return to_text(obj.textual_implementation.text)
78
+ return None
79
+
80
+
81
+ def header_keyword(decl):
82
+ m = _HEADER_RE.match(decl or "")
83
+ return m.group(1).upper() if m else None
84
+
85
+
86
+ def subkind(kind, decl):
87
+ if kind == "pou":
88
+ kw = header_keyword(decl)
89
+ return {"PROGRAM": "program", "FUNCTION_BLOCK": "function_block", "FUNCTION": "function"}.get(kw)
90
+ if kind == "dut":
91
+ m = _DUT_RE.match(decl or "")
92
+ if not m:
93
+ return None
94
+ tok = (m.group(1) or "").upper()
95
+ return {"STRUCT": "struct", "UNION": "union", "(": "enum"}.get(tok, "alias")
96
+ return None
97
+
98
+
99
+ def kind_of(obj, app_guid=None):
100
+ k = GUID_KIND.get((to_text(_safe(lambda: obj.type)) or "").strip("{}").lower())
101
+ if k:
102
+ return k
103
+ if app_guid and guid_of(obj) == app_guid:
104
+ return "application"
105
+ if _safe(lambda: obj.is_folder, False):
106
+ return "folder"
107
+ if _safe(lambda: obj.is_device, False):
108
+ return "device"
109
+ return "other"
110
+
111
+
112
+ def is_st(obj, kind):
113
+ """True for text objects, False for graphical bodies (SFC/CFC/LD/FBD/IL)."""
114
+ if kind in ("pou", "method", "action", "accessor"):
115
+ return bool(_safe(lambda: obj.has_textual_implementation, False))
116
+ if kind in ("dut", "gvl", "interface", "interface_method", "property"):
117
+ return True
118
+ return None
119
+
120
+
121
+ def chain_names(obj):
122
+ """Names from the top of the project down to obj."""
123
+ names = []
124
+ cur = obj
125
+ for _ in range(64):
126
+ if cur is None:
127
+ break
128
+ nm = _safe(lambda: cur.get_name())
129
+ if nm is None:
130
+ break
131
+ names.insert(0, to_text(nm))
132
+ cur = _safe(lambda: cur.parent)
133
+ return names
134
+
135
+
136
+ class Ctx(object):
137
+ def __init__(self, require_project=True):
138
+ self.proj = se.projects.primary
139
+ if self.proj is None and require_project:
140
+ raise OpError("no_project", "No project is open. Call open_project first.")
141
+ self.app = _safe(lambda: self.proj.active_application) if self.proj is not None else None
142
+ self.app_guid = guid_of(self.app) if self.app is not None else None
143
+ self.app_chain = chain_names(self.app) if self.app is not None else []
144
+
145
+ def rel(self, names):
146
+ n = len(self.app_chain)
147
+ if n and names[:n] == self.app_chain and len(names) > n:
148
+ return "/".join(names[n:])
149
+ return "/" + "/".join(names)
150
+
151
+ def resolve(self, path):
152
+ path = (path or "").strip()
153
+ if path in ("", ".", "/"):
154
+ if path == "/" or self.app is None:
155
+ raise OpError("bad_path", "Give an object path, e.g. 'PLC_PRG' or 'FB_Pump/Start'.")
156
+ return self.app
157
+ absolute = path.startswith("/")
158
+ parts = [p for p in path.split("/") if p]
159
+ if absolute or self.app is None:
160
+ base_children = children(self.proj)
161
+ cur = None
162
+ else:
163
+ if parts and parts[0].lower() == name_of(self.app).lower() and _find(children(self.app), parts[0]) is None:
164
+ parts = parts[1:]
165
+ base_children = None
166
+ cur = self.app
167
+ walked = []
168
+ for part in parts:
169
+ pool = base_children if cur is None else children(cur)
170
+ nxt = _find(pool, part)
171
+ if nxt is None:
172
+ where = "/".join(walked) or ("project root" if (absolute or self.app is None) else name_of(self.app))
173
+ hint = ", ".join(sorted(name_of(c) for c in pool)[:25])
174
+ raise OpError("not_found", "No object '%s' under %s. Children there: %s" % (part, where, hint or "(none)"))
175
+ walked.append(name_of(nxt))
176
+ cur = nxt
177
+ return cur
178
+
179
+ def path_of(self, obj):
180
+ return self.rel(chain_names(obj))
181
+
182
+ def save_if(self, args):
183
+ if args.get("save"):
184
+ self.proj.save()
185
+ return True
186
+ return False
187
+
188
+
189
+ def _find(objs, name):
190
+ for c in objs:
191
+ if name_of(c) == name:
192
+ return c
193
+ low = name.lower()
194
+ for c in objs:
195
+ if name_of(c).lower() == low:
196
+ return c
197
+ return None
198
+
199
+
200
+ def _norm(p):
201
+ return os.path.normcase(os.path.abspath(p)) if p else p
202
+
203
+
204
+ def _build_props(obj):
205
+ """An object's build properties, only those that are set (they are rare)."""
206
+ out = {}
207
+ if _safe(lambda: obj.effectively_excluded_from_build, False):
208
+ out["excluded"] = True
209
+ bp = _safe(lambda: obj.build_properties)
210
+ if bp is not None:
211
+ if _safe(lambda: bp.link_always_is_valid, False) and _safe(lambda: bp.link_always, False):
212
+ out["link_always"] = True
213
+ if _safe(lambda: bp.external_is_valid, False) and _safe(lambda: bp.external, False):
214
+ out["external"] = True
215
+ return out
216
+
217
+
218
+ def _split_defines(text):
219
+ """CODESYS compiler defines: 'A, B:=2; C' -> ['A', 'B', 'C']."""
220
+ out = []
221
+ for part in re.split(r"[,;\s]+", to_text(text) or ""):
222
+ name = part.split(":=")[0].strip()
223
+ if name:
224
+ out.append(name)
225
+ return out
226
+
227
+
228
+ def _project_info(proj, ctx=None):
229
+ ctx = ctx or Ctx()
230
+ info = {
231
+ "path": to_text(_safe(lambda: proj.path)),
232
+ "dirty": bool(_safe(lambda: proj.dirty, False)),
233
+ "application": name_of(ctx.app) if ctx.app is not None else None,
234
+ }
235
+ if ctx.app is not None:
236
+ bp = _safe(lambda: ctx.app.build_properties)
237
+ if bp is not None and _safe(lambda: bp.compiler_defines_is_valid, False):
238
+ info["defines"] = _split_defines(_safe(lambda: bp.compiler_defines, ""))
239
+ settings = _safe(lambda: proj.project_settings)
240
+ if settings is not None:
241
+ # Scripting 4.2 and later
242
+ cv = _safe(lambda: settings.get_compilerversion())
243
+ if cv is None:
244
+ cv = _safe(lambda: settings.compilerversion)
245
+ if cv is not None:
246
+ info["compiler_version"] = to_text(cv)
247
+ pd = _safe(lambda: settings.project_defines)
248
+ if pd is not None:
249
+ info["project_defines"] = _split_defines(pd if isinstance(pd, (str, type(u""))) else ", ".join(to_text(x) for x in pd))
250
+ return info
251
+
252
+
253
+ def _node(ctx, obj, names, depth, with_text):
254
+ kind = kind_of(obj, ctx.app_guid)
255
+ decl = decl_of(obj) if (with_text or kind in ("pou", "dut")) else None
256
+ node = {
257
+ "path": ctx.rel(names),
258
+ "name": names[-1],
259
+ "kind": kind,
260
+ "depth": depth,
261
+ }
262
+ sk = subkind(kind, decl)
263
+ if sk:
264
+ node["sub"] = sk
265
+ st = is_st(obj, kind)
266
+ if st is not None:
267
+ node["st"] = st
268
+ if kind in ("pou", "gvl", "dut", "folder", "interface"):
269
+ b = _build_props(obj)
270
+ if b:
271
+ node["build"] = b
272
+ if with_text:
273
+ if decl is not None:
274
+ node["declaration"] = decl
275
+ impl = impl_of(obj)
276
+ if impl is not None:
277
+ node["implementation"] = impl
278
+ if st is False:
279
+ # A graphical body has no text: keep its PLCopenXML, so checkpoints can restore it.
280
+ xml = _export_xml(obj)
281
+ if xml:
282
+ node["xml"] = xml
283
+ return node
284
+
285
+
286
+ def _export_xml(obj):
287
+ import io
288
+ f = os.path.join(os.environ.get("CODESYS_MCP_DIR") or os.environ.get("TEMP") or ".", "node-export.xml")
289
+ try:
290
+ obj.export_xml(None, f, True)
291
+ with io.open(f, "r", encoding="utf-8-sig") as fh:
292
+ return fh.read()
293
+ except Exception:
294
+ return None
295
+ finally:
296
+ _safe(lambda: os.remove(f))
297
+
298
+
299
+ def op_object_import_xml(args):
300
+ """Recreate an object from PLCopenXML (export_xml) under parent, e.g. a graphical POU
301
+ when a checkpoint is restored."""
302
+ import io
303
+ c = Ctx()
304
+ parent = c.resolve(args.get("parent") or ".")
305
+ before = set(name_of(x).lower() for x in children(parent))
306
+ f = os.path.join(os.environ.get("CODESYS_MCP_DIR") or os.environ.get("TEMP") or ".", "import.xml")
307
+ try:
308
+ with io.open(f, "w", encoding="utf-8") as fh:
309
+ fh.write(args["xml"] if not PY2 or isinstance(args["xml"], unicode) else args["xml"].decode("utf-8")) # noqa: F821
310
+ parent.import_xml(None, f)
311
+ finally:
312
+ _safe(lambda: os.remove(f))
313
+ added = [c.path_of(x) for x in children(parent) if name_of(x).lower() not in before]
314
+ if not added:
315
+ raise OpError("codesys_error", "CODESYS imported nothing under %s." % (args.get("parent") or "the application"))
316
+ return {"added": added, "saved": c.save_if(args)}
317
+
318
+
319
+ def _walk(ctx, root, root_names, max_depth, with_text, out):
320
+ def rec(obj, names, depth):
321
+ for c in children(obj):
322
+ cn = names + [name_of(c)]
323
+ out.append(_node(ctx, c, cn, depth, with_text))
324
+ if max_depth is None or depth < max_depth:
325
+ rec(c, cn, depth + 1)
326
+ rec(root, root_names, 0)
327
+
328
+
329
+ # ---------------------------------------------------------------- ops
330
+
331
+ def op_ping(args):
332
+ info = {"engine": "codesys" if not hasattr(se, "FAKE") else "fake"}
333
+ proj = _safe(lambda: se.projects.primary)
334
+ info["project"] = to_text(_safe(lambda: proj.path)) if proj is not None else None
335
+ return info
336
+
337
+
338
+ def op_project_open(args):
339
+ path = args["path"]
340
+ cur = se.projects.primary
341
+ if cur is not None:
342
+ if _norm(to_text(_safe(lambda: cur.path))) == _norm(path):
343
+ info = _project_info(cur)
344
+ info["reused"] = True
345
+ return info
346
+ if _safe(lambda: cur.dirty, False) and not args.get("discard_changes"):
347
+ raise OpError("unsaved_changes", "Project %s has unsaved changes. Save it, or pass discardChanges." % to_text(cur.path))
348
+ cur.close()
349
+ if not os.path.exists(path):
350
+ raise OpError("not_found", "Project file not found: %s" % path)
351
+ flags = None
352
+ try:
353
+ flags = se.VersionUpdateFlags.NoUpdates | se.VersionUpdateFlags.SilentMode
354
+ except AttributeError:
355
+ flags = None
356
+ if path.lower().endswith(".projectarchive"):
357
+ # Extract next to the archive (or where asked); never over an existing project.
358
+ target = args.get("extract_to") or (os.path.splitext(path)[0] + ".project")
359
+ if os.path.exists(target):
360
+ raise OpError("exists", "%s already exists. Open it directly, or pass extractTo for another file name." % target)
361
+ proj = (se.projects.open_archive(path, target, False, None, None, None, flags) if flags is not None
362
+ else se.projects.open_archive(path, target, False, None))
363
+ if proj is None:
364
+ raise OpError("open_failed", "CODESYS returned no project for archive %s" % path)
365
+ info = _project_info(proj)
366
+ info["extracted_from"] = path
367
+ return info
368
+ proj = se.projects.open(path, update_flags=flags) if flags is not None else se.projects.open(path)
369
+ if proj is None:
370
+ raise OpError("open_failed", "CODESYS returned no project for %s" % path)
371
+ return _project_info(proj)
372
+
373
+
374
+ def _version_key(v):
375
+ return [int(x) if x.isdigit() else 0 for x in to_text(v).split(".")]
376
+
377
+
378
+ def _update_device_to_newest(dev):
379
+ """Move a device to the newest version of it in the repository. Returns that version, or None."""
380
+ did = dev.get_device_identification()
381
+ same = [d.device_id for d in se.device_repository.get_all_devices()
382
+ if d.device_id.type == did.type and to_text(d.device_id.id) == to_text(did.id)]
383
+ if not same:
384
+ return None
385
+ newest = max(same, key=lambda x: _version_key(x.version))
386
+ if _version_key(newest.version) <= _version_key(did.version):
387
+ return None
388
+ dev.update(newest)
389
+ return to_text(newest.version)
390
+
391
+
392
+ def op_project_open_new(args):
393
+ """Open a project just copied from CODESYS's Standard.project and bring it up to this
394
+ CODESYS. The shipped file can be years old: SP22's names device 3.1.3.0 and IoStandard
395
+ 3.1.3.1, which a current install doesn't have, so it can't compile. Only for such a
396
+ fresh copy: a project the user opens is never updated (see op_project_open)."""
397
+ path = args["path"]
398
+ cur = se.projects.primary
399
+ if cur is not None:
400
+ if _safe(lambda: cur.dirty, False) and not args.get("discard_changes"):
401
+ raise OpError("unsaved_changes", "Project %s has unsaved changes. Save it, or pass discardChanges." % to_text(cur.path))
402
+ cur.close()
403
+ flags = se.VersionUpdateFlags
404
+ proj = se.projects.open(path, update_flags=flags.UpdateAll | flags.SilentMode)
405
+ if proj is None:
406
+ raise OpError("open_failed", "CODESYS returned no project for %s" % path)
407
+ updated = None
408
+ for obj in proj.get_children(False):
409
+ if _safe(lambda: obj.is_device, False):
410
+ updated = _update_device_to_newest(obj) or updated
411
+ proj.save()
412
+ # CODESYS keeps reporting the old template's load errors until the project is loaded again.
413
+ proj.close()
414
+ proj = se.projects.open(path, update_flags=flags.NoUpdates | flags.SilentMode)
415
+ if proj is None:
416
+ raise OpError("open_failed", "CODESYS returned no project for %s" % path)
417
+ info = _project_info(proj)
418
+ if updated:
419
+ info["updated_device"] = updated
420
+ return info
421
+
422
+
423
+ def op_project_create_empty(args):
424
+ path = args["path"]
425
+ if os.path.exists(path):
426
+ raise OpError("exists", "File already exists: %s" % path)
427
+ cur = se.projects.primary
428
+ if cur is not None:
429
+ if _safe(lambda: cur.dirty, False) and not args.get("discard_changes"):
430
+ raise OpError("unsaved_changes", "Project %s has unsaved changes." % to_text(cur.path))
431
+ cur.close()
432
+ proj = se.projects.create(path, True)
433
+ proj.save()
434
+ return _project_info(proj, Ctx())
435
+
436
+
437
+ def op_project_info(args):
438
+ c = Ctx(require_project=False)
439
+ if c.proj is None:
440
+ return {"open": False}
441
+ info = _project_info(c.proj, c)
442
+ info["open"] = True
443
+ return info
444
+
445
+
446
+ def op_project_save(args):
447
+ c = Ctx()
448
+ c.proj.save()
449
+ info = _project_info(c.proj, c)
450
+ if args.get("archive"):
451
+ if os.path.exists(args["archive"]):
452
+ raise OpError("exists", "%s already exists." % args["archive"])
453
+ c.proj.save_archive(args["archive"])
454
+ info["archive"] = args["archive"]
455
+ return info
456
+
457
+
458
+ def op_project_close(args):
459
+ c = Ctx(require_project=False)
460
+ if c.proj is None:
461
+ return {"closed": False}
462
+ if _safe(lambda: c.proj.dirty, False) and not args.get("discard_changes"):
463
+ raise OpError("unsaved_changes", "Project has unsaved changes. Save it, or pass discardChanges.")
464
+ c.proj.close()
465
+ return {"closed": True}
466
+
467
+
468
+ def op_tree(args):
469
+ c = Ctx()
470
+ out = []
471
+ if args.get("root"):
472
+ root = c.resolve(args["root"])
473
+ names = chain_names(root)
474
+ elif args.get("whole_project") or c.app is None:
475
+ root, names = c.proj, []
476
+ else:
477
+ root, names = c.app, c.app_chain
478
+ _walk(c, root, names, args.get("max_depth"), False, out)
479
+ return {"nodes": out, "application": name_of(c.app) if c.app is not None else None}
480
+
481
+
482
+ def op_objects_read(args):
483
+ c = Ctx()
484
+ res = []
485
+ for p in args.get("paths", []):
486
+ try:
487
+ obj = c.resolve(p)
488
+ res.append(_node(c, obj, chain_names(obj), 0, True))
489
+ except OpError as e:
490
+ res.append({"path": p, "error": e.message, "code": e.code})
491
+ return {"objects": res}
492
+
493
+
494
+ def op_object_write(args):
495
+ c = Ctx()
496
+ obj = c.resolve(args["path"])
497
+ kind = kind_of(obj, c.app_guid)
498
+ if "declaration" in args and args["declaration"] is not None:
499
+ if not _safe(lambda: obj.has_textual_declaration, False):
500
+ raise OpError("no_declaration", "%s (%s) has no textual declaration." % (args["path"], kind))
501
+ obj.textual_declaration.replace(args["declaration"])
502
+ if "implementation" in args and args["implementation"] is not None:
503
+ if not _safe(lambda: obj.has_textual_implementation, False):
504
+ raise OpError("not_st", "%s (%s) has no textual (ST) implementation; v2 edits ST only." % (args["path"], kind))
505
+ obj.textual_implementation.replace(args["implementation"])
506
+ return {"path": c.path_of(obj), "kind": kind, "saved": c.save_if(args)}
507
+
508
+
509
+ def op_object_create(args):
510
+ c = Ctx()
511
+ parent = c.resolve(args.get("parent") or ".")
512
+ kind = args["kind"]
513
+ name = args["name"]
514
+ rt = args.get("return_type")
515
+ if kind in ("program", "function_block", "function"):
516
+ pt = {"program": se.PouType.Program, "function_block": se.PouType.FunctionBlock,
517
+ "function": se.PouType.Function}[kind]
518
+ if kind == "function":
519
+ if not rt:
520
+ raise OpError("bad_args", "A function needs returnType.")
521
+ obj = parent.create_pou(name, pt, return_type=rt)
522
+ else:
523
+ obj = parent.create_pou(name, pt)
524
+ elif kind == "method":
525
+ obj = parent.create_method(name, rt) if rt else parent.create_method(name)
526
+ elif kind == "property":
527
+ if not rt:
528
+ raise OpError("bad_args", "A property needs returnType.")
529
+ obj = parent.create_property(name, rt)
530
+ elif kind == "action":
531
+ obj = parent.create_action(name)
532
+ elif kind == "interface":
533
+ base = args.get("base_interfaces")
534
+ try:
535
+ # Empty base list: no implicit __System.IQueryInterface.
536
+ obj = parent.create_interface(name, base if base else "")
537
+ except Exception:
538
+ obj = parent.create_interface(name)
539
+ elif kind == "dut":
540
+ dt = {"struct": se.DutType.Structure, "enum": se.DutType.Enumeration,
541
+ "union": se.DutType.Union, "alias": se.DutType.Alias}[args.get("dut_type") or "struct"]
542
+ obj = parent.create_dut(name, dt)
543
+ elif kind == "gvl":
544
+ obj = parent.create_gvl(name)
545
+ elif kind == "folder":
546
+ obj = parent.create_folder(name)
547
+ else:
548
+ raise OpError("bad_args", "Unknown kind %s" % kind)
549
+ if args.get("declaration") is not None:
550
+ obj.textual_declaration.replace(args["declaration"])
551
+ if args.get("implementation") is not None:
552
+ if not _safe(lambda: obj.has_textual_implementation, False):
553
+ raise OpError("not_st", "Created %s has no textual implementation." % name)
554
+ obj.textual_implementation.replace(args["implementation"])
555
+ return {"path": c.path_of(obj), "kind": kind_of(obj, c.app_guid), "saved": c.save_if(args)}
556
+
557
+
558
+ def op_object_delete(args):
559
+ c = Ctx()
560
+ obj = c.resolve(args["path"])
561
+ path = c.path_of(obj)
562
+ obj.remove()
563
+ return {"path": path, "saved": c.save_if(args)}
564
+
565
+
566
+ def op_object_rename(args):
567
+ c = Ctx()
568
+ obj = c.resolve(args["path"])
569
+ obj.rename(args["new_name"])
570
+ return {"path": c.path_of(obj), "saved": c.save_if(args)}
571
+
572
+
573
+ def op_export(args):
574
+ c = Ctx()
575
+ out = []
576
+ if args.get("root"):
577
+ root = c.resolve(args["root"])
578
+ names = chain_names(root)
579
+ elif c.app is not None:
580
+ root, names = c.app, c.app_chain
581
+ else:
582
+ root, names = c.proj, []
583
+ _walk(c, root, names, None, True, out)
584
+ return {"nodes": out, "application": name_of(c.app) if c.app is not None else None}
585
+
586
+
587
+ def op_build(args):
588
+ c = Ctx()
589
+ if c.app is None:
590
+ raise OpError("no_application", "The project has no active application to build.")
591
+ steps = []
592
+ linked = []
593
+ was_dirty = bool(_safe(lambda: c.proj.dirty, True))
594
+ try:
595
+ if args.get("check_all"):
596
+ # A build only compiles what a task reaches. Mark every other POU, GVL and
597
+ # DUT "link always" for this build, then put the flags back.
598
+ # (project.check_all_pool_objects() checks the POU pool, not the
599
+ # application, and clears the build's messages.)
600
+ for o in _walk_all(c, c.app):
601
+ if kind_of(o, c.app_guid) not in ("pou", "gvl", "dut"):
602
+ continue
603
+ if _TEST_ATTR.search(decl_of(o) or ""):
604
+ continue # programs marked as tests can use assertions CODESYS doesn't know
605
+ bp = _safe(lambda: o.build_properties)
606
+ if bp is None or not _safe(lambda: bp.link_always_is_valid, False):
607
+ continue
608
+ if _safe(lambda: bp.exclude_from_build, False) or _safe(lambda: bp.link_always, True):
609
+ continue
610
+ bp.link_always = True
611
+ linked.append(bp)
612
+ steps.append("check_all")
613
+ if args.get("clean"):
614
+ c.app.clean()
615
+ steps.append("clean")
616
+ if args.get("deep"):
617
+ # Code generation finds errors the build alone doesn't.
618
+ c.app.generate_code()
619
+ steps.append("generate_code")
620
+ else:
621
+ c.app.build()
622
+ steps.append("build")
623
+ finally:
624
+ for bp in linked:
625
+ _safe(lambda: setattr(bp, "link_always", False))
626
+ if linked and not was_dirty:
627
+ _safe(lambda: c.proj.save()) # same content as before; leaves no unsaved change behind
628
+ msgs = []
629
+ seen = set()
630
+ for cat in se.system.get_message_categories(True):
631
+ if (to_text(cat) or "").strip("{}").lower() == SCRIPT_MESSAGE_CATEGORY:
632
+ continue
633
+ desc = to_text(_safe(lambda: se.system.get_message_category_description(cat), ""))
634
+ for m in se.system.get_message_objects(cat):
635
+ obj = _safe(lambda: m.object)
636
+ opath = c.path_of(obj) if obj is not None else None
637
+ key = (to_text(_safe(lambda: m.text, "")), to_text(_safe(lambda: m.position_text)), opath)
638
+ if key in seen: # the same message from the build and the full check
639
+ continue
640
+ seen.add(key)
641
+ msgs.append({
642
+ "category": desc,
643
+ "severity": to_text(_safe(lambda: m.severity, "")),
644
+ "text": to_text(_safe(lambda: m.text, "")),
645
+ "prefix": to_text(_safe(lambda: m.prefix)),
646
+ "number": _safe(lambda: int(m.number)),
647
+ "object": opath,
648
+ "position": _exact_position(m),
649
+ "length": _safe(lambda: int(m.length)),
650
+ })
651
+ return {"messages": msgs, "steps": steps}
652
+
653
+
654
+ def _exact_position(m):
655
+ """position_text, with the column from position_offset: CODESYS's text says
656
+ 'Column 1' while position_offset holds where the token starts in the line
657
+ (verified on SP21: 'Line 2, Column 1' for an error at offset 4, length 6)."""
658
+ text = to_text(_safe(lambda: m.position_text))
659
+ offset = _safe(lambda: int(m.position_offset))
660
+ if text and offset is not None and offset >= 0:
661
+ text = re.sub(r"Column \d+", "Column %d" % (offset + 1), text, count=1)
662
+ return text
663
+
664
+
665
+ def _library_manager(c):
666
+ root = c.app if c.app is not None else c.proj
667
+ for ch in children(root):
668
+ if kind_of(ch, c.app_guid) == "library_manager":
669
+ return ch
670
+ raise OpError("no_library_manager", "No Library Manager found under %s." % (name_of(root) if c.app is not None else "the project"))
671
+
672
+
673
+ def op_libraries_list(args):
674
+ c = Ctx()
675
+ lm = _library_manager(c)
676
+ refs = []
677
+ for r in list(_safe(lambda: lm.references, []) or []):
678
+ rec = {"name": to_text(_safe(lambda: r.name))}
679
+ for attr in ("is_placeholder", "is_managed", "namespace", "effective_resolution", "default_resolution"):
680
+ v = _safe(lambda: getattr(r, attr))
681
+ if v is not None:
682
+ rec[attr] = v if isinstance(v, bool) else to_text(v)
683
+ refs.append(rec)
684
+ if not refs:
685
+ refs = [{"name": to_text(n)} for n in (_safe(lambda: lm.get_libraries(False), []) or [])]
686
+ return {"libraries": refs}
687
+
688
+
689
+ def op_library_api(args):
690
+ """Declarations of a referenced library's objects, read from its source
691
+ .library file opened as a secondary, read-only project. Compiled libraries
692
+ (.compiled-library) carry no readable source."""
693
+ c = Ctx()
694
+ want = (args.get("name") or "").strip().lower()
695
+ lm = _library_manager(c)
696
+ match = None
697
+ for r in list(_safe(lambda: lm.references, []) or []):
698
+ nm = (to_text(_safe(lambda: r.name)) or "").strip()
699
+ short = nm.lstrip("#").split(",")[0].strip().lower()
700
+ if nm.lower() == want or short == want:
701
+ match = r
702
+ break
703
+ if match is None:
704
+ raise OpError("not_found", "No library reference named '%s'. Use list_libraries." % args.get("name"))
705
+ display = to_text(_safe(lambda: match.effective_resolution)) or to_text(_safe(lambda: match.name))
706
+ glm = getattr(se, "librarymanager", None)
707
+ if glm is None:
708
+ raise OpError("unavailable", "The CODESYS library manager is not available to scripts here.")
709
+ managed = _safe(lambda: match.managed_library)
710
+ if managed is None:
711
+ found = _safe(lambda: glm.find_library(display))
712
+ if found:
713
+ managed = found[0]
714
+ if managed is None:
715
+ raise OpError("not_installed", "Library %s is not installed in any repository." % display)
716
+ path = to_text(_safe(lambda: glm.get_file_path(managed))) or ""
717
+ info = {"library": display, "path": path, "title": to_text(_safe(lambda: managed.title)),
718
+ "version": to_text(_safe(lambda: managed.version)), "company": to_text(_safe(lambda: managed.company))}
719
+ if not path.lower().endswith(".library"):
720
+ info["compiled"] = True
721
+ info["nodes"] = []
722
+ return info
723
+ proj = se.projects.open(path, primary=False)
724
+ if proj is None:
725
+ raise OpError("open_failed", "CODESYS could not open %s." % path)
726
+ try:
727
+ out = []
728
+ _walk(c, proj, [], None, True, out)
729
+ for n in out:
730
+ n.pop("implementation", None)
731
+ info["nodes"] = out
732
+ finally:
733
+ _safe(lambda: proj.close())
734
+ return info
735
+
736
+
737
+ def _installed_libraries(name):
738
+ """The installed libraries with this title; None when scripts can't see the repositories."""
739
+ glm = getattr(se, "librarymanager", None)
740
+ if glm is None:
741
+ return None
742
+ want = (name or "").strip().lstrip("#").split(",")[0].strip().lower()
743
+ return [l for l in glm.get_all_libraries() if (to_text(l.title) or "").lower() == want]
744
+
745
+
746
+ def op_library_find(args):
747
+ found = _installed_libraries(args.get("name"))
748
+ if found is None:
749
+ raise OpError("unavailable", "The CODESYS library manager is not available to scripts here.")
750
+ return {
751
+ "repositories": [to_text(r.root_folder) for r in se.librarymanager.repositories],
752
+ "matches": [{"title": to_text(l.title), "version": to_text(l.version), "company": to_text(l.company)} for l in found],
753
+ }
754
+
755
+
756
+ def op_libraries_add(args):
757
+ c = Ctx()
758
+ lm = _library_manager(c)
759
+ name = args["name"].strip()
760
+ if "," not in name:
761
+ # A bare title, e.g. "Util": the newest installed, or the version asked for.
762
+ found = _installed_libraries(name)
763
+ if found is not None:
764
+ if not found:
765
+ raise OpError("not_found", "No library titled '%s' is installed in the CODESYS repositories." % name)
766
+ newest = max(found, key=lambda l: _version_key(l.version))
767
+ name = "%s, %s (%s)" % (to_text(newest.title), args.get("version") or "*", to_text(newest.company))
768
+ lm.add_library(name)
769
+ return {"added": name, "saved": c.save_if(args)}
770
+
771
+
772
+ def op_libraries_remove(args):
773
+ c = Ctx()
774
+ lm = _library_manager(c)
775
+ name = args["name"]
776
+ names = [to_text(n) for n in (_safe(lambda: list(lm.get_libraries(False)), []) or [])]
777
+ match = [n for n in names if n.lower() == name.lower()] or [n for n in names if n.lower().split(",")[0].strip() == name.lower().split(",")[0].strip()]
778
+ if not match:
779
+ raise OpError("not_found", "%s is not referenced. Referenced: %s" % (name, ", ".join(names) or "none"))
780
+ lm.remove_library(match[0])
781
+ left = [to_text(n) for n in (_safe(lambda: list(lm.get_libraries(False)), []) or [])]
782
+ if match[0] in left:
783
+ raise OpError("codesys_error", "CODESYS did not remove %s." % match[0])
784
+ return {"removed": match[0], "saved": c.save_if(args)}
785
+
786
+
787
+ def op_tasks_list(args):
788
+ c = Ctx()
789
+ tasks = []
790
+ root = c.app if c.app is not None else c.proj
791
+
792
+ def rec(obj):
793
+ for ch in children(obj):
794
+ k = kind_of(ch, c.app_guid)
795
+ if k == "task":
796
+ tasks.append(_task_info(c, ch))
797
+ elif k in ("task_config", "folder"):
798
+ rec(ch)
799
+ rec(root)
800
+ return {"tasks": tasks}
801
+
802
+
803
+ def op_objects_xml(args):
804
+ """Objects as PLCopenXML (export_xml), e.g. graphical bodies (SFC, CFC, LD, FBD) that
805
+ have no ST text. Read-only: the XML goes through a temp file in the session folder."""
806
+ import io
807
+ c = Ctx()
808
+ tmp = os.environ.get("CODESYS_MCP_DIR") or os.environ.get("TEMP") or "."
809
+ limit = int(args.get("max_chars") or 200000)
810
+ res = []
811
+ for k, p in enumerate(args.get("paths", [])):
812
+ f = os.path.join(tmp, "export-%d.xml" % k)
813
+ try:
814
+ obj = c.resolve(p)
815
+ obj.export_xml(None, f, True)
816
+ with io.open(f, "r", encoding="utf-8-sig") as fh:
817
+ xml = fh.read()
818
+ res.append({"path": c.path_of(obj), "kind": kind_of(obj, c.app_guid), "xml": xml[:limit], "truncated": len(xml) > limit, "chars": len(xml)})
819
+ except OpError as e:
820
+ res.append({"path": p, "error": e.message, "code": e.code})
821
+ except Exception as e:
822
+ res.append({"path": p, "error": to_text(e), "code": "codesys_error"})
823
+ finally:
824
+ _safe(lambda: os.remove(f))
825
+ return {"objects": res}
826
+
827
+
828
+ def op_devices_list(args):
829
+ """The device tree with identification and state, and each device's I/O mapping
830
+ as CODESYS exports it (export_io_mappings_as_csv: 'CoDeSys Mapping Export V1.2').
831
+ Read-only: the CSV goes to a temp file in the session folder."""
832
+ c = Ctx()
833
+ tmp = os.environ.get("CODESYS_MCP_DIR") or os.environ.get("TEMP") or "."
834
+ out = []
835
+
836
+ def rec(obj, names, depth):
837
+ for ch in children(obj):
838
+ nm = name_of(ch)
839
+ if _safe(lambda: ch.is_device, False):
840
+ ident = _safe(lambda: ch.get_device_identification())
841
+ info = {
842
+ "path": "/".join(names + [nm]),
843
+ "name": nm,
844
+ "depth": depth,
845
+ "type": _safe(lambda: int(ident.type)),
846
+ "id": to_text(_safe(lambda: ident.id)),
847
+ "version": to_text(_safe(lambda: ident.version)),
848
+ "enabled": _safe(lambda: bool(ch.is_enabled())),
849
+ }
850
+ if args.get("io", True):
851
+ p = os.path.join(tmp, "io-mapping-%d.csv" % len(out))
852
+ try:
853
+ ch.export_io_mappings_as_csv(p)
854
+ with open(p) as f:
855
+ text = f.read()
856
+ if any(l.strip() and not l.startswith("//") for l in text.splitlines()):
857
+ info["io_csv"] = text
858
+ except Exception as e:
859
+ info["io_error"] = to_text(e)
860
+ finally:
861
+ _safe(lambda: os.remove(p))
862
+ out.append(info)
863
+ rec(ch, names + [nm], depth + 1)
864
+ elif kind_of(ch, c.app_guid) in ("folder", "other"):
865
+ rec(ch, names + [nm], depth) # devices sit under "Plc Logic" and folders
866
+ rec(c.proj, [], 0)
867
+ return {"devices": out}
868
+
869
+
870
+ def _task_info(c, t):
871
+ wd = _safe(lambda: t.watchdog)
872
+ return {
873
+ "name": name_of(t),
874
+ "path": c.path_of(t),
875
+ "interval": to_text(_safe(lambda: t.interval)),
876
+ "interval_unit": to_text(_safe(lambda: t.interval_unit)),
877
+ "priority": to_text(_safe(lambda: t.priority)),
878
+ "kind": to_text(_safe(lambda: t.kind_of_task)),
879
+ "event": to_text(_safe(lambda: t.event)) or None,
880
+ "core_binding": _safe(lambda: int(t.core_binding)),
881
+ "watchdog": {
882
+ "enabled": bool(_safe(lambda: wd.enabled, False)),
883
+ "time": to_text(_safe(lambda: wd.time)),
884
+ "time_unit": to_text(_safe(lambda: wd.time_unit)),
885
+ "sensitivity": to_text(_safe(lambda: wd.sensitivity)),
886
+ } if wd is not None else None,
887
+ "pous": [to_text(getattr(p, "pou_name", None) or getattr(p, "name", None) or p)
888
+ for p in (_safe(lambda: list(t.pous), []) or [])],
889
+ }
890
+
891
+
892
+ def _find_kind(c, root, kind):
893
+ out = []
894
+
895
+ def rec(obj):
896
+ for ch in children(obj):
897
+ k = kind_of(ch, c.app_guid)
898
+ if k == kind:
899
+ out.append(ch)
900
+ if k in ("task_config", "folder"):
901
+ rec(ch)
902
+ rec(root)
903
+ return out
904
+
905
+
906
+ _TASK_KINDS = {"cyclic": "Cyclic", "freewheeling": "Freewheeling", "event": "Event", "external_event": "ExternalEvent", "status": "Status"}
907
+
908
+
909
+ def op_task_configure(args):
910
+ """Create or change a task: kind, interval, priority, event, watchdog, and its program calls."""
911
+ c = Ctx()
912
+ if c.app is None:
913
+ raise OpError("no_application", "The project has no active application.")
914
+ name = args["name"]
915
+ tasks = [t for t in _find_kind(c, c.app, "task") if name_of(t).lower() == name.lower()]
916
+ created = False
917
+ if tasks:
918
+ task = tasks[0]
919
+ elif args.get("create"):
920
+ cfgs = _find_kind(c, c.app, "task_config")
921
+ cfg = cfgs[0] if cfgs else c.app.create_task_configuration()
922
+ task = cfg.create_task(name)
923
+ created = True
924
+ else:
925
+ known = ", ".join(name_of(t) for t in _find_kind(c, c.app, "task")) or "none"
926
+ raise OpError("not_found", "No task %s (tasks: %s). Pass create to make it." % (name, known))
927
+
928
+ if args.get("kind"):
929
+ k = _TASK_KINDS.get(args["kind"].lower())
930
+ if k is None:
931
+ raise OpError("bad_args", "kind must be one of %s" % ", ".join(sorted(_TASK_KINDS)))
932
+ task.kind_of_task = getattr(se.KindOfTask, k)
933
+ for key in ("interval", "interval_unit", "priority", "event"):
934
+ if args.get(key) is not None:
935
+ setattr(task, key, to_text(args[key]))
936
+ if args.get("core_binding") is not None:
937
+ task.core_binding = int(args["core_binding"])
938
+ wd = args.get("watchdog")
939
+ if wd:
940
+ w = task.watchdog
941
+ if wd.get("enabled") is not None:
942
+ w.enabled = bool(wd["enabled"])
943
+ for key in ("time", "time_unit", "sensitivity"):
944
+ if wd.get(key) is not None:
945
+ setattr(w, key, to_text(wd[key]))
946
+
947
+ calls = [to_text(getattr(p, "pou_name", None) or getattr(p, "name", None) or p).lower() for p in list(task.pous)]
948
+ for prog in args.get("remove_calls") or []:
949
+ if prog.lower() not in calls:
950
+ raise OpError("not_found", "Task %s doesn't call %s." % (name_of(task), prog))
951
+ _remove_call(task, calls.index(prog.lower()), prog)
952
+ calls.remove(prog.lower())
953
+ if args.get("add_calls"):
954
+ programs = dict((name_of(p).lower(), name_of(p)) for p in _walk_kind(c, c.app, "pou") if subkind("pou", decl_of(p)) == "program")
955
+ for prog in args["add_calls"]:
956
+ real = programs.get(prog.lower())
957
+ if real is None:
958
+ raise OpError("not_found", "%s is not a PROGRAM in the application; a task can only call programs." % prog)
959
+ if real.lower() in calls:
960
+ continue
961
+ task.pous.add(real, "")
962
+ calls.append(real.lower())
963
+ info = _task_info(c, task)
964
+ info["created"] = created
965
+ info["saved"] = c.save_if(args)
966
+ return info
967
+
968
+
969
+ def _remove_call(task, index, name):
970
+ """Remove a task's program call, and check that it is gone. CODESYS V3.5 SP21's
971
+ scripting can't do this: pous.remove(name) returns without removing anything,
972
+ and pous.remove(index) fails with "not found in the specified Collection"
973
+ (verified 9 November 2026). Say so instead of pretending."""
974
+ before = [to_text(x).lower() for x in task.pous]
975
+ for attempt in (lambda: task.pous.remove(name), lambda: task.pous.remove(index)):
976
+ try:
977
+ attempt()
978
+ except Exception:
979
+ pass
980
+ if len([x for x in task.pous]) < len(before):
981
+ return
982
+ raise OpError(
983
+ "not_supported",
984
+ "CODESYS's scripting can't remove a program call from a task (Scripting 4.1 and 4.2, on SP21 and SP22: "
985
+ "remove() leaves the call in place). Remove it in CODESYS: Task Configuration > %s > select %s > Remove Call. "
986
+ "Adding calls works." % (name_of(task), name))
987
+
988
+
989
+ _TEST_ATTR = re.compile(r"\{\s*attribute\s+'test'\s*\}", re.I)
990
+
991
+
992
+ def _walk_all(c, root):
993
+ """Every object directly in root or in its folders (not inside POUs)."""
994
+ out = []
995
+
996
+ def rec(obj):
997
+ for ch in children(obj):
998
+ if kind_of(ch, c.app_guid) == "folder":
999
+ rec(ch)
1000
+ else:
1001
+ out.append(ch)
1002
+ rec(root)
1003
+ return out
1004
+
1005
+
1006
+ def _walk_kind(c, root, kind):
1007
+ """Every object of a kind below root, through folders."""
1008
+ out = []
1009
+
1010
+ def rec(obj):
1011
+ for ch in children(obj):
1012
+ k = kind_of(ch, c.app_guid)
1013
+ if k == kind:
1014
+ out.append(ch)
1015
+ if k == "folder":
1016
+ rec(ch)
1017
+ rec(root)
1018
+ return out
1019
+
1020
+
1021
+ def _members(obj):
1022
+ """Public member names of a scripting object (names only; nothing is called).
1023
+ CODESYS's script objects are dynamic, so dir() can be empty: then the .NET
1024
+ type's members are listed instead."""
1025
+ if obj is None:
1026
+ return None
1027
+ names = [n for n in (_safe(lambda: dir(obj), []) or []) if not n.startswith("_")]
1028
+ if not names:
1029
+ t = _safe(lambda: obj.GetType())
1030
+ if t is not None:
1031
+ names = [to_text(m.Name) for m in (_safe(lambda: t.GetMembers(), []) or [])]
1032
+ names = ["%s" % n for n in names if n and not n.startswith("_") and not n.startswith("get_") and not n.startswith("set_") and n not in (".ctor", "GetType", "ToString", "Equals", "GetHashCode")]
1033
+ names.append("<.NET %s>" % to_text(_safe(lambda: t.FullName)))
1034
+ return sorted(set(names))
1035
+
1036
+
1037
+ def _versionish(obj):
1038
+ """Values of non-callable members named like a version or define (to compare CODESYS versions)."""
1039
+ out = {}
1040
+ for n in _members(obj) or []:
1041
+ low = n.lower()
1042
+ if "version" not in low and "define" not in low:
1043
+ continue
1044
+ v = _safe(lambda: getattr(obj, n))
1045
+ if v is None or callable(v):
1046
+ continue
1047
+ out[n] = to_text(v)
1048
+ return out
1049
+
1050
+
1051
+ def _reflect_api(path):
1052
+ """Every public type and member of the loaded scripting assemblies, through .NET
1053
+ reflection, in the shape of system.dump_scripting_api (which Scripting 4.2 removed)."""
1054
+ try:
1055
+ import System # IronPython only
1056
+ except ImportError:
1057
+ return "not available (no .NET)"
1058
+ import json
1059
+ types = []
1060
+ for asm in System.AppDomain.CurrentDomain.GetAssemblies():
1061
+ aname = to_text(_safe(lambda: asm.GetName().Name, ""))
1062
+ if "script" not in aname.lower():
1063
+ continue
1064
+ exported = _safe(lambda: asm.GetExportedTypes(), []) or []
1065
+ for t in exported:
1066
+ members = []
1067
+ for m in _safe(lambda: t.GetMembers(), []) or []:
1068
+ mt = to_text(m.MemberType)
1069
+ if mt == "Method":
1070
+ params = [{"Type": to_text(_safe(lambda: p.ParameterType.FullName) or _safe(lambda: p.ParameterType.Name))} for p in (_safe(lambda: m.GetParameters(), []) or [])]
1071
+ rt = _safe(lambda: m.ReturnType)
1072
+ elif mt == "Property":
1073
+ params = [{"Type": to_text(_safe(lambda: p.ParameterType.FullName) or _safe(lambda: p.ParameterType.Name))} for p in (_safe(lambda: m.GetIndexParameters(), []) or [])]
1074
+ rt = _safe(lambda: m.PropertyType)
1075
+ else:
1076
+ continue
1077
+ members.append({"Name": to_text(m.Name), "MemberType": mt, "Parameters": params,
1078
+ "Type": to_text(_safe(lambda: rt.FullName) or _safe(lambda: rt.Name)) if rt is not None else None})
1079
+ types.append({"Namespace": to_text(t.Namespace), "Name": to_text(t.Name), "Assembly": aname, "Members": members})
1080
+ doc = {"MetaData": {"Generator": "reflection, %s" % to_text(sys.version).replace("\n", " ")[:120]}, "Types": types}
1081
+ text = json.dumps(doc)
1082
+ with open(path, "w") as f:
1083
+ f.write(text.encode("utf-8") if PY2 and isinstance(text, unicode) else text) # noqa: F821
1084
+ return "%d types" % len(types)
1085
+
1086
+
1087
+ def op_api_probe(args):
1088
+ """The scripting API this CODESYS offers: member names of the main objects, the
1089
+ enums the watcher relies on, version-like values, and the object type GUIDs of
1090
+ the open project with their kinds. For comparing service packs (docs/sp22-plan.md)."""
1091
+ c = Ctx(require_project=False)
1092
+ objs = {"se": se, "system": _safe(lambda: se.system), "projects": _safe(lambda: se.projects), "project": c.proj, "application": c.app}
1093
+ samples = {}
1094
+ types = {}
1095
+ if c.proj is not None:
1096
+ stack = [c.proj]
1097
+ while stack and len(types) < 200:
1098
+ o = stack.pop()
1099
+ t = (to_text(_safe(lambda: o.type)) or "").strip("{}").lower()
1100
+ k = kind_of(o, c.app_guid)
1101
+ if t and t not in types:
1102
+ types[t] = {"kind": k, "example": name_of(o)}
1103
+ if k in ("pou", "method", "property", "accessor", "action", "dut", "gvl", "interface", "folder") and k not in samples:
1104
+ samples[k] = o
1105
+ stack.extend(_safe(lambda: children(o), []) or [])
1106
+ for k, o in samples.items():
1107
+ objs["obj_" + k] = o
1108
+ pou = samples.get("pou")
1109
+ if pou is not None:
1110
+ objs["textual_declaration"] = _safe(lambda: pou.textual_declaration)
1111
+ objs["textual_implementation"] = _safe(lambda: pou.textual_implementation)
1112
+ enums = {}
1113
+ for n in ("VersionUpdateFlags", "PouType", "DutType", "ScriptPromptHandling", "PromptHandling", "ImplementationLanguages", "ProjectProperties"):
1114
+ e = _safe(lambda: getattr(se, n))
1115
+ enums[n] = _members(e) if e is not None else None
1116
+ reflected = None
1117
+ if args.get("reflect_path"):
1118
+ reflected = _reflect_api(args["reflect_path"])
1119
+ dump = None
1120
+ if args.get("dump_path"):
1121
+ # CODESYS's own description of its scripting API (system.dump_scripting_api).
1122
+ fn = _safe(lambda: se.system.dump_scripting_api)
1123
+ if fn is None:
1124
+ dump = "not available"
1125
+ else:
1126
+ try:
1127
+ r = fn(args["dump_path"])
1128
+ dump = "written" if os.path.exists(args["dump_path"]) else ("returned: %s" % to_text(r)[:200])
1129
+ except Exception as e:
1130
+ try:
1131
+ r = fn()
1132
+ text = to_text(r) or ""
1133
+ if text:
1134
+ with open(args["dump_path"], "w") as f:
1135
+ f.write(text.encode("utf-8") if PY2 else text)
1136
+ dump = "returned %d chars (no-argument form; with a path: %s)" % (len(text), to_text(e))
1137
+ except Exception as e2:
1138
+ dump = "failed: %s / %s" % (to_text(e), to_text(e2))
1139
+ return {
1140
+ "python": to_text(_safe(lambda: sys.version, "")).replace("\n", " ")[:120],
1141
+ "dump": dump,
1142
+ "reflected": reflected,
1143
+ "members": dict((k, _members(v)) for k, v in objs.items()),
1144
+ "versions": dict((k, _versionish(v)) for k, v in objs.items() if v is not None),
1145
+ "enums": enums,
1146
+ "types": types,
1147
+ "unknown_types": sorted(t for t, v in types.items() if v["kind"] == "other"),
1148
+ }
1149
+
1150
+
1151
+ OPS = {
1152
+ "api.probe": op_api_probe,
1153
+ "ping": op_ping,
1154
+ "project.open": op_project_open,
1155
+ "project.open_new": op_project_open_new,
1156
+ "project.create_empty": op_project_create_empty,
1157
+ "project.info": op_project_info,
1158
+ "project.save": op_project_save,
1159
+ "project.close": op_project_close,
1160
+ "tree": op_tree,
1161
+ "objects.read": op_objects_read,
1162
+ "object.write": op_object_write,
1163
+ "object.create": op_object_create,
1164
+ "object.delete": op_object_delete,
1165
+ "object.rename": op_object_rename,
1166
+ "export": op_export,
1167
+ "build": op_build,
1168
+ "libraries.list": op_libraries_list,
1169
+ "libraries.add": op_libraries_add,
1170
+ "libraries.remove": op_libraries_remove,
1171
+ "tasks.list": op_tasks_list,
1172
+ "task.configure": op_task_configure,
1173
+ "devices.list": op_devices_list,
1174
+ "objects.xml": op_objects_xml,
1175
+ "object.import_xml": op_object_import_xml,
1176
+ "library.api": op_library_api,
1177
+ "library.find": op_library_find,
1178
+ }