UNPKG

@strato-automation/node-red-contrib-strato-automation

Version:
1,879 lines 104 kB
<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> -->