node-red-contrib-knx-ultimate 6.3.15 → 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.
@@ -3,6 +3,7 @@ const CHAT_CONTEXT_MAX_BYTES = 512 * 1024
3
3
  const CHAT_CONTEXT_MAX_SESSIONS = 50
4
4
  const CHAT_CONTEXT_MAX_TURNS_PER_SESSION = 8
5
5
  const CHAT_CONTEXT_MAX_INSTRUCTIONS_PER_SESSION = 20
6
+ const CHAT_CONTEXT_MAX_CAMERA_WATCHES_PER_SESSION = 20
6
7
  const CHAT_CONTEXT_MAX_QUESTION_CHARS = 4000
7
8
  const CHAT_CONTEXT_MAX_REPLY_CHARS = 8000
8
9
  const CHAT_CONTEXT_MAX_INSTRUCTION_CHARS = 2000
@@ -42,6 +43,30 @@ const normalizeInstruction = (instruction) => {
42
43
  }
43
44
  }
44
45
 
46
+ const normalizeCameraWatch = (watch) => {
47
+ if (!watch || typeof watch !== 'object' || Array.isArray(watch)) return null
48
+ const id = clampText(watch.id, 160)
49
+ const cameraId = clampText(watch.cameraId, 160)
50
+ const cameraName = clampText(watch.cameraName, 240)
51
+ const eventType = clampText(watch.eventType, 80)
52
+ if (!id || (!cameraId && !cameraName) || !eventType) return null
53
+ return {
54
+ id,
55
+ createdAt: clampText(watch.createdAt || new Date().toISOString(), 64),
56
+ cameraId,
57
+ cameraName,
58
+ eventType,
59
+ scopeId: clampText(watch.scopeId, 160),
60
+ scopeName: clampText(watch.scopeName, 240),
61
+ objectTypes: Array.from(new Set((Array.isArray(watch.objectTypes) ? watch.objectTypes : [])
62
+ .map(value => clampText(value, 80).toLocaleLowerCase())
63
+ .filter(Boolean))).slice(0, 12),
64
+ cooldownSeconds: Math.max(10, Math.min(86400, Number(watch.cooldownSeconds) || 60)),
65
+ sendSnapshot: watch.sendSnapshot !== false,
66
+ language: clampText(watch.language || 'en', 8).toLocaleLowerCase()
67
+ }
68
+ }
69
+
45
70
  const normalizeSession = (session) => {
46
71
  if (!session || typeof session !== 'object' || Array.isArray(session)) return null
47
72
  const id = normalizeSessionId(session.id)
@@ -53,11 +78,16 @@ const normalizeSession = (session) => {
53
78
  .map(normalizeInstruction)
54
79
  .filter(Boolean)
55
80
  .slice(-CHAT_CONTEXT_MAX_INSTRUCTIONS_PER_SESSION)
81
+ const cameraWatches = (Array.isArray(session.cameraWatches) ? session.cameraWatches : [])
82
+ .map(normalizeCameraWatch)
83
+ .filter(Boolean)
84
+ .slice(-CHAT_CONTEXT_MAX_CAMERA_WATCHES_PER_SESSION)
56
85
  return {
57
86
  id,
58
87
  updatedAt: clampText(session.updatedAt || new Date().toISOString(), 64),
59
88
  turns,
60
- instructions
89
+ instructions,
90
+ cameraWatches
61
91
  }
62
92
  }
63
93
 
@@ -87,7 +117,7 @@ const touchSession = (context, sessionId) => {
87
117
  const target = normalizeKnxAiChatContext(context)
88
118
  const id = normalizeSessionId(sessionId)
89
119
  const existing = target.sessions.find(session => session.id === id)
90
- const session = existing || { id, updatedAt: new Date().toISOString(), turns: [], instructions: [] }
120
+ const session = existing || { id, updatedAt: new Date().toISOString(), turns: [], instructions: [], cameraWatches: [] }
91
121
  target.sessions = target.sessions.filter(item => item.id !== id)
92
122
  target.sessions.push(session)
93
123
  target.sessions = target.sessions.slice(-CHAT_CONTEXT_MAX_SESSIONS)
@@ -146,19 +176,45 @@ const clearKnxAiChatSession = (context, sessionId) => {
146
176
  return target
147
177
  }
148
178
 
179
+ const addKnxAiCameraWatch = (context, { sessionId, watch } = {}) => {
180
+ const normalized = normalizeCameraWatch(watch)
181
+ if (!normalized) return normalizeKnxAiChatContext(context)
182
+ const { target, session } = touchSession(context, sessionId)
183
+ session.cameraWatches = (Array.isArray(session.cameraWatches) ? session.cameraWatches : [])
184
+ .filter(item => item.id !== normalized.id)
185
+ session.cameraWatches.push(normalized)
186
+ session.cameraWatches = session.cameraWatches.slice(-CHAT_CONTEXT_MAX_CAMERA_WATCHES_PER_SESSION)
187
+ return target
188
+ }
189
+
190
+ const removeKnxAiCameraWatches = (context, { sessionId, predicate } = {}) => {
191
+ const { target, session } = touchSession(context, sessionId)
192
+ const before = Array.isArray(session.cameraWatches) ? session.cameraWatches.length : 0
193
+ session.cameraWatches = (Array.isArray(session.cameraWatches) ? session.cameraWatches : [])
194
+ .filter(watch => !(typeof predicate === 'function' && predicate(watch)))
195
+ return { context: target, removed: Math.max(0, before - session.cameraWatches.length) }
196
+ }
197
+
198
+ const listKnxAiCameraWatches = (context, sessionId) => getKnxAiChatSession(context, sessionId).cameraWatches.slice()
199
+
200
+ const listAllKnxAiCameraWatches = (context) => normalizeKnxAiChatContext(context).sessions.flatMap(session => {
201
+ return session.cameraWatches.map(watch => Object.assign({ sessionId: session.id }, watch))
202
+ })
203
+
149
204
  const getKnxAiChatSession = (context, sessionId) => {
150
205
  const session = findSession(context, sessionId)
151
206
  return session || {
152
207
  id: normalizeSessionId(sessionId),
153
208
  updatedAt: '',
154
209
  turns: [],
155
- instructions: []
210
+ instructions: [],
211
+ cameraWatches: []
156
212
  }
157
213
  }
158
214
 
159
215
  const buildKnxAiChatPromptContext = ({ context, sessionId, maxChars = 16000 } = {}) => {
160
216
  const session = getKnxAiChatSession(context, sessionId)
161
- if (!session.instructions.length && !session.turns.length) return ''
217
+ if (!session.instructions.length && !session.turns.length && !session.cameraWatches.length) return ''
162
218
  const lines = []
163
219
  if (session.instructions.length) {
164
220
  lines.push('PERSISTENT CHAT INSTRUCTIONS AND PREFERENCES (explicitly supplied by this user; follow them unless they conflict with safety or the KNX contract; newer entries override older conflicting entries):')
@@ -172,6 +228,16 @@ const buildKnxAiChatPromptContext = ({ context, sessionId, maxChars = 16000 } =
172
228
  lines.push(`Assistant: ${turn.reply}`)
173
229
  })
174
230
  }
231
+ if (session.cameraWatches.length) {
232
+ if (lines.length) lines.push('')
233
+ lines.push('ACTIVE CAMERA WATCHES (persistent notification rules created by this chat):')
234
+ session.cameraWatches.forEach((watch) => {
235
+ const camera = watch.cameraName || watch.cameraId
236
+ const scope = watch.scopeName || watch.scopeId
237
+ const objects = watch.objectTypes.length ? `; objects ${watch.objectTypes.join(', ')}` : ''
238
+ lines.push(`- ${watch.id}: ${camera}; event ${watch.eventType}${scope ? `; scope ${scope}` : ''}${objects}; cooldown ${watch.cooldownSeconds}s`)
239
+ })
240
+ }
175
241
  return lines.join('\n').slice(0, Math.max(1000, Number(maxChars) || 16000))
176
242
  }
177
243
 
@@ -201,6 +267,14 @@ const renderKnxAiChatContext = (context) => {
201
267
  lines.push('### Persistent instructions', '')
202
268
  if (!session.instructions.length) lines.push('_None._')
203
269
  session.instructions.forEach(item => lines.push(`- ${escapeMarkdownText(item.at)} — ${escapeMarkdownText(item.text)}`))
270
+ lines.push('', '### Camera watches', '')
271
+ if (!session.cameraWatches.length) lines.push('_None._')
272
+ session.cameraWatches.forEach((watch) => {
273
+ const camera = watch.cameraName || watch.cameraId
274
+ const scope = watch.scopeName || watch.scopeId
275
+ const objects = watch.objectTypes.length ? `; objects ${watch.objectTypes.join(', ')}` : ''
276
+ lines.push(`- ${escapeMarkdownText(watch.id)} — ${escapeMarkdownText(camera)}; event ${escapeMarkdownText(watch.eventType)}${scope ? `; scope ${escapeMarkdownText(scope)}` : ''}${objects}; cooldown ${watch.cooldownSeconds}s`)
277
+ })
204
278
  lines.push('', '### Recent turns', '')
205
279
  if (!session.turns.length) lines.push('_None._')
206
280
  session.turns.forEach((turn) => {
@@ -260,10 +334,12 @@ const conversationMapFromKnxAiChatContext = (context) => {
260
334
  }
261
335
 
262
336
  module.exports = {
337
+ CHAT_CONTEXT_MAX_CAMERA_WATCHES_PER_SESSION,
263
338
  CHAT_CONTEXT_MAX_BYTES,
264
339
  CHAT_CONTEXT_MAX_INSTRUCTIONS_PER_SESSION,
265
340
  CHAT_CONTEXT_MAX_SESSIONS,
266
341
  CHAT_CONTEXT_MAX_TURNS_PER_SESSION,
342
+ addKnxAiCameraWatch,
267
343
  addKnxAiChatTurn,
268
344
  buildKnxAiChatContextMarkdown,
269
345
  buildKnxAiChatPromptContext,
@@ -272,6 +348,10 @@ module.exports = {
272
348
  createEmptyKnxAiChatContext,
273
349
  extractExplicitKnxAiChatInstruction,
274
350
  getKnxAiChatSession,
351
+ listAllKnxAiCameraWatches,
352
+ listKnxAiCameraWatches,
275
353
  normalizeKnxAiChatContext,
276
- parseKnxAiChatContextMarkdown
354
+ normalizeCameraWatch,
355
+ parseKnxAiChatContextMarkdown,
356
+ removeKnxAiCameraWatches
277
357
  }
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "engines": {
4
4
  "node": ">=20.18.1"
5
5
  },
6
- "version": "6.3.15",
6
+ "version": "6.3.16",
7
7
  "description": "KNX Ultimate is the most advanced KNX integration for Node-RED, providing secure KNX/IP communication, routing, ETS project import, Philips Hue, Matter Controller and Matter Bridge (control matter device via KNX and expose KNX GA via Matter), MQTT, diagnostics with AI, virtual devices, and powerful automation nodes. Build professional, reliable, and scalable smart home and building automation projects with minimal effort.",
8
8
  "files": [
9
9
  "nodes/",
@@ -52,6 +52,23 @@ if (content && typeof content === 'object') {
52
52
  }
53
53
  content = String(content === undefined || content === null ? '' : content);
54
54
 
55
+ const image = msg.knxAi && msg.knxAi.image;
56
+ if (image && Buffer.isBuffer(image.data)) {
57
+ msg.payload = {
58
+ chatId: chatId,
59
+ type: 'photo',
60
+ content: image.data,
61
+ options: {
62
+ caption: content.slice(0, 1024)
63
+ },
64
+ fileOptions: {
65
+ filename: image.filename || 'camera-snapshot.jpg',
66
+ contentType: image.mediaType || 'image/jpeg'
67
+ }
68
+ };
69
+ return msg;
70
+ }
71
+
55
72
  const telegramPayload = {
56
73
  chatId: chatId,
57
74
  type: 'message',
@@ -133,7 +150,37 @@ if (chatId === undefined || chatId === null) return;
133
150
  msg[key] = source[key];
134
151
  }
135
152
  });
136
- if (typeof msg.chat !== 'function' || typeof msg.client !== 'function') return;
153
+
154
+ // A persisted camera watch can fire immediately after Node-RED restarts,
155
+ // before a new inbound RedBot message has recreated its conversation helpers.
156
+ // RedBot's Telegram Sender still needs transport metadata and a chat accessor,
157
+ // even when tracking is disabled. Supply the smallest compatible outbound
158
+ // envelope; the configured Sender remains responsible for choosing the bot.
159
+ if (!msg.originalMessage || typeof msg.originalMessage !== 'object') {
160
+ msg.originalMessage = {};
161
+ }
162
+ if (!msg.originalMessage.transport) msg.originalMessage.transport = sourcePayload.transport || 'telegram';
163
+ if (msg.originalMessage.chatId === undefined) msg.originalMessage.chatId = chatId;
164
+ if (sourcePayload.userId !== undefined && msg.originalMessage.userId === undefined) {
165
+ msg.originalMessage.userId = sourcePayload.userId;
166
+ }
167
+ if (typeof msg.chat !== 'function') {
168
+ msg.chat = function () {
169
+ return {
170
+ get: function () { return {}; },
171
+ set: function () { return undefined; },
172
+ remove: function () { return undefined; }
173
+ };
174
+ };
175
+ }
176
+ if (typeof msg.get !== 'function') {
177
+ msg.get = function (key) {
178
+ if (key === 'userId') return sourcePayload.userId;
179
+ if (key === 'chatId') return chatId;
180
+ if (key === 'transport') return sourcePayload.transport || 'telegram';
181
+ return undefined;
182
+ };
183
+ }
137
184
 
138
185
  let content = msg.payload;
139
186
  if (content && typeof content === 'object') {
@@ -141,6 +188,22 @@ if (content && typeof content === 'object') {
141
188
  }
142
189
  content = String(content === undefined || content === null ? '' : content);
143
190
 
191
+ const image = msg.knxAi && msg.knxAi.image;
192
+ if (image && Buffer.isBuffer(image.data)) {
193
+ msg.payload = {
194
+ transport: sourcePayload.transport || 'telegram',
195
+ chatId: chatId,
196
+ type: 'photo',
197
+ inbound: false,
198
+ content: image.data,
199
+ filename: image.filename || 'camera-snapshot.jpg',
200
+ mimeType: image.mediaType || 'image/jpeg',
201
+ caption: content.slice(0, 1024)
202
+ };
203
+ if (sourcePayload.userId !== undefined) msg.payload.userId = sourcePayload.userId;
204
+ return msg;
205
+ }
206
+
144
207
  const redbotPayload = {
145
208
  transport: sourcePayload.transport || 'telegram',
146
209
  chatId: chatId,