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.
@@ -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.30",
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/",