node-red-contrib-knx-ultimate 6.2.0 → 6.2.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.
- package/CHANGELOG.md +5 -0
- package/examples/KNX AI - Telegrambot Direct Chat.json +18 -0
- package/nodes/knxUltimateAI.html +186 -76
- package/nodes/knxUltimateAI.js +477 -3
- package/nodes/locales/de/knxUltimateAI.html +43 -2
- package/nodes/locales/de/knxUltimateAI.json +27 -9
- package/nodes/locales/en/knxUltimateAI.html +47 -2
- package/nodes/locales/en/knxUltimateAI.json +27 -9
- package/nodes/locales/es/knxUltimateAI.html +43 -2
- package/nodes/locales/es/knxUltimateAI.json +27 -9
- package/nodes/locales/fr/knxUltimateAI.html +43 -2
- package/nodes/locales/fr/knxUltimateAI.json +27 -9
- package/nodes/locales/it/knxUltimateAI.html +47 -2
- package/nodes/locales/it/knxUltimateAI.json +27 -9
- package/nodes/locales/zh-CN/knxUltimateAI.html +43 -2
- package/nodes/locales/zh-CN/knxUltimateAI.json +27 -9
- package/nodes/utils/knxAiHomeMemory.js +507 -0
- package/package.json +1 -1
package/nodes/knxUltimateAI.js
CHANGED
|
@@ -5,6 +5,23 @@ const fs = require('fs')
|
|
|
5
5
|
const path = require('path')
|
|
6
6
|
const { spawn } = require('child_process')
|
|
7
7
|
const { getRequestAccessToken, normalizeAuthFromAccessTokenQuery } = require('./utils/httpAdminAccessToken')
|
|
8
|
+
const {
|
|
9
|
+
HOME_MEMORY_MAX_EDUCATION_CHARS,
|
|
10
|
+
HOME_MEMORY_MAX_SEMANTIC_OBJECTS,
|
|
11
|
+
addBoundedKnxAiNotification,
|
|
12
|
+
addBoundedKnxAiObservation,
|
|
13
|
+
buildKnxAiHomeMemoryMarkdown,
|
|
14
|
+
buildKnxAiProactiveFallback,
|
|
15
|
+
clampHomeMemoryKb,
|
|
16
|
+
classifyKnxAiOpenState,
|
|
17
|
+
createEmptyKnxAiHomeMemory,
|
|
18
|
+
enrichKnxAiHomeCatalog,
|
|
19
|
+
isKnxAiQuietTime,
|
|
20
|
+
normalizeKnxAiHomeMemory,
|
|
21
|
+
normalizeHomeLanguage,
|
|
22
|
+
parseKnxAiHomeMemoryMarkdown,
|
|
23
|
+
updateKnxAiCoverHabit
|
|
24
|
+
} = require('./utils/knxAiHomeMemory')
|
|
8
25
|
let googleTranslateTTS = null
|
|
9
26
|
try {
|
|
10
27
|
googleTranslateTTS = require('google-translate-tts')
|
|
@@ -5036,6 +5053,14 @@ module.exports = function (RED) {
|
|
|
5036
5053
|
node.chatAdapterPreset = String(config.chatAdapterPreset || 'none')
|
|
5037
5054
|
node.chatInputCode = String(config.chatInputCode || '')
|
|
5038
5055
|
node.chatOutputCode = String(config.chatOutputCode || '')
|
|
5056
|
+
node.proactiveEnabled = config.proactiveEnabled !== undefined ? coerceBoolean(config.proactiveEnabled) : false
|
|
5057
|
+
node.proactiveRecipient = String(config.proactiveRecipient || '').trim()
|
|
5058
|
+
node.proactiveOpenMinutes = Math.max(1, Math.min(1440, Number(config.proactiveOpenMinutes) || 120))
|
|
5059
|
+
node.proactiveCooldownMinutes = Math.max(5, Math.min(10080, Number(config.proactiveCooldownMinutes) || 360))
|
|
5060
|
+
node.proactiveQuietStart = String(config.proactiveQuietStart || '23:00').trim()
|
|
5061
|
+
node.proactiveQuietEnd = String(config.proactiveQuietEnd || '07:00').trim()
|
|
5062
|
+
node.homeMemoryMaxKb = clampHomeMemoryKb(config.homeMemoryMaxKb)
|
|
5063
|
+
node.aiEducation = String(config.aiEducation || '').slice(0, HOME_MEMORY_MAX_EDUCATION_CHARS)
|
|
5039
5064
|
|
|
5040
5065
|
const pushStatus = (status) => {
|
|
5041
5066
|
if (!status) return
|
|
@@ -5122,6 +5147,16 @@ module.exports = function (RED) {
|
|
|
5122
5147
|
node._gaLabelCsvCache = { ref: null, map: {} }
|
|
5123
5148
|
node._busConnectionWatchTimer = null
|
|
5124
5149
|
node._historyDiskLastPruneAt = 0
|
|
5150
|
+
node._homeMemory = createEmptyKnxAiHomeMemory()
|
|
5151
|
+
node._homeMemoryWriteTimer = null
|
|
5152
|
+
node._homeMemoryPeriodicTimer = null
|
|
5153
|
+
node._proactiveCheckTimer = null
|
|
5154
|
+
node._proactiveStates = new Map()
|
|
5155
|
+
node._proactiveInFlight = new Set()
|
|
5156
|
+
node._proactiveGlobalSentAt = []
|
|
5157
|
+
node._homeCatalogByGa = null
|
|
5158
|
+
node._homeCatalogSnapshotRef = null
|
|
5159
|
+
node._closing = false
|
|
5125
5160
|
node._busConnectionState = (node.serverKNX && typeof node.serverKNX.linkStatus === 'string')
|
|
5126
5161
|
? String(node.serverKNX.linkStatus).toLowerCase()
|
|
5127
5162
|
: 'unknown'
|
|
@@ -6234,6 +6269,7 @@ module.exports = function (RED) {
|
|
|
6234
6269
|
const wantsFunctionNodeSourceContext = node.llmIncludeFlowContext && shouldIncludeFunctionNodeSourceContext(question)
|
|
6235
6270
|
const areasSnapshot = buildAreasSnapshot({ summary })
|
|
6236
6271
|
const areasContext = buildAreasPromptContext(areasSnapshot)
|
|
6272
|
+
const homeMemoryContext = getHomeMemoryPromptContext({ maxChars: compactMode ? 2200 : 6000 })
|
|
6237
6273
|
const summaryForPrompt = buildLlmSummarySnapshot(summary)
|
|
6238
6274
|
const summaryText = truncatePromptText(safeStringify(summaryForPrompt), compactMode ? 4000 : 10000)
|
|
6239
6275
|
const lines = recent.map(t => {
|
|
@@ -6315,6 +6351,8 @@ module.exports = function (RED) {
|
|
|
6315
6351
|
'',
|
|
6316
6352
|
areasContext || '',
|
|
6317
6353
|
areasContext ? '' : '',
|
|
6354
|
+
homeMemoryContext || '',
|
|
6355
|
+
homeMemoryContext ? '' : '',
|
|
6318
6356
|
flowContext ? 'Node-RED context:' : '',
|
|
6319
6357
|
flowContext || '',
|
|
6320
6358
|
flowContext ? '' : '',
|
|
@@ -6355,10 +6393,10 @@ module.exports = function (RED) {
|
|
|
6355
6393
|
if (node._gaCatalogCache && node._gaCatalogCache.ref === csv && node._gaCatalogCache.roleOverridesKey === roleOverridesKey && Array.isArray(node._gaCatalogCache.snapshot)) {
|
|
6356
6394
|
return node._gaCatalogCache.snapshot
|
|
6357
6395
|
}
|
|
6358
|
-
const snapshot = applyGaRoleOverridesToCatalog({
|
|
6396
|
+
const snapshot = enrichKnxAiHomeCatalog(applyGaRoleOverridesToCatalog({
|
|
6359
6397
|
catalog: buildGaCatalogFromCsv(csv),
|
|
6360
6398
|
roleOverrides
|
|
6361
|
-
})
|
|
6399
|
+
}))
|
|
6362
6400
|
node._gaCatalogCache = { ref: csv, roleOverridesKey, snapshot }
|
|
6363
6401
|
return snapshot
|
|
6364
6402
|
}
|
|
@@ -6386,6 +6424,147 @@ module.exports = function (RED) {
|
|
|
6386
6424
|
|
|
6387
6425
|
const getHistoryArchiveFile = (dayKey) => path.join(getHistoryArchiveDir(), `${String(dayKey || '').trim() || formatArchiveDayKey(Date.now())}.jsonl`)
|
|
6388
6426
|
|
|
6427
|
+
const getHomeMemoryFile = () => {
|
|
6428
|
+
const baseDir = (node.serverKNX && node.serverKNX.userDir)
|
|
6429
|
+
? node.serverKNX.userDir
|
|
6430
|
+
: path.join(RED.settings.userDir, 'knxultimatestorage')
|
|
6431
|
+
return path.join(baseDir, 'knxai', 'memory', `knxai-home-memory-${node.id}.md`)
|
|
6432
|
+
}
|
|
6433
|
+
|
|
6434
|
+
const cleanupHomeMemoryTempFiles = () => {
|
|
6435
|
+
try {
|
|
6436
|
+
const filePath = getHomeMemoryFile()
|
|
6437
|
+
const dirPath = path.dirname(filePath)
|
|
6438
|
+
if (!fs.existsSync(dirPath)) return
|
|
6439
|
+
const prefix = `${path.basename(filePath)}.tmp-`
|
|
6440
|
+
fs.readdirSync(dirPath)
|
|
6441
|
+
.filter(name => String(name).startsWith(prefix))
|
|
6442
|
+
.forEach(name => {
|
|
6443
|
+
try { fs.unlinkSync(path.join(dirPath, name)) } catch (error) { /* ignore */ }
|
|
6444
|
+
})
|
|
6445
|
+
} catch (error) {
|
|
6446
|
+
try { node.sysLogger?.warn(`KNX AI home memory temporary-file cleanup error: ${error.message || error}`) } catch (logError) { /* ignore */ }
|
|
6447
|
+
}
|
|
6448
|
+
}
|
|
6449
|
+
|
|
6450
|
+
const getHomeCatalogMap = () => {
|
|
6451
|
+
const snapshot = getGaCatalogSnapshot()
|
|
6452
|
+
if (node._homeCatalogSnapshotRef === snapshot && node._homeCatalogByGa instanceof Map) return node._homeCatalogByGa
|
|
6453
|
+
node._homeCatalogSnapshotRef = snapshot
|
|
6454
|
+
node._homeCatalogByGa = new Map(snapshot.map(item => [String(item && item.ga ? item.ga : '').trim(), item]))
|
|
6455
|
+
return node._homeCatalogByGa
|
|
6456
|
+
}
|
|
6457
|
+
|
|
6458
|
+
const synchronizeHomeMemorySemanticObjects = () => {
|
|
6459
|
+
const semanticObjects = getGaCatalogSnapshot()
|
|
6460
|
+
.filter(item => item && item.semantic && item.semantic.kind !== 'unknown')
|
|
6461
|
+
.sort((a, b) => Number(b.semantic.confidence || 0) - Number(a.semantic.confidence || 0))
|
|
6462
|
+
.slice(0, HOME_MEMORY_MAX_SEMANTIC_OBJECTS)
|
|
6463
|
+
.map(item => ({
|
|
6464
|
+
ga: item.ga,
|
|
6465
|
+
dpt: item.dpt,
|
|
6466
|
+
label: item.label || item.etsName || item.ga,
|
|
6467
|
+
kind: item.semantic.kind,
|
|
6468
|
+
area: item.semantic.area || '',
|
|
6469
|
+
role: item.role || 'neutral',
|
|
6470
|
+
confidence: Number(item.semantic.confidence || 0)
|
|
6471
|
+
}))
|
|
6472
|
+
node._homeMemory.semanticObjects = semanticObjects
|
|
6473
|
+
node._homeMemory.updatedAt = new Date().toISOString()
|
|
6474
|
+
}
|
|
6475
|
+
|
|
6476
|
+
const persistHomeMemoryNow = () => {
|
|
6477
|
+
try {
|
|
6478
|
+
synchronizeHomeMemorySemanticObjects()
|
|
6479
|
+
const rendered = buildKnxAiHomeMemoryMarkdown({
|
|
6480
|
+
memory: node._homeMemory,
|
|
6481
|
+
education: node.aiEducation,
|
|
6482
|
+
maxKb: node.homeMemoryMaxKb
|
|
6483
|
+
})
|
|
6484
|
+
node._homeMemory = rendered.memory
|
|
6485
|
+
const filePath = getHomeMemoryFile()
|
|
6486
|
+
const dirPath = path.dirname(filePath)
|
|
6487
|
+
if (!ensureDirectorySync(dirPath)) throw new Error(`Unable to create ${dirPath}`)
|
|
6488
|
+
const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`
|
|
6489
|
+
try {
|
|
6490
|
+
fs.writeFileSync(tempPath, rendered.markdown, 'utf8')
|
|
6491
|
+
fs.renameSync(tempPath, filePath)
|
|
6492
|
+
} catch (error) {
|
|
6493
|
+
try { if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath) } catch (cleanupError) { /* ignore */ }
|
|
6494
|
+
throw error
|
|
6495
|
+
}
|
|
6496
|
+
return {
|
|
6497
|
+
filePath,
|
|
6498
|
+
bytes: rendered.bytes,
|
|
6499
|
+
maxBytes: rendered.maxBytes
|
|
6500
|
+
}
|
|
6501
|
+
} catch (error) {
|
|
6502
|
+
try { node.sysLogger?.warn(`KNX AI home memory write error: ${error.message || error}`) } catch (logError) { /* ignore */ }
|
|
6503
|
+
return null
|
|
6504
|
+
}
|
|
6505
|
+
}
|
|
6506
|
+
|
|
6507
|
+
const scheduleHomeMemoryPersist = ({ immediate = false } = {}) => {
|
|
6508
|
+
if (node._homeMemoryWriteTimer) {
|
|
6509
|
+
clearTimeout(node._homeMemoryWriteTimer)
|
|
6510
|
+
node._homeMemoryWriteTimer = null
|
|
6511
|
+
}
|
|
6512
|
+
if (immediate) return persistHomeMemoryNow()
|
|
6513
|
+
node._homeMemoryWriteTimer = setTimeout(() => {
|
|
6514
|
+
node._homeMemoryWriteTimer = null
|
|
6515
|
+
persistHomeMemoryNow()
|
|
6516
|
+
}, 1500)
|
|
6517
|
+
return null
|
|
6518
|
+
}
|
|
6519
|
+
|
|
6520
|
+
const loadHomeMemoryFromDisk = () => {
|
|
6521
|
+
const filePath = getHomeMemoryFile()
|
|
6522
|
+
try {
|
|
6523
|
+
cleanupHomeMemoryTempFiles()
|
|
6524
|
+
if (!fs.existsSync(filePath)) {
|
|
6525
|
+
node._homeMemory = createEmptyKnxAiHomeMemory()
|
|
6526
|
+
return scheduleHomeMemoryPersist({ immediate: true })
|
|
6527
|
+
}
|
|
6528
|
+
const stat = fs.statSync(filePath)
|
|
6529
|
+
const absoluteReadLimit = 4 * 1024 * 1024
|
|
6530
|
+
if (Number(stat.size || 0) > absoluteReadLimit) {
|
|
6531
|
+
throw new Error(`memory file exceeds the safe read limit (${absoluteReadLimit} bytes)`)
|
|
6532
|
+
}
|
|
6533
|
+
node._homeMemory = normalizeKnxAiHomeMemory(parseKnxAiHomeMemoryMarkdown(fs.readFileSync(filePath, 'utf8')))
|
|
6534
|
+
return scheduleHomeMemoryPersist({ immediate: true })
|
|
6535
|
+
} catch (error) {
|
|
6536
|
+
node._homeMemory = createEmptyKnxAiHomeMemory()
|
|
6537
|
+
try { node.sysLogger?.warn(`KNX AI home memory load error: ${error.message || error}`) } catch (logError) { /* ignore */ }
|
|
6538
|
+
return scheduleHomeMemoryPersist({ immediate: true })
|
|
6539
|
+
}
|
|
6540
|
+
}
|
|
6541
|
+
|
|
6542
|
+
const getHomeMemoryPromptContext = ({ maxChars = 6000 } = {}) => {
|
|
6543
|
+
const memory = normalizeKnxAiHomeMemory(node._homeMemory)
|
|
6544
|
+
const education = String(node.aiEducation || '').trim().slice(0, HOME_MEMORY_MAX_EDUCATION_CHARS)
|
|
6545
|
+
const habitLines = memory.habits.slice(-20).map(item => {
|
|
6546
|
+
return `- ${item.label || item.ga}: average open ${Number(item.averageMinutes || 0).toFixed(1)} min (${Number(item.samples || 0)} samples), last ${Number(item.lastMinutes || 0).toFixed(1)} min`
|
|
6547
|
+
})
|
|
6548
|
+
const observationLines = memory.observations.slice(-20).map(item => {
|
|
6549
|
+
return `- ${item.at || ''} ${item.label || item.ga || ''}: ${item.event || item.value || item.type || ''}`
|
|
6550
|
+
})
|
|
6551
|
+
const educationContext = [
|
|
6552
|
+
'USER-MANAGED AI EDUCATION (authoritative; never rewrite or contradict it):',
|
|
6553
|
+
education || '(none)'
|
|
6554
|
+
].join('\n')
|
|
6555
|
+
const learnedContext = [
|
|
6556
|
+
'BOUNDED LEARNED HOME MEMORY:',
|
|
6557
|
+
habitLines.length ? habitLines.join('\n') : '(no stable habits learned yet)',
|
|
6558
|
+
observationLines.length ? `\nRecent significant observations:\n${observationLines.join('\n')}` : ''
|
|
6559
|
+
].join('\n')
|
|
6560
|
+
const targetChars = Math.max(500, Number(maxChars) || 6000)
|
|
6561
|
+
const remainingChars = Math.max(0, targetChars - educationContext.length - 2)
|
|
6562
|
+
return [
|
|
6563
|
+
educationContext,
|
|
6564
|
+
remainingChars > 0 ? truncatePromptText(learnedContext, remainingChars) : ''
|
|
6565
|
+
].filter(Boolean).join('\n\n')
|
|
6566
|
+
}
|
|
6567
|
+
|
|
6389
6568
|
const pruneHistoryArchiveFiles = ({ force = false } = {}) => {
|
|
6390
6569
|
if (node.historyStoreToDisk !== true) return
|
|
6391
6570
|
const retentionDays = Math.max(1, Math.round(Number.isFinite(Number(node.historyStoreRetentionDays)) ? Number(node.historyStoreRetentionDays) : 1))
|
|
@@ -8593,11 +8772,15 @@ module.exports = function (RED) {
|
|
|
8593
8772
|
const role = String(item && item.role ? item.role : 'neutral').trim()
|
|
8594
8773
|
const dpt = String(item && item.dpt ? item.dpt : '').trim() || '?'
|
|
8595
8774
|
const label = String(item && item.label ? item.label : item && item.ga ? item.ga : '').trim()
|
|
8775
|
+
const semantic = item && item.semantic && typeof item.semantic === 'object' ? item.semantic : {}
|
|
8596
8776
|
const valueOptions = (Array.isArray(item && item.valueOptions) ? item.valueOptions : [])
|
|
8597
8777
|
.slice(0, 20)
|
|
8598
8778
|
.map(option => `${option.value}=${option.label}`)
|
|
8599
8779
|
.join(', ')
|
|
8600
|
-
|
|
8780
|
+
const semanticText = semantic.kind && semantic.kind !== 'unknown'
|
|
8781
|
+
? ` | semantic ${semantic.kind}${semantic.area ? `/${semantic.area}` : ''} confidence=${Number(semantic.confidence || 0).toFixed(2)}`
|
|
8782
|
+
: ''
|
|
8783
|
+
return `${item.ga} | dpt ${dpt} | role ${role} | ${label}${semanticText}${valueOptions ? ` | values ${valueOptions}` : ''}`
|
|
8601
8784
|
})
|
|
8602
8785
|
const conversationLines = history.flatMap(turn => [
|
|
8603
8786
|
`User: ${turn.question}`,
|
|
@@ -8628,6 +8811,8 @@ module.exports = function (RED) {
|
|
|
8628
8811
|
history.length ? 'RECENT CONVERSATION:' : '',
|
|
8629
8812
|
history.length ? conversationLines.join('\n') : '',
|
|
8630
8813
|
history.length ? '' : '',
|
|
8814
|
+
getHomeMemoryPromptContext({ maxChars: 6000 }),
|
|
8815
|
+
'',
|
|
8631
8816
|
analysisContext,
|
|
8632
8817
|
'',
|
|
8633
8818
|
`AVAILABLE KNX OBJECTS (showing ${Math.min(catalog.length, gaLimit)} of ${catalog.length}; every exact object may be read, but only role command may be written):`,
|
|
@@ -9136,6 +9321,267 @@ module.exports = function (RED) {
|
|
|
9136
9321
|
node._gaState.set(ga, state)
|
|
9137
9322
|
}
|
|
9138
9323
|
|
|
9324
|
+
const rememberHomeOwner = ({ sessionId, language } = {}) => {
|
|
9325
|
+
try {
|
|
9326
|
+
const normalizedSessionId = String(sessionId || '').trim()
|
|
9327
|
+
if (normalizedSessionId && normalizedSessionId !== 'default') {
|
|
9328
|
+
node._homeMemory.ownerSessionId = normalizedSessionId
|
|
9329
|
+
}
|
|
9330
|
+
if (language) node._homeMemory.ownerLanguage = normalizeHomeLanguage(language)
|
|
9331
|
+
scheduleHomeMemoryPersist()
|
|
9332
|
+
} catch (error) {
|
|
9333
|
+
try { node.sysLogger?.warn(`KNX AI home owner memory error: ${error.message || error}`) } catch (logError) { /* ignore */ }
|
|
9334
|
+
}
|
|
9335
|
+
}
|
|
9336
|
+
|
|
9337
|
+
const recordProactiveObservation = ({ catalogItem, telegram, event }) => {
|
|
9338
|
+
const semantic = catalogItem && catalogItem.semantic ? catalogItem.semantic : {}
|
|
9339
|
+
node._homeMemory = addBoundedKnxAiObservation(node._homeMemory, {
|
|
9340
|
+
at: new Date(Number(telegram.ts || nowMs())).toISOString(),
|
|
9341
|
+
type: 'semantic_state_change',
|
|
9342
|
+
event,
|
|
9343
|
+
ga: catalogItem.ga,
|
|
9344
|
+
dpt: catalogItem.dpt,
|
|
9345
|
+
label: catalogItem.label || telegram.devicename || catalogItem.ga,
|
|
9346
|
+
kind: semantic.kind || '',
|
|
9347
|
+
area: semantic.area || '',
|
|
9348
|
+
value: normalizeValueForCompare(telegram.payload)
|
|
9349
|
+
})
|
|
9350
|
+
scheduleHomeMemoryPersist()
|
|
9351
|
+
}
|
|
9352
|
+
|
|
9353
|
+
const processProactiveTelegram = (telegram) => {
|
|
9354
|
+
if (!telegram || !telegram.destination) return
|
|
9355
|
+
const catalogItem = getHomeCatalogMap().get(String(telegram.destination).trim())
|
|
9356
|
+
if (!catalogItem || !catalogItem.semantic) return
|
|
9357
|
+
const openState = classifyKnxAiOpenState({
|
|
9358
|
+
semantic: catalogItem.semantic,
|
|
9359
|
+
dpt: telegram.dpt || catalogItem.dpt,
|
|
9360
|
+
payload: telegram.payload,
|
|
9361
|
+
valueOptions: catalogItem.valueOptions
|
|
9362
|
+
})
|
|
9363
|
+
if (!openState || Number(openState.confidence || 0) < 0.7) return
|
|
9364
|
+
const ga = String(catalogItem.ga || telegram.destination).trim()
|
|
9365
|
+
const now = Number(telegram.ts || nowMs())
|
|
9366
|
+
const previous = node._proactiveStates.get(ga)
|
|
9367
|
+
if (openState.open) {
|
|
9368
|
+
if (previous && previous.open === true) {
|
|
9369
|
+
previous.lastSeenAt = now
|
|
9370
|
+
previous.value = openState.value
|
|
9371
|
+
node._proactiveStates.set(ga, previous)
|
|
9372
|
+
return
|
|
9373
|
+
}
|
|
9374
|
+
const lastNotification = normalizeKnxAiHomeMemory(node._homeMemory).notifications
|
|
9375
|
+
.filter(item => item && item.ga === ga)
|
|
9376
|
+
.sort((a, b) => String(a.at || '').localeCompare(String(b.at || '')))
|
|
9377
|
+
.pop()
|
|
9378
|
+
node._proactiveStates.set(ga, {
|
|
9379
|
+
ga,
|
|
9380
|
+
open: true,
|
|
9381
|
+
openedAt: now,
|
|
9382
|
+
lastSeenAt: now,
|
|
9383
|
+
lastSentAt: lastNotification ? Date.parse(lastNotification.at || '') || 0 : 0,
|
|
9384
|
+
value: openState.value,
|
|
9385
|
+
confidence: openState.confidence,
|
|
9386
|
+
catalogItem
|
|
9387
|
+
})
|
|
9388
|
+
recordProactiveObservation({
|
|
9389
|
+
catalogItem,
|
|
9390
|
+
telegram,
|
|
9391
|
+
event: openState.reason || 'opened'
|
|
9392
|
+
})
|
|
9393
|
+
return
|
|
9394
|
+
}
|
|
9395
|
+
if (previous && previous.open === true) {
|
|
9396
|
+
const durationMinutes = Math.max(0, (now - Number(previous.openedAt || now)) / 60000)
|
|
9397
|
+
node._homeMemory = updateKnxAiCoverHabit(node._homeMemory, {
|
|
9398
|
+
ga,
|
|
9399
|
+
label: catalogItem.label || ga,
|
|
9400
|
+
area: catalogItem.semantic.area || '',
|
|
9401
|
+
durationMinutes,
|
|
9402
|
+
at: new Date(now).toISOString()
|
|
9403
|
+
})
|
|
9404
|
+
recordProactiveObservation({
|
|
9405
|
+
catalogItem,
|
|
9406
|
+
telegram,
|
|
9407
|
+
event: `${openState.reason || 'closed'} after ${durationMinutes.toFixed(1)} minutes`
|
|
9408
|
+
})
|
|
9409
|
+
}
|
|
9410
|
+
node._proactiveStates.set(ga, {
|
|
9411
|
+
ga,
|
|
9412
|
+
open: false,
|
|
9413
|
+
openedAt: 0,
|
|
9414
|
+
lastSeenAt: now,
|
|
9415
|
+
lastSentAt: previous ? Number(previous.lastSentAt || 0) : 0,
|
|
9416
|
+
value: openState.value,
|
|
9417
|
+
confidence: openState.confidence,
|
|
9418
|
+
catalogItem
|
|
9419
|
+
})
|
|
9420
|
+
}
|
|
9421
|
+
|
|
9422
|
+
const createProactiveNotificationText = async ({ state, durationMinutes, language }) => {
|
|
9423
|
+
const label = state.catalogItem.label || state.ga
|
|
9424
|
+
const fallback = buildKnxAiProactiveFallback({ language, label, durationMinutes })
|
|
9425
|
+
const hasAuthoritativeEducation = String(node.aiEducation || '').trim() !== ''
|
|
9426
|
+
if (node.llmEnabled !== true) {
|
|
9427
|
+
return hasAuthoritativeEducation
|
|
9428
|
+
? { notify: false, content: '' }
|
|
9429
|
+
: { notify: true, content: fallback }
|
|
9430
|
+
}
|
|
9431
|
+
try {
|
|
9432
|
+
const ret = await callLLMChat({
|
|
9433
|
+
systemPrompt: [
|
|
9434
|
+
'You decide whether to send one concise proactive smart-home notification.',
|
|
9435
|
+
`Use language ${normalizeHomeLanguage(language)}.`,
|
|
9436
|
+
'Return JSON only with exactly: {"notify":boolean,"message":"text"}.',
|
|
9437
|
+
'Set notify=false when the authoritative user-managed AI Education says this condition is normal, allowed, unwanted, or should not generate a notification.',
|
|
9438
|
+
'When notify=false, set message to an empty string.',
|
|
9439
|
+
'Do not claim that a KNX command was sent or that an actuator changed.',
|
|
9440
|
+
'The message must not contain Markdown, lists, addresses, DPTs, or technical details.',
|
|
9441
|
+
'Explain the observed condition and end by asking whether the user wants help.',
|
|
9442
|
+
'The user-managed AI Education is authoritative.'
|
|
9443
|
+
].join('\n'),
|
|
9444
|
+
userContent: [
|
|
9445
|
+
getHomeMemoryPromptContext({ maxChars: 3500 }),
|
|
9446
|
+
'',
|
|
9447
|
+
`Observed object: ${label}`,
|
|
9448
|
+
`Semantic type: ${state.catalogItem.semantic.kind}`,
|
|
9449
|
+
`Semantic area: ${state.catalogItem.semantic.area || 'unknown'}`,
|
|
9450
|
+
`Condition duration: ${Math.max(1, Math.round(durationMinutes))} minutes`,
|
|
9451
|
+
'Return the JSON decision now.'
|
|
9452
|
+
].join('\n'),
|
|
9453
|
+
jsonSchema: {
|
|
9454
|
+
name: 'knx_ai_proactive_decision',
|
|
9455
|
+
strict: true,
|
|
9456
|
+
schema: {
|
|
9457
|
+
type: 'object',
|
|
9458
|
+
additionalProperties: false,
|
|
9459
|
+
properties: {
|
|
9460
|
+
notify: { type: 'boolean' },
|
|
9461
|
+
message: { type: 'string' }
|
|
9462
|
+
},
|
|
9463
|
+
required: ['notify', 'message']
|
|
9464
|
+
}
|
|
9465
|
+
},
|
|
9466
|
+
maxTokensOverride: 2000
|
|
9467
|
+
})
|
|
9468
|
+
const decision = extractJsonFragmentFromText(ret && ret.content)
|
|
9469
|
+
if (!decision || typeof decision !== 'object' || Array.isArray(decision) || typeof decision.notify !== 'boolean') {
|
|
9470
|
+
throw new Error('The proactive decision is not a valid JSON object')
|
|
9471
|
+
}
|
|
9472
|
+
if (decision.notify === false) return { notify: false, content: '' }
|
|
9473
|
+
const candidate = String(decision.message || '').trim()
|
|
9474
|
+
if (!candidate || candidate.length > 1200 || candidate.startsWith('{') || candidate.startsWith('```')) {
|
|
9475
|
+
return { notify: true, content: fallback }
|
|
9476
|
+
}
|
|
9477
|
+
return { notify: true, content: candidate }
|
|
9478
|
+
} catch (error) {
|
|
9479
|
+
try { node.sysLogger?.warn(`KNX AI proactive wording fallback: ${error.message || error}`) } catch (logError) { /* ignore */ }
|
|
9480
|
+
return hasAuthoritativeEducation
|
|
9481
|
+
? { notify: false, content: '' }
|
|
9482
|
+
: { notify: true, content: fallback }
|
|
9483
|
+
}
|
|
9484
|
+
}
|
|
9485
|
+
|
|
9486
|
+
const emitProactiveNotification = async ({ state, durationMinutes }) => {
|
|
9487
|
+
if (node._closing === true) return false
|
|
9488
|
+
const recipient = String(node.proactiveRecipient || node._homeMemory.ownerSessionId || '').trim()
|
|
9489
|
+
if (node.chatAdapterPreset === 'windkh-telegrambot' && !recipient) return false
|
|
9490
|
+
const language = normalizeHomeLanguage(node._homeMemory.ownerLanguage || node.llmDocsLanguage || 'en')
|
|
9491
|
+
const notification = await createProactiveNotificationText({ state, durationMinutes, language })
|
|
9492
|
+
if (!notification.notify) return 'suppressed'
|
|
9493
|
+
const content = notification.content
|
|
9494
|
+
if (node._closing === true) return false
|
|
9495
|
+
const syntheticInputMessage = {
|
|
9496
|
+
topic: 'proactive',
|
|
9497
|
+
payload: Object.assign({
|
|
9498
|
+
type: 'message',
|
|
9499
|
+
content: ''
|
|
9500
|
+
}, recipient ? { chatId: recipient } : {}),
|
|
9501
|
+
sessionId: recipient || 'proactive',
|
|
9502
|
+
language,
|
|
9503
|
+
knxAi: {
|
|
9504
|
+
type: 'proactive_observation',
|
|
9505
|
+
destination: state.ga
|
|
9506
|
+
}
|
|
9507
|
+
}
|
|
9508
|
+
const metadata = {
|
|
9509
|
+
type: 'proactive_notification',
|
|
9510
|
+
reason: 'open_too_long',
|
|
9511
|
+
destination: state.ga,
|
|
9512
|
+
dpt: state.catalogItem.dpt,
|
|
9513
|
+
label: state.catalogItem.label || state.ga,
|
|
9514
|
+
semantic: state.catalogItem.semantic,
|
|
9515
|
+
openedAt: new Date(state.openedAt).toISOString(),
|
|
9516
|
+
durationMinutes: Number(durationMinutes.toFixed(1)),
|
|
9517
|
+
recipient,
|
|
9518
|
+
sessionId: recipient || 'proactive',
|
|
9519
|
+
language,
|
|
9520
|
+
requiresConfirmationForCommands: true
|
|
9521
|
+
}
|
|
9522
|
+
const replyMessage = buildKnxAiReplyMessage({
|
|
9523
|
+
inputMessage: syntheticInputMessage,
|
|
9524
|
+
content,
|
|
9525
|
+
metadata
|
|
9526
|
+
})
|
|
9527
|
+
if (!sendKnxAiOutputs([null, null, replyMessage, null], syntheticInputMessage)) return false
|
|
9528
|
+
node._homeMemory = addBoundedKnxAiNotification(node._homeMemory, {
|
|
9529
|
+
at: new Date().toISOString(),
|
|
9530
|
+
type: 'proactive_notification',
|
|
9531
|
+
reason: 'open_too_long',
|
|
9532
|
+
ga: state.ga,
|
|
9533
|
+
dpt: state.catalogItem.dpt,
|
|
9534
|
+
label: state.catalogItem.label || state.ga,
|
|
9535
|
+
durationMinutes: Number(durationMinutes.toFixed(1)),
|
|
9536
|
+
recipient
|
|
9537
|
+
})
|
|
9538
|
+
rememberConversationTurn({
|
|
9539
|
+
sessionId: recipient || 'proactive',
|
|
9540
|
+
question: '[Proactive home observation]',
|
|
9541
|
+
reply: content
|
|
9542
|
+
})
|
|
9543
|
+
scheduleHomeMemoryPersist({ immediate: true })
|
|
9544
|
+
return true
|
|
9545
|
+
}
|
|
9546
|
+
|
|
9547
|
+
const checkProactiveHomeState = () => {
|
|
9548
|
+
if (node._closing === true || node.proactiveEnabled !== true) return
|
|
9549
|
+
if (isKnxAiQuietTime({
|
|
9550
|
+
date: new Date(),
|
|
9551
|
+
start: node.proactiveQuietStart,
|
|
9552
|
+
end: node.proactiveQuietEnd
|
|
9553
|
+
})) return
|
|
9554
|
+
const now = nowMs()
|
|
9555
|
+
const thresholdMs = node.proactiveOpenMinutes * 60 * 1000
|
|
9556
|
+
const cooldownMs = node.proactiveCooldownMinutes * 60 * 1000
|
|
9557
|
+
node._proactiveGlobalSentAt = node._proactiveGlobalSentAt.filter(ts => (now - ts) < (60 * 60 * 1000))
|
|
9558
|
+
if (node._proactiveGlobalSentAt.length >= 3) return
|
|
9559
|
+
const candidate = Array.from(node._proactiveStates.values())
|
|
9560
|
+
.filter(state => {
|
|
9561
|
+
if (!state || state.open !== true || node._proactiveInFlight.has(state.ga)) return false
|
|
9562
|
+
if ((now - Number(state.openedAt || now)) < thresholdMs) return false
|
|
9563
|
+
if (Number(state.lastSentAt || 0) > 0 && (now - Number(state.lastSentAt)) < cooldownMs) return false
|
|
9564
|
+
return true
|
|
9565
|
+
})
|
|
9566
|
+
.sort((a, b) => Number(a.openedAt || 0) - Number(b.openedAt || 0))[0]
|
|
9567
|
+
if (!candidate) return
|
|
9568
|
+
candidate.lastSentAt = now
|
|
9569
|
+
node._proactiveInFlight.add(candidate.ga)
|
|
9570
|
+
const durationMinutes = Math.max(1, (now - Number(candidate.openedAt || now)) / 60000)
|
|
9571
|
+
Promise.resolve(emitProactiveNotification({ state: candidate, durationMinutes }))
|
|
9572
|
+
.then(result => {
|
|
9573
|
+
if (result === true) node._proactiveGlobalSentAt.push(now)
|
|
9574
|
+
else if (result !== 'suppressed') candidate.lastSentAt = 0
|
|
9575
|
+
})
|
|
9576
|
+
.catch(error => {
|
|
9577
|
+
candidate.lastSentAt = 0
|
|
9578
|
+
try { node.sysLogger?.warn(`KNX AI proactive notification error: ${error.message || error}`) } catch (logError) { /* ignore */ }
|
|
9579
|
+
})
|
|
9580
|
+
.finally(() => {
|
|
9581
|
+
node._proactiveInFlight.delete(candidate.ga)
|
|
9582
|
+
})
|
|
9583
|
+
}
|
|
9584
|
+
|
|
9139
9585
|
// Called by knxUltimate-config.js
|
|
9140
9586
|
node.handleSend = (msg) => {
|
|
9141
9587
|
try {
|
|
@@ -9149,6 +9595,7 @@ module.exports = function (RED) {
|
|
|
9149
9595
|
trimHistory(now)
|
|
9150
9596
|
maybeEmitGAAnomalies(telegram)
|
|
9151
9597
|
maybeEmitOverallAnomaly(now)
|
|
9598
|
+
processProactiveTelegram(telegram)
|
|
9152
9599
|
scheduleRealtimeSummaryRebuild()
|
|
9153
9600
|
} catch (error) {
|
|
9154
9601
|
try { node.sysLogger?.error(`knxUltimateAI handleSend error: ${error.message || error}`) } catch (e) { /* ignore */ }
|
|
@@ -9174,6 +9621,11 @@ module.exports = function (RED) {
|
|
|
9174
9621
|
node._lastSummaryAt = 0
|
|
9175
9622
|
node._conversationSessions = new Map()
|
|
9176
9623
|
node._pendingKnxCommands = new Map()
|
|
9624
|
+
node._homeMemory = createEmptyKnxAiHomeMemory()
|
|
9625
|
+
node._proactiveStates = new Map()
|
|
9626
|
+
node._proactiveInFlight = new Set()
|
|
9627
|
+
node._proactiveGlobalSentAt = []
|
|
9628
|
+
scheduleHomeMemoryPersist({ immediate: true })
|
|
9177
9629
|
if (node._summaryRebuildTimer) {
|
|
9178
9630
|
clearTimeout(node._summaryRebuildTimer)
|
|
9179
9631
|
node._summaryRebuildTimer = null
|
|
@@ -9225,6 +9677,7 @@ module.exports = function (RED) {
|
|
|
9225
9677
|
const readCommands = preparedCommands.filter(command => command && command.event === 'GroupValue_Read')
|
|
9226
9678
|
const writeCommands = preparedCommands.filter(command => !command || command.event !== 'GroupValue_Read')
|
|
9227
9679
|
const language = resolveKnxAiLanguage(msg, node.llmDocsLanguage || 'en', question, ret.language)
|
|
9680
|
+
rememberHomeOwner({ sessionId, language })
|
|
9228
9681
|
const copy = getKnxAiConfirmationCopy(language)
|
|
9229
9682
|
const awaitingConfirmation = node.llmAllowKnxCommands &&
|
|
9230
9683
|
node.llmRequireCommandConfirmation &&
|
|
@@ -9542,8 +9995,16 @@ module.exports = function (RED) {
|
|
|
9542
9995
|
|
|
9543
9996
|
node.on('close', function (done) {
|
|
9544
9997
|
try {
|
|
9998
|
+
node._closing = true
|
|
9545
9999
|
if (node._timerEmit) clearInterval(node._timerEmit)
|
|
9546
10000
|
if (node._busConnectionWatchTimer) clearInterval(node._busConnectionWatchTimer)
|
|
10001
|
+
if (node._homeMemoryPeriodicTimer) clearInterval(node._homeMemoryPeriodicTimer)
|
|
10002
|
+
if (node._proactiveCheckTimer) clearInterval(node._proactiveCheckTimer)
|
|
10003
|
+
if (node._homeMemoryWriteTimer) {
|
|
10004
|
+
clearTimeout(node._homeMemoryWriteTimer)
|
|
10005
|
+
node._homeMemoryWriteTimer = null
|
|
10006
|
+
}
|
|
10007
|
+
persistHomeMemoryNow()
|
|
9547
10008
|
if (node._summaryRebuildTimer) {
|
|
9548
10009
|
clearTimeout(node._summaryRebuildTimer)
|
|
9549
10010
|
node._summaryRebuildTimer = null
|
|
@@ -9579,10 +10040,23 @@ module.exports = function (RED) {
|
|
|
9579
10040
|
try {
|
|
9580
10041
|
pruneHistoryArchiveFiles({ force: true })
|
|
9581
10042
|
loadRecentHistoryFromDisk()
|
|
10043
|
+
loadHomeMemoryFromDisk()
|
|
9582
10044
|
} catch (error) {
|
|
9583
10045
|
node.sysLogger?.warn(`KNX AI history startup error: ${error.message || error}`)
|
|
9584
10046
|
}
|
|
9585
10047
|
|
|
10048
|
+
if (node._homeMemoryPeriodicTimer) clearInterval(node._homeMemoryPeriodicTimer)
|
|
10049
|
+
node._homeMemoryPeriodicTimer = setInterval(() => {
|
|
10050
|
+
try { persistHomeMemoryNow() } catch (error) { /* persistHomeMemoryNow already guards */ }
|
|
10051
|
+
}, 15 * 60 * 1000)
|
|
10052
|
+
|
|
10053
|
+
if (node._proactiveCheckTimer) clearInterval(node._proactiveCheckTimer)
|
|
10054
|
+
node._proactiveCheckTimer = setInterval(() => {
|
|
10055
|
+
try { checkProactiveHomeState() } catch (error) {
|
|
10056
|
+
try { node.sysLogger?.warn(`KNX AI proactive check error: ${error.message || error}`) } catch (logError) { /* ignore */ }
|
|
10057
|
+
}
|
|
10058
|
+
}, 30 * 1000)
|
|
10059
|
+
|
|
9586
10060
|
if (node._busConnectionWatchTimer) clearInterval(node._busConnectionWatchTimer)
|
|
9587
10061
|
node._busConnectionWatchTimer = setInterval(() => {
|
|
9588
10062
|
pollBusConnectionStatus()
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script type="text/markdown" data-help-name="knxUltimateAI">
|
|
2
2
|
Dieser Node überwacht **alle KNX-Telegramme** des ausgewählten KNX-Ultimate-Gateways, erstellt Verkehrsstatistiken, erkennt Anomalien und kann optional ein LLM befragen.
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
Der Editor verwendet drei Hauptbereiche als Akkordeon: **KI-Assistent** enthält Einrichtung, Wissen/Kontext und Anbietergrenzen; **Gespräche & Zuhause** enthält Chat-Kanäle, proaktives Zuhause und begrenztes Gedächtnis; **KNX-Verkehrsanalyse** enthält Bus-Telegramme, Verlauf/Zusammenfassungen und Anomalien/Muster. Beim Öffnen eines Hauptbereichs werden alle zugehörigen Optionen gemeinsam angezeigt. Gespeicherte Feld-IDs und Werte bleiben unverändert.
|
|
5
5
|
|
|
6
6
|
## Ausgänge
|
|
7
7
|
1. **Zusammenfassung/Statistik** (`msg.payload` JSON)
|
|
@@ -14,7 +14,7 @@ Jede an den Ausgängen 3 und 4 ausgegebene Nachricht enthält außerdem eine Kop
|
|
|
14
14
|
## Befehle (Eingang)
|
|
15
15
|
Sende `msg.topic`:
|
|
16
16
|
- `summary` (oder leer): Summary sofort senden
|
|
17
|
-
- `reset`: internen Verlauf
|
|
17
|
+
- `reset`: internen Verlauf, Zähler und gelerntes Hausgedächtnis löschen; die KI-Erziehung bleibt unverändert
|
|
18
18
|
- `ask`: Frage an das konfigurierte LLM senden
|
|
19
19
|
- `confirm` / `cancel`: ausstehende KNX-Befehle ohne erneuten LLM-Aufruf bestätigen oder abbrechen
|
|
20
20
|
- `clear_chat`: Gesprächsspeicher der aktuellen Sitzung löschen
|
|
@@ -35,6 +35,40 @@ Der Tab **Chat-Adapter** lädt seine auswählbaren Zuordnungen aus `resources/KN
|
|
|
35
35
|
|
|
36
36
|
Die enthaltene Vorlage **windkh/node-red-contrib-telegrambot** folgt dem Receiver-/Sender-Vertrag des Pakets. Verbinden Sie einen `telegram receiver` direkt mit KNX AI und Ausgang 3 direkt mit einem `telegram sender`. Für Inline-Bestätigungsschaltflächen verbinden Sie zusätzlich einen als `callback_query` konfigurierten `telegram event` mit demselben KNX-AI-Eingang. Die Eingangszuordnung liest `msg.payload.content`, `msg.payload.chatId` und die Telegram-Sprache. Die Ausgangszuordnung erstellt `msg.payload.chatId`, `type` und `content` und ergänzt bei ausstehender Schreibbestätigung `options.reply_markup` aus `msg.knxAi.confirmationRequest`. Das Telegram-Paket bleibt eine separate optionale Abhängigkeit.
|
|
37
37
|
|
|
38
|
+
## Proaktive Hausintelligenz und begrenztes Gedächtnis
|
|
39
|
+
Der Unterbereich **Proaktives Zuhause & Gedächtnis** in **Gespräche & Zuhause** aktiviert proaktive Benachrichtigungen auf Wunsch des Benutzers. Aus ETS-Hierarchie, Namen, Rollen und DPTs erstellt der Node ein deterministisches semantisches Modell für Rollläden, Fenster, Türen, Licht, Temperatur, Klima, Anwesenheit und Alarme mit italienischen, englischen, deutschen, französischen, spanischen und chinesischen Begriffen. Der erste proaktive Detektor überwacht nur zuverlässig erkannte Nicht-Befehlszustände von Rollläden/Fenstern/Türen. Nach der konfigurierten Offenzeit und außerhalb der Ruhezeiten gibt Ausgang 3 eine lokalisierte Nachricht mit `msg.knxAi.type = "proactive_notification"` aus. Ausgang 4 wird niemals proaktiv verwendet und KNX wird nicht selbstständig verändert; eine spätere Benutzeranfrage durchläuft weiterhin die normale Validierung und Bestätigung.
|
|
40
|
+
|
|
41
|
+
Die letzte Chat-Sitzung wird als Eigentümer gespeichert; alternativ kann **Hauptempfänger / Chat-ID** sie ausdrücklich festlegen. Ein synthetisches `msg.inputMessage` bewahrt den Empfänger, damit der Telegram-Adapter eine spontane Nachricht senden kann. Cooldown und höchstens drei proaktive Nachrichten pro Stunde verhindern eine Nachrichtenflut.
|
|
42
|
+
|
|
43
|
+
Die gelernte Referenz wird beim Start aus `<userDir>/knxai/memory/knxai-home-memory-<node-id>.md` geladen, alle 15 Minuten atomar neu geschrieben und strikt auf konfigurierbare 64–1.024 KB begrenzt (standardmäßig 256 KB). Sie enthält höchstens 120 wichtige Beobachtungen, 80 aggregierte Gewohnheiten, 80 Benachrichtigungen und 300 semantische ETS-Objekte, niemals einen unbegrenzten Rohtelegrammstrom. Alte Einträge mit niedriger Priorität werden zuerst entfernt. **KI-Erziehung** ist auf 16.000 Zeichen begrenzt und stammt immer aus der Node-Konfiguration: Die KI darf sie als verbindliche Vorgabe lesen, aber weder ändern noch überschreiben. Ist eine Erziehung vorhanden, kann das LLM sie aber nicht auswerten, wird die mögliche Benachrichtigung unterdrückt, statt ihr möglicherweise zu widersprechen.
|
|
44
|
+
|
|
45
|
+
## Praktisches Konfigurationsbeispiel
|
|
46
|
+
Dieses Beispiel erstellt einen knappen Assistenten, der wichtige Öffnungen meldet, aber akzeptiert, dass der Rollladen im Büro offen bleiben darf:
|
|
47
|
+
|
|
48
|
+
| Editor-Feld | Beispielwert | Wirkung |
|
|
49
|
+
|---|---|---|
|
|
50
|
+
| **Proaktive Hausbenachrichtigungen aktivieren** (`proactiveEnabled`) | aktiv | Zuverlässig erkannte offene Rollladen-/Fenster-/Türzustände werden bewertet. |
|
|
51
|
+
| **Hauptempfänger / Chat-ID** (`proactiveRecipient`) | `123456789` | Spontane Nachrichten gehen an diesen Chat; leer bedeutet: letzte Ask-Sitzung merken. |
|
|
52
|
+
| **Nach offener Dauer benachrichtigen** (`proactiveOpenMinutes`) | `120` | Nach zwei Stunden wird eine mögliche Meldung bewertet. |
|
|
53
|
+
| **Ruhezeit Beginn / Ende** | `23:00` / `07:00` | Nachts werden keine proaktiven Nachrichten ausgegeben. |
|
|
54
|
+
| **Wiederholungs-Cooldown** (`proactiveCooldownMinutes`) | `360` | Dasselbe Objekt meldet sich sechs Stunden lang nicht erneut. |
|
|
55
|
+
| **Maximale Hausgedächtnis-Datei** (`homeMemoryMaxKb`) | `256` | Die Markdown-Referenz dieses Nodes bleibt unter 256 KB. |
|
|
56
|
+
|
|
57
|
+
Beispiel für **KI-Erziehung** (`aiEducation`):
|
|
58
|
+
|
|
59
|
+
```text
|
|
60
|
+
Nenne mich Alex und antworte in derselben Sprache wie ich.
|
|
61
|
+
Antworte kurz, außer ich bitte um technische Einzelheiten.
|
|
62
|
+
Der Büro-Rollladen darf tagsüber offen bleiben: benachrichtige mich nicht.
|
|
63
|
+
Melde andere Rollläden, Fenster oder Türen, die ungewöhnlich lange offen bleiben.
|
|
64
|
+
Wenn „Wohnzimmerlicht“ mehrdeutig ist, frage nach der gemeinten Leuchte.
|
|
65
|
+
Behaupte nie eine Aktoränderung, bevor ein KNX-Statusobjekt sie bestätigt.
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Damit kann Ausgang 3 nach 120 Minuten eine lokalisierte `proactive_notification` für den Wohnzimmer-Rollladen ausgeben, während eine Meldung für den Büro-Rollladen durch die Erziehung unterdrückt wird. Bittet Alex danach um das Schließen, erstellt KNX AI den exakten ETS-Befehl, behält aber Validierung und Bestätigung vor Ausgang 4 bei.
|
|
69
|
+
|
|
70
|
+
Verwenden Sie aussagekräftige ETS-Hierarchien und Objektnamen sowie korrekte Status-/Befehlsrollen. Die Erziehung personalisiert Entscheidungen und Formulierungen, kann aber keine Gruppenadresse erfinden, keinen DPT ändern und die KNX-Validierung nicht umgehen.
|
|
71
|
+
|
|
38
72
|
## Kurzer Ablauf: KNX-Steuerung
|
|
39
73
|
1. Importieren Sie die ETS-CSV in das Gateway und konfigurieren Sie LLM-Anbieter, Modell und Zugangsdaten.
|
|
40
74
|
2. Aktivieren Sie **LLM-Assistent** und **KNX-Zustände lesen und Aktoren steuern**; lassen Sie die Bestätigung aktiviert.
|
|
@@ -90,6 +124,13 @@ Hier sind alle Felder aufgeführt, wie sie im KNX-AI-Editor sichtbar sind.
|
|
|
90
124
|
- **Adapter-Vorlage**: Lädt ein Paar aus Ein- und Ausgangszuordnung aus der mitgelieferten Chat-Adapter-Datei. Die Auswahl ersetzt bewusst beide Textfelder; der Code bleibt danach bearbeitbar.
|
|
91
125
|
- **Eingangszuordnung (Chat → KNX AI)**: Synchrones JavaScript vor der Verarbeitung des Eingangsbefehls.
|
|
92
126
|
- **Ausgangszuordnung (KNX AI → Chat)**: Synchrones JavaScript ausschließlich für Nachrichten an Ausgang 3.
|
|
127
|
+
- **Proaktive Hausbenachrichtigungen aktivieren**: Optionaler Detektor für zuverlässig erkannte offene Rollladen-/Fenster-/Türzustände; er schreibt nie selbstständig auf KNX.
|
|
128
|
+
- **Hauptempfänger / Chat-ID**: Optionales Ziel für unaufgeforderte Chatnachrichten; andernfalls wird die letzte Ask-Sitzung gespeichert.
|
|
129
|
+
- **Nach offener Dauer benachrichtigen (Minuten)**: Schwelle, bevor eine proaktive Nachricht erwogen wird.
|
|
130
|
+
- **Ruhezeit Beginn / Ende**: Täglicher Zeitraum, in dem proaktive Nachrichten unterdrückt werden.
|
|
131
|
+
- **KI-Erziehung**: Verbindliche, ausschließlich vom Benutzer verwaltete Hinweise, die die KI lesen, aber nie ändern darf.
|
|
132
|
+
- **Wiederholungs-Cooldown (Minuten)**: Mindestintervall vor einer weiteren Meldung desselben Objekts.
|
|
133
|
+
- **Maximale Hausgedächtnis-Datei (KB)**: Harte Grenze von 64 bis 1.024 KB; standardmäßig 256 KB.
|
|
93
134
|
- Wenn das Festplattenarchiv aktiv ist, nutzt **Ask** standardmäßig dieses Archiv: explizite Datumsangaben/Zeitbereiche werden beachtet, sonst durchsucht der Assistent die letzten 24 Stunden plus aktuelle RAM-Events.
|
|
94
135
|
- **Include raw payload hex**: Rohe Hex-Payload im Prompt einfügen.
|
|
95
136
|
- **Node-RED-Projektinventar einbeziehen**: Nimmt das gesamte Node-RED-Projektinventar in den Prompt auf, einschließlich KNX-Nodes und anderer hilfreicher Nodes wie function/change/inject/template, wenn sie KNX-Logik oder Gruppenadressen enthalten.
|