@tsplc/build 0.0.0-stage → 0.1.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 (82) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +6 -2
  3. package/dist/cli.js +1652 -0
  4. package/dist/cli.js.map +34 -0
  5. package/dist/index.js +1552 -0
  6. package/dist/index.js.map +31 -0
  7. package/dist/libraries.js +128 -0
  8. package/dist/libraries.js.map +10 -0
  9. package/docker/Dockerfile +173 -0
  10. package/docker/Dockerfile.dockerignore +17 -0
  11. package/docker/Dockerfile.source +15 -0
  12. package/docker/build-image +38 -0
  13. package/docker/codesys-build +5 -0
  14. package/docker/config.ts +8 -0
  15. package/docker/control-linux-sl/Dockerfile +38 -0
  16. package/docker/entrypoint.sh +10 -0
  17. package/docker/install/NOTICE.md +21 -0
  18. package/docker/install/core.sh +36 -0
  19. package/docker/install/decode.c +12 -0
  20. package/docker/install/device/Runtime.ico +0 -0
  21. package/docker/install/device/Runtime.png +0 -0
  22. package/docker/install/device/codesyscontrol.devdesc.xml +1502 -0
  23. package/docker/install/extract.py +43 -0
  24. package/docker/install/helper-launcher.c +46 -0
  25. package/docker/install/install.sh +94 -0
  26. package/docker/install/prerequisites.sh +15 -0
  27. package/docker/install/verify.py +133 -0
  28. package/docker/install/verify.sh +34 -0
  29. package/docker/install/wine-ncrypt/COPYING.LIB +502 -0
  30. package/docker/install/wine-ncrypt/README.md +28 -0
  31. package/docker/install/wine-ncrypt/enable-oaep.py +34 -0
  32. package/docker/install/wine-ncrypt/main.c +619 -0
  33. package/docker/install/wine-ncrypt/oaep.patch +11 -0
  34. package/docker/install/wine-signal/README.md +22 -0
  35. package/docker/install/wine-signal/build.sh +10 -0
  36. package/docker/install/wine-signal/check.sh +34 -0
  37. package/docker/install/wine-signal/noop-i386.c +2 -0
  38. package/docker/install/wine-signal/signal-state.c +105 -0
  39. package/docker/install/wine-signal/tests/avx-registers.c +53 -0
  40. package/docker/install/wine-signal/tests/frame-and-handler.c +192 -0
  41. package/docker/install/wine-signal/tests/loader-i386.c +8 -0
  42. package/docker/install/wine-signal/tests/signal-return.c +47 -0
  43. package/docker/install/wine-signal/wine +4 -0
  44. package/docker/runner.ts +131 -0
  45. package/package.json +53 -4
  46. package/python/build.py +655 -0
  47. package/python/codesys_online.py +300 -0
  48. package/python/test_build.py +444 -0
  49. package/python/test_codesys_online.py +170 -0
  50. package/python/test_installer_packages.py +216 -0
  51. package/python/test_installer_verification.py +307 -0
  52. package/python/test_worker.py +142 -0
  53. package/python/worker.py +89 -0
  54. package/runtime/install.sh +74 -0
  55. package/src/__tests__/fixtures/xml/fb-simple.xml +40 -0
  56. package/src/__tests__/fixtures/xml/fb-state-machine.xml +86 -0
  57. package/src/__tests__/fixtures/xml/prg-cyclic.xml +44 -0
  58. package/src/artifacts.ts +77 -0
  59. package/src/build.ts +87 -0
  60. package/src/cli.ts +116 -0
  61. package/src/config.ts +12 -0
  62. package/src/controller-online.ts +143 -0
  63. package/src/controller-session.ts +229 -0
  64. package/src/diagnostics.ts +66 -0
  65. package/src/docker-build.ts +148 -0
  66. package/src/docker-context.ts +35 -0
  67. package/src/docker-run.ts +104 -0
  68. package/src/host/codesys-command.ts +45 -0
  69. package/src/host/codesys-host.ts +145 -0
  70. package/src/host/codesys-worker.ts +345 -0
  71. package/src/host/windows-path.ts +73 -0
  72. package/src/index.ts +26 -0
  73. package/src/io.ts +127 -0
  74. package/src/libraries.ts +166 -0
  75. package/src/package-path.ts +5 -0
  76. package/src/run.ts +133 -0
  77. package/src/runtime-status.ts +21 -0
  78. package/src/sources.ts +248 -0
  79. package/src/st/declarations.ts +229 -0
  80. package/src/st/lexer.ts +125 -0
  81. package/src/st/parse.ts +202 -0
  82. package/src/st/reader.ts +50 -0
@@ -0,0 +1,655 @@
1
+ """Runs inside CODESYS's Python engine, not the Mac's Python installation."""
2
+ from __future__ import print_function
3
+
4
+ import io
5
+ import json
6
+ import os
7
+ import re
8
+ from decimal import Decimal
9
+
10
+ try:
11
+ unicode
12
+ except NameError:
13
+ unicode = str
14
+ import shutil
15
+ import time
16
+
17
+
18
+ class CompilerError(RuntimeError):
19
+ def __init__(self, diagnostics):
20
+ self.diagnostics = diagnostics
21
+ RuntimeError.__init__(self, "CODESYS errors:\n" + "\n".join(item["text"] for item in diagnostics))
22
+
23
+
24
+ def diagnostic(message, category):
25
+ metadata_errors = {}
26
+ def field(name, convert=unicode):
27
+ try:
28
+ value = getattr(message, name, None)
29
+ return convert(value) if value is not None else None
30
+ except Exception as error:
31
+ metadata_errors[name] = unicode(error)
32
+ return None
33
+ obj, project = field("object", lambda value: value), field("project", lambda value: value)
34
+ identity, project_path = None, None
35
+ if obj is not None:
36
+ try:
37
+ path, ancestors, parent = [], [], obj
38
+ while parent is not None and not parent.is_root:
39
+ path.insert(0, unicode(parent.get_name()))
40
+ ancestors.insert(0, parent)
41
+ parent = parent.parent
42
+ identity = {"name": unicode(obj.get_name()), "guid": unicode(obj.guid), "path": path, "isApplicationChild": None}
43
+ application = None
44
+ try:
45
+ application = project.active_application if project is not None else None
46
+ if application is not None and obj.parent is not None:
47
+ identity["isApplicationChild"] = bool(obj.parent.guid == application.guid)
48
+ except Exception as error:
49
+ metadata_errors["isApplicationChild"] = unicode(error)
50
+ try:
51
+ if application is not None:
52
+ for index in range(len(ancestors) - 1, -1, -1):
53
+ if ancestors[index].guid == application.guid:
54
+ identity["applicationPath"] = path[index + 1:]
55
+ break
56
+ except Exception as error:
57
+ metadata_errors["applicationPath"] = unicode(error)
58
+ except Exception as error:
59
+ metadata_errors["object"] = unicode(error)
60
+ if project is not None:
61
+ try:
62
+ project_path = unicode(project.path)
63
+ except Exception as error:
64
+ metadata_errors["project"] = unicode(error)
65
+ result = {
66
+ "category": unicode(category), "text": unicode(message.text),
67
+ "severity": field("severity"), "project": project_path, "object": identity,
68
+ # The vendor owns position interpretation. Preserve all bits as a string;
69
+ # consumers use positionText, never decode this opaque integer.
70
+ "position": field("position"), "positionText": field("position_text"),
71
+ # CODESYS exposes .NET short/int values: convert explicitly for json.dumps.
72
+ "positionOffset": field("position_offset", int), "length": field("length", int),
73
+ "prefix": field("prefix"), "number": field("number", int),
74
+ }
75
+ if metadata_errors:
76
+ result["metadataErrors"] = metadata_errors
77
+ return result
78
+
79
+
80
+ def fail_on_errors(engine):
81
+ errors = []
82
+ for category in engine.system.get_message_categories():
83
+ errors.extend(diagnostic(message, category) for message in engine.system.get_message_objects(
84
+ category, engine.Severity.Error | engine.Severity.FatalError,
85
+ ))
86
+ if errors:
87
+ raise CompilerError(errors)
88
+
89
+
90
+ def object_key(spec):
91
+ return (spec["kind"], spec["name"], bool(spec.get("persistent")), tuple(spec.get("folder", [])),
92
+ tuple(object_key(child) for child in spec.get("children", [])))
93
+
94
+
95
+ def project_key(request):
96
+ return (request["templateHash"], request["entry"], request["task"],
97
+ tuple(request["removeObjects"]), request["symbols"], tuple(request.get("libraryReferences", [])),
98
+ json.dumps(request.get("deviceTree"), sort_keys=True),
99
+ json.dumps(request.get("controller"), sort_keys=True),
100
+ tuple((item["title"],item["sha256"],item.get("namespace")) for item in request.get("libraries", [])),
101
+ tuple((task["name"], task["intervalNanoseconds"], task["priority"], tuple(task["programs"])) for task in request.get("tasks", [])),
102
+ tuple(object_key(spec) for spec in request["objects"]))
103
+
104
+
105
+ def unique_child(parent, name):
106
+ matches = parent.find_ignore_transient_objects(name, False)
107
+ if len(matches) > 1:
108
+ raise RuntimeError("Ambiguous child " + name)
109
+ return matches[0] if matches else None
110
+
111
+
112
+ def create_object(engine, parent, spec):
113
+ kind, name = spec["kind"], spec["name"]
114
+ if kind == "dut":
115
+ dut_types = {"structure": engine.DutType.Structure, "enumeration": engine.DutType.Enumeration,
116
+ "union": engine.DutType.Union, "alias": engine.DutType.Alias}
117
+ subtype = spec["dutType"]
118
+ # The complete declaration replaces this valid creation placeholder below.
119
+ return parent.create_dut(name, dut_types[subtype], "INT" if subtype == "alias" else None)
120
+ if kind == "gvl":
121
+ return parent.create_persistentvars(name) if spec.get("persistent") else parent.create_gvl(name)
122
+ if kind == "program":
123
+ return parent.create_program(name)
124
+ if kind == "function_block":
125
+ return parent.create_function_block(name)
126
+ if kind == "function":
127
+ # The creation API requires a type even for a no-return function. Importing
128
+ # its complete declaration below replaces this temporary BOOL header.
129
+ return parent.create_function(name, spec.get("returnType", "BOOL"))
130
+ if kind == "interface":
131
+ return parent.create_interface(name)
132
+ if kind == "method":
133
+ return parent.create_method(name, spec.get("returnType"))
134
+ if kind == "property":
135
+ return parent.create_property(name, spec["returnType"])
136
+ if kind == "action":
137
+ return parent.create_action(name)
138
+ if kind in ("get", "set"):
139
+ # CODESYS creates accessors with the property, not with create_method.
140
+ obj = unique_child(parent, name)
141
+ if obj is None:
142
+ raise RuntimeError("Property accessor is missing: " + name)
143
+ return obj
144
+ raise RuntimeError("Unsupported IEC object kind " + kind)
145
+
146
+
147
+ def import_object(engine, parent, spec):
148
+ obj = unique_child(parent, spec["name"])
149
+ if obj is None:
150
+ obj = create_object(engine, parent, spec)
151
+ if spec["kind"] != "action":
152
+ obj.textual_declaration.replace(spec["declaration"])
153
+ if "implementation" in spec:
154
+ obj.textual_implementation.replace(spec["implementation"])
155
+ children = spec.get("children", [])
156
+ if spec["kind"] == "property":
157
+ # Remove default accessors absent from source (e.g. a read-only property).
158
+ names = set(child["name"].lower() for child in children)
159
+ for child in list(obj.get_children()):
160
+ if child.get_name().lower() in ("get", "set") and child.get_name().lower() not in names:
161
+ child.remove()
162
+ for child in children:
163
+ import_object(engine, obj, child)
164
+
165
+
166
+ def install_project_libraries(engine, application, libraries, progress):
167
+ if not libraries:
168
+ return
169
+ import hashlib
170
+ manager = application.get_library_manager()
171
+ # SP22's scripting bridge passes a null repository when the optional argument
172
+ # is omitted. Select the configured first repository explicitly.
173
+ repositories = engine.librarymanager.repositories
174
+ if not repositories:
175
+ raise RuntimeError("No CODESYS library repository is configured")
176
+ for library in libraries:
177
+ progress("Installing library " + library["title"])
178
+ with open(library["path"], "rb") as source:
179
+ if hashlib.sha256(source.read()).hexdigest() != library["sha256"]:
180
+ raise RuntimeError("Library payload hash mismatch: " + library["title"])
181
+ managed = engine.librarymanager.install_library(library["path"], repositories[0], True)
182
+ if managed is None or unicode(managed.title) != library["title"]:
183
+ raise RuntimeError("Library title does not match dependency: " + library["title"])
184
+ for key in ("company", "version"):
185
+ if library.get(key) is not None and unicode(getattr(managed, key)) != library[key]:
186
+ raise RuntimeError("Library " + key + " does not match dependency: " + library["title"])
187
+ matches = []
188
+ for reference in list(manager.references):
189
+ if unicode(reference.name).startswith("#"):
190
+ continue
191
+ existing = reference.managed_library
192
+ if unicode(existing.title) == unicode(managed.title) and unicode(existing.company) == unicode(managed.company):
193
+ matches.append(reference)
194
+ if len(matches) > 1:
195
+ raise RuntimeError("Ambiguous existing library reference: " + library["title"])
196
+ exact = matches and unicode(matches[0].managed_library.version) == unicode(managed.version)
197
+ namespace = library.get("namespace") or (unicode(matches[0].namespace) if matches else None)
198
+ if not exact:
199
+ for reference in matches:
200
+ manager.remove_library(unicode(reference.name))
201
+ manager.add_library(managed)
202
+ refs = [reference for reference in list(manager.references) if not unicode(reference.name).startswith("#")
203
+ and unicode(reference.managed_library.title) == unicode(managed.title)
204
+ and unicode(reference.managed_library.company) == unicode(managed.company)
205
+ and unicode(reference.managed_library.version) == unicode(managed.version)]
206
+ if len(refs) != 1:
207
+ raise RuntimeError("Installed library reference could not be verified: " + library["title"])
208
+ if namespace:
209
+ refs[0].namespace = namespace
210
+
211
+
212
+ def import_objects(engine, application, request, reusing, progress):
213
+ install_project_libraries(engine, application, request.get("libraries", []), progress)
214
+ if request.get("libraryReferences"):
215
+ manager = application.get_library_manager()
216
+ for reference in request["libraryReferences"]:
217
+ manager.add_library(reference)
218
+ progress("Importing source objects")
219
+ if not reusing:
220
+ for name in request["removeObjects"]:
221
+ for obj in application.find_ignore_transient_objects(name, True):
222
+ obj.remove()
223
+ # Source objects own their complete subtree, including removed methods.
224
+ for spec in request["objects"]:
225
+ existing = application.find_ignore_transient_objects(spec["name"], True)
226
+ if len(existing) > 1:
227
+ raise RuntimeError("Ambiguous IEC object " + spec["name"])
228
+ for obj in existing:
229
+ obj.remove()
230
+ for spec in request["objects"]:
231
+ parent = application
232
+ for name in spec["folder"]:
233
+ folder = unique_child(parent, name)
234
+ if folder is None:
235
+ # create_folder returns None in CODESYS; resolve the inserted object.
236
+ parent.create_folder(name)
237
+ folder = unique_child(parent, name)
238
+ if folder is None:
239
+ raise RuntimeError("Could not create source folder " + name)
240
+ elif not folder.is_folder:
241
+ raise RuntimeError("Source folder conflicts with IEC object " + name)
242
+ parent = folder
243
+ import_object(engine, parent, spec)
244
+ if "tasks" in request:
245
+ return configure_tasks(engine, application, request["tasks"], request["objects"])
246
+ matches = application.find_ignore_transient_objects(request["task"], True)
247
+ if len(matches) != 1 or not getattr(matches[0], "is_task", False):
248
+ raise RuntimeError("Template must contain one task named " + request["task"])
249
+ task = matches[0]
250
+ # Only this configured task is rewired; its timing and watchdog stay in the template.
251
+ if list(task.pous) == [request["entry"]]:
252
+ return
253
+ # Task calls are transient children. Remove the call objects themselves;
254
+ # ScriptPouObjectList.remove can change its list without removing those objects.
255
+ for call in list(task.get_children()):
256
+ call.remove()
257
+ task.pous.add(request["entry"])
258
+ if list(task.pous) != [request["entry"]]:
259
+ raise RuntimeError("Could not configure the entry task: " + repr(list(task.pous)))
260
+
261
+ def configure_tasks(engine, application, tasks, objects):
262
+ # An explicit task plan owns the entire schedule in this private project copy.
263
+ # Unspecified tasks are removed so template programs cannot execute implicitly.
264
+ if not tasks:
265
+ raise RuntimeError("Explicit task plan must not be empty")
266
+ available = set(spec["name"] for spec in objects if spec["kind"] == "program")
267
+ names, scheduled = set(), set()
268
+ for spec in tasks:
269
+ name = spec["name"].lower()
270
+ ns, priority = int(spec["intervalNanoseconds"]), spec["priority"]
271
+ if name in names or not re.match(r"^[A-Za-z][A-Za-z0-9_]*$", spec["name"]):
272
+ raise RuntimeError("Invalid or duplicate task name " + spec["name"])
273
+ names.add(name)
274
+ if ns <= 0 or ns % 1000000 or ns > 4294967295000000 or isinstance(priority, bool) or not isinstance(priority, int) or not 0 <= priority <= 31:
275
+ raise RuntimeError("Invalid cyclic interval or priority for " + spec["name"])
276
+ if not spec["programs"]:
277
+ raise RuntimeError("Task must schedule at least one PROGRAM: " + spec["name"])
278
+ for program in spec["programs"]:
279
+ if program not in available or program.lower() in scheduled:
280
+ raise RuntimeError("Missing or multiply scheduled source PROGRAM " + program)
281
+ scheduled.add(program.lower())
282
+ configurations = [child for child in application.get_children() if getattr(child, "is_task_configuration", False)]
283
+ if len(configurations) > 1:
284
+ raise RuntimeError("Application contains ambiguous task configurations")
285
+ configuration = configurations[0] if configurations else application.create_task_configuration()
286
+ for child in list(configuration.get_children()):
287
+ if getattr(child, "is_task", False) and child.get_name().lower() not in names:
288
+ child.remove()
289
+ for spec in tasks:
290
+ matches = application.find_ignore_transient_objects(spec["name"], True)
291
+ if len(matches) > 1 or matches and not getattr(matches[0], "is_task", False):
292
+ raise RuntimeError("Ambiguous or conflicting task name " + spec["name"])
293
+ task = matches[0] if matches else configuration.create_task(spec["name"])
294
+ if task.kind_of_task != engine.KindOfTask.Cyclic:
295
+ task.kind_of_task = engine.KindOfTask.Cyclic
296
+ if str(task.priority) != str(spec["priority"]):
297
+ task.priority = str(spec["priority"])
298
+ if str(task.interval_unit).lower() != "ms":
299
+ task.interval_unit = "ms"
300
+ milliseconds = str(int(spec["intervalNanoseconds"]) // 1000000)
301
+ current = str(task.interval).strip()
302
+ match = re.match(r"^(?:T|TIME)#([0-9]+(?:\.[0-9]+)?)MS$", current, re.I)
303
+ if not match or Decimal(match.group(1)) != Decimal(milliseconds):
304
+ task.interval = milliseconds
305
+ # Preserve call-object GUIDs across source-only online changes.
306
+ if list(task.pous) != spec["programs"]:
307
+ for call in list(task.get_children()):
308
+ call.remove()
309
+ for program in spec["programs"]:
310
+ task.pous.add(program)
311
+ observed = str(task.interval).strip()
312
+ match = re.match(r"^(?:T|TIME)#([0-9]+(?:\.[0-9]+)?)MS$", observed, re.I)
313
+ interval = Decimal(match.group(1) if match else observed)
314
+ if task.kind_of_task != engine.KindOfTask.Cyclic or int(task.priority) != spec["priority"] or str(task.interval_unit).lower() != "ms" or interval != Decimal(milliseconds) or list(task.pous) != spec["programs"]:
315
+ raise RuntimeError("Task configuration readback differs for {0}: {1}, {2}, {3}, {4}".format(spec["name"], task.priority, task.interval, task.interval_unit, list(task.pous)))
316
+ actual = [child.get_name().lower() for child in configuration.get_children() if getattr(child, "is_task", False)]
317
+ if set(actual) != names or len(actual) != len(names):
318
+ raise RuntimeError("Unplanned or duplicate tasks remain in the application")
319
+ return tasks
320
+
321
+
322
+ # CODESYS EDS Import's converter factory; plain import_device expects device XML.
323
+ EDS_CONVERTER = "1ce4a9c1-37d3-496c-9e80-cd99ad3807ee"
324
+ ETHERNET = ("Ethernet", 110, "0000 0002", "4.2.0.0")
325
+ SCANNER = ("EtherNet_IP_Scanner", 100, "0000 100B", "4.9.0.0")
326
+ # Ethernet adapter interface settings and EtherNet/IP adapter parameter IDs.
327
+ ETHERNET_PARAMETERS = (("address", 0), ("subnet", 1), ("gateway", 2), ("interface", 10))
328
+ TARGET_ADDRESS = 256
329
+ CONNECTION_SETTINGS = 268435457
330
+ MODBUS_CLIENT = ("Modbus_TCP_Client", 88, "0000 0003", "4.6.0.0")
331
+ MODBUS_SERVER = (89, "0000 0005", "4.6.0.0")
332
+ MODBUS_EDITOR = r"C:\CODESYS-3.5.22.30\CODESYS\PlugIns\7e343689-9a5f-46aa-bf56-5903c4efec59\4.6.0.0\DeviceEditorModbus.plugin.dll"
333
+ # Modbus client and server parameter IDs.
334
+ MODBUS_AUTO_RECONNECT = 9002
335
+ MODBUS_CHANNEL_STATUS = 9003
336
+ MODBUS_ADDRESS = 9102
337
+ MODBUS_PORT = 9103
338
+ # The editor's parameter IDs for channel number n: n * 2^24 plus these.
339
+ MODBUS_CHANNEL = 1 << 24
340
+ MODBUS_TRIGGER = 0x200001
341
+ MODBUS_STATUS = 0x200002
342
+ MODBUS_REQUESTS = 0x200003
343
+ MODBUS_HOLDING_WRITE = 0x840000
344
+ MODBUS_WRITE_SINGLE_REGISTER = 6
345
+ MODBUS_RISING_EDGE = 6
346
+
347
+
348
+ def host_parameters(device):
349
+ connectors = [connector for connector in device.connectors if len(list(connector.host_parameters))]
350
+ if len(connectors) != 1:
351
+ raise RuntimeError("Expected one configurable connector on " + device.get_name())
352
+ return dict((parameter.id, parameter) for parameter in connectors[0].host_parameters)
353
+
354
+
355
+ def child_device(engine, parent, spec):
356
+ name, kind, identifier, version = spec
357
+ parent.add(name, kind, identifier, version)
358
+ matches = [child for child in parent.find(name, False) if getattr(child, "is_device", False)]
359
+ if len(matches) != 1:
360
+ raise RuntimeError("Could not add device " + name)
361
+ return matches[0]
362
+
363
+
364
+ def set_parameter(parameter, value):
365
+ parameter.value = value
366
+ if unicode(parameter.value).replace(" ", "") != unicode(value).replace(" ", ""):
367
+ raise RuntimeError("{0} reads back {1}, not {2}".format(parameter.visible_name, parameter.value, value))
368
+
369
+
370
+ def ip_array(address, hexadecimal=False):
371
+ octets = [int(octet) for octet in address.split(".")]
372
+ return "[" + ",".join("16#{0:02X}".format(octet) if hexadecimal else str(octet) for octet in octets) + "]"
373
+
374
+
375
+ def pin(channel, address):
376
+ # CODESYS validates a manual address against the automatic one until it is disabled.
377
+ channel.io_mapping.manual_iec_address = address
378
+ channel.io_mapping.automatic_iec_address = False
379
+
380
+
381
+ def settle(pinned):
382
+ """Pin only channels whose automatic address differs; each write revalidates the whole tree."""
383
+ for address, channel in sorted(pinned.items()):
384
+ # An automatic mapping reports its assigned address here.
385
+ if channel.io_mapping.manual_iec_address != address:
386
+ pin(channel, address)
387
+
388
+
389
+ def pin_channels(engine, adapter, parameters, device):
390
+ """Give each raw payload byte the address of its offset, so AT declarations read it."""
391
+ pinned = {}
392
+ for direction, region, kind in (("inputs", "I", engine.ChannelType.Input), ("outputs", "Q", engine.ChannelType.Output)):
393
+ channels = sorted((parameter for parameter in parameters.values() if parameter.channel_type == kind), key=lambda parameter: parameter.id)
394
+ area = device.get(direction)
395
+ if not area:
396
+ if channels:
397
+ raise RuntimeError("{0} has {1} without a configured address".format(device["name"], direction))
398
+ continue
399
+ if len(channels) != area["size"] or any(channel.bit_size != 8 for channel in channels):
400
+ raise RuntimeError("{0} {1} are not {2} raw byte channels".format(device["name"], direction, area["size"]))
401
+ base = int(area["address"][3:])
402
+ # Later automatic channels follow the first one consecutively.
403
+ pin(channels[0], "%{0}B{1}".format(region, base))
404
+ for offset, channel in enumerate(channels):
405
+ pinned["%{0}B{1}".format(region, base + offset)] = channel
406
+ settle(pinned)
407
+ return pinned
408
+
409
+
410
+ def channel_at(pinned, address):
411
+ if address in pinned:
412
+ return pinned[address]
413
+ match = re.match(r"^%([IQ])(X|B)(\d+)(?:\.([0-7]))?$", address)
414
+ channel = pinned.get("%{0}B{1}".format(match.group(1), match.group(3)))
415
+ if channel is None:
416
+ raise RuntimeError("No generated channel at " + address)
417
+ return list(channel)[int(match.group(4))] if match.group(2) == "X" else channel
418
+
419
+
420
+ def map_channels(pinned, tree, application):
421
+ """Map bound bits and bytes to their variables; comment the channels of IO that keeps AT."""
422
+ mapped = set()
423
+ for mapping in tree.get("mappings", []):
424
+ channel_at(pinned, mapping["address"]).io_mapping.variable = application + "." + mapping["variable"]
425
+ mapped.add(mapping["address"])
426
+ for device in tree["devices"]:
427
+ for comment in device.get("comments", []):
428
+ if comment["address"] not in mapped:
429
+ channel_at(pinned, comment["address"]).user_comment = comment["text"]
430
+
431
+
432
+ def add_modbus_channel(project, server, register):
433
+ """Add a write channel with the editor's own routine: CODESYS scripting has no API for channels."""
434
+ from System import Activator, Array, Enum, Object, UInt16, UInt32
435
+ from System.Reflection import Assembly, BindingFlags
436
+ flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static
437
+ editor = Assembly.LoadFrom(MODBUS_EDITOR)
438
+ def named(name):
439
+ return editor.GetType("_3S.CoDeSys.DeviceEditorModbus." + name, True)
440
+ def call(owner, target, name, *arguments):
441
+ return owner.GetMethod(name, flags).Invoke(target, Array[Object](list(arguments)))
442
+ manager = named("APEnvironment").GetProperty("ObjectMgr", flags).GetValue(None, None)
443
+ meta = manager.GetObjectToModify(project.handle, server.guid)
444
+ connector = [item for item in meta.Object.Connectors if str(item.ConnectorRole) == "Child"][0]
445
+ data = call(named("New.ChannelDataBase"), None, "Create", connector)
446
+ item_type = named("ModbusChannelItem")
447
+ item = Activator.CreateInstance(item_type)
448
+ fields = (
449
+ ("stName", register["name"]),
450
+ ("usFunctionCode", UInt16(MODBUS_WRITE_SINGLE_REGISTER)),
451
+ ("usWriteOffset", UInt16(register["offset"])),
452
+ ("usWriteLength", UInt16(1)),
453
+ ("usReadLength", UInt16(0)),
454
+ ("triggerType", Enum.ToObject(named("TriggerType"), MODBUS_RISING_EDGE)),
455
+ ("uiParamID", call(data.GetType(), data, "GetNextAvailableParamID", call(data.GetType(), data, "LoadSlaveChannels", connector))),
456
+ )
457
+ for name, value in fields:
458
+ item_type.GetField(name).SetValue(item, value)
459
+ factory = call(named("New.PresetFactory"), None, "Create", connector, False)
460
+ call(data.GetType(), data, "CreateSlaveChannel", connector, item, factory)
461
+ manager.SetObject(meta, True, None)
462
+ return int(UInt32(item_type.GetField("uiParamID").GetValue(item))) // MODBUS_CHANNEL
463
+
464
+
465
+ def configure_modbus(engine, project, ethernet, devices):
466
+ """One Modbus TCP server per card with registers, with channels pinned like the EtherNet/IP bytes."""
467
+ pinned = {}
468
+ client = child_device(engine, ethernet, MODBUS_CLIENT)
469
+ parameters = host_parameters(client)
470
+ # The line runs unattended, so a card that restarts must come back on its own.
471
+ set_parameter(parameters[MODBUS_AUTO_RECONNECT], "TRUE")
472
+ # Status and request count are how the program learns a write finished.
473
+ set_parameter(parameters[MODBUS_CHANNEL_STATUS], "TRUE")
474
+ for device in devices:
475
+ server = child_device(engine, client, (device["name"] + "_Modbus",) + MODBUS_SERVER)
476
+ server.driver_info.always_update_variables = engine.AlwaysUpdateVariablesMode.OnlyIfUnused
477
+ parameters = host_parameters(server)
478
+ set_parameter(parameters[MODBUS_ADDRESS], ip_array(device["address"]))
479
+ set_parameter(parameters[MODBUS_PORT], str(device["modbus"]["port"]))
480
+ for register in device["modbus"]["registers"]:
481
+ channel = add_modbus_channel(project, server, register) * MODBUS_CHANNEL
482
+ parameters = host_parameters(server)
483
+ pinned[register["write"]] = parameters[channel + MODBUS_TRIGGER]
484
+ pinned[register["status"]] = parameters[channel + MODBUS_STATUS]
485
+ pinned[register["requests"]] = parameters[channel + MODBUS_REQUESTS]
486
+ pinned[register["value"]] = parameters[channel + MODBUS_HOLDING_WRITE + register["offset"]]
487
+ settle(pinned)
488
+ return pinned
489
+
490
+
491
+ def template_controller(project):
492
+ controllers = [child for child in project.get_children() if getattr(child, "is_device", False)]
493
+ if len(controllers) != 1:
494
+ raise RuntimeError("The template must contain one controller device")
495
+ return controllers[0]
496
+
497
+
498
+ def apply_controller(project, identity, progress):
499
+ controller = template_controller(project)
500
+ wanted = (identity["type"], identity["id"], identity["version"])
501
+ current = controller.get_device_identification()
502
+ if (current.type, current.id, current.version) == wanted:
503
+ return
504
+ progress("Updating controller to {0} {1} {2}".format(*wanted))
505
+ # Update Device keeps the Application and its children under the new device.
506
+ controller.update(*wanted)
507
+ updated = controller.get_device_identification()
508
+ if (updated.type, updated.id, updated.version) != wanted:
509
+ raise RuntimeError("CODESYS kept controller {0} {1} {2}; install the device description for {3} {4} {5}".format(
510
+ updated.type, updated.id, updated.version, *wanted))
511
+
512
+
513
+ def configure_device_tree(engine, project, tree, root, progress):
514
+ from System import Guid
515
+ controller = template_controller(project)
516
+ for child in controller.get_children(True):
517
+ if getattr(child, "is_device", False) and child.get_device_identification().type in (ETHERNET[1], SCANNER[1]):
518
+ raise RuntimeError("Template already configures " + child.get_name() + "; remove it or codesys.deviceTree")
519
+ progress("Generating EtherNet/IP device tree")
520
+ ethernet = child_device(engine, controller, ETHERNET)
521
+ parameters = host_parameters(ethernet)
522
+ for key, identifier in ETHERNET_PARAMETERS:
523
+ value = tree.get("ethernet", {}).get(key)
524
+ if value is not None:
525
+ set_parameter(parameters[identifier], "'{0}'".format(value) if key == "interface" else ip_array(value))
526
+ scanner = child_device(engine, ethernet, SCANNER)
527
+ repository = engine.device_repository
528
+ pinned = {}
529
+ for device in tree["devices"]:
530
+ identity = repository.import_device(os.path.join(root, device["eds"]), repository.sources[0], Guid(EDS_CONVERTER), True)
531
+ if identity is None:
532
+ raise RuntimeError("CODESYS could not import the EDS of " + device["name"])
533
+ adapter = child_device(engine, scanner, (device["name"], identity.type, identity.id, identity.version))
534
+ # CODESYS otherwise skips mapped variables no IEC code reads, such as inputs only clients observe.
535
+ adapter.driver_info.always_update_variables = engine.AlwaysUpdateVariablesMode.OnlyIfUnused
536
+ parameters = host_parameters(adapter)
537
+ if device.get("address"):
538
+ set_parameter(parameters[TARGET_ADDRESS], ip_array(device["address"], True))
539
+ if device.get("rpiMicroseconds"):
540
+ fields = [field for field in parameters[CONNECTION_SETTINGS] if field.identifier == "RPI"]
541
+ set_parameter(fields[0], str(device["rpiMicroseconds"]))
542
+ for address, channel in pin_channels(engine, adapter, parameters, device).items():
543
+ if address in pinned:
544
+ raise RuntimeError("{0} overlaps another adapter".format(address))
545
+ pinned[address] = channel
546
+ registers = [device for device in tree["devices"] if device.get("modbus")]
547
+ if registers:
548
+ progress("Generating Modbus TCP devices")
549
+ pinned.update(configure_modbus(engine, project, ethernet, registers))
550
+ # Adding a later adapter can reflow earlier automatic addresses.
551
+ settle(pinned)
552
+ map_channels(pinned, tree, project.active_application.get_name())
553
+ return pinned
554
+
555
+
556
+ def verify_pinned(pinned):
557
+ for address, channel in pinned.items():
558
+ if channel.io_mapping.manual_iec_address != address:
559
+ raise RuntimeError("Channel {0} moved to {1}".format(address, channel.io_mapping.manual_iec_address))
560
+
561
+
562
+ def build(engine, request, loaded, workspace):
563
+ progress_path = os.path.join(os.path.dirname(request["receipt"]), "progress.log")
564
+
565
+ def progress(stage):
566
+ with io.open(progress_path, "a", encoding="utf-8") as log:
567
+ log.write(u"{0}Z {1}\n".format(
568
+ time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime()), stage,
569
+ ))
570
+
571
+ # Reopen when the template or object membership changes, so removed/renamed
572
+ # declarations cannot survive from an earlier build. Text changes are imported below.
573
+ key = project_key(request)
574
+ reusing = loaded.get("key") == key
575
+ project = None
576
+ try:
577
+ if reusing:
578
+ progress("Reusing loaded project")
579
+ project = loaded["project"]
580
+ else:
581
+ if loaded.get("project") is not None:
582
+ loaded["project"].close()
583
+ loaded.clear()
584
+ progress("Opening project")
585
+ project = engine.projects.open(request["template"], primary=True)
586
+ working = os.path.join(workspace, "project")
587
+ if os.path.isdir(working):
588
+ shutil.rmtree(working)
589
+ os.mkdir(working)
590
+ project.save_as(os.path.join(working, "Application.project"))
591
+ if request.get("controller"):
592
+ apply_controller(project, request["controller"], progress)
593
+ loaded.clear()
594
+ application = project.active_application
595
+ if application is None:
596
+ raise RuntimeError("Template must have an active Application")
597
+ configured_tasks = import_objects(engine, application, request, reusing, progress)
598
+ # A reused project already holds the tree for this key.
599
+ pinned = configure_device_tree(engine, project, request["deviceTree"], os.path.dirname(request["receipt"]), progress) \
600
+ if request.get("deviceTree") and not reusing else {}
601
+ if request["symbols"] and not any(
602
+ getattr(child, "is_symbol_config", False)
603
+ for child in application.get_children()
604
+ ):
605
+ from System import Guid
606
+ application.create_symbol_config(True, True, Guid("{0141eb75-141b-4ea1-9a8c-75f952b22a6c}"))
607
+ progress("Saving generated project")
608
+ project.save()
609
+ progress("Clearing previous diagnostics")
610
+ # Check diagnostics from this compilation, not cached editor-load messages.
611
+ for category in engine.system.get_message_categories():
612
+ engine.system.clear_messages(category)
613
+ progress("Rebuilding application")
614
+ application.clean()
615
+ application.rebuild()
616
+ fail_on_errors(engine)
617
+ progress("Generating application code")
618
+ application.generate_code()
619
+ fail_on_errors(engine)
620
+ verify_pinned(pinned)
621
+ project.save()
622
+ # Keep the open file private to the worker; the published project can be
623
+ # opened interactively without conflicting with this persistent compiler.
624
+ shutil.copyfile(project.path, request["project"])
625
+ if request.get("deviceTree"):
626
+ # An IDE resolves devices from its own repository; the archive carries
627
+ # the generated descriptions and offers to install them on open.
628
+ progress("Saving project archive")
629
+ project.save_archive(os.path.join(os.path.dirname(request["receipt"]), request["archive"]),
630
+ engine.ArchiveCategories.devices, engine.ArchiveCategories.libraries)
631
+ progress("Exporting boot application")
632
+ application.create_boot_application(request["output"], False, False)
633
+ fail_on_errors(engine)
634
+ if not os.path.isfile(request["output"]) or os.path.getsize(request["output"]) == 0:
635
+ raise RuntimeError("Compiler did not produce a non-empty boot application")
636
+ progress("Build complete")
637
+ with io.open(request["receipt"], "w", encoding="utf-8") as receipt:
638
+ receipt.write(json.dumps(
639
+ dict({"mode": request["mode"], "application": application.get_name()}, **({"tasks": configured_tasks} if configured_tasks is not None else {})),
640
+ ensure_ascii=False,
641
+ ))
642
+ loaded.update({"key": key, "project": project})
643
+ except Exception:
644
+ loaded.clear()
645
+ if project is not None:
646
+ # Never retain a partially imported/compiled project after failure.
647
+ try:
648
+ if os.path.normcase(project.path) != os.path.normcase(request["template"]):
649
+ project.save()
650
+ shutil.copyfile(project.path, request["project"])
651
+ except Exception:
652
+ print("Could not save the diagnostic project")
653
+ finally:
654
+ project.close()
655
+ raise