@codesys/mcp-toolkit 2.5.0 → 2.7.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 (43) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +13 -9
  3. package/README.zh-CN.md +11 -6
  4. package/dist/codesys/device-tools.d.ts +21 -0
  5. package/dist/codesys/device-tools.js +60 -0
  6. package/dist/codesys/device-tools.js.map +1 -1
  7. package/dist/codesys/devices.d.ts +6 -0
  8. package/dist/codesys/devices.js +24 -1
  9. package/dist/codesys/devices.js.map +1 -1
  10. package/dist/codesys/recipes.d.ts +45 -0
  11. package/dist/codesys/recipes.js +130 -0
  12. package/dist/codesys/recipes.js.map +1 -0
  13. package/dist/hmi/native.d.ts +14 -2
  14. package/dist/hmi/native.js +116 -5
  15. package/dist/hmi/native.js.map +1 -1
  16. package/dist/hmi/render.d.ts +4 -0
  17. package/dist/hmi/render.js +13 -1
  18. package/dist/hmi/render.js.map +1 -1
  19. package/dist/hmi/service.d.ts +3 -0
  20. package/dist/hmi/service.js +29 -2
  21. package/dist/hmi/service.js.map +1 -1
  22. package/dist/hmi/spec.d.ts +18 -0
  23. package/dist/hmi/spec.js +45 -0
  24. package/dist/hmi/spec.js.map +1 -1
  25. package/dist/hmi/tools.js +69 -15
  26. package/dist/hmi/tools.js.map +1 -1
  27. package/dist/hmi/value.js +3 -0
  28. package/dist/hmi/value.js.map +1 -1
  29. package/dist/panel/hmi.js +66 -61
  30. package/dist/panel.js +1 -1
  31. package/dist/panel.js.map +1 -1
  32. package/dist/server.d.ts +1 -1
  33. package/dist/server.js +1 -1
  34. package/dist/tools.d.ts +2 -0
  35. package/dist/tools.js +44 -4
  36. package/dist/tools.js.map +1 -1
  37. package/package.json +1 -1
  38. package/watcher/mcp_alarms.py +42 -0
  39. package/watcher/mcp_caps.py +66 -0
  40. package/watcher/mcp_devices.py +158 -0
  41. package/watcher/mcp_lang.py +13 -1
  42. package/watcher/mcp_visu.py +81 -1
  43. package/watcher/watcher.py +2 -0
@@ -0,0 +1,66 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Which optional features this CODESYS offers. Several actions rely on CODESYS's
3
+ # services beyond its documented scripting API, or on add-ons (visualization, alarm
4
+ # management). Each check reaches the service the same way its module does, so a
5
+ # feature reported available is one that action can use.
6
+ #
7
+ # Keep this file ASCII-only (IronPython 2.7 inside CODESYS).
8
+ import os
9
+
10
+ import mcp_ops
11
+ from mcp_ops import _safe
12
+
13
+ import scriptengine as se
14
+
15
+
16
+ def _type(name):
17
+ from System import AppDomain
18
+ for a in AppDomain.CurrentDomain.GetAssemblies():
19
+ try:
20
+ t = a.GetType(name)
21
+ except Exception:
22
+ t = None
23
+ if t is not None:
24
+ return t
25
+ return None
26
+
27
+
28
+ def _assembly(name):
29
+ from System import AppDomain
30
+ for a in AppDomain.CurrentDomain.GetAssemblies():
31
+ if a.GetName().Name == name:
32
+ return True
33
+ return False
34
+
35
+
36
+ def _check(fn):
37
+ try:
38
+ return bool(fn())
39
+ except Exception:
40
+ return False
41
+
42
+
43
+ def op_capabilities(args):
44
+ import mcp_lang
45
+ import mcp_debug
46
+ caps = {
47
+ "visualization": _check(lambda: hasattr(se, "VisualElementType")),
48
+ "signatures": _check(lambda: mcp_lang._service("LanguageModelMgr") is not None),
49
+ "cross_references": _check(lambda: mcp_lang._service("CrossReferenceService") is not None),
50
+ "debugging": _check(lambda: mcp_debug._bpm() is not None),
51
+ "trace": _check(lambda: hasattr(se, "trace")),
52
+ "alarms": _check(lambda: _assembly("AlarmConfigurationObjects.plugin")),
53
+ "visu_users": _check(lambda: _type("_3S.CoDeSys.VisualObject.VisuUserMgmtLibraryInserter") is not None),
54
+ "device_repository": _check(lambda: _safe(lambda: se.device_repository) is not None),
55
+ "library_manager": _check(lambda: getattr(se, "librarymanager", None) is not None),
56
+ "editor_window": os.environ.get("CODESYS_MCP_UI") == "1",
57
+ }
58
+ fpu = None
59
+ glm = getattr(se, "librarymanager", None)
60
+ if glm is not None:
61
+ fpu = _check(lambda: any((l.title or "").lower() == "floatingpointutils" for l in glm.get_all_libraries()))
62
+ caps["floating_point_utils"] = fpu
63
+ return {"capabilities": caps}
64
+
65
+
66
+ mcp_ops.OPS["capabilities"] = op_capabilities
@@ -79,5 +79,163 @@ def op_devices_add(args):
79
79
  return {"path": c.path_of(dev), "saved": c.save_if(args)}
80
80
 
81
81
 
82
+ def _param_sets(dev):
83
+ """(prefix, parameter set) for the device itself and each of its connectors."""
84
+ out = []
85
+ ps = _safe(lambda: dev.device_parameters)
86
+ if ps is not None:
87
+ out.append(("", ps))
88
+ conns = _safe(lambda: dev.connectors) or []
89
+ for i, con in enumerate(conns):
90
+ hp = _safe(lambda: con.host_parameters)
91
+ if hp is not None and len(hp):
92
+ out.append(("c%d/" % i, hp))
93
+ return out
94
+
95
+
96
+ def _walk_elements(prefix, el, depth, out, limit):
97
+ """Every data element under el, depth-first, keyed by its identifier path."""
98
+ if len(out) >= limit:
99
+ return
100
+ ident = to_text(_safe(lambda: el.identifier, "") or "")
101
+ key = prefix + ident
102
+ has_sub = bool(_safe(lambda: el.has_sub_elements, False))
103
+ item = {
104
+ "key": key,
105
+ "name": to_text(_safe(lambda: el.visible_name, "") or ""),
106
+ "depth": depth,
107
+ }
108
+ desc = to_text(_safe(lambda: el.description, "") or "")
109
+ if desc and desc != item["name"]:
110
+ item["description"] = desc[:160]
111
+ unit = to_text(_safe(lambda: el.unit, "") or "")
112
+ if unit:
113
+ item["unit"] = unit
114
+ param = _safe(lambda: el.parameter)
115
+ if depth == 0 and param is not None:
116
+ sec = to_text(_safe(lambda: param.section, "") or "")
117
+ if sec:
118
+ item["section"] = sec
119
+ t = to_text(_safe(lambda: param.type_string, "") or "")
120
+ if t:
121
+ item["type"] = t
122
+ acc = to_text(_safe(lambda: param.offline_access_rights, "") or "")
123
+ if acc:
124
+ item["access"] = acc
125
+ ch = to_text(_safe(lambda: el.parameter.channel_type, "") or "")
126
+ if ch and ch.lower() not in ("none", "0"):
127
+ item["channel"] = ch
128
+ if not has_sub:
129
+ v = _safe(lambda: el.value)
130
+ if v is not None:
131
+ item["value"] = to_text(v)
132
+ d = _safe(lambda: el.default_value)
133
+ if d:
134
+ item["default"] = to_text(d)
135
+ if _safe(lambda: el.is_enumeration, False):
136
+ item["choices"] = [to_text(_safe(lambda: e.identifier, "")) + (("=" + to_text(e.visible_name)) if _safe(lambda: e.visible_name) else "") for e in (_safe(lambda: el.allowed_values) or [])][:40]
137
+ if _safe(lambda: el.is_mappable_io, False):
138
+ m = _safe(lambda: el.io_mapping)
139
+ if m is not None:
140
+ item["mapping"] = {
141
+ "variable": to_text(_safe(lambda: m.variable, "") or ""),
142
+ "address": to_text(_safe(lambda: m.manual_iec_address, "") or ""),
143
+ "automatic_address": bool(_safe(lambda: m.automatic_iec_address, True)),
144
+ }
145
+ out.append(item)
146
+ if has_sub:
147
+ for sub in list(el):
148
+ _walk_elements(key + "/", sub, depth + 1, out, limit)
149
+
150
+
151
+ def _device(c, path):
152
+ dev = c.resolve(path)
153
+ if not _safe(lambda: dev.is_device, False):
154
+ raise OpError("bad_path", "%s is not a device (get_devices lists them)." % path)
155
+ return dev
156
+
157
+
158
+ def op_devices_params(args):
159
+ c = Ctx()
160
+ dev = _device(c, args["path"])
161
+ limit = int(args.get("limit") or 600)
162
+ out = []
163
+ for prefix, ps in _param_sets(dev):
164
+ for p in ps:
165
+ _walk_elements(prefix, p, 0, out, limit)
166
+ return {"path": c.path_of(dev), "parameters": out, "truncated": len(out) >= limit}
167
+
168
+
169
+ def _element(dev, key):
170
+ """The data element with this key (as devices.params gives it)."""
171
+ sets = dict(_param_sets(dev))
172
+ prefix = ""
173
+ rest = key
174
+ if key.startswith("c") and "/" in key and key.split("/")[0][1:].isdigit():
175
+ prefix = key.split("/")[0] + "/"
176
+ rest = key[len(prefix):]
177
+ ps = sets.get(prefix)
178
+ if ps is None:
179
+ raise OpError("not_found", "No parameter set '%s' on this device." % (prefix or "device"))
180
+ parts = rest.split("/")
181
+ el = None
182
+ for p in ps:
183
+ if to_text(_safe(lambda: p.identifier, "")) == parts[0]:
184
+ el = p
185
+ break
186
+ if el is None:
187
+ raise OpError("not_found", "No parameter '%s' (device_parameters lists the keys)." % key)
188
+ for part in parts[1:]:
189
+ nxt = None
190
+ for sub in list(el):
191
+ if to_text(_safe(lambda: sub.identifier, "")) == part:
192
+ nxt = sub
193
+ break
194
+ if nxt is None:
195
+ raise OpError("not_found", "No element '%s' in parameter '%s'." % (part, key))
196
+ el = nxt
197
+ return el
198
+
199
+
200
+ def op_devices_configure(args):
201
+ c = Ctx()
202
+ dev = _device(c, args["path"])
203
+ done = []
204
+ errors = []
205
+ for v in args.get("values") or []:
206
+ try:
207
+ el = _element(dev, v["key"])
208
+ val = to_text(v["value"])
209
+ if _safe(lambda: el.is_enumeration, False):
210
+ match = None
211
+ for e in el.allowed_values or []:
212
+ if val in (to_text(e.identifier), to_text(_safe(lambda: e.visible_name, "")), to_text(_safe(lambda: e.value, ""))):
213
+ match = e
214
+ break
215
+ if match is None:
216
+ raise OpError("bad_value", "'%s' isn't one of %s" % (val, ", ".join(to_text(e.identifier) for e in el.allowed_values)))
217
+ el.enum_value = match
218
+ else:
219
+ el.value = val
220
+ done.append({"key": v["key"], "value": to_text(_safe(lambda: el.value, val))})
221
+ except Exception as e:
222
+ errors.append({"key": v.get("key"), "error": to_text(e)[:200]})
223
+ for m in args.get("mappings") or []:
224
+ try:
225
+ el = _element(dev, m["key"])
226
+ if not _safe(lambda: el.is_mappable_io, False):
227
+ raise OpError("bad_value", "'%s' is not an I/O channel." % m["key"])
228
+ io = el.io_mapping
229
+ io.variable = to_text(m.get("variable") or "")
230
+ if "address" in m:
231
+ io.manual_iec_address = to_text(m["address"]) if m["address"] else None
232
+ done.append({"key": m["key"], "variable": to_text(_safe(lambda: io.variable, "")), "address": to_text(_safe(lambda: io.manual_iec_address, "") or "")})
233
+ except Exception as e:
234
+ errors.append({"key": m.get("key"), "error": to_text(e)[:200]})
235
+ return {"path": c.path_of(dev), "done": done, "errors": errors, "saved": c.save_if(args)}
236
+
237
+
238
+ mcp_ops.OPS["devices.params"] = op_devices_params
239
+ mcp_ops.OPS["devices.configure"] = mcp_sim._locked(op_devices_configure)
82
240
  mcp_ops.OPS["devices.search"] = op_devices_search
83
241
  mcp_ops.OPS["devices.add"] = mcp_sim._locked(op_devices_add)
@@ -101,7 +101,19 @@ def _signature(lmm, c, sig):
101
101
  ins = set(to_text(_get(v, "OrgName")) for v in (_get(sig, "Inputs") or []))
102
102
  outs = set(to_text(_get(v, "OrgName")) for v in (_get(sig, "Outputs") or []))
103
103
  variables = []
104
- for v in lmm.GetAllVariables(sig, c.app.guid):
104
+ found = list(lmm.GetAllVariables(sig, c.app.guid) or [])
105
+ if not found:
106
+ # A method of a library FB: the language model keeps its variables on the signature.
107
+ for prop in ("All", "Variables", "AllVariables"):
108
+ vs = _get(sig, prop)
109
+ if vs:
110
+ try:
111
+ found = list(vs)
112
+ except Exception:
113
+ found = []
114
+ if found:
115
+ break
116
+ for v in found:
105
117
  n = to_text(_get(v, "OrgName"))
106
118
  variables.append(_var(v, "input" if n in ins else "output" if n in outs else "other"))
107
119
  subs = []
@@ -198,6 +198,51 @@ def _script_element(visu, eid):
198
198
  return None
199
199
 
200
200
 
201
+ def _editor_context(c, visu, cache):
202
+ """The open visual editor's element context for this visualization (needs CODESYS's window)."""
203
+ if "ctx" in cache:
204
+ return cache["ctx"]
205
+ import os
206
+ if os.environ.get("CODESYS_MCP_UI") != "1":
207
+ raise OpError("unavailable", "needs CODESYS's window (start with --show-window)")
208
+ from System.Reflection import BindingFlags
209
+ from System import Guid
210
+ allf = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static
211
+ eng = _static("_3S.CoDeSys.VisualEditor.APEnvironment", "Engine")
212
+ frame = getattr(eng, "Frame", None) if eng is not None else None
213
+ if frame is None:
214
+ raise OpError("unavailable", "needs CODESYS's window (start with --show-window)")
215
+ view = frame.OpenEditorView(_object_mgr().GetMetaObjectStub(c.proj.handle, visu.guid), Guid.Empty, None)
216
+ se.system.delay(800)
217
+ ctl = view.Control
218
+ ve = ctl.GetType().GetField("_visualEditor", allf).GetValue(ctl)
219
+ ctx = ve.GetType().GetField("_editorCtx", allf).GetValue(ve)
220
+ vom = _find_type("_3S.CoDeSys.VisualEditor.VisualObjectManager").GetProperty("Singleton", allf).GetValue(None, None)
221
+ cache["ctx"] = (ctx, vom)
222
+ return cache["ctx"]
223
+
224
+
225
+ def _complex_element(c, visu, el, label, cache):
226
+ ctx, vom = _editor_context(c, visu, cache)
227
+ mgr = _object_mgr()
228
+ mo = mgr.GetObjectToModify(c.proj.handle, visu.guid)
229
+ ok = False
230
+ try:
231
+ vo = mo.Object
232
+ nid = vo.UniqueId
233
+ elem = vom.CreateElementByTypeName(el["type_name"], None, label, nid, ctx, True, ctx.AvailableHotkeysProvider, ctx.HotkeysConfiguration)
234
+ elem.Id = nid
235
+ elem.OwningObjectGuid = vo.MetaObject.ObjectGuid
236
+ vo.VisualElemList.Add(elem)
237
+ ok = True
238
+ finally:
239
+ mgr.SetObject(mo, ok, None)
240
+ e = _script_element(visu, nid)
241
+ for p, v in (("Position.X", el["x"]), ("Position.Y", el["y"]), ("Position.Width", el["w"]), ("Position.Height", el["h"])):
242
+ e.set_property(p, int(v))
243
+ return e
244
+
245
+
201
246
  def _find_child(c, name):
202
247
  for ch in c.app.get_children(False):
203
248
  if name_of(ch).lower() == name.lower():
@@ -234,8 +279,28 @@ def op_visu_build(args):
234
279
  warnings.append("size/dialog: %s" % to_text(ex))
235
280
  T = se.VisualElementType
236
281
  corners = []
237
- for i, el in enumerate(args.get("elements") or []):
282
+ queue = list(args.get("elements") or [])
283
+ editor = {}
284
+ i = -1
285
+ while queue:
286
+ el = queue.pop(0)
287
+ i += 1
238
288
  label = el.get("name") or "E%d" % i
289
+ if el["kind"] == "complex":
290
+ # Meters, bar displays and alarm tables need the visual editor's context, which only
291
+ # exists with CODESYS's window. Without it, the plan's fallback elements are drawn.
292
+ try:
293
+ e = _complex_element(c, visu, el, label, editor)
294
+ except Exception as ex:
295
+ warnings.append("%s: drawn without %s (%s)" % (label, el.get("type_name"), to_text(ex)[:160]))
296
+ queue = list(el.get("fallback") or []) + queue
297
+ continue
298
+ for pair in el.get("pairs") or []:
299
+ try:
300
+ e.set_property(pair[0], _value(pair[1]))
301
+ except Exception as ex:
302
+ warnings.append("%s %s: %s" % (label, pair[0], to_text(ex)[:120]))
303
+ continue
239
304
  try:
240
305
  if el["kind"] in _SIMPLE:
241
306
  eid = _add_created(c.proj, visu, _simple_creator(el), label)
@@ -415,7 +480,22 @@ def op_visu_render(args):
415
480
  return {"path": path}
416
481
 
417
482
 
483
+ def op_ui_open(args):
484
+ """With CODESYS's window: open an object in its editor (brings its tab forward)."""
485
+ import os
486
+ import mcp_ui
487
+ if os.environ.get("CODESYS_MCP_UI") != "1":
488
+ raise OpError("unavailable", "CODESYS runs without its window. Start the toolkit with --show-window to see objects in CODESYS.")
489
+ c = Ctx()
490
+ obj = c.resolve(args["path"])
491
+ state = mcp_ui.open_editor(se, obj)
492
+ if state == "unavailable":
493
+ raise OpError("unavailable", "This CODESYS doesn't let scripts open editors.")
494
+ return {"path": c.path_of(obj), "opened": state}
495
+
496
+
418
497
  mcp_ops.OPS.update({
498
+ "ui.open": op_ui_open,
419
499
  "visu.manager": op_visu_manager,
420
500
  "visu.build": op_visu_build,
421
501
  "visu.render": op_visu_render,
@@ -260,6 +260,7 @@ def main(ui=False):
260
260
  import mcp_debug # noqa: F401 (adds breakpoints and stepping)
261
261
  import mcp_devices # noqa: F401 (adds device search and add)
262
262
  import mcp_alarms # noqa: F401 (adds alarm management and visu user groups)
263
+ import mcp_caps # noqa: F401 (adds the capability check)
263
264
  prompts = setup_prompts(se)
264
265
  log(prompts)
265
266
  if not hasattr(se, "FAKE"):
@@ -311,6 +312,7 @@ def main_attach():
311
312
  import mcp_debug # noqa: F401 (adds breakpoints and stepping)
312
313
  import mcp_devices # noqa: F401 (adds device search and add)
313
314
  import mcp_alarms # noqa: F401 (adds alarm management and visu user groups)
315
+ import mcp_caps # noqa: F401 (adds the capability check)
314
316
  prompts = setup_prompts(se)
315
317
  log(prompts)
316
318
  old = getattr(__builtin__, "_mcp_attach_timer", None)