@strato-automation/node-red-contrib-strato-automation
Version:
Strato Automation Official Node Red Library
1,879 lines • 104 kB
HTML
<script src="https://cdn.jsdelivr.net/npm/monaco-editor@latest/min/vs/loader.js"></script>
<script type="text/html" data-template-name="prog-function">
<style>
/* .monaco-editor {
height: 100%;
width: 100%;
} */
#node-fn-point-done {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
background: #ff9800 !important;
color: #fff !important;
border: none !important;
padding: 10px 20px !important;
font-size: 13px !important;
font-weight: 500 !important;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif !important;
border-radius: 4px !important;
box-shadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24) !important;
transition: all 0.15s ease !important;
cursor: pointer !important;
visibility: visible !important;
opacity: 1 !important;
position: relative !important;
z-index: 10 !important;
text-transform: none !important;
letter-spacing: 0.3px !important;
line-height: 1.4 !important;
}
#node-fn-point-selector-panel.active #node-fn-point-done {
display: inline-flex !important;
visibility: visible !important;
opacity: 1 !important;
}
#node-fn-point-done:hover {
background: #fb8c00 !important;
box-shadow: 0 2px 6px rgba(0,0,0,0.16), 0 2px 4px rgba(0,0,0,0.23) !important;
transform: translateY(-1px);
}
#node-fn-point-done:active {
transform: translateY(0);
box-shadow: 0 1px 2px rgba(0,0,0,0.2) !important;
background: #f57c00 !important;
}
#node-fn-point-done:focus {
outline: none !important;
box-shadow: 0 0 0 2px rgba(255, 152, 0, 0.3) !important;
}
#node-fn-point-done i {
display: none !important;
}
#node-fn-point-selector-panel {
display: none;
opacity: 0;
transform: translateX(30px);
transition: opacity 0.25s ease-out, transform 0.25s ease-out;
}
#node-fn-point-selector-panel.active {
display: block;
opacity: 1;
transform: translateX(0);
}
#node-fn-controller-list {
display: block;
opacity: 1;
transform: translateX(0);
transition: opacity 0.25s ease-out, transform 0.25s ease-out;
}
#node-fn-controller-list.hidden {
opacity: 0;
transform: translateX(-30px);
pointer-events: none;
}
#node-fn-controller-list.hidden.hidden-complete {
display: none;
}
</style>
<input type="hidden" id="node-input-func">
<input type="hidden" id="node-input-noerr">
<input type="hidden" id="node-input-finalize">
<input type="hidden" id="node-input-initialize">
<input type="hidden" id="node-input-onstart">
<input type="hidden" id="node-input-onstop">
<div class="form-row">
<ul id="node-fn-tabs" class="red-ui-tabs"></ul>
</div>
<div id="node-fn-tabs-content" style="min-height:420px;">
<div id="node-fn-tab-code" style="display:none">
<div class="form-row">
<label for="node-input-name"><i class="fa fa-tag"></i> <span data-i18n="common.label.name"></span></label>
<div style="display: inline-block; width: 100%"><input type="text" id="node-input-name" data-i18n="[placeholder]common.label.name"></div>
</div>
<div class="form-row node-text-editor-row" style="position:relative; margin-top: 12px;">
<div style="height: 100%; min-height:300px;" class="node-text-editor" id="node-input-func-editor"></div>
</div>
</div>
<div id="node-fn-tab-setup" style="display:none">
<div class="form-row">
<label><i class="fa fa-cog"></i> Setup</label>
</div>
<div class="form-row">
<label for="node-input-outputs"><i class="fa fa-sign-out"></i> Outputs</label>
<input type="number" id="node-input-outputs" min="1" max="10" value="1" style="width: 80px;">
</div>
</div>
<div id="node-fn-tab-points" style="display:none">
<div class="form-row">
<label for="node-input-ocn_server_node"><i class="fa fa-server"></i> OCN Server</label>
<input type="text" id="node-input-ocn_server_node" />
</div>
<!-- Controller List View -->
<div id="node-fn-controller-list" class="form-row">
<div style="display:flex;align-items:center;justify-content:space-between;width:100%;height:32px;">
<label style="line-height:32px;">Controllers</label>
<button type="button" class="red-ui-button" id="node-fn-refresh" title="Refresh list" style="width:32px;height:32px;display:flex;align-items:center;justify-content:center;padding:0;">
<i id="node-fn-refresh-icon" class="fa fa-refresh" style="font-size:14px;"></i>
</button>
</div>
<div id="node-fn-tip" style="display:none;margin:6px 0 0 0;padding:6px 8px;border:1px solid #ffd37a;background:#fff6df;color:#7a5b00;border-radius:6px;font-size:12px;"></div>
<input type="text" id="node-fn-search" placeholder="Search name/model/instance/network" style="width:100%;margin:6px 0 8px 0;padding:6px 8px;border:1px solid #ddd;border-radius:6px" />
<div id="node-fn-list-wrap" style="position:relative;">
<div id="node-fn-list" style="height:240px;overflow:auto;border:1px solid #ddd;border-radius:8px;padding:6px"></div>
<div id="node-fn-loading" style="display:none;position:absolute;left:0;top:0;right:0;bottom:0;background:rgba(255,255,255,0.7);align-items:center;justify-content:center;border:1px solid #ddd;border-radius:8px;">
<i class="fa fa-spinner fa-spin" style="font-size:18px;margin-right:8px;"></i>
Loading...
</div>
</div>
<div style="margin-top:8px;display:flex;align-items:center;gap:4px;color:#666;font-size:12px;">
<span>Why is my controller/object not present?</span>
<i class="fa fa-info-circle" id="node-fn-controller-help" style="cursor:help;color:#999;" title="Only controllers and points that were created using the Bacnet-Controller node are shown. The devices list only contains devices that have been discovered and are currently available in your Node-RED flow."></i>
</div>
</div>
<!-- Point Selection Panel (slides in to replace controller list) -->
<div id="node-fn-point-selector-panel">
<div style="display:flex;align-items:center;justify-content:space-between;width:100%;height:32px;">
<div style="display:flex;align-items:center;gap:8px;min-width:0;flex:1;">
<button type="button" id="node-fn-point-back" class="red-ui-button" style="width:32px;height:32px;display:flex;align-items:center;justify-content:center;padding:0;flex-shrink:0;" title="Back to controllers">
<i class="fa fa-arrow-left"></i>
</button>
<label style="line-height:32px;margin:0;min-width:0;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;" id="node-fn-point-title">Select Points</label>
</div>
</div>
<div id="node-fn-point-toolbar" style="display:flex;gap:8px;align-items:center;margin:6px 0 8px 0;">
<input type="text" id="node-fn-point-search" placeholder="Search name/type/instance" style="flex:1;padding:6px 8px;border:1px solid #ddd;border-radius:6px" />
<button type="button" id="node-fn-point-selectall" class="red-ui-button red-ui-button-small">Select all</button>
<button type="button" id="node-fn-point-clear" class="red-ui-button red-ui-button-small">Clear</button>
<span id="node-fn-point-count" style="color:#666;margin-left:8px;">0 selected</span>
</div>
<div id="node-fn-point-list-wrap" style="position:relative;">
<div id="node-fn-point-list" style="height:240px;overflow:auto;border:1px solid #ddd;border-radius:8px;padding:6px"></div>
<div id="node-fn-point-loading" style="display:none;position:absolute;left:0;top:0;right:0;bottom:0;background:rgba(255,255,255,0.7);align-items:center;justify-content:center;border:1px solid #ddd;border-radius:8px;">
<i class="fa fa-spinner fa-spin" style="font-size:18px;margin-right:8px;"></i>
Loading points...
</div>
</div>
<div style="margin-top:8px;display:flex;align-items:center;gap:4px;color:#666;font-size:12px;">
<span>Why is my controller/object not present?</span>
<i class="fa fa-info-circle" id="node-fn-point-help" style="cursor:help;color:#999;" title="Only controllers and points that were created using the Bacnet-Controller node are shown. The devices list only contains devices that have been discovered and are currently available in your Node-RED flow."></i>
</div>
<div style="margin-top:12px;display:flex;justify-content:flex-end;">
<button type="button" id="node-fn-point-done" class="red-ui-button">
Define variables
</button>
</div>
</div>
</div>
</div>
</script>
<script type="text/javascript" data-template-name="prog-function">
let editorInitialized = false;
/** Set `window.STRATO_FUNCTION_DEBUG_RENDER_POINTS = true` in the browser devtools to log renderPointsList (expensive). */
window.STRATO_FUNCTION_DEBUG_RENDER_POINTS = false;
function dbgPoints() {
if (!window.STRATO_FUNCTION_DEBUG_RENDER_POINTS) return;
console.log.apply(console, arguments);
}
// Cache for device keys to avoid repeated HTTP requests
var knownDeviceKeys = [];
var deviceKeysCacheTime = 0;
var DEVICE_KEYS_CACHE_TTL = 1000; // Cache for 1 second
var deviceKeysRefreshInFlight = false;
// Store reference to current node instance
var currentNodeInstance = null;
/**
* Starts an async refresh when cache is stale or empty (non-blocking).
* provideCompletionItems and getDeviceKeys only read knownDeviceKeys.
*/
function maybeRefreshDeviceKeys(nodeInstance) {
var now = Date.now();
var instanceToUse = nodeInstance || currentNodeInstance;
if (!instanceToUse || !instanceToUse.id) {
return;
}
var cacheFresh =
knownDeviceKeys.length > 0 &&
(now - deviceKeysCacheTime) < DEVICE_KEYS_CACHE_TTL;
if (cacheFresh) {
return;
}
if (deviceKeysRefreshInFlight) {
return;
}
deviceKeysRefreshInFlight = true;
$.ajax({
url: "prog-function/device-keys",
method: "GET",
cache: false,
data: { nodeId: instanceToUse.id },
dataType: "json",
async: true
})
.done(function (data) {
if (data && data.deviceKeys && Array.isArray(data.deviceKeys)) {
knownDeviceKeys.length = 0;
data.deviceKeys.forEach(function (k) {
knownDeviceKeys.push(String(k));
});
deviceKeysCacheTime = Date.now();
}
})
.fail(function () {
knownDeviceKeys.length = 0;
deviceKeysCacheTime = 0;
})
.always(function () {
deviceKeysRefreshInFlight = false;
});
}
/** Returns the current in-memory snapshot; triggers background refresh when TTL miss or empty. */
function getDeviceKeys(nodeInstance) {
if (nodeInstance) {
currentNodeInstance = nodeInstance;
}
maybeRefreshDeviceKeys(nodeInstance || currentNodeInstance);
return knownDeviceKeys;
}
let initEditor = function (that, code = null){
require.config({ paths: { 'vs': 'https://cdn.jsdelivr.net/npm/monaco-editor@latest/min/vs' } });
require(['vs/editor/editor.main'], function () {
// Store node instance for accessing context
currentNodeInstance = that;
// Pre-fetch device keys from runtime when editor initializes
getDeviceKeys(that);
// Register completion providers for JavaScript
if (!editorInitialized) {
// Configure JavaScript language service
if (monaco.languages.typescript && monaco.languages.typescript.javascriptDefaults) {
monaco.languages.typescript.javascriptDefaults.setCompilerOptions({
target: monaco.languages.typescript.ScriptTarget.ES2015,
allowNonTsExtensions: true,
allowJs: true
});
// Add type definitions for Node-RED globals
monaco.languages.typescript.javascriptDefaults.addExtraLib(
'declare var msg: any;\n' +
'declare var devices: { [key: string]: any };\n' +
'declare var context: any;\n' +
'declare var flow: any;\n' +
'declare var global: any;\n' +
'declare var node: any;\n' +
'declare var RED: any;',
'node-red-globals.d.ts'
);
}
// Register for JavaScript language
monaco.languages.registerCompletionItemProvider('javascript', {
triggerCharacters: ['.'],
provideCompletionItems: function (model, position, context) {
// Get device keys via HTTP endpoint (with caching)
var deviceKeys = getDeviceKeys(currentNodeInstance);
var textUntilPosition = model.getValueInRange({
startLineNumber: 1,
startColumn: 1,
endLineNumber: position.lineNumber,
endColumn: position.column
});
var word = model.getWordUntilPosition(position);
var range = {
startLineNumber: position.lineNumber,
startColumn: word.startColumn,
endLineNumber: position.lineNumber,
endColumn: word.endColumn
};
var suggestions = [];
// Check if we're typing after "devices[" or "devices."
// Only trigger on explicit trigger (like Ctrl+Space) or when typing after a dot
var devicesMatch = textUntilPosition.match(/devices\[([^\]]*)$/);
var devicesDotMatch = textUntilPosition.match(/devices\.([^.]*)$/);
// Only show suggestions if explicitly triggered (Ctrl+Space) or after a dot
var isExplicitTrigger = context.triggerKind === monaco.languages.CompletionTriggerKind.Invoke;
var isAfterDot = context.triggerCharacter === '.';
if ((devicesMatch || devicesDotMatch) && (isExplicitTrigger || isAfterDot)) {
// Provide device key suggestions
var prefix = devicesMatch ? devicesMatch[1] : (devicesDotMatch ? devicesDotMatch[1] : '');
// Add device key suggestions if available
if (deviceKeys.length > 0) {
deviceKeys.forEach(function(key) {
if (key.toLowerCase().indexOf(prefix.toLowerCase()) === 0 || prefix === '') {
var isNumeric = /^\d+$/.test(String(key));
var insertText = isNumeric ? String(key) : '"' + String(key) + '"';
var label = isNumeric ? 'devices[' + key + ']' : 'devices["' + key + '"]';
suggestions.push({
label: label,
kind: monaco.languages.CompletionItemKind.Property,
documentation: 'Device: ' + key,
insertText: insertText,
range: range,
detail: 'Device key',
sortText: '!' + label // Sort at the top
});
}
});
}
// Only show placeholder if we have no keys AND no prefix (user just typed devices[)
if (deviceKeys.length === 0 && prefix === '') {
suggestions.push({
label: 'devices[...]',
kind: monaco.languages.CompletionItemKind.Property,
documentation: 'Devices object - device keys will be available at runtime',
insertText: '',
range: range,
detail: 'Device key (runtime)'
});
}
// Always suggest 'devices' variable if prefix is empty
if (prefix === '') {
suggestions.push({
label: 'devices',
kind: monaco.languages.CompletionItemKind.Variable,
documentation: 'Devices object from global context',
insertText: 'devices',
range: range,
sortText: '!devices'
});
}
// Always return suggestions when in devices context
return { suggestions: suggestions };
}
// For general autocompletion, suggest Node-RED specific variables
// Only suggest when user is typing something that matches
if (word.word.length > 0) {
var commonVars = ['msg', 'devices', 'context', 'flow', 'global', 'node', 'RED'];
commonVars.forEach(function(varName) {
if (varName.toLowerCase().indexOf(word.word.toLowerCase()) === 0) {
suggestions.push({
label: varName,
kind: monaco.languages.CompletionItemKind.Variable,
documentation: varName === 'devices' ? 'Devices object from global context' :
varName === 'msg' ? 'Message object' :
varName === 'flow' ? 'Flow-scoped context' :
varName === 'global' ? 'Global-scoped context' :
varName === 'context' ? 'Node context' :
varName === 'node' ? 'Node instance' :
'Node-RED API',
insertText: varName,
range: range,
sortText: '!' + varName
});
}
});
// Suggest library functions (FctMax, FctMin, FctRamp)
if (word.word.length === 0 || word.word.match(/^Fct/)) {
// FctMax
if (word.word.length === 0 || word.word.toLowerCase().indexOf('fctmax') === 0) {
suggestions.push({
label: 'FctMax',
kind: monaco.languages.CompletionItemKind.Function,
documentation: 'Returns the maximum value between two numbers',
insertText: 'FctMax(${1: a}, ${2: b})',
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
range: range,
sortText: '!FctMax'
});
}
// FctMin
if (word.word.length === 0 || word.word.toLowerCase().indexOf('fctmin') === 0) {
suggestions.push({
label: 'FctMin',
kind: monaco.languages.CompletionItemKind.Function,
documentation: 'Returns the minimum value between two numbers',
insertText: 'FctMin(${1: a}, ${2: b})',
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
range: range,
sortText: '!FctMin'
});
}
// FctRamp (documented but not implemented - kept for autocomplete)
if (word.word.length === 0 || word.word.match(/^FctRamp?/)) {
suggestions.push({
label: 'FctRamp',
kind: monaco.languages.CompletionItemKind.Function,
documentation: 'Scale a ramp (with output value limited between Y1 and Y2)',
insertText: 'FctRamp(${1: input}, ${2: X1}, ${3: Y1}, ${4: X2}, ${5: Y2});',
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
range: range,
sortText: '!FctRamp'
});
}
}
}
// Return suggestions if we have any, otherwise return null to let Monaco's default provider work
return suggestions.length > 0 ? { suggestions: suggestions } : null;
}
});
// Also register for C language (for backward compatibility)
monaco.languages.registerCompletionItemProvider('c', {
provideCompletionItems: function (model, position) {
// Fetch device keys dynamically each time (with caching)
var deviceKeys = getDeviceKeys();
var word = model.getWordUntilPosition(position);
var range = {
startLineNumber: position.lineNumber,
startColumn: word.startColumn,
endLineNumber: position.lineNumber,
endColumn: word.endColumn
};
var suggestions = [];
// Check for devices autocompletion
var textUntilPosition = model.getValueInRange({
startLineNumber: 1,
startColumn: 1,
endLineNumber: position.lineNumber,
endColumn: position.column
});
var devicesMatch = textUntilPosition.match(/devices\[([^\]]*)$/);
if (devicesMatch) {
var prefix = devicesMatch[1];
deviceKeys.forEach(function(key) {
if (key.toLowerCase().indexOf(prefix.toLowerCase()) === 0 || prefix === '') {
var isNumeric = /^\d+$/.test(key);
var insertText = isNumeric ? key : '"' + key + '"';
suggestions.push({
label: 'devices[' + insertText + ']',
kind: monaco.languages.CompletionItemKind.Property,
documentation: 'Device: ' + key,
insertText: insertText,
range: range
});
}
});
}
// Suggest library functions (FctMax, FctMin, FctRamp)
if (word.word.length === 0 || word.word.match(/^Fct/)) {
// FctMax
if (word.word.length === 0 || word.word.toLowerCase().indexOf('fctmax') === 0) {
suggestions.push({
label: 'FctMax',
kind: monaco.languages.CompletionItemKind.Function,
documentation: 'Returns the maximum value between two numbers',
insertText: 'FctMax(${1: a}, ${2: b})',
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
range: range
});
}
// FctMin
if (word.word.length === 0 || word.word.toLowerCase().indexOf('fctmin') === 0) {
suggestions.push({
label: 'FctMin',
kind: monaco.languages.CompletionItemKind.Function,
documentation: 'Returns the minimum value between two numbers',
insertText: 'FctMin(${1: a}, ${2: b})',
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
range: range
});
}
// FctRamp (documented but not implemented - kept for autocomplete)
if (word.word.length === 0 || word.word.match(/^FctRamp?/)) {
suggestions.push({
label: 'FctRamp',
kind: monaco.languages.CompletionItemKind.Function,
documentation: 'Scale a ramp (with output value limited between Y1 and Y2)',
insertText: 'FctRamp(${1: input}, ${2: X1}, ${3: Y1}, ${4: X2}, ${5: Y2});',
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
range: range
});
}
}
return { suggestions: suggestions };
}
});
editorInitialized = true;
}
that.editor = monaco.editor.create(document.getElementById('node-input-func-editor'), {
value: code || that.func || 'return msg;',
language: 'javascript',
suggestOnTriggerCharacters: true,
quickSuggestions: {
other: false,
comments: false,
strings: false
},
acceptSuggestionOnEnter: 'on',
tabCompletion: 'off',
wordBasedSuggestions: 'off',
wordWrap: 'off',
scrollBeyondLastLine: false,
horizontalScrollbarSize: 10,
autoIndent: 'full',
formatOnPaste: false,
formatOnType: false
});
});
}
RED.nodes.registerType('prog-function', {
color: '#f3b567',
category: 'Strato Automation',
paletteLabel: 'Strato Function',
defaults: {
name: { value: "function" },
func: { value: "\nreturn msg;" },
onstart: { value: "" },
onstop: { value: "" },
outputs: { value: 1, validate: function(v) { return v >= 1 && v <= 10; } },
ocn_server_node: { type: "ocn-server", required: false },
},
inputs: 1,
outputs: function() {
return parseInt(this.outputs || 1);
},
icon: "function.svg",
label: function () {
return String(this.name || "Strato Function");
},
labelStyle: function () {
return String(this.name ? "node_label_italic" : "");
},
oneditprepare: function () {
console.log("[function-node] ========================================");
console.log("[function-node] oneditprepare() CALLED - Script loaded!");
console.log("[function-node] ========================================");
var that = this;
// Pre-fetch device keys from runtime when editor opens
// Pass 'this' (the node instance) to access its context
getDeviceKeys(that);
// Setup tabs
var activeTabId = "node-fn-tab-code";
var tabs = RED.tabs.create({
id: "node-fn-tabs",
onchange: function (tab) {
activeTabId = tab.id;
$("#node-fn-tabs-content").children().hide();
$("#" + tab.id).show();
}
});
tabs.addTab({ id: "node-fn-tab-code", label: "Code", iconClass: "fa fa-code" });
tabs.addTab({ id: "node-fn-tab-setup", label: "Setup", iconClass: "fa fa-cog" });
tabs.addTab({ id: "node-fn-tab-points", label: "Select Points", iconClass: "fa fa-dot-circle-o" });
tabs.activateTab("node-fn-tab-code");
// Show the code tab initially
$("#node-fn-tab-code").show();
// Initialize outputs field
var outputsField = $("#node-input-outputs");
if (that.outputs) {
outputsField.val(parseInt(that.outputs) || 1);
}
// Initialize code editor
initEditor(that);
// Save setup code when dialog closes - use a one-shot override that restores immediately
if (RED.editor && RED.editor.validate && typeof RED.editor.validate.edit === "function") {
that._originalValidateEdit = RED.editor.validate.edit;
RED.editor.validate.edit = function () {
try {
if (that.editor && typeof that.editor.getValue === "function" && activeTabId === "node-fn-tab-code") {
that.func = that.editor.getValue() || "return msg;";
}
if (outputsField && outputsField.length) {
that.outputs = parseInt(outputsField.val() || 1);
}
} catch (e) {
// DOM/editor may be torn down if dialog was closed
}
var orig = that._originalValidateEdit;
if (orig && RED.editor && RED.editor.validate) {
RED.editor.validate.edit = orig;
delete that._originalValidateEdit;
}
return orig ? orig.apply(this, arguments) : true;
};
}
// Setup OCN Server field (Node-RED handles it automatically via defaults)
$("#node-input-ocn_server_node").on("change", function() {
var value = $(this).val() || "";
if (value) {
// Show message to deploy
$("#node-fn-list").empty();
$("#node-fn-tip").text("Please deploy for your OCN server to take effect").show();
setLoadingControllers(false);
} else {
$("#node-fn-list").empty();
$("#node-fn-tip").text("Select an OCN Server profile").show();
setLoadingControllers(false);
}
});
// Controller selection variables
var lastDevices = [];
var lastControllerDeviceKeysSet = new Set();
var isLoadingControllers = false;
var selectedController = null;
var selectedPoints = new Set();
var currentPointsCache = [];
var currentFilteredPoints = []; // Points currently displayed in the list (after filtering)
var currentDeviceKeysSet = new Set();
var currentDevicesObj = null;
// Helper functions
function toAbbr(typeValue) {
var typeMap = { 0: "AI", 1: "AO", 2: "AV", 3: "BI", 4: "BO", 5: "BV", 12: "PID", 19: "MSV" };
if (typeof typeValue === "number") return typeMap[typeValue] || String(typeValue);
var stringValue = String(typeValue || "").trim().toUpperCase();
if (/^\d+$/.test(stringValue)) return typeMap[+stringValue] || stringValue;
return typeMap[stringValue] != null ? typeMap[stringValue] : stringValue;
}
function setLoadingControllers(isLoading) {
isLoadingControllers = !!isLoading;
$("#node-fn-refresh").prop("disabled", isLoadingControllers);
$("#node-fn-refresh-icon")
.removeClass("fa-refresh fa-spinner fa-spin")
.addClass(isLoadingControllers ? "fa-spinner fa-spin" : "fa-refresh");
$("#node-fn-loading").css("display", isLoadingControllers ? "flex" : "none");
}
function renderFilteredControllers(devices, query, deviceKeysSet, devicesObj) {
if (!deviceKeysSet || typeof deviceKeysSet.has !== "function") {
deviceKeysSet = new Set();
}
var container = $("#node-fn-list");
container.empty();
var controllersArray = Array.isArray(devices) ? devices : [];
var originalCount = controllersArray.length;
// Filter controllers to only show those present in devices list
// normalizeKeyPart converts deviceInstance to string, so compare as strings
// If no device keys found, filter out all controllers
controllersArray = controllersArray.filter(function (deviceInfo) {
var deviceInstance = deviceInfo.deviceInstance;
if (deviceInstance == null || deviceInstance === "") return false;
// normalizeKeyPart converts to string, so compare as strings
var instanceStr = String(deviceInstance).trim();
var instanceNum = Number(instanceStr);
return deviceKeysSet.has(instanceStr) ||
(!isNaN(instanceNum) && deviceKeysSet.has(String(instanceNum)));
});
var searchTerm = (query || "").toLowerCase();
if (searchTerm) {
controllersArray = controllersArray.filter(function (deviceInfo) {
return (
(deviceInfo.deviceName || "").toLowerCase().includes(searchTerm) ||
(deviceInfo.modelName || "").toLowerCase().includes(searchTerm) ||
String(deviceInfo.deviceInstance || "").includes(searchTerm) ||
String(deviceInfo.networkNumber || "").includes(searchTerm)
);
});
}
if (!controllersArray.length) {
container.append('<div style="padding:8px;color:#666">No controllers</div>');
return;
}
controllersArray.forEach(function (deviceInfo) {
var row = $('<div class="nr-bacnet-row" style="display:flex;align-items:center;gap:12px;padding:8px;border:1px solid #ddd;border-radius:8px;margin-bottom:8px;cursor:pointer;user-select:none" data-di="' + String(deviceInfo.deviceInstance || "") + '" data-nn="' + String(deviceInfo.networkNumber || "") + '"></div>');
var leftColumn = $('<div style="min-width:0;flex:1"></div>');
var dot = " " + String.fromCharCode(183) + " ";
var titleParts = [];
if (deviceInfo.deviceName) titleParts.push(deviceInfo.deviceName);
var titleElement = $('<div style="font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis"></div>').text(titleParts.join(dot) || "Controller");
var subtitleParts = [String(deviceInfo.deviceInstance || "")];
if (deviceInfo.networkNumber) subtitleParts.push("Net " + deviceInfo.networkNumber);
if (deviceInfo.modelName) subtitleParts.push(deviceInfo.modelName);
var subtitleElement = $('<div style="font-size:12px;color:#666"></div>').text(subtitleParts.join(dot));
leftColumn.append(titleElement).append(subtitleElement);
row.append(leftColumn);
row.on("click keypress", function (event) {
if (event.type === "keypress" && event.key !== "Enter") return;
selectedController = deviceInfo;
openPointSelector();
});
container.append(row);
});
}
function loadControllers(retryCount) {
retryCount = retryCount || 0;
if (isLoadingControllers && retryCount === 0) return; // Allow retries even if loading
var configId = $("#node-input-ocn_server_node").val() || "";
if (!configId) {
$("#node-fn-tip").text("Select an OCN Server profile").show();
$("#node-fn-list").empty();
setLoadingControllers(false); // Ensure loading is cleared
return;
}
$("#node-fn-tip").hide();
setLoadingControllers(true);
// Set a timeout to ensure loading state is cleared
var loadingTimeout = setTimeout(function() {
setLoadingControllers(false);
$("#node-fn-tip").text("Request timeout - try refreshing").show();
}, 10000); // 10 second timeout
$.ajax({
url: "bacnet-controller/devices",
method: "GET",
cache: false,
data: { nodeId: that.id, configId: configId },
dataType: "text",
timeout: 8000 // 8 second timeout
})
.done(function (_unusedUrl, _unusedStatus, xhr) {
var responseText = xhr.responseText || "[]";
try {
responseText = responseText.replace(/^\)\]\}',?\s*/, "");
} catch (error) {}
var parsedArray = [];
try {
parsedArray = JSON.parse(responseText);
} catch (error) {}
lastDevices = Array.isArray(parsedArray) ? parsedArray : [];
// Fetch device keys asynchronously, then render
if (that && that.id) {
$.ajax({
url: "prog-function/device-keys",
method: "GET",
cache: false,
data: { nodeId: that.id },
dataType: "json"
})
.done(function(data) {
var deviceKeysSet = new Set();
var devicesObj = null;
if (data && data.deviceKeys && Array.isArray(data.deviceKeys)) {
devicesObj = data.devicesIndex || null;
data.deviceKeys.forEach(function(key) {
if (key !== null && key !== undefined && key !== "") {
var keyStr = String(key).trim();
if (keyStr !== "") {
deviceKeysSet.add(keyStr);
var keyNum = Number(keyStr);
if (!isNaN(keyNum) && String(keyNum) !== keyStr) {
deviceKeysSet.add(String(keyNum));
}
}
}
});
}
clearTimeout(loadingTimeout);
lastControllerDeviceKeysSet = deviceKeysSet;
renderFilteredControllers(lastDevices, $("#node-fn-search").val(), lastControllerDeviceKeysSet, devicesObj);
setLoadingControllers(false);
})
.fail(function(xhr) {
clearTimeout(loadingTimeout);
// If 404, node doesn't exist yet - retry once
if (xhr && xhr.status === 404 && retryCount < 1) {
setLoadingControllers(false); // Clear loading before retry
setTimeout(function() {
loadControllers(retryCount + 1);
}, 500);
} else {
lastControllerDeviceKeysSet = new Set();
renderFilteredControllers(lastDevices, $("#node-fn-search").val(), lastControllerDeviceKeysSet, null);
setLoadingControllers(false);
}
});
} else {
clearTimeout(loadingTimeout);
lastControllerDeviceKeysSet = new Set();
renderFilteredControllers(lastDevices, $("#node-fn-search").val(), lastControllerDeviceKeysSet, null);
setLoadingControllers(false);
}
})
.fail(function (xhr) {
clearTimeout(loadingTimeout);
var message = "HTTP " + (xhr && xhr.status ? xhr.status : "");
if (xhr.statusText === "timeout" || xhr.status === 0) {
message = "Request timeout or network error";
}
try {
if (xhr && typeof xhr.responseText === "string") {
var text = xhr.responseText.replace(/^\)\]\}',?\s*/, "");
var json = JSON.parse(text);
if (json && json.error) message = String(json.error);
}
} catch (error) {}
$("#node-fn-tip").text(message).show();
$("#node-fn-list").empty();
setLoadingControllers(false);
});
}
function renderPointsList(items, query, deviceKeysSet, devicesObj) {
if (window.STRATO_FUNCTION_DEBUG_RENDER_POINTS) {
dbgPoints("[function-node] renderPointsList() called");
dbgPoints("[function-node] Input:", {
itemsCount: Array.isArray(items) ? items.length : 0,
query: query,
deviceKeysCount: deviceKeysSet ? deviceKeysSet.size : 0,
hasDevicesObj: !!devicesObj
});
}
deviceKeysSet = deviceKeysSet || new Set();
devicesObj = devicesObj || null;
currentPointsCache = Array.isArray(items) ? items : [];
var pointsArray = currentPointsCache.slice();
if (window.STRATO_FUNCTION_DEBUG_RENDER_POINTS) {
dbgPoints("[function-node] Starting with", pointsArray.length, "points from cache");
}
// Filter points: only show if controller is in devices AND point exists in that device
// Structure: devices[deviceInstance][typeAbbr][objectInstance]
// If no devices found, filter out all points
if (!selectedController) {
if (window.STRATO_FUNCTION_DEBUG_RENDER_POINTS) {
dbgPoints("[function-node] No selected controller - filtering out all points");
}
pointsArray = [];
} else if (!devicesObj || typeof devicesObj !== 'object') {
// No devices object - filter out all points
if (window.STRATO_FUNCTION_DEBUG_RENDER_POINTS) {
dbgPoints("[function-node] No devices object - filtering out all points");
}
pointsArray = [];
} else {
var controllerInstance = selectedController.deviceInstance;
if (controllerInstance == null || controllerInstance === "") {
if (window.STRATO_FUNCTION_DEBUG_RENDER_POINTS) {
dbgPoints("[function-node] Controller instance is empty - filtering out all points");
}
pointsArray = [];
} else {
var instanceStr = String(controllerInstance).trim();
var instanceNum = Number(instanceStr);
if (window.STRATO_FUNCTION_DEBUG_RENDER_POINTS) {
dbgPoints("[function-node] Checking if controller", instanceStr, "is in devices list");
}
// Check if controller is in device keys
var controllerInDevices = deviceKeysSet.has(instanceStr) ||
(!isNaN(instanceNum) && deviceKeysSet.has(String(instanceNum)));
if (!controllerInDevices) {
// Controller not in devices, filter out all points
if (window.STRATO_FUNCTION_DEBUG_RENDER_POINTS) {
dbgPoints("[function-node] Controller", instanceStr, "NOT found in devices list - filtering out all points");
}
pointsArray = [];
} else {
if (window.STRATO_FUNCTION_DEBUG_RENDER_POINTS) {
dbgPoints("[function-node] Controller", instanceStr, "found in devices list");
}
// Try both string and number keys for device instance
var controllerDevice = devicesObj[instanceStr] || devicesObj[instanceNum];
if (controllerDevice && typeof controllerDevice === 'object') {
// Get all keys from controllerDevice
var allKeys = Object.keys(controllerDevice);
if (window.STRATO_FUNCTION_DEBUG_RENDER_POINTS) {
dbgPoints("[function-node] Controller device object found, all keys:", allKeys);
}
// Filter to only valid BACnet type abbreviations (AI, AO, AV, BI, BO, BV, etc.)
// Valid type abbreviations are those returned by toAbbr() function
var validTypeAbbrs = new Set();
var typeAbbrMap = { 0: "AI", 1: "AO", 2: "AV", 3: "BI", 4: "BO", 5: "BV", 12: "PID", 19: "MSV" };
// Check which keys are valid type abbreviations
allKeys.forEach(function(key) {
// Check if this key is a valid type abbreviation
var isValid = false;
for (var typeNum in typeAbbrMap) {
if (typeAbbrMap[typeNum] === key) {
isValid = true;
break;
}
}
if (isValid) {
validTypeAbbrs.add(key);
}
});
if (window.STRATO_FUNCTION_DEBUG_RENDER_POINTS) {
dbgPoints("[function-node] Valid BACnet type abbreviations found:", Array.from(validTypeAbbrs));
}
var ignoredKeys = allKeys.filter(function(k) { return !validTypeAbbrs.has(k); });
if (ignoredKeys.length > 0 && window.STRATO_FUNCTION_DEBUG_RENDER_POINTS) {
dbgPoints("[function-node] Ignoring non-type keys:", ignoredKeys);
}
// Log the content of each valid type abbreviation (only when debug flag is on; avoids JSON.stringify cost)
if (window.STRATO_FUNCTION_DEBUG_RENDER_POINTS) {
validTypeAbbrs.forEach(function(typeAbbr) {
var typeObj = controllerDevice[typeAbbr];
if (typeObj && typeof typeObj === "object") {
var instanceKeys = Object.keys(typeObj);
dbgPoints("[function-node] devices[" + instanceStr + "][" + typeAbbr + "] contains", instanceKeys.length, "objects");
dbgPoints("[function-node] devices[" + instanceStr + "][" + typeAbbr + "] keys:", instanceKeys.slice(0, 20));
dbgPoints("[function-node] devices[" + instanceStr + "][" + typeAbbr + "] full structure:", JSON.stringify(typeObj, null, 2).substring(0, 500));
} else {
dbgPoints("[function-node] devices[" + instanceStr + "][" + typeAbbr + "] is not an object:", typeObj);
}
});
}
var beforeFilterCount = pointsArray.length;
if (window.STRATO_FUNCTION_DEBUG_RENDER_POINTS) {
dbgPoints("[function-node] Sample points before filtering:", pointsArray.slice(0, 10).map(function(p) {
return {
type: p.objectType,
typeAbbr: toAbbr(p.objectType),
instance: p.objectInstance,
name: p.objectName
};
}));
}
// Filter points to only those that exist in the device object under valid type abbreviations
pointsArray = pointsArray.filter(function (objectInfo) {
// Get type abbreviation from objectType
var typeAbbr = toAbbr(objectInfo.objectType);
var objectInstance = objectInfo.objectInstance;
if (!typeAbbr || objectInstance == null || objectInstance === undefined) {
return false;
}
// Only check if this type abbreviation is valid (ignore Temp_Alim, Hum_Exte, Press_Exte, etc.)
if (!validTypeAbbrs.has(typeAbbr)) {
return false;
}
// Check if devices[deviceInstance][typeAbbr][objectInstance] exists
var typeObj = controllerDevice[typeAbbr];
if (!typeObj || typeof typeObj !== 'object') {
return false;
}
// Try both string and number keys for object instance
var instanceKeyStr = String(objectInstance);
var instanceKeyNum = Number(objectInstance);
// Check if the point exists
var exists = (instanceKeyStr in typeObj && typeObj[instanceKeyStr] != null) ||
(!isNaN(instanceKeyNum) && (String(instanceKeyNum) in typeObj && typeObj[String(instanceKeyNum)] != null)) ||
(!isNaN(instanceKeyNum) && (instanceKeyNum in typeObj && typeObj[instanceKeyNum] != null));
if (!exists && window.STRATO_FUNCTION_DEBUG_RENDER_POINTS) {
dbgPoints("[function-node] Point", typeAbbr + "-" + objectInstance, "not found in devices[" + instanceStr + "][" + typeAbbr + "]");
}
return exists;
});
if (window.STRATO_FUNCTION_DEBUG_RENDER_POINTS) {
dbgPoints("[function-node] Filtered points:", beforeFilterCount, "->", pointsArray.length,
"(" + (beforeFilterCount - pointsArray.length) + " filtered out)");
}
if (pointsArray.length > 0) {
if (window.STRATO_FUNCTION_DEBUG_RENDER_POINTS) {
dbgPoints("[function-node] Sample filtered points:", pointsArray.slice(0, 3).map(function(p) {
return { type: p.objectType, instance: p.objectInstance, name: p.objectName };
}));
}
} else {
if (window.STRATO_FUNCTION_DEBUG_RENDER_POINTS) {
dbgPoints("[function-node] No points matched after filtering");
}
}
} else {
// Controller device object not found in devices, filter out all points
if (window.STRATO_FUNCTION_DEBUG_RENDER_POINTS) {
dbgPoints("[function-node] Controller device object not found in devices - filtering out all points");
}
pointsArray = [];
}
}
}
}
var searchTerm = (query || "").toLowerCase();
if (searchTerm) {
var beforeSearchCount = pointsArray.length;
pointsArray = pointsArray.filter(function (objectInfo) {
var typeAbbr = toAbbr(objectInfo.objectType);
var compositeA = typeAbbr + "-" + String(objectInfo.objectInstance);
var compositeN = String(objectInfo.objectType) + "-" + String(objectInfo.objectInstance);
var blob = [
objectInfo.objectName || "",
objectInfo.units || "",
String(objectInfo.objectInstance || ""),
String(objectInfo.objectType || ""),
String(objectInfo.objectTypeName || ""),
compositeA,
compositeN
].join(" ").toLowerCase();
return blob.includes(searchTerm);
});
if (window.STRATO_FUNCTION_DEBUG_RENDER_POINTS) {
dbgPoints("[function-node] Search filter:", beforeSearchCount, "->", pointsArray.length, "for query:", searchTerm);
}
}
// Store the filtered points array for Select All button (after all filtering is complete)
currentFilteredPoints = pointsArray.slice();
var container = $("#node-fn-point-list");
container.empty();
if (!pointsArray.length) {
if (window.STRATO_FUNCTION_DEBUG_RENDER_POINTS) {
dbgPoints("[function-node] No points to display");
}
container.append('<div style="padding:8px;color:#666">No points</div>');
// Clear currentFilteredPoints if no points to display
currentFilteredPoints = [];
return;
}
if (window.STRATO_FUNCTION_DEBUG_RENDER_POINTS) {
dbgPoints("[function-node] Rendering", pointsArray.length, "points in the list");
}
pointsArray.forEach(function (objectInfo) {
var key = String(objectInfo.objectType) + "|" + String(objectInfo.objectInstance);
var row = $('<div tabindex="0" style="display:flex;align-items:center;gap:8px;padding:6px 8px;border:1px solid #ddd;border-radius:8px;margin-bottom:8px;cursor:pointer;user-select:none"></div>');
var checkbox = $('<input type="checkbox">')
.prop("checked", selectedPoints.has(key))
.data("key", key)
.css({ margin: "0 6px 0 0" });
var leftColumn = $('<div style="min-width:0;flex:1"></div>');
var typeAbbr = toAbbr(objectInfo.objectType);
var dot = " " + String.fromCharCode(183) + " ";
var titleParts = [];
if (objectInfo.objectName) titleParts.push(objectInfo.objectName);
var titleElement = $('<div style="font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis"></div>').text(
titleParts.join(dot) || (typeAbbr + "-" + objectInfo.objectInstance)
);
var subtitleParts = [typeAbbr + "-" + objectInfo.objectInstance];
if (objectInfo.presentValue != null && objectInfo.presentValue !== "") {
subtitleParts.push("PV: " + objectInfo.presentValue + (objectInfo.units ? " " + objectInfo.units : ""));
}
var subtitleElement = $('<div style="font-size:12px;color:#666"></div>').text(subtitleParts.join(dot));
leftColumn.append(titleElement).append(subtitleElement);
row.append(checkbox).append(leftColumn);
checkbox.on("change", function () {
var checkboxKey = $(this).data("key");
if (this.checked) selectedPoints.add(checkboxKey);
else selectedPoints.delete(checkboxKey);
$("#node-fn-point-count").text(selectedPoints.size + " selected");
});
row.on("click", function (event) {
if (event.target && event.target.tagName === "INPUT") return;
checkbox.prop("checked", !checkbox.prop("checked")).trigger("change");
});
container.append(row);
});
}
function loadPoints() {
console.log("[function-node] ========================================");
console.log("[function-node] loadPoints() CALLED");
console.log("[function-node] ========================================");
if (!selectedController) {
console.log("[function-node] No controller selected");
$("#node-fn-point-list").empty().append('<div style="padding:8px;color:#666">No controller selected</div>');
return;
}
var configId = $("#node-input-ocn_server_node").val() || "";
// Get deviceInstance and networkNumber from selected controller
var deviceInstance = selectedController.deviceInstance;
var networkNumber = selectedController.networkNumber;
console.log("[function-node] Selected controller:", {
deviceInstance: deviceInstance,
networkNumber: networkNumber,
deviceName: selectedController.deviceName,
configId: configId
});
// Normalize networkNumber: convert to string, handle 0/null/undefined/empty as empty string
// The endpoint expects empty string for network 0 (which maps to null internally)
var networkNumberParam = "";
if (networkNumber !== undefined && networkNumber !== null && networkNumber !== "") {
var networkNum = Number(networkNumber);
if (!isNaN(networkNum) && networkNum !== 0) {
networkNumberParam = String(networkNum);
}
// If networkNumber is 0 or falsy, keep as empty string (which means network 0)
}
// Ensure deviceInstance is a string
deviceInstance = String(deviceInstance || "");
// Check if values are missing
var hasDeviceInstance = deviceInstance !== undefined && deviceInstance !== null && deviceInstance !== "";
if (!configId || !hasDeviceInstance) {
console.log("[function-node] Missing required values:", { configId: configId, hasDeviceInstance: hasDeviceInstance });
$("#node-fn-point-list").empty().append('<div style="padding:8px;color:#666">Select a controller first</div>');
$("#node-fn-point-loading").hide();
return;
}
// Clear the list and show loading
$("#node-fn-point-list").empty();
$("#node-fn-point-loading").show();
console.log("[function-node] Step 1: Fetching cached points list from bacnetHub");
console.log("[function-node] Request params:", {
configId: configId,
deviceInstance: deviceInstance,
networkNumber: networkNumberParam
});
// STEP 1: Fetch cached points list from bacnetHub via /bacnet-controller/objects endpoint
// This endpoint uses bacnetHub internally and waits for cache to be ready
$.ajax({
url: "bacnet-controller/objects",
method: "GET",
cache: false,
timeout: 30000, // 30 second timeout
data: {
configId: configId,
deviceInstance: String(deviceInstance),
networkNumber: networkNumberParam
},
dataType: "text"
})
.done(function (_unusedUrl, _unusedStatus, xhr) {
var responseText = xhr.responseText || "[]";
try {
responseText = responseText.replace(/^\)\]\}',?\s*/, "");
} catch (error) {}
var parsedArray = [];
try {
parsedArray = JSON.parse(responseText);
} catch (error) {
console.error("[function-node] Error parsing points response:", error, responseText);
}
if (!Array.isArray(parsedArray)) {
parsedArray = [];
}
console.log("[function-node] Step 1 complete: Fetched", parsedArray.length, "points from bacnetHub cache");
if (parsedArray.length > 0) {
console.log("[function-node] Sample points:", parsedArray.slice(0, 3).map(function(p) {
return { type: p.objectType, instance: p.objectInstance, name: p.objectName };
}));
}
// Store the points cache for later use
currentPointsCache = parsedArray;
// STEP 2: Fetch device keys and devices list
console.log("[function-node] Step 2: Fetching devices list");
if (that && that.id) {
$.ajax({
url: "prog-function/device-keys",
method: "GET",
cache: false,
data: { nodeId: that.id },
dataType: "json"
})
.done(function(data) {
console.log("[function-node] Step 2 complete: Received device keys data");
var deviceKeysSet = new Set();
var devicesObj = null;
if (data && data.deviceKeys && Array.isArray(data.deviceKeys)) {
devicesObj = data.devicesIndex || null;
console.log("[function-node] Device keys count:", data.deviceKeys.length);
console.log("[function-node] Devices object:", devicesObj ? Object.keys(devicesObj).length + " devices" : "null");
data.deviceKeys.forEach(function(key) {
if (key !== null && key !== undefined && key !== "") {
var keyStr = String(key).trim();
if (keyStr !== "") {
deviceKeysSet.add(keyStr);
var keyNum = Number(keyStr);
if (!isNaN(keyNum) && String(keyNum) !== keyStr) {
deviceKeysSet.add(String(keyNum));
}
}
}
});
// Log controller device info if available
var controllerInstance = String(deviceInstance);
var controllerInDevices = deviceKeysSet.has(controllerInstance) || deviceKeysSet.has(String(Number(controllerInstance)));
console.log("[function-node] Controller", controllerInstance, "in devices:", controllerInDevices);
if (devicesObj && devicesObj[controllerInstance]) {
var controllerDevice = devicesObj[controllerInstance];
console.log("[function-node] Controller device object keys:", Object.keys(controllerDevice));
}
} else {
console.log("[function-node] No device keys found in response");
}
// Store the device keys and devices object for use by event handlers
currentDeviceKeysSet = deviceKeysSet;
currentDevicesObj = devicesObj;
// STEP 3: Filter points to only show those present in devices list
console.log("[function-node] Step 3: Filtering points to only show those in devices list");
console.log("[function-node] Points before filtering:", parsedArray.length);
// renderPointsList will filter the points based on devicesObj
renderPointsList(parsedArray, $("#node-fn-point-search").val(), deviceKeysSet, devicesObj);
console.log("[function-node] Step 3 complete: Points filtered and rendered");
$("#node-fn-point-loading").hide();
})
.fail(function(xhr) {
console.error("[function-node] Step 2 failed: Could not fetch device keys", xhr);
// Store empty values on failure
currentDeviceKeysSet = new Set();
currentDevicesObj = null;
console.log("[function-node] Rendering points without device filtering");
renderPointsList(parsedArray, $("#node-fn-point-search").val(), new Set(), null);
$("#node-fn-point-loading").hide();
});
} else {
console.log("[function-node] Node ID not available, skipping device keys fetch");
// Store empty values when node ID is not available
currentDeviceKeysSet = new Set();
currentDevicesObj = null;
renderPointsList(parsedArray, $("#node-fn-point-search").val(), new Set(), null);
$("#node-fn-point-loading").hide();
}
})
.fail(function (xhr) {
console.error("[function-node] Step 1 failed: Could not fetch points from bacnetHub", xhr);
var message = "HTTP " + (xhr && xhr.status ? xhr.status : "");
try {
if (xhr && typeof xhr.responseText === "string") {
var text = xhr.responseText.replace(/^\)\]\}',?\s*/, "");
var json = JSON.parse(text);
if (json && json.error) message = String(json.error);
}
} catch (error) {}
$("#node-fn-point-loading").hide();
if (xhr && xhr.status === 0) {
// Timeout or connection error
$("#node-fn-point-list").empty().append('<div style="padding:8px;color:#f00">Request timed out. Check your connection and try refreshing.</div>');
} else {
$("#node-fn-point-list").empty().append('<div style="padding:8px;color:#f00">' + message + '</div>');
}
});
}
function openPointSelector() {
console.log("[function-node] ========================================");
console.log("[function-node] openPointSelector() CALLED");
console.log("[function-node] ========================================");
if (!selectedController) {
console.log("[function-node] ERROR: No selected controller in openPointSelector");
return;
}
console.log("[function-node] Selected controller:", {
deviceName: selectedController.deviceName,
deviceInstance: selectedController.deviceInstance,
networkNumber: selectedController.networkNumber
});
var deviceName = selectedController.deviceName || "";
var deviceInstance = selectedController.deviceInstance || "";
var dot = " " + String.fromCharCode(183) + " ";
$("#node-fn-point-title").text((deviceName || "") + (deviceName && deviceInstance ? dot + deviceInstance : (deviceInstance || "Select Points")));
selectedPoints.clear();
$("#node-fn-point-count").text("0 selected");
$("#node-fn-point-search").val("");
// Clear the points cache and list before loading new points
currentPointsCache = [];
$("#node-fn-point-list").empty();
// Ensure controller list is visible and point panel is reset
$("#node-fn-controller-list").removeClass("hidden").removeClass("hidden-complete");
// Remove inline display style and active class to reset state
$("#node-fn-point-selector-panel").removeClass("active").removeAttr("style");
// Hide controller list with animation, then show point selector panel
$("#node-fn-controller-list").addClass("hidden");
setTimeout(function() {
console.log("[function-node] Timeout fired, showing panel and calling loadPoints()");
$("#node-fn-controller-list").addClass("hidden-complete");
// Explicitly set display and add active class
$("#node-fn-point-selector-panel").css("display", "block").addClass("active");
// Ensure button is visible when panel is shown
$("#node-fn-point-done").css({
"display": "inline-block",
"visibility": "visible",
"opacity": "1"
});
// Load points after the panel is visible
loadPoints();
}, 250);
}
function closePointSelector() {
// Hide point selector panel with animation, then show controller list
$("#node-fn-point-selector-panel").removeClass("active");
setTimeout(function() {
$("#node-fn-point-selector-panel").css("display", "none");
$("#node-fn-controller-list").removeClass("hidden").removeClass("hidden-complete");
}, 250);
}
function generateCode() {
if (!selectedController || selectedPoints.size === 0) return "";
var controllerInstance = selectedController.deviceInstance || "";
var controllerName = (selectedController.deviceName || "").trim() || ("Controller_" + controllerInstance);
// Sanitize controller name for JavaScript variable
controllerName = controllerName.replace(/[^a-zA-Z0-9_]/g, "_").replace(/^[0-9]/, "_$&");
// Check if controller variable already exists in the code
var existingControllerName = null;
if (that.editor && typeof that.editor.getValue === 'function') {
try {
var currentCode = that.editor.getValue() || "";
// Look for pattern: var VariableName = devices[instance];
// Match device instance exactly (as string or number)
// Escape special regex characters in controllerInstance
var escapedInstance = String(controllerInstance).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Try multiple patterns to catch different formatting styles
// Pattern 1: var VARNAME = devices[1000];
var pattern1 = new RegExp('var\\s+([a-zA-Z_][a-zA-Z0-9_]*)\\s*=\\s*devices\\s*\\[\\s*' + escapedInstance + '\\s*\\]', 'g');
// Pattern 2: var VARNAME = devices["1000"];
var pattern2 = new RegExp('var\\s+([a-zA-Z_][a-zA-Z0-9_]*)\\s*=\\s*devices\\s*\\[\\s*"' + escapedInstance + '"\\s*\\]', 'g');
var match;
// Try pattern 1 first
while ((match = pattern1.exec(currentCode)) !== null) {
if (match && match[1]) {
existingControllerName = match[1];
break;
}
}
// If not found, try pattern 2
if (!existingControllerName) {
while ((match = pattern2.exec(currentCode)) !== null) {
if (match && match[1]) {
existingControllerName = match[1];
break;
}
}
}
} catch (e) {
// If parsing fails, proceed with creating new controller variable
console.error("Error checking for existing controller:", e);
}
}
var codeLines = [];
var hasController = !!existingControllerName;
// Only add controller declaration if it doesn't already exist
if (!existingControllerName) {
codeLines.push("var " + controllerName + " = devices[" + controllerInstance + "];");
// No blank line - points come immediately after controller
} else {
// Use existing controller variable name
controllerName = existingControllerName;
}
var objectsByKey = new Map();
currentPointsCache.forEach(function (objectInfo) {
objectsByKey.set(String(objectInfo.objectType) + "|" + String(objectInfo.objectInstance), objectInfo);
});
selectedPoints.forEach(function (key) {
var objectInfo = objectsByKey.get(key);
if (objectInfo) {
var baseName = (objectInfo.objectName || "").trim() || (toAbbr(objectInfo.objectType) + "_" + objectInfo.objectInstance);
// Sanitize for JavaScript variable
baseName = baseName.replace(/[^a-zA-Z0-9_]/g, "_").replace(/^[0-9]/, "_$&");
// Prefix with controller name to avoid collisions when different controllers share point names (e.g. AI_1, AI_2)
var pointName = controllerName + "_" + baseName;
var typeAbbr = toAbbr(objectInfo.objectType);
var objectInstance = objectInfo.objectInstance;
// Use objectInstance directly (1-based, not 0-based)
codeLines.push("var " + pointName + " = " + controllerName + '["' + typeAbbr + '"][' + objectInstance + '].pv;');
}
});
// Add blank line at the end of the variable bundle
if (codeLines.length > 0) {
codeLines.push("");
}
return {
code: codeLines.join("\n"),
hasController: hasController,
controllerLineIndex: null // Will be set in insertCodeIntoEditor
};
}
function insertCodeIntoEditor() {
var codeResult = generateCode();
if (!codeResult || !codeResult.code) return;
var code = codeResult.code;
if (that.editor && typeof that.editor.getValue === 'function') {
try {
var currentCode = that.editor.getValue() || "";
var lines = currentCode.split("\n");
var insertLine = null;
var insertColumn = 1;
// Check if controller already exists and find its position
if (selectedController) {
var controllerInstance = selectedController.deviceInstance || "";
var escapedInstance = String(controllerInstance).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Look for controller declaration in the code
var pattern1 = new RegExp('var\\s+([a-zA-Z_][a-zA-Z0-9_]*)\\s*=\\s*devices\\s*\\[\\s*' + escapedInstance + '\\s*\\]');
var pattern2 = new RegExp('var\\s+([a-zA-Z_][a-zA-Z0-9_]*)\\s*=\\s*devices\\s*\\[\\s*"' + escapedInstance + '"\\s*\\]');
var controllerLineIndex = null;
var controllerVarName = null;
// Search through lines to find controller declaration
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
var match1 = pattern1.exec(line);
var match2 = pattern2.exec(line);
if (match1 || match2) {
controllerLineIndex = i;
controllerVarName = (match1 && match1[1]) || (match2 && match2[1]);
// Reset regex lastIndex for next search
pattern1.lastIndex = 0;
pattern2.lastIndex = 0;
break;
}
}
if (controllerLineIndex !== null && controllerVarName) {
// Controller exists - insert points right after the controller line
// This means new points will appear above any existing points
insertLine = controllerLineIndex + 2; // Line after controller (1-indexed: controllerLineIndex is 0-indexed, +1 for next line, +1 for 1-indexed)
}
}
// If controller not found, insert at the top of the file
if (insertLine === null) {
insertLine = 1; // Insert at the very top (line 1)
insertColumn = 1;
}
// Insert code at the determined position
var range = new monaco.Range(
insertLine,
insertColumn,
insertLine,
insertColumn
);
that.editor.executeEdits("insert-points", [{
range: range,
text: code + "\n"
}]);
// Move cursor after inserted code
var codeLines = code.split("\n");
that.editor.setPosition({
lineNumber: insertLine + codeLines.length,
column: 1
});
that.editor.focus();
} catch (e) {
// Fallback: append to end
var currentCode = that.editor.getValue();
var newCode = currentCode + (currentCode ? "\n\n" : "") + code;
that.editor.setValue(newCode);
}
}
}
// Event handlers
$("#node-fn-refresh").on("click", loadControllers);
$("#node-fn-search").on("input", function () {
renderFilteredControllers(lastDevices, this.value, lastControllerDeviceKeysSet, null);
});
$("#node-fn-point-back").on("click", closePointSelector);
$("#node-fn-point-done").on("click", function () {
insertCodeIntoEditor();
closePointSelector();
// Switch back to the Code tab after inserting points
tabs.activateTab("node-fn-tab-code");
});
$("#node-fn-point-selectall").on("click", function () {
selectedPoints.clear();
// Only select points that are currently visible in the filtered list
currentFilteredPoints.forEach(function (objectInfo) {
selectedPoints.add(String(objectInfo.objectType) + "|" + String(objectInfo.objectInstance));
});
$("#node-fn-point-count").text(selectedPoints.size + " selected");
renderPointsList(currentPointsCache, $("#node-fn-point-search").val(), currentDeviceKeysSet, currentDevicesObj);
});
$("#node-fn-point-clear").on("click", function () {
selectedPoints.clear();
$("#node-fn-point-count").text("0 selected");
renderPointsList(currentPointsCache, $("#node-fn-point-search").val(), currentDeviceKeysSet, currentDevicesObj);
});
$("#node-fn-point-search").on("input", function () {
renderPointsList(currentPointsCache, this.value, currentDeviceKeysSet, currentDevicesObj);
});
// Initialize
$("#node-fn-list").html("Select an OCN Server and click refresh to list controllers.");
var configId = $("#node-input-ocn_server_node").val() || "";
if (configId) {
// Wait a bit longer on initial load to allow runtime to initialize
setTimeout(function () {
loadControllers();
}, 200);
}
// Refresh controllers after deployment (same fn reference removed in oneditsave/oneditcancel)
that._deployHandler = function () {
if (that && that.id && $("#node-input-ocn_server_node").val()) {
setTimeout(function () {
if ($("#node-input-ocn_server_node").val()) {
loadControllers(0);
}
}, 500);
}
};
RED.events.on("deploy", that._deployHandler);
},
oneditsave: function () {
if (this._deployHandler && typeof RED.events.off === "function") {
RED.events.off("deploy", this._deployHandler);
delete this._deployHandler;
}
if (this._originalValidateEdit && RED.editor && RED.editor.validate) {
RED.editor.validate.edit = this._originalValidateEdit;
delete this._originalValidateEdit;
}
let code = this.editor.getValue();
console.log(code);
$("#node-input-func").val(code);
this.editor.dispose();
delete this.editor;
},
oneditcancel: function () {
if (this._deployHandler && typeof RED.events.off === "function") {
RED.events.off("deploy", this._deployHandler);
delete this._deployHandler;
}
if (this._originalValidateEdit && RED.editor && RED.editor.validate) {
RED.editor.validate.edit = this._originalValidateEdit;
delete this._originalValidateEdit;
}
if (this.editor) {
try { this.editor.dispose(); } catch (e) {}
delete this.editor;
}
},
oneditresize: function (size) {
var height = size.height;
$("#node-input-func-editor").css("height", (height - 83)+"px");
if (this.editor && typeof this.editor.getValue === 'function') {
let code = this.editor.getValue();
this.editor.dispose();
delete this.editor;
initEditor(this, code);
}
},
onadd: function () {
}
});
</script>
<!-- <script type="text/javascript">
(function() {
var invalidModuleVNames = [
'console',
'util',
'Buffer',
'Date',
'RED',
'node',
'__node__',
'context',
'flow',
'global',
'env',
'setTimeout',
'clearTimeout',
'setInterval',
'clearInterval',
'promisify'
]
var knownFunctionNodes = {};
RED.events.on("nodes:add", function(n) {
if (n.type === "function") {
knownFunctionNodes[n.id] = n;
}
})
RED.events.on("nodes:remove", function(n) {
if (n.type === "function") {
delete knownFunctionNodes[n.id];
}
})
var missingModules = [];
var missingModuleReasons = {};
RED.events.on("runtime-state", function(event) {
if (event.error === "missing-modules") {
missingModules = event.modules.map(function(m) { missingModuleReasons[m.module] = m.error; return m.module });
for (var id in knownFunctionNodes) {
if (knownFunctionNodes.hasOwnProperty(id) && knownFunctionNodes[id].libs && knownFunctionNodes[id].libs.length > 0) {
RED.editor.validateNode(knownFunctionNodes[id])
}
}
} else if (!event.text) {
missingModuleReasons = {};
missingModules = [];
for (var id in knownFunctionNodes) {
if (knownFunctionNodes.hasOwnProperty(id) && knownFunctionNodes[id].libs && knownFunctionNodes[id].libs.length > 0) {
RED.editor.validateNode(knownFunctionNodes[id])
}
}
}
RED.view.redraw();
});
var installAllowList = ['*'];
var installDenyList = [];
var modulesEnabled = true;
if (RED.settings.get('externalModules.modules.allowInstall', true) === false) {
modulesEnabled = false;
}
var settingsAllowList = RED.settings.get("externalModules.modules.allowList")
var settingsDenyList = RED.settings.get("externalModules.modules.denyList")
if (settingsAllowList || settingsDenyList) {
installAllowList = settingsAllowList;
installDenyList = settingsDenyList
}
installAllowList = RED.utils.parseModuleList(installAllowList);
installDenyList = RED.utils.parseModuleList(installDenyList);
// object that maps from library name to its descriptor
var allLibs = [];
function moduleName(module) {
var match = /^([^@]+)@(.+)/.exec(module);
if (match) {
return [match[1], match[2]];
}
return [module, undefined];
}
function getAllUsedModules() {
var moduleSet = new Set();
for (var id in knownFunctionNodes) {
if (knownFunctionNodes.hasOwnProperty(id)) {
if (knownFunctionNodes[id].libs) {
for (var i=0, l=knownFunctionNodes[id].libs.length; i<l; i++) {
if (RED.utils.checkModuleAllowed(knownFunctionNodes[id].libs[i].module,null,installAllowList,installDenyList)) {
moduleSet.add(knownFunctionNodes[id].libs[i].module);
}
}
}
}
}
var modules = Array.from(moduleSet);
modules.sort();
return modules;
}
function prepareLibraryConfig(node) {
$(".node-input-libs-row").show();
var usedModules = getAllUsedModules();
var typedModules = usedModules.map(function(l) {
return {icon:"fa fa-cube", value:l,label:l,hasValue:false}
})
typedModules.push({
value:"_custom_", label:RED._("editor:subflow.licenseOther"), icon:"red/images/typedInput/az.svg"
})
var libList = $("#node-input-libs-container").css('min-height','100px').css('min-width','450px').editableList({
header: $('<div><div data-i18n="node-red:function.require.moduleName"></div><div data-i18n="node-red:function.require.importAs"></div></div>'),
addItem: function(container,i,opt) {
var parent = container.parent();
var row0 = $("<div/>").addClass("node-libs-entry").appendTo(container);
var fmoduleSpan = $("<span>").appendTo(row0);
var fmodule = $("<input/>", {
class: "node-input-libs-val",
placeholder: RED._("node-red:function.require.module"),
type: "text"
}).css({
}).appendTo(fmoduleSpan).typedInput({
types: typedModules,
default: usedModules.indexOf(opt.module) > -1 ? opt.module : "_custom_"
});
if (usedModules.indexOf(opt.module) === -1) {
fmodule.typedInput('value', opt.module);
}
var moduleWarning = $('<span style="position: absolute;right:2px;top:7px; display:inline-block; width: 16px;"><i class="fa fa-warning"></i></span>').appendTo(fmoduleSpan);
RED.popover.tooltip(moduleWarning.find("i"),function() {
var val = fmodule.typedInput("type");
if (val === "_custom_") {
val = fmodule.val();
}
var errors = [];
if (!RED.utils.checkModuleAllowed(val,null,installAllowList,installDenyList)) {
return RED._("node-red:function.error.moduleNotAllowed",{module:val});
} else {
return RED._("node-red:function.error.moduleLoadError",{module:val,error:missingModuleReasons[val]});
}
})
var fvarSpan = $("<span>").appendTo(row0);
var fvar = $("<input/>", {
class: "node-input-libs-var red-ui-font-code",
placeholder: RED._("node-red:function.require.var"),
type: "text"
}).css({
}).appendTo(fvarSpan).val(opt.var);
var vnameWarning = $('<span style="position: absolute; right:2px;top:7px;display:inline-block; width: 16px;"><i class="fa fa-warning"></i></span>').appendTo(fvarSpan);
RED.popover.tooltip(vnameWarning.find("i"),function() {
var val = fvar.val();
if (invalidModuleVNames.indexOf(val) !== -1) {
return RED._("node-red:function.error.moduleNameReserved",{name:val})
} else {
return RED._("node-red:function.error.moduleNameError",{name:val})
}
})
fvar.on("change keyup paste", function (e) {
var v = $(this).val().trim();
if (v === "" || / /.test(v) || invalidModuleVNames.indexOf(v) !== -1) {
fvar.addClass("input-error");
vnameWarning.addClass("input-error");
} else {
fvar.removeClass("input-error");
vnameWarning.removeClass("input-error");
}
});
fmodule.on("change keyup paste", function (e) {
var val = $(this).typedInput("type");
if (val === "_custom_") {
val = $(this).val();
}
if (val == null) val = "";
var varName = String(val || "").trim().replace(/^@/,"").replace(/@.*$/,"").replace(/[-_/\.].?/g, function(v) { return v[1]?v[1].toUpperCase():"" });
fvar.val(varName);
fvar.trigger("change");
if (RED.utils.checkModuleAllowed(val,null,installAllowList,installDenyList) && (missingModules.indexOf(val) === -1)) {
fmodule.removeClass("input-error");
moduleWarning.removeClass("input-error");
} else {
fmodule.addClass("input-error");
moduleWarning.addClass("input-error");
}
});
if (RED.utils.checkModuleAllowed(opt.module,null,installAllowList,installDenyList) && (missingModules.indexOf(opt.module) === -1)) {
fmodule.removeClass("input-error");
moduleWarning.removeClass("input-error");
} else {
fmodule.addClass("input-error");
moduleWarning.addClass("input-error");
}
if (opt.var) {
fvar.trigger("change");
}
},
removable: true
});
var libs = node.libs || [];
for (var i=0,l=libs.length;i<l; i++) {
libList.editableList('addItem',libs[i])
}
}
function getLibsList() {
var _libs = [];
if (RED.settings.functionExternalModules !== false) {
var libs = $("#node-input-libs-container").editableList("items");
libs.each(function(i) {
var item = $(this);
var v = item.find(".node-input-libs-var").val();
var n = item.find(".node-input-libs-val").typedInput("type");
if (n === "_custom_") {
n = item.find(".node-input-libs-val").val();
}
if ((!v || (v === "")) ||
(!n || (n === ""))) {
return;
}
_libs.push({
var: v,
module: n
});
});
}
return _libs;
}
// Duplicate registration removed - using the one at line 492 instead
// This entire block was removed to prevent redeclaration errors
</script> -->