@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.
@@ -6,7 +6,8 @@ import json
6
6
  import os
7
7
  import re
8
8
  import time
9
- from build import CompilerError, apply_controller, configure_device_tree, fail_on_errors, import_objects, reported_errors, unique_child, verify_pinned
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('Opening project; processor count {0}'.format(Environment.ProcessorCount))
197
- project = engine.projects.open(request['project'], primary=True)
198
- # Online changes reuse the downloaded project, which already has this controller.
199
- if request['operation'] == 'download' and request.get('controller'):
200
- apply_controller(project, request['controller'], progress)
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
- if request['operation'] == 'online-change':
240
- reconcile(application, previous['objects'], request['objects'])
241
- import_objects(engine, application, request, request['operation'] != 'download', progress)
242
- # Only a download carries the tree: it starts from the template, and an online change keeps the downloaded one.
243
- pinned = configure_device_tree(engine, project, request['deviceTree'], request['devices'], progress) \
244
- if request.get('deviceTree') else {}
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))
@@ -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
- cycle = configuration.create_task(task)
126
- cycle.children.clear()
127
- cycle.create(call, "task_call")
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("compiled project")
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
- self.objects[0]["implementation"] = "NewBody();"
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 build import map_channels, pin_channels, verify_pinned
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 build import pin_channels
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
- 'apply_controller': Mock(side_effect=lambda *args: self.events.append('controller')),
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.assertEqual(self.events, ['import', 'clean', 'compile', 'generate', 'online-wrapper'])
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 test_download_generates_the_device_tree_after_importing_and_verifies_its_channels(self):
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, ['import', 'device tree', 'clean', 'compile', 'generate', 'online-wrapper'])
125
- self.assertEqual(codesys_online.configure_device_tree.call_args.args[2:4], ({'devices': []}, 'C:\\request'))
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 test_download_switches_the_template_controller_before_importing(self):
129
- self.request['controller'] = {'type': 4102, 'id': '0000 0005', 'version': '4.22.0.0'}
130
- codesys_online.run_online(self.engine, self.request)
131
- self.assertEqual(self.events[:2], ['controller', 'import'])
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'."))