@blokcert/node-red-contrib-ocpp 1.1.0 → 1.2.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/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [1.2.0] - 2026-02-12
6
+
7
+ ### Changed
8
+ - **BREAKING**: `ocpp-command` node now has single output (device response only)
9
+ - `ocpp-command` now subscribes to Redis Pub/Sub to receive responses directly
10
+ - Removed "Command Sent" output - node only outputs when device responds or times out
11
+
12
+ ### Removed
13
+ - **Removed `ocpp-response` node** - No longer needed, `ocpp-command` handles responses directly
14
+
15
+ ### Fixed
16
+ - Responses from chargers now properly route back to `ocpp-command` node via WS Server
17
+
18
+ ## [1.1.1] - 2026-02-12
19
+
20
+ ### Fixed
21
+ - Fixed `ocpp-response` node to correctly find and notify `ocpp-command` nodes using `RED.nodes.getNode()` instead of `RED.nodes.eachNode()`
22
+
5
23
  ## [1.1.0] - 2026-02-12
6
24
 
7
25
  ### Added
@@ -8,11 +8,10 @@
8
8
  command: { value: '' },
9
9
  timeout: { value: 30000, validate: RED.validators.number() },
10
10
  streamMaxLen: { value: 1000, validate: RED.validators.number() },
11
- waitForResponse: { value: true },
12
11
  },
13
12
  inputs: 1,
14
- outputs: 2,
15
- outputLabels: ['sent', 'response/error'],
13
+ outputs: 1,
14
+ outputLabels: ['response'],
16
15
  icon: 'font-awesome/fa-bolt',
17
16
  paletteLabel: 'ocpp command',
18
17
  label: function () {
@@ -75,11 +74,6 @@
75
74
  <label for="node-input-timeout"><i class="fa fa-clock-o"></i> Timeout (ms)</label>
76
75
  <input type="number" id="node-input-timeout" placeholder="30000">
77
76
  </div>
78
- <div class="form-row">
79
- <label for="node-input-waitForResponse"><i class="fa fa-reply"></i> Wait Response</label>
80
- <input type="checkbox" id="node-input-waitForResponse" style="width: auto; margin-left: 0;">
81
- <span style="margin-left: 10px;">Wait for device response (enables second output)</span>
82
- </div>
83
77
  <div class="form-row">
84
78
  <label for="node-input-streamMaxLen"><i class="fa fa-list-ol"></i> Stream Max Len</label>
85
79
  <input type="number" id="node-input-streamMaxLen" placeholder="1000">
@@ -87,7 +81,7 @@
87
81
  </script>
88
82
 
89
83
  <script type="text/html" data-help-name="ocpp-command">
90
- <p>Sends CSMS-initiated commands to charging stations.</p>
84
+ <p>Sends CSMS-initiated commands to charging stations and outputs the device response.</p>
91
85
 
92
86
  <h3>Inputs</h3>
93
87
  <dl class="message-properties">
@@ -107,31 +101,23 @@
107
101
  <dd>Custom message ID (auto-generated if not provided).</dd>
108
102
  </dl>
109
103
 
110
- <h3>Outputs</h3>
111
- <ol class="node-ports">
112
- <li>Command Sent
113
- <dl class="message-properties">
114
- <dt>payload <span class="property-type">object</span></dt>
115
- <dd>Original payload</dd>
116
- <dt>ocpp.messageId <span class="property-type">string</span></dt>
117
- <dd>Generated/provided message ID</dd>
118
- <dt>ocpp.sentAt <span class="property-type">number</span></dt>
119
- <dd>Timestamp when command was sent</dd>
120
- </dl>
121
- </li>
122
- <li>Response/Error (when Wait Response is enabled)
123
- <dl class="message-properties">
124
- <dt>payload <span class="property-type">object</span></dt>
125
- <dd>Response payload from device</dd>
126
- <dt>ocpp.response <span class="property-type">boolean</span></dt>
127
- <dd>True if this is a response</dd>
128
- <dt>ocpp.error <span class="property-type">boolean</span></dt>
129
- <dd>True if this is an error/timeout</dd>
130
- <dt>ocpp.receivedAt <span class="property-type">number</span></dt>
131
- <dd>Timestamp when response was received</dd>
132
- </dl>
133
- </li>
134
- </ol>
104
+ <h3>Output</h3>
105
+ <dl class="message-properties">
106
+ <dt>payload <span class="property-type">object</span></dt>
107
+ <dd>Response payload from device (or null on timeout)</dd>
108
+ <dt>ocpp.messageId <span class="property-type">string</span></dt>
109
+ <dd>Message ID of the command</dd>
110
+ <dt>ocpp.action <span class="property-type">string</span></dt>
111
+ <dd>Command action that was sent</dd>
112
+ <dt>ocpp.response <span class="property-type">boolean</span></dt>
113
+ <dd>Always true for output messages</dd>
114
+ <dt>ocpp.error <span class="property-type">boolean</span></dt>
115
+ <dd>True if this is an error or timeout</dd>
116
+ <dt>ocpp.errorCode <span class="property-type">string</span></dt>
117
+ <dd>Error code (if error)</dd>
118
+ <dt>ocpp.latency <span class="property-type">number</span></dt>
119
+ <dd>Round-trip time in milliseconds</dd>
120
+ </dl>
135
121
 
136
122
  <h3>Properties</h3>
137
123
  <dl class="message-properties">
@@ -139,16 +125,14 @@
139
125
  <dd>OCPP command to send. Can be overridden via <code>msg.ocpp.command</code>.</dd>
140
126
 
141
127
  <dt>Timeout <span class="property-type">number</span></dt>
142
- <dd>How long to wait for device response (ms).</dd>
143
-
144
- <dt>Wait Response <span class="property-type">boolean</span></dt>
145
- <dd>If enabled, waits for device response and outputs on second port.</dd>
128
+ <dd>How long to wait for device response (ms). Default: 30000</dd>
146
129
  </dl>
147
130
 
148
131
  <h3>Common Commands</h3>
149
132
  <table>
150
133
  <tr><th>Command</th><th>Description</th></tr>
151
134
  <tr><td>Reset</td><td>Reboot the charging station</td></tr>
135
+ <tr><td>GetConfiguration</td><td>Get device configuration keys</td></tr>
152
136
  <tr><td>RemoteStartTransaction</td><td>Start charging remotely (1.6)</td></tr>
153
137
  <tr><td>RequestStartTransaction</td><td>Start charging remotely (2.0.1)</td></tr>
154
138
  <tr><td>UnlockConnector</td><td>Unlock a connector</td></tr>
@@ -156,34 +140,18 @@
156
140
  <tr><td>ChangeAvailability</td><td>Set connector availability</td></tr>
157
141
  </table>
158
142
 
159
- <h3>Example: Remote Start</h3>
143
+ <h3>Example: GetConfiguration</h3>
160
144
  <pre>
161
- // For OCPP 1.6
162
145
  msg.ocpp = {
163
- tenant: "operator1",
146
+ tenant: "test",
164
147
  deviceId: "charger001",
165
- command: "RemoteStartTransaction"
166
- };
167
- msg.payload = {
168
- connectorId: 1,
169
- idTag: "USER123"
148
+ command: "GetConfiguration"
170
149
  };
150
+ msg.payload = {}; // Empty = get all keys
171
151
  return msg;
172
152
 
173
- // For OCPP 2.0.1
174
- msg.ocpp = {
175
- tenant: "operator1",
176
- deviceId: "station001",
177
- command: "RequestStartTransaction"
178
- };
179
- msg.payload = {
180
- evseId: 1,
181
- idToken: {
182
- idToken: "USER123",
183
- type: "ISO14443"
184
- }
185
- };
186
- return msg;
153
+ // Response will contain:
154
+ // msg.payload.configurationKey = [...]
187
155
  </pre>
188
156
 
189
157
  <h3>Example: Reset</h3>
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * OCPP Command Node
3
3
  * Sends CSMS-initiated commands to charging stations
4
+ *
5
+ * Output: Device response (or timeout error)
4
6
  */
5
7
  const { Keys } = require('../lib/redis-client');
6
8
  const { buildCall } = require('../lib/ocpp-normalizer');
@@ -23,13 +25,16 @@ module.exports = function (RED) {
23
25
  node.command = config.command || ''; // Can be overridden by msg
24
26
  node.timeout = parseInt(config.timeout, 10) || 30000;
25
27
  node.streamMaxLen = parseInt(config.streamMaxLen, 10) || 1000;
26
- node.waitForResponse = config.waitForResponse !== false; // Default true
27
28
 
28
29
  const redis = node.server.redis;
29
30
 
30
- // Pending commands waiting for response
31
+ // Create a separate Redis connection for Pub/Sub
32
+ let subRedis = null;
31
33
  const pendingCommands = new Map();
32
34
 
35
+ // Generate unique server ID for this node instance
36
+ const nodeServerId = `nodered-${node.id.slice(0, 8)}-${Date.now()}`;
37
+
33
38
  /**
34
39
  * Generate unique message ID
35
40
  */
@@ -37,6 +42,101 @@ module.exports = function (RED) {
37
42
  return crypto.randomUUID();
38
43
  }
39
44
 
45
+ /**
46
+ * Initialize Pub/Sub subscriber
47
+ */
48
+ async function initSubscriber() {
49
+ try {
50
+ // Duplicate the Redis connection for Pub/Sub
51
+ subRedis = redis.duplicate();
52
+
53
+ subRedis.on('error', (err) => {
54
+ node.error(`Subscriber Redis error: ${err.message}`);
55
+ });
56
+
57
+ // Subscribe to our response channel
58
+ const respChannel = `ws:resp:${nodeServerId}`;
59
+ await subRedis.subscribe(respChannel);
60
+ node.log(`Subscribed to ${respChannel}`);
61
+
62
+ // Handle incoming messages
63
+ subRedis.on('message', (channel, message) => {
64
+ try {
65
+ const data = JSON.parse(message);
66
+ const messageId = data.messageId || data.uniqueID;
67
+
68
+ if (!messageId) {
69
+ node.warn('Received response without messageId');
70
+ return;
71
+ }
72
+
73
+ const pending = pendingCommands.get(messageId);
74
+ if (!pending) {
75
+ // Not our command, ignore
76
+ return;
77
+ }
78
+
79
+ // Clear timeout
80
+ clearTimeout(pending.timeoutId);
81
+ pendingCommands.delete(messageId);
82
+
83
+ // Remove from Redis pending hash
84
+ const pendingKey = Keys.pending(pending.tenant, pending.deviceId);
85
+ redis.hdel(pendingKey, messageId).catch(() => {});
86
+
87
+ // Determine if error
88
+ const isError = data.messageType === 4 || data.error === true;
89
+
90
+ // Update status
91
+ node.status({
92
+ fill: isError ? 'red' : 'green',
93
+ shape: 'dot',
94
+ text: `${pending.action}: ${isError ? 'error' : 'ok'}`,
95
+ });
96
+
97
+ // Build output message with device response
98
+ const responseMsg = {
99
+ payload: data.payload || data,
100
+ ocpp: {
101
+ messageId,
102
+ action: pending.action,
103
+ tenant: pending.tenant,
104
+ deviceId: pending.deviceId,
105
+ identity: `${pending.tenant}:${pending.deviceId}`,
106
+ response: true,
107
+ error: isError,
108
+ errorCode: data.errorCode,
109
+ errorDescription: data.errorDescription,
110
+ sentAt: pending.sentAt,
111
+ receivedAt: Date.now(),
112
+ latency: Date.now() - pending.sentAt,
113
+ },
114
+ };
115
+
116
+ node.send(responseMsg);
117
+
118
+ } catch (err) {
119
+ node.error(`Error processing response: ${err.message}`);
120
+ }
121
+ });
122
+
123
+ node.status({ fill: 'green', shape: 'ring', text: 'ready' });
124
+
125
+ } catch (err) {
126
+ node.error(`Failed to initialize subscriber: ${err.message}`);
127
+ node.status({ fill: 'red', shape: 'ring', text: 'sub error' });
128
+ }
129
+ }
130
+
131
+ // Initialize subscriber when Redis is ready
132
+ if (redis.status === 'ready') {
133
+ initSubscriber();
134
+ } else {
135
+ redis.once('ready', () => {
136
+ initSubscriber();
137
+ });
138
+ }
139
+
40
140
  /**
41
141
  * Handle incoming messages
42
142
  */
@@ -65,6 +165,22 @@ module.exports = function (RED) {
65
165
  // Build OCPP CALL message
66
166
  const callMessage = buildCall(messageId, action, payload);
67
167
 
168
+ // Store pending command for response matching
169
+ const pendingKey = Keys.pending(tenant, deviceId);
170
+ const sentAt = Date.now();
171
+
172
+ // Store command info in Redis for response matching
173
+ // Include our serverId so WS Server knows where to send response
174
+ await redis.hset(pendingKey,
175
+ messageId, JSON.stringify({
176
+ action,
177
+ sentAt,
178
+ timeout: node.timeout,
179
+ serverId: nodeServerId, // This tells WS Server where to send response
180
+ })
181
+ );
182
+ await redis.expire(pendingKey, Math.ceil(node.timeout / 1000) + 10);
183
+
68
184
  // Send to device
69
185
  const outboundKey = Keys.outbound(tenant, deviceId);
70
186
  await redis.xadd(
@@ -73,10 +189,11 @@ module.exports = function (RED) {
73
189
  '*',
74
190
  'id', messageId,
75
191
  'payload', callMessage,
76
- 'timestamp', Date.now().toString()
192
+ 'timestamp', sentAt.toString(),
193
+ 'serverId', nodeServerId // Include serverId in outbound message
77
194
  );
78
195
 
79
- node.log(`Sent command ${action} to ${tenant}:${deviceId} (${messageId})`);
196
+ node.log(`Sent ${action} to ${tenant}:${deviceId} (${messageId})`);
80
197
 
81
198
  // Update status
82
199
  node.status({
@@ -85,82 +202,52 @@ module.exports = function (RED) {
85
202
  text: `${action} → ${tenant}:${deviceId}`,
86
203
  });
87
204
 
88
- // Prepare output message
89
- const outMsg = {
90
- ...msg,
91
- ocpp: {
92
- ...msg.ocpp,
93
- messageId,
94
- action,
95
- tenant,
96
- deviceId,
97
- identity: `${tenant}:${deviceId}`,
98
- sentAt: Date.now(),
99
- },
100
- };
101
-
102
- if (node.waitForResponse) {
103
- // Store pending command for response matching
104
- const pendingKey = Keys.pending(tenant, deviceId);
105
-
106
- // Store command info in Redis for response matching
107
- await redis.hset(pendingKey,
108
- messageId, JSON.stringify({
109
- action,
110
- sentAt: Date.now(),
111
- timeout: node.timeout,
112
- })
113
- );
114
- await redis.expire(pendingKey, Math.ceil(node.timeout / 1000) + 10);
115
-
116
- // Set up timeout
117
- const timeoutId = setTimeout(async () => {
118
- pendingCommands.delete(messageId);
119
-
120
- // Remove from Redis
121
- await redis.hdel(pendingKey, messageId).catch(() => {});
122
-
123
- node.status({
124
- fill: 'yellow',
125
- shape: 'dot',
126
- text: `timeout: ${action}`,
127
- });
205
+ // Set up timeout
206
+ const timeoutId = setTimeout(async () => {
207
+ pendingCommands.delete(messageId);
128
208
 
129
- // Send timeout error on second output
130
- const timeoutMsg = {
131
- ...outMsg,
132
- ocpp: {
133
- ...outMsg.ocpp,
134
- error: true,
135
- errorCode: 'Timeout',
136
- errorDescription: `Command ${action} timed out after ${node.timeout}ms`,
137
- },
138
- };
209
+ // Remove from Redis
210
+ await redis.hdel(pendingKey, messageId).catch(() => {});
139
211
 
140
- node.send([null, timeoutMsg]);
141
- }, node.timeout);
142
-
143
- // Store for cleanup
144
- pendingCommands.set(messageId, {
145
- timeoutId,
146
- msg: outMsg,
147
- action,
212
+ node.status({
213
+ fill: 'yellow',
214
+ shape: 'dot',
215
+ text: `timeout: ${action}`,
148
216
  });
149
217
 
150
- // Send on first output (command sent)
151
- if (send) {
152
- send([outMsg, null]);
153
- } else {
154
- node.send([outMsg, null]);
155
- }
156
- } else {
157
- // Don't wait for response, just send confirmation
218
+ // Send timeout error
219
+ const timeoutMsg = {
220
+ payload: null,
221
+ ocpp: {
222
+ messageId,
223
+ action,
224
+ tenant,
225
+ deviceId,
226
+ identity: `${tenant}:${deviceId}`,
227
+ response: true,
228
+ error: true,
229
+ errorCode: 'Timeout',
230
+ errorDescription: `Command ${action} timed out after ${node.timeout}ms`,
231
+ sentAt,
232
+ receivedAt: Date.now(),
233
+ },
234
+ };
235
+
158
236
  if (send) {
159
- send(outMsg);
237
+ send(timeoutMsg);
160
238
  } else {
161
- node.send(outMsg);
239
+ node.send(timeoutMsg);
162
240
  }
163
- }
241
+ }, node.timeout);
242
+
243
+ // Store for response matching
244
+ pendingCommands.set(messageId, {
245
+ timeoutId,
246
+ action,
247
+ tenant,
248
+ deviceId,
249
+ sentAt,
250
+ });
164
251
 
165
252
  if (done) {
166
253
  done();
@@ -176,63 +263,27 @@ module.exports = function (RED) {
176
263
  }
177
264
  });
178
265
 
179
- /**
180
- * Handle response messages (can be connected from another ocpp-in node filtering for responses)
181
- */
182
- node.handleResponse = function (messageId, response, isError) {
183
- const pending = pendingCommands.get(messageId);
184
- if (!pending) {
185
- return false; // Not our command
186
- }
187
-
188
- // Clear timeout
189
- clearTimeout(pending.timeoutId);
190
- pendingCommands.delete(messageId);
191
-
192
- // Update status
193
- node.status({
194
- fill: isError ? 'red' : 'green',
195
- shape: 'dot',
196
- text: `${pending.action}: ${isError ? 'error' : 'response'}`,
197
- });
198
-
199
- // Send response on second output
200
- const responseMsg = {
201
- ...pending.msg,
202
- payload: response,
203
- ocpp: {
204
- ...pending.msg.ocpp,
205
- response: true,
206
- error: isError,
207
- receivedAt: Date.now(),
208
- },
209
- };
210
-
211
- node.send([null, responseMsg]);
212
- return true;
213
- };
214
-
215
266
  // Initial status
216
- node.status({ fill: 'grey', shape: 'ring', text: 'ready' });
217
-
218
- // Update status when Redis is ready
219
- if (redis) {
220
- redis.once('ready', () => {
221
- node.status({ fill: 'green', shape: 'ring', text: 'connected' });
222
- });
223
-
224
- if (redis.status === 'ready') {
225
- node.status({ fill: 'green', shape: 'ring', text: 'connected' });
226
- }
227
- }
267
+ node.status({ fill: 'grey', shape: 'ring', text: 'initializing' });
228
268
 
229
269
  // Cleanup on close
230
- node.on('close', (done) => {
270
+ node.on('close', async (done) => {
231
271
  // Clear all pending timeouts
232
272
  for (const [, pending] of pendingCommands) {
233
273
  clearTimeout(pending.timeoutId);
234
274
  }
235
275
  pendingCommands.clear();
276
+
277
+ // Cleanup subscriber
278
+ if (subRedis) {
279
+ try {
280
+ await subRedis.unsubscribe();
281
+ await subRedis.quit();
282
+ } catch (e) {
283
+ // Ignore cleanup errors
284
+ }
285
+ }
286
+
236
287
  done();
237
288
  });
238
289
  }
@@ -81,16 +81,32 @@ module.exports = function (RED) {
81
81
 
82
82
  // Find and notify all ocpp-command nodes
83
83
  let handled = false;
84
+
85
+ // Get all flows and find ocpp-command nodes
86
+ const allNodes = [];
84
87
  RED.nodes.eachNode((n) => {
85
- if (n.type === 'ocpp-command' && n.handleResponse) {
86
- // Try to handle this response
87
- const wasHandled = n.handleResponse(messageId, msg.payload, isError);
88
- if (wasHandled) {
89
- handled = true;
90
- }
88
+ if (n.type === 'ocpp-command') {
89
+ allNodes.push(n.id);
91
90
  }
92
91
  });
93
92
 
93
+ // Try to get the runtime node instance and call handleResponse
94
+ for (const nodeId of allNodes) {
95
+ try {
96
+ const commandNode = RED.nodes.getNode(nodeId);
97
+ if (commandNode && typeof commandNode.handleResponse === 'function') {
98
+ const wasHandled = commandNode.handleResponse(messageId, msg.payload, isError);
99
+ if (wasHandled) {
100
+ handled = true;
101
+ node.log(`Response ${messageId} handled by ocpp-command node ${nodeId}`);
102
+ break; // Only one node should handle each response
103
+ }
104
+ }
105
+ } catch (e) {
106
+ node.warn(`Error calling handleResponse on node ${nodeId}: ${e.message}`);
107
+ }
108
+ }
109
+
94
110
  // Update status
95
111
  if (handled) {
96
112
  node.status({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blokcert/node-red-contrib-ocpp",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Node-RED nodes for OCPP (Open Charge Point Protocol) message handling via Redis Streams",
5
5
  "keywords": [
6
6
  "node-red",
@@ -22,7 +22,6 @@
22
22
  "ocpp-in": "nodes/ocpp-in.js",
23
23
  "ocpp-out": "nodes/ocpp-out.js",
24
24
  "ocpp-command": "nodes/ocpp-command.js",
25
- "ocpp-response": "nodes/ocpp-response.js",
26
25
  "ocpp-config": "nodes/ocpp-config.js"
27
26
  }
28
27
  },