node-red-contrib-knx-ultimate 5.0.0 → 5.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/nodes/knxUltimate-config.html +1 -3
- package/nodes/knxUltimateIoTBridge.html +373 -15
- package/nodes/knxUltimateIoTBridge.js +178 -11
- package/nodes/lib/knx-home-assistant.js +171 -0
- package/nodes/lib/mqtt-bridge.js +596 -0
- package/nodes/locales/de/knxUltimateIoTBridge.html +25 -1
- package/nodes/locales/de/knxUltimateIoTBridge.json +55 -3
- package/nodes/locales/en/knxUltimate-config.json +8 -8
- package/nodes/locales/en/knxUltimateIoTBridge.html +26 -2
- package/nodes/locales/en/knxUltimateIoTBridge.json +55 -3
- package/nodes/locales/es/knxUltimateIoTBridge.html +25 -1
- package/nodes/locales/es/knxUltimateIoTBridge.json +55 -3
- package/nodes/locales/fr/knxUltimateIoTBridge.html +25 -1
- package/nodes/locales/fr/knxUltimateIoTBridge.json +55 -3
- package/nodes/locales/it/knxUltimate-config.json +14 -14
- package/nodes/locales/it/knxUltimateIoTBridge.html +25 -1
- package/nodes/locales/it/knxUltimateIoTBridge.json +55 -3
- package/nodes/locales/zh-CN/knxUltimateIoTBridge.html +25 -1
- package/nodes/locales/zh-CN/knxUltimateIoTBridge.json +55 -3
- package/package.json +3 -2
|
@@ -22,7 +22,7 @@ module.exports = function (RED) {
|
|
|
22
22
|
return
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
node.name = config.name || 'KNX IoT
|
|
25
|
+
node.name = config.name || 'KNX MQTT - IoT'
|
|
26
26
|
node.outputtopic = config.outputtopic || ''
|
|
27
27
|
|
|
28
28
|
node.listenallga = true
|
|
@@ -40,6 +40,27 @@ module.exports = function (RED) {
|
|
|
40
40
|
|
|
41
41
|
node.mappings = Array.isArray(config.mappings) ? config.mappings : []
|
|
42
42
|
|
|
43
|
+
// Operation mode: 'iot' (classic IoT mappings, default) or 'homeassistant' (native
|
|
44
|
+
// MQTT bridge with Home Assistant discovery for every group address + cover/climate).
|
|
45
|
+
node.nodeMode = config.nodeMode === 'homeassistant' ? 'homeassistant' : 'iot'
|
|
46
|
+
// Home Assistant bus wiring: 'standalone' (default) talks to the KNX gateway directly,
|
|
47
|
+
// 'flow' uses the node's input/output pins instead (wire a KNXUltimate universal node to
|
|
48
|
+
// both): the input pin feeds KNX bus telegrams in, the output pin emits telegrams to write.
|
|
49
|
+
node.haBusMode = config.haBusMode === 'flow' ? 'flow' : 'standalone'
|
|
50
|
+
node.mqttUrl = typeof config.mqttUrl === 'string' ? config.mqttUrl.trim() : ''
|
|
51
|
+
node.mqttBaseTopic = typeof config.mqttBaseTopic === 'string' && config.mqttBaseTopic.trim() !== '' ? config.mqttBaseTopic.trim() : 'knx-ultimate'
|
|
52
|
+
node.mqttDiscovery = config.mqttDiscovery !== false && config.mqttDiscovery !== 'false'
|
|
53
|
+
node.mqttDiscoveryPrefix = typeof config.mqttDiscoveryPrefix === 'string' && config.mqttDiscoveryPrefix.trim() !== '' ? config.mqttDiscoveryPrefix.trim() : 'homeassistant'
|
|
54
|
+
node.mqttCustomEntities = Array.isArray(config.mqttCustomEntities) ? config.mqttCustomEntities : []
|
|
55
|
+
// Group addresses to expose as simple entities. Once the user curates the list
|
|
56
|
+
// (mqttExposeConfigured), only the listed GAs are exposed; otherwise all imported GAs are.
|
|
57
|
+
node.mqttExposeConfigured = config.mqttExposeConfigured === true
|
|
58
|
+
node.mqttExposedGAs = Array.isArray(config.mqttExposedGAs) ? config.mqttExposedGAs : []
|
|
59
|
+
// Group addresses the user marked as read-only: they are still exposed (state is
|
|
60
|
+
// published to HA) but never accept commands back to the KNX bus.
|
|
61
|
+
node.mqttReadOnlyGAs = Array.isArray(config.mqttReadOnlyGAs) ? config.mqttReadOnlyGAs : []
|
|
62
|
+
node.mqttBridge = null
|
|
63
|
+
|
|
43
64
|
const safeNumber = (value, fallback = 0) => {
|
|
44
65
|
if (value === null || value === undefined || value === '') return fallback
|
|
45
66
|
const parsed = Number(value)
|
|
@@ -143,6 +164,98 @@ module.exports = function (RED) {
|
|
|
143
164
|
}
|
|
144
165
|
}
|
|
145
166
|
|
|
167
|
+
// HOME ASSISTANT (MQTT) BRIDGE -----------------------------------------------------------
|
|
168
|
+
node.startMqttBridge = () => {
|
|
169
|
+
if (node.mqttBridge !== null) return // already running
|
|
170
|
+
if (!node.mqttUrl) {
|
|
171
|
+
pushStatus({ fill: 'red', shape: 'dot', text: 'MQTT broker URL missing' })
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
// Lazy-require so the node still loads if the optional mqtt dependency is missing.
|
|
176
|
+
const { createMqttBridge } = require('./lib/mqtt-bridge.js')
|
|
177
|
+
node.mqttBridge = createMqttBridge({
|
|
178
|
+
node,
|
|
179
|
+
url: node.mqttUrl,
|
|
180
|
+
baseTopic: node.mqttBaseTopic,
|
|
181
|
+
discovery: node.mqttDiscovery,
|
|
182
|
+
discoveryPrefix: node.mqttDiscoveryPrefix,
|
|
183
|
+
username: node.credentials ? node.credentials.mqttUsername : undefined,
|
|
184
|
+
password: node.credentials ? node.credentials.mqttPassword : undefined,
|
|
185
|
+
groupAddresses: (node.serverKNX && Array.isArray(node.serverKNX.csv)) ? node.serverKNX.csv : [],
|
|
186
|
+
customEntities: node.mqttCustomEntities,
|
|
187
|
+
// null => expose all imported GAs (until the user curates the list).
|
|
188
|
+
exposedGAs: node.mqttExposeConfigured ? node.mqttExposedGAs : null,
|
|
189
|
+
// GAs exposed as read-only (state only, no command topic back to KNX).
|
|
190
|
+
readOnlyGAs: node.mqttReadOnlyGAs,
|
|
191
|
+
onCommand: ({ ga, dpt, value }) => {
|
|
192
|
+
// A Home Assistant command arrived: write it to the KNX bus.
|
|
193
|
+
try {
|
|
194
|
+
if (node.haBusMode === 'flow') {
|
|
195
|
+
// Flow mode: emit a message on the (single) output pin for a downstream
|
|
196
|
+
// KNXUltimate universal node to write to the bus (destination + dpt + payload).
|
|
197
|
+
node.send({
|
|
198
|
+
topic: ga,
|
|
199
|
+
destination: ga,
|
|
200
|
+
dpt: dpt || '',
|
|
201
|
+
payload: value
|
|
202
|
+
})
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
node.serverKNX.sendKNXTelegramToKNXEngine({
|
|
206
|
+
grpaddr: ga,
|
|
207
|
+
payload: value,
|
|
208
|
+
dpt,
|
|
209
|
+
outputtype: 'write',
|
|
210
|
+
nodecallerid: node.id
|
|
211
|
+
})
|
|
212
|
+
} catch (error) {
|
|
213
|
+
if (node.sysLogger) node.sysLogger.error('HA bridge write failed (' + ga + '): ' + error.message)
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
onStatus: (status) => {
|
|
217
|
+
if (!status) return
|
|
218
|
+
if (status.state === 'connected') {
|
|
219
|
+
pushStatus({ fill: 'green', shape: 'dot', text: 'HA connected (' + (status.detail || '0') + ' entities)' })
|
|
220
|
+
} else if (status.state === 'error') {
|
|
221
|
+
pushStatus({ fill: 'red', shape: 'dot', text: 'MQTT ' + (status.detail || 'error') })
|
|
222
|
+
} else if (status.state === 'reconnect' || status.state === 'offline') {
|
|
223
|
+
pushStatus({ fill: 'yellow', shape: 'ring', text: 'MQTT ' + status.state })
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
})
|
|
227
|
+
node.mqttBridge.connect()
|
|
228
|
+
pushStatus({ fill: 'grey', shape: 'ring', text: 'HA mode: connecting (' + node.mqttBridge.entityCount + ' entities)' })
|
|
229
|
+
} catch (error) {
|
|
230
|
+
node.mqttBridge = null
|
|
231
|
+
if (node.sysLogger) node.sysLogger.error('startMqttBridge failed: ' + error.message)
|
|
232
|
+
pushStatus({ fill: 'red', shape: 'dot', text: 'MQTT bridge: ' + error.message })
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
node.stopMqttBridge = (done) => {
|
|
237
|
+
const bridge = node.mqttBridge
|
|
238
|
+
node.mqttBridge = null
|
|
239
|
+
let called = false
|
|
240
|
+
const cb = () => {
|
|
241
|
+
if (called) return
|
|
242
|
+
called = true
|
|
243
|
+
if (typeof done === 'function') {
|
|
244
|
+
try { done() } catch (error) { /* ignore */ }
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (!bridge) {
|
|
248
|
+
cb()
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
try {
|
|
252
|
+
bridge.close(cb)
|
|
253
|
+
} catch (error) {
|
|
254
|
+
if (node.sysLogger) node.sysLogger.error('stopMqttBridge error: ' + (error && error.message))
|
|
255
|
+
cb()
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
146
259
|
const isBooleanDpt = (dpt) => typeof dpt === 'string' && dpt.startsWith('1.')
|
|
147
260
|
|
|
148
261
|
const toBoolean = (value) => {
|
|
@@ -333,12 +446,30 @@ module.exports = function (RED) {
|
|
|
333
446
|
}
|
|
334
447
|
}
|
|
335
448
|
|
|
449
|
+
// Home Assistant mode: mirror a decoded KNX telegram to MQTT. Works with a telegram coming
|
|
450
|
+
// from the gateway (standalone) or from the input pin (flow mode); both share the shape
|
|
451
|
+
// produced by the KNXUltimate universal node ({ payload, knx: { destination, event } }).
|
|
452
|
+
const publishKnxToMqtt = (msg) => {
|
|
453
|
+
if (!msg || !node.mqttBridge) return
|
|
454
|
+
const destination = msg.knx && msg.knx.destination ? msg.knx.destination : sanitizeString(msg.topic)
|
|
455
|
+
if (!destination) return
|
|
456
|
+
const event = msg.knx ? msg.knx.event : undefined
|
|
457
|
+
if (event === 'GroupValue_Read') return // read requests carry no value
|
|
458
|
+
node.mqttBridge.publishState(destination, msg.payload)
|
|
459
|
+
}
|
|
460
|
+
|
|
336
461
|
const handleKnxTelegram = (msg) => {
|
|
337
462
|
try {
|
|
338
463
|
if (!msg) return
|
|
339
464
|
const destination = msg.knx && msg.knx.destination ? msg.knx.destination : sanitizeString(msg.topic)
|
|
340
465
|
if (!destination) return
|
|
341
466
|
|
|
467
|
+
// Home Assistant mode: mirror the decoded value to MQTT and stop (no IoT mappings).
|
|
468
|
+
if (node.nodeMode === 'homeassistant') {
|
|
469
|
+
publishKnxToMqtt(msg)
|
|
470
|
+
return
|
|
471
|
+
}
|
|
472
|
+
|
|
342
473
|
const meta = {
|
|
343
474
|
event: msg.knx ? msg.knx.event : undefined,
|
|
344
475
|
source: msg.knx ? msg.knx.source : undefined,
|
|
@@ -382,6 +513,14 @@ module.exports = function (RED) {
|
|
|
382
513
|
node.handleSend = handleKnxTelegram
|
|
383
514
|
|
|
384
515
|
node.on('input', (msg, send, done) => {
|
|
516
|
+
// In Home Assistant mode, commands flow in over MQTT, not via the flow input. The one
|
|
517
|
+
// exception is 'flow' bus mode, where the input pin carries KNX bus telegrams (from a
|
|
518
|
+
// KNXUltimate universal node) that must be mirrored to MQTT.
|
|
519
|
+
if (node.nodeMode === 'homeassistant') {
|
|
520
|
+
if (node.haBusMode === 'flow') publishKnxToMqtt(msg)
|
|
521
|
+
if (done) done()
|
|
522
|
+
return
|
|
523
|
+
}
|
|
385
524
|
if (!node.acceptFlowInput) {
|
|
386
525
|
if (done) done()
|
|
387
526
|
return
|
|
@@ -446,14 +585,30 @@ module.exports = function (RED) {
|
|
|
446
585
|
})
|
|
447
586
|
|
|
448
587
|
node.on('close', (done) => {
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
588
|
+
// Always call done() exactly once, even if something throws, so a deploy / Node-RED exit
|
|
589
|
+
// is never blocked by the bridge teardown.
|
|
590
|
+
let finished = false
|
|
591
|
+
const finish = () => {
|
|
592
|
+
if (finished) return
|
|
593
|
+
finished = true
|
|
594
|
+
if (typeof done === 'function') {
|
|
595
|
+
try { done() } catch (error) { /* ignore */ }
|
|
454
596
|
}
|
|
455
597
|
}
|
|
456
|
-
|
|
598
|
+
try {
|
|
599
|
+
if (node.serverKNX && typeof node.serverKNX.removeClient === 'function') {
|
|
600
|
+
try {
|
|
601
|
+
node.serverKNX.removeClient(node)
|
|
602
|
+
} catch (error) {
|
|
603
|
+
/* empty */
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
// Stop the MQTT bridge (best-effort, hard-capped so redeploy never blocks on the broker).
|
|
607
|
+
node.stopMqttBridge(finish)
|
|
608
|
+
} catch (error) {
|
|
609
|
+
if (node.sysLogger) node.sysLogger.error('close handler error: ' + (error && error.message))
|
|
610
|
+
finish()
|
|
611
|
+
}
|
|
457
612
|
})
|
|
458
613
|
|
|
459
614
|
const registerClient = () => {
|
|
@@ -491,10 +646,22 @@ module.exports = function (RED) {
|
|
|
491
646
|
}
|
|
492
647
|
}
|
|
493
648
|
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
649
|
+
// In HA 'flow' mode the KNX telegrams arrive on the input pin, so we must NOT subscribe to
|
|
650
|
+
// the gateway's client feed (that would double-publish and bypass the intended wiring).
|
|
651
|
+
const useFlowBus = node.nodeMode === 'homeassistant' && node.haBusMode === 'flow'
|
|
652
|
+
if (!useFlowBus) registerClient()
|
|
653
|
+
if (node.nodeMode === 'homeassistant') {
|
|
654
|
+
node.startMqttBridge()
|
|
655
|
+
} else {
|
|
656
|
+
updateIdleStatus()
|
|
657
|
+
issueInitialReads()
|
|
658
|
+
}
|
|
497
659
|
}
|
|
498
660
|
|
|
499
|
-
RED.nodes.registerType('knxUltimateIoTBridge', knxUltimateIoTBridge
|
|
661
|
+
RED.nodes.registerType('knxUltimateIoTBridge', knxUltimateIoTBridge, {
|
|
662
|
+
credentials: {
|
|
663
|
+
mqttUsername: { type: 'text' },
|
|
664
|
+
mqttPassword: { type: 'password' }
|
|
665
|
+
}
|
|
666
|
+
})
|
|
500
667
|
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Shared Home Assistant mapping helpers for the native KNX MQTT bridge.
|
|
4
|
+
//
|
|
5
|
+
// A KNX gateway exposes many group addresses (GA), each carrying a Datapoint Type (DPT).
|
|
6
|
+
// Home Assistant, on the other hand, models things as typed entities (switch, sensor,
|
|
7
|
+
// binary_sensor, number, text...). This module is the translation layer between the two:
|
|
8
|
+
//
|
|
9
|
+
// - mapDptToHa(dpt) -> { domain, deviceClass?, unit?, min?, max?, step? }
|
|
10
|
+
// - formatValueForMqtt(value) -> string to publish on a HA state topic
|
|
11
|
+
// - parseCommandFromMqtt(text, dpt) -> JS value to write on the KNX bus (or null)
|
|
12
|
+
//
|
|
13
|
+
// The mapping is intentionally pragmatic: it covers the common DPTs and falls back to a
|
|
14
|
+
// read-only "sensor" for everything else, so an unknown DPT never breaks discovery.
|
|
15
|
+
|
|
16
|
+
// Extract the main/sub parts of a DPT string ("DPT9.001", "9.001", "9" ...).
|
|
17
|
+
function splitDpt (dptRaw) {
|
|
18
|
+
const clean = String(dptRaw == null ? '' : dptRaw).replace(/^dpt/i, '').trim()
|
|
19
|
+
const parts = clean.split('.')
|
|
20
|
+
const main = parseInt(parts[0], 10)
|
|
21
|
+
const sub = parts.length > 1 ? parts[1].trim() : ''
|
|
22
|
+
return { main: Number.isFinite(main) ? main : null, sub }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Unit + HA device_class for the most common 2-byte float DPTs (DPT 9.xxx).
|
|
26
|
+
function unitForFloat (sub) {
|
|
27
|
+
switch (sub) {
|
|
28
|
+
case '001': return { unit: '°C', deviceClass: 'temperature' }
|
|
29
|
+
case '002': return { unit: 'K' }
|
|
30
|
+
case '003': return { unit: 'K/h' }
|
|
31
|
+
case '004': return { unit: 'lx', deviceClass: 'illuminance' }
|
|
32
|
+
case '005': return { unit: 'm/s', deviceClass: 'wind_speed' }
|
|
33
|
+
case '006': return { unit: 'Pa', deviceClass: 'pressure' }
|
|
34
|
+
case '007': return { unit: '%', deviceClass: 'humidity' }
|
|
35
|
+
case '008': return { unit: 'ppm' }
|
|
36
|
+
case '009': return { unit: 'mg/m³' }
|
|
37
|
+
case '010': return { unit: 'm/s' }
|
|
38
|
+
case '011': return { unit: 's' }
|
|
39
|
+
case '020': return { unit: 'mV', deviceClass: 'voltage' }
|
|
40
|
+
case '021': return { unit: 'mA', deviceClass: 'current' }
|
|
41
|
+
case '022': return { unit: 'W/m²', deviceClass: 'irradiance' }
|
|
42
|
+
case '024': return { unit: 'K' }
|
|
43
|
+
case '025': return { unit: '1/h' }
|
|
44
|
+
case '026': return { unit: 'l/h' }
|
|
45
|
+
case '027': return { unit: '°F', deviceClass: 'temperature' }
|
|
46
|
+
case '028': return { unit: 'km/h', deviceClass: 'wind_speed' }
|
|
47
|
+
default: return {}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Map a KNX DPT to a Home Assistant entity descriptor.
|
|
52
|
+
// `domain` is the HA MQTT platform; the extra fields tune the discovery config.
|
|
53
|
+
function mapDptToHa (dptRaw) {
|
|
54
|
+
const { main, sub } = splitDpt(dptRaw)
|
|
55
|
+
|
|
56
|
+
if (main === 1) {
|
|
57
|
+
// 1-bit boolean. A handful of subtypes are clearly read-only states; the rest are
|
|
58
|
+
// treated as controllable switches.
|
|
59
|
+
const readOnly = ['005', '011', '019']
|
|
60
|
+
if (readOnly.includes(sub)) {
|
|
61
|
+
const deviceClass = sub === '005' ? 'problem' : (sub === '019' ? 'opening' : undefined)
|
|
62
|
+
return { domain: 'binary_sensor', deviceClass, writable: false }
|
|
63
|
+
}
|
|
64
|
+
return { domain: 'switch', writable: true }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (main === 5) {
|
|
68
|
+
if (sub === '001') return { domain: 'number', min: 0, max: 100, step: 1, unit: '%', writable: true } // scaling
|
|
69
|
+
if (sub === '003') return { domain: 'number', min: 0, max: 360, step: 1, unit: '°', writable: true } // angle
|
|
70
|
+
return { domain: 'number', min: 0, max: 255, step: 1, writable: true }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (main === 6) return { domain: 'sensor', writable: false } // 1-byte signed
|
|
74
|
+
if (main === 7) return { domain: 'sensor', writable: false } // 2-byte unsigned
|
|
75
|
+
if (main === 8) return { domain: 'sensor', writable: false } // 2-byte signed
|
|
76
|
+
|
|
77
|
+
if (main === 9) {
|
|
78
|
+
const u = unitForFloat(sub)
|
|
79
|
+
return { domain: 'sensor', unit: u.unit, deviceClass: u.deviceClass, writable: false }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (main === 12) return { domain: 'sensor', writable: false } // 4-byte unsigned
|
|
83
|
+
if (main === 13) {
|
|
84
|
+
if (sub === '010') return { domain: 'sensor', unit: 'Wh', deviceClass: 'energy', writable: false }
|
|
85
|
+
if (sub === '013') return { domain: 'sensor', unit: 'kWh', deviceClass: 'energy', writable: false }
|
|
86
|
+
return { domain: 'sensor', writable: false } // 4-byte signed
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (main === 14) {
|
|
90
|
+
// 4-byte float (engineering values). A few well-known subtypes carry a clear unit.
|
|
91
|
+
if (sub === '056') return { domain: 'sensor', unit: 'W', deviceClass: 'power', writable: false }
|
|
92
|
+
if (sub === '027') return { domain: 'sensor', unit: 'V', deviceClass: 'voltage', writable: false }
|
|
93
|
+
if (sub === '019') return { domain: 'sensor', unit: 'A', deviceClass: 'current', writable: false }
|
|
94
|
+
if (sub === '068') return { domain: 'sensor', unit: '°C', deviceClass: 'temperature', writable: false }
|
|
95
|
+
return { domain: 'sensor', writable: false }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (main === 16) return { domain: 'text', writable: true } // character string
|
|
99
|
+
|
|
100
|
+
// Scenes (17/18), HVAC modes (20), colours (232) and anything else: expose as a
|
|
101
|
+
// read-only sensor so the value is visible without risking a wrong write encoding.
|
|
102
|
+
return { domain: 'sensor', writable: false }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Render a KNX-decoded JS value to the string published on a HA state topic.
|
|
106
|
+
function formatValueForMqtt (value) {
|
|
107
|
+
if (value === null || value === undefined) return ''
|
|
108
|
+
if (typeof value === 'boolean') return value ? 'true' : 'false'
|
|
109
|
+
if (typeof value === 'object') {
|
|
110
|
+
try {
|
|
111
|
+
return JSON.stringify(value)
|
|
112
|
+
} catch (_err) {
|
|
113
|
+
return ''
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return String(value)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Parse a HA command payload (string) into the JS value to write on the KNX bus.
|
|
120
|
+
// Returns null when the payload can't be coerced to the DPT (the write is then skipped).
|
|
121
|
+
function parseCommandFromMqtt (text, dptRaw) {
|
|
122
|
+
const { main } = splitDpt(dptRaw)
|
|
123
|
+
const s = (text == null ? '' : String(text)).trim()
|
|
124
|
+
if (s === '') return null
|
|
125
|
+
|
|
126
|
+
if (main === 1) {
|
|
127
|
+
const low = s.toLowerCase()
|
|
128
|
+
if (['true', 'on', '1', 'open', 'up', 'yes'].includes(low)) return true
|
|
129
|
+
if (['false', 'off', '0', 'closed', 'close', 'down', 'no'].includes(low)) return false
|
|
130
|
+
return null
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if ([5, 6, 7, 8, 9, 12, 13, 14].includes(main)) {
|
|
134
|
+
const n = Number(s)
|
|
135
|
+
return Number.isFinite(n) ? n : null
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (main === 16) return s
|
|
139
|
+
|
|
140
|
+
// Unknown/structured DPT: accept JSON objects, otherwise pass the raw string through.
|
|
141
|
+
if (s.startsWith('{') || s.startsWith('[')) {
|
|
142
|
+
try {
|
|
143
|
+
return JSON.parse(s)
|
|
144
|
+
} catch (_err) {
|
|
145
|
+
return s
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return s
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Default KNX DPTs for the roles of composite (cover/climate) entities. Used to encode
|
|
152
|
+
// writes when the role GA is not present in the ETS CSV (so its DPT can't be resolved).
|
|
153
|
+
const COVER_DEFAULT_DPT = {
|
|
154
|
+
upDown: '1.008', // 0 = Up/Open, 1 = Down/Close
|
|
155
|
+
stop: '1.007', // step/stop
|
|
156
|
+
position: '5.001' // 0..100 %
|
|
157
|
+
}
|
|
158
|
+
const CLIMATE_DEFAULT_DPT = {
|
|
159
|
+
currentTemp: '9.001',
|
|
160
|
+
setpoint: '9.001',
|
|
161
|
+
onOff: '1.001'
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
module.exports = {
|
|
165
|
+
splitDpt,
|
|
166
|
+
mapDptToHa,
|
|
167
|
+
formatValueForMqtt,
|
|
168
|
+
parseCommandFromMqtt,
|
|
169
|
+
COVER_DEFAULT_DPT,
|
|
170
|
+
CLIMATE_DEFAULT_DPT
|
|
171
|
+
}
|