node-red-contrib-knx-ultimate 6.3.19 → 6.3.21
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 +5 -0
- package/nodes/knxUltimateAI.html +7 -2
- package/nodes/knxUltimateAI.js +245 -53
- package/nodes/locales/de/knxUltimateAI.html +2 -0
- package/nodes/locales/de/knxUltimateAI.json +4 -0
- package/nodes/locales/en/knxUltimateAI.html +3 -1
- package/nodes/locales/en/knxUltimateAI.json +4 -0
- package/nodes/locales/es/knxUltimateAI.html +2 -0
- package/nodes/locales/es/knxUltimateAI.json +4 -0
- package/nodes/locales/fr/knxUltimateAI.html +2 -0
- package/nodes/locales/fr/knxUltimateAI.json +4 -0
- package/nodes/locales/it/knxUltimateAI.html +3 -1
- package/nodes/locales/it/knxUltimateAI.json +4 -0
- package/nodes/locales/zh-CN/knxUltimateAI.html +2 -0
- package/nodes/locales/zh-CN/knxUltimateAI.json +4 -0
- package/nodes/utils/knxAiEventHistory.js +246 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
|
|
7
7
|
# CHANGELOG
|
|
8
8
|
|
|
9
|
+
**Version 6.3.21** - August 2026<br/>
|
|
10
|
+
|
|
11
|
+
- **KNX AI — persistent adapter-event history**: every event published by an automatically detected adapter is now normalized into vendor-neutral metadata and appended to a node-specific daily JSONL archive. The archive follows the 10-day KNX retention, guaranteeing at least 24 hours of camera and future adapter events across Node-RED restarts without storing snapshot image data. The editor context card exposes the actual adapter-history directories.<br/>
|
|
12
|
+
- **KNX AI — authoritative historical queries**: web Assistant, Telegram, RedBot and custom CHAT channels now query both the adapter-event archive and the existing KNX daily telegram files. Historical prompts include totals calculated across every stored row in the requested interval plus relevance-selected detail samples, preventing sample size from being reported as the total. Natural-language ranges now include multilingual “last N hours” requests and are bounded by available retention.<br/>
|
|
13
|
+
|
|
9
14
|
**Version 6.3.19** - August 2026<br/>
|
|
10
15
|
|
|
11
16
|
- **KNX AI — conversational multi-step routines**: added coordinated routines such as leaving home, bedtime and cinema mode. KNX AI can now perform a first pass with up to 20 fresh KNX state reads, use the authoritative bus results to prepare an ordered plan of up to 12 validated writes, request one confirmation and then execute the complete routine. After confirmation it waits up to four seconds for immediate matching bus feedback, reports verified and unverified operations without treating missing immediate feedback as a device failure, and dispatches any explicitly requested TTS Ultimate announcement only after execution. Routine details, preliminary readings and execution results are exposed as structured chat metadata. The importable confirmation example, editor help and wiki documentation were updated in EN, IT, DE, FR, ES and zh-CN.<br/>
|
package/nodes/knxUltimateAI.html
CHANGED
|
@@ -635,6 +635,7 @@
|
|
|
635
635
|
|
|
636
636
|
const sourceLabels = {
|
|
637
637
|
knxTraffic: t("knxUltimateAI.messages.chatContextSourceKnxTraffic", "Live KNX summary, anomalies, topology and selected telegrams."),
|
|
638
|
+
adapterHistory: t("knxUltimateAI.messages.chatContextSourceAdapterHistory", "Persistent history of automatically detected adapter events."),
|
|
638
639
|
etsProject: t("knxUltimateAI.messages.chatContextSourceEtsProject", "ETS semantics and the full Node-RED project inventory."),
|
|
639
640
|
memoryEducation: t("knxUltimateAI.messages.chatContextSourceMemoryEducation", "Session context, AI Education and bounded home memory."),
|
|
640
641
|
camerasDocs: t("knxUltimateAI.messages.chatContextSourceCamerasDocs", "Detected cameras and relevant help, README and example snippets."),
|
|
@@ -668,7 +669,9 @@
|
|
|
668
669
|
|
|
669
670
|
const directoryLabels = {
|
|
670
671
|
archiveRoot: t("knxUltimateAI.messages.chatContextDirectoryRoot", "Telegram archive root"),
|
|
671
|
-
nodeArchive: t("knxUltimateAI.messages.chatContextDirectoryNode", "This node's telegram archive")
|
|
672
|
+
nodeArchive: t("knxUltimateAI.messages.chatContextDirectoryNode", "This node's telegram archive"),
|
|
673
|
+
adapterArchiveRoot: t("knxUltimateAI.messages.chatContextDirectoryAdapterRoot", "Adapter event archive root"),
|
|
674
|
+
adapterNodeArchive: t("knxUltimateAI.messages.chatContextDirectoryAdapterNode", "This node's adapter event archive")
|
|
672
675
|
};
|
|
673
676
|
(Array.isArray(overview.telegramDirectories) ? overview.telegramDirectories : []).forEach(function (item) {
|
|
674
677
|
if (!item || !item.path) return;
|
|
@@ -677,7 +680,9 @@
|
|
|
677
680
|
directoryLabels[item.id] || String(item.id || ""),
|
|
678
681
|
"",
|
|
679
682
|
item.path,
|
|
680
|
-
|
|
683
|
+
/^adapter/.test(String(item.id || ""))
|
|
684
|
+
? t("knxUltimateAI.messages.chatContextDirectoryAdapterBadge", "Adapter")
|
|
685
|
+
: t("knxUltimateAI.messages.chatContextDirectoryBadge", "KNX")
|
|
681
686
|
);
|
|
682
687
|
});
|
|
683
688
|
|
package/nodes/knxUltimateAI.js
CHANGED
|
@@ -46,6 +46,13 @@ const {
|
|
|
46
46
|
normalizeSearchText,
|
|
47
47
|
resolveKnxAiCamera
|
|
48
48
|
} = require('./utils/knxAiCamera')
|
|
49
|
+
const {
|
|
50
|
+
KNX_AI_ADAPTER_HISTORY_MIN_HOURS,
|
|
51
|
+
buildKnxAiHistoryEventKey,
|
|
52
|
+
createKnxAiHistoryAccumulator,
|
|
53
|
+
formatKnxAiAdapterHistoryEventForPrompt,
|
|
54
|
+
normalizeKnxAiAdapterHistoryEvent
|
|
55
|
+
} = require('./utils/knxAiEventHistory')
|
|
49
56
|
let googleTranslateTTS = null
|
|
50
57
|
try {
|
|
51
58
|
googleTranslateTTS = require('google-translate-tts')
|
|
@@ -72,6 +79,7 @@ const KNX_AI_LOCAL_LLM_TIMEOUT_MIN_MS = 10 * 60 * 1000
|
|
|
72
79
|
const KNX_AI_MINIMAL_CONTEXT_MAX_TOKENS = 16 * 1024
|
|
73
80
|
const KNX_AI_COMPACT_CONTEXT_MAX_TOKENS = 64 * 1024
|
|
74
81
|
const KNX_AI_ROUTINE_FEEDBACK_TIMEOUT_MS = 4000
|
|
82
|
+
const KNX_AI_ADAPTER_HISTORY_RETENTION_DAYS = Math.max(1, KNX_AI_TRAFFIC_DEFAULTS.historyStoreRetentionDays)
|
|
75
83
|
|
|
76
84
|
const resolveKnxAiLlmTimeoutMs = ({ provider, configuredTimeoutMs } = {}) => {
|
|
77
85
|
const configured = Number(configuredTimeoutMs)
|
|
@@ -324,6 +332,8 @@ const summarizeKnxAiChatContext = ({ node, nodeId, redUserDir } = {}) => {
|
|
|
324
332
|
const configDir = path.join(knxAiDir, 'config')
|
|
325
333
|
const telegramArchiveRoot = path.join(knxAiDir, 'history')
|
|
326
334
|
const telegramNodeDir = safeNodeId ? path.join(telegramArchiveRoot, safeNodeId) : ''
|
|
335
|
+
const adapterArchiveRoot = path.join(knxAiDir, 'adapter-history')
|
|
336
|
+
const adapterNodeDir = safeNodeId ? path.join(adapterArchiveRoot, safeNodeId) : ''
|
|
327
337
|
|
|
328
338
|
const files = [
|
|
329
339
|
{
|
|
@@ -346,11 +356,13 @@ const summarizeKnxAiChatContext = ({ node, nodeId, redUserDir } = {}) => {
|
|
|
346
356
|
}
|
|
347
357
|
|
|
348
358
|
return {
|
|
349
|
-
sources: ['knxTraffic', 'etsProject', 'memoryEducation', 'camerasDocs', 'ttsUltimate'],
|
|
359
|
+
sources: ['knxTraffic', 'adapterHistory', 'etsProject', 'memoryEducation', 'camerasDocs', 'ttsUltimate'],
|
|
350
360
|
files: files.map(item => Object.assign({}, item, { exists: fs.existsSync(item.path) })),
|
|
351
361
|
telegramDirectories: [
|
|
352
362
|
{ id: 'archiveRoot', path: telegramArchiveRoot, exists: fs.existsSync(telegramArchiveRoot) },
|
|
353
|
-
...(telegramNodeDir ? [{ id: 'nodeArchive', path: telegramNodeDir, exists: fs.existsSync(telegramNodeDir) }] : [])
|
|
363
|
+
...(telegramNodeDir ? [{ id: 'nodeArchive', path: telegramNodeDir, exists: fs.existsSync(telegramNodeDir) }] : []),
|
|
364
|
+
{ id: 'adapterArchiveRoot', path: adapterArchiveRoot, exists: fs.existsSync(adapterArchiveRoot) },
|
|
365
|
+
...(adapterNodeDir ? [{ id: 'adapterNodeArchive', path: adapterNodeDir, exists: fs.existsSync(adapterNodeDir) }] : [])
|
|
354
366
|
],
|
|
355
367
|
telegramFilePattern: 'YYYY-MM-DD.jsonl'
|
|
356
368
|
}
|
|
@@ -2505,7 +2517,18 @@ const parseQuestionTimeRange = (question, nowTs = Date.now()) => {
|
|
|
2505
2517
|
return { fromTs: yesterdayStart, toTs: yesterdayEnd, label: 'yesterday', explicit: true }
|
|
2506
2518
|
}
|
|
2507
2519
|
|
|
2508
|
-
const
|
|
2520
|
+
const lastHoursMatch = text.match(/\b(?:last|ultime|ultimi|letzte[n]?|derni[eè]res?|[uú]ltimas?)\s+(\d{1,3})\s+(?:hour|hours|ora|ore|stunde|stunden|heure|heures|hora|horas)\b/)
|
|
2521
|
+
if (lastHoursMatch) {
|
|
2522
|
+
const hours = Math.max(1, Number(lastHoursMatch[1] || 1))
|
|
2523
|
+
return {
|
|
2524
|
+
fromTs: nowTs - (hours * 60 * 60 * 1000),
|
|
2525
|
+
toTs: nowTs,
|
|
2526
|
+
label: `last ${hours} hours`,
|
|
2527
|
+
explicit: true
|
|
2528
|
+
}
|
|
2529
|
+
}
|
|
2530
|
+
|
|
2531
|
+
const lastDaysMatch = text.match(/\b(?:last|ultimi|ultime|letzte[n]?|derni[eè]res?|[uú]ltimos?|[uú]ltimas?)\s+(\d{1,3})\s+(?:day|days|giorno|giorni|tag|tage|tagen|jour|jours|d[ií]a|d[ií]as)\b/)
|
|
2509
2532
|
if (lastDaysMatch) {
|
|
2510
2533
|
const days = Math.max(1, Number(lastDaysMatch[1] || 1))
|
|
2511
2534
|
return {
|
|
@@ -6148,6 +6171,9 @@ module.exports = function (RED) {
|
|
|
6148
6171
|
node._gaLabelCsvCache = { ref: null, map: {} }
|
|
6149
6172
|
node._busConnectionWatchTimer = null
|
|
6150
6173
|
node._historyDiskLastPruneAt = 0
|
|
6174
|
+
node._historyDiskPending = new Map()
|
|
6175
|
+
node._adapterHistoryDiskLastPruneAt = 0
|
|
6176
|
+
node._adapterHistoryDiskPending = new Map()
|
|
6151
6177
|
node._homeMemory = createEmptyKnxAiHomeMemory()
|
|
6152
6178
|
node._homeMemoryWriteTimer = null
|
|
6153
6179
|
node._homeMemoryPeriodicTimer = null
|
|
@@ -7269,6 +7295,12 @@ module.exports = function (RED) {
|
|
|
7269
7295
|
const maxEvents = Math.min(minimalMode ? 20 : compactMode ? 50 : 240, maxEventsRequested)
|
|
7270
7296
|
const promptEvents = selectTelegramsForPrompt({ question, maxEvents })
|
|
7271
7297
|
const recent = Array.isArray(promptEvents.events) ? promptEvents.events : []
|
|
7298
|
+
const adapterPromptEvents = selectAdapterEventsForPrompt({
|
|
7299
|
+
question,
|
|
7300
|
+
maxEvents: minimalMode ? 12 : compactMode ? 30 : 160,
|
|
7301
|
+
range: promptEvents.range
|
|
7302
|
+
})
|
|
7303
|
+
const recentAdapterEvents = Array.isArray(adapterPromptEvents.events) ? adapterPromptEvents.events : []
|
|
7272
7304
|
const wantsSvgChart = shouldGenerateSvgChart(question)
|
|
7273
7305
|
const wantsFunctionNodeSourceContext = shouldIncludeFunctionNodeSourceContext(question)
|
|
7274
7306
|
const areasSnapshot = buildAreasSnapshot({ summary })
|
|
@@ -7286,7 +7318,14 @@ module.exports = function (RED) {
|
|
|
7286
7318
|
return `${new Date(t.ts).toISOString()} ${t.event} ${t.source} -> ${t.destination}${devName} dpt=${t.dpt} payload=${payloadStr}${rawStr}`
|
|
7287
7319
|
})
|
|
7288
7320
|
const recentLines = takeLastItemsByCharBudget(lines, minimalMode ? 1000 : compactMode ? 2200 : 7000)
|
|
7289
|
-
const archiveScopeLine = `Prompt event source: ${promptEvents.source}. Time range: ${promptEvents.range && promptEvents.range.label ? promptEvents.range.label : 'recent events'}. Events selected: ${recent.length}.`
|
|
7321
|
+
const archiveScopeLine = `Prompt event source: ${promptEvents.source}. Time range: ${promptEvents.range && promptEvents.range.label ? promptEvents.range.label : 'recent events'}${promptEvents.range && promptEvents.range.clampedToRetention ? ` (clamped to ${promptEvents.range.retentionDays} available day(s))` : ''}. Events selected: ${recent.length}.`
|
|
7322
|
+
const knxArchiveSummary = truncatePromptText(safeStringify(promptEvents.summary || {}), minimalMode ? 1200 : compactMode ? 3000 : 9000)
|
|
7323
|
+
const adapterArchiveSummary = truncatePromptText(safeStringify(adapterPromptEvents.summary || {}), minimalMode ? 1000 : compactMode ? 2600 : 8000)
|
|
7324
|
+
const adapterLines = takeLastItemsByCharBudget(
|
|
7325
|
+
recentAdapterEvents.map(formatKnxAiAdapterHistoryEventForPrompt).filter(Boolean),
|
|
7326
|
+
minimalMode ? 700 : compactMode ? 1800 : 6000
|
|
7327
|
+
)
|
|
7328
|
+
const adapterArchiveScopeLine = `Adapter event source: ${adapterPromptEvents.source}. Time range: ${adapterPromptEvents.range && adapterPromptEvents.range.label ? adapterPromptEvents.range.label : 'last 24 hours'}${adapterPromptEvents.range && adapterPromptEvents.range.clampedToRetention ? ` (clamped to ${adapterPromptEvents.range.retentionDays} available day(s))` : ''}. Events selected: ${recentAdapterEvents.length}.`
|
|
7290
7329
|
|
|
7291
7330
|
let flowContext = ''
|
|
7292
7331
|
const flowMaxChars = minimalMode ? 600 : compactMode ? 1200 : 5000
|
|
@@ -7388,10 +7427,22 @@ module.exports = function (RED) {
|
|
|
7388
7427
|
wantsSvgChart ? '- Prefer width via viewBox and include labels + legend when useful.' : '',
|
|
7389
7428
|
wantsSvgChart ? '' : '',
|
|
7390
7429
|
archiveScopeLine,
|
|
7430
|
+
'The KNX archive summary below is calculated from every stored telegram in the requested interval. Use its totals for counts; the selected telegrams are only a relevant/recent sample.',
|
|
7431
|
+
'KNX historical archive summary (JSON):',
|
|
7432
|
+
knxArchiveSummary,
|
|
7391
7433
|
'',
|
|
7392
7434
|
'Selected KNX telegrams:',
|
|
7393
7435
|
recentLines.join('\n'),
|
|
7394
7436
|
'',
|
|
7437
|
+
adapterArchiveScopeLine,
|
|
7438
|
+
`Adapter history retention: ${KNX_AI_ADAPTER_HISTORY_RETENTION_DAYS} day(s), with a guaranteed minimum query window of ${KNX_AI_ADAPTER_HISTORY_MIN_HOURS} hours.`,
|
|
7439
|
+
'The adapter archive summary below is calculated from every stored event in the requested interval. Use its totals for counts; the selected events are only a relevant/recent sample.',
|
|
7440
|
+
'Adapter historical archive summary (JSON):',
|
|
7441
|
+
adapterArchiveSummary,
|
|
7442
|
+
'',
|
|
7443
|
+
'Selected adapter events:',
|
|
7444
|
+
adapterLines.length ? adapterLines.join('\n') : '(no stored adapter events in this interval)',
|
|
7445
|
+
'',
|
|
7395
7446
|
'User request:',
|
|
7396
7447
|
question || ''
|
|
7397
7448
|
].join('\n')
|
|
@@ -7445,6 +7496,15 @@ module.exports = function (RED) {
|
|
|
7445
7496
|
|
|
7446
7497
|
const getHistoryArchiveFile = (dayKey) => path.join(getHistoryArchiveDir(), `${String(dayKey || '').trim() || formatArchiveDayKey(Date.now())}.jsonl`)
|
|
7447
7498
|
|
|
7499
|
+
const getAdapterHistoryArchiveDir = () => {
|
|
7500
|
+
const baseDir = (node.serverKNX && node.serverKNX.userDir)
|
|
7501
|
+
? node.serverKNX.userDir
|
|
7502
|
+
: path.join(RED.settings.userDir, 'knxultimatestorage')
|
|
7503
|
+
return path.join(baseDir, 'knxai', 'adapter-history', node.id)
|
|
7504
|
+
}
|
|
7505
|
+
|
|
7506
|
+
const getAdapterHistoryArchiveFile = dayKey => path.join(getAdapterHistoryArchiveDir(), `${String(dayKey || '').trim() || formatArchiveDayKey(Date.now())}.jsonl`)
|
|
7507
|
+
|
|
7448
7508
|
const getHomeMemoryFile = () => {
|
|
7449
7509
|
const baseDir = (node.serverKNX && node.serverKNX.userDir)
|
|
7450
7510
|
? node.serverKNX.userDir
|
|
@@ -7752,7 +7812,7 @@ module.exports = function (RED) {
|
|
|
7752
7812
|
try {
|
|
7753
7813
|
if (!fs.existsSync(dirPath)) return
|
|
7754
7814
|
const entries = fs.readdirSync(dirPath, { withFileTypes: true })
|
|
7755
|
-
const cutoffTs = now - (
|
|
7815
|
+
const cutoffTs = now - (retentionDays * 24 * 60 * 60 * 1000)
|
|
7756
7816
|
const cutoffDayKey = formatArchiveDayKey(cutoffTs)
|
|
7757
7817
|
for (let i = 0; i < entries.length; i++) {
|
|
7758
7818
|
const entry = entries[i]
|
|
@@ -7776,7 +7836,10 @@ module.exports = function (RED) {
|
|
|
7776
7836
|
const dayKey = formatArchiveDayKey(telegram.ts || Date.now())
|
|
7777
7837
|
const filePath = getHistoryArchiveFile(dayKey)
|
|
7778
7838
|
const line = JSON.stringify(telegram) + '\n'
|
|
7839
|
+
const pendingKey = buildKnxAiHistoryEventKey(telegram, 'knx')
|
|
7840
|
+
if (pendingKey) node._historyDiskPending.set(pendingKey, telegram)
|
|
7779
7841
|
fs.appendFile(filePath, line, 'utf8', (error) => {
|
|
7842
|
+
if (pendingKey && node._historyDiskPending.get(pendingKey) === telegram) node._historyDiskPending.delete(pendingKey)
|
|
7780
7843
|
if (error) node.sysLogger?.warn(`KNX AI history append error: ${error.message || error}`)
|
|
7781
7844
|
})
|
|
7782
7845
|
pruneHistoryArchiveFiles()
|
|
@@ -7820,45 +7883,68 @@ module.exports = function (RED) {
|
|
|
7820
7883
|
}
|
|
7821
7884
|
}
|
|
7822
7885
|
|
|
7823
|
-
const
|
|
7824
|
-
|
|
7886
|
+
const loadHistoryQueryFromDisk = ({ fromTs, toTs, limit = 240, question = '' } = {}) => {
|
|
7887
|
+
const emptyAccumulator = () => createKnxAiHistoryAccumulator({ kind: 'knx', question, limit }).finish()
|
|
7888
|
+
if (node.historyStoreToDisk !== true) return emptyAccumulator()
|
|
7825
7889
|
const archiveDir = getHistoryArchiveDir()
|
|
7826
7890
|
try {
|
|
7827
|
-
if (!fs.existsSync(archiveDir)) return []
|
|
7828
7891
|
const from = Number(fromTs || 0)
|
|
7829
7892
|
const to = Number(toTs || 0)
|
|
7830
|
-
if (!Number.isFinite(from) || !Number.isFinite(to) || to < from) return
|
|
7893
|
+
if (!Number.isFinite(from) || !Number.isFinite(to) || to < from) return emptyAccumulator()
|
|
7894
|
+
const accumulator = createKnxAiHistoryAccumulator({ kind: 'knx', question, limit })
|
|
7895
|
+
const pending = node._historyDiskPending instanceof Map ? node._historyDiskPending : new Map()
|
|
7831
7896
|
const dayKeys = collectArchiveDayKeysBetween({ fromTs: from, toTs: to })
|
|
7832
|
-
if (
|
|
7833
|
-
|
|
7834
|
-
|
|
7835
|
-
|
|
7836
|
-
|
|
7837
|
-
|
|
7838
|
-
|
|
7839
|
-
|
|
7840
|
-
|
|
7841
|
-
|
|
7842
|
-
|
|
7843
|
-
|
|
7844
|
-
|
|
7845
|
-
|
|
7846
|
-
|
|
7847
|
-
|
|
7848
|
-
|
|
7849
|
-
|
|
7897
|
+
if (fs.existsSync(archiveDir)) {
|
|
7898
|
+
for (let i = 0; i < dayKeys.length; i++) {
|
|
7899
|
+
const filePath = getHistoryArchiveFile(dayKeys[i])
|
|
7900
|
+
if (!fs.existsSync(filePath)) continue
|
|
7901
|
+
const raw = fs.readFileSync(filePath, 'utf8')
|
|
7902
|
+
if (!raw || String(raw).trim() === '') continue
|
|
7903
|
+
const lines = raw.split(/\r?\n/)
|
|
7904
|
+
for (let j = 0; j < lines.length; j++) {
|
|
7905
|
+
const line = lines[j]
|
|
7906
|
+
if (!line) continue
|
|
7907
|
+
try {
|
|
7908
|
+
const telegram = JSON.parse(line)
|
|
7909
|
+
const ts = Number(telegram && telegram.ts ? telegram.ts : 0)
|
|
7910
|
+
if (!Number.isFinite(ts) || ts < from || ts > to) continue
|
|
7911
|
+
const key = buildKnxAiHistoryEventKey(telegram, 'knx')
|
|
7912
|
+
if (key && pending.has(key)) continue
|
|
7913
|
+
accumulator.add(telegram)
|
|
7914
|
+
} catch (error) {
|
|
7915
|
+
// Ignore malformed archive rows.
|
|
7916
|
+
}
|
|
7850
7917
|
}
|
|
7851
7918
|
}
|
|
7852
7919
|
}
|
|
7853
|
-
|
|
7854
|
-
|
|
7855
|
-
|
|
7920
|
+
pending.forEach(telegram => {
|
|
7921
|
+
const ts = Number(telegram && telegram.ts ? telegram.ts : 0)
|
|
7922
|
+
if (Number.isFinite(ts) && ts >= from && ts <= to) accumulator.add(telegram)
|
|
7923
|
+
})
|
|
7924
|
+
return accumulator.finish()
|
|
7856
7925
|
} catch (error) {
|
|
7857
7926
|
node.sysLogger?.warn(`KNX AI history load slice error: ${error.message || error}`)
|
|
7858
|
-
return
|
|
7927
|
+
return emptyAccumulator()
|
|
7859
7928
|
}
|
|
7860
7929
|
}
|
|
7861
7930
|
|
|
7931
|
+
const clampArchiveRangeToRetention = ({ range, retentionDays }) => {
|
|
7932
|
+
const now = nowMs()
|
|
7933
|
+
const days = Math.max(1, Number(retentionDays) || 1)
|
|
7934
|
+
const earliest = now - (days * 24 * 60 * 60 * 1000)
|
|
7935
|
+
const source = range && typeof range === 'object'
|
|
7936
|
+
? range
|
|
7937
|
+
: { fromTs: now - (24 * 60 * 60 * 1000), toTs: now, label: 'last 24 hours', explicit: false }
|
|
7938
|
+
const fromTs = Math.max(earliest, Number(source.fromTs || earliest))
|
|
7939
|
+
const toTs = Math.min(now, Number(source.toTs || now))
|
|
7940
|
+
return Object.assign({}, source, {
|
|
7941
|
+
fromTs,
|
|
7942
|
+
toTs: Math.max(fromTs, toTs),
|
|
7943
|
+
retentionDays: days,
|
|
7944
|
+
clampedToRetention: Number(source.fromTs || 0) < earliest
|
|
7945
|
+
})
|
|
7946
|
+
}
|
|
7947
|
+
|
|
7862
7948
|
const selectTelegramsForPrompt = ({ question, maxEvents }) => {
|
|
7863
7949
|
const now = nowMs()
|
|
7864
7950
|
const maxItems = Math.max(10, Number(maxEvents) || 120)
|
|
@@ -7866,36 +7952,132 @@ module.exports = function (RED) {
|
|
|
7866
7952
|
const fallbackRange = node.historyStoreToDisk === true
|
|
7867
7953
|
? { fromTs: now - (24 * 60 * 60 * 1000), toTs: now, label: 'last 24 hours', explicit: false }
|
|
7868
7954
|
: { fromTs: now - (Math.max(5, Number(node.historyWindowSec || 5)) * 1000), toTs: now, label: 'memory window', explicit: false }
|
|
7869
|
-
const range =
|
|
7955
|
+
const range = clampArchiveRangeToRetention({
|
|
7956
|
+
range: explicitRange || fallbackRange,
|
|
7957
|
+
retentionDays: node.historyStoreRetentionDays
|
|
7958
|
+
})
|
|
7870
7959
|
|
|
7871
7960
|
let selected = []
|
|
7872
7961
|
let source = 'memory'
|
|
7962
|
+
let archiveSummary = null
|
|
7873
7963
|
if (node.historyStoreToDisk === true) {
|
|
7874
|
-
const
|
|
7875
|
-
|
|
7876
|
-
|
|
7877
|
-
|
|
7878
|
-
if (!telegram || typeof telegram !== 'object') return
|
|
7879
|
-
const key = [
|
|
7880
|
-
Number(telegram.ts || 0),
|
|
7881
|
-
String(telegram.event || ''),
|
|
7882
|
-
String(telegram.source || ''),
|
|
7883
|
-
String(telegram.destination || ''),
|
|
7884
|
-
normalizeValueForCompare(telegram.payload),
|
|
7885
|
-
String(telegram.rawHex || '')
|
|
7886
|
-
].join('|')
|
|
7887
|
-
dedupe.set(key, telegram)
|
|
7888
|
-
})
|
|
7889
|
-
selected = Array.from(dedupe.values()).sort((a, b) => Number(a.ts || 0) - Number(b.ts || 0)).slice(-maxItems)
|
|
7890
|
-
source = 'archive+memory'
|
|
7964
|
+
const query = loadHistoryQueryFromDisk({ fromTs: range.fromTs, toTs: range.toTs, limit: maxItems, question })
|
|
7965
|
+
selected = query.events
|
|
7966
|
+
archiveSummary = query.summary
|
|
7967
|
+
source = 'daily JSONL archive'
|
|
7891
7968
|
} else {
|
|
7892
7969
|
selected = node._history.slice(-maxItems)
|
|
7970
|
+
const accumulator = createKnxAiHistoryAccumulator({ kind: 'knx', question, limit: maxItems })
|
|
7971
|
+
selected.forEach(telegram => accumulator.add(telegram))
|
|
7972
|
+
const memoryQuery = accumulator.finish()
|
|
7973
|
+
selected = memoryQuery.events
|
|
7974
|
+
archiveSummary = memoryQuery.summary
|
|
7893
7975
|
}
|
|
7894
7976
|
|
|
7895
7977
|
return {
|
|
7896
7978
|
events: selected,
|
|
7897
7979
|
source,
|
|
7898
|
-
range
|
|
7980
|
+
range,
|
|
7981
|
+
summary: archiveSummary
|
|
7982
|
+
}
|
|
7983
|
+
}
|
|
7984
|
+
|
|
7985
|
+
const pruneAdapterHistoryArchiveFiles = ({ force = false } = {}) => {
|
|
7986
|
+
const now = nowMs()
|
|
7987
|
+
if (!force && (now - Number(node._adapterHistoryDiskLastPruneAt || 0)) < (60 * 60 * 1000)) return
|
|
7988
|
+
node._adapterHistoryDiskLastPruneAt = now
|
|
7989
|
+
const dirPath = getAdapterHistoryArchiveDir()
|
|
7990
|
+
try {
|
|
7991
|
+
if (!fs.existsSync(dirPath)) return
|
|
7992
|
+
const entries = fs.readdirSync(dirPath, { withFileTypes: true })
|
|
7993
|
+
const retentionDays = Math.max(1, KNX_AI_ADAPTER_HISTORY_RETENTION_DAYS)
|
|
7994
|
+
const cutoffDayKey = formatArchiveDayKey(now - (retentionDays * 24 * 60 * 60 * 1000))
|
|
7995
|
+
entries.forEach(entry => {
|
|
7996
|
+
if (!entry || !entry.isFile()) return
|
|
7997
|
+
const match = String(entry.name || '').match(/^(\d{4}-\d{2}-\d{2})\.jsonl$/)
|
|
7998
|
+
if (!match || match[1] >= cutoffDayKey) return
|
|
7999
|
+
try { fs.unlinkSync(path.join(dirPath, entry.name)) } catch (error) { /* ignore */ }
|
|
8000
|
+
})
|
|
8001
|
+
} catch (error) {
|
|
8002
|
+
node.sysLogger?.warn(`KNX AI adapter history prune error: ${error.message || error}`)
|
|
8003
|
+
}
|
|
8004
|
+
}
|
|
8005
|
+
|
|
8006
|
+
const persistAdapterEventToDisk = ({ event, adapter, provider } = {}) => {
|
|
8007
|
+
const normalized = normalizeKnxAiAdapterHistoryEvent({ event, adapter, provider, nowTs: nowMs() })
|
|
8008
|
+
if (!normalized) return null
|
|
8009
|
+
const archiveDir = getAdapterHistoryArchiveDir()
|
|
8010
|
+
if (!ensureDirectorySync(archiveDir)) return normalized
|
|
8011
|
+
const filePath = getAdapterHistoryArchiveFile(formatArchiveDayKey(normalized.ts))
|
|
8012
|
+
const pendingKey = buildKnxAiHistoryEventKey(normalized, 'adapter')
|
|
8013
|
+
if (pendingKey) node._adapterHistoryDiskPending.set(pendingKey, normalized)
|
|
8014
|
+
fs.appendFile(filePath, `${JSON.stringify(normalized)}\n`, 'utf8', error => {
|
|
8015
|
+
if (pendingKey && node._adapterHistoryDiskPending.get(pendingKey) === normalized) node._adapterHistoryDiskPending.delete(pendingKey)
|
|
8016
|
+
if (error) node.sysLogger?.warn(`KNX AI adapter history append error: ${error.message || error}`)
|
|
8017
|
+
})
|
|
8018
|
+
pruneAdapterHistoryArchiveFiles()
|
|
8019
|
+
return normalized
|
|
8020
|
+
}
|
|
8021
|
+
|
|
8022
|
+
const loadAdapterHistoryQueryFromDisk = ({ fromTs, toTs, limit = 160, question = '' } = {}) => {
|
|
8023
|
+
const accumulator = createKnxAiHistoryAccumulator({ kind: 'adapter', question, limit })
|
|
8024
|
+
const from = Number(fromTs || 0)
|
|
8025
|
+
const to = Number(toTs || 0)
|
|
8026
|
+
if (!Number.isFinite(from) || !Number.isFinite(to) || to < from) return accumulator.finish()
|
|
8027
|
+
const pending = node._adapterHistoryDiskPending instanceof Map ? node._adapterHistoryDiskPending : new Map()
|
|
8028
|
+
try {
|
|
8029
|
+
const archiveDir = getAdapterHistoryArchiveDir()
|
|
8030
|
+
const dayKeys = collectArchiveDayKeysBetween({ fromTs: from, toTs: to })
|
|
8031
|
+
if (fs.existsSync(archiveDir)) {
|
|
8032
|
+
dayKeys.forEach(dayKey => {
|
|
8033
|
+
const filePath = getAdapterHistoryArchiveFile(dayKey)
|
|
8034
|
+
if (!fs.existsSync(filePath)) return
|
|
8035
|
+
const raw = fs.readFileSync(filePath, 'utf8')
|
|
8036
|
+
if (!raw || String(raw).trim() === '') return
|
|
8037
|
+
raw.split(/\r?\n/).forEach(line => {
|
|
8038
|
+
if (!line) return
|
|
8039
|
+
try {
|
|
8040
|
+
const item = JSON.parse(line)
|
|
8041
|
+
const ts = Number(item && item.ts ? item.ts : 0)
|
|
8042
|
+
if (!Number.isFinite(ts) || ts < from || ts > to) return
|
|
8043
|
+
const key = buildKnxAiHistoryEventKey(item, 'adapter')
|
|
8044
|
+
if (key && pending.has(key)) return
|
|
8045
|
+
accumulator.add(item)
|
|
8046
|
+
} catch (error) { /* ignore malformed archive rows */ }
|
|
8047
|
+
})
|
|
8048
|
+
})
|
|
8049
|
+
}
|
|
8050
|
+
pending.forEach(item => {
|
|
8051
|
+
const ts = Number(item && item.ts ? item.ts : 0)
|
|
8052
|
+
if (Number.isFinite(ts) && ts >= from && ts <= to) accumulator.add(item)
|
|
8053
|
+
})
|
|
8054
|
+
} catch (error) {
|
|
8055
|
+
node.sysLogger?.warn(`KNX AI adapter history load error: ${error.message || error}`)
|
|
8056
|
+
}
|
|
8057
|
+
return accumulator.finish()
|
|
8058
|
+
}
|
|
8059
|
+
|
|
8060
|
+
const selectAdapterEventsForPrompt = ({ question, maxEvents, range } = {}) => {
|
|
8061
|
+
const effectiveRange = clampArchiveRangeToRetention({
|
|
8062
|
+
range: range || parseQuestionTimeRange(question, nowMs()) || {
|
|
8063
|
+
fromTs: nowMs() - (KNX_AI_ADAPTER_HISTORY_MIN_HOURS * 60 * 60 * 1000),
|
|
8064
|
+
toTs: nowMs(),
|
|
8065
|
+
label: `last ${KNX_AI_ADAPTER_HISTORY_MIN_HOURS} hours`,
|
|
8066
|
+
explicit: false
|
|
8067
|
+
},
|
|
8068
|
+
retentionDays: KNX_AI_ADAPTER_HISTORY_RETENTION_DAYS
|
|
8069
|
+
})
|
|
8070
|
+
const query = loadAdapterHistoryQueryFromDisk({
|
|
8071
|
+
fromTs: effectiveRange.fromTs,
|
|
8072
|
+
toTs: effectiveRange.toTs,
|
|
8073
|
+
limit: Math.max(1, Number(maxEvents) || 160),
|
|
8074
|
+
question
|
|
8075
|
+
})
|
|
8076
|
+
return {
|
|
8077
|
+
events: query.events,
|
|
8078
|
+
summary: query.summary,
|
|
8079
|
+
source: 'daily JSONL adapter archive',
|
|
8080
|
+
range: effectiveRange
|
|
7899
8081
|
}
|
|
7900
8082
|
}
|
|
7901
8083
|
|
|
@@ -10190,6 +10372,8 @@ module.exports = function (RED) {
|
|
|
10190
10372
|
'- 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.',
|
|
10191
10373
|
'- GroupValue_Read is allowed for exact status, neutral, or command objects in AVAILABLE KNX OBJECTS because it does not modify the bus state.',
|
|
10192
10374
|
'- For a question that can be answered from recent data, return commands as an empty array. If current data is missing or the user explicitly asks for a fresh read, request it instead of claiming that read-only objects cannot be queried.',
|
|
10375
|
+
'- Historical questions must use the KNX and adapter archive summaries in the supplied analysis context. Totals describe every stored row in the requested interval; selected rows are samples for detail and must not be used as the total count.',
|
|
10376
|
+
'- Adapter history includes automatically detected provider events such as camera motion and smart detections. Do not claim that the absence of an archived event proves physical absence; report only what the adapters recorded.',
|
|
10193
10377
|
'- Create a GroupValue_Write only when the user clearly asks to control an actuator now.',
|
|
10194
10378
|
'- Never invent, guess, transform, or substitute a group address or DPT.',
|
|
10195
10379
|
'- A GroupValue_Write destination must appear in AVAILABLE KNX OBJECTS with role command. Status and neutral objects must never receive GroupValue_Write.',
|
|
@@ -10901,9 +11085,14 @@ module.exports = function (RED) {
|
|
|
10901
11085
|
}
|
|
10902
11086
|
}
|
|
10903
11087
|
|
|
10904
|
-
const handleCameraAdapterEvent = (providerEvent) => {
|
|
11088
|
+
const handleCameraAdapterEvent = (providerEvent, provider = null) => {
|
|
10905
11089
|
const event = normalizeKnxAiCameraEvent(providerEvent)
|
|
10906
|
-
if (!event
|
|
11090
|
+
if (!event) return false
|
|
11091
|
+
const adapter = provider && node._cameraAdapters instanceof Map
|
|
11092
|
+
? node._cameraAdapters.get(String(provider.adapterId || ''))
|
|
11093
|
+
: null
|
|
11094
|
+
persistAdapterEventToDisk({ event: Object.assign({}, providerEvent, event), adapter, provider })
|
|
11095
|
+
if (event.active === false) return true
|
|
10907
11096
|
const now = nowMs()
|
|
10908
11097
|
listAllKnxAiCameraWatches(node._chatContext).filter(watch => cameraWatchMatchesEvent(watch, event)).forEach((watch) => {
|
|
10909
11098
|
const lastAt = Number(node._cameraWatchLastTriggered.get(watch.id) || 0)
|
|
@@ -10958,7 +11147,7 @@ module.exports = function (RED) {
|
|
|
10958
11147
|
if (previousProvider === provider && node._cameraProviderUnsubscribers.has(providerId)) return
|
|
10959
11148
|
if (typeof provider.subscribe === 'function') {
|
|
10960
11149
|
const unsubscribe = provider.subscribe(event => {
|
|
10961
|
-
try { handleCameraAdapterEvent(event) } catch (error) {
|
|
11150
|
+
try { handleCameraAdapterEvent(event, provider) } catch (error) {
|
|
10962
11151
|
try { node.sysLogger?.warn(`KNX AI camera event error: ${error.message || error}`) } catch (logError) { /* ignore */ }
|
|
10963
11152
|
}
|
|
10964
11153
|
})
|
|
@@ -12312,6 +12501,7 @@ module.exports = function (RED) {
|
|
|
12312
12501
|
|
|
12313
12502
|
try {
|
|
12314
12503
|
pruneHistoryArchiveFiles({ force: true })
|
|
12504
|
+
pruneAdapterHistoryArchiveFiles({ force: true })
|
|
12315
12505
|
loadRecentHistoryFromDisk()
|
|
12316
12506
|
loadHomeMemoryFromDisk()
|
|
12317
12507
|
loadChatContextFromDisk()
|
|
@@ -12367,6 +12557,7 @@ module.exports = function (RED) {
|
|
|
12367
12557
|
}
|
|
12368
12558
|
|
|
12369
12559
|
module.exports.__test = {
|
|
12560
|
+
KNX_AI_ADAPTER_HISTORY_RETENTION_DAYS,
|
|
12370
12561
|
KNX_AI_CLOUD_LLM_TIMEOUT_MIN_MS,
|
|
12371
12562
|
KNX_AI_COMPACT_CONTEXT_MAX_TOKENS,
|
|
12372
12563
|
KNX_AI_LOCAL_CONTEXT_RETRY_CHAR_BUDGETS,
|
|
@@ -12410,6 +12601,7 @@ module.exports.__test = {
|
|
|
12410
12601
|
normalizeKnxAiCommandCandidates,
|
|
12411
12602
|
normalizeKnxAiRoutineDescriptor,
|
|
12412
12603
|
normalizeLmStudioModelCatalog,
|
|
12604
|
+
parseQuestionTimeRange,
|
|
12413
12605
|
parseKnxAiConversationResponse,
|
|
12414
12606
|
postLocalLlmWithContextFallbacks,
|
|
12415
12607
|
postOpenAiCompatibleChatWithFallbacks,
|
|
@@ -51,6 +51,8 @@ Installierte Kamerapakete können KNX AI zur Laufzeit einen Kamera-Adapter berei
|
|
|
51
51
|
|
|
52
52
|
Der Benutzer kann einen aktuellen Snapshot anfordern oder das Vision-Modell nach dem sichtbaren Inhalt fragen. Die Telegram- und RedBot-Vorlagen senden das Bild als natives Foto mit Bildunterschrift. Außerdem lassen sich dauerhafte Benachrichtigungen für Bewegung, das Überqueren einer intelligenten Linie oder das Betreten einer Einbruchs-/Verweilzone erstellen, optional auf erkannte Personen und eine genau benannte Linie oder Zone begrenzt. Diese Regeln werden in derselben Datei `knxai-chat-context.md` gespeichert und nach einem Neustart von Node-RED wiederhergestellt. UniFi-Ereignisse und Snapshot-Anfragen laufen direkt über den erkannten Anbieter; Ausgang 4 von KNX AI und zusätzliche Flow-Verkabelung sind nicht erforderlich.
|
|
53
53
|
|
|
54
|
+
Jedes von einem automatisch erkannten Adapter veröffentlichte Ereignis wird normalisiert und in eine tägliche Datei `YYYY-MM-DD.jsonl` unter `knxultimatestorage/knxai/adapter-history/<node-id>/` geschrieben. Das Archiv bewahrt 10 Tage auf, garantiert mehr als 24 Stunden Historie und speichert Ereignismetadaten, jedoch keine Snapshot-Bilder. Web-Assistent und alle CHAT-Kanäle fragen es zusammen mit dem täglichen KNX-Telegrammarchiv ab. Summen umfassen alle gespeicherten Zeilen; ausgewählte Details sind nur eine relevante Stichprobe.
|
|
55
|
+
|
|
54
56
|
### Ansagen mit TTS Ultimate
|
|
55
57
|
Wenn das optionale Paket `node-red-contrib-tts-ultimate` installiert ist, erscheint es unter den automatisch erkannten Adaptern. Die Auswahl listet alle `ttsultimate`-Nodes in sämtlichen Projekt-Flows mit Flow, Node-Name und konfiguriertem Player auf. Wählen Sie den Node für Chat-Ansagen aus und deployen Sie den Flow.
|
|
56
58
|
|
|
@@ -90,6 +90,7 @@
|
|
|
90
90
|
"chatContextFilesTitle": "Dauerhafte Kontextdateien",
|
|
91
91
|
"chatContextDirectoriesTitle": "KNX-Telegrammarchiv",
|
|
92
92
|
"chatContextSourceKnxTraffic": "Aktuelle KNX-Zusammenfassung, Anomalien, Topologie und ausgewählte Telegramme.",
|
|
93
|
+
"chatContextSourceAdapterHistory": "Dauerhaftes Tagesarchiv automatisch erkannter Adapterereignisse einschließlich Kameraerkennungen.",
|
|
93
94
|
"chatContextSourceEtsProject": "ETS-Semantik und vollständiges Inventar des Node-RED-Projekts.",
|
|
94
95
|
"chatContextSourceMemoryEducation": "Sitzungskontext, KI-Erziehung und begrenztes Hausgedächtnis.",
|
|
95
96
|
"chatContextSourceCamerasDocs": "Erkannte Kameras und relevante Auszüge aus Hilfe, README und Beispielen.",
|
|
@@ -102,6 +103,9 @@
|
|
|
102
103
|
"chatContextDirectoryRoot": "Stammverzeichnis des Telegrammarchivs",
|
|
103
104
|
"chatContextDirectoryNode": "Telegrammarchiv dieses Nodes",
|
|
104
105
|
"chatContextDirectoryBadge": "KNX",
|
|
106
|
+
"chatContextDirectoryAdapterRoot": "Stammverzeichnis des Adapterereignisarchivs",
|
|
107
|
+
"chatContextDirectoryAdapterNode": "Adapterereignisarchiv dieses Nodes",
|
|
108
|
+
"chatContextDirectoryAdapterBadge": "Adapter",
|
|
105
109
|
"chatContextTelegramPattern": "Tagesdateien",
|
|
106
110
|
"chatAdapterIntro": "Wählen Sie eine Vorlage, um den Code für Ein- und Ausgang einzufügen. Die Liste wird aus der mitgelieferten Chat-Adapter-Datei geladen; der erzeugte Code bleibt bearbeitbar.",
|
|
107
111
|
"chatAdapterCodeHelp": "Zuordnungen laufen synchron. Geben Sie msg zurück, um fortzufahren, oder keinen Wert, um die Nachricht zu verwerfen. Fehler werden abgefangen und gemeldet, ohne Node-RED anzuhalten.",
|
|
@@ -51,13 +51,15 @@ Installed camera packages can publish a camera adapter to KNX AI at runtime. The
|
|
|
51
51
|
|
|
52
52
|
The user can ask for a current snapshot or ask the vision model what is visible. Telegram and RedBot presets emit the returned image as a native photo with a caption. The user can also create persistent notifications for motion, a smart line crossing or entry into an intrusion/loiter zone, optionally limited to detected people and to an exact named line or zone. These rules are stored in the same `knxai-chat-context.md` file and are restored after Node-RED restarts. UniFi event subscriptions and snapshot requests are made directly through the detected provider; KNX AI output 4 is not involved and no intermediate flow wiring is required.
|
|
53
53
|
|
|
54
|
+
Every event published by an automatically detected adapter is normalized and appended to a daily `YYYY-MM-DD.jsonl` file under `knxultimatestorage/knxai/adapter-history/<node-id>/`. The archive keeps 10 days, guarantees more than 24 hours of history and stores event metadata rather than snapshot images. The web Assistant and every CHAT channel query it together with the KNX daily telegram archive. Totals cover every stored row in the requested interval; selected details are only a relevant sample.
|
|
55
|
+
|
|
54
56
|
### TTS Ultimate announcements
|
|
55
57
|
When the optional `node-red-contrib-tts-ultimate` package is installed, it appears among the automatically detected adapters. The selector lists every `ttsultimate` node in all project flows, with its flow, node name and configured player. Choose the node that must handle chat announcements and deploy the flow.
|
|
56
58
|
|
|
57
59
|
Only an explicit request in the current chat message can create an announcement. KNX AI sends the exact text directly to the selected node as `msg.payload`, with `msg.topic = "knx_ai_announcement"`; no intermediate flow wiring is required. TTS Ultimate then handles the configured Sonos player, voice, volume, hailing and queue. Persistent context, AI Education, camera content and inferred events never trigger speech by themselves.
|
|
58
60
|
|
|
59
61
|
### Chat context overview
|
|
60
|
-
The node editor shows a compact card summarizing the sources available to the chat: live KNX traffic, ETS semantics and the Node-RED project, session and home memory, AI Education, detected cameras and relevant documentation. It also lists
|
|
62
|
+
The node editor shows a compact card summarizing the sources available to the chat: live and archived KNX traffic, persistent adapter events, ETS semantics and the Node-RED project, session and home memory, AI Education, detected cameras and relevant documentation. It also lists the absolute KNX telegram and adapter-event archive directories and the `YYYY-MM-DD.jsonl` daily-file pattern.
|
|
61
63
|
|
|
62
64
|
## Education-driven proactive home intelligence and bounded memory
|
|
63
65
|
From ETS hierarchy, names, roles and DPTs, the node builds a deterministic semantic model for covers, windows, doors, lights, temperature, climate, occupancy and alarms using Italian, English, German, French, Spanish and Chinese terms. Its proactive detector watches only reliably recognized non-command cover/window/door states.
|
|
@@ -91,6 +91,7 @@
|
|
|
91
91
|
"chatContextFilesTitle": "Persistent context files",
|
|
92
92
|
"chatContextDirectoriesTitle": "KNX telegram archive",
|
|
93
93
|
"chatContextSourceKnxTraffic": "Live KNX summary, anomalies, topology and selected telegrams.",
|
|
94
|
+
"chatContextSourceAdapterHistory": "Persistent daily history of automatically detected adapter events, including camera detections.",
|
|
94
95
|
"chatContextSourceEtsProject": "ETS semantics and the full Node-RED project inventory.",
|
|
95
96
|
"chatContextSourceMemoryEducation": "Session context, AI Education and bounded home memory.",
|
|
96
97
|
"chatContextSourceCamerasDocs": "Detected cameras and relevant help, README and example snippets.",
|
|
@@ -103,6 +104,9 @@
|
|
|
103
104
|
"chatContextDirectoryRoot": "Telegram archive root",
|
|
104
105
|
"chatContextDirectoryNode": "This node's telegram archive",
|
|
105
106
|
"chatContextDirectoryBadge": "KNX",
|
|
107
|
+
"chatContextDirectoryAdapterRoot": "Adapter event archive root",
|
|
108
|
+
"chatContextDirectoryAdapterNode": "This node's adapter event archive",
|
|
109
|
+
"chatContextDirectoryAdapterBadge": "Adapter",
|
|
106
110
|
"chatContextTelegramPattern": "Daily files",
|
|
107
111
|
"chatAdapterIntro": "Choose a mapping preset to insert its input and output code. The list is loaded from the packaged chat-adapter mappings file; the generated code remains editable.",
|
|
108
112
|
"chatAdapterCodeHelp": "Mappings run synchronously. Return msg to continue or return no value to discard it. Errors are caught and reported without stopping Node-RED.",
|
|
@@ -51,6 +51,8 @@ Los paquetes de cámaras instalados pueden publicar en tiempo de ejecución un a
|
|
|
51
51
|
|
|
52
52
|
El usuario puede pedir una captura actual o preguntar al modelo de visión qué se ve. Los preajustes de Telegram y RedBot envían la imagen como foto nativa con pie. También se pueden crear notificaciones persistentes por movimiento, cruce de una línea inteligente o entrada en una zona de intrusión/merodeo, limitadas opcionalmente a personas detectadas y a una línea o zona concreta por nombre. Estas reglas se guardan en el mismo archivo `knxai-chat-context.md` y se restauran después de reiniciar Node-RED. Las suscripciones a eventos UniFi y las solicitudes de captura se realizan directamente a través del proveedor detectado; no interviene la salida 4 de KNX AI ni hace falta cableado intermedio en el flujo.
|
|
53
53
|
|
|
54
|
+
Cada evento publicado por un adaptador detectado automáticamente se normaliza y se añade a un archivo diario `YYYY-MM-DD.jsonl` bajo `knxultimatestorage/knxai/adapter-history/<id-nodo>/`. El archivo conserva 10 días, garantiza más de 24 horas de historial y guarda metadatos, pero no imágenes. El Asistente web y todos los canales CHAT lo consultan junto con el archivo diario KNX. Los totales abarcan todas las filas almacenadas; los detalles seleccionados son solo una muestra relevante.
|
|
55
|
+
|
|
54
56
|
### Anuncios con TTS Ultimate
|
|
55
57
|
Cuando está instalado el paquete opcional `node-red-contrib-tts-ultimate`, aparece entre los adaptadores detectados automáticamente. El selector muestra todos los nodos `ttsultimate` de todos los flows del proyecto, con el flow, el nombre del nodo y el reproductor configurado. Elige el nodo que gestionará los anuncios del chat y despliega el flow.
|
|
56
58
|
|
|
@@ -83,6 +83,7 @@
|
|
|
83
83
|
"chatContextFilesTitle": "Archivos de contexto persistentes",
|
|
84
84
|
"chatContextDirectoriesTitle": "Archivo de telegramas KNX",
|
|
85
85
|
"chatContextSourceKnxTraffic": "Resumen KNX actual, anomalías, topología y telegramas seleccionados.",
|
|
86
|
+
"chatContextSourceAdapterHistory": "Historial diario persistente de eventos de los adaptadores detectados, incluidas las detecciones de cámaras.",
|
|
86
87
|
"chatContextSourceEtsProject": "Semántica ETS e inventario completo del proyecto Node-RED.",
|
|
87
88
|
"chatContextSourceMemoryEducation": "Contexto de sesión, Educación IA y memoria doméstica limitada.",
|
|
88
89
|
"chatContextSourceCamerasDocs": "Cámaras detectadas y fragmentos relevantes de la ayuda, README y ejemplos.",
|
|
@@ -95,6 +96,9 @@
|
|
|
95
96
|
"chatContextDirectoryRoot": "Raíz del archivo de telegramas",
|
|
96
97
|
"chatContextDirectoryNode": "Archivo de telegramas de este nodo",
|
|
97
98
|
"chatContextDirectoryBadge": "KNX",
|
|
99
|
+
"chatContextDirectoryAdapterRoot": "Raíz del archivo de eventos de adaptadores",
|
|
100
|
+
"chatContextDirectoryAdapterNode": "Archivo de eventos de adaptadores de este nodo",
|
|
101
|
+
"chatContextDirectoryAdapterBadge": "Adaptador",
|
|
98
102
|
"chatContextTelegramPattern": "Archivos diarios",
|
|
99
103
|
"chatAdapterIntro": "Elige un preajuste para insertar su código de mapeo de entrada y salida. La lista se carga desde el archivo de adaptadores de chat incluido; el código generado sigue siendo editable.",
|
|
100
104
|
"chatAdapterCodeHelp": "Los mapeos se ejecutan de forma síncrona. Devuelve msg para continuar o ningún valor para descartarlo. Los errores se capturan y notifican sin detener Node-RED.",
|
|
@@ -51,6 +51,8 @@ Les paquets de caméra installés peuvent publier à l’exécution un adaptateu
|
|
|
51
51
|
|
|
52
52
|
L’utilisateur peut demander une capture actuelle ou demander au modèle de vision ce qui est visible. Les préréglages Telegram et RedBot envoient l’image comme photo native avec une légende. L’utilisateur peut aussi créer des notifications persistantes pour un mouvement, le franchissement d’une ligne intelligente ou l’entrée dans une zone d’intrusion/de stationnement, avec une limitation facultative aux personnes détectées et à une ligne ou zone nommée précise. Ces règles sont stockées dans le même fichier `knxai-chat-context.md` et restaurées après les redémarrages de Node-RED. Les abonnements aux événements UniFi et les demandes de capture passent directement par le fournisseur détecté ; la sortie 4 de KNX AI et un câblage intermédiaire ne sont pas nécessaires.
|
|
53
53
|
|
|
54
|
+
Chaque événement publié par un adaptateur détecté automatiquement est normalisé puis ajouté à un fichier quotidien `YYYY-MM-DD.jsonl` sous `knxultimatestorage/knxai/adapter-history/<id-nœud>/`. L’archive conserve 10 jours, garantit plus de 24 heures d’historique et stocke les métadonnées, mais pas les images. L’Assistant web et tous les canaux CHAT l’interrogent avec l’archive quotidienne KNX. Les totaux couvrent toutes les lignes stockées ; les détails sélectionnés ne sont qu’un échantillon pertinent.
|
|
55
|
+
|
|
54
56
|
### Annonces avec TTS Ultimate
|
|
55
57
|
Lorsque le paquet facultatif `node-red-contrib-tts-ultimate` est installé, il apparaît parmi les adaptateurs détectés automatiquement. Le sélecteur recense tous les nœuds `ttsultimate` de tous les flows du projet, avec le flow, le nom du nœud et le lecteur configuré. Sélectionnez le nœud chargé des annonces du chat, puis déployez le flow.
|
|
56
58
|
|
|
@@ -83,6 +83,7 @@
|
|
|
83
83
|
"chatContextFilesTitle": "Fichiers de contexte persistants",
|
|
84
84
|
"chatContextDirectoriesTitle": "Archive des télégrammes KNX",
|
|
85
85
|
"chatContextSourceKnxTraffic": "Résumé KNX actuel, anomalies, topologie et télégrammes sélectionnés.",
|
|
86
|
+
"chatContextSourceAdapterHistory": "Historique quotidien persistant des événements des adaptateurs détectés, y compris les détections des caméras.",
|
|
86
87
|
"chatContextSourceEtsProject": "Sémantique ETS et inventaire complet du projet Node-RED.",
|
|
87
88
|
"chatContextSourceMemoryEducation": "Contexte de session, Éducation IA et mémoire domestique limitée.",
|
|
88
89
|
"chatContextSourceCamerasDocs": "Caméras détectées et extraits pertinents de l'aide, du README et des exemples.",
|
|
@@ -95,6 +96,9 @@
|
|
|
95
96
|
"chatContextDirectoryRoot": "Racine de l'archive des télégrammes",
|
|
96
97
|
"chatContextDirectoryNode": "Archive des télégrammes de ce nœud",
|
|
97
98
|
"chatContextDirectoryBadge": "KNX",
|
|
99
|
+
"chatContextDirectoryAdapterRoot": "Racine de l’archive des événements des adaptateurs",
|
|
100
|
+
"chatContextDirectoryAdapterNode": "Archive des événements des adaptateurs de ce nœud",
|
|
101
|
+
"chatContextDirectoryAdapterBadge": "Adaptateur",
|
|
98
102
|
"chatContextTelegramPattern": "Fichiers quotidiens",
|
|
99
103
|
"chatAdapterIntro": "Choisissez un préréglage pour insérer son code de mappage d’entrée et de sortie. La liste est chargée depuis le fichier d’adaptateurs de chat fourni ; le code généré reste modifiable.",
|
|
100
104
|
"chatAdapterCodeHelp": "Les mappages s’exécutent de façon synchrone. Renvoyez msg pour continuer ou aucune valeur pour l’écarter. Les erreurs sont interceptées et signalées sans arrêter Node-RED.",
|
|
@@ -51,13 +51,15 @@ I pacchetti di telecamere installati possono pubblicare a runtime un adapter per
|
|
|
51
51
|
|
|
52
52
|
L'utente può chiedere uno snapshot aggiornato oppure domandare al modello vision che cosa è visibile. I preset Telegram e RedBot inviano l'immagine come foto nativa con didascalia. L'utente può anche creare notifiche persistenti per movimento, attraversamento di una linea intelligente o ingresso in una zona di intrusione/stazionamento, limitandole facoltativamente alle persone rilevate e a una linea o zona nominata esatta. Le regole vengono salvate nello stesso file `knxai-chat-context.md` e ripristinate dopo i riavvii di Node-RED. Le sottoscrizioni agli eventi UniFi e le richieste snapshot avvengono direttamente tramite il provider rilevato: l'uscita 4 di KNX AI non è coinvolta e non servono collegamenti intermedi nel flow.
|
|
53
53
|
|
|
54
|
+
Ogni evento pubblicato da un adapter rilevato automaticamente viene normalizzato e aggiunto a un file giornaliero `YYYY-MM-DD.jsonl` sotto `knxultimatestorage/knxai/adapter-history/<id-nodo>/`. L'archivio conserva 10 giorni, garantisce più di 24 ore di storico e salva i metadati degli eventi, non le immagini. Assistente web e canali CHAT lo interrogano insieme all'archivio giornaliero KNX. I totali comprendono tutte le righe memorizzate nell'intervallo richiesto; i dettagli selezionati sono soltanto un campione pertinente.
|
|
55
|
+
|
|
54
56
|
### Annunci con TTS Ultimate
|
|
55
57
|
Quando è installato il pacchetto opzionale `node-red-contrib-tts-ultimate`, questo compare tra gli adapter rilevati automaticamente. Il selettore elenca tutti i nodi `ttsultimate` presenti in tutti i flow del progetto, indicando flow, nome del nodo e player configurato. Scegli il nodo che deve gestire gli annunci della chat e fai il deploy del flow.
|
|
56
58
|
|
|
57
59
|
Solo una richiesta esplicita nel messaggio corrente della chat può creare un annuncio. KNX AI invia il testo esatto direttamente al nodo scelto come `msg.payload`, con `msg.topic = "knx_ai_announcement"`; non servono collegamenti intermedi nel flow. TTS Ultimate gestisce poi il player Sonos configurato, voce, volume, hailing e coda. Contesto persistente, Educazione AI, contenuto delle telecamere ed eventi dedotti non attivano mai autonomamente la voce.
|
|
58
60
|
|
|
59
61
|
### Riepilogo del contesto della chat
|
|
60
|
-
L'editor del nodo mostra una scheda compatta con le fonti disponibili alla chat: traffico KNX corrente, semantica ETS e progetto Node-RED, memoria di sessione e domestica, Educazione AI, telecamere rilevate e documentazione pertinente. Elenca
|
|
62
|
+
L'editor del nodo mostra una scheda compatta con le fonti disponibili alla chat: traffico KNX corrente e archiviato, eventi persistenti degli adapter, semantica ETS e progetto Node-RED, memoria di sessione e domestica, Educazione AI, telecamere rilevate e documentazione pertinente. Elenca anche le directory assolute degli archivi KNX e degli eventi adapter e il formato giornaliero `YYYY-MM-DD.jsonl`.
|
|
61
63
|
|
|
62
64
|
## Intelligenza domestica proattiva guidata dall'Educazione e memoria limitata
|
|
63
65
|
Da gerarchia ETS, nomi, ruoli e DPT, il nodo crea un modello semantico deterministico per persiane, finestre, porte, luci, temperatura, clima, presenza e allarmi usando termini italiani, inglesi, tedeschi, francesi, spagnoli e cinesi. Il rilevatore proattivo osserva soltanto stati non di comando di persiane, finestre e porte riconosciuti con sufficiente affidabilità.
|
|
@@ -91,6 +91,7 @@
|
|
|
91
91
|
"chatContextFilesTitle": "File persistenti di contesto",
|
|
92
92
|
"chatContextDirectoriesTitle": "Archivio telegrammi KNX",
|
|
93
93
|
"chatContextSourceKnxTraffic": "Riepilogo KNX corrente, anomalie, topologia e telegrammi selezionati.",
|
|
94
|
+
"chatContextSourceAdapterHistory": "Storico giornaliero persistente degli eventi degli adapter rilevati, comprese le rilevazioni delle telecamere.",
|
|
94
95
|
"chatContextSourceEtsProject": "Semantica ETS e inventario completo del progetto Node-RED.",
|
|
95
96
|
"chatContextSourceMemoryEducation": "Contesto di sessione, Educazione AI e memoria domestica limitata.",
|
|
96
97
|
"chatContextSourceCamerasDocs": "Telecamere rilevate ed estratti pertinenti da help, README ed esempi.",
|
|
@@ -103,6 +104,9 @@
|
|
|
103
104
|
"chatContextDirectoryRoot": "Radice archivio telegrammi",
|
|
104
105
|
"chatContextDirectoryNode": "Archivio telegrammi di questo nodo",
|
|
105
106
|
"chatContextDirectoryBadge": "KNX",
|
|
107
|
+
"chatContextDirectoryAdapterRoot": "Radice archivio eventi adapter",
|
|
108
|
+
"chatContextDirectoryAdapterNode": "Archivio eventi adapter di questo nodo",
|
|
109
|
+
"chatContextDirectoryAdapterBadge": "Adapter",
|
|
106
110
|
"chatContextTelegramPattern": "File giornalieri",
|
|
107
111
|
"chatAdapterIntro": "Scegli un preset per inserire il codice di mappatura in ingresso e in uscita. La lista viene caricata dal file degli adattatori chat incluso nel pacchetto; il codice generato resta modificabile.",
|
|
108
112
|
"chatAdapterCodeHelp": "Le mappature sono sincrone. Restituisci msg per continuare oppure nessun valore per scartarlo. Gli errori vengono intercettati e segnalati senza arrestare Node-RED.",
|
|
@@ -51,6 +51,8 @@ Canvas 上的节点状态专门用于显示最近收到的请求,以及 LLM
|
|
|
51
51
|
|
|
52
52
|
用户可以请求当前快照,或询问视觉模型画面中可见的内容。Telegram 和 RedBot 预设会把图像作为带说明文字的原生照片发送。用户还可以为移动、智能越线或进入入侵/徘徊区域创建持久通知,并可按检测到的人员以及指定名称的线或区域进行限制。这些规则保存在同一个 `knxai-chat-context.md` 文件中,并在 Node-RED 重启后恢复。UniFi 事件订阅和快照请求直接通过检测到的提供方完成;不会使用 KNX AI 输出 4,也不需要中间 Flow 连线。
|
|
53
53
|
|
|
54
|
+
自动检测到的适配器发布的每个事件都会被标准化,并追加到 `knxultimatestorage/knxai/adapter-history/<节点ID>/` 下的每日 `YYYY-MM-DD.jsonl` 文件。存档保留 10 天,保证超过 24 小时的历史,只保存事件元数据,不保存图像。Web 助手和所有 CHAT 渠道会同时查询它与每日 KNX 报文存档。总数涵盖所请求区间内的全部存档行;选出的详情仅是相关样本。
|
|
55
|
+
|
|
54
56
|
### 使用 TTS Ultimate 播报
|
|
55
57
|
安装可选软件包 `node-red-contrib-tts-ultimate` 后,它会显示在自动检测的适配器中。选择器会列出项目所有 Flow 中的全部 `ttsultimate` 节点,并显示 Flow、节点名称和已配置的播放器。请选择负责聊天播报的节点,然后部署 Flow。
|
|
56
58
|
|
|
@@ -83,6 +83,7 @@
|
|
|
83
83
|
"chatContextFilesTitle": "持久化上下文文件",
|
|
84
84
|
"chatContextDirectoriesTitle": "KNX 报文归档",
|
|
85
85
|
"chatContextSourceKnxTraffic": "当前 KNX 摘要、异常、拓扑和选定报文。",
|
|
86
|
+
"chatContextSourceAdapterHistory": "自动检测到的适配器事件的持久每日历史记录,包括摄像机检测事件。",
|
|
86
87
|
"chatContextSourceEtsProject": "ETS 语义和完整的 Node-RED 项目清单。",
|
|
87
88
|
"chatContextSourceMemoryEducation": "会话上下文、AI 教育和有界家庭记忆。",
|
|
88
89
|
"chatContextSourceCamerasDocs": "检测到的摄像机以及相关帮助、README 和示例片段。",
|
|
@@ -95,6 +96,9 @@
|
|
|
95
96
|
"chatContextDirectoryRoot": "报文归档根目录",
|
|
96
97
|
"chatContextDirectoryNode": "此节点的报文归档",
|
|
97
98
|
"chatContextDirectoryBadge": "KNX",
|
|
99
|
+
"chatContextDirectoryAdapterRoot": "适配器事件存档根目录",
|
|
100
|
+
"chatContextDirectoryAdapterNode": "此节点的适配器事件存档",
|
|
101
|
+
"chatContextDirectoryAdapterBadge": "适配器",
|
|
98
102
|
"chatContextTelegramPattern": "每日文件",
|
|
99
103
|
"chatAdapterIntro": "选择预设即可插入输入和输出映射代码。列表从随包提供的聊天适配器文件加载;生成的代码仍可编辑。",
|
|
100
104
|
"chatAdapterCodeHelp": "映射同步运行。返回 msg 以继续,或不返回值以丢弃消息。错误会被捕获并报告,不会停止 Node-RED。",
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
const KNX_AI_ADAPTER_HISTORY_MIN_HOURS = 24
|
|
2
|
+
const KNX_AI_HISTORY_DETAILS_MAX_CHARS = 12000
|
|
3
|
+
|
|
4
|
+
const clampText = (value, maxChars = 500) => String(value === undefined || value === null ? '' : value)
|
|
5
|
+
.trim()
|
|
6
|
+
.slice(0, Math.max(0, Number(maxChars) || 0))
|
|
7
|
+
|
|
8
|
+
const normalizeSearchText = value => clampText(value, 4000)
|
|
9
|
+
.normalize('NFKD')
|
|
10
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
11
|
+
.toLocaleLowerCase()
|
|
12
|
+
.replace(/[^a-z0-9./_-]+/g, ' ')
|
|
13
|
+
.trim()
|
|
14
|
+
|
|
15
|
+
const parseTimestamp = (value, fallback = Date.now()) => {
|
|
16
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value > 100000000000 ? value : value * 1000
|
|
17
|
+
const parsed = new Date(String(value || '')).getTime()
|
|
18
|
+
return Number.isFinite(parsed) ? parsed : fallback
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const sanitizeHistoryValue = (value, depth = 0, seen = new Set()) => {
|
|
22
|
+
if (value === null || value === undefined) return value
|
|
23
|
+
if (typeof value === 'string') return clampText(value, 1000)
|
|
24
|
+
if (typeof value === 'number' || typeof value === 'boolean') return value
|
|
25
|
+
if (typeof value === 'bigint') return String(value)
|
|
26
|
+
if (Buffer.isBuffer(value)) return `[binary ${value.length} bytes]`
|
|
27
|
+
if (typeof value !== 'object') return clampText(value, 1000)
|
|
28
|
+
if (depth >= 3 || seen.has(value)) return '[nested]'
|
|
29
|
+
seen.add(value)
|
|
30
|
+
if (Array.isArray(value)) {
|
|
31
|
+
const out = value.slice(0, 30).map(item => sanitizeHistoryValue(item, depth + 1, seen))
|
|
32
|
+
seen.delete(value)
|
|
33
|
+
return out
|
|
34
|
+
}
|
|
35
|
+
const out = {}
|
|
36
|
+
Object.keys(value).slice(0, 60).forEach(key => {
|
|
37
|
+
const normalizedKey = clampText(key, 120)
|
|
38
|
+
if (!normalizedKey || /^(data|image|snapshot|buffer|base64)$/i.test(normalizedKey)) return
|
|
39
|
+
out[normalizedKey] = sanitizeHistoryValue(value[key], depth + 1, seen)
|
|
40
|
+
})
|
|
41
|
+
seen.delete(value)
|
|
42
|
+
return out
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const normalizeKnxAiAdapterHistoryEvent = ({ event, adapter, provider, nowTs = Date.now() } = {}) => {
|
|
46
|
+
const source = event && typeof event === 'object' && !Array.isArray(event) ? event : {}
|
|
47
|
+
const adapterSource = adapter && typeof adapter === 'object' ? adapter : {}
|
|
48
|
+
const providerSource = provider && typeof provider === 'object' ? provider : {}
|
|
49
|
+
const ts = parseTimestamp(source.ts || source.timestamp || source.at || source.start || source.date, nowTs)
|
|
50
|
+
const eventType = clampText(source.eventType || source.type || source.event || source.kind, 120)
|
|
51
|
+
if (!eventType) return null
|
|
52
|
+
const cameraId = clampText(source.cameraId || source.resourceId || source.deviceId, 200)
|
|
53
|
+
const cameraName = clampText(source.cameraName || source.resourceName || source.deviceName, 300)
|
|
54
|
+
const details = sanitizeHistoryValue(source.raw || source.details || source.metadata || {})
|
|
55
|
+
let detailsText = ''
|
|
56
|
+
try { detailsText = JSON.stringify(details) } catch (error) { detailsText = '' }
|
|
57
|
+
return {
|
|
58
|
+
ts,
|
|
59
|
+
at: new Date(ts).toISOString(),
|
|
60
|
+
adapterId: clampText(source.adapterId || providerSource.adapterId || adapterSource.id, 160),
|
|
61
|
+
adapterTitle: clampText(source.adapterTitle || adapterSource.title || adapterSource.name, 240),
|
|
62
|
+
providerId: clampText(source.providerId || providerSource.id, 220),
|
|
63
|
+
providerTitle: clampText(source.providerTitle || providerSource.title || providerSource.name, 240),
|
|
64
|
+
controllerId: clampText(source.controllerId || providerSource.controllerId, 180),
|
|
65
|
+
controllerName: clampText(source.controllerName || providerSource.controllerName, 240),
|
|
66
|
+
resourceType: clampText(source.resourceType || (cameraId || cameraName ? 'camera' : 'adapter'), 80),
|
|
67
|
+
resourceId: cameraId,
|
|
68
|
+
resourceName: cameraName,
|
|
69
|
+
eventType,
|
|
70
|
+
eventId: clampText(source.eventId || source.id, 200),
|
|
71
|
+
active: source.active !== false,
|
|
72
|
+
scopeId: clampText(source.scopeId, 180),
|
|
73
|
+
scopeName: clampText(source.scopeName, 240),
|
|
74
|
+
objectTypes: Array.from(new Set((Array.isArray(source.objectTypes) ? source.objectTypes : [])
|
|
75
|
+
.map(value => clampText(value, 100))
|
|
76
|
+
.filter(Boolean))).slice(0, 24),
|
|
77
|
+
details: detailsText.length <= KNX_AI_HISTORY_DETAILS_MAX_CHARS ? details : { truncated: true }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const HISTORY_STOP_WORDS = new Set([
|
|
82
|
+
'a', 'al', 'alla', 'alle', 'anche', 'and', 'auf', 'aux', 'avec', 'che', 'con', 'da', 'dal', 'dalla', 'das', 'de', 'dei', 'del', 'della', 'des', 'di', 'die', 'do', 'du', 'e', 'el', 'en', 'et', 'for', 'gli', 'ha', 'hanno', 'i', 'il', 'in', 'is', 'la', 'le', 'les', 'lo', 'mit', 'nel', 'nella', 'of', 'on', 'or', 'per', 'pour', 'que', 'qui', 'se', 'sono', 'su', 'the', 'to', 'tra', 'un', 'una', 'und', 'was', 'what', 'with', 'zu'
|
|
83
|
+
])
|
|
84
|
+
|
|
85
|
+
const historyQuestionTokens = question => Array.from(new Set(normalizeSearchText(question)
|
|
86
|
+
.split(/\s+/)
|
|
87
|
+
.filter(token => token.length >= 2 && !HISTORY_STOP_WORDS.has(token) && !/^\d{1,4}$/.test(token))))
|
|
88
|
+
.slice(0, 24)
|
|
89
|
+
|
|
90
|
+
const incrementCount = (map, key) => {
|
|
91
|
+
const normalized = clampText(key, 500) || '(unknown)'
|
|
92
|
+
map.set(normalized, Number(map.get(normalized) || 0) + 1)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const topCounts = (map, limit) => Array.from(map.entries())
|
|
96
|
+
.map(([key, count]) => ({ key, count }))
|
|
97
|
+
.sort((left, right) => right.count - left.count || left.key.localeCompare(right.key))
|
|
98
|
+
.slice(0, Math.max(1, Number(limit) || 1))
|
|
99
|
+
|
|
100
|
+
const buildEventSearchText = (event, kind) => {
|
|
101
|
+
if (kind === 'knx') {
|
|
102
|
+
return normalizeSearchText([
|
|
103
|
+
event.event,
|
|
104
|
+
event.source,
|
|
105
|
+
event.destination,
|
|
106
|
+
event.devicename,
|
|
107
|
+
event.dpt,
|
|
108
|
+
event.payloadmeasureunit,
|
|
109
|
+
typeof event.payload === 'object' ? JSON.stringify(event.payload) : event.payload
|
|
110
|
+
].join(' '))
|
|
111
|
+
}
|
|
112
|
+
return normalizeSearchText([
|
|
113
|
+
event.adapterId,
|
|
114
|
+
event.adapterTitle,
|
|
115
|
+
event.providerId,
|
|
116
|
+
event.providerTitle,
|
|
117
|
+
event.controllerId,
|
|
118
|
+
event.controllerName,
|
|
119
|
+
event.resourceType,
|
|
120
|
+
event.resourceId,
|
|
121
|
+
event.resourceName,
|
|
122
|
+
event.eventType,
|
|
123
|
+
event.scopeId,
|
|
124
|
+
event.scopeName,
|
|
125
|
+
...(Array.isArray(event.objectTypes) ? event.objectTypes : []),
|
|
126
|
+
event.details && typeof event.details === 'object' ? JSON.stringify(event.details) : ''
|
|
127
|
+
].join(' '))
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const buildKnxAiHistoryEventKey = (event, kind = 'adapter') => {
|
|
131
|
+
if (!event || typeof event !== 'object') return ''
|
|
132
|
+
if (kind === 'knx') {
|
|
133
|
+
let payload = ''
|
|
134
|
+
try { payload = typeof event.payload === 'object' ? JSON.stringify(event.payload) : String(event.payload) } catch (error) { payload = '' }
|
|
135
|
+
return [Number(event.ts || 0), event.event, event.source, event.destination, payload, event.rawHex].join('|')
|
|
136
|
+
}
|
|
137
|
+
return [Number(event.ts || 0), event.adapterId, event.providerId, event.resourceId, event.eventType, event.eventId, event.scopeId, (event.objectTypes || []).join(',')].join('|')
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const createKnxAiHistoryAccumulator = ({ kind = 'adapter', question = '', limit = 120 } = {}) => {
|
|
141
|
+
const normalizedKind = kind === 'knx' ? 'knx' : 'adapter'
|
|
142
|
+
const maxItems = Math.max(1, Number(limit) || 120)
|
|
143
|
+
const tokens = historyQuestionTokens(question)
|
|
144
|
+
const recent = []
|
|
145
|
+
const relevant = []
|
|
146
|
+
const counts = {
|
|
147
|
+
byEvent: new Map(),
|
|
148
|
+
bySource: new Map(),
|
|
149
|
+
byResource: new Map(),
|
|
150
|
+
byObjectType: new Map(),
|
|
151
|
+
byCombination: new Map()
|
|
152
|
+
}
|
|
153
|
+
let total = 0
|
|
154
|
+
let active = 0
|
|
155
|
+
let firstTs = 0
|
|
156
|
+
let lastTs = 0
|
|
157
|
+
|
|
158
|
+
const add = event => {
|
|
159
|
+
if (!event || typeof event !== 'object') return
|
|
160
|
+
const ts = Number(event.ts || new Date(event.at || '').getTime() || 0)
|
|
161
|
+
if (!Number.isFinite(ts) || ts <= 0) return
|
|
162
|
+
total += 1
|
|
163
|
+
if (event.active !== false) active += 1
|
|
164
|
+
firstTs = firstTs > 0 ? Math.min(firstTs, ts) : ts
|
|
165
|
+
lastTs = Math.max(lastTs, ts)
|
|
166
|
+
const eventName = normalizedKind === 'knx' ? event.event : event.eventType
|
|
167
|
+
const sourceName = normalizedKind === 'knx'
|
|
168
|
+
? event.source
|
|
169
|
+
: (event.adapterTitle || event.adapterId || event.providerTitle || event.providerId)
|
|
170
|
+
const resourceName = normalizedKind === 'knx'
|
|
171
|
+
? `${event.destination || '?'}${event.devicename ? ` (${event.devicename})` : ''}`
|
|
172
|
+
: (event.resourceName || event.resourceId || event.controllerName || event.controllerId)
|
|
173
|
+
incrementCount(counts.byEvent, eventName)
|
|
174
|
+
incrementCount(counts.bySource, sourceName)
|
|
175
|
+
incrementCount(counts.byResource, resourceName)
|
|
176
|
+
;(Array.isArray(event.objectTypes) ? event.objectTypes : []).forEach(value => incrementCount(counts.byObjectType, value))
|
|
177
|
+
const combination = normalizedKind === 'knx'
|
|
178
|
+
? `${resourceName || '?'} | ${eventName || '?'} | ${clampText(typeof event.payload === 'object' ? JSON.stringify(event.payload) : event.payload, 160)}`
|
|
179
|
+
: `${resourceName || '?'} | ${eventName || '?'}${event.scopeName ? ` | ${event.scopeName}` : ''}${event.objectTypes && event.objectTypes.length ? ` | ${event.objectTypes.join(',')}` : ''}`
|
|
180
|
+
incrementCount(counts.byCombination, combination)
|
|
181
|
+
|
|
182
|
+
recent.push(event)
|
|
183
|
+
if (recent.length > maxItems) recent.shift()
|
|
184
|
+
if (tokens.length) {
|
|
185
|
+
const haystack = buildEventSearchText(event, normalizedKind)
|
|
186
|
+
let score = 0
|
|
187
|
+
tokens.forEach(token => { if (haystack.includes(token)) score += token.length + 2 })
|
|
188
|
+
if (score > 0) {
|
|
189
|
+
relevant.push({ event, score, ts })
|
|
190
|
+
if (relevant.length > maxItems * 4) {
|
|
191
|
+
relevant.sort((left, right) => right.score - left.score || right.ts - left.ts)
|
|
192
|
+
relevant.length = maxItems * 2
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const finish = () => {
|
|
199
|
+
let events = recent
|
|
200
|
+
if (relevant.length) {
|
|
201
|
+
const selected = relevant
|
|
202
|
+
.sort((left, right) => right.score - left.score || right.ts - left.ts)
|
|
203
|
+
.slice(0, maxItems)
|
|
204
|
+
.map(item => item.event)
|
|
205
|
+
events = selected.sort((left, right) => Number(left.ts || 0) - Number(right.ts || 0))
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
events,
|
|
209
|
+
summary: {
|
|
210
|
+
kind: normalizedKind,
|
|
211
|
+
totalEvents: total,
|
|
212
|
+
activeEvents: active,
|
|
213
|
+
inactiveEvents: total - active,
|
|
214
|
+
firstAt: firstTs ? new Date(firstTs).toISOString() : '',
|
|
215
|
+
lastAt: lastTs ? new Date(lastTs).toISOString() : '',
|
|
216
|
+
selection: relevant.length ? 'question-relevant' : 'most-recent',
|
|
217
|
+
selectedEvents: events.length,
|
|
218
|
+
byEvent: topCounts(counts.byEvent, 20),
|
|
219
|
+
bySource: topCounts(counts.bySource, 12),
|
|
220
|
+
byResource: topCounts(counts.byResource, 30),
|
|
221
|
+
byObjectType: topCounts(counts.byObjectType, 20),
|
|
222
|
+
byCombination: topCounts(counts.byCombination, 40)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return { add, finish }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const formatKnxAiAdapterHistoryEventForPrompt = event => {
|
|
231
|
+
if (!event || typeof event !== 'object') return ''
|
|
232
|
+
const resource = event.resourceName || event.resourceId || event.controllerName || event.controllerId || '?'
|
|
233
|
+
const scope = event.scopeName || event.scopeId
|
|
234
|
+
const objects = Array.isArray(event.objectTypes) && event.objectTypes.length ? event.objectTypes.join(',') : ''
|
|
235
|
+
return `${event.at || new Date(event.ts || Date.now()).toISOString()} | adapter ${event.adapterTitle || event.adapterId || '?'} | ${event.resourceType || 'resource'} ${resource} | ${event.eventType || '?'} | ${event.active === false ? 'inactive' : 'active'}${scope ? ` | scope ${scope}` : ''}${objects ? ` | objects ${objects}` : ''}`
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
module.exports = {
|
|
239
|
+
KNX_AI_ADAPTER_HISTORY_MIN_HOURS,
|
|
240
|
+
KNX_AI_HISTORY_DETAILS_MAX_CHARS,
|
|
241
|
+
buildKnxAiHistoryEventKey,
|
|
242
|
+
createKnxAiHistoryAccumulator,
|
|
243
|
+
formatKnxAiAdapterHistoryEventForPrompt,
|
|
244
|
+
normalizeKnxAiAdapterHistoryEvent,
|
|
245
|
+
sanitizeHistoryValue
|
|
246
|
+
}
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"engines": {
|
|
4
4
|
"node": ">=20.18.1"
|
|
5
5
|
},
|
|
6
|
-
"version": "6.3.
|
|
6
|
+
"version": "6.3.21",
|
|
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/",
|