node-red-contrib-knx-ultimate 6.3.30 → 6.3.32
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 +12 -0
- package/nodes/knxUltimateAI.html +191 -77
- package/nodes/knxUltimateAI.js +1113 -1549
- package/nodes/locales/de/knxUltimateAI.html +19 -16
- package/nodes/locales/de/knxUltimateAI.json +35 -29
- package/nodes/locales/en/knxUltimateAI.html +17 -16
- package/nodes/locales/en/knxUltimateAI.json +35 -29
- package/nodes/locales/es/knxUltimateAI.html +19 -16
- package/nodes/locales/es/knxUltimateAI.json +35 -29
- package/nodes/locales/fr/knxUltimateAI.html +19 -16
- package/nodes/locales/fr/knxUltimateAI.json +35 -29
- package/nodes/locales/it/knxUltimateAI.html +17 -16
- package/nodes/locales/it/knxUltimateAI.json +35 -29
- package/nodes/locales/zh-CN/knxUltimateAI.html +19 -16
- package/nodes/locales/zh-CN/knxUltimateAI.json +35 -29
- package/nodes/plugins/knxUltimateAI-vue/assets/app.css +1 -1
- package/nodes/plugins/knxUltimateAI-vue/assets/app.js +4 -4
- package/nodes/utils/knxAiCatalogRetrieval.js +349 -0
- package/nodes/utils/knxAiChatContext.js +43 -1
- package/nodes/utils/knxAiHomeMemory.js +15 -7
- package/nodes/utils/knxAiScheduler.js +4 -1
- package/nodes/utils/knxAiSemanticContext.js +662 -0
- package/package.json +1 -1
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
const KNX_AI_SEMANTIC_CONTEXT_VERSION = 2
|
|
2
|
+
|
|
3
|
+
const CLOUD_SCHEMA = [
|
|
4
|
+
'id',
|
|
5
|
+
'ga',
|
|
6
|
+
'name',
|
|
7
|
+
'path',
|
|
8
|
+
'aliases',
|
|
9
|
+
'area',
|
|
10
|
+
'kind',
|
|
11
|
+
'capability',
|
|
12
|
+
'access',
|
|
13
|
+
'dpt',
|
|
14
|
+
'values',
|
|
15
|
+
'refs'
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
const MANIFEST_SCHEMA = [
|
|
19
|
+
'id',
|
|
20
|
+
'ga',
|
|
21
|
+
'name',
|
|
22
|
+
'aliases',
|
|
23
|
+
'path',
|
|
24
|
+
'area',
|
|
25
|
+
'kind',
|
|
26
|
+
'capability',
|
|
27
|
+
'access'
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
const cleanText = (value) => String(value === undefined || value === null ? '' : value)
|
|
31
|
+
.normalize('NFC')
|
|
32
|
+
.replace(/[\u0000-\u001f\u007f]+/g, ' ')
|
|
33
|
+
.replace(/\s+/g, ' ')
|
|
34
|
+
.trim()
|
|
35
|
+
|
|
36
|
+
const normalizeIdentity = (value) => cleanText(value)
|
|
37
|
+
.normalize('NFKD')
|
|
38
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
39
|
+
.toLowerCase()
|
|
40
|
+
|
|
41
|
+
const compareText = (left, right) => {
|
|
42
|
+
const normalizedLeft = normalizeIdentity(left)
|
|
43
|
+
const normalizedRight = normalizeIdentity(right)
|
|
44
|
+
if (normalizedLeft < normalizedRight) return -1
|
|
45
|
+
if (normalizedLeft > normalizedRight) return 1
|
|
46
|
+
const rawLeft = cleanText(left)
|
|
47
|
+
const rawRight = cleanText(right)
|
|
48
|
+
if (rawLeft < rawRight) return -1
|
|
49
|
+
if (rawLeft > rawRight) return 1
|
|
50
|
+
return 0
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const asPlainObject = (value) => value && typeof value === 'object' && !Array.isArray(value) ? value : {}
|
|
54
|
+
|
|
55
|
+
const flattenValues = (value) => {
|
|
56
|
+
if (Array.isArray(value)) return value.flatMap(flattenValues)
|
|
57
|
+
return [value]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const uniqueSortedText = (values, excludedValues = []) => {
|
|
61
|
+
const excluded = new Set(flattenValues(excludedValues).map(normalizeIdentity).filter(Boolean))
|
|
62
|
+
const byIdentity = new Map()
|
|
63
|
+
flattenValues(values).forEach(value => {
|
|
64
|
+
const text = cleanText(value)
|
|
65
|
+
const identity = normalizeIdentity(text)
|
|
66
|
+
if (!identity || excluded.has(identity)) return
|
|
67
|
+
const previous = byIdentity.get(identity)
|
|
68
|
+
if (!previous || compareText(text, previous) < 0) byIdentity.set(identity, text)
|
|
69
|
+
})
|
|
70
|
+
return Array.from(byIdentity.values()).sort(compareText)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const pickText = (records, selectors, fallback = '') => {
|
|
74
|
+
for (const selector of selectors) {
|
|
75
|
+
const candidates = uniqueSortedText(records.flatMap(record => flattenValues(selector(record))))
|
|
76
|
+
if (candidates.length > 0) return candidates[0]
|
|
77
|
+
}
|
|
78
|
+
return cleanText(fallback)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const normalizeGa = (value) => {
|
|
82
|
+
const raw = cleanText(value).replace(/\s*\/\s*/g, '/')
|
|
83
|
+
const parts = raw.split('/')
|
|
84
|
+
if (parts.length >= 2 && parts.length <= 3 && parts.every(part => /^\d+$/.test(part))) {
|
|
85
|
+
return parts.map(part => String(Number(part))).join('/')
|
|
86
|
+
}
|
|
87
|
+
return raw
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const compareGa = (left, right) => {
|
|
91
|
+
const leftParts = String(left || '').split('/')
|
|
92
|
+
const rightParts = String(right || '').split('/')
|
|
93
|
+
const leftNumeric = leftParts.length >= 2 && leftParts.every(part => /^\d+$/.test(part))
|
|
94
|
+
const rightNumeric = rightParts.length >= 2 && rightParts.every(part => /^\d+$/.test(part))
|
|
95
|
+
if (leftNumeric && rightNumeric) {
|
|
96
|
+
const maxLength = Math.max(leftParts.length, rightParts.length)
|
|
97
|
+
for (let index = 0; index < maxLength; index += 1) {
|
|
98
|
+
if (index >= leftParts.length) return -1
|
|
99
|
+
if (index >= rightParts.length) return 1
|
|
100
|
+
const difference = Number(leftParts[index]) - Number(rightParts[index])
|
|
101
|
+
if (difference !== 0) return difference
|
|
102
|
+
}
|
|
103
|
+
return 0
|
|
104
|
+
}
|
|
105
|
+
if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1
|
|
106
|
+
return compareText(left, right)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const fnv1a64 = (value) => {
|
|
110
|
+
let hash = 0xcbf29ce484222325n
|
|
111
|
+
const bytes = Buffer.from(String(value || ''), 'utf8')
|
|
112
|
+
for (const byte of bytes) {
|
|
113
|
+
hash ^= BigInt(byte)
|
|
114
|
+
hash = BigInt.asUintN(64, hash * 0x100000001b3n)
|
|
115
|
+
}
|
|
116
|
+
return hash.toString(36)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const buildShortId = (ga) => {
|
|
120
|
+
const parts = String(ga || '').split('/')
|
|
121
|
+
if (parts.length >= 2 && parts.length <= 3 && parts.every(part => /^\d+$/.test(part))) {
|
|
122
|
+
return `g${parts.map(part => Number(part).toString(36)).join('.')}`
|
|
123
|
+
}
|
|
124
|
+
return `g${fnv1a64(ga)}`
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const normalizeDpt = (value) => cleanText(value).replace(/^dpt\s*/i, '')
|
|
128
|
+
|
|
129
|
+
const normalizeAccess = (value) => {
|
|
130
|
+
const access = normalizeIdentity(value).replace(/[\s_-]+/g, '')
|
|
131
|
+
if (['ro', 'readonly', 'read'].includes(access)) return 'ro'
|
|
132
|
+
if (['rw', 'readwrite', 'writeread'].includes(access)) return 'rw'
|
|
133
|
+
if (['wo', 'writeonly', 'write'].includes(access)) return 'wo'
|
|
134
|
+
return ''
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const pickAccess = (records) => {
|
|
138
|
+
const candidates = new Set()
|
|
139
|
+
records.forEach(record => {
|
|
140
|
+
const explicit = normalizeAccess(record.access)
|
|
141
|
+
if (explicit) candidates.add(explicit)
|
|
142
|
+
if (record.readOnly === true) candidates.add('ro')
|
|
143
|
+
if (record.readOnly === false) candidates.add('rw')
|
|
144
|
+
if (record.writeOnly === true) candidates.add('wo')
|
|
145
|
+
})
|
|
146
|
+
if (candidates.has('ro')) return 'ro'
|
|
147
|
+
if (candidates.has('wo')) return 'wo'
|
|
148
|
+
if (candidates.has('rw')) return 'rw'
|
|
149
|
+
return '?'
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const pickSemanticKind = (records) => {
|
|
153
|
+
const candidates = records.flatMap(record => {
|
|
154
|
+
const semantic = asPlainObject(record.semantic)
|
|
155
|
+
const confidence = Number(semantic.confidence)
|
|
156
|
+
return [
|
|
157
|
+
{ value: semantic.kind, confidence: Number.isFinite(confidence) ? confidence : 0 },
|
|
158
|
+
{ value: record.semanticKind, confidence: 0 },
|
|
159
|
+
{ value: record.kind, confidence: 0 }
|
|
160
|
+
]
|
|
161
|
+
}).map(candidate => ({
|
|
162
|
+
value: cleanText(candidate.value).toLowerCase().replace(/[\s_]+/g, '-'),
|
|
163
|
+
confidence: candidate.confidence
|
|
164
|
+
})).filter(candidate => candidate.value && candidate.value !== 'unknown')
|
|
165
|
+
|
|
166
|
+
candidates.sort((left, right) => {
|
|
167
|
+
if (left.confidence !== right.confidence) return right.confidence - left.confidence
|
|
168
|
+
return compareText(left.value, right.value)
|
|
169
|
+
})
|
|
170
|
+
return candidates.length > 0 ? candidates[0].value : 'unknown'
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const stableScalar = (value) => {
|
|
174
|
+
if (typeof value === 'boolean') return value
|
|
175
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value
|
|
176
|
+
return cleanText(value)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const compareScalars = (left, right) => {
|
|
180
|
+
if (typeof left === 'number' && typeof right === 'number') return left - right
|
|
181
|
+
if (typeof left === 'boolean' && typeof right === 'boolean') return Number(left) - Number(right)
|
|
182
|
+
return compareText(String(left), String(right))
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const normalizeValueOptions = (records) => {
|
|
186
|
+
const byKey = new Map()
|
|
187
|
+
records.flatMap(record => {
|
|
188
|
+
const semantic = asPlainObject(record.semantic)
|
|
189
|
+
return [record.valueOptions, record.values, semantic.valueOptions]
|
|
190
|
+
}).flatMap(flattenValues).forEach(option => {
|
|
191
|
+
const source = asPlainObject(option)
|
|
192
|
+
const isObject = Object.keys(source).length > 0
|
|
193
|
+
const value = stableScalar(isObject
|
|
194
|
+
? source.value !== undefined
|
|
195
|
+
? source.value
|
|
196
|
+
: source.id !== undefined
|
|
197
|
+
? source.id
|
|
198
|
+
: source.key
|
|
199
|
+
: option)
|
|
200
|
+
const label = cleanText(isObject
|
|
201
|
+
? source.label !== undefined
|
|
202
|
+
? source.label
|
|
203
|
+
: source.name !== undefined
|
|
204
|
+
? source.name
|
|
205
|
+
: source.text
|
|
206
|
+
: option)
|
|
207
|
+
if (value === '' && label === '') return
|
|
208
|
+
const normalized = { value, label }
|
|
209
|
+
const key = JSON.stringify([value, normalizeIdentity(label)])
|
|
210
|
+
const previous = byKey.get(key)
|
|
211
|
+
if (!previous || compareText(label, previous.label) < 0) byKey.set(key, normalized)
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
return Array.from(byKey.values()).sort((left, right) => {
|
|
215
|
+
const valueDifference = compareScalars(left.value, right.value)
|
|
216
|
+
if (valueDifference !== 0) return valueDifference
|
|
217
|
+
return compareText(left.label, right.label)
|
|
218
|
+
})
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const extractReferenceValues = (value) => flattenValues(value).flatMap(candidate => {
|
|
222
|
+
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) return [candidate]
|
|
223
|
+
return [candidate.id, candidate.ga, candidate.destination, candidate.groupAddress, candidate.reference]
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
const collectReferences = (records, ga) => {
|
|
227
|
+
const candidates = records.flatMap(record => {
|
|
228
|
+
const semantic = asPlainObject(record.semantic)
|
|
229
|
+
return extractReferenceValues([
|
|
230
|
+
record.references,
|
|
231
|
+
record.refs,
|
|
232
|
+
record.relatedGAs,
|
|
233
|
+
record.commandGA,
|
|
234
|
+
record.statusGA,
|
|
235
|
+
record.stateGA,
|
|
236
|
+
record.feedbackGA,
|
|
237
|
+
semantic.references
|
|
238
|
+
])
|
|
239
|
+
})
|
|
240
|
+
return uniqueSortedText(candidates, [ga])
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const buildCapability = ({ records, semanticKind, dpt }) => {
|
|
244
|
+
const explicit = pickText(records, [
|
|
245
|
+
record => record.capability,
|
|
246
|
+
record => record.capabilities,
|
|
247
|
+
record => asPlainObject(record.semantic).capability,
|
|
248
|
+
record => asPlainObject(record.semantic).capabilities
|
|
249
|
+
])
|
|
250
|
+
const tag = pickText(records, [record => record.tags])
|
|
251
|
+
const dptFamily = String(dpt || '').split('.')[0]
|
|
252
|
+
return explicit || (semanticKind !== 'unknown' ? semanticKind : '') || tag || (dptFamily ? `dpt:${dptFamily}` : 'unknown')
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const canonicalizeGroup = (normalizedGa, records, id) => {
|
|
256
|
+
const ga = cleanText(records
|
|
257
|
+
.map(record => record.ga || record.groupAddress || record.destination || record.address)
|
|
258
|
+
.find(Boolean)) || normalizedGa
|
|
259
|
+
const semanticKind = pickSemanticKind(records)
|
|
260
|
+
const dpt = pickText(records, [record => record.dpt, record => asPlainObject(record.semantic).dpt])
|
|
261
|
+
const path = pickText(records, [
|
|
262
|
+
record => record.hierarchyPath,
|
|
263
|
+
record => record.path,
|
|
264
|
+
record => [record.mainGroup, record.middleGroup].map(cleanText).filter(Boolean).join(' / ')
|
|
265
|
+
])
|
|
266
|
+
const name = pickText(records, [
|
|
267
|
+
record => record.label,
|
|
268
|
+
record => record.name,
|
|
269
|
+
record => record.deviceLabel,
|
|
270
|
+
record => asPlainObject(record.semantic).originalLabel,
|
|
271
|
+
record => record.etsName
|
|
272
|
+
], ga)
|
|
273
|
+
const area = pickText(records, [
|
|
274
|
+
record => asPlainObject(record.semantic).area,
|
|
275
|
+
record => record.area,
|
|
276
|
+
record => record.middleGroup,
|
|
277
|
+
record => record.mainGroup
|
|
278
|
+
])
|
|
279
|
+
const aliasCandidates = records.flatMap(record => [
|
|
280
|
+
record.aliases,
|
|
281
|
+
record.names,
|
|
282
|
+
record.alternativeNames,
|
|
283
|
+
record.etsName,
|
|
284
|
+
record.label,
|
|
285
|
+
record.name,
|
|
286
|
+
record.deviceLabel,
|
|
287
|
+
asPlainObject(record.semantic).originalLabel
|
|
288
|
+
])
|
|
289
|
+
const normalizedDpt = normalizeDpt(dpt)
|
|
290
|
+
return {
|
|
291
|
+
id,
|
|
292
|
+
ga,
|
|
293
|
+
name,
|
|
294
|
+
path,
|
|
295
|
+
aliases: uniqueSortedText(aliasCandidates, [ga, name, path]),
|
|
296
|
+
area,
|
|
297
|
+
semanticKind,
|
|
298
|
+
capability: buildCapability({ records, semanticKind, dpt: normalizedDpt }),
|
|
299
|
+
access: pickAccess(records),
|
|
300
|
+
dpt: normalizedDpt,
|
|
301
|
+
valueOptions: normalizeValueOptions(records),
|
|
302
|
+
references: collectReferences(records, ga)
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const canonicalizeKnxAiCatalog = (catalog) => {
|
|
307
|
+
const byGa = new Map()
|
|
308
|
+
;(Array.isArray(catalog) ? catalog : []).forEach(candidate => {
|
|
309
|
+
const record = asPlainObject(candidate)
|
|
310
|
+
const ga = normalizeGa(record.ga || record.groupAddress || record.destination || record.address)
|
|
311
|
+
if (!ga) return
|
|
312
|
+
if (!byGa.has(ga)) byGa.set(ga, [])
|
|
313
|
+
byGa.get(ga).push(record)
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
const usedIds = new Map()
|
|
317
|
+
return Array.from(byGa.keys()).sort(compareGa).map(ga => {
|
|
318
|
+
const baseId = buildShortId(ga)
|
|
319
|
+
const occurrence = Number(usedIds.get(baseId) || 0) + 1
|
|
320
|
+
usedIds.set(baseId, occurrence)
|
|
321
|
+
const id = occurrence === 1 ? baseId : `${baseId}~${occurrence.toString(36)}`
|
|
322
|
+
return canonicalizeGroup(ga, byGa.get(ga), id)
|
|
323
|
+
})
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const toCloudRow = (record) => [
|
|
327
|
+
record.id,
|
|
328
|
+
record.ga,
|
|
329
|
+
record.name,
|
|
330
|
+
record.path,
|
|
331
|
+
record.aliases,
|
|
332
|
+
record.area,
|
|
333
|
+
record.semanticKind,
|
|
334
|
+
record.capability,
|
|
335
|
+
record.access,
|
|
336
|
+
record.dpt,
|
|
337
|
+
record.valueOptions.map(option => [option.value, option.label]),
|
|
338
|
+
record.references
|
|
339
|
+
]
|
|
340
|
+
|
|
341
|
+
const toManifestRow = (record) => [
|
|
342
|
+
record.id,
|
|
343
|
+
record.ga,
|
|
344
|
+
record.name,
|
|
345
|
+
record.aliases,
|
|
346
|
+
record.path,
|
|
347
|
+
record.area,
|
|
348
|
+
record.semanticKind,
|
|
349
|
+
record.capability,
|
|
350
|
+
record.access
|
|
351
|
+
]
|
|
352
|
+
|
|
353
|
+
const serializeRows = ({ section, schema, rows }) => [
|
|
354
|
+
`${section}/${KNX_AI_SEMANTIC_CONTEXT_VERSION}|${schema.join(',')}`,
|
|
355
|
+
...rows.map(row => JSON.stringify(row))
|
|
356
|
+
].join('\n')
|
|
357
|
+
|
|
358
|
+
const serializeCloudFromCanonical = (records, section = 'KNX-CATALOG') => serializeRows({
|
|
359
|
+
section,
|
|
360
|
+
schema: CLOUD_SCHEMA,
|
|
361
|
+
rows: records.map(toCloudRow)
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
const serializeManifestFromCanonical = (records) => serializeRows({
|
|
365
|
+
section: 'KNX-MANIFEST',
|
|
366
|
+
schema: MANIFEST_SCHEMA,
|
|
367
|
+
rows: records.map(toManifestRow)
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
const serializeKnxAiCloudCatalog = (catalog) => serializeCloudFromCanonical(canonicalizeKnxAiCatalog(catalog))
|
|
371
|
+
|
|
372
|
+
const byteLength = (value) => Buffer.byteLength(String(value || ''), 'utf8')
|
|
373
|
+
|
|
374
|
+
const countBy = (records, selector, emptyKey = 'unknown') => {
|
|
375
|
+
const counts = new Map()
|
|
376
|
+
records.forEach(record => {
|
|
377
|
+
const key = cleanText(selector(record)) || emptyKey
|
|
378
|
+
counts.set(key, Number(counts.get(key) || 0) + 1)
|
|
379
|
+
})
|
|
380
|
+
return Object.fromEntries(Array.from(counts.entries()).sort((left, right) => compareText(left[0], right[0])))
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const buildKnxAiSemanticContextStats = ({ source, canonical, cloudText, manifestText }) => {
|
|
384
|
+
const validSourceRecords = source.filter(candidate => {
|
|
385
|
+
const record = asPlainObject(candidate)
|
|
386
|
+
return normalizeGa(record.ga || record.groupAddress || record.destination || record.address) !== ''
|
|
387
|
+
}).length
|
|
388
|
+
return {
|
|
389
|
+
formatVersion: KNX_AI_SEMANTIC_CONTEXT_VERSION,
|
|
390
|
+
sourceRecords: source.length,
|
|
391
|
+
validSourceRecords,
|
|
392
|
+
invalidSourceRecords: source.length - validSourceRecords,
|
|
393
|
+
canonicalRecords: canonical.length,
|
|
394
|
+
duplicateRecords: Math.max(0, validSourceRecords - canonical.length),
|
|
395
|
+
aliases: canonical.reduce((total, record) => total + record.aliases.length, 0),
|
|
396
|
+
valueOptions: canonical.reduce((total, record) => total + record.valueOptions.length, 0),
|
|
397
|
+
references: canonical.reduce((total, record) => total + record.references.length, 0),
|
|
398
|
+
uniqueAreas: new Set(canonical.map(record => record.area).filter(Boolean)).size,
|
|
399
|
+
uniqueSemanticKinds: new Set(canonical.map(record => record.semanticKind).filter(kind => kind && kind !== 'unknown')).size,
|
|
400
|
+
uniqueDpts: new Set(canonical.map(record => record.dpt).filter(Boolean)).size,
|
|
401
|
+
access: countBy(canonical, record => record.access),
|
|
402
|
+
semanticKinds: countBy(canonical, record => record.semanticKind),
|
|
403
|
+
bytes: {
|
|
404
|
+
cloudCatalog: typeof cloudText === 'string' ? byteLength(cloudText) : null,
|
|
405
|
+
localManifest: byteLength(manifestText),
|
|
406
|
+
allDetails: typeof cloudText === 'string'
|
|
407
|
+
? byteLength(cloudText) + byteLength('KNX-DETAILS') - byteLength('KNX-CATALOG')
|
|
408
|
+
: null
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const normalizeDetailReference = (candidate) => {
|
|
414
|
+
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) return cleanText(candidate)
|
|
415
|
+
return cleanText(candidate.id || candidate.ga || candidate.reference || candidate.destination || candidate.groupAddress)
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const selectDetailRecords = (canonical, detailReferences) => {
|
|
419
|
+
if (detailReferences === undefined || detailReferences === null) return canonical.slice()
|
|
420
|
+
const byReference = new Map()
|
|
421
|
+
canonical.forEach(record => {
|
|
422
|
+
;[record.id, record.ga].forEach(reference => {
|
|
423
|
+
const key = normalizeIdentity(reference)
|
|
424
|
+
if (key && !byReference.has(key)) byReference.set(key, record)
|
|
425
|
+
})
|
|
426
|
+
})
|
|
427
|
+
canonical.forEach(record => {
|
|
428
|
+
record.references.forEach(reference => {
|
|
429
|
+
const key = normalizeIdentity(reference)
|
|
430
|
+
if (key && !byReference.has(key)) byReference.set(key, record)
|
|
431
|
+
})
|
|
432
|
+
})
|
|
433
|
+
const selected = []
|
|
434
|
+
const selectedIds = new Set()
|
|
435
|
+
;(Array.isArray(detailReferences) ? detailReferences : [detailReferences]).forEach(candidate => {
|
|
436
|
+
const record = byReference.get(normalizeIdentity(normalizeDetailReference(candidate)))
|
|
437
|
+
if (!record || selectedIds.has(record.id)) return
|
|
438
|
+
selectedIds.add(record.id)
|
|
439
|
+
selected.push(record)
|
|
440
|
+
})
|
|
441
|
+
return selected
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const resolveByteBudget = (value) => {
|
|
445
|
+
if (value === undefined || value === null || value === '') return Number.POSITIVE_INFINITY
|
|
446
|
+
const parsed = Number(value)
|
|
447
|
+
if (!Number.isFinite(parsed)) return Number.POSITIVE_INFINITY
|
|
448
|
+
return Math.max(0, Math.floor(parsed))
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const pickOverflowDeclaration = ({ byteBudget, manifestBytes, recordCount, includedCount = 0 }) => {
|
|
452
|
+
const candidates = [
|
|
453
|
+
`KNX-MANIFEST/${KNX_AI_SEMANTIC_CONTEXT_VERSION}|PARTIAL|included=${includedCount}|records=${recordCount}|bytes=${manifestBytes}|budget=${Number.isFinite(byteBudget) ? byteBudget : 'unlimited'}`,
|
|
454
|
+
`KNX-MANIFEST/${KNX_AI_SEMANTIC_CONTEXT_VERSION}|PARTIAL|${includedCount}/${recordCount}`,
|
|
455
|
+
`KNX-MANIFEST/${KNX_AI_SEMANTIC_CONTEXT_VERSION}|OVERFLOW`,
|
|
456
|
+
'OVERFLOW',
|
|
457
|
+
'!'
|
|
458
|
+
]
|
|
459
|
+
return candidates.find(candidate => byteLength(candidate) <= byteBudget) || ''
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const packKnxAiSemanticContext = ({ catalog, byteBudget, detailReferences } = {}) => {
|
|
463
|
+
const source = Array.isArray(catalog) ? catalog : []
|
|
464
|
+
const canonical = canonicalizeKnxAiCatalog(source)
|
|
465
|
+
const explicitDetails = detailReferences !== undefined && detailReferences !== null
|
|
466
|
+
const cloudText = explicitDetails ? null : serializeCloudFromCanonical(canonical)
|
|
467
|
+
const manifestText = serializeManifestFromCanonical(canonical)
|
|
468
|
+
const manifestBytes = byteLength(manifestText)
|
|
469
|
+
const budget = resolveByteBudget(byteBudget)
|
|
470
|
+
const selectedDetails = selectDetailRecords(canonical, detailReferences)
|
|
471
|
+
|
|
472
|
+
const createAtomicBuffer = (maxBytes) => {
|
|
473
|
+
const lines = []
|
|
474
|
+
let usedBytes = 0
|
|
475
|
+
const appendAtomic = (value) => {
|
|
476
|
+
const text = String(value || '')
|
|
477
|
+
if (!text) return false
|
|
478
|
+
const separatorBytes = lines.length > 0 ? 1 : 0
|
|
479
|
+
const requiredBytes = separatorBytes + byteLength(text)
|
|
480
|
+
if (usedBytes + requiredBytes > maxBytes) return false
|
|
481
|
+
lines.push(text)
|
|
482
|
+
usedBytes += requiredBytes
|
|
483
|
+
return true
|
|
484
|
+
}
|
|
485
|
+
return {
|
|
486
|
+
lines,
|
|
487
|
+
appendAtomic,
|
|
488
|
+
usedBytes: () => usedBytes
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const packDetails = (maxBytes, records) => {
|
|
493
|
+
const buffer = createAtomicBuffer(maxBytes)
|
|
494
|
+
const included = []
|
|
495
|
+
if (records.length > 0) {
|
|
496
|
+
const detailHeader = `KNX-DETAILS/${KNX_AI_SEMANTIC_CONTEXT_VERSION}|${CLOUD_SCHEMA.join(',')}`
|
|
497
|
+
if (buffer.appendAtomic(detailHeader)) {
|
|
498
|
+
records.forEach(record => {
|
|
499
|
+
if (buffer.appendAtomic(JSON.stringify(toCloudRow(record)))) included.push(record)
|
|
500
|
+
})
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
if (included.length === 0) return { lines: [], bytes: 0, included: [] }
|
|
504
|
+
return { lines: buffer.lines, bytes: buffer.usedBytes(), included }
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const packManifest = (maxBytes, records = canonical) => {
|
|
508
|
+
if (records.length === 0) {
|
|
509
|
+
return {
|
|
510
|
+
lines: [],
|
|
511
|
+
bytes: 0,
|
|
512
|
+
complete: true,
|
|
513
|
+
includedCount: 0,
|
|
514
|
+
overflowDeclaration: '',
|
|
515
|
+
totalCount: 0
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
const packedManifestText = records === canonical ? manifestText : serializeManifestFromCanonical(records)
|
|
519
|
+
const packedManifestBytes = byteLength(packedManifestText)
|
|
520
|
+
if (packedManifestBytes <= maxBytes) {
|
|
521
|
+
return {
|
|
522
|
+
lines: packedManifestText ? packedManifestText.split('\n') : [],
|
|
523
|
+
bytes: packedManifestBytes,
|
|
524
|
+
complete: true,
|
|
525
|
+
includedCount: records.length,
|
|
526
|
+
overflowDeclaration: '',
|
|
527
|
+
totalCount: records.length
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
const buffer = createAtomicBuffer(maxBytes)
|
|
531
|
+
const header = `KNX-MANIFEST/${KNX_AI_SEMANTIC_CONTEXT_VERSION}|${MANIFEST_SCHEMA.join(',')}`
|
|
532
|
+
if (!buffer.appendAtomic(header)) {
|
|
533
|
+
const overflowDeclaration = pickOverflowDeclaration({
|
|
534
|
+
byteBudget: maxBytes,
|
|
535
|
+
manifestBytes: packedManifestBytes,
|
|
536
|
+
recordCount: records.length,
|
|
537
|
+
includedCount: 0
|
|
538
|
+
})
|
|
539
|
+
const fallback = createAtomicBuffer(maxBytes)
|
|
540
|
+
fallback.appendAtomic(overflowDeclaration)
|
|
541
|
+
return {
|
|
542
|
+
lines: fallback.lines,
|
|
543
|
+
bytes: fallback.usedBytes(),
|
|
544
|
+
complete: false,
|
|
545
|
+
includedCount: 0,
|
|
546
|
+
overflowDeclaration,
|
|
547
|
+
totalCount: records.length
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
let includedCount = 0
|
|
551
|
+
let omittedCount = 0
|
|
552
|
+
const rows = records.map(record => JSON.stringify(toManifestRow(record)))
|
|
553
|
+
for (let index = 0; index < rows.length; index += 1) {
|
|
554
|
+
const row = rows[index]
|
|
555
|
+
const wouldCompleteManifest = omittedCount === 0 && index === rows.length - 1
|
|
556
|
+
const marker = pickOverflowDeclaration({
|
|
557
|
+
byteBudget: Math.max(0, maxBytes - buffer.usedBytes() - 1),
|
|
558
|
+
manifestBytes: packedManifestBytes,
|
|
559
|
+
recordCount: records.length,
|
|
560
|
+
includedCount
|
|
561
|
+
})
|
|
562
|
+
const reserveBytes = wouldCompleteManifest ? 0 : (marker ? byteLength(marker) + 1 : 2)
|
|
563
|
+
if (buffer.usedBytes() + byteLength(row) + 1 + reserveBytes > maxBytes) {
|
|
564
|
+
omittedCount += 1
|
|
565
|
+
continue
|
|
566
|
+
}
|
|
567
|
+
if (!buffer.appendAtomic(row)) {
|
|
568
|
+
omittedCount += 1
|
|
569
|
+
continue
|
|
570
|
+
}
|
|
571
|
+
includedCount += 1
|
|
572
|
+
}
|
|
573
|
+
const complete = omittedCount === 0 && includedCount === records.length
|
|
574
|
+
const overflowDeclaration = complete
|
|
575
|
+
? ''
|
|
576
|
+
: pickOverflowDeclaration({
|
|
577
|
+
byteBudget: Math.max(0, maxBytes - buffer.usedBytes() - 1),
|
|
578
|
+
manifestBytes: packedManifestBytes,
|
|
579
|
+
recordCount: records.length,
|
|
580
|
+
includedCount
|
|
581
|
+
})
|
|
582
|
+
if (overflowDeclaration) buffer.appendAtomic(overflowDeclaration)
|
|
583
|
+
return {
|
|
584
|
+
lines: buffer.lines,
|
|
585
|
+
bytes: buffer.usedBytes(),
|
|
586
|
+
complete,
|
|
587
|
+
includedCount,
|
|
588
|
+
overflowDeclaration,
|
|
589
|
+
totalCount: records.length
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
let manifestPack
|
|
594
|
+
let detailPack
|
|
595
|
+
if (explicitDetails) {
|
|
596
|
+
detailPack = packDetails(budget, selectedDetails)
|
|
597
|
+
const detailIds = new Set(detailPack.included.map(record => record.id))
|
|
598
|
+
const manifestRecords = canonical.filter(record => !detailIds.has(record.id))
|
|
599
|
+
const separatorBytes = detailPack.lines.length > 0 ? 1 : 0
|
|
600
|
+
const manifestBudget = Number.isFinite(budget)
|
|
601
|
+
? Math.max(0, budget - detailPack.bytes - separatorBytes)
|
|
602
|
+
: budget
|
|
603
|
+
manifestPack = packManifest(manifestBudget, manifestRecords)
|
|
604
|
+
} else {
|
|
605
|
+
const cloudBytes = byteLength(cloudText)
|
|
606
|
+
if (cloudBytes <= budget) {
|
|
607
|
+
detailPack = {
|
|
608
|
+
lines: cloudText ? cloudText.split('\n') : [],
|
|
609
|
+
bytes: cloudBytes,
|
|
610
|
+
included: canonical.slice()
|
|
611
|
+
}
|
|
612
|
+
manifestPack = { lines: [], bytes: 0, complete: true, includedCount: 0, overflowDeclaration: '', totalCount: 0 }
|
|
613
|
+
} else {
|
|
614
|
+
manifestPack = packManifest(budget)
|
|
615
|
+
detailPack = { lines: [], bytes: 0, included: [] }
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const lines = [...manifestPack.lines, ...detailPack.lines]
|
|
620
|
+
const text = lines.join('\n')
|
|
621
|
+
const includedDetailIds = detailPack.included.map(record => record.id)
|
|
622
|
+
const includedDetailGAs = detailPack.included.map(record => record.ga)
|
|
623
|
+
const omittedDetailCount = selectedDetails.length - includedDetailIds.length
|
|
624
|
+
const coveredCount = Math.min(canonical.length, manifestPack.includedCount + includedDetailIds.length)
|
|
625
|
+
const manifestOmittedCount = Math.max(0, canonical.length - coveredCount)
|
|
626
|
+
const manifestStatus = !explicitDetails && includedDetailIds.length === canonical.length
|
|
627
|
+
? 'full-details'
|
|
628
|
+
: manifestOmittedCount === 0
|
|
629
|
+
? 'complete'
|
|
630
|
+
: (manifestPack.includedCount > 0 || includedDetailIds.length > 0 ? 'partial' : 'overflow')
|
|
631
|
+
return {
|
|
632
|
+
text,
|
|
633
|
+
byteLength: byteLength(text),
|
|
634
|
+
byteBudget: Number.isFinite(budget) ? budget : null,
|
|
635
|
+
manifestStatus,
|
|
636
|
+
manifestBytes,
|
|
637
|
+
manifestIncludedCount: manifestPack.includedCount,
|
|
638
|
+
manifestOmittedCount,
|
|
639
|
+
overflowDeclaration: manifestPack.overflowDeclaration,
|
|
640
|
+
requestedDetailCount: selectedDetails.length,
|
|
641
|
+
includedDetailCount: includedDetailIds.length,
|
|
642
|
+
omittedDetailCount,
|
|
643
|
+
includedDetailIds,
|
|
644
|
+
includedDetailGAs,
|
|
645
|
+
stats: Object.assign({}, buildKnxAiSemanticContextStats({ source, canonical, cloudText, manifestText }), {
|
|
646
|
+
packedBytes: byteLength(text),
|
|
647
|
+
manifestStatus,
|
|
648
|
+
manifestIncluded: manifestPack.includedCount,
|
|
649
|
+
manifestOmitted: manifestOmittedCount,
|
|
650
|
+
requestedDetails: selectedDetails.length,
|
|
651
|
+
includedDetails: includedDetailIds.length,
|
|
652
|
+
omittedDetails: omittedDetailCount
|
|
653
|
+
})
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
module.exports = {
|
|
658
|
+
KNX_AI_SEMANTIC_CONTEXT_VERSION,
|
|
659
|
+
canonicalizeKnxAiCatalog,
|
|
660
|
+
serializeKnxAiCloudCatalog,
|
|
661
|
+
packKnxAiSemanticContext
|
|
662
|
+
}
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"engines": {
|
|
4
4
|
"node": ">=20.18.1"
|
|
5
5
|
},
|
|
6
|
-
"version": "6.3.
|
|
6
|
+
"version": "6.3.32",
|
|
7
7
|
"description": "KNX Ultimate is the most advanced KNX integration for Node-RED, providing secure KNX/IP communication, routing, ETS project import, Philips Hue, Matter Controller and Matter Bridge (control matter device via KNX and expose KNX GA via Matter), MQTT and Modbus adapters, diagnostics with AI, virtual devices, and powerful automation nodes. Build professional, reliable, and scalable smart home and building automation projects with minimal effort.",
|
|
8
8
|
"files": [
|
|
9
9
|
"nodes/",
|