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.
@@ -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
- return `${item.ga} | dpt ${dpt} | role ${role} | ${label}${valueOptions ? ` | values ${valueOptions}` : ''}`
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
- Die Editor-Bereiche verwenden dieselben vertikalen Tabs auf der linken Seite wie die Matter-Nodes. **Schnelleinrichtung** enthält nur die üblichen KI-Einstellungen (Aktivierung, Anbieter, Zugangsdaten, Modell, KNX-Zustandsabfrage/Aktorsteuerung und Bestätigung); technische Parameter sind thematisch in den übrigen Tabs gruppiert.
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/Zähler zurücksetzen
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.