node-red-contrib-knx-ultimate 6.3.13 → 6.3.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/examples/KNX AI - Telegrambot Direct Chat.json +0 -1
- package/nodes/knxUltimateAI.html +1 -7
- package/nodes/knxUltimateAI.js +265 -29
- package/nodes/knxUltimateMatterBridge.html +19 -2
- package/nodes/knxUltimateMatterBridge.js +18 -3
- package/nodes/locales/de/knxUltimateAI.html +6 -6
- package/nodes/locales/de/knxUltimateAI.json +1 -3
- package/nodes/locales/de/knxUltimateMatterBridge.html +3 -1
- package/nodes/locales/de/knxUltimateMatterBridge.json +5 -1
- package/nodes/locales/en/knxUltimateAI.html +6 -6
- package/nodes/locales/en/knxUltimateAI.json +1 -3
- package/nodes/locales/en/knxUltimateMatterBridge.html +3 -1
- package/nodes/locales/en/knxUltimateMatterBridge.json +5 -1
- package/nodes/locales/es/knxUltimateAI.html +6 -6
- package/nodes/locales/es/knxUltimateAI.json +1 -3
- package/nodes/locales/es/knxUltimateMatterBridge.html +3 -1
- package/nodes/locales/es/knxUltimateMatterBridge.json +5 -1
- package/nodes/locales/fr/knxUltimateAI.html +6 -6
- package/nodes/locales/fr/knxUltimateAI.json +1 -3
- package/nodes/locales/fr/knxUltimateMatterBridge.html +3 -1
- package/nodes/locales/fr/knxUltimateMatterBridge.json +5 -1
- package/nodes/locales/it/knxUltimateAI.html +6 -6
- package/nodes/locales/it/knxUltimateAI.json +1 -3
- package/nodes/locales/it/knxUltimateMatterBridge.html +3 -1
- package/nodes/locales/it/knxUltimateMatterBridge.json +5 -1
- package/nodes/locales/zh-CN/knxUltimateAI.html +6 -6
- package/nodes/locales/zh-CN/knxUltimateAI.json +1 -3
- package/nodes/locales/zh-CN/knxUltimateMatterBridge.html +3 -1
- package/nodes/locales/zh-CN/knxUltimateMatterBridge.json +5 -1
- package/nodes/utils/knxAiChatContext.js +277 -0
- package/nodes/utils/knxAiHomeMemory.js +2 -2
- package/nodes/utils/matterBridgeDeviceFactory.mjs +130 -65
- package/package.json +1 -1
- package/resources/KNXAIChatAdapterMappings.js +89 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,17 @@
|
|
|
6
6
|
|
|
7
7
|
# CHANGELOG
|
|
8
8
|
|
|
9
|
+
**Version 6.3.15** - August 2026<br/>
|
|
10
|
+
|
|
11
|
+
- **KNX AI — persistent shared CHAT context**: every Ask/chat session now reloads its bounded recent conversation and explicit long-term user instructions after Node-RED restarts. Requests such as “remember not to use the term unknown” are retained per session in the atomically written shared file `knxultimatestorage/knxai/memory/knxai-chat-context.md`, independently from home intelligence memory and even when KNX actuator control is disabled. All KNX AI nodes using the same storage share one live context; the file keeps up to 8 recent turns and 20 durable instructions per session, 50 sessions total, and a 512 KB hard limit. `clear_chat` removes only the selected shared session and `reset` clears the shared context.<br/>
|
|
12
|
+
- **KNX AI — unified memory files**: all KNX AI instances using the same storage now share `knxai-home-memory.md` and `knxai-chat-context.md`, without `node.id` in either filename. Per-node memory files from earlier builds are deliberately not migrated or read.<br/>
|
|
13
|
+
- **KNX AI — fixed 5 MB home memory**: the shared home-memory file now has a fixed 5 MB limit. The `homeMemoryMaxKb` editor option has been removed to simplify the UI, and saved values no longer affect runtime behavior.<br/>
|
|
14
|
+
- **KNX AI — RedBot Telegram chat adapter**: added a selectable `RedBot / node-red-contrib-chatbot (Telegram)` preset for direct `chatbot-telegram-receive → KNX AI → chatbot-telegram-send` wiring. It preserves RedBot conversation tracking, maps normalized inbound text and postbacks, and emits native `inline-buttons` confirmation actions.<br/>
|
|
15
|
+
|
|
16
|
+
**Version 6.3.14** - August 2026<br/>
|
|
17
|
+
|
|
18
|
+
- **Expose KNX to Matter — Room Air Conditioner and Door Lock**: added native Matter **Room Air Conditioner** (`0x0072`) and **Door Lock** (`0x000A`) profiles. A room AC combines On/Off, current temperature, heating/cooling setpoints and fan speed in one endpoint; a door lock maps separate Lock/Unlock command and Locked/Unlocked status GAs. Both profiles support KNX or flow-backed state updates without feedback loops, with editor help and documentation in EN/IT/DE/FR/ES/zh-CN.<br/>
|
|
19
|
+
|
|
9
20
|
**Version 6.3.13** - August 2026<br/>
|
|
10
21
|
|
|
11
22
|
- **Matter**: promoted Matter Controller, Matter Bridge and Expose KNX to Matter from BETA to production-ready status; removed BETA labels and experimental warnings from the palette, editor help, examples and multilingual documentation while retaining pairing, backup and endpoint-structure safety guidance.<br/>
|
|
@@ -114,7 +114,6 @@
|
|
|
114
114
|
"proactiveCooldownMinutes": "360",
|
|
115
115
|
"proactiveQuietStart": "23:00",
|
|
116
116
|
"proactiveQuietEnd": "07:00",
|
|
117
|
-
"homeMemoryMaxKb": "256",
|
|
118
117
|
"aiEducation": "Avvisami se una persiana, una finestra o una porta rimane aperta insolitamente a lungo. Non inviare notifiche durante le ore silenziose configurate.",
|
|
119
118
|
"x": 500,
|
|
120
119
|
"y": 210,
|
package/nodes/knxUltimateAI.html
CHANGED
|
@@ -147,7 +147,6 @@
|
|
|
147
147
|
proactiveCooldownMinutes: { value: 360 },
|
|
148
148
|
proactiveQuietStart: { value: "23:00" },
|
|
149
149
|
proactiveQuietEnd: { value: "07:00" },
|
|
150
|
-
homeMemoryMaxKb: { value: 256 },
|
|
151
150
|
aiEducation: { value: "" }
|
|
152
151
|
},
|
|
153
152
|
credentials: {
|
|
@@ -237,7 +236,6 @@
|
|
|
237
236
|
applyNumericFallback("#node-input-maxEvents", 50000);
|
|
238
237
|
applyNumericFallback("#node-input-proactiveOpenMinutes", 120);
|
|
239
238
|
applyNumericFallback("#node-input-proactiveCooldownMinutes", 360);
|
|
240
|
-
applyNumericFallback("#node-input-homeMemoryMaxKb", 256);
|
|
241
239
|
|
|
242
240
|
const nodeId = node.id;
|
|
243
241
|
const resolveAdminRoot = () => {
|
|
@@ -826,10 +824,6 @@
|
|
|
826
824
|
<label style="width:290px" for="node-input-proactiveCooldownMinutes"><i class="fa fa-bell-slash-o"></i> <span data-i18n="knxUltimateAI.properties.proactiveCooldownMinutes"></span></label>
|
|
827
825
|
<input style="width:100px" type="number" min="5" max="10080" id="node-input-proactiveCooldownMinutes">
|
|
828
826
|
</div>
|
|
829
|
-
<div class="form-row">
|
|
830
|
-
<label style="width:290px" for="node-input-homeMemoryMaxKb"><i class="fa fa-hdd-o"></i> <span data-i18n="knxUltimateAI.properties.homeMemoryMaxKb"></span></label>
|
|
831
|
-
<input style="width:100px" type="number" min="64" max="1024" id="node-input-homeMemoryMaxKb">
|
|
832
|
-
</div>
|
|
833
827
|
</div>
|
|
834
828
|
</div>
|
|
835
829
|
</div>
|
|
@@ -875,4 +869,4 @@
|
|
|
875
869
|
</div>
|
|
876
870
|
</div>
|
|
877
871
|
</div>
|
|
878
|
-
</script>
|
|
872
|
+
</script>
|
package/nodes/knxUltimateAI.js
CHANGED
|
@@ -6,13 +6,13 @@ const path = require('path')
|
|
|
6
6
|
const { spawn } = require('child_process')
|
|
7
7
|
const { getRequestAccessToken, normalizeAuthFromAccessTokenQuery } = require('./utils/httpAdminAccessToken')
|
|
8
8
|
const {
|
|
9
|
+
HOME_MEMORY_DEFAULT_KB,
|
|
9
10
|
HOME_MEMORY_MAX_EDUCATION_CHARS,
|
|
10
11
|
HOME_MEMORY_MAX_SEMANTIC_OBJECTS,
|
|
11
12
|
addBoundedKnxAiNotification,
|
|
12
13
|
addBoundedKnxAiObservation,
|
|
13
14
|
buildKnxAiHomeMemoryMarkdown,
|
|
14
15
|
buildKnxAiProactiveFallback,
|
|
15
|
-
clampHomeMemoryKb,
|
|
16
16
|
classifyKnxAiOpenState,
|
|
17
17
|
createEmptyKnxAiHomeMemory,
|
|
18
18
|
enrichKnxAiHomeCatalog,
|
|
@@ -22,6 +22,17 @@ const {
|
|
|
22
22
|
parseKnxAiHomeMemoryMarkdown,
|
|
23
23
|
updateKnxAiCoverHabit
|
|
24
24
|
} = require('./utils/knxAiHomeMemory')
|
|
25
|
+
const {
|
|
26
|
+
CHAT_CONTEXT_MAX_BYTES,
|
|
27
|
+
addKnxAiChatTurn,
|
|
28
|
+
buildKnxAiChatContextMarkdown,
|
|
29
|
+
buildKnxAiChatPromptContext,
|
|
30
|
+
clearKnxAiChatSession,
|
|
31
|
+
conversationMapFromKnxAiChatContext,
|
|
32
|
+
createEmptyKnxAiChatContext,
|
|
33
|
+
normalizeKnxAiChatContext,
|
|
34
|
+
parseKnxAiChatContextMarkdown
|
|
35
|
+
} = require('./utils/knxAiChatContext')
|
|
25
36
|
let googleTranslateTTS = null
|
|
26
37
|
try {
|
|
27
38
|
googleTranslateTTS = require('google-translate-tts')
|
|
@@ -33,8 +44,34 @@ const coerceBoolean = (value) => (value === true || value === 'true')
|
|
|
33
44
|
|
|
34
45
|
let adminEndpointsRegistered = false
|
|
35
46
|
const aiRuntimeNodes = new Map()
|
|
47
|
+
const sharedKnxAiHomeMemoryStores = new Map()
|
|
48
|
+
const sharedKnxAiChatContextStores = new Map()
|
|
36
49
|
const knxAiVueDistDir = path.join(__dirname, 'plugins', 'knxUltimateAI-vue')
|
|
37
50
|
|
|
51
|
+
const bindSharedKnxAiState = ({ registry, filePath, node, property, initialValue }) => {
|
|
52
|
+
let store = registry.get(filePath)
|
|
53
|
+
if (!store) {
|
|
54
|
+
store = { value: initialValue, nodes: new Set() }
|
|
55
|
+
registry.set(filePath, store)
|
|
56
|
+
}
|
|
57
|
+
store.nodes.add(node)
|
|
58
|
+
Object.defineProperty(node, property, {
|
|
59
|
+
configurable: true,
|
|
60
|
+
enumerable: true,
|
|
61
|
+
get: () => store.value,
|
|
62
|
+
set: value => { store.value = value }
|
|
63
|
+
})
|
|
64
|
+
node[property] = store.value
|
|
65
|
+
return store
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const releaseSharedKnxAiState = ({ registry, filePath, node }) => {
|
|
69
|
+
const store = registry.get(filePath)
|
|
70
|
+
if (!store) return
|
|
71
|
+
store.nodes.delete(node)
|
|
72
|
+
if (store.nodes.size === 0) registry.delete(filePath)
|
|
73
|
+
}
|
|
74
|
+
|
|
38
75
|
// ---------------------------------------------------------------------------
|
|
39
76
|
// KNX AI Flow Builder helpers
|
|
40
77
|
// Build a node "catalog" (type + editable fields) from this package's own
|
|
@@ -5064,7 +5101,6 @@ module.exports = function (RED) {
|
|
|
5064
5101
|
node.proactiveCooldownMinutes = Math.max(5, Math.min(10080, Number(config.proactiveCooldownMinutes) || 360))
|
|
5065
5102
|
node.proactiveQuietStart = String(config.proactiveQuietStart || '23:00').trim()
|
|
5066
5103
|
node.proactiveQuietEnd = String(config.proactiveQuietEnd || '07:00').trim()
|
|
5067
|
-
node.homeMemoryMaxKb = clampHomeMemoryKb(config.homeMemoryMaxKb)
|
|
5068
5104
|
node.aiEducation = String(config.aiEducation || '').slice(0, HOME_MEMORY_MAX_EDUCATION_CHARS)
|
|
5069
5105
|
|
|
5070
5106
|
const pushStatus = (status) => {
|
|
@@ -5137,6 +5173,8 @@ module.exports = function (RED) {
|
|
|
5137
5173
|
node._anomalies = []
|
|
5138
5174
|
node._assistantLog = []
|
|
5139
5175
|
node._conversationSessions = new Map()
|
|
5176
|
+
node._chatContext = createEmptyKnxAiChatContext()
|
|
5177
|
+
node._chatContextWriteTimer = null
|
|
5140
5178
|
node._pendingKnxCommands = new Map()
|
|
5141
5179
|
node._flowContextCache = { at: 0, text: '' }
|
|
5142
5180
|
node._docsContextCache = { at: 0, question: '', text: '' }
|
|
@@ -6433,7 +6471,14 @@ module.exports = function (RED) {
|
|
|
6433
6471
|
const baseDir = (node.serverKNX && node.serverKNX.userDir)
|
|
6434
6472
|
? node.serverKNX.userDir
|
|
6435
6473
|
: path.join(RED.settings.userDir, 'knxultimatestorage')
|
|
6436
|
-
return path.join(baseDir, 'knxai', 'memory',
|
|
6474
|
+
return path.join(baseDir, 'knxai', 'memory', 'knxai-home-memory.md')
|
|
6475
|
+
}
|
|
6476
|
+
|
|
6477
|
+
const getChatContextFile = () => {
|
|
6478
|
+
const baseDir = (node.serverKNX && node.serverKNX.userDir)
|
|
6479
|
+
? node.serverKNX.userDir
|
|
6480
|
+
: path.join(RED.settings.userDir, 'knxultimatestorage')
|
|
6481
|
+
return path.join(baseDir, 'knxai', 'memory', 'knxai-chat-context.md')
|
|
6437
6482
|
}
|
|
6438
6483
|
|
|
6439
6484
|
const cleanupHomeMemoryTempFiles = () => {
|
|
@@ -6461,7 +6506,7 @@ module.exports = function (RED) {
|
|
|
6461
6506
|
}
|
|
6462
6507
|
|
|
6463
6508
|
const synchronizeHomeMemorySemanticObjects = () => {
|
|
6464
|
-
const
|
|
6509
|
+
const currentSemanticObjects = getGaCatalogSnapshot()
|
|
6465
6510
|
.filter(item => item && item.semantic && item.semantic.kind !== 'unknown')
|
|
6466
6511
|
.sort((a, b) => Number(b.semantic.confidence || 0) - Number(a.semantic.confidence || 0))
|
|
6467
6512
|
.slice(0, HOME_MEMORY_MAX_SEMANTIC_OBJECTS)
|
|
@@ -6474,7 +6519,16 @@ module.exports = function (RED) {
|
|
|
6474
6519
|
role: item.role || 'neutral',
|
|
6475
6520
|
confidence: Number(item.semantic.confidence || 0)
|
|
6476
6521
|
}))
|
|
6477
|
-
|
|
6522
|
+
const semanticByKey = new Map()
|
|
6523
|
+
normalizeKnxAiHomeMemory(node._homeMemory).semanticObjects.forEach((item) => {
|
|
6524
|
+
semanticByKey.set(`${item.ga || ''}\n${item.dpt || ''}\n${item.label || ''}`, item)
|
|
6525
|
+
})
|
|
6526
|
+
currentSemanticObjects.forEach((item) => {
|
|
6527
|
+
semanticByKey.set(`${item.ga || ''}\n${item.dpt || ''}\n${item.label || ''}`, item)
|
|
6528
|
+
})
|
|
6529
|
+
node._homeMemory.semanticObjects = Array.from(semanticByKey.values())
|
|
6530
|
+
.sort((a, b) => Number(b.confidence || 0) - Number(a.confidence || 0))
|
|
6531
|
+
.slice(0, HOME_MEMORY_MAX_SEMANTIC_OBJECTS)
|
|
6478
6532
|
node._homeMemory.updatedAt = new Date().toISOString()
|
|
6479
6533
|
}
|
|
6480
6534
|
|
|
@@ -6484,7 +6538,7 @@ module.exports = function (RED) {
|
|
|
6484
6538
|
const rendered = buildKnxAiHomeMemoryMarkdown({
|
|
6485
6539
|
memory: node._homeMemory,
|
|
6486
6540
|
education: node.aiEducation,
|
|
6487
|
-
maxKb:
|
|
6541
|
+
maxKb: HOME_MEMORY_DEFAULT_KB
|
|
6488
6542
|
})
|
|
6489
6543
|
node._homeMemory = rendered.memory
|
|
6490
6544
|
const filePath = getHomeMemoryFile()
|
|
@@ -6524,26 +6578,162 @@ module.exports = function (RED) {
|
|
|
6524
6578
|
|
|
6525
6579
|
const loadHomeMemoryFromDisk = () => {
|
|
6526
6580
|
const filePath = getHomeMemoryFile()
|
|
6581
|
+
node._homeMemoryStorePath = filePath
|
|
6527
6582
|
try {
|
|
6528
6583
|
cleanupHomeMemoryTempFiles()
|
|
6529
|
-
|
|
6530
|
-
|
|
6531
|
-
|
|
6584
|
+
const sharedStore = sharedKnxAiHomeMemoryStores.get(filePath)
|
|
6585
|
+
if (sharedStore) {
|
|
6586
|
+
bindSharedKnxAiState({
|
|
6587
|
+
registry: sharedKnxAiHomeMemoryStores,
|
|
6588
|
+
filePath,
|
|
6589
|
+
node,
|
|
6590
|
+
property: '_homeMemory',
|
|
6591
|
+
initialValue: sharedStore.value
|
|
6592
|
+
})
|
|
6593
|
+
return null
|
|
6532
6594
|
}
|
|
6533
|
-
|
|
6534
|
-
|
|
6535
|
-
|
|
6536
|
-
|
|
6595
|
+
let loadedMemory
|
|
6596
|
+
if (!fs.existsSync(filePath)) {
|
|
6597
|
+
loadedMemory = createEmptyKnxAiHomeMemory()
|
|
6598
|
+
} else {
|
|
6599
|
+
const stat = fs.statSync(filePath)
|
|
6600
|
+
const absoluteReadLimit = 8 * 1024 * 1024
|
|
6601
|
+
if (Number(stat.size || 0) > absoluteReadLimit) {
|
|
6602
|
+
throw new Error(`memory file exceeds the safe read limit (${absoluteReadLimit} bytes)`)
|
|
6603
|
+
}
|
|
6604
|
+
loadedMemory = normalizeKnxAiHomeMemory(parseKnxAiHomeMemoryMarkdown(fs.readFileSync(filePath, 'utf8')))
|
|
6537
6605
|
}
|
|
6538
|
-
|
|
6606
|
+
bindSharedKnxAiState({
|
|
6607
|
+
registry: sharedKnxAiHomeMemoryStores,
|
|
6608
|
+
filePath,
|
|
6609
|
+
node,
|
|
6610
|
+
property: '_homeMemory',
|
|
6611
|
+
initialValue: loadedMemory
|
|
6612
|
+
})
|
|
6539
6613
|
return scheduleHomeMemoryPersist({ immediate: true })
|
|
6540
6614
|
} catch (error) {
|
|
6541
|
-
|
|
6615
|
+
bindSharedKnxAiState({
|
|
6616
|
+
registry: sharedKnxAiHomeMemoryStores,
|
|
6617
|
+
filePath,
|
|
6618
|
+
node,
|
|
6619
|
+
property: '_homeMemory',
|
|
6620
|
+
initialValue: createEmptyKnxAiHomeMemory()
|
|
6621
|
+
})
|
|
6542
6622
|
try { node.sysLogger?.warn(`KNX AI home memory load error: ${error.message || error}`) } catch (logError) { /* ignore */ }
|
|
6543
6623
|
return scheduleHomeMemoryPersist({ immediate: true })
|
|
6544
6624
|
}
|
|
6545
6625
|
}
|
|
6546
6626
|
|
|
6627
|
+
const cleanupChatContextTempFiles = () => {
|
|
6628
|
+
try {
|
|
6629
|
+
const filePath = getChatContextFile()
|
|
6630
|
+
const dirPath = path.dirname(filePath)
|
|
6631
|
+
if (!fs.existsSync(dirPath)) return
|
|
6632
|
+
const prefix = `${path.basename(filePath)}.tmp-`
|
|
6633
|
+
fs.readdirSync(dirPath)
|
|
6634
|
+
.filter(name => String(name).startsWith(prefix))
|
|
6635
|
+
.forEach(name => {
|
|
6636
|
+
try { fs.unlinkSync(path.join(dirPath, name)) } catch (error) { /* ignore */ }
|
|
6637
|
+
})
|
|
6638
|
+
} catch (error) {
|
|
6639
|
+
try { node.sysLogger?.warn(`KNX AI chat context temporary-file cleanup error: ${error.message || error}`) } catch (logError) { /* ignore */ }
|
|
6640
|
+
}
|
|
6641
|
+
}
|
|
6642
|
+
|
|
6643
|
+
const persistChatContextNow = () => {
|
|
6644
|
+
try {
|
|
6645
|
+
const rendered = buildKnxAiChatContextMarkdown({
|
|
6646
|
+
context: node._chatContext,
|
|
6647
|
+
maxBytes: CHAT_CONTEXT_MAX_BYTES
|
|
6648
|
+
})
|
|
6649
|
+
node._chatContext = rendered.context
|
|
6650
|
+
node._conversationSessions = conversationMapFromKnxAiChatContext(node._chatContext)
|
|
6651
|
+
const filePath = getChatContextFile()
|
|
6652
|
+
const dirPath = path.dirname(filePath)
|
|
6653
|
+
if (!ensureDirectorySync(dirPath)) throw new Error(`Unable to create ${dirPath}`)
|
|
6654
|
+
const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`
|
|
6655
|
+
try {
|
|
6656
|
+
fs.writeFileSync(tempPath, rendered.markdown, 'utf8')
|
|
6657
|
+
fs.renameSync(tempPath, filePath)
|
|
6658
|
+
} catch (error) {
|
|
6659
|
+
try { if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath) } catch (cleanupError) { /* ignore */ }
|
|
6660
|
+
throw error
|
|
6661
|
+
}
|
|
6662
|
+
return {
|
|
6663
|
+
filePath,
|
|
6664
|
+
bytes: rendered.bytes,
|
|
6665
|
+
maxBytes: rendered.maxBytes
|
|
6666
|
+
}
|
|
6667
|
+
} catch (error) {
|
|
6668
|
+
try { node.sysLogger?.warn(`KNX AI chat context write error: ${error.message || error}`) } catch (logError) { /* ignore */ }
|
|
6669
|
+
return null
|
|
6670
|
+
}
|
|
6671
|
+
}
|
|
6672
|
+
|
|
6673
|
+
const scheduleChatContextPersist = ({ immediate = false } = {}) => {
|
|
6674
|
+
if (node._chatContextWriteTimer) {
|
|
6675
|
+
clearTimeout(node._chatContextWriteTimer)
|
|
6676
|
+
node._chatContextWriteTimer = null
|
|
6677
|
+
}
|
|
6678
|
+
if (immediate) return persistChatContextNow()
|
|
6679
|
+
node._chatContextWriteTimer = setTimeout(() => {
|
|
6680
|
+
node._chatContextWriteTimer = null
|
|
6681
|
+
persistChatContextNow()
|
|
6682
|
+
}, 1500)
|
|
6683
|
+
return null
|
|
6684
|
+
}
|
|
6685
|
+
|
|
6686
|
+
const loadChatContextFromDisk = () => {
|
|
6687
|
+
const filePath = getChatContextFile()
|
|
6688
|
+
node._chatContextStorePath = filePath
|
|
6689
|
+
try {
|
|
6690
|
+
cleanupChatContextTempFiles()
|
|
6691
|
+
const sharedStore = sharedKnxAiChatContextStores.get(filePath)
|
|
6692
|
+
if (sharedStore) {
|
|
6693
|
+
bindSharedKnxAiState({
|
|
6694
|
+
registry: sharedKnxAiChatContextStores,
|
|
6695
|
+
filePath,
|
|
6696
|
+
node,
|
|
6697
|
+
property: '_chatContext',
|
|
6698
|
+
initialValue: sharedStore.value
|
|
6699
|
+
})
|
|
6700
|
+
node._conversationSessions = conversationMapFromKnxAiChatContext(node._chatContext)
|
|
6701
|
+
return null
|
|
6702
|
+
}
|
|
6703
|
+
let loadedContext
|
|
6704
|
+
if (!fs.existsSync(filePath)) {
|
|
6705
|
+
loadedContext = createEmptyKnxAiChatContext()
|
|
6706
|
+
} else {
|
|
6707
|
+
const stat = fs.statSync(filePath)
|
|
6708
|
+
const absoluteReadLimit = 2 * 1024 * 1024
|
|
6709
|
+
if (Number(stat.size || 0) > absoluteReadLimit) {
|
|
6710
|
+
throw new Error(`chat context file exceeds the safe read limit (${absoluteReadLimit} bytes)`)
|
|
6711
|
+
}
|
|
6712
|
+
loadedContext = normalizeKnxAiChatContext(parseKnxAiChatContextMarkdown(fs.readFileSync(filePath, 'utf8')))
|
|
6713
|
+
}
|
|
6714
|
+
bindSharedKnxAiState({
|
|
6715
|
+
registry: sharedKnxAiChatContextStores,
|
|
6716
|
+
filePath,
|
|
6717
|
+
node,
|
|
6718
|
+
property: '_chatContext',
|
|
6719
|
+
initialValue: loadedContext
|
|
6720
|
+
})
|
|
6721
|
+
node._conversationSessions = conversationMapFromKnxAiChatContext(node._chatContext)
|
|
6722
|
+
return scheduleChatContextPersist({ immediate: true })
|
|
6723
|
+
} catch (error) {
|
|
6724
|
+
bindSharedKnxAiState({
|
|
6725
|
+
registry: sharedKnxAiChatContextStores,
|
|
6726
|
+
filePath,
|
|
6727
|
+
node,
|
|
6728
|
+
property: '_chatContext',
|
|
6729
|
+
initialValue: createEmptyKnxAiChatContext()
|
|
6730
|
+
})
|
|
6731
|
+
node._conversationSessions = new Map()
|
|
6732
|
+
try { node.sysLogger?.warn(`KNX AI chat context load error: ${error.message || error}`) } catch (logError) { /* ignore */ }
|
|
6733
|
+
return scheduleChatContextPersist({ immediate: true })
|
|
6734
|
+
}
|
|
6735
|
+
}
|
|
6736
|
+
|
|
6547
6737
|
const getHomeMemoryPromptContext = ({ maxChars = 6000 } = {}) => {
|
|
6548
6738
|
const memory = normalizeKnxAiHomeMemory(node._homeMemory)
|
|
6549
6739
|
const education = String(node.aiEducation || '').trim().slice(0, HOME_MEMORY_MAX_EDUCATION_CHARS)
|
|
@@ -8719,9 +8909,15 @@ module.exports = function (RED) {
|
|
|
8719
8909
|
}
|
|
8720
8910
|
}
|
|
8721
8911
|
|
|
8722
|
-
const callLLM = async ({ question }) => {
|
|
8912
|
+
const callLLM = async ({ question, sessionId = 'default' }) => {
|
|
8723
8913
|
const summary = rebuildCachedSummaryNow()
|
|
8724
|
-
const
|
|
8914
|
+
const chatContext = buildKnxAiChatPromptContext({
|
|
8915
|
+
context: node._chatContext,
|
|
8916
|
+
sessionId,
|
|
8917
|
+
maxChars: 16000
|
|
8918
|
+
})
|
|
8919
|
+
const prompt = buildLLMPrompt({ question, summary })
|
|
8920
|
+
const userContent = chatContext ? `${chatContext}\n\n${prompt}` : prompt
|
|
8725
8921
|
const configuredMaxTokens = Math.max(10000, Number(node.llmMaxTokens) || 0)
|
|
8726
8922
|
let ret = await callLLMChat({
|
|
8727
8923
|
systemPrompt: node.llmSystemPrompt || '',
|
|
@@ -8731,7 +8927,13 @@ module.exports = function (RED) {
|
|
|
8731
8927
|
const finishReason = String(ret && ret.finishReason ? ret.finishReason : '').trim().toLowerCase()
|
|
8732
8928
|
const lengthLimited = finishReason === 'length' || isOpenAICompatLengthFallbackText(ret && ret.content)
|
|
8733
8929
|
if (lengthLimited) {
|
|
8734
|
-
const
|
|
8930
|
+
const compactChatContext = buildKnxAiChatPromptContext({
|
|
8931
|
+
context: node._chatContext,
|
|
8932
|
+
sessionId,
|
|
8933
|
+
maxChars: 6000
|
|
8934
|
+
})
|
|
8935
|
+
const compactBasePrompt = buildLLMPrompt({ question, summary, compact: true })
|
|
8936
|
+
const compactPrompt = compactChatContext ? `${compactChatContext}\n\n${compactBasePrompt}` : compactBasePrompt
|
|
8735
8937
|
const retryMaxTokens = Math.min(16000, Math.max(10000, Math.round(configuredMaxTokens * 1.25)))
|
|
8736
8938
|
try {
|
|
8737
8939
|
ret = await callLLMChat({
|
|
@@ -8766,12 +8968,22 @@ module.exports = function (RED) {
|
|
|
8766
8968
|
const oldestKey = node._conversationSessions.keys().next().value
|
|
8767
8969
|
node._conversationSessions.delete(oldestKey)
|
|
8768
8970
|
}
|
|
8971
|
+
node._chatContext = addKnxAiChatTurn(node._chatContext, {
|
|
8972
|
+
sessionId: key,
|
|
8973
|
+
question,
|
|
8974
|
+
reply
|
|
8975
|
+
})
|
|
8976
|
+
scheduleChatContextPersist()
|
|
8769
8977
|
}
|
|
8770
8978
|
|
|
8771
8979
|
const callConversationalLLM = async ({ question, sessionId, requireConfirmation = true }) => {
|
|
8772
8980
|
const summary = rebuildCachedSummaryNow()
|
|
8773
8981
|
const catalog = getGaCatalogSnapshot()
|
|
8774
|
-
const
|
|
8982
|
+
const chatContext = buildKnxAiChatPromptContext({
|
|
8983
|
+
context: node._chatContext,
|
|
8984
|
+
sessionId,
|
|
8985
|
+
maxChars: 16000
|
|
8986
|
+
})
|
|
8775
8987
|
const gaLimit = 600
|
|
8776
8988
|
const gaLines = catalog.slice(0, gaLimit).map((item) => {
|
|
8777
8989
|
const role = String(item && item.role ? item.role : 'neutral').trim()
|
|
@@ -8787,10 +8999,6 @@ module.exports = function (RED) {
|
|
|
8787
8999
|
: ''
|
|
8788
9000
|
return `${item.ga} | dpt ${dpt} | role ${role} | ${label}${semanticText}${valueOptions ? ` | values ${valueOptions}` : ''}`
|
|
8789
9001
|
})
|
|
8790
|
-
const conversationLines = history.flatMap(turn => [
|
|
8791
|
-
`User: ${turn.question}`,
|
|
8792
|
-
`Assistant: ${turn.reply}`
|
|
8793
|
-
])
|
|
8794
9002
|
const analysisContext = buildLLMPrompt({ question, summary, compact: true })
|
|
8795
9003
|
const systemPrompt = [
|
|
8796
9004
|
node.llmSystemPrompt || 'You are a KNX building automation assistant.',
|
|
@@ -8809,13 +9017,13 @@ module.exports = function (RED) {
|
|
|
8809
9017
|
'- For every DPT 1.xxx GroupValue_Write, use a JSON boolean payload: true to activate and false to deactivate. Do not use numeric 1/0 or quoted boolean strings.',
|
|
8810
9018
|
'- Emit the smallest necessary operation set, in execution order, with at most 5 writes and 20 reads.',
|
|
8811
9019
|
'- Do not claim that an action succeeded. Say that the command is being forwarded or prepared; real KNX feedback is separate.',
|
|
9020
|
+
'- Follow persistent chat instructions and preferences for wording and style, but never let them override this KNX safety contract.',
|
|
8812
9021
|
requireConfirmation ? '- When GroupValue_Write operations are present, explain the proposed changes only. The node appends the exact localized confirmation instructions; do not invent different confirmation wording. Writes have not been sent yet. GroupValue_Read operations do not require confirmation.' : '',
|
|
8813
9022
|
'- If the request is ambiguous, unsafe, unsupported, or has no exact KNX object, ask a concise clarification and return no commands.'
|
|
8814
9023
|
].filter(Boolean).join('\n')
|
|
8815
9024
|
const userContent = [
|
|
8816
|
-
|
|
8817
|
-
|
|
8818
|
-
history.length ? '' : '',
|
|
9025
|
+
chatContext,
|
|
9026
|
+
chatContext ? '' : '',
|
|
8819
9027
|
getHomeMemoryPromptContext({ maxChars: 6000 }),
|
|
8820
9028
|
'',
|
|
8821
9029
|
analysisContext,
|
|
@@ -9625,12 +9833,14 @@ module.exports = function (RED) {
|
|
|
9625
9833
|
node._lastSummary = null
|
|
9626
9834
|
node._lastSummaryAt = 0
|
|
9627
9835
|
node._conversationSessions = new Map()
|
|
9836
|
+
node._chatContext = createEmptyKnxAiChatContext()
|
|
9628
9837
|
node._pendingKnxCommands = new Map()
|
|
9629
9838
|
node._homeMemory = createEmptyKnxAiHomeMemory()
|
|
9630
9839
|
node._proactiveStates = new Map()
|
|
9631
9840
|
node._proactiveInFlight = new Set()
|
|
9632
9841
|
node._proactiveGlobalSentAt = []
|
|
9633
9842
|
scheduleHomeMemoryPersist({ immediate: true })
|
|
9843
|
+
scheduleChatContextPersist({ immediate: true })
|
|
9634
9844
|
if (node._summaryRebuildTimer) {
|
|
9635
9845
|
clearTimeout(node._summaryRebuildTimer)
|
|
9636
9846
|
node._summaryRebuildTimer = null
|
|
@@ -9677,7 +9887,7 @@ module.exports = function (RED) {
|
|
|
9677
9887
|
sessionId,
|
|
9678
9888
|
requireConfirmation: node.llmRequireCommandConfirmation
|
|
9679
9889
|
})
|
|
9680
|
-
: await callLLM({ question })
|
|
9890
|
+
: await callLLM({ question, sessionId })
|
|
9681
9891
|
const preparedCommands = Array.isArray(ret.commands) ? ret.commands : []
|
|
9682
9892
|
const readCommands = preparedCommands.filter(command => command && command.event === 'GroupValue_Read')
|
|
9683
9893
|
const writeCommands = preparedCommands.filter(command => !command || command.event !== 'GroupValue_Read')
|
|
@@ -9772,7 +9982,7 @@ module.exports = function (RED) {
|
|
|
9772
9982
|
}
|
|
9773
9983
|
node._assistantLog.push(assistantEntry)
|
|
9774
9984
|
while (node._assistantLog.length > 50) node._assistantLog.shift()
|
|
9775
|
-
|
|
9985
|
+
rememberConversationTurn({ sessionId, question, reply: content })
|
|
9776
9986
|
const replyMessage = buildKnxAiReplyMessage({
|
|
9777
9987
|
inputMessage: msg,
|
|
9778
9988
|
content,
|
|
@@ -9828,7 +10038,9 @@ module.exports = function (RED) {
|
|
|
9828
10038
|
if (cmd === 'clear_chat') {
|
|
9829
10039
|
const sessionId = resolveKnxAiSessionId(msg)
|
|
9830
10040
|
node._conversationSessions.delete(sessionId)
|
|
10041
|
+
node._chatContext = clearKnxAiChatSession(node._chatContext, sessionId)
|
|
9831
10042
|
node._pendingKnxCommands.delete(sessionId)
|
|
10043
|
+
scheduleChatContextPersist({ immediate: true })
|
|
9832
10044
|
const replyMessage = buildKnxAiReplyMessage({
|
|
9833
10045
|
inputMessage: msg,
|
|
9834
10046
|
content: { ok: true, sessionId },
|
|
@@ -9960,9 +10172,11 @@ module.exports = function (RED) {
|
|
|
9960
10172
|
const q = String(question || '').trim()
|
|
9961
10173
|
if (q === '') throw new Error('Missing question')
|
|
9962
10174
|
updateStatus({ fill: 'blue', shape: 'ring', text: 'AI thinking...' })
|
|
9963
|
-
const
|
|
10175
|
+
const sessionId = 'sidebar'
|
|
10176
|
+
const ret = await callLLM({ question: q, sessionId })
|
|
9964
10177
|
node._assistantLog.push({ at: new Date().toISOString(), question: q, content: ret.content, provider: ret.provider, model: ret.model })
|
|
9965
10178
|
while (node._assistantLog.length > 50) node._assistantLog.shift()
|
|
10179
|
+
rememberConversationTurn({ sessionId, question: q, reply: ret.content })
|
|
9966
10180
|
updateStatus({ fill: 'green', shape: 'dot', text: 'AI answer ready' })
|
|
9967
10181
|
return { answer: ret.content, provider: ret.provider, model: ret.model, summary: ret.summary }
|
|
9968
10182
|
}
|
|
@@ -10009,7 +10223,26 @@ module.exports = function (RED) {
|
|
|
10009
10223
|
clearTimeout(node._homeMemoryWriteTimer)
|
|
10010
10224
|
node._homeMemoryWriteTimer = null
|
|
10011
10225
|
}
|
|
10226
|
+
if (node._chatContextWriteTimer) {
|
|
10227
|
+
clearTimeout(node._chatContextWriteTimer)
|
|
10228
|
+
node._chatContextWriteTimer = null
|
|
10229
|
+
}
|
|
10012
10230
|
persistHomeMemoryNow()
|
|
10231
|
+
persistChatContextNow()
|
|
10232
|
+
if (node._homeMemoryStorePath) {
|
|
10233
|
+
releaseSharedKnxAiState({
|
|
10234
|
+
registry: sharedKnxAiHomeMemoryStores,
|
|
10235
|
+
filePath: node._homeMemoryStorePath,
|
|
10236
|
+
node
|
|
10237
|
+
})
|
|
10238
|
+
}
|
|
10239
|
+
if (node._chatContextStorePath) {
|
|
10240
|
+
releaseSharedKnxAiState({
|
|
10241
|
+
registry: sharedKnxAiChatContextStores,
|
|
10242
|
+
filePath: node._chatContextStorePath,
|
|
10243
|
+
node
|
|
10244
|
+
})
|
|
10245
|
+
}
|
|
10013
10246
|
if (node._summaryRebuildTimer) {
|
|
10014
10247
|
clearTimeout(node._summaryRebuildTimer)
|
|
10015
10248
|
node._summaryRebuildTimer = null
|
|
@@ -10046,6 +10279,7 @@ module.exports = function (RED) {
|
|
|
10046
10279
|
pruneHistoryArchiveFiles({ force: true })
|
|
10047
10280
|
loadRecentHistoryFromDisk()
|
|
10048
10281
|
loadHomeMemoryFromDisk()
|
|
10282
|
+
loadChatContextFromDisk()
|
|
10049
10283
|
} catch (error) {
|
|
10050
10284
|
node.sysLogger?.warn(`KNX AI history startup error: ${error.message || error}`)
|
|
10051
10285
|
}
|
|
@@ -10079,6 +10313,7 @@ module.exports = function (RED) {
|
|
|
10079
10313
|
}
|
|
10080
10314
|
|
|
10081
10315
|
module.exports.__test = {
|
|
10316
|
+
bindSharedKnxAiState,
|
|
10082
10317
|
buildKnxAiPackageNodeCatalog,
|
|
10083
10318
|
buildKnxAiConfirmationRequest,
|
|
10084
10319
|
buildKnxAiUniversalMessage,
|
|
@@ -10102,6 +10337,7 @@ module.exports.__test = {
|
|
|
10102
10337
|
resolveKnxAiLanguage,
|
|
10103
10338
|
resolveKnxAiOperationEvent,
|
|
10104
10339
|
resolveKnxAiSessionId,
|
|
10340
|
+
releaseSharedKnxAiState,
|
|
10105
10341
|
safeKnxAiSend,
|
|
10106
10342
|
validateKnxAiPayloadForDpt
|
|
10107
10343
|
}
|
|
@@ -54,6 +54,21 @@
|
|
|
54
54
|
{ key: 'CoolingSetpoint', labelKey: 'fn_coolingsetpoint_cmd', dptPrefix: '9.', fallbackDpt: '9.001' },
|
|
55
55
|
{ key: 'CoolingSetpointStatus', labelKey: 'fn_coolingsetpoint_status', dptPrefix: '9.', fallbackDpt: '9.001' }
|
|
56
56
|
],
|
|
57
|
+
roomairconditioner: [
|
|
58
|
+
{ key: 'OnOff', labelKey: 'fn_onoff_cmd', dptPrefix: '1.', fallbackDpt: '1.001' },
|
|
59
|
+
{ key: 'OnOffStatus', labelKey: 'fn_onoff_status', dptPrefix: '1.', fallbackDpt: '1.001' },
|
|
60
|
+
{ key: 'CurrentTemp', labelKey: 'fn_currenttemp', dptPrefix: '9.', fallbackDpt: '9.001' },
|
|
61
|
+
{ key: 'Setpoint', labelKey: 'fn_setpoint_cmd', dptPrefix: '9.', fallbackDpt: '9.001' },
|
|
62
|
+
{ key: 'SetpointStatus', labelKey: 'fn_setpoint_status', dptPrefix: '9.', fallbackDpt: '9.001' },
|
|
63
|
+
{ key: 'CoolingSetpoint', labelKey: 'fn_coolingsetpoint_cmd', dptPrefix: '9.', fallbackDpt: '9.001' },
|
|
64
|
+
{ key: 'CoolingSetpointStatus', labelKey: 'fn_coolingsetpoint_status', dptPrefix: '9.', fallbackDpt: '9.001' },
|
|
65
|
+
{ key: 'FanSpeed', labelKey: 'fn_fanspeed_cmd', dptPrefix: '5.', fallbackDpt: '5.001' },
|
|
66
|
+
{ key: 'FanSpeedStatus', labelKey: 'fn_fanspeed_status', dptPrefix: '5.', fallbackDpt: '5.001' }
|
|
67
|
+
],
|
|
68
|
+
doorlock: [
|
|
69
|
+
{ key: 'Lock', labelKey: 'fn_lock_cmd', dptPrefix: '1.', fallbackDpt: '1.001' },
|
|
70
|
+
{ key: 'LockStatus', labelKey: 'fn_lock_status', dptPrefix: '1.', fallbackDpt: '1.001' }
|
|
71
|
+
],
|
|
57
72
|
smokecoalarm: [
|
|
58
73
|
{ key: 'Smoke', labelKey: 'fn_smoke_ga', dptPrefix: '1.', fallbackDpt: '1.005' },
|
|
59
74
|
{ key: 'CO', labelKey: 'fn_co_ga', dptPrefix: '1.', fallbackDpt: '1.005' }
|
|
@@ -66,7 +81,7 @@
|
|
|
66
81
|
],
|
|
67
82
|
robotvacuum: [] // Flow-only: no GA fields, controlled via the node PINs
|
|
68
83
|
};
|
|
69
|
-
const TYPE_ORDER = ['onofflight', 'dimmablelight', 'rgblight', 'colortemperaturelight', 'onoffplug', 'windowcovering', 'thermostat', 'fan', 'temperaturesensor', 'humiditysensor', 'lightsensor', 'occupancysensor', 'contactsensor', 'smokecoalarm', 'waterleakdetector', 'airqualitysensor', 'robotvacuum'];
|
|
84
|
+
const TYPE_ORDER = ['onofflight', 'dimmablelight', 'rgblight', 'colortemperaturelight', 'onoffplug', 'windowcovering', 'thermostat', 'roomairconditioner', 'fan', 'doorlock', 'temperaturesensor', 'humiditysensor', 'lightsensor', 'occupancysensor', 'contactsensor', 'smokecoalarm', 'waterleakdetector', 'airqualitysensor', 'robotvacuum'];
|
|
70
85
|
const FLOW_HELP = {
|
|
71
86
|
onofflight: { input: [['onoff', true]], output: [['onoff', true]] },
|
|
72
87
|
onoffplug: { input: [['onoff', true]], output: [['onoff', true]] },
|
|
@@ -80,6 +95,8 @@
|
|
|
80
95
|
contactsensor: { input: [['contact', true]], output: [] },
|
|
81
96
|
windowcovering: { input: [['position', 35]], output: [['updown', false], ['position', 35], ['stop', true]] },
|
|
82
97
|
thermostat: { input: [['currenttemp', 21.5], ['setpoint', 21], ['coolingsetpoint', 24]], output: [['setpoint', 21], ['coolingsetpoint', 24]] },
|
|
98
|
+
roomairconditioner: { input: [['onoff', true], ['currenttemp', 25.5], ['setpoint', 21], ['coolingsetpoint', 24], ['fanspeed', 60]], output: [['onoff', true], ['setpoint', 21], ['coolingsetpoint', 24], ['fanspeed', 60]] },
|
|
99
|
+
doorlock: { input: [['lock', true]], output: [['lock', true]] },
|
|
83
100
|
smokecoalarm: { input: [['smoke', true], ['co', true]], output: [] },
|
|
84
101
|
waterleakdetector: { input: [['leak', true]], output: [] },
|
|
85
102
|
airqualitysensor: { input: [['co2', 850]], output: [] },
|
|
@@ -379,7 +396,7 @@
|
|
|
379
396
|
const isCover = selected === 'windowcovering';
|
|
380
397
|
// The Alexa duplicate-command dedup applies to any device that emits On/Off or
|
|
381
398
|
// level commands (plain on/off lights and plugs included), not just dimmers.
|
|
382
|
-
const hasCommand = selected === 'onofflight' || selected === 'onoffplug' || hasLevel;
|
|
399
|
+
const hasCommand = selected === 'onofflight' || selected === 'onoffplug' || selected === 'roomairconditioner' || hasLevel;
|
|
383
400
|
$advancedRows.toggle(hasCommand || isCover);
|
|
384
401
|
$commandAdvancedRows.toggle(hasCommand);
|
|
385
402
|
$dimmerAdvancedRows.toggle(hasLevel);
|
|
@@ -55,6 +55,21 @@ module.exports = function (RED) {
|
|
|
55
55
|
{ fn: 'coolingsetpoint', direction: 'command', ga: 'gaCoolingSetpoint', dpt: 'dptCoolingSetpoint', fallbackDpt: '9.001' },
|
|
56
56
|
{ fn: 'coolingsetpoint', direction: 'status', ga: 'gaCoolingSetpointStatus', dpt: 'dptCoolingSetpointStatus', fallbackDpt: '9.001' }
|
|
57
57
|
],
|
|
58
|
+
roomairconditioner: [
|
|
59
|
+
{ fn: 'onoff', direction: 'command', ga: 'gaOnOff', dpt: 'dptOnOff', fallbackDpt: '1.001' },
|
|
60
|
+
{ fn: 'onoff', direction: 'status', ga: 'gaOnOffStatus', dpt: 'dptOnOffStatus', fallbackDpt: '1.001' },
|
|
61
|
+
{ fn: 'currenttemp', direction: 'status', ga: 'gaCurrentTemp', dpt: 'dptCurrentTemp', fallbackDpt: '9.001' },
|
|
62
|
+
{ fn: 'setpoint', direction: 'command', ga: 'gaSetpoint', dpt: 'dptSetpoint', fallbackDpt: '9.001' },
|
|
63
|
+
{ fn: 'setpoint', direction: 'status', ga: 'gaSetpointStatus', dpt: 'dptSetpointStatus', fallbackDpt: '9.001' },
|
|
64
|
+
{ fn: 'coolingsetpoint', direction: 'command', ga: 'gaCoolingSetpoint', dpt: 'dptCoolingSetpoint', fallbackDpt: '9.001' },
|
|
65
|
+
{ fn: 'coolingsetpoint', direction: 'status', ga: 'gaCoolingSetpointStatus', dpt: 'dptCoolingSetpointStatus', fallbackDpt: '9.001' },
|
|
66
|
+
{ fn: 'fanspeed', direction: 'command', ga: 'gaFanSpeed', dpt: 'dptFanSpeed', fallbackDpt: '5.001' },
|
|
67
|
+
{ fn: 'fanspeed', direction: 'status', ga: 'gaFanSpeedStatus', dpt: 'dptFanSpeedStatus', fallbackDpt: '5.001' }
|
|
68
|
+
],
|
|
69
|
+
doorlock: [
|
|
70
|
+
{ fn: 'lock', direction: 'command', ga: 'gaLock', dpt: 'dptLock', fallbackDpt: '1.001' },
|
|
71
|
+
{ fn: 'lock', direction: 'status', ga: 'gaLockStatus', dpt: 'dptLockStatus', fallbackDpt: '1.001' }
|
|
72
|
+
],
|
|
58
73
|
smokecoalarm: [
|
|
59
74
|
{ fn: 'smoke', direction: 'status', ga: 'gaSmoke', dpt: 'dptSmoke', fallbackDpt: '1.005' },
|
|
60
75
|
{ fn: 'co', direction: 'status', ga: 'gaCO', dpt: 'dptCO', fallbackDpt: '1.005' }
|
|
@@ -275,9 +290,9 @@ module.exports = function (RED) {
|
|
|
275
290
|
coverSliderDebounceMs: node.coverSliderDebounceMs,
|
|
276
291
|
commandDedupMs: node.commandDedupMs,
|
|
277
292
|
coverUpdateMode: node.coverUpdateMode,
|
|
278
|
-
// Thermostat
|
|
279
|
-
// configured, so the bridge engine can build a Heating-only,
|
|
280
|
-
// ThermostatServer without a dedicated
|
|
293
|
+
// Thermostat/Room Air Conditioner: whether a heating/cooling setpoint GA (command
|
|
294
|
+
// or status) was configured, so the bridge engine can build a Heating-only,
|
|
295
|
+
// Cooling-only or dual ThermostatServer without a dedicated mode dropdown.
|
|
281
296
|
hasHeatingSetpoint: devConfigValue('gaSetpoint') !== '' || devConfigValue('gaSetpointStatus') !== '',
|
|
282
297
|
hasCoolingSetpoint: devConfigValue('gaCoolingSetpoint') !== '' || devConfigValue('gaCoolingSetpointStatus') !== ''
|
|
283
298
|
})
|