node-red-contrib-knx-ultimate 6.3.30 → 6.4.1

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.
Files changed (30) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/nodes/knxUltimateAI.html +210 -82
  3. package/nodes/knxUltimateAI.js +2554 -1660
  4. package/nodes/knxUltimateAIHomeAssistant.html +38 -0
  5. package/nodes/knxUltimateAIHomeAssistant.js +152 -0
  6. package/nodes/locales/de/knxUltimateAI.html +19 -16
  7. package/nodes/locales/de/knxUltimateAI.json +40 -31
  8. package/nodes/locales/en/knxUltimateAI.html +27 -18
  9. package/nodes/locales/en/knxUltimateAI.json +40 -31
  10. package/nodes/locales/es/knxUltimateAI.html +19 -16
  11. package/nodes/locales/es/knxUltimateAI.json +40 -31
  12. package/nodes/locales/fr/knxUltimateAI.html +19 -16
  13. package/nodes/locales/fr/knxUltimateAI.json +40 -31
  14. package/nodes/locales/it/knxUltimateAI.html +27 -18
  15. package/nodes/locales/it/knxUltimateAI.json +40 -31
  16. package/nodes/locales/zh-CN/knxUltimateAI.html +19 -16
  17. package/nodes/locales/zh-CN/knxUltimateAI.json +40 -31
  18. package/nodes/plugins/knxUltimate-cerebrum-runtime-plugin.js +79 -0
  19. package/nodes/plugins/knxUltimateAI-vue/assets/app.css +1 -1
  20. package/nodes/plugins/knxUltimateAI-vue/assets/app.js +13 -4
  21. package/nodes/utils/knxAiCamera.js +4 -4
  22. package/nodes/utils/knxAiCatalogRetrieval.js +349 -0
  23. package/nodes/utils/knxAiCerebrum.js +406 -0
  24. package/nodes/utils/knxAiChatContext.js +43 -1
  25. package/nodes/utils/knxAiHomeMemory.js +555 -13
  26. package/nodes/utils/knxAiScheduler.js +4 -1
  27. package/nodes/utils/knxAiSemanticContext.js +662 -0
  28. package/package.json +5 -3
  29. package/resources/KNXAIChatAdapterMappings.js +31 -5
  30. package/resources/hueControllerProfiles.js +7614 -7622
@@ -163,8 +163,8 @@ const normalizeKnxAiCameraAction = (value, cameras = []) => {
163
163
  ? eventType === 'smartDetectLine'
164
164
  ? camera.lines
165
165
  : ['smartDetectZone', 'smartDetectLoiterZone'].includes(eventType)
166
- ? camera.zones
167
- : []
166
+ ? camera.zones
167
+ : []
168
168
  : []
169
169
  const normalizedScopeTarget = normalizeSearchText(requestedScope)
170
170
  const matchingScopes = normalizedScopeTarget
@@ -231,8 +231,8 @@ const cameraWatchMatchesEvent = (watch, event) => {
231
231
  const wantedObjects = (Array.isArray(watch.objectTypes) ? watch.objectTypes : []).map(normalizeCameraObjectType).filter(Boolean)
232
232
  const watchEventType = normalizeCameraEventType(watch.eventType)
233
233
  const eventType = normalizeCameraEventType(event.eventType)
234
- const genericSmartDetection = watchEventType === 'smartDetect'
235
- || (watchEventType === 'motion' && wantedObjects.length > 0)
234
+ const genericSmartDetection = watchEventType === 'smartDetect' ||
235
+ (watchEventType === 'motion' && wantedObjects.length > 0)
236
236
  if (genericSmartDetection) {
237
237
  if (!['smartDetectZone', 'smartDetectLine', 'smartDetectLoiterZone'].includes(eventType)) return false
238
238
  } else if (watchEventType && watchEventType !== eventType) return false
@@ -0,0 +1,349 @@
1
+ const KNX_AI_CATALOG_MAX_RESEARCH_ROUNDS = 1
2
+ const KNX_AI_CATALOG_MAX_ACTIONS_PER_ROUND = 4
3
+ const KNX_AI_CATALOG_MAX_RESULTS_PER_ACTION = 12
4
+ const KNX_AI_CATALOG_MAX_ACCUMULATED_OBJECTS = 24
5
+
6
+ const normalizeText = (value) => String(value || '')
7
+ .normalize('NFKD')
8
+ .replace(/[\u0300-\u036f]/g, '')
9
+ .toLowerCase()
10
+ .replace(/[^\p{L}\p{N}/]+/gu, ' ')
11
+ .replace(/\s+/g, ' ')
12
+ .trim()
13
+
14
+ const normalizeGa = (value) => String(value || '').trim()
15
+
16
+ const clampLimit = (value) => Math.max(1, Math.min(
17
+ KNX_AI_CATALOG_MAX_RESULTS_PER_ACTION,
18
+ Math.round(Number(value) || 8)
19
+ ))
20
+
21
+ const normalizeStringList = (value, maxItems = 12) => Array.from(new Set(
22
+ (Array.isArray(value) ? value : [])
23
+ .map(item => String(item || '').trim())
24
+ .filter(Boolean)
25
+ )).slice(0, maxItems)
26
+
27
+ const normalizeKnxAiCatalogActions = (value, { maxActions = KNX_AI_CATALOG_MAX_ACTIONS_PER_ROUND } = {}) => {
28
+ const accepted = []
29
+ const allowedOperations = new Set(['search', 'get', 'list_areas', 'browse_area', 'related'])
30
+ const allowedAccess = new Set(['any', 'read-only', 'read-write'])
31
+ const allowedPurposes = new Set(['any', 'read', 'write', 'inspect'])
32
+ ;(Array.isArray(value) ? value : []).slice(0, Math.max(1, Number(maxActions) || 1)).forEach(candidate => {
33
+ const source = candidate && typeof candidate === 'object' && !Array.isArray(candidate) ? candidate : {}
34
+ const operation = String(source.operation || '').trim().toLowerCase()
35
+ if (!allowedOperations.has(operation)) return
36
+ const query = String(source.query || '').trim().slice(0, 300)
37
+ const destinations = normalizeStringList(source.destinations, 20)
38
+ const area = String(source.area || '').trim().slice(0, 300)
39
+ if (operation === 'search' && !query) return
40
+ if ((operation === 'get' || operation === 'related') && !destinations.length) return
41
+ if (operation === 'browse_area' && !area) return
42
+ const semanticKinds = normalizeStringList(source.semanticKinds, 12)
43
+ .map(kind => normalizeText(kind))
44
+ .filter(Boolean)
45
+ const requestedAccess = String(source.access || '').trim().toLowerCase()
46
+ const requestedPurpose = String(source.purpose || '').trim().toLowerCase()
47
+ accepted.push({
48
+ operation,
49
+ query,
50
+ destinations,
51
+ area,
52
+ semanticKinds,
53
+ access: allowedAccess.has(requestedAccess) ? requestedAccess : 'any',
54
+ purpose: allowedPurposes.has(requestedPurpose) ? requestedPurpose : 'any',
55
+ offset: Math.max(0, Math.min(100000, Math.round(Number(source.offset) || 0))),
56
+ limit: clampLimit(source.limit),
57
+ reason: String(source.reason || '').trim().slice(0, 1000)
58
+ })
59
+ })
60
+ return accepted
61
+ }
62
+
63
+ const editDistanceAtMostTwo = (left, right) => {
64
+ if (left === right) return 0
65
+ if (!left || !right || Math.abs(left.length - right.length) > 2) return 3
66
+ let previous = Array.from({ length: right.length + 1 }, (_, index) => index)
67
+ for (let i = 1; i <= left.length; i += 1) {
68
+ const current = [i]
69
+ let rowMinimum = current[0]
70
+ for (let j = 1; j <= right.length; j += 1) {
71
+ const substitution = previous[j - 1] + (left[i - 1] === right[j - 1] ? 0 : 1)
72
+ current[j] = Math.min(previous[j] + 1, current[j - 1] + 1, substitution)
73
+ rowMinimum = Math.min(rowMinimum, current[j])
74
+ }
75
+ if (rowMinimum > 2) return 3
76
+ previous = current
77
+ }
78
+ return previous[right.length]
79
+ }
80
+
81
+ const tokenQuality = (queryToken, candidateToken) => {
82
+ if (queryToken === candidateToken) return 1
83
+ const substringMinimum = /[^\u0000-\u007f]/.test(queryToken) ? 2 : 3
84
+ if (queryToken.length >= substringMinimum && (candidateToken.includes(queryToken) || queryToken.includes(candidateToken))) return 0.82
85
+ if (queryToken.length < 4) return 0
86
+ const distance = editDistanceAtMostTwo(queryToken, candidateToken)
87
+ if (distance === 1) return 0.68
88
+ if (distance === 2 && queryToken.length >= 7) return 0.5
89
+ return 0
90
+ }
91
+
92
+ const buildSearchDocument = (item) => {
93
+ const semantic = item && item.semantic && typeof item.semantic === 'object' ? item.semantic : {}
94
+ const aliases = Array.isArray(item && item.aliases) ? item.aliases : []
95
+ const options = Array.isArray(item && item.valueOptions) ? item.valueOptions : []
96
+ const fields = {
97
+ ga: normalizeText(item && item.ga),
98
+ label: normalizeText(item && item.label),
99
+ etsName: normalizeText(item && item.etsName),
100
+ hierarchy: normalizeText(item && item.hierarchyPath),
101
+ aliases: normalizeText(aliases.join(' ')),
102
+ semantic: normalizeText([semantic.kind, semantic.area, item && item.mainGroup, item && item.middleGroup, ...(Array.isArray(item && item.tags) ? item.tags : [])].join(' ')),
103
+ values: normalizeText(options.map(option => `${option && option.value} ${option && option.label}`).join(' ')),
104
+ dpt: normalizeText(item && item.dpt)
105
+ }
106
+ const combined = Object.values(fields).join(' ').trim()
107
+ return { fields, combined, tokens: Array.from(new Set(combined.split(' ').filter(Boolean))) }
108
+ }
109
+
110
+ const itemPassesFilters = (item, action) => {
111
+ if (action.access === 'read-only' && item && item.readOnly !== true) return false
112
+ if (action.access === 'read-write' && item && item.readOnly === true) return false
113
+ if (action.purpose === 'write' && item && item.readOnly === true) return false
114
+ const semantic = item && item.semantic && typeof item.semantic === 'object' ? item.semantic : {}
115
+ if (action.semanticKinds.length && !action.semanticKinds.includes(normalizeText(semantic.kind))) return false
116
+ return true
117
+ }
118
+
119
+ const purposeScore = (item, purpose) => {
120
+ const readOnly = item && item.readOnly === true
121
+ if (purpose === 'write') return readOnly ? -1000 : 130
122
+ if (purpose === 'read') return readOnly ? 90 : 50
123
+ if (purpose === 'inspect') return readOnly ? 70 : 55
124
+ return 0
125
+ }
126
+
127
+ const scoreSearchMatch = (item, action) => {
128
+ if (!itemPassesFilters(item, action)) return 0
129
+ const query = normalizeText(action.query)
130
+ if (!query) return 0
131
+ const document = buildSearchDocument(item)
132
+ const exactGa = normalizeGa(item && item.ga) === normalizeGa(action.query)
133
+ if (exactGa) return 2000 + purposeScore(item, action.purpose)
134
+ let score = 0
135
+ if (document.fields.label === query) score += 900
136
+ if (document.fields.etsName === query) score += 850
137
+ if (document.fields.aliases.split(' ').includes(query)) score += 800
138
+ if (document.fields.hierarchy === query) score += 750
139
+ if (document.combined.includes(query)) score += 360
140
+ const queryTokens = query.split(' ').filter(Boolean)
141
+ let qualityTotal = 0
142
+ let matchedTokens = 0
143
+ let strongMatches = 0
144
+ for (const queryToken of queryTokens) {
145
+ let best = 0
146
+ for (const candidateToken of document.tokens) {
147
+ best = Math.max(best, tokenQuality(queryToken, candidateToken))
148
+ if (best === 1) break
149
+ }
150
+ if (best >= 0.5) {
151
+ matchedTokens += 1
152
+ qualityTotal += best
153
+ if (best >= 0.68) strongMatches += 1
154
+ }
155
+ }
156
+ if (strongMatches === 0) return 0
157
+ const missingTokens = Math.max(0, queryTokens.length - matchedTokens)
158
+ const coverage = queryTokens.length > 0 ? matchedTokens / queryTokens.length : 0
159
+ score += qualityTotal * 120
160
+ score += coverage * 200
161
+ score += Math.max(0, matchedTokens - 1) * 50
162
+ score -= missingTokens * 18
163
+ score += purposeScore(item, action.purpose)
164
+ if (action.area) {
165
+ const area = normalizeText(action.area)
166
+ const itemArea = normalizeText([
167
+ item && item.hierarchyPath,
168
+ item && item.mainGroup,
169
+ item && item.middleGroup,
170
+ item && item.semantic && item.semantic.area
171
+ ].join(' '))
172
+ if (!itemArea.includes(area)) return 0
173
+ score += 120
174
+ }
175
+ return score
176
+ }
177
+
178
+ const sortScoredItems = (scored) => scored
179
+ .sort((left, right) => right.score - left.score || String(left.item && left.item.hierarchyPath || left.item && left.item.label || left.item && left.item.ga || '').localeCompare(String(right.item && right.item.hierarchyPath || right.item && right.item.label || right.item && right.item.ga || '')))
180
+ .map(entry => entry.item)
181
+
182
+ const findSearchItems = (catalog, action) => sortScoredItems(
183
+ catalog.map(item => ({ item, score: scoreSearchMatch(item, action) })).filter(entry => entry.score > 0)
184
+ )
185
+
186
+ const buildAreaRows = (catalog) => {
187
+ const areas = new Map()
188
+ catalog.forEach(item => {
189
+ const semantic = item && item.semantic && typeof item.semantic === 'object' ? item.semantic : {}
190
+ const candidates = [semantic.area, item && item.hierarchyPath, item && item.middleGroup, item && item.mainGroup]
191
+ .map(value => String(value || '').trim())
192
+ .filter(Boolean)
193
+ const area = candidates[0] || 'Unclassified'
194
+ const key = normalizeText(area)
195
+ if (!key) return
196
+ if (!areas.has(key)) areas.set(key, { area, count: 0, sampleLabels: [] })
197
+ const row = areas.get(key)
198
+ row.count += 1
199
+ const label = String(item && item.label || item && item.ga || '').trim()
200
+ if (label && row.sampleLabels.length < 3 && !row.sampleLabels.includes(label)) row.sampleLabels.push(label)
201
+ })
202
+ return Array.from(areas.values()).sort((left, right) => right.count - left.count || left.area.localeCompare(right.area))
203
+ }
204
+
205
+ const findAreaItems = (catalog, action) => {
206
+ const requestedArea = normalizeText(action.area)
207
+ return sortScoredItems(catalog
208
+ .filter(item => itemPassesFilters(item, action))
209
+ .map(item => {
210
+ const semantic = item && item.semantic && typeof item.semantic === 'object' ? item.semantic : {}
211
+ const areaText = normalizeText([semantic.area, item && item.hierarchyPath, item && item.middleGroup, item && item.mainGroup].join(' '))
212
+ if (!areaText.includes(requestedArea)) return { item, score: 0 }
213
+ return { item, score: 400 + purposeScore(item, action.purpose) }
214
+ })
215
+ .filter(entry => entry.score > 0))
216
+ }
217
+
218
+ const findRelatedItems = (catalog, action) => {
219
+ const requested = new Set(action.destinations.map(normalizeGa))
220
+ const seeds = catalog.filter(item => requested.has(normalizeGa(item && item.ga)))
221
+ if (!seeds.length) return []
222
+ const seedDocuments = seeds.map(seed => ({ seed, tokens: new Set(buildSearchDocument(seed).tokens) }))
223
+ const scored = catalog.map(item => {
224
+ if (!itemPassesFilters(item, action)) return { item, score: 0 }
225
+ let best = requested.has(normalizeGa(item && item.ga)) ? 1000 : 0
226
+ const itemTokens = buildSearchDocument(item).tokens
227
+ seedDocuments.forEach(({ seed, tokens: seedTokens }) => {
228
+ if (seed === item) return
229
+ const seedSemantic = seed && seed.semantic && typeof seed.semantic === 'object' ? seed.semantic : {}
230
+ const itemSemantic = item && item.semantic && typeof item.semantic === 'object' ? item.semantic : {}
231
+ let relationScore = 0
232
+ if (normalizeText(seed.hierarchyPath) && normalizeText(seed.hierarchyPath) === normalizeText(item.hierarchyPath)) relationScore += 260
233
+ if (normalizeText(seedSemantic.area) && normalizeText(seedSemantic.area) === normalizeText(itemSemantic.area)) relationScore += 180
234
+ if (normalizeText(seed.middleGroup) && normalizeText(seed.middleGroup) === normalizeText(item.middleGroup)) relationScore += 100
235
+ if (normalizeText(seed.mainGroup) && normalizeText(seed.mainGroup) === normalizeText(item.mainGroup)) relationScore += 45
236
+ if (normalizeText(seedSemantic.kind) && normalizeText(seedSemantic.kind) === normalizeText(itemSemantic.kind)) relationScore += 65
237
+ const overlap = itemTokens.filter(token => token.length >= 4 && seedTokens.has(token)).length
238
+ relationScore += Math.min(100, overlap * 20)
239
+ if (relationScore <= 0) return
240
+ const score = relationScore + purposeScore(item, action.purpose)
241
+ best = Math.max(best, score)
242
+ })
243
+ return { item, score: best }
244
+ }).filter(entry => entry.score > 0)
245
+ return sortScoredItems(scored)
246
+ }
247
+
248
+ const actionFingerprint = (action) => JSON.stringify({
249
+ operation: action.operation,
250
+ query: normalizeText(action.query),
251
+ destinations: action.destinations.map(normalizeGa).sort(),
252
+ area: normalizeText(action.area),
253
+ semanticKinds: action.semanticKinds.slice().sort(),
254
+ access: action.access,
255
+ purpose: action.purpose,
256
+ offset: action.offset,
257
+ limit: action.limit
258
+ })
259
+
260
+ const executeKnxAiCatalogActions = ({ actions, catalog, priorResults = [] } = {}) => {
261
+ const safeCatalog = Array.isArray(catalog) ? catalog : []
262
+ const normalized = normalizeKnxAiCatalogActions(actions)
263
+ const seen = new Set((Array.isArray(priorResults) ? priorResults : []).map(result => String(result && result.fingerprint || '')).filter(Boolean))
264
+ const results = []
265
+ normalized.forEach(action => {
266
+ const fingerprint = actionFingerprint(action)
267
+ if (seen.has(fingerprint)) return
268
+ seen.add(fingerprint)
269
+ if (action.operation === 'list_areas') {
270
+ const allAreas = buildAreaRows(safeCatalog.filter(item => itemPassesFilters(item, action)))
271
+ results.push({
272
+ action,
273
+ fingerprint,
274
+ ok: true,
275
+ totalMatches: allAreas.length,
276
+ truncated: allAreas.length > action.offset + action.limit,
277
+ areas: allAreas.slice(action.offset, action.offset + action.limit),
278
+ items: []
279
+ })
280
+ return
281
+ }
282
+ let matched = []
283
+ if (action.operation === 'get') {
284
+ const requested = new Set(action.destinations.map(normalizeGa))
285
+ matched = safeCatalog.filter(item => requested.has(normalizeGa(item && item.ga)) && itemPassesFilters(item, action))
286
+ } else if (action.operation === 'search') {
287
+ matched = findSearchItems(safeCatalog, action)
288
+ } else if (action.operation === 'browse_area') {
289
+ matched = findAreaItems(safeCatalog, action)
290
+ } else if (action.operation === 'related') {
291
+ matched = findRelatedItems(safeCatalog, action)
292
+ }
293
+ results.push({
294
+ action,
295
+ fingerprint,
296
+ ok: true,
297
+ totalMatches: matched.length,
298
+ truncated: matched.length > action.offset + action.limit,
299
+ items: matched.slice(action.offset, action.offset + action.limit),
300
+ areas: []
301
+ })
302
+ })
303
+ return results
304
+ }
305
+
306
+ const collectKnxAiCatalogObjects = (results, maxItems = KNX_AI_CATALOG_MAX_ACCUMULATED_OBJECTS) => {
307
+ const byGa = new Map()
308
+ ;(Array.isArray(results) ? results.slice().reverse() : []).forEach(result => {
309
+ ;(Array.isArray(result && result.items) ? result.items : []).forEach(item => {
310
+ const ga = normalizeGa(item && item.ga)
311
+ if (!ga || byGa.has(ga) || byGa.size >= maxItems) return
312
+ byGa.set(ga, item)
313
+ })
314
+ })
315
+ return Array.from(byGa.values())
316
+ }
317
+
318
+ const buildKnxAiCatalogResearchContext = (results) => {
319
+ const source = Array.isArray(results) ? results : []
320
+ if (!source.length) return ''
321
+ const lines = ['LOCAL ETS CATALOG RETRIEVAL RESULTS:']
322
+ source.forEach((result, index) => {
323
+ const action = result && result.action ? result.action : {}
324
+ const target = action.operation === 'search'
325
+ ? ` query=${JSON.stringify(action.query)}`
326
+ : action.operation === 'browse_area'
327
+ ? ` area=${JSON.stringify(action.area)}`
328
+ : (action.operation === 'get' || action.operation === 'related')
329
+ ? ` destinations=${JSON.stringify(action.destinations)}`
330
+ : ''
331
+ lines.push(`[C${index + 1}] ${action.operation || 'unknown'}${target}: ${Number(result && result.totalMatches) || 0} match(es); page offset=${Math.max(0, Number(action.offset) || 0)}, limit=${Math.max(1, Number(action.limit) || 1)}${result && result.truncated ? '; more matches are available with a larger offset' : ''}.`)
332
+ ;(Array.isArray(result && result.areas) ? result.areas : []).forEach(area => {
333
+ lines.push(`- area ${JSON.stringify(area.area)} | ${area.count} objects | examples ${area.sampleLabels.join(', ') || '?'}`)
334
+ })
335
+ })
336
+ lines.push('END LOCAL ETS CATALOG RETRIEVAL RESULTS')
337
+ return lines.join('\n')
338
+ }
339
+
340
+ module.exports = {
341
+ KNX_AI_CATALOG_MAX_ACCUMULATED_OBJECTS,
342
+ KNX_AI_CATALOG_MAX_ACTIONS_PER_ROUND,
343
+ KNX_AI_CATALOG_MAX_RESEARCH_ROUNDS,
344
+ KNX_AI_CATALOG_MAX_RESULTS_PER_ACTION,
345
+ buildKnxAiCatalogResearchContext,
346
+ collectKnxAiCatalogObjects,
347
+ executeKnxAiCatalogActions,
348
+ normalizeKnxAiCatalogActions
349
+ }