@tsplc/build 0.2.2 → 0.2.3

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.
@@ -4,7 +4,7 @@ import tempfile
4
4
  import unittest
5
5
  from pathlib import Path
6
6
  from types import SimpleNamespace
7
- from unittest.mock import Mock
7
+ from unittest.mock import Mock, patch
8
8
  from decimal import Decimal
9
9
  from build import CompilerError, build, diagnostic
10
10
 
@@ -80,6 +80,9 @@ class Controller:
80
80
  def __init__(self, installed):
81
81
  self.installed = installed
82
82
  self.identity = SimpleNamespace(type=4096, id="0000 1008", version="4.22.0.0")
83
+ self.devices = []
84
+ def get_children(self, recursive):
85
+ return self.devices
83
86
  def get_device_identification(self):
84
87
  return self.identity
85
88
  def update(self, type, id, version):
@@ -94,6 +97,7 @@ class CompilerFixture:
94
97
  def __init__(self):
95
98
  self.opened = []
96
99
  self.installed = {(4102, "0000 0005", "4.22.0.0")}
100
+ self.device_tasks = []
97
101
  self.fail_compile = False
98
102
  self.empty_output = False
99
103
  self.engine = SimpleNamespace(
@@ -101,6 +105,7 @@ class CompilerFixture:
101
105
  DutType=SimpleNamespace(Structure="structure", Enumeration="enumeration", Union="union", Alias="alias"),
102
106
  Severity=SimpleNamespace(Error=1, FatalError=2),
103
107
  KindOfTask=SimpleNamespace(Cyclic=1),
108
+ ArchiveCategories=SimpleNamespace(devices="devices", libraries="libraries"),
104
109
  system=SimpleNamespace(get_message_categories=lambda: ["compiler"], get_message_objects=lambda category, severity: [], clear_messages=Mock()),
105
110
  )
106
111
  def open(self, path, primary):
@@ -114,6 +119,12 @@ class CompilerFixture:
114
119
  app.create("Symbols", "symbols")
115
120
  app.create("PLC_PRG", "program")
116
121
  controller = Controller(self.installed)
122
+ app.parent = controller
123
+ for name, task, call in self.device_tasks:
124
+ 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")
117
128
  project = SimpleNamespace(path=path, close=Mock(), active_application=app, controller=controller,
118
129
  get_children=lambda: [controller])
119
130
  def save(): Path(project.path).write_text("compiled project")
@@ -124,7 +135,7 @@ class CompilerFixture:
124
135
  if self.fail_compile: raise RuntimeError("Fixture compiler error")
125
136
  def export(destination, update, compact):
126
137
  Path(destination).write_text("" if self.empty_output else "boot application")
127
- project.save, project.save_as = save, save_as
138
+ project.save, project.save_as, project.save_archive = save, save_as, Mock()
128
139
  app.clean, app.rebuild, app.generate_code = Mock(), rebuild, Mock()
129
140
  app.create_boot_application = export
130
141
  self.opened.append(project)
@@ -142,6 +153,8 @@ class BuildTests(unittest.TestCase):
142
153
  self.root = Path(self.temp.name)
143
154
  self.template = self.root / "Template.project"
144
155
  self.template.write_text("original template")
156
+ self.worker = self.root / "worker"
157
+ self.worker.mkdir()
145
158
  self.compiler = CompilerFixture()
146
159
  self.loaded = {}
147
160
  self.counter = 0
@@ -157,7 +170,7 @@ class BuildTests(unittest.TestCase):
157
170
  return request
158
171
  def run_build(self, **overrides):
159
172
  request = self.request(**overrides)
160
- build(self.compiler.engine, request, self.loaded, str(self.root))
173
+ build(self.compiler.engine, request, self.loaded, str(self.worker))
161
174
  return request
162
175
  def find(self, name):
163
176
  return self.loaded["project"].active_application.find_ignore_transient_objects(name, True)[0]
@@ -177,6 +190,11 @@ class BuildTests(unittest.TestCase):
177
190
  self.assertEqual(task.calls, ["Second", "Main"])
178
191
  self.assertEqual(self.loaded["project"].active_application.find_ignore_transient_objects("BackgroundTask", True), [])
179
192
  self.assertEqual(json.loads(Path(request["receipt"]).read_text())["tasks"], tasks)
193
+ def test_explicit_tasks_keep_the_device_trees_own_tasks(self):
194
+ self.compiler.device_tasks = [("EtherNet_IP_Scanner", "ENIPScannerIOTask", "EtherNet_IP_Scanner.IOCycle")]
195
+ self.run_build(tasks=[dict(name="MainTask", intervalNanoseconds="10000000", priority=0, programs=["Main"])])
196
+ self.assertEqual(self.find("ENIPScannerIOTask").calls, ["EtherNet_IP_Scanner.IOCycle"])
197
+ self.assertEqual(self.loaded["project"].active_application.find_ignore_transient_objects("BackgroundTask", True), [])
180
198
  def test_creates_missing_tasks_and_reloads_for_schedule_changes(self):
181
199
  self.objects.append(spec("program", "Second", implementation="RETURN;"))
182
200
  tasks = [dict(name="FastTask", intervalNanoseconds="10000000", priority=1, programs=["Main"]),
@@ -235,7 +253,7 @@ class BuildTests(unittest.TestCase):
235
253
  self.assertEqual(len(self.compiler.opened), 2)
236
254
  self.assertEqual(self.compiler.opened[0].path, self.compiler.opened[1].path)
237
255
  self.compiler.opened[0].close.assert_called_once()
238
- self.assertEqual([p.name for p in self.root.glob("project*")], ["project"])
256
+ self.assertEqual([p.name for p in self.worker.glob("project*")], ["project"])
239
257
  def test_updates_the_template_controller_and_reloads_when_it_changes(self):
240
258
  self.run_build(controller=LINUX_SL)
241
259
  self.assertEqual(vars(self.loaded["project"].controller.identity), LINUX_SL)
@@ -281,7 +299,7 @@ class BuildTests(unittest.TestCase):
281
299
  self.compiler.fail_compile = True
282
300
  request = self.request()
283
301
  with self.assertRaisesRegex(RuntimeError, "Fixture compiler error"):
284
- build(self.compiler.engine, request, self.loaded, str(self.root))
302
+ build(self.compiler.engine, request, self.loaded, str(self.worker))
285
303
  self.assertEqual(self.loaded, {})
286
304
  self.assertFalse(Path(request["receipt"]).exists())
287
305
  self.compiler.opened[0].close.assert_called_once()
@@ -300,6 +318,35 @@ class BuildTests(unittest.TestCase):
300
318
  with self.assertRaisesRegex(RuntimeError, "Unresolved identifier"):
301
319
  self.run_build()
302
320
 
321
+ def tree_build(self, tree):
322
+ """A build in a new container, with the tree's generation and EDS imports recorded."""
323
+ self.loaded = {}
324
+ with patch("build.configure_device_tree", return_value={}) as generate, patch("build.import_device_description") as describe:
325
+ self.run_build(deviceTree=tree, archive="Application.projectarchive")
326
+ return generate.call_count, describe.call_count, self.compiler.opened[-1].path
327
+
328
+ def test_generates_a_device_tree_once_and_reuses_it_in_later_containers(self):
329
+ tree = dict(devices=[dict(name="Io", eds="devices/Io.eds", address="10.0.0.2")])
330
+ self.assertEqual(self.tree_build(tree)[:2], (1, 0))
331
+ generated, described, opened = self.tree_build(tree)
332
+ self.assertEqual((generated, described), (0, 1))
333
+ self.assertEqual(Path(opened), self.worker / "project" / "Application.project")
334
+
335
+ def test_regenerates_a_changed_tree_and_keeps_only_the_newest(self):
336
+ self.tree_build(dict(devices=[dict(name="Io", eds="devices/Io.eds", address="10.0.0.2")]))
337
+ moved = dict(devices=[dict(name="Io", eds="devices/Io.eds", address="10.0.0.3")])
338
+ self.assertEqual(self.tree_build(moved)[0], 1)
339
+ self.assertEqual(len(list((self.root / "device-tree").iterdir())), 1)
340
+ self.assertEqual(self.tree_build(moved)[0], 0)
341
+
342
+ def test_keeps_no_tree_from_a_failed_build(self):
343
+ tree = dict(devices=[dict(name="Io", eds="devices/Io.eds")])
344
+ self.compiler.fail_compile = True
345
+ with self.assertRaisesRegex(RuntimeError, "Fixture compiler error"):
346
+ self.tree_build(tree)
347
+ self.compiler.fail_compile = False
348
+ self.assertEqual(self.tree_build(tree)[0], 1)
349
+
303
350
  def test_preserves_compiler_position_metadata_without_decoding(self):
304
351
  project = SimpleNamespace(path="C:\\scratch\\Application.project", is_root=True, active_application=None)
305
352
  folder = SimpleNamespace(get_name=lambda: "Functions", is_root=False, parent=project)
@@ -83,6 +83,8 @@ class OnlineLifecycleTests(unittest.TestCase):
83
83
  replacements = {
84
84
  'apply_controller': Mock(side_effect=lambda *args: self.events.append('controller')),
85
85
  'import_objects': Mock(side_effect=imported),
86
+ 'configure_device_tree': Mock(side_effect=lambda *args: self.events.append('device tree') or {'%IB0': 'channel'}),
87
+ 'verify_pinned': Mock(),
86
88
  'core_application': Mock(return_value=self.core),
87
89
  'session_login': Mock(),
88
90
  'controller_ids': Mock(return_value=self.ids),
@@ -116,6 +118,13 @@ class OnlineLifecycleTests(unittest.TestCase):
116
118
  self.project.close.assert_called_once()
117
119
  self.app.build.assert_not_called()
118
120
 
121
+ def test_download_generates_the_device_tree_after_importing_and_verifies_its_channels(self):
122
+ self.request.update(deviceTree={'devices': []}, devices='C:\\request')
123
+ 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'))
126
+ codesys_online.verify_pinned.assert_called_once_with({'%IB0': 'channel'})
127
+
119
128
  def test_download_switches_the_template_controller_before_importing(self):
120
129
  self.request['controller'] = {'type': 4102, 'id': '0000 0005', 'version': '4.22.0.0'}
121
130
  codesys_online.run_online(self.engine, self.request)
package/src/build.ts CHANGED
@@ -4,6 +4,7 @@ import { resolve } from "node:path";
4
4
  import { buildFiles, publishBuild, readBuild } from "./artifacts";
5
5
  import { dockerImage, type ResolvedConfig } from "./config";
6
6
  import { buildInDocker } from "./docker-build";
7
+ import { writeDeviceTree } from "./device-tree";
7
8
  import { createCodesysWorker, withWorkerLock } from "./host/codesys-worker";
8
9
  import { loadSources } from "./sources";
9
10
  import { toWindowsPath } from "./host/windows-path.ts";
@@ -28,19 +29,7 @@ export const buildInContainer = async (config: ResolvedConfig) => {
28
29
  await mkdir(resolve(directory, "runtime"), { recursive: true });
29
30
  const template = resolve(directory, "Template.project");
30
31
  await copyFile(config.template, template);
31
- // CODESYS imports each adapter's EDS from the build directory.
32
- const deviceTree = config.deviceTree && {
33
- ...config.deviceTree,
34
- devices: await Promise.all(
35
- config.deviceTree.devices.map(async (device) => {
36
- const eds = `${buildFiles.devices}/${device.name}.eds`;
37
- await mkdir(resolve(directory, buildFiles.devices), { recursive: true });
38
- await writeFile(resolve(directory, eds), device.eds);
39
- // The digest keys project reuse on the description's content.
40
- return { ...device, eds, sha256: createHash("sha256").update(device.eds).digest("hex") };
41
- }),
42
- ),
43
- };
32
+ const deviceTree = config.deviceTree && (await writeDeviceTree(config.deviceTree, directory));
44
33
  const request = {
45
34
  libraryReferences: config.libraryReferences,
46
35
  libraries: await Promise.all(
@@ -28,6 +28,10 @@ export const controllerResultSchema = z.strictObject({
28
28
  gateway: endpointSchema,
29
29
  device: endpointSchema,
30
30
  controller: controllerSchema.optional(),
31
+ deviceTree: z
32
+ .string()
33
+ .regex(/^[a-f0-9]{64}$/)
34
+ .optional(),
31
35
  }),
32
36
  generation: z.string().nonempty(),
33
37
  application: z.string().nonempty(),
@@ -45,6 +49,23 @@ export type ControllerUpdateOptions = {
45
49
  read?: string[];
46
50
  };
47
51
 
52
+ /**
53
+ * What a download binds the controller to. An online change needs the same connection, controller
54
+ * and device tree, because it only recompiles the program into the downloaded project.
55
+ */
56
+ export const controllerTarget = (
57
+ config: ResolvedConfig,
58
+ online: NonNullable<ResolvedConfig["online"]>,
59
+ ) => ({
60
+ network: online.network,
61
+ gateway: online.gateway,
62
+ device: online.device,
63
+ controller: config.controller,
64
+ ...(config.deviceTree && {
65
+ deviceTree: createHash("sha256").update(JSON.stringify(config.deviceTree)).digest("hex"),
66
+ }),
67
+ });
68
+
48
69
  /** Checks the settings every engineering connection needs and names its builder image and target. */
49
70
  const controllerConnection = async (config: ResolvedConfig) => {
50
71
  if (!config.docker || !config.online) {
@@ -76,12 +97,7 @@ const controllerConnection = async (config: ResolvedConfig) => {
76
97
  templateHash: createHash("sha256")
77
98
  .update(await readFile(config.template))
78
99
  .digest("hex"),
79
- target: {
80
- network: config.online.network,
81
- gateway: config.online.gateway,
82
- device: config.online.device,
83
- controller: config.controller,
84
- },
100
+ target: controllerTarget(config, config.online),
85
101
  };
86
102
  };
87
103
 
@@ -3,13 +3,19 @@ import { copyFile, cp, mkdir, readdir, readFile, rm, writeFile } from "node:fs/p
3
3
  import { resolve } from "node:path";
4
4
  import { z } from "zod";
5
5
  import type { ResolvedConfig } from "./config";
6
- import { controllerResultSchema, explicitDownload, loginCommandSchema } from "./controller-online";
6
+ import {
7
+ controllerResultSchema,
8
+ controllerTarget,
9
+ explicitDownload,
10
+ loginCommandSchema,
11
+ } from "./controller-online";
7
12
  import {
8
13
  createCodesysWorker,
9
14
  withWorkerLock,
10
15
  withWorkerShutdown,
11
16
  writeJson,
12
17
  } from "./host/codesys-worker";
18
+ import { writeDeviceTree } from "./device-tree";
13
19
  import { toWindowsPath } from "./host/windows-path";
14
20
  import { loadSources } from "./sources";
15
21
 
@@ -74,7 +80,7 @@ const readBaseline = async (
74
80
  JSON.stringify(baseline.target) !== JSON.stringify(expected.target)
75
81
  ) {
76
82
  throw new Error(
77
- `Downloaded baseline uses a different image, template or controller; ${explicitDownload}`,
83
+ `Downloaded baseline uses a different image, template, controller or device tree; ${explicitDownload}`,
78
84
  );
79
85
  }
80
86
  const project = resolve(previous, "project");
@@ -103,9 +109,8 @@ export const runControllerSession = async (
103
109
  const resultPath = resolve(root, "result.json");
104
110
  await rm(resultPath, { force: true });
105
111
  const templateHash = sha256(await readFile(config.template));
106
- const { network, gateway, device } = config.online;
107
- // A different development controller needs a fresh download from the template.
108
- const target = { network, gateway, device, controller: config.controller };
112
+ // A different controller or device tree needs a fresh download from the template.
113
+ const target = controllerTarget(config, config.online);
109
114
  const previous =
110
115
  operation === "download"
111
116
  ? undefined
@@ -133,6 +138,13 @@ export const runControllerSession = async (
133
138
  await mkdir(requestDirectory, { recursive: true });
134
139
  const sources = operation === "inspect" ? undefined : await loadSources(config);
135
140
  const request = {
141
+ libraryReferences: config.libraryReferences,
142
+ // A download starts from the template; an online change keeps the downloaded project's tree.
143
+ ...(config.deviceTree &&
144
+ operation === "download" && {
145
+ deviceTree: await writeDeviceTree(config.deviceTree, requestDirectory),
146
+ devices: await toWindowsPath(requestDirectory, config.codesys.wine),
147
+ }),
136
148
  libraries: await Promise.all(
137
149
  (config.libraries ?? []).map(async (library) => ({
138
150
  ...library,
@@ -0,0 +1,24 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import { resolve } from "node:path";
4
+ import { buildFiles } from "./artifacts";
5
+ import type { ResolvedConfig } from "./config";
6
+
7
+ /** Writes each adapter's EDS into `directory`, where CODESYS imports it, and returns the tree the worker reads. */
8
+ export const writeDeviceTree = async (
9
+ deviceTree: NonNullable<ResolvedConfig["deviceTree"]>,
10
+ directory: string,
11
+ ) => {
12
+ await mkdir(resolve(directory, buildFiles.devices), { recursive: true });
13
+ return {
14
+ ...deviceTree,
15
+ devices: await Promise.all(
16
+ deviceTree.devices.map(async (device) => {
17
+ const eds = `${buildFiles.devices}/${device.name}.eds`;
18
+ await writeFile(resolve(directory, eds), device.eds);
19
+ // The digest keys project reuse on the description's content.
20
+ return { ...device, eds, sha256: createHash("sha256").update(device.eds).digest("hex") };
21
+ }),
22
+ ),
23
+ };
24
+ };