node-red-contrib-knx-ultimate 7.1.0-beta.2 → 7.1.2

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.
Files changed (52) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +5 -1
  3. package/nodes/knxUltimateAI.html +1820 -0
  4. package/nodes/knxUltimateAI.js +16797 -0
  5. package/nodes/knxUltimateAIHomeAssistant.html +40 -0
  6. package/nodes/knxUltimateAIHomeAssistant.js +152 -0
  7. package/nodes/knxUltimateHueController.html +1 -1
  8. package/nodes/knxUltimateMatterBridge.html +2 -2
  9. package/nodes/knxUltimateMatterControllerDevice.html +2 -2
  10. package/nodes/locales/de/knxUltimateAI.html +5 -0
  11. package/nodes/locales/de/knxUltimateAI.json +231 -0
  12. package/nodes/locales/de/knxUltimateUtility.json +8 -0
  13. package/nodes/locales/en/knxUltimateAI.html +5 -0
  14. package/nodes/locales/en/knxUltimateAI.json +234 -0
  15. package/nodes/locales/en/knxUltimateUtility.json +8 -0
  16. package/nodes/locales/es/knxUltimateAI.html +5 -0
  17. package/nodes/locales/es/knxUltimateAI.json +231 -0
  18. package/nodes/locales/es/knxUltimateUtility.json +9 -1
  19. package/nodes/locales/fr/knxUltimateAI.html +5 -0
  20. package/nodes/locales/fr/knxUltimateAI.json +231 -0
  21. package/nodes/locales/fr/knxUltimateUtility.json +8 -0
  22. package/nodes/locales/it/knxUltimateAI.html +5 -0
  23. package/nodes/locales/it/knxUltimateAI.json +234 -0
  24. package/nodes/locales/it/knxUltimateUtility.json +8 -0
  25. package/nodes/locales/zh-CN/knxUltimateAI.html +5 -0
  26. package/nodes/locales/zh-CN/knxUltimateAI.json +231 -0
  27. package/nodes/locales/zh-CN/knxUltimateUtility.json +8 -0
  28. package/nodes/plugins/knxUltimate-cerebrum-runtime-plugin.js +79 -0
  29. package/nodes/plugins/knxUltimate-migration-notice-plugin.html +19 -0
  30. package/nodes/plugins/knxUltimateAI-vue/assets/app.css +1 -0
  31. package/nodes/plugins/knxUltimateAI-vue/assets/app.js +13 -0
  32. package/nodes/plugins/knxUltimateAI-vue/assets/chunk-html2canvas.esm.js +5 -0
  33. package/nodes/plugins/knxUltimateAI-vue/assets/chunk-index.es.js +5 -0
  34. package/nodes/plugins/knxUltimateAI-vue/assets/chunk-jspdf.es.min.js +79 -0
  35. package/nodes/plugins/knxUltimateAI-vue/assets/chunk-purify.es.js +3 -0
  36. package/nodes/plugins/knxUltimateAI-vue/index.html +13 -0
  37. package/nodes/plugins/knxUltimateViewer-vue/assets/app.js +2 -2
  38. package/nodes/utils/knxAiCamera.js +302 -0
  39. package/nodes/utils/knxAiCatalogRetrieval.js +349 -0
  40. package/nodes/utils/knxAiCerebrum.js +406 -0
  41. package/nodes/utils/knxAiChatContext.js +496 -0
  42. package/nodes/utils/knxAiEventHistory.js +521 -0
  43. package/nodes/utils/knxAiHomeMemory.js +1021 -0
  44. package/nodes/utils/knxAiScheduler.js +445 -0
  45. package/nodes/utils/knxAiSemanticContext.js +662 -0
  46. package/nodes/utils/knxAiTelegramVoice.js +545 -0
  47. package/nodes/utils/knxAiWebAccess.js +631 -0
  48. package/package.json +17 -9
  49. package/resources/KNXAIChatAdapterMappings.js +416 -0
  50. package/resources/hueControllerMigrationDialog.js +3 -0
  51. package/resources/knxUtilityMigration.js +3 -0
  52. package/resources/legacyMigrationNotice.js +143 -0
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "engines": {
4
4
  "node": ">=20.18.1"
5
5
  },
6
- "version": "7.1.0-beta.2",
6
+ "version": "7.1.2",
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 and Modbus adapters, diagnostics, 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/",
@@ -24,13 +24,17 @@
24
24
  "mqtt": "^5.15.2",
25
25
  "ping": "0.4.4",
26
26
  "simple-get": "4.0.1",
27
- "winston": "^3.17.0"
27
+ "winston": "^3.17.0",
28
+ "google-translate-tts": "^0.3.0",
29
+ "js-yaml": "4.3.2"
28
30
  },
29
31
  "node-red": {
30
32
  "version": ">=3.1.1",
31
33
  "plugins": {
32
34
  "commonFunctions": "/nodes/commonFunctions.js",
33
- "knxUltimateFlowBubbles": "/nodes/plugins/knxUltimate-flow-bubbles-plugin.html"
35
+ "knxUltimateFlowBubbles": "/nodes/plugins/knxUltimate-flow-bubbles-plugin.html",
36
+ "knxUltimateMigrationNotice": "/nodes/plugins/knxUltimate-migration-notice-plugin.html",
37
+ "knxUltimateCerebrumRuntime": "/nodes/plugins/knxUltimate-cerebrum-runtime-plugin.js"
34
38
  },
35
39
  "nodes": {
36
40
  "knxUltimate": "/nodes/knxUltimate.js",
@@ -71,7 +75,9 @@
71
75
  "knxUltimateHueCameraMotion": "/nodes/knxUltimateHueCameraMotion.js",
72
76
  "matter-config": "/nodes/matter-config.js",
73
77
  "matterbridge-config": "/nodes/matterbridge-config.js",
74
- "knxUltimateMatterBridge": "/nodes/knxUltimateMatterBridge.js"
78
+ "knxUltimateMatterBridge": "/nodes/knxUltimateMatterBridge.js",
79
+ "knxUltimateAI": "/nodes/knxUltimateAI.js",
80
+ "knxUltimateAIHomeAssistant": "/nodes/knxUltimateAIHomeAssistant.js"
75
81
  }
76
82
  },
77
83
  "repository": {
@@ -105,7 +111,7 @@
105
111
  },
106
112
  "license": "MIT",
107
113
  "scripts": {
108
- "build": "npm run knx-viewer:build",
114
+ "build": "npm run knx-ai:build && npm run knx-viewer:build",
109
115
  "prepack": "npm run build",
110
116
  "prepublishOnly": "npm run build",
111
117
  "test": "npm run hue-controller:check && npm run knx-utility:check && DEBUG_KNX_HUE_TEST=0 npm run test:unit && node scripts/check-node-load.js",
@@ -137,15 +143,17 @@
137
143
  "docs:serve": "node scripts/dev-serve-docs.js",
138
144
  "docs:serve:norl": "node scripts/dev-serve-docs.js",
139
145
  "knx-utility:generate": "node scripts/generate-knx-utility-profiles.js",
140
- "knx-utility:check": "node scripts/generate-knx-utility-profiles.js --check"
146
+ "knx-utility:check": "node scripts/generate-knx-utility-profiles.js --check",
147
+ "knx-ai:dev": "vite --config ui/knxUltimateAI-vue/vite.config.mjs",
148
+ "knx-ai:build": "vite build --config ui/knxUltimateAI-vue/vite.config.mjs"
141
149
  },
142
150
  "devDependencies": {
143
151
  "@vitejs/plugin-vue": "^6.0.8",
144
152
  "chai": "^4.3.10",
145
- "js-yaml": "4.3.2",
146
153
  "marked": "^14.1.0",
147
154
  "mocha": "^10.4.0",
148
155
  "vite": "^7.3.6",
149
- "vue": "^3.5.41"
156
+ "vue": "^3.5.41",
157
+ "jspdf": "^4.2.1"
150
158
  }
151
- }
159
+ }
@@ -0,0 +1,416 @@
1
+ ; (function (root) {
2
+ const KNXAIChatAdapterMappings = [
3
+ {
4
+ id: 'windkh-telegrambot',
5
+ title: 'windkh/node-red-contrib-telegrambot',
6
+ inputCode: `// Receiver/Event -> Cerebrum Ultimate
7
+ // Supports normal "telegram receiver" messages, including the one-time
8
+ // reply-keyboard confirmation buttons, plus legacy callback_query events.
9
+ const telegram = msg.payload;
10
+ if (!telegram || typeof telegram !== 'object') return;
11
+
12
+ const chatId = telegram.chatId;
13
+ if (chatId === undefined || chatId === null) return;
14
+
15
+ msg.sessionId = String(chatId);
16
+ msg.language =
17
+ (msg.originalMessage && msg.originalMessage.from && msg.originalMessage.from.language_code) ||
18
+ msg.language ||
19
+ '';
20
+
21
+ if (telegram.type === 'voice') {
22
+ const original = msg.originalMessage && typeof msg.originalMessage === 'object'
23
+ ? msg.originalMessage
24
+ : {};
25
+ const voice = original.voice && typeof original.voice === 'object'
26
+ ? original.voice
27
+ : original.message && original.message.voice && typeof original.message.voice === 'object'
28
+ ? original.message.voice
29
+ : {};
30
+ const fileId = String(telegram.content || voice.file_id || '').trim();
31
+ const weblink = String(telegram.weblink || msg.weblink || '').trim();
32
+ const botDetails = msg.telegramBot && typeof msg.telegramBot === 'object' ? msg.telegramBot : {};
33
+ const botApiBase = String(botDetails.baseApiUrl || botDetails.baseapiurl || botDetails.baseApiURL || '').trim();
34
+ let allowedOrigin = 'https://api.telegram.org';
35
+ if (botApiBase) {
36
+ try { allowedOrigin = new URL(botApiBase).origin; } catch (error) { /* use Telegram default */ }
37
+ }
38
+ if (!fileId && !weblink) return;
39
+ msg.topic = 'ask';
40
+ msg.knxAi = Object.assign({}, msg.knxAi, {
41
+ sessionId: String(chatId),
42
+ voiceInput: {
43
+ source: 'telegram',
44
+ originalType: 'voice',
45
+ fileId: fileId,
46
+ weblink: weblink,
47
+ allowedOrigin: allowedOrigin,
48
+ mediaType: String(voice.mime_type || telegram.contentType || telegram.mimeType || 'audio/ogg'),
49
+ filename: String(voice.file_name || telegram.filename || 'telegram-voice.ogg'),
50
+ durationSeconds: Math.max(0, Number(voice.duration || telegram.duration) || 0),
51
+ fileSize: Math.max(0, Number(voice.file_size || telegram.fileSize) || 0)
52
+ }
53
+ });
54
+ return msg;
55
+ }
56
+
57
+ const content = typeof telegram.content === 'string' ? telegram.content.trim() : '';
58
+ if (content === '') return;
59
+
60
+ if (telegram.type === 'callback_query' && (content === 'confirm' || content === 'cancel')) {
61
+ msg.topic = content;
62
+ msg.knxAi = Object.assign({}, msg.knxAi, {
63
+ sessionId: String(chatId),
64
+ confirm: content === 'confirm'
65
+ });
66
+ return msg;
67
+ }
68
+
69
+ if (telegram.type !== 'message') return;
70
+ msg.topic = 'ask';
71
+ msg.prompt = content;
72
+ return msg;`,
73
+ outputCode: `// Cerebrum Ultimate chat output -> Telegram sender
74
+ // Converts the text reply and, when required, adds a one-time reply keyboard.
75
+ // A button click returns through the normal Telegram receiver, so no separate
76
+ // callback event node is required.
77
+ const source = msg.inputMessage && typeof msg.inputMessage === 'object'
78
+ ? msg.inputMessage
79
+ : inputMessage;
80
+ const sourcePayload = source && source.payload && typeof source.payload === 'object'
81
+ ? source.payload
82
+ : {};
83
+ const chatId = sourcePayload.chatId !== undefined
84
+ ? sourcePayload.chatId
85
+ : source && source.chatId;
86
+ if (chatId === undefined || chatId === null) return;
87
+
88
+ let content = msg.payload;
89
+ if (content && typeof content === 'object') {
90
+ content = content.error || content.message || JSON.stringify(content);
91
+ }
92
+ content = String(content === undefined || content === null ? '' : content);
93
+
94
+ const image = msg.knxAi && msg.knxAi.image;
95
+ if (image && Buffer.isBuffer(image.data)) {
96
+ msg.payload = {
97
+ chatId: chatId,
98
+ type: 'photo',
99
+ content: image.data,
100
+ options: {
101
+ caption: content.slice(0, 1024)
102
+ },
103
+ fileOptions: {
104
+ filename: image.filename || 'camera-snapshot.jpg',
105
+ contentType: image.mediaType || 'image/jpeg'
106
+ }
107
+ };
108
+ return msg;
109
+ }
110
+
111
+ const audio = msg.knxAi && msg.knxAi.audio;
112
+ if (audio && Buffer.isBuffer(audio.data)) {
113
+ const voiceLanguage = String(msg.knxAi.language || (source && source.language) || '').trim().toLowerCase().split(/[-_]/)[0];
114
+ const disclosureLabels = {
115
+ de: 'KI-generierte Stimme',
116
+ en: 'AI-generated voice',
117
+ es: 'Voz generada por IA',
118
+ fr: 'Voix générée par l’IA',
119
+ it: 'Voce generata dall’IA',
120
+ zh: 'AI 生成的语音'
121
+ };
122
+ const disclosure = disclosureLabels[voiceLanguage] || disclosureLabels.en;
123
+ const voiceOptions = { caption: [disclosure, content].filter(Boolean).join('\\n').slice(0, 1024) };
124
+ const confirmation = msg.knxAi && msg.knxAi.confirmationRequest;
125
+ if (confirmation && confirmation.required === true && Array.isArray(confirmation.actions)) {
126
+ voiceOptions.reply_markup = JSON.stringify({
127
+ keyboard: [confirmation.actions.map(function (action) {
128
+ return { text: action.label };
129
+ })],
130
+ resize_keyboard: true,
131
+ one_time_keyboard: true
132
+ });
133
+ } else if (msg.knxAi && Array.isArray(msg.knxAi.suggestions) && msg.knxAi.suggestions.length) {
134
+ voiceOptions.reply_markup = JSON.stringify({
135
+ keyboard: msg.knxAi.suggestions.slice(0, 3).map(function (suggestion) {
136
+ return [{ text: Array.from(String(suggestion.text || suggestion.label || '').trim()).slice(0, 64).join('') }];
137
+ }).filter(function (row) { return row[0].text; }),
138
+ resize_keyboard: true,
139
+ one_time_keyboard: true
140
+ });
141
+ } else if (/^knx_confirmation_/.test(String(msg.knxAi.type || '')) || /^knx_routine_/.test(String(msg.knxAi.type || ''))) {
142
+ voiceOptions.reply_markup = JSON.stringify({ remove_keyboard: true });
143
+ }
144
+ msg.payload = {
145
+ chatId: chatId,
146
+ type: 'voice',
147
+ content: audio.data,
148
+ options: voiceOptions,
149
+ fileOptions: {
150
+ filename: audio.filename || 'knx-ai-reply.ogg',
151
+ contentType: audio.mediaType || 'audio/ogg'
152
+ }
153
+ };
154
+ return msg;
155
+ }
156
+
157
+ const telegramPayload = {
158
+ chatId: chatId,
159
+ type: 'message',
160
+ content: content
161
+ };
162
+ const confirmation = msg.knxAi && msg.knxAi.confirmationRequest;
163
+ if (confirmation && confirmation.required === true && Array.isArray(confirmation.actions)) {
164
+ telegramPayload.options = {
165
+ reply_markup: JSON.stringify({
166
+ keyboard: [confirmation.actions.map(function (action) {
167
+ return { text: action.label };
168
+ })],
169
+ resize_keyboard: true,
170
+ one_time_keyboard: true
171
+ })
172
+ };
173
+ } else if (msg.knxAi && Array.isArray(msg.knxAi.suggestions) && msg.knxAi.suggestions.length) {
174
+ telegramPayload.options = {
175
+ reply_markup: JSON.stringify({
176
+ keyboard: msg.knxAi.suggestions.slice(0, 3).map(function (suggestion) {
177
+ return [{ text: Array.from(String(suggestion.text || suggestion.label || '').trim()).slice(0, 64).join('') }];
178
+ }).filter(function (row) { return row[0].text; }),
179
+ resize_keyboard: true,
180
+ one_time_keyboard: true
181
+ })
182
+ };
183
+ } else if (msg.knxAi && (/^knx_confirmation_/.test(String(msg.knxAi.type || '')) || /^knx_routine_/.test(String(msg.knxAi.type || '')))) {
184
+ telegramPayload.options = {
185
+ reply_markup: JSON.stringify({ remove_keyboard: true })
186
+ };
187
+ }
188
+
189
+ msg.payload = telegramPayload;
190
+ return msg;`
191
+ },
192
+ {
193
+ id: 'redbot-telegram',
194
+ title: 'RedBot / node-red-contrib-chatbot (Telegram)',
195
+ inputCode: `// RedBot Telegram Receiver -> Cerebrum Ultimate
196
+ // RedBot normalizes Telegram text, voice audio and inline-button postbacks in msg.payload.
197
+ const redbot = msg.payload;
198
+ if (!redbot || typeof redbot !== 'object') return;
199
+ if (redbot.transport && redbot.transport !== 'telegram') return;
200
+
201
+ const chatId = redbot.chatId;
202
+ if (chatId === undefined || chatId === null) return;
203
+
204
+ msg.sessionId = String(chatId);
205
+ msg.language =
206
+ (msg.originalMessage && msg.originalMessage.from && msg.originalMessage.from.language_code) ||
207
+ redbot.language ||
208
+ msg.language ||
209
+ '';
210
+
211
+ const redbotType = String(redbot.type || '').trim().toLowerCase();
212
+ if ((redbotType === 'audio' || redbotType === 'voice') && Buffer.isBuffer(redbot.content)) {
213
+ const original = msg.originalMessage && typeof msg.originalMessage === 'object'
214
+ ? msg.originalMessage
215
+ : {};
216
+ const voice = original.voice && typeof original.voice === 'object'
217
+ ? original.voice
218
+ : original.message && original.message.voice && typeof original.message.voice === 'object'
219
+ ? original.message.voice
220
+ : {};
221
+ msg.topic = 'ask';
222
+ msg.knxAi = Object.assign({}, msg.knxAi, {
223
+ sessionId: String(chatId),
224
+ voiceInput: {
225
+ source: 'telegram',
226
+ originalType: 'voice',
227
+ transport: 'redbot-buffer',
228
+ data: redbot.content,
229
+ fileId: String(voice.file_id || '').trim(),
230
+ mediaType: String(voice.mime_type || redbot.mimeType || 'audio/ogg'),
231
+ filename: String(voice.file_name || redbot.filename || 'telegram-voice.ogg'),
232
+ durationSeconds: Math.max(0, Number(voice.duration || redbot.duration) || 0),
233
+ fileSize: Math.max(0, Number(voice.file_size || redbot.fileSize) || redbot.content.length)
234
+ }
235
+ });
236
+ return msg;
237
+ }
238
+
239
+ const content = typeof redbot.content === 'string' ? redbot.content.trim() : '';
240
+ if (redbotType !== 'message' || content === '') return;
241
+
242
+ const action = content.toLowerCase();
243
+ if (action === 'confirm' || action === 'cancel') {
244
+ msg.topic = action;
245
+ msg.knxAi = Object.assign({}, msg.knxAi, {
246
+ sessionId: String(chatId),
247
+ confirm: action === 'confirm'
248
+ });
249
+ return msg;
250
+ }
251
+
252
+ msg.topic = 'ask';
253
+ msg.prompt = content;
254
+ return msg;`,
255
+ outputCode: `// Cerebrum Ultimate chat output -> RedBot Telegram Sender
256
+ // Preserves RedBot conversation helpers and emits either a plain message
257
+ // or inline postback buttons for KNX confirmation/cancellation.
258
+ const source = msg.inputMessage && typeof msg.inputMessage === 'object'
259
+ ? msg.inputMessage
260
+ : inputMessage;
261
+ const sourcePayload = source && source.payload && typeof source.payload === 'object'
262
+ ? source.payload
263
+ : {};
264
+ const originalMessage = source && source.originalMessage && typeof source.originalMessage === 'object'
265
+ ? source.originalMessage
266
+ : {};
267
+ const chatId = sourcePayload.chatId !== undefined
268
+ ? sourcePayload.chatId
269
+ : originalMessage.chat && originalMessage.chat.id;
270
+ if (chatId === undefined || chatId === null) return;
271
+
272
+ // RedBot's Sender uses these helpers to resolve the active bot and chat context.
273
+ ['originalMessage', 'chat', 'api', 'client', 'redBot'].forEach(function (key) {
274
+ if (msg[key] === undefined && source && source[key] !== undefined) {
275
+ msg[key] = source[key];
276
+ }
277
+ });
278
+
279
+ // A persisted camera watch can fire immediately after Node-RED restarts,
280
+ // before a new inbound RedBot message has recreated its conversation helpers.
281
+ // RedBot's Telegram Sender still needs transport metadata and a chat accessor,
282
+ // even when tracking is disabled. Supply the smallest compatible outbound
283
+ // envelope; the configured Sender remains responsible for choosing the bot.
284
+ if (!msg.originalMessage || typeof msg.originalMessage !== 'object') {
285
+ msg.originalMessage = {};
286
+ }
287
+ if (!msg.originalMessage.transport) msg.originalMessage.transport = sourcePayload.transport || 'telegram';
288
+ if (msg.originalMessage.chatId === undefined) msg.originalMessage.chatId = chatId;
289
+ if (sourcePayload.userId !== undefined && msg.originalMessage.userId === undefined) {
290
+ msg.originalMessage.userId = sourcePayload.userId;
291
+ }
292
+ if (typeof msg.chat !== 'function') {
293
+ const contextValues = {
294
+ chatId: chatId,
295
+ userId: sourcePayload.userId,
296
+ transport: sourcePayload.transport || 'telegram'
297
+ };
298
+ const syntheticContext = {
299
+ get: function () {
300
+ const keys = Array.prototype.slice.call(arguments);
301
+ if (keys.length === 0) return Object.assign({}, contextValues);
302
+ if (keys.length === 1) return contextValues[keys[0]];
303
+ return keys.reduce(function (result, key) {
304
+ result[key] = contextValues[key];
305
+ return result;
306
+ }, {});
307
+ },
308
+ set: function (key, value) {
309
+ if (key && typeof key === 'object') Object.assign(contextValues, key);
310
+ else if (key !== undefined) contextValues[key] = value;
311
+ return syntheticContext;
312
+ },
313
+ remove: function () {
314
+ Array.prototype.slice.call(arguments).forEach(function (key) { delete contextValues[key]; });
315
+ return syntheticContext;
316
+ },
317
+ clear: function () {
318
+ Object.keys(contextValues).forEach(function (key) { delete contextValues[key]; });
319
+ return syntheticContext;
320
+ },
321
+ all: function () { return Object.assign({}, contextValues); }
322
+ };
323
+ msg.chat = function () {
324
+ return syntheticContext;
325
+ };
326
+ }
327
+ if (typeof msg.get !== 'function') {
328
+ msg.get = function (key) {
329
+ if (key === 'userId') return sourcePayload.userId;
330
+ if (key === 'chatId') return chatId;
331
+ if (key === 'transport') return sourcePayload.transport || 'telegram';
332
+ return undefined;
333
+ };
334
+ }
335
+
336
+ let content = msg.payload;
337
+ if (content && typeof content === 'object') {
338
+ content = content.error || content.message || JSON.stringify(content);
339
+ }
340
+ content = String(content === undefined || content === null ? '' : content);
341
+
342
+ const image = msg.knxAi && msg.knxAi.image;
343
+ if (image && Buffer.isBuffer(image.data)) {
344
+ msg.payload = {
345
+ transport: sourcePayload.transport || 'telegram',
346
+ chatId: chatId,
347
+ type: 'photo',
348
+ inbound: false,
349
+ content: image.data,
350
+ filename: image.filename || 'camera-snapshot.jpg',
351
+ mimeType: image.mediaType || 'image/jpeg',
352
+ caption: content.slice(0, 1024)
353
+ };
354
+ if (sourcePayload.userId !== undefined) msg.payload.userId = sourcePayload.userId;
355
+ return msg;
356
+ }
357
+
358
+ const confirmation = msg.knxAi && msg.knxAi.confirmationRequest;
359
+ const audio = msg.knxAi && msg.knxAi.audio;
360
+ if (audio && Buffer.isBuffer(audio.data) && !(confirmation && confirmation.required === true)) {
361
+ const voiceLanguage = String(msg.knxAi.language || (source && source.language) || '').trim().toLowerCase().split(/[-_]/)[0];
362
+ const disclosureLabels = {
363
+ de: 'KI-generierte Stimme',
364
+ en: 'AI-generated voice',
365
+ es: 'Voz generada por IA',
366
+ fr: 'Voix générée par l’IA',
367
+ it: 'Voce generata dall’IA',
368
+ zh: 'AI 生成的语音'
369
+ };
370
+ const disclosure = disclosureLabels[voiceLanguage] || disclosureLabels.en;
371
+ msg.payload = {
372
+ transport: sourcePayload.transport || 'telegram',
373
+ chatId: chatId,
374
+ type: 'audio',
375
+ inbound: false,
376
+ content: audio.data,
377
+ filename: audio.filename || 'knx-ai-reply.ogg',
378
+ mimeType: audio.mediaType || 'audio/ogg',
379
+ caption: [disclosure, content].filter(Boolean).join('\\n').slice(0, 1024)
380
+ };
381
+ if (sourcePayload.userId !== undefined) msg.payload.userId = sourcePayload.userId;
382
+ return msg;
383
+ }
384
+
385
+ const redbotPayload = {
386
+ transport: sourcePayload.transport || 'telegram',
387
+ chatId: chatId,
388
+ type: 'message',
389
+ inbound: false,
390
+ content: content
391
+ };
392
+ if (sourcePayload.userId !== undefined) redbotPayload.userId = sourcePayload.userId;
393
+
394
+ if (confirmation && confirmation.required === true && Array.isArray(confirmation.actions)) {
395
+ redbotPayload.type = 'inline-buttons';
396
+ redbotPayload.buttons = confirmation.actions.map(function (action) {
397
+ return {
398
+ type: 'postback',
399
+ label: action.label,
400
+ value: action.callbackData
401
+ };
402
+ });
403
+ }
404
+
405
+ msg.payload = redbotPayload;
406
+ return msg;`
407
+ }
408
+ ]
409
+
410
+ if (root) {
411
+ root.KNXAIChatAdapterMappings = KNXAIChatAdapterMappings
412
+ }
413
+ if (typeof module !== 'undefined' && module.exports) {
414
+ module.exports = KNXAIChatAdapterMappings
415
+ }
416
+ })(typeof window !== 'undefined' ? window : (typeof globalThis !== 'undefined' ? globalThis : this))
@@ -115,12 +115,14 @@
115
115
  if (RED && typeof RED.notify === 'function') {
116
116
  RED.notify(translate('migration_unavailable', 'The HUE migration tool is unavailable. Restart Node-RED after updating the package.'), 'error')
117
117
  }
118
+ if (typeof options.onClose === 'function') options.onClose()
118
119
  return function () {}
119
120
  }
120
121
 
121
122
  const legacyNodes = migrationApi.collectLegacyHueNodes(RED)
122
123
  if (legacyNodes.length === 0) {
123
124
  if (typeof RED.notify === 'function') RED.notify(translate('migration_none', 'No legacy HUE nodes were found.'), 'warning')
125
+ if (typeof options.onClose === 'function') options.onClose()
124
126
  return function () {}
125
127
  }
126
128
 
@@ -158,6 +160,7 @@
158
160
  if ($convert) $convert.off(EVENT_NAMESPACE)
159
161
  try { $dialog.dialog('destroy') } catch (error) { /* already detached */ }
160
162
  $dialog.remove()
163
+ if (typeof options.onClose === 'function') options.onClose()
161
164
  }
162
165
 
163
166
  const performMigration = () => {
@@ -210,6 +210,7 @@
210
210
  const t = (key, fallback, values) => translate(RED, key, fallback, values)
211
211
  if (legacyNodes.length === 0) {
212
212
  RED.notify(t('migration_none', 'No compatible legacy KNX utility nodes were found.'), 'info')
213
+ if (typeof options.onClose === 'function') options.onClose()
213
214
  return
214
215
  }
215
216
  if (activeNotification && typeof activeNotification.close === 'function') activeNotification.close()
@@ -217,8 +218,10 @@
217
218
  let closed = false
218
219
  let notification
219
220
  const close = () => {
221
+ if (closed) return
220
222
  closed = true
221
223
  if (notification && typeof notification.close === 'function') notification.close()
224
+ if (typeof options.onClose === 'function') options.onClose()
222
225
  }
223
226
  notification = RED.notify(t('migration_confirm', 'Convert all {{count}} compatible legacy KNX utility nodes in every flow and subflow to KNX Utility? Before conversion, the browser automatically starts downloading a JSON backup of all flows. Protected credentials are excluded, as in the standard Node-RED export. Your browser may ask where to save the file. The current node editor will close and discard unsaved edits. IDs, settings, connections and groups are preserved. Conversion happens in this browser, can be undone, and takes effect when you Deploy.', { count: legacyNodes.length }), {
224
227
  modal: true,
@@ -0,0 +1,143 @@
1
+ (function (root, factory) {
2
+ const api = factory()
3
+ if (typeof module === 'object' && module.exports) module.exports = api
4
+ if (root) root.KNXUltimateLegacyMigrationNotice = api
5
+ }(typeof globalThis !== 'undefined' ? globalThis : this, function () {
6
+ 'use strict'
7
+
8
+ const I18N_PREFIX = 'node-red-contrib-knx-ultimate/knxUltimateUtility:knxUltimateUtility.startup_migration.'
9
+ const installations = new WeakMap()
10
+
11
+ function install (RED, options = {}) {
12
+ if (installations.has(RED)) return installations.get(RED)
13
+ const environment = options.environment || globalThis
14
+ const $ = options.$ || environment.jQuery
15
+ const documentObject = environment.document
16
+ let notification
17
+ let timer
18
+ let dismissed = false
19
+ let disposed = false
20
+ let migrating = false
21
+ let closeMigration
22
+ let lastCounts = ''
23
+
24
+ function translate (key, fallback, values = {}) {
25
+ const qualifiedKey = I18N_PREFIX + key
26
+ let result
27
+ try { result = RED._(qualifiedKey, values) } catch (error) { /* use fallback */ }
28
+ result = result && result !== qualifiedKey ? result : fallback
29
+ return result.replace(/{{\s*(\w+)\s*}}/g, (match, name) => values[name] === undefined ? match : String(values[name]))
30
+ }
31
+
32
+ function closeNotice () {
33
+ if (notification) notification.close()
34
+ notification = undefined
35
+ lastCounts = ''
36
+ }
37
+
38
+ // Defer scans until the current import, undo or conversion batch finishes.
39
+ // Node events also cover initial loading in Node-RED's safe mode.
40
+ function schedule () {
41
+ if (disposed) return
42
+ if (timer !== undefined) environment.clearTimeout(timer)
43
+ timer = environment.setTimeout(refresh, 150)
44
+ }
45
+
46
+ function migrationClosed () {
47
+ migrating = false
48
+ closeMigration = undefined
49
+ schedule()
50
+ }
51
+
52
+ function openMigration (kind) {
53
+ if (disposed || migrating) return
54
+ closeNotice()
55
+ migrating = true
56
+ try {
57
+ if (kind === 'hue') {
58
+ closeMigration = environment.KNXUltimateHueControllerMigrationDialog.open({
59
+ RED, $, windowObject: environment, documentObject, onClose: migrationClosed
60
+ })
61
+ } else {
62
+ closeMigration = environment.KNXUltimateUtilityMigration.migrate(RED, {
63
+ environment, $, onClose: migrationClosed
64
+ })
65
+ }
66
+ } catch (error) {
67
+ migrationClosed()
68
+ RED.notify(translate('unavailable', 'The migration tool is unavailable. Reload the editor after restarting Node-RED.'), 'error')
69
+ }
70
+ }
71
+
72
+ function refresh () {
73
+ timer = undefined
74
+ if (disposed || dismissed || migrating) return
75
+ const hueApi = environment.KNXUltimateHueControllerMigration
76
+ const knxApi = environment.KNXUltimateUtilityMigration
77
+ if (!hueApi || !knxApi) return
78
+ const hue = hueApi.collectLegacyHueNodes(RED).length
79
+ const knx = knxApi.collectLegacyUtilityNodes(RED).length
80
+ if (!hue && !knx) {
81
+ closeNotice()
82
+ return
83
+ }
84
+ const counts = `${hue}:${knx}`
85
+ if (notification && counts === lastCounts) return
86
+ lastCounts = counts
87
+
88
+ const message = $('<div></div>')
89
+ $('<p></p>').append($('<strong></strong>').text(translate('title', 'Legacy KNX Ultimate nodes'))).appendTo(message)
90
+ $('<p></p>').text(translate('message', 'Legacy nodes found in your flows and subflows: HUE: {{hue}}; KNX utility: {{knx}}. These nodes still work, but are hidden from the palette. Migrate them to HUE Controller and KNX Utility. Each migration downloads a flow backup; review the result before deploying.', { hue, knx })).appendTo(message)
91
+ const buttons = []
92
+ if (hue) {
93
+ buttons.push({
94
+ text: translate('hue_button', 'Migrate HUE ({{count}})', { count: hue }),
95
+ click: () => openMigration('hue')
96
+ })
97
+ }
98
+ if (knx) {
99
+ buttons.push({
100
+ text: translate('knx_button', 'Migrate KNX ({{count}})', { count: knx }),
101
+ click: () => openMigration('knx')
102
+ })
103
+ }
104
+ buttons.push({
105
+ text: translate('later', 'Later'),
106
+ click () {
107
+ dismissed = true
108
+ closeNotice()
109
+ }
110
+ })
111
+ const settings = { type: 'warning', fixed: true, modal: false, buttons, silent: true }
112
+ if (notification) notification.update(message, settings)
113
+ else notification = RED.notify(message, settings)
114
+ }
115
+
116
+ function reset () {
117
+ dismissed = false
118
+ closeNotice()
119
+ if (closeMigration) closeMigration()
120
+ migrating = false
121
+ schedule()
122
+ }
123
+
124
+ const events = ['nodes:add', 'nodes:remove', 'nodes:change', 'workspace:dirty', 'deploy']
125
+ events.forEach(event => RED.events.on(event, schedule))
126
+ RED.events.on('workspace:clear', reset)
127
+ const dispose = () => {
128
+ disposed = true
129
+ if (timer !== undefined) environment.clearTimeout(timer)
130
+ events.forEach(event => RED.events.off(event, schedule))
131
+ RED.events.off('workspace:clear', reset)
132
+ if (closeMigration) closeMigration()
133
+ closeNotice()
134
+ installations.delete(RED)
135
+ }
136
+ const installation = { dispose }
137
+ installations.set(RED, installation)
138
+ schedule()
139
+ return installation
140
+ }
141
+
142
+ return { install }
143
+ }))