120 lines
5.0 KiB
JavaScript
120 lines
5.0 KiB
JavaScript
/**
|
|
* 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();
|
|
}
|
|
}
|