Initial commit with Dockerfile and demo code
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user