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.
- package/CHANGELOG.md +11 -0
- package/examples/KNX AI - Telegrambot Direct Chat.json +0 -1
- package/nodes/knxUltimateAI.html +100 -7
- package/nodes/knxUltimateAI.js +995 -53
- package/nodes/locales/de/knxUltimateAI.html +11 -6
- package/nodes/locales/de/knxUltimateAI.json +8 -3
- package/nodes/locales/en/knxUltimateAI.html +11 -6
- package/nodes/locales/en/knxUltimateAI.json +8 -3
- package/nodes/locales/es/knxUltimateAI.html +11 -6
- package/nodes/locales/es/knxUltimateAI.json +8 -3
- package/nodes/locales/fr/knxUltimateAI.html +11 -6
- package/nodes/locales/fr/knxUltimateAI.json +8 -3
- package/nodes/locales/it/knxUltimateAI.html +11 -6
- package/nodes/locales/it/knxUltimateAI.json +8 -3
- package/nodes/locales/zh-CN/knxUltimateAI.html +11 -6
- package/nodes/locales/zh-CN/knxUltimateAI.json +8 -3
- package/nodes/utils/knxAiCamera.js +302 -0
- package/nodes/utils/knxAiChatContext.js +357 -0
- package/nodes/utils/knxAiHomeMemory.js +2 -2
- package/package.json +1 -1
- package/resources/KNXAIChatAdapterMappings.js +152 -0
package/nodes/knxUltimateAI.js
CHANGED
|
@@ -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
|
-
|
|
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',
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
6530
|
-
|
|
6531
|
-
|
|
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
|
-
|
|
6534
|
-
|
|
6535
|
-
|
|
6536
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: [{
|
|
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
|
-
{
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
8817
|
-
|
|
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 =
|
|
8878
|
-
|
|
8879
|
-
|
|
8880
|
-
|
|
8881
|
-
|
|
8882
|
-
|
|
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 (
|
|
8910
|
-
const adaptOne = message =>
|
|
8911
|
-
|
|
8912
|
-
|
|
8913
|
-
|
|
8914
|
-
|
|
8915
|
-
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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
|
|
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
|
}
|