node-red-contrib-knx-ultimate 7.1.0-beta.2 → 7.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/README.md +5 -1
- package/nodes/knxUltimateAI.html +1820 -0
- package/nodes/knxUltimateAI.js +16797 -0
- package/nodes/knxUltimateAIHomeAssistant.html +40 -0
- package/nodes/knxUltimateAIHomeAssistant.js +152 -0
- package/nodes/knxUltimateHueController.html +1 -1
- package/nodes/knxUltimateMatterBridge.html +2 -2
- package/nodes/knxUltimateMatterControllerDevice.html +2 -2
- package/nodes/locales/de/knxUltimateAI.html +5 -0
- package/nodes/locales/de/knxUltimateAI.json +231 -0
- package/nodes/locales/de/knxUltimateUtility.json +8 -0
- package/nodes/locales/en/knxUltimateAI.html +5 -0
- package/nodes/locales/en/knxUltimateAI.json +234 -0
- package/nodes/locales/en/knxUltimateUtility.json +8 -0
- package/nodes/locales/es/knxUltimateAI.html +5 -0
- package/nodes/locales/es/knxUltimateAI.json +231 -0
- package/nodes/locales/es/knxUltimateUtility.json +9 -1
- package/nodes/locales/fr/knxUltimateAI.html +5 -0
- package/nodes/locales/fr/knxUltimateAI.json +231 -0
- package/nodes/locales/fr/knxUltimateUtility.json +8 -0
- package/nodes/locales/it/knxUltimateAI.html +5 -0
- package/nodes/locales/it/knxUltimateAI.json +234 -0
- package/nodes/locales/it/knxUltimateUtility.json +8 -0
- package/nodes/locales/zh-CN/knxUltimateAI.html +5 -0
- package/nodes/locales/zh-CN/knxUltimateAI.json +231 -0
- package/nodes/locales/zh-CN/knxUltimateUtility.json +8 -0
- package/nodes/plugins/knxUltimate-cerebrum-runtime-plugin.js +79 -0
- package/nodes/plugins/knxUltimate-migration-notice-plugin.html +19 -0
- package/nodes/plugins/knxUltimateAI-vue/assets/app.css +1 -0
- package/nodes/plugins/knxUltimateAI-vue/assets/app.js +13 -0
- package/nodes/plugins/knxUltimateAI-vue/assets/chunk-html2canvas.esm.js +5 -0
- package/nodes/plugins/knxUltimateAI-vue/assets/chunk-index.es.js +5 -0
- package/nodes/plugins/knxUltimateAI-vue/assets/chunk-jspdf.es.min.js +79 -0
- package/nodes/plugins/knxUltimateAI-vue/assets/chunk-purify.es.js +3 -0
- package/nodes/plugins/knxUltimateAI-vue/index.html +13 -0
- package/nodes/plugins/knxUltimateViewer-vue/assets/app.js +2 -2
- package/nodes/utils/knxAiCamera.js +302 -0
- package/nodes/utils/knxAiCatalogRetrieval.js +349 -0
- package/nodes/utils/knxAiCerebrum.js +406 -0
- package/nodes/utils/knxAiChatContext.js +496 -0
- package/nodes/utils/knxAiEventHistory.js +521 -0
- package/nodes/utils/knxAiHomeMemory.js +1021 -0
- package/nodes/utils/knxAiScheduler.js +445 -0
- package/nodes/utils/knxAiSemanticContext.js +662 -0
- package/nodes/utils/knxAiTelegramVoice.js +545 -0
- package/nodes/utils/knxAiWebAccess.js +631 -0
- package/package.json +17 -9
- package/resources/KNXAIChatAdapterMappings.js +416 -0
- package/resources/hueControllerMigrationDialog.js +3 -0
- package/resources/knxUtilityMigration.js +3 -0
- package/resources/legacyMigrationNotice.js +143 -0
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
const KNX_AI_ADAPTER_HISTORY_MIN_HOURS = 24
|
|
2
|
+
const KNX_AI_HISTORY_DETAILS_MAX_CHARS = 12000
|
|
3
|
+
const KNX_AI_COMPACT_ARCHIVE_EXTENSION = 'knxctx'
|
|
4
|
+
|
|
5
|
+
const KNX_AI_COMPACT_ARCHIVE_COLUMNS = Object.freeze({
|
|
6
|
+
knx: Object.freeze([
|
|
7
|
+
'ts', 'event', 'source', 'destination', 'dpt', 'devicename', 'payload',
|
|
8
|
+
'payloadmeasureunit', 'echoed', 'repeated', 'rawHex', 'dptdesc'
|
|
9
|
+
]),
|
|
10
|
+
adapter: Object.freeze([
|
|
11
|
+
'ts', 'adapterId', 'adapterTitle', 'providerId', 'providerTitle',
|
|
12
|
+
'controllerId', 'controllerName', 'resourceType', 'resourceId',
|
|
13
|
+
'resourceName', 'eventType', 'eventId', 'active', 'scopeId', 'scopeName',
|
|
14
|
+
'objectTypes', 'details'
|
|
15
|
+
])
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const clampText = (value, maxChars = 500) => String(value === undefined || value === null ? '' : value)
|
|
19
|
+
.trim()
|
|
20
|
+
.slice(0, Math.max(0, Number(maxChars) || 0))
|
|
21
|
+
|
|
22
|
+
const normalizeSearchText = value => clampText(value, 4000)
|
|
23
|
+
.normalize('NFKD')
|
|
24
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
25
|
+
.toLocaleLowerCase()
|
|
26
|
+
.replace(/[^a-z0-9./_-]+/g, ' ')
|
|
27
|
+
.trim()
|
|
28
|
+
|
|
29
|
+
const parseTimestamp = (value, fallback = Date.now()) => {
|
|
30
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value > 100000000000 ? value : value * 1000
|
|
31
|
+
const parsed = new Date(String(value || '')).getTime()
|
|
32
|
+
return Number.isFinite(parsed) ? parsed : fallback
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const sanitizeHistoryValue = (value, depth = 0, seen = new Set()) => {
|
|
36
|
+
if (value === null || value === undefined) return value
|
|
37
|
+
if (typeof value === 'string') return clampText(value, 1000)
|
|
38
|
+
if (typeof value === 'number' || typeof value === 'boolean') return value
|
|
39
|
+
if (typeof value === 'bigint') return String(value)
|
|
40
|
+
if (Buffer.isBuffer(value)) return `[binary ${value.length} bytes]`
|
|
41
|
+
if (typeof value !== 'object') return clampText(value, 1000)
|
|
42
|
+
if (depth >= 3 || seen.has(value)) return '[nested]'
|
|
43
|
+
seen.add(value)
|
|
44
|
+
if (Array.isArray(value)) {
|
|
45
|
+
const out = value.slice(0, 30).map(item => sanitizeHistoryValue(item, depth + 1, seen))
|
|
46
|
+
seen.delete(value)
|
|
47
|
+
return out
|
|
48
|
+
}
|
|
49
|
+
const out = {}
|
|
50
|
+
Object.keys(value).slice(0, 60).forEach(key => {
|
|
51
|
+
const normalizedKey = clampText(key, 120)
|
|
52
|
+
if (!normalizedKey || /^(data|image|snapshot|buffer|base64)$/i.test(normalizedKey)) return
|
|
53
|
+
out[normalizedKey] = sanitizeHistoryValue(value[key], depth + 1, seen)
|
|
54
|
+
})
|
|
55
|
+
seen.delete(value)
|
|
56
|
+
return out
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const encodeCompactText = value => encodeURIComponent(String(value === undefined || value === null ? '' : value))
|
|
60
|
+
.replace(/[!'()*]/g, char => `%${char.charCodeAt(0).toString(16).toUpperCase()}`)
|
|
61
|
+
|
|
62
|
+
const decodeCompactText = value => {
|
|
63
|
+
try { return decodeURIComponent(String(value || '')) } catch (error) { return String(value || '') }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const encodeKnxAiCompactValue = (value, depth = 0, seen = new Set()) => {
|
|
67
|
+
if (value === undefined) return 'u'
|
|
68
|
+
if (value === null) return 'z'
|
|
69
|
+
if (typeof value === 'boolean') return value ? 'b1' : 'b0'
|
|
70
|
+
if (typeof value === 'number') return Number.isFinite(value) ? `n${value}` : 'u'
|
|
71
|
+
if (typeof value === 'bigint') return `s${encodeCompactText(String(value))}`
|
|
72
|
+
if (typeof value === 'string') return `s${encodeCompactText(value)}`
|
|
73
|
+
if (Buffer.isBuffer(value)) return `x${value.toString('base64')}`
|
|
74
|
+
if (typeof value !== 'object') return `s${encodeCompactText(String(value))}`
|
|
75
|
+
if (depth >= 4 || seen.has(value)) return `s${encodeCompactText('[nested]')}`
|
|
76
|
+
seen.add(value)
|
|
77
|
+
if (Array.isArray(value)) {
|
|
78
|
+
const encoded = value.slice(0, 40).map(item => encodeKnxAiCompactValue(item, depth + 1, seen))
|
|
79
|
+
seen.delete(value)
|
|
80
|
+
return `a(${encoded.join(',')})`
|
|
81
|
+
}
|
|
82
|
+
const encoded = Object.keys(value).slice(0, 80).map(key => (
|
|
83
|
+
`${encodeCompactText(key)}=${encodeKnxAiCompactValue(value[key], depth + 1, seen)}`
|
|
84
|
+
))
|
|
85
|
+
seen.delete(value)
|
|
86
|
+
return `o(${encoded.join(';')})`
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const decodeKnxAiCompactValue = value => {
|
|
90
|
+
const source = String(value || '')
|
|
91
|
+
let offset = 0
|
|
92
|
+
const readUntil = delimiters => {
|
|
93
|
+
const start = offset
|
|
94
|
+
while (offset < source.length && !delimiters.includes(source[offset])) offset += 1
|
|
95
|
+
return source.slice(start, offset)
|
|
96
|
+
}
|
|
97
|
+
const parseValue = () => {
|
|
98
|
+
const tag = source[offset]
|
|
99
|
+
offset += 1
|
|
100
|
+
if (tag === 'u') return undefined
|
|
101
|
+
if (tag === 'z') return null
|
|
102
|
+
if (tag === 'b') {
|
|
103
|
+
const bool = source[offset] === '1'
|
|
104
|
+
offset += 1
|
|
105
|
+
return bool
|
|
106
|
+
}
|
|
107
|
+
if (tag === 'n') {
|
|
108
|
+
const parsed = Number(readUntil([',', ';', ')']))
|
|
109
|
+
return Number.isFinite(parsed) ? parsed : undefined
|
|
110
|
+
}
|
|
111
|
+
if (tag === 's') return decodeCompactText(readUntil([',', ';', ')']))
|
|
112
|
+
if (tag === 'x') {
|
|
113
|
+
const encoded = readUntil([',', ';', ')'])
|
|
114
|
+
try { return Buffer.from(encoded, 'base64') } catch (error) { return encoded }
|
|
115
|
+
}
|
|
116
|
+
if (tag === 'a' && source[offset] === '(') {
|
|
117
|
+
offset += 1
|
|
118
|
+
const out = []
|
|
119
|
+
while (offset < source.length && source[offset] !== ')') {
|
|
120
|
+
out.push(parseValue())
|
|
121
|
+
if (source[offset] === ',') offset += 1
|
|
122
|
+
else if (source[offset] !== ')') break
|
|
123
|
+
}
|
|
124
|
+
if (source[offset] === ')') offset += 1
|
|
125
|
+
return out
|
|
126
|
+
}
|
|
127
|
+
if (tag === 'o' && source[offset] === '(') {
|
|
128
|
+
offset += 1
|
|
129
|
+
const out = {}
|
|
130
|
+
while (offset < source.length && source[offset] !== ')') {
|
|
131
|
+
const key = decodeCompactText(readUntil(['=']))
|
|
132
|
+
if (source[offset] !== '=') break
|
|
133
|
+
offset += 1
|
|
134
|
+
out[key] = parseValue()
|
|
135
|
+
if (source[offset] === ';') offset += 1
|
|
136
|
+
else if (source[offset] !== ')') break
|
|
137
|
+
}
|
|
138
|
+
if (source[offset] === ')') offset += 1
|
|
139
|
+
return out
|
|
140
|
+
}
|
|
141
|
+
return decodeCompactText(source.slice(Math.max(0, offset - 1)))
|
|
142
|
+
}
|
|
143
|
+
try { return parseValue() } catch (error) { return source }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const escapeKnxAiCompactField = value => String(value === undefined || value === null ? '' : value)
|
|
147
|
+
.replace(/\\/g, '\\\\')
|
|
148
|
+
.replace(/\t/g, '\\t')
|
|
149
|
+
.replace(/\r/g, '\\r')
|
|
150
|
+
.replace(/\n/g, '\\n')
|
|
151
|
+
|
|
152
|
+
const unescapeKnxAiCompactField = value => String(value || '').replace(/\\([\\tnr])/g, (match, escaped) => {
|
|
153
|
+
if (escaped === 't') return '\t'
|
|
154
|
+
if (escaped === 'r') return '\r'
|
|
155
|
+
if (escaped === 'n') return '\n'
|
|
156
|
+
return '\\'
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
const serializeKnxAiCompactHistoryRecord = (event, kind = 'adapter') => {
|
|
160
|
+
if (!event || typeof event !== 'object') return ''
|
|
161
|
+
const normalizedKind = kind === 'knx' ? 'knx' : 'adapter'
|
|
162
|
+
const values = normalizedKind === 'knx'
|
|
163
|
+
? [
|
|
164
|
+
Number(event.ts || 0),
|
|
165
|
+
event.event,
|
|
166
|
+
event.source,
|
|
167
|
+
event.destination,
|
|
168
|
+
event.dpt,
|
|
169
|
+
event.devicename,
|
|
170
|
+
encodeKnxAiCompactValue(sanitizeHistoryValue(event.payload)),
|
|
171
|
+
event.payloadmeasureunit,
|
|
172
|
+
event.echoed === true ? 1 : 0,
|
|
173
|
+
event.repeated === true ? 1 : 0,
|
|
174
|
+
event.rawHex,
|
|
175
|
+
event.dptdesc
|
|
176
|
+
]
|
|
177
|
+
: [
|
|
178
|
+
Number(event.ts || 0),
|
|
179
|
+
event.adapterId,
|
|
180
|
+
event.adapterTitle,
|
|
181
|
+
event.providerId,
|
|
182
|
+
event.providerTitle,
|
|
183
|
+
event.controllerId,
|
|
184
|
+
event.controllerName,
|
|
185
|
+
event.resourceType,
|
|
186
|
+
event.resourceId,
|
|
187
|
+
event.resourceName,
|
|
188
|
+
event.eventType,
|
|
189
|
+
event.eventId,
|
|
190
|
+
event.active === false ? 0 : 1,
|
|
191
|
+
event.scopeId,
|
|
192
|
+
event.scopeName,
|
|
193
|
+
encodeKnxAiCompactValue(Array.isArray(event.objectTypes) ? event.objectTypes : []),
|
|
194
|
+
encodeKnxAiCompactValue(sanitizeHistoryValue(event.details || {}))
|
|
195
|
+
]
|
|
196
|
+
return values.map(escapeKnxAiCompactField).join('\t')
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const parseKnxAiCompactHistoryRecord = (line, kind = 'adapter') => {
|
|
200
|
+
const source = String(line || '')
|
|
201
|
+
if (!source.trim()) return null
|
|
202
|
+
const normalizedKind = kind === 'knx' ? 'knx' : 'adapter'
|
|
203
|
+
const values = source.split('\t').map(unescapeKnxAiCompactField)
|
|
204
|
+
const columns = KNX_AI_COMPACT_ARCHIVE_COLUMNS[normalizedKind]
|
|
205
|
+
if (values.length !== columns.length) return null
|
|
206
|
+
const ts = Number(values[0])
|
|
207
|
+
if (!Number.isFinite(ts) || ts <= 0) return null
|
|
208
|
+
if (normalizedKind === 'knx') {
|
|
209
|
+
return {
|
|
210
|
+
ts,
|
|
211
|
+
event: values[1],
|
|
212
|
+
source: values[2],
|
|
213
|
+
destination: values[3],
|
|
214
|
+
dpt: values[4],
|
|
215
|
+
devicename: values[5],
|
|
216
|
+
payload: decodeKnxAiCompactValue(values[6]),
|
|
217
|
+
payloadmeasureunit: values[7],
|
|
218
|
+
echoed: values[8] === '1',
|
|
219
|
+
repeated: values[9] === '1',
|
|
220
|
+
rawHex: values[10] || undefined,
|
|
221
|
+
dptdesc: values[11]
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const objectTypes = decodeKnxAiCompactValue(values[15])
|
|
225
|
+
const details = decodeKnxAiCompactValue(values[16])
|
|
226
|
+
return {
|
|
227
|
+
ts,
|
|
228
|
+
at: new Date(ts).toISOString(),
|
|
229
|
+
adapterId: values[1],
|
|
230
|
+
adapterTitle: values[2],
|
|
231
|
+
providerId: values[3],
|
|
232
|
+
providerTitle: values[4],
|
|
233
|
+
controllerId: values[5],
|
|
234
|
+
controllerName: values[6],
|
|
235
|
+
resourceType: values[7],
|
|
236
|
+
resourceId: values[8],
|
|
237
|
+
resourceName: values[9],
|
|
238
|
+
eventType: values[10],
|
|
239
|
+
eventId: values[11],
|
|
240
|
+
active: values[12] !== '0',
|
|
241
|
+
scopeId: values[13],
|
|
242
|
+
scopeName: values[14],
|
|
243
|
+
objectTypes: Array.isArray(objectTypes) ? objectTypes : [],
|
|
244
|
+
details: details && typeof details === 'object' ? details : {}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const compactPromptScalar = value => String(value === undefined || value === null ? '-' : value)
|
|
249
|
+
.replace(/[\r\n\t]+/g, ' ')
|
|
250
|
+
.replace(/\|/g, '\\|')
|
|
251
|
+
.trim()
|
|
252
|
+
|
|
253
|
+
const formatKnxAiCompactContextForPrompt = value => {
|
|
254
|
+
const lines = []
|
|
255
|
+
const walk = (current, path = '') => {
|
|
256
|
+
if (current === undefined || current === null || typeof current !== 'object') {
|
|
257
|
+
lines.push(`${path || 'value'}=${compactPromptScalar(current)}`)
|
|
258
|
+
return
|
|
259
|
+
}
|
|
260
|
+
if (Array.isArray(current)) {
|
|
261
|
+
if (current.every(item => item === null || item === undefined || typeof item !== 'object')) {
|
|
262
|
+
lines.push(`${path || 'values'}=${current.map(compactPromptScalar).join(',')}`)
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
current.forEach((item, index) => {
|
|
266
|
+
const itemPath = `${path || 'items'}[${index}]`
|
|
267
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
|
268
|
+
walk(item, itemPath)
|
|
269
|
+
return
|
|
270
|
+
}
|
|
271
|
+
const scalarEntries = Object.entries(item).filter(([, child]) => child === null || child === undefined || typeof child !== 'object')
|
|
272
|
+
if (scalarEntries.length) {
|
|
273
|
+
lines.push(`${itemPath} ${scalarEntries.map(([key, child]) => `${key}=${compactPromptScalar(child)}`).join(' | ')}`)
|
|
274
|
+
}
|
|
275
|
+
Object.entries(item).filter(([, child]) => child && typeof child === 'object').forEach(([key, child]) => walk(child, `${itemPath}.${key}`))
|
|
276
|
+
})
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
Object.entries(current).forEach(([key, child]) => walk(child, path ? `${path}.${key}` : key))
|
|
280
|
+
}
|
|
281
|
+
walk(value)
|
|
282
|
+
return lines.join('\n')
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const formatKnxAiHistorySummaryForPrompt = summary => {
|
|
286
|
+
const source = summary && typeof summary === 'object' ? summary : {}
|
|
287
|
+
const lines = [
|
|
288
|
+
[
|
|
289
|
+
`kind=${compactPromptScalar(source.kind)}`,
|
|
290
|
+
`total=${Number(source.totalEvents || 0)}`,
|
|
291
|
+
`active=${Number(source.activeEvents || 0)}`,
|
|
292
|
+
`inactive=${Number(source.inactiveEvents || 0)}`,
|
|
293
|
+
`first=${compactPromptScalar(source.firstAt)}`,
|
|
294
|
+
`last=${compactPromptScalar(source.lastAt)}`,
|
|
295
|
+
`selection=${compactPromptScalar(source.selection)}`,
|
|
296
|
+
`selected=${Number(source.selectedEvents || 0)}`
|
|
297
|
+
].join(' | ')
|
|
298
|
+
]
|
|
299
|
+
;[
|
|
300
|
+
['events', source.byEvent],
|
|
301
|
+
['sources', source.bySource],
|
|
302
|
+
['resources', source.byResource],
|
|
303
|
+
['objects', source.byObjectType],
|
|
304
|
+
['combinations', source.byCombination]
|
|
305
|
+
].forEach(([label, items]) => {
|
|
306
|
+
const entries = (Array.isArray(items) ? items : []).map(item => `${compactPromptScalar(item && item.key)}=${Number(item && item.count || 0)}`)
|
|
307
|
+
if (entries.length) lines.push(`${label}: ${entries.join(' | ')}`)
|
|
308
|
+
})
|
|
309
|
+
return lines.join('\n')
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const normalizeKnxAiAdapterHistoryEvent = ({ event, adapter, provider, nowTs = Date.now() } = {}) => {
|
|
313
|
+
const source = event && typeof event === 'object' && !Array.isArray(event) ? event : {}
|
|
314
|
+
const adapterSource = adapter && typeof adapter === 'object' ? adapter : {}
|
|
315
|
+
const providerSource = provider && typeof provider === 'object' ? provider : {}
|
|
316
|
+
const ts = parseTimestamp(source.ts || source.timestamp || source.at || source.start || source.date, nowTs)
|
|
317
|
+
const eventType = clampText(source.eventType || source.type || source.event || source.kind, 120)
|
|
318
|
+
if (!eventType) return null
|
|
319
|
+
const cameraId = clampText(source.cameraId || source.resourceId || source.deviceId, 200)
|
|
320
|
+
const cameraName = clampText(source.cameraName || source.resourceName || source.deviceName, 300)
|
|
321
|
+
const details = sanitizeHistoryValue(source.raw || source.details || source.metadata || {})
|
|
322
|
+
let detailsText = ''
|
|
323
|
+
try { detailsText = JSON.stringify(details) } catch (error) { detailsText = '' }
|
|
324
|
+
return {
|
|
325
|
+
ts,
|
|
326
|
+
at: new Date(ts).toISOString(),
|
|
327
|
+
adapterId: clampText(source.adapterId || providerSource.adapterId || adapterSource.id, 160),
|
|
328
|
+
adapterTitle: clampText(source.adapterTitle || adapterSource.title || adapterSource.name, 240),
|
|
329
|
+
providerId: clampText(source.providerId || providerSource.id, 220),
|
|
330
|
+
providerTitle: clampText(source.providerTitle || providerSource.title || providerSource.name, 240),
|
|
331
|
+
controllerId: clampText(source.controllerId || providerSource.controllerId, 180),
|
|
332
|
+
controllerName: clampText(source.controllerName || providerSource.controllerName, 240),
|
|
333
|
+
resourceType: clampText(source.resourceType || (cameraId || cameraName ? 'camera' : 'adapter'), 80),
|
|
334
|
+
resourceId: cameraId,
|
|
335
|
+
resourceName: cameraName,
|
|
336
|
+
eventType,
|
|
337
|
+
eventId: clampText(source.eventId || source.id, 200),
|
|
338
|
+
active: source.active !== false,
|
|
339
|
+
scopeId: clampText(source.scopeId, 180),
|
|
340
|
+
scopeName: clampText(source.scopeName, 240),
|
|
341
|
+
objectTypes: Array.from(new Set((Array.isArray(source.objectTypes) ? source.objectTypes : [])
|
|
342
|
+
.map(value => clampText(value, 100))
|
|
343
|
+
.filter(Boolean))).slice(0, 24),
|
|
344
|
+
details: detailsText.length <= KNX_AI_HISTORY_DETAILS_MAX_CHARS ? details : { truncated: true }
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const HISTORY_STOP_WORDS = new Set([
|
|
349
|
+
'a', 'al', 'alla', 'alle', 'anche', 'and', 'auf', 'aux', 'avec', 'che', 'con', 'da', 'dal', 'dalla', 'das', 'de', 'dei', 'del', 'della', 'des', 'di', 'die', 'do', 'du', 'e', 'el', 'en', 'et', 'for', 'gli', 'ha', 'hanno', 'i', 'il', 'in', 'is', 'la', 'le', 'les', 'lo', 'mit', 'nel', 'nella', 'of', 'on', 'or', 'per', 'pour', 'que', 'qui', 'se', 'sono', 'su', 'the', 'to', 'tra', 'un', 'una', 'und', 'was', 'what', 'with', 'zu'
|
|
350
|
+
])
|
|
351
|
+
|
|
352
|
+
const historyQuestionTokens = question => Array.from(new Set(normalizeSearchText(question)
|
|
353
|
+
.split(/\s+/)
|
|
354
|
+
.filter(token => token.length >= 2 && !HISTORY_STOP_WORDS.has(token) && !/^\d{1,4}$/.test(token))))
|
|
355
|
+
.slice(0, 24)
|
|
356
|
+
|
|
357
|
+
const incrementCount = (map, key) => {
|
|
358
|
+
const normalized = clampText(key, 500) || '(unknown)'
|
|
359
|
+
map.set(normalized, Number(map.get(normalized) || 0) + 1)
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const topCounts = (map, limit) => Array.from(map.entries())
|
|
363
|
+
.map(([key, count]) => ({ key, count }))
|
|
364
|
+
.sort((left, right) => right.count - left.count || left.key.localeCompare(right.key))
|
|
365
|
+
.slice(0, Math.max(1, Number(limit) || 1))
|
|
366
|
+
|
|
367
|
+
const buildEventSearchText = (event, kind) => {
|
|
368
|
+
if (kind === 'knx') {
|
|
369
|
+
return normalizeSearchText([
|
|
370
|
+
event.event,
|
|
371
|
+
event.source,
|
|
372
|
+
event.destination,
|
|
373
|
+
event.devicename,
|
|
374
|
+
event.dpt,
|
|
375
|
+
event.payloadmeasureunit,
|
|
376
|
+
typeof event.payload === 'object' ? JSON.stringify(event.payload) : event.payload
|
|
377
|
+
].join(' '))
|
|
378
|
+
}
|
|
379
|
+
return normalizeSearchText([
|
|
380
|
+
event.adapterId,
|
|
381
|
+
event.adapterTitle,
|
|
382
|
+
event.providerId,
|
|
383
|
+
event.providerTitle,
|
|
384
|
+
event.controllerId,
|
|
385
|
+
event.controllerName,
|
|
386
|
+
event.resourceType,
|
|
387
|
+
event.resourceId,
|
|
388
|
+
event.resourceName,
|
|
389
|
+
event.eventType,
|
|
390
|
+
event.scopeId,
|
|
391
|
+
event.scopeName,
|
|
392
|
+
...(Array.isArray(event.objectTypes) ? event.objectTypes : []),
|
|
393
|
+
event.details && typeof event.details === 'object' ? JSON.stringify(event.details) : ''
|
|
394
|
+
].join(' '))
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const buildKnxAiHistoryEventKey = (event, kind = 'adapter') => {
|
|
398
|
+
if (!event || typeof event !== 'object') return ''
|
|
399
|
+
if (kind === 'knx') {
|
|
400
|
+
let payload = ''
|
|
401
|
+
try { payload = typeof event.payload === 'object' ? JSON.stringify(event.payload) : String(event.payload) } catch (error) { payload = '' }
|
|
402
|
+
return [Number(event.ts || 0), event.event, event.source, event.destination, payload, event.rawHex].join('|')
|
|
403
|
+
}
|
|
404
|
+
return [Number(event.ts || 0), event.adapterId, event.providerId, event.resourceId, event.eventType, event.eventId, event.scopeId, (event.objectTypes || []).join(',')].join('|')
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const createKnxAiHistoryAccumulator = ({ kind = 'adapter', question = '', limit = 120 } = {}) => {
|
|
408
|
+
const normalizedKind = kind === 'knx' ? 'knx' : 'adapter'
|
|
409
|
+
const maxItems = Math.max(1, Number(limit) || 120)
|
|
410
|
+
const tokens = historyQuestionTokens(question)
|
|
411
|
+
const recent = []
|
|
412
|
+
const relevant = []
|
|
413
|
+
const counts = {
|
|
414
|
+
byEvent: new Map(),
|
|
415
|
+
bySource: new Map(),
|
|
416
|
+
byResource: new Map(),
|
|
417
|
+
byObjectType: new Map(),
|
|
418
|
+
byCombination: new Map()
|
|
419
|
+
}
|
|
420
|
+
let total = 0
|
|
421
|
+
let active = 0
|
|
422
|
+
let firstTs = 0
|
|
423
|
+
let lastTs = 0
|
|
424
|
+
|
|
425
|
+
const add = event => {
|
|
426
|
+
if (!event || typeof event !== 'object') return
|
|
427
|
+
const ts = Number(event.ts || new Date(event.at || '').getTime() || 0)
|
|
428
|
+
if (!Number.isFinite(ts) || ts <= 0) return
|
|
429
|
+
total += 1
|
|
430
|
+
if (event.active !== false) active += 1
|
|
431
|
+
firstTs = firstTs > 0 ? Math.min(firstTs, ts) : ts
|
|
432
|
+
lastTs = Math.max(lastTs, ts)
|
|
433
|
+
const eventName = normalizedKind === 'knx' ? event.event : event.eventType
|
|
434
|
+
const sourceName = normalizedKind === 'knx'
|
|
435
|
+
? event.source
|
|
436
|
+
: (event.adapterTitle || event.adapterId || event.providerTitle || event.providerId)
|
|
437
|
+
const resourceName = normalizedKind === 'knx'
|
|
438
|
+
? `${event.destination || '?'}${event.devicename ? ` (${event.devicename})` : ''}`
|
|
439
|
+
: (event.resourceName || event.resourceId || event.controllerName || event.controllerId)
|
|
440
|
+
incrementCount(counts.byEvent, eventName)
|
|
441
|
+
incrementCount(counts.bySource, sourceName)
|
|
442
|
+
incrementCount(counts.byResource, resourceName)
|
|
443
|
+
;(Array.isArray(event.objectTypes) ? event.objectTypes : []).forEach(value => incrementCount(counts.byObjectType, value))
|
|
444
|
+
const combination = normalizedKind === 'knx'
|
|
445
|
+
? `${resourceName || '?'} | ${eventName || '?'} | ${clampText(typeof event.payload === 'object' ? JSON.stringify(event.payload) : event.payload, 160)}`
|
|
446
|
+
: `${resourceName || '?'} | ${eventName || '?'}${event.scopeName ? ` | ${event.scopeName}` : ''}${event.objectTypes && event.objectTypes.length ? ` | ${event.objectTypes.join(',')}` : ''}`
|
|
447
|
+
incrementCount(counts.byCombination, combination)
|
|
448
|
+
|
|
449
|
+
recent.push(event)
|
|
450
|
+
if (recent.length > maxItems) recent.shift()
|
|
451
|
+
if (tokens.length) {
|
|
452
|
+
const haystack = buildEventSearchText(event, normalizedKind)
|
|
453
|
+
let score = 0
|
|
454
|
+
tokens.forEach(token => { if (haystack.includes(token)) score += token.length + 2 })
|
|
455
|
+
if (score > 0) {
|
|
456
|
+
relevant.push({ event, score, ts })
|
|
457
|
+
if (relevant.length > maxItems * 4) {
|
|
458
|
+
relevant.sort((left, right) => right.score - left.score || right.ts - left.ts)
|
|
459
|
+
relevant.length = maxItems * 2
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const finish = () => {
|
|
466
|
+
let events = recent
|
|
467
|
+
if (relevant.length) {
|
|
468
|
+
const selected = relevant
|
|
469
|
+
.sort((left, right) => right.score - left.score || right.ts - left.ts)
|
|
470
|
+
.slice(0, maxItems)
|
|
471
|
+
.map(item => item.event)
|
|
472
|
+
events = selected.sort((left, right) => Number(left.ts || 0) - Number(right.ts || 0))
|
|
473
|
+
}
|
|
474
|
+
return {
|
|
475
|
+
events,
|
|
476
|
+
summary: {
|
|
477
|
+
kind: normalizedKind,
|
|
478
|
+
totalEvents: total,
|
|
479
|
+
activeEvents: active,
|
|
480
|
+
inactiveEvents: total - active,
|
|
481
|
+
firstAt: firstTs ? new Date(firstTs).toISOString() : '',
|
|
482
|
+
lastAt: lastTs ? new Date(lastTs).toISOString() : '',
|
|
483
|
+
selection: relevant.length ? 'question-relevant' : 'most-recent',
|
|
484
|
+
selectedEvents: events.length,
|
|
485
|
+
byEvent: topCounts(counts.byEvent, 20),
|
|
486
|
+
bySource: topCounts(counts.bySource, 12),
|
|
487
|
+
byResource: topCounts(counts.byResource, 30),
|
|
488
|
+
byObjectType: topCounts(counts.byObjectType, 20),
|
|
489
|
+
byCombination: topCounts(counts.byCombination, 40)
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
return { add, finish }
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const formatKnxAiAdapterHistoryEventForPrompt = event => {
|
|
498
|
+
if (!event || typeof event !== 'object') return ''
|
|
499
|
+
const resource = event.resourceName || event.resourceId || event.controllerName || event.controllerId || '?'
|
|
500
|
+
const scope = event.scopeName || event.scopeId
|
|
501
|
+
const objects = Array.isArray(event.objectTypes) && event.objectTypes.length ? event.objectTypes.join(',') : ''
|
|
502
|
+
return `${event.at || new Date(event.ts || Date.now()).toISOString()} | adapter ${event.adapterTitle || event.adapterId || '?'} | ${event.resourceType || 'resource'} ${resource} | ${event.eventType || '?'} | ${event.active === false ? 'inactive' : 'active'}${scope ? ` | scope ${scope}` : ''}${objects ? ` | objects ${objects}` : ''}`
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
module.exports = {
|
|
506
|
+
KNX_AI_ADAPTER_HISTORY_MIN_HOURS,
|
|
507
|
+
KNX_AI_COMPACT_ARCHIVE_COLUMNS,
|
|
508
|
+
KNX_AI_COMPACT_ARCHIVE_EXTENSION,
|
|
509
|
+
KNX_AI_HISTORY_DETAILS_MAX_CHARS,
|
|
510
|
+
buildKnxAiHistoryEventKey,
|
|
511
|
+
createKnxAiHistoryAccumulator,
|
|
512
|
+
decodeKnxAiCompactValue,
|
|
513
|
+
encodeKnxAiCompactValue,
|
|
514
|
+
formatKnxAiAdapterHistoryEventForPrompt,
|
|
515
|
+
formatKnxAiCompactContextForPrompt,
|
|
516
|
+
formatKnxAiHistorySummaryForPrompt,
|
|
517
|
+
normalizeKnxAiAdapterHistoryEvent,
|
|
518
|
+
parseKnxAiCompactHistoryRecord,
|
|
519
|
+
serializeKnxAiCompactHistoryRecord,
|
|
520
|
+
sanitizeHistoryValue
|
|
521
|
+
}
|