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.
- package/CHANGELOG.md +14 -4
- package/examples/KNX AI - Telegrambot Direct Chat.json +18 -0
- package/nodes/knxUltimateAI.html +361 -251
- package/nodes/knxUltimateAI.js +491 -17
- package/nodes/locales/de/knxUltimateAI.html +50 -31
- package/nodes/locales/de/knxUltimateAI.json +27 -9
- package/nodes/locales/en/knxUltimateAI.html +54 -30
- package/nodes/locales/en/knxUltimateAI.json +27 -9
- package/nodes/locales/es/knxUltimateAI.html +50 -31
- package/nodes/locales/es/knxUltimateAI.json +27 -9
- package/nodes/locales/fr/knxUltimateAI.html +50 -31
- package/nodes/locales/fr/knxUltimateAI.json +27 -9
- package/nodes/locales/it/knxUltimateAI.html +54 -30
- package/nodes/locales/it/knxUltimateAI.json +27 -9
- package/nodes/locales/zh-CN/knxUltimateAI.html +50 -31
- package/nodes/locales/zh-CN/knxUltimateAI.json +27 -9
- package/nodes/utils/knxAiHomeMemory.js +507 -0
- package/package.json +2 -2
|
@@ -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.
|
|
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.
|
|
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",
|