@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 ADDED
@@ -0,0 +1,188 @@
1
+ # node-red-contrib-ocpp
2
+
3
+ Node-RED nodes for OCPP (Open Charge Point Protocol) message handling via Redis Streams.
4
+
5
+ ## Features
6
+
7
+ - **OCPP 1.6 & 2.0.1 Support** - Handle both protocol versions with unified message format
8
+ - **Redis Streams Integration** - Consumer groups for scalable message processing
9
+ - **Automatic Normalization** - Convert between OCPP versions transparently
10
+ - **CSMS Commands** - Send remote commands to charging stations
11
+
12
+ ## Installation
13
+
14
+ ```bash
15
+ cd ~/.node-red
16
+ npm install /path/to/node-red-contrib-ocpp
17
+ ```
18
+
19
+ Or from npm (when published):
20
+ ```bash
21
+ npm install node-red-contrib-ocpp
22
+ ```
23
+
24
+ ## Nodes
25
+
26
+ ### ocpp-config
27
+
28
+ Configuration node for Redis connection settings. Shared by other OCPP nodes.
29
+
30
+ ### ocpp in
31
+
32
+ Receives OCPP messages from charging stations via Redis Streams.
33
+
34
+ **Features:**
35
+ - Consumer group support for load balancing
36
+ - Optional payload normalization (OCPP 1.6/2.0.1 → unified format)
37
+ - Filter by action type
38
+ - Auto-acknowledge messages
39
+
40
+ **Output:**
41
+ ```javascript
42
+ {
43
+ payload: { /* OCPP message payload */ },
44
+ ocpp: {
45
+ messageId: "abc123",
46
+ action: "BootNotification",
47
+ version: "1.6",
48
+ tenant: "operator1",
49
+ deviceId: "charger001",
50
+ identity: "operator1:charger001",
51
+ timestamp: 1699999999999,
52
+ streamId: "1699999999999-0"
53
+ }
54
+ }
55
+ ```
56
+
57
+ ### ocpp out
58
+
59
+ Sends OCPP responses back to charging stations.
60
+
61
+ **Features:**
62
+ - Automatic denormalization (unified → OCPP version)
63
+ - Error response support
64
+ - Auto-acknowledge inbound message
65
+
66
+ **Input:**
67
+ ```javascript
68
+ // Normal response
69
+ msg.payload = {
70
+ status: "Accepted",
71
+ currentTime: new Date().toISOString(),
72
+ interval: 300
73
+ };
74
+
75
+ // Error response
76
+ msg.ocpp.error = true;
77
+ msg.ocpp.errorCode = "NotImplemented";
78
+ msg.ocpp.errorDescription = "Action not supported";
79
+ ```
80
+
81
+ ### ocpp command
82
+
83
+ Sends CSMS-initiated commands to charging stations.
84
+
85
+ **Supported Commands:**
86
+ - Reset
87
+ - RemoteStartTransaction / RequestStartTransaction
88
+ - RemoteStopTransaction / RequestStopTransaction
89
+ - UnlockConnector
90
+ - TriggerMessage
91
+ - ChangeAvailability
92
+ - GetConfiguration / GetVariables
93
+ - SetVariables
94
+ - ClearCache
95
+ - And more...
96
+
97
+ **Example:**
98
+ ```javascript
99
+ msg.ocpp = {
100
+ tenant: "operator1",
101
+ deviceId: "charger001",
102
+ command: "Reset"
103
+ };
104
+ msg.payload = {
105
+ type: "Soft"
106
+ };
107
+ return msg;
108
+ ```
109
+
110
+ ## Example Flow
111
+
112
+ ```
113
+ [ocpp in] → [switch] → [BootNotification handler] → [ocpp out]
114
+ └→ [Heartbeat handler] → [ocpp out]
115
+ └→ [StatusNotification handler] → [ocpp out]
116
+ ```
117
+
118
+ ### Simple BootNotification Handler
119
+
120
+ ```javascript
121
+ // Function node
122
+ msg.payload = {
123
+ status: "Accepted",
124
+ currentTime: new Date().toISOString(),
125
+ interval: 300
126
+ };
127
+ return msg;
128
+ ```
129
+
130
+ ### Remote Start Command
131
+
132
+ ```javascript
133
+ // Inject node → Function node → ocpp command
134
+ msg.ocpp = {
135
+ tenant: "operator1",
136
+ deviceId: "charger001",
137
+ command: "RemoteStartTransaction"
138
+ };
139
+ msg.payload = {
140
+ connectorId: 1,
141
+ idTag: "RFID12345"
142
+ };
143
+ return msg;
144
+ ```
145
+
146
+ ## Message Normalization
147
+
148
+ The nodes automatically normalize OCPP messages to a unified format, allowing you to handle both OCPP 1.6 and 2.0.1 with the same logic.
149
+
150
+ ### BootNotification
151
+
152
+ | OCPP 1.6 | OCPP 2.0.1 | Unified |
153
+ |----------|------------|---------|
154
+ | chargePointVendor | chargingStation.vendorName | vendor |
155
+ | chargePointModel | chargingStation.model | model |
156
+ | chargePointSerialNumber | chargingStation.serialNumber | serialNumber |
157
+ | firmwareVersion | chargingStation.firmwareVersion | firmwareVersion |
158
+
159
+ ### StatusNotification
160
+
161
+ | OCPP 1.6 | OCPP 2.0.1 | Unified |
162
+ |----------|------------|---------|
163
+ | connectorId | evseId + connectorId | evseId, connectorId |
164
+ | status | connectorStatus | status |
165
+ | errorCode | (not in 2.0.1) | errorCode |
166
+
167
+ ## Redis Data Structures
168
+
169
+ | Key | Type | Description |
170
+ |-----|------|-------------|
171
+ | `ws:inbound` | Stream | Messages from devices (consumer group: biz-workers) |
172
+ | `ws:out:{tenant}:{deviceId}` | Stream | Messages to specific device |
173
+ | `ws:conn:{tenant}:{deviceId}` | Hash | Connection registry |
174
+ | `ws:pending:{tenant}:{deviceId}` | Hash | Pending command tracking |
175
+
176
+ ## Requirements
177
+
178
+ - Node-RED >= 2.0.0
179
+ - Node.js >= 18.0.0
180
+ - Redis >= 6.0 (for Streams support)
181
+
182
+ ## License
183
+
184
+ MIT
185
+
186
+ ## Related
187
+
188
+ - [OCPP WebSocket Server](https://github.com/your-org/ocpp-ws-server) - The WebSocket server that works with these nodes
package/icons/ocpp.svg ADDED
@@ -0,0 +1,4 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="20" height="20">
2
+ <path fill="#4CAF50" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
3
+ <path fill="#2196F3" d="M7 10h2v7H7zm4-3h2v10h-2zm4 6h2v4h-2z"/>
4
+ </svg>
@@ -0,0 +1,368 @@
1
+ /**
2
+ * OCPP Message Normalizer
3
+ * Converts between OCPP 1.6 and 2.0.1 formats to a unified internal format
4
+ *
5
+ * Reference: docs/NODE-RED-ABSTRACTION.md
6
+ */
7
+
8
+ /**
9
+ * OCPP Message Types
10
+ */
11
+ const MessageType = {
12
+ CALL: 2,
13
+ CALLRESULT: 3,
14
+ CALLERROR: 4,
15
+ };
16
+
17
+ /**
18
+ * Parse raw OCPP message from Redis Stream
19
+ * @param {object} streamData - Data from Redis XREADGROUP
20
+ * @returns {object} Parsed message with metadata
21
+ */
22
+ function parseStreamMessage(streamData) {
23
+ const { messageId, tenant, deviceId, action, payload, protocol, timestamp } = streamData;
24
+
25
+ // Determine OCPP version from protocol
26
+ const version = protocol === 'ocpp2.0.1' ? '2.0.1' : '1.6';
27
+
28
+ // Parse payload if it's a string
29
+ let parsedPayload = payload;
30
+ if (typeof payload === 'string') {
31
+ try {
32
+ parsedPayload = JSON.parse(payload);
33
+ } catch (e) {
34
+ // Keep as string if not valid JSON
35
+ }
36
+ }
37
+
38
+ return {
39
+ messageId,
40
+ tenant,
41
+ deviceId,
42
+ action,
43
+ payload: parsedPayload,
44
+ version,
45
+ protocol,
46
+ timestamp: timestamp || Date.now(),
47
+ // Identity string for routing
48
+ identity: `${tenant}:${deviceId}`,
49
+ };
50
+ }
51
+
52
+ /**
53
+ * Normalize OCPP message to unified format
54
+ * @param {object} msg - Parsed message
55
+ * @returns {object} Normalized message
56
+ */
57
+ function normalize(msg) {
58
+ const { action, payload, version } = msg;
59
+
60
+ // Apply action-specific normalization
61
+ const normalizer = normalizers[action];
62
+ const normalizedPayload = normalizer
63
+ ? normalizer(payload, version)
64
+ : payload;
65
+
66
+ return {
67
+ ...msg,
68
+ payload: normalizedPayload,
69
+ _original: payload, // Keep original for reference
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Denormalize unified format back to OCPP version-specific format
75
+ * @param {object} msg - Normalized message with response
76
+ * @param {string} targetVersion - Target OCPP version ('1.6' or '2.0.1')
77
+ * @returns {object} Version-specific message
78
+ */
79
+ function denormalize(msg, targetVersion) {
80
+ const { action, payload } = msg;
81
+ const version = targetVersion || msg.version;
82
+
83
+ const denormalizer = denormalizers[action];
84
+ const denormalizedPayload = denormalizer
85
+ ? denormalizer(payload, version)
86
+ : payload;
87
+
88
+ return {
89
+ ...msg,
90
+ payload: denormalizedPayload,
91
+ version,
92
+ };
93
+ }
94
+
95
+ // ============================================================================
96
+ // Action-specific Normalizers (OCPP 1.6/2.0.1 -> Unified)
97
+ // ============================================================================
98
+
99
+ const normalizers = {
100
+ /**
101
+ * BootNotification normalizer
102
+ * 1.6: { chargePointVendor, chargePointModel, ... }
103
+ * 2.0.1: { chargingStation: { vendorName, model, ... }, reason }
104
+ * Unified: { vendor, model, serialNumber, firmwareVersion, reason }
105
+ */
106
+ BootNotification: (payload, version) => {
107
+ if (version === '2.0.1') {
108
+ const cs = payload.chargingStation || {};
109
+ return {
110
+ vendor: cs.vendorName,
111
+ model: cs.model,
112
+ serialNumber: cs.serialNumber,
113
+ firmwareVersion: cs.firmwareVersion,
114
+ reason: payload.reason,
115
+ // Keep modem info if present
116
+ modem: cs.modem,
117
+ };
118
+ }
119
+ // OCPP 1.6
120
+ return {
121
+ vendor: payload.chargePointVendor,
122
+ model: payload.chargePointModel,
123
+ serialNumber: payload.chargePointSerialNumber || payload.chargeBoxSerialNumber,
124
+ firmwareVersion: payload.firmwareVersion,
125
+ reason: 'PowerUp', // 1.6 doesn't have reason field
126
+ };
127
+ },
128
+
129
+ /**
130
+ * StatusNotification normalizer
131
+ * 1.6: { connectorId, status, errorCode, timestamp }
132
+ * 2.0.1: { evseId, connectorId, connectorStatus, timestamp }
133
+ * Unified: { evseId, connectorId, status, errorCode, timestamp }
134
+ */
135
+ StatusNotification: (payload, version) => {
136
+ if (version === '2.0.1') {
137
+ return {
138
+ evseId: payload.evseId,
139
+ connectorId: payload.connectorId,
140
+ status: payload.connectorStatus,
141
+ errorCode: 'NoError', // 2.0.1 doesn't have errorCode in StatusNotification
142
+ timestamp: payload.timestamp,
143
+ };
144
+ }
145
+ // OCPP 1.6
146
+ return {
147
+ evseId: payload.connectorId > 0 ? 1 : 0, // Derive evseId
148
+ connectorId: payload.connectorId,
149
+ status: payload.status,
150
+ errorCode: payload.errorCode,
151
+ timestamp: payload.timestamp,
152
+ info: payload.info,
153
+ vendorId: payload.vendorId,
154
+ vendorErrorCode: payload.vendorErrorCode,
155
+ };
156
+ },
157
+
158
+ /**
159
+ * Authorize normalizer
160
+ * 1.6: { idTag }
161
+ * 2.0.1: { idToken: { idToken, type } }
162
+ * Unified: { idToken, idTokenType }
163
+ */
164
+ Authorize: (payload, version) => {
165
+ if (version === '2.0.1') {
166
+ return {
167
+ idToken: payload.idToken?.idToken,
168
+ idTokenType: payload.idToken?.type,
169
+ };
170
+ }
171
+ // OCPP 1.6
172
+ return {
173
+ idToken: payload.idTag,
174
+ idTokenType: 'ISO14443', // Default for 1.6
175
+ };
176
+ },
177
+
178
+ /**
179
+ * StartTransaction / TransactionEvent normalizer
180
+ */
181
+ StartTransaction: (payload, version) => {
182
+ return {
183
+ connectorId: payload.connectorId,
184
+ idToken: payload.idTag,
185
+ meterStart: payload.meterStart,
186
+ timestamp: payload.timestamp,
187
+ reservationId: payload.reservationId,
188
+ };
189
+ },
190
+
191
+ TransactionEvent: (payload, version) => {
192
+ // 2.0.1 only
193
+ const tx = payload.transactionInfo || {};
194
+ return {
195
+ eventType: payload.eventType,
196
+ timestamp: payload.timestamp,
197
+ triggerReason: payload.triggerReason,
198
+ seqNo: payload.seqNo,
199
+ transactionId: tx.transactionId,
200
+ chargingState: tx.chargingState,
201
+ evseId: payload.evse?.id,
202
+ connectorId: payload.evse?.connectorId,
203
+ idToken: payload.idToken?.idToken,
204
+ idTokenType: payload.idToken?.type,
205
+ meterValue: payload.meterValue,
206
+ };
207
+ },
208
+
209
+ /**
210
+ * MeterValues normalizer
211
+ */
212
+ MeterValues: (payload, version) => {
213
+ if (version === '2.0.1') {
214
+ return {
215
+ evseId: payload.evseId,
216
+ meterValue: payload.meterValue,
217
+ };
218
+ }
219
+ // OCPP 1.6
220
+ return {
221
+ connectorId: payload.connectorId,
222
+ transactionId: payload.transactionId,
223
+ meterValue: payload.meterValue,
224
+ };
225
+ },
226
+
227
+ /**
228
+ * Heartbeat - no transformation needed
229
+ */
230
+ Heartbeat: (payload, version) => payload,
231
+ };
232
+
233
+ // ============================================================================
234
+ // Action-specific Denormalizers (Unified -> OCPP 1.6/2.0.1)
235
+ // ============================================================================
236
+
237
+ const denormalizers = {
238
+ /**
239
+ * BootNotification response denormalizer
240
+ */
241
+ BootNotification: (payload, version) => {
242
+ if (version === '2.0.1') {
243
+ return {
244
+ currentTime: payload.currentTime || new Date().toISOString(),
245
+ interval: payload.interval || 300,
246
+ status: payload.status || 'Accepted',
247
+ };
248
+ }
249
+ // OCPP 1.6
250
+ return {
251
+ currentTime: payload.currentTime || new Date().toISOString(),
252
+ interval: payload.interval || 300,
253
+ status: payload.status || 'Accepted',
254
+ };
255
+ },
256
+
257
+ /**
258
+ * StatusNotification response (empty for both versions)
259
+ */
260
+ StatusNotification: (payload, version) => ({}),
261
+
262
+ /**
263
+ * Authorize response denormalizer
264
+ */
265
+ Authorize: (payload, version) => {
266
+ if (version === '2.0.1') {
267
+ return {
268
+ idTokenInfo: {
269
+ status: payload.status || 'Accepted',
270
+ },
271
+ };
272
+ }
273
+ // OCPP 1.6
274
+ return {
275
+ idTagInfo: {
276
+ status: payload.status || 'Accepted',
277
+ expiryDate: payload.expiryDate,
278
+ parentIdTag: payload.parentIdTag,
279
+ },
280
+ };
281
+ },
282
+
283
+ /**
284
+ * Heartbeat response
285
+ */
286
+ Heartbeat: (payload, version) => ({
287
+ currentTime: payload.currentTime || new Date().toISOString(),
288
+ }),
289
+
290
+ /**
291
+ * StartTransaction response
292
+ */
293
+ StartTransaction: (payload, version) => ({
294
+ transactionId: payload.transactionId,
295
+ idTagInfo: {
296
+ status: payload.status || 'Accepted',
297
+ expiryDate: payload.expiryDate,
298
+ parentIdTag: payload.parentIdTag,
299
+ },
300
+ }),
301
+
302
+ /**
303
+ * TransactionEvent response (2.0.1 only)
304
+ */
305
+ TransactionEvent: (payload, version) => ({
306
+ // 2.0.1 response is mostly optional
307
+ ...(payload.totalCost !== undefined && { totalCost: payload.totalCost }),
308
+ ...(payload.chargingPriority !== undefined && { chargingPriority: payload.chargingPriority }),
309
+ ...(payload.idTokenInfo && { idTokenInfo: payload.idTokenInfo }),
310
+ ...(payload.updatedPersonalMessage && { updatedPersonalMessage: payload.updatedPersonalMessage }),
311
+ }),
312
+
313
+ /**
314
+ * MeterValues response (empty)
315
+ */
316
+ MeterValues: (payload, version) => ({}),
317
+ };
318
+
319
+ /**
320
+ * Build OCPP response message
321
+ * @param {string} messageId - Original message ID
322
+ * @param {object} payload - Response payload
323
+ * @returns {string} JSON string of OCPP response
324
+ */
325
+ function buildResponse(messageId, payload) {
326
+ return JSON.stringify([MessageType.CALLRESULT, messageId, payload]);
327
+ }
328
+
329
+ /**
330
+ * Build OCPP error message
331
+ * @param {string} messageId - Original message ID
332
+ * @param {string} errorCode - OCPP error code
333
+ * @param {string} errorDescription - Error description
334
+ * @param {object} errorDetails - Optional error details
335
+ * @returns {string} JSON string of OCPP error
336
+ */
337
+ function buildError(messageId, errorCode, errorDescription, errorDetails = {}) {
338
+ return JSON.stringify([
339
+ MessageType.CALLERROR,
340
+ messageId,
341
+ errorCode,
342
+ errorDescription,
343
+ errorDetails,
344
+ ]);
345
+ }
346
+
347
+ /**
348
+ * Build OCPP CALL message (for CSMS-initiated commands)
349
+ * @param {string} messageId - Unique message ID
350
+ * @param {string} action - OCPP action name
351
+ * @param {object} payload - Request payload
352
+ * @returns {string} JSON string of OCPP call
353
+ */
354
+ function buildCall(messageId, action, payload) {
355
+ return JSON.stringify([MessageType.CALL, messageId, action, payload]);
356
+ }
357
+
358
+ module.exports = {
359
+ MessageType,
360
+ parseStreamMessage,
361
+ normalize,
362
+ denormalize,
363
+ buildResponse,
364
+ buildError,
365
+ buildCall,
366
+ normalizers,
367
+ denormalizers,
368
+ };
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Redis Client Manager for OCPP Node-RED nodes
3
+ * Manages shared Redis connections with connection pooling
4
+ */
5
+ const Redis = require('ioredis');
6
+
7
+ // Store connections by config node ID
8
+ const connections = new Map();
9
+
10
+ /**
11
+ * Get or create a Redis connection for a config node
12
+ * @param {string} configId - The config node ID
13
+ * @param {object} config - Redis connection configuration
14
+ * @returns {Redis} Redis client instance
15
+ */
16
+ function getConnection(configId, config) {
17
+ if (connections.has(configId)) {
18
+ return connections.get(configId);
19
+ }
20
+
21
+ const redisConfig = {
22
+ host: config.host || 'localhost',
23
+ port: config.port || 6379,
24
+ password: config.password || undefined,
25
+ db: config.db || 0,
26
+ retryDelayOnFailover: 100,
27
+ maxRetriesPerRequest: 3,
28
+ lazyConnect: false,
29
+ };
30
+
31
+ const client = new Redis(redisConfig);
32
+
33
+ client.on('error', (err) => {
34
+ console.error(`[ocpp-redis] Connection error for ${configId}:`, err.message);
35
+ });
36
+
37
+ client.on('connect', () => {
38
+ console.log(`[ocpp-redis] Connected to Redis for ${configId}`);
39
+ });
40
+
41
+ client.on('close', () => {
42
+ console.log(`[ocpp-redis] Connection closed for ${configId}`);
43
+ });
44
+
45
+ connections.set(configId, client);
46
+ return client;
47
+ }
48
+
49
+ /**
50
+ * Close and remove a Redis connection
51
+ * @param {string} configId - The config node ID
52
+ */
53
+ function closeConnection(configId) {
54
+ if (connections.has(configId)) {
55
+ const client = connections.get(configId);
56
+ client.quit().catch(() => {});
57
+ connections.delete(configId);
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Close all Redis connections
63
+ */
64
+ function closeAllConnections() {
65
+ for (const [configId, client] of connections) {
66
+ client.quit().catch(() => {});
67
+ }
68
+ connections.clear();
69
+ }
70
+
71
+ /**
72
+ * Redis key helpers matching wsserver/redis/keys.go
73
+ */
74
+ const Keys = {
75
+ // Inbound stream (device -> business logic)
76
+ inbound: () => 'ws:inbound',
77
+
78
+ // Outbound stream (business logic -> device)
79
+ outbound: (tenant, deviceId) => `ws:out:${tenant}:${deviceId}`,
80
+
81
+ // Connection registry
82
+ connection: (tenant, deviceId) => `ws:conn:${tenant}:${deviceId}`,
83
+
84
+ // Pending responses
85
+ pending: (tenant, deviceId) => `ws:pending:${tenant}:${deviceId}`,
86
+
87
+ // Control channels
88
+ disconnect: () => 'ws:ctrl:disconnect',
89
+ broadcast: () => 'ws:broadcast',
90
+ };
91
+
92
+ module.exports = {
93
+ getConnection,
94
+ closeConnection,
95
+ closeAllConnections,
96
+ Keys,
97
+ };