node-red-contrib-knx-ultimate 6.2.2 → 6.2.3-beta.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 +14 -0
- package/nodes/knxUltimateAI.js +7 -1
- package/nodes/knxUltimateHueAreaMotion.html +16 -4
- package/nodes/knxUltimateHueBattery.html +16 -4
- package/nodes/knxUltimateHueButton.html +16 -4
- package/nodes/knxUltimateHueCameraMotion.html +16 -4
- package/nodes/knxUltimateHueContactSensor.html +16 -4
- package/nodes/knxUltimateHueController.html +1259 -0
- package/nodes/knxUltimateHueController.js +28 -0
- package/nodes/knxUltimateHueHumiditySensor.html +16 -4
- package/nodes/knxUltimateHueLight.html +16 -4
- package/nodes/knxUltimateHueLightSensor.html +16 -4
- package/nodes/knxUltimateHueMotion.html +16 -4
- package/nodes/knxUltimateHuePlug.html +16 -4
- package/nodes/knxUltimateHueScene.html +19 -7
- package/nodes/knxUltimateHueTapDial.html +16 -4
- package/nodes/knxUltimateHueTemperatureSensor.html +16 -4
- package/nodes/knxUltimateHueZigbeeConnectivity.html +16 -4
- package/nodes/knxUltimateHuedevice_software_update.html +16 -4
- package/nodes/locales/de/knxUltimateHueAreaMotion.html +1 -1
- package/nodes/locales/de/knxUltimateHueBattery.html +1 -0
- package/nodes/locales/de/knxUltimateHueButton.html +1 -0
- package/nodes/locales/de/knxUltimateHueCameraMotion.html +1 -0
- package/nodes/locales/de/knxUltimateHueContactSensor.html +1 -0
- package/nodes/locales/de/knxUltimateHueController.html +668 -0
- package/nodes/locales/de/knxUltimateHueController.json +47 -0
- package/nodes/locales/de/knxUltimateHueHumiditySensor.html +1 -0
- package/nodes/locales/de/knxUltimateHueLight.html +1 -0
- package/nodes/locales/de/knxUltimateHueLightSensor.html +1 -0
- package/nodes/locales/de/knxUltimateHueMotion.html +1 -0
- package/nodes/locales/de/knxUltimateHuePlug.html +1 -0
- package/nodes/locales/de/knxUltimateHueScene.html +1 -0
- package/nodes/locales/de/knxUltimateHueTapDial.html +1 -0
- package/nodes/locales/de/knxUltimateHueTemperatureSensor.html +1 -0
- package/nodes/locales/de/knxUltimateHueZigbeeConnectivity.html +1 -0
- package/nodes/locales/de/knxUltimateHuedevice_software_update.html +1 -0
- package/nodes/locales/en/knxUltimateHueAreaMotion.html +1 -1
- package/nodes/locales/en/knxUltimateHueBattery.html +1 -0
- package/nodes/locales/en/knxUltimateHueButton.html +1 -0
- package/nodes/locales/en/knxUltimateHueCameraMotion.html +1 -0
- package/nodes/locales/en/knxUltimateHueContactSensor.html +1 -0
- package/nodes/locales/en/knxUltimateHueController.html +671 -0
- package/nodes/locales/en/knxUltimateHueController.json +47 -0
- package/nodes/locales/en/knxUltimateHueHumiditySensor.html +1 -0
- package/nodes/locales/en/knxUltimateHueLight.html +1 -0
- package/nodes/locales/en/knxUltimateHueLightSensor.html +1 -0
- package/nodes/locales/en/knxUltimateHueMotion.html +1 -0
- package/nodes/locales/en/knxUltimateHuePlug.html +1 -0
- package/nodes/locales/en/knxUltimateHueScene.html +1 -0
- package/nodes/locales/en/knxUltimateHueTapDial.html +1 -0
- package/nodes/locales/en/knxUltimateHueTemperatureSensor.html +1 -0
- package/nodes/locales/en/knxUltimateHueZigbeeConnectivity.html +1 -0
- package/nodes/locales/en/knxUltimateHuedevice_software_update.html +1 -0
- package/nodes/locales/es/knxUltimateHueAreaMotion.html +1 -1
- package/nodes/locales/es/knxUltimateHueBattery.html +1 -0
- package/nodes/locales/es/knxUltimateHueButton.html +1 -0
- package/nodes/locales/es/knxUltimateHueCameraMotion.html +1 -0
- package/nodes/locales/es/knxUltimateHueContactSensor.html +1 -0
- package/nodes/locales/es/knxUltimateHueController.html +671 -0
- package/nodes/locales/es/knxUltimateHueController.json +47 -0
- package/nodes/locales/es/knxUltimateHueHumiditySensor.html +1 -0
- package/nodes/locales/es/knxUltimateHueLight.html +1 -0
- package/nodes/locales/es/knxUltimateHueLightSensor.html +1 -0
- package/nodes/locales/es/knxUltimateHueMotion.html +1 -0
- package/nodes/locales/es/knxUltimateHuePlug.html +1 -0
- package/nodes/locales/es/knxUltimateHueScene.html +1 -0
- package/nodes/locales/es/knxUltimateHueTapDial.html +1 -0
- package/nodes/locales/es/knxUltimateHueTemperatureSensor.html +1 -0
- package/nodes/locales/es/knxUltimateHueZigbeeConnectivity.html +1 -0
- package/nodes/locales/es/knxUltimateHuedevice_software_update.html +1 -0
- package/nodes/locales/fr/knxUltimateHueAreaMotion.html +1 -1
- package/nodes/locales/fr/knxUltimateHueBattery.html +1 -0
- package/nodes/locales/fr/knxUltimateHueButton.html +1 -0
- package/nodes/locales/fr/knxUltimateHueCameraMotion.html +1 -0
- package/nodes/locales/fr/knxUltimateHueContactSensor.html +1 -0
- package/nodes/locales/fr/knxUltimateHueController.html +671 -0
- package/nodes/locales/fr/knxUltimateHueController.json +47 -0
- package/nodes/locales/fr/knxUltimateHueHumiditySensor.html +1 -0
- package/nodes/locales/fr/knxUltimateHueLight.html +1 -0
- package/nodes/locales/fr/knxUltimateHueLightSensor.html +1 -0
- package/nodes/locales/fr/knxUltimateHueMotion.html +1 -0
- package/nodes/locales/fr/knxUltimateHuePlug.html +1 -0
- package/nodes/locales/fr/knxUltimateHueScene.html +1 -0
- package/nodes/locales/fr/knxUltimateHueTapDial.html +1 -0
- package/nodes/locales/fr/knxUltimateHueTemperatureSensor.html +1 -0
- package/nodes/locales/fr/knxUltimateHueZigbeeConnectivity.html +1 -0
- package/nodes/locales/fr/knxUltimateHuedevice_software_update.html +1 -0
- package/nodes/locales/it/knxUltimateHueAreaMotion.html +1 -1
- package/nodes/locales/it/knxUltimateHueBattery.html +1 -0
- package/nodes/locales/it/knxUltimateHueButton.html +1 -0
- package/nodes/locales/it/knxUltimateHueCameraMotion.html +1 -0
- package/nodes/locales/it/knxUltimateHueContactSensor.html +1 -0
- package/nodes/locales/it/knxUltimateHueController.html +671 -0
- package/nodes/locales/it/knxUltimateHueController.json +47 -0
- package/nodes/locales/it/knxUltimateHueHumiditySensor.html +1 -0
- package/nodes/locales/it/knxUltimateHueLight.html +1 -0
- package/nodes/locales/it/knxUltimateHueLightSensor.html +1 -0
- package/nodes/locales/it/knxUltimateHueMotion.html +1 -0
- package/nodes/locales/it/knxUltimateHuePlug.html +1 -0
- package/nodes/locales/it/knxUltimateHueScene.html +1 -0
- package/nodes/locales/it/knxUltimateHueTapDial.html +1 -0
- package/nodes/locales/it/knxUltimateHueTemperatureSensor.html +1 -0
- package/nodes/locales/it/knxUltimateHueZigbeeConnectivity.html +1 -0
- package/nodes/locales/it/knxUltimateHuedevice_software_update.html +1 -0
- package/nodes/locales/zh-CN/knxUltimateHueAreaMotion.html +1 -1
- package/nodes/locales/zh-CN/knxUltimateHueBattery.html +1 -0
- package/nodes/locales/zh-CN/knxUltimateHueButton.html +1 -0
- package/nodes/locales/zh-CN/knxUltimateHueCameraMotion.html +1 -0
- package/nodes/locales/zh-CN/knxUltimateHueContactSensor.html +1 -0
- package/nodes/locales/zh-CN/knxUltimateHueController.html +673 -0
- package/nodes/locales/zh-CN/knxUltimateHueController.json +47 -0
- package/nodes/locales/zh-CN/knxUltimateHueHumiditySensor.html +1 -0
- package/nodes/locales/zh-CN/knxUltimateHueLight.html +1 -0
- package/nodes/locales/zh-CN/knxUltimateHueLightSensor.html +1 -0
- package/nodes/locales/zh-CN/knxUltimateHueMotion.html +1 -0
- package/nodes/locales/zh-CN/knxUltimateHuePlug.html +1 -0
- package/nodes/locales/zh-CN/knxUltimateHueScene.html +1 -0
- package/nodes/locales/zh-CN/knxUltimateHueTapDial.html +1 -0
- package/nodes/locales/zh-CN/knxUltimateHueTemperatureSensor.html +1 -0
- package/nodes/locales/zh-CN/knxUltimateHueZigbeeConnectivity.html +1 -0
- package/nodes/locales/zh-CN/knxUltimateHuedevice_software_update.html +1 -0
- package/nodes/utils/hueControllerProfileAdapter.js +104 -0
- package/nodes/utils/hueControllerProfiles/runtime/area_motion.js +221 -0
- package/nodes/utils/hueControllerProfiles/runtime/button.js +295 -0
- package/nodes/utils/hueControllerProfiles/runtime/camera_motion.js +221 -0
- package/nodes/utils/hueControllerProfiles/runtime/contact.js +191 -0
- package/nodes/utils/hueControllerProfiles/runtime/device_power.js +185 -0
- package/nodes/utils/hueControllerProfiles/runtime/device_software_update.js +185 -0
- package/nodes/utils/hueControllerProfiles/runtime/humidity.js +204 -0
- package/nodes/utils/hueControllerProfiles/runtime/light.js +1583 -0
- package/nodes/utils/hueControllerProfiles/runtime/light_level.js +180 -0
- package/nodes/utils/hueControllerProfiles/runtime/motion.js +185 -0
- package/nodes/utils/hueControllerProfiles/runtime/plug.js +344 -0
- package/nodes/utils/hueControllerProfiles/runtime/relative_rotary.js +271 -0
- package/nodes/utils/hueControllerProfiles/runtime/scene.js +352 -0
- package/nodes/utils/hueControllerProfiles/runtime/temperature.js +187 -0
- package/nodes/utils/hueControllerProfiles/runtime/zigbee_connectivity.js +185 -0
- package/package.json +5 -2
- package/resources/hueControllerMigration.js +338 -0
- package/resources/hueControllerMigrationDialog.js +235 -0
- package/resources/hueControllerProfiles.js +9290 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// Canonical private runtime profile for HUE Controller: zigbee_connectivity.
|
|
2
|
+
// This module is shipped with the Controller but is intentionally absent from
|
|
3
|
+
// package.json, so Node-RED never registers it as a separate palette node.
|
|
4
|
+
module.exports = function (RED) {
|
|
5
|
+
function knxUltimateHueZigbeeConnectivity (config) {
|
|
6
|
+
RED.nodes.createNode(this, config)
|
|
7
|
+
const node = this
|
|
8
|
+
node.serverKNX = RED.nodes.getNode(config.server) || undefined
|
|
9
|
+
node.serverHue = RED.nodes.getNode(config.serverHue) || undefined
|
|
10
|
+
node.topic = node.name
|
|
11
|
+
node.name = config.name === undefined ? 'Hue' : config.name
|
|
12
|
+
node.dpt = ''
|
|
13
|
+
node.notifyreadrequest = true
|
|
14
|
+
node.notifyreadrequestalsorespondtobus = 'false'
|
|
15
|
+
node.notifyreadrequestalsorespondtobusdefaultvalueifnotinitialized = ''
|
|
16
|
+
node.notifyresponse = false
|
|
17
|
+
node.notifywrite = true
|
|
18
|
+
node.initialread = true
|
|
19
|
+
node.listenallga = true // Don't remove
|
|
20
|
+
node.outputtype = 'write'
|
|
21
|
+
node.outputRBE = 'false' // Apply or not RBE to the output (Messages coming from flow)
|
|
22
|
+
node.inputRBE = 'false' // Apply or not RBE to the input (Messages coming from BUS)
|
|
23
|
+
node.currentPayload = '' // Current value for the RBE input and for the .previouspayload msg
|
|
24
|
+
node.passthrough = 'no'
|
|
25
|
+
node.formatmultiplyvalue = 1
|
|
26
|
+
node.formatnegativevalue = 'leave'
|
|
27
|
+
node.formatdecimalsvalue = 2
|
|
28
|
+
node.hueDevice = config.hueDevice
|
|
29
|
+
node.initializingAtStart = (config.readStatusAtStartup === undefined || config.readStatusAtStartup === 'yes')
|
|
30
|
+
node.currentDeviceValue = 0
|
|
31
|
+
const pinsSetting = (config.enableNodePINS === undefined || config.enableNodePINS === 'yes' || config.enableNodePINS === true)
|
|
32
|
+
node.enableNodePINS = pinsSetting ? 'yes' : 'no'
|
|
33
|
+
node.outputs = pinsSetting ? 1 : 0
|
|
34
|
+
if (!node.serverKNX && node.outputs === 0) {
|
|
35
|
+
node.enableNodePINS = 'yes'
|
|
36
|
+
node.outputs = 1
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const pushStatus = (status) => {
|
|
40
|
+
if (!status) return
|
|
41
|
+
const provider = node.serverKNX
|
|
42
|
+
if (provider && typeof provider.applyStatusUpdate === 'function') {
|
|
43
|
+
provider.applyStatusUpdate(node, status)
|
|
44
|
+
} else {
|
|
45
|
+
node.status(status)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const updateStatus = (status) => {
|
|
50
|
+
if (!status) return
|
|
51
|
+
pushStatus(status)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const formatTs = (date) => {
|
|
55
|
+
const d = date instanceof Date ? date : new Date(date)
|
|
56
|
+
const provider = node.serverKNX
|
|
57
|
+
if (provider && typeof provider.formatStatusTimestamp === 'function') return provider.formatStatusTimestamp(d)
|
|
58
|
+
return `${d.getDate()}, ${d.toLocaleTimeString()}`
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const safeSendToKNX = (telegram, context = 'write') => {
|
|
62
|
+
try {
|
|
63
|
+
if (!node.serverKNX || typeof node.serverKNX.sendKNXTelegramToKNXEngine !== 'function') {
|
|
64
|
+
const now = new Date()
|
|
65
|
+
updateStatus({ fill: 'red', shape: 'dot', text: `KNX server missing (${context}) (${formatTs(now)})` })
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
node.serverKNX.sendKNXTelegramToKNXEngine({ ...telegram, nodecallerid: node.id })
|
|
69
|
+
} catch (error) {
|
|
70
|
+
updateStatus({ fill: 'red', shape: 'dot', text: `KNX send error ${error.message}` })
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Used to call the status update from the config node.
|
|
75
|
+
node.setNodeStatus = ({
|
|
76
|
+
fill, shape, text, payload
|
|
77
|
+
}) => {
|
|
78
|
+
try {
|
|
79
|
+
if (payload === undefined) payload = ''
|
|
80
|
+
const dDate = new Date()
|
|
81
|
+
payload = typeof payload === 'object' ? JSON.stringify(payload) : payload.toString()
|
|
82
|
+
node.sKNXNodeStatusText = `|KNX: ${text} ${payload} (${formatTs(dDate)})`
|
|
83
|
+
pushStatus({ fill, shape, text: (node.sHUENodeStatusText || '') + ' ' + (node.sKNXNodeStatusText || '') })
|
|
84
|
+
} catch (error) { }
|
|
85
|
+
}
|
|
86
|
+
// Used to call the status update from the HUE config node.
|
|
87
|
+
node.setNodeStatusHue = ({ fill, shape, text, payload }) => {
|
|
88
|
+
try {
|
|
89
|
+
if (payload === undefined) payload = ''
|
|
90
|
+
const dDate = new Date()
|
|
91
|
+
payload = typeof payload === 'object' ? JSON.stringify(payload) : payload.toString()
|
|
92
|
+
node.sHUENodeStatusText = `|HUE: ${text} ${payload} (${formatTs(dDate)})`
|
|
93
|
+
pushStatus({ fill, shape, text: node.sHUENodeStatusText + ' ' + (node.sKNXNodeStatusText || '') })
|
|
94
|
+
} catch (error) { }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// This function is called by the knx-ultimate config node, to output a msg.payload.
|
|
98
|
+
node.handleSend = (msg) => {
|
|
99
|
+
// Respond to KNX read telegram, by sending the current value as response telegram.
|
|
100
|
+
if (msg.knx.event === 'GroupValue_Read') {
|
|
101
|
+
switch (msg.knx.destination) {
|
|
102
|
+
case config.GAzigbeeconnectivity:
|
|
103
|
+
// To the KNX bus wires
|
|
104
|
+
node.sendResponseToKNX(node.currentDeviceValue)
|
|
105
|
+
break
|
|
106
|
+
default:
|
|
107
|
+
break
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
node.handleSendHUE = (_event) => {
|
|
113
|
+
try {
|
|
114
|
+
// Event status: one of connected, disconnected, connectivity_issue, unidirectional_incoming
|
|
115
|
+
if (_event.id === config.hueDevice) {
|
|
116
|
+
if (!_event.hasOwnProperty('status') || _event.status === undefined) return
|
|
117
|
+
|
|
118
|
+
const knxMsgPayload = {}
|
|
119
|
+
knxMsgPayload.topic = config.GAzigbeeconnectivity
|
|
120
|
+
knxMsgPayload.dpt = config.dptzigbeeconnectivity
|
|
121
|
+
|
|
122
|
+
knxMsgPayload.payload = (_event.status === 'connected')
|
|
123
|
+
// Send to KNX bus
|
|
124
|
+
if (knxMsgPayload.topic !== '' && knxMsgPayload.topic !== undefined) {
|
|
125
|
+
safeSendToKNX({
|
|
126
|
+
grpaddr: knxMsgPayload.topic, payload: knxMsgPayload.payload, dpt: knxMsgPayload.dpt, outputtype: 'write'
|
|
127
|
+
}, 'write')
|
|
128
|
+
}
|
|
129
|
+
node.currentDeviceValue = knxMsgPayload.payload
|
|
130
|
+
// Setup the output msg
|
|
131
|
+
knxMsgPayload.name = node.name
|
|
132
|
+
knxMsgPayload.status = _event.status
|
|
133
|
+
knxMsgPayload.event = 'zigbee_connectivity'
|
|
134
|
+
// Send payload
|
|
135
|
+
knxMsgPayload.rawEvent = _event
|
|
136
|
+
node.send(knxMsgPayload)
|
|
137
|
+
node.setNodeStatusHue({
|
|
138
|
+
fill: 'blue', shape: 'ring', text: 'HUE->KNX', payload: knxMsgPayload.payload
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
} catch (error) {
|
|
142
|
+
updateStatus({ fill: 'red', shape: 'dot', text: `HUE->KNX error ${error.message} (${formatTs(new Date())})` })
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
node.sendResponseToKNX = (_level) => {
|
|
147
|
+
const knxMsgPayload = {}
|
|
148
|
+
knxMsgPayload.topic = config.GAzigbeeconnectivity
|
|
149
|
+
knxMsgPayload.dpt = config.dptzigbeeconnectivity
|
|
150
|
+
|
|
151
|
+
knxMsgPayload.payload = _level
|
|
152
|
+
// Send to KNX bus
|
|
153
|
+
if (knxMsgPayload.topic !== '' && knxMsgPayload.topic !== undefined) {
|
|
154
|
+
safeSendToKNX({
|
|
155
|
+
grpaddr: knxMsgPayload.topic, payload: knxMsgPayload.payload, dpt: knxMsgPayload.dpt, outputtype: 'response'
|
|
156
|
+
}, 'response')
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// On each deploy, unsubscribe+resubscribe
|
|
161
|
+
if (node.serverKNX) {
|
|
162
|
+
node.serverKNX.removeClient(node)
|
|
163
|
+
node.serverKNX.addClient(node)
|
|
164
|
+
}
|
|
165
|
+
if (node.serverHue) {
|
|
166
|
+
node.serverHue.removeClient(node)
|
|
167
|
+
node.serverHue.addClient(node)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
node.on('input', (msg) => {
|
|
171
|
+
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
node.on('close', (done) => {
|
|
175
|
+
if (node.serverKNX) {
|
|
176
|
+
node.serverKNX.removeClient(node)
|
|
177
|
+
}
|
|
178
|
+
if (node.serverHue) {
|
|
179
|
+
node.serverHue.removeClient(node)
|
|
180
|
+
}
|
|
181
|
+
done()
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
RED.nodes.registerType('knxUltimateHueZigbeeConnectivity', knxUltimateHueZigbeeConnectivity)
|
|
185
|
+
}
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"engines": {
|
|
4
4
|
"node": ">=20.18.1"
|
|
5
5
|
},
|
|
6
|
-
"version": "6.2.
|
|
6
|
+
"version": "6.2.3-beta.1",
|
|
7
7
|
"description": "KNX Ultimate is the most advanced KNX integration for Node-RED, providing secure KNX/IP communication, routing, ETS project import, Philips Hue, Matter Controller and Matter Bridge (control matter device via KNX and expose KNX GA via Matter), MQTT, diagnostics with AI, virtual devices, and powerful automation nodes. Build professional, reliable, and scalable smart home and building automation projects with minimal effort.",
|
|
8
8
|
"files": [
|
|
9
9
|
"nodes/",
|
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
"knxUltimateWatchDog": "/nodes/knxUltimateWatchDog.js",
|
|
54
54
|
"knxUltimate-config": "/nodes/knxUltimate-config.js",
|
|
55
55
|
"hueConfig": "/nodes/hue-config.js",
|
|
56
|
+
"knxUltimateHueController": "/nodes/knxUltimateHueController.js",
|
|
56
57
|
"knxUltimateHueLight": "/nodes/knxUltimateHueLight.js",
|
|
57
58
|
"knxUltimateMatterControllerDevice": "/nodes/knxUltimateMatterControllerDevice.js",
|
|
58
59
|
"knxUltimateHueButton": "/nodes/knxUltimateHueButton.js",
|
|
@@ -108,8 +109,10 @@
|
|
|
108
109
|
"build": "npm run knx-ai:build && npm run knx-viewer:build",
|
|
109
110
|
"prepack": "npm run knx-ai:build && npm run knx-viewer:build",
|
|
110
111
|
"prepublishOnly": "npm run build",
|
|
111
|
-
"test": "DEBUG_KNX_HUE_TEST=0 npm run test:unit && node scripts/check-node-load.js",
|
|
112
|
+
"test": "npm run hue-controller:check && DEBUG_KNX_HUE_TEST=0 npm run test:unit && node scripts/check-node-load.js",
|
|
112
113
|
"test:unit": "mocha \"test/**/*.test.js\"",
|
|
114
|
+
"hue-controller:generate": "node scripts/generate-hue-controller-profiles.js",
|
|
115
|
+
"hue-controller:check": "node scripts/generate-hue-controller-profiles.js --check",
|
|
113
116
|
"avviaquesto-itest:light": "node test/integration/hueLiveTest.mjs",
|
|
114
117
|
"avviaquesto-itest:matterlight": "node test/integration/matterLiveTest.mjs",
|
|
115
118
|
"avviaquesto-itest:nodered-knx": "node test/integration/nodeRedLiveTest.mjs",
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
(function (root, factory) {
|
|
2
|
+
const api = factory()
|
|
3
|
+
if (typeof module === 'object' && module.exports) module.exports = api
|
|
4
|
+
if (root) root.KNXUltimateHueControllerMigration = api
|
|
5
|
+
}(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
|
6
|
+
'use strict'
|
|
7
|
+
|
|
8
|
+
const LEGACY_NODE_PROFILES = Object.freeze({
|
|
9
|
+
knxUltimateHueLight: { controllerType: 'light', pinMode: 'dual', pinsDefault: false },
|
|
10
|
+
knxUltimateHuePlug: { controllerType: 'plug', pinMode: 'dual', pinsDefault: false },
|
|
11
|
+
knxUltimateHueButton: { controllerType: 'button', pinMode: 'output', pinsDefault: true },
|
|
12
|
+
knxUltimateHueTapDial: { controllerType: 'relative_rotary', pinMode: 'output', pinsDefault: true },
|
|
13
|
+
knxUltimateHueMotion: { controllerType: 'motion', pinMode: 'output', pinsDefault: true },
|
|
14
|
+
knxUltimateHueAreaMotion: { controllerType: 'area_motion', pinMode: 'output', pinsDefault: true },
|
|
15
|
+
knxUltimateHueCameraMotion: { controllerType: 'camera_motion', pinMode: 'output', pinsDefault: true },
|
|
16
|
+
knxUltimateHueContactSensor: { controllerType: 'contact', pinMode: 'output', pinsDefault: true },
|
|
17
|
+
knxUltimateHueLightSensor: { controllerType: 'light_level', pinMode: 'output', pinsDefault: true },
|
|
18
|
+
knxUltimateHueTemperatureSensor: { controllerType: 'temperature', pinMode: 'output', pinsDefault: true },
|
|
19
|
+
knxUltimateHueHumiditySensor: { controllerType: 'humidity', pinMode: 'output', pinsDefault: true },
|
|
20
|
+
knxUltimateHueScene: { controllerType: 'scene', pinMode: 'dual', pinsDefault: false },
|
|
21
|
+
knxUltimateHueBattery: { controllerType: 'device_power', pinMode: 'output', pinsDefault: true },
|
|
22
|
+
knxUltimateHueZigbeeConnectivity: { controllerType: 'zigbee_connectivity', pinMode: 'output', pinsDefault: true },
|
|
23
|
+
knxUltimateHuedevice_software_update: { controllerType: 'device_software_update', pinMode: 'output', pinsDefault: true }
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
const CONFIG_NODE_REFERENCE_FIELDS = Object.freeze({
|
|
27
|
+
'knxUltimate-config': 'server',
|
|
28
|
+
'hue-config': 'serverHue'
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const MIGRATION_USAGE_EMAIL = 'maxsupergiovane@icloud.com'
|
|
32
|
+
const MIGRATION_DONATION_URL = 'https://www.paypal.com/donate/?hosted_button_id=S8SKPUBSPK758'
|
|
33
|
+
|
|
34
|
+
// Kept as a tiny public predicate so the editor can decide whether the
|
|
35
|
+
// migration entry point is relevant without duplicating the canonical type
|
|
36
|
+
// catalog. It only classifies an object; it never reads or changes a flow.
|
|
37
|
+
function isLegacyHueNode (node) {
|
|
38
|
+
return Boolean(node && typeof node === 'object' && LEGACY_NODE_PROFILES[node.type])
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function hasEnabledPins (node, profile) {
|
|
42
|
+
const rawValue = node.enableNodePINS
|
|
43
|
+
if (rawValue === true) return true
|
|
44
|
+
if (rawValue === false) return false
|
|
45
|
+
if (typeof rawValue === 'string' && rawValue.trim() !== '') {
|
|
46
|
+
return rawValue.trim().toLowerCase() === 'yes'
|
|
47
|
+
}
|
|
48
|
+
return profile.pinsDefault === true
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function readPinCount (value) {
|
|
52
|
+
if (value === undefined || value === null || value === '') return undefined
|
|
53
|
+
const numericValue = Number(value)
|
|
54
|
+
if (!Number.isInteger(numericValue) || numericValue < 0) return undefined
|
|
55
|
+
return numericValue
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function derivePinShape (node, profile) {
|
|
59
|
+
const enabled = hasEnabledPins(node, profile)
|
|
60
|
+
const defaults = profile.pinMode === 'dual'
|
|
61
|
+
? { inputs: enabled ? 1 : 0, outputs: enabled ? 1 : 0 }
|
|
62
|
+
: { inputs: 0, outputs: enabled ? 1 : 0 }
|
|
63
|
+
const exportedInputs = readPinCount(node.inputs)
|
|
64
|
+
const exportedOutputs = readPinCount(node.outputs)
|
|
65
|
+
const wiredOutputs = Array.isArray(node.wires) ? node.wires.length : undefined
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
inputs: exportedInputs === undefined ? defaults.inputs : exportedInputs,
|
|
69
|
+
outputs: exportedOutputs === undefined
|
|
70
|
+
? (wiredOutputs === undefined ? defaults.outputs : wiredOutputs)
|
|
71
|
+
: exportedOutputs
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function collectLegacyHueNodes (RED) {
|
|
76
|
+
const nodes = []
|
|
77
|
+
if (!RED || !RED.nodes || typeof RED.nodes.eachNode !== 'function') return nodes
|
|
78
|
+
RED.nodes.eachNode(function (node) {
|
|
79
|
+
if (isLegacyHueNode(node)) nodes.push(node)
|
|
80
|
+
})
|
|
81
|
+
return nodes
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function createLocalMigrationPatches (legacyNodes) {
|
|
85
|
+
if (!Array.isArray(legacyNodes)) throw new TypeError('Legacy HUE nodes must be an array')
|
|
86
|
+
return legacyNodes.map(function (node, index) {
|
|
87
|
+
if (!isLegacyHueNode(node)) throw new TypeError(`Entry ${index} is not a supported legacy HUE node`)
|
|
88
|
+
const profile = LEGACY_NODE_PROFILES[node.type]
|
|
89
|
+
const pinShape = derivePinShape(node, profile)
|
|
90
|
+
return {
|
|
91
|
+
index,
|
|
92
|
+
type: 'knxUltimateHueController',
|
|
93
|
+
hueControllerType: profile.controllerType,
|
|
94
|
+
inputs: pinShape.inputs,
|
|
95
|
+
outputs: pinShape.outputs
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function createUsageMailto (convertedCount, options = {}) {
|
|
101
|
+
if (!Number.isInteger(convertedCount) || convertedCount < 1) {
|
|
102
|
+
throw new TypeError('Converted HUE node count must be a positive integer')
|
|
103
|
+
}
|
|
104
|
+
const subject = String(options.subject || 'KNX Ultimate - legacy HUE conversion used')
|
|
105
|
+
const bodyTemplate = String(options.body || 'Hello Massimo,\n\nI used the legacy HUE node conversion button.\nConverted legacy HUE nodes: {{count}}.\n\nOptional notes:\n')
|
|
106
|
+
const body = bodyTemplate.replace(/{{\s*count\s*}}/g, String(convertedCount))
|
|
107
|
+
return `mailto:${MIGRATION_USAGE_EMAIL}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function openUsageMailto (mailto, environment = {}) {
|
|
111
|
+
if (typeof mailto !== 'string' || !mailto.startsWith('mailto:')) {
|
|
112
|
+
throw new TypeError('A valid mailto link is required')
|
|
113
|
+
}
|
|
114
|
+
const documentObject = environment.document
|
|
115
|
+
if (!documentObject || !documentObject.body || typeof documentObject.createElement !== 'function') {
|
|
116
|
+
throw new Error('The browser cannot open the email draft')
|
|
117
|
+
}
|
|
118
|
+
const mailFrame = documentObject.createElement('iframe')
|
|
119
|
+
mailFrame.setAttribute('aria-hidden', 'true')
|
|
120
|
+
mailFrame.style.display = 'none'
|
|
121
|
+
mailFrame.src = mailto
|
|
122
|
+
documentObject.body.appendChild(mailFrame)
|
|
123
|
+
const removeMailFrame = () => {
|
|
124
|
+
try {
|
|
125
|
+
if (mailFrame.parentNode) mailFrame.parentNode.removeChild(mailFrame)
|
|
126
|
+
} catch (error) { /* best-effort cleanup */ }
|
|
127
|
+
}
|
|
128
|
+
if (typeof environment.setTimeout === 'function') environment.setTimeout(removeMailFrame, 1000)
|
|
129
|
+
return mailFrame
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function applyLocalMigration (RED, legacyNodes) {
|
|
133
|
+
if (!RED || !RED.nodes || typeof RED.nodes.getType !== 'function') throw new Error('The Node-RED editor API is unavailable')
|
|
134
|
+
const patches = createLocalMigrationPatches(legacyNodes)
|
|
135
|
+
const controllerDefinition = RED.nodes.getType('knxUltimateHueController')
|
|
136
|
+
if (!controllerDefinition) throw new Error('The HUE Controller node is not registered')
|
|
137
|
+
|
|
138
|
+
const previousDirty = typeof RED.nodes.dirty === 'function' ? RED.nodes.dirty() : false
|
|
139
|
+
const prepared = patches.map(function (patch, index) {
|
|
140
|
+
const node = legacyNodes[index]
|
|
141
|
+
if (!node || !isLegacyHueNode(node) || patch.index !== index) {
|
|
142
|
+
throw new Error('A legacy HUE node changed before the local conversion')
|
|
143
|
+
}
|
|
144
|
+
if (RED.workspaces && typeof RED.workspaces.isLocked === 'function' && RED.workspaces.isLocked(node.z)) {
|
|
145
|
+
throw new Error('A flow containing a legacy HUE node is locked')
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
node,
|
|
149
|
+
oldValues: {
|
|
150
|
+
type: node.type,
|
|
151
|
+
_def: node._def,
|
|
152
|
+
_: node._,
|
|
153
|
+
hueControllerType: node.hueControllerType,
|
|
154
|
+
inputs: node.inputs,
|
|
155
|
+
outputs: node.outputs
|
|
156
|
+
},
|
|
157
|
+
oldChanged: node.changed,
|
|
158
|
+
patch
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
const applied = []
|
|
163
|
+
try {
|
|
164
|
+
prepared.forEach(function (entry) {
|
|
165
|
+
const { node, patch } = entry
|
|
166
|
+
node.type = patch.type
|
|
167
|
+
node._def = controllerDefinition
|
|
168
|
+
node._ = controllerDefinition._ || RED._
|
|
169
|
+
node.hueControllerType = patch.hueControllerType
|
|
170
|
+
node.inputs = patch.inputs
|
|
171
|
+
node.outputs = patch.outputs
|
|
172
|
+
node.changed = true
|
|
173
|
+
node.dirty = true
|
|
174
|
+
if (RED.editor && typeof RED.editor.validateNode === 'function') RED.editor.validateNode(node)
|
|
175
|
+
if (RED.events && typeof RED.events.emit === 'function') RED.events.emit('nodes:change', node)
|
|
176
|
+
applied.push(entry)
|
|
177
|
+
})
|
|
178
|
+
} catch (error) {
|
|
179
|
+
applied.reverse().forEach(function (entry) {
|
|
180
|
+
Object.entries(entry.oldValues).forEach(([key, value]) => { entry.node[key] = value })
|
|
181
|
+
entry.node.changed = entry.oldChanged
|
|
182
|
+
entry.node.dirty = true
|
|
183
|
+
})
|
|
184
|
+
if (typeof RED.nodes.dirty === 'function') RED.nodes.dirty(previousDirty)
|
|
185
|
+
if (RED.view && typeof RED.view.redraw === 'function') RED.view.redraw(true)
|
|
186
|
+
throw error
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (RED.history && typeof RED.history.push === 'function' && prepared.length > 0) {
|
|
190
|
+
const events = prepared.map((entry) => ({
|
|
191
|
+
t: 'edit',
|
|
192
|
+
node: entry.node,
|
|
193
|
+
changes: entry.oldValues,
|
|
194
|
+
changed: entry.oldChanged,
|
|
195
|
+
dirty: previousDirty
|
|
196
|
+
}))
|
|
197
|
+
RED.history.push(events.length === 1 ? events[0] : { t: 'multi', events })
|
|
198
|
+
}
|
|
199
|
+
if (typeof RED.nodes.dirty === 'function') RED.nodes.dirty(true)
|
|
200
|
+
if (RED.view && typeof RED.view.redraw === 'function') RED.view.redraw(true)
|
|
201
|
+
return prepared.length
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function cloneFlowNode (node, index) {
|
|
205
|
+
if (!node || typeof node !== 'object' || Array.isArray(node)) {
|
|
206
|
+
throw new TypeError(`Flow entry ${index} is not a Node-RED node object`)
|
|
207
|
+
}
|
|
208
|
+
return JSON.parse(JSON.stringify(node))
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function copyTextWithTemporaryTextarea (text, documentObject) {
|
|
212
|
+
if (!documentObject || !documentObject.body || typeof documentObject.createElement !== 'function') {
|
|
213
|
+
throw new Error('Clipboard fallback is unavailable')
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const temporary = documentObject.createElement('textarea')
|
|
217
|
+
temporary.value = String(text)
|
|
218
|
+
temporary.readOnly = true
|
|
219
|
+
temporary.setAttribute('aria-hidden', 'true')
|
|
220
|
+
temporary.style.position = 'fixed'
|
|
221
|
+
temporary.style.left = '-9999px'
|
|
222
|
+
temporary.style.top = '0'
|
|
223
|
+
documentObject.body.appendChild(temporary)
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
temporary.focus()
|
|
227
|
+
temporary.select()
|
|
228
|
+
if (typeof temporary.setSelectionRange === 'function') {
|
|
229
|
+
temporary.setSelectionRange(0, temporary.value.length)
|
|
230
|
+
}
|
|
231
|
+
if (typeof documentObject.execCommand !== 'function' || documentObject.execCommand('copy') !== true) {
|
|
232
|
+
throw new Error('The browser refused clipboard access')
|
|
233
|
+
}
|
|
234
|
+
return true
|
|
235
|
+
} finally {
|
|
236
|
+
if (temporary.parentNode) temporary.parentNode.removeChild(temporary)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function copyTextToClipboard (text, environment = {}) {
|
|
241
|
+
const value = String(text)
|
|
242
|
+
const navigatorObject = environment.navigator
|
|
243
|
+
const documentObject = environment.document
|
|
244
|
+
const clipboard = navigatorObject && navigatorObject.clipboard
|
|
245
|
+
const fallback = () => copyTextWithTemporaryTextarea(value, documentObject)
|
|
246
|
+
|
|
247
|
+
if (clipboard && typeof clipboard.writeText === 'function') {
|
|
248
|
+
try {
|
|
249
|
+
return Promise.resolve(clipboard.writeText(value))
|
|
250
|
+
.then(() => true)
|
|
251
|
+
.catch(fallback)
|
|
252
|
+
} catch (error) {
|
|
253
|
+
return Promise.resolve().then(fallback)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return Promise.resolve().then(fallback)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function convertLegacyHueFlow (sourceFlow, options = {}) {
|
|
260
|
+
if (!Array.isArray(sourceFlow)) {
|
|
261
|
+
throw new TypeError('The pasted Node-RED flow must be a JSON array')
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
let convertedCount = 0
|
|
265
|
+
const convertedByType = {}
|
|
266
|
+
const referencedConfigIds = new Set()
|
|
267
|
+
const flow = sourceFlow.map(function (sourceNode, index) {
|
|
268
|
+
const node = cloneFlowNode(sourceNode, index)
|
|
269
|
+
const profile = LEGACY_NODE_PROFILES[node.type]
|
|
270
|
+
if (!profile) return node
|
|
271
|
+
|
|
272
|
+
const oldType = node.type
|
|
273
|
+
const pinShape = derivePinShape(node, profile)
|
|
274
|
+
node.type = 'knxUltimateHueController'
|
|
275
|
+
node.hueControllerType = profile.controllerType
|
|
276
|
+
node.inputs = pinShape.inputs
|
|
277
|
+
node.outputs = pinShape.outputs
|
|
278
|
+
Object.values(CONFIG_NODE_REFERENCE_FIELDS).forEach(function (field) {
|
|
279
|
+
if (typeof node[field] === 'string' && node[field].trim() !== '') {
|
|
280
|
+
referencedConfigIds.add(node[field])
|
|
281
|
+
}
|
|
282
|
+
})
|
|
283
|
+
convertedCount += 1
|
|
284
|
+
convertedByType[oldType] = (convertedByType[oldType] || 0) + 1
|
|
285
|
+
return node
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
const reuseConfigNodes = options.reuseConfigNodes !== false
|
|
289
|
+
const omittedConfigByType = {}
|
|
290
|
+
const convertedFlow = reuseConfigNodes && convertedCount > 0
|
|
291
|
+
? flow.filter(function (node) {
|
|
292
|
+
const referenceField = CONFIG_NODE_REFERENCE_FIELDS[node.type]
|
|
293
|
+
const omitNode = Boolean(referenceField && referencedConfigIds.has(node.id))
|
|
294
|
+
if (omitNode) omittedConfigByType[node.type] = (omittedConfigByType[node.type] || 0) + 1
|
|
295
|
+
return !omitNode
|
|
296
|
+
})
|
|
297
|
+
: flow
|
|
298
|
+
const omittedConfigCount = Object.values(omittedConfigByType)
|
|
299
|
+
.reduce((total, count) => total + count, 0)
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
flow: convertedFlow,
|
|
303
|
+
convertedCount,
|
|
304
|
+
convertedByType,
|
|
305
|
+
omittedConfigCount,
|
|
306
|
+
omittedConfigByType
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function convertLegacyHueFlowJson (rawJson, options = {}) {
|
|
311
|
+
if (typeof rawJson !== 'string' || rawJson.trim() === '') {
|
|
312
|
+
throw new TypeError('Paste a Node-RED flow before starting the conversion')
|
|
313
|
+
}
|
|
314
|
+
const sourceFlow = JSON.parse(rawJson)
|
|
315
|
+
const result = convertLegacyHueFlow(sourceFlow, options)
|
|
316
|
+
return {
|
|
317
|
+
...result,
|
|
318
|
+
json: JSON.stringify(result.flow, null, 2)
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
CONFIG_NODE_REFERENCE_FIELDS,
|
|
324
|
+
LEGACY_NODE_PROFILES,
|
|
325
|
+
MIGRATION_DONATION_URL,
|
|
326
|
+
MIGRATION_USAGE_EMAIL,
|
|
327
|
+
applyLocalMigration,
|
|
328
|
+
collectLegacyHueNodes,
|
|
329
|
+
copyTextToClipboard,
|
|
330
|
+
convertLegacyHueFlow,
|
|
331
|
+
convertLegacyHueFlowJson,
|
|
332
|
+
createLocalMigrationPatches,
|
|
333
|
+
createUsageMailto,
|
|
334
|
+
derivePinShape,
|
|
335
|
+
isLegacyHueNode,
|
|
336
|
+
openUsageMailto
|
|
337
|
+
}
|
|
338
|
+
}))
|