@tsplc/opcua 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.
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Reads and writes PLC symbols over an OPC UA session.
3
+ *
4
+ * Every symbol's IEC type comes from the generated contract. Reads turn OPC UA
5
+ * values into `Reading`s and mark anything unexpected as bad quality; writes
6
+ * validate the whole patch before sending, so a bad value never reaches the PLC.
7
+ */
8
+ import type { Reading, Variable } from "./types";
9
+ import {
10
+ AttributeIds,
11
+ DataType,
12
+ type DataValue,
13
+ type ReadValueIdOptions,
14
+ type StatusCode,
15
+ VariantArrayType,
16
+ type WriteValueOptions,
17
+ } from "./native";
18
+
19
+ interface ReadSession {
20
+ read: (nodes: Array<ReadValueIdOptions>, maxAge: number) => Promise<Array<DataValue>>;
21
+ }
22
+
23
+ export interface VariableSession extends ReadSession {
24
+ write: (nodes: Array<WriteValueOptions>) => Promise<Array<StatusCode>>;
25
+ }
26
+
27
+ /** Nodes per OPC UA read or write request. */
28
+ const BATCH_SIZE = 50;
29
+
30
+ // --- IEC type mapping -------------------------------------------------------
31
+
32
+ const isInteger =
33
+ (min: number, max: number) =>
34
+ (value: unknown): value is number =>
35
+ typeof value === "number" && Number.isInteger(value) && value >= min && value <= max;
36
+
37
+ /** The OPC UA type each published scalar arrives as, and the values it accepts. */
38
+ const scalarTypes: Record<string, { dataType: DataType; accepts: (value: unknown) => boolean }> = {
39
+ BOOL: {
40
+ accepts: (value) => typeof value === "boolean",
41
+ dataType: DataType.Boolean,
42
+ },
43
+ BYTE: { accepts: isInteger(0, 255), dataType: DataType.Byte },
44
+ DINT: {
45
+ accepts: isInteger(-2_147_483_648, 2_147_483_647),
46
+ dataType: DataType.Int32,
47
+ },
48
+ DWORD: { accepts: isInteger(0, 4_294_967_295), dataType: DataType.UInt32 },
49
+ INT: { accepts: isInteger(-32_768, 32_767), dataType: DataType.Int16 },
50
+ LREAL: {
51
+ accepts: (value) => typeof value === "number" && Number.isFinite(value),
52
+ dataType: DataType.Double,
53
+ },
54
+ REAL: {
55
+ accepts: (value) => typeof value === "number" && Number.isFinite(value),
56
+ dataType: DataType.Float,
57
+ },
58
+ SINT: { accepts: isInteger(-128, 127), dataType: DataType.SByte },
59
+ UDINT: { accepts: isInteger(0, 4_294_967_295), dataType: DataType.UInt32 },
60
+ UINT: { accepts: isInteger(0, 65_535), dataType: DataType.UInt16 },
61
+ USINT: { accepts: isInteger(0, 255), dataType: DataType.Byte },
62
+ WORD: { accepts: isInteger(0, 65_535), dataType: DataType.UInt16 },
63
+ };
64
+
65
+ const scalarTypeFor = (type: string) => {
66
+ const capacity = /^STRING\((\d+)\)$/.exec(type)?.[1];
67
+ if (capacity === undefined) {
68
+ return scalarTypes[type];
69
+ }
70
+
71
+ return {
72
+ accepts: (value: unknown) =>
73
+ typeof value === "string" &&
74
+ !value.includes("\0") &&
75
+ Buffer.byteLength(value, "utf8") <= Number(capacity),
76
+ dataType: DataType.String,
77
+ };
78
+ };
79
+
80
+ /** Length of an `ARRAY[0..n] OF BYTE` scanner buffer, or null for any other type. */
81
+ const byteBufferLength = (type: string) => {
82
+ const match = /^ARRAY\[0\.\.(\d+)\] OF BYTE$/.exec(type);
83
+ return match ? Number(match[1]) + 1 : null;
84
+ };
85
+
86
+ const byteArrayValue = (value: unknown, length: number): Array<number> | null => {
87
+ const bytes: unknown = value instanceof Uint8Array ? Array.from(value) : value;
88
+ if (!Array.isArray(bytes) || bytes.length !== length) {
89
+ return null;
90
+ }
91
+ return bytes.every(isInteger(0, 255)) ? bytes : null;
92
+ };
93
+
94
+ // --- Reads ------------------------------------------------------------------
95
+
96
+ const badReading = (status: string): Reading => ({
97
+ quality: "bad",
98
+ status,
99
+ value: null,
100
+ });
101
+
102
+ const toReading = (variable: Variable, data: DataValue): Reading => {
103
+ if (!data.statusCode.isGood()) {
104
+ return badReading(data.statusCode.toString());
105
+ }
106
+
107
+ const value: unknown = data.value.value;
108
+ const bufferLength = byteBufferLength(variable.type);
109
+ if (bufferLength !== null) {
110
+ const bytes = byteArrayValue(value, bufferLength);
111
+ // CODESYS may publish a byte buffer as Byte[] or as a scalar ByteString.
112
+ const typeMatches =
113
+ (data.value.dataType === DataType.Byte && data.value.arrayType === VariantArrayType.Array) ||
114
+ (data.value.dataType === DataType.ByteString &&
115
+ data.value.arrayType === VariantArrayType.Scalar);
116
+ if (!typeMatches || !bytes) {
117
+ return badReading("Unexpected OPC UA byte buffer type or length");
118
+ }
119
+ return {
120
+ quality: "good",
121
+ status: data.statusCode.toString(),
122
+ value: bytes,
123
+ };
124
+ }
125
+
126
+ const scalar =
127
+ typeof value === "boolean" ||
128
+ typeof value === "string" ||
129
+ (typeof value === "number" && Number.isFinite(value));
130
+ if (
131
+ !scalar ||
132
+ data.value.dataType !== scalarTypeFor(variable.type)?.dataType ||
133
+ data.value.arrayType !== VariantArrayType.Scalar
134
+ ) {
135
+ return badReading("Unexpected OPC UA data type");
136
+ }
137
+ return { quality: "good", status: data.statusCode.toString(), value };
138
+ };
139
+
140
+ /** Reads `selected` in batches; symbols without a published node are reported unavailable. */
141
+ export const readVariables = async (
142
+ session: ReadSession,
143
+ nodes: ReadonlyMap<string, string>,
144
+ selected: ReadonlyArray<Variable>,
145
+ ): Promise<Record<string, Reading>> => {
146
+ const readings: Record<string, Reading> = Object.fromEntries(
147
+ selected.map(({ path }) => [
148
+ path,
149
+ { quality: "unavailable", status: "Symbol not published", value: null },
150
+ ]),
151
+ );
152
+ const bound = selected.flatMap((variable) => {
153
+ const nodeId = nodes.get(variable.path);
154
+ return nodeId ? [{ nodeId, variable }] : [];
155
+ });
156
+
157
+ const batches = Array.from({ length: Math.ceil(bound.length / BATCH_SIZE) }, (_, index) =>
158
+ bound.slice(index * BATCH_SIZE, (index + 1) * BATCH_SIZE),
159
+ );
160
+ // Batches go out together so a large read costs about one round trip.
161
+ await Promise.all(
162
+ batches.map(async (batch) => {
163
+ const values = await session.read(
164
+ batch.map(({ nodeId }) => ({ attributeId: AttributeIds.Value, nodeId })),
165
+ 0,
166
+ );
167
+ if (values.length !== batch.length) {
168
+ throw new Error("Incomplete OPC UA read response");
169
+ }
170
+ for (const [index, { variable }] of batch.entries()) {
171
+ const data = values[index];
172
+ if (!data) {
173
+ throw new Error("Missing OPC UA value in read response");
174
+ }
175
+ readings[variable.path] = toReading(variable, data);
176
+ }
177
+ }),
178
+ );
179
+ return readings;
180
+ };
181
+
182
+ // --- Writes -----------------------------------------------------------------
183
+
184
+ export interface WriteResult {
185
+ path: string;
186
+ status: string;
187
+ /** null means the request outcome is unknown after a transport failure. */
188
+ written: boolean | null;
189
+ }
190
+
191
+ /** Carries every field's outcome so the caller can see what reached the PLC before retrying. */
192
+ export class VariableWriteError extends Error {
193
+ readonly results: ReadonlyArray<WriteResult>;
194
+ constructor(results: ReadonlyArray<WriteResult>, cause?: unknown) {
195
+ super("PLC write did not succeed for every field; inspect results before retrying", { cause });
196
+ this.name = "VariableWriteError";
197
+ this.results = results;
198
+ }
199
+ }
200
+
201
+ const notAttempted = (updates: ReadonlyArray<{ variable: Variable }>) =>
202
+ updates.map(({ variable }) => ({
203
+ path: variable.path,
204
+ status: "Not attempted",
205
+ written: false,
206
+ }));
207
+
208
+ /** Validates the value and builds its write request; byte buffers need the node's current representation. */
209
+ const prepareWrite = (
210
+ session: ReadSession,
211
+ nodes: ReadonlyMap<string, string>,
212
+ { variable, value }: { variable: Variable; value: unknown },
213
+ ) => {
214
+ const nodeId = nodes.get(variable.path);
215
+ if (!nodeId) {
216
+ throw new Error(`Symbol not published: ${variable.path}`);
217
+ }
218
+ const invalid = new Error(`Invalid ${variable.type} value for ${variable.path}`);
219
+
220
+ const bufferLength = byteBufferLength(variable.type);
221
+ if (bufferLength === null) {
222
+ const scalarType = scalarTypeFor(variable.type);
223
+ if (!scalarType?.accepts(value)) {
224
+ throw invalid;
225
+ }
226
+ const request: WriteValueOptions = {
227
+ attributeId: AttributeIds.Value,
228
+ nodeId,
229
+ value: {
230
+ value: {
231
+ arrayType: VariantArrayType.Scalar,
232
+ dataType: scalarType.dataType,
233
+ value,
234
+ },
235
+ },
236
+ };
237
+ return () => Promise.resolve(request);
238
+ }
239
+
240
+ const bytes = byteArrayValue(value, bufferLength);
241
+ if (!bytes) {
242
+ throw invalid;
243
+ }
244
+ return async (): Promise<WriteValueOptions> => {
245
+ // Write back in whichever representation (Byte[] or ByteString) the node publishes.
246
+ const [current] = await session.read([{ attributeId: AttributeIds.Value, nodeId }], 0);
247
+ if (!current || toReading(variable, current).quality !== "good") {
248
+ throw new Error(`Cannot determine byte-buffer representation: ${variable.path}`);
249
+ }
250
+ return {
251
+ attributeId: AttributeIds.Value,
252
+ nodeId,
253
+ value: {
254
+ value: {
255
+ arrayType: current.value.arrayType,
256
+ dataType: current.value.dataType,
257
+ value: Buffer.from(bytes),
258
+ },
259
+ },
260
+ };
261
+ };
262
+ };
263
+
264
+ /**
265
+ * Writes `updates` in order, stopping at the first batch that does not fully
266
+ * succeed. Throws `VariableWriteError` with each field's outcome.
267
+ */
268
+ export const writeVariables = async (
269
+ session: VariableSession,
270
+ nodes: ReadonlyMap<string, string>,
271
+ updates: ReadonlyArray<{ variable: Variable; value: unknown }>,
272
+ ): Promise<ReadonlyArray<WriteResult>> => {
273
+ // Validate the whole patch and all bindings before sending any writes.
274
+ const prepared = updates.map((update) => prepareWrite(session, nodes, update));
275
+ const requests = await Promise.all(prepared.map((build) => build()));
276
+
277
+ const results: Array<WriteResult> = [];
278
+ for (let offset = 0; offset < requests.length; offset += BATCH_SIZE) {
279
+ const batch = requests.slice(offset, offset + BATCH_SIZE);
280
+ const batchUpdates = updates.slice(offset, offset + batch.length);
281
+ const remaining = updates.slice(offset + batch.length);
282
+ const statuses = await session.write(batch).catch((cause: unknown) => {
283
+ throw new VariableWriteError(
284
+ [
285
+ ...results,
286
+ ...batchUpdates.map(({ variable }) => ({
287
+ path: variable.path,
288
+ status: "Unknown write outcome",
289
+ written: null,
290
+ })),
291
+ ...notAttempted(remaining),
292
+ ],
293
+ cause,
294
+ );
295
+ });
296
+ for (const [index, { variable }] of batchUpdates.entries()) {
297
+ const status = statuses[index];
298
+ results.push({
299
+ path: variable.path,
300
+ status: status?.toString() ?? "Missing write result",
301
+ written: status ? status.isGood() : null,
302
+ });
303
+ }
304
+ if (statuses.length !== batch.length || results.some(({ written }) => written !== true)) {
305
+ throw new VariableWriteError([...results, ...notAttempted(remaining)]);
306
+ }
307
+ }
308
+ return results;
309
+ };