@tsplc/build 0.2.6 → 0.2.8
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 +3 -3
- 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/codesys_online.py
CHANGED
|
@@ -6,7 +6,8 @@ import json
|
|
|
6
6
|
import os
|
|
7
7
|
import re
|
|
8
8
|
import time
|
|
9
|
-
from build import CompilerError,
|
|
9
|
+
from build import CompilerError, fail_on_errors, import_objects, keep_device_tree, open_template, reconcile, reported_errors
|
|
10
|
+
from hardware import verify_pinned
|
|
10
11
|
from System import Activator, AppDomain, Array, Enum, Environment, Object
|
|
11
12
|
from System.Net import Dns, IPAddress
|
|
12
13
|
from System.Reflection import BindingFlags, TargetInvocationException
|
|
@@ -118,34 +119,6 @@ def load_downloaded_reference(application):
|
|
|
118
119
|
return context_ids(context)
|
|
119
120
|
|
|
120
121
|
|
|
121
|
-
def remove_deleted(parent, previous, current):
|
|
122
|
-
"""Reconcile only source-owned objects; preserve GUIDs of unchanged objects."""
|
|
123
|
-
new = {item['name'].lower(): item for item in current}
|
|
124
|
-
for old in previous:
|
|
125
|
-
obj = unique_child(parent, old['name'])
|
|
126
|
-
fresh = new.get(old['name'].lower())
|
|
127
|
-
if obj is None:
|
|
128
|
-
raise RuntimeError('Saved source object is missing: ' + old['name'])
|
|
129
|
-
if fresh is None or old['kind'] != fresh['kind'] or bool(old.get('persistent')) != bool(fresh.get('persistent')):
|
|
130
|
-
obj.remove()
|
|
131
|
-
else:
|
|
132
|
-
remove_deleted(obj, old.get('children', []), fresh.get('children', []))
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
def reconcile(application, previous, current):
|
|
136
|
-
# A folder move changes placement rather than text. Remove/recreate just
|
|
137
|
-
# that moved object, letting the vendor decide whether the update is valid.
|
|
138
|
-
new = {(tuple(item['folder']), item['name'].lower()): item for item in current}
|
|
139
|
-
for old in previous:
|
|
140
|
-
parent = application
|
|
141
|
-
for name in old['folder']:
|
|
142
|
-
parent = unique_child(parent, name)
|
|
143
|
-
if parent is None:
|
|
144
|
-
raise RuntimeError('Saved source folder is missing: ' + name)
|
|
145
|
-
fresh = new.get((tuple(old['folder']), old['name'].lower()))
|
|
146
|
-
remove_deleted(parent, [old], [fresh] if fresh else [])
|
|
147
|
-
|
|
148
|
-
|
|
149
122
|
def fail_on_errors_after_transfer(engine, progress):
|
|
150
123
|
"""Like fail_on_errors, once the controller runs the verified application.
|
|
151
124
|
|
|
@@ -193,11 +166,14 @@ def run_online(engine, request):
|
|
|
193
166
|
if progress_path:
|
|
194
167
|
with io.open(progress_path, 'a', encoding='utf-8') as output:
|
|
195
168
|
output.write(u'{0}Z {1}\n'.format(time.strftime('%Y-%m-%dT%H:%M:%S', time.gmtime()), stage))
|
|
196
|
-
progress('
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
169
|
+
progress('Starting; processor count {0}'.format(Environment.ProcessorCount))
|
|
170
|
+
# A download starts from the template; online changes reuse the downloaded project, which has its controller and tree.
|
|
171
|
+
if request['operation'] == 'download':
|
|
172
|
+
project, pinned, generated = open_template(engine, request, request['project'], request.get('devices'), request['caches'], progress)
|
|
173
|
+
else:
|
|
174
|
+
progress('Opening project')
|
|
175
|
+
project = engine.projects.open(request['project'], primary=True)
|
|
176
|
+
pinned, generated = {}, None
|
|
201
177
|
application = project.active_application
|
|
202
178
|
online_app = None
|
|
203
179
|
online_device = None
|
|
@@ -236,12 +212,12 @@ def run_online(engine, request):
|
|
|
236
212
|
'state': state, 'values': {expression: online_app.read_value(expression) for expression in request.get('read', [])}}
|
|
237
213
|
finally:
|
|
238
214
|
core.Logout()
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
215
|
+
# A download starts from the template; an online change writes only what changed since the baseline.
|
|
216
|
+
held = previous['objects'] if request['operation'] == 'online-change' else None
|
|
217
|
+
gone = reconcile(application, held, request['objects']) if held is not None else []
|
|
218
|
+
import_objects(engine, application, request, held, progress)
|
|
219
|
+
for obj in gone:
|
|
220
|
+
obj.remove()
|
|
245
221
|
if request['symbols'] and not any(getattr(child, 'is_symbol_config', False) for child in application.get_children()):
|
|
246
222
|
from System import Guid
|
|
247
223
|
application.create_symbol_config(True, True, Guid('{0141eb75-141b-4ea1-9a8c-75f952b22a6c}'))
|
|
@@ -318,6 +294,8 @@ def run_online(engine, request):
|
|
|
318
294
|
online_app.logout()
|
|
319
295
|
progress('Saving downloaded project')
|
|
320
296
|
project.save()
|
|
297
|
+
if generated:
|
|
298
|
+
keep_device_tree(*generated)
|
|
321
299
|
return result
|
|
322
300
|
finally:
|
|
323
301
|
if online_app is not None:
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""The controller and its device tree, which a build generates once and caches by this file's hash.
|
|
2
|
+
Runs inside CODESYS's Python engine, not the Mac's Python installation."""
|
|
3
|
+
from __future__ import print_function
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
unicode
|
|
10
|
+
except NameError:
|
|
11
|
+
unicode = str
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# CODESYS EDS Import's converter factory; plain import_device expects device XML.
|
|
15
|
+
EDS_CONVERTER = "1ce4a9c1-37d3-496c-9e80-cd99ad3807ee"
|
|
16
|
+
ETHERNET = ("Ethernet", 110, "0000 0002", "4.2.0.0")
|
|
17
|
+
SCANNER = ("EtherNet_IP_Scanner", 100, "0000 100B", "4.9.0.0")
|
|
18
|
+
# Ethernet adapter interface settings and EtherNet/IP adapter parameter IDs.
|
|
19
|
+
# The interface name is a STRING (10) and a WSTRING (12); the device editor and runtime read the WSTRING.
|
|
20
|
+
ETHERNET_PARAMETERS = (("address", 0), ("subnet", 1), ("gateway", 2), ("interface", 10), ("interface", 12))
|
|
21
|
+
TARGET_ADDRESS = 256
|
|
22
|
+
CONNECTION_SETTINGS = 268435457
|
|
23
|
+
MODBUS_CLIENT = ("Modbus_TCP_Client", 88, "0000 0003", "4.6.0.0")
|
|
24
|
+
MODBUS_SERVER = (89, "0000 0005", "4.6.0.0")
|
|
25
|
+
MODBUS_EDITOR = r"C:\CODESYS-3.5.22.30\CODESYS\PlugIns\7e343689-9a5f-46aa-bf56-5903c4efec59\4.6.0.0\DeviceEditorModbus.plugin.dll"
|
|
26
|
+
# Modbus client and server parameter IDs.
|
|
27
|
+
MODBUS_AUTO_RECONNECT = 9002
|
|
28
|
+
MODBUS_CHANNEL_STATUS = 9003
|
|
29
|
+
MODBUS_ADDRESS = 9102
|
|
30
|
+
MODBUS_PORT = 9103
|
|
31
|
+
# The editor's parameter IDs for channel number n: n * 2^24 plus these.
|
|
32
|
+
MODBUS_CHANNEL = 1 << 24
|
|
33
|
+
MODBUS_TRIGGER = 0x200001
|
|
34
|
+
MODBUS_STATUS = 0x200002
|
|
35
|
+
MODBUS_REQUESTS = 0x200003
|
|
36
|
+
MODBUS_HOLDING_WRITE = 0x840000
|
|
37
|
+
MODBUS_WRITE_SINGLE_REGISTER = 6
|
|
38
|
+
MODBUS_RISING_EDGE = 6
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def host_parameters(device):
|
|
42
|
+
connectors = [connector for connector in device.connectors if len(list(connector.host_parameters))]
|
|
43
|
+
if len(connectors) != 1:
|
|
44
|
+
raise RuntimeError("Expected one configurable connector on " + device.get_name())
|
|
45
|
+
return dict((parameter.id, parameter) for parameter in connectors[0].host_parameters)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def child_device(engine, parent, spec):
|
|
49
|
+
name, kind, identifier, version = spec
|
|
50
|
+
parent.add(name, kind, identifier, version)
|
|
51
|
+
matches = [child for child in parent.find(name, False) if getattr(child, "is_device", False)]
|
|
52
|
+
if len(matches) != 1:
|
|
53
|
+
raise RuntimeError("Could not add device " + name)
|
|
54
|
+
return matches[0]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def set_parameter(parameter, value):
|
|
58
|
+
parameter.value = value
|
|
59
|
+
if unicode(parameter.value).replace(" ", "") != unicode(value).replace(" ", ""):
|
|
60
|
+
raise RuntimeError("{0} reads back {1}, not {2}".format(parameter.visible_name, parameter.value, value))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def string_literal(parameter, text):
|
|
64
|
+
return ('"{0}"' if parameter.type_string == "WSTRING" else "'{0}'").format(text)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def ip_array(address, hexadecimal=False):
|
|
68
|
+
octets = [int(octet) for octet in address.split(".")]
|
|
69
|
+
return "[" + ",".join("16#{0:02X}".format(octet) if hexadecimal else str(octet) for octet in octets) + "]"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def pin(channel, address):
|
|
73
|
+
# CODESYS validates a manual address against the automatic one until it is disabled.
|
|
74
|
+
channel.io_mapping.manual_iec_address = address
|
|
75
|
+
channel.io_mapping.automatic_iec_address = False
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def settle(pinned):
|
|
79
|
+
"""Pin only channels whose automatic address differs; each write revalidates the whole tree."""
|
|
80
|
+
for address, channel in sorted(pinned.items()):
|
|
81
|
+
# An automatic mapping reports its assigned address here.
|
|
82
|
+
if channel.io_mapping.manual_iec_address != address:
|
|
83
|
+
pin(channel, address)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def pin_channels(engine, adapter, parameters, device):
|
|
87
|
+
"""Give each raw payload byte the address of its offset, so AT declarations read it."""
|
|
88
|
+
pinned = {}
|
|
89
|
+
for direction, region, kind in (("inputs", "I", engine.ChannelType.Input), ("outputs", "Q", engine.ChannelType.Output)):
|
|
90
|
+
channels = sorted((parameter for parameter in parameters.values() if parameter.channel_type == kind), key=lambda parameter: parameter.id)
|
|
91
|
+
area = device.get(direction)
|
|
92
|
+
if not area:
|
|
93
|
+
if channels:
|
|
94
|
+
raise RuntimeError("{0} has {1} without a configured address".format(device["name"], direction))
|
|
95
|
+
continue
|
|
96
|
+
if len(channels) != area["size"] or any(channel.bit_size != 8 for channel in channels):
|
|
97
|
+
raise RuntimeError("{0} {1} are not {2} raw byte channels".format(device["name"], direction, area["size"]))
|
|
98
|
+
base = int(area["address"][3:])
|
|
99
|
+
# Later automatic channels follow the first one consecutively.
|
|
100
|
+
pin(channels[0], "%{0}B{1}".format(region, base))
|
|
101
|
+
for offset, channel in enumerate(channels):
|
|
102
|
+
pinned["%{0}B{1}".format(region, base + offset)] = channel
|
|
103
|
+
settle(pinned)
|
|
104
|
+
return pinned
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def channel_at(pinned, address):
|
|
108
|
+
if address in pinned:
|
|
109
|
+
return pinned[address]
|
|
110
|
+
match = re.match(r"^%([IQ])(X|B)(\d+)(?:\.([0-7]))?$", address)
|
|
111
|
+
channel = pinned.get("%{0}B{1}".format(match.group(1), match.group(3)))
|
|
112
|
+
if channel is None:
|
|
113
|
+
raise RuntimeError("No generated channel at " + address)
|
|
114
|
+
return list(channel)[int(match.group(4))] if match.group(2) == "X" else channel
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def map_channels(pinned, tree, application):
|
|
118
|
+
"""Map bound bits and bytes to their variables; comment the channels of IO that keeps AT."""
|
|
119
|
+
mapped = set()
|
|
120
|
+
for mapping in tree.get("mappings", []):
|
|
121
|
+
channel_at(pinned, mapping["address"]).io_mapping.variable = application + "." + mapping["variable"]
|
|
122
|
+
mapped.add(mapping["address"])
|
|
123
|
+
for device in tree["devices"]:
|
|
124
|
+
for comment in device.get("comments", []):
|
|
125
|
+
if comment["address"] not in mapped:
|
|
126
|
+
channel_at(pinned, comment["address"]).user_comment = comment["text"]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def add_modbus_channel(project, server, register):
|
|
130
|
+
"""Add a write channel with the editor's own routine: CODESYS scripting has no API for channels."""
|
|
131
|
+
from System import Activator, Array, Enum, Object, UInt16, UInt32
|
|
132
|
+
from System.Reflection import Assembly, BindingFlags
|
|
133
|
+
flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static
|
|
134
|
+
editor = Assembly.LoadFrom(MODBUS_EDITOR)
|
|
135
|
+
def named(name):
|
|
136
|
+
return editor.GetType("_3S.CoDeSys.DeviceEditorModbus." + name, True)
|
|
137
|
+
def call(owner, target, name, *arguments):
|
|
138
|
+
return owner.GetMethod(name, flags).Invoke(target, Array[Object](list(arguments)))
|
|
139
|
+
manager = named("APEnvironment").GetProperty("ObjectMgr", flags).GetValue(None, None)
|
|
140
|
+
meta = manager.GetObjectToModify(project.handle, server.guid)
|
|
141
|
+
connector = [item for item in meta.Object.Connectors if str(item.ConnectorRole) == "Child"][0]
|
|
142
|
+
data = call(named("New.ChannelDataBase"), None, "Create", connector)
|
|
143
|
+
item_type = named("ModbusChannelItem")
|
|
144
|
+
item = Activator.CreateInstance(item_type)
|
|
145
|
+
fields = (
|
|
146
|
+
("stName", register["name"]),
|
|
147
|
+
("usFunctionCode", UInt16(MODBUS_WRITE_SINGLE_REGISTER)),
|
|
148
|
+
("usWriteOffset", UInt16(register["offset"])),
|
|
149
|
+
("usWriteLength", UInt16(1)),
|
|
150
|
+
("usReadLength", UInt16(0)),
|
|
151
|
+
("triggerType", Enum.ToObject(named("TriggerType"), MODBUS_RISING_EDGE)),
|
|
152
|
+
("uiParamID", call(data.GetType(), data, "GetNextAvailableParamID", call(data.GetType(), data, "LoadSlaveChannels", connector))),
|
|
153
|
+
)
|
|
154
|
+
for name, value in fields:
|
|
155
|
+
item_type.GetField(name).SetValue(item, value)
|
|
156
|
+
factory = call(named("New.PresetFactory"), None, "Create", connector, False)
|
|
157
|
+
call(data.GetType(), data, "CreateSlaveChannel", connector, item, factory)
|
|
158
|
+
manager.SetObject(meta, True, None)
|
|
159
|
+
return int(UInt32(item_type.GetField("uiParamID").GetValue(item))) // MODBUS_CHANNEL
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def configure_modbus(engine, project, ethernet, devices):
|
|
163
|
+
"""One Modbus TCP server per card with registers, with channels pinned like the EtherNet/IP bytes."""
|
|
164
|
+
pinned = {}
|
|
165
|
+
client = child_device(engine, ethernet, MODBUS_CLIENT)
|
|
166
|
+
parameters = host_parameters(client)
|
|
167
|
+
# The line runs unattended, so a card that restarts must come back on its own.
|
|
168
|
+
set_parameter(parameters[MODBUS_AUTO_RECONNECT], "TRUE")
|
|
169
|
+
# Status and request count are how the program learns a write finished.
|
|
170
|
+
set_parameter(parameters[MODBUS_CHANNEL_STATUS], "TRUE")
|
|
171
|
+
for device in devices:
|
|
172
|
+
server = child_device(engine, client, (device["name"] + "_Modbus",) + MODBUS_SERVER)
|
|
173
|
+
server.driver_info.always_update_variables = engine.AlwaysUpdateVariablesMode.OnlyIfUnused
|
|
174
|
+
parameters = host_parameters(server)
|
|
175
|
+
set_parameter(parameters[MODBUS_ADDRESS], ip_array(device["address"]))
|
|
176
|
+
set_parameter(parameters[MODBUS_PORT], str(device["modbus"]["port"]))
|
|
177
|
+
for register in device["modbus"]["registers"]:
|
|
178
|
+
channel = add_modbus_channel(project, server, register) * MODBUS_CHANNEL
|
|
179
|
+
parameters = host_parameters(server)
|
|
180
|
+
pinned[register["write"]] = parameters[channel + MODBUS_TRIGGER]
|
|
181
|
+
pinned[register["status"]] = parameters[channel + MODBUS_STATUS]
|
|
182
|
+
pinned[register["requests"]] = parameters[channel + MODBUS_REQUESTS]
|
|
183
|
+
pinned[register["value"]] = parameters[channel + MODBUS_HOLDING_WRITE + register["offset"]]
|
|
184
|
+
settle(pinned)
|
|
185
|
+
return pinned
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def template_controller(project):
|
|
189
|
+
controllers = [child for child in project.get_children() if getattr(child, "is_device", False)]
|
|
190
|
+
if len(controllers) != 1:
|
|
191
|
+
raise RuntimeError("The template must contain one controller device")
|
|
192
|
+
return controllers[0]
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def apply_controller(project, identity, progress):
|
|
196
|
+
controller = template_controller(project)
|
|
197
|
+
wanted = (identity["type"], identity["id"], identity["version"])
|
|
198
|
+
current = controller.get_device_identification()
|
|
199
|
+
if (current.type, current.id, current.version) == wanted:
|
|
200
|
+
return
|
|
201
|
+
progress("Updating controller to {0} {1} {2}".format(*wanted))
|
|
202
|
+
# Update Device keeps the Application and its children under the new device.
|
|
203
|
+
controller.update(*wanted)
|
|
204
|
+
updated = controller.get_device_identification()
|
|
205
|
+
if (updated.type, updated.id, updated.version) != wanted:
|
|
206
|
+
raise RuntimeError("CODESYS kept controller {0} {1} {2}; install the device description for {3} {4} {5}".format(
|
|
207
|
+
updated.type, updated.id, updated.version, *wanted))
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def import_device_description(engine, device, root):
|
|
211
|
+
from System import Guid
|
|
212
|
+
repository = engine.device_repository
|
|
213
|
+
identity = repository.import_device(os.path.join(root, device["eds"]), repository.sources[0], Guid(EDS_CONVERTER), True)
|
|
214
|
+
if identity is None:
|
|
215
|
+
raise RuntimeError("CODESYS could not import the EDS of " + device["name"])
|
|
216
|
+
return identity
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def configure_device_tree(engine, project, tree, root, progress):
|
|
220
|
+
controller = template_controller(project)
|
|
221
|
+
for child in controller.get_children(True):
|
|
222
|
+
if getattr(child, "is_device", False) and child.get_device_identification().type in (ETHERNET[1], SCANNER[1]):
|
|
223
|
+
raise RuntimeError("Template already configures " + child.get_name() + "; remove it or codesys.deviceTree")
|
|
224
|
+
progress("Generating EtherNet/IP device tree")
|
|
225
|
+
ethernet = child_device(engine, controller, ETHERNET)
|
|
226
|
+
parameters = host_parameters(ethernet)
|
|
227
|
+
for key, identifier in ETHERNET_PARAMETERS:
|
|
228
|
+
value = tree.get("ethernet", {}).get(key)
|
|
229
|
+
if value is not None:
|
|
230
|
+
set_parameter(parameters[identifier], string_literal(parameters[identifier], value) if key == "interface" else ip_array(value))
|
|
231
|
+
scanner = child_device(engine, ethernet, SCANNER)
|
|
232
|
+
pinned = {}
|
|
233
|
+
for device in tree["devices"]:
|
|
234
|
+
identity = import_device_description(engine, device, root)
|
|
235
|
+
adapter = child_device(engine, scanner, (device["name"], identity.type, identity.id, identity.version))
|
|
236
|
+
# CODESYS otherwise skips mapped variables no IEC code reads, such as inputs only clients observe.
|
|
237
|
+
adapter.driver_info.always_update_variables = engine.AlwaysUpdateVariablesMode.OnlyIfUnused
|
|
238
|
+
parameters = host_parameters(adapter)
|
|
239
|
+
if device.get("address"):
|
|
240
|
+
set_parameter(parameters[TARGET_ADDRESS], ip_array(device["address"], True))
|
|
241
|
+
if device.get("rpiMicroseconds"):
|
|
242
|
+
fields = [field for field in parameters[CONNECTION_SETTINGS] if field.identifier == "RPI"]
|
|
243
|
+
set_parameter(fields[0], str(device["rpiMicroseconds"]))
|
|
244
|
+
for address, channel in pin_channels(engine, adapter, parameters, device).items():
|
|
245
|
+
if address in pinned:
|
|
246
|
+
raise RuntimeError("{0} overlaps another adapter".format(address))
|
|
247
|
+
pinned[address] = channel
|
|
248
|
+
registers = [device for device in tree["devices"] if device.get("modbus")]
|
|
249
|
+
if registers:
|
|
250
|
+
progress("Generating Modbus TCP devices")
|
|
251
|
+
pinned.update(configure_modbus(engine, project, ethernet, registers))
|
|
252
|
+
# Adding a later adapter can reflow earlier automatic addresses.
|
|
253
|
+
settle(pinned)
|
|
254
|
+
map_channels(pinned, tree, project.active_application.get_name())
|
|
255
|
+
return pinned
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def verify_pinned(pinned):
|
|
259
|
+
for address, channel in pinned.items():
|
|
260
|
+
if channel.io_mapping.manual_iec_address != address:
|
|
261
|
+
raise RuntimeError("Channel {0} moved to {1}".format(address, channel.io_mapping.manual_iec_address))
|
package/python/test_build.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import hashlib
|
|
2
2
|
import json
|
|
3
|
+
import re
|
|
3
4
|
import tempfile
|
|
4
5
|
import unittest
|
|
5
6
|
from pathlib import Path
|
|
@@ -74,6 +75,19 @@ class Node:
|
|
|
74
75
|
return prop
|
|
75
76
|
|
|
76
77
|
|
|
78
|
+
def snapshot(node):
|
|
79
|
+
"""What a saved project keeps of an object tree."""
|
|
80
|
+
return dict(name=node.name, kind=node.kind, declaration=node.declaration, implementation=node.implementation,
|
|
81
|
+
children=[snapshot(child) for child in node.children])
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def restore(saved, parent=None):
|
|
85
|
+
node = Node(saved["name"], saved["kind"], parent)
|
|
86
|
+
node.declaration, node.implementation = saved["declaration"], saved["implementation"]
|
|
87
|
+
node.children = [restore(child, node) for child in saved["children"]]
|
|
88
|
+
return node
|
|
89
|
+
|
|
90
|
+
|
|
77
91
|
class Controller:
|
|
78
92
|
"""The template's controller; Update Device succeeds only for installed descriptions."""
|
|
79
93
|
is_device = True
|
|
@@ -109,25 +123,32 @@ class CompilerFixture:
|
|
|
109
123
|
system=SimpleNamespace(get_message_categories=lambda: ["compiler"], get_message_objects=lambda category, severity: [], clear_messages=Mock()),
|
|
110
124
|
)
|
|
111
125
|
def open(self, path, primary):
|
|
112
|
-
app = Node("Application", "application")
|
|
113
|
-
configuration = app.create_task_configuration()
|
|
114
|
-
configuration.create_task("MainTask")
|
|
115
|
-
background = configuration.create_task("BackgroundTask")
|
|
116
|
-
background.children.clear()
|
|
117
|
-
background.pous.add("Monitor")
|
|
118
|
-
app.create("Monitor", "program")
|
|
119
|
-
app.create("Symbols", "symbols")
|
|
120
|
-
app.create("PLC_PRG", "program")
|
|
121
126
|
controller = Controller(self.installed)
|
|
122
|
-
app.parent = controller
|
|
123
127
|
for name, task, call in self.device_tasks:
|
|
124
128
|
controller.devices.append(SimpleNamespace(is_device=True, get_name=lambda name=name: name))
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
129
|
+
saved = Path(path).read_text()
|
|
130
|
+
if saved.startswith("{"):
|
|
131
|
+
saved = json.loads(saved)
|
|
132
|
+
app = restore(saved["application"])
|
|
133
|
+
controller.identity = SimpleNamespace(**saved["controller"])
|
|
134
|
+
else:
|
|
135
|
+
app = Node("Application", "application")
|
|
136
|
+
configuration = app.create_task_configuration()
|
|
137
|
+
configuration.create_task("MainTask")
|
|
138
|
+
background = configuration.create_task("BackgroundTask")
|
|
139
|
+
background.children.clear()
|
|
140
|
+
background.pous.add("Monitor")
|
|
141
|
+
app.create("Monitor", "program")
|
|
142
|
+
app.create("Symbols", "symbols")
|
|
143
|
+
app.create("PLC_PRG", "program")
|
|
144
|
+
for name, task, call in self.device_tasks:
|
|
145
|
+
cycle = configuration.create_task(task)
|
|
146
|
+
cycle.children.clear()
|
|
147
|
+
cycle.create(call, "task_call")
|
|
148
|
+
app.parent = controller
|
|
128
149
|
project = SimpleNamespace(path=path, close=Mock(), active_application=app, controller=controller,
|
|
129
150
|
get_children=lambda: [controller])
|
|
130
|
-
def save(): Path(project.path).write_text(
|
|
151
|
+
def save(): Path(project.path).write_text(json.dumps(dict(application=snapshot(app), controller=vars(controller.identity))))
|
|
131
152
|
def save_as(destination):
|
|
132
153
|
project.path = destination
|
|
133
154
|
save()
|
|
@@ -242,7 +263,8 @@ class BuildTests(unittest.TestCase):
|
|
|
242
263
|
self.run_build(tasks=[dict(name="MainTask", intervalNanoseconds="20000000", priority=1, programs=["Main"])])
|
|
243
264
|
def test_reuses_the_loaded_project_for_text_changes(self):
|
|
244
265
|
self.run_build()
|
|
245
|
-
|
|
266
|
+
# Each request is read afresh, so a change never reaches the specs of the last build.
|
|
267
|
+
self.objects = [dict(self.objects[0], implementation="NewBody();")]
|
|
246
268
|
self.run_build()
|
|
247
269
|
self.assertEqual(len(self.compiler.opened), 1)
|
|
248
270
|
self.assertEqual(self.find("Main").implementation, "NewBody();")
|
|
@@ -339,6 +361,72 @@ class BuildTests(unittest.TestCase):
|
|
|
339
361
|
self.assertEqual(len(list((self.root / "device-tree").iterdir())), 1)
|
|
340
362
|
self.assertEqual(self.tree_build(moved)[0], 0)
|
|
341
363
|
|
|
364
|
+
def written(self, request):
|
|
365
|
+
log = (Path(request["receipt"]).parent / "progress.log").read_text()
|
|
366
|
+
return int(re.search(r"Wrote (\d+) source objects", log).group(1))
|
|
367
|
+
|
|
368
|
+
def test_a_later_container_writes_only_the_objects_that_changed(self):
|
|
369
|
+
self.objects.append(spec("function_block", "FB_Test", [spec("method", "Run", implementation="RETURN;")], implementation=""))
|
|
370
|
+
self.assertEqual(self.written(self.run_build()), 3)
|
|
371
|
+
self.loaded = {}
|
|
372
|
+
self.objects = [self.objects[0], dict(self.objects[1], children=[spec("method", "Run", implementation="Changed();")])]
|
|
373
|
+
self.assertEqual(self.written(self.run_build()), 1)
|
|
374
|
+
self.assertEqual(self.find("Run").implementation, "Changed();")
|
|
375
|
+
|
|
376
|
+
def test_a_later_container_removes_deleted_objects_and_creates_new_ones(self):
|
|
377
|
+
self.objects.append(spec("function", "F_Old", implementation="RETURN;", folder=["old"]))
|
|
378
|
+
self.run_build()
|
|
379
|
+
self.loaded = {}
|
|
380
|
+
self.objects = [self.objects[0], spec("function", "F_New", implementation="RETURN;")]
|
|
381
|
+
self.assertEqual(self.written(self.run_build()), 1)
|
|
382
|
+
app = self.loaded["project"].active_application
|
|
383
|
+
self.assertEqual(app.find_ignore_transient_objects("F_Old", True), [])
|
|
384
|
+
self.assertEqual(self.find("F_New").implementation, "RETURN;")
|
|
385
|
+
|
|
386
|
+
def test_a_later_container_removes_a_deleted_type_only_after_the_import_stops_using_it(self):
|
|
387
|
+
self.objects = [spec("dut", "T_Old", dutType="alias"), dict(self.objects[0], declaration="PROGRAM Main VAR x : T_Old; END_VAR")]
|
|
388
|
+
self.run_build()
|
|
389
|
+
self.loaded = {}
|
|
390
|
+
self.objects = [spec("dut", "T_New", dutType="alias"), dict(self.objects[1], declaration="PROGRAM Main VAR x : T_New; END_VAR")]
|
|
391
|
+
open_project = self.compiler.engine.projects.open
|
|
392
|
+
|
|
393
|
+
def open_refusing_types_in_use(path, primary):
|
|
394
|
+
project = open_project(path, primary)
|
|
395
|
+
app = project.active_application
|
|
396
|
+
old = app.find_ignore_transient_objects("T_Old", True)[0]
|
|
397
|
+
|
|
398
|
+
def remove():
|
|
399
|
+
# CODESYS refuses to remove a type the source still uses.
|
|
400
|
+
if "T_Old" in app.find_ignore_transient_objects("Main", True)[0].declaration:
|
|
401
|
+
raise TypeError("Value cannot be null.")
|
|
402
|
+
app.children.remove(old)
|
|
403
|
+
|
|
404
|
+
old.remove = remove
|
|
405
|
+
return project
|
|
406
|
+
|
|
407
|
+
self.compiler.engine.projects.open = open_refusing_types_in_use
|
|
408
|
+
self.run_build()
|
|
409
|
+
self.assertEqual(self.loaded["project"].active_application.find_ignore_transient_objects("T_Old", True), [])
|
|
410
|
+
|
|
411
|
+
def test_a_template_change_imports_everything_into_the_new_template(self):
|
|
412
|
+
self.objects.append(spec("gvl", "Globals"))
|
|
413
|
+
self.run_build()
|
|
414
|
+
self.loaded = {}
|
|
415
|
+
self.template.write_text("changed template")
|
|
416
|
+
self.assertEqual(self.written(self.run_build()), 2)
|
|
417
|
+
|
|
418
|
+
def test_a_failed_build_leaves_the_last_successful_one_to_reuse(self):
|
|
419
|
+
self.run_build()
|
|
420
|
+
self.loaded = {}
|
|
421
|
+
self.objects = [dict(self.objects[0], implementation="Broken(")]
|
|
422
|
+
self.compiler.fail_compile = True
|
|
423
|
+
with self.assertRaisesRegex(RuntimeError, "Fixture compiler error"):
|
|
424
|
+
self.run_build()
|
|
425
|
+
self.loaded, self.compiler.fail_compile = {}, False
|
|
426
|
+
self.objects = [dict(self.objects[0], implementation="Fixed();")]
|
|
427
|
+
self.assertEqual(self.written(self.run_build()), 1)
|
|
428
|
+
self.assertEqual(self.find("Main").implementation, "Fixed();")
|
|
429
|
+
|
|
342
430
|
def test_keeps_no_tree_from_a_failed_build(self):
|
|
343
431
|
tree = dict(devices=[dict(name="Io", eds="devices/Io.eds")])
|
|
344
432
|
self.compiler.fail_compile = True
|
|
@@ -452,7 +540,7 @@ class DeviceTreeTests(unittest.TestCase):
|
|
|
452
540
|
return dict((channel.id, channel) for channel in reversed(channels))
|
|
453
541
|
|
|
454
542
|
def test_pins_payload_bytes_at_their_offsets_and_labels_bound_io(self):
|
|
455
|
-
from
|
|
543
|
+
from hardware import map_channels, pin_channels, verify_pinned
|
|
456
544
|
parameters = self.parameters(3, 2)
|
|
457
545
|
# Automatic addresses that already follow the first channel stay automatic.
|
|
458
546
|
parameters[101].io_mapping.address = "%IB17"
|
|
@@ -480,7 +568,7 @@ class DeviceTreeTests(unittest.TestCase):
|
|
|
480
568
|
verify_pinned(pinned)
|
|
481
569
|
|
|
482
570
|
def test_rejects_typed_channels_and_unplaced_directions(self):
|
|
483
|
-
from
|
|
571
|
+
from hardware import pin_channels
|
|
484
572
|
with self.assertRaisesRegex(RuntimeError, "reader inputs are not 2 raw byte channels"):
|
|
485
573
|
pin_channels(self.engine, None, self.parameters(2, 0, 16), {"name": "reader", "inputs": {"address": "%IB0", "size": 2}})
|
|
486
574
|
with self.assertRaisesRegex(RuntimeError, "reader has outputs without a configured address"):
|
|
@@ -80,10 +80,12 @@ class OnlineLifecycleTests(unittest.TestCase):
|
|
|
80
80
|
def imported(*args):
|
|
81
81
|
self.events.append('import')
|
|
82
82
|
self.source_imported = True
|
|
83
|
+
# A download's template, with the channels its tree pinned and the tree to keep once it works.
|
|
84
|
+
self.template = (self.project, {}, None)
|
|
83
85
|
replacements = {
|
|
84
|
-
'
|
|
86
|
+
'open_template': Mock(side_effect=lambda *args: self.events.append('template') or self.template),
|
|
87
|
+
'keep_device_tree': Mock(),
|
|
85
88
|
'import_objects': Mock(side_effect=imported),
|
|
86
|
-
'configure_device_tree': Mock(side_effect=lambda *args: self.events.append('device tree') or {'%IB0': 'channel'}),
|
|
87
89
|
'verify_pinned': Mock(),
|
|
88
90
|
'core_application': Mock(return_value=self.core),
|
|
89
91
|
'session_login': Mock(),
|
|
@@ -91,7 +93,7 @@ class OnlineLifecycleTests(unittest.TestCase):
|
|
|
91
93
|
'controller_state': Mock(return_value='run'),
|
|
92
94
|
'require_current_boot_project': Mock(),
|
|
93
95
|
'load_downloaded_reference': Mock(return_value=self.ids),
|
|
94
|
-
'reconcile': Mock(),
|
|
96
|
+
'reconcile': Mock(return_value=[]),
|
|
95
97
|
'generate_online_code': Mock(),
|
|
96
98
|
'consolidated_manager': Mock(return_value=SimpleNamespace(GetCompileContext=Mock())),
|
|
97
99
|
'context_ids': Mock(return_value=self.ids),
|
|
@@ -102,7 +104,7 @@ class OnlineLifecycleTests(unittest.TestCase):
|
|
|
102
104
|
patcher.start()
|
|
103
105
|
self.addCleanup(patcher.stop)
|
|
104
106
|
self.request = {
|
|
105
|
-
'project': 'Application.project', 'operation': 'download', 'symbols': False,
|
|
107
|
+
'project': 'Application.project', 'operation': 'download', 'symbols': False, 'caches': 'C:\\controller',
|
|
106
108
|
'objects': [], 'removeObjects': ['Visualization Manager'],
|
|
107
109
|
'online': {'gateway': {'host': 'gateway', 'port': 1217},
|
|
108
110
|
'device': {'host': 'plc', 'port': 11740}},
|
|
@@ -110,7 +112,8 @@ class OnlineLifecycleTests(unittest.TestCase):
|
|
|
110
112
|
|
|
111
113
|
def test_download_rebuilds_imported_tree_before_online_login(self):
|
|
112
114
|
result = codesys_online.run_online(self.engine, self.request)
|
|
113
|
-
self.
|
|
115
|
+
self.assertIsNone(codesys_online.import_objects.call_args.args[3])
|
|
116
|
+
self.assertEqual(self.events, ['template', 'import', 'clean', 'compile', 'generate', 'online-wrapper'])
|
|
114
117
|
self.assertEqual(result['controllerIds'], self.ids)
|
|
115
118
|
self.core.CreateBootProject.assert_called_once()
|
|
116
119
|
self.boot_api.GetMethod.assert_called_once_with('CreateBootProject')
|
|
@@ -118,17 +121,22 @@ class OnlineLifecycleTests(unittest.TestCase):
|
|
|
118
121
|
self.project.close.assert_called_once()
|
|
119
122
|
self.app.build.assert_not_called()
|
|
120
123
|
|
|
121
|
-
def
|
|
124
|
+
def test_download_imports_into_the_templates_tree_verifies_its_channels_and_keeps_it(self):
|
|
122
125
|
self.request.update(deviceTree={'devices': []}, devices='C:\\request')
|
|
126
|
+
self.template = (self.project, {'%IB0': 'channel'}, ('pending', 'cache'))
|
|
123
127
|
codesys_online.run_online(self.engine, self.request)
|
|
124
|
-
self.assertEqual(self.events, ['
|
|
125
|
-
self.assertEqual(codesys_online.
|
|
128
|
+
self.assertEqual(self.events[:2], ['template', 'import'])
|
|
129
|
+
self.assertEqual(codesys_online.open_template.call_args.args[2:4], ('Application.project', 'C:\\request'))
|
|
130
|
+
self.assertEqual(codesys_online.open_template.call_args.args[4], 'C:\\controller')
|
|
126
131
|
codesys_online.verify_pinned.assert_called_once_with({'%IB0': 'channel'})
|
|
132
|
+
codesys_online.keep_device_tree.assert_called_once_with('pending', 'cache')
|
|
127
133
|
|
|
128
|
-
def
|
|
129
|
-
self.
|
|
130
|
-
|
|
131
|
-
self.
|
|
134
|
+
def test_failed_download_keeps_no_generated_tree(self):
|
|
135
|
+
self.template = (self.project, {}, ('pending', 'cache'))
|
|
136
|
+
self.app.rebuild.side_effect = RuntimeError('Invalid source declaration')
|
|
137
|
+
with self.assertRaisesRegex(RuntimeError, 'Invalid source declaration'):
|
|
138
|
+
codesys_online.run_online(self.engine, self.request)
|
|
139
|
+
codesys_online.keep_device_tree.assert_not_called()
|
|
132
140
|
|
|
133
141
|
def test_invalid_full_download_source_never_creates_online_wrapper_or_transfers(self):
|
|
134
142
|
self.app.rebuild.side_effect = RuntimeError('Invalid source declaration')
|
|
@@ -147,12 +155,23 @@ class OnlineLifecycleTests(unittest.TestCase):
|
|
|
147
155
|
})
|
|
148
156
|
result = codesys_online.run_online(self.engine, self.request)
|
|
149
157
|
self.assertEqual(self.events, ['online-wrapper', 'import', 'compile'])
|
|
158
|
+
codesys_online.open_template.assert_not_called()
|
|
159
|
+
# Only what changed since the baseline is written.
|
|
160
|
+
self.assertIs(codesys_online.import_objects.call_args.args[3], self.request['baseline']['objects'])
|
|
150
161
|
self.app.clean.assert_not_called()
|
|
151
162
|
self.app.rebuild.assert_not_called()
|
|
152
163
|
self.app.generate_code.assert_not_called()
|
|
153
164
|
self.core.Download.assert_called_once_with(True, 'UseProjectSetting')
|
|
154
165
|
self.assertEqual(result['state'], 'run')
|
|
155
166
|
|
|
167
|
+
def test_online_change_removes_deleted_objects_once_the_import_replaced_their_users(self):
|
|
168
|
+
self.request.update(operation='online-change', baseline={
|
|
169
|
+
'applicationGuid': self.app.guid, 'controllerIds': self.ids, 'objects': [],
|
|
170
|
+
})
|
|
171
|
+
codesys_online.reconcile.return_value = [SimpleNamespace(remove=lambda: self.events.append('remove'))]
|
|
172
|
+
codesys_online.run_online(self.engine, self.request)
|
|
173
|
+
self.assertEqual(self.events, ['online-wrapper', 'import', 'remove', 'compile'])
|
|
174
|
+
|
|
156
175
|
def test_full_download_rejects_library_errors_reported_after_transfer(self):
|
|
157
176
|
def report_library_error(core):
|
|
158
177
|
self.messages.append(SimpleNamespace(text="Could not open library '#VisuShared'."))
|