/************************************************************* * Getestet für: node-opcua-client 2.158.0 (ioBroker javascript Adapter) *************************************************************/ /************************************************************* * 1) GLOBAL-KONFIGURATION *************************************************************/ // OPC-UA Endpoint const ENDPOINT = 'opc.tcp://192.168.1.20:4840'; // Debugoptionen const DEBUG_OPCUA = false; // Debug-Ausgaben aktivieren const WRITE_TRACE = false; // Write-Trace aktivieren // Namespace-URIs (Siemens TIA) const NS_URI_SIEMENS = 'http://www.siemens.com/simatic-s7-opcua'; const NS_URI_APP = 'http://OPCUA_Server'; // Sammlungen / Datenbanken const BASE_FOLDER = '0_userdata.0.OPCUA'; const Folder = BASE_FOLDER + '.'; /************************************************************* * COLLECTIONS – Mapping zwischen S7 OPC UA und ioBroker * * Jede Collection beschreibt: * → Welche S7-DB gelesen wird * → Wohin die Daten in ioBroker geschrieben werden * → Welche Unterbereiche (Areas) verwendet werden * * Aufbau: * { * name: Interner Name (nur für Logs) * statePrefix: Zielpfad in ioBroker * dbName: Name der S7 DB im OPC UA Server * areas: Unterbereiche der DB * } * *************************************************************/ /* * Erklärung der Felder: * * name: * → Nur für Logging/Debug * → Hat KEINEN Einfluss auf ioBroker * * statePrefix: * → Zielpfad in ioBroker * → Beispiel: * Folder = '0_userdata.0.OPCUA.' * → ergibt: * 0_userdata.0.OPCUA.Fenster * * dbName: * → Muss exakt dem DB-Namen im TIA Portal entsprechen * → Pfad im OPC UA: * Objects → ServerInterfaces → OPCUA_Server → * * areas: * → Unterbereiche innerhalb des DB * * Varianten: * * '*' → Auto-Discovery (empfohlen zum Testen) * Script erkennt automatisch alle Bereiche * * ['EG','OG'] → nur diese Bereiche verwenden (stabiler) * * Beispiel S7: * DB_Licht_EG * ├── Wohnzimmer * ├── Küche * * → ioBroker: * 0_userdata.0.OPCUA.Licht.EG.Wohnzimmer.* * 0_userdata.0.OPCUA.Licht.EG.Küche.* * * * Neue Collection hinzufügen: * * Beispiel: * DB_Temperatur * ├── Wohnzimmer * ├── Schlafzimmer * * → Collection: * * { * name: 'Temperatur', * statePrefix: Folder + 'Temperatur', * dbName: 'DB_Temperatur', * areas: '*' * } * */ const COLLECTIONS = [ { name: 'Taster', statePrefix: Folder + 'Eingänge', dbName: 'DB_Taster', areas: '*' }, { name: 'Zeit_Wetter', statePrefix: Folder + 'Zeit_Wetter_s7', dbName: 'DB_IOB_ZW', areas: '*' }, { name: 'SMA', statePrefix: Folder + 'SMA', dbName: 'DB_SMA_Read', areas: '*' }, { name: 'S7_LS', statePrefix: Folder + 'S7_LS', dbName: 'DB_ModbusData_LS', areas: '*' }, { name: 'Error_Meldungen', statePrefix: Folder + 'Error_Meldungen', dbName: 'DB_Meldungen', areas: '*' }, ]; // -> Zugangsdaten für OPC-UA-Server, falls du Security/UserAuth aktivierst (User/Passwort Login am Server) const USER = { userName: 'User', password: 'Password' }; // Write-Debounce // -> Mindestabstand zwischen zwei Writes auf denselben ioBroker-State (verhindert Spam/Doppelklicks) const WRITE_DEBOUNCE_MS = 250; // Browsing Limits // -> Begrenzung fürs OPC-UA-Browsing (Discovery), damit das Skript nicht “zu tief”/“zu viel” durchsucht const MAX_BROWSE_DEPTH = 4; // maximale Ordner-/Knoten-Tiefe beim Durchsuchen const MAX_BROWSE_NODES = 4000; // maximale Anzahl Nodes, die insgesamt gebrowst werden dürfen // Monitoring-Intervall // -> Sampling-Rate der OPC-UA-MonitoredItems (wie oft Änderungen/Values geprüft/geliefert werden) const MONITOR_SAMPLING_MS = 250; // Reconnect Backoff // -> Wartezeiten bei Reconnect-Versuchen (Backoff), um Server/Netz nicht zu fluten const RECONNECT_BASE_MS = 2000; // Start-Wartezeit nach erstem Disconnect const RECONNECT_MAX_MS = 60000; // maximale Wartezeit zwischen Reconnect-Versuchen const RECONNECT_JITTER_MS = 500; // Zufallsanteil (+/-) zur Entzerrung (gegen “Reconnect-Sync”) const RECONNECT_MAX_TRIES = 10; // Max. Reconnect-Versuche pro Runde (0 = unendlich) // Pause nach Abbruch einer Runde const RECONNECT_PAUSE_MS = 30 * 60 * 1000; // 30 Minuten // Translate-Rate-Limit // -> Mindestabstand zwischen Translate/Browse-Aufrufen (NodeId/URI-Auflösung), um Last/Spam zu vermeiden const TRANSLATE_MIN_GAP = 300; // Anti-Loop Zeitfenster (ms) – nur kurz nach User-Write aktiv // -> Zeitraum nach einem User-Write, in dem Readback/Abweichungen als “Round/Anti-Loop” behandelt werden const ANTI_LOOP_WINDOW_MS = 1500; // Telegram // -> Schaltet Telegram-Benachrichtigungen ein/aus (z.B. bei Verbindungsproblemen/Reconnect/Failsafe) const TELEGRAM_ENABLED = true; // -> Ab wie vielen aufeinanderfolgenden Fehlerevents (z.B. Reconnect/Write/Session-Probleme) eine Telegram gesendet wird const TELEGRAM_ON_FAILS = 4; const TELEGRAM_INSTANCE = 0; const TELEGRAM_USERS = 'User'; function tg(text) { try { if (!TELEGRAM_ENABLED) return; sendTo(`telegram.${TELEGRAM_INSTANCE}`, 'send', { text, user: TELEGRAM_USERS }); } catch (e) { log(`Telegram-Fehler: ${e.message}`, 'warn'); } } /************************************************************* * 2) CACHE-SYSTEM (Hit/Miss + Namespace Tracking) *************************************************************/ const CACHE_ROOT = `${BASE_FOLDER}._Cache`; const CACHE_NODEIDS_ID = `${CACHE_ROOT}.NodeIds`; const CACHE_MISS_ID = `${CACHE_ROOT}.MissKeys`; const CACHE_NSARRAY_ID = `${CACHE_ROOT}.NamespaceArray`; const NS_WATCH_INTERVAL = 60000; // Cache-In-Memory let nodeIdCache = {}; let missCache = new Set(); let nsUris = []; async function ensureCacheStates() { if (!existsState(CACHE_NODEIDS_ID)) await createStateAsync(CACHE_NODEIDS_ID, { type: 'string', role: 'json', write: true, def: '{}' }); if (!existsState(CACHE_MISS_ID)) await createStateAsync(CACHE_MISS_ID, { type: 'string', role: 'json', write: true, def: '[]' }); if (!existsState(CACHE_NSARRAY_ID)) await createStateAsync(CACHE_NSARRAY_ID, { type: 'string', role: 'json', write: true, def: '[]' }); } function readJsonState(id, fallback) { try { const s = getState(id)?.val; return typeof s === 'string' ? JSON.parse(s) : fallback; } catch { return fallback; } } function writeJsonState(id, obj) { setState(id, JSON.stringify(obj), true); } function cacheKey(db, area) { return `${db}/${area}`; } function cachePut(db, area, nodeId) { const key = cacheKey(db, area); nodeIdCache[key] = nodeId; writeJsonState(CACHE_NODEIDS_ID, nodeIdCache); if (missCache.has(key)) { missCache.delete(key); writeJsonState(CACHE_MISS_ID, Array.from(missCache)); } } function cacheMiss(db, area) { const key = cacheKey(db, area); missCache.add(key); writeJsonState(CACHE_MISS_ID, Array.from(missCache)); } function cacheGet(db, area) { return nodeIdCache[cacheKey(db, area)] || null; } function cacheClearAll() { nodeIdCache = {}; missCache = new Set(); writeJsonState(CACHE_NODEIDS_ID, {}); writeJsonState(CACHE_MISS_ID, []); } /************************************************************* * 3) IMPORTS – DEBUG-SAFE & TS-SAFE *************************************************************/ // Wir casten als "any", damit ioBroker/VSCode keine Fehler wirft /** @type {any} */ const opcua = require('node-opcua-client'); /** @type {any} */ const dyn = require('node-opcua-client-dynamic-extension-object'); /** @type {any} */ const nodeopcua = require('node-opcua'); // OpcUa Client Funktionen const { OPCUAClient, AttributeIds, MessageSecurityMode, SecurityPolicy, TimestampsToReturn, StatusCodes, BrowseDirection, NodeClassMask, ResultMask, } = opcua; // ExtensionObject Handling const { getExtraDataTypeManager, populateDataTypeManager, getExtensionObjectConstructor, resolveDynamicExtensionObject, } = dyn; // Datentypen & Tools const { DataType, Variant, makeBrowsePath } = nodeopcua; function opcuaStartupSelftest() { try { const paths = Object.keys(require.cache || {}) .filter(p => p.includes('node-opcua')); const uniqRoots = new Set( paths.map(p => p.split('node-opcua')[0]) ); if (uniqRoots.size > 1) { log( '[OPCUA][SELFTEST][WARN] node-opcua mehrfach geladen! Pfade:\n' + Array.from(uniqRoots).join('\n'), 'error' ); } else { log('[OPCUA][SELFTEST] node-opcua Single-Load OK', 'info'); } } catch (e) { log('[OPCUA][SELFTEST] Fehler: ' + e.message, 'warn'); } } /************************************************************* * 4) RUNTIME-STRUKTUREN PRO (je Collection) *************************************************************/ for (const c of COLLECTIONS) { // Statuspfad c.statusPrefix = `${c.statePrefix}._Status`; // Schreib-Regex: // - matcht sowohl: . // - als auch: .. const prefixEsc = c.statePrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); c.rxWrite = new RegExp('^' + prefixEsc + '\\.[^.]+(\\..+)?$'); // NodeId der Area-Struktur c.structNodes = {}; // TypeConstructor für ExtensionObject c.typeCtors = {}; // vorheriges Plain-Objekt (für DIFF) c.prevPlain = new Map(); } /************************************************************* * 5) NAMESPACE-HANDLING PRO *************************************************************/ let nsPollTimer = null; let lastTranslateTs = 0; // Frische Namespace-Array lesen async function refreshNamespaces(session) { nsUris = await session.readNamespaceArray(); writeJsonState(CACHE_NSARRAY_ID, nsUris); if (DEBUG_OPCUA) { log(`[DBG] NamespaceArray aktualisiert: ${JSON.stringify(nsUris)}`, 'info'); } } function namespacesChanged(oldArr, newArr) { if (!Array.isArray(oldArr) || !Array.isArray(newArr)) return true; if (oldArr.length !== newArr.length) return true; for (let i = 0; i < newArr.length; i++) if (oldArr[i] !== newArr[i]) return true; return false; } // Namespace-Index bestimmen function nsIdxByUriLocal(uri) { const idx = nsUris.indexOf(uri); if (idx < 0) throw new Error(`Namespace-URI nicht gefunden: ${uri}`); return idx; } /************************************************************* * 6) THROTTLED TRANSLATE * Verhindert, dass Siemens-OPCUA bei vielen translateBrowsePath * Anfragen überlastet → stabilisiert den Resolve-Prozess. *************************************************************/ async function throttledTranslate(session, browsePath) { const now = Date.now(); const diff = now - lastTranslateTs; if (diff < TRANSLATE_MIN_GAP) await new Promise(res => setTimeout(res, TRANSLATE_MIN_GAP - diff)); const res = await session.translateBrowsePath(browsePath); lastTranslateTs = Date.now(); if (DEBUG_OPCUA) { log(`[DBG] translateBrowsePath → ${JSON.stringify(res.targets || [])}`, 'debug'); } return res; } /************************************************************* * 7) BROWSING-ENGINE PRO * - URI-based precise path resolution * - Fuzzy fallback * - Debug-Ausgaben für Entscheidungswege *************************************************************/ // Namen sicher quoten function qp(name) { return /[^A-Za-z0-9_]/.test(name) ? `"${String(name).replace(/"/g, '""')}"` : String(name); } // Präzise Pfadauflösung via Namespace-URIs async function translateFromObjectsByURIs(path /* [ [uri,name], ... ] */) { const segs = path.map(([uri, name]) => `${nsIdxByUriLocal(uri)}:${qp(name)}`); const rel = '/' + segs.join('/'); const bp = makeBrowsePath('ObjectsFolder', rel); if (DEBUG_OPCUA) { log(`[DBG] translateFromObjectsByURIs → ${rel}`, 'info'); } const res = await throttledTranslate(session, bp); const target = res?.targets?.[0]?.targetId; return target ? target.toString() : null; } // Kinder browsen (Object/Variable) async function browseChildren(nodeId) { const res = await session.browse({ nodeId, browseDirection: BrowseDirection.Forward, includeSubtypes: true, referenceTypeId: 'i=33', // HierarchicalReferences nodeClassMask: NodeClassMask.Object | NodeClassMask.Variable, resultMask: ResultMask.BrowseName | ResultMask.DisplayName | ResultMask.NodeClass, }); if (DEBUG_OPCUA) { log(`[DBG] browseChildren(${nodeId}) → ${res.references?.length || 0} Kinder`, 'debug'); } return (res.references || []).map(r => ({ nodeId: r.nodeId.toString(), browseName: r.browseName?.name || '', displayName: r.displayName?.text || '', nodeClass: r.nodeClass, })); } // Normalisierter Name für Fuzzy-Suche const norm = s => String(s || '') .toLowerCase() .replace(/[\s_]+/g, ''); // Fuzzy-Matching für Namen function findChildFuzzy(children, wanted) { const w = norm(wanted); // 1) exakte Übereinstimmung let hit = children.find(ch => norm(ch.browseName) === w || norm(ch.displayName) === w); if (hit) { if (DEBUG_OPCUA) log(`[DBG][Fuzzy] Exakt gefunden: ${wanted}`, 'debug'); return hit; } // 2) partielle Übereinstimmung hit = children.find(ch => norm(ch.browseName).includes(w) || norm(ch.displayName).includes(w)); if (hit) { if (DEBUG_OPCUA) log(`[DBG][Fuzzy] Teiltreffer: ${wanted}`, 'debug'); return hit; } if (DEBUG_OPCUA) log(`[DBG][Fuzzy] Nichts gefunden für: ${wanted}`, 'debug'); return null; } // Einen Schritt tiefer browsen async function stepByNameFuzzy(curNodeId, name) { const kids = await browseChildren(curNodeId); const hit = findChildFuzzy(kids, name); return hit ? hit.nodeId : null; } // Erste sinnvolle Variable unterhalb einer Struktur finden async function findFirstVariableDescendant(rootNodeId, maxDepth = MAX_BROWSE_DEPTH) { const queue = [{ id: rootNodeId, depth: 0 }]; const visited = new Set([String(rootNodeId)]); let scanned = 0; while (queue.length && scanned < MAX_BROWSE_NODES) { const cur = queue.shift(); scanned++; const kids = await browseChildren(cur.id); for (const k of kids) { const key = k.nodeId; if (visited.has(key)) continue; visited.add(key); // Variable gefunden → prüfen ob sie ECHTEN Wert hat if (k.nodeClass === 2 /* Variable */) { const dv = await session.read({ nodeId: k.nodeId, attributeId: AttributeIds.Value, }); if (dv.statusCode === StatusCodes.Good && dv.value?.dataType !== DataType.Null) { if (DEBUG_OPCUA) { log(`[DBG] variable descendant found: ${k.nodeId}`, 'info'); } return k.nodeId; } } // tiefer browsen if (cur.depth < maxDepth && k.nodeClass === 1 /* Object */) { queue.push({ id: k.nodeId, depth: cur.depth + 1 }); } } } return null; } // Prüfen ob NodeId gültig async function verifyNodeIdExists(nodeId) { try { const dv = await session.read({ nodeId, attributeId: AttributeIds.BrowseName, }); return dv.statusCode === StatusCodes.Good; } catch { return false; } } /************************************************************* * 8) DB-AUFBAU PRO (URI + Fuzzy Fallback) *************************************************************/ function buildBasePath(dbName) { return [ [NS_URI_SIEMENS, 'ServerInterfaces'], [NS_URI_APP, 'OPCUA_Server'], [NS_URI_APP, dbName], ]; } async function resolveDbNodeId(c) { // 1) Präziser Versuch let nodeId = await translateFromObjectsByURIs(buildBasePath(c.dbName)); if (nodeId) { if (DEBUG_OPCUA) log(`[DBG] DB ${c.dbName} via URI gefunden → ${nodeId}`, 'info'); return nodeId; } // 2) Fallback Fuzzy Browsing let cur = 'ObjectsFolder'; for (const seg of ['ServerInterfaces', 'OPCUA_Server']) { const next = await stepByNameFuzzy(cur, seg); if (!next) throw new Error(`BrowsePath nicht gefunden: ${seg}`); cur = next; } const kids = await browseChildren(cur); const hit = findChildFuzzy(kids, c.dbName); if (!hit) { const available = kids.map(k => k.browseName).join(', '); log(`[${c.name}] DB "${c.dbName}" nicht gefunden. Vorhanden: ${available}`, 'warn'); throw new Error(`DB ${c.dbName} nicht gefunden`); } if (DEBUG_OPCUA) log(`[DBG] DB ${c.dbName} via fuzzy found → ${hit.nodeId}`, 'info'); return hit.nodeId; } /************************************************************* * 9) AREA-AUFLÖSUNG PRO *************************************************************/ async function resolveAreasForCollection(c) { try { // 1) DB-NodeId ermitteln const dbId = await resolveDbNodeId(c); // 2) Auto-Discovery der Areas if (c.areas === '*' || (Array.isArray(c.areas) && c.areas[0] === '*')) { const kids = await browseChildren(dbId); c.areas = kids.map(k => k.browseName).filter(Boolean); log(`[${c.name}] Auto-Discovery Areas: ${c.areas.join(', ')}`, 'info'); } // 3) Jede Area einzeln pfadauflösen for (const area of c.areas) { const key = cacheKey(c.dbName, area); const cached = cacheGet(c.dbName, area); // Falls Area in Miss-Cache → überspringen if (missCache.has(key)) { if (DEBUG_OPCUA) log(`[DBG][AreaResolve] MISS Cache aktiv → ${c.dbName}/${area}`, 'debug'); continue; } // Cache Treffer prüfen if (cached && (await verifyNodeIdExists(cached))) { if (DEBUG_OPCUA) log(`[DBG][AreaResolve] CACHE HIT ${c.dbName}/${area} → ${cached}`, 'info'); c.structNodes[area] = cached; continue; } // Cache Miss → neu suchen missCache.delete(key); writeJsonState(CACHE_MISS_ID, Array.from(missCache)); const segs = String(area).split('/').filter(Boolean); // 1) Präzise Pfadauflösung let nodeId = await translateFromObjectsByURIs( buildBasePath(c.dbName).concat(segs.map(s => [NS_URI_APP, s])), ); // 2) Fallback fuzzy Auflösung if (!nodeId) { let cur = dbId; let ok = true; for (const seg of segs) { const next = await stepByNameFuzzy(cur, seg); if (!next) { ok = false; break; } cur = next; } nodeId = ok ? cur : null; } if (!nodeId) { log(`[${c.name}] Area "${area}" nicht gefunden unter DB "${c.dbName}"`, 'warn'); cacheMiss(c.dbName, area); continue; } // Prüfen ob diese NodeId selbst kein Value hat → ggf. Kindvariable finden const dv = await session.read({ nodeId, attributeId: AttributeIds.Value }); if (dv.value?.dataType === DataType.Null) { const varNode = await findFirstVariableDescendant(nodeId); if (varNode) { if (DEBUG_OPCUA) log(`[DBG] Area ${area} hatte Null-Datentyp, ersetze durch Variable ${varNode}`, 'debug'); nodeId = varNode.toString(); } } c.structNodes[area] = nodeId; cachePut(c.dbName, area, nodeId); if (DEBUG_OPCUA) log(`[DBG][AreaResolve] ${c.name}.${area} → resolved NodeId ${nodeId}`, 'info'); } } catch (e) { log(`[${c.name}] Area-Resolve Fehler: ${e.message}`, 'error'); } } /************************************************************* * 10) TYPE CONSTRUCTOR (ExtensionObject Builder) *************************************************************/ async function primeTypeCtor(c, area, nodeId) { const dv = await session.read({ nodeId, attributeId: AttributeIds.Value, }); try { resolveDynamicExtensionObject(dv.value, extraDTM); const plain = toPlainObject(dv.value.value); if (DEBUG_OPCUA) { log(`[DBG][TypeCtor] ${c.name}.${area} → decode OK`, 'debug'); } if (plain && typeof plain === 'object' && !Array.isArray(plain)) { const dt = await session.read({ nodeId, attributeId: AttributeIds.DataType, }); try { c.typeCtors[area] = await getExtensionObjectConstructor(session, dt.value.value, extraDTM); if (DEBUG_OPCUA) log(`[DBG][TypeCtor] Constructor geladen für ${c.name}.${area}`, 'info'); } catch (err) { c.typeCtors[area] = null; if (DEBUG_OPCUA) log(`[DBG][TypeCtor] KEIN Constructor für ${c.name}.${area}`, 'debug'); } } else { c.typeCtors[area] = null; } } catch (e) { c.typeCtors[area] = null; if (DEBUG_OPCUA) log(`[DBG][TypeCtor] decode FEHLER bei ${c.name}.${area}: ${e.message}`, 'warn'); } } /************************************************************* * 11) STRUCTURE DECODER → JS-PlainObject *************************************************************/ function toPlainObject(ext) { if (ext === undefined || ext === null) return null; // SPS liefert oft toJSON() if (typeof ext.toJSON === 'function') { try { return ext.toJSON(); } catch { } } // JSON Fallback try { const s = JSON.stringify(ext); if (s && s !== '{}' && s !== '[]') return JSON.parse(s); } catch { } // Manuelles Auflösen if (typeof ext === 'object' && !Array.isArray(ext) && !(ext instanceof Date)) { const out = {}; for (const key of Object.keys(ext)) { try { const v = ext[key]; if (v === undefined) continue; if (v === null || typeof v !== 'object' || v instanceof Date) out[key] = v; else if (Array.isArray(v)) out[key] = v.map(x => (x && typeof x === 'object' ? toPlainObject(x) : x)); else { const sub = toPlainObject(v); if (sub !== null) out[key] = sub; } } catch { } } return Object.keys(out).length ? out : null; } return ext; } /************************************************************* * 12) WALK-LEAVES → flache Liste aller Felder *************************************************************/ function* walkLeaves(obj, prefix = []) { if (obj === null || obj === undefined) return; if (obj instanceof Date) { yield [prefix, obj]; return; } if (typeof obj !== 'object' || Array.isArray(obj)) { yield [prefix, obj]; return; } for (const [key, val] of Object.entries(obj)) { if (val && typeof val === 'object' && !(val instanceof Date) && !Array.isArray(val)) { yield* walkLeaves(val, prefix.concat(key)); } else { yield [prefix.concat(key), val]; } } } /************************************************************* * 13) INITIAL READ PRO – States anlegen und setzen *************************************************************/ async function initialReadAndCreateStates(c, area, nodeId) { try { const dv = await session.read({ nodeId, attributeId: AttributeIds.Value, }); // AccessLevel lesen → entscheidet, ob der State schreibbar ist let canWrite = true; // Default: wie bisher (write: true) try { const acc = await session.read({ nodeId, attributeId: AttributeIds.AccessLevel, }); if (acc && acc.value && typeof acc.value.value === 'number') { const accessRaw = acc.value.value; // OPC UA Bitmaske // Bit 0x02 = CurrentWrite canWrite = (accessRaw & 0x02) !== 0; if (DEBUG_OPCUA) { log( `[DBG][AccessLevel] ${c.name}.${area} accessLevel=${accessRaw} → canWrite=${canWrite}`, 'debug', ); } } else if (DEBUG_OPCUA) { log( `[DBG][AccessLevel] ${c.name}.${area} kein numerischer accessLevel gefunden → Fallback canWrite=true`, 'debug', ); } } catch (e) { if (DEBUG_OPCUA) { log( `[DBG][AccessLevel] Fehler beim Lesen von accessLevel für ${c.name}.${area}: ${e.message} → Fallback canWrite=true`, 'debug', ); } // canWrite bleibt true → Verhalten wie vorher } if (!c.canWrite) c.canWrite = {}; c.canWrite[area] = canWrite; if (DEBUG_OPCUA) dumpVariantInfo(`${c.name}.${area} initial`, dv); resolveDynamicExtensionObject(dv.value, extraDTM); const obj = toPlainObject(dv.value.value); // --- NEU: Leere / nichtssagende SMA-Strukturen ignorieren --- if (typeof obj === 'object' && !Array.isArray(obj)) { const hasRealValues = Object.values(obj).some(v => v !== null && v !== undefined); if (!hasRealValues) { if (DEBUG_OPCUA) { log(`[DBG][InitialRead] ${c.name}.${area} liefert nur Null/Leerwerte – übersprungen`, 'debug'); } // Snapshot ebenfalls leeren, falls vorhanden const snapId = `${c.statePrefix}._Snapshot.${area}`; if (existsState(snapId)) setState(snapId, '', true); return; } } // A) Einfacher Wert → direkt schreiben if (typeof obj !== 'object' || Array.isArray(obj) || obj instanceof Date) { const id = `${c.statePrefix}.${area}`; if (!existsState(id)) { const defValSimple = obj !== null && obj !== undefined ? toStateValue(obj) : undefined; await createStateAsync(id, { type: leafStateType(obj), role: leafRole(obj), read: true, write: canWrite, ...(defValSimple !== undefined ? { def: defValSimple } : {}), }); } if (obj !== null && obj !== undefined) { setState(id, toStateValue(obj), true); } setSnapshot(c, area, obj); return; } // B) Struktur → States erzeugen for (const [pathArr, valLeaf] of walkLeaves(obj)) { const id = `${c.statePrefix}.${area}.${pathArr.join('.')}`; if (!existsState(id)) { const defVal = valLeaf !== null && valLeaf !== undefined ? toStateValue(valLeaf) : undefined; await createStateAsync(id, { type: leafStateType(valLeaf), role: leafRole(valLeaf), read: true, write: canWrite, ...(defVal !== undefined ? { def: defVal } : {}), // def nur setzen wenn vorhanden }); } if (valLeaf !== null && valLeaf !== undefined) { setState(id, toStateValue(valLeaf), true); } } c.prevPlain.set(area, obj); setSnapshot(c, area, obj); } catch (e) { log(`InitialRead Fehler bei ${c.name}.${area}: ${e.message}`, 'error'); setState(`${c.statusPrefix}.LastError`, e.message, true); } } /************************************************************* * 14) SNAPSHOT-SYSTEM PRO *************************************************************/ async function setSnapshot(c, area, obj) { // Wenn kein gültiges Objekt → Snapshot NICHT setzen if (obj === null || obj === undefined) { // Falls alter Snapshot existiert → löschen, damit ioBroker ihn NICHT initial schreibt const id = `${c.statePrefix}._Snapshot.${area}`; if (existsState(id)) { setState(id, '', true); // oder: setState(id, "{}", true) // wichtig: KEIN null! } if (DEBUG_OPCUA) { log(`[DBG][Snapshot] Null → Snapshot für ${c.name}.${area} geleert`, 'debug'); } return; } const id = `${c.statePrefix}._Snapshot.${area}`; if (!existsState(id)) { await createStateAsync(id, { type: 'string', role: 'json', read: true, write: false, }); } setState(id, JSON.stringify(obj), true); } /************************************************************* * 15) State-Helfer für Typen *************************************************************/ function leafStateType(v) { if (typeof v === 'boolean') return 'boolean'; if (typeof v === 'number') return 'number'; return 'string'; } function leafRole(v) { if (typeof v === 'boolean') return 'switch'; if (typeof v === 'number') return 'value'; return 'text'; } function toStateValue(v) { if (v instanceof Date) return v.toISOString(); if (Array.isArray(v)) return JSON.stringify(v); return v; } function getByPath(obj, pathArr) { return pathArr.reduce((o, p) => (o && typeof o === 'object' ? o[p] : undefined), obj); } /************************************************************* * 16) SUBSCRIPTION PRO – überwacht alle DB-Variablen *************************************************************/ async function createAndAttachSubscription(reuse = false) { // Alte Subscription prüfen if (reuse && subscription && session) { try { await session.read({ nodeId: 'i=2258', // ServerStatus attributeId: AttributeIds.Value, }); } catch { try { await subscription.terminate(); } catch { } subscription = null; } } // Neue Subscription erzeugen wenn nötig if (!subscription) { subscription = await session.createSubscription2({ requestedPublishingInterval: 250, requestedLifetimeCount: 600, requestedMaxKeepAliveCount: 20, maxNotificationsPerPublish: 100, publishingEnabled: true, priority: 10, }); if (DEBUG_OPCUA) log(`[DBG] Subscription erstellt`, 'info'); } // Für jede Area Observer erstellen for (const c of COLLECTIONS) { for (const [area, nodeId] of Object.entries(c.structNodes)) { const mi = await subscription.monitor( { nodeId, attributeId: AttributeIds.Value }, { samplingInterval: MONITOR_SAMPLING_MS, queueSize: 10, discardOldest: true, }, TimestampsToReturn.Both, ); if (DEBUG_OPCUA) log(`[DBG] Monitoring aktiv: ${c.name}.${area} → ${nodeId}`, 'info'); // Datenänderung mi.on('changed', dv => { if (DEBUG_OPCUA) dumpVariantInfo(`${c.name}.${area} changed`, dv); try { resolveDynamicExtensionObject(dv.value, extraDTM); const obj = toPlainObject(dv.value.value); if (obj === null) return; // Einfacher Wert if (typeof obj !== 'object' || Array.isArray(obj) || obj instanceof Date) { setState(`${c.statePrefix}.${area}`, toStateValue(obj), true); } else { updateStatesFromStruct(c, area, obj); } setSnapshot(c, area, obj); setState(`${c.statusPrefix}.LastChange`, new Date().toISOString(), true); } catch (e) { log(`Decode Fehler bei ${c.name}.${area}: ${e.message}`, 'warn'); } }); mi.on('err', err => log(`MonitoredItem Fehler ${c.name}.${area}: ${err.message}`, 'warn')); } } } /************************************************************* * 17) DIFF-ENGINE PRO – aktualisiert nur geänderte Felder *************************************************************/ function updateStatesFromStruct(c, area, obj) { const prev = c.prevPlain.get(area); // erster Lauf → einfach übernehmen if (!prev) { c.prevPlain.set(area, obj); return; } // Felder vergleichen for (const [pathArr, val] of walkLeaves(obj)) { const oldVal = getByPath(prev, pathArr); const newNorm = normLeaf(val); const oldNorm = normLeaf(oldVal); if (newNorm !== oldNorm) { const id = `${c.statePrefix}.${area}.${pathArr.join('.')}`; if (existsState(id)) { if (DEBUG_OPCUA) log(`[DBG][DIFF] Update ${id} → ${JSON.stringify(val)}`, 'debug'); if (val !== null && val !== undefined) { setState(id, toStateValue(val), true); } } } } c.prevPlain.set(area, obj); } function normLeaf(v) { if (v instanceof Date) return v.toISOString(); if (Array.isArray(v)) return JSON.stringify(v); return v; } /************************************************************* * 18) WRITE ENGINE PRO – robust, mit Auto-Recover & Debug *************************************************************/ let writeHandlersBound = false; const writeInFlight = new Set(); const writeQueued = new Map(); const lastWriteTs = new Map(); function isRecentUserWrite(stateId) { const ts = lastWriteTs.get(stateId); return ts && (Date.now() - ts) < ANTI_LOOP_WINDOW_MS; } function bindWriteHandlersOnce() { if (writeHandlersBound) return; writeHandlersBound = true; for (const c of COLLECTIONS) { // Listener für alle schreibbaren States on({ id: c.rxWrite, change: 'ne' }, async ev => { if (WRITE_TRACE) { log(`[WRITE][EV] ${ev.id} ack=${ev.state && ev.state.ack} val=${JSON.stringify(ev.state && ev.state.val)}`, 'info'); } if (!ev || ev.state.ack) return; const id = ev.id; const newVal = ev.state.val; if (!session) { log('Write verworfen – keine Session aktiv.', 'warn'); return; } const rel = id.slice(c.statePrefix.length + 1); const parts = rel.split('.'); const area = parts.shift(); const pathArr = parts; if (!c.structNodes[area]) return; const now = Date.now(); // Debounce Spam if (now - (lastWriteTs.get(id) || 0) < WRITE_DEBOUNCE_MS) { writeQueued.set(id, newVal); return; } // Läuft bereits? if (writeInFlight.has(id)) { writeQueued.set(id, newVal); return; } // IndexRange (Array-Schreibzugriff) const idxInfo = detectIndexRange(pathArr); if (idxInfo && pathArr.length === 1) { try { await writeArrayIndex(c.structNodes[area], idxInfo.index, newVal); setState(id, newVal, true); lastWriteTs.set(id, Date.now()); if (WRITE_TRACE) log(`[WRITE][Index] ${id} ← ${newVal}`, 'info'); return; } catch (e) { log(`IndexRangeWrite Fehler: ${e.message} – fallback auf StandardWrite`, 'warn'); } } // Strukturwrite await doWriteStructPath(c, area, pathArr, newVal, id); }); } } /************************************************************* * 19) INDEX RANGE WRITE (Array-Write) *************************************************************/ function detectIndexRange(pathArr) { if (!pathArr || !pathArr.length) return null; const last = pathArr[pathArr.length - 1]; const m = /^(.*)\[(\d+)\]$/.exec(last); if (!m) return null; return { baseName: m[1], index: Number(m[2]) }; } async function writeArrayIndex(nodeId, idx, desired) { const dv = await session.read({ nodeId, attributeId: AttributeIds.Value, indexRange: String(idx), }); if (dv.statusCode !== StatusCodes.Good) throw new Error('IndexRange-Read fehlgeschlagen'); const dt = dv.value.dataType; let val = desired; // Typkonvertierung if (dt === DataType.Boolean) val = !!desired; else if ([DataType.Double, DataType.Float].includes(dt)) val = Number(desired); else if ( [ DataType.Int16, DataType.Int32, DataType.Int64, DataType.Byte, DataType.SByte, DataType.UInt16, DataType.UInt32, DataType.UInt64, ].includes(dt) ) { val = parseInt(desired, 10); } else if (dt === DataType.String) val = String(desired); const status = await session.write({ nodeId, attributeId: AttributeIds.Value, indexRange: String(idx), value: { value: new Variant({ dataType: dt, value: val }) }, }); if (status !== StatusCodes.Good) throw new Error(`Write Status: ${status}`); } /************************************************************* * 20) STRUKTUR-WRITE PRO – mit Auto-Recover *************************************************************/ async function doWriteStructPath(c, area, pathArr, desired, stateId) { if (c.canWrite && c.canWrite[area] === false) return; writeInFlight.add(stateId); try { const nodeId = c.structNodes[area]; // ioBroker Ziel-Typ ermitteln /** @type {any} */ const obj = getObject(stateId); const stateType = obj?.common?.type; // desired typ-sicher normalisieren (Option A: Arrays kommen als JSON-String rein) const safeDesired = stateType === 'number' ? Number(desired) : toStateValue(desired); // aktuellen SPS-Wert lesen (auch Datentyp!) const dv = await session.read({ nodeId, attributeId: AttributeIds.Value, }); resolveDynamicExtensionObject(dv.value, extraDTM); const dt = dv.value.dataType; // z.B. Boolean const curRaw = dv.value.value; // z.B. JS-Array const currentPlain = toPlainObject(curRaw); if (currentPlain === null || currentPlain === undefined) { throw new Error(`Wert ${c.name}.${area} konnte nicht dekodiert werden`); } // =========================== // FALL 1: Area ist SIMPLE VALUE / ARRAY (kein ExtensionObject) // - typischerweise BOOL[], INT[], REAL, BOOL etc. // - Schreibziel: komplette Variable (pathArr leer) oder einzelnes Element via IndexRange (Block 19) // =========================== const isSimple = (dt !== DataType.ExtensionObject) && (typeof currentPlain !== 'object' || Array.isArray(currentPlain) || currentPlain instanceof Date); if (isSimple) { if (pathArr && pathArr.length) { // Für einfache Variablen gibt es keinen Unterpfad throw new Error(`Pfad ${pathArr.join('.')} nicht gültig (Area ist kein Struct)`); } let writeValue = safeDesired; // BOOL[] / NUMBER[] als JSON-String aus ioBroker if (Array.isArray(currentPlain)) { if (Array.isArray(writeValue)) { // ok } else if (typeof writeValue === 'string') { try { const parsed = JSON.parse(writeValue); if (!Array.isArray(parsed)) throw new Error('JSON ist kein Array'); writeValue = parsed; } catch (e) { throw new Error(`Array-JSON ungültig: ${e.message}`); } } else { writeValue = [writeValue]; } // Elementtyp konvertieren (hier wichtig: Boolean) if (dt === DataType.Boolean) writeValue = writeValue.map(v => !!v); else if ([DataType.Double, DataType.Float].includes(dt)) writeValue = writeValue.map(v => Number(v)); else if ( [ DataType.Int16, DataType.Int32, DataType.Int64, DataType.Byte, DataType.SByte, DataType.UInt16, DataType.UInt32, DataType.UInt64, ].includes(dt) ) writeValue = writeValue.map(v => parseInt(v, 10)); else if (dt === DataType.String) writeValue = writeValue.map(v => String(v)); } else { // scalar conversion if (dt === DataType.Boolean) writeValue = !!writeValue; else if ([DataType.Double, DataType.Float].includes(dt)) writeValue = Number(writeValue); else if ( [ DataType.Int16, DataType.Int32, DataType.Int64, DataType.Byte, DataType.SByte, DataType.UInt16, DataType.UInt32, DataType.UInt64, ].includes(dt) ) writeValue = parseInt(writeValue, 10); else if (dt === DataType.String) writeValue = String(writeValue); } if (WRITE_TRACE) { log(`[WRITE][SIMPLE] ${c.name}.${area} → ${JSON.stringify(writeValue)}`, 'info'); } const status = await session.write({ nodeId, attributeId: AttributeIds.Value, value: { value: new Variant({ dataType: dt, value: writeValue, // bei Array: JS-Array }), }, }); if (status !== StatusCodes.Good) { setState(`${c.statusPrefix}.WriteBlocked`, true, true); throw new Error(`Write-Status: ${status.toString()}`); } setState(`${c.statusPrefix}.WriteBlocked`, false, true); // ioBroker aktualisieren (bleibt JSON-String bei Arrays) setState(stateId, toStateValue(writeValue), true); lastWriteTs.set(stateId, Date.now()); setState(`${c.statusPrefix}.LastChange`, new Date().toISOString(), true); // Snapshot (für Arrays ok) setSnapshot(c, area, writeValue); return; } // =========================== // FALL 2: ExtensionObject / Struct // =========================== const oldValue = getByPath(currentPlain, pathArr); if (!setByPathTyped(currentPlain, pathArr, safeDesired)) throw new Error(`Pfad ${pathArr.join('.')} nicht schreibbar oder falscher Typ`); const Ctor = c.typeCtors[area]; const writeVal = Ctor ? new Ctor(currentPlain) : dv.value.value; if (WRITE_TRACE) { log( `[WRITE] ${c.name}.${area}.${pathArr.join('.')} → ${JSON.stringify(safeDesired)} (alt: ${JSON.stringify(oldValue)})`, 'info', ); } const status = await session.write({ nodeId, attributeId: AttributeIds.Value, value: { value: new Variant({ dataType: DataType.ExtensionObject, value: writeVal, }), }, }); if (status !== StatusCodes.Good) { setState(`${c.statusPrefix}.WriteBlocked`, true, true); throw new Error(`Write-Status: ${status.toString()}`); } setState(`${c.statusPrefix}.WriteBlocked`, false, true); setState(stateId, safeDesired, true); lastWriteTs.set(stateId, Date.now()); setState(`${c.statusPrefix}.LastChange`, new Date().toISOString(), true); const dvAfter = await session.read({ nodeId, attributeId: AttributeIds.Value }); resolveDynamicExtensionObject(dvAfter.value, extraDTM); const readback = toPlainObject(dvAfter.value.value); setSnapshot(c, area, readback); } catch (e) { log(`Write-Fehler ${c.name}.${area}: ${e.message}`, 'error'); if (WRITE_TRACE) log(`[WRITE][ERROR] ${c.name}.${area}.${(pathArr || []).join('.')} – ${e.message}`, 'warn'); } finally { writeInFlight.delete(stateId); if (writeQueued.has(stateId)) { const next = writeQueued.get(stateId); writeQueued.delete(stateId); if (next !== undefined) { setTimeout(() => doWriteStructPath(c, area, pathArr, next, stateId), WRITE_DEBOUNCE_MS); } } } } /************************************************************* * 21) FELD-TYP SETTER (bool/number/string/date/array) *************************************************************/ function setByPathTyped(obj, pathArr, newVal) { let ref = obj; for (let i = 0; i < pathArr.length - 1; i++) { const p = pathArr[i]; if (!ref[p] || typeof ref[p] !== 'object') return false; ref = ref[p]; } const last = pathArr[pathArr.length - 1]; const sample = ref[last]; if (typeof sample === 'boolean') { ref[last] = !!newVal; } else if (typeof sample === 'number') { const n = Number(newVal); if (Number.isNaN(n)) return false; ref[last] = n; } else if (typeof sample === 'string') { ref[last] = String(newVal); } else if (sample instanceof Date) { const d = newVal instanceof Date ? newVal : new Date(newVal); if (isNaN(d.getTime())) return false; ref[last] = d; } else if (Array.isArray(sample)) { if (Array.isArray(newVal)) { ref[last] = newVal; } else if (typeof newVal === 'string') { try { const parsed = JSON.parse(newVal); if (!Array.isArray(parsed)) return false; ref[last] = parsed; } catch (e) { return false; // nur gültiges JSON akzeptieren } } else { // Fallback: einzelner Wert -> 1-Element-Array ref[last] = [newVal]; } } else { return false; } return true; } /************************************************************* * 22) RECONNECT BACKOFF PRO *************************************************************/ function backoffDelay(attempt) { const base = Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * Math.pow(2, Math.max(0, attempt - 1))); const jitter = Math.floor((Math.random() * 2 - 1) * RECONNECT_JITTER_MS); const delay = Math.max(500, base + jitter); if (DEBUG_OPCUA) log(`[DBG][Backoff] attempt=${attempt} delay=${delay}`, 'debug'); return delay; } function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } /************************************************************* * 23) RECONNECT TRIGGER PRO (kontrolliert) *************************************************************/ let reconnectRequested = false; let stopping = false; async function triggerReconnect(reason) { if (reconnectRequested || stopping) return; reconnectRequested = true; log(`Reconnect ausgelöst (${reason})`, 'warn'); if (DEBUG_OPCUA) log(`[DBG][Reconnect] Trigger von ${reason}`, 'info'); try { await shutdown(); } catch (e) { log(`Fehler beim Shutdown vor Reconnect: ${e.message}`, 'warn'); } reconnectRequested = false; } /************************************************************* * 24) Warten, bis Session weg ist (ConnectLoop) *************************************************************/ async function waitUntilDisconnected() { return new Promise(resolve => { const int = setInterval(() => { if (!client || stopping) { clearInterval(int); resolve(); } }, 500); }); } /************************************************************* * 25) CONNECT LOOP PRO (unendlich) *************************************************************/ let connectAttempt = 0; let notifiedAfterThreshold = false; let lastConnectErrorText = ''; async function connectLoop() { while (!stopping) { try { await connectAndSetup(); // ✅ Erfolgreich verbunden → Zähler/Flags zurücksetzen connectAttempt = 0; notifiedAfterThreshold = false; lastConnectErrorText = ''; // Warten, bis Verbindung stirbt await waitUntilDisconnected(); if (stopping) break; } catch (e) { lastConnectErrorText = e?.message || String(e); log(`Verbindungsfehler: ${lastConnectErrorText}`, 'warn'); if (e?.stack) { log(`STACK: ${e.stack}`, 'error'); } if (typeof e === 'object') { try { log(`DETAIL: ${JSON.stringify(e)}`, 'error'); } catch { } } } // Backoff / nächster Versuch connectAttempt++; // ✅ LIMIT-RUNDE: nach X Versuchen → Pause → wieder von vorne if (RECONNECT_MAX_TRIES > 0 && connectAttempt > RECONNECT_MAX_TRIES) { log( `[RECONNECT] Runde abgebrochen nach ${RECONNECT_MAX_TRIES} Versuchen. Pause: ${Math.round(RECONNECT_PAUSE_MS / 60000)} min. Letzter Fehler: ${lastConnectErrorText}`, 'error' ); // Status sauber setzen try { setState('0_userdata.0.OPCUA.Connected', false, true); } catch { } for (const c of COLLECTIONS) { try { setState(`${c.statusPrefix}.Connected`, false, true); setState( `${c.statusPrefix}.LastError`, `Reconnect-Pause ${Math.round(RECONNECT_PAUSE_MS / 60000)}min nach ${RECONNECT_MAX_TRIES} Versuchen`, true ); } catch { } } // Optional: Telegram einmal pro Runde const msg = [ `⛔ OPC UA: ${RECONNECT_MAX_TRIES} Fehlversuche erreicht`, `⏸ Pause: ${Math.round(RECONNECT_PAUSE_MS / 60000)} min`, `Endpoint: ${ENDPOINT}`, lastConnectErrorText ? `Fehler:\n${lastConnectErrorText}` : null, ].filter(Boolean).join('\n'); tg(msg); // ✅ Runde resetten und pausieren connectAttempt = 0; notifiedAfterThreshold = false; await sleep(RECONNECT_PAUSE_MS); continue; // danach wieder Versuch 1/10 } const delay = backoffDelay(connectAttempt); log( `Reconnect in ${delay} ms (Versuch ${connectAttempt}/${RECONNECT_MAX_TRIES})`, 'warn' ); // Telegram-Benachrichtigung nach X Fehlversuchen (innerhalb einer Runde) if (!notifiedAfterThreshold && connectAttempt >= TELEGRAM_ON_FAILS) { const msg = [ `⚠️ OPC UA: ${connectAttempt} Fehlversuche`, `Endpoint: ${ENDPOINT}`, lastConnectErrorText ? `Fehler:\n${lastConnectErrorText}` : null, ].filter(Boolean).join('\n'); tg(msg); notifiedAfterThreshold = true; } await sleep(delay); } } /************************************************************* * 26) CONNECT + SETUP PRO *************************************************************/ let client = null; let session = null; let subscription = null; let extraDTM = null; async function connectAndSetup() { await shutdown(); // idempotent client = OPCUAClient.create({ endpointMustExist: false, securityMode: MessageSecurityMode.None, securityPolicy: SecurityPolicy.None, keepSessionAlive: true, connectionStrategy: { initialDelay: 1000, maxRetry: 0 }, }); /********************************************************* * Client Event: Verbindung verloren *********************************************************/ client.on('connection_lost', () => { log('Client: connection_lost → Reconnect', 'warn'); if (DEBUG_OPCUA) log('[DBG][Client] connection_lost', 'debug'); for (const c of COLLECTIONS) { setState(`${c.statusPrefix}.Connected`, false, true); setState(`${c.statusPrefix}.LastError`, 'connection_lost', true); } setState('0_userdata.0.OPCUA.Connected', false, true); triggerReconnect('connection_lost'); }); /********************************************************* * Client Event: Verbindung wiederhergestellt *********************************************************/ client.on('connection_reestablished', () => { log('Verbindung wiederhergestellt – Rebuild…', 'info'); if (DEBUG_OPCUA) log('[DBG][Client] connection_reestablished', 'info'); rebuildAfterReconnect().catch(e => log('Rebuild Fehler: ' + e.message, 'error')); }); /********************************************************* * Physische Verbindung *********************************************************/ await client.connect(ENDPOINT); if (DEBUG_OPCUA) log('[DBG] OPC UA TCP-Verbindung steht', 'info'); session = await client.createSession(USER); if (DEBUG_OPCUA) log('[DBG] Session erstellt', 'info'); /********************************************************* * Namespace-Handling *********************************************************/ const nsBefore = readJsonState(CACHE_NSARRAY_ID, []); await refreshNamespaces(session); if (namespacesChanged(nsBefore, nsUris)) { log('NamespaceArray geändert → Cache invalidiert', 'warn'); if (DEBUG_OPCUA) log('[DBG][Namespace] Änderung erkannt, Cache reset', 'debug'); cacheClearAll(); } /********************************************************* * Namespace-Watcher *********************************************************/ if (nsPollTimer) clearInterval(nsPollTimer); nsPollTimer = setInterval(async () => { if (!session) return; try { const old = nsUris.slice(); await refreshNamespaces(session); if (namespacesChanged(old, nsUris)) { log('NamespaceArray geändert → Rebuild', 'warn'); if (DEBUG_OPCUA) log('[DBG][Namespace] Änderung festgestellt', 'info'); cacheClearAll(); rebuildAfterReconnect().catch(e => log(`Rebuild nach NS-Änderung: ${e.message}`, 'error')); } } catch (e) { log('NamespaceWatcher Fehler: ' + e.message, 'warn'); if (DEBUG_OPCUA) log(`[DBG][Namespace] Fehler: ${e.message}`, 'debug'); } }, NS_WATCH_INTERVAL); /********************************************************* * Session: Keepalive-Fehler *********************************************************/ session.on('keepalive_failure', () => { log('Session keepalive_failure → Reconnect…', 'warn'); if (DEBUG_OPCUA) log('[DBG][Session] keepalive_failure', 'debug'); for (const c of COLLECTIONS) setState(`${c.statusPrefix}.LastError`, 'keepalive_failure', true); triggerReconnect('keepalive_failure'); }); /********************************************************* * ExtensionObject Type Manager laden *********************************************************/ extraDTM = await getExtraDataTypeManager(session); await populateDataTypeManager(session, extraDTM); if (DEBUG_OPCUA) log('[DBG] DataTypeManager geladen', 'info'); /********************************************************* * Areas & TypeConstructor laden *********************************************************/ for (const c of COLLECTIONS) { c.structNodes = {}; c.typeCtors = {}; c.prevPlain = new Map(); await resolveAreasForCollection(c); } // TypeCtor + InitialRead for (const c of COLLECTIONS) { for (const [area, nodeId] of Object.entries(c.structNodes)) { await primeTypeCtor(c, area, nodeId); await initialReadAndCreateStates(c, area, nodeId); } } /********************************************************* * Subscription aktivieren *********************************************************/ await createAndAttachSubscription(); if (DEBUG_OPCUA) log('[DBG] Subscription ready', 'info'); /********************************************************* * Status setzen *********************************************************/ for (const c of COLLECTIONS) { setState(`${c.statusPrefix}.Connected`, true, true); } setState('0_userdata.0.OPCUA.Connected', true, true); bindWriteHandlersOnce(); log('OPC UA verbunden – Subscription aktiv.', 'info'); } /************************************************************* * 27) REBUILD AFTER RECONNECT PRO *************************************************************/ async function rebuildAfterReconnect() { if (!session) return; try { if (DEBUG_OPCUA) log('[DBG] Rebuild gestartet', 'info'); extraDTM = await getExtraDataTypeManager(session); await populateDataTypeManager(session, extraDTM); await createAndAttachSubscription(true); for (const c of COLLECTIONS) { for (const [area, nodeId] of Object.entries(c.structNodes)) { try { await initialReadAndCreateStates(c, area, nodeId); } catch (e) { log(`Rebuild InitialRead Fehler ${c.name}.${area}: ${e.message}`, 'warn'); } } setState(`${c.statusPrefix}.Connected`, true, true); setState(`${c.statusPrefix}.LastChange`, new Date().toISOString(), true); } setState('0_userdata.0.OPCUA.Connected', true, true); if (DEBUG_OPCUA) log('[DBG] Rebuild abgeschlossen', 'info'); } catch (e) { log(`RebuildAfterReconnect Fehler: ${e.message}`, 'error'); } } /************************************************************* * 28) SHUTDOWN PRO *************************************************************/ async function shutdown() { if (DEBUG_OPCUA) log('[DBG] Shutdown gestartet', 'info'); try { if (nsPollTimer) { clearInterval(nsPollTimer); nsPollTimer = null; } } catch { } try { if (subscription) await subscription.terminate(); } catch (e) { if (DEBUG_OPCUA) log(`[DBG] Subscription Terminate Fehler: ${e.message}`, 'debug'); } try { if (session) await session.close(); } catch (e) { if (DEBUG_OPCUA) log(`[DBG] Session Close Fehler: ${e.message}`, 'debug'); } try { if (client) await client.disconnect(); } catch (e) { if (DEBUG_OPCUA) log(`[DBG] Client Disconnect Fehler: ${e.message}`, 'debug'); } for (const c of COLLECTIONS) try { setState(`${c.statusPrefix}.Connected`, false, true); } catch { } try { setState('0_userdata.0.OPCUA.Connected', false, true); } catch { } subscription = null; session = null; client = null; if (DEBUG_OPCUA) log('[DBG] Shutdown abgeschlossen', 'info'); } /************************************************************* * 28a) SNAPSHOT START-CLEANUP – alle _Snapshot.* Werte leeren *************************************************************/ async function clearAllSnapshotsOnStart() { try { for (const c of COLLECTIONS) { const snapshotRoot = `${c.statePrefix}._Snapshot`; // Prüfen, ob der Snapshot-Ordner überhaupt existiert const rootObj = getObject(snapshotRoot); if (!rootObj) continue; // Falls keine "members" definiert sind → wir müssen rekursiv scannen // Das tun wir über ein Pattern: . const pattern = snapshotRoot + '.'; const allObjects = await new Promise(resolve => { sendTo('javascript.0', 'getObject', { id: pattern, recursive: true }, res => resolve(res?.result || {}), ); }); for (const [id, obj] of Object.entries(allObjects)) { // Nur States leeren, keine Channels/Folders if (obj?.type === 'state') { setState(id, '', true); } } if (DEBUG_OPCUA) { log(`[DBG][SnapshotCleanup] Snapshot unter ${snapshotRoot} vollständig geleert.`, 'debug'); } } } catch (e) { log(`[INIT] Snapshot-Cleanup Fehler: ${e.message}`, 'warn'); } } /************************************************************* * 29) MAIN START – PRO + DEBUG Version *************************************************************/ async function main() { opcuaStartupSelftest(); if (DEBUG_OPCUA) log('[DBG] main() gestartet → Cache laden…', 'info'); await ensureCacheStates(); if (!existsState('0_userdata.0.OPCUA.Connected')) { await createStateAsync( '0_userdata.0.OPCUA.Connected', undefined, true, { role: 'indicator.connected', read: true, write: false, }, {}, ); } setState('0_userdata.0.OPCUA.Connected', false, true); // NEU: alle vorhandenen Snapshot-Werte beim Start leeren clearAllSnapshotsOnStart(); nodeIdCache = readJsonState(CACHE_NODEIDS_ID, {}); missCache = new Set(readJsonState(CACHE_MISS_ID, [])); nsUris = readJsonState(CACHE_NSARRAY_ID, []); if (DEBUG_OPCUA) { log(`[DBG] Cache geladen: NodeIds=${Object.keys(nodeIdCache).length}`, 'debug'); log(`[DBG] MissKeys=${missCache.size}`, 'debug'); log(`[DBG] NamespaceArray=${JSON.stringify(nsUris)}`, 'debug'); } /********************************************************* * Status-Objekte erzeugen (TS-sicher → kein boolean|string mix) *********************************************************/ for (const c of COLLECTIONS) { const defs = [ { id: `${c.statusPrefix}.Connected`, common: { role: 'indicator.connected', read: true, write: false, def: false }, }, { id: `${c.statusPrefix}.LastChange`, common: { role: 'text', read: true, write: false, def: '' }, }, { id: `${c.statusPrefix}.LastError`, common: { role: 'text', read: true, write: false, def: '' }, }, { id: `${c.statusPrefix}.WriteBlocked`, common: { role: 'indicator.error', read: true, write: false, def: false }, }, ]; for (const entry of defs) { const stateId = entry.id; const common = entry.common; if (!existsState(stateId)) { if (DEBUG_OPCUA) log(`[DBG][StateCreate] ${stateId}`, 'debug'); await createStateAsync( stateId, undefined, // kein type → verhindert TS-Mixfehler true, { role: common.role, read: common.read, write: common.write, }, {}, ); setState(stateId, common.def, true); } } } /********************************************************* * Endlosschleife (ConnectLoop) *********************************************************/ if (DEBUG_OPCUA) log('[DBG] connectLoop() wird gestartet…', 'info'); await connectLoop(); } /************************************************************* * 30) MAIN STARTEN *************************************************************/ main().catch(err => { log('Startfehler OPC UA: ' + err.message, 'error'); if (DEBUG_OPCUA) log('[DBG] Startfehler Detail: ' + err.stack, 'error'); for (const c of COLLECTIONS) setState(`${c.statusPrefix}.LastError`, err.message, true); }); /************************************************************* * 31) ioBroker Stop-Handler (sauber beenden) *************************************************************/ onStop(async cb => { stopping = true; if (DEBUG_OPCUA) log('[DBG] Script Stop erkannt → shutdown()', 'info'); await shutdown(); cb(); }, 5000); /************************************************************* * 32) UTILITY: Variant-Dump (Debug-Ausgabe) *************************************************************/ function dumpVariantInfo(tag, dv) { try { const dt = dv?.value?.dataType; const val = dv?.value?.value; const typeName = typeof dt === 'number' && DataType[dt] ? DataType[dt] : dt; const ctor = val && val.constructor ? val.constructor.name : typeof val; log(`[DBG][Variant] ${tag}: dataType=${typeName}, ctor=${ctor}, typeof=${typeof val}`, 'info'); } catch (e) { log(`[DBG][Variant][ERROR] ${tag}: ${e.message}`, 'warn'); } } /************************************************************* * 33) Optional: Objekt-Debug Ausgabe (nicht aktiv benutzt) ************************************************************* function debugLogObj(obj) { if (!DEBUG_OPCUA) return; try { log("[DBG][ObjectDump] " + JSON.stringify(obj, null, 2), "debug"); } catch (e) { log("[DBG][ObjectDump][ERROR] " + e.message, "warn"); } } /************************************************************* * 34) normLeaf *************************************************************/ function normLeaf(v) { if (v instanceof Date) return v.toISOString(); if (Array.isArray(v)) return JSON.stringify(v); return v; } /************************************************************* * 35) Abschlussmeldung *************************************************************/ log('OPCUA Skript geladen.', 'info');