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,336 lines (1,235 loc) • 328 kB
JavaScript
// 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')
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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
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/.test(text)) {
const d = new Date(nowTs)
const dow = d.getDay()
const offset = dow === 0 ? 6 : dow - 1
d.setHours(0, 0, 0, 0)
d.setDate(d.getDate() - offset)
return { fromTs: d.getTime(), toTs: nowTs, label: 'this week', explicit: true }
}
return null
}
const normalizeAreaOverridePayload = (payload) => {
const p = payload && typeof payload === 'object' ? payload : {}
const normalized = {}
if (Object.prototype.hasOwnProperty.call(p, 'name')) normalized.name = normalizeAreaText(p.name)
if (Object.prototype.hasOwnProperty.call(p, 'description')) normalized.description = normalizeAreaText(p.description)
if (Object.prototype.hasOwnProperty.call(p, 'deleted')) normalized.deleted = p.deleted === true
if (Object.prototype.hasOwnProperty.call(p, 'tags')) {
normalized.tags = Array.isArray(p.tags)
? Array.from(new Set(p.tags.map(tag => slugifyAreaText(tag)).filter(Boolean))).slice(0, 12)
: []
}
if (Object.prototype.hasOwnProperty.call(p, 'gaList')) {
normalized.gaList = Array.isArray(p.gaList)
? Array.from(new Set(p.gaList.map(ga => normalizeAreaText(ga)).filter(Boolean))).slice(0, 5000)
: []
}
return normalized
}
const normalizeCustomAreaId = (value, fallback = '') => {
const raw = normalizeAreaText(value || fallback)
const slug = slugifyAreaText(raw)
return slug ? `custom:${slug}` : ''
}
const applyAreaOverridesToSnapshot = ({ snapshot, overrides, gaCatalog }) => {
const baseSnapshot = snapshot && typeof snapshot === 'object' ? snapshot : buildSuggestedAreasFromCsv([])
const rawOverrides = overrides && typeof overrides === 'object' ? overrides : {}
const gaCatalogMap = new Map((Array.isArray(gaCatalog) ? gaCatalog : []).map(item => [String(item && item.ga ? item.ga : '').trim(), item]))
const baseAreas = Array.isArray(baseSnapshot.suggested) ? baseSnapshot.suggested : []
const byId = new Map()
baseAreas.forEach((area) => {
const override = rawOverrides[area.id] && typeof rawOverrides[area.id] === 'object'
? normalizeAreaOverridePayload(rawOverrides[area.id])
: {}
if (override.deleted === true) return
byId.set(area.id, Object.assign({}, area, {
customName: Object.prototype.hasOwnProperty.call(override, 'name') ? override.name : '',
customDescription: Object.prototype.hasOwnProperty.call(override, 'description') ? override.description : '',
customTags: Object.prototype.hasOwnProperty.call(override, 'tags') ? override.tags : null,
customGaList: Object.prototype.hasOwnProperty.call(override, 'gaList') ? override.gaList : null,
hasOverride: Object.keys(override).length > 0
}))
})
Object.keys(rawOverrides).forEach((overrideId) => {
if (byId.has(overrideId)) return
const override = normalizeAreaOverridePayload(rawOverrides[overrideId])
if (override.deleted === true) return
const customGaList = Array.isArray(override.gaList) ? override.gaList.filter(ga => gaCatalogMap.has(ga)) : []
const inferredTags = new Set(Array.isArray(override.tags) ? override.tags : [])
const sampleLabels = []
const dptSet = new Set()
customGaList.forEach((ga) => {
const item = gaCatalogMap.get(ga)
if (!item) return
if (item.dpt) dptSet.add(item.dpt)
pushUniqueValue(sampleLabels, item.label, 4)
; (Array.isArray(item.tags) ? item.tags : []).forEach(tag => inferredTags.add(tag))
})
const customName = normalizeAreaText(override.name || overrideId.replace(/^custom:/, ''))
const isLlmGenerated = String(overrideId || '').startsWith('llm:')
byId.set(overrideId, {
id: overrideId,
kind: isLlmGenerated ? 'custom_llm' : 'custom_manual',
name: customName,
baseName: customName,
parentId: '',
parentName: '',
baseParentName: '',
path: customName,
basePath: customName,
gaCount: customGaList.length,
dptCount: dptSet.size,
gaList: customGaList,
dptList: Array.from(dptSet.values()).sort(),
tags: Array.from(inferredTags.values()).sort(),
baseTags: Array.from(inferredTags.values()).sort(),
sampleGAs: customGaList.slice(0, 6),
sampleLabels,
description: normalizeAreaText(override.description || `${customName} (${customGaList.length} GA)`),
priority: 3,
customName,
customDescription: normalizeAreaText(override.description || ''),
customTags: Array.isArray(override.tags) ? override.tags.slice(0, 12) : null,
customGaList,
hasOverride: true
})
})
const resolveAreaName = (area) => normalizeAreaText((area && area.customName) || (area && area.baseName) || (area && area.name))
byId.forEach((area) => {
const parentArea = area.parentId ? byId.get(area.parentId) : null
const resolvedName = resolveAreaName(area)
const resolvedParentName = parentArea ? resolveAreaName(parentArea) : normalizeAreaText(area.baseParentName || area.parentName)
const resolvedPath = parentArea
? [normalizeAreaText(parentArea.path || parentArea.name), resolvedName].filter(Boolean).join(' / ')
: resolvedName
let tags = Array.isArray(area.customTags) ? area.customTags.slice(0, 12) : (Array.isArray(area.baseTags) ? area.baseTags.slice(0, 12) : [])
let gaList = Array.isArray(area.gaList) ? area.gaList.slice() : []
let dptList = Array.isArray(area.dptList) ? area.dptList.slice() : []
let sampleGAs = Array.isArray(area.sampleGAs) ? area.sampleGAs.slice(0, 6) : []
let sampleLabels = Array.isArray(area.sampleLabels) ? area.sampleLabels.slice(0, 4) : []
if (Array.isArray(area.gaList) && Array.isArray(area.customGaList)) {
const filtered = area.customGaList
.filter(ga => gaCatalogMap.has(ga))
gaList = filtered
const nextDptSet = new Set()
const nextLabelSet = []
const inferredTags = new Set()
filtered.forEach((ga) => {
const item = gaCatalogMap.get(ga)
if (!item) return
if (item.dpt) nextDptSet.add(item.dpt)
pushUniqueValue(nextLabelSet, item.label, 4)
; (Array.isArray(item.tags) ? item.tags : []).forEach(tag => inferredTags.add(tag))
})
dptList = Array.from(nextDptSet.values()).sort()
sampleGAs = filtered.slice(0, 6)
sampleLabels = nextLabelSet
if (!Array.isArray(area.customTags)) tags = Array.from(inferredTags.values()).sort()
}
const gaCount = gaList.length
const dptCount = dptList.length
const description = area.customDescription !== ''
? area.customDescription
: area.kind === 'secondary_group'
? `${resolvedParentName || 'ETS'} / ${resolvedName} (${gaCount} GA)`
: `${resolvedName} (${gaCount} GA)`
Object.assign(area, {
name: resolvedName,
parentName: resolvedParentName,
path: resolvedPath,
tags,
description,
gaList,
dptList,
gaCount,
dptCount,
sampleGAs,
sampleLabels
})
})
return Object.assign({}, baseSnapshot, {
generatedAt: new Date().toISOString(),
suggested: Array.from(byId.values())
})
}
const DEFAULT_AREA_PROFILES = [
{
id: 'area_diagnostic',
builtIn: true,
name: 'Control Area',
description: 'General read-only diagnostic of the selected area based on ETS structure and current KNX activity.',
minActivityPct: 20,
maxSilentPct: 60,
maxAnomalies: 2,
targetTags: []
},
{
id: 'lighting_area',
builtIn: true,
name: 'Lighting Area',
description: 'Focus on lighting-oriented areas and highlight low activity or repeated anomalies.',
minActivityPct: 15,
maxSilentPct: 70,
maxAnomalies: 1,
targetTags: ['lighting']
},
{
id: 'hvac_area',
builtIn: true,
name: 'HVAC Area',
description: 'Focus on HVAC-oriented areas and check whether the related addresses are alive.',
minActivityPct: 10,
maxSilentPct: 80,
maxAnomalies: 1,
targetTags: ['hvac']
}
]
const clampNumber = (value, { min = 0, max = 100, fallback = 0 } = {}) => {
const n = Number(value)
if (!Number.isFinite(n)) return fallback
if (n < min) return min
if (n > max) return max
return n
}
const normalizeProfileText = (value, fallback = '') => normalizeAreaText(value || fallback)
const normalizeAreaProfilePayload = (payload, fallbackId = '') => {
const p = payload && typeof payload === 'object' ? payload : {}
const name = normalizeProfileText(p.name, 'Custom Area Profile')
const baseId = normalizeAreaText(p.id || fallbackId || name)
return {
id: slugifyAreaText(baseId),
builtIn: false,
name,
description: normalizeProfileText(p.description),
minActivityPct: clampNumber(p.minActivityPct, { min: 0, max: 100, fallback: 20 }),
maxSilentPct: clampNumber(p.maxSilentPct, { min: 0, max: 100, fallback: 60 }),
maxAnomalies: clampNumber(p.maxAnomalies, { min: 0, max: 999, fallback: 2 }),
targetTags: Array.isArray(p.targetTags)
? Array.from(new Set(p.targetTags.map(tag => slugifyAreaText(tag)).filter(Boolean))).slice(0, 12)
: []
}
}
const mergeAreaProfiles = ({ customProfiles }) => {
const out = new Map()
DEFAULT_AREA_PROFILES.forEach((profile) => {
out.set(profile.id, Object.assign({}, profile))
})
; (Array.isArray(customProfiles) ? customProfiles : []).forEach((profile, index) => {
const normalized = normalizeAreaProfilePayload(profile, `custom-${index + 1}`)
if (!normalized.id) return
out.set(normalized.id, normalized)
})
return Array.from(out.values())
}
const severityRank = (status) => {
const value = String(status || '').toLowerCase()
if (value === 'fail') return 3
if (value === 'warn') return 2
if (value === 'pass') return 1
return 0
}
const buildAreaProfileReport = ({ area, profile, summary, anomalies, generatedAt }) => {
const safeArea = area && typeof area === 'object' ? area : {}
const safeProfile = profile && typeof profile === 'object' ? profile : {}
const safeSummary = summary && typeof summary === 'object' ? summary : {}
const gaList = Array.isArray(safeArea.gaList) ? safeArea.gaList.slice() : []
const gaSet = new Set(gaList.map(ga => String(ga || '').trim()).filter(Boolean))
const gaLastSeenAt = safeSummary && typeof safeSummary.gaLastSeenAt === 'object' ? safeSummary.gaLastSeenAt : {}
const gaLastPayload = safeSummary && typeof safeSummary.gaLastPayload === 'object' ? safeSummary.gaLastPayload : {}
const analysisWindowSec = Math.max(30, Number(safeSummary && safeSummary.meta && safeSummary.meta.analysisWindowSec) || 0)
const activeCutoffMs = nowMs() - (analysisWindowSec * 1000)
const activeGAs = []
const silentGAs = []
gaList.forEach((ga) => {
const ts = new Date(String(gaLastSeenAt[ga] || '')).getTime()
if (Number.isFinite(ts) && ts > 0 && ts >= activeCutoffMs) {
activeGAs.push(ga)
} else {
silentGAs.push(ga)
}
})
const relevantAnomalies = (Array.isArray(anomalies) ? anomalies : [])
.filter((entry) => {
const ga = String(entry && entry.payload && entry.payload.ga ? entry.payload.ga : '').trim()
return ga && gaSet.has(ga)
})
.slice(-50)
.reverse()
const totalGAs = gaList.length
const activeGaCount = activeGAs.length
const silentGaCount = silentGAs.length
const activityPct = totalGAs > 0 ? roundTo((activeGaCount / totalGAs) * 100, 1) : 0
const silentPct = totalGAs > 0 ? roundTo((silentGaCount / totalGAs) * 100, 1) : 0
const tagMismatch = Array.isArray(safeProfile.targetTags) && safeProfile.targetTags.length > 0
? !safeProfile.targetTags.some(tag => Array.isArray(safeArea.tags) && safeArea.tags.includes(tag))
: false
const checks = [
{
id: 'scope_match',
title: 'Profile scope alignment',
status: tagMismatch ? 'warn' : 'pass',
message: tagMismatch
? `Area tags ${Array.isArray(safeArea.tags) ? safeArea.tags.join(', ') : 'n/a'} do not match profile focus ${safeProfile.targetTags.join(', ')}.`
: 'Area tags are compatible with the selected profile.',
metrics: {
areaTags: Array.isArray(safeArea.tags) ? safeArea.tags : [],
targetTags: Array.isArray(safeProfile.targetTags) ? safeProfile.targetTags : []
}
},
{
id: 'activity',
title: 'Area activity',
status: activityPct >= Number(safeProfile.minActivityPct || 0) ? 'pass' : (activityPct > 0 ? 'warn' : 'fail'),
message: `${activeGaCount}/${totalGAs} GA active in the last ${analysisWindowSec}s.`,
metrics: {
activeGaCount,
totalGAs,
activityPct,
minActivityPct: Number(safeProfile.minActivityPct || 0)
}
},
{
id: 'silence',
title: 'Silent addresses',
status: silentPct <= Number(safeProfile.maxSilentPct || 100) ? 'pass' : (silentPct < 100 ? 'warn' : 'fail'),
message: `${silentGaCount}/${totalGAs} GA silent in the current analysis window.`,
metrics: {
silentGaCount,
totalGAs,
silentPct,
maxSilentPct: Number(safeProfile.maxSilentPct || 0)
},
sample: silentGAs.slice(0, 10).map(ga => ({
ga,
lastPayload: gaLastPayload[ga] || ''
}))
},
{
id: 'anomalies',
title: 'Recent anomalies in area',
status: relevantAnomalies.length <= Number(safeProfile.maxAnomalies || 0) ? 'pass' : 'warn',
message: `${relevantAnomalies.length} recent anomalies match the selected area.`,
metrics: {
anomalyCount: relevantAnomalies.length,
maxAnomalies: Number(safeProfile.maxAnomalies || 0)
}
}
]
const suggestions = []
if (tagMismatch) suggestions.push('Check whether the selected profile is appropriate for this area or add matching tags.')
if (activityPct < Number(safeProfile.minActivityPct || 0)) suggestions.push('Run a guided verification on the area or trigger live activity before diagnosing.')
if (silentGaCount > 0) suggestions.push('Inspect the silent GA list first: they are the best candidates for missing feedback or dormant devices.')
if (relevantAnomalies.length > Number(safeProfile.maxAnomalies || 0)) suggestions.push('Open the anomaly list for this area and correlate the failing GA with the ETS object names.')
if (!suggestions.length) suggestions.push('Area looks consistent in read-only mode. Continue with a focused active test only if the issue is still reproducible.')
const overallStatus = checks
.map(check => check.status)
.sort((a, b) => severityRank(b) - severityRank(a))[0] || 'pass'
return {
id: `${safeProfile.id || 'profile'}:${safeArea.id || 'area'}:${Date.now()}`,
generatedAt: generatedAt || new Date().toISOString(),
mode: 'read_only',
overallStatus,
source: {
type: 'profile',
profile