UNPKG

node-red-contrib-knx-ultimate

Version:

Control your KNX and KNX Secure intallation via Node-Red! A bunch of KNX nodes, with integrated Philips HUE control, ETS group address importer, KNX AI for diagnosticsand KNX routing between interfaces. Easy to use and highly configurable.

1,335 lines (1,242 loc) 353 kB
// KNX Ultimate AI / Traffic Analyzer const loggerClass = require('./utils/sysLogger') const dptlib = require('knxultimate').dptlib const fs = require('fs') const path = require('path') const { spawn } = require('child_process') const { getRequestAccessToken, normalizeAuthFromAccessTokenQuery } = require('./utils/httpAdminAccessToken') let googleTranslateTTS = null try { googleTranslateTTS = require('google-translate-tts') } catch (error) { googleTranslateTTS = null } const coerceBoolean = (value) => (value === true || value === 'true') let adminEndpointsRegistered = false const aiRuntimeNodes = new Map() const knxAiVueDistDir = path.join(__dirname, 'plugins', 'knxUltimateAI-vue') // --------------------------------------------------------------------------- // KNX AI Flow Builder helpers // Build a node "catalog" (type + editable fields) from this package's own // editor .html files, so the LLM knows exactly which node types and config // fields it can emit when generating a Node-RED flow to paste in the editor. // --------------------------------------------------------------------------- // Native Node-RED core nodes we explicitly allow in generated flows. const KNX_AI_FLOW_CORE_NODES = [ { type: 'tab', paletteLabel: 'Flow tab (do not emit, added automatically)', category: 'config', inputs: 0, outputs: 0, fields: {} }, { type: 'inject', paletteLabel: 'inject (manual/scheduled trigger)', category: 'common', inputs: 0, outputs: 1, fields: { name: {}, props: {}, repeat: {}, crontab: {}, once: {}, topic: {}, payload: {}, payloadType: {} } }, { type: 'debug', paletteLabel: 'debug (sidebar log)', category: 'common', inputs: 1, outputs: 0, fields: { name: {}, active: {}, complete: {}, console: {}, tosidebar: {} } }, { type: 'function', paletteLabel: 'function (custom JavaScript)', category: 'function', inputs: 1, outputs: 1, fields: { name: {}, func: {}, outputs: {}, initialize: {}, finalize: {} } }, { type: 'switch', paletteLabel: 'switch (route by rules)', category: 'function', inputs: 1, outputs: 1, fields: { name: {}, property: {}, propertyType: {}, rules: {}, outputs: {} } }, { type: 'change', paletteLabel: 'change (set/move/delete properties)', category: 'function', inputs: 1, outputs: 1, fields: { name: {}, rules: {} } }, { type: 'range', paletteLabel: 'range (scale a number)', category: 'function', inputs: 1, outputs: 1, fields: { name: {}, minin: {}, maxin: {}, minout: {}, maxout: {}, action: {}, round: {}, property: {} } }, { type: 'delay', paletteLabel: 'delay (delay/rate limit)', category: 'function', inputs: 1, outputs: 1, fields: { name: {}, pauseType: {}, timeout: {}, timeoutUnits: {}, rate: {}, rateUnits: {} } }, { type: 'trigger', paletteLabel: 'trigger (send-then-reset / debounce)', category: 'function', inputs: 1, outputs: 1, fields: { name: {}, op1: {}, op2: {}, duration: {}, units: {}, reset: {} } }, { type: 'comment', paletteLabel: 'comment (annotation)', category: 'common', inputs: 0, outputs: 0, fields: { name: {}, info: {} } }, { type: 'link in', paletteLabel: 'link in', category: 'common', inputs: 0, outputs: 1, fields: { name: {}, links: {} } }, { type: 'link out', paletteLabel: 'link out', category: 'common', inputs: 1, outputs: 0, fields: { name: {}, links: {} } } ] // Scan a JS object literal body (the text between its outer braces) and return, // for each top-level key, the inner object text. String- and comment-aware so // commented-out entries (e.g. "//buttonState: {value:true}") are ignored. const knxAiScanObjectEntries = (body) => { const entries = {} const len = body.length let i = 0 let depth = 0 let pendingKey = '' let collecting = '' let innerStart = -1 while (i < len) { const c = body[i] const next = body[i + 1] if (c === '/' && next === '/') { i += 2 while (i < len && body[i] !== '\n') i++ continue } if (c === '/' && next === '*') { i += 2 while (i < len && !(body[i] === '*' && body[i + 1] === '/')) i++ i += 2 continue } if (c === '"' || c === "'" || c === '`') { i++ while (i < len) { if (body[i] === '\\') { i += 2; continue } if (body[i] === c) { i++; break } i++ } continue } if (c === '{') { if (depth === 0 && pendingKey) { collecting = pendingKey; innerStart = i + 1 } depth++ i++ continue } if (c === '}') { depth-- if (depth === 0 && collecting) { entries[collecting] = body.slice(innerStart, i) collecting = '' pendingKey = '' } i++ continue } if (depth === 0 && /[A-Za-z_$]/.test(c)) { let j = i while (j < len && /[A-Za-z0-9_$]/.test(body[j])) j++ pendingKey = body.slice(i, j) i = j continue } i++ } return entries } // Given full text and the index of an opening brace, return the substring up to // and including the matching closing brace (string/comment aware). const knxAiSliceBalanced = (text, openIndex) => { const len = text.length let i = openIndex let depth = 0 while (i < len) { const c = text[i] const next = text[i + 1] if (c === '/' && next === '/') { i += 2 while (i < len && text[i] !== '\n') i++ continue } if (c === '/' && next === '*') { i += 2 while (i < len && !(text[i] === '*' && text[i + 1] === '/')) i++ i += 2 continue } if (c === '"' || c === "'" || c === '`') { i++ while (i < len) { if (text[i] === '\\') { i += 2; continue } if (text[i] === c) { i++; break } i++ } continue } if (c === '{') depth++ else if (c === '}') { depth-- if (depth === 0) return text.slice(openIndex, i + 1) } i++ } return text.slice(openIndex) } const knxAiMatchAfter = (text, regex) => { const m = regex.exec(text) return m ? m[1] : '' } // Parse one editor `defaults: { ... }` block into a field map. // Each field becomes { configType, isConfig } where configType is set when the // field references a config node (e.g. server: { type: 'knxUltimate-config' }). const knxAiParseDefaultsFields = (defaultsBody) => { const fields = {} const entries = knxAiScanObjectEntries(defaultsBody) Object.keys(entries).forEach((key) => { const inner = entries[key] || '' const configType = knxAiMatchAfter(inner, /\btype\s*:\s*['"]([^'"]+)['"]/) fields[key] = configType ? { configType, isConfig: true } : {} }) return fields } let knxAiPackageNodeCatalogCache = null // Read every registerType(...) declaration in this package's editor .html files // and return a catalog: [{ type, paletteLabel, category, inputs, outputs, fields }]. const buildKnxAiPackageNodeCatalog = () => { if (knxAiPackageNodeCatalogCache) return knxAiPackageNodeCatalogCache const catalog = [] const seen = new Set() let nodeMap = {} try { const pkg = require(path.join(__dirname, '..', 'package.json')) nodeMap = (pkg['node-red'] && pkg['node-red'].nodes) || {} } catch (error) { nodeMap = {} } Object.keys(nodeMap).forEach((mapKey) => { try { const jsRel = String(nodeMap[mapKey] || '') const base = path.basename(jsRel).replace(/\.js$/i, '') const htmlPath = path.join(__dirname, `${base}.html`) if (!fs.existsSync(htmlPath)) return const html = fs.readFileSync(htmlPath, 'utf8') const re = /registerType\(\s*['"]([^'"]+)['"]\s*,\s*\{/g let m while ((m = re.exec(html))) { const type = m[1] if (seen.has(type)) continue seen.add(type) const objOpen = html.indexOf('{', m.index + m[0].length - 1) if (objOpen < 0) continue const objText = knxAiSliceBalanced(html, objOpen) const category = knxAiMatchAfter(objText, /\bcategory\s*:\s*['"]([^'"]+)['"]/) const paletteLabel = knxAiMatchAfter(objText, /\bpaletteLabel\s*:\s*['"]([^'"]+)['"]/) const inputsRaw = knxAiMatchAfter(objText, /\binputs\s*:\s*(\d+)/) const outputsRaw = knxAiMatchAfter(objText, /\boutputs\s*:\s*(\d+)/) let fields = {} const defIdx = objText.search(/\bdefaults\s*:\s*\{/) if (defIdx >= 0) { const braceIdx = objText.indexOf('{', defIdx) const defaultsBlock = knxAiSliceBalanced(objText, braceIdx) fields = knxAiParseDefaultsFields(defaultsBlock.slice(1, -1)) } catalog.push({ type, paletteLabel: paletteLabel || type, category: category || '', inputs: inputsRaw === '' ? 1 : Number(inputsRaw), outputs: outputsRaw === '' ? 1 : Number(outputsRaw), isConfig: category === 'config', fields }) } } catch (error) { // skip nodes we cannot parse } }) knxAiPackageNodeCatalogCache = catalog return catalog } // Combined catalog (package + core), the set of config-node types, and a // per-type map of which fields are config references. const buildKnxAiFlowCatalog = () => { const packageNodes = buildKnxAiPackageNodeCatalog() const all = packageNodes.concat(KNX_AI_FLOW_CORE_NODES) const configTypes = new Set() const configFieldsByType = {} const allowedTypes = new Set() all.forEach((node) => { allowedTypes.add(node.type) if (node.isConfig) configTypes.add(node.type) const refs = [] Object.keys(node.fields || {}).forEach((field) => { const meta = node.fields[field] if (meta && meta.isConfig && meta.configType) { refs.push({ field, configType: meta.configType }) configTypes.add(meta.configType) } }) if (refs.length) configFieldsByType[node.type] = refs }) return { nodes: all, packageNodes, configTypes, configFieldsByType, allowedTypes } } // Render the catalog as a compact text block for the LLM system/user prompt. const renderKnxAiCatalogForPrompt = (catalog) => { const lines = [] catalog.nodes .filter(node => node.type !== 'tab') .forEach((node) => { const fieldNames = Object.keys(node.fields || {}).map((field) => { const meta = node.fields[field] return (meta && meta.isConfig && meta.configType) ? `${field}[ref:${meta.configType}]` : field }) const io = `${node.inputs}in/${node.outputs}out` const fieldsText = fieldNames.length ? ` | fields: ${fieldNames.join(', ')}` : '' lines.push(`- ${node.type} — ${node.paletteLabel} (${io})${fieldsText}`) }) return lines.join('\n') } // Try hard to extract a JSON flow (array of node objects) from an LLM reply. const parseKnxAiFlowFromLlm = (content) => { const raw = String(content || '').trim() if (!raw) return { nodes: [], notes: '', error: 'Empty model response' } let text = raw const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/i) if (fence) text = fence[1].trim() const tryParse = (candidate) => { try { return JSON.parse(candidate) } catch (error) { return undefined } } const fromObject = (obj) => { if (Array.isArray(obj)) return { nodes: obj, notes: '' } if (obj && typeof obj === 'object') { const nodes = Array.isArray(obj.flow) ? obj.flow : (Array.isArray(obj.nodes) ? obj.nodes : null) if (nodes) return { nodes, notes: String(obj.notes || obj.comment || '') } } return null } let parsed = tryParse(text) if (parsed === undefined) { const firstArr = text.indexOf('[') const lastArr = text.lastIndexOf(']') const firstObj = text.indexOf('{') const lastObj = text.lastIndexOf('}') if (firstArr >= 0 && lastArr > firstArr) parsed = tryParse(text.slice(firstArr, lastArr + 1)) if (parsed === undefined && firstObj >= 0 && lastObj > firstObj) parsed = tryParse(text.slice(firstObj, lastObj + 1)) } if (parsed === undefined) return { nodes: [], notes: '', error: 'Could not parse JSON from model response' } const shaped = fromObject(parsed) if (!shaped) return { nodes: [], notes: '', error: 'Model response did not contain a flow array' } return { nodes: shaped.nodes, notes: shaped.notes, error: '' } } // Validate / normalize the generated nodes into an importable flow: // - drops invalid + tab nodes, regenerates unique ids, rewires references // - puts every wire-able node on a fresh flow tab // - points config references at real existing config nodes when possible const normalizeKnxAiGeneratedFlow = ({ rawNodes, catalog, knxServerId, existingConfigByType, genId }) => { const warnings = [] const allowedTypes = catalog.allowedTypes const configTypes = catalog.configTypes const configFieldsByType = catalog.configFieldsByType const input = Array.isArray(rawNodes) ? rawNodes : [] // Keep only objects with a usable type; skip tab nodes (we add our own) and // config nodes (we reference existing ones instead of duplicating them). const kept = [] input.forEach((node) => { if (!node || typeof node !== 'object' || Array.isArray(node)) return const type = String(node.type || '').trim() if (!type || type === 'tab') return if (configTypes.has(type)) { warnings.push(`Skipped a generated config node of type "${type}"; existing config nodes are reused instead.`) return } if (!allowedTypes.has(type)) { warnings.push(`Node type "${type}" is not part of the allowed catalog and may not import cleanly.`) } kept.push(node) }) // Map old ids -> fresh ids for every kept node. const idRemap = new Map() kept.forEach((node) => { const oldId = String(node.id || '').trim() const newId = genId() if (oldId) idRemap.set(oldId, newId) node.id = newId }) const tabId = genId() let x = 140 let y = 80 const out = kept.map((node) => { const type = String(node.type).trim() node.z = tabId if (!Number.isFinite(Number(node.x))) { node.x = x } if (!Number.isFinite(Number(node.y))) { node.y = y; y += 70; if (y > 80 + 70 * 6) { y = 80; x += 220 } } // Remap wires (arrays of arrays of node ids). if (Array.isArray(node.wires)) { node.wires = node.wires.map(port => (Array.isArray(port) ? port.map(id => idRemap.get(String(id)) || String(id)) : [])) } else { node.wires = type === 'link out' || type === 'debug' || type === 'comment' ? [] : [[]] } // Resolve config-node references. const refs = configFieldsByType[type] || [] refs.forEach(({ field, configType }) => { const current = String(node[field] || '').trim() if (current && idRemap.has(current)) { node[field] = idRemap.get(current) return } const existing = existingConfigByType.get(configType) || [] if (configType === 'knxUltimate-config' && knxServerId) { if (!current || !existing.some(c => c.id === current)) node[field] = knxServerId return } if (!current || !existing.some(c => c.id === current)) { if (existing.length === 1) node[field] = existing[0].id else if (existing.length > 1) warnings.push(`Node "${type}" needs a ${configType} config: set it manually after import (several available).`) else { node[field] = ''; warnings.push(`Node "${type}" needs a ${configType} config node, but none exists yet. Configure it after import.`) } } }) return node }) const tabNode = { id: tabId, type: 'tab', label: 'KNX AI generated flow', disabled: false, info: '' } return { nodes: [tabNode].concat(out), warnings, tabId } } const sendKnxAiVueIndex = (req, res) => { const entryPath = path.join(knxAiVueDistDir, 'index.html') fs.readFile(entryPath, 'utf8', (error, html) => { if (error || typeof html !== 'string') { res.status(503).type('text/plain').send('KNX AI Vue build not found. Run "npm run knx-ai:build" in the module root.') return } const rawToken = getRequestAccessToken(req) if (!rawToken) { res.type('text/html').send(html) return } const encodedToken = encodeURIComponent(rawToken) const htmlWithToken = html .replace('./assets/app.js', `./assets/app.js?access_token=${encodedToken}`) .replace('./assets/app.css', `./assets/app.css?access_token=${encodedToken}`) res.type('text/html').send(htmlWithToken) }) } const sendStaticFileSafe = ({ rootDir, relativePath, res }) => { const rootPath = path.resolve(rootDir) const requestedPath = String(relativePath || '').replace(/^\/+/, '') const fullPath = path.resolve(rootPath, requestedPath) if (!fullPath.startsWith(rootPath + path.sep) && fullPath !== rootPath) { res.status(403).type('text/plain').send('Forbidden') return } fs.stat(fullPath, (statError, stats) => { if (statError || !stats || !stats.isFile()) { res.status(404).type('text/plain').send('File not found') return } res.sendFile(fullPath, (sendError) => { if (!sendError || res.headersSent) return res.status(sendError.statusCode || 500).type('text/plain').send(sendError.message || String(sendError)) }) }) } const GOOGLE_TRANSLATE_MAX_CHARS = 200 const stripId3v2 = (buffer) => { if (!Buffer.isBuffer(buffer) || buffer.length < 10) return buffer if (buffer[0] !== 0x49 || buffer[1] !== 0x44 || buffer[2] !== 0x33) return buffer const size = ((buffer[6] & 0x7f) << 21) | ((buffer[7] & 0x7f) << 14) | ((buffer[8] & 0x7f) << 7) | (buffer[9] & 0x7f) const tagEnd = 10 + size if (tagEnd <= 10 || tagEnd >= buffer.length) return buffer return buffer.subarray(tagEnd) } const splitGoogleTranslateText = (text, maxLen = GOOGLE_TRANSLATE_MAX_CHARS) => { const chunks = [] let remaining = String(text || '').trim() if (!remaining) return chunks const breakChars = ['\n', '.', '!', '?', ';', ':', ',', ' '] while (remaining.length > maxLen) { const window = remaining.slice(0, maxLen + 1) let breakAt = -1 for (const ch of breakChars) { const idx = window.lastIndexOf(ch) if (idx > breakAt) breakAt = idx } if (breakAt <= 0) breakAt = maxLen const cutAt = breakAt === maxLen ? maxLen : breakAt + 1 const chunk = remaining.slice(0, cutAt).trim() if (chunk) chunks.push(chunk) remaining = remaining.slice(cutAt).trimStart() } if (remaining) chunks.push(remaining) return chunks } const synthesizeGoogleTranslateSpeech = async ({ text, voice = 'it', slow = false } = {}) => { if (!googleTranslateTTS || typeof googleTranslateTTS.synthesize !== 'function') { throw new Error('Google Translate TTS is not available') } const resolvedVoice = typeof voice === 'string' && voice.includes('-') ? voice.split('-')[0] : String(voice || 'it') const textChunks = splitGoogleTranslateText(text, GOOGLE_TRANSLATE_MAX_CHARS) if (!textChunks.length) return Buffer.from([]) if (textChunks.length === 1) { return await googleTranslateTTS.synthesize({ text: textChunks[0], voice: resolvedVoice, slow: slow === true }) } const buffers = [] for (let i = 0; i < textChunks.length; i += 1) { // Google Translate TTS accepts only short chunks; concatenate the resulting mp3 frames. // eslint-disable-next-line no-await-in-loop const chunkBuffer = await googleTranslateTTS.synthesize({ text: textChunks[i], voice: resolvedVoice, slow: slow === true }) buffers.push(i === 0 ? chunkBuffer : stripId3v2(chunkBuffer)) } return Buffer.concat(buffers) } const sanitizeApiKey = (value) => { if (value === undefined || value === null) return '' let key = String(value).trim() if (key === '') return '' // Node-RED password placeholder when credential is already set if (key === '__PWRD__') return '' // Common copy/paste mistakes key = key.replace(/^authorization:\s*/i, '') key = key.replace(/^bearer\s+/i, '') key = key.replace(/^"(.+)"$/, '$1').replace(/^'(.+)'$/, '$1') // If user pasted a full header line, extract the token-like part const match = key.match(/(sk-[A-Za-z0-9_-]{10,})/) if (match) return match[1] return key } const safeStringify = (value) => { try { if (value === undefined) return '' if (typeof value === 'string') return value return JSON.stringify(value) } catch (error) { return String(value) } } const truncatePromptText = (value, maxChars = 10000) => { const text = String(value || '') const limit = Math.max(256, Number(maxChars) || 0) if (text.length <= limit) return text const marker = '\n...[truncated]' const keep = Math.max(0, limit - marker.length) return text.slice(0, keep) + marker } const compactObjectForPrompt = (value, { preferredKeys = [], maxEntries = 40, formatValue } = {}) => { if (!value || typeof value !== 'object' || Array.isArray(value)) return {} const source = value const out = {} const preferred = Array.isArray(preferredKeys) ? preferredKeys.map(key => String(key || '').trim()).filter(Boolean) : [] const preferredSet = new Set(preferred) const keys = [ ...preferred, ...Object.keys(source).filter(key => !preferredSet.has(key)) ] const limit = Math.max(1, Number(maxEntries) || 1) for (const key of keys) { if (!Object.prototype.hasOwnProperty.call(source, key)) continue const raw = source[key] const normalized = typeof formatValue === 'function' ? formatValue(raw, key) : raw out[key] = normalized if (Object.keys(out).length >= limit) break } return out } const takeLastItemsByCharBudget = (items, maxChars = 7000) => { const source = Array.isArray(items) ? items : [] const limit = Math.max(200, Number(maxChars) || 0) const selected = [] let total = 0 for (let i = source.length - 1; i >= 0; i -= 1) { const item = String(source[i] || '') if (!item) continue const next = item.length + (selected.length > 0 ? 1 : 0) if (selected.length > 0 && (total + next) > limit) break selected.push(item) total += next } return selected.reverse() } const buildLlmSummarySnapshot = (summary) => { const s = summary && typeof summary === 'object' ? summary : {} const topGAs = Array.isArray(s.topGAs) ? s.topGAs.slice(0, 30) : [] const topGaKeys = topGAs .map(item => String(item && item.ga ? item.ga : '').trim()) .filter(Boolean) const graph = s.graph && typeof s.graph === 'object' ? { windowSec: Number(s.graph.windowSec || 0), edges: (Array.isArray(s.graph.edges) ? s.graph.edges : []).slice(0, 60), hotEdgesDelta: (Array.isArray(s.graph.hotEdgesDelta) ? s.graph.hotEdgesDelta : []).slice(0, 40), anomalyLifecycle: (Array.isArray(s.graph.anomalyLifecycle) ? s.graph.anomalyLifecycle : []).slice(0, 30) } : {} const flowMapTopology = s.flowMapTopology && typeof s.flowMapTopology === 'object' ? { mode: String(s.flowMapTopology.mode || '').trim(), windowSec: Number(s.flowMapTopology.windowSec || 0), nodes: (Array.isArray(s.flowMapTopology.nodes) ? s.flowMapTopology.nodes : []).slice(0, 80).map((node) => ({ id: String(node && node.id ? node.id : '').trim(), displayId: String(node && node.displayId ? node.displayId : '').trim(), kind: String(node && node.kind ? node.kind : '').trim(), subtitle: String(node && node.subtitle ? node.subtitle : '').trim(), payload: compactPayloadForNodeLabel(node && Object.prototype.hasOwnProperty.call(node, 'payload') ? node.payload : '', 36), anomalyCount: Number(node && node.anomalyCount ? node.anomalyCount : 0), lastSeenAtMs: Number(node && node.lastSeenAtMs ? node.lastSeenAtMs : 0) })), edges: (Array.isArray(s.flowMapTopology.edges) ? s.flowMapTopology.edges : []).slice(0, 120).map((edge) => ({ from: String(edge && edge.from ? edge.from : '').trim(), to: String(edge && edge.to ? edge.to : '').trim(), linkType: String(edge && edge.linkType ? edge.linkType : '').trim(), event: String(edge && edge.event ? edge.event : '').trim(), currentWindowCount: Number(edge && edge.currentWindowCount ? edge.currentWindowCount : 0), totalCount: Number(edge && edge.totalCount ? edge.totalCount : 0), delta: Number(edge && edge.delta ? edge.delta : 0), delayMs: Number(edge && edge.delayMs ? edge.delayMs : 0), lastAt: String(edge && edge.lastAt ? edge.lastAt : '').trim() })) } : undefined return { meta: s.meta && typeof s.meta === 'object' ? s.meta : {}, counters: s.counters && typeof s.counters === 'object' ? s.counters : {}, byEvent: s.byEvent && typeof s.byEvent === 'object' ? s.byEvent : {}, topGAs, topSources: (Array.isArray(s.topSources) ? s.topSources : []).slice(0, 20), patterns: (Array.isArray(s.patterns) ? s.patterns : []).slice(0, 30), gaLastSeenAt: compactObjectForPrompt(s.gaLastSeenAt, { preferredKeys: topGaKeys, maxEntries: 60 }), gaLastPayload: compactObjectForPrompt(s.gaLastPayload, { preferredKeys: topGaKeys, maxEntries: 60, formatValue: value => compactPayloadForNodeLabel(value, 42) }), flowKnownCount: Number(s.flowKnownCount || 0), busConnection: s.busConnection && typeof s.busConnection === 'object' ? s.busConnection : {}, anomalyLifecycle: (Array.isArray(s.anomalyLifecycle) ? s.anomalyLifecycle : []).slice(-40), graph, flowMapTopology } } const extractJsonFragmentFromText = (value) => { const text = String(value || '').trim() if (!text) throw new Error('Empty AI response') const normalizeCandidate = (input) => String(input || '') .replace(/^\uFEFF/, '') .replace(/^\s*json\s*\n/i, '') .trim() const tryParse = (input) => { const source = normalizeCandidate(input) if (!source) return null try { return JSON.parse(source) } catch (error) { } // Fallback: tolerate comments and trailing commas that some models emit. const relaxed = source .replace(/\/\*[\s\S]*?\*\//g, '') .replace(/^\s*\/\/.*$/gm, '') .replace(/,\s*([}\]])/g, '$1') .trim() if (!relaxed || relaxed === source) return null try { return JSON.parse(relaxed) } catch (error) { return null } } const extractBalancedJsonSlices = (input, maxSlices = 24) => { const source = String(input || '') const out = [] for (let i = 0; i < source.length; i += 1) { const ch = source[i] if (ch !== '{' && ch !== '[') continue const stack = [ch === '{' ? '}' : ']'] let inString = false let escaped = false for (let j = i + 1; j < source.length; j += 1) { const current = source[j] if (inString) { if (escaped) { escaped = false continue } if (current === '\\') { escaped = true continue } if (current === '"') inString = false continue } if (current === '"') { inString = true continue } if (current === '{') { stack.push('}') continue } if (current === '[') { stack.push(']') continue } if ((current === '}' || current === ']') && stack.length) { if (current !== stack[stack.length - 1]) break stack.pop() if (!stack.length) { const slice = normalizeCandidate(source.slice(i, j + 1)) if (slice) out.push(slice) i = j break } } } if (out.length >= maxSlices) break } return out } const candidates = [] const seen = new Set() const pushCandidate = (input) => { const normalized = normalizeCandidate(input) if (!normalized || seen.has(normalized)) return seen.add(normalized) candidates.push(normalized) } pushCandidate(text) const fencedRe = /```(?:[a-zA-Z0-9_-]+)?\s*([\s\S]*?)```/g let fenceMatch while ((fenceMatch = fencedRe.exec(text)) !== null) { pushCandidate(fenceMatch[1]) } for (const candidate of candidates) { const direct = tryParse(candidate) if (direct !== null) return direct const objectStart = candidate.indexOf('{') const objectEnd = candidate.lastIndexOf('}') if (objectStart !== -1 && objectEnd !== -1 && objectEnd > objectStart) { const parsedObject = tryParse(candidate.slice(objectStart, objectEnd + 1)) if (parsedObject !== null) return parsedObject } const arrayStart = candidate.indexOf('[') const arrayEnd = candidate.lastIndexOf(']') if (arrayStart !== -1 && arrayEnd !== -1 && arrayEnd > arrayStart) { const parsedArray = tryParse(candidate.slice(arrayStart, arrayEnd + 1)) if (parsedArray !== null) return parsedArray } const balancedSlices = extractBalancedJsonSlices(candidate) for (const slice of balancedSlices) { const parsedSlice = tryParse(slice) if (parsedSlice !== null) return parsedSlice } } const preview = text.slice(0, 180).replace(/\s+/g, ' ').trim() throw new Error(`The LLM response did not contain valid JSON${preview ? ` (preview: ${preview})` : ''}`) } const normalizeValueForCompare = (value) => { if (value === undefined) return 'undefined' if (value === null) return 'null' if (Buffer.isBuffer(value)) return `buffer:${value.toString('hex')}` if (typeof value === 'object') return safeStringify(value) return String(value) } const nowMs = () => Date.now() const roundTo = (value, digits = 2) => { const n = Number(value) if (!Number.isFinite(n)) return 0 const f = 10 ** Math.max(0, Number(digits) || 0) return Math.round(n * f) / f } const percentileFromArray = (values, percentile = 0.95) => { const arr = Array.isArray(values) ? values.filter(v => Number.isFinite(Number(v))).map(v => Number(v)) : [] if (!arr.length) return 0 arr.sort((a, b) => a - b) const p = Math.max(0, Math.min(1, Number(percentile) || 0)) if (arr.length === 1) return arr[0] const idx = Math.floor((arr.length - 1) * p) return arr[idx] } const edgeKey = (from, to) => `${from} -> ${to}` const computeAnomalySeverity = (payload) => { const p = payload || {} let ratio = 1 if (p.thresholdPerSec > 0 && p.ratePerSec > 0) ratio = Number(p.ratePerSec) / Number(p.thresholdPerSec) if (p.thresholdChanges > 0 && p.changesInWindow > 0) ratio = Number(p.changesInWindow) / Number(p.thresholdChanges) if (!Number.isFinite(ratio) || ratio <= 0) ratio = 1 if (ratio >= 3) return { label: 'critical', score: roundTo(ratio, 2) } if (ratio >= 2) return { label: 'high', score: roundTo(ratio, 2) } if (ratio >= 1.25) return { label: 'medium', score: roundTo(ratio, 2) } return { label: 'low', score: roundTo(ratio, 2) } } const SVG_REQUEST_RE = /\b(svg|chart|graph|plot|diagram|bar|pie|line|grafico|grafici|diagramma|istogramma|torta)\b/i const SVG_PRESENT_RE = /```svg[\s\S]*?```|<svg[\s>][\s\S]*?<\/svg>/i const FUNCTION_NODE_CODE_REVIEW_RE = /\b(function|function node|nodo function|nodi function)\b/i const JAVASCRIPT_REVIEW_RE = /\b(js|javascript|java\s*script|code|codice|script|sorgente|source|errore|errori|error|bug|review|reviewa|analizza|analy(?:s|z)e|check|controlla|debug)\b/i const escapeXml = (value) => String(value || '') .replace(/&/g, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;') .replace(/'/g, '&apos;') const truncateLabel = (value, maxLen = 14) => { const s = String(value || '') if (s.length <= maxLen) return s return s.slice(0, Math.max(1, maxLen - 2)) + '..' } const shouldGenerateSvgChart = (question) => SVG_REQUEST_RE.test(String(question || '')) const shouldIncludeFunctionNodeSourceContext = (question) => { const q = String(question || '').trim() if (!q) return false return FUNCTION_NODE_CODE_REVIEW_RE.test(q) && JAVASCRIPT_REVIEW_RE.test(q) } const normalizeCodeBlockText = (value) => String(value || '') .replace(/\r\n/g, '\n') .replace(/\r/g, '\n') .trim() const stripPayloadDecimals = (value) => { if (value === undefined || value === null) return value if (typeof value === 'number') { if (!Number.isFinite(value)) return value return Math.trunc(value) } if (Array.isArray(value)) return value.map(v => stripPayloadDecimals(v)) if (typeof value === 'object') { const out = {} Object.keys(value).forEach((k) => { out[k] = stripPayloadDecimals(value[k]) }) return out } if (typeof value === 'string') { const s = String(value).trim() if (s === '') return '' if (/^[+-]?\d+(?:\.\d+)?$/.test(s)) { const n = Number(s) if (Number.isFinite(n)) return String(Math.trunc(n)) } if ((s.startsWith('{') && s.endsWith('}')) || (s.startsWith('[') && s.endsWith(']'))) { try { const parsed = JSON.parse(s) return safeStringify(stripPayloadDecimals(parsed)) } catch (error) { return s } } return s } return value } const compactPayloadForNodeLabel = (value, maxLen = 28) => { const normalizedPayload = stripPayloadDecimals(value) let s = normalizeValueForCompare(normalizedPayload) s = String(s || '').replace(/\s+/g, ' ').trim() if (s.length <= maxLen) return s return s.slice(0, Math.max(1, maxLen - 2)) + '..' } const normalizeAreaText = (value) => String(value || '') .replace(/\s+/g, ' ') .replace(/[–—]/g, '-') .trim() const slugifyAreaText = (value) => normalizeAreaText(value) .normalize('NFKD') .replace(/[\u0300-\u036f]/g, '') .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 80) || 'area' const pushUniqueValue = (list, value, maxItems = 6) => { const normalized = normalizeAreaText(value) if (!normalized) return if (!Array.isArray(list)) return if (list.includes(normalized)) return if (list.length >= maxItems) return list.push(normalized) } const normalizeGaRoleValue = (value, fallback = 'auto') => { const raw = normalizeAreaText(value).toLowerCase() if (['auto', 'command', 'status', 'neutral'].includes(raw)) return raw return fallback } const parseEtsHierarchyLabel = (value) => { const raw = normalizeAreaText(value) if (!raw) { return { raw: '', deviceLabel: '', mainGroup: '', middleGroup: '', hierarchyPath: '' } } const match = raw.match(/^\(([^()]+)\)\s*(.*)$/) if (!match) { return { raw, deviceLabel: raw, mainGroup: '', middleGroup: '', hierarchyPath: '' } } const hierarchy = String(match[1] || '') .split('->') .map(part => normalizeAreaText(part)) .filter(Boolean) return { raw, deviceLabel: normalizeAreaText(match[2] || raw), mainGroup: hierarchy[0] || '', middleGroup: hierarchy[1] || '', hierarchyPath: hierarchy.join(' / ') } } const AREA_TAG_RULES = [ { tag: 'lighting', pattern: /\b(light|lights|lighting|luce|luci|lamp|dimmer)\b/i }, { tag: 'hvac', pattern: /\b(hvac|clima|climate|fan\s?coil|fancoil|heating|cooling|thermo|temp|temperature)\b/i }, { tag: 'shading', pattern: /\b(blind|blinds|shutter|shutters|jalousie|curtain|curtains|tapparella|tapparelle)\b/i }, { tag: 'presence', pattern: /\b(presence|occupancy|motion|presence detector|pir|presence sensor|presence)\b/i }, { tag: 'access', pattern: /\b(door|doors|window|windows|access|lock|badge|porta|porte|finestra|finestre)\b/i }, { tag: 'energy', pattern: /\b(power|energy|meter|consumption|load|carico|consumo|misura)\b/i } ] const inferAreaTags = ({ mainGroup, middleGroup, deviceLabel, dpt }) => { const text = [mainGroup, middleGroup, deviceLabel, dpt].filter(Boolean).join(' ') const tags = [] AREA_TAG_RULES.forEach((rule) => { if (rule.pattern.test(text)) tags.push(rule.tag) }) return tags } const buildSuggestedAreasFromCsv = (csv) => { const rows = Array.isArray(csv) ? csv : [] const areasById = new Map() let hierarchicalGaCount = 0 let secondaryGroupCount = 0 let mainGroupCount = 0 const ensureArea = ({ id, kind, name, parentName, pathTokens }) => { const key = String(id || '').trim() if (!key) return null if (!areasById.has(key)) { areasById.set(key, { id: key, kind: String(kind || 'area').trim() || 'area', name: normalizeAreaText(name || ''), parentName: normalizeAreaText(parentName || ''), pathTokens: Array.isArray(pathTokens) ? pathTokens.map(token => normalizeAreaText(token)).filter(Boolean) : [], gaSet: new Set(), dptSet: new Set(), tags: new Set(), sampleGAs: [], sampleLabels: [] }) if (kind === 'secondary_group') secondaryGroupCount += 1 if (kind === 'main_group') mainGroupCount += 1 } return areasById.get(key) } const registerAreaRow = ({ areaId, kind, name, parentName, pathTokens, row, parsed }) => { const area = ensureArea({ id: areaId, kind, name, parentName, pathTokens }) if (!area) return const ga = normalizeAreaText(row && row.ga) const dpt = normalizeAreaText(row && row.dpt) if (ga) area.gaSet.add(ga) if (dpt) area.dptSet.add(dpt) pushUniqueValue(area.sampleGAs, ga, 6) pushUniqueValue(area.sampleLabels, parsed && parsed.deviceLabel, 4) inferAreaTags({ mainGroup: parsed && parsed.mainGroup, middleGroup: parsed && parsed.middleGroup, deviceLabel: parsed && parsed.deviceLabel, dpt }).forEach(tag => area.tags.add(tag)) } rows.forEach((row) => { const ga = normalizeAreaText(row && row.ga) if (!ga) return const parsed = parseEtsHierarchyLabel(row && row.devicename) if (parsed.mainGroup || parsed.middleGroup) hierarchicalGaCount += 1 if (parsed.mainGroup) { registerAreaRow({ areaId: `main:${slugifyAreaText(parsed.mainGroup)}`, kind: 'main_group', name: parsed.mainGroup, parentName: '', pathTokens: [parsed.mainGroup], row, parsed }) } if (parsed.mainGroup && parsed.middleGroup) { registerAreaRow({ areaId: `secondary:${slugifyAreaText(parsed.mainGroup)}:${slugifyAreaText(parsed.middleGroup)}`, kind: 'secondary_group', name: parsed.middleGroup, parentName: parsed.mainGroup, pathTokens: [parsed.mainGroup, parsed.middleGroup], row, parsed }) } }) const suggested = Array.from(areasById.values()) .map((entry) => { const gaCount = entry.gaSet.size const dptCount = entry.dptSet.size const path = entry.pathTokens.join(' / ') return { id: entry.id, kind: entry.kind, name: entry.name, baseName: entry.name, parentId: entry.kind === 'secondary_group' ? `main:${slugifyAreaText(entry.parentName)}` : '', parentName: entry.parentName, baseParentName: entry.parentName, path, basePath: path, gaCount, dptCount, gaList: Array.from(entry.gaSet.values()).sort(), dptList: Array.from(entry.dptSet.values()).sort(), tags: Array.from(entry.tags.values()).sort(), baseTags: Array.from(entry.tags.values()).sort(), sampleGAs: entry.sampleGAs.slice(0, 6), sampleLabels: entry.sampleLabels.slice(0, 4), description: entry.kind === 'secondary_group' ? `${entry.parentName || 'ETS'} / ${entry.name} (${gaCount} GA)` : `${entry.name} (${gaCount} GA)`, priority: entry.kind === 'secondary_group' ? 2 : 1 } }) .sort((a, b) => { if (b.priority !== a.priority) return b.priority - a.priority if (b.gaCount !== a.gaCount) return b.gaCount - a.gaCount return String(a.path || a.name || '').localeCompare(String(b.path || b.name || '')) }) return { source: rows.length ? 'ets_csv' : 'none', generatedAt: new Date().toISOString(), totals: { gaCount: rows.length, hierarchicalGaCount, suggestedAreaCount: suggested.length, secondaryGroupCount, mainGroupCount }, suggested } } const buildGaCatalogFromCsv = (csv) => { const rows = Array.isArray(csv) ? csv : [] const byGa = new Map() rows.forEach((row) => { const ga = normalizeAreaText(row && row.ga) if (!ga || byGa.has(ga)) return const parsed = parseEtsHierarchyLabel(row && row.devicename) const dpt = normalizeAreaText(row && row.dpt) const label = normalizeAreaText(parsed.deviceLabel || row.devicename || ga) const roleDetails = inferSignalRoleDetails({ label, dpt }) const tags = inferAreaTags({ mainGroup: parsed.mainGroup, middleGroup: parsed.middleGroup, deviceLabel: label, dpt }) byGa.set(ga, { ga, dpt, label, etsName: normalizeAreaText(row && row.devicename), baseRole: roleDetails.role, baseRoleSource: roleDetails.source, role: roleDetails.role, roleSource: roleDetails.source, roleOverride: 'auto', mainGroup: parsed.mainGroup || '', middleGroup: parsed.middleGroup || '', hierarchyPath: parsed.hierarchyPath || '', tags, valueOptions: getDptValueOptions(dpt) }) }) return Array.from(byGa.values()) .sort((a, b) => { const left = `${a.hierarchyPath} ${a.label} ${a.ga}`.trim() const right = `${b.hierarchyPath} ${b.label} ${b.ga}`.trim() return left.localeCompare(right) }) } const applyGaRoleOverridesToCatalog = ({ catalog, roleOverrides }) => { const rawCatalog = Array.isArray(catalog) ? catalog : [] const overrides = roleOverrides && typeof roleOverrides === 'object' ? roleOverrides : {} return rawCatalog.map((item) => { const ga = String(item && item.ga ? item.ga : '').trim() const overrideRole = normalizeGaRoleValue(overrides[ga], 'auto') return Object.assign({}, item, { role: overrideRole === 'auto' ? normalizeGaRoleValue(item && item.baseRole ? item.baseRole : item && item.role ? item.role : 'neutral', 'neutral') : overrideRole, roleSource: overrideRole === 'auto' ? String(item && item.baseRoleSource ? item.baseRoleSource : item && item.roleSource ? item.roleSource : 'unknown_rule') : 'user_override', roleOverride: overrideRole }) }) } const isAmbiguousGaRoleSource = (source) => { const value = normalizeAreaText(source).toLowerCase() return value === 'dpt_rule' || value === 'unknown_rule' } const normalizeGaRoleSuggestionPayload = ({ payload, gaCatalogMap }) => { const parsed = payload && typeof payload === 'object' ? payload : {} const rawRoles = Array.isArray(parsed) ? parsed : Array.isArray(parsed.roles) ? parsed.roles : Array.isArray(parsed.items) ? parsed.items : [] const overrides = {} rawRoles.forEach((entry) => { const ga = normalizeAreaText(entry && (entry.ga || entry.groupAddress || entry.address)) if (!ga || !gaCatalogMap.has(ga)) return const role = normalizeGaRoleValue(entry && entry.role, 'auto') if (role === 'auto') return overrides[ga] = role }) return overrides } const normalizeLanguageCode = (value, fallback = 'en') => { const raw = normalizeAreaText(value).toLowerCase() if (!raw) return fallback const match = raw.match(/^[a-z]{2,3}/) return match ? match[0] : fallback } const extractLanguageCodeFromHeader = (value, fallback = 'en') => { const raw = normalizeAreaText(value) if (!raw) return fallback const first = raw.split(',')[0] || '' return normalizeLanguageCode(first, fallback) } const languageNameFromCode = (value) => { const code = normalizeLanguageCode(value, 'en') const map = { it: 'Italian', en: 'English', de: 'German', fr: 'French', es: 'Spanish', pt: 'Portuguese', nl: 'Dutch' } return map[code] || code } const enrichSuggestedAreasWithSummary = ({ baseSnapshot, summary }) => { const snapshot = baseSnapshot && typeof baseSnapshot === 'object' ? baseSnapshot : buildSuggestedAreasFromCsv([]) const gaLastSeenAt = summary && typeof summary.gaLastSeenAt === 'object' ? summary.gaLastSeenAt : {} const gaLastPayload = summary && typeof summary.gaLastPayload === 'object' ? summary.gaLastPayload : {} const analysisWindowSec = Math.max(30, Number(summary && summary.meta && summary.meta.analysisWindowSec) || 0) const activeCutoffMs = nowMs() - (analysisWindowSec * 1000) let activeAreaCount = 0 const suggested = (Array.isArray(snapshot.suggested) ? snapshot.suggested : []).map((area) => { let activeGaCount = 0 let lastSeenAtMs = 0 const recentPayloads = [] ; (Array.isArray(area.sampleGAs) ? area.sampleGAs : []).forEach((ga) => { const ts = new Date(String(gaLastSeenAt[ga] || '')).getTime() if (Number.isFinite(ts) && ts > 0) { lastSeenAtMs = Math.max(lastSeenAtMs, ts) if (ts >= activeCutoffMs) activeGaCount += 1 } if (gaLastPayload[ga] !== undefined) { pushUniqueValue(recentPayloads, `${ga}: ${compactPayloadForNodeLabel(gaLastPayload[ga], 22)}`, 4) } }) if (activeGaCount > 0) activeAreaCount += 1 return Object.assign({}, area, { activeGaCount, activityPct: area.gaCount > 0 ? roundTo((activeGaCount / area.gaCount) * 100, 1) : 0, lastSeenAt: lastSeenAtMs > 0 ? new Date(lastSeenAtMs).toISOString() : '', recentPayloads }) }) return { source: snapshot.source || 'none', generatedAt: new Date().toISOString(), totals: Object.assign({}, snapshot.totals || {}, { activeAreaCount }), suggested } } const buildAreasPromptContext = (areasSnapshot) => { const suggested = Array.isArray(areasSnapshot && areasSnapshot.suggested) ? areasSnapshot.suggested : [] if (!suggested.length) return '' const lines = suggested.slice(0, 12).map((area) => { const tags = Array.isArray(area.tags) && area.tags.length ? ` tags=${area.tags.join(',')}` : '' const activity = area.gaCount > 0 ? ` active=${Number(area.activeGaCount || 0)}/${Number(area.gaCount || 0)}` : '' return `- ${area.path || area.name} [${area.kind}]${activity}${tags}` }) return [ 'Suggested installation areas derived from ETS hierarchy:', lines.join('\n') ].join('\n') } const ensureDirectorySync = (dirPath) => { const target = String(dirPath || '').trim() if (!target) return false try { fs.mkdirSync(target, { recursive: true }) return true } catch (error) { return false } } const readJsonFileSafe = (filePath, fallbackValue) => { try { if (!fs.existsSync(filePath)) return fallbackValue const raw = fs.readFileSync(filePath, 'utf8') if (!raw || String(raw).trim() === '') return fallbackValue return JSON.parse(raw) } catch (error) { return fallbackValue } } const formatArchiveDayKey = (ts) => { try { return new Date(ts).toISOString().slice(0, 10) } catch (error) { return new Date().toISOString().slice(0, 10) } } const collectArchiveDayKeysBetween = ({ fromTs, toTs }) => { const out = [] const start = Number(fromTs || 0) const end = Number(toTs || 0) if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return out const cursor = new Date(start) cursor.setUTCHours(0, 0, 0, 0) const endDay = new Date(end) endDay.setUTCHours(0, 0, 0, 0) while (cursor.getTime() <= endDay.getTime()) { out.push(cursor.toISOString().slice(0, 10)) cursor.setUTCDate(cursor.getUTCDate() + 1) } return out } const startOfLocalDayMs = (ts) => { const d = new Date(ts) d.setHours(0, 0, 0, 0) return d.getTime() } const endOfLocalDayMs = (ts) => { const d = new Date(ts) d.setHours(23, 59, 59, 999) return d.getTime() } const parseQuestionTimeRange = (question, nowTs = Date.now()) => { const text = String(question || '').trim().toLowerCase() if (!text) return null const exactDates = Array.from(text.matchAll(/\b(\d{4}-\d{2}-\d{2})\b/g)).map(match => match[1]) if (exactDates.length >= 2) { const start = new Date(`${exactDates[0]}T00:00:00`).getTime() const end = new Date(`${exactDates[1]}T23:59:59.999`).getTime() if (Number.isFinite(start) && Number.isFinite(end) && end >= start) { return { fromTs: start, toTs: end, label: `${exactDates[0]}..${exactDates[1]}`, explicit: true } } } if (exactDates.length === 1) { const start = new Date(`${exactDates[0]}T00:00:00`).getTime() const end = new Date(`${exactDates[0]}T23:59:59.999`).getTime() if (Number.isFinite(start) && Number.isFinite(end)) { return { fromTs: start, toTs: end, label: exactDates[0], explicit: true } } } const dayStart = startOfLocalDayMs(nowTs) const dayEnd = endOfLocalDayMs(nowTs) const yesterdayStart = dayStart - (24 * 60 * 60 * 1000) const yesterdayEnd = dayStart - 1 if (/\b(stamattina|this morning)\b/.test(text)) { return { fromTs: dayStart, toTs: Math.min(dayEnd, dayStart + (12 * 60 * 60 * 1000) - 1), label: 'this morning', explicit: true } } if (/\b(oggi pomeriggio|this afternoon)\b/.test(text)) { return { fromTs: dayStart + (12 * 60 * 60 * 1000), toTs: Math.min(dayEnd, dayStart + (18 * 60 * 60 * 1000) - 1), label: 'this afternoon', explicit: true } } if (/\b(stasera|this evening|tonight)\b/.test(text)) { return { fromTs: dayStart + (18 * 60 * 60 * 1000), toTs: dayEnd, label: 'this evening', explicit: true } } if (/\b(oggi|today)\b/.test(text)) { return { fromTs: dayStart, toTs: dayEnd, label: 'today', explicit: true } } if (/\b(ieri|yesterday)\b/.test(text)) { return { fromTs: yesterdayStart, toTs: yesterdayEnd, label: 'yesterday', explicit: true } } const lastDaysMatch = text.match(/\b(?:last|ultimi)\s+(\d{1,3})\s+(?:day|days|giorno|giorni)\b/) if (lastDaysMatch) { const days = Math.max(1, Number(lastDaysMatch[1] || 1)) return { fromTs: nowTs - (days * 24 * 60 * 60 * 1000), toTs: nowTs, label: `last ${days} days`, explicit: true } } if (/\b(this week|questa settimana)\b/.tes