@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.
package/src/native.ts ADDED
@@ -0,0 +1,35 @@
1
+ import "reflect-metadata";
2
+
3
+ // Bun executable bundles can initialize ESM dependencies before CommonJS side effects.
4
+ // Load the provider only after its required metadata polyfill has run.
5
+ const [client, certificates] = await Promise.all([
6
+ import("node-opcua-client"),
7
+ import("node-opcua-certificate-manager"),
8
+ ]);
9
+ export const {
10
+ AttributeIds,
11
+ DataType,
12
+ VariantArrayType,
13
+ MessageSecurityMode,
14
+ OPCUAClient,
15
+ SecurityPolicy,
16
+ UserTokenType,
17
+ TimestampsToReturn,
18
+ BrowseDirection,
19
+ makeResultMask,
20
+ NodeClass,
21
+ ObjectIds,
22
+ resolveNodeId,
23
+ } = client;
24
+ export const { OPCUACertificateManager } = certificates;
25
+ export type DataType = import("node-opcua-client").DataType;
26
+ export type {
27
+ ClientSession,
28
+ BrowseDescriptionOptions,
29
+ BrowseResult,
30
+ NodeIdLike,
31
+ DataValue,
32
+ ReadValueIdOptions,
33
+ StatusCode,
34
+ WriteValueOptions,
35
+ } from "node-opcua-client";
package/src/plc.ts ADDED
@@ -0,0 +1,146 @@
1
+ import { setTimeout as delay } from "node:timers/promises";
2
+ import { type ClientSession, type ConnectionOptions, connectPlc } from "./client";
3
+ import { contractVariables, type PlcContract } from "./contract";
4
+ import { discoverVariables } from "./discovery";
5
+ import { createMachineClient, type MachineDefinition } from "./machines";
6
+ import { type Snapshot, unavailableSnapshot } from "./snapshot";
7
+ import { createVariableManager, type VariableBinding } from "./variable-manager";
8
+ import { readVariables, writeVariables } from "./variables";
9
+
10
+ export interface PlcLog {
11
+ info: (message: string) => void;
12
+ warn: (message: string, error: unknown) => void;
13
+ }
14
+
15
+ export interface PlcOptions extends ConnectionOptions {
16
+ contract: PlcContract;
17
+ /** Published symbols to poll, such as the generated `publication.production`. */
18
+ publication: readonly string[];
19
+ log?: PlcLog;
20
+ }
21
+
22
+ const pollIntervalMs = 1000;
23
+ const reconnectDelayMs = 2000;
24
+ const staleAfterMs = 5000;
25
+
26
+ const sleep = (ms: number, signal: AbortSignal) =>
27
+ delay(ms, undefined, { signal }).catch(() => undefined);
28
+
29
+ /**
30
+ * One reconnecting OPC UA session to a tsPLC application. `run` connects,
31
+ * discovers the published symbols and polls them into `snapshot()` until aborted;
32
+ * `get`/`set` read and write whole symbols through the current session.
33
+ */
34
+ export const createPlc = <Globals>(options: PlcOptions) => {
35
+ const variables = contractVariables(options.contract, options.publication);
36
+ let state = unavailableSnapshot("Waiting for the PLC");
37
+ let binding: (VariableBinding & { session: ClientSession }) | null = null;
38
+
39
+ const currentBinding = () => {
40
+ if (!binding) throw new Error("PLC is not connected");
41
+ return binding;
42
+ };
43
+
44
+ /** The latest poll, or unavailable when disconnected or the last read is stale. */
45
+ const snapshot = (): Snapshot =>
46
+ state.updatedAt && Date.now() - Date.parse(state.updatedAt) > staleAfterMs
47
+ ? unavailableSnapshot("PLC data is stale; waiting for a fresh read")
48
+ : state;
49
+
50
+ const poll = async (signal: AbortSignal) => {
51
+ const client = await connectPlc(options);
52
+ const clear = () => {
53
+ binding = null;
54
+ };
55
+ signal.addEventListener("abort", clear, { once: true });
56
+ try {
57
+ client.session.on("session_closed", clear);
58
+ signal.throwIfAborted();
59
+ const nodes = await discoverVariables(
60
+ client.session,
61
+ variables.map(({ path }) => path),
62
+ );
63
+ if (!nodes.size)
64
+ throw new Error(
65
+ "No declared PLC symbols are published. Check the downloaded program and OPC UA symbol configuration.",
66
+ );
67
+ options.log?.info(`Connected to the PLC (${nodes.size} symbols)`);
68
+ signal.throwIfAborted();
69
+ binding = { nodes, session: client.session };
70
+ while (!signal.aborted) {
71
+ const values = await readVariables(client.session, nodes, variables);
72
+ signal.throwIfAborted();
73
+ state = { connected: true, error: null, updatedAt: new Date().toISOString(), values };
74
+ await sleep(pollIntervalMs, signal);
75
+ }
76
+ } finally {
77
+ clear();
78
+ signal.removeEventListener("abort", clear);
79
+ // disconnect also closes active sessions and disposes the certificate manager.
80
+ await client.disconnect();
81
+ }
82
+ };
83
+
84
+ const run = async (signal: AbortSignal) => {
85
+ while (!signal.aborted) {
86
+ try {
87
+ await poll(signal);
88
+ } catch (error) {
89
+ if (signal.aborted) return;
90
+ const message = error instanceof Error ? error.message : "OPC UA connection failed";
91
+ if (state.error !== message)
92
+ options.log?.warn("PLC connection unavailable; retrying", error);
93
+ state = unavailableSnapshot(message);
94
+ }
95
+ await sleep(reconnectDelayMs, signal);
96
+ }
97
+ };
98
+
99
+ return {
100
+ run,
101
+ snapshot,
102
+ ...createVariableManager<Globals>({ getVariableSession: currentBinding }, variables),
103
+ /** Reads these published paths now instead of from the last poll; fails if the session changes meanwhile. */
104
+ read: async (paths: Iterable<string>) => {
105
+ const selected = new Set(paths);
106
+ const current = currentBinding();
107
+ const readings = await readVariables(
108
+ current.session,
109
+ current.nodes,
110
+ variables.filter(({ path }) => selected.has(path)),
111
+ );
112
+ if (binding !== current) throw new Error("PLC session changed during read");
113
+ return readings;
114
+ },
115
+ /** Writes published variables by path; fails with `VariableWriteError` if any write fails. */
116
+ write: async (values: ReadonlyMap<string, unknown>) => {
117
+ const current = currentBinding();
118
+ const byPath = new Map(variables.map((variable) => [variable.path, variable]));
119
+ return writeVariables(
120
+ current.session,
121
+ current.nodes,
122
+ [...values].map(([path, value]) => {
123
+ const variable = byPath.get(path);
124
+ if (!variable) throw new Error(`Unknown or unpublished PLC variable ${path}`);
125
+ return { variable, value };
126
+ }),
127
+ );
128
+ },
129
+ /** The current session; it changes on every reconnect. */
130
+ session: () => currentBinding().session,
131
+ /** Reads exposed read-only telemetry from the latest poll. */
132
+ machine: <const Definition extends MachineDefinition>(definition: Definition) =>
133
+ createMachineClient(definition, {
134
+ read: (paths) => {
135
+ const { values } = snapshot();
136
+ return paths.map((path) => {
137
+ const reading = values[path];
138
+ if (reading?.quality !== "good") throw new Error(`PLC telemetry unavailable: ${path}`);
139
+ return reading.value;
140
+ });
141
+ },
142
+ }),
143
+ };
144
+ };
145
+
146
+ export type Plc<Globals> = ReturnType<typeof createPlc<Globals>>;
@@ -0,0 +1,17 @@
1
+ import type { Reading } from "./types";
2
+
3
+ /** The latest poll of a PLC's published variables, keyed by symbol path. */
4
+ export interface Snapshot {
5
+ connected: boolean;
6
+ error: string | null;
7
+ updatedAt: string | null;
8
+ values: Record<string, Reading>;
9
+ }
10
+
11
+ export const unavailableSnapshot = (error: string): Snapshot => ({
12
+ connected: false,
13
+ error,
14
+ updatedAt: null,
15
+ values: {},
16
+ });
17
+ export type { Reading };
package/src/types.ts ADDED
@@ -0,0 +1,10 @@
1
+ export interface Variable {
2
+ path: string;
3
+ type: string;
4
+ field: string;
5
+ }
6
+ export interface Reading {
7
+ quality: "good" | "unavailable" | "bad";
8
+ status: string;
9
+ value: boolean | number | string | number[] | string[] | null;
10
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Typed access to whole PLC symbols (e.g. `LineIO.Conveyor_Inbound`).
3
+ *
4
+ * `get` reads every field of a symbol and assembles the generated ST shape.
5
+ * `set` takes a partial patch of that shape and writes only the given fields.
6
+ * Each symbol's fields are indexed once, at load, as a tree of structs/arrays.
7
+ */
8
+ import type { Reading, Variable } from "./types";
9
+ import { readVariables, type VariableSession, writeVariables } from "./variables";
10
+
11
+ export type VariableName<PlcGlobals> = {
12
+ [G in keyof PlcGlobals & string]: `${G}.${Extract<keyof PlcGlobals[G], string>}`;
13
+ }[keyof PlcGlobals & string];
14
+
15
+ export type VariableValue<
16
+ PlcGlobals,
17
+ N extends VariableName<PlcGlobals>,
18
+ > = N extends `${infer G extends keyof PlcGlobals & string}.${infer V}`
19
+ ? V extends keyof PlcGlobals[G]
20
+ ? PlcGlobals[G][V]
21
+ : never
22
+ : never;
23
+
24
+ /** Struct arrays use zero-based index patches; primitive arrays are replaced whole. */
25
+ export type VariablePatch<T> =
26
+ T extends Array<infer Item>
27
+ ? Item extends object
28
+ ? { readonly [index: number]: VariablePatch<Item> }
29
+ : ReadonlyArray<Item>
30
+ : T extends object
31
+ ? { [K in keyof T]?: VariablePatch<T[K]> }
32
+ : T;
33
+
34
+ export interface VariableBinding {
35
+ nodes: ReadonlyMap<string, string>;
36
+ session: VariableSession;
37
+ }
38
+
39
+ export interface VariableConnection {
40
+ getVariableSession: () => VariableBinding;
41
+ }
42
+
43
+ interface Branch {
44
+ children: Map<string | number, Node>;
45
+ kind: "branch";
46
+ }
47
+ type Node = Branch | { kind: "leaf"; variable: Variable };
48
+
49
+ const newBranch = (): Branch => ({ children: new Map(), kind: "branch" });
50
+
51
+ /** Splits `Cards[1].Output.MotionAsserted` into `["Cards", 1, "Output", "MotionAsserted"]`. */
52
+ const fieldKeys = (field: string) =>
53
+ (field.match(/[^.[\]]+|\[\d+\]/g) ?? []).map((part) =>
54
+ part.startsWith("[") ? Number(part.slice(1, -1)) : part,
55
+ );
56
+
57
+ /**
58
+ * Indexes every contract variable under its symbol (e.g. `LineIO.Conveyor_Inbound`)
59
+ * as a tree of struct fields and array indexes, ending in leaf variables.
60
+ */
61
+ const buildSchemas = (variables: readonly Variable[]) => {
62
+ const schemas = new Map<string, { root: Branch; fields: Array<Variable> }>();
63
+ for (const variable of variables) {
64
+ // Whole-array globals, such as service channels, are read by path rather than by symbol.
65
+ if (!variable.field) continue;
66
+ const name = variable.path.slice(0, -(variable.field.length + 1));
67
+ const schema = schemas.get(name) ?? { fields: [], root: newBranch() };
68
+ schemas.set(name, schema);
69
+ schema.fields.push(variable);
70
+
71
+ const keys = fieldKeys(variable.field);
72
+ const leafKey = keys.pop();
73
+ if (leafKey === undefined) {
74
+ throw new Error(`Empty PLC field: ${variable.path}`);
75
+ }
76
+ const parent = keys.reduce<Branch>((branch, key) => {
77
+ const child = branch.children.get(key) ?? newBranch();
78
+ if (child.kind !== "branch") {
79
+ throw new Error(`Conflicting PLC field: ${variable.path}`);
80
+ }
81
+ branch.children.set(key, child);
82
+ return child;
83
+ }, schema.root);
84
+ parent.children.set(leafKey, { kind: "leaf", variable });
85
+ }
86
+ return schemas;
87
+ };
88
+
89
+ const arrayKeys = (branch: Branch) =>
90
+ [...branch.children.keys()]
91
+ .filter((key): key is number => typeof key === "number")
92
+ .sort((a, b) => a - b);
93
+
94
+ const assemble = (node: Node, readings: Record<string, Reading>): unknown => {
95
+ if (node.kind === "leaf") {
96
+ const reading = readings[node.variable.path];
97
+ if (reading?.quality !== "good" || reading.value === null) {
98
+ throw new Error(`Cannot read ${node.variable.path}: ${reading?.status ?? "Missing value"}`);
99
+ }
100
+ return reading.value;
101
+ }
102
+ const keys = arrayKeys(node);
103
+ if (keys.length) {
104
+ return keys.map((key) => {
105
+ const child = node.children.get(key);
106
+ if (!child) {
107
+ throw new Error(`Missing PLC array element: ${key}`);
108
+ }
109
+ return assemble(child, readings);
110
+ });
111
+ }
112
+ return Object.fromEntries(
113
+ [...node.children].map(([key, child]) => [key, assemble(child, readings)]),
114
+ );
115
+ };
116
+
117
+ const collectUpdates = (
118
+ node: Node,
119
+ patch: unknown,
120
+ path: string,
121
+ ): Array<{ variable: Variable; value: unknown }> => {
122
+ if (node.kind === "leaf") {
123
+ return [{ value: patch, variable: node.variable }];
124
+ }
125
+ if (
126
+ !patch ||
127
+ typeof patch !== "object" ||
128
+ (!Array.isArray(patch) &&
129
+ Object.getPrototypeOf(patch) !== Object.prototype &&
130
+ Object.getPrototypeOf(patch) !== null)
131
+ ) {
132
+ throw new Error(`Expected an object patch for ${path}`);
133
+ }
134
+ const keys = arrayKeys(node);
135
+ if (!keys.length && Array.isArray(patch)) {
136
+ throw new Error(`Expected a struct patch for ${path}`);
137
+ }
138
+ return Object.keys(patch).flatMap((key: string) => {
139
+ const value: unknown = Reflect.get(patch, key);
140
+ const index = Number(key);
141
+ const arrayKey =
142
+ keys.length && Number.isInteger(index) && index >= 0 && String(index) === key
143
+ ? keys[index]
144
+ : undefined;
145
+ const child = keys.length ? node.children.get(arrayKey ?? -1) : node.children.get(key);
146
+ if (!child) {
147
+ throw new Error(`Unknown PLC field or array index: ${path}.${key}`);
148
+ }
149
+ return collectUpdates(child, value, `${path}.${key}`);
150
+ });
151
+ };
152
+
153
+ /** Server-side API using the connection's current session and discovered node IDs. */
154
+ export const createVariableManager = <PlcGlobals>(
155
+ connection: VariableConnection,
156
+ variables: readonly Variable[],
157
+ ) => {
158
+ const schemas = buildSchemas(variables);
159
+ const schemaFor = (name: string) => {
160
+ const schema = schemas.get(name);
161
+ if (!schema) {
162
+ throw new Error(`Unknown PLC variable: ${name}`);
163
+ }
164
+ return schema;
165
+ };
166
+
167
+ return {
168
+ async get<N extends VariableName<PlcGlobals>>(name: N): Promise<VariableValue<PlcGlobals, N>> {
169
+ const schema = schemaFor(name);
170
+ const binding = connection.getVariableSession();
171
+ const readings = await readVariables(binding.session, binding.nodes, schema.fields);
172
+ if (connection.getVariableSession() !== binding) {
173
+ throw new Error("PLC session changed during read");
174
+ }
175
+ // Every declared leaf is checked before returning the generated ST shape.
176
+ return assemble(schema.root, readings) as VariableValue<PlcGlobals, N>;
177
+ },
178
+ async set<N extends VariableName<PlcGlobals>>(
179
+ name: N,
180
+ patch: VariablePatch<VariableValue<PlcGlobals, NoInfer<N>>>,
181
+ ) {
182
+ const schema = schemaFor(name);
183
+ const updates = collectUpdates(schema.root, patch, name);
184
+ if (!updates.length) {
185
+ throw new Error(`Empty PLC patch: ${name}`);
186
+ }
187
+ const { session, nodes } = connection.getVariableSession();
188
+ return await writeVariables(session, nodes, updates);
189
+ },
190
+ };
191
+ };