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