node-red-contrib-scorp-io 0.1.0
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/LICENSE +21 -0
- package/README.md +171 -0
- package/examples/scorp-io-basic-flow.json +124 -0
- package/locales/en-US/scorp-io.json +43 -0
- package/locales/fr/scorp-io.json +43 -0
- package/nodes/scorp-io-config.html +96 -0
- package/nodes/scorp-io-config.js +104 -0
- package/nodes/scorp-io-device.html +300 -0
- package/nodes/scorp-io-device.js +281 -0
- package/package.json +60 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SCorp-io
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# node-red-contrib-scorp-io
|
|
2
|
+
|
|
3
|
+
Node-RED nodes for SCorp-io MQTTS integration.
|
|
4
|
+
|
|
5
|
+
The package exposes a shared SCorp-io configuration node and a multi-device publishing node for:
|
|
6
|
+
|
|
7
|
+
- `DBIRTH` messages: device metric declarations;
|
|
8
|
+
- `DDATA` messages: device metric values with timestamps.
|
|
9
|
+
|
|
10
|
+
## Requirements
|
|
11
|
+
|
|
12
|
+
- Node.js `>=18.0.0`
|
|
13
|
+
- Node-RED `>=3.0.0`
|
|
14
|
+
- SCorp-io MQTT credentials for production mode
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
### From the Node-RED editor
|
|
19
|
+
|
|
20
|
+
Open **Menu → Manage palette → Install**, then search for:
|
|
21
|
+
|
|
22
|
+
```text
|
|
23
|
+
node-red-contrib-scorp-io
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### From npm
|
|
27
|
+
|
|
28
|
+
Run from the Node-RED user directory, usually `~/.node-red`:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
cd ~/.node-red
|
|
32
|
+
npm install node-red-contrib-scorp-io
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Restart Node-RED after installation.
|
|
36
|
+
|
|
37
|
+
## Nodes
|
|
38
|
+
|
|
39
|
+
### `scorp-io-config`
|
|
40
|
+
|
|
41
|
+
Shared configuration node for the SCorp-io MQTT connection.
|
|
42
|
+
|
|
43
|
+
| Field | Description |
|
|
44
|
+
| --- | --- |
|
|
45
|
+
| Mode | `test` disables MQTT publishing; `production` publishes to the broker |
|
|
46
|
+
| Client ID | MQTT client identifier |
|
|
47
|
+
| Login | MQTT username |
|
|
48
|
+
| Password | MQTT password, stored as a Node-RED credential |
|
|
49
|
+
| Project ID | SCorp-io project identifier used in MQTT topics |
|
|
50
|
+
| Node ID | Edge node identifier used in MQTT topics |
|
|
51
|
+
|
|
52
|
+
Production broker endpoint:
|
|
53
|
+
|
|
54
|
+
```text
|
|
55
|
+
mqtts://broker-public-prod.scorp-io.com:8883
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### `scorp-io-device`
|
|
59
|
+
|
|
60
|
+
Multi-device node that builds and publishes SCorp-io `DBIRTH` and `DDATA` payloads.
|
|
61
|
+
|
|
62
|
+
#### Input
|
|
63
|
+
|
|
64
|
+
The node has one input.
|
|
65
|
+
|
|
66
|
+
| Message | Behavior |
|
|
67
|
+
| --- | --- |
|
|
68
|
+
| `msg.topic === "birth"` | Emits a `DBIRTH` for all configured devices |
|
|
69
|
+
| `msg.deviceId` set | Emits `DDATA` for the matching configured device |
|
|
70
|
+
| regular payload | Emits `DDATA` for every device where at least one metric path resolves |
|
|
71
|
+
|
|
72
|
+
#### Output
|
|
73
|
+
|
|
74
|
+
The node has one debug output containing the generated message:
|
|
75
|
+
|
|
76
|
+
| Property | Description |
|
|
77
|
+
| --- | --- |
|
|
78
|
+
| `msg.topic` | MQTT topic that was/would be published |
|
|
79
|
+
| `msg.payload` | Generated SCorp-io payload |
|
|
80
|
+
| `msg._scorp.type` | `DBIRTH` or `DDATA` |
|
|
81
|
+
| `msg._scorp.device` | Target device id |
|
|
82
|
+
| `msg._scorp.simulated` | `true` in test mode |
|
|
83
|
+
|
|
84
|
+
## Metric configuration
|
|
85
|
+
|
|
86
|
+
Each device has a list of metrics.
|
|
87
|
+
|
|
88
|
+
| Metric field | Description | Example |
|
|
89
|
+
| --- | --- | --- |
|
|
90
|
+
| `name` | Metric name sent to SCorp-io | `pompe-1/etats` |
|
|
91
|
+
| `dataType` | Metric type | `Integer`, `Boolean`, `Float` |
|
|
92
|
+
| `valuePath` | Path resolved from incoming `msg` for `DDATA` | `msg.payload.pompe1.etats` |
|
|
93
|
+
|
|
94
|
+
Supported value paths:
|
|
95
|
+
|
|
96
|
+
```text
|
|
97
|
+
msg.payload.pompe1.etats
|
|
98
|
+
msg.pompe.etat
|
|
99
|
+
pompe1.etats
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Paths without the `msg.` prefix are resolved from `msg.payload`.
|
|
103
|
+
|
|
104
|
+
## Topic format
|
|
105
|
+
|
|
106
|
+
```text
|
|
107
|
+
mqtts/{PROJECT_ID}/{MESSAGE_TYPE}/{EDGE_NODE_ID}/{DEVICE_ID}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Examples:
|
|
111
|
+
|
|
112
|
+
```text
|
|
113
|
+
mqtts/my-project/DBIRTH/edge-01/pompe-1
|
|
114
|
+
mqtts/my-project/DDATA/edge-01/pompe-1
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Payload examples
|
|
118
|
+
|
|
119
|
+
### DBIRTH
|
|
120
|
+
|
|
121
|
+
```json
|
|
122
|
+
{
|
|
123
|
+
"metrics": [
|
|
124
|
+
{ "name": "pompe-1/etats", "dataType": "Integer" },
|
|
125
|
+
{ "name": "pompe-1/defaut", "dataType": "Boolean" }
|
|
126
|
+
]
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### DDATA
|
|
131
|
+
|
|
132
|
+
```json
|
|
133
|
+
{
|
|
134
|
+
"metrics": [
|
|
135
|
+
{
|
|
136
|
+
"name": "pompe-1/etats",
|
|
137
|
+
"timestamp": 1710000000000,
|
|
138
|
+
"dataType": "Integer",
|
|
139
|
+
"value": 1
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
"name": "pompe-1/defaut",
|
|
143
|
+
"timestamp": 1710000000000,
|
|
144
|
+
"dataType": "Boolean",
|
|
145
|
+
"value": false
|
|
146
|
+
}
|
|
147
|
+
]
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Example flow
|
|
152
|
+
|
|
153
|
+
An importable example is provided in:
|
|
154
|
+
|
|
155
|
+
```text
|
|
156
|
+
examples/scorp-io-basic-flow.json
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
It runs in `test` mode by default, so it does not publish to the production MQTT broker.
|
|
160
|
+
|
|
161
|
+
## Development
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
npm install
|
|
165
|
+
npm test
|
|
166
|
+
npm run pack:dry-run
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## License
|
|
170
|
+
|
|
171
|
+
MIT
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"id": "f1f2f3f4f5f6f7f8",
|
|
4
|
+
"type": "tab",
|
|
5
|
+
"label": "SCorp-io basic example",
|
|
6
|
+
"disabled": false,
|
|
7
|
+
"info": "Example flow for @scorp-io/node-red-contrib-scorp-io. Runs in test mode by default."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"id": "cfg-scorp-io-example",
|
|
11
|
+
"type": "scorp-io-config",
|
|
12
|
+
"name": "SCorp-io test",
|
|
13
|
+
"clientId": "example-client",
|
|
14
|
+
"projectId": "my-project",
|
|
15
|
+
"edgeNodeId": "edge-01",
|
|
16
|
+
"mode": "test",
|
|
17
|
+
"login": ""
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"id": "inject-pompe-1",
|
|
21
|
+
"type": "inject",
|
|
22
|
+
"z": "f1f2f3f4f5f6f7f8",
|
|
23
|
+
"name": "Sample DDATA pompe-1",
|
|
24
|
+
"props": [
|
|
25
|
+
{
|
|
26
|
+
"p": "payload"
|
|
27
|
+
}
|
|
28
|
+
],
|
|
29
|
+
"repeat": "",
|
|
30
|
+
"crontab": "",
|
|
31
|
+
"once": false,
|
|
32
|
+
"onceDelay": 0.1,
|
|
33
|
+
"topic": "",
|
|
34
|
+
"payload": "{\"pompe1\":{\"etats\":1,\"defaut\":false,\"status\":3.14}}",
|
|
35
|
+
"payloadType": "json",
|
|
36
|
+
"x": 180,
|
|
37
|
+
"y": 120,
|
|
38
|
+
"wires": [
|
|
39
|
+
[
|
|
40
|
+
"scorp-device-example"
|
|
41
|
+
]
|
|
42
|
+
]
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"id": "inject-birth",
|
|
46
|
+
"type": "inject",
|
|
47
|
+
"z": "f1f2f3f4f5f6f7f8",
|
|
48
|
+
"name": "Force DBIRTH",
|
|
49
|
+
"props": [
|
|
50
|
+
{
|
|
51
|
+
"p": "topic",
|
|
52
|
+
"vt": "str"
|
|
53
|
+
}
|
|
54
|
+
],
|
|
55
|
+
"repeat": "",
|
|
56
|
+
"crontab": "",
|
|
57
|
+
"once": false,
|
|
58
|
+
"onceDelay": 0.1,
|
|
59
|
+
"topic": "birth",
|
|
60
|
+
"x": 160,
|
|
61
|
+
"y": 180,
|
|
62
|
+
"wires": [
|
|
63
|
+
[
|
|
64
|
+
"scorp-device-example"
|
|
65
|
+
]
|
|
66
|
+
]
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"id": "scorp-device-example",
|
|
70
|
+
"type": "scorp-io-device",
|
|
71
|
+
"z": "f1f2f3f4f5f6f7f8",
|
|
72
|
+
"name": "Example pumps",
|
|
73
|
+
"config": "cfg-scorp-io-example",
|
|
74
|
+
"devices": [
|
|
75
|
+
{
|
|
76
|
+
"deviceId": "pompe-1",
|
|
77
|
+
"metrics": [
|
|
78
|
+
{
|
|
79
|
+
"name": "pompe-1/etats",
|
|
80
|
+
"dataType": "Integer",
|
|
81
|
+
"valuePath": "msg.payload.pompe1.etats"
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
"name": "pompe-1/defaut",
|
|
85
|
+
"dataType": "Boolean",
|
|
86
|
+
"valuePath": "msg.payload.pompe1.defaut"
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
"name": "pompe-1/status",
|
|
90
|
+
"dataType": "Float",
|
|
91
|
+
"valuePath": "msg.payload.pompe1.status"
|
|
92
|
+
}
|
|
93
|
+
]
|
|
94
|
+
}
|
|
95
|
+
],
|
|
96
|
+
"birthOnDeploy": true,
|
|
97
|
+
"birthPeriodic": false,
|
|
98
|
+
"birthInterval": "24h",
|
|
99
|
+
"x": 430,
|
|
100
|
+
"y": 140,
|
|
101
|
+
"wires": [
|
|
102
|
+
[
|
|
103
|
+
"debug-scorp-output"
|
|
104
|
+
]
|
|
105
|
+
]
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
"id": "debug-scorp-output",
|
|
109
|
+
"type": "debug",
|
|
110
|
+
"z": "f1f2f3f4f5f6f7f8",
|
|
111
|
+
"name": "SCorp-io generated message",
|
|
112
|
+
"active": true,
|
|
113
|
+
"tosidebar": true,
|
|
114
|
+
"console": false,
|
|
115
|
+
"tostatus": false,
|
|
116
|
+
"complete": "true",
|
|
117
|
+
"targetType": "full",
|
|
118
|
+
"statusVal": "",
|
|
119
|
+
"statusType": "auto",
|
|
120
|
+
"x": 710,
|
|
121
|
+
"y": 140,
|
|
122
|
+
"wires": []
|
|
123
|
+
}
|
|
124
|
+
]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"scorp-io-config": {
|
|
3
|
+
"label": {
|
|
4
|
+
"clientid": "Client ID",
|
|
5
|
+
"login": "Login",
|
|
6
|
+
"password": "Password",
|
|
7
|
+
"projectid": "Project ID",
|
|
8
|
+
"edgenodeid": "Edge Node ID"
|
|
9
|
+
},
|
|
10
|
+
"tooltip": {
|
|
11
|
+
"clientid": "Unique MQTT connection identifier",
|
|
12
|
+
"projectid": "SCorp-io project identifier",
|
|
13
|
+
"edgenodeid": "Edge node identifier"
|
|
14
|
+
},
|
|
15
|
+
"status": {
|
|
16
|
+
"connected": "Connected",
|
|
17
|
+
"disconnected": "Disconnected",
|
|
18
|
+
"connecting": "Connecting...",
|
|
19
|
+
"error": "Connection error"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"scorp-io-device": {
|
|
23
|
+
"label": {
|
|
24
|
+
"deviceid": "Device ID",
|
|
25
|
+
"config": "SCorp-io Config",
|
|
26
|
+
"metrics": "Metrics",
|
|
27
|
+
"metricname": "Name",
|
|
28
|
+
"metricdatatype": "Data Type",
|
|
29
|
+
"metricvalue": "Value (msg.payload path)"
|
|
30
|
+
},
|
|
31
|
+
"placeholder": {
|
|
32
|
+
"deviceid": "e.g. pompe-1",
|
|
33
|
+
"metricname": "e.g. pompe-1/etats",
|
|
34
|
+
"metricvalue": "e.g. pompe1.etats"
|
|
35
|
+
},
|
|
36
|
+
"status": {
|
|
37
|
+
"connected": "Connected",
|
|
38
|
+
"disconnected": "Disconnected",
|
|
39
|
+
"birth_sent": "DBIRTH sent",
|
|
40
|
+
"data_sent": "DDATA sent"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"scorp-io-config": {
|
|
3
|
+
"label": {
|
|
4
|
+
"clientid": "Client ID",
|
|
5
|
+
"login": "Login",
|
|
6
|
+
"password": "Mot de passe",
|
|
7
|
+
"projectid": "Project ID",
|
|
8
|
+
"edgenodeid": "Edge Node ID"
|
|
9
|
+
},
|
|
10
|
+
"tooltip": {
|
|
11
|
+
"clientid": "Identifiant unique de connexion MQTT",
|
|
12
|
+
"projectid": "Identifiant du projet SCorp-io",
|
|
13
|
+
"edgenodeid": "Identifiant du nœud edge"
|
|
14
|
+
},
|
|
15
|
+
"status": {
|
|
16
|
+
"connected": "Connecté",
|
|
17
|
+
"disconnected": "Déconnecté",
|
|
18
|
+
"connecting": "Connexion...",
|
|
19
|
+
"error": "Erreur de connexion"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"scorp-io-device": {
|
|
23
|
+
"label": {
|
|
24
|
+
"deviceid": "Device ID",
|
|
25
|
+
"config": "Config SCorp-io",
|
|
26
|
+
"metrics": "Métriques",
|
|
27
|
+
"metricname": "Nom",
|
|
28
|
+
"metricdatatype": "Type de données",
|
|
29
|
+
"metricvalue": "Valeur (chemin msg.payload)"
|
|
30
|
+
},
|
|
31
|
+
"placeholder": {
|
|
32
|
+
"deviceid": "ex: pompe-1",
|
|
33
|
+
"metricname": "ex: pompe-1/etats",
|
|
34
|
+
"metricvalue": "ex: pompe1.etats"
|
|
35
|
+
},
|
|
36
|
+
"status": {
|
|
37
|
+
"connected": "Connecté",
|
|
38
|
+
"disconnected": "Déconnecté",
|
|
39
|
+
"birth_sent": "DBIRTH envoyé",
|
|
40
|
+
"data_sent": "DDATA envoyé"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
RED.nodes.registerType('scorp-io-config', {
|
|
3
|
+
category: 'config',
|
|
4
|
+
defaults: {
|
|
5
|
+
name: { value: '' },
|
|
6
|
+
clientId: { value: '', required: true },
|
|
7
|
+
projectId: { value: '', required: true },
|
|
8
|
+
edgeNodeId: { value: '', required: true },
|
|
9
|
+
mode: { value: 'test' },
|
|
10
|
+
login: { value: '' }
|
|
11
|
+
},
|
|
12
|
+
credentials: {
|
|
13
|
+
password: { type: 'password' }
|
|
14
|
+
},
|
|
15
|
+
label: function() {
|
|
16
|
+
const modeTag = this.mode === 'production' ? '🟢 PROD' : '🧪 TEST';
|
|
17
|
+
return this.name || ('SCorp-io [' + (this.projectId || '?') + '] ' + modeTag);
|
|
18
|
+
},
|
|
19
|
+
oneditprepare: function() {
|
|
20
|
+
// Pré-remplir le mot de passe depuis credentials
|
|
21
|
+
if (this.credentials && this.credentials.password) {
|
|
22
|
+
$('#node-config-input-password').val(this.credentials.password);
|
|
23
|
+
}
|
|
24
|
+
// Afficher/masquer avertissement selon mode
|
|
25
|
+
$('#node-config-input-mode').on('change', function() {
|
|
26
|
+
if ($(this).val() === 'production') {
|
|
27
|
+
$('#scorp-mode-warning').show();
|
|
28
|
+
} else {
|
|
29
|
+
$('#scorp-mode-warning').hide();
|
|
30
|
+
}
|
|
31
|
+
}).trigger('change');
|
|
32
|
+
},
|
|
33
|
+
oneditsave: function() {
|
|
34
|
+
// Sauvegarder le mot de passe dans credentials
|
|
35
|
+
this.credentials = this.credentials || {};
|
|
36
|
+
this.credentials.password = $('#node-config-input-password').val();
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
</script>
|
|
40
|
+
|
|
41
|
+
<script type="text/html" data-template-name="scorp-io-config">
|
|
42
|
+
|
|
43
|
+
<div class="form-row">
|
|
44
|
+
<label for="node-config-input-name"><i class="fa fa-tag"></i> Nom</label>
|
|
45
|
+
<input type="text" id="node-config-input-name" placeholder="ex: SCorp-io Production" style="width:70%">
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<!-- MODE -->
|
|
49
|
+
<div class="form-row">
|
|
50
|
+
<label for="node-config-input-mode"><i class="fa fa-toggle-on"></i> Mode</label>
|
|
51
|
+
<select id="node-config-input-mode" style="width:70%">
|
|
52
|
+
<option value="test">🧪 Test (simulation, pas d'envoi MQTT)</option>
|
|
53
|
+
<option value="production">🟢 Production (envoi MQTT réel)</option>
|
|
54
|
+
</select>
|
|
55
|
+
</div>
|
|
56
|
+
<div id="scorp-mode-warning" class="form-tips"
|
|
57
|
+
style="display:none; background:#fff3cd; border-left:4px solid #ffc107; padding:8px; margin-bottom:10px;">
|
|
58
|
+
⚠️ Mode <b>Production</b> activé — les messages MQTT seront envoyés au broker SCorp-io.
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<hr/>
|
|
62
|
+
<div class="form-row">
|
|
63
|
+
<label style="width:100%;font-weight:bold;"><i class="fa fa-plug"></i> Connexion MQTT</label>
|
|
64
|
+
</div>
|
|
65
|
+
<div class="form-tips" style="margin-bottom:12px;">
|
|
66
|
+
Broker fixe : <b>broker-public-prod.scorp-io.com : 8883</b> (TLS automatique)
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<div class="form-row">
|
|
70
|
+
<label for="node-config-input-clientId"><i class="fa fa-id-card"></i> Client ID</label>
|
|
71
|
+
<input type="text" id="node-config-input-clientId" placeholder="ex: my-edge-client-01" style="width:70%">
|
|
72
|
+
</div>
|
|
73
|
+
<div class="form-row">
|
|
74
|
+
<label for="node-config-input-login"><i class="fa fa-user"></i> Login</label>
|
|
75
|
+
<input type="text" id="node-config-input-login" placeholder="Nom d'utilisateur MQTT" style="width:70%" autocomplete="off">
|
|
76
|
+
</div>
|
|
77
|
+
<div class="form-row">
|
|
78
|
+
<label for="node-config-input-password"><i class="fa fa-lock"></i> Mot de passe</label>
|
|
79
|
+
<input type="password" id="node-config-input-password" placeholder="Mot de passe MQTT" style="width:70%" autocomplete="new-password">
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<hr/>
|
|
83
|
+
<div class="form-row">
|
|
84
|
+
<label style="width:100%;font-weight:bold;"><i class="fa fa-sitemap"></i> Identifiants SCorp-io</label>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
<div class="form-row">
|
|
88
|
+
<label for="node-config-input-projectId"><i class="fa fa-folder"></i> Project ID</label>
|
|
89
|
+
<input type="text" id="node-config-input-projectId" placeholder="ex: my-project" style="width:70%">
|
|
90
|
+
</div>
|
|
91
|
+
<div class="form-row">
|
|
92
|
+
<label for="node-config-input-edgeNodeId"><i class="fa fa-microchip"></i> Node ID</label>
|
|
93
|
+
<input type="text" id="node-config-input-edgeNodeId" placeholder="ex: edge-node-01" style="width:70%">
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
</script>
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
module.exports = function(RED) {
|
|
2
|
+
const mqtt = require('mqtt');
|
|
3
|
+
|
|
4
|
+
function ScorpIoConfigNode(config) {
|
|
5
|
+
RED.nodes.createNode(this, config);
|
|
6
|
+
|
|
7
|
+
this.clientId = config.clientId;
|
|
8
|
+
this.projectId = config.projectId;
|
|
9
|
+
this.edgeNodeId = config.edgeNodeId;
|
|
10
|
+
this.mode = config.mode || 'test';
|
|
11
|
+
this.login = config.login;
|
|
12
|
+
this.password = this.credentials && this.credentials.password;
|
|
13
|
+
this.brokerUrl = 'mqtts://broker-public-prod.scorp-io.com:8883';
|
|
14
|
+
|
|
15
|
+
this._devices = {};
|
|
16
|
+
this.connected = false;
|
|
17
|
+
|
|
18
|
+
const node = this;
|
|
19
|
+
|
|
20
|
+
// ── API partagée — toujours définie, mode test ou production ──────────
|
|
21
|
+
|
|
22
|
+
node.register = function(deviceNode) {
|
|
23
|
+
node._devices[deviceNode.id] = deviceNode;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
node.deregister = function(deviceNode) {
|
|
27
|
+
delete node._devices[deviceNode.id];
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
node.publish = function(topic, payload, options, callback) {
|
|
31
|
+
// Compatibilité : publish(topic, payload, callback)
|
|
32
|
+
if (typeof options === 'function') { callback = options; options = {}; }
|
|
33
|
+
options = options || {};
|
|
34
|
+
|
|
35
|
+
if (node.mode === 'test') {
|
|
36
|
+
if (callback) callback(null);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (node.client && node.client.connected) {
|
|
40
|
+
const mqttOpts = { qos: 1, retain: !!options.retain };
|
|
41
|
+
node.client.publish(topic, JSON.stringify(payload), mqttOpts, callback);
|
|
42
|
+
} else {
|
|
43
|
+
node.warn('SCorp-io : tentative de publication hors connexion');
|
|
44
|
+
if (callback) callback(new Error('Non connecté'));
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// ── Connexion MQTT — uniquement en mode production ────────────────────
|
|
49
|
+
|
|
50
|
+
if (node.mode === 'test') {
|
|
51
|
+
node.log('[MODE TEST] Connexion MQTT désactivée');
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const options = {
|
|
56
|
+
clientId: node.clientId,
|
|
57
|
+
username: node.login,
|
|
58
|
+
password: node.password,
|
|
59
|
+
rejectUnauthorized: true,
|
|
60
|
+
reconnectPeriod: 5000,
|
|
61
|
+
connectTimeout: 30000
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
node.client = mqtt.connect(node.brokerUrl, options);
|
|
65
|
+
|
|
66
|
+
node.client.on('connect', function() {
|
|
67
|
+
node.connected = true;
|
|
68
|
+
node.log('SCorp-io connecté à ' + node.brokerUrl);
|
|
69
|
+
Object.values(node._devices).forEach(d => d.onConnected());
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
node.client.on('reconnect', function() {
|
|
73
|
+
node.connected = false;
|
|
74
|
+
node.log('SCorp-io reconnexion en cours...');
|
|
75
|
+
Object.values(node._devices).forEach(d => d.onConnecting());
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
node.client.on('error', function(err) {
|
|
79
|
+
node.connected = false;
|
|
80
|
+
node.error('SCorp-io erreur MQTT : ' + err.message);
|
|
81
|
+
Object.values(node._devices).forEach(d => d.onError(err.message));
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
node.client.on('offline', function() {
|
|
85
|
+
node.connected = false;
|
|
86
|
+
node.log('SCorp-io déconnecté');
|
|
87
|
+
Object.values(node._devices).forEach(d => d.onDisconnected());
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
node.on('close', function(done) {
|
|
91
|
+
if (node.client) {
|
|
92
|
+
node.client.end(true, {}, done);
|
|
93
|
+
} else {
|
|
94
|
+
done();
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
RED.nodes.registerType('scorp-io-config', ScorpIoConfigNode, {
|
|
100
|
+
credentials: {
|
|
101
|
+
password: { type: 'password' }
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
};
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
|
|
3
|
+
const SCORP_DATA_TYPES = [
|
|
4
|
+
'Int8','Int16','Int32','Int64',
|
|
5
|
+
'UInt8','UInt16','UInt32','UInt64',
|
|
6
|
+
'Float','Double','Boolean','String','DateTime'
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
function scorpMakeMetricRow(container, metric) {
|
|
10
|
+
metric = metric || { name: '', dataType: 'String', valuePath: '' };
|
|
11
|
+
const row = $('<div/>').css({
|
|
12
|
+
display: 'flex', gap: '4px', alignItems: 'center', width: '100%', padding: '2px 0'
|
|
13
|
+
}).appendTo(container);
|
|
14
|
+
|
|
15
|
+
$('<input/>', { type: 'text', placeholder: 'ex: pompe-1/etats', class: 'scorp-metric-name' })
|
|
16
|
+
.css({ flex: '2', minWidth: 0 }).val(metric.name || '').appendTo(row);
|
|
17
|
+
|
|
18
|
+
const sel = $('<select/>', { class: 'scorp-metric-datatype' })
|
|
19
|
+
.css({ flex: '1.3', minWidth: 0 }).appendTo(row);
|
|
20
|
+
SCORP_DATA_TYPES.forEach(function(t) {
|
|
21
|
+
$('<option/>', { value: t, text: t }).appendTo(sel);
|
|
22
|
+
});
|
|
23
|
+
sel.val(metric.dataType || 'String');
|
|
24
|
+
|
|
25
|
+
$('<input/>', { type: 'text', placeholder: 'msg.payload.pompe1.etats', class: 'scorp-metric-valuepath' })
|
|
26
|
+
.css({ flex: '2.5', minWidth: 0 }).val(metric.valuePath || '').appendTo(row);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function scorpMakeDeviceBlock(container, device) {
|
|
30
|
+
device = device || { deviceId: '', metrics: [] };
|
|
31
|
+
|
|
32
|
+
const block = $('<div/>').addClass('scorp-device-block').css({
|
|
33
|
+
border: '1px solid #ccc', borderRadius: '4px', marginBottom: '8px', background: '#f9f9f9'
|
|
34
|
+
}).appendTo(container);
|
|
35
|
+
|
|
36
|
+
// En-tête
|
|
37
|
+
const header = $('<div/>').css({
|
|
38
|
+
display: 'flex', alignItems: 'center', gap: '6px', padding: '6px 8px',
|
|
39
|
+
background: '#e8f4f8', borderBottom: '1px solid #ccc',
|
|
40
|
+
borderRadius: '4px 4px 0 0', cursor: 'pointer'
|
|
41
|
+
}).appendTo(block);
|
|
42
|
+
|
|
43
|
+
const toggle = $('<span/>').css({
|
|
44
|
+
fontFamily: 'monospace', fontWeight: 'bold', minWidth: '12px', color: '#555'
|
|
45
|
+
}).text('▼').appendTo(header);
|
|
46
|
+
|
|
47
|
+
$('<label/>').css({ fontWeight: 'bold', marginBottom: 0, flex: '0 0 auto' })
|
|
48
|
+
.text('Device ID :').appendTo(header);
|
|
49
|
+
|
|
50
|
+
$('<input/>', { type: 'text', placeholder: 'ex: pompe-1', class: 'scorp-device-id' })
|
|
51
|
+
.css({ flex: '1', minWidth: 0 }).val(device.deviceId || '').appendTo(header);
|
|
52
|
+
|
|
53
|
+
$('<button/>', { type: 'button' })
|
|
54
|
+
.addClass('red-ui-button red-ui-button-small')
|
|
55
|
+
.css({ marginLeft: 'auto', flexShrink: 0 })
|
|
56
|
+
.html('<i class="fa fa-trash"></i>')
|
|
57
|
+
.on('click', function(e) { e.stopPropagation(); block.remove(); })
|
|
58
|
+
.appendTo(header);
|
|
59
|
+
|
|
60
|
+
// Corps
|
|
61
|
+
const body = $('<div/>').css({ padding: '8px' }).appendTo(block);
|
|
62
|
+
|
|
63
|
+
// En-têtes colonnes
|
|
64
|
+
$('<div/>').css({
|
|
65
|
+
display: 'flex', gap: '4px',
|
|
66
|
+
fontSize: '11px', color: '#888', fontWeight: 'bold', padding: '0 0 4px 0'
|
|
67
|
+
}).html(
|
|
68
|
+
'<span style="flex:2">Nom métrique</span>' +
|
|
69
|
+
'<span style="flex:1.3">Type</span>' +
|
|
70
|
+
'<span style="flex:2.5">Chemin (depuis msg)</span>'
|
|
71
|
+
).appendTo(body);
|
|
72
|
+
|
|
73
|
+
// editableList SANS bouton natif
|
|
74
|
+
const metricList = $('<ol/>').css({ paddingLeft: 0, marginBottom: '6px' }).appendTo(body);
|
|
75
|
+
metricList.editableList({
|
|
76
|
+
addItem: function(rowContainer, i, opt) {
|
|
77
|
+
const m = (opt && opt.name !== undefined) ? opt : { name: '', dataType: 'String', valuePath: '' };
|
|
78
|
+
scorpMakeMetricRow(rowContainer, m);
|
|
79
|
+
},
|
|
80
|
+
removable: true,
|
|
81
|
+
sortable: true,
|
|
82
|
+
addButton: false // ← supprime le bouton natif dupliqué
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
(device.metrics || []).forEach(function(m) { metricList.editableList('addItem', m); });
|
|
86
|
+
|
|
87
|
+
// Bouton unique "Ajouter une métrique"
|
|
88
|
+
$('<button/>', { type: 'button' })
|
|
89
|
+
.addClass('red-ui-button red-ui-button-small')
|
|
90
|
+
.html('<i class="fa fa-plus"></i> Ajouter une métrique')
|
|
91
|
+
.on('click', function() { metricList.editableList('addItem', {}); })
|
|
92
|
+
.appendTo(body);
|
|
93
|
+
|
|
94
|
+
// Toggle collapse
|
|
95
|
+
header.on('click', function(e) {
|
|
96
|
+
if ($(e.target).is('input, button, i')) return;
|
|
97
|
+
if (body.is(':visible')) { body.hide(); toggle.text('▶'); }
|
|
98
|
+
else { body.show(); toggle.text('▼'); }
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
block.data('metricList', metricList);
|
|
102
|
+
return block;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
RED.nodes.registerType('scorp-io-device', {
|
|
106
|
+
category: 'SCorp-io',
|
|
107
|
+
color: '#00AEEF',
|
|
108
|
+
defaults: {
|
|
109
|
+
name: { value: '' },
|
|
110
|
+
config: { value: '', type: 'scorp-io-config', required: true },
|
|
111
|
+
devices: { value: [] },
|
|
112
|
+
birthOnDeploy: { value: true },
|
|
113
|
+
birthPeriodic: { value: false },
|
|
114
|
+
birthInterval: { value: '24h' }
|
|
115
|
+
},
|
|
116
|
+
inputs: 1,
|
|
117
|
+
outputs: 1,
|
|
118
|
+
icon: 'bridge.svg',
|
|
119
|
+
inputLabels: ['data (msg.topic="birth" pour forcer DBIRTH)'],
|
|
120
|
+
outputLabels: ['debug (topic + payload)'],
|
|
121
|
+
label: function() {
|
|
122
|
+
const n = (this.devices || []).length;
|
|
123
|
+
return this.name || ('SCorp-io — ' + n + ' device' + (n > 1 ? 's' : ''));
|
|
124
|
+
},
|
|
125
|
+
labelStyle: function() { return this.name ? 'node_label_italic' : ''; },
|
|
126
|
+
|
|
127
|
+
oneditprepare: function() {
|
|
128
|
+
const node = this;
|
|
129
|
+
|
|
130
|
+
// ── Devices ──
|
|
131
|
+
const container = $('#scorp-devices-container');
|
|
132
|
+
(node.devices || []).forEach(function(d) { scorpMakeDeviceBlock(container, d); });
|
|
133
|
+
$('#scorp-add-device').on('click', function() {
|
|
134
|
+
scorpMakeDeviceBlock(container, { deviceId: '', metrics: [] });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// ── DBIRTH au déploiement ──
|
|
138
|
+
$('#scorp-birth-on-deploy').prop('checked', node.birthOnDeploy !== false);
|
|
139
|
+
|
|
140
|
+
// ── DBIRTH périodique ──
|
|
141
|
+
const periodicChk = $('#scorp-birth-periodic');
|
|
142
|
+
const intervalSel = $('#scorp-birth-interval');
|
|
143
|
+
|
|
144
|
+
periodicChk.prop('checked', !!node.birthPeriodic);
|
|
145
|
+
intervalSel.val(node.birthInterval || '24h');
|
|
146
|
+
intervalSel.prop('disabled', !node.birthPeriodic);
|
|
147
|
+
|
|
148
|
+
periodicChk.on('change', function() {
|
|
149
|
+
intervalSel.prop('disabled', !$(this).prop('checked'));
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// ── Bouton forcer DBIRTH ──
|
|
153
|
+
const btn = $('#scorp-btn-dbirth');
|
|
154
|
+
const status = $('#scorp-btn-dbirth-status');
|
|
155
|
+
|
|
156
|
+
if (!node.id) { btn.prop('disabled', true); }
|
|
157
|
+
|
|
158
|
+
btn.on('click', function() {
|
|
159
|
+
btn.prop('disabled', true);
|
|
160
|
+
status.text('Envoi…').css('color', '#aaa');
|
|
161
|
+
$.ajax({
|
|
162
|
+
method: 'POST',
|
|
163
|
+
url: 'scorp-io/dbirth/' + node.id,
|
|
164
|
+
success: function(data) {
|
|
165
|
+
status.text('✅ DBIRTH envoyé : ' + data.devices.join(', ')).css('color', 'green');
|
|
166
|
+
btn.prop('disabled', false);
|
|
167
|
+
},
|
|
168
|
+
error: function(xhr) {
|
|
169
|
+
const msg = xhr.responseJSON ? xhr.responseJSON.error : xhr.statusText;
|
|
170
|
+
status.text('❌ ' + msg).css('color', 'red');
|
|
171
|
+
btn.prop('disabled', false);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
},
|
|
176
|
+
|
|
177
|
+
oneditsave: function() {
|
|
178
|
+
// Devices
|
|
179
|
+
const devices = [];
|
|
180
|
+
$('#scorp-devices-container .scorp-device-block').each(function() {
|
|
181
|
+
const block = $(this);
|
|
182
|
+
const deviceId = block.find('.scorp-device-id').val().trim();
|
|
183
|
+
if (!deviceId) return;
|
|
184
|
+
const metrics = [];
|
|
185
|
+
const metricList = block.data('metricList');
|
|
186
|
+
if (metricList) {
|
|
187
|
+
metricList.editableList('items').each(function() {
|
|
188
|
+
const name = $(this).find('.scorp-metric-name').val().trim();
|
|
189
|
+
const dataType = $(this).find('.scorp-metric-datatype').val();
|
|
190
|
+
const valuePath = $(this).find('.scorp-metric-valuepath').val().trim();
|
|
191
|
+
if (name) metrics.push({ name, dataType, valuePath });
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
devices.push({ deviceId, metrics });
|
|
195
|
+
});
|
|
196
|
+
this.devices = devices;
|
|
197
|
+
|
|
198
|
+
// DBIRTH config
|
|
199
|
+
this.birthOnDeploy = $('#scorp-birth-on-deploy').prop('checked');
|
|
200
|
+
this.birthPeriodic = $('#scorp-birth-periodic').prop('checked');
|
|
201
|
+
this.birthInterval = $('#scorp-birth-interval').val();
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
</script>
|
|
205
|
+
|
|
206
|
+
<script type="text/html" data-template-name="scorp-io-device">
|
|
207
|
+
|
|
208
|
+
<div class="form-row">
|
|
209
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> Nom</label>
|
|
210
|
+
<input type="text" id="node-input-name" placeholder="ex: Mes pompes">
|
|
211
|
+
</div>
|
|
212
|
+
<div class="form-row">
|
|
213
|
+
<label for="node-input-config"><i class="fa fa-cog"></i> Config</label>
|
|
214
|
+
<input type="text" id="node-input-config">
|
|
215
|
+
</div>
|
|
216
|
+
|
|
217
|
+
<hr/>
|
|
218
|
+
|
|
219
|
+
<!-- DEVICES -->
|
|
220
|
+
<div class="form-row">
|
|
221
|
+
<label style="width:100%; font-weight:bold; margin-bottom:6px;">
|
|
222
|
+
<i class="fa fa-microchip"></i> Devices
|
|
223
|
+
</label>
|
|
224
|
+
<div id="scorp-devices-container" style="width:100%;"></div>
|
|
225
|
+
<button type="button" id="scorp-add-device" class="red-ui-button" style="margin-top:6px;">
|
|
226
|
+
<i class="fa fa-plus"></i> Ajouter un device
|
|
227
|
+
</button>
|
|
228
|
+
</div>
|
|
229
|
+
|
|
230
|
+
<hr/>
|
|
231
|
+
|
|
232
|
+
<!-- STRATÉGIE DBIRTH -->
|
|
233
|
+
<div class="form-row" style="width:100%;">
|
|
234
|
+
<label style="width:100%; font-weight:bold; margin-bottom:8px;">
|
|
235
|
+
<i class="fa fa-refresh"></i> Émission DBIRTH
|
|
236
|
+
</label>
|
|
237
|
+
|
|
238
|
+
<!-- Option A : au déploiement -->
|
|
239
|
+
<div style="display:flex; align-items:center; gap:8px; margin-bottom:10px; width:100%;">
|
|
240
|
+
<input type="checkbox" id="scorp-birth-on-deploy" style="width:auto; margin:0; flex-shrink:0;">
|
|
241
|
+
<label for="scorp-birth-on-deploy" style="margin:0; font-weight:normal; flex:1;">
|
|
242
|
+
Envoyer un DBIRTH automatiquement après déploiement / redémarrage
|
|
243
|
+
</label>
|
|
244
|
+
</div>
|
|
245
|
+
|
|
246
|
+
<!-- Option B : périodique -->
|
|
247
|
+
<div style="display:flex; align-items:center; gap:8px; width:100%;">
|
|
248
|
+
<input type="checkbox" id="scorp-birth-periodic" style="width:auto; margin:0; flex-shrink:0;">
|
|
249
|
+
<label for="scorp-birth-periodic" style="margin:0; font-weight:normal;">
|
|
250
|
+
Envoyer un DBIRTH périodiquement toutes les
|
|
251
|
+
</label>
|
|
252
|
+
<select id="scorp-birth-interval" style="width:auto; flex-shrink:0;">
|
|
253
|
+
<option value="1h">1 heure</option>
|
|
254
|
+
<option value="6h">6 heures</option>
|
|
255
|
+
<option value="12h">12 heures</option>
|
|
256
|
+
<option value="24h">24 heures</option>
|
|
257
|
+
<option value="48h">48 heures</option>
|
|
258
|
+
</select>
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
261
|
+
|
|
262
|
+
<hr/>
|
|
263
|
+
|
|
264
|
+
<!-- BOUTON FORCER DBIRTH -->
|
|
265
|
+
<div class="form-row">
|
|
266
|
+
<label style="width:100%; font-weight:bold; margin-bottom:6px;">
|
|
267
|
+
<i class="fa fa-bolt"></i> Actions
|
|
268
|
+
</label>
|
|
269
|
+
<button type="button" id="scorp-btn-dbirth" class="red-ui-button">
|
|
270
|
+
<i class="fa fa-refresh"></i> Forcer DBIRTH maintenant
|
|
271
|
+
</button>
|
|
272
|
+
<span id="scorp-btn-dbirth-status" style="margin-left:10px; font-size:12px;"></span>
|
|
273
|
+
</div>
|
|
274
|
+
|
|
275
|
+
<hr/>
|
|
276
|
+
|
|
277
|
+
<div class="form-tips">
|
|
278
|
+
<b>Entrée :</b> message normal → <b>DDATA</b> auto |
|
|
279
|
+
<code>msg.topic = "birth"</code> → <b>DBIRTH</b><br/>
|
|
280
|
+
<b>Chemins :</b> <code>msg.payload.pompe1.etats</code> ou <code>msg.pompe.etat</code>
|
|
281
|
+
</div>
|
|
282
|
+
|
|
283
|
+
</script>
|
|
284
|
+
|
|
285
|
+
<script type="text/html" data-help-name="scorp-io-device">
|
|
286
|
+
<p>Nœud SCorp-io multi-devices. Publie <b>DBIRTH</b> et <b>DDATA</b> via MQTTS.</p>
|
|
287
|
+
<h3>Entrée</h3>
|
|
288
|
+
<dl class="message-properties">
|
|
289
|
+
<dt>msg.topic <span class="property-type">string</span></dt>
|
|
290
|
+
<dd><code>"birth"</code> → force DBIRTH pour tous les devices.</dd>
|
|
291
|
+
<dt>msg.payload <span class="property-type">object</span></dt>
|
|
292
|
+
<dd>Routage automatique : DDATA publié pour chaque device dont une métrique est résolvable.</dd>
|
|
293
|
+
</dl>
|
|
294
|
+
<h3>Sortie — debug</h3>
|
|
295
|
+
<dl class="message-properties">
|
|
296
|
+
<dt>msg.topic</dt><dd>Topic MQTT publié.</dd>
|
|
297
|
+
<dt>msg.payload</dt><dd>Trame publiée.</dd>
|
|
298
|
+
<dt>msg._scorp</dt><dd>type, mode, device, simulated.</dd>
|
|
299
|
+
</dl>
|
|
300
|
+
</script>
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
module.exports = function(RED) {
|
|
2
|
+
|
|
3
|
+
// ── resolvePath ───────────────────────────────────────────────────────────
|
|
4
|
+
// Résout un chemin depuis la racine msg
|
|
5
|
+
// "msg.payload.pompe1.etats" → msg.payload.pompe1.etats
|
|
6
|
+
// "msg.pompe.etat" → msg.pompe.etat
|
|
7
|
+
// "pompe1.etats" → fallback sur msg.payload.pompe1.etats
|
|
8
|
+
|
|
9
|
+
function resolvePath(msg, rawPath) {
|
|
10
|
+
if (!rawPath || !msg) return undefined;
|
|
11
|
+
let path = rawPath.trim();
|
|
12
|
+
if (path.startsWith('msg.')) {
|
|
13
|
+
path = path.slice(4);
|
|
14
|
+
} else {
|
|
15
|
+
path = 'payload.' + path;
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
return path.split('.').reduce(function(acc, key) {
|
|
19
|
+
if (acc === undefined || acc === null) return undefined;
|
|
20
|
+
return acc[key];
|
|
21
|
+
}, msg);
|
|
22
|
+
} catch(e) { return undefined; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ── deviceIsRelevant ──────────────────────────────────────────────────────
|
|
26
|
+
// Retourne true si au moins une métrique du device est résolvable dans msg
|
|
27
|
+
|
|
28
|
+
function deviceIsRelevant(device, msg) {
|
|
29
|
+
if (msg && msg.deviceId && msg.deviceId === device.deviceId) {
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
return device.metrics.some(function(m) {
|
|
33
|
+
return m.valuePath && resolvePath(msg, m.valuePath) !== undefined;
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ── buildTopic ────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
function buildTopic(projectId, edgeNodeId, deviceId, msgType) {
|
|
40
|
+
return ['mqtts', projectId, msgType, edgeNodeId, deviceId].join('/');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── ScorpIoDeviceNode ─────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
function ScorpIoDeviceNode(config) {
|
|
46
|
+
RED.nodes.createNode(this, config);
|
|
47
|
+
|
|
48
|
+
this.devices = config.devices || [];
|
|
49
|
+
this.birthOnDeploy = config.birthOnDeploy !== false; // true par défaut
|
|
50
|
+
this.birthPeriodic = !!config.birthPeriodic;
|
|
51
|
+
this.birthInterval = config.birthInterval || '24h';
|
|
52
|
+
this.configId = config.config;
|
|
53
|
+
this.config = RED.nodes.getNode(this.configId);
|
|
54
|
+
|
|
55
|
+
const node = this;
|
|
56
|
+
const isTest = !node.config || node.config.mode === 'test';
|
|
57
|
+
|
|
58
|
+
if (!node.config) {
|
|
59
|
+
node.status({ fill: 'red', shape: 'ring', text: 'Config manquante' });
|
|
60
|
+
node.error('Nœud de configuration SCorp-io introuvable');
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── Constructeurs de payload ──────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
function buildDBirth(device) {
|
|
67
|
+
return {
|
|
68
|
+
metrics: device.metrics.map(m => ({
|
|
69
|
+
name: m.name,
|
|
70
|
+
dataType: m.dataType
|
|
71
|
+
}))
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function buildDData(device, msg) {
|
|
76
|
+
return {
|
|
77
|
+
metrics: device.metrics.map(function(m) {
|
|
78
|
+
const value = resolvePath(msg, m.valuePath);
|
|
79
|
+
const timestamp = Date.now();
|
|
80
|
+
if (value === undefined && m.valuePath) {
|
|
81
|
+
node.warn(
|
|
82
|
+
'[' + device.deviceId + '] Métrique "' + m.name + '" : ' +
|
|
83
|
+
'chemin "' + m.valuePath + '" introuvable dans msg.'
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return { name: m.name, timestamp: timestamp, dataType: m.dataType, value: value };
|
|
87
|
+
})
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── Debug output ──────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
function emitDebug(msgType, deviceId, topic, payload) {
|
|
94
|
+
node.send({
|
|
95
|
+
topic: topic,
|
|
96
|
+
payload: payload,
|
|
97
|
+
_scorp: {
|
|
98
|
+
type: msgType,
|
|
99
|
+
mode: node.config.mode,
|
|
100
|
+
device: deviceId,
|
|
101
|
+
simulated: isTest
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── Publication DBIRTH (1 device) ─────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
function sendBirthForDevice(device) {
|
|
109
|
+
const topic = buildTopic(node.config.projectId, node.config.edgeNodeId, device.deviceId, 'DBIRTH');
|
|
110
|
+
const payload = buildDBirth(device);
|
|
111
|
+
|
|
112
|
+
node.log('DBIRTH [' + device.deviceId + '] → ' + topic);
|
|
113
|
+
|
|
114
|
+
if (isTest) {
|
|
115
|
+
emitDebug('DBIRTH', device.deviceId, topic, payload);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
node.config.publish(topic, payload, { retain: true }, function(err) {
|
|
120
|
+
if (err) {
|
|
121
|
+
node.error('Erreur DBIRTH [' + device.deviceId + '] : ' + err.message);
|
|
122
|
+
} else {
|
|
123
|
+
node.log('DBIRTH [' + device.deviceId + '] publié');
|
|
124
|
+
emitDebug('DBIRTH', device.deviceId, topic, payload);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Publication DBIRTH (tous les devices) ─────────────────────────────
|
|
130
|
+
|
|
131
|
+
node.sendAllBirths = function() {
|
|
132
|
+
if (node.devices.length === 0) {
|
|
133
|
+
node.warn('Aucun device configuré, DBIRTH ignoré');
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
node.devices.forEach(sendBirthForDevice);
|
|
137
|
+
|
|
138
|
+
if (isTest) {
|
|
139
|
+
node.status({ fill: 'blue', shape: 'ring', text: '🧪 DBIRTH x' + node.devices.length + ' simulé' });
|
|
140
|
+
} else {
|
|
141
|
+
node.status({ fill: 'green', shape: 'dot', text: 'DBIRTH x' + node.devices.length + ' envoyé' });
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// ── Publication DDATA — routage automatique par correspondance ─────────
|
|
146
|
+
// Pour chaque device, on vérifie si au moins une métrique est résolvable
|
|
147
|
+
// dans le msg. Si oui → DDATA publié pour ce device.
|
|
148
|
+
// Si aucun device matche → warn.
|
|
149
|
+
|
|
150
|
+
node.sendDataAuto = function(msg) {
|
|
151
|
+
const matched = node.devices.filter(d => deviceIsRelevant(d, msg));
|
|
152
|
+
|
|
153
|
+
if (matched.length === 0) {
|
|
154
|
+
node.warn(
|
|
155
|
+
'Aucun device ne correspond au msg reçu. ' +
|
|
156
|
+
'Vérifier les chemins de métriques configurés. ' +
|
|
157
|
+
'msg.payload : ' + JSON.stringify(msg.payload)
|
|
158
|
+
);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
matched.forEach(function(device) {
|
|
163
|
+
const topic = buildTopic(node.config.projectId, node.config.edgeNodeId, device.deviceId, 'DDATA');
|
|
164
|
+
const data = buildDData(device, msg);
|
|
165
|
+
|
|
166
|
+
node.log('DDATA [' + device.deviceId + '] → ' + topic);
|
|
167
|
+
|
|
168
|
+
if (isTest) {
|
|
169
|
+
node.status({ fill: 'blue', shape: 'dot', text: '🧪 DDATA [' + device.deviceId + '] simulé' });
|
|
170
|
+
emitDebug('DDATA', device.deviceId, topic, data);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
node.config.publish(topic, data, { retain: false }, function(err) {
|
|
175
|
+
if (err) {
|
|
176
|
+
node.error('Erreur DDATA [' + device.deviceId + '] : ' + err.message);
|
|
177
|
+
node.status({ fill: 'red', shape: 'dot', text: 'Erreur DDATA' });
|
|
178
|
+
} else {
|
|
179
|
+
node.status({ fill: 'green', shape: 'dot', text: 'DDATA [' + device.deviceId + '] envoyé' });
|
|
180
|
+
emitDebug('DDATA', device.deviceId, topic, data);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
// ── États connexion ───────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
node.onConnected = function() {
|
|
189
|
+
node.status({ fill: 'green', shape: 'ring', text: 'Connecté' });
|
|
190
|
+
if (node.birthOnDeploy) node.sendAllBirths();
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
node.onConnecting = function() {
|
|
194
|
+
node.status({ fill: 'yellow', shape: 'ring', text: 'Connexion...' });
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
node.onDisconnected = function() {
|
|
198
|
+
node.status({ fill: 'red', shape: 'ring', text: 'Déconnecté' });
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
node.onError = function(errMsg) {
|
|
202
|
+
node.status({ fill: 'red', shape: 'dot', text: 'Erreur: ' + errMsg });
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
// ── Init ──────────────────────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
node.config.register(node);
|
|
208
|
+
|
|
209
|
+
if (isTest) {
|
|
210
|
+
const deviceLabel = node.devices.length + ' device' + (node.devices.length > 1 ? 's' : '');
|
|
211
|
+
node.status({ fill: 'blue', shape: 'ring', text: '🧪 Test — ' + deviceLabel });
|
|
212
|
+
if (node.birthOnDeploy) node.sendAllBirths();
|
|
213
|
+
} else if (node.config.client && node.config.client.connected) {
|
|
214
|
+
node.onConnected();
|
|
215
|
+
} else {
|
|
216
|
+
node.status({ fill: 'yellow', shape: 'ring', text: 'Connexion...' });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── DBIRTH périodique ─────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
const INTERVAL_MAP = {
|
|
222
|
+
'1h': 3600000,
|
|
223
|
+
'6h': 21600000,
|
|
224
|
+
'12h': 43200000,
|
|
225
|
+
'24h': 86400000,
|
|
226
|
+
'48h': 172800000
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
if (node.birthPeriodic) {
|
|
230
|
+
const ms = INTERVAL_MAP[node.birthInterval] || INTERVAL_MAP['24h'];
|
|
231
|
+
node._birthTimer = setInterval(function() {
|
|
232
|
+
node.log('DBIRTH périodique (' + node.birthInterval + ')');
|
|
233
|
+
node.sendAllBirths();
|
|
234
|
+
}, ms);
|
|
235
|
+
node.log('DBIRTH périodique activé : toutes les ' + node.birthInterval);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ── Entrée ────────────────────────────────────────────────────────────
|
|
239
|
+
// msg.topic === 'birth' → DBIRTH pour tous les devices
|
|
240
|
+
// sinon → DDATA routage automatique
|
|
241
|
+
|
|
242
|
+
node.on('input', function(msg, send, done) {
|
|
243
|
+
if (msg.topic === 'birth' || msg._inputPort === 1) {
|
|
244
|
+
node.sendAllBirths();
|
|
245
|
+
} else {
|
|
246
|
+
node.sendDataAuto(msg);
|
|
247
|
+
}
|
|
248
|
+
done();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
node.on('close', function(done) {
|
|
252
|
+
if (node._birthTimer) {
|
|
253
|
+
clearInterval(node._birthTimer);
|
|
254
|
+
node._birthTimer = null;
|
|
255
|
+
}
|
|
256
|
+
node.config.deregister(node);
|
|
257
|
+
done();
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
RED.nodes.registerType('scorp-io-device', ScorpIoDeviceNode);
|
|
262
|
+
|
|
263
|
+
// ── Endpoint HTTP admin — bouton DBIRTH depuis l'UI ───────────────────────
|
|
264
|
+
// POST /scorp-io/dbirth/:nodeId
|
|
265
|
+
|
|
266
|
+
RED.httpAdmin.post('/scorp-io/dbirth/:nodeId', function(req, res) {
|
|
267
|
+
const node = RED.nodes.getNode(req.params.nodeId);
|
|
268
|
+
if (!node) {
|
|
269
|
+
return res.status(404).json({ error: 'Nœud introuvable : ' + req.params.nodeId });
|
|
270
|
+
}
|
|
271
|
+
if (typeof node.sendAllBirths !== 'function') {
|
|
272
|
+
return res.status(400).json({ error: 'Ce nœud ne supporte pas sendAllBirths' });
|
|
273
|
+
}
|
|
274
|
+
try {
|
|
275
|
+
node.sendAllBirths();
|
|
276
|
+
res.json({ ok: true, devices: (node.devices || []).map(d => d.deviceId) });
|
|
277
|
+
} catch(e) {
|
|
278
|
+
res.status(500).json({ error: e.message });
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "node-red-contrib-scorp-io",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Node-RED nodes for SCorp-io MQTTS integration with DBIRTH and DDATA messages.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"node-red",
|
|
7
|
+
"node-red-contrib",
|
|
8
|
+
"scorp-io",
|
|
9
|
+
"mqtt",
|
|
10
|
+
"mqtts",
|
|
11
|
+
"iot",
|
|
12
|
+
"industrial"
|
|
13
|
+
],
|
|
14
|
+
"author": "SCorp-io",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+ssh://git@github.com/SCORPIO-JRB/node-red-contrib-scorp-io.git"
|
|
19
|
+
},
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/SCORPIO-JRB/node-red-contrib-scorp-io/issues"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://github.com/SCORPIO-JRB/node-red-contrib-scorp-io#readme",
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"nodes/",
|
|
29
|
+
"locales/",
|
|
30
|
+
"examples/",
|
|
31
|
+
"README.md",
|
|
32
|
+
"LICENSE"
|
|
33
|
+
],
|
|
34
|
+
"node-red": {
|
|
35
|
+
"version": ">=3.0.0",
|
|
36
|
+
"nodes": {
|
|
37
|
+
"scorp-io-config": "nodes/scorp-io-config.js",
|
|
38
|
+
"scorp-io-device": "nodes/scorp-io-device.js"
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"mqtt": "^5.3.4"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"mocha": "^10.4.0",
|
|
46
|
+
"node-red": "^3.1.15",
|
|
47
|
+
"node-red-node-test-helper": "^0.3.4",
|
|
48
|
+
"should": "^13.2.3"
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"test": "mocha test/**/*_spec.js --timeout 5000",
|
|
52
|
+
"test:helpers": "mocha test/scorp-io-helpers_spec.js --timeout 5000",
|
|
53
|
+
"test:config": "mocha test/scorp-io-config_spec.js --timeout 5000",
|
|
54
|
+
"test:device": "mocha test/scorp-io-device_spec.js --timeout 5000",
|
|
55
|
+
"pack:dry-run": "npm pack --dry-run"
|
|
56
|
+
},
|
|
57
|
+
"engines": {
|
|
58
|
+
"node": ">=18.0.0"
|
|
59
|
+
}
|
|
60
|
+
}
|