node-red-contrib-knx-ultimate 6.3.14 → 6.3.16

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.
@@ -6,13 +6,13 @@ const path = require('path')
6
6
  const { spawn } = require('child_process')
7
7
  const { getRequestAccessToken, normalizeAuthFromAccessTokenQuery } = require('./utils/httpAdminAccessToken')
8
8
  const {
9
+ HOME_MEMORY_DEFAULT_KB,
9
10
  HOME_MEMORY_MAX_EDUCATION_CHARS,
10
11
  HOME_MEMORY_MAX_SEMANTIC_OBJECTS,
11
12
  addBoundedKnxAiNotification,
12
13
  addBoundedKnxAiObservation,
13
14
  buildKnxAiHomeMemoryMarkdown,
14
15
  buildKnxAiProactiveFallback,
15
- clampHomeMemoryKb,
16
16
  classifyKnxAiOpenState,
17
17
  createEmptyKnxAiHomeMemory,
18
18
  enrichKnxAiHomeCatalog,
@@ -22,6 +22,32 @@ const {
22
22
  parseKnxAiHomeMemoryMarkdown,
23
23
  updateKnxAiCoverHabit
24
24
  } = require('./utils/knxAiHomeMemory')
25
+ const {
26
+ CHAT_CONTEXT_MAX_BYTES,
27
+ addKnxAiCameraWatch,
28
+ addKnxAiChatTurn,
29
+ buildKnxAiChatContextMarkdown,
30
+ buildKnxAiChatPromptContext,
31
+ clearKnxAiChatSession,
32
+ conversationMapFromKnxAiChatContext,
33
+ createEmptyKnxAiChatContext,
34
+ listAllKnxAiCameraWatches,
35
+ listKnxAiCameraWatches,
36
+ normalizeKnxAiChatContext,
37
+ parseKnxAiChatContextMarkdown,
38
+ removeKnxAiCameraWatches
39
+ } = require('./utils/knxAiChatContext')
40
+ const {
41
+ buildKnxAiCameraNotificationText,
42
+ cameraWatchMatchesEvent,
43
+ getKnxAiCameraAdapterRegistry,
44
+ normalizeKnxAiCameraActions,
45
+ normalizeKnxAiCameraEvent,
46
+ normalizeKnxAiCameraImage,
47
+ normalizeKnxAiCameraRegistration,
48
+ normalizeSearchText,
49
+ resolveKnxAiCamera
50
+ } = require('./utils/knxAiCamera')
25
51
  let googleTranslateTTS = null
26
52
  try {
27
53
  googleTranslateTTS = require('google-translate-tts')
@@ -33,8 +59,73 @@ const coerceBoolean = (value) => (value === true || value === 'true')
33
59
 
34
60
  let adminEndpointsRegistered = false
35
61
  const aiRuntimeNodes = new Map()
62
+ const sharedKnxAiHomeMemoryStores = new Map()
63
+ const sharedKnxAiChatContextStores = new Map()
36
64
  const knxAiVueDistDir = path.join(__dirname, 'plugins', 'knxUltimateAI-vue')
37
65
 
66
+ const summarizeDetectedKnxAiCameraAdapters = ({ registry, node } = {}) => {
67
+ const sourceRegistry = registry || getKnxAiCameraAdapterRegistry()
68
+ const adapters = new Map(sourceRegistry && sourceRegistry.adapters instanceof Map ? sourceRegistry.adapters : [])
69
+ const providers = new Map(sourceRegistry && sourceRegistry.providers instanceof Map ? sourceRegistry.providers : [])
70
+ const cameras = new Map()
71
+
72
+ if (node && node._cameraAdapters instanceof Map) {
73
+ node._cameraAdapters.forEach((adapter, id) => adapters.set(String(id), adapter))
74
+ }
75
+ if (node && node._cameraProviders instanceof Map) {
76
+ node._cameraProviders.forEach((provider, id) => providers.set(String(id), provider))
77
+ }
78
+ if (node && node._cameraCatalog instanceof Map) {
79
+ node._cameraCatalog.forEach((camera, id) => cameras.set(String(id), camera))
80
+ }
81
+
82
+ return Array.from(adapters.entries()).map(([adapterId, value]) => {
83
+ const adapter = value && typeof value === 'object' ? value : {}
84
+ const matchingProviderIds = new Set()
85
+ providers.forEach((provider, providerId) => {
86
+ if (String(provider && provider.adapterId || '') === adapterId) matchingProviderIds.add(String(providerId))
87
+ })
88
+ let cameraCount = 0
89
+ cameras.forEach(camera => {
90
+ const cameraAdapterId = String(camera && camera.adapterId || '')
91
+ const cameraProviderId = String(camera && camera.providerId || '')
92
+ if (cameraAdapterId === adapterId || matchingProviderIds.has(cameraProviderId)) cameraCount += 1
93
+ })
94
+ return {
95
+ id: adapterId,
96
+ title: String(adapter.title || adapterId),
97
+ packageName: String(adapter.packageName || ''),
98
+ capabilities: Array.isArray(adapter.capabilities) ? adapter.capabilities.map(String) : [],
99
+ providerCount: matchingProviderIds.size,
100
+ cameraCount
101
+ }
102
+ }).sort((left, right) => left.title.localeCompare(right.title))
103
+ }
104
+
105
+ const bindSharedKnxAiState = ({ registry, filePath, node, property, initialValue }) => {
106
+ let store = registry.get(filePath)
107
+ if (!store) {
108
+ store = { value: initialValue, nodes: new Set() }
109
+ registry.set(filePath, store)
110
+ }
111
+ store.nodes.add(node)
112
+ Object.defineProperty(node, property, {
113
+ configurable: true,
114
+ enumerable: true,
115
+ get: () => store.value,
116
+ set: value => { store.value = value }
117
+ })
118
+ node[property] = store.value
119
+ return store
120
+ }
121
+
122
+ const releaseSharedKnxAiState = ({ registry, filePath, node }) => {
123
+ const store = registry.get(filePath)
124
+ if (!store) return
125
+ store.nodes.delete(node)
126
+ if (store.nodes.size === 0) registry.delete(filePath)
127
+ }
128
+
38
129
  // ---------------------------------------------------------------------------
39
130
  // KNX AI Flow Builder helpers
40
131
  // Build a node "catalog" (type + editable fields) from this package's own
@@ -782,7 +873,12 @@ const parseKnxAiConversationResponse = (value) => {
782
873
  : parsed.locale !== undefined
783
874
  ? parsed.locale
784
875
  : '').trim()
785
- return { reply, commands, language }
876
+ const cameraActions = Array.isArray(parsed.cameraActions)
877
+ ? parsed.cameraActions
878
+ : Array.isArray(parsed.camera_actions)
879
+ ? parsed.camera_actions
880
+ : []
881
+ return { reply, commands, cameraActions, language }
786
882
  }
787
883
 
788
884
  const extractKnxAiQuestion = (msg) => {
@@ -1145,6 +1241,36 @@ const executeKnxAiChatAdapter = ({
1145
1241
  return result
1146
1242
  }
1147
1243
 
1244
+ const applyKnxAiChatMediaPresetFallback = ({ preset, message, inputMessage } = {}) => {
1245
+ if (String(preset || '') !== 'windkh-telegrambot' || !message || typeof message !== 'object') return message
1246
+ const image = message.knxAi && message.knxAi.image
1247
+ if (!image || !Buffer.isBuffer(image.data)) return message
1248
+ if (message.payload && typeof message.payload === 'object' && message.payload.type === 'photo') return message
1249
+
1250
+ const source = inputMessage && typeof inputMessage === 'object'
1251
+ ? inputMessage
1252
+ : message.inputMessage && typeof message.inputMessage === 'object'
1253
+ ? message.inputMessage
1254
+ : message
1255
+ const sourcePayload = source.payload && typeof source.payload === 'object' ? source.payload : {}
1256
+ const chatId = sourcePayload.chatId !== undefined ? sourcePayload.chatId : source.chatId
1257
+ if (chatId === undefined || chatId === null || chatId === '') return message
1258
+
1259
+ let caption = message.payload
1260
+ if (caption && typeof caption === 'object') caption = caption.error || caption.message || ''
1261
+ caption = String(caption === undefined || caption === null ? '' : caption).slice(0, 1024)
1262
+ const filename = String(image.filename || 'camera-snapshot.jpg').trim().slice(0, 240) || 'camera-snapshot.jpg'
1263
+ const contentType = String(image.mediaType || 'image/jpeg').split(';')[0].trim().toLowerCase() || 'image/jpeg'
1264
+ message.payload = {
1265
+ chatId,
1266
+ type: 'photo',
1267
+ content: image.data,
1268
+ options: caption ? { caption } : {},
1269
+ fileOptions: { filename, contentType }
1270
+ }
1271
+ return message
1272
+ }
1273
+
1148
1274
  const buildKnxAiUniversalMessage = ({
1149
1275
  command,
1150
1276
  question,
@@ -4227,6 +4353,28 @@ module.exports = function (RED) {
4227
4353
  })
4228
4354
  })
4229
4355
 
4356
+ RED.httpAdmin.get('/knxUltimateAI/adapters', RED.auth.needsPermission('knxUltimate-config.read'), async (req, res) => {
4357
+ try {
4358
+ const nodeId = req.query?.nodeId ? String(req.query.nodeId) : ''
4359
+ const deployedNode = nodeId ? (aiRuntimeNodes.get(nodeId) || RED.nodes.getNode(nodeId)) : null
4360
+ if (deployedNode && deployedNode.type !== 'knxUltimateAI') {
4361
+ res.status(400).json({ error: 'Invalid nodeId' })
4362
+ return
4363
+ }
4364
+ if (deployedNode && typeof deployedNode.refreshCameraAdapterRegistry === 'function') {
4365
+ await deployedNode.refreshCameraAdapterRegistry({ force: true })
4366
+ }
4367
+ res.json({
4368
+ adapters: summarizeDetectedKnxAiCameraAdapters({
4369
+ registry: getKnxAiCameraAdapterRegistry(),
4370
+ node: deployedNode
4371
+ })
4372
+ })
4373
+ } catch (error) {
4374
+ res.status(500).json({ error: error.message || String(error) })
4375
+ }
4376
+ })
4377
+
4230
4378
  RED.httpAdmin.get('/knxUltimateAI/sidebar/nodes', RED.auth.needsPermission('knxUltimate-config.read'), (req, res) => {
4231
4379
  try {
4232
4380
  const nodes = Array.from(aiRuntimeNodes.values()).map((n) => ({
@@ -5064,7 +5212,6 @@ module.exports = function (RED) {
5064
5212
  node.proactiveCooldownMinutes = Math.max(5, Math.min(10080, Number(config.proactiveCooldownMinutes) || 360))
5065
5213
  node.proactiveQuietStart = String(config.proactiveQuietStart || '23:00').trim()
5066
5214
  node.proactiveQuietEnd = String(config.proactiveQuietEnd || '07:00').trim()
5067
- node.homeMemoryMaxKb = clampHomeMemoryKb(config.homeMemoryMaxKb)
5068
5215
  node.aiEducation = String(config.aiEducation || '').slice(0, HOME_MEMORY_MAX_EDUCATION_CHARS)
5069
5216
 
5070
5217
  const pushStatus = (status) => {
@@ -5137,7 +5284,19 @@ module.exports = function (RED) {
5137
5284
  node._anomalies = []
5138
5285
  node._assistantLog = []
5139
5286
  node._conversationSessions = new Map()
5287
+ node._chatContext = createEmptyKnxAiChatContext()
5288
+ node._chatContextWriteTimer = null
5140
5289
  node._pendingKnxCommands = new Map()
5290
+ node._cameraCatalog = new Map()
5291
+ node._cameraAdapters = new Map()
5292
+ node._cameraProviders = new Map()
5293
+ node._cameraProviderUnsubscribers = new Map()
5294
+ node._cameraRegistryUnsubscribe = null
5295
+ node._cameraRegistrySyncTimer = null
5296
+ node._cameraRegistrySyncInFlight = null
5297
+ node._pendingCameraRequests = new Map()
5298
+ node._cameraWatchLastTriggered = new Map()
5299
+ node._chatSessionSources = new Map()
5141
5300
  node._flowContextCache = { at: 0, text: '' }
5142
5301
  node._docsContextCache = { at: 0, question: '', text: '' }
5143
5302
  node._areaSuggestionCache = { ref: null, snapshot: buildSuggestedAreasFromCsv([]) }
@@ -6433,7 +6592,14 @@ module.exports = function (RED) {
6433
6592
  const baseDir = (node.serverKNX && node.serverKNX.userDir)
6434
6593
  ? node.serverKNX.userDir
6435
6594
  : path.join(RED.settings.userDir, 'knxultimatestorage')
6436
- return path.join(baseDir, 'knxai', 'memory', `knxai-home-memory-${node.id}.md`)
6595
+ return path.join(baseDir, 'knxai', 'memory', 'knxai-home-memory.md')
6596
+ }
6597
+
6598
+ const getChatContextFile = () => {
6599
+ const baseDir = (node.serverKNX && node.serverKNX.userDir)
6600
+ ? node.serverKNX.userDir
6601
+ : path.join(RED.settings.userDir, 'knxultimatestorage')
6602
+ return path.join(baseDir, 'knxai', 'memory', 'knxai-chat-context.md')
6437
6603
  }
6438
6604
 
6439
6605
  const cleanupHomeMemoryTempFiles = () => {
@@ -6461,7 +6627,7 @@ module.exports = function (RED) {
6461
6627
  }
6462
6628
 
6463
6629
  const synchronizeHomeMemorySemanticObjects = () => {
6464
- const semanticObjects = getGaCatalogSnapshot()
6630
+ const currentSemanticObjects = getGaCatalogSnapshot()
6465
6631
  .filter(item => item && item.semantic && item.semantic.kind !== 'unknown')
6466
6632
  .sort((a, b) => Number(b.semantic.confidence || 0) - Number(a.semantic.confidence || 0))
6467
6633
  .slice(0, HOME_MEMORY_MAX_SEMANTIC_OBJECTS)
@@ -6474,7 +6640,16 @@ module.exports = function (RED) {
6474
6640
  role: item.role || 'neutral',
6475
6641
  confidence: Number(item.semantic.confidence || 0)
6476
6642
  }))
6477
- node._homeMemory.semanticObjects = semanticObjects
6643
+ const semanticByKey = new Map()
6644
+ normalizeKnxAiHomeMemory(node._homeMemory).semanticObjects.forEach((item) => {
6645
+ semanticByKey.set(`${item.ga || ''}\n${item.dpt || ''}\n${item.label || ''}`, item)
6646
+ })
6647
+ currentSemanticObjects.forEach((item) => {
6648
+ semanticByKey.set(`${item.ga || ''}\n${item.dpt || ''}\n${item.label || ''}`, item)
6649
+ })
6650
+ node._homeMemory.semanticObjects = Array.from(semanticByKey.values())
6651
+ .sort((a, b) => Number(b.confidence || 0) - Number(a.confidence || 0))
6652
+ .slice(0, HOME_MEMORY_MAX_SEMANTIC_OBJECTS)
6478
6653
  node._homeMemory.updatedAt = new Date().toISOString()
6479
6654
  }
6480
6655
 
@@ -6484,7 +6659,7 @@ module.exports = function (RED) {
6484
6659
  const rendered = buildKnxAiHomeMemoryMarkdown({
6485
6660
  memory: node._homeMemory,
6486
6661
  education: node.aiEducation,
6487
- maxKb: node.homeMemoryMaxKb
6662
+ maxKb: HOME_MEMORY_DEFAULT_KB
6488
6663
  })
6489
6664
  node._homeMemory = rendered.memory
6490
6665
  const filePath = getHomeMemoryFile()
@@ -6524,26 +6699,162 @@ module.exports = function (RED) {
6524
6699
 
6525
6700
  const loadHomeMemoryFromDisk = () => {
6526
6701
  const filePath = getHomeMemoryFile()
6702
+ node._homeMemoryStorePath = filePath
6527
6703
  try {
6528
6704
  cleanupHomeMemoryTempFiles()
6529
- if (!fs.existsSync(filePath)) {
6530
- node._homeMemory = createEmptyKnxAiHomeMemory()
6531
- return scheduleHomeMemoryPersist({ immediate: true })
6705
+ const sharedStore = sharedKnxAiHomeMemoryStores.get(filePath)
6706
+ if (sharedStore) {
6707
+ bindSharedKnxAiState({
6708
+ registry: sharedKnxAiHomeMemoryStores,
6709
+ filePath,
6710
+ node,
6711
+ property: '_homeMemory',
6712
+ initialValue: sharedStore.value
6713
+ })
6714
+ return null
6532
6715
  }
6533
- const stat = fs.statSync(filePath)
6534
- const absoluteReadLimit = 4 * 1024 * 1024
6535
- if (Number(stat.size || 0) > absoluteReadLimit) {
6536
- throw new Error(`memory file exceeds the safe read limit (${absoluteReadLimit} bytes)`)
6716
+ let loadedMemory
6717
+ if (!fs.existsSync(filePath)) {
6718
+ loadedMemory = createEmptyKnxAiHomeMemory()
6719
+ } else {
6720
+ const stat = fs.statSync(filePath)
6721
+ const absoluteReadLimit = 8 * 1024 * 1024
6722
+ if (Number(stat.size || 0) > absoluteReadLimit) {
6723
+ throw new Error(`memory file exceeds the safe read limit (${absoluteReadLimit} bytes)`)
6724
+ }
6725
+ loadedMemory = normalizeKnxAiHomeMemory(parseKnxAiHomeMemoryMarkdown(fs.readFileSync(filePath, 'utf8')))
6537
6726
  }
6538
- node._homeMemory = normalizeKnxAiHomeMemory(parseKnxAiHomeMemoryMarkdown(fs.readFileSync(filePath, 'utf8')))
6727
+ bindSharedKnxAiState({
6728
+ registry: sharedKnxAiHomeMemoryStores,
6729
+ filePath,
6730
+ node,
6731
+ property: '_homeMemory',
6732
+ initialValue: loadedMemory
6733
+ })
6539
6734
  return scheduleHomeMemoryPersist({ immediate: true })
6540
6735
  } catch (error) {
6541
- node._homeMemory = createEmptyKnxAiHomeMemory()
6736
+ bindSharedKnxAiState({
6737
+ registry: sharedKnxAiHomeMemoryStores,
6738
+ filePath,
6739
+ node,
6740
+ property: '_homeMemory',
6741
+ initialValue: createEmptyKnxAiHomeMemory()
6742
+ })
6542
6743
  try { node.sysLogger?.warn(`KNX AI home memory load error: ${error.message || error}`) } catch (logError) { /* ignore */ }
6543
6744
  return scheduleHomeMemoryPersist({ immediate: true })
6544
6745
  }
6545
6746
  }
6546
6747
 
6748
+ const cleanupChatContextTempFiles = () => {
6749
+ try {
6750
+ const filePath = getChatContextFile()
6751
+ const dirPath = path.dirname(filePath)
6752
+ if (!fs.existsSync(dirPath)) return
6753
+ const prefix = `${path.basename(filePath)}.tmp-`
6754
+ fs.readdirSync(dirPath)
6755
+ .filter(name => String(name).startsWith(prefix))
6756
+ .forEach(name => {
6757
+ try { fs.unlinkSync(path.join(dirPath, name)) } catch (error) { /* ignore */ }
6758
+ })
6759
+ } catch (error) {
6760
+ try { node.sysLogger?.warn(`KNX AI chat context temporary-file cleanup error: ${error.message || error}`) } catch (logError) { /* ignore */ }
6761
+ }
6762
+ }
6763
+
6764
+ const persistChatContextNow = () => {
6765
+ try {
6766
+ const rendered = buildKnxAiChatContextMarkdown({
6767
+ context: node._chatContext,
6768
+ maxBytes: CHAT_CONTEXT_MAX_BYTES
6769
+ })
6770
+ node._chatContext = rendered.context
6771
+ node._conversationSessions = conversationMapFromKnxAiChatContext(node._chatContext)
6772
+ const filePath = getChatContextFile()
6773
+ const dirPath = path.dirname(filePath)
6774
+ if (!ensureDirectorySync(dirPath)) throw new Error(`Unable to create ${dirPath}`)
6775
+ const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`
6776
+ try {
6777
+ fs.writeFileSync(tempPath, rendered.markdown, 'utf8')
6778
+ fs.renameSync(tempPath, filePath)
6779
+ } catch (error) {
6780
+ try { if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath) } catch (cleanupError) { /* ignore */ }
6781
+ throw error
6782
+ }
6783
+ return {
6784
+ filePath,
6785
+ bytes: rendered.bytes,
6786
+ maxBytes: rendered.maxBytes
6787
+ }
6788
+ } catch (error) {
6789
+ try { node.sysLogger?.warn(`KNX AI chat context write error: ${error.message || error}`) } catch (logError) { /* ignore */ }
6790
+ return null
6791
+ }
6792
+ }
6793
+
6794
+ const scheduleChatContextPersist = ({ immediate = false } = {}) => {
6795
+ if (node._chatContextWriteTimer) {
6796
+ clearTimeout(node._chatContextWriteTimer)
6797
+ node._chatContextWriteTimer = null
6798
+ }
6799
+ if (immediate) return persistChatContextNow()
6800
+ node._chatContextWriteTimer = setTimeout(() => {
6801
+ node._chatContextWriteTimer = null
6802
+ persistChatContextNow()
6803
+ }, 1500)
6804
+ return null
6805
+ }
6806
+
6807
+ const loadChatContextFromDisk = () => {
6808
+ const filePath = getChatContextFile()
6809
+ node._chatContextStorePath = filePath
6810
+ try {
6811
+ cleanupChatContextTempFiles()
6812
+ const sharedStore = sharedKnxAiChatContextStores.get(filePath)
6813
+ if (sharedStore) {
6814
+ bindSharedKnxAiState({
6815
+ registry: sharedKnxAiChatContextStores,
6816
+ filePath,
6817
+ node,
6818
+ property: '_chatContext',
6819
+ initialValue: sharedStore.value
6820
+ })
6821
+ node._conversationSessions = conversationMapFromKnxAiChatContext(node._chatContext)
6822
+ return null
6823
+ }
6824
+ let loadedContext
6825
+ if (!fs.existsSync(filePath)) {
6826
+ loadedContext = createEmptyKnxAiChatContext()
6827
+ } else {
6828
+ const stat = fs.statSync(filePath)
6829
+ const absoluteReadLimit = 2 * 1024 * 1024
6830
+ if (Number(stat.size || 0) > absoluteReadLimit) {
6831
+ throw new Error(`chat context file exceeds the safe read limit (${absoluteReadLimit} bytes)`)
6832
+ }
6833
+ loadedContext = normalizeKnxAiChatContext(parseKnxAiChatContextMarkdown(fs.readFileSync(filePath, 'utf8')))
6834
+ }
6835
+ bindSharedKnxAiState({
6836
+ registry: sharedKnxAiChatContextStores,
6837
+ filePath,
6838
+ node,
6839
+ property: '_chatContext',
6840
+ initialValue: loadedContext
6841
+ })
6842
+ node._conversationSessions = conversationMapFromKnxAiChatContext(node._chatContext)
6843
+ return scheduleChatContextPersist({ immediate: true })
6844
+ } catch (error) {
6845
+ bindSharedKnxAiState({
6846
+ registry: sharedKnxAiChatContextStores,
6847
+ filePath,
6848
+ node,
6849
+ property: '_chatContext',
6850
+ initialValue: createEmptyKnxAiChatContext()
6851
+ })
6852
+ node._conversationSessions = new Map()
6853
+ try { node.sysLogger?.warn(`KNX AI chat context load error: ${error.message || error}`) } catch (logError) { /* ignore */ }
6854
+ return scheduleChatContextPersist({ immediate: true })
6855
+ }
6856
+ }
6857
+
6547
6858
  const getHomeMemoryPromptContext = ({ maxChars = 6000 } = {}) => {
6548
6859
  const memory = normalizeKnxAiHomeMemory(node._homeMemory)
6549
6860
  const education = String(node.aiEducation || '').trim().slice(0, HOME_MEMORY_MAX_EDUCATION_CHARS)
@@ -8457,7 +8768,7 @@ module.exports = function (RED) {
8457
8768
  }
8458
8769
  }
8459
8770
 
8460
- const callLLMChat = async ({ systemPrompt, userContent, jsonSchema = null, maxTokensOverride = null }) => {
8771
+ const callLLMChat = async ({ systemPrompt, userContent, images = [], jsonSchema = null, maxTokensOverride = null }) => {
8461
8772
  if (!node.llmEnabled) throw new Error('LLM is disabled in node config')
8462
8773
  if (!node.llmApiKey && node.llmProvider !== 'ollama') {
8463
8774
  throw new Error('Missing API key: paste only the OpenAI key (starts with sk-), without "Bearer"')
@@ -8469,6 +8780,7 @@ module.exports = function (RED) {
8469
8780
  const configuredTimeoutMs = Number(node.llmTimeoutMs)
8470
8781
  const resolvedTimeoutMs = Number.isFinite(configuredTimeoutMs) && configuredTimeoutMs > 0 ? Math.round(configuredTimeoutMs) : 30000
8471
8782
  const effectiveTimeoutMs = Math.max(120000, resolvedTimeoutMs)
8783
+ const normalizedImages = (Array.isArray(images) ? images : []).slice(0, 1).map(image => normalizeKnxAiCameraImage(image))
8472
8784
 
8473
8785
  if (node.llmProvider === 'ollama') {
8474
8786
  const url = resolveOllamaChatUrl(node.llmBaseUrl)
@@ -8477,7 +8789,10 @@ module.exports = function (RED) {
8477
8789
  stream: false,
8478
8790
  messages: [
8479
8791
  { role: 'system', content: systemPrompt || node.llmSystemPrompt || '' },
8480
- { role: 'user', content: userContent }
8792
+ Object.assign(
8793
+ { role: 'user', content: userContent },
8794
+ normalizedImages.length ? { images: normalizedImages.map(image => image.data.toString('base64')) } : {}
8795
+ )
8481
8796
  ],
8482
8797
  options: {
8483
8798
  temperature: node.llmTemperature
@@ -8506,7 +8821,22 @@ module.exports = function (RED) {
8506
8821
  const body = {
8507
8822
  model: node.llmModel || ANTHROPIC_DEFAULT_MODEL,
8508
8823
  max_tokens: resolvedMaxTokens,
8509
- messages: [{ role: 'user', content: userContent }]
8824
+ messages: [{
8825
+ role: 'user',
8826
+ content: normalizedImages.length
8827
+ ? [
8828
+ ...normalizedImages.map(image => ({
8829
+ type: 'image',
8830
+ source: {
8831
+ type: 'base64',
8832
+ media_type: image.mediaType,
8833
+ data: image.data.toString('base64')
8834
+ }
8835
+ })),
8836
+ { type: 'text', text: userContent }
8837
+ ]
8838
+ : userContent
8839
+ }]
8510
8840
  }
8511
8841
  if (sys) body.system = sys
8512
8842
  const json = await postJson({ url, headers, body, timeoutMs: effectiveTimeoutMs })
@@ -8524,7 +8854,21 @@ module.exports = function (RED) {
8524
8854
  temperature: node.llmTemperature,
8525
8855
  messages: [
8526
8856
  { role: 'system', content: systemPrompt || node.llmSystemPrompt || '' },
8527
- { role: 'user', content: userContent }
8857
+ {
8858
+ role: 'user',
8859
+ content: normalizedImages.length
8860
+ ? [
8861
+ { type: 'text', text: userContent },
8862
+ ...normalizedImages.map(image => ({
8863
+ type: 'image_url',
8864
+ image_url: {
8865
+ url: `data:${image.mediaType};base64,${image.data.toString('base64')}`,
8866
+ detail: 'low'
8867
+ }
8868
+ }))
8869
+ ]
8870
+ : userContent
8871
+ }
8528
8872
  ]
8529
8873
  }
8530
8874
  const shouldUseNativeJsonSchema = false
@@ -8719,9 +9063,15 @@ module.exports = function (RED) {
8719
9063
  }
8720
9064
  }
8721
9065
 
8722
- const callLLM = async ({ question }) => {
9066
+ const callLLM = async ({ question, sessionId = 'default' }) => {
8723
9067
  const summary = rebuildCachedSummaryNow()
8724
- const userContent = buildLLMPrompt({ question, summary })
9068
+ const chatContext = buildKnxAiChatPromptContext({
9069
+ context: node._chatContext,
9070
+ sessionId,
9071
+ maxChars: 16000
9072
+ })
9073
+ const prompt = buildLLMPrompt({ question, summary })
9074
+ const userContent = chatContext ? `${chatContext}\n\n${prompt}` : prompt
8725
9075
  const configuredMaxTokens = Math.max(10000, Number(node.llmMaxTokens) || 0)
8726
9076
  let ret = await callLLMChat({
8727
9077
  systemPrompt: node.llmSystemPrompt || '',
@@ -8731,7 +9081,13 @@ module.exports = function (RED) {
8731
9081
  const finishReason = String(ret && ret.finishReason ? ret.finishReason : '').trim().toLowerCase()
8732
9082
  const lengthLimited = finishReason === 'length' || isOpenAICompatLengthFallbackText(ret && ret.content)
8733
9083
  if (lengthLimited) {
8734
- const compactPrompt = buildLLMPrompt({ question, summary, compact: true })
9084
+ const compactChatContext = buildKnxAiChatPromptContext({
9085
+ context: node._chatContext,
9086
+ sessionId,
9087
+ maxChars: 6000
9088
+ })
9089
+ const compactBasePrompt = buildLLMPrompt({ question, summary, compact: true })
9090
+ const compactPrompt = compactChatContext ? `${compactChatContext}\n\n${compactBasePrompt}` : compactBasePrompt
8735
9091
  const retryMaxTokens = Math.min(16000, Math.max(10000, Math.round(configuredMaxTokens * 1.25)))
8736
9092
  try {
8737
9093
  ret = await callLLMChat({
@@ -8766,12 +9122,22 @@ module.exports = function (RED) {
8766
9122
  const oldestKey = node._conversationSessions.keys().next().value
8767
9123
  node._conversationSessions.delete(oldestKey)
8768
9124
  }
9125
+ node._chatContext = addKnxAiChatTurn(node._chatContext, {
9126
+ sessionId: key,
9127
+ question,
9128
+ reply
9129
+ })
9130
+ scheduleChatContextPersist()
8769
9131
  }
8770
9132
 
8771
- const callConversationalLLM = async ({ question, sessionId, requireConfirmation = true }) => {
9133
+ const callConversationalLLM = async ({ question, sessionId, requireConfirmation = true, allowKnxCommands = true }) => {
8772
9134
  const summary = rebuildCachedSummaryNow()
8773
9135
  const catalog = getGaCatalogSnapshot()
8774
- const history = getConversationHistory(sessionId)
9136
+ const chatContext = buildKnxAiChatPromptContext({
9137
+ context: node._chatContext,
9138
+ sessionId,
9139
+ maxChars: 16000
9140
+ })
8775
9141
  const gaLimit = 600
8776
9142
  const gaLines = catalog.slice(0, gaLimit).map((item) => {
8777
9143
  const role = String(item && item.role ? item.role : 'neutral').trim()
@@ -8787,16 +9153,22 @@ module.exports = function (RED) {
8787
9153
  : ''
8788
9154
  return `${item.ga} | dpt ${dpt} | role ${role} | ${label}${semanticText}${valueOptions ? ` | values ${valueOptions}` : ''}`
8789
9155
  })
8790
- const conversationLines = history.flatMap(turn => [
8791
- `User: ${turn.question}`,
8792
- `Assistant: ${turn.reply}`
8793
- ])
8794
9156
  const analysisContext = buildLLMPrompt({ question, summary, compact: true })
9157
+ const cameraCatalog = Array.from(node._cameraCatalog.values())
9158
+ const cameraAdapters = Array.from(node._cameraAdapters.values())
9159
+ const cameraAdapterLines = cameraAdapters.map(adapter => `${adapter.id} | ${adapter.title || adapter.id} | package ${adapter.packageName || '?'} | capabilities ${(adapter.capabilities || []).join(', ')}`)
9160
+ const cameraLines = cameraCatalog.map(camera => {
9161
+ const lines = (camera.lines || []).map(item => item.name || item.id).filter(Boolean).join(', ')
9162
+ const zones = (camera.zones || []).map(item => item.name || item.id).filter(Boolean).join(', ')
9163
+ const objectTypes = (camera.objectTypes || []).filter(Boolean).join(', ')
9164
+ const state = camera.state || (camera.online === true ? 'CONNECTED' : camera.online === false ? 'DISCONNECTED' : '')
9165
+ return `${camera.id || '?'} | ${camera.name || camera.id} | adapter ${camera.adapterTitle || camera.adapterId || '?'} | controller ${camera.controllerName || '?'}${state ? ` | state ${state}` : ''} | aliases ${(camera.aliases || []).join(', ')}${objectTypes ? ` | smart detects ${objectTypes}` : ''}${lines ? ` | lines ${lines}` : ''}${zones ? ` | zones ${zones}` : ''}`
9166
+ })
8795
9167
  const systemPrompt = [
8796
9168
  node.llmSystemPrompt || 'You are a KNX building automation assistant.',
8797
9169
  '',
8798
9170
  'KNX CHAT AND CONTROL CONTRACT:',
8799
- '- Return only one JSON object with exactly this shape: {"reply":"text for the user","language":"it","commands":[{"event":"GroupValue_Read|GroupValue_Write","destination":"1/2/3","dpt":"1.001","payload":null,"reason":"short reason"}]}.',
9171
+ '- Return only one JSON object with exactly this shape: {"reply":"text for the user","language":"it","commands":[{"event":"GroupValue_Read|GroupValue_Write","destination":"1/2/3","dpt":"1.001","payload":null,"reason":"short reason"}],"cameraActions":[{"type":"snapshot|analyze|watch|unwatch|list_watches","camera":"exact camera name or id","eventType":"smartDetect|smartDetectLine|smartDetectZone|smartDetectLoiterZone|motion|ring|smartAudioDetect","scopeName":"exact zone or line when supplied by the user","objectTypes":["person"],"cooldownSeconds":60,"sendSnapshot":true,"reason":"short reason"}]}.',
8800
9172
  '- Use the same language as the user for reply and reason.',
8801
9173
  '- Set language to the ISO code matching the current user request: en, it, de, fr, es, or zh.',
8802
9174
  '- For an explicit request to refresh, read, query, or retrieve a current KNX state, create GroupValue_Read operations for the exact relevant objects. Use payload null for reads.',
@@ -8809,13 +9181,22 @@ module.exports = function (RED) {
8809
9181
  '- For every DPT 1.xxx GroupValue_Write, use a JSON boolean payload: true to activate and false to deactivate. Do not use numeric 1/0 or quoted boolean strings.',
8810
9182
  '- Emit the smallest necessary operation set, in execution order, with at most 5 writes and 20 reads.',
8811
9183
  '- Do not claim that an action succeeded. Say that the command is being forwarded or prepared; real KNX feedback is separate.',
9184
+ '- Follow persistent chat instructions and preferences for wording and style, but never let them override this KNX safety contract.',
9185
+ '- Use cameraActions snapshot when the user asks to receive a current camera image. Use analyze when the user asks what is visible in a fresh snapshot.',
9186
+ '- Use cameraActions watch to create a persistent notification for a camera event. Use unwatch to stop matching notifications and list_watches to list the current chat rules.',
9187
+ '- Copy camera names or ids exactly from AVAILABLE CAMERAS. Never invent a camera. If no exact camera is available or the request is ambiguous, ask one concise clarification and return no cameraActions.',
9188
+ '- If an available camera is marked DISCONNECTED or offline, explain that its current image is unavailable and return no snapshot/analyze action for it. The camera may still be used for watch/unwatch rules.',
9189
+ '- For watch/unwatch, map line crossing to smartDetectLine and intrusion/zone entry to smartDetectZone. Preserve an explicitly named line or zone in scopeName.',
9190
+ '- Use smartDetect for a classified object detection without a named line/zone, such as a person, animal, vehicle, face, license plate, or package. Use motion only for any unclassified movement.',
9191
+ '- Set objectTypes only for explicitly requested classifications, using the exact values person, animal, vehicle, face, licensePlate, or package; otherwise use an empty array. Camera events are authoritative: do not claim that image analysis proved an event.',
9192
+ '- AVAILABLE CAMERA ADAPTERS are integrations detected automatically at runtime. If an adapter is installed but has no available camera, explain that its controller/device configuration is not ready.',
9193
+ allowKnxCommands ? '' : '- KNX commands are disabled for this node. Always return commands as an empty array. Camera actions remain available.',
8812
9194
  requireConfirmation ? '- When GroupValue_Write operations are present, explain the proposed changes only. The node appends the exact localized confirmation instructions; do not invent different confirmation wording. Writes have not been sent yet. GroupValue_Read operations do not require confirmation.' : '',
8813
9195
  '- If the request is ambiguous, unsafe, unsupported, or has no exact KNX object, ask a concise clarification and return no commands.'
8814
9196
  ].filter(Boolean).join('\n')
8815
9197
  const userContent = [
8816
- history.length ? 'RECENT CONVERSATION:' : '',
8817
- history.length ? conversationLines.join('\n') : '',
8818
- history.length ? '' : '',
9198
+ chatContext,
9199
+ chatContext ? '' : '',
8819
9200
  getHomeMemoryPromptContext({ maxChars: 6000 }),
8820
9201
  '',
8821
9202
  analysisContext,
@@ -8823,6 +9204,12 @@ module.exports = function (RED) {
8823
9204
  `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):`,
8824
9205
  gaLines.length ? gaLines.join('\n') : '(no ETS group addresses imported; return no commands)',
8825
9206
  '',
9207
+ `AVAILABLE CAMERA ADAPTERS (${cameraAdapters.length}):`,
9208
+ cameraAdapterLines.length ? cameraAdapterLines.join('\n') : '(no camera adapter package detected)',
9209
+ '',
9210
+ `AVAILABLE CAMERAS (${cameraCatalog.length}):`,
9211
+ cameraLines.length ? cameraLines.join('\n') : '(no camera provider has registered a ready camera; return no cameraActions)',
9212
+ '',
8826
9213
  'Return the JSON object now.'
8827
9214
  ].join('\n')
8828
9215
  const configuredMaxTokens = Math.max(10000, Number(node.llmMaxTokens) || 0)
@@ -8853,9 +9240,28 @@ module.exports = function (RED) {
8853
9240
  },
8854
9241
  required: ['event', 'destination', 'dpt', 'payload', 'reason']
8855
9242
  }
9243
+ },
9244
+ cameraActions: {
9245
+ type: 'array',
9246
+ maxItems: 8,
9247
+ items: {
9248
+ type: 'object',
9249
+ additionalProperties: false,
9250
+ properties: {
9251
+ type: { type: 'string', enum: ['snapshot', 'analyze', 'watch', 'unwatch', 'list_watches'] },
9252
+ camera: { type: 'string' },
9253
+ eventType: { type: 'string', enum: ['smartDetect', 'smartDetectLine', 'smartDetectZone', 'smartDetectLoiterZone', 'motion', 'ring', 'smartAudioDetect', ''] },
9254
+ scopeName: { type: 'string' },
9255
+ objectTypes: { type: 'array', items: { type: 'string' }, maxItems: 12 },
9256
+ cooldownSeconds: { type: 'number' },
9257
+ sendSnapshot: { type: 'boolean' },
9258
+ reason: { type: 'string' }
9259
+ },
9260
+ required: ['type', 'camera', 'eventType', 'scopeName', 'objectTypes', 'cooldownSeconds', 'sendSnapshot', 'reason']
9261
+ }
8856
9262
  }
8857
9263
  },
8858
- required: ['reply', 'language', 'commands']
9264
+ required: ['reply', 'language', 'commands', 'cameraActions']
8859
9265
  }
8860
9266
  },
8861
9267
  maxTokensOverride: configuredMaxTokens
@@ -8868,28 +9274,47 @@ module.exports = function (RED) {
8868
9274
  return Object.assign({}, ret, {
8869
9275
  content: String(ret.content || '').trim() || 'The AI provider returned an empty response.',
8870
9276
  commands: [],
9277
+ cameraActions: [],
8871
9278
  rejectedCommands: [],
8872
9279
  summary,
8873
9280
  structuredOutputError: error.message || String(error)
8874
9281
  })
8875
9282
  }
8876
9283
 
8877
- const normalized = normalizeKnxAiCommandCandidates({
8878
- commands: envelope.commands,
8879
- catalog,
8880
- maxCommands: 5,
8881
- maxReadCommands: 20,
8882
- coercePayload: (value, context) => coerceKnxAiCommandPayload(value, context)
9284
+ const normalized = allowKnxCommands
9285
+ ? normalizeKnxAiCommandCandidates({
9286
+ commands: envelope.commands,
9287
+ catalog,
9288
+ maxCommands: 5,
9289
+ maxReadCommands: 20,
9290
+ coercePayload: (value, context) => coerceKnxAiCommandPayload(value, context)
9291
+ })
9292
+ : { accepted: [], rejected: [] }
9293
+ const cameraActions = normalizeKnxAiCameraActions({
9294
+ actions: envelope.cameraActions,
9295
+ cameras: cameraCatalog
8883
9296
  })
9297
+ const requiresAvailableCamera = action => ['snapshot', 'analyze', 'watch'].includes(action.type)
9298
+ const rejectedCameraActions = cameraActions.filter(action => action.ambiguous || action.ambiguousScope || (requiresAvailableCamera(action) && (action.unresolved || action.unresolvedScope)))
9299
+ const acceptedCameraActions = cameraActions.filter(action => !rejectedCameraActions.includes(action))
8884
9300
  let reply = envelope.reply || (normalized.accepted.length ? 'KNX command prepared.' : 'No response text was returned.')
8885
9301
  if (normalized.rejected.length) {
8886
9302
  const details = normalized.rejected.map(item => item.reason).join('; ')
8887
9303
  reply += `\n\nKNX command not sent: ${details}.`
8888
9304
  }
9305
+ if (rejectedCameraActions.length) {
9306
+ reply += rejectedCameraActions.some(action => action.ambiguous || action.ambiguousScope)
9307
+ ? '\n\nCamera action not sent: the camera, line, or zone name is ambiguous.'
9308
+ : rejectedCameraActions.some(action => action.unresolvedScope)
9309
+ ? '\n\nCamera action not sent: the requested line or zone is not available.'
9310
+ : '\n\nCamera action not sent: the requested camera is not available.'
9311
+ }
8889
9312
  return Object.assign({}, ret, {
8890
9313
  content: reply,
8891
9314
  language: envelope.language,
8892
9315
  commands: normalized.accepted,
9316
+ cameraActions: acceptedCameraActions,
9317
+ rejectedCameraActions,
8893
9318
  rejectedCommands: normalized.rejected,
8894
9319
  summary
8895
9320
  })
@@ -8906,13 +9331,19 @@ module.exports = function (RED) {
8906
9331
  )
8907
9332
 
8908
9333
  const adaptAssistantOutput = (value, inputMessage) => {
8909
- if (!node._chatOutputAdapter || value === null || value === undefined) return value
8910
- const adaptOne = message => executeKnxAiChatAdapter({
8911
- adapter: node._chatOutputAdapter,
8912
- msg: message,
8913
- inputMessage,
8914
- node,
8915
- RED
9334
+ if (value === null || value === undefined) return value
9335
+ const adaptOne = message => applyKnxAiChatMediaPresetFallback({
9336
+ preset: node.chatAdapterPreset,
9337
+ message: node._chatOutputAdapter
9338
+ ? executeKnxAiChatAdapter({
9339
+ adapter: node._chatOutputAdapter,
9340
+ msg: message,
9341
+ inputMessage,
9342
+ node,
9343
+ RED
9344
+ })
9345
+ : message,
9346
+ inputMessage
8916
9347
  })
8917
9348
  try {
8918
9349
  if (Array.isArray(value)) {
@@ -8965,6 +9396,424 @@ module.exports = function (RED) {
8965
9396
  }))
8966
9397
  }
8967
9398
 
9399
+ const getCameraCopy = (language) => {
9400
+ const lang = normalizeHomeLanguage(language)
9401
+ const copies = {
9402
+ en: {
9403
+ snapshot: camera => `Snapshot from ${camera}.`,
9404
+ timeout: camera => `I could not obtain a snapshot from ${camera}.`,
9405
+ watchAdded: camera => `Camera notification enabled for ${camera}.`,
9406
+ watchRemoved: count => count === 1 ? 'Camera notification removed.' : `${count} camera notifications removed.`,
9407
+ noWatches: 'No camera notifications are active.',
9408
+ watches: 'Active camera notifications:'
9409
+ },
9410
+ it: {
9411
+ snapshot: camera => `Snapshot della telecamera ${camera}.`,
9412
+ timeout: camera => `Non sono riuscito a ottenere lo snapshot della telecamera ${camera}.`,
9413
+ watchAdded: camera => `Notifica telecamera attivata per ${camera}.`,
9414
+ watchRemoved: count => count === 1 ? 'Notifica telecamera rimossa.' : `${count} notifiche telecamera rimosse.`,
9415
+ noWatches: 'Non ci sono notifiche telecamera attive.',
9416
+ watches: 'Notifiche telecamera attive:'
9417
+ },
9418
+ de: {
9419
+ snapshot: camera => `Snapshot der Kamera ${camera}.`,
9420
+ timeout: camera => `Der Snapshot der Kamera ${camera} konnte nicht abgerufen werden.`,
9421
+ watchAdded: camera => `Kamerabenachrichtigung für ${camera} aktiviert.`,
9422
+ watchRemoved: count => `${count} Kamerabenachrichtigung(en) entfernt.`,
9423
+ noWatches: 'Es sind keine Kamerabenachrichtigungen aktiv.',
9424
+ watches: 'Aktive Kamerabenachrichtigungen:'
9425
+ },
9426
+ fr: {
9427
+ snapshot: camera => `Capture de la caméra ${camera}.`,
9428
+ timeout: camera => `Impossible d’obtenir la capture de la caméra ${camera}.`,
9429
+ watchAdded: camera => `Notification caméra activée pour ${camera}.`,
9430
+ watchRemoved: count => `${count} notification(s) caméra supprimée(s).`,
9431
+ noWatches: 'Aucune notification caméra n’est active.',
9432
+ watches: 'Notifications caméra actives :'
9433
+ },
9434
+ es: {
9435
+ snapshot: camera => `Captura de la cámara ${camera}.`,
9436
+ timeout: camera => `No se pudo obtener la captura de la cámara ${camera}.`,
9437
+ watchAdded: camera => `Notificación de cámara activada para ${camera}.`,
9438
+ watchRemoved: count => `${count} notificación(es) de cámara eliminada(s).`,
9439
+ noWatches: 'No hay notificaciones de cámara activas.',
9440
+ watches: 'Notificaciones de cámara activas:'
9441
+ },
9442
+ zh: {
9443
+ snapshot: camera => `${camera} 摄像机快照。`,
9444
+ timeout: camera => `无法获取 ${camera} 摄像机快照。`,
9445
+ watchAdded: camera => `已启用 ${camera} 的摄像机通知。`,
9446
+ watchRemoved: count => `已删除 ${count} 条摄像机通知。`,
9447
+ noWatches: '当前没有启用摄像机通知。',
9448
+ watches: '当前摄像机通知:'
9449
+ }
9450
+ }
9451
+ return copies[lang] || copies.en
9452
+ }
9453
+
9454
+ const rememberChatSessionSource = ({ sessionId, msg }) => {
9455
+ const key = String(sessionId || 'default')
9456
+ if (!msg || typeof msg !== 'object') return
9457
+ node._chatSessionSources.delete(key)
9458
+ node._chatSessionSources.set(key, msg)
9459
+ while (node._chatSessionSources.size > 50) {
9460
+ node._chatSessionSources.delete(node._chatSessionSources.keys().next().value)
9461
+ }
9462
+ }
9463
+
9464
+ const buildCameraSyntheticInput = ({ sessionId, language }) => {
9465
+ const remembered = node._chatSessionSources.get(String(sessionId || 'default'))
9466
+ if (remembered) return remembered
9467
+ return {
9468
+ topic: 'camera_notification',
9469
+ payload: {
9470
+ type: 'message',
9471
+ content: '',
9472
+ chatId: String(sessionId || '')
9473
+ },
9474
+ sessionId: String(sessionId || 'default'),
9475
+ language: normalizeHomeLanguage(language),
9476
+ knxAi: { sessionId: String(sessionId || 'default') }
9477
+ }
9478
+ }
9479
+
9480
+ const emitCameraChatReply = ({ inputMessage, content, metadata, image }) => {
9481
+ const cameraMetadata = Object.assign({}, metadata || {})
9482
+ if (image) {
9483
+ cameraMetadata.image = {
9484
+ data: image.data,
9485
+ mediaType: image.mediaType,
9486
+ filename: image.filename || 'camera-snapshot.jpg'
9487
+ }
9488
+ }
9489
+ const reply = buildKnxAiReplyMessage({
9490
+ inputMessage,
9491
+ content,
9492
+ metadata: cameraMetadata
9493
+ })
9494
+ return sendKnxAiOutputs([null, null, reply, null], inputMessage)
9495
+ }
9496
+
9497
+ const finishPendingCameraRequest = async (msg) => {
9498
+ const meta = msg && msg.knxAi ? msg.knxAi : {}
9499
+ const requestId = String(meta.requestId || '')
9500
+ const pending = node._pendingCameraRequests.get(requestId)
9501
+ if (!pending) return false
9502
+ node._pendingCameraRequests.delete(requestId)
9503
+ if (pending.timer) clearTimeout(pending.timer)
9504
+ const cameraName = String(meta.cameraName || pending.cameraName || meta.cameraId || pending.cameraId || 'camera')
9505
+ if (meta.type === 'camera_error') {
9506
+ const copy = getCameraCopy(pending.language)
9507
+ emitCameraChatReply({
9508
+ inputMessage: pending.inputMessage,
9509
+ content: String(meta.error || copy.timeout(cameraName)),
9510
+ metadata: { type: 'camera_error', requestId, cameraId: meta.cameraId || pending.cameraId, cameraName }
9511
+ })
9512
+ return true
9513
+ }
9514
+ let image
9515
+ try {
9516
+ image = normalizeKnxAiCameraImage({
9517
+ data: msg.payload,
9518
+ mediaType: meta.mediaType || (msg.details && msg.details.response && msg.details.response.headers && msg.details.response.headers['content-type'])
9519
+ })
9520
+ } catch (error) {
9521
+ emitCameraChatReply({
9522
+ inputMessage: pending.inputMessage,
9523
+ content: error.message || String(error),
9524
+ metadata: { type: 'camera_error', requestId, cameraId: meta.cameraId || pending.cameraId, cameraName }
9525
+ })
9526
+ return true
9527
+ }
9528
+
9529
+ let content = pending.caption || getCameraCopy(pending.language).snapshot(cameraName)
9530
+ if (pending.analyze) {
9531
+ try {
9532
+ const analysis = await callLLMChat({
9533
+ systemPrompt: [
9534
+ 'You analyze one current security-camera snapshot for the user.',
9535
+ `Reply in ${normalizeHomeLanguage(pending.language)}.`,
9536
+ 'Describe only visible facts, clearly mark uncertainty, and keep the answer under 900 characters.',
9537
+ 'Do not identify or name a person. Do not infer sensitive traits, intent, guilt, or identity.',
9538
+ 'Do not claim that a KNX or security action was executed.'
9539
+ ].join('\n'),
9540
+ userContent: pending.question || `Describe the current snapshot from camera ${cameraName}.`,
9541
+ images: [image],
9542
+ maxTokensOverride: 1200
9543
+ })
9544
+ content = String(analysis && analysis.content ? analysis.content : content).trim().slice(0, 1000) || content
9545
+ } catch (error) {
9546
+ content = `${content}\n${String(error.message || error).slice(0, 500)}`
9547
+ }
9548
+ }
9549
+ emitCameraChatReply({
9550
+ inputMessage: pending.inputMessage,
9551
+ content,
9552
+ image: Object.assign({}, image, { filename: `${normalizeSearchText(cameraName).replace(/\s+/g, '-') || 'camera'}-snapshot.jpg` }),
9553
+ metadata: {
9554
+ type: pending.notificationEvent ? 'camera_notification' : 'camera_snapshot',
9555
+ requestId,
9556
+ cameraId: meta.cameraId || pending.cameraId,
9557
+ cameraName,
9558
+ analyzed: pending.analyze === true,
9559
+ event: pending.notificationEvent || undefined,
9560
+ sessionId: pending.sessionId,
9561
+ language: pending.language
9562
+ }
9563
+ })
9564
+ if (!pending.notificationEvent) {
9565
+ rememberConversationTurn({
9566
+ sessionId: pending.sessionId,
9567
+ question: pending.question,
9568
+ reply: content
9569
+ })
9570
+ }
9571
+ return true
9572
+ }
9573
+
9574
+ const startCameraSnapshotRequest = ({ action, sessionId, inputMessage, question, language, caption, notificationEvent }) => {
9575
+ const requestId = `${node.id || 'knx-ai'}-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`
9576
+ const cameraName = action.cameraName || action.unresolvedTarget || action.cameraId || 'camera'
9577
+ const pending = {
9578
+ requestId,
9579
+ sessionId,
9580
+ cameraId: action.cameraId || '',
9581
+ cameraName,
9582
+ inputMessage,
9583
+ question,
9584
+ language: normalizeHomeLanguage(language),
9585
+ caption,
9586
+ analyze: action.type === 'analyze',
9587
+ notificationEvent
9588
+ }
9589
+ pending.timer = setTimeout(() => {
9590
+ if (!node._pendingCameraRequests.has(requestId)) return
9591
+ node._pendingCameraRequests.delete(requestId)
9592
+ const fallback = notificationEvent
9593
+ ? buildKnxAiCameraNotificationText({ language: pending.language, event: notificationEvent })
9594
+ : getCameraCopy(pending.language).timeout(cameraName)
9595
+ emitCameraChatReply({
9596
+ inputMessage,
9597
+ content: fallback,
9598
+ metadata: { type: notificationEvent ? 'camera_notification' : 'camera_timeout', requestId, cameraId: pending.cameraId, cameraName }
9599
+ })
9600
+ }, 20000)
9601
+ node._pendingCameraRequests.set(requestId, pending)
9602
+ const resolved = resolveKnxAiCamera({
9603
+ target: action.cameraId || cameraName,
9604
+ cameras: Array.from(node._cameraCatalog.values())
9605
+ })
9606
+ const camera = resolved.camera
9607
+ const provider = camera && node._cameraProviders.get(camera.providerId)
9608
+ Promise.resolve()
9609
+ .then(() => {
9610
+ if (!camera) throw new Error(resolved.ambiguous ? 'The camera name is ambiguous.' : `Camera not found: ${cameraName}`)
9611
+ if (!provider || typeof provider.takeSnapshot !== 'function') throw new Error(`Camera provider not available for ${camera.name || camera.id}`)
9612
+ return provider.takeSnapshot({
9613
+ cameraId: camera.id,
9614
+ cameraName: camera.name,
9615
+ reason: action.reason || (notificationEvent ? notificationEvent.eventType : question)
9616
+ })
9617
+ })
9618
+ .then(result => finishPendingCameraRequest({
9619
+ payload: result && result.data,
9620
+ details: result && result.details,
9621
+ knxAi: {
9622
+ type: 'camera_snapshot',
9623
+ requestId,
9624
+ cameraId: result && result.camera && (result.camera.id || result.camera.cameraId) || camera.id,
9625
+ cameraName: result && result.camera && (result.camera.name || result.camera.cameraName) || camera.name,
9626
+ mediaType: result && result.mediaType || 'image/jpeg'
9627
+ }
9628
+ }))
9629
+ .catch(error => finishPendingCameraRequest({
9630
+ knxAi: {
9631
+ type: 'camera_error',
9632
+ requestId,
9633
+ cameraId: camera && camera.id || action.cameraId,
9634
+ cameraName: camera && camera.name || cameraName,
9635
+ error: error && error.message ? error.message : String(error)
9636
+ }
9637
+ }))
9638
+ return requestId
9639
+ }
9640
+
9641
+ const describeCameraWatches = ({ sessionId, language }) => {
9642
+ const copy = getCameraCopy(language)
9643
+ const watches = listKnxAiCameraWatches(node._chatContext, sessionId)
9644
+ if (!watches.length) return copy.noWatches
9645
+ return [copy.watches].concat(watches.map((watch, index) => {
9646
+ const scope = watch.scopeName || watch.scopeId
9647
+ const objects = watch.objectTypes.length ? ` — ${watch.objectTypes.join(', ')}` : ''
9648
+ return `${index + 1}. ${watch.cameraName || watch.cameraId} — ${watch.eventType}${scope ? ` — ${scope}` : ''}${objects}`
9649
+ })).join('\n')
9650
+ }
9651
+
9652
+ const applyCameraActions = ({ actions, sessionId, inputMessage, question, language, reply }) => {
9653
+ const list = Array.isArray(actions) ? actions : []
9654
+ const additions = []
9655
+ let deferredSnapshotReply = false
9656
+ list.forEach((action) => {
9657
+ if (action.type === 'snapshot' || action.type === 'analyze') {
9658
+ startCameraSnapshotRequest({
9659
+ action,
9660
+ sessionId,
9661
+ inputMessage,
9662
+ question,
9663
+ language,
9664
+ caption: action.type === 'snapshot' ? reply : '',
9665
+ notificationEvent: null
9666
+ })
9667
+ deferredSnapshotReply = true
9668
+ return
9669
+ }
9670
+ if (action.type === 'watch') {
9671
+ if (!action.eventType || (!action.cameraId && !action.cameraName && !action.unresolvedTarget)) return
9672
+ const watch = {
9673
+ id: `camera-watch-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`,
9674
+ createdAt: new Date().toISOString(),
9675
+ cameraId: action.cameraId,
9676
+ cameraName: action.cameraName || action.unresolvedTarget,
9677
+ eventType: action.eventType,
9678
+ scopeId: action.scopeId,
9679
+ scopeName: action.scopeName,
9680
+ objectTypes: action.objectTypes,
9681
+ cooldownSeconds: action.cooldownSeconds,
9682
+ sendSnapshot: action.sendSnapshot,
9683
+ language
9684
+ }
9685
+ node._chatContext = addKnxAiCameraWatch(node._chatContext, { sessionId, watch })
9686
+ scheduleChatContextPersist({ immediate: true })
9687
+ additions.push(getCameraCopy(language).watchAdded(watch.cameraName || watch.cameraId))
9688
+ return
9689
+ }
9690
+ if (action.type === 'unwatch') {
9691
+ const targetCamera = normalizeSearchText(action.cameraId || action.cameraName || action.unresolvedTarget)
9692
+ const targetScope = normalizeSearchText(action.scopeId || action.scopeName)
9693
+ const removal = removeKnxAiCameraWatches(node._chatContext, {
9694
+ sessionId,
9695
+ predicate: watch => {
9696
+ const cameraMatches = !targetCamera || [watch.cameraId, watch.cameraName].map(normalizeSearchText).includes(targetCamera)
9697
+ const eventMatches = !action.eventType || watch.eventType === action.eventType
9698
+ const scopeMatches = !targetScope || [watch.scopeId, watch.scopeName].map(normalizeSearchText).includes(targetScope)
9699
+ return cameraMatches && eventMatches && scopeMatches
9700
+ }
9701
+ })
9702
+ node._chatContext = removal.context
9703
+ scheduleChatContextPersist({ immediate: true })
9704
+ additions.push(removal.removed ? getCameraCopy(language).watchRemoved(removal.removed) : getCameraCopy(language).noWatches)
9705
+ return
9706
+ }
9707
+ if (action.type === 'list_watches') additions.push(describeCameraWatches({ sessionId, language }))
9708
+ })
9709
+ return {
9710
+ deferredSnapshotReply: deferredSnapshotReply && list.every(action => action.type === 'snapshot' || action.type === 'analyze'),
9711
+ additions
9712
+ }
9713
+ }
9714
+
9715
+ const handleCameraAdapterEvent = (providerEvent) => {
9716
+ const event = normalizeKnxAiCameraEvent(providerEvent)
9717
+ if (!event || event.active === false) return false
9718
+ const now = nowMs()
9719
+ listAllKnxAiCameraWatches(node._chatContext).filter(watch => cameraWatchMatchesEvent(watch, event)).forEach((watch) => {
9720
+ const lastAt = Number(node._cameraWatchLastTriggered.get(watch.id) || 0)
9721
+ if (lastAt > 0 && (now - lastAt) < (Math.max(10, Number(watch.cooldownSeconds) || 60) * 1000)) return
9722
+ node._cameraWatchLastTriggered.set(watch.id, now)
9723
+ const inputMessage = buildCameraSyntheticInput({ sessionId: watch.sessionId, language: watch.language })
9724
+ const content = buildKnxAiCameraNotificationText({ language: watch.language, event })
9725
+ if (watch.sendSnapshot === false) {
9726
+ emitCameraChatReply({
9727
+ inputMessage,
9728
+ content,
9729
+ metadata: { type: 'camera_notification', event, sessionId: watch.sessionId, language: watch.language }
9730
+ })
9731
+ return
9732
+ }
9733
+ startCameraSnapshotRequest({
9734
+ action: {
9735
+ type: 'snapshot',
9736
+ cameraId: event.cameraId || watch.cameraId,
9737
+ cameraName: event.cameraName || watch.cameraName,
9738
+ reason: event.eventType
9739
+ },
9740
+ sessionId: watch.sessionId,
9741
+ inputMessage,
9742
+ question: '[Camera adapter event]',
9743
+ language: watch.language,
9744
+ caption: content,
9745
+ notificationEvent: event
9746
+ })
9747
+ })
9748
+ return true
9749
+ }
9750
+
9751
+ const syncCameraAdapterRegistry = ({ force = false } = {}) => {
9752
+ if (node._cameraRegistrySyncInFlight) return node._cameraRegistrySyncInFlight
9753
+ const registry = getKnxAiCameraAdapterRegistry()
9754
+ const syncPromise = Promise.resolve().then(async () => {
9755
+ node._cameraAdapters = new Map(registry.adapters)
9756
+ const currentProviders = new Map(registry.providers)
9757
+
9758
+ node._cameraProviderUnsubscribers.forEach((unsubscribe, providerId) => {
9759
+ const previousProvider = node._cameraProviders.get(providerId)
9760
+ const currentProvider = currentProviders.get(providerId)
9761
+ if (currentProvider && currentProvider === previousProvider) return
9762
+ try { if (typeof unsubscribe === 'function') unsubscribe() } catch (error) { /* ignore */ }
9763
+ node._cameraProviderUnsubscribers.delete(providerId)
9764
+ })
9765
+
9766
+ currentProviders.forEach((provider, providerId) => {
9767
+ const previousProvider = node._cameraProviders.get(providerId)
9768
+ node._cameraProviders.set(providerId, provider)
9769
+ if (previousProvider === provider && node._cameraProviderUnsubscribers.has(providerId)) return
9770
+ if (typeof provider.subscribe === 'function') {
9771
+ const unsubscribe = provider.subscribe(event => {
9772
+ try { handleCameraAdapterEvent(event) } catch (error) {
9773
+ try { node.sysLogger?.warn(`KNX AI camera event error: ${error.message || error}`) } catch (logError) { /* ignore */ }
9774
+ }
9775
+ })
9776
+ node._cameraProviderUnsubscribers.set(providerId, typeof unsubscribe === 'function' ? unsubscribe : () => {})
9777
+ }
9778
+ })
9779
+
9780
+ Array.from(node._cameraProviders.keys()).forEach(providerId => {
9781
+ if (!currentProviders.has(providerId)) node._cameraProviders.delete(providerId)
9782
+ })
9783
+
9784
+ const adapterById = node._cameraAdapters
9785
+ const catalogResults = await Promise.all(Array.from(currentProviders.entries()).map(async ([providerId, provider]) => {
9786
+ if (!provider || typeof provider.listCameras !== 'function') return []
9787
+ try {
9788
+ const cameras = await provider.listCameras({ force })
9789
+ const adapter = adapterById.get(String(provider.adapterId || '')) || {}
9790
+ return (Array.isArray(cameras) ? cameras : []).map(camera => normalizeKnxAiCameraRegistration(Object.assign({}, camera, {
9791
+ providerId,
9792
+ adapterId: camera && camera.adapterId || provider.adapterId,
9793
+ adapterTitle: camera && camera.adapterTitle || adapter.title || provider.title,
9794
+ controllerId: camera && camera.controllerId || provider.controllerId,
9795
+ controllerName: camera && camera.controllerName || provider.controllerName
9796
+ }))).filter(Boolean)
9797
+ } catch (error) {
9798
+ try { node.sysLogger?.warn(`KNX AI camera catalog '${providerId}' unavailable: ${error.message || error}`) } catch (logError) { /* ignore */ }
9799
+ return []
9800
+ }
9801
+ }))
9802
+ if (node._closing === true) return
9803
+ const nextCatalog = new Map()
9804
+ catalogResults.flat().forEach(camera => {
9805
+ const key = camera.id || `${camera.providerId}:${normalizeSearchText(camera.name)}`
9806
+ if (key) nextCatalog.set(key, camera)
9807
+ })
9808
+ node._cameraCatalog = nextCatalog
9809
+ }).finally(() => {
9810
+ if (node._cameraRegistrySyncInFlight === syncPromise) node._cameraRegistrySyncInFlight = null
9811
+ })
9812
+ node._cameraRegistrySyncInFlight = syncPromise
9813
+ return syncPromise
9814
+ }
9815
+ node.refreshCameraAdapterRegistry = syncCameraAdapterRegistry
9816
+
8968
9817
  const handleKnxAiConfirmationDecision = ({ msg, question, sessionId, decision }) => {
8969
9818
  const pending = node._pendingKnxCommands.get(sessionId)
8970
9819
  const language = pending && pending.language
@@ -9625,12 +10474,19 @@ module.exports = function (RED) {
9625
10474
  node._lastSummary = null
9626
10475
  node._lastSummaryAt = 0
9627
10476
  node._conversationSessions = new Map()
10477
+ node._chatContext = createEmptyKnxAiChatContext()
9628
10478
  node._pendingKnxCommands = new Map()
10479
+ node._pendingCameraRequests.forEach(pending => {
10480
+ try { if (pending && pending.timer) clearTimeout(pending.timer) } catch (error) { /* ignore */ }
10481
+ })
10482
+ node._pendingCameraRequests = new Map()
10483
+ node._cameraWatchLastTriggered = new Map()
9629
10484
  node._homeMemory = createEmptyKnxAiHomeMemory()
9630
10485
  node._proactiveStates = new Map()
9631
10486
  node._proactiveInFlight = new Set()
9632
10487
  node._proactiveGlobalSentAt = []
9633
10488
  scheduleHomeMemoryPersist({ immediate: true })
10489
+ scheduleChatContextPersist({ immediate: true })
9634
10490
  if (node._summaryRebuildTimer) {
9635
10491
  clearTimeout(node._summaryRebuildTimer)
9636
10492
  node._summaryRebuildTimer = null
@@ -9671,14 +10527,18 @@ module.exports = function (RED) {
9671
10527
  node._pendingKnxCommands.delete(sessionId)
9672
10528
  updateStatus({ fill: 'blue', shape: 'ring', text: 'AI thinking...' })
9673
10529
  try {
9674
- const ret = node.llmAllowKnxCommands
10530
+ await syncCameraAdapterRegistry()
10531
+ const cameraChatAvailable = node._cameraAdapters.size > 0 || node._cameraCatalog.size > 0
10532
+ const ret = node.llmAllowKnxCommands || cameraChatAvailable
9675
10533
  ? await callConversationalLLM({
9676
10534
  question,
9677
10535
  sessionId,
9678
- requireConfirmation: node.llmRequireCommandConfirmation
10536
+ requireConfirmation: node.llmRequireCommandConfirmation,
10537
+ allowKnxCommands: node.llmAllowKnxCommands
9679
10538
  })
9680
- : await callLLM({ question })
10539
+ : await callLLM({ question, sessionId })
9681
10540
  const preparedCommands = Array.isArray(ret.commands) ? ret.commands : []
10541
+ const preparedCameraActions = Array.isArray(ret.cameraActions) ? ret.cameraActions : []
9682
10542
  const readCommands = preparedCommands.filter(command => command && command.event === 'GroupValue_Read')
9683
10543
  const writeCommands = preparedCommands.filter(command => !command || command.event !== 'GroupValue_Read')
9684
10544
  const language = resolveKnxAiLanguage(msg, node.llmDocsLanguage || 'en', question, ret.language)
@@ -9688,6 +10548,18 @@ module.exports = function (RED) {
9688
10548
  node.llmRequireCommandConfirmation &&
9689
10549
  writeCommands.length > 0
9690
10550
  let content = ret.content
10551
+ const cameraActionResult = applyCameraActions({
10552
+ actions: preparedCameraActions,
10553
+ sessionId,
10554
+ inputMessage: msg,
10555
+ question,
10556
+ language,
10557
+ reply: content
10558
+ })
10559
+ if (cameraActionResult.additions.length) {
10560
+ content = [content].concat(cameraActionResult.additions).filter(Boolean).join('\n\n')
10561
+ }
10562
+ const deferCameraReply = cameraActionResult.deferredSnapshotReply && preparedCommands.length === 0
9691
10563
  let commandsToEmit = preparedCommands
9692
10564
  let confirmationRequest = null
9693
10565
  if (awaitingConfirmation) {
@@ -9766,13 +10638,14 @@ module.exports = function (RED) {
9766
10638
  sessionId,
9767
10639
  commandCount: writeCommands.length,
9768
10640
  readCount: readCommands.length,
10641
+ cameraActionCount: preparedCameraActions.length,
9769
10642
  language,
9770
10643
  awaitingConfirmation,
9771
10644
  rejectedCommandCount: Array.isArray(ret.rejectedCommands) ? ret.rejectedCommands.length : 0
9772
10645
  }
9773
10646
  node._assistantLog.push(assistantEntry)
9774
10647
  while (node._assistantLog.length > 50) node._assistantLog.shift()
9775
- if (node.llmAllowKnxCommands) rememberConversationTurn({ sessionId, question, reply: content })
10648
+ if (!deferCameraReply) rememberConversationTurn({ sessionId, question, reply: content })
9776
10649
  const replyMessage = buildKnxAiReplyMessage({
9777
10650
  inputMessage: msg,
9778
10651
  content,
@@ -9786,6 +10659,7 @@ module.exports = function (RED) {
9786
10659
  operationCount: preparedCommands.length,
9787
10660
  commandCount: writeCommands.length,
9788
10661
  readCount: readCommands.length,
10662
+ cameraActionCount: preparedCameraActions.length,
9789
10663
  readResults: readResultMetadata,
9790
10664
  awaitingConfirmation,
9791
10665
  confirmationExpiresAt: confirmationRequest ? confirmationRequest.expiresAt : 0,
@@ -9795,7 +10669,10 @@ module.exports = function (RED) {
9795
10669
  },
9796
10670
  summary: emittedReadCommands.length > 0 ? rebuildCachedSummaryNow() : ret.summary
9797
10671
  })
9798
- if (emittedReadCommands.length > 0) {
10672
+ if (deferCameraReply) {
10673
+ // The matching camera provider returns the snapshot asynchronously;
10674
+ // its image (and optional visual analysis) becomes the chat reply.
10675
+ } else if (emittedReadCommands.length > 0) {
9799
10676
  if (!sendKnxAiOutputs([null, null, replyMessage, null], msg)) return
9800
10677
  } else if (!sendKnxAiOutputs([null, null, replyMessage, commandMessages.length ? commandMessages : null], msg)) {
9801
10678
  return
@@ -9828,7 +10705,9 @@ module.exports = function (RED) {
9828
10705
  if (cmd === 'clear_chat') {
9829
10706
  const sessionId = resolveKnxAiSessionId(msg)
9830
10707
  node._conversationSessions.delete(sessionId)
10708
+ node._chatContext = clearKnxAiChatSession(node._chatContext, sessionId)
9831
10709
  node._pendingKnxCommands.delete(sessionId)
10710
+ scheduleChatContextPersist({ immediate: true })
9832
10711
  const replyMessage = buildKnxAiReplyMessage({
9833
10712
  inputMessage: msg,
9834
10713
  content: { ok: true, sessionId },
@@ -9960,9 +10839,11 @@ module.exports = function (RED) {
9960
10839
  const q = String(question || '').trim()
9961
10840
  if (q === '') throw new Error('Missing question')
9962
10841
  updateStatus({ fill: 'blue', shape: 'ring', text: 'AI thinking...' })
9963
- const ret = await callLLM({ question: q })
10842
+ const sessionId = 'sidebar'
10843
+ const ret = await callLLM({ question: q, sessionId })
9964
10844
  node._assistantLog.push({ at: new Date().toISOString(), question: q, content: ret.content, provider: ret.provider, model: ret.model })
9965
10845
  while (node._assistantLog.length > 50) node._assistantLog.shift()
10846
+ rememberConversationTurn({ sessionId, question: q, reply: ret.content })
9966
10847
  updateStatus({ fill: 'green', shape: 'dot', text: 'AI answer ready' })
9967
10848
  return { answer: ret.content, provider: ret.provider, model: ret.model, summary: ret.summary }
9968
10849
  }
@@ -9985,6 +10866,10 @@ module.exports = function (RED) {
9985
10866
  return
9986
10867
  }
9987
10868
  if (!adaptedMessage) return
10869
+ const adaptedTopic = String(adaptedMessage.topic || '').toLocaleLowerCase()
10870
+ if (adaptedTopic === 'ask' || adaptedTopic === 'chat' || adaptedTopic === 'question' || adaptedTopic === 'prompt') {
10871
+ rememberChatSessionSource({ sessionId: resolveKnxAiSessionId(adaptedMessage), msg })
10872
+ }
9988
10873
  const p = handleCommand(adaptedMessage)
9989
10874
  if (p && typeof p.catch === 'function') {
9990
10875
  p.catch((error) => {
@@ -10005,11 +10890,44 @@ module.exports = function (RED) {
10005
10890
  if (node._busConnectionWatchTimer) clearInterval(node._busConnectionWatchTimer)
10006
10891
  if (node._homeMemoryPeriodicTimer) clearInterval(node._homeMemoryPeriodicTimer)
10007
10892
  if (node._proactiveCheckTimer) clearInterval(node._proactiveCheckTimer)
10893
+ if (node._cameraRegistrySyncTimer) clearInterval(node._cameraRegistrySyncTimer)
10894
+ node._cameraRegistrySyncTimer = null
10895
+ try { if (typeof node._cameraRegistryUnsubscribe === 'function') node._cameraRegistryUnsubscribe() } catch (error) { /* ignore */ }
10896
+ node._cameraRegistryUnsubscribe = null
10897
+ node._cameraProviderUnsubscribers.forEach(unsubscribe => {
10898
+ try { if (typeof unsubscribe === 'function') unsubscribe() } catch (error) { /* ignore */ }
10899
+ })
10900
+ node._cameraProviderUnsubscribers.clear()
10008
10901
  if (node._homeMemoryWriteTimer) {
10009
10902
  clearTimeout(node._homeMemoryWriteTimer)
10010
10903
  node._homeMemoryWriteTimer = null
10011
10904
  }
10905
+ if (node._chatContextWriteTimer) {
10906
+ clearTimeout(node._chatContextWriteTimer)
10907
+ node._chatContextWriteTimer = null
10908
+ }
10909
+ if (node._pendingCameraRequests instanceof Map) {
10910
+ node._pendingCameraRequests.forEach(pending => {
10911
+ try { if (pending && pending.timer) clearTimeout(pending.timer) } catch (error) { /* ignore */ }
10912
+ })
10913
+ node._pendingCameraRequests.clear()
10914
+ }
10012
10915
  persistHomeMemoryNow()
10916
+ persistChatContextNow()
10917
+ if (node._homeMemoryStorePath) {
10918
+ releaseSharedKnxAiState({
10919
+ registry: sharedKnxAiHomeMemoryStores,
10920
+ filePath: node._homeMemoryStorePath,
10921
+ node
10922
+ })
10923
+ }
10924
+ if (node._chatContextStorePath) {
10925
+ releaseSharedKnxAiState({
10926
+ registry: sharedKnxAiChatContextStores,
10927
+ filePath: node._chatContextStorePath,
10928
+ node
10929
+ })
10930
+ }
10013
10931
  if (node._summaryRebuildTimer) {
10014
10932
  clearTimeout(node._summaryRebuildTimer)
10015
10933
  node._summaryRebuildTimer = null
@@ -10046,10 +10964,30 @@ module.exports = function (RED) {
10046
10964
  pruneHistoryArchiveFiles({ force: true })
10047
10965
  loadRecentHistoryFromDisk()
10048
10966
  loadHomeMemoryFromDisk()
10967
+ loadChatContextFromDisk()
10049
10968
  } catch (error) {
10050
10969
  node.sysLogger?.warn(`KNX AI history startup error: ${error.message || error}`)
10051
10970
  }
10052
10971
 
10972
+ try {
10973
+ const cameraRegistry = getKnxAiCameraAdapterRegistry()
10974
+ node._cameraRegistryUnsubscribe = cameraRegistry.subscribe(() => {
10975
+ Promise.resolve(syncCameraAdapterRegistry({ force: true })).catch(error => {
10976
+ try { node.sysLogger?.warn(`KNX AI camera adapter refresh error: ${error.message || error}`) } catch (logError) { /* ignore */ }
10977
+ })
10978
+ })
10979
+ Promise.resolve(syncCameraAdapterRegistry({ force: true })).catch(error => {
10980
+ try { node.sysLogger?.warn(`KNX AI camera adapter startup error: ${error.message || error}`) } catch (logError) { /* ignore */ }
10981
+ })
10982
+ node._cameraRegistrySyncTimer = setInterval(() => {
10983
+ Promise.resolve(syncCameraAdapterRegistry()).catch(error => {
10984
+ try { node.sysLogger?.warn(`KNX AI camera adapter refresh error: ${error.message || error}`) } catch (logError) { /* ignore */ }
10985
+ })
10986
+ }, 30 * 1000)
10987
+ } catch (error) {
10988
+ try { node.sysLogger?.warn(`KNX AI camera registry unavailable: ${error.message || error}`) } catch (logError) { /* ignore */ }
10989
+ }
10990
+
10053
10991
  if (node._homeMemoryPeriodicTimer) clearInterval(node._homeMemoryPeriodicTimer)
10054
10992
  node._homeMemoryPeriodicTimer = setInterval(() => {
10055
10993
  try { persistHomeMemoryNow() } catch (error) { /* persistHomeMemoryNow already guards */ }
@@ -10079,6 +11017,8 @@ module.exports = function (RED) {
10079
11017
  }
10080
11018
 
10081
11019
  module.exports.__test = {
11020
+ bindSharedKnxAiState,
11021
+ applyKnxAiChatMediaPresetFallback,
10082
11022
  buildKnxAiPackageNodeCatalog,
10083
11023
  buildKnxAiConfirmationRequest,
10084
11024
  buildKnxAiUniversalMessage,
@@ -10102,6 +11042,8 @@ module.exports.__test = {
10102
11042
  resolveKnxAiLanguage,
10103
11043
  resolveKnxAiOperationEvent,
10104
11044
  resolveKnxAiSessionId,
11045
+ releaseSharedKnxAiState,
10105
11046
  safeKnxAiSend,
11047
+ summarizeDetectedKnxAiCameraAdapters,
10106
11048
  validateKnxAiPayloadForDpt
10107
11049
  }