@blokcert/node-red-contrib-ocpp 1.0.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/README.md +188 -0
- package/icons/ocpp.svg +4 -0
- package/lib/ocpp-normalizer.js +368 -0
- package/lib/redis-client.js +97 -0
- package/nodes/ocpp-command.html +201 -0
- package/nodes/ocpp-command.js +241 -0
- package/nodes/ocpp-config.html +85 -0
- package/nodes/ocpp-config.js +54 -0
- package/nodes/ocpp-in.html +131 -0
- package/nodes/ocpp-in.js +213 -0
- package/nodes/ocpp-out.html +129 -0
- package/nodes/ocpp-out.js +153 -0
- package/package.json +43 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
RED.nodes.registerType('ocpp-in', {
|
|
3
|
+
category: 'OCPP',
|
|
4
|
+
color: '#4CAF50',
|
|
5
|
+
defaults: {
|
|
6
|
+
name: { value: '' },
|
|
7
|
+
server: { value: '', type: 'ocpp-config', required: true },
|
|
8
|
+
blockTimeout: { value: 5000, validate: RED.validators.number() },
|
|
9
|
+
batchSize: { value: 10, validate: RED.validators.number() },
|
|
10
|
+
autoAck: { value: true },
|
|
11
|
+
normalizePayload: { value: true },
|
|
12
|
+
filterAction: { value: '' },
|
|
13
|
+
},
|
|
14
|
+
inputs: 0,
|
|
15
|
+
outputs: 1,
|
|
16
|
+
icon: 'font-awesome/fa-sign-in',
|
|
17
|
+
paletteLabel: 'ocpp in',
|
|
18
|
+
label: function () {
|
|
19
|
+
if (this.filterAction) {
|
|
20
|
+
return this.name || `ocpp in (${this.filterAction})`;
|
|
21
|
+
}
|
|
22
|
+
return this.name || 'ocpp in';
|
|
23
|
+
},
|
|
24
|
+
labelStyle: function () {
|
|
25
|
+
return this.name ? 'node_label_italic' : '';
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<script type="text/html" data-template-name="ocpp-in">
|
|
31
|
+
<div class="form-row">
|
|
32
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
33
|
+
<input type="text" id="node-input-name" placeholder="Name">
|
|
34
|
+
</div>
|
|
35
|
+
<div class="form-row">
|
|
36
|
+
<label for="node-input-server"><i class="fa fa-server"></i> Server</label>
|
|
37
|
+
<input type="text" id="node-input-server">
|
|
38
|
+
</div>
|
|
39
|
+
<div class="form-row">
|
|
40
|
+
<label for="node-input-blockTimeout"><i class="fa fa-clock-o"></i> Block Timeout (ms)</label>
|
|
41
|
+
<input type="number" id="node-input-blockTimeout" placeholder="5000">
|
|
42
|
+
</div>
|
|
43
|
+
<div class="form-row">
|
|
44
|
+
<label for="node-input-batchSize"><i class="fa fa-list"></i> Batch Size</label>
|
|
45
|
+
<input type="number" id="node-input-batchSize" placeholder="10">
|
|
46
|
+
</div>
|
|
47
|
+
<div class="form-row">
|
|
48
|
+
<label for="node-input-autoAck"><i class="fa fa-check"></i> Auto Acknowledge</label>
|
|
49
|
+
<input type="checkbox" id="node-input-autoAck" style="width: auto; margin-left: 0;">
|
|
50
|
+
<span style="margin-left: 10px;">Automatically ACK messages after processing</span>
|
|
51
|
+
</div>
|
|
52
|
+
<div class="form-row">
|
|
53
|
+
<label for="node-input-normalizePayload"><i class="fa fa-exchange"></i> Normalize</label>
|
|
54
|
+
<input type="checkbox" id="node-input-normalizePayload" style="width: auto; margin-left: 0;">
|
|
55
|
+
<span style="margin-left: 10px;">Convert OCPP 1.6/2.0.1 to unified format</span>
|
|
56
|
+
</div>
|
|
57
|
+
<div class="form-row">
|
|
58
|
+
<label for="node-input-filterAction"><i class="fa fa-filter"></i> Filter Action</label>
|
|
59
|
+
<input type="text" id="node-input-filterAction" placeholder="(all actions)">
|
|
60
|
+
</div>
|
|
61
|
+
</script>
|
|
62
|
+
|
|
63
|
+
<script type="text/html" data-help-name="ocpp-in">
|
|
64
|
+
<p>Receives OCPP messages from charging stations via Redis Streams.</p>
|
|
65
|
+
|
|
66
|
+
<h3>Outputs</h3>
|
|
67
|
+
<dl class="message-properties">
|
|
68
|
+
<dt>payload <span class="property-type">object</span></dt>
|
|
69
|
+
<dd>The OCPP message payload (normalized if enabled).</dd>
|
|
70
|
+
|
|
71
|
+
<dt>ocpp <span class="property-type">object</span></dt>
|
|
72
|
+
<dd>OCPP message metadata:
|
|
73
|
+
<ul>
|
|
74
|
+
<li><code>messageId</code> - Unique message ID</li>
|
|
75
|
+
<li><code>action</code> - OCPP action (e.g., BootNotification, Heartbeat)</li>
|
|
76
|
+
<li><code>version</code> - OCPP version (1.6 or 2.0.1)</li>
|
|
77
|
+
<li><code>tenant</code> - Operator/tenant identifier</li>
|
|
78
|
+
<li><code>deviceId</code> - Charger/station ID</li>
|
|
79
|
+
<li><code>identity</code> - Combined tenant:deviceId</li>
|
|
80
|
+
<li><code>timestamp</code> - Message timestamp</li>
|
|
81
|
+
<li><code>streamId</code> - Redis Stream message ID</li>
|
|
82
|
+
<li><code>_original</code> - Original payload before normalization</li>
|
|
83
|
+
</ul>
|
|
84
|
+
</dd>
|
|
85
|
+
</dl>
|
|
86
|
+
|
|
87
|
+
<h3>Properties</h3>
|
|
88
|
+
<dl class="message-properties">
|
|
89
|
+
<dt>Server <span class="property-type">ocpp-config</span></dt>
|
|
90
|
+
<dd>The OCPP configuration node with Redis connection settings.</dd>
|
|
91
|
+
|
|
92
|
+
<dt>Block Timeout <span class="property-type">number</span></dt>
|
|
93
|
+
<dd>How long to wait (ms) for new messages before checking again.</dd>
|
|
94
|
+
|
|
95
|
+
<dt>Batch Size <span class="property-type">number</span></dt>
|
|
96
|
+
<dd>Maximum number of messages to read per batch.</dd>
|
|
97
|
+
|
|
98
|
+
<dt>Auto Acknowledge <span class="property-type">boolean</span></dt>
|
|
99
|
+
<dd>If enabled, messages are automatically acknowledged after being sent.
|
|
100
|
+
Disable if you need manual control over acknowledgment.</dd>
|
|
101
|
+
|
|
102
|
+
<dt>Normalize <span class="property-type">boolean</span></dt>
|
|
103
|
+
<dd>If enabled, converts OCPP 1.6 and 2.0.1 messages to a unified format.
|
|
104
|
+
This allows your flow to handle both versions with the same logic.</dd>
|
|
105
|
+
|
|
106
|
+
<dt>Filter Action <span class="property-type">string</span></dt>
|
|
107
|
+
<dd>Optional. Only output messages with this action name.
|
|
108
|
+
Leave empty to receive all actions.</dd>
|
|
109
|
+
</dl>
|
|
110
|
+
|
|
111
|
+
<h3>Details</h3>
|
|
112
|
+
<p>This node reads from the <code>ws:inbound</code> Redis Stream using consumer groups,
|
|
113
|
+
allowing multiple Node-RED instances to share the message load.</p>
|
|
114
|
+
|
|
115
|
+
<h3>Supported Actions</h3>
|
|
116
|
+
<p>Common OCPP actions that can be filtered:</p>
|
|
117
|
+
<ul>
|
|
118
|
+
<li><code>BootNotification</code></li>
|
|
119
|
+
<li><code>Heartbeat</code></li>
|
|
120
|
+
<li><code>StatusNotification</code></li>
|
|
121
|
+
<li><code>Authorize</code></li>
|
|
122
|
+
<li><code>StartTransaction</code> / <code>TransactionEvent</code></li>
|
|
123
|
+
<li><code>StopTransaction</code></li>
|
|
124
|
+
<li><code>MeterValues</code></li>
|
|
125
|
+
</ul>
|
|
126
|
+
|
|
127
|
+
<h3>Example Flow</h3>
|
|
128
|
+
<pre>
|
|
129
|
+
[ocpp in] → [switch (by action)] → [handler] → [ocpp out]
|
|
130
|
+
</pre>
|
|
131
|
+
</script>
|
package/nodes/ocpp-in.js
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OCPP Input Node
|
|
3
|
+
* Reads OCPP messages from Redis Stream (ws:inbound) using Consumer Groups
|
|
4
|
+
*/
|
|
5
|
+
const { Keys } = require('../lib/redis-client');
|
|
6
|
+
const { parseStreamMessage, normalize } = require('../lib/ocpp-normalizer');
|
|
7
|
+
|
|
8
|
+
module.exports = function (RED) {
|
|
9
|
+
function OCPPInNode(config) {
|
|
10
|
+
RED.nodes.createNode(this, config);
|
|
11
|
+
const node = this;
|
|
12
|
+
|
|
13
|
+
// Get config node
|
|
14
|
+
node.server = RED.nodes.getNode(config.server);
|
|
15
|
+
if (!node.server) {
|
|
16
|
+
node.error('No OCPP config node configured');
|
|
17
|
+
node.status({ fill: 'red', shape: 'ring', text: 'no config' });
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Node configuration
|
|
22
|
+
node.blockTimeout = parseInt(config.blockTimeout, 10) || 5000;
|
|
23
|
+
node.batchSize = parseInt(config.batchSize, 10) || 10;
|
|
24
|
+
node.autoAck = config.autoAck !== false; // Default true
|
|
25
|
+
node.normalizePayload = config.normalizePayload !== false; // Default true
|
|
26
|
+
node.filterAction = config.filterAction || ''; // Optional action filter
|
|
27
|
+
|
|
28
|
+
// State
|
|
29
|
+
let running = false;
|
|
30
|
+
let consumerGroupCreated = false;
|
|
31
|
+
|
|
32
|
+
const redis = node.server.redis;
|
|
33
|
+
const consumerGroup = node.server.consumerGroup;
|
|
34
|
+
const consumerName = node.server.consumerName;
|
|
35
|
+
const streamKey = Keys.inbound();
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Ensure consumer group exists
|
|
39
|
+
*/
|
|
40
|
+
async function ensureConsumerGroup() {
|
|
41
|
+
if (consumerGroupCreated) return;
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
await redis.xgroup('CREATE', streamKey, consumerGroup, '0', 'MKSTREAM');
|
|
45
|
+
node.log(`Created consumer group: ${consumerGroup}`);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
if (err.message.includes('BUSYGROUP')) {
|
|
48
|
+
// Group already exists, that's fine
|
|
49
|
+
node.log(`Consumer group already exists: ${consumerGroup}`);
|
|
50
|
+
} else {
|
|
51
|
+
throw err;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
consumerGroupCreated = true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Read messages from stream
|
|
59
|
+
*/
|
|
60
|
+
async function readMessages() {
|
|
61
|
+
if (!running) return;
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
// XREADGROUP GROUP group consumer [COUNT count] [BLOCK milliseconds] STREAMS key id
|
|
65
|
+
const results = await redis.xreadgroup(
|
|
66
|
+
'GROUP', consumerGroup, consumerName,
|
|
67
|
+
'COUNT', node.batchSize,
|
|
68
|
+
'BLOCK', node.blockTimeout,
|
|
69
|
+
'STREAMS', streamKey, '>'
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
if (results && results.length > 0) {
|
|
73
|
+
const [, messages] = results[0]; // [streamKey, [[id, fields], ...]]
|
|
74
|
+
|
|
75
|
+
for (const [streamId, fields] of messages) {
|
|
76
|
+
await processMessage(streamId, fields);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
} catch (err) {
|
|
80
|
+
if (err.message.includes('NOGROUP')) {
|
|
81
|
+
// Consumer group was deleted, recreate it
|
|
82
|
+
consumerGroupCreated = false;
|
|
83
|
+
await ensureConsumerGroup();
|
|
84
|
+
} else {
|
|
85
|
+
node.error(`Read error: ${err.message}`);
|
|
86
|
+
node.status({ fill: 'red', shape: 'dot', text: 'read error' });
|
|
87
|
+
// Wait before retry
|
|
88
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Continue reading
|
|
93
|
+
if (running) {
|
|
94
|
+
setImmediate(readMessages);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Process a single message from the stream
|
|
100
|
+
*/
|
|
101
|
+
async function processMessage(streamId, fields) {
|
|
102
|
+
try {
|
|
103
|
+
// Convert Redis fields array to object
|
|
104
|
+
// fields = ['key1', 'val1', 'key2', 'val2', ...]
|
|
105
|
+
const data = {};
|
|
106
|
+
for (let i = 0; i < fields.length; i += 2) {
|
|
107
|
+
data[fields[i]] = fields[i + 1];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Parse the stream message
|
|
111
|
+
let msg = parseStreamMessage(data);
|
|
112
|
+
msg.streamId = streamId;
|
|
113
|
+
|
|
114
|
+
// Apply action filter if configured
|
|
115
|
+
if (node.filterAction && msg.action !== node.filterAction) {
|
|
116
|
+
// Auto-ack filtered messages if autoAck is enabled
|
|
117
|
+
if (node.autoAck) {
|
|
118
|
+
await redis.xack(streamKey, consumerGroup, streamId);
|
|
119
|
+
}
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Normalize payload if enabled
|
|
124
|
+
if (node.normalizePayload) {
|
|
125
|
+
msg = normalize(msg);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Build Node-RED message
|
|
129
|
+
const nodeMsg = {
|
|
130
|
+
payload: msg.payload,
|
|
131
|
+
ocpp: {
|
|
132
|
+
messageId: msg.messageId,
|
|
133
|
+
action: msg.action,
|
|
134
|
+
version: msg.version,
|
|
135
|
+
protocol: msg.protocol,
|
|
136
|
+
tenant: msg.tenant,
|
|
137
|
+
deviceId: msg.deviceId,
|
|
138
|
+
identity: msg.identity,
|
|
139
|
+
timestamp: msg.timestamp,
|
|
140
|
+
streamId: streamId,
|
|
141
|
+
_original: msg._original,
|
|
142
|
+
},
|
|
143
|
+
_msgid: RED.util.generateId(),
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// Update status
|
|
147
|
+
node.status({
|
|
148
|
+
fill: 'green',
|
|
149
|
+
shape: 'dot',
|
|
150
|
+
text: `${msg.action} from ${msg.identity}`,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// Send to output
|
|
154
|
+
node.send(nodeMsg);
|
|
155
|
+
|
|
156
|
+
// Auto-acknowledge if enabled
|
|
157
|
+
if (node.autoAck) {
|
|
158
|
+
await redis.xack(streamKey, consumerGroup, streamId);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
} catch (err) {
|
|
162
|
+
node.error(`Process error: ${err.message}`, { streamId, fields });
|
|
163
|
+
node.status({ fill: 'red', shape: 'dot', text: 'process error' });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Start reading from stream
|
|
169
|
+
*/
|
|
170
|
+
async function start() {
|
|
171
|
+
if (running) return;
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
await ensureConsumerGroup();
|
|
175
|
+
running = true;
|
|
176
|
+
node.status({ fill: 'green', shape: 'ring', text: 'listening' });
|
|
177
|
+
node.log(`Started listening on ${streamKey}`);
|
|
178
|
+
readMessages();
|
|
179
|
+
} catch (err) {
|
|
180
|
+
node.error(`Start error: ${err.message}`);
|
|
181
|
+
node.status({ fill: 'red', shape: 'ring', text: 'start failed' });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Stop reading
|
|
187
|
+
*/
|
|
188
|
+
function stop() {
|
|
189
|
+
running = false;
|
|
190
|
+
node.status({ fill: 'grey', shape: 'ring', text: 'stopped' });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Start when Node-RED is ready
|
|
194
|
+
if (redis) {
|
|
195
|
+
redis.once('ready', () => {
|
|
196
|
+
start();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// If already connected
|
|
200
|
+
if (redis.status === 'ready') {
|
|
201
|
+
start();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Cleanup on node removal
|
|
206
|
+
node.on('close', (done) => {
|
|
207
|
+
stop();
|
|
208
|
+
done();
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
RED.nodes.registerType('ocpp-in', OCPPInNode);
|
|
213
|
+
};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
RED.nodes.registerType('ocpp-out', {
|
|
3
|
+
category: 'OCPP',
|
|
4
|
+
color: '#2196F3',
|
|
5
|
+
defaults: {
|
|
6
|
+
name: { value: '' },
|
|
7
|
+
server: { value: '', type: 'ocpp-config', required: true },
|
|
8
|
+
denormalizePayload: { value: true },
|
|
9
|
+
autoAck: { value: true },
|
|
10
|
+
streamMaxLen: { value: 1000, validate: RED.validators.number() },
|
|
11
|
+
},
|
|
12
|
+
inputs: 1,
|
|
13
|
+
outputs: 1,
|
|
14
|
+
icon: 'font-awesome/fa-sign-out',
|
|
15
|
+
paletteLabel: 'ocpp out',
|
|
16
|
+
label: function () {
|
|
17
|
+
return this.name || 'ocpp out';
|
|
18
|
+
},
|
|
19
|
+
labelStyle: function () {
|
|
20
|
+
return this.name ? 'node_label_italic' : '';
|
|
21
|
+
},
|
|
22
|
+
align: 'right',
|
|
23
|
+
});
|
|
24
|
+
</script>
|
|
25
|
+
|
|
26
|
+
<script type="text/html" data-template-name="ocpp-out">
|
|
27
|
+
<div class="form-row">
|
|
28
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
29
|
+
<input type="text" id="node-input-name" placeholder="Name">
|
|
30
|
+
</div>
|
|
31
|
+
<div class="form-row">
|
|
32
|
+
<label for="node-input-server"><i class="fa fa-server"></i> Server</label>
|
|
33
|
+
<input type="text" id="node-input-server">
|
|
34
|
+
</div>
|
|
35
|
+
<div class="form-row">
|
|
36
|
+
<label for="node-input-denormalizePayload"><i class="fa fa-exchange"></i> Denormalize</label>
|
|
37
|
+
<input type="checkbox" id="node-input-denormalizePayload" style="width: auto; margin-left: 0;">
|
|
38
|
+
<span style="margin-left: 10px;">Convert unified format back to OCPP version</span>
|
|
39
|
+
</div>
|
|
40
|
+
<div class="form-row">
|
|
41
|
+
<label for="node-input-autoAck"><i class="fa fa-check"></i> Auto ACK</label>
|
|
42
|
+
<input type="checkbox" id="node-input-autoAck" style="width: auto; margin-left: 0;">
|
|
43
|
+
<span style="margin-left: 10px;">Acknowledge inbound message after response</span>
|
|
44
|
+
</div>
|
|
45
|
+
<div class="form-row">
|
|
46
|
+
<label for="node-input-streamMaxLen"><i class="fa fa-list-ol"></i> Stream Max Len</label>
|
|
47
|
+
<input type="number" id="node-input-streamMaxLen" placeholder="1000">
|
|
48
|
+
</div>
|
|
49
|
+
</script>
|
|
50
|
+
|
|
51
|
+
<script type="text/html" data-help-name="ocpp-out">
|
|
52
|
+
<p>Sends OCPP responses back to charging stations via Redis Streams.</p>
|
|
53
|
+
|
|
54
|
+
<h3>Inputs</h3>
|
|
55
|
+
<dl class="message-properties">
|
|
56
|
+
<dt>payload <span class="property-type">object</span></dt>
|
|
57
|
+
<dd>The response payload to send back to the device.</dd>
|
|
58
|
+
|
|
59
|
+
<dt>ocpp <span class="property-type">object</span></dt>
|
|
60
|
+
<dd>OCPP metadata from the input node (required):
|
|
61
|
+
<ul>
|
|
62
|
+
<li><code>messageId</code> - Original message ID (required)</li>
|
|
63
|
+
<li><code>action</code> - OCPP action for denormalization</li>
|
|
64
|
+
<li><code>version</code> - OCPP version for denormalization</li>
|
|
65
|
+
<li><code>tenant</code> - Operator/tenant identifier (required)</li>
|
|
66
|
+
<li><code>deviceId</code> - Charger/station ID (required)</li>
|
|
67
|
+
<li><code>streamId</code> - For auto-acknowledge</li>
|
|
68
|
+
</ul>
|
|
69
|
+
</dd>
|
|
70
|
+
|
|
71
|
+
<dt>ocpp.error <span class="property-type">boolean</span></dt>
|
|
72
|
+
<dd>If true, sends an error response instead of a normal response.</dd>
|
|
73
|
+
|
|
74
|
+
<dt>ocpp.errorCode <span class="property-type">string</span></dt>
|
|
75
|
+
<dd>OCPP error code (e.g., InternalError, NotImplemented).</dd>
|
|
76
|
+
|
|
77
|
+
<dt>ocpp.errorDescription <span class="property-type">string</span></dt>
|
|
78
|
+
<dd>Human-readable error description.</dd>
|
|
79
|
+
</dl>
|
|
80
|
+
|
|
81
|
+
<h3>Outputs</h3>
|
|
82
|
+
<p>The message is passed through unchanged after sending the response.</p>
|
|
83
|
+
|
|
84
|
+
<h3>Properties</h3>
|
|
85
|
+
<dl class="message-properties">
|
|
86
|
+
<dt>Server <span class="property-type">ocpp-config</span></dt>
|
|
87
|
+
<dd>The OCPP configuration node with Redis connection settings.</dd>
|
|
88
|
+
|
|
89
|
+
<dt>Denormalize <span class="property-type">boolean</span></dt>
|
|
90
|
+
<dd>If enabled, converts unified format back to OCPP 1.6 or 2.0.1
|
|
91
|
+
based on the original message version.</dd>
|
|
92
|
+
|
|
93
|
+
<dt>Auto ACK <span class="property-type">boolean</span></dt>
|
|
94
|
+
<dd>If enabled and streamId is present, acknowledges the original
|
|
95
|
+
inbound message after sending the response.</dd>
|
|
96
|
+
|
|
97
|
+
<dt>Stream Max Len <span class="property-type">number</span></dt>
|
|
98
|
+
<dd>Maximum length of the outbound stream (older messages are trimmed).</dd>
|
|
99
|
+
</dl>
|
|
100
|
+
|
|
101
|
+
<h3>Details</h3>
|
|
102
|
+
<p>This node sends responses to the device-specific outbound stream:
|
|
103
|
+
<code>ws:out:{tenant}:{deviceId}</code></p>
|
|
104
|
+
|
|
105
|
+
<p>The WebSocket server reads from this stream and forwards the response
|
|
106
|
+
to the connected charging station.</p>
|
|
107
|
+
|
|
108
|
+
<h3>Error Responses</h3>
|
|
109
|
+
<p>To send an error response, set <code>msg.ocpp.error = true</code>:</p>
|
|
110
|
+
<pre>
|
|
111
|
+
msg.ocpp.error = true;
|
|
112
|
+
msg.ocpp.errorCode = "NotImplemented";
|
|
113
|
+
msg.ocpp.errorDescription = "This action is not supported";
|
|
114
|
+
return msg;
|
|
115
|
+
</pre>
|
|
116
|
+
|
|
117
|
+
<h3>Example Flow</h3>
|
|
118
|
+
<pre>
|
|
119
|
+
[ocpp in] → [handler function] → [ocpp out]
|
|
120
|
+
|
|
121
|
+
// Handler function example:
|
|
122
|
+
msg.payload = {
|
|
123
|
+
status: "Accepted",
|
|
124
|
+
currentTime: new Date().toISOString(),
|
|
125
|
+
interval: 300
|
|
126
|
+
};
|
|
127
|
+
return msg;
|
|
128
|
+
</pre>
|
|
129
|
+
</script>
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OCPP Output Node
|
|
3
|
+
* Sends OCPP responses back to charging stations via Redis Stream
|
|
4
|
+
*/
|
|
5
|
+
const { Keys } = require('../lib/redis-client');
|
|
6
|
+
const { denormalize, buildResponse, buildError } = require('../lib/ocpp-normalizer');
|
|
7
|
+
|
|
8
|
+
module.exports = function (RED) {
|
|
9
|
+
function OCPPOutNode(config) {
|
|
10
|
+
RED.nodes.createNode(this, config);
|
|
11
|
+
const node = this;
|
|
12
|
+
|
|
13
|
+
// Get config node
|
|
14
|
+
node.server = RED.nodes.getNode(config.server);
|
|
15
|
+
if (!node.server) {
|
|
16
|
+
node.error('No OCPP config node configured');
|
|
17
|
+
node.status({ fill: 'red', shape: 'ring', text: 'no config' });
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Node configuration
|
|
22
|
+
node.denormalizePayload = config.denormalizePayload !== false; // Default true
|
|
23
|
+
node.autoAck = config.autoAck !== false; // Default true
|
|
24
|
+
node.streamMaxLen = parseInt(config.streamMaxLen, 10) || 1000;
|
|
25
|
+
|
|
26
|
+
const redis = node.server.redis;
|
|
27
|
+
const consumerGroup = node.server.consumerGroup;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Handle incoming messages
|
|
31
|
+
*/
|
|
32
|
+
node.on('input', async (msg, send, done) => {
|
|
33
|
+
try {
|
|
34
|
+
// Extract OCPP metadata
|
|
35
|
+
const ocpp = msg.ocpp || {};
|
|
36
|
+
const {
|
|
37
|
+
messageId,
|
|
38
|
+
action,
|
|
39
|
+
version,
|
|
40
|
+
tenant,
|
|
41
|
+
deviceId,
|
|
42
|
+
identity,
|
|
43
|
+
streamId,
|
|
44
|
+
} = ocpp;
|
|
45
|
+
|
|
46
|
+
// Validate required fields
|
|
47
|
+
if (!messageId) {
|
|
48
|
+
throw new Error('Missing ocpp.messageId');
|
|
49
|
+
}
|
|
50
|
+
if (!tenant || !deviceId) {
|
|
51
|
+
throw new Error('Missing ocpp.tenant or ocpp.deviceId');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let responsePayload = msg.payload;
|
|
55
|
+
|
|
56
|
+
// Check if this is an error response
|
|
57
|
+
const isError = msg.ocpp?.error || msg.error;
|
|
58
|
+
|
|
59
|
+
if (isError) {
|
|
60
|
+
// Build error response
|
|
61
|
+
const errorCode = msg.ocpp?.errorCode || 'InternalError';
|
|
62
|
+
const errorDescription = msg.ocpp?.errorDescription || msg.error?.message || 'Unknown error';
|
|
63
|
+
const errorDetails = msg.ocpp?.errorDetails || {};
|
|
64
|
+
|
|
65
|
+
const response = buildError(messageId, errorCode, errorDescription, errorDetails);
|
|
66
|
+
|
|
67
|
+
await sendToDevice(tenant, deviceId, response, messageId);
|
|
68
|
+
|
|
69
|
+
} else {
|
|
70
|
+
// Denormalize payload if enabled
|
|
71
|
+
if (node.denormalizePayload && action) {
|
|
72
|
+
const denormalized = denormalize({
|
|
73
|
+
action,
|
|
74
|
+
payload: responsePayload,
|
|
75
|
+
version,
|
|
76
|
+
}, version);
|
|
77
|
+
responsePayload = denormalized.payload;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Build OCPP response
|
|
81
|
+
const response = buildResponse(messageId, responsePayload);
|
|
82
|
+
|
|
83
|
+
await sendToDevice(tenant, deviceId, response, messageId);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Auto-acknowledge the original message if streamId is present
|
|
87
|
+
if (node.autoAck && streamId) {
|
|
88
|
+
const inboundKey = Keys.inbound();
|
|
89
|
+
await redis.xack(inboundKey, consumerGroup, streamId);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Update status
|
|
93
|
+
node.status({
|
|
94
|
+
fill: 'green',
|
|
95
|
+
shape: 'dot',
|
|
96
|
+
text: `${action || 'response'} → ${identity}`,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Forward message if needed
|
|
100
|
+
if (send) {
|
|
101
|
+
send(msg);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (done) {
|
|
105
|
+
done();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
} catch (err) {
|
|
109
|
+
node.error(`Output error: ${err.message}`, msg);
|
|
110
|
+
node.status({ fill: 'red', shape: 'dot', text: err.message });
|
|
111
|
+
|
|
112
|
+
if (done) {
|
|
113
|
+
done(err);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Send response to device via Redis Stream
|
|
120
|
+
*/
|
|
121
|
+
async function sendToDevice(tenant, deviceId, response, messageId) {
|
|
122
|
+
const outboundKey = Keys.outbound(tenant, deviceId);
|
|
123
|
+
|
|
124
|
+
// XADD with MAXLEN to prevent unbounded growth
|
|
125
|
+
await redis.xadd(
|
|
126
|
+
outboundKey,
|
|
127
|
+
'MAXLEN', '~', node.streamMaxLen,
|
|
128
|
+
'*',
|
|
129
|
+
'messageId', messageId,
|
|
130
|
+
'data', response,
|
|
131
|
+
'timestamp', Date.now().toString()
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
node.log(`Sent response to ${outboundKey}: ${messageId}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Initial status
|
|
138
|
+
node.status({ fill: 'grey', shape: 'ring', text: 'ready' });
|
|
139
|
+
|
|
140
|
+
// Update status when Redis is ready
|
|
141
|
+
if (redis) {
|
|
142
|
+
redis.once('ready', () => {
|
|
143
|
+
node.status({ fill: 'green', shape: 'ring', text: 'connected' });
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
if (redis.status === 'ready') {
|
|
147
|
+
node.status({ fill: 'green', shape: 'ring', text: 'connected' });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
RED.nodes.registerType('ocpp-out', OCPPOutNode);
|
|
153
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@blokcert/node-red-contrib-ocpp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Node-RED nodes for OCPP (Open Charge Point Protocol) message handling via Redis Streams",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"node-red",
|
|
7
|
+
"ocpp",
|
|
8
|
+
"ocpp1.6",
|
|
9
|
+
"ocpp2.0.1",
|
|
10
|
+
"ev-charging",
|
|
11
|
+
"redis-streams"
|
|
12
|
+
],
|
|
13
|
+
"author": "BLOKcert",
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "https://github.com/MingShyanWei/node-red-contrib-ocpp"
|
|
18
|
+
},
|
|
19
|
+
"node-red": {
|
|
20
|
+
"version": ">=2.0.0",
|
|
21
|
+
"nodes": {
|
|
22
|
+
"ocpp-in": "nodes/ocpp-in.js",
|
|
23
|
+
"ocpp-out": "nodes/ocpp-out.js",
|
|
24
|
+
"ocpp-command": "nodes/ocpp-command.js",
|
|
25
|
+
"ocpp-config": "nodes/ocpp-config.js"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"ioredis": "^5.3.2"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"node-red": "^3.0.0",
|
|
33
|
+
"node-red-node-test-helper": "^0.3.2",
|
|
34
|
+
"mocha": "^10.2.0",
|
|
35
|
+
"chai": "^4.3.7"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"test": "mocha 'test/**/*_spec.js' --timeout 10000"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=18.0.0"
|
|
42
|
+
}
|
|
43
|
+
}
|