@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.
- package/dist/cli.js +13 -8
- package/dist/cli.js.map +4 -4
- package/dist/index.js +13 -8
- package/dist/index.js.map +4 -4
- package/package.json +3 -3
- package/python/build.py +84 -20
- package/python/codesys_online.py +5 -1
- package/python/test_build.py +52 -5
- package/python/test_codesys_online.py +9 -0
- package/src/build.ts +2 -13
- package/src/controller-online.ts +22 -6
- package/src/controller-session.ts +17 -5
- package/src/device-tree.ts +24 -0
package/python/test_build.py
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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(
|
package/src/controller-online.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
107
|
-
|
|
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
|
+
};
|