@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
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
|
+
};
|