node-red-contrib-knx-ultimate 4.3.24 → 5.0.1
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 +13 -0
- package/nodes/commonFunctions.js +115 -0
- package/nodes/knxUltimate-config.html +61 -7
- package/nodes/knxUltimate-config.js +11 -0
- package/nodes/knxUltimateIoTBridge.html +305 -15
- package/nodes/knxUltimateIoTBridge.js +142 -10
- package/nodes/lib/knx-home-assistant.js +171 -0
- package/nodes/lib/mqtt-bridge.js +584 -0
- package/nodes/locales/de/knxUltimate-config.json +5 -1
- package/nodes/locales/de/knxUltimateIoTBridge.html +22 -1
- package/nodes/locales/de/knxUltimateIoTBridge.json +44 -2
- package/nodes/locales/en/knxUltimate-config.json +13 -9
- package/nodes/locales/en/knxUltimateIoTBridge.html +23 -2
- package/nodes/locales/en/knxUltimateIoTBridge.json +44 -2
- package/nodes/locales/es/knxUltimate-config.json +5 -1
- package/nodes/locales/es/knxUltimateIoTBridge.html +22 -1
- package/nodes/locales/es/knxUltimateIoTBridge.json +44 -2
- package/nodes/locales/fr/knxUltimate-config.json +5 -1
- package/nodes/locales/fr/knxUltimateIoTBridge.html +22 -1
- package/nodes/locales/fr/knxUltimateIoTBridge.json +44 -2
- package/nodes/locales/it/knxUltimate-config.json +19 -15
- package/nodes/locales/it/knxUltimateIoTBridge.html +22 -1
- package/nodes/locales/it/knxUltimateIoTBridge.json +44 -2
- package/nodes/locales/zh-CN/knxUltimate-config.json +5 -1
- package/nodes/locales/zh-CN/knxUltimateIoTBridge.html +22 -1
- package/nodes/locales/zh-CN/knxUltimateIoTBridge.json +44 -2
- package/package.json +4 -3
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Native MQTT bridge for the KNX gateway config node.
|
|
4
|
+
//
|
|
5
|
+
// When enabled it:
|
|
6
|
+
// - exposes every imported ETS group address (GA) as a Home Assistant MQTT entity
|
|
7
|
+
// (switch / sensor / binary_sensor / number / text, derived from the DPT),
|
|
8
|
+
// - exposes user-defined composite entities (cover / climate) that aggregate several GAs,
|
|
9
|
+
// - publishes each GA value to a retained state topic as telegrams arrive on the bus,
|
|
10
|
+
// - subscribes to the command topics and writes incoming HA commands back to the KNX bus,
|
|
11
|
+
// - publishes Home Assistant MQTT Discovery so the entities appear automatically in HA,
|
|
12
|
+
// - tracks an availability (online/offline) topic via Last Will,
|
|
13
|
+
// - re-announces discovery on the HA "birth" message (HA restart / integration re-add).
|
|
14
|
+
//
|
|
15
|
+
// Internally everything is reduced to two maps, so simple and composite entities share the
|
|
16
|
+
// same plumbing:
|
|
17
|
+
// - gaPublishers: GA -> [fn(decodedValue)] (KNX bus -> MQTT state topics)
|
|
18
|
+
// - commandHandlers: command topic -> fn(text) -> [{ ga, dpt, value }] (MQTT -> KNX bus)
|
|
19
|
+
//
|
|
20
|
+
// The bridge is best-effort: a broker that is down or misconfigured must never crash the
|
|
21
|
+
// runtime, and stopping/redeploying must never block on a slow broker.
|
|
22
|
+
|
|
23
|
+
const mqtt = require('mqtt')
|
|
24
|
+
const ha = require('./knx-home-assistant.js')
|
|
25
|
+
|
|
26
|
+
function slugify (value, fallback) {
|
|
27
|
+
const s = String(value == null ? '' : value)
|
|
28
|
+
.trim()
|
|
29
|
+
.toLowerCase()
|
|
30
|
+
.replace(/[^a-z0-9_-]+/g, '_')
|
|
31
|
+
.replace(/_+/g, '_')
|
|
32
|
+
.replace(/^_|_$/g, '')
|
|
33
|
+
return s || fallback
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function sanitizeBaseTopic (value) {
|
|
37
|
+
const s = typeof value === 'string' ? value.trim() : ''
|
|
38
|
+
// Strip wildcards and surrounding slashes; MQTT base topics must be concrete.
|
|
39
|
+
return (s || 'knx-ultimate').replace(/[#+]/g, '').replace(/^\/+|\/+$/g, '') || 'knx-ultimate'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// A GA "1/2/3" becomes "1_2_3" for use inside topics and HA object ids.
|
|
43
|
+
function gaToSlug (ga) {
|
|
44
|
+
return String(ga == null ? '' : ga).replace(/[^0-9]+/g, '_').replace(/^_|_$/g, '')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function normalizeGa (ga) {
|
|
48
|
+
return typeof ga === 'string' ? ga.trim() : ''
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function toFiniteNumber (value, fallback) {
|
|
52
|
+
const n = Number(value)
|
|
53
|
+
return Number.isFinite(n) ? n : fallback
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function clamp (value, min, max) {
|
|
57
|
+
return Math.min(max, Math.max(min, value))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function createMqttBridge (options) {
|
|
61
|
+
const opts = options || {}
|
|
62
|
+
const node = opts.node
|
|
63
|
+
const onCommand = typeof opts.onCommand === 'function' ? opts.onCommand : () => {}
|
|
64
|
+
const onStatus = typeof opts.onStatus === 'function' ? opts.onStatus : () => {}
|
|
65
|
+
|
|
66
|
+
const url = typeof opts.url === 'string' ? opts.url.trim() : ''
|
|
67
|
+
const baseTopic = sanitizeBaseTopic(opts.baseTopic)
|
|
68
|
+
const discovery = opts.discovery !== false
|
|
69
|
+
const discoveryPrefix = (typeof opts.discoveryPrefix === 'string' && opts.discoveryPrefix.trim()) || 'homeassistant'
|
|
70
|
+
const username = typeof opts.username === 'string' && opts.username ? opts.username : undefined
|
|
71
|
+
const password = typeof opts.password === 'string' && opts.password ? opts.password : undefined
|
|
72
|
+
|
|
73
|
+
// Source per-GA entities: [{ ga, dpt, devicename }]
|
|
74
|
+
const sourceGAs = Array.isArray(opts.groupAddresses) ? opts.groupAddresses : []
|
|
75
|
+
// User-defined composite entities: [{ type:'cover'|'climate', name, ga... }]
|
|
76
|
+
const customEntities = Array.isArray(opts.customEntities) ? opts.customEntities : []
|
|
77
|
+
// Optional whitelist of GAs to expose as simple entities. null => expose all imported GAs.
|
|
78
|
+
const exposeFilter = Array.isArray(opts.exposedGAs)
|
|
79
|
+
? new Set(opts.exposedGAs.map((g) => normalizeGa(g)).filter((g) => g !== ''))
|
|
80
|
+
: null
|
|
81
|
+
|
|
82
|
+
const gatewayName = (node && node.name) || 'KNX Gateway'
|
|
83
|
+
const gatewaySlug = slugify(node && node.name, '') || slugify(node && node.id, 'knx')
|
|
84
|
+
const deviceId = `knx_${gatewaySlug}`
|
|
85
|
+
|
|
86
|
+
const root = `${baseTopic}/${gatewaySlug}`
|
|
87
|
+
const availabilityTopic = `${root}/availability`
|
|
88
|
+
// Home Assistant birth topic(s): on (re)start HA publishes "online" here and devices must
|
|
89
|
+
// re-announce their discovery. Default homeassistant/status; also cover a custom prefix.
|
|
90
|
+
const birthTopics = Array.from(new Set(['homeassistant/status', `${discoveryPrefix}/status`]))
|
|
91
|
+
|
|
92
|
+
const deviceBlock = {
|
|
93
|
+
identifiers: [deviceId],
|
|
94
|
+
name: gatewayName,
|
|
95
|
+
manufacturer: 'node-red-contrib-knx-ultimate',
|
|
96
|
+
model: 'KNX Ultimate Gateway'
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Resolve a GA's DPT from the ETS CSV (used to encode composite-entity writes).
|
|
100
|
+
const dptByGa = new Map()
|
|
101
|
+
sourceGAs.forEach((entry) => {
|
|
102
|
+
if (entry && typeof entry.ga === 'string' && entry.ga.trim() !== '' && entry.dpt) {
|
|
103
|
+
dptByGa.set(entry.ga.trim(), entry.dpt)
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
const resolveDpt = (ga, fallback) => dptByGa.get(normalizeGa(ga)) || fallback
|
|
107
|
+
|
|
108
|
+
// Unified plumbing (filled by the builders below).
|
|
109
|
+
const discoveryConfigs = [] // [{ topic, config }]
|
|
110
|
+
const gaPublishers = new Map() // ga -> [fn(decodedValue)]
|
|
111
|
+
const commandHandlers = new Map() // topic -> fn(text) -> [{ ga, dpt, value }]
|
|
112
|
+
const lastByTopic = new Map() // topic -> last published payload (dedupe + re-announce)
|
|
113
|
+
|
|
114
|
+
const usedSlugs = new Set()
|
|
115
|
+
function uniqueSlug (base) {
|
|
116
|
+
let slug = base
|
|
117
|
+
let i = 2
|
|
118
|
+
while (slug === '' || usedSlugs.has(slug)) {
|
|
119
|
+
slug = `${base || 'entity'}_${i}`
|
|
120
|
+
i += 1
|
|
121
|
+
}
|
|
122
|
+
usedSlugs.add(slug)
|
|
123
|
+
return slug
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function addPublisher (ga, fn) {
|
|
127
|
+
const key = normalizeGa(ga)
|
|
128
|
+
if (key === '') return
|
|
129
|
+
if (!gaPublishers.has(key)) gaPublishers.set(key, [])
|
|
130
|
+
gaPublishers.get(key).push(fn)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function addDiscovery (domain, slug, config) {
|
|
134
|
+
discoveryConfigs.push({ topic: `${discoveryPrefix}/${domain}/${deviceId}/${slug}/config`, config })
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---- Composite entities (cover / climate) ----------------------------------------------
|
|
138
|
+
// Track GAs consumed by composite entities so they are NOT also exposed as plain per-GA
|
|
139
|
+
// entities (which would duplicate them in Home Assistant).
|
|
140
|
+
const consumedGAs = new Set()
|
|
141
|
+
|
|
142
|
+
function buildCover (def, index) {
|
|
143
|
+
const name = (typeof def.name === 'string' && def.name.trim()) ? def.name.trim() : `Cover ${index + 1}`
|
|
144
|
+
const slug = uniqueSlug(slugify(name, `cover_${index + 1}`))
|
|
145
|
+
const uniqueId = `${deviceId}_${slug}`
|
|
146
|
+
const invert = def.invertPosition !== false // KNX 0% = open by default
|
|
147
|
+
|
|
148
|
+
const gaUpDown = normalizeGa(def.gaUpDown)
|
|
149
|
+
const gaStop = normalizeGa(def.gaStop)
|
|
150
|
+
const gaPosSet = normalizeGa(def.gaPosSet)
|
|
151
|
+
const gaPosState = normalizeGa(def.gaPosState)
|
|
152
|
+
if (!gaUpDown && !gaPosSet && !gaPosState) return // nothing usable
|
|
153
|
+
|
|
154
|
+
;[gaUpDown, gaStop, gaPosSet, gaPosState].forEach((g) => { if (g) consumedGAs.add(g) })
|
|
155
|
+
|
|
156
|
+
const commandTopic = `${root}/${slug}/set`
|
|
157
|
+
const positionTopic = `${root}/${slug}/position`
|
|
158
|
+
const setPositionTopic = `${root}/${slug}/position/set`
|
|
159
|
+
|
|
160
|
+
const config = {
|
|
161
|
+
name,
|
|
162
|
+
unique_id: uniqueId,
|
|
163
|
+
object_id: uniqueId,
|
|
164
|
+
device_class: typeof def.deviceClass === 'string' && def.deviceClass ? def.deviceClass : 'blind',
|
|
165
|
+
availability_topic: availabilityTopic,
|
|
166
|
+
payload_available: 'online',
|
|
167
|
+
payload_not_available: 'offline',
|
|
168
|
+
device: deviceBlock
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (gaUpDown) {
|
|
172
|
+
config.command_topic = commandTopic
|
|
173
|
+
config.payload_open = 'OPEN'
|
|
174
|
+
config.payload_close = 'CLOSE'
|
|
175
|
+
config.payload_stop = gaStop ? 'STOP' : null
|
|
176
|
+
const dptUpDown = resolveDpt(gaUpDown, ha.COVER_DEFAULT_DPT.upDown)
|
|
177
|
+
const dptStop = resolveDpt(gaStop, ha.COVER_DEFAULT_DPT.stop)
|
|
178
|
+
commandHandlers.set(commandTopic, (text) => {
|
|
179
|
+
const cmd = String(text || '').trim().toUpperCase()
|
|
180
|
+
if (cmd === 'OPEN') return [{ ga: gaUpDown, dpt: dptUpDown, value: false }] // 0 = Up/Open
|
|
181
|
+
if (cmd === 'CLOSE') return [{ ga: gaUpDown, dpt: dptUpDown, value: true }] // 1 = Down/Close
|
|
182
|
+
if (cmd === 'STOP' && gaStop) return [{ ga: gaStop, dpt: dptStop, value: true }]
|
|
183
|
+
return []
|
|
184
|
+
})
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (gaPosSet || gaPosState) {
|
|
188
|
+
config.position_topic = positionTopic
|
|
189
|
+
config.position_open = 100
|
|
190
|
+
config.position_closed = 0
|
|
191
|
+
}
|
|
192
|
+
if (gaPosSet) {
|
|
193
|
+
config.set_position_topic = setPositionTopic
|
|
194
|
+
const dptPos = resolveDpt(gaPosSet, ha.COVER_DEFAULT_DPT.position)
|
|
195
|
+
commandHandlers.set(setPositionTopic, (text) => {
|
|
196
|
+
const haPos = toFiniteNumber(text, null)
|
|
197
|
+
if (haPos === null) return []
|
|
198
|
+
const knxPos = clamp(invert ? 100 - haPos : haPos, 0, 100)
|
|
199
|
+
return [{ ga: gaPosSet, dpt: dptPos, value: knxPos }]
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
if (gaPosState) {
|
|
203
|
+
addPublisher(gaPosState, (value) => {
|
|
204
|
+
const knxPos = toFiniteNumber(value, null)
|
|
205
|
+
if (knxPos === null) return
|
|
206
|
+
const haPos = clamp(invert ? 100 - knxPos : knxPos, 0, 100)
|
|
207
|
+
pub(positionTopic, String(Math.round(haPos)), true)
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
addDiscovery('cover', slug, config)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function buildClimate (def, index) {
|
|
215
|
+
const name = (typeof def.name === 'string' && def.name.trim()) ? def.name.trim() : `Climate ${index + 1}`
|
|
216
|
+
const slug = uniqueSlug(slugify(name, `climate_${index + 1}`))
|
|
217
|
+
const uniqueId = `${deviceId}_${slug}`
|
|
218
|
+
|
|
219
|
+
const gaCurrentTemp = normalizeGa(def.gaCurrentTemp)
|
|
220
|
+
const gaSetpointSet = normalizeGa(def.gaSetpointSet)
|
|
221
|
+
const gaSetpointState = normalizeGa(def.gaSetpointState) || gaSetpointSet
|
|
222
|
+
const gaOnOff = normalizeGa(def.gaOnOff)
|
|
223
|
+
if (!gaCurrentTemp && !gaSetpointSet) return // nothing usable
|
|
224
|
+
|
|
225
|
+
;[gaCurrentTemp, gaSetpointSet, gaSetpointState, gaOnOff].forEach((g) => { if (g) consumedGAs.add(g) })
|
|
226
|
+
|
|
227
|
+
const currentTempTopic = `${root}/${slug}/current_temp`
|
|
228
|
+
const tempSetTopic = `${root}/${slug}/temp/set`
|
|
229
|
+
const tempStateTopic = `${root}/${slug}/temp/state`
|
|
230
|
+
const modeSetTopic = `${root}/${slug}/mode/set`
|
|
231
|
+
const modeStateTopic = `${root}/${slug}/mode/state`
|
|
232
|
+
|
|
233
|
+
const config = {
|
|
234
|
+
name,
|
|
235
|
+
unique_id: uniqueId,
|
|
236
|
+
object_id: uniqueId,
|
|
237
|
+
availability_topic: availabilityTopic,
|
|
238
|
+
payload_available: 'online',
|
|
239
|
+
payload_not_available: 'offline',
|
|
240
|
+
device: deviceBlock,
|
|
241
|
+
min_temp: toFiniteNumber(def.minTemp, 5),
|
|
242
|
+
max_temp: toFiniteNumber(def.maxTemp, 35),
|
|
243
|
+
temp_step: toFiniteNumber(def.tempStep, 0.5),
|
|
244
|
+
temperature_unit: 'C'
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (gaCurrentTemp) {
|
|
248
|
+
config.current_temperature_topic = currentTempTopic
|
|
249
|
+
addPublisher(gaCurrentTemp, (value) => {
|
|
250
|
+
const n = toFiniteNumber(value, null)
|
|
251
|
+
if (n !== null) pub(currentTempTopic, String(n), true)
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (gaSetpointSet) {
|
|
256
|
+
config.temperature_command_topic = tempSetTopic
|
|
257
|
+
const dptSet = resolveDpt(gaSetpointSet, ha.CLIMATE_DEFAULT_DPT.setpoint)
|
|
258
|
+
commandHandlers.set(tempSetTopic, (text) => {
|
|
259
|
+
const n = toFiniteNumber(text, null)
|
|
260
|
+
if (n === null) return []
|
|
261
|
+
return [{ ga: gaSetpointSet, dpt: dptSet, value: n }]
|
|
262
|
+
})
|
|
263
|
+
}
|
|
264
|
+
if (gaSetpointState) {
|
|
265
|
+
config.temperature_state_topic = tempStateTopic
|
|
266
|
+
addPublisher(gaSetpointState, (value) => {
|
|
267
|
+
const n = toFiniteNumber(value, null)
|
|
268
|
+
if (n !== null) pub(tempStateTopic, String(n), true)
|
|
269
|
+
})
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (gaOnOff) {
|
|
273
|
+
// Minimal HVAC mode support: off / heat, backed by a 1-bit on/off GA.
|
|
274
|
+
config.modes = ['off', 'heat']
|
|
275
|
+
config.mode_command_topic = modeSetTopic
|
|
276
|
+
config.mode_state_topic = modeStateTopic
|
|
277
|
+
const dptOnOff = resolveDpt(gaOnOff, ha.CLIMATE_DEFAULT_DPT.onOff)
|
|
278
|
+
commandHandlers.set(modeSetTopic, (text) => {
|
|
279
|
+
const mode = String(text || '').trim().toLowerCase()
|
|
280
|
+
if (mode === 'off') return [{ ga: gaOnOff, dpt: dptOnOff, value: false }]
|
|
281
|
+
return [{ ga: gaOnOff, dpt: dptOnOff, value: true }] // heat / any other -> on
|
|
282
|
+
})
|
|
283
|
+
addPublisher(gaOnOff, (value) => {
|
|
284
|
+
pub(modeStateTopic, value === true || value === 'true' || value === 1 ? 'heat' : 'off', true)
|
|
285
|
+
})
|
|
286
|
+
} else {
|
|
287
|
+
config.modes = ['heat']
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
addDiscovery('climate', slug, config)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
customEntities.forEach((def, index) => {
|
|
294
|
+
if (!def || typeof def !== 'object') return
|
|
295
|
+
try {
|
|
296
|
+
if (def.type === 'cover') buildCover(def, index)
|
|
297
|
+
else if (def.type === 'climate') buildClimate(def, index)
|
|
298
|
+
} catch (_err) {
|
|
299
|
+
// A single malformed entity must not break the whole bridge.
|
|
300
|
+
}
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
// ---- Simple per-GA entities ------------------------------------------------------------
|
|
304
|
+
const simpleSeen = new Set()
|
|
305
|
+
sourceGAs.forEach((entry) => {
|
|
306
|
+
if (!entry || typeof entry.ga !== 'string' || entry.ga.trim() === '') return
|
|
307
|
+
const ga = entry.ga.trim()
|
|
308
|
+
if (consumedGAs.has(ga)) return // already part of a cover/climate
|
|
309
|
+
if (exposeFilter && !exposeFilter.has(ga)) return // deselected by the user
|
|
310
|
+
const baseSlug = gaToSlug(ga)
|
|
311
|
+
if (baseSlug === '' || simpleSeen.has(ga)) return
|
|
312
|
+
simpleSeen.add(ga)
|
|
313
|
+
const map = ha.mapDptToHa(entry.dpt)
|
|
314
|
+
const slug = uniqueSlug(baseSlug)
|
|
315
|
+
const uniqueId = `${deviceId}_${slug}`
|
|
316
|
+
const name = (typeof entry.devicename === 'string' && entry.devicename.trim()) ? entry.devicename.trim() : ga
|
|
317
|
+
const stateTopic = `${root}/${slug}/state`
|
|
318
|
+
const commandTopic = `${root}/${slug}/set`
|
|
319
|
+
|
|
320
|
+
const config = {
|
|
321
|
+
name,
|
|
322
|
+
unique_id: uniqueId,
|
|
323
|
+
object_id: uniqueId,
|
|
324
|
+
state_topic: stateTopic,
|
|
325
|
+
availability_topic: availabilityTopic,
|
|
326
|
+
payload_available: 'online',
|
|
327
|
+
payload_not_available: 'offline',
|
|
328
|
+
device: deviceBlock
|
|
329
|
+
}
|
|
330
|
+
switch (map.domain) {
|
|
331
|
+
case 'switch':
|
|
332
|
+
config.command_topic = commandTopic
|
|
333
|
+
config.payload_on = 'true'
|
|
334
|
+
config.payload_off = 'false'
|
|
335
|
+
config.state_on = 'true'
|
|
336
|
+
config.state_off = 'false'
|
|
337
|
+
break
|
|
338
|
+
case 'binary_sensor':
|
|
339
|
+
config.payload_on = 'true'
|
|
340
|
+
config.payload_off = 'false'
|
|
341
|
+
if (map.deviceClass) config.device_class = map.deviceClass
|
|
342
|
+
break
|
|
343
|
+
case 'number':
|
|
344
|
+
config.command_topic = commandTopic
|
|
345
|
+
if (typeof map.min === 'number') config.min = map.min
|
|
346
|
+
if (typeof map.max === 'number') config.max = map.max
|
|
347
|
+
if (typeof map.step === 'number') config.step = map.step
|
|
348
|
+
config.mode = 'box'
|
|
349
|
+
if (map.unit) config.unit_of_measurement = map.unit
|
|
350
|
+
break
|
|
351
|
+
case 'text':
|
|
352
|
+
config.command_topic = commandTopic
|
|
353
|
+
break
|
|
354
|
+
case 'sensor':
|
|
355
|
+
default:
|
|
356
|
+
if (map.unit) config.unit_of_measurement = map.unit
|
|
357
|
+
if (map.deviceClass) config.device_class = map.deviceClass
|
|
358
|
+
break
|
|
359
|
+
}
|
|
360
|
+
addDiscovery(map.domain, slug, config)
|
|
361
|
+
|
|
362
|
+
// KNX -> MQTT: publish the decoded value to the state topic.
|
|
363
|
+
addPublisher(ga, (value) => pub(stateTopic, ha.formatValueForMqtt(value), true))
|
|
364
|
+
|
|
365
|
+
// MQTT -> KNX: writable domains accept commands.
|
|
366
|
+
if (map.writable === true) {
|
|
367
|
+
const dpt = entry.dpt
|
|
368
|
+
commandHandlers.set(commandTopic, (text) => {
|
|
369
|
+
const value = ha.parseCommandFromMqtt(text, dpt)
|
|
370
|
+
if (value === null) return []
|
|
371
|
+
return [{ ga, dpt, value }]
|
|
372
|
+
})
|
|
373
|
+
}
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
const entityCount = discoveryConfigs.length
|
|
377
|
+
const commandTopics = Array.from(commandHandlers.keys())
|
|
378
|
+
|
|
379
|
+
let client = null
|
|
380
|
+
let closed = false
|
|
381
|
+
|
|
382
|
+
function log (msg) {
|
|
383
|
+
if (node && typeof node.log === 'function') node.log(`[mqtt] ${msg}`)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function publishRaw (topic, payload, retain) {
|
|
387
|
+
if (!client || closed) return
|
|
388
|
+
try {
|
|
389
|
+
client.publish(topic, payload, { retain: retain === true, qos: 0 })
|
|
390
|
+
} catch (_err) {
|
|
391
|
+
// best-effort
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Publish a state value, retained and deduplicated per topic.
|
|
396
|
+
function pub (topic, payload, retain) {
|
|
397
|
+
if (lastByTopic.get(topic) === payload) return
|
|
398
|
+
lastByTopic.set(topic, payload)
|
|
399
|
+
publishRaw(topic, payload, retain)
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Called from the config node on every GroupValue_Write/Response, with the value already
|
|
403
|
+
// decoded by KNXUltimate. Routes it to every MQTT topic that depends on this GA.
|
|
404
|
+
function publishState (ga, value) {
|
|
405
|
+
const publishers = gaPublishers.get(normalizeGa(ga))
|
|
406
|
+
if (!publishers) return
|
|
407
|
+
publishers.forEach((fn) => {
|
|
408
|
+
try {
|
|
409
|
+
fn(value)
|
|
410
|
+
} catch (_err) {
|
|
411
|
+
// best-effort
|
|
412
|
+
}
|
|
413
|
+
})
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function warn (msg) {
|
|
417
|
+
if (node && typeof node.warn === 'function') {
|
|
418
|
+
try { node.warn(msg) } catch (_err) { /* ignore */ }
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Never let a callback passed in by the caller throw out of an mqtt event handler.
|
|
423
|
+
function safeStatus (status) {
|
|
424
|
+
try {
|
|
425
|
+
onStatus(status)
|
|
426
|
+
} catch (_err) {
|
|
427
|
+
// ignore
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// (Re)publish availability + discovery + last known state for every entity. Called on
|
|
432
|
+
// connect and whenever HA comes online, so entities reappear after an HA restart.
|
|
433
|
+
function announce () {
|
|
434
|
+
try {
|
|
435
|
+
publishRaw(availabilityTopic, 'online', true)
|
|
436
|
+
if (discovery) {
|
|
437
|
+
discoveryConfigs.forEach((d) => {
|
|
438
|
+
try {
|
|
439
|
+
publishRaw(d.topic, JSON.stringify(d.config), true)
|
|
440
|
+
} catch (_err) {
|
|
441
|
+
// skip a single malformed config rather than abort the whole announce
|
|
442
|
+
}
|
|
443
|
+
})
|
|
444
|
+
}
|
|
445
|
+
// Re-send cached states so HA doesn't show "unknown" right after (re)announcing.
|
|
446
|
+
lastByTopic.forEach((payload, topic) => publishRaw(topic, payload, true))
|
|
447
|
+
log(`announced discovery (${discovery ? entityCount + ' entit(y/ies)' : 'discovery OFF'})`)
|
|
448
|
+
} catch (err) {
|
|
449
|
+
warn('MQTT announce error: ' + (err && err.message))
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function handleIncoming (topic, buf) {
|
|
454
|
+
try {
|
|
455
|
+
const text = buf == null ? '' : buf.toString()
|
|
456
|
+
|
|
457
|
+
// Home Assistant birth message: re-announce everything when HA comes online.
|
|
458
|
+
if (birthTopics.includes(topic)) {
|
|
459
|
+
if (text.trim().toLowerCase() === 'online') announce()
|
|
460
|
+
return
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const handler = commandHandlers.get(topic)
|
|
464
|
+
if (!handler) return
|
|
465
|
+
let writes = []
|
|
466
|
+
try {
|
|
467
|
+
writes = handler(text) || []
|
|
468
|
+
} catch (err) {
|
|
469
|
+
if (node && typeof node.error === 'function') node.error(err)
|
|
470
|
+
return
|
|
471
|
+
}
|
|
472
|
+
writes.forEach((w) => {
|
|
473
|
+
if (!w || !w.ga) return
|
|
474
|
+
try {
|
|
475
|
+
onCommand(w)
|
|
476
|
+
} catch (err) {
|
|
477
|
+
if (node && typeof node.error === 'function') node.error(err)
|
|
478
|
+
}
|
|
479
|
+
})
|
|
480
|
+
} catch (err) {
|
|
481
|
+
warn('MQTT message handling error: ' + (err && err.message))
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function connect () {
|
|
486
|
+
if (!url) {
|
|
487
|
+
onStatus({ state: 'error', detail: 'missing url' })
|
|
488
|
+
return
|
|
489
|
+
}
|
|
490
|
+
const connectOpts = {
|
|
491
|
+
reconnectPeriod: 5000,
|
|
492
|
+
connectTimeout: 15000,
|
|
493
|
+
will: { topic: availabilityTopic, payload: 'offline', retain: true, qos: 0 }
|
|
494
|
+
}
|
|
495
|
+
if (username) connectOpts.username = username
|
|
496
|
+
if (password) connectOpts.password = password
|
|
497
|
+
|
|
498
|
+
try {
|
|
499
|
+
client = mqtt.connect(url, connectOpts)
|
|
500
|
+
} catch (err) {
|
|
501
|
+
safeStatus({ state: 'error', detail: err && err.message })
|
|
502
|
+
return
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Every handler is fully guarded: an exception thrown inside an mqtt event listener would
|
|
506
|
+
// otherwise become an uncaught exception and could take Node-RED down.
|
|
507
|
+
client.on('connect', () => {
|
|
508
|
+
try {
|
|
509
|
+
log(`connected to ${url} (${entityCount} entities)`)
|
|
510
|
+
announce()
|
|
511
|
+
try {
|
|
512
|
+
commandTopics.forEach((t) => client.subscribe(t, { qos: 0 }))
|
|
513
|
+
birthTopics.forEach((t) => client.subscribe(t, { qos: 0 }))
|
|
514
|
+
} catch (_err) {
|
|
515
|
+
// best-effort
|
|
516
|
+
}
|
|
517
|
+
safeStatus({ state: 'connected', detail: String(entityCount) })
|
|
518
|
+
} catch (err) {
|
|
519
|
+
warn('MQTT connect handler error: ' + (err && err.message))
|
|
520
|
+
}
|
|
521
|
+
})
|
|
522
|
+
client.on('message', handleIncoming)
|
|
523
|
+
client.on('reconnect', () => safeStatus({ state: 'reconnect' }))
|
|
524
|
+
client.on('offline', () => safeStatus({ state: 'offline' }))
|
|
525
|
+
client.on('error', (err) => {
|
|
526
|
+
warn(`MQTT error: ${err && err.message}`)
|
|
527
|
+
safeStatus({ state: 'error', detail: err && err.message })
|
|
528
|
+
})
|
|
529
|
+
client.on('close', () => safeStatus({ state: 'offline' }))
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function close (done) {
|
|
533
|
+
closed = true
|
|
534
|
+
const c = client
|
|
535
|
+
client = null
|
|
536
|
+
|
|
537
|
+
let finished = false
|
|
538
|
+
function finish () {
|
|
539
|
+
if (finished) return
|
|
540
|
+
finished = true
|
|
541
|
+
clearTimeout(guard)
|
|
542
|
+
// Force-close the socket and stop reconnection attempts; never wait on the broker.
|
|
543
|
+
if (c) {
|
|
544
|
+
try {
|
|
545
|
+
c.end(true)
|
|
546
|
+
} catch (_err) {
|
|
547
|
+
// ignore
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
if (typeof done === 'function') done()
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Hard cap so stopping/redeploying is never blocked when the broker is slow or unreachable.
|
|
554
|
+
const guard = setTimeout(finish, 700)
|
|
555
|
+
if (typeof guard.unref === 'function') guard.unref()
|
|
556
|
+
|
|
557
|
+
if (!c) {
|
|
558
|
+
finish()
|
|
559
|
+
return
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (c.connected) {
|
|
563
|
+
// Best-effort retained "offline" before a graceful disconnect (the Last Will only fires
|
|
564
|
+
// on an ungraceful drop). The guard above bounds how long we wait for it.
|
|
565
|
+
try {
|
|
566
|
+
c.publish(availabilityTopic, 'offline', { retain: true, qos: 0 }, () => finish())
|
|
567
|
+
} catch (_err) {
|
|
568
|
+
finish()
|
|
569
|
+
}
|
|
570
|
+
} else {
|
|
571
|
+
finish()
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
return {
|
|
576
|
+
connect,
|
|
577
|
+
close,
|
|
578
|
+
publishState,
|
|
579
|
+
entityCount,
|
|
580
|
+
topics: { root, availabilityTopic, commandTopics }
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
module.exports = { createMqttBridge }
|
|
@@ -144,7 +144,11 @@
|
|
|
144
144
|
"clear_persist_ok": "Löschen",
|
|
145
145
|
"clear_persist_cancel": "Abbrechen",
|
|
146
146
|
"clear_persist_done": "Persistenter GA-Cache gelöscht.",
|
|
147
|
-
"clear_persist_fail": "Persistenter GA-Cache konnte nicht gelöscht werden"
|
|
147
|
+
"clear_persist_fail": "Persistenter GA-Cache konnte nicht gelöscht werden",
|
|
148
|
+
"reveal_keyring_label": "Keyring-Passwörter im Klartext anzeigen (KNX Secure)",
|
|
149
|
+
"reveal_keyring_button": "Anzeigen",
|
|
150
|
+
"reveal_keyring_loading": "Keyring wird dekodiert, bitte warten...",
|
|
151
|
+
"reveal_keyring_fail": "Keyring konnte nicht dekodiert werden. Prüfen Sie die Keyring-Datei und das allgemeine Passwort."
|
|
148
152
|
}
|
|
149
153
|
}
|
|
150
154
|
}
|
|
@@ -1,8 +1,29 @@
|
|
|
1
1
|
<script type="text/markdown" data-help-name="knxUltimateIoTBridge">
|
|
2
|
-
#
|
|
2
|
+
# MQTT Home Assistant - IoT
|
|
3
3
|
|
|
4
4
|
Erstellen Sie bidirektionale Brücken zwischen KNX-Gruppenadressen und IoT-Kanälen (MQTT-Themen, REST-Endpunkte, Modbus-Register). Jede Zuordnung kann Werte skalieren, Payloads formatieren und Metadaten für nachgelagerte Nodes bereitstellen.
|
|
5
5
|
|
|
6
|
+
> **Zwei Modi.** Verwenden Sie den **Modus**-Schalter oben im Node:
|
|
7
|
+
> - **IoT-Bridge** (Standard) — das unten beschriebene klassische Verhalten (Zuordnungsliste, MQTT/REST/Modbus-Ausgabemeldungen).
|
|
8
|
+
> - **MQTT / Home Assistant (nativ)** — der Node verbindet sich direkt mit einem MQTT-Broker und überbrückt KNX ↔ MQTT in beide Richtungen, mit Home-Assistant-MQTT-Discovery, sodass Entitäten automatisch erscheinen.
|
|
9
|
+
|
|
10
|
+
## Modus MQTT / Home Assistant
|
|
11
|
+
|
|
12
|
+
Eine native MQTT-Bridge mit Home-Assistant-Discovery. Benötigt einen MQTT-Broker, der sowohl von Node-RED als auch von Home Assistant erreichbar ist, mit aktivierter MQTT-Integration in HA. Alle Entitäten erscheinen unter einem einzigen Gerät mit dem Namen dieses Nodes.
|
|
13
|
+
|
|
14
|
+
- **Broker-URL / Benutzername / Passwort** — Verbindung zu Ihrem MQTT-Broker.
|
|
15
|
+
- **Basis-Topic / Discovery-Präfix** — Root-Topic für Status-/Befehls-Topics und das HA-Discovery-Präfix (Standard `homeassistant`).
|
|
16
|
+
- **Bereitzustellende Gruppenadressen** — jede im KNX-Gateway importierte Adresse (ETS-Liste) wird mit einem Kontrollkästchen angezeigt. Angehakte Adressen werden als Home-Assistant-Entitäten veröffentlicht, automatisch nach dem DPT typisiert (switch, sensor, binary_sensor, number, text). Verwenden Sie das Filterfeld und die Schaltflächen *Alle auswählen* / *Keine auswählen*. Standardmäßig sind alle Adressen ausgewählt.
|
|
17
|
+
- **Rollläden & Thermostate** — fassen mehrere Adressen zu einer Entität zusammen:
|
|
18
|
+
- *Rollladen*: Auf/Ab-GA (1.008), optionale Stopp-GA (1.007), optionale Positions-GA Befehl/Status (5.001). *Position invertieren* bildet die KNX-Konvention (0% = offen) auf Home Assistant (100% = offen) ab.
|
|
19
|
+
- *Thermostat*: GA Ist-Temperatur (9.001), GA Sollwert Befehl/Status (9.001), optionale Ein/Aus-GA (1.001 → off/heat), plus Min-/Max-Temperatur und Schrittweite.
|
|
20
|
+
|
|
21
|
+
KNX-Buswerte werden an MQTT veröffentlicht und beschreibbare Datenpunkte akzeptieren Befehle von Home Assistant. Die Datenpunkttypen stammen aus der importierten ETS-Liste, sofern vorhanden, andernfalls aus KNX-Standardwerten (1.008 Auf/Ab, 1.007 Stopp, 5.001 Position, 9.001 Temperatur, 1.001 Ein/Aus); für zuverlässige Statuswerte sollten die von Rollläden/Thermostaten verwendeten Adressen im ETS-Import enthalten sein.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
Der Rest dieser Hilfe beschreibt den klassischen **IoT-Bridge**-Modus.
|
|
26
|
+
|
|
6
27
|
## Eingänge
|
|
7
28
|
|
|
8
29
|
|Eigenschaft|Beschreibung|
|
|
@@ -1,13 +1,55 @@
|
|
|
1
1
|
{
|
|
2
2
|
"knxUltimateIoTBridge": {
|
|
3
|
-
"title": "
|
|
4
|
-
"paletteLabel": "IoT
|
|
3
|
+
"title": "MQTT Home Assistant - IoT",
|
|
4
|
+
"paletteLabel": "MQTT Home Assistant - IoT",
|
|
5
5
|
"node-input-server": "KNX-Gateway",
|
|
6
6
|
"node-input-name": "Name",
|
|
7
7
|
"node-input-outputtopic": "Standard-Topic für Ausgaben",
|
|
8
8
|
"node-input-emitOnChangeOnly": "KNX→IoT nur bei Wertänderung senden",
|
|
9
9
|
"node-input-readOnDeploy": "KNX-Werte beim Deploy lesen",
|
|
10
10
|
"node-input-acceptFlowInput": "Flow-Eingaben akzeptieren (IoT → KNX)",
|
|
11
|
+
"node-input-nodeMode": "Modus",
|
|
12
|
+
"mode": {
|
|
13
|
+
"iot": "IoT-Bridge (MQTT/REST/Modbus-Nachrichten)",
|
|
14
|
+
"homeassistant": "MQTT / Home Assistant (nativ)"
|
|
15
|
+
},
|
|
16
|
+
"ha": {
|
|
17
|
+
"intro": "Native MQTT-Bridge mit Home-Assistant-Discovery. Jede im KNX-Gateway importierte Gruppenadresse (ETS-Liste) wird automatisch als Home-Assistant-Entität (switch, sensor, binary_sensor, number, text) bereitgestellt, zusätzlich zu den unten definierten Rollläden und Thermostaten.",
|
|
18
|
+
"broker_url": "Broker-URL",
|
|
19
|
+
"username": "Benutzername",
|
|
20
|
+
"password": "Passwort",
|
|
21
|
+
"optional": "(optional)",
|
|
22
|
+
"base_topic": "Basis-Topic",
|
|
23
|
+
"discovery": "Home-Assistant-Discovery veröffentlichen",
|
|
24
|
+
"discovery_prefix": "Discovery-Präfix",
|
|
25
|
+
"hint": "Erfordert einen MQTT-Broker, der sowohl von Node-RED als auch von Home Assistant erreichbar ist, mit aktivierter MQTT-Integration in HA. Die Entitäten erscheinen unter einem Gerät mit dem Namen dieses Knotens. KNX-Buswerte werden an MQTT veröffentlicht und beschreibbare Datenpunkte akzeptieren Befehle von Home Assistant.",
|
|
26
|
+
"exposed_gas": "Bereitzustellende Gruppenadressen",
|
|
27
|
+
"exposed_gas_hint": "Wähle die Gruppenadressen aus, die an Home Assistant veröffentlicht werden sollen. Standardmäßig sind alle importierten Adressen ausgewählt. Von einem Rollladen/Thermostat (unten) verwendete Adressen werden dort behandelt und müssen hier nicht ausgewählt werden.",
|
|
28
|
+
"filter_placeholder": "Nach Adresse oder Name filtern…",
|
|
29
|
+
"select_all": "Alle auswählen",
|
|
30
|
+
"select_none": "Keine auswählen",
|
|
31
|
+
"exposed_count": "ausgewählt",
|
|
32
|
+
"no_gateway": "Wähle zuerst ein KNX-Gateway aus.",
|
|
33
|
+
"no_ga": "Keine Gruppenadressen gefunden. Importiere die ETS-Liste im KNX-Gateway.",
|
|
34
|
+
"csv_error": "Die Gruppenadressliste konnte nicht vom Gateway geladen werden.",
|
|
35
|
+
"custom_entities": "Rollläden & Thermostate",
|
|
36
|
+
"custom_entities_hint": "Rollläden und Thermostate fassen mehrere Gruppenadressen zu einer Home-Assistant-Entität zusammen und können daher nicht automatisch aus dem DPT erstellt werden. Füge sie hier hinzu. Die Datenpunkttypen stammen aus der importierten ETS-Liste, sofern vorhanden, andernfalls aus KNX-Standardwerten (1.008 Auf/Ab, 1.007 Stopp, 5.001 Position, 9.001 Temperatur, 1.001 Ein/Aus). Für zuverlässige Statuswerte sollten diese GAs im ETS-Import enthalten sein.",
|
|
37
|
+
"ce_type_cover": "Rollladen",
|
|
38
|
+
"ce_type_climate": "Thermostat",
|
|
39
|
+
"ce_name": "Entitätsname",
|
|
40
|
+
"ce_gaUpDown": "Auf/Ab-GA (1.008)",
|
|
41
|
+
"ce_gaStop": "Stopp-GA (1.007)",
|
|
42
|
+
"ce_gaPosSet": "Position setzen GA (5.001)",
|
|
43
|
+
"ce_gaPosState": "Positionsstatus-GA (5.001)",
|
|
44
|
+
"ce_invertPosition": "Position invertieren (KNX 0% = offen)",
|
|
45
|
+
"ce_gaCurrentTemp": "Ist-Temperatur-GA (9.001)",
|
|
46
|
+
"ce_gaSetpointSet": "Sollwert setzen GA (9.001)",
|
|
47
|
+
"ce_gaSetpointState": "Sollwert-Status-GA (9.001)",
|
|
48
|
+
"ce_gaOnOff": "Ein/Aus-GA (1.001)",
|
|
49
|
+
"ce_minTemp": "Min. Temperatur",
|
|
50
|
+
"ce_maxTemp": "Max. Temperatur",
|
|
51
|
+
"ce_tempStep": "Temperaturschritt"
|
|
52
|
+
},
|
|
11
53
|
"section_mappings": "Bridge-Zuordnungen",
|
|
12
54
|
"mapping": {
|
|
13
55
|
"enabled": "Aktivieren",
|