@tsplc/config 0.0.0-stage → 0.2.0

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/src/loader.ts ADDED
@@ -0,0 +1,20 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { dirname, resolve } from "node:path";
3
+ import { createJiti } from "jiti";
4
+ import { tsplcConfigSchema } from "./schema.ts";
5
+ export async function loadTsplcConfig(file = "tsplc.config.ts") {
6
+ const configFile = resolve(file);
7
+ await readFile(configFile);
8
+ const jiti = createJiti(import.meta.url, {
9
+ moduleCache: false,
10
+ fsCache: false,
11
+ interopDefault: false,
12
+ // Bun can import TS natively, but its module cache would hide config edits.
13
+ tryNative: false,
14
+ });
15
+ const module = await jiti.import<{ default?: unknown; config?: unknown }>(configFile);
16
+ const config = tsplcConfigSchema.parse(module.default ?? module.config);
17
+ if (config.compiler && !config.tasks)
18
+ throw new Error("TypeScript programs require a tasks configuration.");
19
+ return { configFile, root: dirname(configFile), config };
20
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,408 @@
1
+ import { z } from "zod";
2
+ import {
3
+ configSchema,
4
+ controllerDevices,
5
+ controllerTargetSchema,
6
+ cyclicTasksSchema,
7
+ directIoBindingSchema,
8
+ ioBindingOptions,
9
+ ethernetSchema,
10
+ type CodesysBuildConfig,
11
+ } from "./build-schema.ts";
12
+ import {
13
+ durationSchema,
14
+ validateConfig as validateCompilerConfig,
15
+ type PlcConfig,
16
+ } from "./compiler.ts";
17
+
18
+ export {
19
+ ns,
20
+ ms,
21
+ seconds,
22
+ definePlcConfig,
23
+ validateConfig,
24
+ type Duration,
25
+ type NormalizedConfig,
26
+ type PlcConfig,
27
+ type SourceExport,
28
+ type TargetDescriptor,
29
+ type TaskConfig,
30
+ } from "./compiler.ts";
31
+ export type { CodesysBuildConfig, Controller, DeviceTree } from "./build-schema.ts";
32
+
33
+ export const httpOptionsSchema = z
34
+ .strictObject({
35
+ timeoutMs: z.number().int().positive().max(60000).default(2000),
36
+ credentials: z
37
+ .array(
38
+ z.strictObject({
39
+ originEnv: z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/),
40
+ bearerTokenEnv: z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/),
41
+ }),
42
+ )
43
+ .default([]),
44
+ })
45
+ .default({ timeoutMs: 2000, credentials: [] });
46
+ export type HttpOptions = z.infer<typeof httpOptionsSchema>;
47
+
48
+ const ioBindingSchema = z.union([
49
+ directIoBindingSchema,
50
+ z.strictObject({
51
+ device: z.string().min(1),
52
+ channel: z.string().min(1),
53
+ ...ioBindingOptions,
54
+ }),
55
+ ]);
56
+ // Each field defaults: the assembly from the connection, the size from the EDS and,
57
+ // with a generated device tree, the address from tsPLC's process-image layout.
58
+ const assemblySchema = z.strictObject({
59
+ assembly: z.number().int().nonnegative().optional(),
60
+ size: z.number().int().positive().optional(),
61
+ address: z
62
+ .string()
63
+ .regex(/^%[IQ]B\d+$/)
64
+ .optional(),
65
+ });
66
+ const hardwareDeviceSchema = z.strictObject({
67
+ file: z.string().min(1),
68
+ connection: z.string().min(1).optional(),
69
+ inputs: assemblySchema.optional(),
70
+ outputs: assemblySchema.optional(),
71
+ // Registers the generated device tree writes over Modbus TCP, at network.address.
72
+ // Each maps a name to its wire offset, and is written with FC06 when its write channel rises.
73
+ modbus: z
74
+ .strictObject({
75
+ port: z.number().int().min(1).max(65535).default(502),
76
+ registers: z.record(
77
+ z.string().regex(/^[A-Za-z][A-Za-z0-9_]*$/),
78
+ z.number().int().min(0).max(65535),
79
+ ),
80
+ })
81
+ .optional(),
82
+ statusSymbols: z.record(z.string(), z.string()).optional(),
83
+ // The adapter's network identity in a generated device tree.
84
+ network: z
85
+ .strictObject({
86
+ address: z.ipv4().optional(),
87
+ rpi: durationSchema
88
+ .refine(
89
+ (rpi) => BigInt(rpi.nanoseconds) % 1000n === 0n,
90
+ "An RPI must be a whole number of microseconds.",
91
+ )
92
+ .optional(),
93
+ })
94
+ .optional(),
95
+ });
96
+ const dependencySchema = z.union([
97
+ z.string().regex(/^\d+\.\d+\.\d+\.\d+$/),
98
+ z.strictObject({
99
+ version: z.string().regex(/^\d+\.\d+\.\d+\.\d+$/),
100
+ company: z.string().optional(),
101
+ namespace: z.string().optional(),
102
+ }),
103
+ z.strictObject({ path: z.string().min(1), namespace: z.string().optional() }),
104
+ ]);
105
+ const taskSchema = z.strictObject({
106
+ name: z.string().regex(/^[A-Za-z][A-Za-z0-9_]*$/),
107
+ schedule: z.strictObject({ kind: z.literal("cyclic"), interval: durationSchema }),
108
+ priority: z.number().int().min(0).max(31),
109
+ programs: z.array(z.string().min(1)).nonempty(),
110
+ });
111
+ const monitorSchema = z
112
+ .strictObject({
113
+ endpoint: z.string().url(),
114
+ namespaceUri: z.string().optional(),
115
+ symbols: z.array(z.string().min(1)).optional(),
116
+ securityMode: z.enum(["None", "Sign", "SignAndEncrypt"]).default("SignAndEncrypt"),
117
+ securityPolicy: z.enum(["None", "Basic256Sha256"]).optional(),
118
+ certificateDirectory: z.string().min(1).default(".tsplc/opcua-pki"),
119
+ username: z.string().min(1).optional(),
120
+ passwordEnv: z
121
+ .string()
122
+ .regex(/^[A-Za-z_][A-Za-z_0-9]*$/)
123
+ .optional(),
124
+ })
125
+ .superRefine((monitor, context) => {
126
+ if (
127
+ monitor.securityPolicy &&
128
+ (monitor.securityMode === "None") !== (monitor.securityPolicy === "None")
129
+ )
130
+ context.addIssue({
131
+ code: "custom",
132
+ path: ["securityPolicy"],
133
+ message: "None mode requires None policy; secure modes require Basic256Sha256.",
134
+ });
135
+ if (monitor.passwordEnv && !monitor.username)
136
+ context.addIssue({
137
+ code: "custom",
138
+ path: ["username"],
139
+ message: "Configure username when passwordEnv is used.",
140
+ });
141
+ if (monitor.username && monitor.securityMode === "None")
142
+ context.addIssue({
143
+ code: "custom",
144
+ path: ["securityMode"],
145
+ message: "Username authentication requires a secure OPC-UA connection.",
146
+ });
147
+ })
148
+ .transform((monitor) => ({
149
+ ...monitor,
150
+ securityPolicy:
151
+ monitor.securityPolicy ??
152
+ (monitor.securityMode === "None" ? ("None" as const) : ("Basic256Sha256" as const)),
153
+ }));
154
+ export const typegenSchema = z.strictObject({
155
+ types: z.union([z.string(), z.array(z.string())]).optional(),
156
+ globals: z.union([z.string(), z.array(z.string())]).optional(),
157
+ /** Directory for the generated `plc.ts`, `hardware.ts` and `machines.ts`. */
158
+ dir: z.string().default("generated"),
159
+ /** Emit TypeScript declarations in `plc.ts`, not just the runtime field and symbol tables. */
160
+ declarations: z.boolean().default(true),
161
+ /** Emit the I/O binding types and device catalog in `hardware.ts`, not just the publication selectors. */
162
+ ioTypes: z.boolean().default(true),
163
+ });
164
+ export const buildTargetSchema = z.enum(["production", "development"]);
165
+ export type BuildTarget = z.infer<typeof buildTargetSchema>;
166
+ export const publicationSchema = z.strictObject({
167
+ production: z.array(z.string().nonempty()),
168
+ development: z.array(z.string().nonempty()),
169
+ });
170
+ export type Publication = Readonly<Record<BuildTarget, readonly string[]>>;
171
+ /** A published PLC variable, as `Global.Field`. */
172
+ const hmiTarget = z.string().regex(/^\w+(\.\w+|\[\d+\])+$/);
173
+ const hmiControlSchema = z.discriminatedUnion("kind", [
174
+ /** Pulses a BOOL on for 200 ms, like a pushbutton. */
175
+ z.strictObject({
176
+ kind: z.literal("button"),
177
+ label: z.string().nonempty(),
178
+ target: hmiTarget,
179
+ tone: z.enum(["start", "stop"]).optional(),
180
+ /** Ask before pressing, for controls that start motion. */
181
+ confirm: z.boolean().optional(),
182
+ }),
183
+ /** Switches a BOOL on or off, and shows its current state. */
184
+ z.strictObject({
185
+ kind: z.literal("toggle"),
186
+ label: z.string().nonempty(),
187
+ target: hmiTarget,
188
+ }),
189
+ /** Writes an integer when released, and shows its current value. */
190
+ z.strictObject({
191
+ kind: z.literal("slider"),
192
+ label: z.string().nonempty(),
193
+ target: hmiTarget,
194
+ min: z.number().int(),
195
+ max: z.number().int(),
196
+ }),
197
+ ]);
198
+ export const hmiSchema = z.strictObject({
199
+ title: z.string().nonempty(),
200
+ controls: z.array(hmiControlSchema).default([]),
201
+ /** The PLC reports whether each position of these conveyors is occupied, in this order. */
202
+ conveyors: z
203
+ .array(
204
+ z.strictObject({
205
+ label: z.string().nonempty(),
206
+ positions: z.number().int().positive(),
207
+ /** Positions that are diverters, drawn differently. */
208
+ diverters: z.array(z.number().int().nonnegative()).default([]),
209
+ }),
210
+ )
211
+ .default([]),
212
+ });
213
+ export type HmiConfig = z.input<typeof hmiSchema>;
214
+ export type ResolvedHmiConfig = z.output<typeof hmiSchema>;
215
+
216
+ export const tsplcConfigSchema = z.strictObject({
217
+ name: z.string().default("Application"),
218
+ backend: z.literal("codesys"),
219
+ publish: publicationSchema.optional(),
220
+ server: z
221
+ .strictObject({
222
+ baseUrlEnv: z.string().optional(),
223
+ transport: z.enum(["http", "opcua"]).default("http"),
224
+ runtime: z
225
+ .strictObject({
226
+ entry: z.string(),
227
+ target: z
228
+ .enum([
229
+ "bun-linux-x64",
230
+ "bun-linux-arm64",
231
+ "bun-darwin-x64",
232
+ "bun-darwin-arm64",
233
+ "bun-windows-x64",
234
+ ])
235
+ .optional(),
236
+ })
237
+ .optional(),
238
+ })
239
+ .optional(),
240
+ compiler: z
241
+ .strictObject({
242
+ tsconfig: z.string().default("tsconfig.json"),
243
+ programs: z.record(z.string(), z.strictObject({ module: z.string(), export: z.string() })),
244
+ })
245
+ .optional(),
246
+ tasks: z.array(taskSchema).nonempty().optional(),
247
+ // Keep an authored legacy task distinguishable from resolveConfig's default.
248
+ // An explicit shared schedule must have a single owner.
249
+ build: configSchema.extend({ task: configSchema.shape.task.unwrap().optional() }).optional(),
250
+ codesys: z
251
+ .strictObject({
252
+ http: httpOptionsSchema.optional(),
253
+ programNames: z.record(z.string(), z.string()).optional(),
254
+ // Generate an EtherNet/IP scanner with one adapter per entry in devices.
255
+ deviceTree: z.strictObject({ ethernet: ethernetSchema.optional() }).optional(),
256
+ // Each omitted controller keeps the template's device. See controllerFor.
257
+ controllers: z
258
+ .strictObject({
259
+ production: controllerTargetSchema.optional(),
260
+ development: controllerTargetSchema.optional(),
261
+ })
262
+ .optional(),
263
+ })
264
+ .optional(),
265
+ eds: z.union([z.string(), z.array(z.string())]).default("eds"),
266
+ devices: z.record(z.string(), hardwareDeviceSchema).default({}),
267
+ io: z.record(z.string(), ioBindingSchema).default({}),
268
+ dependencies: z.record(z.string(), dependencySchema).default({}),
269
+ // Raw ST builds opt into PLC contract generation by configuring this section.
270
+ typegen: typegenSchema.optional(),
271
+ monitor: monitorSchema.optional(),
272
+ hmi: hmiSchema.optional(),
273
+ });
274
+ export type TsplcConfig = z.input<typeof tsplcConfigSchema>;
275
+ export type ResolvedTsplcConfig = z.output<typeof tsplcConfigSchema>;
276
+ export type HardwareDevice = z.infer<typeof hardwareDeviceSchema>;
277
+ export type LibraryDependency = z.infer<typeof dependencySchema>;
278
+
279
+ /**
280
+ * `build` compiles for the production controller. `run` and controller
281
+ * operations target the local runtime, so they compile for the development one.
282
+ */
283
+ export const buildTargetFor = (command: string): BuildTarget =>
284
+ ["run", "download", "online-change", "inspect"].includes(command) ? "development" : "production";
285
+ export const controllerFor = (config: ResolvedTsplcConfig, command: string) => {
286
+ const controller = config.codesys?.controllers?.[buildTargetFor(command)];
287
+ return controller && { ...controllerDevices[controller.target], version: controller.version };
288
+ };
289
+
290
+ /**
291
+ * The project without fieldbus I/O, for a plant simulator that writes inputs over
292
+ * OPC UA: fieldbus input copies would overwrite those writes every scan.
293
+ */
294
+ export const simulationConfig = (config: ResolvedTsplcConfig): ResolvedTsplcConfig => ({
295
+ ...config,
296
+ eds: [],
297
+ devices: {},
298
+ io: {},
299
+ ...(config.codesys && {
300
+ codesys: {
301
+ ...config.codesys,
302
+ deviceTree: undefined,
303
+ },
304
+ }),
305
+ });
306
+
307
+ /** All paths are relative to this file; no package has a second project config. */
308
+ export function defineConfig<const C extends TsplcConfig>(config: C): C {
309
+ return config;
310
+ }
311
+
312
+ /** Raw ST program names already are IEC names; use the shared schedule directly. */
313
+ export function rawBuildConfig(config: ResolvedTsplcConfig): CodesysBuildConfig {
314
+ if (config.compiler) throw new Error("Use the compiler build plan for TypeScript projects.");
315
+ if (!config.build) throw new Error("Configure build settings.");
316
+ if (!config.tasks) return config.build;
317
+ if (config.build.tasks || config.build.task !== undefined)
318
+ throw new Error("Configure the raw ST schedule in tasks or build.tasks/build.task, not both.");
319
+ return {
320
+ ...config.build,
321
+ tasks: cyclicTasksSchema.parse(
322
+ config.tasks.map((task) => ({
323
+ name: task.name,
324
+ intervalNanoseconds: task.schedule.interval.nanoseconds,
325
+ priority: task.priority,
326
+ programs: task.programs,
327
+ })),
328
+ ),
329
+ };
330
+ }
331
+
332
+ /** Adapt the shared project to the compiler's backend-independent scan model. */
333
+ export function compilerConfig(
334
+ config: ResolvedTsplcConfig,
335
+ io: Record<string, { address: string; type?: string }>,
336
+ buildTarget: BuildTarget = "production",
337
+ ) {
338
+ if (!config.compiler || !config.tasks)
339
+ throw new Error("This project uses raw Structured Text; no TypeScript compiler is configured.");
340
+ // The device tree and controllers are build-time engineering; the compiler only sees addresses.
341
+ const { deviceTree: _deviceTree, controllers: _controllers, ...codesys } = config.codesys ?? {};
342
+ const value: PlcConfig = {
343
+ name: config.name,
344
+ tsconfig: config.compiler.tsconfig,
345
+ programs: config.compiler.programs,
346
+ tasks: config.tasks,
347
+ target: {
348
+ kind: config.backend,
349
+ options: {
350
+ ...codesys,
351
+ publish: config.publish,
352
+ buildTarget,
353
+ io: Object.fromEntries(
354
+ Object.entries(io).map(([name, binding]) => [
355
+ name,
356
+ {
357
+ kind: "direct",
358
+ ...binding,
359
+ ...(mappedChannel(config, name, binding) && { mapped: true }),
360
+ },
361
+ ]),
362
+ ),
363
+ engineering: {
364
+ kind: "v3.5",
365
+ ...(config.build ? { build: compilerBuildOptions(config.build) } : {}),
366
+ },
367
+ },
368
+ },
369
+ };
370
+ const validated = validateCompilerConfig(value);
371
+ if (!validated.config)
372
+ throw new Error(validated.diagnostics.map((item) => item.message).join("\n"));
373
+ return validated.config;
374
+ }
375
+
376
+ /**
377
+ * A generated device tree maps a device's bit or byte scalar to its variable in the
378
+ * I/O mapping. Wider values span several byte channels and keep an AT declaration.
379
+ */
380
+ function mappedChannel(
381
+ config: ResolvedTsplcConfig,
382
+ name: string,
383
+ binding: { address: string; type?: string; inverted?: boolean; shared?: boolean },
384
+ ) {
385
+ const source = config.io[name];
386
+ return (
387
+ config.codesys?.deviceTree !== undefined &&
388
+ source !== undefined &&
389
+ "device" in source &&
390
+ // A channel maps to one variable as it stands, so these need an AT wire and a copy.
391
+ !binding.inverted &&
392
+ !binding.shared &&
393
+ /^%[IQ][XB]/.test(binding.address) &&
394
+ !/^ARRAY/i.test(binding.type ?? "")
395
+ );
396
+ }
397
+
398
+ function compilerBuildOptions(build: CodesysBuildConfig) {
399
+ const {
400
+ sourceDir: _source,
401
+ entry: _entry,
402
+ outDir: _out,
403
+ task: _task,
404
+ tasks: _tasks,
405
+ ...settings
406
+ } = build;
407
+ return settings;
408
+ }
@@ -0,0 +1,17 @@
1
+ # Default engineering scaffold
2
+
3
+ `default.project` is the SDK-owned CODESYS Virtual Control for Linux ARM64
4
+ 4.22.0.0 scaffold, saved with CODESYS V3.5 SP22 Patch 3. It contains the controller,
5
+ Application, Library Manager and a 20 ms MainTask with an empty `PLC_PRG`.
6
+ Library references include Standard, Util 3.5.22.0 (System), and device-provided
7
+ placeholders. The compiler’s “UTF8 Encoding for STRING” option is enabled, matching
8
+ tsPLC’s STRING contract and OPC UA encoding. No gateway or runtime credentials are selected.
9
+
10
+ `provenance.json` pins the project hash. Builds copy the file into their private
11
+ workspace and remove `PLC_PRG` automatically. Generated task definitions,
12
+ controller selection, dependencies and hardware configuration supply the
13
+ application-specific settings. The installed builder image supplies the vendor
14
+ libraries and device descriptions.
15
+
16
+ Applications may override `build.template` for custom engineering projects.
17
+ Those keep their objects unless explicitly removed or replaced during import.
Binary file
@@ -0,0 +1,8 @@
1
+ {
2
+ "project": "default.project",
3
+ "sha256": "41eae36376ecbb7556b124a1e3b5f975e2fce23703d1fc2d6421c9bc95378155",
4
+ "codesys": "CODESYS V3.5 SP22 Patch 3",
5
+ "target": "CODESYS Virtual Control for Linux ARM64 4.22.0.0",
6
+ "addedLibrary": "Util, 3.5.22.0 (System)",
7
+ "stringEncoding": "UTF-8"
8
+ }