/** * OPC-UA telemetry source - DOCUMENTED STUB. * * OPC-UA is usually the right answer when the customer already has a PLC or SCADA * layer, because it gives you a browsable address space, real subscriptions, and * server-side timestamps rather than a flat topic namespace. * * To implement: * 1. npm i node-opcua * 2. Fill NODE_MAP with the customer's actual NodeIds (browse the server first). * 3. Implement start() as marked below. * 4. Set TELEMETRY_SOURCE=opcua and OPCUA_ENDPOINT in .env * * For testing without a plant, Prosys OPC-UA Simulation Server or the * node-opcua sample server both work locally. */ import { TelemetrySource } from './source.js'; import { AnalyticsEngine } from '../analytics/alarms.js'; /** * Maps an OPC-UA NodeId to a station signal. * * NodeIds are namespace-qualified and installation-specific - never guess them. * Browse the server's address space and read them off. */ export const NODE_MAP = { // 'ns=2;s=Line1.CONV01.BeltSpeed': { station: 'CONV-01', signal: 'beltSpeed' }, // 'ns=2;s=Line1.CNC02.VibrationRMS': { station: 'CNC-02', signal: 'vibration' }, // 'ns=2;s=Line1.CNC02.SpindleLoad': { station: 'CNC-02', signal: 'spindleLoad' }, // 'ns=2;s=Line1.OVN03.Zone2PV': { station: 'OVN-03', signal: 'zone2Temp' }, // 'ns=2;s=Line1.OVN03.Zone2SP': { station: 'OVN-03', signal: 'setpoint' }, // 'ns=2;s=Line1.INS04.RejectRate': { station: 'INS-04', signal: 'rejectRate' }, // 'ns=2;s=Line1.PKG05.UnitsPerMin': { station: 'PKG-05', signal: 'unitsPerMin' }, }; export class OpcUaSource extends TelemetrySource { constructor({ endpoint, securityMode, username, password } = {}) { super('opcua'); this.endpoint = endpoint; this.securityMode = securityMode; this.username = username; this.password = password; this.analytics = new AnalyticsEngine(); } /** * Read-only by default, deliberately. * * OPC-UA can write back to a PLC, and a twin that can change a real setpoint is * a genuinely useful thing - but it is also a safety-critical action that needs * interlocks, an audit trail, and the customer's explicit sign-off. Do not turn * setpointControl on here because the demo UI has a slider. */ get capabilities() { return { timeControl: false, faultInjection: false, setpointControl: false }; } async start() { throw new Error( 'OPC-UA source is not configured. This is a documented stub.\n' + 'To enable it: npm i node-opcua, populate NODE_MAP in ' + 'server/ingest/opcuaSource.js with NodeIds browsed from your server, ' + 'implement start(), then set TELEMETRY_SOURCE=opcua and OPCUA_ENDPOINT in .env.\n' + 'Run with TELEMETRY_SOURCE=simulated for the demo.', ); /* Implementation outline: * * const { OPCUAClient, MessageSecurityMode, SecurityPolicy, AttributeIds, * ClientSubscription, TimestampsToReturn } = await import('node-opcua'); * * this.client = OPCUAClient.create({ endpointMustExist: false }); * await this.client.connect(this.endpoint); * this.session = await this.client.createSession( * this.username ? { userName: this.username, password: this.password } : undefined); * * this.subscription = await this.session.createSubscription2({ * requestedPublishingInterval: 500, * publishingEnabled: true, * }); * * for (const [nodeId, tag] of Object.entries(NODE_MAP)) { * const item = await this.subscription.monitor( * { nodeId, attributeId: AttributeIds.Value }, * { samplingInterval: 500, queueSize: 10, discardOldest: true }, * TimestampsToReturn.Both); * item.on('changed', (dataValue) => { * // Honour the status code. A Bad or Uncertain value must NOT be * // rendered as live data - that is how a twin starts lying. * if (!dataValue.statusCode.isGood()) { * this.markStale(tag); * return; * } * this.values[`${tag.station}.${tag.signal}`] = { * value: dataValue.value.value, * // Prefer the SOURCE timestamp: it is when the PLC sampled the * // sensor, not when the message happened to reach us. * t: dataValue.sourceTimestamp ?? dataValue.serverTimestamp, * }; * }); * } * * this.timer = setInterval(() => this.assembleAndEmit(), 500); * * assembleAndEmit() builds the same frame shape SimulatedSource emits, then * calls this.analytics.update(snapshot) and this.emit(frame). The analytics * layer needs no changes at all: TrendTracker and BaselineBank work on * timestamped values regardless of where they came from. */ } async stop() { if (this.timer) clearInterval(this.timer); if (this.subscription) await this.subscription.terminate(); if (this.session) await this.session.close(); if (this.client) await this.client.disconnect(); } }