node-red-contrib-knx-ultimate 6.2.0 → 6.2.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.
@@ -0,0 +1,507 @@
1
+ const HOME_MEMORY_VERSION = 1
2
+ const HOME_MEMORY_MIN_KB = 64
3
+ const HOME_MEMORY_MAX_KB = 1024
4
+ const HOME_MEMORY_DEFAULT_KB = 256
5
+ const HOME_MEMORY_MAX_EDUCATION_CHARS = 16000
6
+ const HOME_MEMORY_MAX_OBSERVATIONS = 120
7
+ const HOME_MEMORY_MAX_HABITS = 80
8
+ const HOME_MEMORY_MAX_NOTIFICATIONS = 80
9
+ const HOME_MEMORY_MAX_SEMANTIC_OBJECTS = 300
10
+
11
+ const normalizeText = (value) => String(value === undefined || value === null ? '' : value)
12
+ .normalize('NFD')
13
+ .replace(/[\u0300-\u036f]/g, '')
14
+ .toLowerCase()
15
+ .replace(/[^\p{L}\p{N}]+/gu, ' ')
16
+ .trim()
17
+
18
+ const clampHomeMemoryKb = (value) => {
19
+ const parsed = Number(value)
20
+ if (!Number.isFinite(parsed)) return HOME_MEMORY_DEFAULT_KB
21
+ return Math.max(HOME_MEMORY_MIN_KB, Math.min(HOME_MEMORY_MAX_KB, Math.round(parsed)))
22
+ }
23
+
24
+ const clampText = (value, maxChars) => {
25
+ const text = String(value === undefined || value === null ? '' : value).trim()
26
+ return text.length > maxChars ? text.slice(0, maxChars) : text
27
+ }
28
+
29
+ const clampUtf8Bytes = (value, maxBytes) => {
30
+ const text = String(value === undefined || value === null ? '' : value)
31
+ const limit = Math.max(0, Math.floor(Number(maxBytes) || 0))
32
+ if (Buffer.byteLength(text, 'utf8') <= limit) return text
33
+ let low = 0
34
+ let high = text.length
35
+ while (low < high) {
36
+ const midpoint = Math.ceil((low + high) / 2)
37
+ if (Buffer.byteLength(text.slice(0, midpoint), 'utf8') <= limit) low = midpoint
38
+ else high = midpoint - 1
39
+ }
40
+ return text.slice(0, low).replace(/[\uD800-\uDBFF]$/, '')
41
+ }
42
+
43
+ const clonePlain = (value, fallback) => {
44
+ try {
45
+ return JSON.parse(JSON.stringify(value))
46
+ } catch (error) {
47
+ return fallback
48
+ }
49
+ }
50
+
51
+ const matches = (text, expressions) => expressions.some(expression => expression.test(text))
52
+
53
+ const CONCEPT_PATTERNS = [
54
+ {
55
+ kind: 'cover',
56
+ confidence: 0.96,
57
+ patterns: [
58
+ /\b(persian[ae]|tapparell[ae]|avvolgibil[ei]|venezian[ae])\b/,
59
+ /\b(shutter|shutters|roller blind|roller blinds|blind|blinds)\b/,
60
+ /\b(rollladen|rolllaeden|jalousie|jalousien)\b/,
61
+ /\b(volet|volets|store|stores)\b/,
62
+ /\b(persiana|persianas|contraventana|contraventanas)\b/,
63
+ /(卷帘|百叶窗|遮阳帘|窗帘)/
64
+ ]
65
+ },
66
+ {
67
+ kind: 'window',
68
+ confidence: 0.94,
69
+ patterns: [
70
+ /\b(finestr[ae]|serramento|serramenti)\b/,
71
+ /\b(window|windows)\b/,
72
+ /\b(fenster)\b/,
73
+ /\b(fenetre|fenetres)\b/,
74
+ /\b(ventana|ventanas)\b/,
75
+ /(窗户|窗)/
76
+ ]
77
+ },
78
+ {
79
+ kind: 'door',
80
+ confidence: 0.93,
81
+ patterns: [
82
+ /\b(porta|porte|portone|portoni)\b/,
83
+ /\b(door|doors|gate|gates)\b/,
84
+ /\b(tur|ture|tor|tore)\b/,
85
+ /\b(porte|portes|portail|portails)\b/,
86
+ /\b(puerta|puertas|porton|portones)\b/,
87
+ /(门|大门|车库门)/
88
+ ]
89
+ },
90
+ {
91
+ kind: 'light',
92
+ confidence: 0.92,
93
+ patterns: [
94
+ /\b(luce|luci|lampada|lampade|plafoniera|applique|piantana)\b/,
95
+ /\b(light|lights|lamp|lamps|ceiling light)\b/,
96
+ /\b(licht|leuchte|lampe|lampen)\b/,
97
+ /\b(lumiere|lumieres|lampe|lampes)\b/,
98
+ /\b(luz|luces|lampara|lamparas)\b/,
99
+ /(灯|照明|灯光)/
100
+ ]
101
+ },
102
+ {
103
+ kind: 'temperature',
104
+ confidence: 0.95,
105
+ patterns: [
106
+ /\b(temperatura|termometro)\b/,
107
+ /\b(temperature|thermometer)\b/,
108
+ /\b(temperatur|thermometer)\b/,
109
+ /\b(temperature|thermometre)\b/,
110
+ /\b(temperatura|termometro)\b/,
111
+ /(温度|温度计)/
112
+ ]
113
+ },
114
+ {
115
+ kind: 'climate',
116
+ confidence: 0.9,
117
+ patterns: [
118
+ /\b(riscaldamento|raffrescamento|climatizzazione|termostato|setpoint)\b/,
119
+ /\b(heating|cooling|climate|thermostat|setpoint)\b/,
120
+ /\b(heizung|kuhlung|klima|thermostat|sollwert)\b/,
121
+ /\b(chauffage|refroidissement|climatisation|thermostat|consigne)\b/,
122
+ /\b(calefaccion|refrigeracion|climatizacion|termostato|consigna)\b/,
123
+ /(供暖|制冷|空调|恒温器|设定温度)/
124
+ ]
125
+ },
126
+ {
127
+ kind: 'occupancy',
128
+ confidence: 0.9,
129
+ patterns: [
130
+ /\b(presenza|presenze|occupazione|movimento)\b/,
131
+ /\b(presence|occupancy|motion)\b/,
132
+ /\b(prasenz|anwesenheit|bewegung)\b/,
133
+ /\b(presence|occupation|mouvement)\b/,
134
+ /\b(presencia|ocupacion|movimiento)\b/,
135
+ /(存在|占用|人体感应|移动)/
136
+ ]
137
+ },
138
+ {
139
+ kind: 'alarm',
140
+ confidence: 0.92,
141
+ patterns: [
142
+ /\b(allarme|antifurto|fumo|allagamento)\b/,
143
+ /\b(alarm|intrusion|smoke|flood)\b/,
144
+ /\b(alarm|einbruch|rauch|wasserleck)\b/,
145
+ /\b(alarme|intrusion|fumee|inondation)\b/,
146
+ /\b(alarma|intrusion|humo|inundacion)\b/,
147
+ /(报警|入侵|烟雾|漏水)/
148
+ ]
149
+ }
150
+ ]
151
+
152
+ const AREA_PATTERNS = [
153
+ {
154
+ area: 'living_room',
155
+ patterns: [
156
+ /\b(soggiorno|salotto|living)\b/,
157
+ /\b(wohnzimmer)\b/,
158
+ /\b(sejour|salon)\b/,
159
+ /\b(sala de estar|salon)\b/,
160
+ /(客厅|起居室)/
161
+ ]
162
+ },
163
+ {
164
+ area: 'kitchen',
165
+ patterns: [/\b(cucina|kitchen|kuche|cuisine|cocina)\b/, /(厨房)/]
166
+ },
167
+ {
168
+ area: 'bedroom',
169
+ patterns: [
170
+ /\b(camera da letto|camera matrimoniale|bedroom)\b/,
171
+ /\b(schlafzimmer)\b/,
172
+ /\b(chambre)\b/,
173
+ /\b(dormitorio|habitacion)\b/,
174
+ /(卧室)/
175
+ ]
176
+ },
177
+ {
178
+ area: 'bathroom',
179
+ patterns: [/\b(bagno|bathroom|bad|badezimmer|salle de bain|bano)\b/, /(浴室|卫生间)/
180
+ ]
181
+ },
182
+ {
183
+ area: 'office',
184
+ patterns: [/\b(studio|office|buro|bureau|oficina)\b/, /(书房|办公室)/]
185
+ },
186
+ {
187
+ area: 'garage',
188
+ patterns: [/\b(garage|autorimessa|garaje)\b/, /(车库)/]
189
+ },
190
+ {
191
+ area: 'hallway',
192
+ patterns: [/\b(corridoio|ingresso|hallway|corridor|flur|entree|pasillo)\b/, /(走廊|入口|玄关)/]
193
+ },
194
+ {
195
+ area: 'outdoor',
196
+ patterns: [/\b(esterno|giardino|terrazzo|balcone|outdoor|garden|garten|jardin|exterior)\b/, /(户外|花园|阳台)/]
197
+ }
198
+ ]
199
+
200
+ const inferKnxAiHomeSemantic = (item = {}) => {
201
+ const source = [
202
+ item.mainGroup,
203
+ item.middleGroup,
204
+ item.hierarchyPath,
205
+ item.label,
206
+ item.etsName,
207
+ Array.isArray(item.tags) ? item.tags.join(' ') : ''
208
+ ].filter(Boolean).join(' ')
209
+ const normalized = normalizeText(source)
210
+ const concept = CONCEPT_PATTERNS.find(entry => matches(normalized, entry.patterns))
211
+ const areaMatch = AREA_PATTERNS.find(entry => matches(normalized, entry.patterns))
212
+ const role = String(item.role || 'neutral').trim().toLowerCase()
213
+ const dpt = String(item.dpt || '').trim()
214
+ const semantic = {
215
+ kind: concept ? concept.kind : 'unknown',
216
+ area: areaMatch ? areaMatch.area : '',
217
+ role,
218
+ confidence: concept ? concept.confidence : 0,
219
+ sourceLanguage: /[\u3400-\u9fff]/.test(source) ? 'zh-CN' : 'auto',
220
+ originalLabel: clampText(item.label || item.etsName || item.ga || '', 240),
221
+ dpt
222
+ }
223
+ if (semantic.kind === 'unknown' && /^9\./.test(dpt)) {
224
+ semantic.kind = 'measurement'
225
+ semantic.confidence = 0.55
226
+ }
227
+ return semantic
228
+ }
229
+
230
+ const enrichKnxAiHomeCatalog = (catalog) => (Array.isArray(catalog) ? catalog : []).map(item => {
231
+ return Object.assign({}, item, {
232
+ semantic: inferKnxAiHomeSemantic(item)
233
+ })
234
+ })
235
+
236
+ const createEmptyKnxAiHomeMemory = () => ({
237
+ version: HOME_MEMORY_VERSION,
238
+ createdAt: new Date().toISOString(),
239
+ updatedAt: new Date().toISOString(),
240
+ ownerSessionId: '',
241
+ ownerLanguage: '',
242
+ observations: [],
243
+ habits: [],
244
+ notifications: [],
245
+ semanticObjects: []
246
+ })
247
+
248
+ const normalizeArray = (value, maxItems) => {
249
+ const source = Array.isArray(value) ? value : []
250
+ return source
251
+ .filter(item => item && typeof item === 'object' && !Array.isArray(item))
252
+ .slice(-maxItems)
253
+ .map(item => clonePlain(item, {}))
254
+ }
255
+
256
+ const normalizeKnxAiHomeMemory = (value = {}) => {
257
+ const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {}
258
+ return {
259
+ version: HOME_MEMORY_VERSION,
260
+ createdAt: clampText(source.createdAt || new Date().toISOString(), 64),
261
+ updatedAt: clampText(source.updatedAt || new Date().toISOString(), 64),
262
+ ownerSessionId: clampText(source.ownerSessionId, 160),
263
+ ownerLanguage: clampText(source.ownerLanguage, 16),
264
+ observations: normalizeArray(source.observations, HOME_MEMORY_MAX_OBSERVATIONS),
265
+ habits: normalizeArray(source.habits, HOME_MEMORY_MAX_HABITS),
266
+ notifications: normalizeArray(source.notifications, HOME_MEMORY_MAX_NOTIFICATIONS),
267
+ semanticObjects: normalizeArray(source.semanticObjects, HOME_MEMORY_MAX_SEMANTIC_OBJECTS)
268
+ }
269
+ }
270
+
271
+ const addBoundedKnxAiObservation = (memory, observation) => {
272
+ const target = normalizeKnxAiHomeMemory(memory)
273
+ const item = clonePlain(observation, null)
274
+ if (item) target.observations.push(item)
275
+ target.observations = target.observations.slice(-HOME_MEMORY_MAX_OBSERVATIONS)
276
+ target.updatedAt = new Date().toISOString()
277
+ return target
278
+ }
279
+
280
+ const addBoundedKnxAiNotification = (memory, notification) => {
281
+ const target = normalizeKnxAiHomeMemory(memory)
282
+ const item = clonePlain(notification, null)
283
+ if (item) target.notifications.push(item)
284
+ target.notifications = target.notifications.slice(-HOME_MEMORY_MAX_NOTIFICATIONS)
285
+ target.updatedAt = new Date().toISOString()
286
+ return target
287
+ }
288
+
289
+ const updateKnxAiCoverHabit = (memory, { ga, label, area, durationMinutes, at } = {}) => {
290
+ const target = normalizeKnxAiHomeMemory(memory)
291
+ const normalizedGa = clampText(ga, 32)
292
+ const duration = Math.max(0, Math.min(24 * 60, Number(durationMinutes) || 0))
293
+ if (!normalizedGa || !duration) return target
294
+ const existing = target.habits.find(item => item && item.ga === normalizedGa && item.type === 'cover_open_duration')
295
+ const samples = existing ? Math.max(0, Number(existing.samples) || 0) : 0
296
+ const previousAverage = existing ? Math.max(0, Number(existing.averageMinutes) || 0) : 0
297
+ const next = {
298
+ type: 'cover_open_duration',
299
+ ga: normalizedGa,
300
+ label: clampText(label || normalizedGa, 240),
301
+ area: clampText(area, 80),
302
+ samples: Math.min(1000000, samples + 1),
303
+ averageMinutes: Number((((previousAverage * samples) + duration) / (samples + 1)).toFixed(1)),
304
+ lastMinutes: Number(duration.toFixed(1)),
305
+ updatedAt: clampText(at || new Date().toISOString(), 64)
306
+ }
307
+ if (existing) Object.assign(existing, next)
308
+ else target.habits.push(next)
309
+ target.habits = target.habits
310
+ .sort((a, b) => String(a.updatedAt || '').localeCompare(String(b.updatedAt || '')))
311
+ .slice(-HOME_MEMORY_MAX_HABITS)
312
+ target.updatedAt = new Date().toISOString()
313
+ return target
314
+ }
315
+
316
+ const escapeMarkdownCell = (value) => clampText(value, 260).replace(/\|/g, '\\|').replace(/\r?\n/g, ' ')
317
+
318
+ const buildKnxAiHomeMemoryMarkdown = ({ memory, education, maxKb } = {}) => {
319
+ const targetBytes = clampHomeMemoryKb(maxKb) * 1024
320
+ const protectedEducation = clampText(education, HOME_MEMORY_MAX_EDUCATION_CHARS)
321
+ let renderedEducation = protectedEducation
322
+ const bounded = normalizeKnxAiHomeMemory(memory)
323
+
324
+ const render = () => {
325
+ bounded.updatedAt = new Date().toISOString()
326
+ const metadata = JSON.stringify(bounded)
327
+ const lines = [
328
+ '<!-- KNX_AI_HOME_MEMORY_V1',
329
+ metadata,
330
+ 'KNX_AI_HOME_MEMORY_END -->',
331
+ '',
332
+ '# KNX AI Home Memory',
333
+ '',
334
+ `Updated: ${bounded.updatedAt}`,
335
+ '',
336
+ '## AI Education — user managed, read only for AI',
337
+ '',
338
+ renderedEducation || '_No user education has been provided._',
339
+ '',
340
+ '## Semantic ETS model',
341
+ '',
342
+ '| GA | DPT | Kind | Area | Role | Confidence | ETS label |',
343
+ '|---|---|---|---|---|---:|---|'
344
+ ]
345
+ bounded.semanticObjects.forEach(item => {
346
+ lines.push(`| ${escapeMarkdownCell(item.ga)} | ${escapeMarkdownCell(item.dpt)} | ${escapeMarkdownCell(item.kind)} | ${escapeMarkdownCell(item.area)} | ${escapeMarkdownCell(item.role)} | ${Number(item.confidence || 0).toFixed(2)} | ${escapeMarkdownCell(item.label)} |`)
347
+ })
348
+ lines.push('', '## Learned habits', '')
349
+ if (!bounded.habits.length) lines.push('_No stable habit has been learned yet._')
350
+ bounded.habits.forEach(item => {
351
+ lines.push(`- ${escapeMarkdownCell(item.label || item.ga)}: average open duration ${Number(item.averageMinutes || 0).toFixed(1)} minutes across ${Number(item.samples || 0)} observations; last ${Number(item.lastMinutes || 0).toFixed(1)} minutes.`)
352
+ })
353
+ lines.push('', '## Recent significant observations', '')
354
+ if (!bounded.observations.length) lines.push('_No significant observation recorded._')
355
+ bounded.observations.forEach(item => {
356
+ lines.push(`- ${escapeMarkdownCell(item.at)} — ${escapeMarkdownCell(item.label || item.ga)}: ${escapeMarkdownCell(item.event || item.value || item.type)}`)
357
+ })
358
+ lines.push('', '## Proactive notification history', '')
359
+ if (!bounded.notifications.length) lines.push('_No proactive notification sent._')
360
+ bounded.notifications.forEach(item => {
361
+ lines.push(`- ${escapeMarkdownCell(item.at)} — ${escapeMarkdownCell(item.label || item.ga)}: ${escapeMarkdownCell(item.reason || item.type)}`)
362
+ })
363
+ lines.push('')
364
+ return lines.join('\n')
365
+ }
366
+
367
+ let markdown = render()
368
+ while (Buffer.byteLength(markdown, 'utf8') > targetBytes) {
369
+ if (bounded.observations.length) bounded.observations.shift()
370
+ else if (bounded.notifications.length) bounded.notifications.shift()
371
+ else if (bounded.habits.length) bounded.habits.shift()
372
+ else if (bounded.semanticObjects.length) bounded.semanticObjects.pop()
373
+ else break
374
+ markdown = render()
375
+ }
376
+ if (Buffer.byteLength(markdown, 'utf8') > targetBytes && renderedEducation) {
377
+ renderedEducation = ''
378
+ const emptyEducationMarkdown = render()
379
+ const educationBudget = Math.max(0, targetBytes - Buffer.byteLength(emptyEducationMarkdown, 'utf8'))
380
+ renderedEducation = clampUtf8Bytes(protectedEducation, educationBudget)
381
+ markdown = render()
382
+ while (Buffer.byteLength(markdown, 'utf8') > targetBytes && renderedEducation) {
383
+ renderedEducation = clampUtf8Bytes(renderedEducation, Buffer.byteLength(renderedEducation, 'utf8') - 16)
384
+ markdown = render()
385
+ }
386
+ }
387
+ return {
388
+ markdown,
389
+ memory: bounded,
390
+ bytes: Buffer.byteLength(markdown, 'utf8'),
391
+ maxBytes: targetBytes,
392
+ education: renderedEducation
393
+ }
394
+ }
395
+
396
+ const parseKnxAiHomeMemoryMarkdown = (markdown) => {
397
+ const text = String(markdown || '')
398
+ const match = text.match(/<!-- KNX_AI_HOME_MEMORY_V1\s*\n([\s\S]*?)\nKNX_AI_HOME_MEMORY_END -->/)
399
+ if (!match) return createEmptyKnxAiHomeMemory()
400
+ try {
401
+ return normalizeKnxAiHomeMemory(JSON.parse(match[1]))
402
+ } catch (error) {
403
+ return createEmptyKnxAiHomeMemory()
404
+ }
405
+ }
406
+
407
+ const OPEN_RE = /\b(open|opened|up|aperto|aperta|aperti|aperte|offen|ouvert|ouverte|abierto|abierta)\b|打开|开启/
408
+ const CLOSED_RE = /\b(closed|close|down|chiuso|chiusa|chiusi|chiuse|geschlossen|ferme|fermee|cerrado|cerrada)\b|关闭|闭合/
409
+
410
+ const classifyKnxAiOpenState = ({ semantic, dpt, payload, valueOptions } = {}) => {
411
+ const safeSemantic = semantic && typeof semantic === 'object' ? semantic : {}
412
+ if (!['cover', 'window', 'door'].includes(safeSemantic.kind)) return null
413
+ if (String(safeSemantic.role || '').toLowerCase() === 'command') return null
414
+ const dptId = String(dpt || safeSemantic.dpt || '').trim()
415
+ const main = dptId.split('.')[0]
416
+ if (main === '5' && safeSemantic.kind === 'cover') {
417
+ const numeric = Number(payload)
418
+ if (!Number.isFinite(numeric) || numeric < 0 || numeric > 100) return null
419
+ return {
420
+ open: numeric < 99,
421
+ value: numeric,
422
+ confidence: 0.78,
423
+ reason: numeric < 99 ? 'cover_not_fully_closed' : 'cover_closed'
424
+ }
425
+ }
426
+ if (main !== '1' || typeof payload !== 'boolean') return null
427
+ const options = Array.isArray(valueOptions) ? valueOptions : []
428
+ const matchingOption = options.find(option => {
429
+ const raw = String(option && option.value !== undefined ? option.value : '').toLowerCase()
430
+ return payload ? ['true', '1'].includes(raw) : ['false', '0'].includes(raw)
431
+ })
432
+ const valueLabel = normalizeText(matchingOption && matchingOption.label)
433
+ if (OPEN_RE.test(valueLabel)) return { open: true, value: payload, confidence: 0.94, reason: 'explicit_open_value' }
434
+ if (CLOSED_RE.test(valueLabel)) return { open: false, value: payload, confidence: 0.94, reason: 'explicit_closed_value' }
435
+ return null
436
+ }
437
+
438
+ const parseClockMinutes = (value, fallback) => {
439
+ const match = String(value || '').trim().match(/^(\d{1,2}):(\d{2})$/)
440
+ if (!match) return fallback
441
+ const hour = Number(match[1])
442
+ const minute = Number(match[2])
443
+ if (!Number.isInteger(hour) || !Number.isInteger(minute) || hour < 0 || hour > 23 || minute < 0 || minute > 59) return fallback
444
+ return (hour * 60) + minute
445
+ }
446
+
447
+ const isKnxAiQuietTime = ({ date = new Date(), start = '23:00', end = '07:00' } = {}) => {
448
+ const startMinutes = parseClockMinutes(start, 23 * 60)
449
+ const endMinutes = parseClockMinutes(end, 7 * 60)
450
+ if (startMinutes === endMinutes) return false
451
+ const current = (date.getHours() * 60) + date.getMinutes()
452
+ if (startMinutes < endMinutes) return current >= startMinutes && current < endMinutes
453
+ return current >= startMinutes || current < endMinutes
454
+ }
455
+
456
+ const normalizeHomeLanguage = (value) => {
457
+ const raw = String(value || '').trim().toLowerCase()
458
+ if (raw.startsWith('it')) return 'it'
459
+ if (raw.startsWith('de')) return 'de'
460
+ if (raw.startsWith('fr')) return 'fr'
461
+ if (raw.startsWith('es')) return 'es'
462
+ if (raw.startsWith('zh')) return 'zh-CN'
463
+ return 'en'
464
+ }
465
+
466
+ const buildKnxAiProactiveFallback = ({ language, label, durationMinutes } = {}) => {
467
+ const lang = normalizeHomeLanguage(language)
468
+ const safeLabel = clampText(label || 'KNX object', 240)
469
+ const minutes = Math.max(1, Math.round(Number(durationMinutes) || 1))
470
+ const hours = Math.floor(minutes / 60)
471
+ const remainder = minutes % 60
472
+ const duration = hours > 0 ? `${hours} h${remainder ? ` ${remainder} min` : ''}` : `${minutes} min`
473
+ const messages = {
474
+ en: `${safeLabel} has remained open or not fully closed for ${duration}. Would you like me to help you close it?`,
475
+ it: `${safeLabel} risulta aperta o non completamente chiusa da ${duration}. Vuoi che ti aiuti a chiuderla?`,
476
+ de: `${safeLabel} ist seit ${duration} geöffnet oder nicht vollständig geschlossen. Soll ich dir beim Schließen helfen?`,
477
+ fr: `${safeLabel} est ouvert ou pas complètement fermé depuis ${duration}. Voulez-vous que je vous aide à le fermer ?`,
478
+ es: `${safeLabel} lleva ${duration} abierto o sin cerrar completamente. ¿Quieres que te ayude a cerrarlo?`,
479
+ 'zh-CN': `${safeLabel} 已打开或未完全关闭 ${duration}。需要我帮你关闭吗?`
480
+ }
481
+ return messages[lang] || messages.en
482
+ }
483
+
484
+ module.exports = {
485
+ HOME_MEMORY_DEFAULT_KB,
486
+ HOME_MEMORY_MAX_EDUCATION_CHARS,
487
+ HOME_MEMORY_MAX_HABITS,
488
+ HOME_MEMORY_MAX_KB,
489
+ HOME_MEMORY_MAX_NOTIFICATIONS,
490
+ HOME_MEMORY_MAX_OBSERVATIONS,
491
+ HOME_MEMORY_MAX_SEMANTIC_OBJECTS,
492
+ HOME_MEMORY_MIN_KB,
493
+ addBoundedKnxAiNotification,
494
+ addBoundedKnxAiObservation,
495
+ buildKnxAiHomeMemoryMarkdown,
496
+ buildKnxAiProactiveFallback,
497
+ clampHomeMemoryKb,
498
+ classifyKnxAiOpenState,
499
+ createEmptyKnxAiHomeMemory,
500
+ enrichKnxAiHomeCatalog,
501
+ inferKnxAiHomeSemantic,
502
+ isKnxAiQuietTime,
503
+ normalizeKnxAiHomeMemory,
504
+ normalizeHomeLanguage,
505
+ parseKnxAiHomeMemoryMarkdown,
506
+ updateKnxAiCoverHabit
507
+ }
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "engines": {
4
4
  "node": ">=20.18.1"
5
5
  },
6
- "version": "6.2.0",
6
+ "version": "6.2.2",
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, 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/",
@@ -21,7 +21,7 @@
21
21
  "dns-sync": "0.2.1",
22
22
  "google-translate-tts": "^0.3.0",
23
23
  "js-yaml": "4.2.0",
24
- "knxultimate": "6.0.1",
24
+ "knxultimate": "6.0.2",
25
25
  "lodash": "4.18.1",
26
26
  "mqtt": "^5.15.1",
27
27
  "node-color-log": "12.0.1",