@blokcert/node-red-contrib-ocpp 1.0.2 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,46 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## [1.1.1] - 2026-02-12
6
+
7
+ ### Fixed
8
+ - Fixed `ocpp-response` node to correctly find and notify `ocpp-command` nodes using `RED.nodes.getNode()` instead of `RED.nodes.eachNode()`
9
+
10
+ ## [1.1.0] - 2026-02-12
11
+
12
+ ### Added
13
+ - **New `ocpp-response` node** - Routes CALLRESULT/CALLERROR responses back to the originating ocpp-command nodes
14
+ - Response message support in `ocpp-in` node with `messageType` and `ocppMessageType` fields
15
+ - New filter options in `ocpp-in`:
16
+ - `_CallResult` - Filter for only CALLRESULT responses
17
+ - `_CallError` - Filter for only CALLERROR responses
18
+ - `_Response` - Filter for both response types
19
+
20
+ ### Fixed
21
+ - Fixed parsing of CALLRESULT/CALLERROR messages (previously showed as "[Unknown]")
22
+ - Response messages now correctly set `action` to `_CallResult` or `_CallError` for routing
23
+
24
+ ### Changed
25
+ - `ocpp-in` status now shows descriptive text for response messages (e.g., "Response abc123 from test:charger001")
26
+
27
+ ## [1.0.2] - 2026-02-10
28
+
29
+ ### Fixed
30
+ - Minor bug fixes
31
+
32
+ ## [1.0.1] - 2026-02-08
33
+
34
+ ### Fixed
35
+ - Initial bug fixes after release
36
+
37
+ ## [1.0.0] - 2026-02-05
38
+
39
+ ### Added
40
+ - Initial release
41
+ - `ocpp-in` node for receiving OCPP messages via Redis Streams
42
+ - `ocpp-out` node for sending OCPP responses
43
+ - `ocpp-command` node for sending CSMS-initiated commands
44
+ - `ocpp-config` node for Redis connection configuration
45
+ - OCPP 1.6 and 2.0.1 support with automatic normalization/denormalization
46
+ - Consumer group support for horizontal scaling
package/README.md CHANGED
@@ -34,16 +34,25 @@ Receives OCPP messages from charging stations via Redis Streams.
34
34
  **Features:**
35
35
  - Consumer group support for load balancing
36
36
  - Optional payload normalization (OCPP 1.6/2.0.1 → unified format)
37
- - Filter by action type
37
+ - Filter by action type or response type
38
38
  - Auto-acknowledge messages
39
39
 
40
+ **Filter Options:**
41
+ - Empty: Receive all messages (CALL requests + responses)
42
+ - Action name (e.g., `BootNotification`): Only CALL requests with this action
43
+ - `_CallResult`: Only CALLRESULT responses
44
+ - `_CallError`: Only CALLERROR responses
45
+ - `_Response`: Both CALLRESULT and CALLERROR responses
46
+
40
47
  **Output:**
41
48
  ```javascript
42
49
  {
43
50
  payload: { /* OCPP message payload */ },
44
51
  ocpp: {
45
52
  messageId: "abc123",
46
- action: "BootNotification",
53
+ action: "BootNotification", // or "_CallResult" / "_CallError" for responses
54
+ messageType: "call", // "call", "result", or "error"
55
+ ocppMessageType: 2, // 2=CALL, 3=CALLRESULT, 4=CALLERROR
47
56
  version: "1.6",
48
57
  tenant: "operator1",
49
58
  deviceId: "charger001",
@@ -94,6 +103,10 @@ Sends CSMS-initiated commands to charging stations.
94
103
  - ClearCache
95
104
  - And more...
96
105
 
106
+ **Outputs:**
107
+ - Output 1: Command sent confirmation
108
+ - Output 2: Device response or timeout error (when "Wait for Response" is enabled)
109
+
97
110
  **Example:**
98
111
  ```javascript
99
112
  msg.ocpp = {
@@ -107,6 +120,37 @@ msg.payload = {
107
120
  return msg;
108
121
  ```
109
122
 
123
+ ### ocpp response
124
+
125
+ Routes CALLRESULT/CALLERROR responses back to the originating ocpp-command nodes.
126
+
127
+ **Features:**
128
+ - Automatically matches responses to pending commands
129
+ - Notifies ocpp-command nodes of responses
130
+ - Calculates round-trip latency
131
+
132
+ **Setup:**
133
+ Connect this node to an `ocpp in` node (optionally filtered by `_Response`) to receive device responses:
134
+
135
+ ```
136
+ [ocpp in (filter: _Response)] → [ocpp response] → [debug]
137
+ ```
138
+
139
+ **Output:**
140
+ ```javascript
141
+ {
142
+ payload: { /* device response */ },
143
+ ocpp: {
144
+ messageId: "abc123",
145
+ action: "GetConfiguration", // Original command
146
+ response: true,
147
+ error: false,
148
+ latency: 150, // Round-trip time in ms
149
+ handled: true // Whether an ocpp-command node was notified
150
+ }
151
+ }
152
+ ```
153
+
110
154
  ## Example Flow
111
155
 
112
156
  ```
@@ -22,7 +22,7 @@ const MessageType = {
22
22
  function parseStreamMessage(streamData) {
23
23
  const { tenant, deviceId, timestamp } = streamData;
24
24
 
25
- // Parse metadata (contains action, version, serverID)
25
+ // Parse metadata (contains action, version, serverID, type)
26
26
  let metadata = {};
27
27
  if (streamData.metadata) {
28
28
  try {
@@ -47,8 +47,21 @@ function parseStreamMessage(streamData) {
47
47
  // Extract messageId from payload.uniqueID or streamData.id
48
48
  const messageId = parsedPayload?.uniqueID || streamData.id;
49
49
 
50
- // Extract action from metadata or payload
51
- const action = metadata.action || parsedPayload?.action;
50
+ // Determine message type from metadata (call, result, error)
51
+ // messageType in payload contains the OCPP numeric type (2=CALL, 3=CALLRESULT, 4=CALLERROR)
52
+ const msgType = metadata.type || 'call';
53
+ const ocppMessageType = parsedPayload?.messageType || MessageType.CALL;
54
+
55
+ // Extract action from metadata or payload (only for CALL messages)
56
+ let action = metadata.action || parsedPayload?.action;
57
+
58
+ // For result/error messages, action is not present in the message
59
+ // We mark them with special action names for routing
60
+ if (msgType === 'result' || ocppMessageType === MessageType.CALLRESULT) {
61
+ action = action || '_CallResult';
62
+ } else if (msgType === 'error' || ocppMessageType === MessageType.CALLERROR) {
63
+ action = action || '_CallError';
64
+ }
52
65
 
53
66
  // Extract actual payload content
54
67
  const actualPayload = parsedPayload?.payload || parsedPayload;
@@ -62,6 +75,8 @@ function parseStreamMessage(streamData) {
62
75
  tenant,
63
76
  deviceId,
64
77
  action,
78
+ messageType: msgType, // 'call', 'result', or 'error'
79
+ ocppMessageType, // 2, 3, or 4
65
80
  payload: actualPayload,
66
81
  version,
67
82
  protocol,
@@ -72,7 +72,9 @@
72
72
  <dd>OCPP message metadata:
73
73
  <ul>
74
74
  <li><code>messageId</code> - Unique message ID</li>
75
- <li><code>action</code> - OCPP action (e.g., BootNotification, Heartbeat)</li>
75
+ <li><code>action</code> - OCPP action (e.g., BootNotification) or <code>_CallResult</code>/<code>_CallError</code> for responses</li>
76
+ <li><code>messageType</code> - "call", "result", or "error"</li>
77
+ <li><code>ocppMessageType</code> - Numeric OCPP message type (2=CALL, 3=CALLRESULT, 4=CALLERROR)</li>
76
78
  <li><code>version</code> - OCPP version (1.6 or 2.0.1)</li>
77
79
  <li><code>tenant</code> - Operator/tenant identifier</li>
78
80
  <li><code>deviceId</code> - Charger/station ID</li>
@@ -104,16 +106,26 @@
104
106
  This allows your flow to handle both versions with the same logic.</dd>
105
107
 
106
108
  <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
+ <dd>Optional. Only output messages matching this filter.
110
+ Leave empty to receive all messages.</dd>
109
111
  </dl>
110
112
 
111
113
  <h3>Details</h3>
112
114
  <p>This node reads from the <code>ws:inbound</code> Redis Stream using consumer groups,
113
115
  allowing multiple Node-RED instances to share the message load.</p>
114
116
 
115
- <h3>Supported Actions</h3>
116
- <p>Common OCPP actions that can be filtered:</p>
117
+ <h3>Filter Options</h3>
118
+ <p>The Filter Action field supports these values:</p>
119
+ <ul>
120
+ <li><strong>Empty</strong> - Receive all messages (CALL requests + responses)</li>
121
+ <li><strong>Action name</strong> (e.g., <code>BootNotification</code>) - Only receive CALL requests with this action</li>
122
+ <li><code>_CallResult</code> - Only receive CALLRESULT responses</li>
123
+ <li><code>_CallError</code> - Only receive CALLERROR responses</li>
124
+ <li><code>_Response</code> - Receive both CALLRESULT and CALLERROR responses</li>
125
+ </ul>
126
+
127
+ <h3>Common OCPP Actions</h3>
128
+ <p>Actions that can be filtered:</p>
117
129
  <ul>
118
130
  <li><code>BootNotification</code></li>
119
131
  <li><code>Heartbeat</code></li>
package/nodes/ocpp-in.js CHANGED
@@ -112,12 +112,35 @@ module.exports = function (RED) {
112
112
  msg.streamId = streamId;
113
113
 
114
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);
115
+ // Special filter values:
116
+ // - "_CallResult" - only CALLRESULT responses
117
+ // - "_CallError" - only CALLERROR responses
118
+ // - "_Response" - both CALLRESULT and CALLERROR
119
+ // - Any other value filters CALL messages by action name
120
+ if (node.filterAction) {
121
+ const filter = node.filterAction;
122
+ const isResult = msg.messageType === 'result';
123
+ const isError = msg.messageType === 'error';
124
+
125
+ let shouldPass = false;
126
+ if (filter === '_CallResult') {
127
+ shouldPass = isResult;
128
+ } else if (filter === '_CallError') {
129
+ shouldPass = isError;
130
+ } else if (filter === '_Response') {
131
+ shouldPass = isResult || isError;
132
+ } else {
133
+ // Filter CALL messages by action, pass through responses
134
+ shouldPass = msg.action === filter;
135
+ }
136
+
137
+ if (!shouldPass) {
138
+ // Auto-ack filtered messages if autoAck is enabled
139
+ if (node.autoAck) {
140
+ await redis.xack(streamKey, consumerGroup, streamId);
141
+ }
142
+ return;
119
143
  }
120
- return;
121
144
  }
122
145
 
123
146
  // Normalize payload if enabled
@@ -131,6 +154,8 @@ module.exports = function (RED) {
131
154
  ocpp: {
132
155
  messageId: msg.messageId,
133
156
  action: msg.action,
157
+ messageType: msg.messageType, // 'call', 'result', or 'error'
158
+ ocppMessageType: msg.ocppMessageType, // 2, 3, or 4
134
159
  version: msg.version,
135
160
  protocol: msg.protocol,
136
161
  tenant: msg.tenant,
@@ -143,11 +168,19 @@ module.exports = function (RED) {
143
168
  _msgid: RED.util.generateId(),
144
169
  };
145
170
 
146
- // Update status
171
+ // Update status - show different text for call vs result/error
172
+ let statusText;
173
+ if (msg.messageType === 'result') {
174
+ statusText = `Response ${msg.messageId?.slice(0, 8)} from ${msg.identity}`;
175
+ } else if (msg.messageType === 'error') {
176
+ statusText = `Error ${msg.messageId?.slice(0, 8)} from ${msg.identity}`;
177
+ } else {
178
+ statusText = `${msg.action} from ${msg.identity}`;
179
+ }
147
180
  node.status({
148
181
  fill: 'green',
149
182
  shape: 'dot',
150
- text: `${msg.action} from ${msg.identity}`,
183
+ text: statusText,
151
184
  });
152
185
 
153
186
  // Send to output
@@ -0,0 +1,98 @@
1
+ <script type="text/javascript">
2
+ RED.nodes.registerType('ocpp-response', {
3
+ category: 'OCPP',
4
+ color: '#89D689',
5
+ defaults: {
6
+ name: { value: '' },
7
+ server: { value: '', type: 'ocpp-config', required: true },
8
+ },
9
+ inputs: 1,
10
+ outputs: 1,
11
+ icon: 'font-awesome/fa-reply',
12
+ label: function () {
13
+ return this.name || 'ocpp response';
14
+ },
15
+ paletteLabel: 'ocpp response',
16
+ });
17
+ </script>
18
+
19
+ <script type="text/html" data-template-name="ocpp-response">
20
+ <div class="form-row">
21
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
22
+ <input type="text" id="node-input-name" placeholder="Name">
23
+ </div>
24
+ <div class="form-row">
25
+ <label for="node-input-server"><i class="fa fa-server"></i> Server</label>
26
+ <input type="text" id="node-input-server">
27
+ </div>
28
+ </script>
29
+
30
+ <script type="text/html" data-help-name="ocpp-response">
31
+ <p>Routes CALLRESULT/CALLERROR responses back to the originating ocpp-command nodes.</p>
32
+
33
+ <h3>Overview</h3>
34
+ <p>When you send a command to a charging station using <code>ocpp-command</code>,
35
+ the station will respond with a CALLRESULT (success) or CALLERROR (failure).</p>
36
+ <p>This node routes those responses back to the correct <code>ocpp-command</code> node
37
+ that originated the request.</p>
38
+
39
+ <h3>Setup</h3>
40
+ <p>Connect this node to an <code>ocpp-in</code> node. When a response message arrives,
41
+ this node will:</p>
42
+ <ol>
43
+ <li>Check if it's a CALLRESULT or CALLERROR message</li>
44
+ <li>Look up the pending command in Redis</li>
45
+ <li>Find and notify the originating <code>ocpp-command</code> node</li>
46
+ <li>Pass through the response for additional processing</li>
47
+ </ol>
48
+
49
+ <h3>Inputs</h3>
50
+ <dl class="message-properties">
51
+ <dt>payload <span class="property-type">object</span></dt>
52
+ <dd>The response payload from the charging station.</dd>
53
+
54
+ <dt>ocpp.messageId <span class="property-type">string</span></dt>
55
+ <dd>The unique message ID matching the original command.</dd>
56
+
57
+ <dt>ocpp.messageType <span class="property-type">string</span></dt>
58
+ <dd>"result" for CALLRESULT, "error" for CALLERROR.</dd>
59
+ </dl>
60
+
61
+ <h3>Outputs</h3>
62
+ <dl class="message-properties">
63
+ <dt>payload <span class="property-type">object</span></dt>
64
+ <dd>The response payload (unchanged).</dd>
65
+
66
+ <dt>ocpp.action <span class="property-type">string</span></dt>
67
+ <dd>The original command action (e.g., "GetConfiguration").</dd>
68
+
69
+ <dt>ocpp.response <span class="property-type">boolean</span></dt>
70
+ <dd>Always true for responses.</dd>
71
+
72
+ <dt>ocpp.error <span class="property-type">boolean</span></dt>
73
+ <dd>True if this was a CALLERROR response.</dd>
74
+
75
+ <dt>ocpp.handled <span class="property-type">boolean</span></dt>
76
+ <dd>True if an ocpp-command node was found and notified.</dd>
77
+
78
+ <dt>ocpp.latency <span class="property-type">number</span></dt>
79
+ <dd>Round-trip time in milliseconds.</dd>
80
+ </dl>
81
+
82
+ <h3>Typical Flow</h3>
83
+ <pre>
84
+ [inject] → [ocpp-command] → (sent to device)
85
+ ↘ [debug: command sent]
86
+
87
+ [ocpp-in] → [ocpp-response] → [debug: response received]
88
+
89
+ (notifies ocpp-command)
90
+
91
+ [ocpp-command second output] → [debug: command response]
92
+ </pre>
93
+
94
+ <h3>References</h3>
95
+ <ul>
96
+ <li><a href="https://www.openchargealliance.org/">Open Charge Alliance</a></li>
97
+ </ul>
98
+ </script>
@@ -0,0 +1,177 @@
1
+ /**
2
+ * OCPP Response Router Node
3
+ * Routes CALLRESULT/CALLERROR responses back to the originating ocpp-command nodes
4
+ *
5
+ * Connect this node's input to an ocpp-in node that receives response messages.
6
+ * It will automatically find and notify the corresponding ocpp-command node.
7
+ */
8
+ const { Keys } = require('../lib/redis-client');
9
+ const { MessageType } = require('../lib/ocpp-normalizer');
10
+
11
+ module.exports = function (RED) {
12
+ function OCPPResponseNode(config) {
13
+ RED.nodes.createNode(this, config);
14
+ const node = this;
15
+
16
+ // Get config node
17
+ node.server = RED.nodes.getNode(config.server);
18
+ if (!node.server) {
19
+ node.error('No OCPP config node configured');
20
+ node.status({ fill: 'red', shape: 'ring', text: 'no config' });
21
+ return;
22
+ }
23
+
24
+ const redis = node.server.redis;
25
+
26
+ /**
27
+ * Handle incoming response messages from ocpp-in
28
+ */
29
+ node.on('input', async (msg, send, done) => {
30
+ try {
31
+ const ocpp = msg.ocpp || {};
32
+ const messageId = ocpp.messageId;
33
+ const tenant = ocpp.tenant;
34
+ const deviceId = ocpp.deviceId;
35
+ const messageType = ocpp.messageType; // 'call', 'result', 'error'
36
+ const ocppMessageType = ocpp.ocppMessageType; // 2, 3, 4
37
+
38
+ // Only process result and error messages
39
+ const isResult = messageType === 'result' || ocppMessageType === MessageType.CALLRESULT;
40
+ const isError = messageType === 'error' || ocppMessageType === MessageType.CALLERROR;
41
+
42
+ if (!isResult && !isError) {
43
+ // Not a response message, pass through
44
+ if (send) send(msg);
45
+ if (done) done();
46
+ return;
47
+ }
48
+
49
+ if (!messageId || !tenant || !deviceId) {
50
+ throw new Error('Missing messageId, tenant, or deviceId in response');
51
+ }
52
+
53
+ // Look up the pending command in Redis
54
+ const pendingKey = Keys.pending(tenant, deviceId);
55
+ const pendingData = await redis.hget(pendingKey, messageId);
56
+
57
+ if (!pendingData) {
58
+ // No pending command found - might have timed out or been handled elsewhere
59
+ node.log(`No pending command found for ${messageId}`);
60
+ node.status({
61
+ fill: 'yellow',
62
+ shape: 'ring',
63
+ text: `orphan: ${messageId.slice(0, 8)}`,
64
+ });
65
+ // Still pass through the message
66
+ if (send) send(msg);
67
+ if (done) done();
68
+ return;
69
+ }
70
+
71
+ // Parse pending command data
72
+ let pendingInfo;
73
+ try {
74
+ pendingInfo = JSON.parse(pendingData);
75
+ } catch (e) {
76
+ pendingInfo = {};
77
+ }
78
+
79
+ // Remove from pending
80
+ await redis.hdel(pendingKey, messageId);
81
+
82
+ // Find and notify all ocpp-command nodes
83
+ let handled = false;
84
+
85
+ // Get all flows and find ocpp-command nodes
86
+ const allNodes = [];
87
+ RED.nodes.eachNode((n) => {
88
+ if (n.type === 'ocpp-command') {
89
+ allNodes.push(n.id);
90
+ }
91
+ });
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
+
110
+ // Update status
111
+ if (handled) {
112
+ node.status({
113
+ fill: isError ? 'red' : 'green',
114
+ shape: 'dot',
115
+ text: `${pendingInfo.action || 'response'}: ${isError ? 'error' : 'ok'}`,
116
+ });
117
+ } else {
118
+ // No ocpp-command node claimed this response
119
+ // This could happen if the node was restarted or if using a different flow pattern
120
+ node.status({
121
+ fill: 'yellow',
122
+ shape: 'ring',
123
+ text: `unhandled: ${messageId.slice(0, 8)}`,
124
+ });
125
+ }
126
+
127
+ // Build output message with response info
128
+ const outMsg = {
129
+ ...msg,
130
+ ocpp: {
131
+ ...ocpp,
132
+ action: pendingInfo.action,
133
+ response: true,
134
+ error: isError,
135
+ handled,
136
+ sentAt: pendingInfo.sentAt,
137
+ receivedAt: Date.now(),
138
+ latency: pendingInfo.sentAt ? Date.now() - pendingInfo.sentAt : undefined,
139
+ },
140
+ };
141
+
142
+ // Pass through the response (for additional processing or logging)
143
+ if (send) {
144
+ send(outMsg);
145
+ }
146
+
147
+ if (done) {
148
+ done();
149
+ }
150
+
151
+ } catch (err) {
152
+ node.error(`Response routing error: ${err.message}`, msg);
153
+ node.status({ fill: 'red', shape: 'dot', text: err.message });
154
+
155
+ if (done) {
156
+ done(err);
157
+ }
158
+ }
159
+ });
160
+
161
+ // Initial status
162
+ node.status({ fill: 'grey', shape: 'ring', text: 'ready' });
163
+
164
+ // Update status when Redis is ready
165
+ if (redis) {
166
+ redis.once('ready', () => {
167
+ node.status({ fill: 'green', shape: 'ring', text: 'connected' });
168
+ });
169
+
170
+ if (redis.status === 'ready') {
171
+ node.status({ fill: 'green', shape: 'ring', text: 'connected' });
172
+ }
173
+ }
174
+ }
175
+
176
+ RED.nodes.registerType('ocpp-response', OCPPResponseNode);
177
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blokcert/node-red-contrib-ocpp",
3
- "version": "1.0.2",
3
+ "version": "1.1.1",
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,6 +22,7 @@
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",
25
26
  "ocpp-config": "nodes/ocpp-config.js"
26
27
  }
27
28
  },