@tsplc/build 0.2.6 → 0.2.7
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/dist/cli.js.map +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/python/build.py +160 -291
- package/python/codesys_online.py +18 -40
- package/python/hardware.py +261 -0
- package/python/test_build.py +105 -17
- package/python/test_codesys_online.py +31 -12
- package/src/controller-session.ts +9 -5
- package/src/host/codesys-worker.ts +8 -3
package/python/build.py
CHANGED
|
@@ -15,6 +15,8 @@ except NameError:
|
|
|
15
15
|
import shutil
|
|
16
16
|
import time
|
|
17
17
|
|
|
18
|
+
from hardware import apply_controller, configure_device_tree, import_device_description, verify_pinned
|
|
19
|
+
|
|
18
20
|
|
|
19
21
|
class CompilerError(RuntimeError):
|
|
20
22
|
def __init__(self, diagnostics):
|
|
@@ -98,14 +100,18 @@ def object_key(spec):
|
|
|
98
100
|
tuple(object_key(child) for child in spec.get("children", [])))
|
|
99
101
|
|
|
100
102
|
|
|
101
|
-
def
|
|
103
|
+
def template_key(request):
|
|
104
|
+
"""Everything a build imports into besides its source objects."""
|
|
102
105
|
return (request["templateHash"], request["entry"], request["task"],
|
|
103
106
|
tuple(request["removeObjects"]), request["symbols"], tuple(request.get("libraryReferences", [])),
|
|
104
107
|
json.dumps(request.get("deviceTree"), sort_keys=True),
|
|
105
108
|
json.dumps(request.get("controller"), sort_keys=True),
|
|
106
109
|
tuple((item["title"],item["sha256"],item.get("namespace")) for item in request.get("libraries", [])),
|
|
107
|
-
tuple((task["name"], task["intervalNanoseconds"], task["priority"], tuple(task["programs"])) for task in request.get("tasks", []))
|
|
108
|
-
|
|
110
|
+
tuple((task["name"], task["intervalNanoseconds"], task["priority"], tuple(task["programs"])) for task in request.get("tasks", [])))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def project_key(request):
|
|
114
|
+
return template_key(request) + (tuple(object_key(spec) for spec in request["objects"]),)
|
|
109
115
|
|
|
110
116
|
|
|
111
117
|
def unique_child(parent, name):
|
|
@@ -150,14 +156,21 @@ def create_object(engine, parent, spec):
|
|
|
150
156
|
raise RuntimeError("Unsupported IEC object kind " + kind)
|
|
151
157
|
|
|
152
158
|
|
|
153
|
-
def import_object(engine, parent, spec):
|
|
159
|
+
def import_object(engine, parent, spec, previous):
|
|
160
|
+
"""Writes the object's text unless `previous`, the spec the project already holds, has the same.
|
|
161
|
+
Each write costs a parse, and a compiler model that sees nothing changed builds and transfers less.
|
|
162
|
+
Returns how many objects were written."""
|
|
154
163
|
obj = unique_child(parent, spec["name"])
|
|
155
164
|
if obj is None:
|
|
156
165
|
obj = create_object(engine, parent, spec)
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
if "implementation"
|
|
160
|
-
|
|
166
|
+
previous = None
|
|
167
|
+
written = 0
|
|
168
|
+
if previous is None or any(previous.get(key) != spec.get(key) for key in ("declaration", "implementation")):
|
|
169
|
+
if spec["kind"] != "action":
|
|
170
|
+
obj.textual_declaration.replace(spec["declaration"])
|
|
171
|
+
if "implementation" in spec:
|
|
172
|
+
obj.textual_implementation.replace(spec["implementation"])
|
|
173
|
+
written = 1
|
|
161
174
|
children = spec.get("children", [])
|
|
162
175
|
if spec["kind"] == "property":
|
|
163
176
|
# Remove default accessors absent from source (e.g. a read-only property).
|
|
@@ -165,8 +178,10 @@ def import_object(engine, parent, spec):
|
|
|
165
178
|
for child in list(obj.get_children()):
|
|
166
179
|
if child.get_name().lower() in ("get", "set") and child.get_name().lower() not in names:
|
|
167
180
|
child.remove()
|
|
181
|
+
held = dict((child["name"].lower(), child) for child in previous.get("children", [])) if previous else {}
|
|
168
182
|
for child in children:
|
|
169
|
-
import_object(engine, obj, child)
|
|
183
|
+
written += import_object(engine, obj, child, held.get(child["name"].lower()))
|
|
184
|
+
return written
|
|
170
185
|
|
|
171
186
|
|
|
172
187
|
def install_project_libraries(engine, application, libraries, progress):
|
|
@@ -215,14 +230,62 @@ def install_project_libraries(engine, application, libraries, progress):
|
|
|
215
230
|
refs[0].namespace = namespace
|
|
216
231
|
|
|
217
232
|
|
|
218
|
-
def
|
|
233
|
+
def same_kind(old, fresh):
|
|
234
|
+
return old["kind"] == fresh["kind"] and bool(old.get("persistent")) == bool(fresh.get("persistent"))
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def remove_deleted(parent, previous, current, gone):
|
|
238
|
+
"""Reconcile only source-owned objects; preserve GUIDs of unchanged objects."""
|
|
239
|
+
new = {item["name"].lower(): item for item in current}
|
|
240
|
+
for old in previous:
|
|
241
|
+
obj = unique_child(parent, old["name"])
|
|
242
|
+
fresh = new.get(old["name"].lower())
|
|
243
|
+
if obj is None:
|
|
244
|
+
raise RuntimeError("Saved source object is missing: " + old["name"])
|
|
245
|
+
if fresh is None:
|
|
246
|
+
gone.append(obj)
|
|
247
|
+
elif not same_kind(old, fresh):
|
|
248
|
+
obj.remove()
|
|
249
|
+
else:
|
|
250
|
+
remove_deleted(obj, old.get("children", []), fresh.get("children", []), gone)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def reconcile(application, previous, current):
|
|
254
|
+
"""Removes the source objects the import recreates: those whose kind changed, and those moved to
|
|
255
|
+
another folder. Returns the objects gone from the source. Remove them once the import has rewritten
|
|
256
|
+
what used them: CODESYS fails ("Value cannot be null") removing a type or POU that is still in use."""
|
|
257
|
+
new = {(tuple(item["folder"]), item["name"].lower()): item for item in current}
|
|
258
|
+
names = set(item["name"].lower() for item in current)
|
|
259
|
+
gone = []
|
|
260
|
+
for old in previous:
|
|
261
|
+
parent = application
|
|
262
|
+
for name in old["folder"]:
|
|
263
|
+
parent = unique_child(parent, name)
|
|
264
|
+
if parent is None:
|
|
265
|
+
raise RuntimeError("Saved source folder is missing: " + name)
|
|
266
|
+
fresh = new.get((tuple(old["folder"]), old["name"].lower()))
|
|
267
|
+
if fresh is None and old["name"].lower() in names:
|
|
268
|
+
# A folder move changes placement rather than text. Remove/recreate just
|
|
269
|
+
# that moved object, letting the vendor decide whether the update is valid.
|
|
270
|
+
moved = unique_child(parent, old["name"])
|
|
271
|
+
if moved is None:
|
|
272
|
+
raise RuntimeError("Saved source object is missing: " + old["name"])
|
|
273
|
+
moved.remove()
|
|
274
|
+
else:
|
|
275
|
+
remove_deleted(parent, [old], [fresh] if fresh else [], gone)
|
|
276
|
+
return gone
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def import_objects(engine, application, request, previous, progress):
|
|
280
|
+
"""`previous` lists the source objects the project already holds, reconciled with this request;
|
|
281
|
+
None imports every object afresh."""
|
|
219
282
|
install_project_libraries(engine, application, request.get("libraries", []), progress)
|
|
220
283
|
if request.get("libraryReferences"):
|
|
221
284
|
manager = application.get_library_manager()
|
|
222
285
|
for reference in request["libraryReferences"]:
|
|
223
286
|
manager.add_library(reference)
|
|
224
287
|
progress("Importing source objects")
|
|
225
|
-
if
|
|
288
|
+
if previous is None:
|
|
226
289
|
for name in request["removeObjects"]:
|
|
227
290
|
for obj in application.find_ignore_transient_objects(name, True):
|
|
228
291
|
obj.remove()
|
|
@@ -233,6 +296,8 @@ def import_objects(engine, application, request, reusing, progress):
|
|
|
233
296
|
raise RuntimeError("Ambiguous IEC object " + spec["name"])
|
|
234
297
|
for obj in existing:
|
|
235
298
|
obj.remove()
|
|
299
|
+
held = dict(((tuple(item["folder"]), item["name"].lower()), item) for item in previous or [])
|
|
300
|
+
written = 0
|
|
236
301
|
for spec in request["objects"]:
|
|
237
302
|
parent = application
|
|
238
303
|
for name in spec["folder"]:
|
|
@@ -246,7 +311,8 @@ def import_objects(engine, application, request, reusing, progress):
|
|
|
246
311
|
elif not folder.is_folder:
|
|
247
312
|
raise RuntimeError("Source folder conflicts with IEC object " + name)
|
|
248
313
|
parent = folder
|
|
249
|
-
import_object(engine, parent, spec)
|
|
314
|
+
written += import_object(engine, parent, spec, held.get((tuple(spec["folder"]), spec["name"].lower())))
|
|
315
|
+
progress("Wrote {0} source objects".format(written))
|
|
250
316
|
if "tasks" in request:
|
|
251
317
|
return configure_tasks(engine, application, request["tasks"], request["objects"])
|
|
252
318
|
matches = application.find_ignore_transient_objects(request["task"], True)
|
|
@@ -337,268 +403,34 @@ def configure_tasks(engine, application, tasks, objects):
|
|
|
337
403
|
return tasks
|
|
338
404
|
|
|
339
405
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
MODBUS_RISING_EDGE = 6
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
def host_parameters(device):
|
|
368
|
-
connectors = [connector for connector in device.connectors if len(list(connector.host_parameters))]
|
|
369
|
-
if len(connectors) != 1:
|
|
370
|
-
raise RuntimeError("Expected one configurable connector on " + device.get_name())
|
|
371
|
-
return dict((parameter.id, parameter) for parameter in connectors[0].host_parameters)
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
def child_device(engine, parent, spec):
|
|
375
|
-
name, kind, identifier, version = spec
|
|
376
|
-
parent.add(name, kind, identifier, version)
|
|
377
|
-
matches = [child for child in parent.find(name, False) if getattr(child, "is_device", False)]
|
|
378
|
-
if len(matches) != 1:
|
|
379
|
-
raise RuntimeError("Could not add device " + name)
|
|
380
|
-
return matches[0]
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
def set_parameter(parameter, value):
|
|
384
|
-
parameter.value = value
|
|
385
|
-
if unicode(parameter.value).replace(" ", "") != unicode(value).replace(" ", ""):
|
|
386
|
-
raise RuntimeError("{0} reads back {1}, not {2}".format(parameter.visible_name, parameter.value, value))
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
def string_literal(parameter, text):
|
|
390
|
-
return ('"{0}"' if parameter.type_string == "WSTRING" else "'{0}'").format(text)
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
def ip_array(address, hexadecimal=False):
|
|
394
|
-
octets = [int(octet) for octet in address.split(".")]
|
|
395
|
-
return "[" + ",".join("16#{0:02X}".format(octet) if hexadecimal else str(octet) for octet in octets) + "]"
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
def pin(channel, address):
|
|
399
|
-
# CODESYS validates a manual address against the automatic one until it is disabled.
|
|
400
|
-
channel.io_mapping.manual_iec_address = address
|
|
401
|
-
channel.io_mapping.automatic_iec_address = False
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
def settle(pinned):
|
|
405
|
-
"""Pin only channels whose automatic address differs; each write revalidates the whole tree."""
|
|
406
|
-
for address, channel in sorted(pinned.items()):
|
|
407
|
-
# An automatic mapping reports its assigned address here.
|
|
408
|
-
if channel.io_mapping.manual_iec_address != address:
|
|
409
|
-
pin(channel, address)
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
def pin_channels(engine, adapter, parameters, device):
|
|
413
|
-
"""Give each raw payload byte the address of its offset, so AT declarations read it."""
|
|
414
|
-
pinned = {}
|
|
415
|
-
for direction, region, kind in (("inputs", "I", engine.ChannelType.Input), ("outputs", "Q", engine.ChannelType.Output)):
|
|
416
|
-
channels = sorted((parameter for parameter in parameters.values() if parameter.channel_type == kind), key=lambda parameter: parameter.id)
|
|
417
|
-
area = device.get(direction)
|
|
418
|
-
if not area:
|
|
419
|
-
if channels:
|
|
420
|
-
raise RuntimeError("{0} has {1} without a configured address".format(device["name"], direction))
|
|
421
|
-
continue
|
|
422
|
-
if len(channels) != area["size"] or any(channel.bit_size != 8 for channel in channels):
|
|
423
|
-
raise RuntimeError("{0} {1} are not {2} raw byte channels".format(device["name"], direction, area["size"]))
|
|
424
|
-
base = int(area["address"][3:])
|
|
425
|
-
# Later automatic channels follow the first one consecutively.
|
|
426
|
-
pin(channels[0], "%{0}B{1}".format(region, base))
|
|
427
|
-
for offset, channel in enumerate(channels):
|
|
428
|
-
pinned["%{0}B{1}".format(region, base + offset)] = channel
|
|
429
|
-
settle(pinned)
|
|
430
|
-
return pinned
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
def channel_at(pinned, address):
|
|
434
|
-
if address in pinned:
|
|
435
|
-
return pinned[address]
|
|
436
|
-
match = re.match(r"^%([IQ])(X|B)(\d+)(?:\.([0-7]))?$", address)
|
|
437
|
-
channel = pinned.get("%{0}B{1}".format(match.group(1), match.group(3)))
|
|
438
|
-
if channel is None:
|
|
439
|
-
raise RuntimeError("No generated channel at " + address)
|
|
440
|
-
return list(channel)[int(match.group(4))] if match.group(2) == "X" else channel
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
def map_channels(pinned, tree, application):
|
|
444
|
-
"""Map bound bits and bytes to their variables; comment the channels of IO that keeps AT."""
|
|
445
|
-
mapped = set()
|
|
446
|
-
for mapping in tree.get("mappings", []):
|
|
447
|
-
channel_at(pinned, mapping["address"]).io_mapping.variable = application + "." + mapping["variable"]
|
|
448
|
-
mapped.add(mapping["address"])
|
|
449
|
-
for device in tree["devices"]:
|
|
450
|
-
for comment in device.get("comments", []):
|
|
451
|
-
if comment["address"] not in mapped:
|
|
452
|
-
channel_at(pinned, comment["address"]).user_comment = comment["text"]
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
def add_modbus_channel(project, server, register):
|
|
456
|
-
"""Add a write channel with the editor's own routine: CODESYS scripting has no API for channels."""
|
|
457
|
-
from System import Activator, Array, Enum, Object, UInt16, UInt32
|
|
458
|
-
from System.Reflection import Assembly, BindingFlags
|
|
459
|
-
flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static
|
|
460
|
-
editor = Assembly.LoadFrom(MODBUS_EDITOR)
|
|
461
|
-
def named(name):
|
|
462
|
-
return editor.GetType("_3S.CoDeSys.DeviceEditorModbus." + name, True)
|
|
463
|
-
def call(owner, target, name, *arguments):
|
|
464
|
-
return owner.GetMethod(name, flags).Invoke(target, Array[Object](list(arguments)))
|
|
465
|
-
manager = named("APEnvironment").GetProperty("ObjectMgr", flags).GetValue(None, None)
|
|
466
|
-
meta = manager.GetObjectToModify(project.handle, server.guid)
|
|
467
|
-
connector = [item for item in meta.Object.Connectors if str(item.ConnectorRole) == "Child"][0]
|
|
468
|
-
data = call(named("New.ChannelDataBase"), None, "Create", connector)
|
|
469
|
-
item_type = named("ModbusChannelItem")
|
|
470
|
-
item = Activator.CreateInstance(item_type)
|
|
471
|
-
fields = (
|
|
472
|
-
("stName", register["name"]),
|
|
473
|
-
("usFunctionCode", UInt16(MODBUS_WRITE_SINGLE_REGISTER)),
|
|
474
|
-
("usWriteOffset", UInt16(register["offset"])),
|
|
475
|
-
("usWriteLength", UInt16(1)),
|
|
476
|
-
("usReadLength", UInt16(0)),
|
|
477
|
-
("triggerType", Enum.ToObject(named("TriggerType"), MODBUS_RISING_EDGE)),
|
|
478
|
-
("uiParamID", call(data.GetType(), data, "GetNextAvailableParamID", call(data.GetType(), data, "LoadSlaveChannels", connector))),
|
|
479
|
-
)
|
|
480
|
-
for name, value in fields:
|
|
481
|
-
item_type.GetField(name).SetValue(item, value)
|
|
482
|
-
factory = call(named("New.PresetFactory"), None, "Create", connector, False)
|
|
483
|
-
call(data.GetType(), data, "CreateSlaveChannel", connector, item, factory)
|
|
484
|
-
manager.SetObject(meta, True, None)
|
|
485
|
-
return int(UInt32(item_type.GetField("uiParamID").GetValue(item))) // MODBUS_CHANNEL
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
def configure_modbus(engine, project, ethernet, devices):
|
|
489
|
-
"""One Modbus TCP server per card with registers, with channels pinned like the EtherNet/IP bytes."""
|
|
490
|
-
pinned = {}
|
|
491
|
-
client = child_device(engine, ethernet, MODBUS_CLIENT)
|
|
492
|
-
parameters = host_parameters(client)
|
|
493
|
-
# The line runs unattended, so a card that restarts must come back on its own.
|
|
494
|
-
set_parameter(parameters[MODBUS_AUTO_RECONNECT], "TRUE")
|
|
495
|
-
# Status and request count are how the program learns a write finished.
|
|
496
|
-
set_parameter(parameters[MODBUS_CHANNEL_STATUS], "TRUE")
|
|
497
|
-
for device in devices:
|
|
498
|
-
server = child_device(engine, client, (device["name"] + "_Modbus",) + MODBUS_SERVER)
|
|
499
|
-
server.driver_info.always_update_variables = engine.AlwaysUpdateVariablesMode.OnlyIfUnused
|
|
500
|
-
parameters = host_parameters(server)
|
|
501
|
-
set_parameter(parameters[MODBUS_ADDRESS], ip_array(device["address"]))
|
|
502
|
-
set_parameter(parameters[MODBUS_PORT], str(device["modbus"]["port"]))
|
|
503
|
-
for register in device["modbus"]["registers"]:
|
|
504
|
-
channel = add_modbus_channel(project, server, register) * MODBUS_CHANNEL
|
|
505
|
-
parameters = host_parameters(server)
|
|
506
|
-
pinned[register["write"]] = parameters[channel + MODBUS_TRIGGER]
|
|
507
|
-
pinned[register["status"]] = parameters[channel + MODBUS_STATUS]
|
|
508
|
-
pinned[register["requests"]] = parameters[channel + MODBUS_REQUESTS]
|
|
509
|
-
pinned[register["value"]] = parameters[channel + MODBUS_HOLDING_WRITE + register["offset"]]
|
|
510
|
-
settle(pinned)
|
|
511
|
-
return pinned
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
def template_controller(project):
|
|
515
|
-
controllers = [child for child in project.get_children() if getattr(child, "is_device", False)]
|
|
516
|
-
if len(controllers) != 1:
|
|
517
|
-
raise RuntimeError("The template must contain one controller device")
|
|
518
|
-
return controllers[0]
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
def apply_controller(project, identity, progress):
|
|
522
|
-
controller = template_controller(project)
|
|
523
|
-
wanted = (identity["type"], identity["id"], identity["version"])
|
|
524
|
-
current = controller.get_device_identification()
|
|
525
|
-
if (current.type, current.id, current.version) == wanted:
|
|
526
|
-
return
|
|
527
|
-
progress("Updating controller to {0} {1} {2}".format(*wanted))
|
|
528
|
-
# Update Device keeps the Application and its children under the new device.
|
|
529
|
-
controller.update(*wanted)
|
|
530
|
-
updated = controller.get_device_identification()
|
|
531
|
-
if (updated.type, updated.id, updated.version) != wanted:
|
|
532
|
-
raise RuntimeError("CODESYS kept controller {0} {1} {2}; install the device description for {3} {4} {5}".format(
|
|
533
|
-
updated.type, updated.id, updated.version, *wanted))
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
def import_device_description(engine, device, root):
|
|
537
|
-
from System import Guid
|
|
538
|
-
repository = engine.device_repository
|
|
539
|
-
identity = repository.import_device(os.path.join(root, device["eds"]), repository.sources[0], Guid(EDS_CONVERTER), True)
|
|
540
|
-
if identity is None:
|
|
541
|
-
raise RuntimeError("CODESYS could not import the EDS of " + device["name"])
|
|
542
|
-
return identity
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
def configure_device_tree(engine, project, tree, root, progress):
|
|
546
|
-
controller = template_controller(project)
|
|
547
|
-
for child in controller.get_children(True):
|
|
548
|
-
if getattr(child, "is_device", False) and child.get_device_identification().type in (ETHERNET[1], SCANNER[1]):
|
|
549
|
-
raise RuntimeError("Template already configures " + child.get_name() + "; remove it or codesys.deviceTree")
|
|
550
|
-
progress("Generating EtherNet/IP device tree")
|
|
551
|
-
ethernet = child_device(engine, controller, ETHERNET)
|
|
552
|
-
parameters = host_parameters(ethernet)
|
|
553
|
-
for key, identifier in ETHERNET_PARAMETERS:
|
|
554
|
-
value = tree.get("ethernet", {}).get(key)
|
|
555
|
-
if value is not None:
|
|
556
|
-
set_parameter(parameters[identifier], string_literal(parameters[identifier], value) if key == "interface" else ip_array(value))
|
|
557
|
-
scanner = child_device(engine, ethernet, SCANNER)
|
|
558
|
-
pinned = {}
|
|
559
|
-
for device in tree["devices"]:
|
|
560
|
-
identity = import_device_description(engine, device, root)
|
|
561
|
-
adapter = child_device(engine, scanner, (device["name"], identity.type, identity.id, identity.version))
|
|
562
|
-
# CODESYS otherwise skips mapped variables no IEC code reads, such as inputs only clients observe.
|
|
563
|
-
adapter.driver_info.always_update_variables = engine.AlwaysUpdateVariablesMode.OnlyIfUnused
|
|
564
|
-
parameters = host_parameters(adapter)
|
|
565
|
-
if device.get("address"):
|
|
566
|
-
set_parameter(parameters[TARGET_ADDRESS], ip_array(device["address"], True))
|
|
567
|
-
if device.get("rpiMicroseconds"):
|
|
568
|
-
fields = [field for field in parameters[CONNECTION_SETTINGS] if field.identifier == "RPI"]
|
|
569
|
-
set_parameter(fields[0], str(device["rpiMicroseconds"]))
|
|
570
|
-
for address, channel in pin_channels(engine, adapter, parameters, device).items():
|
|
571
|
-
if address in pinned:
|
|
572
|
-
raise RuntimeError("{0} overlaps another adapter".format(address))
|
|
573
|
-
pinned[address] = channel
|
|
574
|
-
registers = [device for device in tree["devices"] if device.get("modbus")]
|
|
575
|
-
if registers:
|
|
576
|
-
progress("Generating Modbus TCP devices")
|
|
577
|
-
pinned.update(configure_modbus(engine, project, ethernet, registers))
|
|
578
|
-
# Adding a later adapter can reflow earlier automatic addresses.
|
|
579
|
-
settle(pinned)
|
|
580
|
-
map_channels(pinned, tree, project.active_application.get_name())
|
|
581
|
-
return pinned
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
def verify_pinned(pinned):
|
|
585
|
-
for address, channel in pinned.items():
|
|
586
|
-
if channel.io_mapping.manual_iec_address != address:
|
|
587
|
-
raise RuntimeError("Channel {0} moved to {1}".format(address, channel.io_mapping.manual_iec_address))
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
def device_tree_cache(request, workspace):
|
|
591
|
-
"""The project as it stands once its device tree is generated, kept beside the worker, which each
|
|
592
|
-
container starts afresh. Generating the tree takes most of a build, and the same template,
|
|
593
|
-
controller, tree and generator always produce the same one."""
|
|
594
|
-
with io.open(__file__, "rb") as source:
|
|
595
|
-
generator = hashlib.sha256(source.read()).hexdigest()
|
|
596
|
-
inputs = json.dumps([generator, request["templateHash"], request.get("controller"), request["deviceTree"]], sort_keys=True)
|
|
597
|
-
return os.path.join(os.path.dirname(workspace), "device-tree", hashlib.sha256(inputs.encode("utf-8")).hexdigest() + ".project")
|
|
406
|
+
def scripts_hash(*names):
|
|
407
|
+
"""A cached project is only as current as the scripts that made it."""
|
|
408
|
+
digest = hashlib.sha256()
|
|
409
|
+
for name in names:
|
|
410
|
+
with io.open(os.path.join(os.path.dirname(__file__), name), "rb") as source:
|
|
411
|
+
digest.update(source.read())
|
|
412
|
+
return digest.hexdigest()
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def cache_path(directory, kind, scripts, inputs):
|
|
416
|
+
key = hashlib.sha256(json.dumps([scripts_hash(*scripts)] + inputs, sort_keys=True).encode("utf-8")).hexdigest()
|
|
417
|
+
return os.path.join(directory, kind, key + ".project")
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def device_tree_cache(request, directory):
|
|
421
|
+
"""The project as it stands once its device tree is generated. Generating the tree takes most of
|
|
422
|
+
a build, and the same template, controller, tree and hardware.py always produce the same one."""
|
|
423
|
+
return cache_path(directory, "device-tree", ["hardware.py"], [request["templateHash"], request.get("controller"), request["deviceTree"]])
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def build_cache(request, directory):
|
|
427
|
+
"""The last successful build's project, beside the source objects it holds (`.json`). Most builds
|
|
428
|
+
change a few objects, and writing only those is much faster than importing every one."""
|
|
429
|
+
return cache_path(directory, "last-build", ["build.py", "hardware.py"], list(template_key(request)))
|
|
598
430
|
|
|
599
431
|
|
|
600
432
|
def keep_device_tree(pending, cache):
|
|
601
|
-
"""Replace the previous tree once a
|
|
433
|
+
"""Replace the previous tree once a project with this one works, so the cache holds one verified tree."""
|
|
602
434
|
directory = os.path.dirname(cache)
|
|
603
435
|
for name in os.listdir(directory):
|
|
604
436
|
if os.path.join(directory, name) != pending:
|
|
@@ -606,22 +438,33 @@ def keep_device_tree(pending, cache):
|
|
|
606
438
|
os.rename(pending, cache)
|
|
607
439
|
|
|
608
440
|
|
|
609
|
-
def
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
os.
|
|
616
|
-
|
|
441
|
+
def keep_build(project, objects, cache):
|
|
442
|
+
directory = os.path.dirname(cache)
|
|
443
|
+
if os.path.isdir(directory):
|
|
444
|
+
shutil.rmtree(directory)
|
|
445
|
+
os.makedirs(directory)
|
|
446
|
+
# The project appears last: a cache without its objects would import into unknown source.
|
|
447
|
+
with io.open(os.path.splitext(cache)[0] + ".json", "w", encoding="utf-8") as output:
|
|
448
|
+
output.write(u"{0}".format(json.dumps(objects, ensure_ascii=False)))
|
|
449
|
+
shutil.copyfile(project, cache + ".pending")
|
|
450
|
+
os.rename(cache + ".pending", cache)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def describe_devices(engine, tree, root):
|
|
454
|
+
"""A saved tree's adapters need their descriptions in this container's device repository."""
|
|
455
|
+
for device in tree["devices"] if tree else []:
|
|
456
|
+
import_device_description(engine, device, root)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def open_template(engine, request, path, devices, caches, progress):
|
|
460
|
+
"""Opens the template as `path` with its controller and device tree, whose descriptions are in
|
|
461
|
+
`devices`; from the cache in `caches` when it holds this tree. Returns the project, the channels
|
|
462
|
+
it pinned, and a newly generated tree to keep once the project works."""
|
|
617
463
|
tree = request.get("deviceTree")
|
|
618
|
-
cache = device_tree_cache(request,
|
|
464
|
+
cache = device_tree_cache(request, caches) if tree else None
|
|
619
465
|
if cache and os.path.isfile(cache):
|
|
620
466
|
progress("Reusing the generated device tree")
|
|
621
|
-
|
|
622
|
-
# The tree's adapters need their descriptions in this container's device repository.
|
|
623
|
-
for device in tree["devices"]:
|
|
624
|
-
import_device_description(engine, device, root)
|
|
467
|
+
describe_devices(engine, tree, devices)
|
|
625
468
|
shutil.copyfile(cache, path)
|
|
626
469
|
return engine.projects.open(path, primary=True), {}, None
|
|
627
470
|
progress("Opening project")
|
|
@@ -631,7 +474,7 @@ def open_project(engine, request, workspace, progress):
|
|
|
631
474
|
apply_controller(project, request["controller"], progress)
|
|
632
475
|
if not tree:
|
|
633
476
|
return project, {}, None
|
|
634
|
-
pinned = configure_device_tree(engine, project, tree,
|
|
477
|
+
pinned = configure_device_tree(engine, project, tree, devices, progress)
|
|
635
478
|
project.save()
|
|
636
479
|
if not os.path.isdir(os.path.dirname(cache)):
|
|
637
480
|
os.makedirs(os.path.dirname(cache))
|
|
@@ -640,6 +483,29 @@ def open_project(engine, request, workspace, progress):
|
|
|
640
483
|
return project, pinned, (pending, cache)
|
|
641
484
|
|
|
642
485
|
|
|
486
|
+
def open_project(engine, request, workspace, progress):
|
|
487
|
+
"""Opens the project to import into: the last build's when only source objects changed since it,
|
|
488
|
+
else the template. Returns the project, the source objects it already holds (None for none),
|
|
489
|
+
the channels it pinned, and a newly generated tree to keep if the build succeeds."""
|
|
490
|
+
working = os.path.join(workspace, "project")
|
|
491
|
+
if os.path.isdir(working):
|
|
492
|
+
shutil.rmtree(working)
|
|
493
|
+
os.mkdir(working)
|
|
494
|
+
path = os.path.join(working, "Application.project")
|
|
495
|
+
# Caches live beside the worker, which each container starts afresh.
|
|
496
|
+
caches, devices = os.path.dirname(workspace), os.path.dirname(request["receipt"])
|
|
497
|
+
last = build_cache(request, caches)
|
|
498
|
+
if os.path.isfile(last):
|
|
499
|
+
progress("Reusing the last build's project")
|
|
500
|
+
describe_devices(engine, request.get("deviceTree"), devices)
|
|
501
|
+
with io.open(os.path.splitext(last)[0] + ".json", encoding="utf-8") as source:
|
|
502
|
+
previous = json.load(source)
|
|
503
|
+
shutil.copyfile(last, path)
|
|
504
|
+
return engine.projects.open(path, primary=True), previous, {}, None
|
|
505
|
+
project, pinned, generated = open_template(engine, request, path, devices, caches, progress)
|
|
506
|
+
return project, None, pinned, generated
|
|
507
|
+
|
|
508
|
+
|
|
643
509
|
def build(engine, request, loaded, workspace):
|
|
644
510
|
progress_path = os.path.join(os.path.dirname(request["receipt"]), "progress.log")
|
|
645
511
|
|
|
@@ -652,24 +518,26 @@ def build(engine, request, loaded, workspace):
|
|
|
652
518
|
# Reopen when the template or object membership changes, so removed/renamed
|
|
653
519
|
# declarations cannot survive from an earlier build. Text changes are imported below.
|
|
654
520
|
key = project_key(request)
|
|
655
|
-
reusing = loaded.get("key") == key
|
|
656
521
|
project = None
|
|
657
522
|
# Only a tree generated in this build has channels to verify; a reused or cached one passed when it was made.
|
|
658
523
|
pinned, generated = {}, None
|
|
659
524
|
try:
|
|
660
|
-
if
|
|
525
|
+
if loaded.get("key") == key:
|
|
661
526
|
progress("Reusing loaded project")
|
|
662
|
-
project = loaded["project"]
|
|
527
|
+
project, previous = loaded["project"], loaded["objects"]
|
|
663
528
|
else:
|
|
664
529
|
if loaded.get("project") is not None:
|
|
665
530
|
loaded["project"].close()
|
|
666
531
|
loaded.clear()
|
|
667
|
-
project, pinned, generated = open_project(engine, request, workspace, progress)
|
|
532
|
+
project, previous, pinned, generated = open_project(engine, request, workspace, progress)
|
|
668
533
|
loaded.clear()
|
|
669
534
|
application = project.active_application
|
|
670
535
|
if application is None:
|
|
671
536
|
raise RuntimeError("Template must have an active Application")
|
|
672
|
-
|
|
537
|
+
gone = reconcile(application, previous, request["objects"]) if previous is not None else []
|
|
538
|
+
configured_tasks = import_objects(engine, application, request, previous, progress)
|
|
539
|
+
for obj in gone:
|
|
540
|
+
obj.remove()
|
|
673
541
|
if request["symbols"] and not any(
|
|
674
542
|
getattr(child, "is_symbol_config", False)
|
|
675
543
|
for child in application.get_children()
|
|
@@ -707,13 +575,14 @@ def build(engine, request, loaded, workspace):
|
|
|
707
575
|
raise RuntimeError("Compiler did not produce a non-empty boot application")
|
|
708
576
|
if generated:
|
|
709
577
|
keep_device_tree(*generated)
|
|
578
|
+
keep_build(project.path, request["objects"], build_cache(request, os.path.dirname(workspace)))
|
|
710
579
|
progress("Build complete")
|
|
711
580
|
with io.open(request["receipt"], "w", encoding="utf-8") as receipt:
|
|
712
581
|
receipt.write(json.dumps(
|
|
713
582
|
dict({"mode": request["mode"], "application": application.get_name()}, **({"tasks": configured_tasks} if configured_tasks is not None else {})),
|
|
714
583
|
ensure_ascii=False,
|
|
715
584
|
))
|
|
716
|
-
loaded.update({"key": key, "project": project})
|
|
585
|
+
loaded.update({"key": key, "objects": request["objects"], "project": project})
|
|
717
586
|
except Exception:
|
|
718
587
|
loaded.clear()
|
|
719
588
|
if project is not None:
|