Claude and I had a long discussion and I think we figured it out
I tested the fix and works.
mappView EventScript: valueChanged Not Firing Without Test Variable
Symptom
opcVariable.valueChanged(...) registered inside an async function did not fire —
unless an unrelated synchronous testVar.valueChanged(...) on the same node path was
present in the script.
Root Cause: async/await Breaks Synchronous Execution
When the script function is async and contains an await, JavaScript returns control
to the caller immediately at the first await — the rest of the function body is
scheduled as a microtask to run later.
async function initBindings() {
let opcVariable = opcua(fullPath); // ✓ synchronous — runs immediately
var initialState = await opcVariable.getValue(); // ← function PAUSES here
// control returns to #initScript
opcVariable.valueChanged(function(e) { ... }); // ✗ this line has NOT run yet
}
initBindings(); // starts executing, then pauses at await
// Framework continues:
opcuaScriptManager.activateMonitoredItems(); // fires NOW
// → #itemsToMonitor is EMPTY for this node → no subscription created on the server
// → valueChanged handler will never be triggered
The await essentially splits the function into two parts. The first part runs before
activateMonitoredItems, the second part — containing the valueChanged call — runs
after. By then the subscription window has already passed.
Why the Test Variable “Fixed” It
var testVar = opcua('::AsGlobalPV:gVisuMapp.safety.sN[0].S_State');
testVar.valueChanged(function(e) { // ← synchronous, runs immediately
console.log("CHANGED:", e.detail.newValue);
});
testVar used the identical node path as opcVariable inside initBindings.
OpcuaScopeAdapter caches node adapters in a Map keyed by fullNodeId:
opcua(nodeId) {
if (!this.#nodeAdapters.has(fullNodeId)) {
let opcuaNode = new OpcuaNode(nodeId, options, this.#opcuaConnection);
this.#nodeAdapters.set(fullNodeId, new OpcuaNodeAdapter(opcuaNode));
}
return this.#nodeAdapters.get(fullNodeId); // ← returns the SAME instance
}
Because testVar.valueChanged() ran synchronously, it added the node to
#itemsToMonitor before activateMonitoredItems was called. When initBindings()
eventually continued after its await and called opcVariable.valueChanged(), it
received the same already-subscribed adapter instance from the cache. The subscription
already existed on the server, so the handler started working — purely by accident.
The Fix
Register valueChanged before any await, then read the initial value afterwards:
// ✗ BROKEN — valueChanged is placed after await
async function initBindings() {
let opcVariable = opcua(fullPath);
var initialState = await opcVariable.getValue(); // pauses here → fn() returns
setModuleState(sessionVarName, initialState);
opcVariable.valueChanged(function(e) { // too late
setModuleState(sessionVarName, e.detail.newValue);
});
}
// ✓ FIXED — valueChanged is placed before await
async function initBindings() {
let opcVariable = opcua(fullPath);
opcVariable.valueChanged(function(e) { // ✓ synchronous, registered in time
setModuleState(sessionVarName, e.detail.newValue);
});
try {
var initialState = await opcVariable.getValue(); // initial sync after subscription
setModuleState(sessionVarName, initialState);
} catch (err) {
console.log("OPC init error:", fullPath, err);
}
}
Rule of Thumb
In a mappView EventScript, always call opcua(...).valueChanged(...) synchronously
before any await. The framework calls activateMonitoredItems() immediately after
the script function returns — any valueChanged registration that happens asynchronously
(after an await) will be too late and the OPC UA subscription will never be created.