node-red-contrib-knx-ultimate 5.0.0 → 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.
@@ -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
+ }